From dd1597862a26fb94093db14594f1ce282b6540f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 14:09:30 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20aircard-api=20f=C3=BCr=20Ver=C3=B6?= =?UTF-8?q?ffentlichen,=20Melden=20und=20Admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer abhängigkeitsfreier Node-22-Dienst in deploy/api/ (appwrite.js, app.js, server.js), der alle Galerie-Schreibzugriffe mit dem Appwrite-Server-Key ausführt: POST /api/veroeffentlichen, /zurueckziehen, /melden (Rate-Limit 10/h je Melder, IPv6 auf /64, Auto-Ausblenden ab 3), /admin/sichtbarkeit und GET /api/gesundheit. - JWT-Prüfung über GET /account (Header X-Aircard-Jwt), Admin = Label admin - IDs per Muster geprüft, Pfadsegmente kodiert, "." und ".." abgelehnt - deterministische Zeilen-IDs (Galerie, Meldung): Appwrite-409 ersetzt jede Doppelt-Prüfung; Sperre je Karte gegen verlorene Zählerstände - Dateiprüfung: Bucket, delete("user:"), PNG/WebP, höchstens 5 MiB - Appwrite-Aufrufe mit 10-s-Zeitlimit, Logs ohne Key, Salz, JWT und IPs - Tests gegen einen Fake-Appwrite im Speicher (70 Tests), Compose-Service aircard-api (Router aircard-gallery-api) und README ergänzt Co-Authored-By: Claude Sonnet 5.5 --- README.md | 4 +- deploy/README.md | 37 +- deploy/api/README.md | 62 +++ deploy/api/app.js | 650 +++++++++++++++++++++++++ deploy/api/app.test.js | 947 ++++++++++++++++++++++++++++++++++++ deploy/api/appwrite.js | 158 ++++++ deploy/api/appwrite.test.js | 211 ++++++++ deploy/api/fake-appwrite.js | 288 +++++++++++ deploy/api/server.js | 99 ++++ deploy/api/server.test.js | 172 +++++++ deploy/compose-snippet.yml | 44 +- 11 files changed, 2659 insertions(+), 13 deletions(-) create mode 100644 deploy/api/README.md create mode 100644 deploy/api/app.js create mode 100644 deploy/api/app.test.js create mode 100644 deploy/api/appwrite.js create mode 100644 deploy/api/appwrite.test.js create mode 100644 deploy/api/fake-appwrite.js create mode 100644 deploy/api/server.js create mode 100644 deploy/api/server.test.js diff --git a/README.md b/README.md index 6e24f09..c499cff 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,11 @@ docker exec webklar-aircard-build sh /deploy.sh Starten nur mit: ```sh -docker compose up -d aircard-build aircard +docker compose up -d aircard-build aircard aircard-api ``` +`aircard-api` ist der kleine API-Dienst der Community-Galerie (`deploy/api/`, Route `aircard-ios.org/api`, siehe `deploy/api/README.md`). + Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` geroutet (Vorlage: `deploy/traefik/aircard.yml`): Requests auf `aircard-ios.org/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` gehen an den Appwrite-Container. Ohne diesen Header antwortet der nginx der Seite mit 404. ## Appwrite diff --git a/deploy/README.md b/deploy/README.md index 84fb2d5..7788e8d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -10,7 +10,8 @@ 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/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build` und `aircard` | +| `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/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). @@ -20,10 +21,11 @@ Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repo APP=/home/webklar/apps SRC=$APP/aircard-ios.org/dev/deploy -mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx +mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx $APP/aircard-ios.org/api 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/ # 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 @@ -33,17 +35,32 @@ cd $APP && docker compose config -q; echo $? # 0; einzige erlaubte Ausgabe: W cp $SRC/traefik/aircard.yml $APP/traefik/dynamic/aircard.yml.tmp && mv $APP/traefik/dynamic/aircard.yml.tmp $APP/traefik/dynamic/aircard.yml ``` -Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten. Beide Werte gehören nie in Logs, Chats oder Commits. +Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten, für den API-Dienst außerdem `AIRCARD_APPWRITE_PROJECT_ID`, `AIRCARD_API_KEY` und `AIRCARD_API_SALT`. Diese Werte gehören nie in Logs, Chats oder Commits. Das Salz entsteht, ohne dass der Wert auf dem Bildschirm erscheint: + +```sh +printf 'AIRCARD_API_SALT=%s\n' "$(openssl rand -hex 32)" >> /home/webklar/apps/.env +``` + +Den Key `AIRCARD_API_KEY` legt Kenso nach [docs/appwrite-console.md](../docs/appwrite-console.md) (Abschnitt 7.3) an und trägt ihn mit dem verdeckten `read -s`-Befehl ein. ## Starten ```sh -cd /home/webklar/apps && docker compose up -d aircard-build aircard +cd /home/webklar/apps && docker compose up -d aircard-build aircard aircard-api ``` -Nur diese beiden Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`). +Nur diese drei Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`). -Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen. +Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen. Ebenso beendet sich `aircard-api` mit Exit-Code 1, wenn `AIRCARD_API_KEY`, `AIRCARD_API_SALT` oder `AIRCARD_APPWRITE_PROJECT_ID` fehlen (die Meldung nennt nur den Namen der Variable). + +## 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, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern, Admin-Sichtbarkeit). Endpunkte, Statuscodes 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`. +- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`. +- **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. ## Manuell deployen @@ -79,12 +96,13 @@ Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea: - **Gemerkter Commit**: `build/.git/aircard-last-deployed-commit` enthält den zuletzt **versuchten** Commit, geschrieben vor dem Build. Ein kaputter Build wird deshalb nicht alle 15 Minuten wiederholt; die alte Seite bleibt online, bis ein neuer Commit oder ein Webhook-/manueller Lauf kommt. - **Fehler**: Ein fehlgeschlagener Lauf wird mit `[webhook] … deployment failed` protokolliert und beendet den Server nicht. Fehlerzeilen des Skripts beginnen mit `ERROR:`. - **Build-Variablen**: `VITE_APPWRITE_ENDPOINT` und `VITE_APPWRITE_PROJECT_ID` kommen aus der Container-Umgebung (compose); `AIRCARD_APPWRITE_PROJECT_ID` wird aus `/home/webklar/apps/.env` gelesen. Vite liest sie direkt. -- **.env-Variablen**: `AIRCARD_APPWRITE_PROJECT_ID` (Projekt-ID `6ac2130a00289343d6d5`) und `AIRCARD_APPWRITE_API_KEY` (für das Setup-Skript) müssen in `/home/webklar/apps/.env` eingetragen sein. +- **.env-Variablen**: `AIRCARD_APPWRITE_PROJECT_ID` (Projekt-ID `6ac2130a00289343d6d5`) und `AIRCARD_APPWRITE_API_KEY` (für das Setup-Skript) müssen in `/home/webklar/apps/.env` eingetragen sein; für `aircard-api` zusätzlich `AIRCARD_API_KEY` (Key `aircard-api`) und `AIRCARD_API_SALT`. ## Änderungen später - `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`. - 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. @@ -95,7 +113,8 @@ Solange `dig +short www.aircard-ios.org @8.8.8.8` nichts liefert, gibt es nur di ## Tests ```sh -node --test deploy/webhook-server.test.js +node --test deploy/webhook-server.test.js deploy/deploy.test.js +node --test "deploy/api/*.test.js" ``` -Die Tests brauchen nur Node und starten die Server auf Port 0. +Die Tests brauchen nur Node und starten die Server auf Port 0. Die API-Tests laufen gegen einen Fake-Appwrite im Speicher (`deploy/api/fake-appwrite.js`); das echte Appwrite wird nie angesprochen. Das Glob muss in Anführungszeichen stehen. diff --git a/deploy/api/README.md b/deploy/api/README.md new file mode 100644 index 0000000..f413817 --- /dev/null +++ b/deploy/api/README.md @@ -0,0 +1,62 @@ +# 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. + +Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11. 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`) | +| `*.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`. + +## Umgebung + +| Variable | Standard | Bedeutung | +|---|---|---| +| `AIRCARD_API_KEY` | – (Pflicht) | Appwrite-Server-Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) | +| `AIRCARD_API_SALT` | – (Pflicht) | Salz für die Melder-Hashes, 32 Byte hex | +| `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) | +| `AIRCARD_APPWRITE_ENDPOINT` | `http://webklar-appwrite/v1` | interne Appwrite-Adresse im Docker-Netz | +| `AIRCARD_API_PORT` | `8088` | Port des Dienstes | + +Fehlt eine Pflichtvariable, nennt die Meldung nur ihren Namen und der Prozess endet mit Exit-Code 1. Wer das Salz ändert, macht alle bisherigen Melder-Hashes ungültig: dieselben Personen können dieselbe Karte dann nochmal melden (die Karten selbst bleiben unberührt). + +## 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`). + +| Endpunkt | Auth | Antworten | +|---|---|---| +| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falsches Format oder > 5 MiB · `403` Datei nicht meine / falscher Bucket · `409` „Diese Karte ist schon veröffentlicht.“ | +| `POST /api/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` | +| `GET /api/gesundheit` | keine | `200 {ok:true}` | + +Ist Appwrite nicht erreichbar oder antwortet mit einem Fehler, antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“ (Details nur im Log, ohne Secrets), bei unerwarteten Fehlern `500`. + +### Regeln im Einzelnen + +- **IDs**: Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen (nur Zeichenketten), sonst `400`. `appwrite.js` kodiert zusätzlich jedes Pfadsegment und lehnt `.` und `..` ab. +- **Felder**: Titel 1–60, Herausgeber ≤ 40, Anzeigename ≤ 30 Zeichen (getrimmt, keine Steuerzeichen). `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Bild und Vorschau müssen verschiedene Dateien sein. +- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403`. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“ +- **Doppelt veröffentlichen**: Die Zeilen-ID der Galerie ist `sha256(ownerId|sourceCardId)` (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei. +- **Melden**: Rate-Limit 10 pro Stunde und Melder (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge). Der Melder-Hash ist `HMAC-SHA256(salt, ip)`; IPv6-Adressen werden vorher auf ihr /64-Netz gekürzt, IPv4-gemappte IPv6 auf IPv4. Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig. +- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst darf deshalb nur über Traefik erreichbar sein (kein `ports:` in Compose). +- **Rechte** (genau diese Strings): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:")`, `read("label:admin")`; Datei sichtbar `read("any")`, `delete("user:")`; Datei ausgeblendet `read("user:")`, `read("label:admin")`, `delete("user:")`. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). +- **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]`. Sie nennen Methode, Route, Status, Karten-IDs und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. + +## Tests + +```sh +node --test "deploy/api/*.test.js" +``` + +Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen) und greifen nie auf das echte Appwrite zu. diff --git a/deploy/api/app.js b/deploy/api/app.js new file mode 100644 index 0000000..86d25a6 --- /dev/null +++ b/deploy/api/app.js @@ -0,0 +1,650 @@ +'use strict'; + +/** + * 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). + * + * POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId} + * 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format · 403 Datei nicht meine / falscher Bucket + * 409 schon veröffentlicht · 201 {id} + * POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id} + * 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true} + * POST /api/melden ohne Anmeldung {id, reason} + * 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} + * 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`. + * Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik. + * Fehlerantworten sind immer {error: }. + */ + +const crypto = require('node:crypto'); +const net = require('node:net'); +const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js'); + +const TABLE_GALLERY = 'gallery'; +const TABLE_REPORTS = 'reports'; +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; +const REPORT_DELETE_ROUNDS = 50; +const RATE_LIMIT = 10; +const RATE_WINDOW_MS = 60 * 60 * 1000; +const RATE_SWEEP_MS = 60 * 1000; +const RATE_MAX_KEYS = 50_000; +const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']; + +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; +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.', + 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")']; +} + +function visibleFilePermissions(owner) { + assertOwner(owner); + return ['read("any")', `delete("user:${owner}")`]; +} + +function hiddenFilePermissions(owner) { + assertOwner(owner); + return [`read("user:${owner}")`, 'read("label:admin")', `delete("user:${owner}")`]; +} + +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}`); +} + +/** Feste Zeilen-ID je Karte und Melder: eine Meldung pro Melder und Karte. */ +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)); +} + +/** + * Vereinheitlicht eine Client-Adresse für Hash und Rate-Limit: IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4, + * jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen). + */ +function normalizeIp(ip) { + if (typeof ip !== 'string') return ''; + 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 text; + if (net.isIPv6(text)) { + const groups = expandIpv6(text); + if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) { + return `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`; + } + return `${groups.slice(0, 4).map((group) => group.toString(16)).join(':')}::/64`; + } + return text.toLowerCase().slice(0, 64); +} + +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) || ''; +} + +/** HMAC-SHA256 (Hex) der normalisierten IP mit dem Server-Salz. */ +function reporterHash(salt, ip) { + return crypto.createHmac('sha256', salt).update(normalizeIp(ip)).digest('hex'); +} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +const TEXT_FIELDS = [ + { key: 'title', article: 'Der', name: 'Titel', max: 60, required: true }, + { key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false }, + { key: 'displayName', article: 'Der', name: 'Anzeigename', max: 30, required: false }, +]; + +function readText(raw, field) { + if (raw === undefined || raw === null) { + return field.required ? { message: 'Bitte gib einen Titel an.' } : { value: '' }; + } + if (typeof raw !== 'string') return { message: `${field.article} ${field.name} ist ungültig.` }; + const value = raw.trim(); + if (value === '' && field.required) return { message: 'Bitte gib einen Titel an.' }; + if (CONTROL_CHARACTERS.test(value)) return { message: `${field.article} ${field.name} enthält ungültige Zeichen.` }; + if (value.length > field.max) return { message: `${field.article} ${field.name} ist zu lang (höchstens ${field.max} Zeichen).` }; + return { value }; +} + +/** Tags: string[], je getrimmt und klein, Leere raus, Doppelte raus, höchstens 5 mit je höchstens 20 Zeichen. */ +function readTags(raw) { + if (raw === undefined || raw === null) return { tags: [] }; + if (!Array.isArray(raw)) return { message: 'Tags müssen als Liste gesendet werden.' }; + const tags = []; + for (const entry of raw) { + if (typeof entry !== 'string') return { message: 'Ungültige Tags.' }; + const tag = entry.trim().toLowerCase(); + if (tag === '') continue; + if (tag.includes(',')) return { message: 'Tags dürfen kein Komma enthalten.' }; + if (CONTROL_CHARACTERS.test(tag)) return { message: 'Tags enthalten ungültige Zeichen.' }; + if (tag.length > 20) return { message: 'Ein Tag darf höchstens 20 Zeichen lang sein.' }; + if (!tags.includes(tag)) tags.push(tag); + } + if (tags.length > 5) return { message: 'Bitte gib höchstens 5 Tags an.' }; + return { tags }; +} + +/** + * Prüft den Veröffentlichen-Body (Längen aus Spec §6, IDs nach ID_PATTERN). + * @returns {{ok: true, data: object} | {ok: false, message: string}} + */ +function validatePublish(body) { + if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest }; + + const data = {}; + for (const field of TEXT_FIELDS) { + const result = readText(body[field.key], field); + if (result.message) return { ok: false, message: result.message }; + data[field.key] = result.value; + } + const tags = readTags(body.tags); + if (tags.message) return { ok: false, message: tags.message }; + data.tags = tags.tags; + + for (const [key, message] of [ + ['imageFileId', 'Ungültige Bild-ID.'], + ['previewFileId', 'Ungültige Vorschau-ID.'], + ['sourceCardId', MSG.badCardId], + ]) { + if (!isId(body[key])) return { ok: false, message }; + data[key] = body[key]; + } + if (data.imageFileId === data.previewFileId) { + return { ok: false, message: 'Bild und Vorschau müssen verschiedene Dateien sein.' }; + } + return { ok: true, data }; +} + +/** `title issuer tags` klein mit Leerzeichen, höchstens 300 Zeichen (Volltext-Index der Galerie). */ +function buildSearchText({ title = '', issuer = '', tags = [] }) { + const tagList = Array.isArray(tags) ? tags : String(tags).split(','); + return [title, issuer, ...tagList] + .map((part) => String(part).trim()) + .filter((part) => part !== '') + .join(' ') + .toLowerCase() + .slice(0, 300); +} + +// ---- 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, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + ...headers, + }); + res.end(body); +} + +const JSON_CONTENT_TYPE = /^application\/json\s*(;|$)/i; + +/** Liest den Rohbody mit Obergrenze; bei Überschreitung wird der Rest verworfen und die Verbindung geschlossen. */ +function readRawBody(req) { + return new Promise((resolve, reject) => { + const tooLarge = () => new HttpError(413, MSG.tooLarge, { Connection: 'close' }); + const declared = Number(req.headers['content-length']); + if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) { + req.resume(); + reject(tooLarge()); + return; + } + + const chunks = []; + let size = 0; + let settled = false; + const settle = (fn, value) => { + if (settled) return; + settled = true; + chunks.length = 0; + fn(value); + }; + + req.on('data', (chunk) => { + if (settled) return; // Rest verwerfen, Verbindung bleibt sauber + size += chunk.length; + if (size > MAX_BODY_BYTES) settle(reject, tooLarge()); + else chunks.push(chunk); + }); + req.on('end', () => { + if (settled) return; + const raw = Buffer.concat(chunks).toString('utf8'); + settle(resolve, raw); + }); + req.on('error', () => settle(reject, new HttpError(400, MSG.badRequest))); + req.on('close', () => settle(reject, new HttpError(400, MSG.badRequest))); + }); +} + +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(); + return async function withLock(key, fn) { + const previous = tails.get(key) || Promise.resolve(); + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => gate); + tails.set(key, tail); + await previous; + try { + return await fn(); + } finally { + release(); + if (tails.get(key) === tail) tails.delete(key); + } + }; +} + +function isDuplicate(err) { + return err instanceof AppwriteError && err.status === 409; +} + +// ---- Die App --------------------------------------------------------------------------------------- + +/** + * @param {object} options + * @param {object} options.appwrite Objekt aus createAppwrite + * @param {string} options.salt Salz für die Melder-Hashes + * @param {() => number} [options.now] Uhr in ms (für das Rate-Limit) + * @param {(line: string) => void} [options.log] bekommt fertige Zeilen mit Präfix [aircard-api] + * @returns {(req, res) => Promise} Handler für http.createServer, mit `rateLimitEntries()` für Tests + */ +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. + const say = (message) => log(`[aircard-api] ${new Date().toISOString()} ${message}`); + const withLock = createKeyedLock(); + + // ---- Rate-Limit je Melder-Hash (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports`) + const hits = new Map(); + let lastSweep = 0; + + function sweepRateLimit(t) { + if (t - lastSweep < RATE_SWEEP_MS) return; + lastSweep = t; + for (const [key, list] of hits) { + if (t - list[list.length - 1] >= RATE_WINDOW_MS) hits.delete(key); + } + } + + /** @returns {{allowed: boolean, retryAfter?: number}} zählt die Meldung mit, wenn erlaubt */ + function rateLimit(key) { + const t = now(); + sweepRateLimit(t); + const list = (hits.get(key) || []).filter((stamp) => t - stamp < RATE_WINDOW_MS); + if (list.length >= RATE_LIMIT) { + hits.set(key, list); + return { allowed: false, retryAfter: Math.max(1, Math.ceil((list[0] + RATE_WINDOW_MS - t) / 1000)) }; + } + list.push(t); + hits.delete(key); // neu einfügen: Reihenfolge = zuletzt benutzt + hits.set(key, list); + while (hits.size > RATE_MAX_KEYS) hits.delete(hits.keys().next().value); + return { allowed: true }; + } + + // ---- Anmeldung + async function authenticate(req) { + const jwt = req.headers['x-aircard-jwt']; + if (typeof jwt !== 'string' || jwt.length > 4096 || !JWT_CHARACTERS.test(jwt)) return null; + const account = await appwrite.accountFromJwt(jwt); + if (!account || !isId(account.$id)) return null; + return { id: account.$id, isAdmin: Array.isArray(account.labels) && account.labels.includes('admin') }; + } + + async function requireUser(req) { + const user = await authenticate(req); + if (!user) throw new HttpError(401, MSG.unauthorized); + return user; + } + + // ---- Dateien und Zeilen + function checkFile(file, userId) { + if (!file) throw new HttpError(400, MSG.fileMissing); + const permissions = Array.isArray(file.$permissions) ? file.$permissions : []; + if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) { + throw new HttpError(403, MSG.foreignFile); + } + if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType); + if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize); + } + + const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId); + + async function setFilePermissions(row, permissions) { + await Promise.all(fileIdsOf(row).map((fileId) => appwrite.updateFilePermissions(BUCKET, fileId, permissions))); + } + + /** Dateien zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */ + async function hideCard(id, row, extra = {}) { + await setFilePermissions(row, hiddenFilePermissions(row.ownerId)); + await appwrite.updateRow(TABLE_GALLERY, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId)); + } + + async function deleteReports(galleryId) { + for (let round = 0; round < REPORT_DELETE_ROUNDS; round += 1) { + const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [galleryId]), queryLimit(REPORT_LIST_LIMIT)]); + if (reports.length === 0) return; + for (const report of reports) await appwrite.deleteRow(TABLE_REPORTS, report.$id); + } + } + + /** reportCount = Anzahl Meldungszeilen der Karte; ab 3 wird ausgeblendet. */ + async function syncReports(id, row) { + const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [id]), queryLimit(REPORT_LIST_LIMIT)]); + const count = reports.length; + if (count >= HIDE_AT_REPORTS) { + await hideCard(id, row, { reportCount: count }); + say(`card ${id} hidden automatically (${count} reports)`); + } else if (row.reportCount !== count) { + await appwrite.updateRow(TABLE_GALLERY, id, { reportCount: count }); + } + } + + // ---- Endpunkte + async function publish({ req, raw }) { + const user = await requireUser(req); + const validation = validatePublish(parseJsonObject(raw)); + if (!validation.ok) throw new HttpError(400, validation.message); + const data = validation.data; + + const [image, preview] = await Promise.all([ + appwrite.getFile(BUCKET, data.imageFileId), + appwrite.getFile(BUCKET, data.previewFileId), + ]); + checkFile(image, user.id); + checkFile(preview, user.id); + + const id = galleryRowId(user.id, data.sourceCardId); + try { + await appwrite.createRow( + TABLE_GALLERY, + id, + { + title: data.title, + issuer: data.issuer, + tags: data.tags.join(','), + displayName: data.displayName, + imageFileId: data.imageFileId, + previewFileId: data.previewFileId, + sourceCardId: data.sourceCardId, + ownerId: user.id, + hidden: false, + reportCount: 0, + searchText: buildSearchText(data), + }, + visibleRowPermissions(), + ); + } catch (err) { + if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished); + throw err; + } + say(`card ${id} published`); + return { status: 201, body: { id } }; + } + + async function withdraw({ req, raw }) { + const user = await requireUser(req); + const body = parseJsonObject(raw); + if (!isId(body.id)) throw new HttpError(400, MSG.badCardId); + const id = body.id; + + return withLock(id, async () => { + const row = await appwrite.getRow(TABLE_GALLERY, id); + if (!row) throw new HttpError(404, MSG.cardGone); + 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. + for (const fileId of fileIdsOf(row)) await appwrite.deleteFile(BUCKET, fileId); + await deleteReports(id); + await appwrite.deleteRow(TABLE_GALLERY, id); + say(`card ${id} withdrawn${row.ownerId === user.id ? '' : ' by admin'}`); + return { status: 200, body: { ok: true } }; + }); + } + + async function reportCard({ req, raw }) { + const body = parseJsonObject(raw); + if (!isId(body.id)) throw new HttpError(400, MSG.badCardId); + if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason); + const id = body.id; + + const reporter = reporterHash(salt, clientIp(req)); + const limit = rateLimit(reporter); + if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) }); + + return withLock(id, async () => { + const row = await appwrite.getRow(TABLE_GALLERY, id); + if (!row || row.hidden === true) throw new HttpError(404, MSG.cardGone); + + let isNew = true; + try { + await appwrite.createRow(TABLE_REPORTS, reportRowId(id, reporter), { galleryId: 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(`card ${id} reported (${body.reason})`); + return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: 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.badCardId); + if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility); + const id = body.id; + + return withLock(id, async () => { + const row = await appwrite.getRow(TABLE_GALLERY, id); + if (!row) throw new HttpError(404, MSG.cardGone); + + if (body.sichtbar) { + // Dateien, dann Meldungen, Zeile zuletzt: sichtbar wird die Karte erst, wenn alles andere steht. + await setFilePermissions(row, visibleFilePermissions(row.ownerId)); + await deleteReports(id); + await appwrite.updateRow(TABLE_GALLERY, id, { hidden: false, reportCount: 0 }, visibleRowPermissions()); + say(`card ${id} made visible by admin`); + } else { + await hideCard(id, row); + say(`card ${id} hidden by admin`); + } + return { status: 200, body: { ok: true } }; + }); + } + + const ROUTES = new Map([ + ['GET /api/gesundheit', { name: 'gesundheit', run: async () => ({ status: 200, body: { ok: true } }) }], + ['POST /api/veroeffentlichen', { name: 'veroeffentlichen', json: true, run: publish }], + ['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 }], + ]); + + async function handle(req, res) { + const started = Date.now(); + const pathname = (req.url || '').split('?')[0]; + const route = ROUTES.get(`${req.method} ${pathname}`); + let status = 500; + + try { + if (!route) throw new HttpError(404, MSG.notFound); + + let raw = ''; + if (route.json) { + if (!JSON_CONTENT_TYPE.test(req.headers['content-type'] || '')) { + req.resume(); + throw new HttpError(415, MSG.jsonOnly); + } + raw = await readRawBody(req); + } + + const result = await route.run({ req, raw }); + status = result.status; + sendJson(res, result.status, result.body); + } catch (err) { + let headers = {}; + let message = MSG.internal; + if (err instanceof HttpError) { + status = err.status; + message = err.message; + headers = err.headers; + } else if (err instanceof AppwriteError) { + status = 502; + message = MSG.unavailable; + say(`appwrite error: status=${err.status} type=${String(err.type).replace(/[^\w.-]/g, '').slice(0, 60)}`); + } else { + status = 500; + say(`unexpected error: ${err && err.name ? String(err.name).replace(/[^\w.-]/g, '').slice(0, 60) : 'unknown'}`); + } + if (!res.headersSent) sendJson(res, status, { error: message }, headers); + } finally { + if (route && route.name !== 'gesundheit') say(`${req.method} /api/${route.name} -> ${status} (${Date.now() - started} ms)`); + } + } + + function handler(req, res) { + return handle(req, res).catch((err) => { + // handle() fängt alles selbst ab; das hier sichert nur gegen Fehler beim Senden. + say(`unexpected error: ${err && err.name ? String(err.name).replace(/[^\w.-]/g, '').slice(0, 60) : 'unknown'}`); + try { + res.destroy(); + } catch { + // schon geschlossen + } + }); + } + + handler.rateLimitEntries = () => hits.size; + return handler; +} + +module.exports = { + createApp, + visibleRowPermissions, + hiddenRowPermissions, + visibleFilePermissions, + hiddenFilePermissions, + reporterHash, + clientIp, + normalizeIp, + validatePublish, + buildSearchText, + galleryRowId, + reportRowId, +}; diff --git a/deploy/api/app.test.js b/deploy/api/app.test.js new file mode 100644 index 0000000..756ea5c --- /dev/null +++ b/deploy/api/app.test.js @@ -0,0 +1,947 @@ +'use strict'; + +// Tests für deploy/api/app.js: 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 } = require('./fake-appwrite.js'); +const { + createApp, + visibleRowPermissions, + hiddenRowPermissions, + visibleFilePermissions, + hiddenFilePermissions, + reporterHash, + clientIp, + normalizeIp, + validatePublish, + buildSearchText, + galleryRowId, + reportRowId, +} = require('./app.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 MIB5 = 5 * 1024 * 1024; + +// ---- 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 report = (id, ip, reason = 'spam') => post('/api/melden', { id, reason }, null, ip ? { 'X-Real-Ip': ip } : {}); + + return { fake, app, logs, clock, port, call, post, report }; +} + +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 && !('Transfer-Encoding' in requestHeaders)) { + 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(); + }); +} + +function publishBody(overrides = {}) { + return { + title: 'Mein Motiv', + issuer: 'Beispielbank', + tags: ['Blau', ' Neon '], + displayName: 'Kenso', + imageFileId: 'img1', + previewFileId: 'prev1', + sourceCardId: 'card1', + ...overrides, + }; +} + +function seedUploads(fake, owner = ME, { image = 'img1', preview = 'prev1' } = {}) { + fake.addFile({ id: image, owner }); + fake.addFile({ id: preview, owner, mimeType: 'image/webp' }); +} + +/** Legt eine veröffentlichte Karte direkt im Fake an (Zeile + zwei Dateien mit passenden Rechten). */ +function seedCard(fake, { id = 'card-1', owner = ME, hidden = false, reportCount = 0 } = {}) { + const imageFileId = `${id}-img`; + const previewFileId = `${id}-prev`; + fake.addFile({ id: imageFileId, owner }); + fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' }); + if (hidden) { + fake.file(imageFileId).$permissions = hiddenFilePermissions(owner); + fake.file(previewFileId).$permissions = hiddenFilePermissions(owner); + } + fake.addRow( + 'gallery', + id, + { + title: 'Karte', + issuer: '', + tags: '', + displayName: '', + imageFileId, + previewFileId, + sourceCardId: `src-${id}`, + ownerId: owner, + hidden, + reportCount, + searchText: 'karte', + }, + hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(), + ); + return { imageFileId, previewFileId }; +} + +const storageCalls = (fake) => fake.calls.filter((c) => c.path.startsWith('/v1/storage/')); +const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32); + +// ---- Reine Helfer ------------------------------------------------------------------------------ + +test('Rechte-Strings sind genau die aus der Spec', () => { + assert.deepEqual(visibleRowPermissions(), ['read("any")']); + assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']); + assert.deepEqual(visibleFilePermissions('abc'), ['read("any")', 'delete("user:abc")']); + assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")', 'delete("user:abc")']); +}); + +test('Rechte-Helfer lehnen unzulässige Nutzer-IDs ab (kein Einschleusen in Rechte-Strings)', () => { + for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) { + assert.throws(() => hiddenRowPermissions(bad)); + assert.throws(() => visibleFilePermissions(bad)); + assert.throws(() => hiddenFilePermissions(bad)); + } +}); + +test('Zeilen-IDs sind deterministisch: erste 32 Hex von sha256(a|b)', () => { + assert.equal(galleryRowId('u1', 'c1'), sha32('u1|c1')); + assert.equal(reportRowId('g1', 'hash1'), sha32('g1|hash1')); + assert.match(galleryRowId('u1', 'c1'), /^[0-9a-f]{32}$/); + assert.notEqual(galleryRowId('u1', 'c1'), galleryRowId('u2', 'c1')); +}); + +test('reporterHash ist HMAC-SHA256 (Hex) mit dem Salz; IPv6 wird auf /64 gekürzt', () => { + assert.equal(reporterHash(SALT, '203.0.113.7'), crypto.createHmac('sha256', SALT).update('203.0.113.7').digest('hex')); + assert.notEqual(reporterHash('anderes-salz', '203.0.113.7'), reporterHash(SALT, '203.0.113.7')); + assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:12:ffff:eeee:dddd:2')); + assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:13::1')); +}); + +test('normalizeIp: IPv4 bleibt, IPv4-gemappte IPv6 wird IPv4, sonst /64', () => { + assert.equal(normalizeIp('203.0.113.7'), '203.0.113.7'); + assert.equal(normalizeIp(' 203.0.113.7:5555 '), '203.0.113.7'); + assert.equal(normalizeIp('::ffff:203.0.113.7'), '203.0.113.7'); + assert.equal(normalizeIp('::ffff:cb00:7107'), '203.0.113.7'); + assert.equal(normalizeIp('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd:12::/64'); + assert.equal(normalizeIp('2001:db8::1'), '2001:db8:0:0::/64'); + assert.equal(normalizeIp('::1'), '0:0:0:0::/64'); + assert.equal(normalizeIp('[2001:db8:1:2::9]'), '2001:db8:1:2::/64'); + assert.equal(normalizeIp('fe80::1%eth0'), 'fe80:0:0:0::/64'); + assert.equal(normalizeIp('64:ff9b::192.0.2.33'), '64:ff9b:0:0::/64'); + assert.equal(normalizeIp('kein-ip'), 'kein-ip'); + assert.equal(normalizeIp(undefined), ''); +}); + +test('clientIp: x-real-ip, sonst erstes x-forwarded-for, sonst Socket', () => { + const socket = { remoteAddress: '10.0.0.9' }; + assert.equal(clientIp({ headers: { 'x-real-ip': '1.1.1.1', 'x-forwarded-for': '2.2.2.2' }, socket }), '1.1.1.1'); + assert.equal(clientIp({ headers: { 'x-forwarded-for': ' 2.2.2.2 , 3.3.3.3' }, socket }), '2.2.2.2'); + assert.equal(clientIp({ headers: {}, socket }), '10.0.0.9'); + assert.equal(clientIp({ headers: { 'x-real-ip': '' }, socket }), '10.0.0.9'); +}); + +test('buildSearchText: klein, mit Leerzeichen verbunden, höchstens 300 Zeichen', () => { + assert.equal(buildSearchText({ title: 'Mein MOTIV', issuer: 'Beispielbank', tags: ['blau', 'neon'] }), 'mein motiv beispielbank blau neon'); + assert.equal(buildSearchText({ title: 'A', issuer: '', tags: [] }), 'a'); + assert.equal(buildSearchText({ title: 'A', issuer: 'B', tags: 'x,y' }), 'a b x y'); + assert.equal(buildSearchText({ title: 'x'.repeat(400), issuer: '', tags: [] }).length, 300); +}); + +test('validatePublish: normalisiert Tags und liefert saubere Daten', () => { + const result = validatePublish(publishBody({ title: ' Mein Motiv ', tags: [' Blau ', 'NEON', '', 'blau'], issuer: undefined, displayName: null })); + assert.equal(result.ok, true); + assert.deepEqual(result.data, { + title: 'Mein Motiv', + issuer: '', + tags: ['blau', 'neon'], + displayName: '', + imageFileId: 'img1', + previewFileId: 'prev1', + sourceCardId: 'card1', + }); +}); + +test('validatePublish: Längen und Tags an den Grenzen', () => { + assert.equal(validatePublish(publishBody({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), displayName: 'z'.repeat(30) })).ok, true); + assert.equal(validatePublish(publishBody({ tags: ['a', 'b', 'c', 'd', 'e'] })).ok, true); + assert.equal(validatePublish(publishBody({ tags: ['x'.repeat(20)] })).ok, true); + assert.equal(validatePublish(publishBody({ tags: ['a', 'b', 'c', 'd', 'e', 'f'] })).ok, false); + assert.equal(validatePublish(publishBody({ tags: ['x'.repeat(21)] })).ok, false); + const bad = validatePublish(publishBody({ title: '' })); + assert.equal(bad.ok, false); + assert.equal(typeof bad.message, 'string'); +}); + +// ---- Gesundheit, Routing, Body ------------------------------------------------------------------ + +test('GET /api/gesundheit antwortet {ok:true} mit sicheren Kopfzeilen', async () => { + const { call } = await setup(); + const res = await call('GET', '/api/gesundheit?x=1'); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { ok: true }); + assert.match(res.headers['content-type'], /^application\/json/); + assert.equal(res.headers['cache-control'], 'no-store'); + assert.equal(res.headers['x-content-type-options'], 'nosniff'); +}); + +test('Alles andere ist 404, auch falsche Methoden', async () => { + const { call, fake } = await setup(); + for (const [method, path] of [ + ['GET', '/'], + ['GET', '/api'], + ['GET', '/api/melden'], + ['POST', '/api/gesundheit'], + ['POST', '/api/unbekannt'], + ['POST', '/api/melden/'], + ['POST', '/api/admin'], + ['PUT', '/api/melden'], + ['OPTIONS', '/api/melden'], + ['DELETE', '/api/zurueckziehen'], + ['GET', '/v1/account'], + ]) { + const res = await call(method, path, { body: {} }); + assert.equal(res.status, 404, `${method} ${path}`); + assert.equal(typeof res.json.error, 'string'); + } + assert.equal(fake.calls.length, 0); +}); + +test('Nur application/json: sonst 415 (auch mit Charset-Zusatz erlaubt)', async () => { + const { call, fake } = await setup(); + seedCard(fake); + for (const contentType of ['text/plain', 'application/x-www-form-urlencoded', 'application/jsonx', null]) { + const res = await call('POST', '/api/melden', { raw: '{"id":"card-1","reason":"spam"}', contentType }); + assert.equal(res.status, 415, String(contentType)); + } + const ok = await call('POST', '/api/melden', { + raw: '{"id":"card-1","reason":"spam"}', + contentType: 'application/json; charset=utf-8', + headers: { 'X-Real-Ip': '203.0.113.1' }, + }); + assert.equal(ok.status, 200); +}); + +test('Body über 8 KB: 413 (mit Content-Length und chunked), genau 8 KB geht noch durch', async () => { + const { call, fake } = await setup(); + const padded = (length) => { + const base = '{"id":"nope","reason":"spam"}'; + return base + ' '.repeat(length - Buffer.byteLength(base)); + }; + + const declared = await call('POST', '/api/melden', { raw: padded(8193) }); + assert.equal(declared.status, 413); + assert.equal(typeof declared.json.error, 'string'); + assert.equal(declared.headers.connection, 'close', 'nach einem zu großen Body wird die Verbindung geschlossen'); + + const chunked = await call('POST', '/api/veroeffentlichen', { + raw: padded(9000), + headers: { 'Transfer-Encoding': 'chunked', 'X-Aircard-Jwt': JWT_ME }, + }); + assert.equal(chunked.status, 413); + + const exact = await call('POST', '/api/melden', { raw: padded(8192), headers: { 'X-Real-Ip': '203.0.113.2' } }); + assert.equal(exact.status, 404, 'genau 8192 Byte werden verarbeitet (Karte existiert nicht)'); + assert.equal(fake.calls.filter((c) => c.path.includes('/account')).length, 0, 'bei 413 wurde nichts bei Appwrite abgefragt'); +}); + +test('Kaputtes JSON, Array oder leerer Body: 400', async () => { + const { call } = await setup(); + for (const raw of ['{kaputt', '[]', '"text"', 'null', '']) { + const res = await call('POST', '/api/melden', { raw }); + assert.equal(res.status, 400, JSON.stringify(raw)); + assert.equal(typeof res.json.error, 'string'); + } +}); + +// ---- Veröffentlichen -------------------------------------------------------------------------------- + +test('Veröffentlichen: legt eine sichtbare Zeile an (Rechte, searchText, ownerId)', async () => { + const { post, fake } = await setup(); + seedUploads(fake); + + const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); + assert.equal(res.status, 201); + assert.equal(res.json.id, galleryRowId(ME, 'card1')); + + const row = fake.row('gallery', res.json.id); + assert.deepEqual(row.$permissions, ['read("any")']); + assert.equal(row.title, 'Mein Motiv'); + assert.equal(row.issuer, 'Beispielbank'); + assert.equal(row.tags, 'blau,neon'); + assert.equal(row.displayName, 'Kenso'); + assert.equal(row.imageFileId, 'img1'); + assert.equal(row.previewFileId, 'prev1'); + assert.equal(row.sourceCardId, 'card1'); + assert.equal(row.ownerId, ME); + assert.equal(row.hidden, false); + assert.equal(row.reportCount, 0); + assert.equal(row.searchText, 'mein motiv beispielbank blau neon'); + // der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf + const accountCall = fake.calls.find((c) => c.path === '/v1/account'); + assert.equal(accountCall.headers['x-appwrite-jwt'], JWT_ME); + assert.equal(accountCall.headers['x-appwrite-key'], undefined); + for (const c of fake.calls.filter((x) => x.path !== '/v1/account')) { + assert.equal(c.headers['x-appwrite-key'], fake.key); + assert.equal(c.headers['x-appwrite-jwt'], undefined); + } +}); + +test('Veröffentlichen: Bild und Vorschau dürfen PNG oder WebP sein, Anzeigename und Herausgeber sind optional', async () => { + const { post, fake } = await setup(); + fake.addFile({ id: 'img1', owner: ME, mimeType: 'image/png', size: MIB5 }); + fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/png' }); + const res = await post('/api/veroeffentlichen', { title: 'Nur Titel', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1' }, JWT_ME); + assert.equal(res.status, 201); + const row = fake.row('gallery', res.json.id); + assert.equal(row.issuer, ''); + assert.equal(row.tags, ''); + assert.equal(row.displayName, ''); +}); + +test('Veröffentlichen ohne oder mit ungültigem JWT: 401 und nichts angelegt', async () => { + const { post, fake } = await setup(); + seedUploads(fake); + + assert.equal((await post('/api/veroeffentlichen', publishBody())).status, 401); + assert.equal((await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz')).status, 401); + assert.equal((await post('/api/veroeffentlichen', publishBody(), 'zwei teile')).status, 401); + assert.equal((await post('/api/veroeffentlichen', {}, '')).status, 401); + assert.equal(fake.rows('gallery').length, 0); + assert.equal(storageCalls(fake).length, 0); + assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst'); +}); + +test('Veröffentlichen mit fremder Datei: 403 und nichts angelegt', async () => { + const { post, fake } = await setup(); + fake.addFile({ id: 'img1', owner: OTHER }); + fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' }); + let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); + assert.equal(res.status, 403); + assert.equal(typeof res.json.error, 'string'); + + fake.addFile({ id: 'img2', owner: ME }); + fake.addFile({ id: 'prev2', owner: OTHER, mimeType: 'image/webp' }); + res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME); + assert.equal(res.status, 403); + + // Rechte ohne delete("user:me"): nur lesen reicht nicht + fake.addFile({ id: 'img3', owner: ME, permissions: ['read("any")', 'read("user:user-me")'] }); + fake.addFile({ id: 'prev3', owner: ME, mimeType: 'image/webp' }); + res = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img3', previewFileId: 'prev3' }), JWT_ME); + assert.equal(res.status, 403); + + assert.equal(fake.rows('gallery').length, 0); +}); + +test('Veröffentlichen mit Datei in falschem Bucket: 403', async () => { + const { post, fake } = await setup(); + fake.addFile({ id: 'img1', owner: ME, bucket: 'card-images' }); + fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' }); + const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); + assert.equal(res.status, 403); + assert.equal(fake.rows('gallery').length, 0); +}); + +test('Veröffentlichen mit fehlender Datei: 400 "Bild nicht gefunden."', async () => { + const { post, fake } = await setup(); + fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' }); + let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); + assert.equal(res.status, 400); + assert.equal(res.json.error, 'Bild nicht gefunden.'); + + fake.addFile({ id: 'img1', owner: ME }); + res = await post('/api/veroeffentlichen', publishBody({ previewFileId: 'ghost' }), JWT_ME); + assert.equal(res.status, 400); + assert.equal(res.json.error, 'Bild nicht gefunden.'); + assert.equal(fake.rows('gallery').length, 0); +}); + +test('Veröffentlichen: nur PNG/WebP bis 5 MiB, sonst 400', async () => { + const { post, fake } = await setup(); + const attempt = (image, preview) => { + fake.addFile({ id: 'img1', owner: ME, ...image }); + fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', ...preview }); + return post('/api/veroeffentlichen', publishBody(), JWT_ME); + }; + + assert.equal((await attempt({ mimeType: 'image/jpeg' }, {})).status, 400); + assert.equal((await attempt({ mimeType: 'application/pdf' }, {})).status, 400); + assert.equal((await attempt({}, { mimeType: 'image/svg+xml' })).status, 400); + assert.equal((await attempt({ size: MIB5 + 1 }, {})).status, 400); + assert.equal((await attempt({}, { size: MIB5 + 1 })).status, 400); + assert.equal(fake.rows('gallery').length, 0); + + assert.equal((await attempt({ size: MIB5 }, { size: MIB5 })).status, 201); +}); + +test('Veröffentlichen doppelt: 409 "Diese Karte ist schon veröffentlicht."', async () => { + const { post, fake } = await setup(); + seedUploads(fake); + assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201); + + fake.addFile({ id: 'img2', owner: ME }); + fake.addFile({ id: 'prev2', owner: ME, mimeType: 'image/webp' }); + const again = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2' }), JWT_ME); + assert.equal(again.status, 409); + assert.equal(again.json.error, 'Diese Karte ist schon veröffentlicht.'); + assert.equal(fake.rows('gallery').length, 1); + + // eine andere Person darf dieselbe sourceCardId-Zeichenfolge haben (Zeilen-ID enthält den Besitzer) + seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9' }); + const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9' }), JWT_OTHER); + assert.equal(other.status, 201); + assert.notEqual(other.json.id, galleryRowId(ME, 'card1')); + assert.equal(fake.rows('gallery').length, 2); +}); + +test('Veröffentlichen nach Zurückziehen ist wieder möglich (gleiche Zeilen-ID)', async () => { + const { post, fake } = await setup(); + seedUploads(fake); + const first = await post('/api/veroeffentlichen', publishBody(), JWT_ME); + assert.equal((await post('/api/zurueckziehen', { id: first.json.id }, JWT_ME)).status, 200); + seedUploads(fake); + const second = await post('/api/veroeffentlichen', publishBody(), JWT_ME); + assert.equal(second.status, 201); + assert.equal(second.json.id, first.json.id); +}); + +test('Veröffentlichen: Validierung liefert 400 und fasst Appwrite (Dateien, Zeilen) nicht an', async () => { + const { post, fake } = await setup(); + seedUploads(fake); + const cases = [ + ['leerer Titel', { title: '' }, /Titel/], + ['nur Leerzeichen als Titel', { title: ' ' }, /Titel/], + ['Titel fehlt', { title: undefined }, /Titel/], + ['Titel zu lang', { title: 'x'.repeat(61) }, /Titel/], + ['Titel keine Zeichenkette', { title: 42 }, /Titel/], + ['Titel mit Steuerzeichen', { title: 'a\nb' }, /Titel/], + ['Herausgeber zu lang', { issuer: 'x'.repeat(41) }, /Herausgeber/], + ['Anzeigename zu lang', { displayName: 'x'.repeat(31) }, /Anzeigename/], + ['6 Tags', { tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, /Tags/], + ['Tag zu lang', { tags: ['x'.repeat(21)] }, /Tag/], + ['Tags als Zeichenkette', { tags: 'a,b' }, /Tags/], + ['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/], + ['Tag mit Komma', { tags: ['a,b'] }, /Komma/], + ['Bild-ID mit ../', { imageFileId: '../x' }, /ID/], + ['Bild-ID ..', { imageFileId: '..' }, /ID/], + ['Bild-ID Zahl', { imageFileId: 123 }, /ID/], + ['Bild-ID mit Schrägstrich', { imageFileId: 'a/b' }, /ID/], + ['Bild-ID zu lang', { imageFileId: 'a'.repeat(37) }, /ID/], + ['Bild-ID beginnt mit Punkt', { imageFileId: '.hidden' }, /ID/], + ['Bild-ID leer', { imageFileId: '' }, /ID/], + ['Vorschau-ID null', { previewFileId: null }, /ID/], + ['Vorschau-ID mit Leerzeichen', { previewFileId: 'a b' }, /ID/], + ['sourceCardId ../x', { sourceCardId: '../x' }, /ID/], + ['sourceCardId Zahl', { sourceCardId: 7 }, /ID/], + ['sourceCardId fehlt', { sourceCardId: undefined }, /ID/], + ['Bild = Vorschau', { previewFileId: 'img1' }, /verschieden/], + ]; + for (const [label, overrides, pattern] of cases) { + const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME); + assert.equal(res.status, 400, label); + assert.match(res.json.error, pattern, label); + } + assert.equal(fake.rows('gallery').length, 0); + assert.equal(storageCalls(fake).length, 0, 'ungültige IDs erreichen Appwrite nie'); + assert.equal((await post('/api/veroeffentlichen', publishBody({ tags: 'a,b' }), JWT_ME)).status, 400); +}); + +test('Veröffentlichen: Tags werden serverseitig erneut normalisiert', async () => { + const { post, fake } = await setup(); + seedUploads(fake); + const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME); + assert.equal(res.status, 201); + assert.equal(fake.row('gallery', res.json.id).tags, 'blau,neon'); +}); + +test('Veröffentlichen: Appwrite-Ausfall ergibt 502 mit deutschem Text, nichts angelegt', async () => { + const { post, fake } = await setup(); + seedUploads(fake); + fake.failNext((c) => c.path === '/v1/account', { status: 500 }); + let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); + assert.equal(res.status, 502); + assert.equal(res.json.error, 'Der Dienst ist gerade nicht erreichbar.'); + + fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/rows'), { status: 500 }); + res = await post('/api/veroeffentlichen', publishBody(), JWT_ME); + assert.equal(res.status, 502); + assert.equal(fake.rows('gallery').length, 0); +}); + +// ---- Melden --------------------------------------------------------------------------------------- + +test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirechte)', async () => { + const { report, fake } = await setup(); + const { imageFileId, previewFileId } = seedCard(fake); + + let res = await report('card-1', '203.0.113.1'); + assert.deepEqual(res.json, { ok: true }); + assert.equal(fake.row('gallery', 'card-1').reportCount, 1); + assert.equal(fake.row('gallery', 'card-1').hidden, false); + res = await report('card-1', '203.0.113.2', 'urheberrecht'); + assert.deepEqual(res.json, { ok: true }); + assert.equal(fake.row('gallery', 'card-1').reportCount, 2); + assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']); + + res = await report('card-1', '203.0.113.3', 'kartendaten'); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { ok: true }); + + const row = fake.row('gallery', 'card-1'); + assert.equal(row.hidden, true); + assert.equal(row.reportCount, 3); + assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']); + const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")']; + assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile); + assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile); + assert.equal(fake.rows('reports').length, 3); + + // ausgeblendete Karte melden: 404 + res = await report('card-1', '203.0.113.4'); + assert.equal(res.status, 404); + assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.'); + assert.equal(fake.rows('reports').length, 3); +}); + +test('Melden: zwei Melder reichen nicht, die Meldungen speichern nur Hash, Grund und Karte', async () => { + const { report, fake } = await setup(); + seedCard(fake); + await report('card-1', '203.0.113.1', 'spam'); + await report('card-1', '203.0.113.2', 'sonstiges'); + assert.equal(fake.row('gallery', 'card-1').hidden, false); + + const hash = reporterHash(SALT, '203.0.113.1'); + const stored = fake.row('reports', reportRowId('card-1', hash)); + assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)'); + assert.equal(stored.galleryId, 'card-1'); + assert.equal(stored.reason, 'spam'); + assert.equal(stored.reporterHash, hash); + assert.deepEqual(stored.$permissions, []); + assert.ok(!JSON.stringify(fake.rows('reports')).includes('203.0.113'), 'keine IP im Klartext gespeichert'); +}); + +test('Melden: dieselbe IP fünfmal zählt einmal, Antworten sind schonGemeldet', async () => { + const { report, fake } = await setup(); + seedCard(fake); + + const first = await report('card-1', '203.0.113.9'); + assert.deepEqual(first.json, { ok: true }); + for (let i = 0; i < 4; i += 1) { + const again = await report('card-1', '203.0.113.9', i % 2 ? 'spam' : 'unangemessen'); + assert.equal(again.status, 200); + assert.deepEqual(again.json, { schonGemeldet: true }); + } + assert.equal(fake.row('gallery', 'card-1').reportCount, 1); + assert.equal(fake.row('gallery', 'card-1').hidden, false); + assert.equal(fake.rows('reports').length, 1); +}); + +test('Melden: Rate-Limit 10 pro Stunde und IP, danach 429, nach einer Stunde wieder erlaubt', async () => { + const { report, fake, clock } = await setup(); + seedCard(fake); + + for (let i = 0; i < 10; i += 1) { + const res = await report('card-1', '203.0.113.50'); + assert.equal(res.status, 200, `Meldung ${i + 1}`); + } + let res = await report('card-1', '203.0.113.50'); + assert.equal(res.status, 429); + assert.equal(res.json.error, 'Zu viele Meldungen – bitte später nochmal.'); + assert.equal(res.headers['retry-after'], '3600'); + + // eine andere IP ist nicht betroffen + assert.equal((await report('card-1', '203.0.113.51')).status, 200); + + // knapp vor Ablauf noch gesperrt, nach einer Stunde wieder frei + clock.value += 3600_000 - 1; + assert.equal((await report('card-1', '203.0.113.50')).status, 429); + clock.value += 1; + res = await report('card-1', '203.0.113.50'); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { schonGemeldet: true }); +}); + +test('Melden: abgelaufene Rate-Limit-Einträge werden entfernt', async () => { + const { report, fake, clock, app } = await setup(); + seedCard(fake); + for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) await report('card-1', ip); + assert.equal(app.rateLimitEntries(), 3); + + clock.value += 3600_000 + 60_000; + await report('card-1', '203.0.113.4'); + assert.equal(app.rateLimitEntries(), 1, 'die drei alten Einträge sind weg, nur der neue bleibt'); +}); + +test('Melden: Ungültige Anfragen (Grund, ID) zählen nicht zum Limit und erreichen Appwrite nicht', async () => { + const { report, post, fake, app } = await setup(); + seedCard(fake); + for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) { + const res = await post('/api/melden', { id: 'card-1', reason }, null, { 'X-Real-Ip': '203.0.113.1' }); + assert.equal(res.status, 400, String(reason)); + assert.equal(typeof res.json.error, 'string'); + } + for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) { + const res = await post('/api/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.113.1' }); + assert.equal(res.status, 400, String(id)); + } + assert.equal(fake.calls.length, 0); + assert.equal(app.rateLimitEntries(), 0); + assert.equal((await report('card-1', '203.0.113.1')).status, 200); +}); + +test('Melden: nicht vorhandene Karte ist 404', async () => { + const { report, fake } = await setup(); + const res = await report('gibts-nicht', '203.0.113.1'); + assert.equal(res.status, 404); + assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.'); + assert.equal(fake.rows('reports').length, 0); +}); + +test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spam, sonstiges', async () => { + const { report, fake } = await setup(); + seedCard(fake); + const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']; + for (let i = 0; i < reasons.length; i += 1) { + const res = await report('card-1', `198.51.100.${i + 1}`, reasons[i]); + assert.equal(res.status, 200, reasons[i]); + if (i === 2) break; // ab 3 Meldern ist die Karte weg + } + const stored = fake.rows('reports').map((r) => r.reason).sort(); + assert.deepEqual(stored, ['kartendaten', 'unangemessen', 'urheberrecht']); +}); + +test('Melden: IPv6-Adressen aus demselben /64 zählen als ein Melder', async () => { + const { report, fake } = await setup(); + seedCard(fake); + assert.deepEqual((await report('card-1', '2001:db8:abcd:12::1')).json, { ok: true }); + assert.deepEqual((await report('card-1', '2001:db8:abcd:12:aaaa:bbbb:cccc:dddd')).json, { schonGemeldet: true }); + assert.deepEqual((await report('card-1', '2001:db8:abcd:13::1')).json, { ok: true }); + assert.equal(fake.row('gallery', 'card-1').reportCount, 2); +}); + +test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Adresse', async () => { + const { post, fake } = await setup(); + seedCard(fake); + const viaForwarded = (ip) => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, { 'X-Forwarded-For': `${ip}, 10.0.0.1` }); + assert.deepEqual((await viaForwarded('203.0.113.77')).json, { ok: true }); + assert.deepEqual((await viaForwarded('203.0.113.77')).json, { schonGemeldet: true }); + assert.deepEqual((await viaForwarded('203.0.113.78')).json, { ok: true }); + // ohne beide Header zählt die Socket-Adresse (127.0.0.1) als dritter Melder + assert.deepEqual((await post('/api/melden', { id: 'card-1', reason: 'spam' })).json, { ok: true }); + assert.equal(fake.row('gallery', 'card-1').hidden, true, 'drei verschiedene Quellen'); +}); + +test('Melden: gleichzeitige Meldungen von drei IPs ergeben genau drei und blenden aus', async () => { + const { report, fake } = await setup(); + seedCard(fake); + const results = await Promise.all([report('card-1', '203.0.113.1'), report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]); + assert.deepEqual(results.map((r) => r.status), [200, 200, 200]); + const row = fake.row('gallery', 'card-1'); + assert.equal(row.reportCount, 3); + assert.equal(row.hidden, true); + assert.equal(fake.rows('reports').length, 3); +}); + +test('Melden: eine verspätete Zählerstand-Änderung überschreibt neuere Stände nicht (Sperre je Karte)', async () => { + const { report, fake } = await setup(); + seedCard(fake); + // Die erste Meldung schreibt ihren Zähler (1) erst nach 150 ms; währenddessen kommen zwei weitere. + fake.delayNext((c) => c.method === 'PATCH' && c.body && c.body.data && c.body.data.reportCount === 1, 150); + const first = report('card-1', '203.0.113.1'); + await new Promise((resolve) => setTimeout(resolve, 40)); + const others = await Promise.all([report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]); + const firstResult = await first; + assert.deepEqual([firstResult.status, ...others.map((r) => r.status)], [200, 200, 200]); + + const row = fake.row('gallery', 'card-1'); + assert.equal(row.reportCount, 3); + assert.equal(row.hidden, true); + assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']); +}); + +test('Melden: eine doppelte Meldung heilt einen früher steckengebliebenen Zählerstand', async () => { + const { report, fake } = await setup(); + seedCard(fake); + // drei Meldungen liegen schon vor, der Zähler an der Karte kam aber nie an (z. B. Ausfall mitten im Ablauf) + for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) { + const hash = reporterHash(SALT, ip); + fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []); + } + assert.equal(fake.row('gallery', 'card-1').hidden, false); + const res = await report('card-1', '203.0.113.1'); + assert.deepEqual(res.json, { schonGemeldet: true }); + assert.equal(fake.row('gallery', 'card-1').hidden, true); + assert.equal(fake.row('gallery', 'card-1').reportCount, 3); +}); + +test('Melden: Ausfall beim Anlegen der Meldung ergibt 502 und zählt nicht', async () => { + const { report, fake } = await setup(); + seedCard(fake); + fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/reports/rows'), { status: 500 }); + const res = await report('card-1', '203.0.113.1'); + assert.equal(res.status, 502); + assert.equal(fake.row('gallery', 'card-1').reportCount, 0); + assert.equal(fake.rows('reports').length, 0); +}); + +test('Melden: Ausfall beim Ausblenden endet mit 502, ein erneuter Versuch blendet aus', async () => { + const { report, fake } = await setup(); + seedCard(fake, { reportCount: 0 }); + await report('card-1', '203.0.113.1'); + await report('card-1', '203.0.113.2'); + fake.failNext((c) => c.method === 'PATCH' && c.path.includes('/tables/gallery/rows/'), { status: 500 }); + const res = await report('card-1', '203.0.113.3'); + assert.equal(res.status, 502); + assert.equal(fake.row('gallery', 'card-1').hidden, false); + const retry = await report('card-1', '203.0.113.3'); + assert.equal(retry.status, 200); + assert.equal(fake.row('gallery', 'card-1').hidden, true); +}); + +// ---- Zurückziehen ---------------------------------------------------------------------------------- + +test('Zurückziehen durch den Autor löscht Zeile, beide Dateien und die Meldungen', async () => { + const { post, fake } = await setup(); + const { imageFileId, previewFileId } = seedCard(fake); + seedCard(fake, { id: 'card-2' }); + for (const [id, hash] of [['card-1', 'h1'], ['card-1', 'h2'], ['card-2', 'h3']]) { + fake.addRow('reports', reportRowId(id, hash), { galleryId: id, reason: 'spam', reporterHash: hash }, []); + } + + const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { ok: true }); + assert.equal(fake.row('gallery', 'card-1'), null); + assert.equal(fake.file(imageFileId), null); + assert.equal(fake.file(previewFileId), null); + assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']); + assert.ok(fake.row('gallery', 'card-2'), 'andere Karten bleiben'); + assert.ok(fake.file('card-2-img')); +}); + +test('Zurückziehen durch Admin geht, auch bei ausgeblendeter Karte', async () => { + const { post, fake } = await setup(); + seedCard(fake, { hidden: true, reportCount: 3 }); + const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ADMIN); + assert.equal(res.status, 200); + assert.equal(fake.row('gallery', 'card-1'), null); + assert.equal(fake.file('card-1-img'), null); +}); + +test('Zurückziehen durch Fremde: 403 und nichts gelöscht', async () => { + const { post, fake } = await setup(); + seedCard(fake); + const res = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_OTHER); + assert.equal(res.status, 403); + assert.equal(typeof res.json.error, 'string'); + assert.ok(fake.row('gallery', 'card-1')); + assert.ok(fake.file('card-1-img')); + assert.ok(fake.file('card-1-prev')); +}); + +test('Zurückziehen: 401 ohne JWT, 404 bei fehlender Karte, 400 bei ungültiger ID', async () => { + const { post, fake } = await setup(); + seedCard(fake); + assert.equal((await post('/api/zurueckziehen', { id: 'card-1' })).status, 401); + assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, 'xxx.yyy.zzz')).status, 401); + assert.equal((await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME)).status, 404); + for (const id of ['../x', '..', 5, null, undefined]) { + assert.equal((await post('/api/zurueckziehen', { id }, JWT_ME)).status, 400, String(id)); + } + assert.ok(fake.row('gallery', 'card-1')); +}); + +test('Zurückziehen ist wiederholbar, wenn schon eine Datei fehlt (Wiederholung nach Teilausfall)', async () => { + const { post, fake } = await setup(); + seedCard(fake); + fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/gallery/rows/'), { status: 500 }); + assert.equal((await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME)).status, 502); + assert.ok(fake.row('gallery', 'card-1'), 'Zeile bleibt, bis alles andere weg ist'); + assert.equal(fake.file('card-1-img'), null); + const retry = await post('/api/zurueckziehen', { id: 'card-1' }, JWT_ME); + assert.equal(retry.status, 200); + assert.equal(fake.row('gallery', 'card-1'), null); +}); + +// ---- Admin ------------------------------------------------------------------------------------------ + +test('Admin-Sichtbarkeit: Nicht-Admin 403, ohne JWT 401', async () => { + const { post, fake } = await setup(); + seedCard(fake); + assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ME)).status, 403); + assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_OTHER)).status, 403); + assert.equal((await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false })).status, 401); + assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']); + assert.equal(fake.row('gallery', 'card-1').hidden, false); +}); + +test('Admin blendet aus: Zeilen- und Dateirechte, hidden=true, Meldungen bleiben', async () => { + const { post, fake } = await setup(); + seedCard(fake, { reportCount: 1 }); + fake.addRow('reports', reportRowId('card-1', 'h1'), { galleryId: 'card-1', reason: 'spam', reporterHash: 'h1' }, []); + + const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN); + assert.equal(res.status, 200); + const row = fake.row('gallery', 'card-1'); + assert.equal(row.hidden, true); + assert.equal(row.reportCount, 1); + assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']); + const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")']; + assert.deepEqual(fake.file('card-1-img').$permissions, hiddenFile); + assert.deepEqual(fake.file('card-1-prev').$permissions, hiddenFile); + assert.equal(fake.rows('reports').length, 1); +}); + +test('Admin blendet ein: sichtbare Rechte, hidden=false, reportCount=0, Meldungen gelöscht', async () => { + const { post, fake, report } = await setup(); + seedCard(fake, { hidden: true, reportCount: 3 }); + seedCard(fake, { id: 'card-2' }); + for (const [id, hash] of [['card-1', 'h1'], ['card-1', 'h2'], ['card-1', 'h3'], ['card-2', 'h4']]) { + fake.addRow('reports', reportRowId(id, hash), { galleryId: id, reason: 'spam', reporterHash: hash }, []); + } + + const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: true }, JWT_ADMIN); + assert.equal(res.status, 200); + const row = fake.row('gallery', 'card-1'); + assert.equal(row.hidden, false); + assert.equal(row.reportCount, 0); + assert.deepEqual(row.$permissions, ['read("any")']); + assert.deepEqual(fake.file('card-1-img').$permissions, ['read("any")', 'delete("user:user-me")']); + assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']); + assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']); + + // die Karte ist wieder meldbar + assert.equal((await report('card-1', '203.0.113.1')).status, 200); +}); + +test('Admin-Sichtbarkeit: Body-Prüfung und fehlende Karte', async () => { + const { post, fake } = await setup(); + seedCard(fake); + for (const body of [ + { id: 'card-1' }, + { id: 'card-1', sichtbar: 'ja' }, + { id: 'card-1', sichtbar: 1 }, + { id: '../x', sichtbar: true }, + { id: 7, sichtbar: true }, + {}, + ]) { + assert.equal((await post('/api/admin/sichtbarkeit', body, JWT_ADMIN)).status, 400, JSON.stringify(body)); + } + assert.equal((await post('/api/admin/sichtbarkeit', { id: 'nope', sichtbar: true }, JWT_ADMIN)).status, 404); +}); + +// ---- Logs und Fehler -------------------------------------------------------------------------------- + +test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [aircard-api]', async () => { + const { post, report, fake, logs } = await setup(); + seedUploads(fake); + seedCard(fake, { id: 'card-9' }); + await post('/api/veroeffentlichen', publishBody(), JWT_ME); + await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz'); + await report('card-9', '203.0.113.7'); + await report('card-9', '203.0.113.8'); + await report('card-9', '203.0.113.9'); + await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN); + fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 }); + await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME); + await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME); + + assert.ok(logs.length > 0); + const all = logs.join('\n'); + for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.113', '127.0.0.1', reporterHash(SALT, '203.0.113.7')]) { + 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('Unerwartete Fehler im Appwrite-Objekt ergeben 500, ohne die Anfrage hängen zu lassen', async () => { + const logs = []; + const broken = { + accountFromJwt: async () => { + throw new Error('kaputt GEHEIM'); + }, + }; + const app = createApp({ appwrite: broken, salt: SALT, log: (line) => logs.push(line) }); + 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 }, + }); + assert.equal(res.status, 500); + assert.equal(typeof res.json.error, 'string'); + assert.ok(!logs.join('\n').includes('GEHEIM'), 'Fehlertexte werden nicht ins Log übernommen'); +}); + +test('createApp verlangt Appwrite-Objekt und Salz', () => { + assert.throws(() => createApp({ salt: SALT })); + assert.throws(() => createApp({ appwrite: {}, salt: '' })); + assert.throws(() => createApp({ appwrite: {} })); +}); diff --git a/deploy/api/appwrite.js b/deploy/api/appwrite.js new file mode 100644 index 0000000..fe1cbfc --- /dev/null +++ b/deploy/api/appwrite.js @@ -0,0 +1,158 @@ +'use strict'; + +/** + * Schmaler REST-Client für Appwrite 1.8 (Node 22, nur Bordmittel) mit dem Server-Key des API-Dienstes. + * + * createAppwrite({ endpoint, project, key, fetchImpl?, database?, timeoutMs? }) + * getRow(table, id) Zeile oder null (404) + * listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit) + * 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 (wiederholbar) + * getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null + * updateFilePermissions(bucket, id, permissions) Datei oder null (404) + * deleteFile(bucket, id) true, false bei 404 + * accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key + * + * Jeder Aufruf bricht nach `timeoutMs` (10 s) ab. Jedes Pfadsegment wird mit encodeURIComponent kodiert; + * "." und ".." lehnt der Client ab, weil encodeURIComponent sie nicht verändert und die URL-Normalisierung + * sie sonst als Verzeichniswechsel auslegen würde. + * + * Fehler: AppwriteError mit `status` (0 = keine Antwort), `type` (Appwrite-Typ, "timeout" oder "unreachable"). + * Der Text enthält nie Key oder JWT. + */ + +const DEFAULT_TIMEOUT_MS = 10_000; + +class AppwriteError extends Error { + constructor(status, type, message) { + super(message); + this.name = 'AppwriteError'; + this.status = status; + this.type = type; + } +} + +/** Appwrite-Abfrage als JSON-String. */ +function queryEqual(attribute, values) { + return JSON.stringify({ method: 'equal', attribute, values }); +} + +function queryLimit(count) { + return JSON.stringify({ method: 'limit', values: [count] }); +} + +/** Ein Pfadsegment: nur nichtleere Zeichenketten, nie "." oder "..", immer kodiert. */ +function segment(value) { + if (typeof value !== 'string' || value === '' || value === '.' || value === '..') { + throw new TypeError('Ungültiges Pfadsegment'); + } + return encodeURIComponent(value); +} + +function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, database = 'aircard', timeoutMs = DEFAULT_TIMEOUT_MS }) { + if (typeof endpoint !== 'string' || endpoint === '') throw new TypeError('endpoint fehlt'); + if (typeof project !== 'string' || project === '') throw new TypeError('project fehlt'); + if (typeof key !== 'string' || key === '') throw new TypeError('key fehlt'); + if (typeof fetchImpl !== 'function') throw new TypeError('fetch fehlt'); + + 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)}`; + + /** Ein HTTP-Aufruf; liefert { status, body } für 2xx und 404, sonst wirft er. */ + async function send(method, path, { body, headers = {}, withKey = true, tolerate = [] } = {}) { + const requestHeaders = { 'X-Appwrite-Project': project, ...headers }; + if (withKey) requestHeaders['X-Appwrite-Key'] = key; + const init = { method, headers: requestHeaders, signal: AbortSignal.timeout(timeoutMs) }; + if (body !== undefined) { + requestHeaders['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + let response; + let text; + try { + response = await fetchImpl(`${base}${path}`, init); + text = await response.text(); + } catch (err) { + // Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten). + const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError'); + throw timedOut + ? new AppwriteError(0, 'timeout', 'Appwrite antwortet nicht rechtzeitig') + : new AppwriteError(0, 'unreachable', 'Appwrite nicht erreichbar'); + } + + let parsed = null; + if (text !== '') { + try { + parsed = JSON.parse(text); + } catch { + parsed = null; + } + } + if (response.status >= 200 && response.status < 300) return { status: response.status, body: parsed }; + if (tolerate.includes(response.status)) return { status: response.status, body: null }; + const type = parsed && typeof parsed.type === 'string' ? parsed.type : 'unknown'; + const message = parsed && typeof parsed.message === 'string' ? parsed.message : `HTTP ${response.status}`; + throw new AppwriteError(response.status, type, message); + } + + return { + async getRow(table, id) { + const result = await send('GET', `${rowsPath(table)}/${segment(id)}`, { tolerate: [404] }); + return result.status === 404 ? null : result.body; + }, + + async listRows(table, queries = []) { + const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&'); + const result = await send('GET', `${rowsPath(table)}${search === '' ? '' : `?${search}`}`); + return Array.isArray(result.body && result.body.rows) ? result.body.rows : []; + }, + + async createRow(table, rowId, data, permissions) { + const result = await send('POST', rowsPath(table), { body: { rowId, data, permissions } }); + return result.body; + }, + + async updateRow(table, id, data, permissions) { + const body = { data }; + if (Array.isArray(permissions)) body.permissions = permissions; + const result = await send('PATCH', `${rowsPath(table)}/${segment(id)}`, { body }); + return result.body; + }, + + async deleteRow(table, id) { + const result = await send('DELETE', `${rowsPath(table)}/${segment(id)}`, { tolerate: [404] }); + return result.status !== 404; + }, + + async getFile(bucket, id) { + const result = await send('GET', filePath(bucket, id), { tolerate: [404] }); + return result.status === 404 ? null : result.body; + }, + + async updateFilePermissions(bucket, id, permissions) { + const result = await send('PUT', filePath(bucket, id), { body: { permissions }, tolerate: [404] }); + return result.status === 404 ? null : result.body; + }, + + async deleteFile(bucket, id) { + const result = await send('DELETE', filePath(bucket, id), { tolerate: [404] }); + return result.status !== 404; + }, + + /** Prüft ein Nutzer-JWT: Konto aus GET /account (ohne Server-Key), null bei 401. */ + async accountFromJwt(jwt) { + try { + const result = await send('GET', '/account', { headers: { 'X-Appwrite-JWT': jwt }, withKey: false }); + return result.body; + } catch (err) { + if (err instanceof AppwriteError && err.status === 401) return null; + throw err; + } + }, + }; +} + +module.exports = { createAppwrite, AppwriteError, queryEqual, queryLimit }; diff --git a/deploy/api/appwrite.test.js b/deploy/api/appwrite.test.js new file mode 100644 index 0000000..3d0fa71 --- /dev/null +++ b/deploy/api/appwrite.test.js @@ -0,0 +1,211 @@ +'use strict'; + +// Tests für deploy/api/appwrite.js gegen einen Fake-Appwrite im Speicher (fake-appwrite.js). +// Aufruf: node --test "deploy/api/*.test.js" + +const { test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); + +const { createAppwrite, AppwriteError, queryEqual, queryLimit } = require('./appwrite.js'); +const { startFakeAppwrite } = require('./fake-appwrite.js'); + +const fakes = []; +afterEach(async () => { + for (const fake of fakes.splice(0)) await fake.close(); +}); + +async function setup(options = {}) { + const fake = await startFakeAppwrite(); + fakes.push(fake); + const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, ...options }); + return { fake, appwrite }; +} + +const lastCall = (fake) => fake.calls[fake.calls.length - 1]; + +test('Zeilen: anlegen, lesen, ändern, löschen mit Projekt- und Key-Header', async () => { + const { fake, appwrite } = await setup(); + + const created = await appwrite.createRow('gallery', 'row1', { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, ['read("any")']); + assert.equal(created.$id, 'row1'); + assert.equal(lastCall(fake).method, 'POST'); + assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows'); + assert.deepEqual(lastCall(fake).body, { + rowId: 'row1', + data: { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, + permissions: ['read("any")'], + }); + assert.equal(lastCall(fake).headers['x-appwrite-project'], fake.project); + assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key); + assert.match(lastCall(fake).headers['content-type'], /^application\/json/); + + assert.equal((await appwrite.getRow('gallery', 'row1')).title, 'T'); + assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/row1'); + + await appwrite.updateRow('gallery', 'row1', { hidden: true }); + assert.equal(lastCall(fake).method, 'PATCH'); + assert.deepEqual(lastCall(fake).body, { data: { hidden: true } }, 'ohne permissions-Feld, wenn keine übergeben werden'); + assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("any")']); + + await appwrite.updateRow('gallery', 'row1', { reportCount: 3 }, ['read("user:o")']); + assert.deepEqual(lastCall(fake).body, { data: { reportCount: 3 }, permissions: ['read("user:o")'] }); + assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("user:o")']); + + assert.equal(await appwrite.deleteRow('gallery', 'row1'), true); + assert.equal(fake.row('gallery', 'row1'), null); +}); + +test('Fehlende Zeile/Datei: getRow und getFile liefern null, deleteRow/deleteFile/updateFilePermissions sind tolerant', async () => { + const { appwrite } = await setup(); + assert.equal(await appwrite.getRow('gallery', 'nope'), null); + assert.equal(await appwrite.getFile('gallery-images', 'nope'), null); + assert.equal(await appwrite.deleteRow('gallery', 'nope'), false); + assert.equal(await appwrite.deleteFile('gallery-images', 'nope'), false); + assert.equal(await appwrite.updateFilePermissions('gallery-images', 'nope', ['read("any")']), null); +}); + +test('Doppelte Zeilen-ID wirft AppwriteError mit Status 409 und Typ row_already_exists', async () => { + const { appwrite } = await setup(); + const data = { galleryId: 'g', reason: 'spam', reporterHash: 'h' }; + await appwrite.createRow('reports', 'r1', data, []); + await assert.rejects( + () => appwrite.createRow('reports', 'r1', data, []), + (err) => err instanceof AppwriteError && err.status === 409 && err.type === 'row_already_exists', + ); +}); + +test('Andere Fehler (400, 401, 500) werden als AppwriteError geworfen', async () => { + const { fake, appwrite } = await setup(); + await assert.rejects( + () => appwrite.createRow('gallery', 'x', { title: 'x'.repeat(61), imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, []), + (err) => err instanceof AppwriteError && err.status === 400 && err.type === 'document_invalid_structure', + ); + fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' }); + await assert.rejects( + () => appwrite.getRow('gallery', 'x'), + (err) => err instanceof AppwriteError && err.status === 500, + ); + const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' }); + await assert.rejects( + () => wrongKey.getRow('gallery', 'x'), + (err) => err instanceof AppwriteError && err.status === 401, + ); +}); + +test('listRows schickt die Queries als queries[]-Parameter und liefert die Zeilen', async () => { + const { fake, appwrite } = await setup(); + for (const id of ['a', 'b', 'c']) { + await appwrite.createRow('reports', id, { galleryId: id === 'c' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []); + } + const rows = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(100)]); + assert.deepEqual(rows.map((row) => row.$id).sort(), ['a', 'b']); + + const { search } = lastCall(fake); + const params = search.split('&').map((part) => decodeURIComponent(part)); + assert.deepEqual(params, [ + 'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}', + 'queries[]={"method":"limit","values":[100]}', + ]); + assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key); +}); + +test('queryEqual und queryLimit liefern Appwrite-JSON-Strings', () => { + assert.equal(queryEqual('galleryId', ['x']), '{"method":"equal","attribute":"galleryId","values":["x"]}'); + assert.equal(queryLimit(100), '{"method":"limit","values":[100]}'); +}); + +test('Dateien: lesen, Rechte setzen, löschen', async () => { + const { fake, appwrite } = await setup(); + fake.addFile({ id: 'f1', owner: 'u1', mimeType: 'image/webp', size: 1234 }); + + const file = await appwrite.getFile('gallery-images', 'f1'); + assert.equal(file.mimeType, 'image/webp'); + assert.equal(file.sizeOriginal, 1234); + assert.equal(file.bucketId, 'gallery-images'); + assert.deepEqual(file.$permissions, ['read("any")', 'delete("user:u1")']); + assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/f1'); + + await appwrite.updateFilePermissions('gallery-images', 'f1', ['read("user:u1")']); + assert.equal(lastCall(fake).method, 'PUT'); + assert.deepEqual(lastCall(fake).body, { permissions: ['read("user:u1")'] }); + assert.deepEqual(fake.file('f1').$permissions, ['read("user:u1")']); + + assert.equal(await appwrite.deleteFile('gallery-images', 'f1'), true); + assert.equal(fake.file('f1'), null); +}); + +test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => { + const { fake, appwrite } = await setup(); + fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] }); + + const user = await appwrite.accountFromJwt('aaa.bbb.ccc'); + assert.equal(user.$id, 'u1'); + assert.deepEqual(user.labels, ['admin']); + const call = lastCall(fake); + assert.equal(call.path, '/v1/account'); + assert.equal(call.headers['x-appwrite-jwt'], 'aaa.bbb.ccc'); + assert.equal(call.headers['x-appwrite-project'], fake.project); + assert.equal(call.headers['x-appwrite-key'], undefined, 'das Konto wird nie mit dem Server-Key abgefragt'); + + assert.equal(await appwrite.accountFromJwt('xxx.yyy.zzz'), null); + + fake.failNext((c) => c.path === '/v1/account', { status: 500 }); + await assert.rejects(() => appwrite.accountFromJwt('aaa.bbb.ccc'), (err) => err instanceof AppwriteError && err.status === 500); +}); + +test('Pfadsegmente: jede ID wird kodiert, "." und ".." werden abgelehnt, bevor eine Anfrage rausgeht', async () => { + const { fake, appwrite } = await setup(); + + await appwrite.getRow('gallery', '../x'); + assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/..%2Fx'); + await appwrite.getFile('gallery-images', 'a b/c'); + assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/a%20b%2Fc'); + + const before = fake.calls.length; + for (const bad of ['..', '.', '', 42, null, undefined]) { + await assert.rejects(() => appwrite.getRow('gallery', bad), /Pfadsegment/); + await assert.rejects(() => appwrite.deleteFile('gallery-images', bad), /Pfadsegment/); + } + await assert.rejects(() => appwrite.getRow('..', 'x'), /Pfadsegment/); + assert.equal(fake.calls.length, before, 'es ging keine Anfrage raus'); +}); + +test('Zeitlimit: Appwrite-Aufrufe tragen standardmäßig AbortSignal.timeout(10000)', async () => { + let seenSignal = null; + const appwrite = createAppwrite({ + endpoint: 'http://appwrite.invalid/v1', + project: 'p', + key: 'k', + fetchImpl: async (url, init) => { + seenSignal = init.signal; + return new Response(JSON.stringify({ $id: 'x' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }, + }); + await appwrite.getRow('gallery', 'x'); + assert.ok(seenSignal instanceof AbortSignal); + assert.equal(seenSignal.aborted, false); +}); + +test('Zeitlimit: hängender Appwrite-Aufruf endet mit AppwriteError (timeout)', async () => { + const { fake, appwrite } = await setup({ timeoutMs: 80 }); + fake.hangNext((call) => call.path.endsWith('/rows/slow')); + await assert.rejects( + () => appwrite.getRow('gallery', 'slow'), + (err) => err instanceof AppwriteError && err.type === 'timeout' && err.status === 0, + ); +}); + +test('Netzfehler: AppwriteError (unreachable) ohne Key im Text', async () => { + const appwrite = createAppwrite({ + endpoint: 'http://appwrite.invalid/v1', + project: 'p', + key: 'GEHEIMER-KEY-123', + fetchImpl: async () => { + throw new TypeError('fetch failed GEHEIMER-KEY-123'); + }, + }); + await assert.rejects( + () => appwrite.getRow('gallery', 'x'), + (err) => err instanceof AppwriteError && err.type === 'unreachable' && !String(err.message).includes('GEHEIMER-KEY-123'), + ); +}); diff --git a/deploy/api/fake-appwrite.js b/deploy/api/fake-appwrite.js new file mode 100644 index 0000000..05ba4a8 --- /dev/null +++ b/deploy/api/fake-appwrite.js @@ -0,0 +1,288 @@ +'use strict'; + +/** + * Nur für Tests: Appwrite im Speicher (Teilmenge der REST-API von 1.8.1) als echter HTTP-Server auf Port 0. + * + * GET /v1/account Header X-Appwrite-JWT (+ Projekt, ohne Key) + * GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409 + * GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?} + * GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions} + * + * Abfragen: `queries[]=` mit den Methoden equal und limit. Zeilen werden gegen die Spaltenlängen der + * echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im Test auffallen. + * Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht), + * so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen. + */ + +const http = require('node:http'); + +const COLUMNS = { + gallery: { + title: { size: 60, required: true }, + issuer: { size: 40 }, + tags: { size: 120 }, + displayName: { size: 30 }, + imageFileId: { size: 36, required: true }, + previewFileId: { size: 36, required: true }, + sourceCardId: { size: 36 }, + ownerId: { size: 36, required: true }, + hidden: { type: 'boolean' }, + reportCount: { type: 'integer' }, + searchText: { size: 300 }, + }, + reports: { + galleryId: { size: 36, required: true }, + reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true }, + reporterHash: { size: 64, required: true }, + }, +}; + +async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) { + const tables = { gallery: new Map(), reports: new Map() }; + const files = new Map(); + const users = new Map(); + const calls = []; + const injections = []; + let sequence = 0; + + function addUser(jwt, { id, labels = [] }) { + users.set(jwt, { $id: id, email: `${id}@example.test`, labels }); + } + + function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000 }) { + files.set(id, { + $id: id, + bucketId: bucket, + name: `${id}.png`, + $permissions: permissions || ['read("any")', `delete("user:${owner}")`], + mimeType, + sizeOriginal: size, + }); + } + + function addRow(table, id, data, permissions) { + const row = { $id: id, $createdAt: new Date(1_700_000_000_000 + (++sequence) * 1000).toISOString(), $permissions: permissions, ...data }; + tables[table].set(id, row); + return row; + } + + /** Nächster passender Aufruf (predicate(call) -> boolean) scheitert mit Status/Typ. */ + function failNext(predicate, { status = 500, type = 'general_server_error' } = {}) { + injections.push({ predicate, status, type, hang: false }); + } + + /** Nächster passender Aufruf bekommt nie eine Antwort. */ + function hangNext(predicate) { + injections.push({ predicate, hang: true }); + } + + /** Nächster passender Aufruf wird erst nach `delayMs` bearbeitet (Zustand ändert sich erst dann). */ + function delayNext(predicate, delayMs) { + injections.push({ predicate, delayMs }); + } + + function validateData(table, data, { partial }) { + const columns = COLUMNS[table]; + for (const [name, value] of Object.entries(data)) { + 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.enum && !column.enum.includes(value)) return `Invalid ${name}`; + if (column.size !== undefined) { + if (typeof value !== 'string') return `Invalid ${name}`; + if (value.length > column.size) return `Invalid ${name}: too long`; + } + } + if (!partial) { + for (const [name, column] of Object.entries(columns)) { + if (column.required && data[name] === undefined) return `Missing required attribute: ${name}`; + } + } + return null; + } + + const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', async () => { + const raw = Buffer.concat(chunks).toString('utf8'); + const [pathname, search = ''] = req.url.split('?'); + const call = { + method: req.method, + path: pathname, + search, + headers: req.headers, + body: raw === '' ? null : safeParse(raw), + }; + calls.push(call); + + const reply = (status, payload) => { + if (status === 204) { + res.writeHead(204); + res.end(); + return; + } + const text = JSON.stringify(payload); + res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) }); + res.end(text); + }; + const error = (status, type, message = type) => reply(status, { message, code: status, type }); + + const injectionIndex = injections.findIndex((entry) => entry.predicate(call)); + if (injectionIndex !== -1) { + const [entry] = injections.splice(injectionIndex, 1); + if (entry.hang) return; + if (entry.delayMs) { + await new Promise((resolve) => setTimeout(resolve, entry.delayMs)); + } else { + return error(entry.status, entry.type); + } + } + + const segments = pathname.split('/').filter(Boolean).map(decodeSegment); + if (segments[0] !== 'v1' || req.headers['x-appwrite-project'] !== project) { + return error(401, 'general_unauthorized_scope', 'Missing or wrong project'); + } + + // GET /v1/account (JWT, ohne Key) + if (segments.length === 2 && segments[1] === 'account' && req.method === 'GET') { + const user = users.get(req.headers['x-appwrite-jwt']); + if (!user) return error(401, 'user_jwt_invalid', 'Invalid JWT'); + return reply(200, user); + } + + if (req.headers['x-appwrite-key'] !== key) return error(401, 'general_unauthorized_scope', 'Missing or wrong key'); + + // Zeilen + if (segments[1] === 'tablesdb' && segments[3] === 'tables' && segments[5] === 'rows') { + const table = segments[4]; + if (segments[2] !== database || !tables[table]) return error(404, 'table_not_found'); + const store = tables[table]; + + if (segments.length === 6 && req.method === 'POST') { + const body = call.body || {}; + if (typeof body.rowId !== 'string' || body.rowId === '') return error(400, 'general_argument_invalid', 'rowId is required'); + if (store.has(body.rowId)) return error(409, 'row_already_exists'); + 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 || []); + 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 }); + } + + if (segments.length === 7) { + const row = store.get(segments[6]); + if (!row) return error(404, 'row_not_found'); + if (req.method === 'GET') return reply(200, row); + if (req.method === 'DELETE') { + store.delete(segments[6]); + return reply(204); + } + if (req.method === 'PATCH') { + const body = call.body || {}; + const data = body.data || {}; + const problem = validateData(table, data, { partial: true }); + if (problem) return error(400, 'document_invalid_structure', problem); + Object.assign(row, data); + if (Array.isArray(body.permissions)) row.$permissions = body.permissions; + return reply(200, row); + } + } + } + + // Dateien + if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) { + const file = files.get(segments[5]); + if (!file) return error(404, 'storage_file_not_found'); + if (req.method === 'GET') return reply(200, file); + if (req.method === 'DELETE') { + files.delete(segments[5]); + return reply(204); + } + if (req.method === 'PUT') { + const body = call.body || {}; + if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid'); + file.$permissions = body.permissions; + return reply(200, file); + } + } + + return error(404, 'general_route_not_found'); + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + return { + url: `http://127.0.0.1:${port}`, + endpoint: `http://127.0.0.1:${port}/v1`, + project, + key, + calls, + addUser, + addFile, + addRow, + failNext, + hangNext, + delayNext, + row: (table, id) => tables[table].get(id) || null, + rows: (table) => [...tables[table].values()], + file: (id) => files.get(id) || null, + close: async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +function decodeSegment(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function safeParse(text) { + try { + return JSON.parse(text); + } catch { + return { _unparsable: text }; + } +} + +/** Wendet equal/limit an; null bei unbekannter Abfrage. */ +function runQueries(rows, search) { + let result = rows; + let limit = 25; + for (const part of search.split('&').filter(Boolean)) { + const eq = part.indexOf('='); + const name = decodeURIComponent(part.slice(0, eq)); + if (name !== 'queries[]') return null; + let query; + try { + query = JSON.parse(decodeURIComponent(part.slice(eq + 1))); + } catch { + return null; + } + if (query.method === 'equal') { + result = result.filter((row) => query.values.includes(row[query.attribute])); + } else if (query.method === 'limit') { + limit = query.values[0]; + } else { + return null; + } + } + return result.slice(0, limit); +} + +module.exports = { startFakeAppwrite }; diff --git a/deploy/api/server.js b/deploy/api/server.js new file mode 100644 index 0000000..5bbe667 --- /dev/null +++ b/deploy/api/server.js @@ -0,0 +1,99 @@ +'use strict'; + +/** + * Start des API-Dienstes `aircard-api` (Node 22, nur Bordmittel): liest die Umgebung, baut createApp und + * lauscht auf einem Port. Beschreibung der Endpunkte: app.js und README.md. + * + * Umgebung: + * AIRCARD_API_KEY Pflicht Appwrite-Server-Key `aircard-api` (rows.read/write, files.read/write) + * AIRCARD_API_SALT Pflicht Salz für die Melder-Hashes (32 Byte hex) + * AIRCARD_APPWRITE_PROJECT_ID Pflicht Projekt-ID + * AIRCARD_APPWRITE_ENDPOINT http://webklar-appwrite/v1 + * AIRCARD_API_PORT 8088 + * Fehlt etwas Pflichtiges, nennt die Meldung nur den Namen der Variable (nie Werte) und der Prozess endet mit Exit-Code 1. + */ + +const http = require('node:http'); +const { createAppwrite } = require('./appwrite.js'); +const { createApp } = require('./app.js'); + +const DEFAULT_PORT = 8088; +const DEFAULT_ENDPOINT = 'http://webklar-appwrite/v1'; + +/** + * Prüft die Umgebung, ohne etwas zu starten. + * @returns {{ok: true, config: object, warnings: string[]} | {ok: false, error: string}} + */ +function loadConfig(env) { + const read = (name) => (typeof env[name] === 'string' ? env[name].trim() : ''); + + const missing = ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID'].filter((name) => read(name) === ''); + if (missing.length > 0) { + return { ok: false, error: `${missing.join(', ')} ${missing.length > 1 ? 'sind' : 'ist'} nicht gesetzt - Abbruch` }; + } + + const endpoint = read('AIRCARD_APPWRITE_ENDPOINT') || DEFAULT_ENDPOINT; + if (!/^https?:\/\/\S+$/.test(endpoint)) { + return { ok: false, error: 'AIRCARD_APPWRITE_ENDPOINT muss mit http:// oder https:// beginnen - Abbruch' }; + } + + const portText = read('AIRCARD_API_PORT'); + const port = portText === '' ? DEFAULT_PORT : Number(portText); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + return { ok: false, error: 'AIRCARD_API_PORT ist keine gültige Portnummer - Abbruch' }; + } + + const warnings = []; + if (read('AIRCARD_API_SALT').length < 16) warnings.push('AIRCARD_API_SALT ist sehr kurz (empfohlen: 32 Byte hex)'); + + return { + ok: true, + warnings, + config: { + port, + endpoint, + project: read('AIRCARD_APPWRITE_PROJECT_ID'), + key: read('AIRCARD_API_KEY'), + salt: read('AIRCARD_API_SALT'), + }, + }; +} + +function main() { + const say = (message) => console.log(`[aircard-api] ${new Date().toISOString()} ${message}`); + + const loaded = loadConfig(process.env); + if (!loaded.ok) { + console.error(`[aircard-api] ${loaded.error}`); + process.exit(1); + } + for (const warning of loaded.warnings) say(`Warnung: ${warning}`); + const { port, endpoint, project, key, salt } = loaded.config; + + const appwrite = createAppwrite({ endpoint, project, key }); + const app = createApp({ appwrite, salt, log: console.log }); + const server = http.createServer(app); + + // Langsame oder hängende Clients nicht ewig halten; Keep-Alive länger als Traefiks Leerlauf (90 s), + // damit Traefik keine Verbindung wiederverwendet, die Node gerade schließt. + server.headersTimeout = 10_000; + server.requestTimeout = 15_000; + server.keepAliveTimeout = 95_000; + + server.listen(port, () => say(`listening on :${server.address().port} (Appwrite: ${endpoint})`)); + + const shutdown = (signal) => { + say(`${signal} received, shutting down`); + server.close(() => process.exit(0)); + server.closeIdleConnections(); + setTimeout(() => process.exit(0), 5000).unref(); + }; + process.once('SIGTERM', () => shutdown('SIGTERM')); + process.once('SIGINT', () => shutdown('SIGINT')); +} + +module.exports = { loadConfig }; + +if (require.main === module) { + main(); +} diff --git a/deploy/api/server.test.js b/deploy/api/server.test.js new file mode 100644 index 0000000..2c69cc3 --- /dev/null +++ b/deploy/api/server.test.js @@ -0,0 +1,172 @@ +'use strict'; + +// Tests für deploy/api/server.js: Umgebungsprüfung und ein Start als echter Prozess gegen einen Fake-Appwrite. +// Aufruf: node --test "deploy/api/*.test.js" + +const { test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const { loadConfig } = require('./server.js'); +const { startFakeAppwrite } = require('./fake-appwrite.js'); + +const SERVER = path.join(__dirname, 'server.js'); +const KEY = 'KEY-test-secret-value'; +const SALT = 'salt-test-secret-0123456789abcdef0123456789'; + +const children = []; +const fakes = []; +afterEach(async () => { + for (const child of children.splice(0)) { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + await new Promise((resolve) => child.once('exit', resolve)); + } + } + for (const fake of fakes.splice(0)) await fake.close(); +}); + +function validEnv(extra = {}) { + return { + AIRCARD_API_KEY: KEY, + AIRCARD_API_SALT: SALT, + AIRCARD_APPWRITE_PROJECT_ID: 'proj-test', + ...extra, + }; +} + +function startProcess(env) { + const child = spawn(process.execPath, [SERVER], { env: { PATH: process.env.PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'] }); + children.push(child); + const output = { text: '' }; + child.stdout.on('data', (chunk) => (output.text += chunk)); + child.stderr.on('data', (chunk) => (output.text += chunk)); + const exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal }))); + return { child, output, exited }; +} + +async function waitFor(condition, what, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +function httpRequest(port, method, urlPath, { headers = {}, body } = {}) { + return new Promise((resolve, reject) => { + const payload = body === undefined ? undefined : JSON.stringify(body); + const req = http.request( + { + host: '127.0.0.1', + port, + method, + path: urlPath, + headers: payload === undefined ? headers : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...headers }, + }, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') })); + }, + ); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +test('loadConfig: Standardwerte und gelesene Werte', () => { + const result = loadConfig(validEnv()); + assert.equal(result.ok, true); + assert.deepEqual(result.config, { + port: 8088, + endpoint: 'http://webklar-appwrite/v1', + project: 'proj-test', + key: KEY, + salt: SALT, + }); + assert.deepEqual(result.warnings, []); + + const custom = loadConfig(validEnv({ AIRCARD_API_PORT: '9100', AIRCARD_APPWRITE_ENDPOINT: 'https://appwrite.example/v1' })); + assert.equal(custom.config.port, 9100); + assert.equal(custom.config.endpoint, 'https://appwrite.example/v1'); +}); + +test('loadConfig: fehlender Key, Salz oder Projekt wird mit dem Namen der Variable gemeldet, nie mit Werten', () => { + for (const name of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID']) { + for (const value of [undefined, '', ' ']) { + const env = validEnv(); + if (value === undefined) delete env[name]; + else env[name] = value; + const result = loadConfig(env); + assert.equal(result.ok, false, `${name}=${JSON.stringify(value)}`); + assert.ok(result.error.includes(name)); + assert.ok(!result.error.includes(KEY) && !result.error.includes(SALT)); + } + } + const none = loadConfig({}); + assert.equal(none.ok, false); + assert.ok(none.error.includes('AIRCARD_API_KEY') && none.error.includes('AIRCARD_API_SALT')); +}); + +test('loadConfig: ungültiger Port oder Endpunkt, kurzes Salz warnt', () => { + assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: 'abc' })).ok, false); + assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '70000' })).ok, false); + assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '-1' })).ok, false); + assert.equal(loadConfig(validEnv({ AIRCARD_APPWRITE_ENDPOINT: 'webklar-appwrite/v1' })).ok, false); + const short = loadConfig(validEnv({ AIRCARD_API_SALT: 'xyz-9' })); + assert.equal(short.ok, true); + assert.equal(short.warnings.length, 1); + assert.ok(short.warnings[0].includes('AIRCARD_API_SALT') && !short.warnings[0].includes('xyz-9')); +}); + +test('Start ohne Key oder ohne Salz: Exit-Code 1, Meldung ohne Werte', async () => { + for (const missing of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT']) { + const env = validEnv({ AIRCARD_API_PORT: '0' }); + delete env[missing]; + const { output, exited } = startProcess(env); + const { code } = await exited; + assert.equal(code, 1, missing); + assert.ok(output.text.includes(missing)); + assert.ok(output.text.includes('[aircard-api]')); + assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT)); + } +}); + +test('Start als Prozess: Gesundheit, Melden über Fake-Appwrite mit Server-Key, sauberes Beenden bei SIGTERM', async () => { + const fake = await startFakeAppwrite({ project: 'proj-test', key: KEY }); + fakes.push(fake); + fake.addRow( + 'gallery', + 'card-1', + { title: 'Karte', imageFileId: 'i', previewFileId: 'p', ownerId: 'user-me', hidden: false, reportCount: 0 }, + ['read("any")'], + ); + + const { child, output, exited } = startProcess(validEnv({ AIRCARD_API_PORT: '0', AIRCARD_APPWRITE_ENDPOINT: fake.endpoint })); + await waitFor(() => /listening on :\d+/.test(output.text), 'Startmeldung'); + const port = Number(/listening on :(\d+)/.exec(output.text)[1]); + + const health = await httpRequest(port, 'GET', '/api/gesundheit'); + assert.deepEqual(health.json, { ok: true }); + + const reported = await httpRequest(port, 'POST', '/api/melden', { + body: { id: 'card-1', reason: 'spam' }, + headers: { 'X-Real-Ip': '203.0.113.5' }, + }); + assert.equal(reported.status, 200); + assert.deepEqual(reported.json, { ok: true }); + assert.equal(fake.row('gallery', 'card-1').reportCount, 1); + assert.ok(fake.calls.length > 0); + for (const call of fake.calls) assert.equal(call.headers['x-appwrite-key'], KEY); + + child.kill('SIGTERM'); + const { code } = await exited; + assert.equal(code, 0); + assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT), 'Ausgabe enthält weder Key noch Salz'); + assert.ok(!output.text.includes('203.0.113.5'), 'Ausgabe enthält keine IP'); + for (const line of output.text.trim().split('\n')) assert.ok(line.startsWith('[aircard-api]'), line); +}); diff --git a/deploy/compose-snippet.yml b/deploy/compose-snippet.yml index 32e264d..13884b4 100644 --- a/deploy/compose-snippet.yml +++ b/deploy/compose-snippet.yml @@ -3,10 +3,12 @@ # URL: aircard-ios.org # Repository: https://git.webklar.com/knso/AirCardIOS # Einfügen unter `services:` direkt nach dem eship-Block (siehe deploy/README.md). - # Starten NUR mit: docker compose up -d aircard-build aircard + # Starten NUR mit: docker compose up -d aircard-build aircard aircard-api # (ein nacktes `up -d` würde andere Dienste wieder anlegen) - # Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml. - # AIRCARD_APPWRITE_PROJECT_ID kommt aus /home/webklar/apps/.env. + # Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml (Router `aircard-api@file`, Pfad /v1). + # Der API-Dienst `aircard-api` hängt per Label am Pfad /api (Router `aircard-gallery-api`, ein anderer Name, + # damit er nicht mit der Datei-Route kollidiert). + # AIRCARD_APPWRITE_PROJECT_ID, AIRCARD_API_KEY und AIRCARD_API_SALT kommen aus /home/webklar/apps/.env. # ============================================== aircard-build: image: node:22-alpine @@ -80,3 +82,39 @@ # - "traefik.http.middlewares.aircard-www.redirectregex.regex=^https?://www\\.aircard-ios\\.org(.*)" # - "traefik.http.middlewares.aircard-www.redirectregex.replacement=https://aircard-ios.org$${1}" # - "traefik.http.middlewares.aircard-www.redirectregex.permanent=true" + + # --- API-Dienst der Community-Galerie (deploy/api/, siehe deploy/api/README.md) --- + # Ohne AIRCARD_API_KEY oder AIRCARD_API_SALT in der .env beendet sich der Dienst mit Exit-Code 1. + # Kein `ports:`: erreichbar nur über Traefik (der Dienst vertraut dem Header X-Real-Ip). + aircard-api: + image: node:22-alpine + container_name: webklar-aircard-api + restart: unless-stopped + init: true + mem_limit: 128m + user: node + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + working_dir: /srv + environment: + - AIRCARD_API_PORT=8088 + - AIRCARD_APPWRITE_ENDPOINT=http://webklar-appwrite/v1 + - AIRCARD_APPWRITE_PROJECT_ID=${AIRCARD_APPWRITE_PROJECT_ID} + - AIRCARD_API_KEY=${AIRCARD_API_KEY} + - AIRCARD_API_SALT=${AIRCARD_API_SALT} + - NODE_OPTIONS=--max-old-space-size=64 + volumes: + - ./aircard-ios.org/api:/srv:ro + networks: + - webklar-network + command: node /srv/server.js + labels: + - "traefik.enable=true" + - "traefik.http.routers.aircard-gallery-api.rule=Host(`aircard-ios.org`) && PathPrefix(`/api`)" + - "traefik.http.routers.aircard-gallery-api.entrypoints=websecure" + - "traefik.http.routers.aircard-gallery-api.tls.certresolver=letsencrypt" + - "traefik.http.routers.aircard-gallery-api.priority=200" + - "traefik.http.services.aircard-gallery-api.loadbalancer.server.port=8088"