From dc5de99611e65c7277a1361ed969c51ac91de63f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:20:25 +0000 Subject: [PATCH] feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common.js (aus app.js herausgelöst, Verhalten gleich): MSG, HttpError, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße und die Dateiprüfungen checkFileExists/checkFileOwnership. Neu: die Logo-Texte in MSG, codeFor, likeRowPermissions. app.js exportiert weiter alles wie bisher. - Fehlerantworten sind jetzt {error, code}: code = MSG-Schlüssel des Textes, dynamische Prüftexte badRequest, Appwrite-Fehler unavailable (502) bzw. internal (500). - logos.js: POST /api/logos/hochladen, /herz, /melden, /loeschen und /api/admin/logo-sichtbarkeit nach dem Vorbild der Karten-Endpunkte (Datei prüfen, Zeile anlegen, Datei auf read("any") festlegen, sonst Rücknahme; likeCount und reportCount immer aus countRows; ein gemeinsames Rate-Limit für Karten- und Logo-Meldungen). - appwrite.js: countRows. fake-appwrite.js: Tabellen logos, logo_likes, logo_reports mit Spaltenlängen, Bucket logo-images und total in den Listen-Antworten. - Tests: logos.test.js (59), code-Fälle und MSG-Eindeutigkeit in app.test.js (+7), countRows in appwrite.test.js (+2); alle 111 bisherigen Tests unverändert grün (179 gesamt). - Doku: api/README.md (Endpunkte, Fehlercodes, Logo-Regeln), deploy/README.md (Betrieb kopiert fünf Dateien). Co-Authored-By: Claude Opus 5.5 --- deploy/README.md | 11 +- deploy/api/README.md | 47 +- deploy/api/app.js | 301 ++-------- deploy/api/app.test.js | 171 ++++++ deploy/api/appwrite.js | 19 +- deploy/api/appwrite.test.js | 46 +- deploy/api/common.js | 335 +++++++++++ deploy/api/fake-appwrite.js | 65 +- deploy/api/logos.js | 366 ++++++++++++ deploy/api/logos.test.js | 1106 +++++++++++++++++++++++++++++++++++ 10 files changed, 2181 insertions(+), 286 deletions(-) create mode 100644 deploy/api/common.js create mode 100644 deploy/api/logos.js create mode 100644 deploy/api/logos.test.js diff --git a/deploy/README.md b/deploy/README.md index b97d6b7..ff7c202 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -10,7 +10,7 @@ Die Seite wird auf dem Webklar-Server aus dem Gitea-Repo `knso/AirCardIOS` gebau | `deploy/webhook-server.js` | `aircard-ios.org/webhook-server.js` | nimmt den Gitea-Webhook an, startet `deploy.sh` nacheinander, pollt alle 15 Minuten | | `deploy/nginx/aircard-ios.org.conf` | `aircard-ios.org/nginx/aircard-ios.org.conf` | nginx-Konfiguration der Seite | | `deploy/traefik/aircard.yml` | `traefik/dynamic/aircard.yml` | Route `/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` an Appwrite | -| `deploy/api/appwrite.js`, `app.js`, `server.js` | `aircard-ios.org/api/` (nur diese drei Dateien) | API-Dienst `aircard-api` der Community-Galerie, siehe [api/README.md](api/README.md) | +| `deploy/api/appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js` | `aircard-ios.org/api/` (nur diese fünf Dateien) | API-Dienst `aircard-api` der Community-Galerie und der Community-Logos, siehe [api/README.md](api/README.md) | | `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build`, `aircard` und `aircard-api` | Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repos, Arbeitsordner des Build-Containers), `out/` (gebaute Seite, von nginx ausgeliefert), `dev/` (Arbeitskopie für die Entwicklung, nicht gemountet). @@ -25,7 +25,7 @@ mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.or cp $SRC/deploy.sh $APP/aircard-ios.org/deploy.sh && chmod +x $APP/aircard-ios.org/deploy.sh cp $SRC/webhook-server.js $APP/aircard-ios.org/webhook-server.js cp $SRC/nginx/aircard-ios.org.conf $APP/aircard-ios.org/nginx/aircard-ios.org.conf -cp $SRC/api/appwrite.js $SRC/api/app.js $SRC/api/server.js $APP/aircard-ios.org/api/ +cp $SRC/api/appwrite.js $SRC/api/common.js $SRC/api/app.js $SRC/api/logos.js $SRC/api/server.js $APP/aircard-ios.org/api/ # Compose: Backup, dann den Inhalt von compose-snippet.yml unter `services:` direkt nach dem eship-Block einfügen cp $APP/docker-compose.yml $APP/docker-compose.yml.bak-2026-10-04-aircard @@ -55,11 +55,12 @@ Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.e ## API-Dienst `aircard-api` -Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie mit dem Appwrite-Server-Key aus (Veröffentlichen mit Bildmaß-Prüfung und höchstens 20 Karten je Konto, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern aus verschiedenen Netzen, Admin-Sichtbarkeit). Endpunkte, Statuscodes und Betrieb stehen in [api/README.md](api/README.md). +Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie und der Community-Logos mit dem Appwrite-Server-Key aus (Veröffentlichen mit Bildmaß-Prüfung und höchstens 20 Karten je Konto, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern aus verschiedenen Netzen, Admin-Sichtbarkeit; bei den Logos Hochladen mit höchstens 30 Logos je Konto, Herzen, Melden, Löschen, Admin-Sichtbarkeit). Endpunkte, Statuscodes, Fehlercodes und Betrieb stehen in [api/README.md](api/README.md). - **Route**: Traefik-Label am Dienst, Router `aircard-gallery-api` (``Host(`aircard-ios.org`) && PathPrefix(`/api`)``, Priorität 200, Port 8088). Der Name ist absichtlich nicht `aircard-api`: so heißt schon die Appwrite-Route (`/v1`) in `traefik/dynamic/aircard.yml`. - **Prüfen** nach dem Start: `curl -s https://aircard-ios.org/api/gesundheit` liefert `{"ok":true}`; `docker logs webklar-aircard-api` zeigt `listening on :8088`. Läuft der Dienst nicht, fehlt auch sein Traefik-Router: nginx antwortet dann auf `/api/*` mit `503` und `{"error":"Der Dienst ist gerade nicht erreichbar."}` (Content-Type `application/json`), nie mit der App. -- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`. +- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen (alle fünf: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`; `app.js` lädt `common.js` und `logos.js`, ohne sie startet der Dienst nicht), dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`. +- **Logos brauchen das Schema**: Die Logo-Endpunkte schreiben in die Tabellen `logos`, `logo_likes`, `logo_reports` und den Bucket `logo-images`. Das Schema-Skript `web/scripts/appwrite-setup.mjs` legt sie an (zuerst mit `--check` ansehen, was fehlt, siehe [../README.md](../README.md)); das gehört vor den Neustart des API-Dienstes mit den neuen Dateien. Ohne die Tabellen antworten die Logo-Endpunkte mit `500` „Interner Fehler.“, die Karten-Endpunkte laufen weiter. - **Log-Grenze**: Der Docker-Daemon des Servers hat keine eigenen Log-Optionen, deshalb steht im Service `logging: json-file` mit `max-size: 5m` und `max-file: 2` (höchstens 10 MB). Der Dienst selbst schreibt kein Zugriffsprotokoll und höchstens 120 Zeilen pro Minute. - **Kein Port nach außen**: der Dienst vertraut dem Header `X-Real-Ip` (setzt Traefik) für Rate-Limit und Melder-Hash, deshalb nie `ports:` ergänzen. Im Docker-Netz `webklar-network` erreichen ihn andere Container trotzdem und könnten `X-Real-Ip` setzen: akzeptiertes Risiko, siehe [api/README.md](api/README.md) (Client-IP). Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen. @@ -103,7 +104,7 @@ Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea: - `deploy.sh` ändern: Datei auf dem Server mit `cp` überschreiben (nicht `mv`, sonst zeigt der Datei-Mount auf die alte Datei). Gilt beim nächsten Lauf. - `webhook-server.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-build`. -- `deploy/api/*.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`. +- `deploy/api/*.js` ändern (die fünf Dateien des Dienstes, nicht die Tests und nicht `fake-appwrite.js`): ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`. - nginx-Konfiguration ändern: mit `cp` ersetzen, dann `docker exec webklar-aircard nginx -t && docker exec webklar-aircard nginx -s reload`. - Traefik-Route ändern: Datei atomar ersetzen wie oben; Löschen der Datei entfernt die Route wieder. diff --git a/deploy/api/README.md b/deploy/api/README.md index 1241e9a..6b94f4f 100644 --- a/deploy/api/README.md +++ b/deploy/api/README.md @@ -1,19 +1,21 @@ # aircard-api -Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit. Die Website liest die Galerie direkt aus Appwrite; nur das Schreiben läuft hierüber. +Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie und die Community-Logos von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: bei den Karten Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit, bei den Logos Hochladen, Herz setzen, Melden, Löschen und die Admin-Sichtbarkeit. Die Website liest Galerie und Logos direkt aus Appwrite; nur das Schreiben läuft hierüber. -Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11. Deployment: [../README.md](../README.md). +Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11 (Karten) und `docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md` §6, §8, §9 (Logos). Deployment: [../README.md](../README.md). ## Dateien | Datei | Aufgabe | |---|---| | `server.js` | Start: liest die Umgebung, `http.createServer`, Zeitlimits, sauberes Beenden | -| `app.js` | Routen, Validierung, Rechte-Strings, Rate-Limit, Logik (`createApp`) | -| `appwrite.js` | REST-Client mit Server-Key (`createAppwrite`) | +| `app.js` | HTTP-Anbindung (Routing, Body, Anmeldung, Rate-Limit, Log, Fehlerantworten) und die Karten-Endpunkte (`createApp`) | +| `logos.js` | die fünf Logo-Endpunkte (`createLogoRoutes`); bekommt Anmeldung, Sperre je Schlüssel, Rate-Limit und Log von `app.js` | +| `common.js` | gemeinsame Helfer: `MSG` (alle Fehlertexte, Schlüssel = `code`), `HttpError`, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße | +| `appwrite.js` | REST-Client mit Server-Key (`createAppwrite`, auch `countRows`) | | `*.test.js`, `fake-appwrite.js` | Tests und Fake-Appwrite im Speicher (gehören nicht auf den Server) | -Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur `server.js`, `app.js` und `appwrite.js`. +Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur die fünf Dateien `appwrite.js`, `common.js`, `app.js`, `logos.js` und `server.js`. ## Umgebung @@ -29,7 +31,7 @@ Fehlt eine Pflichtvariable, nennt die Meldung nur ihren Namen und der Prozess en ## Endpunkte -Alle Antworten sind JSON; Fehler haben die Form `{"error": ""}`. Das Nutzer-JWT kommt im Header `X-Aircard-Jwt` und wird bei Appwrite (`GET /account`) geprüft; Admin = Label `admin`. Alles andere (auch falsche Methoden) ist `404`; `POST` nur mit `Content-Type: application/json` (sonst `415`), Body höchstens 8 KB (sonst `413`). +Alle Antworten sind JSON; Fehler haben die Form `{"error": "", "code": ""}` (Codes siehe unten). Das Nutzer-JWT kommt im Header `X-Aircard-Jwt` und wird bei Appwrite (`GET /account`) geprüft; Admin = Label `admin`. Alles andere (auch falsche Methoden) ist `404`; `POST` nur mit `Content-Type: application/json` (sonst `415`), Body höchstens 8 KB (sonst `413`). | Endpunkt | Auth | Antworten | |---|---|---| @@ -37,9 +39,29 @@ Alle Antworten sind JSON; Fehler haben die Form `{"error": ""}`. | `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt | | `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) | | `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` | +| `POST /api/logos/hochladen` `{name, displayName?, mono?, fileId}` | Nutzer-JWT | `201 {id}` (= Datei-ID) · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), kein PNG („Das Logo muss eine PNG-Datei sein.“), größer als 1 MiB („Das Logo ist größer als 1 MB.“), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“) · `409` „Diese Datei wird schon für ein Logo verwendet.“ · `429` „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.“ | +| `POST /api/logos/herz` `{id, herz}` | Nutzer-JWT | `200 {herz, likeCount}` · `401` · `400` ID ungültig oder `herz` kein Boolean · `404` fehlt oder ausgeblendet („Dieses Logo ist nicht (mehr) verfügbar.“) | +| `POST /api/logos/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`; dasselbe Limit wie `/api/melden`) | +| `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt | +| `POST /api/admin/logo-sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` | | `GET /api/gesundheit` | keine | `200 {ok:true}` | -Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“. Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist `500` „Interner Fehler.“ Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ `row_not_found` bzw. `storage_file_not_found` als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“. +Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“ (`code` `unavailable`). Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist `500` „Interner Fehler.“ (`code` `internal`). Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ `row_not_found` bzw. `storage_file_not_found` als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“ oder „Logo nicht verfügbar“. + +### Fehlercodes + +Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel in `MSG` (`common.js`), dessen Text genau dem `error` entspricht. Die Texte in `MSG` sind paarweise verschieden (ein Test prüft das), der Code ist also eindeutig. Dynamische Prüftexte ohne festen Schlüssel („Der Titel ist zu lang (höchstens 60 Zeichen).“, „Ungültige Bild-ID.“, „Bitte gib höchstens 5 Tags an.“, „Der Name ist zu lang (höchstens 40 Zeichen).“, …) haben bei Status `400` den `code` `badRequest`, sonst `error` (kommt derzeit nicht vor). Der `code` ist die stabile Schnittstelle für die Clients (z. B. um Meldungen zu übersetzen); `MSG` ist dafür ein schlichtes Objekt aus Texten, das `common.js` unverändert exportiert. + +| Status | `code` · Text | +|---|---| +| `400` | `badRequest` „Ungültige Anfrage.“ (auch jeder dynamische Prüftext) · `badCardId` „Ungültige Karten-ID.“ · `badLogoId` „Ungültige Logo-ID.“ · `badReason` „Bitte wähle einen gültigen Grund.“ · `badVisibility` „Ungültige Angabe zur Sichtbarkeit.“ · `badHeart` „Ungültige Angabe zum Herz.“ · `badMono` „Ungültige Angabe zu „einfarbig“.“ · `logoNameMissing` „Bitte gib einen Namen an.“ · `fileMissing` „Bild nicht gefunden.“ · `fileType` „Das Bild muss eine PNG- oder WebP-Datei sein.“ · `fileSize` „Das Bild ist größer als 5 MB.“ · `imageFormat` „Das Bild hat nicht das Kartenformat.“ · `logoFileType` „Das Logo muss eine PNG-Datei sein.“ · `logoFileSize` „Das Logo ist größer als 1 MB.“ · `logoFormat` „Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“ | +| `401` | `unauthorized` „Bitte melde dich an.“ | +| `403` | `forbidden` „Dazu hast du keine Berechtigung.“ · `adminOnly` „Nur für Admins.“ · `foreignFile` „Diese Datei gehört dir nicht.“ · `filesTaken` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ · `notYourCard` „Diese Karte gehört nicht zu deinem Konto.“ | +| `404` | `notFound` „Nicht gefunden.“ · `cardGone` „Diese Karte ist nicht (mehr) verfügbar.“ · `logoGone` „Dieses Logo ist nicht (mehr) verfügbar.“ | +| `409` | `alreadyPublished` „Diese Karte ist schon veröffentlicht.“ · `logoTaken` „Diese Datei wird schon für ein Logo verwendet.“ | +| `413` `415` | `tooLarge` „Die Anfrage ist zu groß.“ · `jsonOnly` „Nur application/json wird unterstützt.“ | +| `429` | `tooManyPublications` „Du hast schon 20 Karten veröffentlicht. …“ · `tooManyLogos` „Du hast schon 30 Logos hochgeladen. …“ · `tooManyReports` „Zu viele Meldungen – bitte später nochmal.“ | +| `500` `502` | `internal` „Interner Fehler.“ · `unavailable` „Der Dienst ist gerade nicht erreichbar.“ | ### Regeln im Einzelnen @@ -48,12 +70,17 @@ Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Server - **Quellkarte**: `sourceCardId` muss eine Zeile der Tabelle `cards` sein, deren `$permissions` `read("user:")` enthalten, sonst `403` „Diese Karte gehört nicht zu deinem Konto.“ (auch wenn es die Zeile nicht gibt). - **Limit**: höchstens 20 Veröffentlichungen je Konto (Zeilen mit `ownerId` = ich, auch ausgeblendete); ab da `429` mit dem Text aus der Tabelle. Zurückziehen gibt einen Platz frei. Das Limit liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index) mit `limit(20)`. - **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403` (Typ und Größe fremder Dateien nennt die API nicht). **Bildmaße**: Die API liest die ersten 64 Bytes beider Dateien (`GET …/files//view` mit Server-Key, der Rest der Übertragung wird abgebrochen) und liest PNG-IHDR bzw. WebP (`VP8 `, `VP8L`, `VP8X`) aus. Das Bild muss genau 1536 × 969 sein, die Vorschau 480 breit und 302–304 hoch (die Karte ist 1536 × 969, die Vorschau 480 × 303), sonst `400` „Das Bild hat nicht das Kartenformat.“ Das ist Defense in Depth gegen Dekompressionsbomben; die maßgebliche Prüfung beim Anzeigen macht der Client (Pixelgrenze 4 Megapixel). **Keine Wiederverwendung**: Beide Dateien müssen `read("any")` tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst `403` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen `sourceCardId` neu einstellen. Die Prüfung läuft zusammen mit Limit, Quellkarte und Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")` (**kein `delete("user:")` mehr**, kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen und schon festgelegte Dateien bekommen `delete("user:")` zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht `rollback failed for card ` im Log). Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“ +- **Logo-Felder** (`/api/logos/hochladen`): `name` 1–40 und `displayName` ≤ 30 Zeichen (getrimmt; leerer Anzeigename = „Anonym“; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. Die Zeilen-ID des Logos ist die Datei-ID: so kann eine Datei nie zwei Logos tragen. +- **Logo-Datei und Reihenfolge**: Die Datei liegt im Bucket `logo-images`, trägt `delete("user:")` und `read("any")`, ist `image/png`, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (`401`) → Body (`400`) → Datei da und im richtigen Bucket (`400`/`403`) → je Konto nacheinander (Sperre `logo-owner:`): schon 30 Logos (`429`, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (`409`) → Besitz (`403`: kein `delete("user:")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → Zeile anlegen (`409`, wenn die ID schon vergeben ist) → Datei auf genau `read("any")` setzen (**kein `delete("user:")` mehr**, sonst ließe sich das Bild unter derselben ID tauschen). Scheitert das Festlegen, wird die Zeile zurückgenommen und nur dann bekommt die Datei `delete("user:")` zurück (scheitert schon das Zurücknehmen, steht `rollback failed for logo ` im Log). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest. +- **Herzen** (`/api/logos/herz`): ein Herz je Konto und Logo, Zeilen-ID `sha256(|)` (erste 32 Hex), Recht `read("user:")`. `herz: true` legt die Zeile an (`409` = schon da, kein Fehler), `false` löscht sie (schon weg = kein Fehler). Danach ist `likeCount` immer `countRows` aller Herzen des Logos (neu gezählt, nie hoch- oder runtergezählt) und wird nur geschrieben, wenn er abweicht. Fehlendes oder ausgeblendetes Logo: `404`, auch beim Entfernen. Pro Logo läuft immer nur eine Änderung gleichzeitig (Sperre `logo:`). Herzen werden nicht geloggt. +- **Logo melden** (`/api/logos/melden`): wie `/api/melden`, mit denselben Gründen, demselben Melder (Netz, gehasht) und **demselben Rate-Limit** (ein gemeinsamer Zähler je Anschluss für Karten und Logos: 6 Karten- und 5 Logo-Meldungen vom selben Anschluss ergeben bei der 11. ein `429`). Die Zeilen-ID der Meldung ist `sha256(logo||)`, `reportCount` ist `countRows` der Meldungen des Logos, ab 3 wird ausgeblendet (Datei und Zeile auf `read("user:")` und `read("label:admin")`). +- **Logo löschen und Sichtbarkeit**: Löschen (Hochlader oder Admin) entfernt Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (`hidden: true`), Einblenden setzt sie auf `read("any")`, löscht die Meldungen und setzt `reportCount` auf 0 (die Herzen bleiben). - **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 Anschluss (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge; Schlüssel: gehashte IPv4, IPv6 auf /64 gekürzt, IPv4-gemappte IPv6 als IPv4). Der Melder-Hash ist `HMAC-SHA256(salt, netz)` mit dem **Netz** der Adresse (IPv4 → `/24`, IPv6 → `/48`, IPv4-gemappte IPv6 wie IPv4): drei Adressen aus einem Netz sind ein Melder und blenden eine Karte nicht aus (sonst genügten drei VPN-/Mobilfunk-Adressen). Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig. - **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst veröffentlicht nie `ports:`, ist aber im Docker-Netz `webklar-network` für andere Container erreichbar, und dort gilt `X-Real-Ip` als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den `clientIp` prüft, oder ein eigenes Netz nur für Traefik und `aircard-api`. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen. -- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). -- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch. -- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion, 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). +- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). Für Logos gilt dasselbe (Zeile und Datei sichtbar `read("any")`, ausgeblendet `read("user:")` und `read("label:admin")`, hochgeladen `read("any")` und `delete("user:")`); Herzen tragen `read("user:")` des Kontos. +- **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; Logo löschen ebenso (Datei, Herzen, Meldungen, 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 bzw. Logo hochgeladen, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen; keine Herzen) 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- bzw. Logo-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien). ## Tests diff --git a/deploy/api/app.js b/deploy/api/app.js index cefaf90..fb39ee2 100644 --- a/deploy/api/app.js +++ b/deploy/api/app.js @@ -2,7 +2,9 @@ /** * Routen und Logik des API-Dienstes `aircard-api` (Node 22, nur Bordmittel). Alle Schreibzugriffe auf die - * Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js). + * Community-Galerie und die Community-Logos laufen hier über den Appwrite-Server-Key (siehe appwrite.js). + * Gemeinsame Helfer, Fehlertexte und -codes: common.js. Die Logo-Endpunkte stehen in logos.js; dieses Modul gibt ihnen + * Anmeldung, Sperre je Schlüssel, das (gemeinsame) Rate-Limit und das Log. * * POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId} * 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine / @@ -14,21 +16,50 @@ * 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true} * POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar} * 401 · 403 kein Admin · 400 · 404 · 200 {ok:true} + * POST /api/logos/hochladen · /api/logos/herz · /api/logos/melden · /api/logos/loeschen · /api/admin/logo-sichtbarkeit + * siehe logos.js * GET /api/gesundheit {ok:true} * alles andere 404; Body höchstens 8 KB (413), nur application/json (415) * * Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`. * Der Appwrite-Key braucht die Console-Scopes documents.read/documents.write (= rows.* der API) und files.read/files.write. * Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik. - * Fehlerantworten sind immer {error: }. - * Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle). + * Fehlerantworten sind immer {error: , code: } (siehe common.js). + * Appwrite-Fehler: 502 (keine Antwort, 429, 5xx; code `unavailable`) bzw. 500 (alle anderen, z. B. falscher Key oder + * fehlende Tabelle; code `internal`). * Logs: kein Zugriffsprotokoll je Anfrage (sonst steuert ein Angreifer das Log-Volumen), nur Ereigniszeilen * (veröffentlicht, gemeldet, ausgeblendet, …) und Serverfehler, zusammen höchstens 120 Zeilen pro Minute. */ -const crypto = require('node:crypto'); -const net = require('node:net'); const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js'); +const { + MAX_PUBLICATIONS, + HIDE_AT_REPORTS, + IMAGE_HEAD_BYTES, + REASONS, + CONTROL_CHARACTERS, + MSG, + HttpError, + isId, + isPlainObject, + isDuplicate, + parseJsonObject, + readImageSize, + hash32, + hmacHex, + reporterHash, + reporterNetwork, + normalizeIp, + clientIp, + visibleRowPermissions, + hiddenRowPermissions, + uploadFilePermissions, + visibleFilePermissions, + hiddenFilePermissions, + checkFileExists, + checkFileOwnership, +} = require('./common.js'); +const { createLogoRoutes } = require('./logos.js'); const TABLE_GALLERY = 'gallery'; const TABLE_REPORTS = 'reports'; @@ -38,13 +69,8 @@ const BUCKET = 'gallery-images'; const MAX_BODY_BYTES = 8 * 1024; const MAX_FILE_BYTES = 5 * 1024 * 1024; const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']); -const HIDE_AT_REPORTS = 3; const REPORT_LIST_LIMIT = 100; -/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */ -const MAX_PUBLICATIONS = 20; const REPORT_DELETE_ROUNDS = 50; -/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */ -const IMAGE_HEAD_BYTES = 64; /** Kartenmaße wie web/src/card/geometry.ts (CARD_W, CARD_H, PREVIEW_WIDTH; Vorschauhöhe 303 plus/minus 1). */ const IMAGE_WIDTH = 1536; const IMAGE_HEIGHT = 969; @@ -57,90 +83,11 @@ const RATE_SWEEP_MS = 60 * 1000; const RATE_MAX_KEYS = 50_000; const LOG_BUDGET_PER_MINUTE = 120; const LOG_WINDOW_MS = 60 * 1000; -const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']; -const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; -// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF): -// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts. -const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/; const JWT_CHARACTERS = /^[\x21-\x7e]+$/; -const MSG = { - unauthorized: 'Bitte melde dich an.', - notFound: 'Nicht gefunden.', - cardGone: 'Diese Karte ist nicht (mehr) verfügbar.', - forbidden: 'Dazu hast du keine Berechtigung.', - adminOnly: 'Nur für Admins.', - foreignFile: 'Diese Datei gehört dir nicht.', - fileMissing: 'Bild nicht gefunden.', - fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.', - fileSize: 'Das Bild ist größer als 5 MB.', - alreadyPublished: 'Diese Karte ist schon veröffentlicht.', - filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.', - imageFormat: 'Das Bild hat nicht das Kartenformat.', - notYourCard: 'Diese Karte gehört nicht zu deinem Konto.', - tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`, - tooManyReports: 'Zu viele Meldungen – bitte später nochmal.', - badRequest: 'Ungültige Anfrage.', - badCardId: 'Ungültige Karten-ID.', - badReason: 'Bitte wähle einen gültigen Grund.', - badVisibility: 'Ungültige Angabe zur Sichtbarkeit.', - tooLarge: 'Die Anfrage ist zu groß.', - jsonOnly: 'Nur application/json wird unterstützt.', - unavailable: 'Der Dienst ist gerade nicht erreichbar.', - internal: 'Interner Fehler.', -}; - // ---- Reine Helfer ---------------------------------------------------------------------------------- -function isId(value) { - return typeof value === 'string' && ID_PATTERN.test(value); -} - -function assertOwner(owner) { - if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID'); - return owner; -} - -/** Sichtbare Galerie-Zeile: lesen darf jeder. */ -function visibleRowPermissions() { - return ['read("any")']; -} - -/** Ausgeblendete Zeile: nur Autor und Admins. */ -function hiddenRowPermissions(owner) { - assertOwner(owner); - return [`read("user:${owner}")`, 'read("label:admin")']; -} - -/** - * Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit - * Fehlversuche auf). Nur so kommen Dateien in `publish`; die API setzt sie beim Veröffentlichen auf `visibleFilePermissions`. - */ -function uploadFilePermissions(owner) { - assertOwner(owner); - return ['read("any")', `delete("user:${owner}")`]; -} - -/** - * Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die - * Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen - * löscht die Dateien serverseitig mit dem Key. - */ -function visibleFilePermissions() { - return ['read("any")']; -} - -/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */ -function hiddenFilePermissions(owner) { - assertOwner(owner); - return [`read("user:${owner}")`, 'read("label:admin")']; -} - -function hash32(text) { - return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32); -} - /** Feste Zeilen-ID je Autor und private Karte: Appwrite-409 ersetzt jede Doppelt-Prüfung (auch bei Gleichzeitigkeit). */ function galleryRowId(ownerId, sourceCardId) { return hash32(`${ownerId}|${sourceCardId}`); @@ -151,128 +98,12 @@ function reportRowId(galleryId, hash) { return hash32(`${galleryId}|${hash}`); } -function expandIpv6(address) { - let text = address; - const lastColon = text.lastIndexOf(':'); - const tail = text.slice(lastColon + 1); - if (tail.includes('.')) { - const o = tail.split('.').map(Number); - text = `${text.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`; - } - const [head, rest] = text.split('::'); - const headGroups = head === '' ? [] : head.split(':'); - const restGroups = rest === undefined || rest === '' ? [] : rest.split(':'); - const groups = rest === undefined ? headGroups : [...headGroups, ...Array(8 - headGroups.length - restGroups.length).fill('0'), ...restGroups]; - return groups.map((group) => Number.parseInt(group, 16)); -} - -/** - * Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen), - * { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text). - */ -function parseIp(ip) { - if (typeof ip !== 'string') return { kind: 'none' }; - let text = ip.trim(); - if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1); - const zone = text.indexOf('%'); - if (zone !== -1) text = text.slice(0, zone); - const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text); - if (withPort) text = withPort[1]; - - if (net.isIPv4(text)) return { kind: 'v4', text }; - if (net.isIPv6(text)) { - const groups = expandIpv6(text); - if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) { - return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` }; - } - return { kind: 'v6', groups }; - } - 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) { - const value = req.headers && req.headers[name]; - const text = Array.isArray(value) ? value[0] : value; - if (typeof text !== 'string') return ''; - return text.split(',')[0].trim(); -} - -/** Echte Client-IP: X-Real-Ip (setzt Traefik), sonst erster X-Forwarded-For-Eintrag, sonst Socket. */ -function clientIp(req) { - return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || ''; -} - -function hmacHex(salt, text) { - return crypto.createHmac('sha256', salt).update(text).digest('hex'); -} - -/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */ -function reporterHash(salt, ip) { - return hmacHex(salt, reporterNetwork(ip)); -} - -function isPlainObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - /** Fehlername oder -typ fürs Log: nur Wortzeichen, Punkt und Bindestrich, höchstens 60 Zeichen. */ function safeToken(value, fallback = 'unknown') { const text = String(value === undefined || value === null ? '' : value).replace(/[^\w.-]/g, '').slice(0, 60); 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; @@ -357,14 +188,6 @@ function buildSearchText({ title = '', issuer = '', tags = [] }) { // ---- HTTP-Hilfen ------------------------------------------------------------------------------------ -class HttpError extends Error { - constructor(status, message, headers = {}) { - super(message); - this.status = status; - this.headers = headers; - } -} - function sendJson(res, status, payload, headers = {}) { const body = JSON.stringify(payload); res.writeHead(status, { @@ -416,17 +239,6 @@ function readRawBody(req) { }); } -function parseJsonObject(raw) { - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - throw new HttpError(400, MSG.badRequest); - } - if (!isPlainObject(parsed)) throw new HttpError(400, MSG.badRequest); - return parsed; -} - /** Je Schlüssel nacheinander abarbeiten (z. B. je Karten-ID), damit Zähler und Rechte nie durcheinanderlaufen. */ function createKeyedLock() { const tails = new Map(); @@ -448,10 +260,6 @@ function createKeyedLock() { }; } -function isDuplicate(err) { - return err instanceof AppwriteError && err.status === 409; -} - // ---- Die App --------------------------------------------------------------------------------------- /** @@ -466,7 +274,7 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { if (!appwrite || typeof appwrite !== 'object') throw new TypeError('appwrite fehlt'); if (typeof salt !== 'string' || salt === '') throw new TypeError('salt fehlt'); - // Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten-IDs, Zahlen und Fehlertypen. Zusätzlich gilt ein + // Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten- und Logo-IDs, Zahlen und Fehlertypen. Zusätzlich gilt ein // Zeilenbudget je Minute; was darüber liegt, wird gezählt und als eine Sammelzeile nachgetragen. const emit = (message) => log(`[aircard-api] ${new Date().toISOString()} ${message}`); let logWindowStart = 0; @@ -489,7 +297,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { } const withLock = createKeyedLock(); - // ---- Rate-Limit je Melder-Hash (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports`) + // ---- Rate-Limit je Anschluss (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports` und `logo_reports`). + // Ein Zähler für Karten- und Logo-Meldungen: logos.js bekommt `rateLimit` von hier. const hits = new Map(); let lastSweep = 0; @@ -533,27 +342,6 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { } // ---- Dateien und Zeilen - /** - * Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im Galerie-Bucket. Metadaten wie Typ und - * Größe prüft erst `checkFileContent`, damit fremde Dateien nichts darüber verraten. - */ - function checkFileExists(file) { - if (!file) throw new HttpError(400, MSG.fileMissing); - if (file.bucketId !== BUCKET) throw new HttpError(403, MSG.foreignFile); - } - - /** - * Besitz: frisch hochgeladene Dateien tragen delete("user:") und read("any"). Eine Datei ohne delete(ich) gehört - * jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Zeilen-Dateien - * hat der Aufrufer vorher mit `used` als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet. - */ - function checkFileOwnership(file, userId) { - const permissions = Array.isArray(file.$permissions) ? file.$permissions : []; - if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile); - // Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie. - if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken); - } - function checkFileContent(file) { if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType); if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize); @@ -637,8 +425,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { appwrite.getFile(BUCKET, data.imageFileId), appwrite.getFile(BUCKET, data.previewFileId), ]); - checkFileExists(image); - checkFileExists(preview); + checkFileExists(image, BUCKET); + checkFileExists(preview, BUCKET); // Je Autor nacheinander: Limit, Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen. return withLock(`owner:${user.id}`, async () => { @@ -782,6 +570,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { ['POST /api/zurueckziehen', { name: 'zurueckziehen', json: true, run: withdraw }], ['POST /api/melden', { name: 'melden', json: true, run: reportCard }], ['POST /api/admin/sichtbarkeit', { name: 'admin/sichtbarkeit', json: true, run: setVisibility }], + // Logos (logos.js): bekommen Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log von hier. + ...createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }), ]); async function handle(req, res) { @@ -807,9 +597,11 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { } catch (err) { let headers = {}; let message = MSG.internal; + let code = 'internal'; if (err instanceof HttpError) { status = err.status; message = err.message; + code = err.code; headers = err.headers; } else if (err instanceof AppwriteError) { // Keine Antwort, Appwrite überlastet (429) oder Serverfehler: nicht erreichbar (502). Alles andere @@ -817,12 +609,13 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { const unavailable = err.status === 0 || err.status === 429 || err.status >= 500; status = unavailable ? 502 : 500; message = unavailable ? MSG.unavailable : MSG.internal; + code = unavailable ? 'unavailable' : 'internal'; say(`${req.method} /api/${route.name} -> ${status}: appwrite error status=${err.status} type=${safeToken(err.type)}`); } else { status = 500; say(`${req.method} /api/${route ? route.name : '-'} -> 500: unexpected error ${safeToken(err && err.name)}`); } - if (!res.headersSent) sendJson(res, status, { error: message }, headers); + if (!res.headersSent) sendJson(res, status, { error: message, code }, headers); } } diff --git a/deploy/api/app.test.js b/deploy/api/app.test.js index b318abf..476fb4a 100644 --- a/deploy/api/app.test.js +++ b/deploy/api/app.test.js @@ -9,6 +9,7 @@ const crypto = require('node:crypto'); const http = require('node:http'); const { createAppwrite, AppwriteError } = require('./appwrite.js'); +const { MSG, HttpError, codeFor } = require('./common.js'); const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js'); const { createApp, @@ -1569,3 +1570,173 @@ test('createApp verlangt Appwrite-Objekt und Salz', () => { assert.throws(() => createApp({ appwrite: {}, salt: '' })); assert.throws(() => createApp({ appwrite: {} })); }); + +// ---- Fehlercodes: jede Fehlerantwort ist {error, code} ------------------------------------------------- + +/** Erwartet Status und die Antwort {error: , code: } (sonst nichts). */ +function expectError(res, status, code, label = code) { + assert.equal(res.status, status, `${label}: Status`); + assert.deepEqual(res.json, { error: MSG[code], code }, label); +} + +test('MSG: alle Texte sind paarweise verschieden (der Text bestimmt den Code), die Logo-Texte stehen wörtlich fest', () => { + const texts = Object.values(MSG); + assert.ok(texts.length > 0); + assert.equal(new Set(texts).size, texts.length, 'jeder Text gehört zu genau einem Schlüssel'); + assert.ok(texts.every((text) => typeof text === 'string' && text !== '')); + + const logoKeys = ['logoGone', 'badLogoId', 'logoNameMissing', 'logoFileType', 'logoFileSize', 'logoFormat', 'logoTaken', 'tooManyLogos', 'badHeart', 'badMono']; + assert.deepEqual(Object.fromEntries(logoKeys.map((key) => [key, MSG[key]])), { + logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.', + badLogoId: 'Ungültige Logo-ID.', + logoNameMissing: 'Bitte gib einen Namen an.', + logoFileType: 'Das Logo muss eine PNG-Datei sein.', + logoFileSize: 'Das Logo ist größer als 1 MB.', + logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).', + logoTaken: 'Diese Datei wird schon für ein Logo verwendet.', + tooManyLogos: 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.', + badHeart: 'Ungültige Angabe zum Herz.', + badMono: 'Ungültige Angabe zu „einfarbig“.', + }); + + // die bisherigen Texte bleiben unverändert (Auszug) + assert.equal(MSG.cardGone, 'Diese Karte ist nicht (mehr) verfügbar.'); + assert.equal(MSG.forbidden, 'Dazu hast du keine Berechtigung.'); + assert.equal(MSG.tooManyReports, 'Zu viele Meldungen – bitte später nochmal.'); + assert.equal(MSG.tooManyPublications, TOO_MANY_CARDS); + assert.equal(MSG.unavailable, 'Der Dienst ist gerade nicht erreichbar.'); + assert.equal(MSG.internal, 'Interner Fehler.'); +}); + +test('codeFor und HttpError: Code = Schlüssel des Textes, sonst badRequest (400) bzw. error', () => { + assert.equal(codeFor(404, MSG.cardGone), 'cardGone'); + assert.equal(codeFor(404, MSG.logoGone), 'logoGone'); + assert.equal(codeFor(429, MSG.tooManyLogos), 'tooManyLogos'); + assert.equal(codeFor(500, MSG.internal), 'internal'); + assert.equal(codeFor(400, 'Ungültige Bild-ID.'), 'badRequest', 'dynamischer Prüftext, Status 400'); + assert.equal(codeFor(400, MSG.badRequest), 'badRequest'); + assert.equal(codeFor(403, 'irgendein anderer Text'), 'error', 'dynamischer Text, anderer Status'); + assert.equal(codeFor(409, undefined), 'error'); + + const withHeaders = new HttpError(429, MSG.tooManyReports, { 'Retry-After': '5' }); + assert.ok(withHeaders instanceof Error); + assert.deepEqual([withHeaders.status, withHeaders.message, withHeaders.code, withHeaders.headers], [429, MSG.tooManyReports, 'tooManyReports', { 'Retry-After': '5' }]); + assert.deepEqual(new HttpError(404, MSG.notFound).headers, {}); + assert.equal(new HttpError(400, 'Der Titel ist ungültig.').code, 'badRequest'); + assert.equal(new HttpError(403, 'Der Titel ist ungültig.').code, 'error'); + assert.equal(new HttpError(409, 'x', {}, 'eigener').code, 'eigener', 'ein ausdrücklicher Code gewinnt'); +}); + +test('Fehlerantworten: Anfrage-Fehler (404, 413, 415, 400, 401) haben {error, code}', async () => { + const { call, post, report, fake } = await setup(); + seedCard(fake, { id: 'card-9' }); + const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`; + + expectError(await call('GET', '/api/gibts-nicht'), 404, 'notFound', 'unbekannte Route'); + expectError(await call('GET', '/api/melden'), 404, 'notFound', 'falsche Methode'); + expectError(await call('POST', '/api/melden', { raw: '{}', contentType: 'text/plain' }), 415, 'jsonOnly'); + expectError(await call('POST', '/api/melden', { raw: big }), 413, 'tooLarge'); + expectError(await call('POST', '/api/melden', { raw: '{kaputt' }), 400, 'badRequest', 'kaputtes JSON'); + expectError(await call('POST', '/api/melden', { raw: '[]' }), 400, 'badRequest', 'Array statt Objekt'); + expectError(await report('../x', '203.0.1.9'), 400, 'badCardId'); + expectError(await report('card-9', '203.0.1.9', 'gibt-es-nicht'), 400, 'badReason'); + expectError(await post('/api/veroeffentlichen', publishBody()), 401, 'unauthorized', 'veröffentlichen ohne JWT'); + expectError(await post('/api/zurueckziehen', { id: 'card-9' }, 'xxx.yyy.zzz'), 401, 'unauthorized', 'zurückziehen mit falschem JWT'); + expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }), 401, 'unauthorized', 'Admin ohne JWT'); +}); + +test('Fehlerantworten: /api/melden mit unbekannter ID ist genau {error, code} aus der Spec', async () => { + const { report } = await setup(); + const res = await report('gibts-nicht', '203.0.1.9'); + assert.equal(res.status, 404); + assert.deepEqual(res.json, { error: 'Diese Karte ist nicht (mehr) verfügbar.', code: 'cardGone' }); +}); + +test('Fehlerantworten: Logik-Fehler der Karten-Endpunkte tragen den MSG-Schlüssel, dynamische Prüftexte badRequest', async () => { + const { post, fake } = await setup(); + seedCard(fake, { id: 'card-9', owner: OTHER }); + seedUploads(fake); + + expectError(await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME), 404, 'cardGone'); + expectError(await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME), 403, 'forbidden'); + expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ME), 403, 'adminOnly'); + expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: 'ja' }, JWT_ADMIN), 400, 'badVisibility'); + expectError(await post('/api/admin/sichtbarkeit', { id: '../x', sichtbar: true }, JWT_ADMIN), 400, 'badCardId'); + expectError(await post('/api/veroeffentlichen', publishBody({ sourceCardId: '../x' }), JWT_ME), 400, 'badCardId'); + + // Prüftexte mit Zahlen oder Feldnamen haben keinen festen Schlüssel: code badRequest, Text bleibt der genaue + for (const [overrides, text] of [ + [{ title: '' }, 'Bitte gib einen Titel an.'], + [{ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).'], + [{ imageFileId: '../x' }, 'Ungültige Bild-ID.'], + [{ tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, 'Bitte gib höchstens 5 Tags an.'], + [{ previewFileId: 'img1' }, 'Bild und Vorschau müssen verschiedene Dateien sein.'], + ]) { + const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME); + assert.equal(res.status, 400, text); + assert.deepEqual(res.json, { error: text, code: 'badRequest' }, text); + } +}); + +test('Fehlerantworten: Datei-, Besitz- und Limit-Fehler beim Veröffentlichen tragen ihren Schlüssel', async () => { + const { post, report, fake } = await setup(); + const attempt = (n, { image = {}, preview = {}, card = `card${n}` } = {}, overrides = {}) => { + fake.addFile({ id: `img${n}`, owner: ME, ...image }); + fake.addFile({ id: `prev${n}`, owner: ME, mimeType: 'image/webp', ...preview }); + if (card) fake.addCard(card, ME); + return post('/api/veroeffentlichen', publishBody({ imageFileId: `img${n}`, previewFileId: `prev${n}`, sourceCardId: `card${n}`, ...overrides }), JWT_ME); + }; + + expectError(await attempt(1, { image: { owner: OTHER } }), 403, 'foreignFile'); + expectError(await attempt(2, { image: { bucket: 'card-images' } }), 403, 'foreignFile', 'falscher Bucket'); + expectError(await attempt(3, {}, { previewFileId: 'ghost' }), 400, 'fileMissing'); + expectError(await attempt(4, { image: { mimeType: 'image/jpeg' } }), 400, 'fileType'); + expectError(await attempt(5, { image: { size: MIB5 + 1 } }), 400, 'fileSize'); + expectError(await attempt(6, { image: { content: pngHeader(100, 100) } }), 400, 'imageFormat'); + expectError(await attempt(7, { image: { permissions: ['delete("user:user-me")'] } }), 403, 'filesTaken'); + expectError(await attempt(8, { card: null }), 403, 'notYourCard'); + + assert.equal((await attempt(9)).status, 201); + expectError(await attempt(10, {}, { sourceCardId: 'card9' }), 409, 'alreadyPublished'); + + seedPublications(fake, ME, 19); // mit der Karte oben sind es 20 + expectError(await attempt(11), 429, 'tooManyPublications'); + + // Rate-Limit der Meldungen (die Karte `card9` ist sichtbar veröffentlicht) + const id = galleryRowId(ME, 'card9'); + for (let i = 0; i < 10; i += 1) assert.equal((await report(id, '203.0.50.9')).status, 200); + const limited = await report(id, '203.0.50.9'); + expectError(limited, 429, 'tooManyReports'); + assert.equal(limited.headers['retry-after'], '3600'); +}); + +test('Fehlerantworten: Appwrite-Fehler haben code unavailable (502) bzw. internal (500), unerwartete Fehler internal', async () => { + const { report, fake } = await setup(); + seedCard(fake); + const isRowRead = (c) => c.method === 'GET' && c.path.includes('/tables/gallery/rows/'); + + fake.failNext(isRowRead, { status: 500 }); + expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '5xx'); + fake.failNext(isRowRead, { status: 429, type: 'general_rate_limit_exceeded' }); + expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '429'); + fake.failNext(isRowRead, { status: 404, type: 'table_not_found' }); + expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'fehlende Tabelle'); + fake.failNext(isRowRead, { status: 401, type: 'general_unauthorized_scope' }); + expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'falscher Key'); + + const broken = { + accountFromJwt: async () => { + throw new Error('kaputt GEHEIM'); + }, + }; + const app = createApp({ appwrite: broken, salt: SALT, log: () => {} }); + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + cleanups.push(() => { + server.closeAllConnections(); + return new Promise((resolve) => server.close(resolve)); + }); + const res = await request(server.address().port, 'POST', '/api/zurueckziehen', { body: { id: 'x' }, headers: { 'X-Aircard-Jwt': JWT_ME } }); + expectError(res, 500, 'internal', 'unerwarteter Fehler'); + assert.ok(!res.text.includes('GEHEIM')); +}); diff --git a/deploy/api/appwrite.js b/deploy/api/appwrite.js index b4f0773..9c9f627 100644 --- a/deploy/api/appwrite.js +++ b/deploy/api/appwrite.js @@ -7,6 +7,8 @@ * getRow(table, id) Zeile oder null (404 row_not_found) * listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit, queryCursorAfter); * eine Antwort ohne `rows`-Array ist ein Fehler (kein stilles []) + * countRows(table, queries) Anzahl aller Treffer (`total` einer Abfrage mit limit(1), ohne eigenes limit + * in `queries`); eine Antwort ohne gültiges `total` ist ein Fehler * createRow(table, rowId, data, permissions) doppelte ID -> AppwriteError mit status 409 * updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben * deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar) @@ -74,6 +76,10 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, const base = endpoint.replace(/\/+$/, ''); const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`; const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`; + const rowsUrl = (table, queries) => { + const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&'); + return `${rowsPath(table)}${search === '' ? '' : `?${search}`}`; + }; /** Liest höchstens `limit` Bytes vom Anfang der Antwort und bricht den Rest ab (Dateien können 5 MiB groß sein). */ async function readHead(response, limit) { @@ -145,14 +151,23 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, }, async listRows(table, queries = []) { - const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&'); - const result = await send('GET', `${rowsPath(table)}${search === '' ? '' : `?${search}`}`); + const result = await send('GET', rowsUrl(table, queries)); if (!result.body || !Array.isArray(result.body.rows)) { throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite'); } return result.body.rows; }, + /** Zählt die Treffer einer Abfrage über `total` (Appwrite zählt bis 5000): es wird höchstens eine Zeile übertragen. */ + async countRows(table, queries = []) { + const result = await send('GET', rowsUrl(table, [...queries, queryLimit(1)])); + const total = result.body && result.body.total; + if (!Number.isInteger(total) || total < 0) { + throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite'); + } + return total; + }, + async createRow(table, rowId, data, permissions) { const result = await send('POST', rowsPath(table), { body: { rowId, data, permissions } }); return result.body; diff --git a/deploy/api/appwrite.test.js b/deploy/api/appwrite.test.js index 54cc579..ff6e4f1 100644 --- a/deploy/api/appwrite.test.js +++ b/deploy/api/appwrite.test.js @@ -129,6 +129,47 @@ test('listRows blättert mit cursorAfter', async () => { assert.deepEqual(last.map((row) => row.$id), ['e']); }); +test('countRows: liefert das total der Abfrage (nicht die Länge der Seite) und schickt limit(1) mit', async () => { + const { fake, appwrite } = await setup(); + for (const id of ['a', 'b', 'c', 'd']) { + await appwrite.createRow('reports', id, { galleryId: id === 'd' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []); + } + assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['g1'])]), 3); + const call = lastCall(fake); + assert.equal(call.method, 'GET'); + assert.equal(call.path, '/v1/tablesdb/aircard/tables/reports/rows'); + assert.deepEqual(call.search.split('&').map((part) => decodeURIComponent(part)), [ + 'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}', + 'queries[]={"method":"limit","values":[1]}', + ]); + assert.equal(call.headers['x-appwrite-key'], fake.key); + assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['nix'])]), 0); + assert.equal(await appwrite.countRows('reports'), 4, 'ohne Abfragen zählt es die ganze Tabelle'); +}); + +test('countRows: fehlendes oder ungültiges total ist ein AppwriteError (502, invalid_response), Appwrite-Fehler werden weitergereicht', async () => { + for (const payload of [{ rows: [] }, { total: '3', rows: [] }, { total: null, rows: [] }, { total: -1, rows: [] }, { total: 1.5, rows: [] }, [], 'text', null]) { + const appwrite = createAppwrite({ + endpoint: 'http://appwrite.invalid/v1', + project: 'p', + key: 'k', + fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }), + }); + await assert.rejects( + () => appwrite.countRows('reports', []), + (err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502, + JSON.stringify(payload), + ); + } + const zero = createAppwrite({ endpoint: 'http://appwrite.invalid/v1', project: 'p', key: 'k', fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }) }); + assert.equal(await zero.countRows('reports', []), 0, 'total 0 ist eine gültige Zahl'); + + const { fake, appwrite } = await setup(); + fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' }); + await assert.rejects(() => appwrite.countRows('reports', []), (err) => err instanceof AppwriteError && err.status === 500); + await assert.rejects(() => appwrite.countRows('keine-tabelle', []), (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'table_not_found'); +}); + test('Dateien: lesen, Rechte setzen, löschen', async () => { const { fake, appwrite } = await setup(); fake.addFile({ id: 'f1', owner: 'u1', mimeType: 'image/webp', size: 1234 }); @@ -335,11 +376,12 @@ test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner We key: 'k', fetchImpl: async (url, init) => { seen.push(init.redirect); - return new Response(JSON.stringify({ rows: [], $id: 'x' }), { status: 200 }); + return new Response(JSON.stringify({ total: 0, rows: [], $id: 'x' }), { status: 200 }); }, }); await appwrite.getRow('gallery', 'x'); await appwrite.listRows('gallery', []); + await appwrite.countRows('gallery', []); await appwrite.createRow('gallery', 'x', {}, []); await appwrite.updateRow('gallery', 'x', {}); await appwrite.deleteRow('gallery', 'x'); @@ -347,7 +389,7 @@ test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner We await appwrite.updateFilePermissions('gallery-images', 'x', []); await appwrite.deleteFile('gallery-images', 'x'); await appwrite.accountFromJwt('a.b.c'); - assert.equal(seen.length, 9); + assert.equal(seen.length, 10); assert.ok(seen.every((value) => value === 'error')); }); diff --git a/deploy/api/common.js b/deploy/api/common.js new file mode 100644 index 0000000..f573c6d --- /dev/null +++ b/deploy/api/common.js @@ -0,0 +1,335 @@ +'use strict'; + +/** + * Gemeinsame Helfer des API-Dienstes `aircard-api` für die Karten (app.js) und die Logos (logos.js): Fehlertexte und + * -codes, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße. Nur reine Funktionen und Konstanten, kein Zustand. + * + * Fehlerantworten sind immer {error: , code: }. Texte ohne festen Schlüssel (dynamische + * Prüftexte wie „Der Titel ist zu lang (höchstens 60 Zeichen).“) haben bei Status 400 den code `badRequest`, sonst `error`. + */ + +const crypto = require('node:crypto'); +const net = require('node:net'); +const { AppwriteError } = require('./appwrite.js'); + +/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */ +const MAX_PUBLICATIONS = 20; +/** Höchstens so viele Logos je Konto (ausgeblendete zählen mit); Löschen gibt einen Platz frei. */ +const MAX_LOGOS = 30; +/** Ab so vielen verschiedenen Meldern wird eine Karte bzw. ein Logo ausgeblendet. */ +const HIDE_AT_REPORTS = 3; +/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */ +const IMAGE_HEAD_BYTES = 64; +const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']; + +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; +// 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]/; + +/** + * Alle Fehlertexte der API, Schlüssel = `code` der Fehlerantwort. Die Texte sind paarweise verschieden (ein Test prüft das): + * `codeFor` findet den Schlüssel über den Text. Die Clients übersetzen Meldungen über den Schlüssel; darum ist MSG ein + * schlichtes Objekt aus Texten, das dieses Modul unverändert exportiert. + */ +const MSG = { + unauthorized: 'Bitte melde dich an.', + notFound: 'Nicht gefunden.', + cardGone: 'Diese Karte ist nicht (mehr) verfügbar.', + forbidden: 'Dazu hast du keine Berechtigung.', + adminOnly: 'Nur für Admins.', + foreignFile: 'Diese Datei gehört dir nicht.', + fileMissing: 'Bild nicht gefunden.', + fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.', + fileSize: 'Das Bild ist größer als 5 MB.', + alreadyPublished: 'Diese Karte ist schon veröffentlicht.', + filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.', + imageFormat: 'Das Bild hat nicht das Kartenformat.', + notYourCard: 'Diese Karte gehört nicht zu deinem Konto.', + tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`, + tooManyReports: 'Zu viele Meldungen – bitte später nochmal.', + badRequest: 'Ungültige Anfrage.', + badCardId: 'Ungültige Karten-ID.', + badReason: 'Bitte wähle einen gültigen Grund.', + badVisibility: 'Ungültige Angabe zur Sichtbarkeit.', + // Logos + logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.', + badLogoId: 'Ungültige Logo-ID.', + logoNameMissing: 'Bitte gib einen Namen an.', + logoFileType: 'Das Logo muss eine PNG-Datei sein.', + logoFileSize: 'Das Logo ist größer als 1 MB.', + logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).', + logoTaken: 'Diese Datei wird schon für ein Logo verwendet.', + tooManyLogos: `Du hast schon ${MAX_LOGOS} Logos hochgeladen. Lösche eins, um ein neues hochzuladen.`, + badHeart: 'Ungültige Angabe zum Herz.', + badMono: 'Ungültige Angabe zu „einfarbig“.', + tooLarge: 'Die Anfrage ist zu groß.', + jsonOnly: 'Nur application/json wird unterstützt.', + unavailable: 'Der Dienst ist gerade nicht erreichbar.', + internal: 'Interner Fehler.', +}; + +// ---- Fehler ---------------------------------------------------------------------------------------- + +/** Text -> Schlüssel (die Texte in MSG sind paarweise verschieden, sonst wäre der Code nicht eindeutig). */ +const CODE_BY_TEXT = new Map(Object.entries(MSG).map(([key, text]) => [text, key])); + +/** + * Der `code` einer Fehlerantwort: der MSG-Schlüssel mit genau diesem Text, sonst `badRequest` bei Status 400 und + * `error` bei jedem anderen Status. + */ +function codeFor(status, message) { + return CODE_BY_TEXT.get(message) || (status === 400 ? 'badRequest' : 'error'); +} + +class HttpError extends Error { + constructor(status, message, headers = {}, code = codeFor(status, message)) { + super(message); + this.status = status; + this.headers = headers; + this.code = code; + } +} + +// ---- Reine Helfer ---------------------------------------------------------------------------------- + +function isId(value) { + return typeof value === 'string' && ID_PATTERN.test(value); +} + +function assertOwner(owner) { + if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID'); + return owner; +} + +/** Sichtbare Zeile (Karte oder Logo): lesen darf jeder. */ +function visibleRowPermissions() { + return ['read("any")']; +} + +/** Ausgeblendete Zeile: nur Autor und Admins. */ +function hiddenRowPermissions(owner) { + assertOwner(owner); + return [`read("user:${owner}")`, 'read("label:admin")']; +} + +/** Herz (Zeile in `logo_likes`): lesen darf nur das Konto selbst. */ +function likeRowPermissions(userId) { + assertOwner(userId); + return [`read("user:${userId}")`]; +} + +/** + * 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` (Karten) und `uploadLogo` (Logos); die API setzt sie danach auf + * `visibleFilePermissions`. + */ +function uploadFilePermissions(owner) { + assertOwner(owner); + return ['read("any")', `delete("user:${owner}")`]; +} + +/** + * Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die + * Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen + * löscht die Dateien serverseitig mit dem Key. + */ +function visibleFilePermissions() { + return ['read("any")']; +} + +/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */ +function hiddenFilePermissions(owner) { + assertOwner(owner); + return [`read("user:${owner}")`, 'read("label:admin")']; +} + +function hash32(text) { + return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32); +} + +function expandIpv6(address) { + let text = address; + const lastColon = text.lastIndexOf(':'); + const tail = text.slice(lastColon + 1); + if (tail.includes('.')) { + const o = tail.split('.').map(Number); + text = `${text.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`; + } + const [head, rest] = text.split('::'); + const headGroups = head === '' ? [] : head.split(':'); + const restGroups = rest === undefined || rest === '' ? [] : rest.split(':'); + const groups = rest === undefined ? headGroups : [...headGroups, ...Array(8 - headGroups.length - restGroups.length).fill('0'), ...restGroups]; + return groups.map((group) => Number.parseInt(group, 16)); +} + +/** + * Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen), + * { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text). + */ +function parseIp(ip) { + if (typeof ip !== 'string') return { kind: 'none' }; + let text = ip.trim(); + if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1); + const zone = text.indexOf('%'); + if (zone !== -1) text = text.slice(0, zone); + const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text); + if (withPort) text = withPort[1]; + + if (net.isIPv4(text)) return { kind: 'v4', text }; + if (net.isIPv6(text)) { + const groups = expandIpv6(text); + if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) { + return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` }; + } + return { kind: 'v6', groups }; + } + 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 keine Karte und kein Logo 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) { + const value = req.headers && req.headers[name]; + const text = Array.isArray(value) ? value[0] : value; + if (typeof text !== 'string') return ''; + return text.split(',')[0].trim(); +} + +/** Echte Client-IP: X-Real-Ip (setzt Traefik), sonst erster X-Forwarded-For-Eintrag, sonst Socket. */ +function clientIp(req) { + return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || ''; +} + +function hmacHex(salt, text) { + return crypto.createHmac('sha256', salt).update(text).digest('hex'); +} + +/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */ +function reporterHash(salt, ip) { + return hmacHex(salt, reporterNetwork(ip)); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +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; +} + +function parseJsonObject(raw) { + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new HttpError(400, MSG.badRequest); + } + if (!isPlainObject(parsed)) throw new HttpError(400, MSG.badRequest); + return parsed; +} + +function isDuplicate(err) { + return err instanceof AppwriteError && err.status === 409; +} + +// ---- Dateiprüfungen (Karten und Logos) ----------------------------------------------------------------- + +/** + * Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im erwarteten Bucket. Metadaten wie Typ und + * Größe prüft der Aufrufer erst nach `checkFileOwnership`, damit fremde Dateien nichts darüber verraten. + */ +function checkFileExists(file, bucket) { + if (!file) throw new HttpError(400, MSG.fileMissing); + if (file.bucketId !== bucket) throw new HttpError(403, MSG.foreignFile); +} + +/** + * Besitz: frisch hochgeladene Dateien tragen delete("user:") und read("any"). Eine Datei ohne delete(ich) gehört + * jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Dateien schon + * verwendeter Zeilen hat der Aufrufer vorher als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet. + */ +function checkFileOwnership(file, userId) { + const permissions = Array.isArray(file.$permissions) ? file.$permissions : []; + if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile); + // Frisch hochgeladene Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie. + if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken); +} + +module.exports = { + MAX_PUBLICATIONS, + MAX_LOGOS, + HIDE_AT_REPORTS, + IMAGE_HEAD_BYTES, + REASONS, + ID_PATTERN, + CONTROL_CHARACTERS, + MSG, + codeFor, + HttpError, + isId, + isPlainObject, + isDuplicate, + parseJsonObject, + readImageSize, + hash32, + hmacHex, + reporterHash, + reporterNetwork, + normalizeIp, + clientIp, + visibleRowPermissions, + hiddenRowPermissions, + likeRowPermissions, + uploadFilePermissions, + visibleFilePermissions, + hiddenFilePermissions, + checkFileExists, + checkFileOwnership, +}; diff --git a/deploy/api/fake-appwrite.js b/deploy/api/fake-appwrite.js index 9660f20..65c989f 100644 --- a/deploy/api/fake-appwrite.js +++ b/deploy/api/fake-appwrite.js @@ -9,17 +9,20 @@ * 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). + * Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an). Es gibt außerdem die Tabellen + * `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports` und die Buckets `gallery-images`, `card-images`, `logo-images`. * - * Abfragen: `queries[]=` mit den Methoden equal, cursorAfter und limit. Zeilen werden gegen die Spaltenlängen der - * echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im Test auffallen. + * Abfragen: `queries[]=` mit den Methoden equal, cursorAfter und limit. Die Antwort enthält wie bei Appwrite `total`: + * die Zahl aller Treffer der equal-Abfragen, unabhängig von limit und cursorAfter (darauf baut `countRows`). + * Zeilen werden gegen die Spaltenlängen der echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im + * Test auffallen. * Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht), * so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen. */ const http = require('node:http'); -const BUCKETS = ['gallery-images', 'card-images']; +const BUCKETS = ['gallery-images', 'card-images', 'logo-images']; /** PNG-Anfang (Signatur + IHDR) mit den Maßen; reicht für die Größenprüfung der API. */ function pngHeader(width, height) { @@ -80,10 +83,43 @@ const COLUMNS = { reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true }, reporterHash: { size: 64, required: true }, }, + // Spalten wie in web/scripts/appwrite-setup.mjs (Community-Logos) + logos: { + name: { size: 40, required: true }, + displayName: { size: 30 }, + fileId: { size: 36, required: true }, + ownerId: { size: 36, required: true }, + mono: { type: 'boolean' }, + likeCount: { type: 'integer', min: 0 }, + hidden: { type: 'boolean' }, + reportCount: { type: 'integer', min: 0 }, + }, + logo_likes: { + logoId: { size: 36, required: true }, + userId: { size: 36, required: true }, + }, + logo_reports: { + logoId: { size: 36, required: true }, + reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true }, + reporterHash: { size: 64, required: true }, + }, +}; + +/** Standardwerte der Spalten, die Appwrite beim Anlegen einsetzt, wenn die Anfrage sie weglässt. */ +const DEFAULTS = { + gallery: { hidden: false, reportCount: 0 }, + logos: { mono: false, likeCount: 0, hidden: false, reportCount: 0 }, }; async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) { - const tables = { gallery: new Map(), reports: new Map(), cards: new Map() }; + const tables = { + gallery: new Map(), + reports: new Map(), + cards: new Map(), + logos: new Map(), + logo_likes: new Map(), + logo_reports: new Map(), + }; const files = new Map(); const contents = new Map(); const users = new Map(); @@ -150,7 +186,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret const column = columns[name]; if (!column) return `Unknown attribute: ${name}`; if (column.type === 'boolean' && typeof value !== 'boolean') return `Invalid ${name}`; - if (column.type === 'integer' && !Number.isInteger(value)) return `Invalid ${name}`; + if (column.type === 'integer' && (!Number.isInteger(value) || (column.min !== undefined && value < column.min))) return `Invalid ${name}`; if (column.enum && !column.enum.includes(value)) return `Invalid ${name}`; if (column.size !== undefined) { if (typeof value !== 'string') return `Invalid ${name}`; @@ -231,15 +267,14 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret const data = body.data || {}; const problem = validateData(table, data, { partial: false }); if (problem) return error(400, 'document_invalid_structure', problem); - const defaults = table === 'gallery' ? { hidden: false, reportCount: 0 } : {}; - const row = addRow(table, body.rowId, { ...defaults, ...data }, body.permissions || []); + const row = addRow(table, body.rowId, { ...DEFAULTS[table], ...data }, body.permissions || []); return reply(201, row); } if (segments.length === 6 && req.method === 'GET') { - const rows = runQueries([...store.values()], search); - if (rows === null) return error(400, 'general_query_invalid'); - return reply(200, { total: rows.length, rows }); + const found = runQueries([...store.values()], search); + if (found === null) return error(400, 'general_query_invalid'); + return reply(200, found); } if (segments.length === 7) { @@ -339,7 +374,10 @@ function safeParse(text) { } } -/** Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage. */ +/** + * Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage. + * Liefert {total, rows}: `total` zählt alle Treffer der equal-Abfragen, `rows` ist die Seite nach cursorAfter und limit. + */ function runQueries(rows, search) { let result = rows; let limit = 25; @@ -364,12 +402,13 @@ function runQueries(rows, search) { return null; } } + const total = result.length; if (cursor !== null) { const index = result.findIndex((row) => row.$id === cursor); if (index === -1) return null; result = result.slice(index + 1); } - return result.slice(0, limit); + return { total, rows: result.slice(0, limit) }; } module.exports = { startFakeAppwrite, pngHeader, webpHeader }; diff --git a/deploy/api/logos.js b/deploy/api/logos.js new file mode 100644 index 0000000..cdb5c17 --- /dev/null +++ b/deploy/api/logos.js @@ -0,0 +1,366 @@ +'use strict'; + +/** + * Endpunkte der Community-Logos im API-Dienst `aircard-api` (Node 22, nur Bordmittel). Sie folgen den Karten-Endpunkten + * in app.js: gleiche Reihenfolge der Prüfungen, gleiche Rücknahme beim Hochladen, gleiche Melde- und Ausblend-Logik. + * app.js hängt die Routen an und gibt Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log mit. + * + * POST /api/logos/hochladen Nutzer-JWT {name, displayName?, mono?, fileId} + * 401 · 400 Validierung, Datei fehlt, kein PNG, größer als 1 MiB, Maße nicht 8 × 8 bis 720 × 250 · 403 Datei nicht + * meine / falscher Bucket / Datei gesperrt (kein read("any")) · 409 Datei schon für ein Logo verwendet · 429 schon + * 30 Logos · 201 {id} (id = Datei-ID = Zeilen-ID) + * POST /api/logos/herz Nutzer-JWT {id, herz} + * 401 · 400 · 404 fehlt oder ausgeblendet · 200 {herz, likeCount} + * POST /api/logos/melden ohne Anmeldung {id, reason} (wie /api/melden, Rate-Limit gemeinsam) + * 400 · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true} + * POST /api/logos/loeschen Nutzer-JWT (Hochlader oder Admin) {id} + * 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true} + * POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar} + * 401 · 403 kein Admin · 400 · 404 · 200 {ok:true} + * + * Tabellen `logos` (Zeilen-ID = Datei-ID, damit eine Datei nie zwei Logos tragen kann), `logo_likes`, `logo_reports`, + * Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows), nie hoch- + * oder runtergezählt. Sperren: `logo-owner:` beim Hochladen, `logo:` für alles je Logo. + * Logs nennen nur Logo-IDs und Zahlen, nie Namen, IPs, Hashes oder JWTs; Herzen werden nicht geloggt. + */ + +const { queryEqual, queryLimit } = require('./appwrite.js'); +const { + MAX_LOGOS, + HIDE_AT_REPORTS, + IMAGE_HEAD_BYTES, + REASONS, + CONTROL_CHARACTERS, + MSG, + HttpError, + isId, + isPlainObject, + isDuplicate, + parseJsonObject, + readImageSize, + hash32, + hmacHex, + reporterHash, + normalizeIp, + clientIp, + visibleRowPermissions, + hiddenRowPermissions, + likeRowPermissions, + uploadFilePermissions, + visibleFilePermissions, + hiddenFilePermissions, + checkFileExists, + checkFileOwnership, +} = require('./common.js'); + +const TABLE_LOGOS = 'logos'; +const TABLE_LIKES = 'logo_likes'; +const TABLE_REPORTS = 'logo_reports'; +const BUCKET = 'logo-images'; + +const MAX_LOGO_BYTES = 1024 * 1024; +/** Maße des fertigen Logos (wie die Verarbeitung im Editor): 8 × 8 bis 720 × 250 Pixel. */ +const LOGO_MIN_SIDE = 8; +const LOGO_MAX_WIDTH = 720; +const LOGO_MAX_HEIGHT = 250; +/** Herzen und Meldungen eines Logos werden in Runden zu 100 gelöscht, höchstens 50 Runden. */ +const ROW_BATCH = 100; +const ROW_ROUNDS = 50; + +const LOGO_TEXT_FIELDS = [ + { key: 'name', label: 'Der Name', max: 40, required: true }, + { key: 'displayName', label: 'Der Anzeigename', max: 30, required: false }, +]; + +// ---- Reine Helfer ---------------------------------------------------------------------------------- + +/** Feste Zeilen-ID je Logo und Nutzer: ein Herz pro Konto, Appwrite-409 ersetzt jede Doppelt-Prüfung. */ +function logoLikeId(logoId, userId) { + return hash32(`${logoId}|${userId}`); +} + +/** Feste Zeilen-ID je Logo und Melder (`logo|` trennt sie von den Karten-Meldungen): eine Meldung pro Melder und Logo. */ +function logoReportId(logoId, reporter) { + return hash32(`logo|${logoId}|${reporter}`); +} + +function readLogoText(raw, field) { + if (raw === undefined || raw === null) return field.required ? { message: MSG.logoNameMissing } : { value: '' }; + if (typeof raw !== 'string') return { message: `${field.label} ist ungültig.` }; + const value = raw.trim(); + if (value === '' && field.required) return { message: MSG.logoNameMissing }; + if (CONTROL_CHARACTERS.test(value)) return { message: `${field.label} enthält ungültige Zeichen.` }; + if (value.length > field.max) return { message: `${field.label} ist zu lang (höchstens ${field.max} Zeichen).` }; + return { value }; +} + +/** + * Prüft den Hochladen-Body: Name 1-40, Anzeigename ≤ 30 (getrimmt, ohne Steuer- und Bidi-Zeichen), `mono` fehlt = false + * (sonst muss es ein Boolean sein), `fileId` nach ID_PATTERN. + * @returns {{ok: true, data: {name: string, displayName: string, mono: boolean, fileId: string}} | {ok: false, message: string}} + */ +function validateLogoUpload(body) { + if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest }; + + const data = {}; + for (const field of LOGO_TEXT_FIELDS) { + const result = readLogoText(body[field.key], field); + if (result.message) return { ok: false, message: result.message }; + data[field.key] = result.value; + } + if (body.mono !== undefined && typeof body.mono !== 'boolean') return { ok: false, message: MSG.badMono }; + data.mono = body.mono === true; + if (!isId(body.fileId)) return { ok: false, message: 'Ungültige Datei-ID.' }; + data.fileId = body.fileId; + return { ok: true, data }; +} + +/** Typ und Größe laut Appwrite (erst nach der Besitzprüfung, damit fremde Dateien nichts darüber verraten). */ +function checkLogoContent(file) { + if (file.mimeType !== 'image/png') throw new HttpError(400, MSG.logoFileType); + if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_LOGO_BYTES) throw new HttpError(400, MSG.logoFileSize); +} + +function isLogoSize(head) { + // readImageSize kennt auch WebP: ein Logo muss einen PNG-Kopf haben, nicht nur den Dateityp laut Appwrite + if (head.toString('latin1', 1, 4) !== 'PNG') return false; + const size = readImageSize(head); + return ( + size !== null && + size.width >= LOGO_MIN_SIDE && + size.height >= LOGO_MIN_SIDE && + size.width <= LOGO_MAX_WIDTH && + size.height <= LOGO_MAX_HEIGHT + ); +} + +// ---- Die Routen ------------------------------------------------------------------------------------ + +/** + * @param {object} options + * @param {object} options.appwrite Objekt aus createAppwrite + * @param {string} options.salt Salz für die Melder-Hashes + * @param {(message: string) => void} options.say Ereigniszeile ins Log (mit Zeilenbudget) + * @param {(key: string, fn: () => Promise) => Promise} options.withLock Je Schlüssel nacheinander + * @param {(key: string) => {allowed: boolean, retryAfter?: number}} options.rateLimit das Rate-Limit der Meldungen (gemeinsam mit den Karten) + * @param {(req) => Promise<{id: string, isAdmin: boolean}>} options.requireUser wirft 401 ohne gültiges JWT + * @returns {Array<[string, {name: string, json: true, run: (ctx: {req, raw: string}) => Promise<{status: number, body: object}>}]>} + */ +function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) { + const withLogoLock = (id, fn) => withLock(`logo:${id}`, fn); + + /** Setzt die Rechte der Logo-Datei; null heißt: die Datei gibt es nicht (mehr). */ + function setFilePermissions(row, permissions) { + return isId(row.fileId) ? appwrite.updateFilePermissions(BUCKET, row.fileId, permissions) : Promise.resolve(null); + } + + /** Datei zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */ + async function hideLogo(id, row, extra = {}) { + await setFilePermissions(row, hiddenFilePermissions(row.ownerId)); + await appwrite.updateRow(TABLE_LOGOS, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId)); + } + + /** Löscht alle Zeilen der Tabelle zu diesem Logo, in Runden (die Zeile des Logos selbst bleibt der Anker für eine Wiederholung). */ + async function deleteRowsOf(table, logoId) { + for (let round = 0; round < ROW_ROUNDS; round += 1) { + const rows = await appwrite.listRows(table, [queryEqual('logoId', [logoId]), queryLimit(ROW_BATCH)]); + if (rows.length === 0) return; + for (const row of rows) await appwrite.deleteRow(table, row.$id); + } + } + + /** reportCount = Anzahl Meldungszeilen des Logos (neu gezählt); ab 3 wird ausgeblendet. */ + async function syncReports(id, row) { + const count = await appwrite.countRows(TABLE_REPORTS, [queryEqual('logoId', [id])]); + if (count >= HIDE_AT_REPORTS) { + await hideLogo(id, row, { reportCount: count }); + say(`logo ${id} hidden automatically (${count} reports)`); + } else if (row.reportCount !== count) { + await appwrite.updateRow(TABLE_LOGOS, id, { reportCount: count }); + } + } + + /** Liest den Anfang der Datei und verlangt einen PNG-Kopf mit Maßen von 8 × 8 bis 720 × 250. */ + async function checkLogoSize(fileId) { + const head = await appwrite.readFileHead(BUCKET, fileId, IMAGE_HEAD_BYTES); + if (head === null) throw new HttpError(400, MSG.fileMissing); + if (!isLogoSize(head)) throw new HttpError(400, MSG.logoFormat); + } + + async function uploadLogo({ req, raw }) { + const user = await requireUser(req); + const validation = validateLogoUpload(parseJsonObject(raw)); + if (!validation.ok) throw new HttpError(400, validation.message); + const data = validation.data; + + const file = await appwrite.getFile(BUCKET, data.fileId); + checkFileExists(file, BUCKET); + + // Je Konto nacheinander: Limit, Prüfung „Datei schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen. + return withLock(`logo-owner:${user.id}`, async () => { + // Die Zeilen dieses Kontos (auch ausgeblendete), höchstens MAX_LOGOS: ab dort ist das Limit erreicht. + const rows = await appwrite.listRows(TABLE_LOGOS, [queryEqual('ownerId', [user.id]), queryLimit(MAX_LOGOS)]); + if (rows.length >= MAX_LOGOS) throw new HttpError(429, MSG.tooManyLogos); + // Zeilen-ID = Datei-ID: dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung). + if (rows.some((row) => row.$id === data.fileId)) throw new HttpError(409, MSG.logoTaken); + checkFileOwnership(file, user.id); + checkLogoContent(file); + await checkLogoSize(data.fileId); + + try { + await appwrite.createRow( + TABLE_LOGOS, + data.fileId, + { + name: data.name, + displayName: data.displayName, + fileId: data.fileId, + ownerId: user.id, + mono: data.mono, + likeCount: 0, + hidden: false, + reportCount: 0, + }, + visibleRowPermissions(), + ); + } catch (err) { + // Eine Zeile mit dieser ID gehört schon jemandem (z. B. der liegengebliebene Anker eines halb gelöschten Logos). + if (isDuplicate(err)) throw new HttpError(409, MSG.logoTaken); + throw err; + } + + // Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen). + // Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen. + try { + const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions()); + if (pinned === null) throw new HttpError(400, MSG.fileMissing); + } catch (err) { + let rolledBack = true; + await appwrite.deleteRow(TABLE_LOGOS, data.fileId).catch(() => { + rolledBack = false; + say(`rollback failed for logo ${data.fileId}`); + }); + // Nur wenn keine Zeile mehr auf die Datei zeigt, bekommt sie delete(owner) zurück (der Client räumt damit auf). + if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {}); + throw err; + } + say(`logo ${data.fileId} uploaded`); + return { status: 201, body: { id: data.fileId } }; + }); + } + + async function setHeart({ req, raw }) { + const user = await requireUser(req); + const body = parseJsonObject(raw); + if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId); + if (typeof body.herz !== 'boolean') throw new HttpError(400, MSG.badHeart); + const id = body.id; + + return withLogoLock(id, async () => { + const row = await appwrite.getRow(TABLE_LOGOS, id); + if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone); + + const likeId = logoLikeId(id, user.id); + if (body.herz) { + try { + await appwrite.createRow(TABLE_LIKES, likeId, { logoId: id, userId: user.id }, likeRowPermissions(user.id)); + } catch (err) { + if (!isDuplicate(err)) throw err; // schon da: kein Fehler + } + } else { + await appwrite.deleteRow(TABLE_LIKES, likeId); // schon weg (false): kein Fehler + } + // Immer neu zählen statt hoch- oder runterzählen: das heilt einen früher steckengebliebenen Zählerstand. + const likeCount = await appwrite.countRows(TABLE_LIKES, [queryEqual('logoId', [id])]); + if (row.likeCount !== likeCount) await appwrite.updateRow(TABLE_LOGOS, id, { likeCount }); + return { status: 200, body: { herz: body.herz, likeCount } }; + }); + } + + async function reportLogo({ req, raw }) { + const body = parseJsonObject(raw); + if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId); + if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason); + const id = body.id; + + // Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt je Anschluss und gilt für Karten und Logos zusammen. + const ip = clientIp(req); + const reporter = reporterHash(salt, ip); + const limit = rateLimit(hmacHex(salt, normalizeIp(ip))); + if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) }); + + return withLogoLock(id, async () => { + const row = await appwrite.getRow(TABLE_LOGOS, id); + if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone); + + let isNew = true; + try { + await appwrite.createRow(TABLE_REPORTS, logoReportId(id, reporter), { logoId: id, reason: body.reason, reporterHash: reporter }, []); + } catch (err) { + if (!isDuplicate(err)) throw err; + isNew = false; + } + // Auch bei einer doppelten Meldung neu zählen: heilt einen früher steckengebliebenen Zählerstand. + await syncReports(id, row); + if (isNew) say(`logo ${id} reported (${body.reason})`); + return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } }; + }); + } + + async function deleteLogo({ req, raw }) { + const user = await requireUser(req); + const body = parseJsonObject(raw); + if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId); + const id = body.id; + + return withLogoLock(id, async () => { + const row = await appwrite.getRow(TABLE_LOGOS, id); + if (!row) throw new HttpError(404, MSG.logoGone); + if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden); + + // Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen. + if (isId(row.fileId)) await appwrite.deleteFile(BUCKET, row.fileId); + await deleteRowsOf(TABLE_LIKES, id); + await deleteRowsOf(TABLE_REPORTS, id); + await appwrite.deleteRow(TABLE_LOGOS, id); + say(`logo ${id} deleted${row.ownerId === user.id ? '' : ' by admin'}`); + return { status: 200, body: { ok: true } }; + }); + } + + async function setVisibility({ req, raw }) { + const user = await requireUser(req); + if (!user.isAdmin) throw new HttpError(403, MSG.adminOnly); + const body = parseJsonObject(raw); + if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId); + if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility); + const id = body.id; + + return withLogoLock(id, async () => { + const row = await appwrite.getRow(TABLE_LOGOS, id); + if (!row) throw new HttpError(404, MSG.logoGone); + + if (body.sichtbar) { + // Datei, dann Meldungen, Zeile zuletzt: sichtbar wird das Logo erst, wenn alles andere steht. + await setFilePermissions(row, visibleFilePermissions()); + await deleteRowsOf(TABLE_REPORTS, id); + await appwrite.updateRow(TABLE_LOGOS, id, { hidden: false, reportCount: 0 }, visibleRowPermissions()); + say(`logo ${id} made visible by admin`); + } else { + await hideLogo(id, row); + say(`logo ${id} hidden by admin`); + } + return { status: 200, body: { ok: true } }; + }); + } + + return [ + ['POST /api/logos/hochladen', { name: 'logos/hochladen', json: true, run: uploadLogo }], + ['POST /api/logos/herz', { name: 'logos/herz', json: true, run: setHeart }], + ['POST /api/logos/melden', { name: 'logos/melden', json: true, run: reportLogo }], + ['POST /api/logos/loeschen', { name: 'logos/loeschen', json: true, run: deleteLogo }], + ['POST /api/admin/logo-sichtbarkeit', { name: 'admin/logo-sichtbarkeit', json: true, run: setVisibility }], + ]; +} + +module.exports = { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload }; diff --git a/deploy/api/logos.test.js b/deploy/api/logos.test.js new file mode 100644 index 0000000..9f5cae0 --- /dev/null +++ b/deploy/api/logos.test.js @@ -0,0 +1,1106 @@ +'use strict'; + +// Tests für deploy/api/logos.js (Community-Logos): echte HTTP-Anfragen auf Port 0 gegen createApp, dahinter ein +// Fake-Appwrite im Speicher (fake-appwrite.js) und das echte appwrite.js. Aufruf: node --test "deploy/api/*.test.js" + +const { test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const http = require('node:http'); + +const { createAppwrite } = require('./appwrite.js'); +const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js'); +const { createApp } = require('./app.js'); +const { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload } = require('./logos.js'); +const { + MSG, + visibleRowPermissions, + hiddenRowPermissions, + likeRowPermissions, + visibleFilePermissions, + hiddenFilePermissions, + reporterHash, +} = require('./common.js'); + +const SALT = 'test-salt-0123456789abcdef0123456789abcdef'; +const ME = 'user-me'; +const OTHER = 'user-other'; +const ADMIN = 'user-admin'; +const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me'; +const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other'; +const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin'; +const MIB = 1024 * 1024; +const LOGO_BUCKET = 'logo-images'; +/** Rechte einer frisch hochgeladenen Logo-Datei von ME. */ +const UPLOAD_PERMISSIONS = ['read("any")', 'delete("user:user-me")']; +const HIDDEN_PERMISSIONS = ['read("user:user-me")', 'read("label:admin")']; + +// ---- Aufbau --------------------------------------------------------------------------------- + +const cleanups = []; +afterEach(async () => { + for (const fn of cleanups.splice(0).reverse()) await fn(); +}); + +async function setup() { + const fake = await startFakeAppwrite(); + fake.addUser(JWT_ME, { id: ME }); + fake.addUser(JWT_OTHER, { id: OTHER }); + fake.addUser(JWT_ADMIN, { id: ADMIN, labels: ['admin'] }); + + const logs = []; + const clock = { value: 1_700_000_000_000 }; + const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key }); + const app = createApp({ appwrite, salt: SALT, now: () => clock.value, log: (line) => logs.push(String(line)) }); + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + cleanups.push(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await fake.close(); + }); + + const call = (method, path, options) => request(port, method, path, options); + const post = (path, body, jwt, headers = {}) => + call('POST', path, { body, headers: { ...(jwt ? { 'X-Aircard-Jwt': jwt } : {}), ...headers } }); + const upload = (body, jwt = JWT_ME) => post('/api/logos/hochladen', body, jwt); + const heart = (id, herz, jwt = JWT_ME) => post('/api/logos/herz', { id, herz }, jwt); + const report = (id, ip, reason = 'spam') => post('/api/logos/melden', { id, reason }, null, ip ? { 'X-Real-Ip': ip } : {}); + const remove = (id, jwt = JWT_ME) => post('/api/logos/loeschen', { id }, jwt); + const setVisible = (id, sichtbar, jwt = JWT_ADMIN) => post('/api/admin/logo-sichtbarkeit', { id, sichtbar }, jwt); + + return { fake, app, logs, clock, port, call, post, upload, heart, report, remove, setVisible }; +} + +function request(port, method, path, { headers = {}, body, raw, contentType = 'application/json' } = {}) { + return new Promise((resolve, reject) => { + const payload = raw !== undefined ? raw : body === undefined ? undefined : JSON.stringify(body); + const requestHeaders = { ...headers }; + if (payload !== undefined && contentType) requestHeaders['Content-Type'] = contentType; + if (payload !== undefined) requestHeaders['Content-Length'] = Buffer.byteLength(payload); + const req = http.request({ host: '127.0.0.1', port, method, path, headers: requestHeaders }, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + let json = null; + try { + json = JSON.parse(text); + } catch { + // kein JSON + } + resolve({ status: res.statusCode, headers: res.headers, text, json }); + }); + }); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +/** Erwartet Status und genau {error: , code: }. */ +function expectError(res, status, code, label = code) { + assert.equal(res.status, status, `${label}: Status`); + assert.deepEqual(res.json, { error: MSG[code], code }, label); +} + +function uploadBody(overrides = {}) { + return { name: 'Mein Logo', displayName: 'Kenso', mono: false, fileId: 'logo1', ...overrides }; +} + +/** Frisch hochgeladene Logo-Datei: PNG im Bucket logo-images mit read("any") und delete(owner). */ +function logoFile(fake, id, owner = ME, { width = 200, height = 100, ...rest } = {}) { + fake.addFile({ id, bucket: LOGO_BUCKET, owner, content: pngHeader(width, height), ...rest }); +} + +/** Veröffentlichtes Logo: Zeile und Datei mit genau den Rechten, die die API vergibt. */ +function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount = 0, likeCount = 0, name = 'Logo', mono = false } = {}) { + fake.addFile({ + id, + bucket: LOGO_BUCKET, + owner, + permissions: hidden ? hiddenFilePermissions(owner) : visibleFilePermissions(), + content: pngHeader(200, 100), + }); + fake.addRow( + 'logos', + id, + { name, displayName: '', fileId: id, ownerId: owner, mono, likeCount, hidden, reportCount }, + hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(), + ); +} + +/** `count` Logo-Zeilen eines Kontos ohne Dateien (für das Limit); jede zweite ist ausgeblendet. */ +function seedLogoRows(fake, owner, count) { + for (let i = 0; i < count; i += 1) { + const id = `${owner}-l${i}`; + fake.addRow('logos', id, { name: `L${i}`, displayName: '', fileId: id, ownerId: owner, mono: false, likeCount: 0, hidden: i % 2 === 1, reportCount: 0 }, visibleRowPermissions()); + } +} + +function seedLikes(fake, logoId, userIds) { + for (const userId of userIds) fake.addRow('logo_likes', logoLikeId(logoId, userId), { logoId, userId }, likeRowPermissions(userId)); +} + +function seedReports(fake, logoId, hashes) { + for (const hash of hashes) fake.addRow('logo_reports', logoReportId(logoId, hash), { logoId, reason: 'spam', reporterHash: hash }, []); +} + +const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32); +const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account'); +const filePuts = (fake, id) => fake.calls.filter((c) => c.method === 'PUT' && c.path.endsWith(`/files/${id}`)); + +// ---- Reine Helfer ------------------------------------------------------------------------------ + +test('logoLikeId und logoReportId: erste 32 Hex von sha256, Logo-Meldungen mit dem Präfix logo|', () => { + assert.equal(logoLikeId('l1', 'u1'), sha32('l1|u1')); + assert.equal(logoReportId('l1', 'h1'), sha32('logo|l1|h1')); + assert.match(logoLikeId('l1', 'u1'), /^[0-9a-f]{32}$/); + assert.match(logoReportId('l1', 'h1'), /^[0-9a-f]{32}$/); + assert.notEqual(logoLikeId('l1', 'u1'), logoLikeId('l1', 'u2')); + assert.notEqual(logoLikeId('l1', 'u1'), logoLikeId('l2', 'u1')); + assert.notEqual(logoReportId('l1', 'h1'), logoReportId('l1', 'h2')); +}); + +test('likeRowPermissions: genau read("user:"), unzulässige Nutzer-IDs werden abgelehnt', () => { + assert.deepEqual(likeRowPermissions('abc'), ['read("user:abc")']); + for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) assert.throws(() => likeRowPermissions(bad)); +}); + +test('validateLogoUpload: normalisiert Name und Anzeigename, mono fehlt = false', () => { + assert.deepEqual(validateLogoUpload({ name: ' Mein Logo ', displayName: ' Kenso ', mono: true, fileId: 'f1' }), { + ok: true, + data: { name: 'Mein Logo', displayName: 'Kenso', mono: true, fileId: 'f1' }, + }); + assert.deepEqual(validateLogoUpload({ name: 'A', fileId: 'f1' }), { ok: true, data: { name: 'A', displayName: '', mono: false, fileId: 'f1' } }); + assert.equal(validateLogoUpload({ name: 'A', displayName: null, mono: false, fileId: 'f1' }).data.displayName, ''); + assert.equal(validateLogoUpload({ name: 'A', displayName: ' ', fileId: 'f1' }).data.displayName, ''); + // unbekannte Felder werden nicht übernommen + assert.deepEqual(Object.keys(validateLogoUpload({ name: 'A', fileId: 'f1', ownerId: 'x', hidden: true, likeCount: 99 }).data).sort(), ['displayName', 'fileId', 'mono', 'name']); +}); + +test('validateLogoUpload: Längen an den Grenzen (Name 40, Anzeigename 30) und Fehlertexte', () => { + assert.equal(validateLogoUpload({ name: 'x'.repeat(40), displayName: 'y'.repeat(30), fileId: 'f1' }).ok, true); + assert.deepEqual(validateLogoUpload({ name: 'x'.repeat(41), fileId: 'f1' }), { ok: false, message: 'Der Name ist zu lang (höchstens 40 Zeichen).' }); + assert.deepEqual(validateLogoUpload({ name: 'x', displayName: 'y'.repeat(31), fileId: 'f1' }), { + ok: false, + message: 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).', + }); + assert.deepEqual(validateLogoUpload({ name: '', fileId: 'f1' }), { ok: false, message: MSG.logoNameMissing }); + assert.deepEqual(validateLogoUpload({ fileId: 'f1' }), { ok: false, message: MSG.logoNameMissing }); + assert.deepEqual(validateLogoUpload({ name: 'x', mono: 'ja', fileId: 'f1' }), { ok: false, message: MSG.badMono }); + assert.deepEqual(validateLogoUpload({ name: 'x', fileId: '../x' }), { ok: false, message: 'Ungültige Datei-ID.' }); + for (const body of [null, [], 'text', 7, undefined]) { + assert.deepEqual(validateLogoUpload(body), { ok: false, message: MSG.badRequest }, JSON.stringify(body)); + } +}); + +test('createLogoRoutes liefert die fünf Routen mit Name, json und run', () => { + const routes = createLogoRoutes({ + appwrite: {}, + salt: SALT, + say: () => {}, + withLock: (key, fn) => fn(), + rateLimit: () => ({ allowed: true }), + requireUser: async () => ({ id: ME, isAdmin: false }), + }); + assert.deepEqual( + routes.map(([key]) => key), + ['POST /api/logos/hochladen', 'POST /api/logos/herz', 'POST /api/logos/melden', 'POST /api/logos/loeschen', 'POST /api/admin/logo-sichtbarkeit'], + ); + for (const [, route] of routes) { + assert.equal(route.json, true); + assert.equal(typeof route.run, 'function'); + assert.match(route.name, /^[a-z/-]+$/); + } +}); + +// ---- Hochladen -------------------------------------------------------------------------------------- + +test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Datei-ID), die Datei trägt danach genau read("any")', async () => { + const { upload, fake, logs } = await setup(); + logoFile(fake, 'logo1'); + + const res = await upload(uploadBody({ name: ' Mein Logo ', mono: true })); + assert.equal(res.status, 201); + assert.deepEqual(res.json, { id: 'logo1' }); + + const row = fake.row('logos', 'logo1'); + assert.deepEqual(row.$permissions, ['read("any")']); + assert.equal(row.name, 'Mein Logo'); + assert.equal(row.displayName, 'Kenso'); + assert.equal(row.fileId, 'logo1'); + assert.equal(row.ownerId, ME); + assert.equal(row.mono, true); + assert.equal(row.likeCount, 0); + assert.equal(row.hidden, false); + assert.equal(row.reportCount, 0); + assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'kein delete(owner) mehr: das Bild lässt sich nicht tauschen'); + + // der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf + assert.equal(fake.calls.find((c) => c.path === '/v1/account').headers['x-appwrite-jwt'], JWT_ME); + for (const c of nonAccountCalls(fake)) { + assert.equal(c.headers['x-appwrite-key'], fake.key); + assert.equal(c.headers['x-appwrite-jwt'], undefined); + } + assert.equal(logs.filter((line) => line.endsWith('logo logo1 uploaded')).length, 1); +}); + +test('Logo hochladen: Anzeigename ist optional, mono fehlt = false', async () => { + const { upload, fake } = await setup(); + logoFile(fake, 'logo1'); + const res = await upload({ name: 'Nur Name', fileId: 'logo1' }); + assert.equal(res.status, 201); + const row = fake.row('logos', 'logo1'); + assert.equal(row.displayName, ''); + assert.equal(row.mono, false); +}); + +test('Logo hochladen ohne oder mit ungültigem JWT: 401, nichts angelegt, die Datei bleibt unberührt', async () => { + const { upload, fake } = await setup(); + logoFile(fake, 'logo1'); + expectError(await upload(uploadBody(), null), 401, 'unauthorized'); + expectError(await upload(uploadBody(), 'xxx.yyy.zzz'), 401, 'unauthorized'); + expectError(await upload(uploadBody(), 'zwei teile'), 401, 'unauthorized'); + expectError(await upload({}, ''), 401, 'unauthorized'); + assert.equal(fake.rows('logos').length, 0); + assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS); + assert.equal(fake.calls.filter((c) => c.path.startsWith('/v1/storage/')).length, 0); + assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst'); +}); + +test('Logo hochladen mit fremder, falscher oder gesperrter Datei: 403, keine Zeile, Datei unverändert, Inhalt wird nicht gelesen', async () => { + const { upload, fake } = await setup(); + logoFile(fake, 'fremd', OTHER); + logoFile(fake, 'fremd-veroeffentlicht', OTHER, { permissions: visibleFilePermissions() }); + logoFile(fake, 'fremd-ausgeblendet', OTHER, { permissions: hiddenFilePermissions(OTHER) }); + logoFile(fake, 'nur-lesen', ME, { permissions: ['read("any")', 'read("user:user-me")'] }); + logoFile(fake, 'ohne-lesen', ME, { permissions: ['delete("user:user-me")'] }); + fake.addFile({ id: 'aus-galerie', owner: ME, content: pngHeader(100, 100) }); // Bucket gallery-images + fake.addFile({ id: 'aus-karten', bucket: 'card-images', owner: ME, content: pngHeader(100, 100) }); + + const cases = [ + ['fremd', 'foreignFile'], + ['fremd-veroeffentlicht', 'foreignFile'], + ['fremd-ausgeblendet', 'foreignFile'], + ['nur-lesen', 'foreignFile'], + ['aus-galerie', 'foreignFile'], + ['aus-karten', 'foreignFile'], + ['ohne-lesen', 'filesTaken'], + ]; + const before = Object.fromEntries(cases.map(([id]) => [id, [...fake.file(id).$permissions]])); + for (const [id, code] of cases) expectError(await upload(uploadBody({ fileId: id })), 403, code, id); + + assert.equal(fake.rows('logos').length, 0); + for (const [id] of cases) assert.deepEqual(fake.file(id).$permissions, before[id], `${id} unverändert`); + assert.equal(fake.calls.filter((c) => c.method === 'PUT').length, 0); + assert.equal(fake.calls.filter((c) => c.path.endsWith('/view')).length, 0, 'fremde oder gesperrte Dateien werden nicht gelesen (kein Maß-Orakel)'); +}); + +test('Logo hochladen mit fehlender Datei: 400 "Bild nicht gefunden."', async () => { + const { upload, fake } = await setup(); + expectError(await upload(uploadBody({ fileId: 'gibts-nicht' })), 400, 'fileMissing'); + assert.equal(fake.rows('logos').length, 0); +}); + +test('Logo hochladen: nur PNG bis 1 MiB, sonst 400 logoFileType bzw. logoFileSize', async () => { + const { upload, fake } = await setup(); + const attempt = (opts) => { + logoFile(fake, 'logo1', ME, opts); + return upload(uploadBody()); + }; + + for (const mimeType of ['image/webp', 'image/jpeg', 'image/svg+xml', 'application/pdf']) { + expectError(await attempt({ mimeType }), 400, 'logoFileType', mimeType); + } + expectError(await attempt({ size: MIB + 1 }), 400, 'logoFileSize', '1 MiB + 1'); + expectError(await attempt({ size: 5 * MIB }), 400, 'logoFileSize', '5 MiB'); + expectError(await attempt({ size: 'groß' }), 400, 'logoFileSize', 'keine Zahl'); + assert.equal(fake.rows('logos').length, 0); + assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'Datei bleibt unangetastet'); + + assert.equal((await attempt({ size: MIB })).status, 201, 'genau 1 MiB geht'); +}); + +test('Logo hochladen: Maße von 8 × 8 bis 720 × 250 gehen, alles andere ist 400 logoFormat (Datei bleibt unverändert)', async () => { + const { upload, fake } = await setup(); + for (const [width, height] of [[721, 100], [7, 7], [720, 251], [8, 7], [7, 8], [0, 0], [1, 1], [60000, 60000], [721, 251]]) { + logoFile(fake, 'logo1', ME, { width, height }); + expectError(await upload(uploadBody()), 400, 'logoFormat', `${width} × ${height}`); + assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS); + } + assert.equal(fake.rows('logos').length, 0); + + let n = 0; + for (const [width, height] of [[8, 8], [720, 250], [720, 8], [8, 250], [200, 100]]) { + n += 1; + logoFile(fake, `ok${n}`, ME, { width, height }); + assert.equal((await upload(uploadBody({ fileId: `ok${n}` }))).status, 201, `${width} × ${height}`); + } +}); + +test('Logo hochladen: Inhalt ohne PNG-Kopf ist 400 logoFormat (kaputt, abgeschnitten, WebP-Inhalt mit PNG-Typ)', async () => { + const { upload, fake } = await setup(); + for (const [label, content] of [ + ['leer', Buffer.alloc(0)], + ['Nullen', Buffer.alloc(100)], + ['Text', Buffer.from('')], + ['PNG abgeschnitten', pngHeader(200, 100).subarray(0, 20)], + ['WebP-Inhalt, Typ PNG', webpHeader('VP8L', 200, 100)], + ]) { + logoFile(fake, 'logo1', ME, { content }); + expectError(await upload(uploadBody()), 400, 'logoFormat', label); + } + assert.equal(fake.rows('logos').length, 0); + assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS); +}); + +test('Logo hochladen: Datei verschwindet vor dem Lesen der Maße ergibt 400 "Bild nicht gefunden.", Appwrite-Fehler dabei 502', async () => { + const { upload, fake } = await setup(); + logoFile(fake, 'logo1'); + fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/logo1/view'), { status: 404, type: 'storage_file_not_found' }); + expectError(await upload(uploadBody()), 400, 'fileMissing'); + fake.failNext((c) => c.path.endsWith('/files/logo1/view'), { status: 500 }); + expectError(await upload(uploadBody()), 502, 'unavailable'); + assert.equal(fake.rows('logos').length, 0); + assert.equal((await upload(uploadBody())).status, 201); +}); + +test('Logo hochladen: dieselbe Datei zweimal ergibt 409 logoTaken, Datei und Zeile bleiben wie sie sind', async () => { + const { upload, fake } = await setup(); + logoFile(fake, 'logo1'); + assert.equal((await upload(uploadBody())).status, 201); + + // die Datei trägt jetzt nur noch read("any"): trotzdem 409 und nicht 403 + expectError(await upload(uploadBody({ name: 'Anderer Name' })), 409, 'logoTaken'); + assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']); + assert.equal(fake.rows('logos').length, 1); + assert.equal(fake.row('logos', 'logo1').name, 'Mein Logo'); + + // dieselbe ID als frisch hochgeladene Datei (z. B. nach Löschen und Neuanlegen): 409, die Rechte der Datei bleiben unangetastet + fake.addFile({ id: 'logo1', bucket: LOGO_BUCKET, owner: ME, permissions: [...UPLOAD_PERMISSIONS, 'update("user:user-me")'], content: pngHeader(50, 50) }); + expectError(await upload(uploadBody()), 409, 'logoTaken'); + assert.deepEqual(fake.file('logo1').$permissions, [...UPLOAD_PERMISSIONS, 'update("user:user-me")']); + assert.equal(filePuts(fake, 'logo1').length, 1, 'nur der erste, erfolgreiche Upload hat Rechte gesetzt'); +}); + +test('Logo hochladen: eine Zeile mit dieser ID gehört schon jemand anderem (liegengebliebener Anker): 409 logoTaken, Datei unangetastet', async () => { + const { upload, fake } = await setup(); + fake.addRow('logos', 'anker', { name: 'Alt', displayName: '', fileId: 'anker', ownerId: OTHER, mono: false, likeCount: 0, hidden: false, reportCount: 0 }, visibleRowPermissions()); + logoFile(fake, 'anker'); + expectError(await upload(uploadBody({ fileId: 'anker' })), 409, 'logoTaken'); + assert.deepEqual(fake.file('anker').$permissions, UPLOAD_PERMISSIONS); + assert.equal(fake.rows('logos').length, 1); + assert.equal(filePuts(fake, 'anker').length, 0, 'ein 409 fasst die Dateirechte nie an'); +}); + +test('Logo hochladen: höchstens 30 Logos je Konto (ausgeblendete zählen mit), das 31. ergibt 429 tooManyLogos', async () => { + const { upload, fake } = await setup(); + seedLogoRows(fake, ME, 30); + logoFile(fake, 'logo1'); + expectError(await upload(uploadBody()), 429, 'tooManyLogos'); + assert.equal(fake.rows('logos').length, 30); + assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS, 'Datei unangetastet'); + + // Logos anderer Konten zählen nicht, und jedes Konto hat sein eigenes Limit + seedLogoRows(fake, OTHER, 40); + expectError(await upload(uploadBody()), 429, 'tooManyLogos'); + logoFile(fake, 'logo-other', OTHER); + expectError(await upload(uploadBody({ fileId: 'logo-other' }), JWT_OTHER), 429, 'tooManyLogos'); +}); + +test('Logo hochladen: mit 29 Logos geht das 30., nach dem Löschen eines Logos wieder eins', async () => { + const { upload, remove, fake } = await setup(); + seedLogoRows(fake, ME, 29); + logoFile(fake, 'logo1'); + assert.equal((await upload(uploadBody())).status, 201); + logoFile(fake, 'logo2'); + expectError(await upload(uploadBody({ fileId: 'logo2' })), 429, 'tooManyLogos'); + assert.equal((await remove('user-me-l0')).status, 200); + assert.equal((await upload(uploadBody({ fileId: 'logo2' }))).status, 201); +}); + +test('Logo hochladen: das Limit liest nur die Zeilen des Kontos (ein Aufruf mit ownerId und limit 30)', async () => { + const { upload, fake } = await setup(); + seedLogoRows(fake, ME, 3); + seedLogoRows(fake, OTHER, 40); + logoFile(fake, 'logo1'); + assert.equal((await upload(uploadBody())).status, 201); + const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/logos/rows')); + assert.equal(lists.length, 1); + const search = decodeURIComponent(lists[0].search); + assert.ok(search.includes('"attribute":"ownerId","values":["user-me"]')); + assert.ok(search.includes('"method":"limit","values":[30]')); +}); + +test('Logo hochladen: scheitert das Festlegen der Dateirechte, wird die Zeile zurückgenommen und die Datei bekommt delete(owner) zurück', async () => { + const { upload, fake } = await setup(); + logoFile(fake, 'logo1'); + fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 500 }); + expectError(await upload(uploadBody()), 502, 'unavailable'); + assert.equal(fake.rows('logos').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert'); + assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS); + const puts = filePuts(fake, 'logo1'); + assert.equal(puts.length, 2, 'Festlegen (scheitert) und Rückgabe von delete(owner)'); + assert.deepEqual(puts[1].body, { permissions: UPLOAD_PERMISSIONS }); + + // Datei verschwindet zwischen Prüfung und Festlegen: 400 und ebenfalls zurückgenommen + fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 404, type: 'storage_file_not_found' }); + expectError(await upload(uploadBody()), 400, 'fileMissing'); + assert.equal(fake.rows('logos').length, 0); + + // danach geht ein neuer Versuch + assert.equal((await upload(uploadBody())).status, 201); + assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']); +}); + +test('Logo hochladen: scheitert auch das Zurücknehmen, bleibt die Zeile stehen, die Datei wird nicht angefasst und `rollback failed for logo ` steht im Log', async () => { + const { upload, fake, logs } = await setup(); + logoFile(fake, 'logo1'); + fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 500 }); + fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/'), { status: 500 }); + expectError(await upload(uploadBody()), 502, 'unavailable'); + assert.equal(fake.rows('logos').length, 1, 'die Zeile blieb stehen (Zurücknehmen scheiterte)'); + assert.equal(filePuts(fake, 'logo1').length, 1, 'ohne erfolgreiches Zurücknehmen bekommt die Datei delete(owner) nicht zurück'); + const lines = logs.filter((line) => line.includes('rollback failed')); + assert.equal(lines.length, 1); + assert.match(lines[0], /^\[aircard-api\] \S+ rollback failed for logo logo1$/); + assert.ok(!logs.join('\n').includes(JWT_ME)); +}); + +test('Logo hochladen: gelingt das Zurücknehmen, steht keine Rollback-Fehlerzeile im Log', async () => { + const { upload, fake, logs } = await setup(); + logoFile(fake, 'logo1'); + fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo1'), { status: 500 }); + assert.equal((await upload(uploadBody())).status, 502); + assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0); +}); + +test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer dem Konto) nicht an', async () => { + const { upload, fake } = await setup(); + logoFile(fake, 'logo1'); + + for (const [label, overrides, code] of [ + ['Name fehlt', { name: undefined }, 'logoNameMissing'], + ['Name leer', { name: '' }, 'logoNameMissing'], + ['Name nur Leerzeichen', { name: ' ' }, 'logoNameMissing'], + ['Name null', { name: null }, 'logoNameMissing'], + ['Name nur BOM', { name: '' }, 'logoNameMissing'], + ['mono "ja"', { mono: 'ja' }, 'badMono'], + ['mono 1', { mono: 1 }, 'badMono'], + ['mono "true"', { mono: 'true' }, 'badMono'], + ['mono null', { mono: null }, 'badMono'], + ['mono Liste', { mono: [true] }, 'badMono'], + ]) { + expectError(await upload(uploadBody(overrides)), 400, code, label); + } + + // Prüftexte mit Feldnamen oder Zahlen haben keinen festen Schlüssel: code badRequest + for (const [label, overrides, text] of [ + ['Name zu lang', { name: 'x'.repeat(41) }, 'Der Name ist zu lang (höchstens 40 Zeichen).'], + ['Name keine Zeichenkette', { name: 42 }, 'Der Name ist ungültig.'], + ['Name mit Zeilenumbruch', { name: 'a\nb' }, 'Der Name enthält ungültige Zeichen.'], + ['Name mit Nullbreiten-Leerzeichen', { name: 'a​b' }, 'Der Name enthält ungültige Zeichen.'], + ['Name mit Rechts-nach-links-Überschreibung', { name: 'logo‮gnp' }, 'Der Name enthält ungültige Zeichen.'], + ['Anzeigename zu lang', { displayName: 'x'.repeat(31) }, 'Der Anzeigename ist zu lang (höchstens 30 Zeichen).'], + ['Anzeigename keine Zeichenkette', { displayName: 5 }, 'Der Anzeigename ist ungültig.'], + ['Anzeigename mit Bidi-Zeichen', { displayName: 'Ken⁦so' }, 'Der Anzeigename enthält ungültige Zeichen.'], + ['Datei-ID mit ../', { fileId: '../x' }, 'Ungültige Datei-ID.'], + ['Datei-ID ..', { fileId: '..' }, 'Ungültige Datei-ID.'], + ['Datei-ID Zahl', { fileId: 123 }, 'Ungültige Datei-ID.'], + ['Datei-ID leer', { fileId: '' }, 'Ungültige Datei-ID.'], + ['Datei-ID fehlt', { fileId: undefined }, 'Ungültige Datei-ID.'], + ['Datei-ID zu lang', { fileId: 'a'.repeat(37) }, 'Ungültige Datei-ID.'], + ['Datei-ID mit Schrägstrich', { fileId: 'a/b' }, 'Ungültige Datei-ID.'], + ]) { + const res = await upload(uploadBody(overrides)); + assert.equal(res.status, 400, label); + assert.deepEqual(res.json, { error: text, code: 'badRequest' }, label); + } + + assert.equal(fake.rows('logos').length, 0); + assert.equal(nonAccountCalls(fake).length, 0, 'ungültige Anfragen erreichen Appwrite (außer dem Konto) nie'); +}); + +test('Logo hochladen: kaputtes JSON, Array oder leerer Body ist 400 badRequest', async () => { + const { call, fake } = await setup(); + for (const raw of ['{kaputt', '[]', '"text"', 'null', '']) { + const res = await call('POST', '/api/logos/hochladen', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } }); + expectError(res, 400, 'badRequest', JSON.stringify(raw)); + } + assert.equal(nonAccountCalls(fake).length, 0); +}); + +test('Logo hochladen: gleichzeitig dieselbe Datei oder zwei Dateien am Limit, genau eine Anfrage gelingt (Sperre je Konto)', async () => { + const { upload, fake } = await setup(); + + logoFile(fake, 'logo1'); + fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/logos/rows'), 80); + const same = await Promise.all([upload(uploadBody()), upload(uploadBody())]); + assert.deepEqual(same.map((r) => r.status).sort(), [201, 409]); + assert.equal(fake.rows('logos').length, 1); + + seedLogoRows(fake, ME, 28); // mit dem Logo oben 29 + logoFile(fake, 'a'); + logoFile(fake, 'b'); + fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/logos/rows'), 80); + const limited = await Promise.all([upload(uploadBody({ fileId: 'a' })), upload(uploadBody({ fileId: 'b' }))]); + assert.deepEqual(limited.map((r) => r.status).sort(), [201, 429]); + assert.equal(fake.rows('logos').filter((row) => row.ownerId === ME).length, 30); +}); + +// ---- Herzen ----------------------------------------------------------------------------------------- + +test('Herz: an, nochmal an (bleibt 1), aus, nochmal aus (bleibt 0); Like-Zeile mit ID logoLikeId und Recht read("user:")', async () => { + const { heart, fake } = await setup(); + seedLogo(fake); + + let res = await heart('logo-1', true); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { herz: true, likeCount: 1 }); + const like = fake.row('logo_likes', logoLikeId('logo-1', ME)); + assert.ok(like, 'Zeilen-ID = hash32("|")'); + assert.deepEqual(like.$permissions, ['read("user:user-me")']); + assert.equal(like.logoId, 'logo-1'); + assert.equal(like.userId, ME); + assert.equal(fake.row('logos', 'logo-1').likeCount, 1); + + res = await heart('logo-1', true); + assert.deepEqual(res.json, { herz: true, likeCount: 1 }); + assert.equal(fake.rows('logo_likes').length, 1); + + res = await heart('logo-1', false); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { herz: false, likeCount: 0 }); + assert.equal(fake.row('logos', 'logo-1').likeCount, 0); + assert.equal(fake.rows('logo_likes').length, 0); + + res = await heart('logo-1', false); + assert.deepEqual(res.json, { herz: false, likeCount: 0 }); + assert.equal(fake.row('logos', 'logo-1').likeCount, 0); +}); + +test('Herz: zwei Nutzer ergeben 2, jeder nimmt nur sein eigenes zurück', async () => { + const { heart, fake } = await setup(); + seedLogo(fake); + assert.deepEqual((await heart('logo-1', true, JWT_ME)).json, { herz: true, likeCount: 1 }); + assert.deepEqual((await heart('logo-1', true, JWT_OTHER)).json, { herz: true, likeCount: 2 }); + assert.deepEqual((await heart('logo-1', true, JWT_ADMIN)).json, { herz: true, likeCount: 3 }); + assert.equal(fake.row('logos', 'logo-1').likeCount, 3); + + assert.deepEqual((await heart('logo-1', false, JWT_ME)).json, { herz: false, likeCount: 2 }); + assert.deepEqual(fake.rows('logo_likes').map((like) => like.userId).sort(), [ADMIN, OTHER]); + assert.equal(fake.row('logos', 'logo-1').likeCount, 2); +}); + +test('Herz: likeCount ist immer die Zahl der Zeilen (heilt einen falschen Zählerstand), die Logo-Zeile ändert sich nur bei Abweichung', async () => { + const { heart, fake } = await setup(); + seedLogo(fake, { likeCount: 7 }); + seedLikes(fake, 'logo-1', ['u1', 'u2']); + seedLogo(fake, { id: 'logo-2' }); + seedLikes(fake, 'logo-2', ['u1', 'u2', 'u3', 'u4']); + + const res = await heart('logo-1', true); + assert.deepEqual(res.json, { herz: true, likeCount: 3 }, 'nur die Herzen dieses Logos zählen'); + assert.equal(fake.row('logos', 'logo-1').likeCount, 3); + const patch = fake.calls.find((c) => c.method === 'PATCH' && c.path.endsWith('/tables/logos/rows/logo-1')); + assert.deepEqual(patch.body, { data: { likeCount: 3 } }, 'ohne permissions: die Rechte der Zeile bleiben'); + assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); + + const seen = fake.calls.length; + assert.deepEqual((await heart('logo-1', true)).json, { herz: true, likeCount: 3 }); + assert.equal(fake.calls.slice(seen).filter((c) => c.method === 'PATCH').length, 0, 'Zähler stimmt schon: kein Schreibzugriff auf die Logo-Zeile'); + + const counts = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/logo_likes/rows')); + assert.ok(counts.length >= 1); + assert.ok(decodeURIComponent(counts[0].search).includes('"method":"limit","values":[1]'), 'gezählt wird über total einer Abfrage mit limit(1)'); +}); + +test('Herz: ausgeblendetes oder fehlendes Logo ist 404 logoGone, auch beim Entfernen, und legt nichts an', async () => { + const { heart, fake } = await setup(); + seedLogo(fake, { id: 'versteckt', owner: OTHER, hidden: true }); + seedLikes(fake, 'versteckt', [ME]); + + expectError(await heart('versteckt', true), 404, 'logoGone', 'ausgeblendet, an'); + expectError(await heart('versteckt', false), 404, 'logoGone', 'ausgeblendet, aus'); + expectError(await heart('gibts-nicht', true), 404, 'logoGone', 'fehlt, an'); + expectError(await heart('gibts-nicht', false), 404, 'logoGone', 'fehlt, aus'); + assert.equal(fake.rows('logo_likes').length, 1, 'die vorhandene Like-Zeile bleibt, nichts Neues'); + assert.equal(fake.row('logos', 'versteckt').likeCount, 0); +}); + +test('Herz: Anmeldung und Body-Prüfung (id, herz), ungültige Anfragen erreichen Appwrite nicht', async () => { + const { heart, post, fake } = await setup(); + seedLogo(fake); + + expectError(await heart('logo-1', true, null), 401, 'unauthorized', 'ohne JWT'); + expectError(await heart('logo-1', true, 'xxx.yyy.zzz'), 401, 'unauthorized', 'falsches JWT'); + expectError(await post('/api/logos/herz', { id: 'logo-1', herz: 'ja' }), 401, 'unauthorized', 'Anmeldung vor Body-Prüfung'); + for (const herz of ['ja', 'true', 1, 0, null, undefined, [true]]) { + expectError(await post('/api/logos/herz', { id: 'logo-1', herz }, JWT_ME), 400, 'badHeart', JSON.stringify(herz)); + } + for (const id of ['../x', '..', 5, null, undefined, 'a/b', 'a'.repeat(37), '']) { + expectError(await heart(id, true), 400, 'badLogoId', String(id)); + } + assert.equal(fake.rows('logo_likes').length, 0); + assert.equal(nonAccountCalls(fake).length, 0); +}); + +test('Herz: Ausfall beim Zählen endet mit 502, die Wiederholung zählt richtig (das Herz war schon angelegt)', async () => { + const { heart, fake } = await setup(); + seedLogo(fake); + fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/logo_likes/rows'), { status: 500 }); + expectError(await heart('logo-1', true), 502, 'unavailable'); + assert.equal(fake.rows('logo_likes').length, 1); + assert.equal(fake.row('logos', 'logo-1').likeCount, 0); + + assert.deepEqual((await heart('logo-1', true)).json, { herz: true, likeCount: 1 }); + assert.equal(fake.row('logos', 'logo-1').likeCount, 1); +}); + +test('Herz: gleichzeitige Herzen mehrerer Nutzer ergeben die richtige Zahl (Sperre je Logo)', async () => { + const { heart, fake } = await setup(); + seedLogo(fake); + fake.delayNext((c) => c.method === 'PATCH' && c.path.endsWith('/tables/logos/rows/logo-1'), 100); + const results = await Promise.all([heart('logo-1', true, JWT_ME), heart('logo-1', true, JWT_OTHER), heart('logo-1', true, JWT_ADMIN)]); + assert.deepEqual(results.map((r) => r.status), [200, 200, 200]); + assert.equal(fake.rows('logo_likes').length, 3); + assert.equal(fake.row('logos', 'logo-1').likeCount, 3, 'ein verspäteter Zählerstand überschreibt keinen neueren'); +}); + +// ---- Melden ----------------------------------------------------------------------------------------- + +test('Logo melden: drei verschiedene Netze blenden das Logo aus (Zeilen- und Dateirechte), danach 404', async () => { + const { report, fake } = await setup(); + seedLogo(fake); + + let res = await report('logo-1', '203.0.1.9'); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { ok: true }); + assert.equal(fake.row('logos', 'logo-1').reportCount, 1); + assert.equal(fake.row('logos', 'logo-1').hidden, false); + res = await report('logo-1', '203.0.2.9', 'urheberrecht'); + assert.deepEqual(res.json, { ok: true }); + assert.equal(fake.row('logos', 'logo-1').reportCount, 2); + assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); + assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']); + + res = await report('logo-1', '203.0.3.9', 'sonstiges'); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { ok: true }); + const row = fake.row('logos', 'logo-1'); + assert.equal(row.hidden, true); + assert.equal(row.reportCount, 3); + assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS); + assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS); + assert.equal(fake.rows('logo_reports').length, 3); + + // ausgeblendetes Logo melden: 404 + expectError(await report('logo-1', '203.0.4.9'), 404, 'logoGone'); + assert.equal(fake.rows('logo_reports').length, 3); +}); + +test('Logo melden: die Meldungen speichern nur Hash, Grund und Logo (ID = hash32("logo||"), keine Rechte)', async () => { + const { report, fake } = await setup(); + seedLogo(fake); + await report('logo-1', '203.0.1.9', 'spam'); + await report('logo-1', '203.0.2.9', 'unangemessen'); + assert.equal(fake.row('logos', 'logo-1').hidden, false); + + const hash = reporterHash(SALT, '203.0.1.9'); + const stored = fake.row('logo_reports', sha32(`logo|logo-1|${hash}`)); + assert.ok(stored); + assert.equal(stored.logoId, 'logo-1'); + assert.equal(stored.reason, 'spam'); + assert.equal(stored.reporterHash, hash); + assert.deepEqual(stored.$permissions, []); + assert.ok(!JSON.stringify(fake.rows('logo_reports')).includes('203.0.'), 'keine IP im Klartext gespeichert'); + assert.equal(fake.rows('reports').length, 0, 'Logo-Meldungen landen nicht bei den Karten-Meldungen'); +}); + +test('Logo melden: dieselbe IP mehrmals zählt einmal ({schonGemeldet:true}), ein IPv4-/24 und ein IPv6-/48 sind je ein Melder', async () => { + const { report, fake } = await setup(); + seedLogo(fake); + assert.deepEqual((await report('logo-1', '203.0.9.9')).json, { ok: true }); + for (let i = 0; i < 4; i += 1) { + const again = await report('logo-1', '203.0.9.9', i % 2 ? 'spam' : 'unangemessen'); + assert.equal(again.status, 200); + assert.deepEqual(again.json, { schonGemeldet: true }); + } + assert.deepEqual((await report('logo-1', '203.0.9.200')).json, { schonGemeldet: true }, 'selbes /24'); + assert.equal(fake.row('logos', 'logo-1').reportCount, 1); + assert.equal(fake.row('logos', 'logo-1').hidden, false); + + assert.deepEqual((await report('logo-1', '2001:db8:abcd:12::1')).json, { ok: true }); + assert.deepEqual((await report('logo-1', '2001:db8:abcd:13::1')).json, { schonGemeldet: true }, 'selbes /48'); + assert.equal(fake.row('logos', 'logo-1').reportCount, 2); + assert.equal(fake.rows('logo_reports').length, 2); +}); + +test('Logo melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spam, sonstiges', async () => { + const { report, fake } = await setup(); + seedLogo(fake); + seedLogo(fake, { id: 'logo-2' }); + const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']; + for (let i = 0; i < reasons.length; i += 1) { + const res = await report(i < 2 ? 'logo-1' : 'logo-2', `198.51.${100 + i}.1`, reasons[i]); + assert.equal(res.status, 200, reasons[i]); + } + assert.deepEqual(fake.rows('logo_reports').map((r) => r.reason).sort(), [...reasons].sort()); +}); + +test('Logo melden: Ungültige Anfragen (Grund, ID) zählen nicht zum Limit und erreichen Appwrite nicht', async () => { + const { report, post, fake, app } = await setup(); + seedLogo(fake); + for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) { + const res = await post('/api/logos/melden', { id: 'logo-1', reason }, null, { 'X-Real-Ip': '203.0.1.9' }); + expectError(res, 400, 'badReason', String(reason)); + } + for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) { + const res = await post('/api/logos/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.1.9' }); + expectError(res, 400, 'badLogoId', String(id)); + } + assert.equal(fake.calls.length, 0); + assert.equal(app.rateLimitEntries(), 0); + assert.equal((await report('logo-1', '203.0.1.9')).status, 200); +}); + +test('Logo melden: fehlendes Logo ist 404 logoGone und legt keine Meldung an', async () => { + const { report, fake } = await setup(); + expectError(await report('gibts-nicht', '203.0.1.9'), 404, 'logoGone'); + assert.equal(fake.rows('logo_reports').length, 0); +}); + +test('Logo melden: Rate-Limit 10 pro Stunde und Anschluss, danach 429 mit Retry-After, nach einer Stunde wieder erlaubt', async () => { + const { report, fake, clock } = await setup(); + seedLogo(fake); + for (let i = 0; i < 10; i += 1) assert.equal((await report('logo-1', '203.0.50.9')).status, 200, `Meldung ${i + 1}`); + const limited = await report('logo-1', '203.0.50.9'); + expectError(limited, 429, 'tooManyReports'); + assert.equal(limited.headers['retry-after'], '3600'); + assert.equal((await report('logo-1', '203.0.51.9')).status, 200, 'eine andere IP ist nicht betroffen'); + + clock.value += 3600_000; + assert.equal((await report('logo-1', '203.0.50.9')).status, 200); +}); + +test('Logo melden: Karten und Logos teilen sich ein Rate-Limit je Anschluss (6 + 5 Meldungen, die 11. ergibt 429)', async () => { + const { report, post, fake, app } = await setup(); + seedLogo(fake); + fake.addRow( + 'gallery', + 'card-1', + { title: 'Karte', issuer: '', tags: '', displayName: '', imageFileId: 'ci', previewFileId: 'cp', sourceCardId: 'src', ownerId: ME, hidden: false, reportCount: 0, searchText: 'karte' }, + visibleRowPermissions(), + ); + const ip = { 'X-Real-Ip': '203.0.50.9' }; + const reportCard = () => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, ip); + + for (let i = 0; i < 6; i += 1) assert.equal((await reportCard()).status, 200, `Karte ${i + 1}`); + for (let i = 0; i < 4; i += 1) assert.equal((await report('logo-1', '203.0.50.9')).status, 200, `Logo ${i + 1}`); + expectError(await report('logo-1', '203.0.50.9'), 429, 'tooManyReports', 'die 11. Meldung'); + expectError(await reportCard(), 429, 'tooManyReports', 'auch Karten sind gesperrt'); + assert.equal(app.rateLimitEntries(), 1, 'ein gemeinsamer Zähler'); + + // eine andere Adresse hat ein eigenes Limit + assert.equal((await report('logo-1', '203.0.51.9')).status, 200); +}); + +test('Logo melden: gleichzeitige Meldungen von drei Netzen ergeben genau drei und blenden aus (Sperre je Logo)', async () => { + const { report, fake } = await setup(); + seedLogo(fake); + const results = await Promise.all([report('logo-1', '203.0.1.9'), report('logo-1', '203.0.2.9'), report('logo-1', '203.0.3.9')]); + assert.deepEqual(results.map((r) => r.status), [200, 200, 200]); + const row = fake.row('logos', 'logo-1'); + assert.equal(row.reportCount, 3); + assert.equal(row.hidden, true); + assert.equal(fake.rows('logo_reports').length, 3); +}); + +test('Logo melden: eine doppelte Meldung heilt einen früher steckengebliebenen Zählerstand', async () => { + const { report, fake } = await setup(); + seedLogo(fake); + seedReports(fake, 'logo-1', ['203.0.1.9', '203.0.2.9', '203.0.3.9'].map((ip) => reporterHash(SALT, ip))); + assert.equal(fake.row('logos', 'logo-1').hidden, false); + const res = await report('logo-1', '203.0.1.9'); + assert.deepEqual(res.json, { schonGemeldet: true }); + assert.equal(fake.row('logos', 'logo-1').hidden, true); + assert.equal(fake.row('logos', 'logo-1').reportCount, 3); + assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS); +}); + +test('Logo melden: Ausfall beim Anlegen der Meldung ist 502 und zählt nicht, Ausfall beim Ausblenden endet mit 502 und der nächste Versuch blendet aus', async () => { + const { report, fake } = await setup(); + seedLogo(fake); + fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/logo_reports/rows'), { status: 500 }); + expectError(await report('logo-1', '203.0.1.9'), 502, 'unavailable'); + assert.equal(fake.row('logos', 'logo-1').reportCount, 0); + assert.equal(fake.rows('logo_reports').length, 0); + + await report('logo-1', '203.0.1.9'); + await report('logo-1', '203.0.2.9'); + fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo-1'), { status: 500 }); + expectError(await report('logo-1', '203.0.3.9'), 502, 'unavailable'); + assert.equal(fake.row('logos', 'logo-1').hidden, false, 'Dateien zuerst, Zeile zuletzt: die Zeile ist noch sichtbar'); + const retry = await report('logo-1', '203.0.3.9'); + assert.equal(retry.status, 200); + assert.equal(fake.row('logos', 'logo-1').hidden, true); + assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS); +}); + +// ---- Löschen ---------------------------------------------------------------------------------------- + +test('Logo löschen durch den Hochlader: Datei, Herzen, Meldungen und Zeile sind weg, andere Logos bleiben', async () => { + const { remove, fake, logs } = await setup(); + seedLogo(fake, { id: 'logo-1' }); + seedLogo(fake, { id: 'logo-2' }); + seedLikes(fake, 'logo-1', ['u1', 'u2']); + seedLikes(fake, 'logo-2', ['u1']); + seedReports(fake, 'logo-1', ['h1', 'h2']); + seedReports(fake, 'logo-2', ['h3']); + + const res = await remove('logo-1'); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { ok: true }); + assert.equal(fake.row('logos', 'logo-1'), null); + assert.equal(fake.file('logo-1'), null); + assert.deepEqual(fake.rows('logo_likes').map((r) => r.logoId), ['logo-2']); + assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']); + assert.ok(fake.row('logos', 'logo-2')); + assert.ok(fake.file('logo-2')); + assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted')).length, 1); + + // Reihenfolge: Datei, Herzen, Meldungen, zuletzt die Zeile + const order = fake.calls.filter((c) => c.method === 'DELETE').map((c) => c.path.split('/').slice(-3).join('/')); + assert.ok(order[0].endsWith('files/logo-1'), order[0]); + assert.ok(order[order.length - 1].endsWith('logos/rows/logo-1'), order[order.length - 1]); +}); + +test('Logo löschen: Herzen und Meldungen werden in Runden zu 100 gelöscht, auch über 100 hinaus', async () => { + const { remove, fake } = await setup(); + seedLogo(fake); + seedLikes(fake, 'logo-1', Array.from({ length: 230 }, (_, i) => `u${i}`)); + seedReports(fake, 'logo-1', Array.from({ length: 120 }, (_, i) => `h${i}`)); + seedLikes(fake, 'anderes-logo', ['u1']); + + assert.equal((await remove('logo-1')).status, 200); + assert.equal(fake.rows('logo_likes').filter((r) => r.logoId === 'logo-1').length, 0); + assert.equal(fake.rows('logo_reports').length, 0); + assert.equal(fake.rows('logo_likes').length, 1, 'Herzen anderer Logos bleiben'); + + const lists = (table) => fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith(`/tables/${table}/rows`)); + assert.equal(lists('logo_likes').length, 4, '100 + 100 + 30 + leere Abschlussrunde'); + assert.equal(lists('logo_reports').length, 3, '100 + 20 + leere Abschlussrunde'); + const search = decodeURIComponent(lists('logo_likes')[0].search); + assert.ok(search.includes('"attribute":"logoId","values":["logo-1"]')); + assert.ok(search.includes('"method":"limit","values":[100]')); +}); + +test('Logo löschen durch Admin geht, auch bei ausgeblendetem Logo', async () => { + const { remove, fake, logs } = await setup(); + seedLogo(fake, { hidden: true, reportCount: 3 }); + seedReports(fake, 'logo-1', ['h1', 'h2', 'h3']); + const res = await remove('logo-1', JWT_ADMIN); + assert.equal(res.status, 200); + assert.equal(fake.row('logos', 'logo-1'), null); + assert.equal(fake.file('logo-1'), null); + assert.equal(fake.rows('logo_reports').length, 0); + assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted by admin')).length, 1); +}); + +test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async () => { + const { remove, fake } = await setup(); + seedLogo(fake); + seedLikes(fake, 'logo-1', ['u1']); + expectError(await remove('logo-1', JWT_OTHER), 403, 'forbidden'); + assert.ok(fake.row('logos', 'logo-1')); + assert.ok(fake.file('logo-1')); + assert.equal(fake.rows('logo_likes').length, 1); + assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0); +}); + +test('Logo löschen: 401 ohne JWT, 404 logoGone bei fehlendem Logo, 400 badLogoId bei ungültiger ID', async () => { + const { remove, fake } = await setup(); + seedLogo(fake); + expectError(await remove('logo-1', null), 401, 'unauthorized'); + expectError(await remove('logo-1', 'xxx.yyy.zzz'), 401, 'unauthorized'); + expectError(await remove('gibts-nicht'), 404, 'logoGone'); + for (const id of ['../x', '..', 5, null, undefined, '']) expectError(await remove(id), 400, 'badLogoId', String(id)); + assert.ok(fake.row('logos', 'logo-1')); +}); + +test('Logo löschen ist wiederholbar, wenn die Zeile zuletzt scheitert (die Zeile bleibt als Anker)', async () => { + const { remove, fake } = await setup(); + seedLogo(fake); + seedLikes(fake, 'logo-1', ['u1']); + fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/'), { status: 500 }); + expectError(await remove('logo-1'), 502, 'unavailable'); + assert.ok(fake.row('logos', 'logo-1'), 'Zeile bleibt, bis alles andere weg ist'); + assert.equal(fake.file('logo-1'), null); + assert.equal(fake.rows('logo_likes').length, 0); + + const retry = await remove('logo-1'); + assert.equal(retry.status, 200, 'die Datei fehlt schon, das ist kein Fehler'); + assert.equal(fake.row('logos', 'logo-1'), null); +}); + +// ---- Admin: Sichtbarkeit ------------------------------------------------------------------------------ + +test('Logo-Sichtbarkeit: Nicht-Admin 403 adminOnly, ohne JWT 401, nichts ändert sich', async () => { + const { setVisible, fake } = await setup(); + seedLogo(fake); + expectError(await setVisible('logo-1', false, JWT_ME), 403, 'adminOnly', 'Hochlader'); + expectError(await setVisible('logo-1', false, JWT_OTHER), 403, 'adminOnly', 'Fremde'); + expectError(await setVisible('logo-1', false, null), 401, 'unauthorized'); + assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); + assert.equal(fake.row('logos', 'logo-1').hidden, false); + assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']); +}); + +test('Admin blendet ein Logo aus: Zeilen- und Dateirechte, hidden=true, Meldungen und Herzen bleiben', async () => { + const { setVisible, fake, logs } = await setup(); + seedLogo(fake, { reportCount: 1, likeCount: 2 }); + seedReports(fake, 'logo-1', ['h1']); + seedLikes(fake, 'logo-1', ['u1', 'u2']); + + const res = await setVisible('logo-1', false); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { ok: true }); + const row = fake.row('logos', 'logo-1'); + assert.equal(row.hidden, true); + assert.equal(row.reportCount, 1); + assert.equal(row.likeCount, 2); + assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS); + assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS); + assert.equal(fake.rows('logo_reports').length, 1); + assert.equal(fake.rows('logo_likes').length, 2); + assert.equal(logs.filter((line) => line.endsWith('logo logo-1 hidden by admin')).length, 1); +}); + +test('Admin blendet ein Logo ein: sichtbare Rechte, hidden=false, reportCount=0, Meldungen gelöscht, Herzen bleiben, wieder meldbar', async () => { + const { setVisible, report, fake, logs } = await setup(); + seedLogo(fake, { hidden: true, reportCount: 3, likeCount: 2 }); + seedLogo(fake, { id: 'logo-2' }); + seedReports(fake, 'logo-1', ['h1', 'h2', 'h3']); + seedReports(fake, 'logo-2', ['h4']); + seedLikes(fake, 'logo-1', ['u1', 'u2']); + + const res = await setVisible('logo-1', true); + assert.equal(res.status, 200); + const row = fake.row('logos', 'logo-1'); + assert.equal(row.hidden, false); + assert.equal(row.reportCount, 0); + assert.equal(row.likeCount, 2); + assert.deepEqual(row.$permissions, ['read("any")']); + assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']); + assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']); + assert.equal(fake.rows('logo_likes').length, 2); + assert.equal(logs.filter((line) => line.endsWith('logo logo-1 made visible by admin')).length, 1); + + assert.equal((await report('logo-1', '203.0.1.9')).status, 200, 'das Logo ist wieder meldbar'); +}); + +test('Logo-Sichtbarkeit: Body-Prüfung und fehlendes Logo', async () => { + const { setVisible, post, fake } = await setup(); + seedLogo(fake); + for (const sichtbar of ['ja', 'true', 1, 0, null, undefined]) { + expectError(await post('/api/admin/logo-sichtbarkeit', { id: 'logo-1', sichtbar }, JWT_ADMIN), 400, 'badVisibility', String(sichtbar)); + } + for (const id of ['../x', '..', 7, null, undefined, '']) expectError(await setVisible(id, true), 400, 'badLogoId', String(id)); + expectError(await setVisible('gibts-nicht', true), 404, 'logoGone'); + expectError(await post('/api/admin/logo-sichtbarkeit', {}, JWT_ADMIN), 400, 'badLogoId'); +}); + +test('Logo-Sichtbarkeit: scheitert das Setzen der Dateirechte, bleibt die Zeile unverändert (Dateien zuerst, Zeile zuletzt)', async () => { + const { setVisible, fake } = await setup(); + seedLogo(fake); + fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo-1'), { status: 500 }); + expectError(await setVisible('logo-1', false), 502, 'unavailable'); + assert.equal(fake.row('logos', 'logo-1').hidden, false); + assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); + assert.equal((await setVisible('logo-1', false)).status, 200, 'der nächste Versuch klappt'); + assert.equal(fake.row('logos', 'logo-1').hidden, true); +}); + +// ---- Routen, Logs, Fehler --------------------------------------------------------------------------- + +test('Logo-Routen: nur POST mit application/json (sonst 404 bzw. 415), Appwrite wird dabei nicht berührt', async () => { + const { call, fake } = await setup(); + for (const path of ['/api/logos/hochladen', '/api/logos/herz', '/api/logos/melden', '/api/logos/loeschen', '/api/admin/logo-sichtbarkeit']) { + for (const method of ['GET', 'PUT', 'DELETE', 'OPTIONS']) { + expectError(await call(method, path, { body: {} }), 404, 'notFound', `${method} ${path}`); + } + expectError(await call('POST', `${path}/`, { body: {} }), 404, 'notFound', `${path}/`); + for (const contentType of ['text/plain', 'application/x-www-form-urlencoded', null]) { + expectError(await call('POST', path, { raw: '{}', contentType }), 415, 'jsonOnly', `${path} ${contentType}`); + } + } + assert.equal(fake.calls.length, 0); +}); + +test('Logo-Routen: Body über 8 KB ist 413 tooLarge', async () => { + const { call } = await setup(); + const raw = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`; + expectError(await call('POST', '/api/logos/melden', { raw }), 413, 'tooLarge'); +}); + +test('Logo-Endpunkte: Appwrite-Ausfall ist 502 unavailable, eine fehlende Tabelle 500 internal (nie „Logo nicht verfügbar“)', async () => { + const { heart, report, upload, fake } = await setup(); + seedLogo(fake); + const isLogoRead = (c) => c.method === 'GET' && c.path.includes('/tables/logos/rows/'); + fake.failNext(isLogoRead, { status: 503 }); + expectError(await heart('logo-1', true), 502, 'unavailable'); + fake.failNext(isLogoRead, { status: 429, type: 'general_rate_limit_exceeded' }); + expectError(await report('logo-1', '203.0.1.9'), 502, 'unavailable'); + fake.failNext(isLogoRead, { status: 404, type: 'table_not_found' }); + expectError(await report('logo-1', '203.0.1.9'), 500, 'internal'); + fake.failNext(isLogoRead, { status: 401, type: 'general_unauthorized_scope' }); + expectError(await heart('logo-1', true), 500, 'internal'); + fake.failNext((c) => c.path === '/v1/account', { status: 500 }); + expectError(await upload(uploadBody()), 502, 'unavailable'); + assert.equal(fake.rows('logo_likes').length, 0); + assert.equal(fake.rows('logo_reports').length, 0); +}); + +test('Logs der Logo-Endpunkte enthalten nie Key, Salz, JWT, IPs oder Namen, aber das Präfix [aircard-api]', async () => { + const { upload, heart, report, setVisible, remove, fake, logs } = await setup(); + logoFile(fake, 'logo1'); + seedLogo(fake, { id: 'logo-9', owner: OTHER }); + await upload(uploadBody({ name: 'GEHEIMER Name', displayName: 'GEHEIMER Anzeigename' })); + await upload(uploadBody(), 'xxx.yyy.zzz'); + await heart('logo-9', true); + await report('logo-9', '203.0.7.9'); + await report('logo-9', '203.0.8.9'); + await report('logo-9', '203.0.9.9'); + await setVisible('logo-9', true); + fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/logos/rows/'), { status: 500 }); + await remove('logo1'); + await remove('logo1'); + + assert.ok(logs.length > 0); + const all = logs.join('\n'); + for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.', '127.0.0.1', reporterHash(SALT, '203.0.7.9'), 'GEHEIMER']) { + assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`); + } + for (const line of logs) assert.ok(line.startsWith('[aircard-api] '), line); +}); + +test('Log-Flut: abgewiesene Logo-Anfragen (400/401/404/429) und Herzen erzeugen keine Logzeilen', async () => { + const { upload, heart, report, remove, setVisible, fake, logs } = await setup(); + seedLogo(fake); + for (let i = 0; i < 12; i += 1) { + await report('gibts-nicht', '203.0.5.9'); // 404, ab der 11. Meldung 429 + await report('../x', '203.0.5.9'); // 400 + await upload(uploadBody(), null); // 401 + await upload(uploadBody({ name: '' })); // 400 + await heart('logo-1', true); // Herzen werden nicht geloggt + await heart('logo-1', false); + await heart('gibts-nicht', true); // 404 + await remove('logo-1', JWT_OTHER); // 403 + await setVisible('logo-1', false, JWT_ME); // 403 + } + assert.equal((await report('gibts-nicht', '203.0.5.9')).status, 429, 'das Rate-Limit hat wirklich gegriffen'); + assert.deepEqual(logs, []); +});