feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer

- common.js (aus app.js herausgelöst, Verhalten gleich): MSG, HttpError, Prüfungen, Rechte-Strings, Melder-Hash,
  Bildmaße und die Dateiprüfungen checkFileExists/checkFileOwnership. Neu: die Logo-Texte in MSG, codeFor,
  likeRowPermissions. app.js exportiert weiter alles wie bisher.
- Fehlerantworten sind jetzt {error, code}: code = MSG-Schlüssel des Textes, dynamische Prüftexte badRequest,
  Appwrite-Fehler unavailable (502) bzw. internal (500).
- logos.js: POST /api/logos/hochladen, /herz, /melden, /loeschen und /api/admin/logo-sichtbarkeit nach dem Vorbild der
  Karten-Endpunkte (Datei prüfen, Zeile anlegen, Datei auf read("any") festlegen, sonst Rücknahme; likeCount und
  reportCount immer aus countRows; ein gemeinsames Rate-Limit für Karten- und Logo-Meldungen).
- appwrite.js: countRows. fake-appwrite.js: Tabellen logos, logo_likes, logo_reports mit Spaltenlängen, Bucket
  logo-images und total in den Listen-Antworten.
- Tests: logos.test.js (59), code-Fälle und MSG-Eindeutigkeit in app.test.js (+7), countRows in appwrite.test.js (+2);
  alle 111 bisherigen Tests unverändert grün (179 gesamt).
- Doku: api/README.md (Endpunkte, Fehlercodes, Logo-Regeln), deploy/README.md (Betrieb kopiert fünf Dateien).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 19:20:25 +00:00
1 parent 88abc2061d
commit dc5de99611
10 files changed
+2181 -286

No files matched your search

+6 -5
View File
@@ -10,7 +10,7 @@ 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/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/api/appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js` | `aircard-ios.org/api/` (nur diese fünf Dateien) | API-Dienst `aircard-api` der Community-Galerie und der Community-Logos, 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).
@@ -25,7 +25,7 @@ mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.or
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/
cp $SRC/api/appwrite.js $SRC/api/common.js $SRC/api/app.js $SRC/api/logos.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
@@ -55,11 +55,12 @@ Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.e
## 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 mit Bildmaß-Prüfung und höchstens 20 Karten je Konto, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern aus verschiedenen Netzen, Admin-Sichtbarkeit). Endpunkte, Statuscodes und Betrieb stehen in [api/README.md](api/README.md).
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie und der Community-Logos mit dem Appwrite-Server-Key aus (Veröffentlichen mit Bildmaß-Prüfung und höchstens 20 Karten je Konto, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern aus verschiedenen Netzen, Admin-Sichtbarkeit; bei den Logos Hochladen mit höchstens 30 Logos je Konto, Herzen, Melden, Löschen, Admin-Sichtbarkeit). Endpunkte, Statuscodes, Fehlercodes 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`. Läuft der Dienst nicht, fehlt auch sein Traefik-Router: nginx antwortet dann auf `/api/*` mit `503` und `{"error":"Der Dienst ist gerade nicht erreichbar."}` (Content-Type `application/json`), nie mit der App.
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`.
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen (alle fünf: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`; `app.js` lädt `common.js` und `logos.js`, ohne sie startet der Dienst nicht), dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`.
- **Logos brauchen das Schema**: Die Logo-Endpunkte schreiben in die Tabellen `logos`, `logo_likes`, `logo_reports` und den Bucket `logo-images`. Das Schema-Skript `web/scripts/appwrite-setup.mjs` legt sie an (zuerst mit `--check` ansehen, was fehlt, siehe [../README.md](../README.md)); das gehört vor den Neustart des API-Dienstes mit den neuen Dateien. Ohne die Tabellen antworten die Logo-Endpunkte mit `500` „Interner Fehler.“, die Karten-Endpunkte laufen weiter.
- **Log-Grenze**: Der Docker-Daemon des Servers hat keine eigenen Log-Optionen, deshalb steht im Service `logging: json-file` mit `max-size: 5m` und `max-file: 2` (höchstens 10 MB). Der Dienst selbst schreibt kein Zugriffsprotokoll und höchstens 120 Zeilen pro Minute.
- **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. Im Docker-Netz `webklar-network` erreichen ihn andere Container trotzdem und könnten `X-Real-Ip` setzen: akzeptiertes Risiko, siehe [api/README.md](api/README.md) (Client-IP). Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
@@ -103,7 +104,7 @@ Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
- `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`.
- `deploy/api/*.js` ändern (die fünf Dateien des Dienstes, nicht die Tests und nicht `fake-appwrite.js`): 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.
+37 -10
View File
@@ -1,19 +1,21 @@
# 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.
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. Deployment: [../README.md](../README.md).
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](../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`) |
| `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 `server.js`, `app.js` und `appwrite.js`.
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
@@ -29,7 +31,7 @@ Fehlt eine Pflichtvariable, nennt die Meldung nur ihren Namen und der Prozess en
## 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`).
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 |
|---|---|---|
@@ -37,9 +39,29 @@ Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>"}`.
| `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}` (= 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.“. 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“.
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
@@ -48,12 +70,17 @@ Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Server
- **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 und `displayName` ≤ 30 Zeichen (getrimmt; leerer Anzeigename = „Anonym“; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. Die Zeilen-ID des Logos ist die Datei-ID: so kann eine Datei nie zwei Logos tragen.
- **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`) → 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`) → Besitz (`403`: kein `delete("user:<ich>")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → 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>` im Log). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest.
- **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>`). 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) 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 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")`).
- **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).
- **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.
- **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/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen, 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
+47 -254
View File
@@ -2,7 +2,9 @@
/**
* 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).
* Community-Galerie und die Community-Logos laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
* Gemeinsame Helfer, Fehlertexte und -codes: common.js. Die Logo-Endpunkte stehen in logos.js; dieses Modul gibt ihnen
* Anmeldung, Sperre je Schlüssel, das (gemeinsame) Rate-Limit und das Log.
*
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
@@ -14,21 +16,50 @@
* 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}
* POST /api/logos/hochladen · /api/logos/herz · /api/logos/melden · /api/logos/loeschen · /api/admin/logo-sichtbarkeit
* siehe logos.js
* 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`.
* Der Appwrite-Key braucht die Console-Scopes documents.read/documents.write (= rows.* der API) und files.read/files.write.
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
* Fehlerantworten sind immer {error: <deutscher Text>}.
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle).
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>} (siehe common.js).
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx; code `unavailable`) bzw. 500 (alle anderen, z. B. falscher Key oder
* fehlende Tabelle; code `internal`).
* Logs: kein Zugriffsprotokoll je Anfrage (sonst steuert ein Angreifer das Log-Volumen), nur Ereigniszeilen
* (veröffentlicht, gemeldet, ausgeblendet, …) und Serverfehler, zusammen höchstens 120 Zeilen pro Minute.
*/
const crypto = require('node:crypto');
const net = require('node:net');
const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
const {
MAX_PUBLICATIONS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
CONTROL_CHARACTERS,
MSG,
HttpError,
isId,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
reporterNetwork,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
} = require('./common.js');
const { createLogoRoutes } = require('./logos.js');
const TABLE_GALLERY = 'gallery';
const TABLE_REPORTS = 'reports';
@@ -38,13 +69,8 @@ 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;
/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
const MAX_PUBLICATIONS = 20;
const REPORT_DELETE_ROUNDS = 50;
/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */
const IMAGE_HEAD_BYTES = 64;
/** Kartenmaße wie web/src/card/geometry.ts (CARD_W, CARD_H, PREVIEW_WIDTH; Vorschauhöhe 303 plus/minus 1). */
const IMAGE_WIDTH = 1536;
const IMAGE_HEIGHT = 969;
@@ -57,90 +83,11 @@ const RATE_SWEEP_MS = 60 * 1000;
const RATE_MAX_KEYS = 50_000;
const LOG_BUDGET_PER_MINUTE = 120;
const LOG_WINDOW_MS = 60 * 1000;
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts.
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
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.',
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
imageFormat: 'Das Bild hat nicht das Kartenformat.',
notYourCard: 'Diese Karte gehört nicht zu deinem Konto.',
tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`,
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")'];
}
/**
* Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit
* Fehlversuche auf). Nur so kommen Dateien in `publish`; die API setzt sie beim Veröffentlichen auf `visibleFilePermissions`.
*/
function uploadFilePermissions(owner) {
assertOwner(owner);
return ['read("any")', `delete("user:${owner}")`];
}
/**
* Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die
* Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen
* löscht die Dateien serverseitig mit dem Key.
*/
function visibleFilePermissions() {
return ['read("any")'];
}
/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */
function hiddenFilePermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")'];
}
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}`);
@@ -151,128 +98,12 @@ 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));
}
/**
* Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
* { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
*/
function parseIp(ip) {
if (typeof ip !== 'string') return { kind: 'none' };
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 { kind: 'v4', text };
if (net.isIPv6(text)) {
const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` };
}
return { kind: 'v6', groups };
}
return { kind: 'other', text: text.toLowerCase().slice(0, 64) };
}
const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':');
/**
* Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): 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) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`;
return parsed.text;
}
/**
* Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4).
* So zählen drei Adressen aus demselben Netz als ein Melder und blenden eine Karte nicht aus.
*/
function reporterNetwork(ip) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`;
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`;
return parsed.text;
}
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) || '';
}
function hmacHex(salt, text) {
return crypto.createHmac('sha256', salt).update(text).digest('hex');
}
/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */
function reporterHash(salt, ip) {
return hmacHex(salt, reporterNetwork(ip));
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/** Fehlername oder -typ fürs Log: nur Wortzeichen, Punkt und Bindestrich, höchstens 60 Zeichen. */
function safeToken(value, fallback = 'unknown') {
const text = String(value === undefined || value === null ? '' : value).replace(/[^\w.-]/g, '').slice(0, 60);
return text === '' ? fallback : text;
}
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang).
* @returns {{width: number, height: number} | null}
*/
function readImageSize(bytes) {
if (!Buffer.isBuffer(bytes)) return null;
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') {
const kind = bytes.toString('latin1', 12, 16);
if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
}
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
const bits = bytes.readUInt32LE(21);
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
}
if (kind === 'VP8X' && bytes.length >= 30) {
return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
}
}
return null;
}
const isCardImageSize = (size) => size !== null && size.width === IMAGE_WIDTH && size.height === IMAGE_HEIGHT;
const isCardPreviewSize = (size) =>
size !== null && size.width === PREVIEW_WIDTH && size.height >= PREVIEW_HEIGHT_MIN && size.height <= PREVIEW_HEIGHT_MAX;
@@ -357,14 +188,6 @@ function buildSearchText({ title = '', issuer = '', tags = [] }) {
// ---- 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, {
@@ -416,17 +239,6 @@ function readRawBody(req) {
});
}
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();
@@ -448,10 +260,6 @@ function createKeyedLock() {
};
}
function isDuplicate(err) {
return err instanceof AppwriteError && err.status === 409;
}
// ---- Die App ---------------------------------------------------------------------------------------
/**
@@ -466,7 +274,7 @@ 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. Zusätzlich gilt ein
// Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten- und Logo-IDs, Zahlen und Fehlertypen. Zusätzlich gilt ein
// Zeilenbudget je Minute; was darüber liegt, wird gezählt und als eine Sammelzeile nachgetragen.
const emit = (message) => log(`[aircard-api] ${new Date().toISOString()} ${message}`);
let logWindowStart = 0;
@@ -489,7 +297,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
}
const withLock = createKeyedLock();
// ---- Rate-Limit je Melder-Hash (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports`)
// ---- Rate-Limit je Anschluss (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports` und `logo_reports`).
// Ein Zähler für Karten- und Logo-Meldungen: logos.js bekommt `rateLimit` von hier.
const hits = new Map();
let lastSweep = 0;
@@ -533,27 +342,6 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
}
// ---- Dateien und Zeilen
/**
* Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im Galerie-Bucket. Metadaten wie Typ und
* Größe prüft erst `checkFileContent`, damit fremde Dateien nichts darüber verraten.
*/
function checkFileExists(file) {
if (!file) throw new HttpError(400, MSG.fileMissing);
if (file.bucketId !== BUCKET) throw new HttpError(403, MSG.foreignFile);
}
/**
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") und read("any"). Eine Datei ohne delete(ich) gehört
* jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Zeilen-Dateien
* hat der Aufrufer vorher mit `used` als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet.
*/
function checkFileOwnership(file, userId) {
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
}
function checkFileContent(file) {
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);
@@ -637,8 +425,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
appwrite.getFile(BUCKET, data.imageFileId),
appwrite.getFile(BUCKET, data.previewFileId),
]);
checkFileExists(image);
checkFileExists(preview);
checkFileExists(image, BUCKET);
checkFileExists(preview, BUCKET);
// Je Autor nacheinander: Limit, Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
return withLock(`owner:${user.id}`, async () => {
@@ -782,6 +570,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
['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 }],
// Logos (logos.js): bekommen Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log von hier.
...createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }),
]);
async function handle(req, res) {
@@ -807,9 +597,11 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
} catch (err) {
let headers = {};
let message = MSG.internal;
let code = 'internal';
if (err instanceof HttpError) {
status = err.status;
message = err.message;
code = err.code;
headers = err.headers;
} else if (err instanceof AppwriteError) {
// Keine Antwort, Appwrite überlastet (429) oder Serverfehler: nicht erreichbar (502). Alles andere
@@ -817,12 +609,13 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
const unavailable = err.status === 0 || err.status === 429 || err.status >= 500;
status = unavailable ? 502 : 500;
message = unavailable ? MSG.unavailable : MSG.internal;
code = unavailable ? 'unavailable' : 'internal';
say(`${req.method} /api/${route.name} -> ${status}: appwrite error status=${err.status} type=${safeToken(err.type)}`);
} else {
status = 500;
say(`${req.method} /api/${route ? route.name : '-'} -> 500: unexpected error ${safeToken(err && err.name)}`);
}
if (!res.headersSent) sendJson(res, status, { error: message }, headers);
if (!res.headersSent) sendJson(res, status, { error: message, code }, headers);
}
}
+171
View File
@@ -9,6 +9,7 @@ const crypto = require('node:crypto');
const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { MSG, HttpError, codeFor } = require('./common.js');
const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
const {
createApp,
@@ -1569,3 +1570,173 @@ test('createApp verlangt Appwrite-Objekt und Salz', () => {
assert.throws(() => createApp({ appwrite: {}, salt: '' }));
assert.throws(() => createApp({ appwrite: {} }));
});
// ---- Fehlercodes: jede Fehlerantwort ist {error, code} -------------------------------------------------
/** Erwartet Status und die Antwort {error: <Text des MSG-Schlüssels>, code: <Schlüssel>} (sonst nichts). */
function expectError(res, status, code, label = code) {
assert.equal(res.status, status, `${label}: Status`);
assert.deepEqual(res.json, { error: MSG[code], code }, label);
}
test('MSG: alle Texte sind paarweise verschieden (der Text bestimmt den Code), die Logo-Texte stehen wörtlich fest', () => {
const texts = Object.values(MSG);
assert.ok(texts.length > 0);
assert.equal(new Set(texts).size, texts.length, 'jeder Text gehört zu genau einem Schlüssel');
assert.ok(texts.every((text) => typeof text === 'string' && text !== ''));
const logoKeys = ['logoGone', 'badLogoId', 'logoNameMissing', 'logoFileType', 'logoFileSize', 'logoFormat', 'logoTaken', 'tooManyLogos', 'badHeart', 'badMono'];
assert.deepEqual(Object.fromEntries(logoKeys.map((key) => [key, MSG[key]])), {
logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.',
badLogoId: 'Ungültige Logo-ID.',
logoNameMissing: 'Bitte gib einen Namen an.',
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).',
logoTaken: 'Diese Datei wird schon für ein Logo verwendet.',
tooManyLogos: 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.',
badHeart: 'Ungültige Angabe zum Herz.',
badMono: 'Ungültige Angabe zu „einfarbig“.',
});
// die bisherigen Texte bleiben unverändert (Auszug)
assert.equal(MSG.cardGone, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(MSG.forbidden, 'Dazu hast du keine Berechtigung.');
assert.equal(MSG.tooManyReports, 'Zu viele Meldungen – bitte später nochmal.');
assert.equal(MSG.tooManyPublications, TOO_MANY_CARDS);
assert.equal(MSG.unavailable, 'Der Dienst ist gerade nicht erreichbar.');
assert.equal(MSG.internal, 'Interner Fehler.');
});
test('codeFor und HttpError: Code = Schlüssel des Textes, sonst badRequest (400) bzw. error', () => {
assert.equal(codeFor(404, MSG.cardGone), 'cardGone');
assert.equal(codeFor(404, MSG.logoGone), 'logoGone');
assert.equal(codeFor(429, MSG.tooManyLogos), 'tooManyLogos');
assert.equal(codeFor(500, MSG.internal), 'internal');
assert.equal(codeFor(400, 'Ungültige Bild-ID.'), 'badRequest', 'dynamischer Prüftext, Status 400');
assert.equal(codeFor(400, MSG.badRequest), 'badRequest');
assert.equal(codeFor(403, 'irgendein anderer Text'), 'error', 'dynamischer Text, anderer Status');
assert.equal(codeFor(409, undefined), 'error');
const withHeaders = new HttpError(429, MSG.tooManyReports, { 'Retry-After': '5' });
assert.ok(withHeaders instanceof Error);
assert.deepEqual([withHeaders.status, withHeaders.message, withHeaders.code, withHeaders.headers], [429, MSG.tooManyReports, 'tooManyReports', { 'Retry-After': '5' }]);
assert.deepEqual(new HttpError(404, MSG.notFound).headers, {});
assert.equal(new HttpError(400, 'Der Titel ist ungültig.').code, 'badRequest');
assert.equal(new HttpError(403, 'Der Titel ist ungültig.').code, 'error');
assert.equal(new HttpError(409, 'x', {}, 'eigener').code, 'eigener', 'ein ausdrücklicher Code gewinnt');
});
test('Fehlerantworten: Anfrage-Fehler (404, 413, 415, 400, 401) haben {error, code}', async () => {
const { call, post, report, fake } = await setup();
seedCard(fake, { id: 'card-9' });
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
expectError(await call('GET', '/api/gibts-nicht'), 404, 'notFound', 'unbekannte Route');
expectError(await call('GET', '/api/melden'), 404, 'notFound', 'falsche Methode');
expectError(await call('POST', '/api/melden', { raw: '{}', contentType: 'text/plain' }), 415, 'jsonOnly');
expectError(await call('POST', '/api/melden', { raw: big }), 413, 'tooLarge');
expectError(await call('POST', '/api/melden', { raw: '{kaputt' }), 400, 'badRequest', 'kaputtes JSON');
expectError(await call('POST', '/api/melden', { raw: '[]' }), 400, 'badRequest', 'Array statt Objekt');
expectError(await report('../x', '203.0.1.9'), 400, 'badCardId');
expectError(await report('card-9', '203.0.1.9', 'gibt-es-nicht'), 400, 'badReason');
expectError(await post('/api/veroeffentlichen', publishBody()), 401, 'unauthorized', 'veröffentlichen ohne JWT');
expectError(await post('/api/zurueckziehen', { id: 'card-9' }, 'xxx.yyy.zzz'), 401, 'unauthorized', 'zurückziehen mit falschem JWT');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }), 401, 'unauthorized', 'Admin ohne JWT');
});
test('Fehlerantworten: /api/melden mit unbekannter ID ist genau {error, code} aus der Spec', async () => {
const { report } = await setup();
const res = await report('gibts-nicht', '203.0.1.9');
assert.equal(res.status, 404);
assert.deepEqual(res.json, { error: 'Diese Karte ist nicht (mehr) verfügbar.', code: 'cardGone' });
});
test('Fehlerantworten: Logik-Fehler der Karten-Endpunkte tragen den MSG-Schlüssel, dynamische Prüftexte badRequest', async () => {
const { post, fake } = await setup();
seedCard(fake, { id: 'card-9', owner: OTHER });
seedUploads(fake);
expectError(await post('/api/zurueckziehen', { id: 'nope' }, JWT_ME), 404, 'cardGone');
expectError(await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME), 403, 'forbidden');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ME), 403, 'adminOnly');
expectError(await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: 'ja' }, JWT_ADMIN), 400, 'badVisibility');
expectError(await post('/api/admin/sichtbarkeit', { id: '../x', sichtbar: true }, JWT_ADMIN), 400, 'badCardId');
expectError(await post('/api/veroeffentlichen', publishBody({ sourceCardId: '../x' }), JWT_ME), 400, 'badCardId');
// Prüftexte mit Zahlen oder Feldnamen haben keinen festen Schlüssel: code badRequest, Text bleibt der genaue
for (const [overrides, text] of [
[{ title: '' }, 'Bitte gib einen Titel an.'],
[{ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).'],
[{ imageFileId: '../x' }, 'Ungültige Bild-ID.'],
[{ tags: ['a', 'b', 'c', 'd', 'e', 'f'] }, 'Bitte gib höchstens 5 Tags an.'],
[{ previewFileId: 'img1' }, 'Bild und Vorschau müssen verschiedene Dateien sein.'],
]) {
const res = await post('/api/veroeffentlichen', publishBody(overrides), JWT_ME);
assert.equal(res.status, 400, text);
assert.deepEqual(res.json, { error: text, code: 'badRequest' }, text);
}
});
test('Fehlerantworten: Datei-, Besitz- und Limit-Fehler beim Veröffentlichen tragen ihren Schlüssel', async () => {
const { post, report, fake } = await setup();
const attempt = (n, { image = {}, preview = {}, card = `card${n}` } = {}, overrides = {}) => {
fake.addFile({ id: `img${n}`, owner: ME, ...image });
fake.addFile({ id: `prev${n}`, owner: ME, mimeType: 'image/webp', ...preview });
if (card) fake.addCard(card, ME);
return post('/api/veroeffentlichen', publishBody({ imageFileId: `img${n}`, previewFileId: `prev${n}`, sourceCardId: `card${n}`, ...overrides }), JWT_ME);
};
expectError(await attempt(1, { image: { owner: OTHER } }), 403, 'foreignFile');
expectError(await attempt(2, { image: { bucket: 'card-images' } }), 403, 'foreignFile', 'falscher Bucket');
expectError(await attempt(3, {}, { previewFileId: 'ghost' }), 400, 'fileMissing');
expectError(await attempt(4, { image: { mimeType: 'image/jpeg' } }), 400, 'fileType');
expectError(await attempt(5, { image: { size: MIB5 + 1 } }), 400, 'fileSize');
expectError(await attempt(6, { image: { content: pngHeader(100, 100) } }), 400, 'imageFormat');
expectError(await attempt(7, { image: { permissions: ['delete("user:user-me")'] } }), 403, 'filesTaken');
expectError(await attempt(8, { card: null }), 403, 'notYourCard');
assert.equal((await attempt(9)).status, 201);
expectError(await attempt(10, {}, { sourceCardId: 'card9' }), 409, 'alreadyPublished');
seedPublications(fake, ME, 19); // mit der Karte oben sind es 20
expectError(await attempt(11), 429, 'tooManyPublications');
// Rate-Limit der Meldungen (die Karte `card9` ist sichtbar veröffentlicht)
const id = galleryRowId(ME, 'card9');
for (let i = 0; i < 10; i += 1) assert.equal((await report(id, '203.0.50.9')).status, 200);
const limited = await report(id, '203.0.50.9');
expectError(limited, 429, 'tooManyReports');
assert.equal(limited.headers['retry-after'], '3600');
});
test('Fehlerantworten: Appwrite-Fehler haben code unavailable (502) bzw. internal (500), unerwartete Fehler internal', async () => {
const { report, fake } = await setup();
seedCard(fake);
const isRowRead = (c) => c.method === 'GET' && c.path.includes('/tables/gallery/rows/');
fake.failNext(isRowRead, { status: 500 });
expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '5xx');
fake.failNext(isRowRead, { status: 429, type: 'general_rate_limit_exceeded' });
expectError(await report('card-1', '203.0.1.9'), 502, 'unavailable', '429');
fake.failNext(isRowRead, { status: 404, type: 'table_not_found' });
expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'fehlende Tabelle');
fake.failNext(isRowRead, { status: 401, type: 'general_unauthorized_scope' });
expectError(await report('card-1', '203.0.1.9'), 500, 'internal', 'falscher Key');
const broken = {
accountFromJwt: async () => {
throw new Error('kaputt GEHEIM');
},
};
const app = createApp({ appwrite: broken, salt: SALT, log: () => {} });
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 } });
expectError(res, 500, 'internal', 'unerwarteter Fehler');
assert.ok(!res.text.includes('GEHEIM'));
});
+17 -2
View File
@@ -7,6 +7,8 @@
* getRow(table, id) Zeile oder null (404 row_not_found)
* listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit, queryCursorAfter);
* eine Antwort ohne `rows`-Array ist ein Fehler (kein stilles [])
* countRows(table, queries) Anzahl aller Treffer (`total` einer Abfrage mit limit(1), ohne eigenes limit
* in `queries`); eine Antwort ohne gültiges `total` ist ein Fehler
* 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 row_not_found (wiederholbar)
@@ -74,6 +76,10 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
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)}`;
const rowsUrl = (table, queries) => {
const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&');
return `${rowsPath(table)}${search === '' ? '' : `?${search}`}`;
};
/** Liest höchstens `limit` Bytes vom Anfang der Antwort und bricht den Rest ab (Dateien können 5 MiB groß sein). */
async function readHead(response, limit) {
@@ -145,14 +151,23 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
},
async listRows(table, queries = []) {
const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&');
const result = await send('GET', `${rowsPath(table)}${search === '' ? '' : `?${search}`}`);
const result = await send('GET', rowsUrl(table, queries));
if (!result.body || !Array.isArray(result.body.rows)) {
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
}
return result.body.rows;
},
/** Zählt die Treffer einer Abfrage über `total` (Appwrite zählt bis 5000): es wird höchstens eine Zeile übertragen. */
async countRows(table, queries = []) {
const result = await send('GET', rowsUrl(table, [...queries, queryLimit(1)]));
const total = result.body && result.body.total;
if (!Number.isInteger(total) || total < 0) {
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
}
return total;
},
async createRow(table, rowId, data, permissions) {
const result = await send('POST', rowsPath(table), { body: { rowId, data, permissions } });
return result.body;
+44 -2
View File
@@ -129,6 +129,47 @@ test('listRows blättert mit cursorAfter', async () => {
assert.deepEqual(last.map((row) => row.$id), ['e']);
});
test('countRows: liefert das total der Abfrage (nicht die Länge der Seite) und schickt limit(1) mit', async () => {
const { fake, appwrite } = await setup();
for (const id of ['a', 'b', 'c', 'd']) {
await appwrite.createRow('reports', id, { galleryId: id === 'd' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
}
assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['g1'])]), 3);
const call = lastCall(fake);
assert.equal(call.method, 'GET');
assert.equal(call.path, '/v1/tablesdb/aircard/tables/reports/rows');
assert.deepEqual(call.search.split('&').map((part) => decodeURIComponent(part)), [
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
'queries[]={"method":"limit","values":[1]}',
]);
assert.equal(call.headers['x-appwrite-key'], fake.key);
assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['nix'])]), 0);
assert.equal(await appwrite.countRows('reports'), 4, 'ohne Abfragen zählt es die ganze Tabelle');
});
test('countRows: fehlendes oder ungültiges total ist ein AppwriteError (502, invalid_response), Appwrite-Fehler werden weitergereicht', async () => {
for (const payload of [{ rows: [] }, { total: '3', rows: [] }, { total: null, rows: [] }, { total: -1, rows: [] }, { total: 1.5, rows: [] }, [], 'text', null]) {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
await assert.rejects(
() => appwrite.countRows('reports', []),
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
JSON.stringify(payload),
);
}
const zero = createAppwrite({ endpoint: 'http://appwrite.invalid/v1', project: 'p', key: 'k', fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }) });
assert.equal(await zero.countRows('reports', []), 0, 'total 0 ist eine gültige Zahl');
const { fake, appwrite } = await setup();
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
await assert.rejects(() => appwrite.countRows('reports', []), (err) => err instanceof AppwriteError && err.status === 500);
await assert.rejects(() => appwrite.countRows('keine-tabelle', []), (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'table_not_found');
});
test('Dateien: lesen, Rechte setzen, löschen', async () => {
const { fake, appwrite } = await setup();
fake.addFile({ id: 'f1', owner: 'u1', mimeType: 'image/webp', size: 1234 });
@@ -335,11 +376,12 @@ test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner We
key: 'k',
fetchImpl: async (url, init) => {
seen.push(init.redirect);
return new Response(JSON.stringify({ rows: [], $id: 'x' }), { status: 200 });
return new Response(JSON.stringify({ total: 0, rows: [], $id: 'x' }), { status: 200 });
},
});
await appwrite.getRow('gallery', 'x');
await appwrite.listRows('gallery', []);
await appwrite.countRows('gallery', []);
await appwrite.createRow('gallery', 'x', {}, []);
await appwrite.updateRow('gallery', 'x', {});
await appwrite.deleteRow('gallery', 'x');
@@ -347,7 +389,7 @@ test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner We
await appwrite.updateFilePermissions('gallery-images', 'x', []);
await appwrite.deleteFile('gallery-images', 'x');
await appwrite.accountFromJwt('a.b.c');
assert.equal(seen.length, 9);
assert.equal(seen.length, 10);
assert.ok(seen.every((value) => value === 'error'));
});
+335
View File
@@ -0,0 +1,335 @@
'use strict';
/**
* Gemeinsame Helfer des API-Dienstes `aircard-api` für die Karten (app.js) und die Logos (logos.js): Fehlertexte und
* -codes, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße. Nur reine Funktionen und Konstanten, kein Zustand.
*
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>}. Texte ohne festen Schlüssel (dynamische
* Prüftexte wie „Der Titel ist zu lang (höchstens 60 Zeichen).“) haben bei Status 400 den code `badRequest`, sonst `error`.
*/
const crypto = require('node:crypto');
const net = require('node:net');
const { AppwriteError } = require('./appwrite.js');
/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
const MAX_PUBLICATIONS = 20;
/** Höchstens so viele Logos je Konto (ausgeblendete zählen mit); Löschen gibt einen Platz frei. */
const MAX_LOGOS = 30;
/** Ab so vielen verschiedenen Meldern wird eine Karte bzw. ein Logo ausgeblendet. */
const HIDE_AT_REPORTS = 3;
/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */
const IMAGE_HEAD_BYTES = 64;
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts.
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
/**
* Alle Fehlertexte der API, Schlüssel = `code` der Fehlerantwort. Die Texte sind paarweise verschieden (ein Test prüft das):
* `codeFor` findet den Schlüssel über den Text. Die Clients übersetzen Meldungen über den Schlüssel; darum ist MSG ein
* schlichtes Objekt aus Texten, das dieses Modul unverändert exportiert.
*/
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.',
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
imageFormat: 'Das Bild hat nicht das Kartenformat.',
notYourCard: 'Diese Karte gehört nicht zu deinem Konto.',
tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`,
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.',
// Logos
logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.',
badLogoId: 'Ungültige Logo-ID.',
logoNameMissing: 'Bitte gib einen Namen an.',
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).',
logoTaken: 'Diese Datei wird schon für ein Logo verwendet.',
tooManyLogos: `Du hast schon ${MAX_LOGOS} Logos hochgeladen. Lösche eins, um ein neues hochzuladen.`,
badHeart: 'Ungültige Angabe zum Herz.',
badMono: 'Ungültige Angabe zu „einfarbig“.',
tooLarge: 'Die Anfrage ist zu groß.',
jsonOnly: 'Nur application/json wird unterstützt.',
unavailable: 'Der Dienst ist gerade nicht erreichbar.',
internal: 'Interner Fehler.',
};
// ---- Fehler ----------------------------------------------------------------------------------------
/** Text -> Schlüssel (die Texte in MSG sind paarweise verschieden, sonst wäre der Code nicht eindeutig). */
const CODE_BY_TEXT = new Map(Object.entries(MSG).map(([key, text]) => [text, key]));
/**
* Der `code` einer Fehlerantwort: der MSG-Schlüssel mit genau diesem Text, sonst `badRequest` bei Status 400 und
* `error` bei jedem anderen Status.
*/
function codeFor(status, message) {
return CODE_BY_TEXT.get(message) || (status === 400 ? 'badRequest' : 'error');
}
class HttpError extends Error {
constructor(status, message, headers = {}, code = codeFor(status, message)) {
super(message);
this.status = status;
this.headers = headers;
this.code = code;
}
}
// ---- 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 Zeile (Karte oder Logo): 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")'];
}
/** Herz (Zeile in `logo_likes`): lesen darf nur das Konto selbst. */
function likeRowPermissions(userId) {
assertOwner(userId);
return [`read("user:${userId}")`];
}
/**
* Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit
* Fehlversuche auf). Nur so kommen Dateien in `publish` (Karten) und `uploadLogo` (Logos); die API setzt sie danach auf
* `visibleFilePermissions`.
*/
function uploadFilePermissions(owner) {
assertOwner(owner);
return ['read("any")', `delete("user:${owner}")`];
}
/**
* Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die
* Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen
* löscht die Dateien serverseitig mit dem Key.
*/
function visibleFilePermissions() {
return ['read("any")'];
}
/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */
function hiddenFilePermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")'];
}
function hash32(text) {
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
}
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));
}
/**
* Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
* { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
*/
function parseIp(ip) {
if (typeof ip !== 'string') return { kind: 'none' };
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 { kind: 'v4', text };
if (net.isIPv6(text)) {
const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` };
}
return { kind: 'v6', groups };
}
return { kind: 'other', text: text.toLowerCase().slice(0, 64) };
}
const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':');
/**
* Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): 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) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`;
return parsed.text;
}
/**
* Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4).
* So zählen drei Adressen aus demselben Netz als ein Melder und blenden keine Karte und kein Logo aus.
*/
function reporterNetwork(ip) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`;
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`;
return parsed.text;
}
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) || '';
}
function hmacHex(salt, text) {
return crypto.createHmac('sha256', salt).update(text).digest('hex');
}
/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */
function reporterHash(salt, ip) {
return hmacHex(salt, reporterNetwork(ip));
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang).
* @returns {{width: number, height: number} | null}
*/
function readImageSize(bytes) {
if (!Buffer.isBuffer(bytes)) return null;
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') {
const kind = bytes.toString('latin1', 12, 16);
if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
}
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
const bits = bytes.readUInt32LE(21);
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
}
if (kind === 'VP8X' && bytes.length >= 30) {
return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
}
}
return null;
}
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;
}
function isDuplicate(err) {
return err instanceof AppwriteError && err.status === 409;
}
// ---- Dateiprüfungen (Karten und Logos) -----------------------------------------------------------------
/**
* Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im erwarteten Bucket. Metadaten wie Typ und
* Größe prüft der Aufrufer erst nach `checkFileOwnership`, damit fremde Dateien nichts darüber verraten.
*/
function checkFileExists(file, bucket) {
if (!file) throw new HttpError(400, MSG.fileMissing);
if (file.bucketId !== bucket) throw new HttpError(403, MSG.foreignFile);
}
/**
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") und read("any"). Eine Datei ohne delete(ich) gehört
* jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Dateien schon
* verwendeter Zeilen hat der Aufrufer vorher als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet.
*/
function checkFileOwnership(file, userId) {
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
// Frisch hochgeladene Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
}
module.exports = {
MAX_PUBLICATIONS,
MAX_LOGOS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
ID_PATTERN,
CONTROL_CHARACTERS,
MSG,
codeFor,
HttpError,
isId,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
reporterNetwork,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
likeRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
};
+52 -13
View File
@@ -9,17 +9,20 @@
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions}
* GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`)
*
* Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an).
* Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an). Es gibt außerdem die Tabellen
* `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports` und die Buckets `gallery-images`, `card-images`, `logo-images`.
*
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Zeilen werden gegen die Spaltenlängen der
* echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im Test auffallen.
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Die Antwort enthält wie bei Appwrite `total`:
* die Zahl aller Treffer der equal-Abfragen, unabhängig von limit und cursorAfter (darauf baut `countRows`).
* 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 BUCKETS = ['gallery-images', 'card-images'];
const BUCKETS = ['gallery-images', 'card-images', 'logo-images'];
/** PNG-Anfang (Signatur + IHDR) mit den Maßen; reicht für die Größenprüfung der API. */
function pngHeader(width, height) {
@@ -80,10 +83,43 @@ const COLUMNS = {
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
reporterHash: { size: 64, required: true },
},
// Spalten wie in web/scripts/appwrite-setup.mjs (Community-Logos)
logos: {
name: { size: 40, required: true },
displayName: { size: 30 },
fileId: { size: 36, required: true },
ownerId: { size: 36, required: true },
mono: { type: 'boolean' },
likeCount: { type: 'integer', min: 0 },
hidden: { type: 'boolean' },
reportCount: { type: 'integer', min: 0 },
},
logo_likes: {
logoId: { size: 36, required: true },
userId: { size: 36, required: true },
},
logo_reports: {
logoId: { size: 36, required: true },
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
reporterHash: { size: 64, required: true },
},
};
/** Standardwerte der Spalten, die Appwrite beim Anlegen einsetzt, wenn die Anfrage sie weglässt. */
const DEFAULTS = {
gallery: { hidden: false, reportCount: 0 },
logos: { mono: false, likeCount: 0, hidden: false, reportCount: 0 },
};
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) {
const tables = { gallery: new Map(), reports: new Map(), cards: new Map() };
const tables = {
gallery: new Map(),
reports: new Map(),
cards: new Map(),
logos: new Map(),
logo_likes: new Map(),
logo_reports: new Map(),
};
const files = new Map();
const contents = new Map();
const users = new Map();
@@ -150,7 +186,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
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.type === 'integer' && (!Number.isInteger(value) || (column.min !== undefined && value < column.min))) return `Invalid ${name}`;
if (column.enum && !column.enum.includes(value)) return `Invalid ${name}`;
if (column.size !== undefined) {
if (typeof value !== 'string') return `Invalid ${name}`;
@@ -231,15 +267,14 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
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 || []);
const row = addRow(table, body.rowId, { ...DEFAULTS[table], ...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 });
const found = runQueries([...store.values()], search);
if (found === null) return error(400, 'general_query_invalid');
return reply(200, found);
}
if (segments.length === 7) {
@@ -339,7 +374,10 @@ function safeParse(text) {
}
}
/** Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage. */
/**
* Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage.
* Liefert {total, rows}: `total` zählt alle Treffer der equal-Abfragen, `rows` ist die Seite nach cursorAfter und limit.
*/
function runQueries(rows, search) {
let result = rows;
let limit = 25;
@@ -364,12 +402,13 @@ function runQueries(rows, search) {
return null;
}
}
const total = result.length;
if (cursor !== null) {
const index = result.findIndex((row) => row.$id === cursor);
if (index === -1) return null;
result = result.slice(index + 1);
}
return result.slice(0, limit);
return { total, rows: result.slice(0, limit) };
}
module.exports = { startFakeAppwrite, pngHeader, webpHeader };
+366
View File
@@ -0,0 +1,366 @@
'use strict';
/**
* Endpunkte der Community-Logos im API-Dienst `aircard-api` (Node 22, nur Bordmittel). Sie folgen den Karten-Endpunkten
* in app.js: gleiche Reihenfolge der Prüfungen, gleiche Rücknahme beim Hochladen, gleiche Melde- und Ausblend-Logik.
* app.js hängt die Routen an und gibt Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log mit.
*
* POST /api/logos/hochladen Nutzer-JWT {name, displayName?, mono?, fileId}
* 401 · 400 Validierung, Datei fehlt, kein PNG, größer als 1 MiB, Maße nicht 8 × 8 bis 720 × 250 · 403 Datei nicht
* meine / falscher Bucket / Datei gesperrt (kein read("any")) · 409 Datei schon für ein Logo verwendet · 429 schon
* 30 Logos · 201 {id} (id = Datei-ID = Zeilen-ID)
* POST /api/logos/herz Nutzer-JWT {id, herz}
* 401 · 400 · 404 fehlt oder ausgeblendet · 200 {herz, likeCount}
* POST /api/logos/melden ohne Anmeldung {id, reason} (wie /api/melden, Rate-Limit gemeinsam)
* 400 · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
* POST /api/logos/loeschen Nutzer-JWT (Hochlader oder Admin) {id}
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
* POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
*
* Tabellen `logos` (Zeilen-ID = Datei-ID, damit eine Datei nie zwei Logos tragen kann), `logo_likes`, `logo_reports`,
* Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows), nie hoch-
* oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo.
* Logs nennen nur Logo-IDs und Zahlen, nie Namen, IPs, Hashes oder JWTs; Herzen werden nicht geloggt.
*/
const { queryEqual, queryLimit } = require('./appwrite.js');
const {
MAX_LOGOS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
CONTROL_CHARACTERS,
MSG,
HttpError,
isId,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
likeRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
} = require('./common.js');
const TABLE_LOGOS = 'logos';
const TABLE_LIKES = 'logo_likes';
const TABLE_REPORTS = 'logo_reports';
const BUCKET = 'logo-images';
const MAX_LOGO_BYTES = 1024 * 1024;
/** Maße des fertigen Logos (wie die Verarbeitung im Editor): 8 × 8 bis 720 × 250 Pixel. */
const LOGO_MIN_SIDE = 8;
const LOGO_MAX_WIDTH = 720;
const LOGO_MAX_HEIGHT = 250;
/** Herzen und Meldungen eines Logos werden in Runden zu 100 gelöscht, höchstens 50 Runden. */
const ROW_BATCH = 100;
const ROW_ROUNDS = 50;
const LOGO_TEXT_FIELDS = [
{ key: 'name', label: 'Der Name', max: 40, required: true },
{ key: 'displayName', label: 'Der Anzeigename', max: 30, required: false },
];
// ---- Reine Helfer ----------------------------------------------------------------------------------
/** Feste Zeilen-ID je Logo und Nutzer: ein Herz pro Konto, Appwrite-409 ersetzt jede Doppelt-Prüfung. */
function logoLikeId(logoId, userId) {
return hash32(`${logoId}|${userId}`);
}
/** Feste Zeilen-ID je Logo und Melder (`logo|` trennt sie von den Karten-Meldungen): eine Meldung pro Melder und Logo. */
function logoReportId(logoId, reporter) {
return hash32(`logo|${logoId}|${reporter}`);
}
function readLogoText(raw, field) {
if (raw === undefined || raw === null) return field.required ? { message: MSG.logoNameMissing } : { value: '' };
if (typeof raw !== 'string') return { message: `${field.label} ist ungültig.` };
const value = raw.trim();
if (value === '' && field.required) return { message: MSG.logoNameMissing };
if (CONTROL_CHARACTERS.test(value)) return { message: `${field.label} enthält ungültige Zeichen.` };
if (value.length > field.max) return { message: `${field.label} ist zu lang (höchstens ${field.max} Zeichen).` };
return { value };
}
/**
* Prüft den Hochladen-Body: Name 1-40, Anzeigename ≤ 30 (getrimmt, ohne Steuer- und Bidi-Zeichen), `mono` fehlt = false
* (sonst muss es ein Boolean sein), `fileId` nach ID_PATTERN.
* @returns {{ok: true, data: {name: string, displayName: string, mono: boolean, fileId: string}} | {ok: false, message: string}}
*/
function validateLogoUpload(body) {
if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest };
const data = {};
for (const field of LOGO_TEXT_FIELDS) {
const result = readLogoText(body[field.key], field);
if (result.message) return { ok: false, message: result.message };
data[field.key] = result.value;
}
if (body.mono !== undefined && typeof body.mono !== 'boolean') return { ok: false, message: MSG.badMono };
data.mono = body.mono === true;
if (!isId(body.fileId)) return { ok: false, message: 'Ungültige Datei-ID.' };
data.fileId = body.fileId;
return { ok: true, data };
}
/** Typ und Größe laut Appwrite (erst nach der Besitzprüfung, damit fremde Dateien nichts darüber verraten). */
function checkLogoContent(file) {
if (file.mimeType !== 'image/png') throw new HttpError(400, MSG.logoFileType);
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_LOGO_BYTES) throw new HttpError(400, MSG.logoFileSize);
}
function isLogoSize(head) {
// readImageSize kennt auch WebP: ein Logo muss einen PNG-Kopf haben, nicht nur den Dateityp laut Appwrite
if (head.toString('latin1', 1, 4) !== 'PNG') return false;
const size = readImageSize(head);
return (
size !== null &&
size.width >= LOGO_MIN_SIDE &&
size.height >= LOGO_MIN_SIDE &&
size.width <= LOGO_MAX_WIDTH &&
size.height <= LOGO_MAX_HEIGHT
);
}
// ---- Die Routen ------------------------------------------------------------------------------------
/**
* @param {object} options
* @param {object} options.appwrite Objekt aus createAppwrite
* @param {string} options.salt Salz für die Melder-Hashes
* @param {(message: string) => void} options.say Ereigniszeile ins Log (mit Zeilenbudget)
* @param {(key: string, fn: () => Promise<any>) => Promise<any>} options.withLock Je Schlüssel nacheinander
* @param {(key: string) => {allowed: boolean, retryAfter?: number}} options.rateLimit das Rate-Limit der Meldungen (gemeinsam mit den Karten)
* @param {(req) => Promise<{id: string, isAdmin: boolean}>} options.requireUser wirft 401 ohne gültiges JWT
* @returns {Array<[string, {name: string, json: true, run: (ctx: {req, raw: string}) => Promise<{status: number, body: object}>}]>}
*/
function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) {
const withLogoLock = (id, fn) => withLock(`logo:${id}`, fn);
/** Setzt die Rechte der Logo-Datei; null heißt: die Datei gibt es nicht (mehr). */
function setFilePermissions(row, permissions) {
return isId(row.fileId) ? appwrite.updateFilePermissions(BUCKET, row.fileId, permissions) : Promise.resolve(null);
}
/** Datei zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
async function hideLogo(id, row, extra = {}) {
await setFilePermissions(row, hiddenFilePermissions(row.ownerId));
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId));
}
/** Löscht alle Zeilen der Tabelle zu diesem Logo, in Runden (die Zeile des Logos selbst bleibt der Anker für eine Wiederholung). */
async function deleteRowsOf(table, logoId) {
for (let round = 0; round < ROW_ROUNDS; round += 1) {
const rows = await appwrite.listRows(table, [queryEqual('logoId', [logoId]), queryLimit(ROW_BATCH)]);
if (rows.length === 0) return;
for (const row of rows) await appwrite.deleteRow(table, row.$id);
}
}
/** reportCount = Anzahl Meldungszeilen des Logos (neu gezählt); ab 3 wird ausgeblendet. */
async function syncReports(id, row) {
const count = await appwrite.countRows(TABLE_REPORTS, [queryEqual('logoId', [id])]);
if (count >= HIDE_AT_REPORTS) {
await hideLogo(id, row, { reportCount: count });
say(`logo ${id} hidden automatically (${count} reports)`);
} else if (row.reportCount !== count) {
await appwrite.updateRow(TABLE_LOGOS, id, { reportCount: count });
}
}
/** Liest den Anfang der Datei und verlangt einen PNG-Kopf mit Maßen von 8 × 8 bis 720 × 250. */
async function checkLogoSize(fileId) {
const head = await appwrite.readFileHead(BUCKET, fileId, IMAGE_HEAD_BYTES);
if (head === null) throw new HttpError(400, MSG.fileMissing);
if (!isLogoSize(head)) throw new HttpError(400, MSG.logoFormat);
}
async function uploadLogo({ req, raw }) {
const user = await requireUser(req);
const validation = validateLogoUpload(parseJsonObject(raw));
if (!validation.ok) throw new HttpError(400, validation.message);
const data = validation.data;
const file = await appwrite.getFile(BUCKET, data.fileId);
checkFileExists(file, BUCKET);
// Je Konto nacheinander: Limit, Prüfung „Datei schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
return withLock(`logo-owner:${user.id}`, async () => {
// Die Zeilen dieses Kontos (auch ausgeblendete), höchstens MAX_LOGOS: ab dort ist das Limit erreicht.
const rows = await appwrite.listRows(TABLE_LOGOS, [queryEqual('ownerId', [user.id]), queryLimit(MAX_LOGOS)]);
if (rows.length >= MAX_LOGOS) throw new HttpError(429, MSG.tooManyLogos);
// Zeilen-ID = Datei-ID: dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung).
if (rows.some((row) => row.$id === data.fileId)) throw new HttpError(409, MSG.logoTaken);
checkFileOwnership(file, user.id);
checkLogoContent(file);
await checkLogoSize(data.fileId);
try {
await appwrite.createRow(
TABLE_LOGOS,
data.fileId,
{
name: data.name,
displayName: data.displayName,
fileId: data.fileId,
ownerId: user.id,
mono: data.mono,
likeCount: 0,
hidden: false,
reportCount: 0,
},
visibleRowPermissions(),
);
} catch (err) {
// Eine Zeile mit dieser ID gehört schon jemandem (z. B. der liegengebliebene Anker eines halb gelöschten Logos).
if (isDuplicate(err)) throw new HttpError(409, MSG.logoTaken);
throw err;
}
// Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen).
// Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen.
try {
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
} catch (err) {
let rolledBack = true;
await appwrite.deleteRow(TABLE_LOGOS, data.fileId).catch(() => {
rolledBack = false;
say(`rollback failed for logo ${data.fileId}`);
});
// Nur wenn keine Zeile mehr auf die Datei zeigt, bekommt sie delete(owner) zurück (der Client räumt damit auf).
if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {});
throw err;
}
say(`logo ${data.fileId} uploaded`);
return { status: 201, body: { id: data.fileId } };
});
}
async function setHeart({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.herz !== 'boolean') throw new HttpError(400, MSG.badHeart);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
const likeId = logoLikeId(id, user.id);
if (body.herz) {
try {
await appwrite.createRow(TABLE_LIKES, likeId, { logoId: id, userId: user.id }, likeRowPermissions(user.id));
} catch (err) {
if (!isDuplicate(err)) throw err; // schon da: kein Fehler
}
} else {
await appwrite.deleteRow(TABLE_LIKES, likeId); // schon weg (false): kein Fehler
}
// Immer neu zählen statt hoch- oder runterzählen: das heilt einen früher steckengebliebenen Zählerstand.
const likeCount = await appwrite.countRows(TABLE_LIKES, [queryEqual('logoId', [id])]);
if (row.likeCount !== likeCount) await appwrite.updateRow(TABLE_LOGOS, id, { likeCount });
return { status: 200, body: { herz: body.herz, likeCount } };
});
}
async function reportLogo({ req, raw }) {
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
const id = body.id;
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt je Anschluss und gilt für Karten und Logos zusammen.
const ip = clientIp(req);
const reporter = reporterHash(salt, ip);
const limit = rateLimit(hmacHex(salt, normalizeIp(ip)));
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
let isNew = true;
try {
await appwrite.createRow(TABLE_REPORTS, logoReportId(id, reporter), { logoId: 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(`logo ${id} reported (${body.reason})`);
return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } };
});
}
async function deleteLogo({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row) throw new HttpError(404, MSG.logoGone);
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.
if (isId(row.fileId)) await appwrite.deleteFile(BUCKET, row.fileId);
await deleteRowsOf(TABLE_LIKES, id);
await deleteRowsOf(TABLE_REPORTS, id);
await appwrite.deleteRow(TABLE_LOGOS, id);
say(`logo ${id} deleted${row.ownerId === user.id ? '' : ' by admin'}`);
return { status: 200, body: { ok: 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.badLogoId);
if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row) throw new HttpError(404, MSG.logoGone);
if (body.sichtbar) {
// Datei, dann Meldungen, Zeile zuletzt: sichtbar wird das Logo erst, wenn alles andere steht.
await setFilePermissions(row, visibleFilePermissions());
await deleteRowsOf(TABLE_REPORTS, id);
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: false, reportCount: 0 }, visibleRowPermissions());
say(`logo ${id} made visible by admin`);
} else {
await hideLogo(id, row);
say(`logo ${id} hidden by admin`);
}
return { status: 200, body: { ok: true } };
});
}
return [
['POST /api/logos/hochladen', { name: 'logos/hochladen', json: true, run: uploadLogo }],
['POST /api/logos/herz', { name: 'logos/herz', json: true, run: setHeart }],
['POST /api/logos/melden', { name: 'logos/melden', json: true, run: reportLogo }],
['POST /api/logos/loeschen', { name: 'logos/loeschen', json: true, run: deleteLogo }],
['POST /api/admin/logo-sichtbarkeit', { name: 'admin/logo-sichtbarkeit', json: true, run: setVisibility }],
];
}
module.exports = { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload };
File diff suppressed because it is too large. Load diff