From 7bbe0a9fe68c60e8a66ad75e976db302e8e650ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 14:27:29 +0000 Subject: [PATCH] =?UTF-8?q?fix(api):=20Dateien=20bereits=20ver=C3=B6ffentl?= =?UTF-8?q?ichter=20oder=20gesperrter=20Karten=20nicht=20wiederverwenden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Veröffentlichen lehnt mit 403 "Diese Bilder sind bereits veröffentlicht oder gesperrt." ab, wenn eine Datei kein read("any") trägt (ausgeblendet) oder von einer Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet wird (auch überkreuz, auch ausgeblendete Karten). Die Nutzungsprüfung liest die Zeilen des Autors über den vorhandenen ownerId-Index seitenweise mit Cursor (auf die Datei-ID-Spalten gibt es keinen Index) und läuft zusammen mit dem Anlegen je Autor nacheinander. 92 Tests. Co-Authored-By: Claude Sonnet 5.5 --- deploy/api/README.md | 2 +- deploy/api/app.js | 113 ++++++++++++++++++---------- deploy/api/app.test.js | 144 +++++++++++++++++++++++++++++++----- deploy/api/appwrite.js | 9 ++- deploy/api/appwrite.test.js | 18 ++++- deploy/api/fake-appwrite.js | 12 ++- 6 files changed, 235 insertions(+), 63 deletions(-) diff --git a/deploy/api/README.md b/deploy/api/README.md index a3de734..626cb05 100644 --- a/deploy/api/README.md +++ b/deploy/api/README.md @@ -45,7 +45,7 @@ Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Server - **IDs**: Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen (nur Zeichenketten), sonst `400`. `appwrite.js` kodiert zusätzlich jedes Pfadsegment und lehnt `.` und `..` ab. - **Felder**: Titel 1–60, Herausgeber ≤ 40, Anzeigename ≤ 30 Zeichen (getrimmt, keine Steuerzeichen). `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Bild und Vorschau müssen verschiedene Dateien sein. -- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403`. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")`, `delete("user:")` (kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen. Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“ +- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403`. **Keine Wiederverwendung**: Beide Dateien müssen `read("any")` tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst `403` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen `sourceCardId` neu einstellen. Die Prüfung liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index, auf die Datei-ID-Spalten nicht) seitenweise à 100 mit Cursor und läuft zusammen mit dem Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")`, `delete("user:")` (kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen. Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“ - **Doppelt veröffentlichen**: Die Zeilen-ID der Galerie ist `sha256(ownerId|sourceCardId)` (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei. - **Melden**: Rate-Limit 10 pro Stunde und Melder (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge). Der Melder-Hash ist `HMAC-SHA256(salt, ip)`; IPv6-Adressen werden vorher auf ihr /64-Netz gekürzt, IPv4-gemappte IPv6 auf IPv4. Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig. - **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst darf deshalb nur über Traefik erreichbar sein (kein `ports:` in Compose). diff --git a/deploy/api/app.js b/deploy/api/app.js index 40f2e36..6ab558d 100644 --- a/deploy/api/app.js +++ b/deploy/api/app.js @@ -5,7 +5,8 @@ * Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js). * * POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId} - * 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format · 403 Datei nicht meine / falscher Bucket + * 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format · 403 Datei nicht meine / falscher Bucket / + * Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet * 409 schon veröffentlicht · 201 {id} * POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id} * 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true} @@ -26,7 +27,7 @@ const crypto = require('node:crypto'); const net = require('node:net'); -const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js'); +const { AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js'); const TABLE_GALLERY = 'gallery'; const TABLE_REPORTS = 'reports'; @@ -37,6 +38,8 @@ const MAX_FILE_BYTES = 5 * 1024 * 1024; const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']); const HIDE_AT_REPORTS = 3; const REPORT_LIST_LIMIT = 100; +const OWNER_ROWS_PAGE = 100; +const OWNER_ROWS_MAX_PAGES = 100; const REPORT_DELETE_ROUNDS = 50; const RATE_LIMIT = 10; const RATE_WINDOW_MS = 60 * 60 * 1000; @@ -61,6 +64,7 @@ const MSG = { fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.', fileSize: 'Das Bild ist größer als 5 MB.', alreadyPublished: 'Diese Karte ist schon veröffentlicht.', + filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.', tooManyReports: 'Zu viele Meldungen – bitte später nochmal.', badRequest: 'Ungültige Anfrage.', badCardId: 'Ungültige Karten-ID.', @@ -446,12 +450,37 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) { throw new HttpError(403, MSG.foreignFile); } + // Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie. + if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken); if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType); if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize); } const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId); + /** + * Datei-IDs, die schon eine Galerie-Zeile dieses Autors verwendet (Bild oder Vorschau, auch ausgeblendete Karten). + * Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht). + * Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId` nicht) und blättert mit + * Cursor. Mehr als OWNER_ROWS_MAX_PAGES Seiten: null (der Aufrufer lehnt dann ab). + */ + async function fileIdsInUse(ownerId) { + const used = new Set(); + let cursor = null; + for (let page = 0; page < OWNER_ROWS_MAX_PAGES; page += 1) { + const queries = [queryEqual('ownerId', [ownerId]), queryLimit(OWNER_ROWS_PAGE)]; + if (cursor !== null) queries.push(queryCursorAfter(cursor)); + const rows = await appwrite.listRows(TABLE_GALLERY, queries); + for (const row of rows) { + used.add(row.imageFileId); + used.add(row.previewFileId); + } + if (rows.length < OWNER_ROWS_PAGE) return used; + cursor = rows[rows.length - 1].$id; + } + return null; + } + /** * Setzt die Rechte beider Dateien. Wartet auf beide Aufrufe, auch wenn einer scheitert (die Sperre der Karte darf * nicht freigegeben werden, solange noch ein PUT unterwegs ist), und wirft danach den ersten Fehler. @@ -504,43 +533,51 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { checkFile(image, user.id); checkFile(preview, user.id); - const id = galleryRowId(user.id, data.sourceCardId); - try { - await appwrite.createRow( - TABLE_GALLERY, - id, - { - title: data.title, - issuer: data.issuer, - tags: data.tags.join(','), - displayName: data.displayName, - imageFileId: data.imageFileId, - previewFileId: data.previewFileId, - sourceCardId: data.sourceCardId, - ownerId: user.id, - hidden: false, - reportCount: 0, - searchText: buildSearchText(data), - }, - visibleRowPermissions(), - ); - } catch (err) { - if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished); - throw err; - } + // Je Autor nacheinander: Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen. + return withLock(`owner:${user.id}`, async () => { + const used = await fileIdsInUse(user.id); + if (used === null || used.has(data.imageFileId) || used.has(data.previewFileId)) { + throw new HttpError(403, MSG.filesTaken); + } - // Kanonischer Zustand: Beide Dateien tragen genau die sichtbaren Rechte (kein update, keine fremden Rollen). - // Erst nach dem Anlegen der Zeile: ein 409 darf die Rechte einer schon veröffentlichten (oder ausgeblendeten) - // Karte nie anfassen. Scheitert das Festlegen, wird die Zeile zurückgenommen. - try { - const pinned = await setFilePermissions(data, visibleFilePermissions(user.id)); - if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing); - } catch (err) { - await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {}); - throw err; - } - say(`card ${id} published`); - return { status: 201, body: { id } }; + const id = galleryRowId(user.id, data.sourceCardId); + try { + await appwrite.createRow( + TABLE_GALLERY, + id, + { + title: data.title, + issuer: data.issuer, + tags: data.tags.join(','), + displayName: data.displayName, + imageFileId: data.imageFileId, + previewFileId: data.previewFileId, + sourceCardId: data.sourceCardId, + ownerId: user.id, + hidden: false, + reportCount: 0, + searchText: buildSearchText(data), + }, + visibleRowPermissions(), + ); + } catch (err) { + if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished); + throw err; + } + + // Kanonischer Zustand: Beide Dateien tragen genau die sichtbaren Rechte (kein update, keine fremden Rollen). + // Erst nach dem Anlegen der Zeile: ein 409 (dieselbe private Karte nochmal) fasst die Dateien nie an. + // Scheitert das Festlegen, wird die Zeile zurückgenommen. + try { + const pinned = await setFilePermissions(data, visibleFilePermissions(user.id)); + if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing); + } catch (err) { + await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {}); + throw err; + } + say(`card ${id} published`); + return { status: 201, body: { id } }; + }); } async function withdraw({ req, raw }) { diff --git a/deploy/api/app.test.js b/deploy/api/app.test.js index 6b49f06..41eb5a7 100644 --- a/deploy/api/app.test.js +++ b/deploy/api/app.test.js @@ -1005,33 +1005,141 @@ test('Fehlende Tabelle oder Bucket ist kein „Karte nicht verfügbar“, sonder test('Veröffentlichen legt beide Dateien auf genau die sichtbaren Rechte fest (kanonischer Zustand)', async () => { const { post, fake } = await setup(); 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: ['delete("user:user-me")', 'write("user:user-me")'] }); + 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")']); }); -test('Ein 409 beim Veröffentlichen fasst die Dateirechte nicht an (auch nicht die einer ausgeblendeten Karte)', async () => { +test('Ein 409 beim Veröffentlichen fasst die Dateirechte der neu hochgeladenen Dateien nicht an', async () => { const { post, fake } = await setup(); - // Karte 1 ist schon veröffentlicht und wurde ausgeblendet; ihr Autor schickt dieselben Dateien erneut. - const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")']; - fake.addFile({ id: 'img1', owner: ME, permissions: hiddenFile }); - fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: hiddenFile }); - const rowId = galleryRowId(ME, 'card1'); - fake.addRow( - 'gallery', - rowId, - { title: 'Karte', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1', ownerId: ME, hidden: true, reportCount: 3 }, - hiddenRowPermissions(ME), - ); + seedUploads(fake); + assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201); - const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); + // Dieselbe private Karte nochmal, mit frisch hochgeladenen Dateien (z. B. zweiter Tab): 409, Dateien bleiben unverändert. + const extra = ['read("any")', 'delete("user:user-me")', 'update("user:user-me")']; + fake.addFile({ id: 'img2', owner: ME, permissions: extra }); + fake.addFile({ id: 'prev2', owner: ME, mimeType: 'image/webp', permissions: extra }); + const res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME); assert.equal(res.status, 409); - assert.deepEqual(fake.file('img1').$permissions, hiddenFile); - assert.deepEqual(fake.file('prev1').$permissions, hiddenFile); - assert.equal(fake.row('gallery', rowId).hidden, true); - assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0, 'kein PUT auf Dateien'); + assert.deepEqual(fake.file('img2').$permissions, extra); + assert.deepEqual(fake.file('prev2').$permissions, extra); + 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")']; + + const res = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'andere-karte' }), JWT_ME); + assert.equal(res.status, 403); + assert.equal(res.json.error, TAKEN); + assert.equal(fake.rows('gallery').length, 1, 'keine neue Zeile'); + assert.equal(fake.row('gallery', 'hid').hidden, true); + assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile); + assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile); + assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0, 'Dateirechte bleiben unangetastet'); + + // auch mit der ursprünglichen sourceCardId bleibt es bei 403 (Dateien gesperrt) und nicht 409 + const same = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'src-hid' }), JWT_ME); + assert.equal(same.status, 403); +}); + +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.addFile({ id: 'fresh-img', owner: ME }); + fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' }); + + for (const [label, overrides] of [ + ['beide Dateien', { imageFileId, previewFileId }], + ['nur das Bild', { imageFileId, previewFileId: 'fresh-prev' }], + ['nur die Vorschau', { imageFileId: 'fresh-img', previewFileId }], + ]) { + const res = await post('/api/veroeffentlichen', publishBody({ ...overrides, sourceCardId: 'andere-karte' }), JWT_ME); + assert.equal(res.status, 403, label); + assert.equal(res.json.error, TAKEN, label); + } + assert.equal(fake.rows('gallery').length, 1); + + // frische Dateien gehen + const ok = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'fresh-img', previewFileId: 'fresh-prev', sourceCardId: 'andere-karte' }), JWT_ME); + assert.equal(ok.status, 201); +}); + +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.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 + const asPreview = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'fresh-img', previewFileId: 'vis-img', sourceCardId: 'x1' }), JWT_ME); + assert.equal(asPreview.status, 403); + assert.equal(asPreview.json.error, TAKEN); + const asImage = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'vis-prev', previewFileId: 'fresh-prev', sourceCardId: 'x2' }), JWT_ME); + assert.equal(asImage.status, 403); + assert.equal(asImage.json.error, TAKEN); + assert.equal(fake.rows('gallery').length, 1); +}); + +test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)', async () => { + const { post, fake } = await setup(); + 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); + assert.equal(res.status, 403); + assert.equal(res.json.error, TAKEN); + + fake.addFile({ id: 'img1', owner: ME }); + fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("user:user-me")', 'delete("user:user-me")'] }); + res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); + assert.equal(res.status, 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 () => { + const { post, fake } = await setup(); + for (let i = 0; i < 250; i += 1) { + const owner = i % 50 === 0 ? OTHER : ME; + fake.addRow( + 'gallery', + `row${i}`, + { title: `K${i}`, imageFileId: `f${i}a`, previewFileId: `f${i}b`, sourceCardId: `s${i}`, ownerId: owner, hidden: false, reportCount: 0 }, + ['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'); + 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"]')); + + seedUploads(fake); + 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); + // 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([ + post('/api/veroeffentlichen', publishBody({ sourceCardId: 'c1' }), JWT_ME), + post('/api/veroeffentlichen', publishBody({ sourceCardId: 'c2' }), JWT_ME), + ]); + assert.deepEqual(results.map((r) => r.status).sort(), [201, 403]); + assert.equal(fake.rows('gallery').length, 1); }); test('Veröffentlichen: scheitert das Festlegen der Dateirechte, wird die Zeile zurückgenommen', async () => { diff --git a/deploy/api/appwrite.js b/deploy/api/appwrite.js index 2331524..da77092 100644 --- a/deploy/api/appwrite.js +++ b/deploy/api/appwrite.js @@ -5,7 +5,7 @@ * * createAppwrite({ endpoint, project, key, fetchImpl?, database?, timeoutMs? }) * getRow(table, id) Zeile oder null (404 row_not_found) - * listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit); + * listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit, queryCursorAfter); * eine Antwort ohne `rows`-Array ist ein Fehler (kein stilles []) * createRow(table, rowId, data, permissions) doppelte ID -> AppwriteError mit status 409 * updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben @@ -49,6 +49,11 @@ function queryLimit(count) { return JSON.stringify({ method: 'limit', values: [count] }); } +/** Nächste Seite nach der Zeile mit dieser ID (Reihenfolge: Anlegereihenfolge). */ +function queryCursorAfter(id) { + return JSON.stringify({ method: 'cursorAfter', values: [id] }); +} + /** Ein Pfadsegment: nur nichtleere Zeichenketten, nie "." oder "..", immer kodiert. */ function segment(value) { if (typeof value !== 'string' || value === '' || value === '.' || value === '..') { @@ -165,4 +170,4 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, }; } -module.exports = { createAppwrite, AppwriteError, queryEqual, queryLimit }; +module.exports = { createAppwrite, AppwriteError, queryEqual, queryLimit, queryCursorAfter }; diff --git a/deploy/api/appwrite.test.js b/deploy/api/appwrite.test.js index d37bbe7..b5c6d1d 100644 --- a/deploy/api/appwrite.test.js +++ b/deploy/api/appwrite.test.js @@ -7,7 +7,7 @@ const { test, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const http = require('node:http'); -const { createAppwrite, AppwriteError, queryEqual, queryLimit } = require('./appwrite.js'); +const { createAppwrite, AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js'); const { startFakeAppwrite } = require('./fake-appwrite.js'); const fakes = []; @@ -110,9 +110,23 @@ test('listRows schickt die Queries als queries[]-Parameter und liefert die Zeile assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key); }); -test('queryEqual und queryLimit liefern Appwrite-JSON-Strings', () => { +test('queryEqual, queryLimit und queryCursorAfter liefern Appwrite-JSON-Strings', () => { assert.equal(queryEqual('galleryId', ['x']), '{"method":"equal","attribute":"galleryId","values":["x"]}'); assert.equal(queryLimit(100), '{"method":"limit","values":[100]}'); + assert.equal(queryCursorAfter('abc'), '{"method":"cursorAfter","values":["abc"]}'); +}); + +test('listRows blättert mit cursorAfter', async () => { + const { appwrite } = await setup(); + for (const id of ['a', 'b', 'c', 'd', 'e']) { + await appwrite.createRow('reports', id, { galleryId: 'g1', reason: 'spam', reporterHash: id }, []); + } + const first = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2)]); + assert.deepEqual(first.map((row) => row.$id), ['a', 'b']); + const second = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('b')]); + assert.deepEqual(second.map((row) => row.$id), ['c', 'd']); + const last = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('d')]); + assert.deepEqual(last.map((row) => row.$id), ['e']); }); test('Dateien: lesen, Rechte setzen, löschen', async () => { diff --git a/deploy/api/fake-appwrite.js b/deploy/api/fake-appwrite.js index 4da329d..106b3f2 100644 --- a/deploy/api/fake-appwrite.js +++ b/deploy/api/fake-appwrite.js @@ -8,7 +8,7 @@ * GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?} * GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions} * - * Abfragen: `queries[]=` mit den Methoden equal und limit. Zeilen werden gegen die Spaltenlängen der + * Abfragen: `queries[]=` mit den Methoden equal, cursorAfter und limit. Zeilen werden gegen die Spaltenlängen der * echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im Test auffallen. * Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht), * so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen. @@ -270,10 +270,11 @@ function safeParse(text) { } } -/** Wendet equal/limit an; null bei unbekannter Abfrage. */ +/** Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage. */ function runQueries(rows, search) { let result = rows; let limit = 25; + let cursor = null; for (const part of search.split('&').filter(Boolean)) { const eq = part.indexOf('='); const name = decodeURIComponent(part.slice(0, eq)); @@ -288,10 +289,17 @@ function runQueries(rows, search) { result = result.filter((row) => query.values.includes(row[query.attribute])); } else if (query.method === 'limit') { limit = query.values[0]; + } else if (query.method === 'cursorAfter') { + cursor = query.values[0]; } else { return null; } } + if (cursor !== null) { + const index = result.findIndex((row) => row.$id === cursor); + if (index === -1) return null; + result = result.slice(index + 1); + } return result.slice(0, limit); }