Files
AirCardIOS/deploy/api
..

aircard-api

Kleiner HTTP-Dienst (Node 22, keine Abhängigkeiten) für die Community-Galerie, die Community-Logos und die Benutzernamen von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: bei den Karten Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit, bei den Logos Hochladen, Herz setzen, Melden, Löschen und die Admin-Sichtbarkeit, bei den Benutzernamen Anlegen und Ändern. Hochladen und Veröffentlichen gehen nur mit Benutzernamen. Die Website liest Galerie, Logos und Benutzernamen direkt aus Appwrite; nur das Schreiben läuft hierüber.

Spec: docs/superpowers/specs/2026-10-04-aircard-community-design.md §7, §8, §11 (Karten) und docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md §6, §8, §9 (Logos) und docs/superpowers/specs/2026-10-05-aircard-benutzernamen-design.md §2–4 (Benutzernamen). Deployment: ../README.md.

Dateien

Datei Aufgabe
server.js Start: liest die Umgebung, http.createServer, Zeitlimits, sauberes Beenden
app.js HTTP-Anbindung (Routing, Body, Anmeldung, Rate-Limit, Log, Fehlerantworten) und die Karten-Endpunkte (createApp)
logos.js die fünf Logo-Endpunkte (createLogoRoutes); bekommt Anmeldung, Sperre je Schlüssel, Rate-Limit und Log von app.js
profiles.js der Benutzernamen-Endpunkt (createProfileRoutes) und hasProfile, das app.js und logos.js vor dem Veröffentlichen und Hochladen fragen; bekommt Anmeldung, Sperre je Schlüssel und Log von app.js
common.js gemeinsame Helfer: MSG (alle Fehlertexte, Schlüssel = code), HttpError, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Namensmuster und Sperrliste der Benutzernamen
appwrite.js REST-Client mit Server-Key (createAppwrite, auch countRows)
*.test.js, fake-appwrite.js Tests und Fake-Appwrite im Speicher (gehören nicht auf den Server)

Auf den Server (/home/webklar/apps/aircard-ios.org/api/) kommen nur die sechs Dateien appwrite.js, common.js, app.js, logos.js, profiles.js und server.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>", "code": "<Schlüssel>"} (Codes siehe unten). Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label admin. Alles andere (auch falsche Methoden) ist 404; POST nur mit Content-Type: application/json (sonst 415), Body höchstens 8 KB (sonst 413).

Endpunkt Auth Antworten
POST /api/veroeffentlichen {title, issuer?, tags?, imageFileId, previewFileId, sourceCardId} Nutzer-JWT 201 {id} · 401 · 403 Konto ohne Benutzernamen („Bitte wähle zuerst einen Benutzernamen.”, vor allen anderen Prüfungen) · 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
POST /api/logos/hochladen {name, mono?, fileId} Nutzer-JWT 201 {id} (Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID) · 401 · 403 Konto ohne Benutzernamen (wie beim Veröffentlichen) · 400 Validierung, Datei fehlt („Bild nicht gefunden.”), kein PNG („Das Logo muss eine PNG-Datei sein.”), größer als 1 MiB („Das Logo ist größer als 1 MB.”), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).”) · 403 Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.”), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.”) · 409 „Diese Datei wird schon für ein Logo verwendet.” · 429 „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.”
POST /api/logos/herz {id, herz} Nutzer-JWT 200 {herz, likeCount} · 401 · 400 ID ungültig oder herz kein Boolean · 404 fehlt oder ausgeblendet („Dieses Logo ist nicht (mehr) verfügbar.“)
POST /api/logos/melden {id, reason} keine 200 {ok:true} oder 200 {schonGemeldet:true} · 400 Grund oder ID · 404 fehlt oder ausgeblendet · 429 „Zu viele Meldungen – bitte später nochmal.“ (mit Retry-After; dasselbe Limit wie /api/melden)
POST /api/logos/loeschen {id} Nutzer-JWT (Hochlader oder Admin) 200 {ok:true} · 401 · 400 · 403 fremd · 404 fehlt
POST /api/admin/logo-sichtbarkeit {id, sichtbar} Admin-JWT 200 {ok:true} · 401 · 403 kein Admin · 400 · 404
POST /api/profil {username} Nutzer-JWT 200 {username} (Name angelegt oder geändert) · 401 · 400 Muster („Der Benutzername muss 3–20 Zeichen lang sein, …“) oder gesperrter Name („Dieser Benutzername ist nicht erlaubt.“) · 409 „Dieser Benutzername ist schon vergeben.“
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.“ (code unavailable). Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist 500 „Interner Fehler.“ (code internal). Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ row_not_found bzw. storage_file_not_found als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“ oder „Logo nicht verfügbar“.

Fehlercodes

Jede Fehlerantwort trägt neben dem deutschen Text einen code: den Schlüssel in MSG (common.js), dessen Text genau dem error entspricht. Die Texte in MSG sind paarweise verschieden (ein Test prüft das), der Code ist also eindeutig. Dynamische Prüftexte ohne festen Schlüssel („Der Titel ist zu lang (höchstens 60 Zeichen).“, „Ungültige Bild-ID.“, „Bitte gib höchstens 5 Tags an.“, „Der Name ist zu lang (höchstens 40 Zeichen).“, …) haben bei Status 400 den code badRequest, sonst error (kommt derzeit nicht vor). Der code ist die stabile Schnittstelle für die Clients (z. B. um Meldungen zu übersetzen); MSG ist dafür ein schlichtes Objekt aus Texten, das common.js unverändert exportiert.

Status code · Text
400 badRequest „Ungültige Anfrage.“ (auch jeder dynamische Prüftext) · badCardId „Ungültige Karten-ID.“ · badLogoId „Ungültige Logo-ID.“ · badReason „Bitte wähle einen gültigen Grund.“ · badVisibility „Ungültige Angabe zur Sichtbarkeit.“ · badHeart „Ungültige Angabe zum Herz.“ · badMono „Ungültige Angabe zu „einfarbig“.“ · logoNameMissing „Bitte gib einen Namen an.“ · fileMissing „Bild nicht gefunden.“ · fileType „Das Bild muss eine PNG- oder WebP-Datei sein.“ · fileSize „Das Bild ist größer als 5 MB.“ · imageFormat „Das Bild hat nicht das Kartenformat.“ · logoFileType „Das Logo muss eine PNG-Datei sein.“ · logoFileSize „Das Logo ist größer als 1 MB.“ · logoFormat „Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“ · usernameInvalid „Der Benutzername muss 3–20 Zeichen lang sein, …“ · usernameReserved „Dieser Benutzername ist nicht erlaubt.“
401 unauthorized „Bitte melde dich an.“
403 forbidden „Dazu hast du keine Berechtigung.“ · adminOnly „Nur für Admins.“ · foreignFile „Diese Datei gehört dir nicht.“ · filesTaken „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ · notYourCard „Diese Karte gehört nicht zu deinem Konto.“ · usernameRequired „Bitte wähle zuerst einen Benutzernamen.“
404 notFound „Nicht gefunden.“ · cardGone „Diese Karte ist nicht (mehr) verfügbar.“ · logoGone „Dieses Logo ist nicht (mehr) verfügbar.“
409 alreadyPublished „Diese Karte ist schon veröffentlicht.“ · logoTaken „Diese Datei wird schon für ein Logo verwendet.“ · usernameTaken „Dieser Benutzername ist schon vergeben.“
413 415 tooLarge „Die Anfrage ist zu groß.“ · jsonOnly „Nur application/json wird unterstützt.“
429 tooManyPublications „Du hast schon 20 Karten veröffentlicht. …“ · tooManyLogos „Du hast schon 30 Logos hochgeladen. …“ · tooManyReports „Zu viele Meldungen – bitte später nochmal.“
500 502 internal „Interner Fehler.“ · unavailable „Der Dienst ist gerade nicht erreichbar.“

Regeln im Einzelnen

  • 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 Zeichen (getrimmt). Nicht erlaubt in Titel, Herausgeber 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.“
  • Logo-Felder (/api/logos/hochladen): name 1–40 Zeichen (getrimmt; dieselben verbotenen Zeichen wie bei den Karten-Texten), mono fehlt = false, sonst muss es ein Boolean sein (badMono), fileId nach dem ID-Muster. Zeilen-ID des Logos = sha256(<ownerId>|<fileId>) (erste 32 Hex), nicht die Datei-ID; die Antwort 201 {id} trägt diese ID. Dieselbe Datei desselben Kontos ergibt dieselbe ID (409). Die ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten; wäre sie die Datei-ID, könnte jeder eine Datei mit genau dieser ID anlegen, als Logo hochladen und so die alte ID neu belegen (gespeicherte Karten anderer zeigten dann sein Bild). Mit dem Konto im Hash geht das nicht. Dass eine Datei nie zwei Logos trägt, sichert der Besitz: nach dem Anlegen hat die Datei kein delete("user:<ich>") mehr, ein anderes Konto bekommt sie nicht (403). Altlogos (vor dieser Regel angelegt) haben noch Zeilen-ID = Datei-ID: Herz, Melden, Löschen und Sichtbarkeit adressieren die Zeile nur über ihre ID und die Datei über das Feld fileId, darum gehen beide Arten; dieselbe Datei eines Altlogos noch einmal hochzuladen ist ebenfalls 409.
  • Logo-Datei und Reihenfolge: Die Datei liegt im Bucket logo-images, trägt delete("user:<ich>") und read("any"), ist image/png, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (401) → Benutzername (403 usernameRequired) → Body (400) → Datei da und im richtigen Bucket (400/403) → je Konto nacheinander (Sperre logo-owner:<id>): schon 30 Logos (429, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (409: Zeile mit dieser Zeilen-ID oder mit dieser fileId, also auch ein Altlogo) → Besitz (403: kein delete("user:<ich>") oder kein read("any")) → Typ und Größe (400) → Maße (400) → ab hier zusätzlich die Sperre des Logos logo:<Zeilen-ID> (immer erst Konto, dann Logo) bis zur zweiten Prüfung unten: Zeile anlegen (409, wenn die ID schon vergeben ist) → Datei auf genau read("any") setzen (kein delete("user:<ich>") mehr, sonst ließe sich das Bild unter derselben ID tauschen). Scheitert das Festlegen, wird die Zeile zurückgenommen und nur dann bekommt die Datei delete("user:<ich>") zurück (scheitert schon das Zurücknehmen, steht rollback failed for logo <id> (file <Datei-ID>) im Log; scheitert danach die Rückgabe von delete("user:<ich>"), bleibt die Datei ohne Zeile und ohne dieses Recht liegen und restore failed for logo <id> (file <Datei-ID>) steht im Log, die Antwort bleibt in beiden Fällen der ursprüngliche Fehler). Zweite Prüfung: Danach liest die API den Dateianfang noch einmal und prüft PNG-Kopf und Maße erneut. Jetzt kann der Hochlader die Datei nicht mehr ändern; so lässt sich der Inhalt nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen (Löschen und Neuanlegen der Datei). Fällt die Datei jetzt durch oder fehlt sie, löscht die API die Datei mit dem Server-Key und die Zeile (beides wird versucht, auch wenn das erste scheitert) und antwortet 400 logoFormat bzw. fileMissing; Log: logo <id> removed after upload check (<code>), bleibt etwas stehen rollback failed for logo <id> (file <Datei-ID>). Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (502, Zeile zurückgenommen, die Datei bekommt delete("user:<ich>") zurück). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben 403, ohne dass die API ihren Inhalt liest. Sperre des Logos beim Hochladen: Die Zeile steht ab dem Anlegen öffentlich in der Liste; ein Herz, eine Meldung oder ein Löschen, das bis zum Ende der zweiten Prüfung eintrifft, wartet auf die Sperre und sieht danach den Endzustand (das fertige Logo oder 404, wenn die Prüfung es entfernt hat). So bleibt keine Herz- oder Meldungszeile ohne Logo zurück.
  • Herzen (/api/logos/herz): ein Herz je Konto und Logo, Zeilen-ID sha256(<logoId>|<userId>) (erste 32 Hex), Recht read("user:<id>"). herz: true legt die Zeile an (409 = schon da, kein Fehler), false löscht sie (schon weg = kein Fehler). Danach ist likeCount immer countRows aller Herzen des Logos (neu gezählt, nie hoch- oder runtergezählt) und wird nur geschrieben, wenn er abweicht. Fehlendes oder ausgeblendetes Logo: 404, auch beim Entfernen. Pro Logo läuft immer nur eine Änderung gleichzeitig (Sperre logo:<id>, auch gegen ein gerade laufendes Hochladen dieses Logos). Herzen werden nicht geloggt.
  • Logo melden (/api/logos/melden): wie /api/melden, mit denselben Gründen, demselben Melder (Netz, gehasht) und demselben Rate-Limit (ein gemeinsamer Zähler je Anschluss für Karten und Logos: 6 Karten- und 5 Logo-Meldungen vom selben Anschluss ergeben bei der 11. ein 429). Die Zeilen-ID der Meldung ist sha256(logo|<logoId>|<reporterHash>), reportCount ist countRows der Meldungen des Logos, ab 3 wird ausgeblendet (Datei und Zeile auf read("user:<owner>") und read("label:admin")).
  • Logo löschen und Sichtbarkeit: Löschen (Hochlader oder Admin) blendet das Logo zuerst aus (Datei, dann Zeile auf die ausgeblendeten Rechte mit hidden: true; ein schon ausgeblendetes Logo überspringt das) und entfernt dann Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (equal logoId, höchstens 50 Runden). Das Ausblenden zuerst verhindert, dass nach einem Abbruch (502) eine öffentliche Zeile mit gelöschter Datei stehen bleibt: unter deren Datei-ID könnte jedes Konto eine neue Datei anlegen, und alle gespeicherten Karten zeigten sie. Es bleibt höchstens eine unsichtbare Zeile (nur Hochlader und Admins lesen sie); die Wiederholung vollendet das Löschen. Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (hidden: true), Einblenden setzt sie auf read("any"), löscht die Meldungen und setzt reportCount auf 0 (die Herzen bleiben).
  • Doppelt veröffentlichen: Die Zeilen-ID der Galerie ist sha256(ownerId|sourceCardId) (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei.
  • Melden: Rate-Limit 10 pro Stunde und Anschluss (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge; Schlüssel: gehashte IPv4, IPv6 auf /64 gekürzt, IPv4-gemappte IPv6 als IPv4). Der Melder-Hash ist HMAC-SHA256(salt, netz) mit dem Netz der Adresse (IPv4 → /24, IPv6 → /48, IPv4-gemappte IPv6 wie IPv4): drei Adressen aus einem Netz sind ein Melder und blenden eine Karte nicht aus (sonst genügten drei VPN-/Mobilfunk-Adressen). Die Zeilen-ID der Meldung ist sha256(galleryId|reporterHash), eine zweite Meldung desselben Melders ergibt {schonGemeldet:true}. reportCount = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig.
  • Client-IP: X-Real-Ip (setzt Traefik), sonst erster Eintrag von X-Forwarded-For, sonst die Socket-Adresse. Der Dienst veröffentlicht nie ports:, ist aber im Docker-Netz webklar-network für andere Container erreichbar, und dort gilt X-Real-Ip als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den clientIp prüft, oder ein eigenes Netz nur für Traefik und aircard-api. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
  • Rechte (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile read("any"); ausgeblendet Zeile read("user:<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")). Für Logos gilt dasselbe (Zeile und Datei sichtbar read("any"), ausgeblendet read("user:<owner>") und read("label:admin"), hochgeladen read("any") und delete("user:<owner>")); Herzen tragen read("user:<id>") des Kontos; Profile (Benutzernamen) tragen genau read("any").
  • Benutzernamen (/api/profil, profiles.js): username muss ^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$ erfüllen (3–20 Zeichen, nur ASCII, kein Trimmen: Leerzeichen sind ungültig), sonst 400 usernameInvalid (auch wenn es keine Zeichenkette ist). Eindeutig ist der Schlüssel usernameKey = der Name klein geschrieben („Kenso“ und „kenso“ sind derselbe Name); gespeichert und angezeigt wird der Name, wie er eingegeben wurde. Gesperrte Schlüssel (400 usernameReserved, in jeder Schreibweise): admin, administrator, root, system, support, moderator, mod, aircard, aircardios, aircard-ios, aircard.ios, aircard_ios, webklar, anonym, anonymous, null, undefined, team, staff, official, offiziell, hilfe, help, kontakt, contact, info. Die Zeile steht in der Tabelle profiles (Spalten username, usernameKey, je höchstens 20 Zeichen): Zeilen-ID = Konto-ID (ein Name je Konto, das Konto kommt aus dem JWT, nie aus dem Body), Recht genau read("any") (öffentlich lesbar; der Client liest die Namen direkt), Tabellenrechte keine. Ablauf je Konto nacheinander (Sperre profile:<Konto-ID>): Gehört der Schlüssel einem anderen Konto (Abfrage equal usernameKey), kommt 409 usernameTaken; die eigene Zeile zählt nicht, die Schreibweise des eigenen Namens zu ändern („kenso“ → „Kenso“) oder denselben Namen nochmal zu senden geht also. Sonst wird die Zeile des Kontos geändert (der alte Name ist damit sofort frei) oder, wenn es noch keine gibt, angelegt. Gleichzeitigkeit: Die Vorabprüfung ist nur der Normalfall mit klarer Antwort; entscheiden tut der Unique-Index username_key auf usernameKey (Schema-Skript). Wollen zwei Konten gleichzeitig denselben Namen, meldet Appwrite dem zweiten beim Anlegen bzw. Ändern 409, und das wird ebenfalls usernameTaken: genau ein Konto bekommt den Namen. Pflicht: /api/veroeffentlichen und /api/logos/hochladen antworten 403 usernameRequired, wenn das Konto keine Zeile in profiles hat (hasProfile, ein GET der Zeile mit der Konto-ID). Die Prüfung läuft nach der Anmeldung und vor allen anderen Prüfungen (Body, Dateien, Karte), es wird nichts angelegt oder angefasst; bestehende Logos und Karten bleiben unberührt. Fehlt die Tabelle profiles (Schema nicht eingespielt), antworten beide Endpunkte 500 „Interner Fehler.“.
  • Reihenfolge bei Mehrschritt-Aktionen (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; Logo löschen blendet zuerst aus und löscht dann Datei, Herzen, Meldungen, Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
  • Logs: Zeilen beginnen mit [aircard-api]. Es gibt kein Zugriffsprotokoll je Anfrage (/api/melden ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen oder nach der zweiten Prüfung entfernt, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen oder Wiederherstellen von delete(owner), profile created bzw. profile updated (Benutzername angelegt bzw. geändert, ohne Namen und Konto-ID); keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID (bei einem Fehler beim Aufräumen eines Logos auch die Datei-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 dem Unique-Index von profiles, der eine zweite Zeile mit demselben usernameKey mit 409 abweist) und greifen nie auf das echte Appwrite zu.