fix(api): Bildmaße prüfen, Dateien ohne delete(owner), Limits, Melder-Netz

- Beim Veröffentlichen liest die API die ersten 64 Bytes beider Dateien (PNG-IHDR, WebP VP8/VP8L/VP8X) und verlangt Bild 1536x969,
  Vorschau 480x302-304, sonst 400 "Das Bild hat nicht das Kartenformat."
- Veröffentlichte und ausgeblendete Dateien tragen kein delete(owner) mehr (Abweichung von Spec §7): kein Löschen und Neuanlegen
  derselben Datei-ID nach dem Veröffentlichen; scheitert das Festlegen, bekommen die Dateien delete(owner) für den Client-Aufräumer zurück
- Melder-Identität aus dem IPv4-/24 bzw. IPv6-/48; das Rate-Limit zählt weiter je Anschluss
- Höchstens 20 Veröffentlichungen je Konto (429); sourceCardId muss eine eigene Zeile in `cards` sein (403)
- Steuer-, Nullbreiten- und Bidi-Zeichen in Titel, Herausgeber, Anzeigename und Tags abgelehnt
- Tags als ",a,b," gespeichert (exakter Tag-Filter)
- fehlgeschlagenes Zurücknehmen wird mit `rollback failed for card <id>` protokolliert
- README: Endpunkt-Tabelle, Regeln, Scopes (documents.*), akzeptiertes Risiko X-Real-Ip im Docker-Netz

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 16:30:28 +00:00
1 parent 87b71ec71f
commit 4c90ee49e8
6 files changed
+743 -163

No files matched your search

+415 -94
View File
@@ -9,16 +9,19 @@ const crypto = require('node:crypto');
const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
const {
createApp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
reporterHash,
reporterNetwork,
clientIp,
normalizeIp,
readImageSize,
validatePublish,
buildSearchText,
galleryRowId,
@@ -33,6 +36,10 @@ const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
const MIB5 = 5 * 1024 * 1024;
const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
const NOT_CARD_FORMAT = 'Das Bild hat nicht das Kartenformat.';
const NOT_YOUR_CARD = 'Diese Karte gehört nicht zu deinem Konto.';
const TOO_MANY_CARDS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
// ---- Aufbau ---------------------------------------------------------------------------------
@@ -110,9 +117,11 @@ function publishBody(overrides = {}) {
};
}
function seedUploads(fake, owner = ME, { image = 'img1', preview = 'prev1' } = {}) {
/** Zwei frisch hochgeladene Dateien und die private Karte, aus der veröffentlicht wird (Standard-sourceCardId `card1`). */
function seedUploads(fake, owner = ME, { image = 'img1', preview = 'prev1', card = 'card1' } = {}) {
fake.addFile({ id: image, owner });
fake.addFile({ id: preview, owner, mimeType: 'image/webp' });
if (card) fake.addCard(card, owner);
}
/** Legt eine veröffentlichte Karte direkt im Fake an (Zeile + zwei Dateien mit passenden Rechten). */
@@ -121,10 +130,11 @@ function seedCard(fake, { id = 'card-1', owner = ME, hidden = false, reportCount
const previewFileId = `${id}-prev`;
fake.addFile({ id: imageFileId, owner });
fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' });
if (hidden) {
fake.file(imageFileId).$permissions = hiddenFilePermissions(owner);
fake.file(previewFileId).$permissions = hiddenFilePermissions(owner);
}
// Veröffentlichte Dateien tragen nie delete(owner): sichtbar nur read("any"), ausgeblendet nur Autor und Admin lesen.
const filePermissions = hidden ? hiddenFilePermissions(owner) : visibleFilePermissions();
fake.file(imageFileId).$permissions = filePermissions;
fake.file(previewFileId).$permissions = filePermissions;
fake.addCard(`src-${id}`, owner);
fake.addRow(
'gallery',
id,
@@ -154,14 +164,18 @@ const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').s
test('Rechte-Strings sind genau die aus der Spec', () => {
assert.deepEqual(visibleRowPermissions(), ['read("any")']);
assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
assert.deepEqual(visibleFilePermissions('abc'), ['read("any")', 'delete("user:abc")']);
assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")', 'delete("user:abc")']);
// Abweichung von Spec §7 (Ruling): veröffentlichte Dateien tragen kein delete(owner), sonst ließe sich der Inhalt
// nach dem Veröffentlichen löschen und unter derselben ID neu anlegen.
assert.deepEqual(visibleFilePermissions(), ['read("any")']);
assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
// frisch hochgeladene (noch nicht veröffentlichte) Dateien: mit delete(owner), damit der Client Fehlversuche aufräumt
assert.deepEqual(uploadFilePermissions('abc'), ['read("any")', 'delete("user:abc")']);
});
test('Rechte-Helfer lehnen unzulässige Nutzer-IDs ab (kein Einschleusen in Rechte-Strings)', () => {
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) {
assert.throws(() => hiddenRowPermissions(bad));
assert.throws(() => visibleFilePermissions(bad));
assert.throws(() => uploadFilePermissions(bad));
assert.throws(() => hiddenFilePermissions(bad));
}
});
@@ -173,17 +187,33 @@ test('Zeilen-IDs sind deterministisch: erste 32 Hex von sha256(a|b)', () => {
assert.notEqual(galleryRowId('u1', 'c1'), galleryRowId('u2', 'c1'));
});
test('reporterHash ist HMAC-SHA256 (Hex) mit dem Salz; IPv6 wird auf /64 gekürzt', () => {
assert.equal(reporterHash(SALT, '203.0.113.7'), crypto.createHmac('sha256', SALT).update('203.0.113.7').digest('hex'));
assert.notEqual(reporterHash('anderes-salz', '203.0.113.7'), reporterHash(SALT, '203.0.113.7'));
assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:12:ffff:eeee:dddd:2'));
assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:13::1'));
test('reporterNetwork: IPv4 -> /24, IPv6 -> /48, IPv4-gemappte IPv6 wie IPv4', () => {
assert.equal(reporterNetwork('203.0.7.9'), '203.0.7.0/24');
assert.equal(reporterNetwork(' 203.0.7.200:5555 '), '203.0.7.0/24');
assert.equal(reporterNetwork('::ffff:203.0.7.9'), '203.0.7.0/24');
assert.equal(reporterNetwork('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd::/48');
assert.equal(reporterNetwork('[2001:db8:abcd:ffff::9]'), '2001:db8:abcd::/48');
assert.equal(reporterNetwork('2001:db8::1'), '2001:db8:0::/48');
assert.equal(reporterNetwork('fe80::1%eth0'), 'fe80:0:0::/48');
assert.equal(reporterNetwork('kein-ip'), 'kein-ip');
assert.equal(reporterNetwork(undefined), '');
// normalizeIp (Rate-Limit je Anschluss) bleibt feiner: volle IPv4, IPv6 /64
assert.equal(normalizeIp('203.0.7.9'), '203.0.7.9');
});
test('reporterHash ist HMAC-SHA256 (Hex) des Netzes: drei Adressen aus einem /24 (IPv6: /48) sind ein Melder', () => {
assert.equal(reporterHash(SALT, '203.0.7.9'), crypto.createHmac('sha256', SALT).update('203.0.7.0/24').digest('hex'));
assert.notEqual(reporterHash('anderes-salz', '203.0.7.9'), reporterHash(SALT, '203.0.7.9'));
assert.equal(reporterHash(SALT, '203.0.7.9'), reporterHash(SALT, '203.0.7.200'));
assert.notEqual(reporterHash(SALT, '203.0.7.9'), reporterHash(SALT, '203.0.8.9'));
assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:ffff:ffff:eeee:dddd:2'));
assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abce:12::1'));
});
test('normalizeIp: IPv4 bleibt, IPv4-gemappte IPv6 wird IPv4, sonst /64', () => {
assert.equal(normalizeIp('203.0.113.7'), '203.0.113.7');
assert.equal(normalizeIp(' 203.0.113.7:5555 '), '203.0.113.7');
assert.equal(normalizeIp('::ffff:203.0.113.7'), '203.0.113.7');
assert.equal(normalizeIp('203.0.7.9'), '203.0.7.9');
assert.equal(normalizeIp(' 203.0.7.9:5555 '), '203.0.7.9');
assert.equal(normalizeIp('::ffff:203.0.7.9'), '203.0.7.9');
assert.equal(normalizeIp('::ffff:cb00:7107'), '203.0.113.7');
assert.equal(normalizeIp('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd:12::/64');
assert.equal(normalizeIp('2001:db8::1'), '2001:db8:0:0::/64');
@@ -279,7 +309,7 @@ test('Nur application/json: sonst 415 (auch mit Charset-Zusatz erlaubt)', async
const ok = await call('POST', '/api/melden', {
raw: '{"id":"card-1","reason":"spam"}',
contentType: 'application/json; charset=utf-8',
headers: { 'X-Real-Ip': '203.0.113.1' },
headers: { 'X-Real-Ip': '203.0.1.9' },
});
assert.equal(ok.status, 200);
});
@@ -302,7 +332,7 @@ test('Body über 8 KB: 413 (mit Content-Length und chunked), genau 8 KB geht noc
});
assert.equal(chunked.status, 413);
const exact = await call('POST', '/api/melden', { raw: padded(8192), headers: { 'X-Real-Ip': '203.0.113.2' } });
const exact = await call('POST', '/api/melden', { raw: padded(8192), headers: { 'X-Real-Ip': '203.0.2.9' } });
assert.equal(exact.status, 404, 'genau 8192 Byte werden verarbeitet (Karte existiert nicht)');
assert.equal(fake.calls.filter((c) => c.path.includes('/account')).length, 0, 'bei 413 wurde nichts bei Appwrite abgefragt');
});
@@ -330,7 +360,7 @@ test('Veröffentlichen: legt eine sichtbare Zeile an (Rechte, searchText, ownerI
assert.deepEqual(row.$permissions, ['read("any")']);
assert.equal(row.title, 'Mein Motiv');
assert.equal(row.issuer, 'Beispielbank');
assert.equal(row.tags, 'blau,neon');
assert.equal(row.tags, ',blau,neon,', 'Tags mit Trennzeichen an beiden Enden (exakter Tag-Filter)');
assert.equal(row.displayName, 'Kenso');
assert.equal(row.imageFileId, 'img1');
assert.equal(row.previewFileId, 'prev1');
@@ -351,8 +381,9 @@ test('Veröffentlichen: legt eine sichtbare Zeile an (Rechte, searchText, ownerI
test('Veröffentlichen: Bild und Vorschau dürfen PNG oder WebP sein, Anzeigename und Herausgeber sind optional', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: ME, mimeType: 'image/png', size: MIB5 });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/png' });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/png', role: 'preview' });
const res = await post('/api/veroeffentlichen', { title: 'Nur Titel', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1' }, JWT_ME);
assert.equal(res.status, 201);
const row = fake.row('gallery', res.json.id);
@@ -421,6 +452,7 @@ test('Veröffentlichen mit fehlender Datei: 400 "Bild nicht gefunden."', async (
test('Veröffentlichen: nur PNG/WebP bis 5 MiB, sonst 400', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
const attempt = (image, preview) => {
fake.addFile({ id: 'img1', owner: ME, ...image });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', ...preview });
@@ -449,9 +481,9 @@ test('Veröffentlichen doppelt: 409 "Diese Karte ist schon veröffentlicht."', a
assert.equal(again.json.error, 'Diese Karte ist schon veröffentlicht.');
assert.equal(fake.rows('gallery').length, 1);
// eine andere Person darf dieselbe sourceCardId-Zeichenfolge haben (Zeilen-ID enthält den Besitzer)
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9' });
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9' }), JWT_OTHER);
// eine andere Person veröffentlicht ihre eigene Karte (Zeilen-ID enthält den Besitzer)
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9', card: 'card9' });
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9', sourceCardId: 'card9' }), JWT_OTHER);
assert.equal(other.status, 201);
assert.notEqual(other.json.id, galleryRowId(ME, 'card1'));
assert.equal(fake.rows('gallery').length, 2);
@@ -485,6 +517,14 @@ test('Veröffentlichen: Validierung liefert 400 und fasst Appwrite (Dateien, Zei
['Tags als Zeichenkette', { tags: 'a,b' }, /Tags/],
['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/],
['Tag mit Komma', { tags: ['a,b'] }, /Komma/],
['Titel mit Nullbreiten-Leerzeichen', { title: 'a\u200bb' }, /Titel/],
['Titel nur aus Nullbreitenzeichen', { title: '\u200b\u200d\ufeff' }, /Titel/],
['Titel mit Rechts-nach-links-Überschreibung', { title: 'foto\u202egnp.exe' }, /Titel/],
['Titel mit Isolate', { title: 'a\u2066b' }, /Titel/],
['Titel mit C1-Steuerzeichen', { title: 'a\u0085b' }, /Titel/],
['Herausgeber mit Bidi-Zeichen', { issuer: 'Bank\u200f' }, /Herausgeber/],
['Anzeigename mit Wortverbinder', { displayName: 'Ken\u2060so' }, /Anzeigename/],
['Tag mit Nullbreiten-Zeichen', { tags: ['gol\u200bd'] }, /Tags/],
['Bild-ID mit ../', { imageFileId: '../x' }, /ID/],
['Bild-ID ..', { imageFileId: '..' }, /ID/],
['Bild-ID Zahl', { imageFileId: 123 }, /ID/],
@@ -514,7 +554,7 @@ test('Veröffentlichen: Tags werden serverseitig erneut normalisiert', async ()
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME);
assert.equal(res.status, 201);
assert.equal(fake.row('gallery', res.json.id).tags, 'blau,neon');
assert.equal(fake.row('gallery', res.json.id).tags, ',blau,neon,');
});
test('Veröffentlichen: Appwrite-Ausfall ergibt 502 mit deutschem Text, nichts angelegt', async () => {
@@ -537,16 +577,16 @@ test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirech
const { report, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake);
let res = await report('card-1', '203.0.113.1');
let res = await report('card-1', '203.0.1.9');
assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
res = await report('card-1', '203.0.113.2', 'urheberrecht');
res = await report('card-1', '203.0.2.9', 'urheberrecht');
assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
res = await report('card-1', '203.0.113.3', 'kartendaten');
res = await report('card-1', '203.0.3.9', 'kartendaten');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { ok: true });
@@ -554,13 +594,13 @@ test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirech
assert.equal(row.hidden, true);
assert.equal(row.reportCount, 3);
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
const hiddenFile = ['read("user:user-me")', 'read("label:admin")'];
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
assert.equal(fake.rows('reports').length, 3);
// ausgeblendete Karte melden: 404
res = await report('card-1', '203.0.113.4');
res = await report('card-1', '203.0.4.9');
assert.equal(res.status, 404);
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(fake.rows('reports').length, 3);
@@ -569,28 +609,28 @@ test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirech
test('Melden: zwei Melder reichen nicht, die Meldungen speichern nur Hash, Grund und Karte', async () => {
const { report, fake } = await setup();
seedCard(fake);
await report('card-1', '203.0.113.1', 'spam');
await report('card-1', '203.0.113.2', 'sonstiges');
await report('card-1', '203.0.1.9', 'spam');
await report('card-1', '203.0.2.9', 'sonstiges');
assert.equal(fake.row('gallery', 'card-1').hidden, false);
const hash = reporterHash(SALT, '203.0.113.1');
const hash = reporterHash(SALT, '203.0.1.9');
const stored = fake.row('reports', reportRowId('card-1', hash));
assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)');
assert.equal(stored.galleryId, 'card-1');
assert.equal(stored.reason, 'spam');
assert.equal(stored.reporterHash, hash);
assert.deepEqual(stored.$permissions, []);
assert.ok(!JSON.stringify(fake.rows('reports')).includes('203.0.113'), 'keine IP im Klartext gespeichert');
assert.ok(!JSON.stringify(fake.rows('reports')).includes('203.0.'), 'keine IP im Klartext gespeichert');
});
test('Melden: dieselbe IP fünfmal zählt einmal, Antworten sind schonGemeldet', async () => {
const { report, fake } = await setup();
seedCard(fake);
const first = await report('card-1', '203.0.113.9');
const first = await report('card-1', '203.0.9.9');
assert.deepEqual(first.json, { ok: true });
for (let i = 0; i < 4; i += 1) {
const again = await report('card-1', '203.0.113.9', i % 2 ? 'spam' : 'unangemessen');
const again = await report('card-1', '203.0.9.9', i % 2 ? 'spam' : 'unangemessen');
assert.equal(again.status, 200);
assert.deepEqual(again.json, { schonGemeldet: true });
}
@@ -604,22 +644,22 @@ test('Melden: Rate-Limit 10 pro Stunde und IP, danach 429, nach einer Stunde wie
seedCard(fake);
for (let i = 0; i < 10; i += 1) {
const res = await report('card-1', '203.0.113.50');
const res = await report('card-1', '203.0.50.9');
assert.equal(res.status, 200, `Meldung ${i + 1}`);
}
let res = await report('card-1', '203.0.113.50');
let res = await report('card-1', '203.0.50.9');
assert.equal(res.status, 429);
assert.equal(res.json.error, 'Zu viele Meldungen – bitte später nochmal.');
assert.equal(res.headers['retry-after'], '3600');
// eine andere IP ist nicht betroffen
assert.equal((await report('card-1', '203.0.113.51')).status, 200);
assert.equal((await report('card-1', '203.0.51.9')).status, 200);
// knapp vor Ablauf noch gesperrt, nach einer Stunde wieder frei
clock.value += 3600_000 - 1;
assert.equal((await report('card-1', '203.0.113.50')).status, 429);
assert.equal((await report('card-1', '203.0.50.9')).status, 429);
clock.value += 1;
res = await report('card-1', '203.0.113.50');
res = await report('card-1', '203.0.50.9');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { schonGemeldet: true });
});
@@ -627,11 +667,11 @@ test('Melden: Rate-Limit 10 pro Stunde und IP, danach 429, nach einer Stunde wie
test('Melden: abgelaufene Rate-Limit-Einträge werden entfernt', async () => {
const { report, fake, clock, app } = await setup();
seedCard(fake);
for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) await report('card-1', ip);
for (const ip of ['203.0.1.9', '203.0.2.9', '203.0.3.9']) await report('card-1', ip);
assert.equal(app.rateLimitEntries(), 3);
clock.value += 3600_000 + 60_000;
await report('card-1', '203.0.113.4');
await report('card-1', '203.0.4.9');
assert.equal(app.rateLimitEntries(), 1, 'die drei alten Einträge sind weg, nur der neue bleibt');
});
@@ -639,22 +679,22 @@ test('Melden: Ungültige Anfragen (Grund, ID) zählen nicht zum Limit und erreic
const { report, post, fake, app } = await setup();
seedCard(fake);
for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) {
const res = await post('/api/melden', { id: 'card-1', reason }, null, { 'X-Real-Ip': '203.0.113.1' });
const res = await post('/api/melden', { id: 'card-1', reason }, null, { 'X-Real-Ip': '203.0.1.9' });
assert.equal(res.status, 400, String(reason));
assert.equal(typeof res.json.error, 'string');
}
for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) {
const res = await post('/api/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.113.1' });
const res = await post('/api/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.1.9' });
assert.equal(res.status, 400, String(id));
}
assert.equal(fake.calls.length, 0);
assert.equal(app.rateLimitEntries(), 0);
assert.equal((await report('card-1', '203.0.113.1')).status, 200);
assert.equal((await report('card-1', '203.0.1.9')).status, 200);
});
test('Melden: nicht vorhandene Karte ist 404', async () => {
const { report, fake } = await setup();
const res = await report('gibts-nicht', '203.0.113.1');
const res = await report('gibts-nicht', '203.0.1.9');
assert.equal(res.status, 404);
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(fake.rows('reports').length, 0);
@@ -665,7 +705,7 @@ test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spa
seedCard(fake);
const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
for (let i = 0; i < reasons.length; i += 1) {
const res = await report('card-1', `198.51.100.${i + 1}`, reasons[i]);
const res = await report('card-1', `198.51.${100 + i}.1`, reasons[i]);
assert.equal(res.status, 200, reasons[i]);
if (i === 2) break; // ab 3 Meldern ist die Karte weg
}
@@ -673,22 +713,46 @@ test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spa
assert.deepEqual(stored, ['kartendaten', 'unangemessen', 'urheberrecht']);
});
test('Melden: IPv6-Adressen aus demselben /64 zählen als ein Melder', async () => {
test('Melden: IPv6-Adressen aus demselben /48 zählen als ein Melder', async () => {
const { report, fake } = await setup();
seedCard(fake);
assert.deepEqual((await report('card-1', '2001:db8:abcd:12::1')).json, { ok: true });
assert.deepEqual((await report('card-1', '2001:db8:abcd:12:aaaa:bbbb:cccc:dddd')).json, { schonGemeldet: true });
assert.deepEqual((await report('card-1', '2001:db8:abcd:13::1')).json, { ok: true });
assert.deepEqual((await report('card-1', '2001:db8:abcd:13::1')).json, { schonGemeldet: true }, 'anderes /64, selbes /48');
assert.deepEqual((await report('card-1', '2001:db8:abce:12::1')).json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
});
test('Melden: drei Adressen aus einem IPv4-/24 zählen als ein Melder und blenden nicht aus', async () => {
const { report, fake } = await setup();
seedCard(fake);
assert.deepEqual((await report('card-1', '198.51.100.1')).json, { ok: true });
assert.deepEqual((await report('card-1', '198.51.100.77')).json, { schonGemeldet: true });
assert.deepEqual((await report('card-1', '198.51.100.254')).json, { schonGemeldet: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
// drei verschiedene /24 blenden aus
await report('card-1', '198.51.101.1');
await report('card-1', '198.51.102.1');
assert.equal(fake.row('gallery', 'card-1').hidden, true);
});
test('Melden: das Rate-Limit zählt weiter je Anschluss (volle IPv4), nicht je Netz', async () => {
const { report, fake } = await setup();
seedCard(fake);
// 10 Meldungen von einer Adresse sind erlaubt, die 11. nicht; eine Nachbaradresse im selben /24 ist noch frei
for (let i = 0; i < 10; i += 1) assert.equal((await report('card-1', '198.51.100.1')).status, 200);
assert.equal((await report('card-1', '198.51.100.1')).status, 429);
assert.equal((await report('card-1', '198.51.100.2')).status, 200);
});
test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Adresse', async () => {
const { post, fake } = await setup();
seedCard(fake);
const viaForwarded = (ip) => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, { 'X-Forwarded-For': `${ip}, 10.0.0.1` });
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { ok: true });
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { schonGemeldet: true });
assert.deepEqual((await viaForwarded('203.0.113.78')).json, { ok: true });
assert.deepEqual((await viaForwarded('203.0.77.9')).json, { ok: true });
assert.deepEqual((await viaForwarded('203.0.77.9')).json, { schonGemeldet: true });
assert.deepEqual((await viaForwarded('203.0.78.9')).json, { ok: true });
// ohne beide Header zählt die Socket-Adresse (127.0.0.1) als dritter Melder
assert.deepEqual((await post('/api/melden', { id: 'card-1', reason: 'spam' })).json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').hidden, true, 'drei verschiedene Quellen');
@@ -697,7 +761,7 @@ test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Ad
test('Melden: gleichzeitige Meldungen von drei IPs ergeben genau drei und blenden aus', async () => {
const { report, fake } = await setup();
seedCard(fake);
const results = await Promise.all([report('card-1', '203.0.113.1'), report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]);
const results = await Promise.all([report('card-1', '203.0.1.9'), report('card-1', '203.0.2.9'), report('card-1', '203.0.3.9')]);
assert.deepEqual(results.map((r) => r.status), [200, 200, 200]);
const row = fake.row('gallery', 'card-1');
assert.equal(row.reportCount, 3);
@@ -710,9 +774,9 @@ test('Melden: eine verspätete Zählerstand-Änderung überschreibt neuere Stän
seedCard(fake);
// Die erste Meldung schreibt ihren Zähler (1) erst nach 150 ms; währenddessen kommen zwei weitere.
fake.delayNext((c) => c.method === 'PATCH' && c.body && c.body.data && c.body.data.reportCount === 1, 150);
const first = report('card-1', '203.0.113.1');
const first = report('card-1', '203.0.1.9');
await new Promise((resolve) => setTimeout(resolve, 40));
const others = await Promise.all([report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]);
const others = await Promise.all([report('card-1', '203.0.2.9'), report('card-1', '203.0.3.9')]);
const firstResult = await first;
assert.deepEqual([firstResult.status, ...others.map((r) => r.status)], [200, 200, 200]);
@@ -726,12 +790,12 @@ test('Melden: eine doppelte Meldung heilt einen früher steckengebliebenen Zähl
const { report, fake } = await setup();
seedCard(fake);
// drei Meldungen liegen schon vor, der Zähler an der Karte kam aber nie an (z. B. Ausfall mitten im Ablauf)
for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) {
for (const ip of ['203.0.1.9', '203.0.2.9', '203.0.3.9']) {
const hash = reporterHash(SALT, ip);
fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []);
}
assert.equal(fake.row('gallery', 'card-1').hidden, false);
const res = await report('card-1', '203.0.113.1');
const res = await report('card-1', '203.0.1.9');
assert.deepEqual(res.json, { schonGemeldet: true });
assert.equal(fake.row('gallery', 'card-1').hidden, true);
assert.equal(fake.row('gallery', 'card-1').reportCount, 3);
@@ -741,7 +805,7 @@ test('Melden: Ausfall beim Anlegen der Meldung ergibt 502 und zählt nicht', asy
const { report, fake } = await setup();
seedCard(fake);
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/reports/rows'), { status: 500 });
const res = await report('card-1', '203.0.113.1');
const res = await report('card-1', '203.0.1.9');
assert.equal(res.status, 502);
assert.equal(fake.row('gallery', 'card-1').reportCount, 0);
assert.equal(fake.rows('reports').length, 0);
@@ -750,13 +814,13 @@ test('Melden: Ausfall beim Anlegen der Meldung ergibt 502 und zählt nicht', asy
test('Melden: Ausfall beim Ausblenden endet mit 502, ein erneuter Versuch blendet aus', async () => {
const { report, fake } = await setup();
seedCard(fake, { reportCount: 0 });
await report('card-1', '203.0.113.1');
await report('card-1', '203.0.113.2');
await report('card-1', '203.0.1.9');
await report('card-1', '203.0.2.9');
fake.failNext((c) => c.method === 'PATCH' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
const res = await report('card-1', '203.0.113.3');
const res = await report('card-1', '203.0.3.9');
assert.equal(res.status, 502);
assert.equal(fake.row('gallery', 'card-1').hidden, false);
const retry = await report('card-1', '203.0.113.3');
const retry = await report('card-1', '203.0.3.9');
assert.equal(retry.status, 200);
assert.equal(fake.row('gallery', 'card-1').hidden, true);
});
@@ -849,7 +913,7 @@ test('Admin blendet aus: Zeilen- und Dateirechte, hidden=true, Meldungen bleiben
assert.equal(row.hidden, true);
assert.equal(row.reportCount, 1);
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
const hiddenFile = ['read("user:user-me")', 'read("label:admin")'];
assert.deepEqual(fake.file('card-1-img').$permissions, hiddenFile);
assert.deepEqual(fake.file('card-1-prev').$permissions, hiddenFile);
assert.equal(fake.rows('reports').length, 1);
@@ -869,12 +933,12 @@ test('Admin blendet ein: sichtbare Rechte, hidden=false, reportCount=0, Meldunge
assert.equal(row.hidden, false);
assert.equal(row.reportCount, 0);
assert.deepEqual(row.$permissions, ['read("any")']);
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("any")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")']);
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
// die Karte ist wieder meldbar
assert.equal((await report('card-1', '203.0.113.1')).status, 200);
assert.equal((await report('card-1', '203.0.1.9')).status, 200);
});
test('Admin-Sichtbarkeit: Body-Prüfung und fehlende Karte', async () => {
@@ -901,9 +965,9 @@ test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [
seedCard(fake, { id: 'card-9' });
await post('/api/veroeffentlichen', publishBody(), JWT_ME);
await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz');
await report('card-9', '203.0.113.7');
await report('card-9', '203.0.113.8');
await report('card-9', '203.0.113.9');
await report('card-9', '203.0.7.9');
await report('card-9', '203.0.8.9');
await report('card-9', '203.0.9.9');
await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 });
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
@@ -911,7 +975,7 @@ test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [
assert.ok(logs.length > 0);
const all = logs.join('\n');
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.113', '127.0.0.1', reporterHash(SALT, '203.0.113.7')]) {
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.', '127.0.0.1', reporterHash(SALT, '203.0.7.9')]) {
assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);
}
for (const line of logs) assert.ok(line.startsWith('[aircard-api] '), line);
@@ -921,7 +985,7 @@ test('Log-Flut: abgewiesene Anfragen (400/401/404/413/415/429) erzeugen keine Lo
const { call, post, report, logs } = await setup();
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
for (let i = 0; i < 12; i += 1) {
await report('gibts-nicht', '203.0.113.5'); // 404, ab der 11. Meldung 429
await report('gibts-nicht', '203.0.5.9'); // 404, ab der 11. Meldung 429
await call('POST', '/api/melden', { raw: '{"id":"x","reason":"spam"}', contentType: 'text/plain' }); // 415
await call('POST', '/api/melden', { raw: big }); // 413
await call('POST', '/api/melden', { raw: '{kaputt' }); // 400
@@ -930,7 +994,7 @@ test('Log-Flut: abgewiesene Anfragen (400/401/404/413/415/429) erzeugen keine Lo
await post('/api/zurueckziehen', { id: 'x' }, 'zwei teile'); // 401
await call('GET', '/api/gibts-nicht'); // 404
}
const lastLimited = await report('gibts-nicht', '203.0.113.5');
const lastLimited = await report('gibts-nicht', '203.0.5.9');
assert.equal(lastLimited.status, 429, 'das Rate-Limit hat wirklich gegriffen');
assert.deepEqual(logs, []);
});
@@ -977,7 +1041,7 @@ test('Appwrite-Fehler: 502 bei 5xx und 429, 500 bei allem anderen (falscher Key,
];
for (const [failure, status, message] of expectations) {
fake.failNext(isRowRead, failure);
const res = await report('card-1', '203.0.113.1');
const res = await report('card-1', '203.0.1.9');
assert.equal(res.status, status, `${failure.status} ${failure.type}`);
assert.equal(res.json.error, message);
}
@@ -1002,14 +1066,29 @@ test('Fehlende Tabelle oder Bucket ist kein „Karte nicht verfügbar“, sonder
assert.equal(res.json.error, 'Interner Fehler.');
});
test('Veröffentlichen legt beide Dateien auf genau die sichtbaren Rechte fest (kanonischer Zustand)', async () => {
test('Veröffentlichen legt beide Dateien auf genau read("any") fest: kein delete(owner) mehr (kanonischer Zustand)', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: ME, permissions: ['read("any")', 'delete("user:user-me")', 'update("user:user-me")', 'read("users")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("any")', 'delete("user:user-me")', 'write("user:user-me")'] });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")']);
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")']);
});
test('Löschen und Neuanlegen derselben Datei nach dem Veröffentlichen ist ausgeschlossen: Autor hat kein delete mehr', async () => {
const { post, fake } = await setup();
seedUploads(fake);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
for (const id of ['img1', 'prev1']) {
assert.ok(!fake.file(id).$permissions.some((p) => p.startsWith('delete(') || p.startsWith('update(') || p.startsWith('write(')), id);
}
// dieselben (jetzt gepinnten) Dateien lassen sich nicht noch einmal einreichen
fake.addCard('card2', ME);
const again = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'card2' }), JWT_ME);
assert.equal(again.status, 403);
assert.equal(again.json.error, TAKEN);
});
test('Ein 409 beim Veröffentlichen fasst die Dateirechte der neu hochgeladenen Dateien nicht an', async () => {
@@ -1028,12 +1107,11 @@ test('Ein 409 beim Veröffentlichen fasst die Dateirechte der neu hochgeladenen
assert.equal(fake.calls.filter((c) => c.method === 'PUT' && /\/files\/(img2|prev2)$/.test(c.path)).length, 0, 'kein PUT auf die neuen Dateien');
});
const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
test('Dateien einer ausgeblendeten Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'hid', hidden: true, reportCount: 3 });
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
fake.addCard('andere-karte', ME);
const hiddenFile = ['read("user:user-me")', 'read("label:admin")'];
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(res.status, 403);
@@ -1052,6 +1130,7 @@ test('Dateien einer ausgeblendeten Karte lassen sich nicht unter anderer sourceC
test('Dateien einer sichtbaren Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'vis' });
fake.addCard('andere-karte', ME);
fake.addFile({ id: 'fresh-img', owner: ME });
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
@@ -1074,6 +1153,8 @@ test('Dateien einer sichtbaren Karte lassen sich nicht unter anderer sourceCardI
test('Überkreuz-Verwendung: Bild einer Karte als Vorschau der neuen (und umgekehrt) wird abgelehnt', async () => {
const { post, fake } = await setup();
seedCard(fake, { id: 'vis' }); // Bild vis-img, Vorschau vis-prev
fake.addCard('x1', ME);
fake.addCard('x2', ME);
fake.addFile({ id: 'fresh-img', owner: ME });
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
// vis-img (PNG) darf Vorschau sein, vis-prev (WebP) darf Bild sein: Format allein verhindert die Kreuzung nicht
@@ -1088,6 +1169,7 @@ test('Überkreuz-Verwendung: Bild einer Karte als Vorschau der neuen (und umgeke
test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: ME, permissions: ['delete("user:user-me")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
@@ -1101,10 +1183,10 @@ test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)
assert.equal(fake.rows('gallery').length, 0);
});
test('Prüfung der Dateinutzung blättert durch alle Karten des Autors (250 Karten) und fragt nur dessen Zeilen ab', async () => {
test('Prüfung der Dateinutzung und des Limits fragt nur die Zeilen des Autors ab (ein Aufruf, höchstens 20 Zeilen)', async () => {
const { post, fake } = await setup();
for (let i = 0; i < 250; i += 1) {
const owner = i % 50 === 0 ? OTHER : ME;
for (let i = 0; i < 40; i += 1) {
const owner = i % 2 === 0 ? OTHER : ME; // 20 Karten je Person
fake.addRow(
'gallery',
`row${i}`,
@@ -1112,26 +1194,31 @@ test('Prüfung der Dateinutzung blättert durch alle Karten des Autors (250 Kart
['read("any")'],
);
}
fake.addFile({ id: 'f230a', owner: ME });
fake.addFile({ id: 'f230b', owner: ME, mimeType: 'image/webp' });
const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f230a', previewFileId: 'f230b', sourceCardId: 'neu1' }), JWT_ME);
assert.equal(taken.status, 403, 'Karte 230 liegt auf der dritten Seite');
// Zeile 39 gehört OTHER: ME hat 19 Zeilen (eine weniger als das Limit), OTHER 21
fake.rows('gallery').find((row) => row.$id === 'row39').ownerId = OTHER;
fake.addFile({ id: 'f37a', owner: ME, permissions: ['read("any")', 'delete("user:user-me")'] });
fake.addFile({ id: 'f37b', owner: ME, mimeType: 'image/webp' });
fake.addCard('neu1', ME);
const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f37a', previewFileId: 'f37b', sourceCardId: 'neu1' }), JWT_ME);
assert.equal(taken.status, 403, 'Karte 37 des Autors gehört zu seinen 19 Zeilen');
assert.equal(taken.json.error, TAKEN);
const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/gallery/rows'));
assert.equal(lists.length, 3, 'drei Seiten à 100');
for (const call of lists) assert.ok(decodeURIComponent(call.search).includes('"attribute":"ownerId","values":["user-me"]'));
assert.equal(lists.length, 1);
const search = decodeURIComponent(lists[0].search);
assert.ok(search.includes('"attribute":"ownerId","values":["user-me"]'));
assert.ok(search.includes('"method":"limit","values":[20]'));
seedUploads(fake);
fake.addCard('neu2', ME);
const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME);
assert.equal(ok.status, 201);
// Karten anderer Autoren zählen nicht (deren Dateien kann dieser Autor ohnehin nicht verwenden)
assert.ok(fake.row('gallery', 'row0'));
});
test('Gleichzeitiges Veröffentlichen derselben Dateien unter zwei sourceCardIds: genau eines gelingt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.addCard('c1', ME);
fake.addCard('c2', ME);
// Das Anlegen der ersten Zeile verzögern, damit beide Anfragen die Prüfung vor dem Anlegen erreichen würden.
fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows'), 80);
const results = await Promise.all([
@@ -1149,6 +1236,8 @@ test('Veröffentlichen: scheitert das Festlegen der Dateirechte, wird die Zeile
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(fake.rows('gallery').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert');
// Das schon festgelegte Bild bekommt delete(owner) zurück, damit der Client den Fehlversuch aufräumen kann
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']);
// Datei verschwindet zwischen Prüfung und Festlegen: 400 und ebenfalls zurückgenommen
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/img1'), { status: 404, type: 'storage_file_not_found' });
@@ -1170,8 +1259,8 @@ test('Dateirechte setzen wartet auf beide Aufrufe, auch wenn einer scheitert (Sp
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
assert.equal(res.status, 502);
// Die Antwort kam erst, nachdem auch der langsame PUT durch war.
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("user:user-me")', 'read("label:admin")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")']);
assert.equal(fake.row('gallery', 'card-1').hidden, false, 'die Zeile wird erst nach den Dateien geändert');
});
@@ -1243,6 +1332,238 @@ test('Unerwartete Fehler im Appwrite-Objekt ergeben 500, ohne die Anfrage hänge
assert.ok(!logs.join('\n').includes('GEHEIM'), 'Fehlertexte werden nicht ins Log übernommen');
});
// ---- Bildmaße (Defense in Depth beim Veröffentlichen) ---------------------------------------------
test('readImageSize liest PNG-IHDR sowie WebP VP8, VP8L und VP8X', () => {
assert.deepEqual(readImageSize(pngHeader(1536, 969)), { width: 1536, height: 969 });
assert.deepEqual(readImageSize(webpHeader('VP8 ', 480, 303)), { width: 480, height: 303 });
assert.deepEqual(readImageSize(webpHeader('VP8L', 1536, 969)), { width: 1536, height: 969 });
assert.deepEqual(readImageSize(webpHeader('VP8X', 16383, 16383)), { width: 16383, height: 16383 });
assert.deepEqual(readImageSize(pngHeader(65535, 70000)), { width: 65535, height: 70000 });
// gekürzt auf genau die nötigen Bytes (PNG 24, VP8 30, VP8L 25, VP8X 30) geht es noch, eines weniger nicht
assert.deepEqual(readImageSize(pngHeader(7, 8).subarray(0, 24)), { width: 7, height: 8 });
assert.equal(readImageSize(pngHeader(7, 8).subarray(0, 23)), null);
assert.deepEqual(readImageSize(webpHeader('VP8 ', 7, 8).subarray(0, 30)), { width: 7, height: 8 });
assert.equal(readImageSize(webpHeader('VP8 ', 7, 8).subarray(0, 29)), null);
assert.deepEqual(readImageSize(webpHeader('VP8L', 7, 8).subarray(0, 25)), { width: 7, height: 8 });
assert.equal(readImageSize(webpHeader('VP8L', 7, 8).subarray(0, 24)), null);
assert.deepEqual(readImageSize(webpHeader('VP8X', 7, 8).subarray(0, 30)), { width: 7, height: 8 });
assert.equal(readImageSize(webpHeader('VP8X', 7, 8).subarray(0, 29)), null);
});
test('readImageSize: Unbekanntes, Leeres und Kaputtes ergibt null', () => {
assert.equal(readImageSize(Buffer.alloc(0)), null);
assert.equal(readImageSize(Buffer.from('GIF89a')), null);
assert.equal(readImageSize(Buffer.alloc(64)), null);
assert.equal(readImageSize(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="1536" height="969"></svg>')), null);
const brokenPng = pngHeader(1536, 969);
brokenPng.write('IDAT', 12, 'latin1'); // erster Chunk ist nicht IHDR
assert.equal(readImageSize(brokenPng), null);
const lossyBadStart = webpHeader('VP8 ', 480, 303);
lossyBadStart[24] = 0; // Startcode kaputt
assert.equal(readImageSize(lossyBadStart), null);
const losslessBadSignature = webpHeader('VP8L', 480, 303);
losslessBadSignature[20] = 0;
assert.equal(readImageSize(losslessBadSignature), null);
const unknownChunk = webpHeader('VP8 ', 480, 303);
unknownChunk.write('ANIM', 12, 'latin1');
assert.equal(readImageSize(unknownChunk), null);
assert.equal(readImageSize(null), null);
assert.equal(readImageSize('PNG'), null);
});
test('Veröffentlichen: Bild muss 1536 x 969 sein, Vorschau 480 breit und 302 bis 304 hoch (sonst 400)', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
const attempt = ({ image, preview }) => {
fake.addFile({ id: 'img1', owner: ME, content: image });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', content: preview });
return post('/api/veroeffentlichen', publishBody(), JWT_ME);
};
const okImage = pngHeader(1536, 969);
const okPreview = webpHeader('VP8 ', 480, 303);
for (const [label, files] of [
['Bild zu schmal', { image: pngHeader(1535, 969), preview: okPreview }],
['Bild zu hoch', { image: pngHeader(1536, 970), preview: okPreview }],
['Bild 4 Megapixel', { image: pngHeader(2000, 2000), preview: okPreview }],
['Bild riesig (Dekompressionsbombe)', { image: pngHeader(60000, 60000), preview: okPreview }],
['Bild WebP riesig', { image: webpHeader('VP8L', 16383, 16383), preview: okPreview }],
['Vorschau zu breit', { image: okImage, preview: webpHeader('VP8 ', 481, 303) }],
['Vorschau zu niedrig', { image: okImage, preview: webpHeader('VP8 ', 480, 301) }],
['Vorschau zu hoch', { image: okImage, preview: webpHeader('VP8 ', 480, 305) }],
['Vorschau in Bildgröße', { image: okImage, preview: webpHeader('VP8L', 1536, 969) }],
['Bild ist kein Bild', { image: Buffer.from('MZ\x90\x00 kein Bild, nur Text, lang genug für die Prüfung 123456789'), preview: okPreview }],
['Vorschau ist kein Bild', { image: okImage, preview: Buffer.alloc(100) }],
['Bild leer', { image: Buffer.alloc(0), preview: okPreview }],
['Bild abgeschnitten', { image: pngHeader(1536, 969).subarray(0, 20), preview: okPreview }],
]) {
const res = await attempt(files);
assert.equal(res.status, 400, label);
assert.equal(res.json.error, NOT_CARD_FORMAT, label);
assert.equal(fake.rows('gallery').length, 0, label);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], `${label}: Dateien bleiben unangetastet`);
}
// genau die erwarteten Maße gehen; die Vorschau darf 302, 303 oder 304 hoch sein, auch als PNG (Browser ohne WebP-Export)
for (const preview of [webpHeader('VP8 ', 480, 302), webpHeader('VP8L', 480, 304), webpHeader('VP8X', 480, 303), pngHeader(480, 303)]) {
const res = await attempt({ image: okImage, preview });
assert.equal(res.status, 201);
assert.equal((await post('/api/zurueckziehen', { id: res.json.id }, JWT_ME)).status, 200);
}
// auch ein WebP-Bild in voller Größe ist ein gültiges Kartenbild
assert.equal((await attempt({ image: webpHeader('VP8L', 1536, 969), preview: okPreview })).status, 201);
});
test('Veröffentlichen liest nur den Anfang der Dateien und schickt dafür den Server-Key an /view', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const big = Buffer.concat([pngHeader(1536, 969), Buffer.alloc(3 * 1024 * 1024)]);
fake.addFile({ id: 'img1', owner: ME, content: big, size: big.length });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
const views = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/view'));
assert.deepEqual(views.map((c) => c.path).sort(), [
'/v1/storage/buckets/gallery-images/files/img1/view',
'/v1/storage/buckets/gallery-images/files/prev1/view',
]);
for (const c of views) assert.equal(c.headers['x-appwrite-key'], fake.key);
});
test('Veröffentlichen: fremde oder gesperrte Dateien werden nicht gelesen (kein Maß-Orakel)', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: OTHER });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 403);
assert.equal(fake.calls.filter((c) => c.path.endsWith('/view')).length, 0);
});
test('Veröffentlichen: Datei verschwindet vor dem Lesen der Maße ergibt 400 "Bild nicht gefunden."', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/prev1/view'), { status: 404, type: 'storage_file_not_found' });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 400);
assert.equal(res.json.error, 'Bild nicht gefunden.');
assert.equal(fake.rows('gallery').length, 0);
});
test('Veröffentlichen: Appwrite-Fehler beim Lesen der Maße ergibt 502, nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.path.endsWith('/files/img1/view'), { status: 500 });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(fake.rows('gallery').length, 0);
});
// ---- Limits und Besitz der Quellkarte ---------------------------------------------------------
function seedPublications(fake, owner, count) {
for (let i = 0; i < count; i += 1) {
fake.addRow(
'gallery',
`${owner}-pub${i}`,
{ title: `P${i}`, imageFileId: `${owner}-i${i}`, previewFileId: `${owner}-p${i}`, sourceCardId: `${owner}-s${i}`, ownerId: owner, hidden: i % 2 === 1, reportCount: 0 },
['read("any")'],
);
}
}
test('Veröffentlichen: höchstens 20 Veröffentlichungen je Konto (ausgeblendete zählen mit), die 21. ergibt 429', async () => {
const { post, fake } = await setup();
seedPublications(fake, ME, 20);
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 429);
assert.equal(res.json.error, TOO_MANY_CARDS);
assert.equal(fake.rows('gallery').length, 20);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], 'Dateien unangetastet');
// Karten anderer Konten zählen nicht
seedPublications(fake, OTHER, 25);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 429);
seedUploads(fake, OTHER, { image: 'oimg', preview: 'oprev', card: 'ocard' });
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'oimg', previewFileId: 'oprev', sourceCardId: 'ocard' }), JWT_OTHER)).status, 429);
});
test('Veröffentlichen: mit 19 Veröffentlichungen geht die 20., nach dem Zurückziehen wieder eine', async () => {
const { post, fake } = await setup();
seedPublications(fake, ME, 19);
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201);
fake.addCard('card2', ME);
seedUploads(fake, ME, { image: 'img2', preview: 'prev2', card: 'card2' });
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2', sourceCardId: 'card2' }), JWT_ME)).status, 429);
assert.equal((await post('/api/zurueckziehen', { id: res.json.id }, JWT_ME)).status, 200);
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2', sourceCardId: 'card2' }), JWT_ME)).status, 201);
});
test('Veröffentlichen: sourceCardId muss eine Zeile in `cards` mit read("user:<ich>") sein, sonst 403', async () => {
const { post, fake } = await setup();
seedUploads(fake, ME, { card: null });
// gibt es nicht
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, NOT_YOUR_CARD);
// gehört jemand anderem
fake.addCard('card1', OTHER);
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, NOT_YOUR_CARD);
// nur lesbar für alle, aber nicht meine
fake.addCard('card1', OTHER, ['read("any")']);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 403);
assert.equal(fake.rows('gallery').length, 0);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], 'Dateien unangetastet');
assert.equal(fake.calls.filter((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows')).length, 0);
// meine Karte geht
fake.addCard('card1', ME);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
});
test('Veröffentlichen: Fehler beim Lesen von `cards` ergibt 502, ein fehlender Tabellenname 500, nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/cards/rows/'), { status: 503 });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 502);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/cards/rows/'), { status: 404, type: 'table_not_found' });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 500);
assert.equal(fake.rows('gallery').length, 0);
});
// ---- Rollback-Protokoll ------------------------------------------------------------------------
test('Veröffentlichen: scheitern Festlegen und Zurücknehmen, steht `rollback failed for card <id>` im Log (nur die ID)', async () => {
const { post, fake, logs } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
const id = galleryRowId(ME, 'card1');
assert.equal(fake.rows('gallery').length, 1, 'die Zeile blieb stehen (Zurücknehmen scheiterte)');
const lines = logs.filter((line) => line.includes('rollback failed'));
assert.equal(lines.length, 1);
assert.match(lines[0], new RegExp(`^\\[aircard-api\\] \\S+ rollback failed for card ${id}$`));
assert.ok(!logs.join('\n').includes(JWT_ME));
});
test('Veröffentlichen: gelingt das Zurücknehmen, steht keine Rollback-Fehlerzeile im Log', async () => {
const { post, fake, logs } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 502);
assert.equal(fake.rows('gallery').length, 0);
assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0);
});
test('createApp verlangt Appwrite-Objekt und Salz', () => {
assert.throws(() => createApp({ salt: SALT }));
assert.throws(() => createApp({ appwrite: {}, salt: '' }));