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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:30:28 +00:00

11 KiB
Raw Blame History

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.

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 (so beschriftet die Console): documents.read, documents.write, files.read, files.write; documents.* entspricht den rows.* der API (TablesDB)
AIRCARD_API_SALT – (Pflicht) Salz für die Melder-Hashes, 32 Byte hex
AIRCARD_APPWRITE_PROJECT_ID – (Pflicht) Projekt-ID (6ac2130a00289343d6d5)
AIRCARD_APPWRITE_ENDPOINT http://webklar-appwrite/v1 interne Appwrite-Adresse im Docker-Netz
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": "<deutscher Text>"}. 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.“), falscher Typ oder > 5 MiB, falsche Bildmaße („Das Bild hat nicht das Kartenformat.“) · 403 Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei schon veröffentlicht oder gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“), Karte nicht meine („Diese Karte gehört nicht zu deinem Konto.“) · 409 „Diese Karte ist schon veröffentlicht.“ · 429 „Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.“
POST /api/zurueckziehen {id} Nutzer-JWT (Autor oder Admin) 200 {ok:true} · 401 · 400 · 403 fremd · 404 fehlt
POST /api/melden {id, reason} keine 200 {ok:true} oder 200 {schonGemeldet:true} · 400 Grund oder ID · 404 fehlt oder ausgeblendet · 429 „Zu viele Meldungen – bitte später nochmal.“ (mit Retry-After)
POST /api/admin/sichtbarkeit {id, sichtbar} Admin-JWT 200 {ok:true} · 401 · 403 kein Admin · 400 · 404
GET /api/gesundheit keine 200 {ok:true}

Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst 502 „Der Dienst ist gerade nicht erreichbar.“. Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist 500 „Interner Fehler.“ Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ row_not_found bzw. storage_file_not_found als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“.

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). Nicht erlaubt in Titel, Herausgeber, Anzeigename und Tags: Steuerzeichen (U+0000–001F, U+007F–009F), Nullbreiten- und Bidi-Zeichen (U+200B–200F, U+202A–202E, U+2060–206F) und das BOM (U+FEFF); sie machen Titel unsichtbar oder ordnen Text um. Dasselbe Muster prüft der Veröffentlichen-Dialog. tags ist ein string[]: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Gespeichert wird die Spalte tags als ,tag1,tag2, (Komma an beiden Enden, leer bei keinen Tags): der Tag-Filter der Website sucht ,<tag>, und trifft so nur ganze Tags. Bild und Vorschau müssen verschiedene Dateien sein.
  • Quellkarte: sourceCardId muss eine Zeile der Tabelle cards sein, deren $permissions read("user:<ich>") enthalten, sonst 403 „Diese Karte gehört nicht zu deinem Konto.“ (auch wenn es die Zeile nicht gibt).
  • Limit: höchstens 20 Veröffentlichungen je Konto (Zeilen mit ownerId = ich, auch ausgeblendete); ab da 429 mit dem Text aus der Tabelle. Zurückziehen gibt einen Platz frei. Das Limit liest die Zeilen des Autors über ownerId (dort gibt es einen Index) mit limit(20).
  • Dateien (beide): liegen im Bucket gallery-images, tragen delete("user:<ich>"), sind image/png oder image/webp und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt 400; eine fremde 403 (Typ und Größe fremder Dateien nennt die API nicht). Bildmaße: Die API liest die ersten 64 Bytes beider Dateien (GET …/files/<id>/view mit Server-Key, der Rest der Übertragung wird abgebrochen) und liest PNG-IHDR bzw. WebP (VP8 , VP8L, VP8X) aus. Das Bild muss genau 1536 × 969 sein, die Vorschau 480 breit und 302–304 hoch (die Karte ist 1536 × 969, die Vorschau 480 × 303), sonst 400 „Das Bild hat nicht das Kartenformat.“ Das ist Defense in Depth gegen Dekompressionsbomben; die maßgebliche Prüfung beim Anzeigen macht der Client (Pixelgrenze 4 Megapixel). Keine Wiederverwendung: Beide Dateien müssen read("any") tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst 403 „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen sourceCardId neu einstellen. Die Prüfung läuft zusammen mit Limit, Quellkarte und Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau read("any") (kein delete("user:<ich>") mehr, kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen und schon festgelegte Dateien bekommen delete("user:<ich>") zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht rollback failed for card <id> im Log). Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als 400 „Bild nicht gefunden.“
  • Doppelt veröffentlichen: Die Zeilen-ID der Galerie ist sha256(ownerId|sourceCardId) (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei.
  • Melden: Rate-Limit 10 pro Stunde und Anschluss (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge; Schlüssel: gehashte IPv4, IPv6 auf /64 gekürzt, IPv4-gemappte IPv6 als IPv4). Der Melder-Hash ist HMAC-SHA256(salt, netz) mit dem Netz der Adresse (IPv4 → /24, IPv6 → /48, IPv4-gemappte IPv6 wie IPv4): drei Adressen aus einem Netz sind ein Melder und blenden eine Karte nicht aus (sonst genügten drei VPN-/Mobilfunk-Adressen). Die Zeilen-ID der Meldung ist sha256(galleryId|reporterHash), eine zweite Meldung desselben Melders ergibt {schonGemeldet:true}. reportCount = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig.
  • Client-IP: X-Real-Ip (setzt Traefik), sonst erster Eintrag von X-Forwarded-For, sonst die Socket-Adresse. Der Dienst veröffentlicht nie ports:, ist aber im Docker-Netz webklar-network für andere Container erreichbar, und dort gilt X-Real-Ip als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den clientIp prüft, oder ein eigenes Netz nur für Traefik und aircard-api. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
  • Rechte (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile read("any"); ausgeblendet Zeile read("user:<owner>"), read("label:admin"); Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) read("any"), delete("user:<owner>"); Datei veröffentlicht read("any"); Datei ausgeblendet read("user:<owner>"), read("label:admin"). Veröffentlichte und ausgeblendete Dateien tragen kein delete(owner): sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur read("label:admin")).
  • Reihenfolge bei Mehrschritt-Aktionen (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
  • Logs: Zeilen beginnen mit [aircard-api]. Es gibt kein Zugriffsprotokoll je Anfrage (/api/melden ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion, fehlgeschlagenes Zurücknehmen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (logging: json-file, 5 MB, 2 Dateien).

Tests

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.