Die Zeilen-ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten. War sie die Datei-ID, konnte 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.
- logos.js: Zeilen-ID = hash32("<ownerId>|<fileId>") (logoRowId); die Antwort 201 {id} trägt sie. Dieselbe Datei desselben Kontos ergibt dieselbe ID
(409 logoTaken, bei einem Altlogo über die fileId der Zeile); alle anderen Regeln bleiben. Die Logzeilen nennen die Zeilen-ID, die Fehlerzeilen
zusätzlich die Datei-ID (sie folgt nicht mehr aus der Zeilen-ID, und die Zeile ist beim Aufräumen vielleicht schon weg).
- Altlogos (Zeilen-ID = Datei-ID, eines ist live) gehen weiter: Herz, Melden, Löschen und Sichtbarkeit adressieren nur die Zeile und lesen die Datei aus fileId.
- Tests: seedLogo legt Zeile und Datei mit verschiedenen IDs an; neu: logoRowId, die ID eines gelöschten Logos lässt sich nicht neu belegen, anderes Konto
bekommt eine verwendete Datei nicht (403), Altlogo (Herz, Meldung, Ausblenden/Einblenden, Löschen, erneutes Hochladen 409).
- README und Spec: ID-Regel. Der Client (upload-logo.ts) folgt im nächsten Commit; beide gehen zusammen live.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aircard-api
Kleiner HTTP-Dienst (Node 22, keine Abhängigkeiten) für die Community-Galerie und die Community-Logos von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: bei den Karten Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit, bei den Logos Hochladen, Herz setzen, Melden, Löschen und die Admin-Sichtbarkeit. Die Website liest Galerie und Logos direkt aus Appwrite; nur das Schreiben läuft hierüber.
Spec: docs/superpowers/specs/2026-10-04-aircard-community-design.md §7, §8, §11 (Karten) und docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md §6, §8, §9 (Logos). 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 |
common.js |
gemeinsame Helfer: MSG (alle Fehlertexte, Schlüssel = code), HttpError, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße |
appwrite.js |
REST-Client mit Server-Key (createAppwrite, auch countRows) |
*.test.js, fake-appwrite.js |
Tests und Fake-Appwrite im Speicher (gehören nicht auf den Server) |
Auf den Server (/home/webklar/apps/aircard-ios.org/api/) kommen nur die fünf Dateien appwrite.js, common.js, app.js, logos.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?, 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 |
POST /api/logos/hochladen {name, displayName?, mono?, fileId} |
Nutzer-JWT | 201 {id} (Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID) · 401 · 400 Validierung, Datei fehlt („Bild nicht gefunden.“), kein PNG („Das Logo muss eine PNG-Datei sein.“), größer als 1 MiB („Das Logo ist größer als 1 MB.“), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“) · 403 Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“) · 409 „Diese Datei wird schon für ein Logo verwendet.“ · 429 „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.“ |
POST /api/logos/herz {id, herz} |
Nutzer-JWT | 200 {herz, likeCount} · 401 · 400 ID ungültig oder herz kein Boolean · 404 fehlt oder ausgeblendet („Dieses Logo ist nicht (mehr) verfügbar.“) |
POST /api/logos/melden {id, reason} |
keine | 200 {ok:true} oder 200 {schonGemeldet:true} · 400 Grund oder ID · 404 fehlt oder ausgeblendet · 429 „Zu viele Meldungen – bitte später nochmal.“ (mit Retry-After; dasselbe Limit wie /api/melden) |
POST /api/logos/loeschen {id} |
Nutzer-JWT (Hochlader oder Admin) | 200 {ok:true} · 401 · 400 · 403 fremd · 404 fehlt |
POST /api/admin/logo-sichtbarkeit {id, sichtbar} |
Admin-JWT | 200 {ok:true} · 401 · 403 kein Admin · 400 · 404 |
GET /api/gesundheit |
keine | 200 {ok:true} |
Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst 502 „Der Dienst ist gerade nicht erreichbar.“ (code unavailable). Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist 500 „Interner Fehler.“ (code internal). Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ row_not_found bzw. storage_file_not_found als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“ oder „Logo nicht verfügbar“.
Fehlercodes
Jede Fehlerantwort trägt neben dem deutschen Text einen code: den Schlüssel in MSG (common.js), dessen Text genau dem error entspricht. Die Texte in MSG sind paarweise verschieden (ein Test prüft das), der Code ist also eindeutig. Dynamische Prüftexte ohne festen Schlüssel („Der Titel ist zu lang (höchstens 60 Zeichen).“, „Ungültige Bild-ID.“, „Bitte gib höchstens 5 Tags an.“, „Der Name ist zu lang (höchstens 40 Zeichen).“, …) haben bei Status 400 den code badRequest, sonst error (kommt derzeit nicht vor). Der code ist die stabile Schnittstelle für die Clients (z. B. um Meldungen zu übersetzen); MSG ist dafür ein schlichtes Objekt aus Texten, das common.js unverändert exportiert.
| Status | code · Text |
|---|---|
400 |
badRequest „Ungültige Anfrage.“ (auch jeder dynamische Prüftext) · badCardId „Ungültige Karten-ID.“ · badLogoId „Ungültige Logo-ID.“ · badReason „Bitte wähle einen gültigen Grund.“ · badVisibility „Ungültige Angabe zur Sichtbarkeit.“ · badHeart „Ungültige Angabe zum Herz.“ · badMono „Ungültige Angabe zu „einfarbig“.“ · logoNameMissing „Bitte gib einen Namen an.“ · fileMissing „Bild nicht gefunden.“ · fileType „Das Bild muss eine PNG- oder WebP-Datei sein.“ · fileSize „Das Bild ist größer als 5 MB.“ · imageFormat „Das Bild hat nicht das Kartenformat.“ · logoFileType „Das Logo muss eine PNG-Datei sein.“ · logoFileSize „Das Logo ist größer als 1 MB.“ · logoFormat „Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“ |
401 |
unauthorized „Bitte melde dich an.“ |
403 |
forbidden „Dazu hast du keine Berechtigung.“ · adminOnly „Nur für Admins.“ · foreignFile „Diese Datei gehört dir nicht.“ · filesTaken „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ · notYourCard „Diese Karte gehört nicht zu deinem Konto.“ |
404 |
notFound „Nicht gefunden.“ · cardGone „Diese Karte ist nicht (mehr) verfügbar.“ · logoGone „Dieses Logo ist nicht (mehr) verfügbar.“ |
409 |
alreadyPublished „Diese Karte ist schon veröffentlicht.“ · logoTaken „Diese Datei wird schon für ein Logo verwendet.“ |
413 415 |
tooLarge „Die Anfrage ist zu groß.“ · jsonOnly „Nur application/json wird unterstützt.“ |
429 |
tooManyPublications „Du hast schon 20 Karten veröffentlicht. …“ · tooManyLogos „Du hast schon 30 Logos hochgeladen. …“ · tooManyReports „Zu viele Meldungen – bitte später nochmal.“ |
500 502 |
internal „Interner Fehler.“ · unavailable „Der Dienst ist gerade nicht erreichbar.“ |
Regeln im Einzelnen
- IDs: Jede ID im Body (
id,imageFileId,previewFileId,sourceCardId) muss^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$erfüllen (nur Zeichenketten), sonst400.appwrite.jskodiert 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.
tagsist einstring[]: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Gespeichert wird die Spaltetagsals,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:
sourceCardIdmuss eine Zeile der Tabellecardssein, deren$permissionsread("user:<ich>")enthalten, sonst403„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 da429mit dem Text aus der Tabelle. Zurückziehen gibt einen Platz frei. Das Limit liest die Zeilen des Autors überownerId(dort gibt es einen Index) mitlimit(20). - Dateien (beide): liegen im Bucket
gallery-images, tragendelete("user:<ich>"), sindimage/pngoderimage/webpund höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt400; eine fremde403(Typ und Größe fremder Dateien nennt die API nicht). Bildmaße: Die API liest die ersten 64 Bytes beider Dateien (GET …/files/<id>/viewmit 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), sonst400„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üssenread("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), sonst403„Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderensourceCardIdneu 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 genauread("any")(keindelete("user:<ich>")mehr, kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen und schon festgelegte Dateien bekommendelete("user:<ich>")zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, stehtrollback 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 als400„Bild nicht gefunden.“ - Logo-Felder (
/api/logos/hochladen):name1–40 unddisplayName≤ 30 Zeichen (getrimmt; leerer Anzeigename = „Anonym“; dieselben verbotenen Zeichen wie bei den Karten-Texten),monofehlt =false, sonst muss es ein Boolean sein (badMono),fileIdnach dem ID-Muster. Zeilen-ID des Logos =sha256(<ownerId>|<fileId>)(erste 32 Hex), nicht die Datei-ID; die Antwort201 {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 keindelete("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 FeldfileId, darum gehen beide Arten; dieselbe Datei eines Altlogos noch einmal hochzuladen ist ebenfalls409. - Logo-Datei und Reihenfolge: Die Datei liegt im Bucket
logo-images, trägtdelete("user:<ich>")undread("any"), istimage/png, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (401) → Body (400) → Datei da und im richtigen Bucket (400/403) → je Konto nacheinander (Sperrelogo-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 dieserfileId, also auch ein Altlogo) → Besitz (403: keindelete("user:<ich>")oder keinread("any")) → Typ und Größe (400) → Maße (400) → Zeile anlegen (409, wenn die ID schon vergeben ist) → Datei auf genauread("any")setzen (keindelete("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 Dateidelete("user:<ich>")zurück (scheitert schon das Zurücknehmen, stehtrollback failed for logo <id> (file <Datei-ID>)im Log). 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 antwortet400logoFormatbzw.fileMissing; Log:logo <id> removed after upload check (<code>), bleibt etwas stehenrollback 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 bekommtdelete("user:<ich>")zurück). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben403, ohne dass die API ihren Inhalt liest. - Herzen (
/api/logos/herz): ein Herz je Konto und Logo, Zeilen-IDsha256(<logoId>|<userId>)(erste 32 Hex), Rechtread("user:<id>").herz: truelegt die Zeile an (409= schon da, kein Fehler),falselöscht sie (schon weg = kein Fehler). Danach istlikeCountimmercountRowsaller 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 (Sperrelogo:<id>). 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. ein429). Die Zeilen-ID der Meldung istsha256(logo|<logoId>|<reporterHash>),reportCountistcountRowsder Meldungen des Logos, ab 3 wird ausgeblendet (Datei und Zeile aufread("user:<owner>")undread("label:admin")). - Logo löschen und Sichtbarkeit: Löschen (Hochlader oder Admin) entfernt Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (
equal logoId, höchstens 50 Runden). Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (hidden: true), Einblenden setzt sie aufread("any"), löscht die Meldungen und setztreportCountauf 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 istsha256(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 vonX-Forwarded-For, sonst die Socket-Adresse. Der Dienst veröffentlicht nieports:, ist aber im Docker-Netzwebklar-networkfür andere Container erreichbar, und dort giltX-Real-Ipals 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, denclientIpprüft, oder ein eigenes Netz nur für Traefik undaircard-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 Zeileread("user:<owner>"),read("label:admin"); Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt)read("any"),delete("user:<owner>"); Datei veröffentlichtread("any"); Datei ausgeblendetread("user:<owner>"),read("label:admin"). Veröffentlichte und ausgeblendete Dateien tragen keindelete(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: nurread("label:admin")). Für Logos gilt dasselbe (Zeile und Datei sichtbarread("any"), ausgeblendetread("user:<owner>")undread("label:admin"), hochgeladenread("any")unddelete("user:<owner>")); Herzen tragenread("user:<id>")des Kontos. - Reihenfolge bei Mehrschritt-Aktionen (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; Logo löschen ebenso (Datei, Herzen, Meldungen, Zeile). Bleibt etwas hängen (502), heilt der nächste Versuch.
- Logs: Zeilen beginnen mit
[aircard-api]. Es gibt kein Zugriffsprotokoll je Anfrage (/api/meldenist 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; keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (logging: json-file, 5 MB, 2 Dateien).
Tests
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.