diff --git a/deploy/api/README.md b/deploy/api/README.md index 626cb05..1241e9a 100644 --- a/deploy/api/README.md +++ b/deploy/api/README.md @@ -19,7 +19,7 @@ Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur `server.js | Variable | Standard | Bedeutung | |---|---|---| -| `AIRCARD_API_KEY` | – (Pflicht) | Appwrite-Server-Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) | +| `AIRCARD_API_KEY` | – (Pflicht) | Appwrite-Server-Key `aircard-api`. Scopes (so beschriftet die Console): `documents.read`, `documents.write`, `files.read`, `files.write`; `documents.*` entspricht den `rows.*` der API (TablesDB) | | `AIRCARD_API_SALT` | – (Pflicht) | Salz für die Melder-Hashes, 32 Byte hex | | `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) | | `AIRCARD_APPWRITE_ENDPOINT` | `http://webklar-appwrite/v1` | interne Appwrite-Adresse im Docker-Netz | @@ -33,7 +33,7 @@ Alle Antworten sind JSON; Fehler haben die Form `{"error": ""}`. | Endpunkt | Auth | Antworten | |---|---|---| -| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falsches Format oder > 5 MiB · `403` Datei nicht meine / falscher Bucket · `409` „Diese Karte ist schon veröffentlicht.“ | +| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falscher Typ oder > 5 MiB, falsche Bildmaße („Das Bild hat nicht das Kartenformat.“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei schon veröffentlicht oder gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“), Karte nicht meine („Diese Karte gehört nicht zu deinem Konto.“) · `409` „Diese Karte ist schon veröffentlicht.“ · `429` „Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.“ | | `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt | | `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) | | `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` | @@ -44,14 +44,16 @@ Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Server ### Regeln im Einzelnen - **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`. **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.“ +- **Felder**: Titel 1–60, Herausgeber ≤ 40, Anzeigename ≤ 30 Zeichen (getrimmt). Nicht erlaubt in Titel, Herausgeber, Anzeigename und Tags: Steuerzeichen (U+0000–001F, U+007F–009F), Nullbreiten- und Bidi-Zeichen (U+200B–200F, U+202A–202E, U+2060–206F) und das BOM (U+FEFF); sie machen Titel unsichtbar oder ordnen Text um. Dasselbe Muster prüft der Veröffentlichen-Dialog. `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Gespeichert wird die Spalte `tags` als `,tag1,tag2,` (Komma an beiden Enden, leer bei keinen Tags): der Tag-Filter der Website sucht `,,` und trifft so nur ganze Tags. Bild und Vorschau müssen verschiedene Dateien sein. +- **Quellkarte**: `sourceCardId` muss eine Zeile der Tabelle `cards` sein, deren `$permissions` `read("user:")` enthalten, sonst `403` „Diese Karte gehört nicht zu deinem Konto.“ (auch wenn es die Zeile nicht gibt). +- **Limit**: höchstens 20 Veröffentlichungen je Konto (Zeilen mit `ownerId` = ich, auch ausgeblendete); ab da `429` mit dem Text aus der Tabelle. Zurückziehen gibt einen Platz frei. Das Limit liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index) mit `limit(20)`. +- **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` (Typ und Größe fremder Dateien nennt die API nicht). **Bildmaße**: Die API liest die ersten 64 Bytes beider Dateien (`GET …/files//view` mit Server-Key, der Rest der Übertragung wird abgebrochen) und liest PNG-IHDR bzw. WebP (`VP8 `, `VP8L`, `VP8X`) aus. Das Bild muss genau 1536 × 969 sein, die Vorschau 480 breit und 302–304 hoch (die Karte ist 1536 × 969, die Vorschau 480 × 303), sonst `400` „Das Bild hat nicht das Kartenformat.“ Das ist Defense in Depth gegen Dekompressionsbomben; die maßgebliche Prüfung beim Anzeigen macht der Client (Pixelgrenze 4 Megapixel). **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 läuft zusammen mit Limit, Quellkarte und Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")` (**kein `delete("user:")` mehr**, kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen und schon festgelegte Dateien bekommen `delete("user:")` zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht `rollback failed for card ` im Log). 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). -- **Rechte** (genau diese Strings): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:")`, `read("label:admin")`; Datei sichtbar `read("any")`, `delete("user:")`; Datei ausgeblendet `read("user:")`, `read("label:admin")`, `delete("user:")`. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). +- **Melden**: Rate-Limit 10 pro Stunde und Anschluss (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge; Schlüssel: gehashte IPv4, IPv6 auf /64 gekürzt, IPv4-gemappte IPv6 als IPv4). Der Melder-Hash ist `HMAC-SHA256(salt, netz)` mit dem **Netz** der Adresse (IPv4 → `/24`, IPv6 → `/48`, IPv4-gemappte IPv6 wie IPv4): drei Adressen aus einem Netz sind ein Melder und blenden eine Karte nicht aus (sonst genügten drei VPN-/Mobilfunk-Adressen). 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 veröffentlicht nie `ports:`, ist aber im Docker-Netz `webklar-network` für andere Container erreichbar, und dort gilt `X-Real-Ip` als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den `clientIp` prüft, oder ein eigenes Netz nur für Traefik und `aircard-api`. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen. +- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). - **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch. -- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien). +- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion, fehlgeschlagenes Zurücknehmen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien). ## Tests diff --git a/deploy/api/app.js b/deploy/api/app.js index 6ab558d..cefaf90 100644 --- a/deploy/api/app.js +++ b/deploy/api/app.js @@ -5,12 +5,12 @@ * 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 / - * Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet - * 409 schon veröffentlicht · 201 {id} + * 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine / + * falscher Bucket / Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet / Karte nicht + * meine (`cards`) · 409 schon veröffentlicht · 429 schon 20 Veröffentlichungen · 201 {id} * POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id} * 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true} - * POST /api/melden ohne Anmeldung {id, reason} + * POST /api/melden ohne Anmeldung {id, reason} (Melder = IPv4-/24 bzw. IPv6-/48, gehasht) * 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true} * POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar} * 401 · 403 kein Admin · 400 · 404 · 200 {ok:true} @@ -18,6 +18,7 @@ * alles andere 404; Body höchstens 8 KB (413), nur application/json (415) * * Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`. + * Der Appwrite-Key braucht die Console-Scopes documents.read/documents.write (= rows.* der API) und files.read/files.write. * Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik. * Fehlerantworten sind immer {error: }. * Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle). @@ -27,10 +28,11 @@ const crypto = require('node:crypto'); const net = require('node:net'); -const { AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js'); +const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js'); const TABLE_GALLERY = 'gallery'; const TABLE_REPORTS = 'reports'; +const TABLE_CARDS = 'cards'; const BUCKET = 'gallery-images'; const MAX_BODY_BYTES = 8 * 1024; @@ -38,9 +40,17 @@ 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; +/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */ +const MAX_PUBLICATIONS = 20; const REPORT_DELETE_ROUNDS = 50; +/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */ +const IMAGE_HEAD_BYTES = 64; +/** Kartenmaße wie web/src/card/geometry.ts (CARD_W, CARD_H, PREVIEW_WIDTH; Vorschauhöhe 303 plus/minus 1). */ +const IMAGE_WIDTH = 1536; +const IMAGE_HEIGHT = 969; +const PREVIEW_WIDTH = 480; +const PREVIEW_HEIGHT_MIN = 302; +const PREVIEW_HEIGHT_MAX = 304; const RATE_LIMIT = 10; const RATE_WINDOW_MS = 60 * 60 * 1000; const RATE_SWEEP_MS = 60 * 1000; @@ -50,7 +60,9 @@ const LOG_WINDOW_MS = 60 * 1000; const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']; const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; -const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; +// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF): +// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts. +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/; const JWT_CHARACTERS = /^[\x21-\x7e]+$/; const MSG = { @@ -65,6 +77,9 @@ const MSG = { 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.', + imageFormat: 'Das Bild hat nicht das Kartenformat.', + notYourCard: 'Diese Karte gehört nicht zu deinem Konto.', + tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`, tooManyReports: 'Zu viele Meldungen – bitte später nochmal.', badRequest: 'Ungültige Anfrage.', badCardId: 'Ungültige Karten-ID.', @@ -98,14 +113,28 @@ function hiddenRowPermissions(owner) { return [`read("user:${owner}")`, 'read("label:admin")']; } -function visibleFilePermissions(owner) { +/** + * Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit + * Fehlversuche auf). Nur so kommen Dateien in `publish`; die API setzt sie beim Veröffentlichen auf `visibleFilePermissions`. + */ +function uploadFilePermissions(owner) { assertOwner(owner); return ['read("any")', `delete("user:${owner}")`]; } +/** + * Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die + * Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen + * löscht die Dateien serverseitig mit dem Key. + */ +function visibleFilePermissions() { + return ['read("any")']; +} + +/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */ function hiddenFilePermissions(owner) { assertOwner(owner); - return [`read("user:${owner}")`, 'read("label:admin")', `delete("user:${owner}")`]; + return [`read("user:${owner}")`, 'read("label:admin")']; } function hash32(text) { @@ -138,11 +167,11 @@ function expandIpv6(address) { } /** - * Vereinheitlicht eine Client-Adresse für Hash und Rate-Limit: IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4, - * jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen). + * Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen), + * { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text). */ -function normalizeIp(ip) { - if (typeof ip !== 'string') return ''; +function parseIp(ip) { + if (typeof ip !== 'string') return { kind: 'none' }; let text = ip.trim(); if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1); const zone = text.indexOf('%'); @@ -150,15 +179,40 @@ function normalizeIp(ip) { const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text); if (withPort) text = withPort[1]; - if (net.isIPv4(text)) return text; + if (net.isIPv4(text)) return { kind: 'v4', text }; if (net.isIPv6(text)) { const groups = expandIpv6(text); if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) { - return `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`; + return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` }; } - return `${groups.slice(0, 4).map((group) => group.toString(16)).join(':')}::/64`; + return { kind: 'v6', groups }; } - return text.toLowerCase().slice(0, 64); + return { kind: 'other', text: text.toLowerCase().slice(0, 64) }; +} + +const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':'); + +/** + * Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4, + * jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen). + */ +function normalizeIp(ip) { + const parsed = parseIp(ip); + if (parsed.kind === 'none') return ''; + if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`; + return parsed.text; +} + +/** + * Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4). + * So zählen drei Adressen aus demselben Netz als ein Melder und blenden eine Karte nicht aus. + */ +function reporterNetwork(ip) { + const parsed = parseIp(ip); + if (parsed.kind === 'none') return ''; + if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`; + if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`; + return parsed.text; } function firstHeaderValue(req, name) { @@ -173,9 +227,13 @@ function clientIp(req) { return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || ''; } -/** HMAC-SHA256 (Hex) der normalisierten IP mit dem Server-Salz. */ +function hmacHex(salt, text) { + return crypto.createHmac('sha256', salt).update(text).digest('hex'); +} + +/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */ function reporterHash(salt, ip) { - return crypto.createHmac('sha256', salt).update(normalizeIp(ip)).digest('hex'); + return hmacHex(salt, reporterNetwork(ip)); } function isPlainObject(value) { @@ -188,6 +246,37 @@ function safeToken(value, fallback = 'unknown') { return text === '' ? fallback : text; } +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** + * Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang). + * @returns {{width: number, height: number} | null} + */ +function readImageSize(bytes) { + if (!Buffer.isBuffer(bytes)) return null; + if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') { + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; + } + if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') { + const kind = bytes.toString('latin1', 12, 16); + if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) { + return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff }; + } + if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) { + const bits = bytes.readUInt32LE(21); + return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; + } + if (kind === 'VP8X' && bytes.length >= 30) { + return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 }; + } + } + return null; +} + +const isCardImageSize = (size) => size !== null && size.width === IMAGE_WIDTH && size.height === IMAGE_HEIGHT; +const isCardPreviewSize = (size) => + size !== null && size.width === PREVIEW_WIDTH && size.height >= PREVIEW_HEIGHT_MIN && size.height <= PREVIEW_HEIGHT_MAX; + const TEXT_FIELDS = [ { key: 'title', article: 'Der', name: 'Titel', max: 60, required: true }, { key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false }, @@ -444,41 +533,59 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { } // ---- Dateien und Zeilen - function checkFile(file, userId) { + /** + * Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im Galerie-Bucket. Metadaten wie Typ und + * Größe prüft erst `checkFileContent`, damit fremde Dateien nichts darüber verraten. + */ + function checkFileExists(file) { if (!file) throw new HttpError(400, MSG.fileMissing); + if (file.bucketId !== BUCKET) throw new HttpError(403, MSG.foreignFile); + } + + /** + * Besitz: frisch hochgeladene Dateien tragen delete("user:") und read("any"). Eine Datei ohne delete(ich) gehört + * jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Zeilen-Dateien + * hat der Aufrufer vorher mit `used` als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet. + */ + function checkFileOwnership(file, userId) { const permissions = Array.isArray(file.$permissions) ? file.$permissions : []; - if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) { - throw new HttpError(403, MSG.foreignFile); - } + if (!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); + } + + function checkFileContent(file) { 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); } + /** Liest den Anfang beider Dateien und verlangt Kartenformat: Bild 1536 x 969, Vorschau 480 x 302-304. */ + async function checkImageSizes(data) { + const [image, preview] = await Promise.all([ + appwrite.readFileHead(BUCKET, data.imageFileId, IMAGE_HEAD_BYTES), + appwrite.readFileHead(BUCKET, data.previewFileId, IMAGE_HEAD_BYTES), + ]); + if (image === null || preview === null) throw new HttpError(400, MSG.fileMissing); + if (!isCardImageSize(readImageSize(image)) || !isCardPreviewSize(readImageSize(preview))) { + throw new HttpError(400, MSG.imageFormat); + } + } + 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). + * Die Galerie-Zeilen dieses Autors (auch ausgeblendete), höchstens MAX_PUBLICATIONS (mehr braucht der Aufrufer nicht: + * ab dort ist das Limit erreicht). Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId` + * nicht). Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht). */ - 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; + function ownerRows(ownerId) { + return appwrite.listRows(TABLE_GALLERY, [queryEqual('ownerId', [ownerId]), queryLimit(MAX_PUBLICATIONS)]); + } + + /** Die private Karte muss es geben und dem Konto gehören (Appwrite gibt dem Besitzer read("user:") auf seine Zeile). */ + async function ownsSourceCard(userId, cardId) { + const card = await appwrite.getRow(TABLE_CARDS, cardId); + return card !== null && Array.isArray(card.$permissions) && card.$permissions.includes(`read("user:${userId}")`); } /** @@ -530,15 +637,20 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { appwrite.getFile(BUCKET, data.imageFileId), appwrite.getFile(BUCKET, data.previewFileId), ]); - checkFile(image, user.id); - checkFile(preview, user.id); + checkFileExists(image); + checkFileExists(preview); - // Je Autor nacheinander: Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen. + // Je Autor nacheinander: Limit, 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); - } + const rows = await ownerRows(user.id); + if (rows.length >= MAX_PUBLICATIONS) throw new HttpError(429, MSG.tooManyPublications); + if (!(await ownsSourceCard(user.id, data.sourceCardId))) throw new HttpError(403, MSG.notYourCard); + + const used = new Set(rows.flatMap(fileIdsOf)); + if (used.has(data.imageFileId) || used.has(data.previewFileId)) throw new HttpError(403, MSG.filesTaken); + for (const file of [image, preview]) checkFileOwnership(file, user.id); + for (const file of [image, preview]) checkFileContent(file); + await checkImageSizes(data); const id = galleryRowId(user.id, data.sourceCardId); try { @@ -548,7 +660,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { { title: data.title, issuer: data.issuer, - tags: data.tags.join(','), + // ",a,b," statt "a,b": der Tag-Filter der Galerie sucht ",," und trifft so nur ganze Tags. + tags: data.tags.length > 0 ? `,${data.tags.join(',')},` : '', displayName: data.displayName, imageFileId: data.imageFileId, previewFileId: data.previewFileId, @@ -565,14 +678,21 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { throw err; } - // Kanonischer Zustand: Beide Dateien tragen genau die sichtbaren Rechte (kein update, keine fremden Rollen). + // Kanonischer Zustand: Beide Dateien tragen genau read("any") (kein update/delete, 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)); + const pinned = await setFilePermissions(data, visibleFilePermissions()); if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing); } catch (err) { - await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {}); + let rolledBack = true; + await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => { + rolledBack = false; + say(`rollback failed for card ${id}`); + }); + // Nur wenn keine Zeile mehr auf die Dateien zeigt: schon festgelegte Dateien bekommen delete(owner) zurück, + // damit der Client den Fehlversuch aufräumen kann (sonst blieben sie öffentlich und unlöschbar liegen). + if (rolledBack) await setFilePermissions(data, uploadFilePermissions(user.id)).catch(() => {}); throw err; } say(`card ${id} published`); @@ -606,8 +726,10 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason); const id = body.id; - const reporter = reporterHash(salt, clientIp(req)); - const limit = rateLimit(reporter); + // Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt weiter je Anschluss (IPv4 voll, IPv6 /64). + const ip = clientIp(req); + const reporter = reporterHash(salt, ip); + const limit = rateLimit(hmacHex(salt, normalizeIp(ip))); if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) }); return withLock(id, async () => { @@ -724,11 +846,14 @@ module.exports = { createApp, visibleRowPermissions, hiddenRowPermissions, + uploadFilePermissions, visibleFilePermissions, hiddenFilePermissions, reporterHash, + reporterNetwork, clientIp, normalizeIp, + readImageSize, validatePublish, buildSearchText, galleryRowId, diff --git a/deploy/api/app.test.js b/deploy/api/app.test.js index 41eb5a7..b318abf 100644 --- a/deploy/api/app.test.js +++ b/deploy/api/app.test.js @@ -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('')), 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:") 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 ` 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: '' })); diff --git a/deploy/api/appwrite.js b/deploy/api/appwrite.js index da77092..b4f0773 100644 --- a/deploy/api/appwrite.js +++ b/deploy/api/appwrite.js @@ -11,6 +11,8 @@ * updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben * deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar) * getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null + * readFileHead(bucket, id, maxBytes) die ersten Bytes des Dateiinhalts (Buffer) oder null; liest nie mehr als + * nötig und bricht den Rest der Übertragung ab * updateFilePermissions(bucket, id, permissions) Datei oder null (404 storage_file_not_found) * deleteFile(bucket, id) true, false bei 404 storage_file_not_found * accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key @@ -28,6 +30,7 @@ */ const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_HEAD_BYTES = 64; const ROW_NOT_FOUND = 'row_not_found'; const FILE_NOT_FOUND = 'storage_file_not_found'; @@ -72,8 +75,30 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`; const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`; - /** Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er. */ - async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [] } = {}) { + /** Liest höchstens `limit` Bytes vom Anfang der Antwort und bricht den Rest ab (Dateien können 5 MiB groß sein). */ + async function readHead(response, limit) { + if (!response.body) return Buffer.from(await response.arrayBuffer()).subarray(0, limit); + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + try { + while (size < limit) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(Buffer.from(value)); + size += value.length; + } + } finally { + await reader.cancel().catch(() => {}); + } + return Buffer.concat(chunks).subarray(0, limit); + } + + /** + * Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er. + * Mit `headBytes` ist `body` bei 2xx ein Buffer mit den ersten Bytes der Antwort statt geparstem JSON. + */ + async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [], headBytes = 0 } = {}) { const requestHeaders = { 'X-Appwrite-Project': project, ...headers }; if (withKey) requestHeaders['X-Appwrite-Key'] = key; const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) }; @@ -86,6 +111,9 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, let text; try { response = await fetchImpl(`${base}${path}`, init); + if (headBytes > 0 && response.status >= 200 && response.status < 300) { + return { status: response.status, body: await readHead(response, headBytes) }; + } text = await response.text(); } catch (err) { // Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten). @@ -147,6 +175,11 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, return result.status === 404 ? null : result.body; }, + async readFileHead(bucket, id, maxBytes = DEFAULT_HEAD_BYTES) { + const result = await send('GET', `${filePath(bucket, id)}/view`, { missingTypes: [FILE_NOT_FOUND], headBytes: maxBytes }); + return result.status === 404 ? null : result.body; + }, + async updateFilePermissions(bucket, id, permissions) { const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] }); return result.status === 404 ? null : result.body; diff --git a/deploy/api/appwrite.test.js b/deploy/api/appwrite.test.js index b5c6d1d..54cc579 100644 --- a/deploy/api/appwrite.test.js +++ b/deploy/api/appwrite.test.js @@ -149,6 +149,36 @@ test('Dateien: lesen, Rechte setzen, löschen', async () => { assert.equal(fake.file('f1'), null); }); +test('readFileHead: liefert nur die ersten Bytes, mit Server-Key, auch bei einer großen Datei', async () => { + const { fake, appwrite } = await setup(); + const content = Buffer.concat([Buffer.from('HEADBYTES'), Buffer.alloc(2 * 1024 * 1024, 7)]); + fake.addFile({ id: 'big', owner: 'u1', content, size: content.length }); + + const head = await appwrite.readFileHead('gallery-images', 'big', 9); + assert.ok(Buffer.isBuffer(head)); + assert.equal(head.toString('latin1'), 'HEADBYTES'); + assert.equal(lastCall(fake).method, 'GET'); + assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/big/view'); + assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key); + + const sixtyFour = await appwrite.readFileHead('gallery-images', 'big'); + assert.equal(sixtyFour.length, 64, 'Standard: 64 Bytes'); + + fake.addFile({ id: 'small', owner: 'u1', content: Buffer.from('abc'), size: 3 }); + assert.equal((await appwrite.readFileHead('gallery-images', 'small', 64)).toString(), 'abc', 'kürzere Datei: alles'); +}); + +test('readFileHead: fehlende Datei ist null, andere Fehler werfen AppwriteError', async () => { + const { fake, appwrite } = await setup(); + assert.equal(await appwrite.readFileHead('gallery-images', 'nope'), null); + fake.addFile({ id: 'f1', owner: 'u1' }); + fake.failNext((c) => c.path.endsWith('/files/f1/view'), { status: 500 }); + await assert.rejects(() => appwrite.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 500); + const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' }); + await assert.rejects(() => wrongKey.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 401); + await assert.rejects(() => appwrite.readFileHead('gallery-images', '..'), TypeError); +}); + test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => { const { fake, appwrite } = await setup(); fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] }); diff --git a/deploy/api/fake-appwrite.js b/deploy/api/fake-appwrite.js index 106b3f2..9660f20 100644 --- a/deploy/api/fake-appwrite.js +++ b/deploy/api/fake-appwrite.js @@ -7,6 +7,9 @@ * GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409 * GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?} * GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions} + * GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`) + * + * Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an). * * 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. @@ -18,6 +21,46 @@ const http = require('node:http'); const BUCKETS = ['gallery-images', 'card-images']; +/** PNG-Anfang (Signatur + IHDR) mit den Maßen; reicht für die Größenprüfung der API. */ +function pngHeader(width, height) { + const head = Buffer.alloc(33); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(head, 0); + head.writeUInt32BE(13, 8); + head.write('IHDR', 12, 'latin1'); + head.writeUInt32BE(width, 16); + head.writeUInt32BE(height, 20); + head[24] = 8; // Bit-Tiefe + head[25] = 6; // RGBA + return head; +} + +/** WebP-Anfang mit den Maßen; kind: 'VP8 ' (verlustbehaftet), 'VP8L' (verlustfrei) oder 'VP8X' (erweitert). */ +function webpHeader(kind, width, height) { + const head = Buffer.alloc(40); + head.write('RIFF', 0, 'latin1'); + head.writeUInt32LE(32, 4); + head.write('WEBP', 8, 'latin1'); + head.write(kind, 12, 'latin1'); + if (kind === 'VP8 ') { + head.writeUInt32LE(10, 16); + Buffer.from([0x30, 0x01, 0x00, 0x9d, 0x01, 0x2a]).copy(head, 20); + head.writeUInt16LE(width & 0x3fff, 26); + head.writeUInt16LE(height & 0x3fff, 28); + } else if (kind === 'VP8L') { + head.writeUInt32LE(5, 16); + head[20] = 0x2f; + head.writeUInt32LE(((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14), 21); + } else if (kind === 'VP8X') { + head.writeUInt32LE(10, 16); + head[20] = 0x10; // Alpha-Flag + head.writeUIntLE(width - 1, 24, 3); + head.writeUIntLE(height - 1, 27, 3); + } else { + throw new Error(`Unbekannte WebP-Art: ${kind}`); + } + return head; +} + const COLUMNS = { gallery: { title: { size: 60, required: true }, @@ -40,8 +83,9 @@ const COLUMNS = { }; async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) { - const tables = { gallery: new Map(), reports: new Map() }; + const tables = { gallery: new Map(), reports: new Map(), cards: new Map() }; const files = new Map(); + const contents = new Map(); const users = new Map(); const calls = []; const injections = []; @@ -56,7 +100,13 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret users.set(jwt, account); } - function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000 }) { + /** + * Datei mit Metadaten und Inhalt. `role` bestimmt den Standardinhalt: 'image' = Kartenbild 1536 x 969, 'preview' = + * Vorschau 480 x 303 (Standard: WebP ist die Vorschau, alles andere das Bild); `content` überschreibt ihn. + */ + function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000, role, content }) { + const kind = role || (mimeType === 'image/webp' ? 'preview' : 'image'); + const [width, height] = kind === 'preview' ? [480, 303] : [1536, 969]; files.set(id, { $id: id, bucketId: bucket, @@ -65,6 +115,12 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret mimeType, sizeOriginal: size, }); + contents.set(id, content || (mimeType === 'image/webp' ? webpHeader('VP8 ', width, height) : pngHeader(width, height))); + } + + /** Private Karte (Tabelle `cards`) mit den Rechten, die Appwrite dem Besitzer beim Anlegen gibt. */ + function addCard(id, owner, permissions) { + return addRow('cards', id, { name: 'Karte' }, permissions || [`read("user:${owner}")`, `update("user:${owner}")`, `delete("user:${owner}")`]); } function addRow(table, id, data, permissions) { @@ -206,6 +262,17 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret } } + // Dateiinhalt: GET .../files/{id}/view + if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 7 && segments[6] === 'view' && req.method === 'GET') { + if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found'); + const file = files.get(segments[5]); + if (!file) return error(404, 'storage_file_not_found'); + const content = contents.get(segments[5]); + res.writeHead(200, { 'Content-Type': file.mimeType, 'Content-Length': content.length }); + res.end(content); + return; + } + // Dateien if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) { if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found'); @@ -214,6 +281,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret if (req.method === 'GET') return reply(200, file); if (req.method === 'DELETE') { files.delete(segments[5]); + contents.delete(segments[5]); return reply(204); } if (req.method === 'PUT') { @@ -240,6 +308,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret addUser, addRawUser, addFile, + addCard, addRow, failNext, hangNext, @@ -303,4 +372,4 @@ function runQueries(rows, search) { return result.slice(0, limit); } -module.exports = { startFakeAppwrite }; +module.exports = { startFakeAppwrite, pngHeader, webpHeader };