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

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

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

No files matched your search

+10 -8
View File
@@ -19,7 +19,7 @@ Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur `server.js
| Variable | Standard | Bedeutung | | 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_API_SALT` | – (Pflicht) | Salz für die Melder-Hashes, 32 Byte hex |
| `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) | | `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) |
| `AIRCARD_APPWRITE_ENDPOINT` | `http://webklar-appwrite/v1` | interne Appwrite-Adresse im Docker-Netz | | `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": "<deutscher Text>"}`.
| Endpunkt | Auth | Antworten | | 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/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/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` | | `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 ### 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. - **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. - **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 `,<tag>,` und trifft so nur ganze Tags. Bild und Vorschau müssen verschiedene Dateien sein.
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, 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:<ich>")` (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.“ - **Quellkarte**: `sourceCardId` muss eine Zeile der Tabelle `cards` sein, deren `$permissions` `read("user:<ich>")` 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:<ich>")`, 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/<id>/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:<ich>")` 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:<ich>")` zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht `rollback failed for card <id>` 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. - **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. - **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 darf deshalb nur über Traefik erreichbar sein (kein `ports:` in Compose). - **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): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei sichtbar `read("any")`, `delete("user:<owner>")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`, `delete("user:<owner>")`. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). - **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `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. - **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 ## Tests
+181 -56
View File
@@ -5,12 +5,12 @@
* Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js). * Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
* *
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId} * POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format · 403 Datei nicht meine / falscher Bucket / * 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
* Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet * falscher Bucket / Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet / Karte nicht
* 409 schon veröffentlicht · 201 {id} * meine (`cards`) · 409 schon veröffentlicht · 429 schon 20 Veröffentlichungen · 201 {id}
* POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id} * POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id}
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true} * 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} * 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
* POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar} * POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true} * 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) * 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`. * 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. * Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
* Fehlerantworten sind immer {error: <deutscher Text>}. * Fehlerantworten sind immer {error: <deutscher Text>}.
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle). * 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 crypto = require('node:crypto');
const net = require('node:net'); 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_GALLERY = 'gallery';
const TABLE_REPORTS = 'reports'; const TABLE_REPORTS = 'reports';
const TABLE_CARDS = 'cards';
const BUCKET = 'gallery-images'; const BUCKET = 'gallery-images';
const MAX_BODY_BYTES = 8 * 1024; 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 FILE_MIME_TYPES = new Set(['image/png', 'image/webp']);
const HIDE_AT_REPORTS = 3; const HIDE_AT_REPORTS = 3;
const REPORT_LIST_LIMIT = 100; const REPORT_LIST_LIMIT = 100;
const OWNER_ROWS_PAGE = 100; /** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
const OWNER_ROWS_MAX_PAGES = 100; const MAX_PUBLICATIONS = 20;
const REPORT_DELETE_ROUNDS = 50; 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_LIMIT = 10;
const RATE_WINDOW_MS = 60 * 60 * 1000; const RATE_WINDOW_MS = 60 * 60 * 1000;
const RATE_SWEEP_MS = 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 REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; 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 JWT_CHARACTERS = /^[\x21-\x7e]+$/;
const MSG = { const MSG = {
@@ -65,6 +77,9 @@ const MSG = {
fileSize: 'Das Bild ist größer als 5 MB.', fileSize: 'Das Bild ist größer als 5 MB.',
alreadyPublished: 'Diese Karte ist schon veröffentlicht.', alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.', 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.', tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
badRequest: 'Ungültige Anfrage.', badRequest: 'Ungültige Anfrage.',
badCardId: 'Ungültige Karten-ID.', badCardId: 'Ungültige Karten-ID.',
@@ -98,14 +113,28 @@ function hiddenRowPermissions(owner) {
return [`read("user:${owner}")`, 'read("label:admin")']; 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); assertOwner(owner);
return ['read("any")', `delete("user:${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) { function hiddenFilePermissions(owner) {
assertOwner(owner); assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")', `delete("user:${owner}")`]; return [`read("user:${owner}")`, 'read("label:admin")'];
} }
function hash32(text) { 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, * Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen). * { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
*/ */
function normalizeIp(ip) { function parseIp(ip) {
if (typeof ip !== 'string') return ''; if (typeof ip !== 'string') return { kind: 'none' };
let text = ip.trim(); let text = ip.trim();
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1); if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
const zone = text.indexOf('%'); const zone = text.indexOf('%');
@@ -150,15 +179,40 @@ function normalizeIp(ip) {
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text); const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
if (withPort) text = withPort[1]; if (withPort) text = withPort[1];
if (net.isIPv4(text)) return text; if (net.isIPv4(text)) return { kind: 'v4', text };
if (net.isIPv6(text)) { if (net.isIPv6(text)) {
const groups = expandIpv6(text); const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) { 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) { 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) || ''; 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) { function reporterHash(salt, ip) {
return crypto.createHmac('sha256', salt).update(normalizeIp(ip)).digest('hex'); return hmacHex(salt, reporterNetwork(ip));
} }
function isPlainObject(value) { function isPlainObject(value) {
@@ -188,6 +246,37 @@ function safeToken(value, fallback = 'unknown') {
return text === '' ? fallback : text; 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 = [ const TEXT_FIELDS = [
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true }, { key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
{ key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false }, { 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 // ---- 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) throw new HttpError(400, MSG.fileMissing);
if (file.bucketId !== BUCKET) throw new HttpError(403, MSG.foreignFile);
}
/**
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") 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 : []; const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) { if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
throw new HttpError(403, MSG.foreignFile);
}
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie. // Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken); 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 (!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); 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); 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). * Die Galerie-Zeilen dieses Autors (auch ausgeblendete), höchstens MAX_PUBLICATIONS (mehr braucht der Aufrufer nicht:
* Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht). * ab dort ist das Limit erreicht). Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId`
* Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId` nicht) und blättert mit * nicht). Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
* Cursor. Mehr als OWNER_ROWS_MAX_PAGES Seiten: null (der Aufrufer lehnt dann ab).
*/ */
async function fileIdsInUse(ownerId) { function ownerRows(ownerId) {
const used = new Set(); return appwrite.listRows(TABLE_GALLERY, [queryEqual('ownerId', [ownerId]), queryLimit(MAX_PUBLICATIONS)]);
let cursor = null; }
for (let page = 0; page < OWNER_ROWS_MAX_PAGES; page += 1) {
const queries = [queryEqual('ownerId', [ownerId]), queryLimit(OWNER_ROWS_PAGE)]; /** Die private Karte muss es geben und dem Konto gehören (Appwrite gibt dem Besitzer read("user:<id>") auf seine Zeile). */
if (cursor !== null) queries.push(queryCursorAfter(cursor)); async function ownsSourceCard(userId, cardId) {
const rows = await appwrite.listRows(TABLE_GALLERY, queries); const card = await appwrite.getRow(TABLE_CARDS, cardId);
for (const row of rows) { return card !== null && Array.isArray(card.$permissions) && card.$permissions.includes(`read("user:${userId}")`);
used.add(row.imageFileId);
used.add(row.previewFileId);
}
if (rows.length < OWNER_ROWS_PAGE) return used;
cursor = rows[rows.length - 1].$id;
}
return null;
} }
/** /**
@@ -530,15 +637,20 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
appwrite.getFile(BUCKET, data.imageFileId), appwrite.getFile(BUCKET, data.imageFileId),
appwrite.getFile(BUCKET, data.previewFileId), appwrite.getFile(BUCKET, data.previewFileId),
]); ]);
checkFile(image, user.id); checkFileExists(image);
checkFile(preview, user.id); 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 () => { return withLock(`owner:${user.id}`, async () => {
const used = await fileIdsInUse(user.id); const rows = await ownerRows(user.id);
if (used === null || used.has(data.imageFileId) || used.has(data.previewFileId)) { if (rows.length >= MAX_PUBLICATIONS) throw new HttpError(429, MSG.tooManyPublications);
throw new HttpError(403, MSG.filesTaken); 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); const id = galleryRowId(user.id, data.sourceCardId);
try { try {
@@ -548,7 +660,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
{ {
title: data.title, title: data.title,
issuer: data.issuer, issuer: data.issuer,
tags: data.tags.join(','), // ",a,b," statt "a,b": der Tag-Filter der Galerie sucht ",<tag>," und trifft so nur ganze Tags.
tags: data.tags.length > 0 ? `,${data.tags.join(',')},` : '',
displayName: data.displayName, displayName: data.displayName,
imageFileId: data.imageFileId, imageFileId: data.imageFileId,
previewFileId: data.previewFileId, previewFileId: data.previewFileId,
@@ -565,14 +678,21 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
throw err; 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. // 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. // Scheitert das Festlegen, wird die Zeile zurückgenommen.
try { 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); if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
} catch (err) { } 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; throw err;
} }
say(`card ${id} published`); 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); if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
const id = body.id; const id = body.id;
const reporter = reporterHash(salt, clientIp(req)); // Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt weiter je Anschluss (IPv4 voll, IPv6 /64).
const limit = rateLimit(reporter); 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) }); if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
return withLock(id, async () => { return withLock(id, async () => {
@@ -724,11 +846,14 @@ module.exports = {
createApp, createApp,
visibleRowPermissions, visibleRowPermissions,
hiddenRowPermissions, hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions, visibleFilePermissions,
hiddenFilePermissions, hiddenFilePermissions,
reporterHash, reporterHash,
reporterNetwork,
clientIp, clientIp,
normalizeIp, normalizeIp,
readImageSize,
validatePublish, validatePublish,
buildSearchText, buildSearchText,
galleryRowId, galleryRowId,
+415 -94
View File
@@ -9,16 +9,19 @@ const crypto = require('node:crypto');
const http = require('node:http'); const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js'); const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js'); const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
const { const {
createApp, createApp,
visibleRowPermissions, visibleRowPermissions,
hiddenRowPermissions, hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions, visibleFilePermissions,
hiddenFilePermissions, hiddenFilePermissions,
reporterHash, reporterHash,
reporterNetwork,
clientIp, clientIp,
normalizeIp, normalizeIp,
readImageSize,
validatePublish, validatePublish,
buildSearchText, buildSearchText,
galleryRowId, galleryRowId,
@@ -33,6 +36,10 @@ const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other'; const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin'; const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
const MIB5 = 5 * 1024 * 1024; 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 --------------------------------------------------------------------------------- // ---- 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: image, owner });
fake.addFile({ id: preview, owner, mimeType: 'image/webp' }); 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). */ /** 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`; const previewFileId = `${id}-prev`;
fake.addFile({ id: imageFileId, owner }); fake.addFile({ id: imageFileId, owner });
fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' }); fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' });
if (hidden) { // Veröffentlichte Dateien tragen nie delete(owner): sichtbar nur read("any"), ausgeblendet nur Autor und Admin lesen.
fake.file(imageFileId).$permissions = hiddenFilePermissions(owner); const filePermissions = hidden ? hiddenFilePermissions(owner) : visibleFilePermissions();
fake.file(previewFileId).$permissions = hiddenFilePermissions(owner); fake.file(imageFileId).$permissions = filePermissions;
} fake.file(previewFileId).$permissions = filePermissions;
fake.addCard(`src-${id}`, owner);
fake.addRow( fake.addRow(
'gallery', 'gallery',
id, 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', () => { test('Rechte-Strings sind genau die aus der Spec', () => {
assert.deepEqual(visibleRowPermissions(), ['read("any")']); assert.deepEqual(visibleRowPermissions(), ['read("any")']);
assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']); assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
assert.deepEqual(visibleFilePermissions('abc'), ['read("any")', 'delete("user:abc")']); // Abweichung von Spec §7 (Ruling): veröffentlichte Dateien tragen kein delete(owner), sonst ließe sich der Inhalt
assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")', 'delete("user:abc")']); // 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)', () => { 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]) { for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) {
assert.throws(() => hiddenRowPermissions(bad)); assert.throws(() => hiddenRowPermissions(bad));
assert.throws(() => visibleFilePermissions(bad)); assert.throws(() => uploadFilePermissions(bad));
assert.throws(() => hiddenFilePermissions(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')); assert.notEqual(galleryRowId('u1', 'c1'), galleryRowId('u2', 'c1'));
}); });
test('reporterHash ist HMAC-SHA256 (Hex) mit dem Salz; IPv6 wird auf /64 gekürzt', () => { test('reporterNetwork: IPv4 -> /24, IPv6 -> /48, IPv4-gemappte IPv6 wie IPv4', () => {
assert.equal(reporterHash(SALT, '203.0.113.7'), crypto.createHmac('sha256', SALT).update('203.0.113.7').digest('hex')); assert.equal(reporterNetwork('203.0.7.9'), '203.0.7.0/24');
assert.notEqual(reporterHash('anderes-salz', '203.0.113.7'), reporterHash(SALT, '203.0.113.7')); assert.equal(reporterNetwork(' 203.0.7.200:5555 '), '203.0.7.0/24');
assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:12:ffff:eeee:dddd:2')); assert.equal(reporterNetwork('::ffff:203.0.7.9'), '203.0.7.0/24');
assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:13::1')); 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', () => { 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.7.9'), '203.0.7.9');
assert.equal(normalizeIp(' 203.0.113.7:5555 '), '203.0.113.7'); assert.equal(normalizeIp(' 203.0.7.9:5555 '), '203.0.7.9');
assert.equal(normalizeIp('::ffff:203.0.113.7'), '203.0.113.7'); 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('::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:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd:12::/64');
assert.equal(normalizeIp('2001:db8::1'), '2001:db8:0:0::/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', { const ok = await call('POST', '/api/melden', {
raw: '{"id":"card-1","reason":"spam"}', raw: '{"id":"card-1","reason":"spam"}',
contentType: 'application/json; charset=utf-8', 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); 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); 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(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'); 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.deepEqual(row.$permissions, ['read("any")']);
assert.equal(row.title, 'Mein Motiv'); assert.equal(row.title, 'Mein Motiv');
assert.equal(row.issuer, 'Beispielbank'); 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.displayName, 'Kenso');
assert.equal(row.imageFileId, 'img1'); assert.equal(row.imageFileId, 'img1');
assert.equal(row.previewFileId, 'prev1'); 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 () => { test('Veröffentlichen: Bild und Vorschau dürfen PNG oder WebP sein, Anzeigename und Herausgeber sind optional', async () => {
const { post, fake } = await setup(); const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: ME, mimeType: 'image/png', size: MIB5 }); 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); const res = await post('/api/veroeffentlichen', { title: 'Nur Titel', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1' }, JWT_ME);
assert.equal(res.status, 201); assert.equal(res.status, 201);
const row = fake.row('gallery', res.json.id); 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 () => { test('Veröffentlichen: nur PNG/WebP bis 5 MiB, sonst 400', async () => {
const { post, fake } = await setup(); const { post, fake } = await setup();
fake.addCard('card1', ME);
const attempt = (image, preview) => { const attempt = (image, preview) => {
fake.addFile({ id: 'img1', owner: ME, ...image }); fake.addFile({ id: 'img1', owner: ME, ...image });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', ...preview }); 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(again.json.error, 'Diese Karte ist schon veröffentlicht.');
assert.equal(fake.rows('gallery').length, 1); assert.equal(fake.rows('gallery').length, 1);
// eine andere Person darf dieselbe sourceCardId-Zeichenfolge haben (Zeilen-ID enthält den Besitzer) // eine andere Person veröffentlicht ihre eigene Karte (Zeilen-ID enthält den Besitzer)
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9' }); seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9', card: 'card9' });
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9' }), JWT_OTHER); const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9', sourceCardId: 'card9' }), JWT_OTHER);
assert.equal(other.status, 201); assert.equal(other.status, 201);
assert.notEqual(other.json.id, galleryRowId(ME, 'card1')); assert.notEqual(other.json.id, galleryRowId(ME, 'card1'));
assert.equal(fake.rows('gallery').length, 2); 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/], ['Tags als Zeichenkette', { tags: 'a,b' }, /Tags/],
['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/], ['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/],
['Tag mit Komma', { tags: ['a,b'] }, /Komma/], ['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 mit ../', { imageFileId: '../x' }, /ID/],
['Bild-ID ..', { imageFileId: '..' }, /ID/], ['Bild-ID ..', { imageFileId: '..' }, /ID/],
['Bild-ID Zahl', { imageFileId: 123 }, /ID/], ['Bild-ID Zahl', { imageFileId: 123 }, /ID/],
@@ -514,7 +554,7 @@ test('Veröffentlichen: Tags werden serverseitig erneut normalisiert', async ()
seedUploads(fake); seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME); const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME);
assert.equal(res.status, 201); 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 () => { 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 { report, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake); 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.deepEqual(res.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 1); assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
assert.equal(fake.row('gallery', 'card-1').hidden, false); 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.deepEqual(res.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 2); assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']); 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.equal(res.status, 200);
assert.deepEqual(res.json, { ok: true }); 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.hidden, true);
assert.equal(row.reportCount, 3); assert.equal(row.reportCount, 3);
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']); 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(imageFileId).$permissions, hiddenFile);
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile); assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
assert.equal(fake.rows('reports').length, 3); assert.equal(fake.rows('reports').length, 3);
// ausgeblendete Karte melden: 404 // 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.status, 404);
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.'); assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(fake.rows('reports').length, 3); 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 () => { test('Melden: zwei Melder reichen nicht, die Meldungen speichern nur Hash, Grund und Karte', async () => {
const { report, fake } = await setup(); const { report, fake } = await setup();
seedCard(fake); seedCard(fake);
await report('card-1', '203.0.113.1', 'spam'); await report('card-1', '203.0.1.9', 'spam');
await report('card-1', '203.0.113.2', 'sonstiges'); await report('card-1', '203.0.2.9', 'sonstiges');
assert.equal(fake.row('gallery', 'card-1').hidden, false); 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)); const stored = fake.row('reports', reportRowId('card-1', hash));
assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)'); assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)');
assert.equal(stored.galleryId, 'card-1'); assert.equal(stored.galleryId, 'card-1');
assert.equal(stored.reason, 'spam'); assert.equal(stored.reason, 'spam');
assert.equal(stored.reporterHash, hash); assert.equal(stored.reporterHash, hash);
assert.deepEqual(stored.$permissions, []); 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 () => { test('Melden: dieselbe IP fünfmal zählt einmal, Antworten sind schonGemeldet', async () => {
const { report, fake } = await setup(); const { report, fake } = await setup();
seedCard(fake); 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 }); assert.deepEqual(first.json, { ok: true });
for (let i = 0; i < 4; i += 1) { 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.equal(again.status, 200);
assert.deepEqual(again.json, { schonGemeldet: true }); 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); seedCard(fake);
for (let i = 0; i < 10; i += 1) { 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}`); 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.status, 429);
assert.equal(res.json.error, 'Zu viele Meldungen – bitte später nochmal.'); assert.equal(res.json.error, 'Zu viele Meldungen – bitte später nochmal.');
assert.equal(res.headers['retry-after'], '3600'); assert.equal(res.headers['retry-after'], '3600');
// eine andere IP ist nicht betroffen // 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 // knapp vor Ablauf noch gesperrt, nach einer Stunde wieder frei
clock.value += 3600_000 - 1; 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; 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.equal(res.status, 200);
assert.deepEqual(res.json, { schonGemeldet: true }); 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 () => { test('Melden: abgelaufene Rate-Limit-Einträge werden entfernt', async () => {
const { report, fake, clock, app } = await setup(); const { report, fake, clock, app } = await setup();
seedCard(fake); 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); assert.equal(app.rateLimitEntries(), 3);
clock.value += 3600_000 + 60_000; 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'); 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(); const { report, post, fake, app } = await setup();
seedCard(fake); seedCard(fake);
for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) { 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(res.status, 400, String(reason));
assert.equal(typeof res.json.error, 'string'); assert.equal(typeof res.json.error, 'string');
} }
for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) { 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(res.status, 400, String(id));
} }
assert.equal(fake.calls.length, 0); assert.equal(fake.calls.length, 0);
assert.equal(app.rateLimitEntries(), 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 () => { test('Melden: nicht vorhandene Karte ist 404', async () => {
const { report, fake } = await setup(); 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.status, 404);
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.'); assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(fake.rows('reports').length, 0); assert.equal(fake.rows('reports').length, 0);
@@ -665,7 +705,7 @@ test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spa
seedCard(fake); seedCard(fake);
const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']; const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
for (let i = 0; i < reasons.length; i += 1) { 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]); assert.equal(res.status, 200, reasons[i]);
if (i === 2) break; // ab 3 Meldern ist die Karte weg 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']); 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(); const { report, fake } = await setup();
seedCard(fake); 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::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: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); 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 () => { test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Adresse', async () => {
const { post, fake } = await setup(); const { post, fake } = await setup();
seedCard(fake); seedCard(fake);
const viaForwarded = (ip) => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, { 'X-Forwarded-For': `${ip}, 10.0.0.1` }); 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.77.9')).json, { ok: true });
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { schonGemeldet: true }); assert.deepEqual((await viaForwarded('203.0.77.9')).json, { schonGemeldet: true });
assert.deepEqual((await viaForwarded('203.0.113.78')).json, { ok: 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 // 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.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'); 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 () => { test('Melden: gleichzeitige Meldungen von drei IPs ergeben genau drei und blenden aus', async () => {
const { report, fake } = await setup(); const { report, fake } = await setup();
seedCard(fake); 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]); assert.deepEqual(results.map((r) => r.status), [200, 200, 200]);
const row = fake.row('gallery', 'card-1'); const row = fake.row('gallery', 'card-1');
assert.equal(row.reportCount, 3); assert.equal(row.reportCount, 3);
@@ -710,9 +774,9 @@ test('Melden: eine verspätete Zählerstand-Änderung überschreibt neuere Stän
seedCard(fake); seedCard(fake);
// Die erste Meldung schreibt ihren Zähler (1) erst nach 150 ms; währenddessen kommen zwei weitere. // 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); 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)); 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; const firstResult = await first;
assert.deepEqual([firstResult.status, ...others.map((r) => r.status)], [200, 200, 200]); 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(); const { report, fake } = await setup();
seedCard(fake); seedCard(fake);
// drei Meldungen liegen schon vor, der Zähler an der Karte kam aber nie an (z. B. Ausfall mitten im Ablauf) // 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); const hash = reporterHash(SALT, ip);
fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []); fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []);
} }
assert.equal(fake.row('gallery', 'card-1').hidden, false); 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.deepEqual(res.json, { schonGemeldet: true });
assert.equal(fake.row('gallery', 'card-1').hidden, true); assert.equal(fake.row('gallery', 'card-1').hidden, true);
assert.equal(fake.row('gallery', 'card-1').reportCount, 3); 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(); const { report, fake } = await setup();
seedCard(fake); seedCard(fake);
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/reports/rows'), { status: 500 }); 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(res.status, 502);
assert.equal(fake.row('gallery', 'card-1').reportCount, 0); assert.equal(fake.row('gallery', 'card-1').reportCount, 0);
assert.equal(fake.rows('reports').length, 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 () => { test('Melden: Ausfall beim Ausblenden endet mit 502, ein erneuter Versuch blendet aus', async () => {
const { report, fake } = await setup(); const { report, fake } = await setup();
seedCard(fake, { reportCount: 0 }); seedCard(fake, { reportCount: 0 });
await report('card-1', '203.0.113.1'); await report('card-1', '203.0.1.9');
await report('card-1', '203.0.113.2'); await report('card-1', '203.0.2.9');
fake.failNext((c) => c.method === 'PATCH' && c.path.includes('/tables/gallery/rows/'), { status: 500 }); 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(res.status, 502);
assert.equal(fake.row('gallery', 'card-1').hidden, false); 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(retry.status, 200);
assert.equal(fake.row('gallery', 'card-1').hidden, true); 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.hidden, true);
assert.equal(row.reportCount, 1); assert.equal(row.reportCount, 1);
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']); 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-img').$permissions, hiddenFile);
assert.deepEqual(fake.file('card-1-prev').$permissions, hiddenFile); assert.deepEqual(fake.file('card-1-prev').$permissions, hiddenFile);
assert.equal(fake.rows('reports').length, 1); 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.hidden, false);
assert.equal(row.reportCount, 0); assert.equal(row.reportCount, 0);
assert.deepEqual(row.$permissions, ['read("any")']); 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-img').$permissions, ['read("any")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']); assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")']);
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']); assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
// die Karte ist wieder meldbar // 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 () => { 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' }); seedCard(fake, { id: 'card-9' });
await post('/api/veroeffentlichen', publishBody(), JWT_ME); await post('/api/veroeffentlichen', publishBody(), JWT_ME);
await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz'); await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz');
await report('card-9', '203.0.113.7'); await report('card-9', '203.0.7.9');
await report('card-9', '203.0.113.8'); await report('card-9', '203.0.8.9');
await report('card-9', '203.0.113.9'); await report('card-9', '203.0.9.9');
await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN); await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 }); fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 });
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME); 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); assert.ok(logs.length > 0);
const all = logs.join('\n'); 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)}…`); assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);
} }
for (const line of logs) assert.ok(line.startsWith('[aircard-api] '), line); 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 { call, post, report, logs } = await setup();
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`; const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
for (let i = 0; i < 12; i += 1) { 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: '{"id":"x","reason":"spam"}', contentType: 'text/plain' }); // 415
await call('POST', '/api/melden', { raw: big }); // 413 await call('POST', '/api/melden', { raw: big }); // 413
await call('POST', '/api/melden', { raw: '{kaputt' }); // 400 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 post('/api/zurueckziehen', { id: 'x' }, 'zwei teile'); // 401
await call('GET', '/api/gibts-nicht'); // 404 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.equal(lastLimited.status, 429, 'das Rate-Limit hat wirklich gegriffen');
assert.deepEqual(logs, []); 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) { for (const [failure, status, message] of expectations) {
fake.failNext(isRowRead, failure); 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.status, status, `${failure.status} ${failure.type}`);
assert.equal(res.json.error, message); 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.'); 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(); 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: '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")'] }); 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); const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201); assert.equal(res.status, 201);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']); assert.deepEqual(fake.file('img1').$permissions, ['read("any")']);
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")', 'delete("user:user-me")']); 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 () => { 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'); 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 () => { test('Dateien einer ausgeblendeten Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
const { post, fake } = await setup(); const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'hid', hidden: true, reportCount: 3 }); 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); const res = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'andere-karte' }), JWT_ME);
assert.equal(res.status, 403); 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 () => { test('Dateien einer sichtbaren Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
const { post, fake } = await setup(); const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'vis' }); const { imageFileId, previewFileId } = seedCard(fake, { id: 'vis' });
fake.addCard('andere-karte', ME);
fake.addFile({ id: 'fresh-img', owner: ME }); fake.addFile({ id: 'fresh-img', owner: ME });
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' }); 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 () => { test('Überkreuz-Verwendung: Bild einer Karte als Vorschau der neuen (und umgekehrt) wird abgelehnt', async () => {
const { post, fake } = await setup(); const { post, fake } = await setup();
seedCard(fake, { id: 'vis' }); // Bild vis-img, Vorschau vis-prev 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-img', owner: ME });
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' }); 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 // 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 () => { test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)', async () => {
const { post, fake } = await setup(); const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: ME, permissions: ['delete("user:user-me")'] }); fake.addFile({ id: 'img1', owner: ME, permissions: ['delete("user:user-me")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' }); fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); 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); 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(); const { post, fake } = await setup();
for (let i = 0; i < 250; i += 1) { for (let i = 0; i < 40; i += 1) {
const owner = i % 50 === 0 ? OTHER : ME; const owner = i % 2 === 0 ? OTHER : ME; // 20 Karten je Person
fake.addRow( fake.addRow(
'gallery', 'gallery',
`row${i}`, `row${i}`,
@@ -1112,26 +1194,31 @@ test('Prüfung der Dateinutzung blättert durch alle Karten des Autors (250 Kart
['read("any")'], ['read("any")'],
); );
} }
fake.addFile({ id: 'f230a', owner: ME }); // Zeile 39 gehört OTHER: ME hat 19 Zeilen (eine weniger als das Limit), OTHER 21
fake.addFile({ id: 'f230b', owner: ME, mimeType: 'image/webp' }); fake.rows('gallery').find((row) => row.$id === 'row39').ownerId = OTHER;
const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f230a', previewFileId: 'f230b', sourceCardId: 'neu1' }), JWT_ME); fake.addFile({ id: 'f37a', owner: ME, permissions: ['read("any")', 'delete("user:user-me")'] });
assert.equal(taken.status, 403, 'Karte 230 liegt auf der dritten Seite'); 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); assert.equal(taken.json.error, TAKEN);
const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/gallery/rows')); const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/gallery/rows'));
assert.equal(lists.length, 3, 'drei Seiten à 100'); assert.equal(lists.length, 1);
for (const call of lists) assert.ok(decodeURIComponent(call.search).includes('"attribute":"ownerId","values":["user-me"]')); 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); seedUploads(fake);
fake.addCard('neu2', ME);
const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME); const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME);
assert.equal(ok.status, 201); 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 () => { test('Gleichzeitiges Veröffentlichen derselben Dateien unter zwei sourceCardIds: genau eines gelingt', async () => {
const { post, fake } = await setup(); const { post, fake } = await setup();
seedUploads(fake); 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. // 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); fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows'), 80);
const results = await Promise.all([ 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); let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502); assert.equal(res.status, 502);
assert.equal(fake.rows('gallery').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert'); 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 // 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' }); 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); const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
assert.equal(res.status, 502); assert.equal(res.status, 502);
// Die Antwort kam erst, nachdem auch der langsame PUT durch war. // 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-img').$permissions, ['read("user:user-me")', 'read("label:admin")']);
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']); 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'); 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'); assert.ok(!logs.join('\n').includes('GEHEIM'), 'Fehlertexte werden nicht ins Log übernommen');
}); });
// ---- Bildmaße (Defense in Depth beim Veröffentlichen) ---------------------------------------------
test('readImageSize liest PNG-IHDR sowie WebP VP8, VP8L und VP8X', () => {
assert.deepEqual(readImageSize(pngHeader(1536, 969)), { width: 1536, height: 969 });
assert.deepEqual(readImageSize(webpHeader('VP8 ', 480, 303)), { width: 480, height: 303 });
assert.deepEqual(readImageSize(webpHeader('VP8L', 1536, 969)), { width: 1536, height: 969 });
assert.deepEqual(readImageSize(webpHeader('VP8X', 16383, 16383)), { width: 16383, height: 16383 });
assert.deepEqual(readImageSize(pngHeader(65535, 70000)), { width: 65535, height: 70000 });
// gekürzt auf genau die nötigen Bytes (PNG 24, VP8 30, VP8L 25, VP8X 30) geht es noch, eines weniger nicht
assert.deepEqual(readImageSize(pngHeader(7, 8).subarray(0, 24)), { width: 7, height: 8 });
assert.equal(readImageSize(pngHeader(7, 8).subarray(0, 23)), null);
assert.deepEqual(readImageSize(webpHeader('VP8 ', 7, 8).subarray(0, 30)), { width: 7, height: 8 });
assert.equal(readImageSize(webpHeader('VP8 ', 7, 8).subarray(0, 29)), null);
assert.deepEqual(readImageSize(webpHeader('VP8L', 7, 8).subarray(0, 25)), { width: 7, height: 8 });
assert.equal(readImageSize(webpHeader('VP8L', 7, 8).subarray(0, 24)), null);
assert.deepEqual(readImageSize(webpHeader('VP8X', 7, 8).subarray(0, 30)), { width: 7, height: 8 });
assert.equal(readImageSize(webpHeader('VP8X', 7, 8).subarray(0, 29)), null);
});
test('readImageSize: Unbekanntes, Leeres und Kaputtes ergibt null', () => {
assert.equal(readImageSize(Buffer.alloc(0)), null);
assert.equal(readImageSize(Buffer.from('GIF89a')), null);
assert.equal(readImageSize(Buffer.alloc(64)), null);
assert.equal(readImageSize(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="1536" height="969"></svg>')), null);
const brokenPng = pngHeader(1536, 969);
brokenPng.write('IDAT', 12, 'latin1'); // erster Chunk ist nicht IHDR
assert.equal(readImageSize(brokenPng), null);
const lossyBadStart = webpHeader('VP8 ', 480, 303);
lossyBadStart[24] = 0; // Startcode kaputt
assert.equal(readImageSize(lossyBadStart), null);
const losslessBadSignature = webpHeader('VP8L', 480, 303);
losslessBadSignature[20] = 0;
assert.equal(readImageSize(losslessBadSignature), null);
const unknownChunk = webpHeader('VP8 ', 480, 303);
unknownChunk.write('ANIM', 12, 'latin1');
assert.equal(readImageSize(unknownChunk), null);
assert.equal(readImageSize(null), null);
assert.equal(readImageSize('PNG'), null);
});
test('Veröffentlichen: Bild muss 1536 x 969 sein, Vorschau 480 breit und 302 bis 304 hoch (sonst 400)', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
const attempt = ({ image, preview }) => {
fake.addFile({ id: 'img1', owner: ME, content: image });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', content: preview });
return post('/api/veroeffentlichen', publishBody(), JWT_ME);
};
const okImage = pngHeader(1536, 969);
const okPreview = webpHeader('VP8 ', 480, 303);
for (const [label, files] of [
['Bild zu schmal', { image: pngHeader(1535, 969), preview: okPreview }],
['Bild zu hoch', { image: pngHeader(1536, 970), preview: okPreview }],
['Bild 4 Megapixel', { image: pngHeader(2000, 2000), preview: okPreview }],
['Bild riesig (Dekompressionsbombe)', { image: pngHeader(60000, 60000), preview: okPreview }],
['Bild WebP riesig', { image: webpHeader('VP8L', 16383, 16383), preview: okPreview }],
['Vorschau zu breit', { image: okImage, preview: webpHeader('VP8 ', 481, 303) }],
['Vorschau zu niedrig', { image: okImage, preview: webpHeader('VP8 ', 480, 301) }],
['Vorschau zu hoch', { image: okImage, preview: webpHeader('VP8 ', 480, 305) }],
['Vorschau in Bildgröße', { image: okImage, preview: webpHeader('VP8L', 1536, 969) }],
['Bild ist kein Bild', { image: Buffer.from('MZ\x90\x00 kein Bild, nur Text, lang genug für die Prüfung 123456789'), preview: okPreview }],
['Vorschau ist kein Bild', { image: okImage, preview: Buffer.alloc(100) }],
['Bild leer', { image: Buffer.alloc(0), preview: okPreview }],
['Bild abgeschnitten', { image: pngHeader(1536, 969).subarray(0, 20), preview: okPreview }],
]) {
const res = await attempt(files);
assert.equal(res.status, 400, label);
assert.equal(res.json.error, NOT_CARD_FORMAT, label);
assert.equal(fake.rows('gallery').length, 0, label);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], `${label}: Dateien bleiben unangetastet`);
}
// genau die erwarteten Maße gehen; die Vorschau darf 302, 303 oder 304 hoch sein, auch als PNG (Browser ohne WebP-Export)
for (const preview of [webpHeader('VP8 ', 480, 302), webpHeader('VP8L', 480, 304), webpHeader('VP8X', 480, 303), pngHeader(480, 303)]) {
const res = await attempt({ image: okImage, preview });
assert.equal(res.status, 201);
assert.equal((await post('/api/zurueckziehen', { id: res.json.id }, JWT_ME)).status, 200);
}
// auch ein WebP-Bild in voller Größe ist ein gültiges Kartenbild
assert.equal((await attempt({ image: webpHeader('VP8L', 1536, 969), preview: okPreview })).status, 201);
});
test('Veröffentlichen liest nur den Anfang der Dateien und schickt dafür den Server-Key an /view', async () => {
const { post, fake } = await setup();
seedUploads(fake);
const big = Buffer.concat([pngHeader(1536, 969), Buffer.alloc(3 * 1024 * 1024)]);
fake.addFile({ id: 'img1', owner: ME, content: big, size: big.length });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
const views = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/view'));
assert.deepEqual(views.map((c) => c.path).sort(), [
'/v1/storage/buckets/gallery-images/files/img1/view',
'/v1/storage/buckets/gallery-images/files/prev1/view',
]);
for (const c of views) assert.equal(c.headers['x-appwrite-key'], fake.key);
});
test('Veröffentlichen: fremde oder gesperrte Dateien werden nicht gelesen (kein Maß-Orakel)', async () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: OTHER });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 403);
assert.equal(fake.calls.filter((c) => c.path.endsWith('/view')).length, 0);
});
test('Veröffentlichen: Datei verschwindet vor dem Lesen der Maße ergibt 400 "Bild nicht gefunden."', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/prev1/view'), { status: 404, type: 'storage_file_not_found' });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 400);
assert.equal(res.json.error, 'Bild nicht gefunden.');
assert.equal(fake.rows('gallery').length, 0);
});
test('Veröffentlichen: Appwrite-Fehler beim Lesen der Maße ergibt 502, nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.path.endsWith('/files/img1/view'), { status: 500 });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
assert.equal(fake.rows('gallery').length, 0);
});
// ---- Limits und Besitz der Quellkarte ---------------------------------------------------------
function seedPublications(fake, owner, count) {
for (let i = 0; i < count; i += 1) {
fake.addRow(
'gallery',
`${owner}-pub${i}`,
{ title: `P${i}`, imageFileId: `${owner}-i${i}`, previewFileId: `${owner}-p${i}`, sourceCardId: `${owner}-s${i}`, ownerId: owner, hidden: i % 2 === 1, reportCount: 0 },
['read("any")'],
);
}
}
test('Veröffentlichen: höchstens 20 Veröffentlichungen je Konto (ausgeblendete zählen mit), die 21. ergibt 429', async () => {
const { post, fake } = await setup();
seedPublications(fake, ME, 20);
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 429);
assert.equal(res.json.error, TOO_MANY_CARDS);
assert.equal(fake.rows('gallery').length, 20);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], 'Dateien unangetastet');
// Karten anderer Konten zählen nicht
seedPublications(fake, OTHER, 25);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 429);
seedUploads(fake, OTHER, { image: 'oimg', preview: 'oprev', card: 'ocard' });
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'oimg', previewFileId: 'oprev', sourceCardId: 'ocard' }), JWT_OTHER)).status, 429);
});
test('Veröffentlichen: mit 19 Veröffentlichungen geht die 20., nach dem Zurückziehen wieder eine', async () => {
const { post, fake } = await setup();
seedPublications(fake, ME, 19);
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 201);
fake.addCard('card2', ME);
seedUploads(fake, ME, { image: 'img2', preview: 'prev2', card: 'card2' });
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2', sourceCardId: 'card2' }), JWT_ME)).status, 429);
assert.equal((await post('/api/zurueckziehen', { id: res.json.id }, JWT_ME)).status, 200);
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2', sourceCardId: 'card2' }), JWT_ME)).status, 201);
});
test('Veröffentlichen: sourceCardId muss eine Zeile in `cards` mit read("user:<ich>") sein, sonst 403', async () => {
const { post, fake } = await setup();
seedUploads(fake, ME, { card: null });
// gibt es nicht
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, NOT_YOUR_CARD);
// gehört jemand anderem
fake.addCard('card1', OTHER);
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 403);
assert.equal(res.json.error, NOT_YOUR_CARD);
// nur lesbar für alle, aber nicht meine
fake.addCard('card1', OTHER, ['read("any")']);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 403);
assert.equal(fake.rows('gallery').length, 0);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], 'Dateien unangetastet');
assert.equal(fake.calls.filter((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows')).length, 0);
// meine Karte geht
fake.addCard('card1', ME);
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
});
test('Veröffentlichen: Fehler beim Lesen von `cards` ergibt 502, ein fehlender Tabellenname 500, nichts angelegt', async () => {
const { post, fake } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/cards/rows/'), { status: 503 });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 502);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/cards/rows/'), { status: 404, type: 'table_not_found' });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 500);
assert.equal(fake.rows('gallery').length, 0);
});
// ---- Rollback-Protokoll ------------------------------------------------------------------------
test('Veröffentlichen: scheitern Festlegen und Zurücknehmen, steht `rollback failed for card <id>` im Log (nur die ID)', async () => {
const { post, fake, logs } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
assert.equal(res.status, 502);
const id = galleryRowId(ME, 'card1');
assert.equal(fake.rows('gallery').length, 1, 'die Zeile blieb stehen (Zurücknehmen scheiterte)');
const lines = logs.filter((line) => line.includes('rollback failed'));
assert.equal(lines.length, 1);
assert.match(lines[0], new RegExp(`^\\[aircard-api\\] \\S+ rollback failed for card ${id}$`));
assert.ok(!logs.join('\n').includes(JWT_ME));
});
test('Veröffentlichen: gelingt das Zurücknehmen, steht keine Rollback-Fehlerzeile im Log', async () => {
const { post, fake, logs } = await setup();
seedUploads(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 502);
assert.equal(fake.rows('gallery').length, 0);
assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0);
});
test('createApp verlangt Appwrite-Objekt und Salz', () => { test('createApp verlangt Appwrite-Objekt und Salz', () => {
assert.throws(() => createApp({ salt: SALT })); assert.throws(() => createApp({ salt: SALT }));
assert.throws(() => createApp({ appwrite: {}, salt: '' })); assert.throws(() => createApp({ appwrite: {}, salt: '' }));
+35 -2
View File
@@ -11,6 +11,8 @@
* updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben * updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben
* deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar) * deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar)
* getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null * 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) * updateFilePermissions(bucket, id, permissions) Datei oder null (404 storage_file_not_found)
* deleteFile(bucket, id) true, false bei 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 * accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
@@ -28,6 +30,7 @@
*/ */
const DEFAULT_TIMEOUT_MS = 10_000; const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_HEAD_BYTES = 64;
const ROW_NOT_FOUND = 'row_not_found'; const ROW_NOT_FOUND = 'row_not_found';
const FILE_NOT_FOUND = 'storage_file_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 rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`;
const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`; 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. */ /** Liest höchstens `limit` Bytes vom Anfang der Antwort und bricht den Rest ab (Dateien können 5 MiB groß sein). */
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [] } = {}) { 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 }; const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
if (withKey) requestHeaders['X-Appwrite-Key'] = key; if (withKey) requestHeaders['X-Appwrite-Key'] = key;
const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) }; 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; let text;
try { try {
response = await fetchImpl(`${base}${path}`, init); 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(); text = await response.text();
} catch (err) { } catch (err) {
// Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten). // 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; 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) { async updateFilePermissions(bucket, id, permissions) {
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] }); const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body; return result.status === 404 ? null : result.body;
+30
View File
@@ -149,6 +149,36 @@ test('Dateien: lesen, Rechte setzen, löschen', async () => {
assert.equal(fake.file('f1'), null); 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 () => { test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => {
const { fake, appwrite } = await setup(); const { fake, appwrite } = await setup();
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] }); fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
+72 -3
View File
@@ -7,6 +7,9 @@
* GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409 * GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409
* GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?} * GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?}
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {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[]=<json>` mit den Methoden equal, cursorAfter und limit. Zeilen werden gegen die Spaltenlängen der * Abfragen: `queries[]=<json>` 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. * 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']; 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 = { const COLUMNS = {
gallery: { gallery: {
title: { size: 60, required: true }, title: { size: 60, required: true },
@@ -40,8 +83,9 @@ const COLUMNS = {
}; };
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) { 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 files = new Map();
const contents = new Map();
const users = new Map(); const users = new Map();
const calls = []; const calls = [];
const injections = []; const injections = [];
@@ -56,7 +100,13 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
users.set(jwt, account); 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, { files.set(id, {
$id: id, $id: id,
bucketId: bucket, bucketId: bucket,
@@ -65,6 +115,12 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
mimeType, mimeType,
sizeOriginal: size, 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) { 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 // Dateien
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) { 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'); 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 === 'GET') return reply(200, file);
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
files.delete(segments[5]); files.delete(segments[5]);
contents.delete(segments[5]);
return reply(204); return reply(204);
} }
if (req.method === 'PUT') { if (req.method === 'PUT') {
@@ -240,6 +308,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
addUser, addUser,
addRawUser, addRawUser,
addFile, addFile,
addCard,
addRow, addRow,
failNext, failNext,
hangNext, hangNext,
@@ -303,4 +372,4 @@ function runQueries(rows, search) {
return result.slice(0, limit); return result.slice(0, limit);
} }
module.exports = { startFakeAppwrite }; module.exports = { startFakeAppwrite, pngHeader, webpHeader };