# 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.