diff --git a/README.md b/README.md index 8305512..6107dfd 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Starten nur mit: docker compose up -d aircard-build aircard aircard-api ``` -`aircard-api` ist der kleine API-Dienst der Community-Galerie und der Community-Logos (`deploy/api/`, Route `aircard-ios.org/api`, siehe `deploy/api/README.md`). +`aircard-api` ist der kleine API-Dienst der Community-Galerie, der Community-Logos und der Benutzernamen (`deploy/api/`, Route `aircard-ios.org/api`, siehe `deploy/api/README.md`). Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` geroutet (Vorlage: `deploy/traefik/aircard.yml`): Requests auf `aircard-ios.org/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` gehen an den Appwrite-Container. Ohne diesen Header antwortet der nginx der Seite mit 404. @@ -85,7 +85,7 @@ Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` ger Projekt „AirCard“ auf `appwrite.webklar.com`: - Projekt-ID (öffentlich, von Appwrite generiert): `6ac2130a00289343d6d5`. Sie steht in `deploy/traefik/aircard.yml`, in `/home/webklar/apps/.env` (`AIRCARD_APPWRITE_PROJECT_ID`) und als Standard in `web/src/account/appwrite.ts`. -- Datenbank: `aircard`; Tabellen: `cards` (private Karten), `gallery` und `reports` (Community-Galerie, Meldungen), `logos`, `logo_likes` und `logo_reports` (Community-Logos, Herzen, Meldungen); Buckets: `card-images`, `gallery-images` und `logo-images` (das Skript unten legt sie an und prüft sie) +- Datenbank: `aircard`; Tabellen: `cards` (private Karten), `gallery` und `reports` (Community-Galerie, Meldungen), `logos`, `logo_likes` und `logo_reports` (Community-Logos, Herzen, Meldungen) und `profiles` (Benutzernamen, eine Zeile je Konto, Unique-Index auf dem klein geschriebenen Namen); Buckets: `card-images`, `gallery-images` und `logo-images` (das Skript unten legt sie an und prüft sie) - Console-Einstellungen laut `docs/appwrite-console.md` (Soll-Stand, nicht maschinell geprüft; `--check` prüft nur das Schema): Auth mit E-Mail + Passwort, Sitzungsdauer 30 Tage, max. 5 Sitzungen pro Nutzer, Nutzerlimit 2000, Web-Plattformen `aircard-ios.org` und `localhost`. Schritte 3 und 4 dort (Auth-Härtung, Dienste abschalten) sind optional und am Live-Projekt unbestätigt. **Schema prüfen:** diff --git a/deploy/README.md b/deploy/README.md index 437dc08..c6e5c3b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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`, `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/api/appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js` | `aircard-ios.org/api/` (nur diese sechs Dateien) | API-Dienst `aircard-api` der Community-Galerie, der Community-Logos und der Benutzernamen, 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/common.js $SRC/api/app.js $SRC/api/logos.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/profiles.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,12 +55,13 @@ 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 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). +Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie, der Community-Logos und der Benutzernamen 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; Benutzernamen anlegen und ändern, Veröffentlichen und Hochladen verlangen einen). 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 (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`. +- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen (alle sechs: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`; `app.js` lädt `common.js`, `logos.js` und `profiles.js`, `logos.js` lädt `profiles.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. Die ganze Reihenfolge steht im nächsten Abschnitt. +- **Benutzernamen brauchen das Schema**: `POST /api/profil` schreibt in die Tabelle `profiles` (Zeilen-ID = Konto-ID, Unique-Index `username_key` auf `usernameKey`); dieselbe Tabelle fragen `/api/veroeffentlichen` und `/api/logos/hochladen` als Erstes ab (ohne Zeile für das Konto: `403` `usernameRequired`). Das Schema-Skript legt sie an, dieselben Scopes des Keys `aircard-api` genügen, in der Console ist nichts zu tun. Ohne die Tabelle antworten Hochladen, Veröffentlichen und `/api/profil` mit `500` „Interner Fehler.“, nicht mit `403`. Zwischen dem Neustart des Dienstes und dem Livegang der neuen Seite kann die alte Seite Konten ohne Benutzernamen nicht mehr hochladen oder veröffentlichen lassen (sie zeigt den Text des Dienstes, „Bitte wähle zuerst einen Benutzernamen.“): die neue Seite direkt danach live bringen. - **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. @@ -68,14 +69,14 @@ Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-air Bringt eine Änderung neue Tabellen, neue Endpunkte und eine Seite mit, die beides braucht (wie die Community-Logos), gilt immer diese Reihenfolge. Die Seite geht zuletzt live: sonst ruft sie Endpunkte auf, die es noch nicht gibt, und ein Push von `main` baut sie sofort. -1. **Schema**: `node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check` zeigt, was fehlt (`FEHLT: …`). Ohne `--check` legt das Skript es an. Danach noch einmal mit `--check`: die Ausgabe muss mit `OK: Schema vollständig` enden (Exit-Code 0). Für die Logos sind das die Tabellen `logos`, `logo_likes`, `logo_reports` und der Bucket `logo-images`. -2. **API**: alle fünf Dateien (`appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`) mit `cp` nach `aircard-ios.org/api/` kopieren, dann `cd /home/webklar/apps && docker compose restart aircard-api`. `docker logs webklar-aircard-api` zeigt `listening on :8088`. Prüfen, dass die Logo-Endpunkte da sind: ein `POST` auf `/api/logos/herz` ohne JWT muss mit `401` und `"code":"unauthorized"` antworten (der Header `Content-Type: application/json` gehört dazu, sonst kommt `415`): +1. **Schema**: `node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check` zeigt, was fehlt (`FEHLT: …`). Ohne `--check` legt das Skript es an. Danach noch einmal mit `--check`: die Ausgabe muss mit `OK: Schema vollständig` enden (Exit-Code 0). Für die Logos sind das die Tabellen `logos`, `logo_likes`, `logo_reports` und der Bucket `logo-images`, für die Benutzernamen die Tabelle `profiles` mit dem Unique-Index `username_key`. +2. **API**: alle sechs Dateien (`appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`) mit `cp` nach `aircard-ios.org/api/` kopieren, dann `cd /home/webklar/apps && docker compose restart aircard-api`. `docker logs webklar-aircard-api` zeigt `listening on :8088`. Prüfen, dass die Logo-Endpunkte da sind: ein `POST` auf `/api/logos/herz` ohne JWT muss mit `401` und `"code":"unauthorized"` antworten (der Header `Content-Type: application/json` gehört dazu, sonst kommt `415`): ```sh curl -s -i -X POST -H 'Content-Type: application/json' -d '{}' https://aircard-ios.org/api/logos/herz ``` - Antwortet er mit `404`, läuft noch die alte API (Dateien oder Neustart prüfen). Das Schema prüft dieser Aufruf nicht, denn die Anmeldung kommt vor jedem Zugriff auf Appwrite: dafür ist Schritt 1 da. + Antwortet er mit `404`, läuft noch die alte API (Dateien oder Neustart prüfen); dasselbe gilt für `POST /api/profil` (ohne JWT ebenfalls `401`). Das Schema prüft dieser Aufruf nicht, denn die Anmeldung kommt vor jedem Zugriff auf Appwrite: dafür ist Schritt 1 da. 3. **Seite**: erst jetzt `main` pushen (Gitea-Webhook, siehe unten). Der Build führt `npm ci` und `npm run build` aus: Typprüfung ohne Testdateien (`web/tsconfig.build.json`), dann `vite build`. ## Manuell deployen @@ -118,7 +119,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 (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`. +- `deploy/api/*.js` ändern (die sechs 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. diff --git a/deploy/api/README.md b/deploy/api/README.md index 3e054c6..d3ccded 100644 --- a/deploy/api/README.md +++ b/deploy/api/README.md @@ -1,8 +1,8 @@ # aircard-api -Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie und die Community-Logos von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: bei den Karten Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit, bei den Logos Hochladen, Herz setzen, Melden, Löschen und die Admin-Sichtbarkeit. Die Website liest Galerie und Logos direkt aus Appwrite; nur das Schreiben läuft hierüber. +Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie, die Community-Logos und die Benutzernamen von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: bei den Karten Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit, bei den Logos Hochladen, Herz setzen, Melden, Löschen und die Admin-Sichtbarkeit, bei den Benutzernamen Anlegen und Ändern. Hochladen und Veröffentlichen gehen nur mit Benutzernamen. Die Website liest Galerie, Logos und Benutzernamen direkt aus Appwrite; nur das Schreiben läuft hierüber. -Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11 (Karten) und `docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md` §6, §8, §9 (Logos). 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) und `docs/superpowers/specs/2026-10-05-aircard-benutzernamen-design.md` §2–4 (Benutzernamen). Deployment: [../README.md](../README.md). ## Dateien @@ -11,11 +11,12 @@ Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, | `server.js` | Start: liest die Umgebung, `http.createServer`, Zeitlimits, sauberes Beenden | | `app.js` | HTTP-Anbindung (Routing, Body, Anmeldung, Rate-Limit, Log, Fehlerantworten) und die Karten-Endpunkte (`createApp`) | | `logos.js` | die fünf Logo-Endpunkte (`createLogoRoutes`); bekommt Anmeldung, Sperre je Schlüssel, Rate-Limit und Log von `app.js` | -| `common.js` | gemeinsame Helfer: `MSG` (alle Fehlertexte, Schlüssel = `code`), `HttpError`, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße | +| `profiles.js` | der Benutzernamen-Endpunkt (`createProfileRoutes`) und `hasProfile`, das `app.js` und `logos.js` vor dem Veröffentlichen und Hochladen fragen; bekommt Anmeldung, Sperre je Schlüssel und Log von `app.js` | +| `common.js` | gemeinsame Helfer: `MSG` (alle Fehlertexte, Schlüssel = `code`), `HttpError`, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Namensmuster und Sperrliste der Benutzernamen | | `appwrite.js` | REST-Client mit Server-Key (`createAppwrite`, auch `countRows`) | | `*.test.js`, `fake-appwrite.js` | Tests und Fake-Appwrite im Speicher (gehören nicht auf den Server) | -Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur die fünf Dateien `appwrite.js`, `common.js`, `app.js`, `logos.js` und `server.js`. +Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur die sechs Dateien `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js` und `server.js`. ## Umgebung @@ -35,15 +36,16 @@ Alle Antworten sind JSON; Fehler haben die Form `{"error": "", " | Endpunkt | Auth | Antworten | |---|---|---| -| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falscher Typ oder > 5 MiB, falsche Bildmaße („Das Bild hat nicht das Kartenformat.“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei schon veröffentlicht oder gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“), Karte nicht meine („Diese Karte gehört nicht zu deinem Konto.“) · `409` „Diese Karte ist schon veröffentlicht.“ · `429` „Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.“ | +| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `403` Konto ohne Benutzernamen („Bitte wähle zuerst einen Benutzernamen.“, vor allen anderen Prüfungen) · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falscher Typ oder > 5 MiB, falsche Bildmaße („Das Bild hat nicht das Kartenformat.“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei schon veröffentlicht oder gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“), Karte nicht meine („Diese Karte gehört nicht zu deinem Konto.“) · `409` „Diese Karte ist schon veröffentlicht.“ · `429` „Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.“ | | `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt | | `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) | | `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` | -| `POST /api/logos/hochladen` `{name, displayName?, mono?, fileId}` | Nutzer-JWT | `201 {id}` (Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID) · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), kein PNG („Das Logo muss eine PNG-Datei sein.“), größer als 1 MiB („Das Logo ist größer als 1 MB.“), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“) · `409` „Diese Datei wird schon für ein Logo verwendet.“ · `429` „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.“ | +| `POST /api/logos/hochladen` `{name, displayName?, mono?, fileId}` | Nutzer-JWT | `201 {id}` (Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID) · `401` · `403` Konto ohne Benutzernamen (wie beim Veröffentlichen) · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), kein PNG („Das Logo muss eine PNG-Datei sein.“), größer als 1 MiB („Das Logo ist größer als 1 MB.“), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“) · `409` „Diese Datei wird schon für ein Logo verwendet.“ · `429` „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.“ | | `POST /api/logos/herz` `{id, herz}` | Nutzer-JWT | `200 {herz, likeCount}` · `401` · `400` ID ungültig oder `herz` kein Boolean · `404` fehlt oder ausgeblendet („Dieses Logo ist nicht (mehr) verfügbar.“) | | `POST /api/logos/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`; dasselbe Limit wie `/api/melden`) | | `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt | | `POST /api/admin/logo-sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` | +| `POST /api/profil` `{username}` | Nutzer-JWT | `200 {username}` (Name angelegt oder geändert) · `401` · `400` Muster („Der Benutzername muss 3–20 Zeichen lang sein, …“) oder gesperrter Name („Dieser Benutzername ist nicht erlaubt.“) · `409` „Dieser Benutzername ist schon vergeben.“ | | `GET /api/gesundheit` | keine | `200 {ok:true}` | Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“ (`code` `unavailable`). Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist `500` „Interner Fehler.“ (`code` `internal`). Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ `row_not_found` bzw. `storage_file_not_found` als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“ oder „Logo nicht verfügbar“. @@ -54,11 +56,11 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel | 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).“ | +| `400` | `badRequest` „Ungültige Anfrage.“ (auch jeder dynamische Prüftext) · `badCardId` „Ungültige Karten-ID.“ · `badLogoId` „Ungültige Logo-ID.“ · `badReason` „Bitte wähle einen gültigen Grund.“ · `badVisibility` „Ungültige Angabe zur Sichtbarkeit.“ · `badHeart` „Ungültige Angabe zum Herz.“ · `badMono` „Ungültige Angabe zu „einfarbig“.“ · `logoNameMissing` „Bitte gib einen Namen an.“ · `fileMissing` „Bild nicht gefunden.“ · `fileType` „Das Bild muss eine PNG- oder WebP-Datei sein.“ · `fileSize` „Das Bild ist größer als 5 MB.“ · `imageFormat` „Das Bild hat nicht das Kartenformat.“ · `logoFileType` „Das Logo muss eine PNG-Datei sein.“ · `logoFileSize` „Das Logo ist größer als 1 MB.“ · `logoFormat` „Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“ · `usernameInvalid` „Der Benutzername muss 3–20 Zeichen lang sein, …“ · `usernameReserved` „Dieser Benutzername ist nicht erlaubt.“ | | `401` | `unauthorized` „Bitte melde dich an.“ | -| `403` | `forbidden` „Dazu hast du keine Berechtigung.“ · `adminOnly` „Nur für Admins.“ · `foreignFile` „Diese Datei gehört dir nicht.“ · `filesTaken` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ · `notYourCard` „Diese Karte gehört nicht zu deinem Konto.“ | +| `403` | `forbidden` „Dazu hast du keine Berechtigung.“ · `adminOnly` „Nur für Admins.“ · `foreignFile` „Diese Datei gehört dir nicht.“ · `filesTaken` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ · `notYourCard` „Diese Karte gehört nicht zu deinem Konto.“ · `usernameRequired` „Bitte wähle zuerst einen Benutzernamen.“ | | `404` | `notFound` „Nicht gefunden.“ · `cardGone` „Diese Karte ist nicht (mehr) verfügbar.“ · `logoGone` „Dieses Logo ist nicht (mehr) verfügbar.“ | -| `409` | `alreadyPublished` „Diese Karte ist schon veröffentlicht.“ · `logoTaken` „Diese Datei wird schon für ein Logo verwendet.“ | +| `409` | `alreadyPublished` „Diese Karte ist schon veröffentlicht.“ · `logoTaken` „Diese Datei wird schon für ein Logo verwendet.“ · `usernameTaken` „Dieser Benutzername ist schon vergeben.“ | | `413` `415` | `tooLarge` „Die Anfrage ist zu groß.“ · `jsonOnly` „Nur application/json wird unterstützt.“ | | `429` | `tooManyPublications` „Du hast schon 20 Karten veröffentlicht. …“ · `tooManyLogos` „Du hast schon 30 Logos hochgeladen. …“ · `tooManyReports` „Zu viele Meldungen – bitte später nochmal.“ | | `500` `502` | `internal` „Interner Fehler.“ · `unavailable` „Der Dienst ist gerade nicht erreichbar.“ | @@ -71,16 +73,17 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel - **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:")`, 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//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:")` 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:")` zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht `rollback failed for card ` 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. **Zeilen-ID des Logos** = `sha256(|)` (erste 32 Hex), **nicht** die Datei-ID; die Antwort `201 {id}` trägt diese ID. Dieselbe Datei desselben Kontos ergibt dieselbe ID (`409`). Die ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten; wäre sie die Datei-ID, könnte jeder eine Datei mit genau dieser ID anlegen, als Logo hochladen und so die alte ID neu belegen (gespeicherte Karten anderer zeigten dann sein Bild). Mit dem Konto im Hash geht das nicht. Dass eine Datei nie zwei Logos trägt, sichert der Besitz: nach dem Anlegen hat die Datei kein `delete("user:")` mehr, ein anderes Konto bekommt sie nicht (`403`). **Altlogos** (vor dieser Regel angelegt) haben noch Zeilen-ID = Datei-ID: Herz, Melden, Löschen und Sichtbarkeit adressieren die Zeile nur über ihre ID und die Datei über das Feld `fileId`, darum gehen beide Arten; dieselbe Datei eines Altlogos noch einmal hochzuladen ist ebenfalls `409`. -- **Logo-Datei und Reihenfolge**: Die Datei liegt im Bucket `logo-images`, trägt `delete("user:")` 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:`): schon 30 Logos (`429`, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (`409`: Zeile mit dieser Zeilen-ID oder mit dieser `fileId`, also auch ein Altlogo) → Besitz (`403`: kein `delete("user:")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → ab hier zusätzlich die Sperre des Logos `logo:` (immer erst Konto, dann Logo) bis zur zweiten Prüfung unten: Zeile anlegen (`409`, wenn die ID schon vergeben ist) → Datei auf genau `read("any")` setzen (**kein `delete("user:")` 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:")` zurück (scheitert schon das Zurücknehmen, steht `rollback failed for logo (file )` im Log; scheitert danach die Rückgabe von `delete("user:")`, bleibt die Datei ohne Zeile und ohne dieses Recht liegen und `restore failed for logo (file )` steht im Log, die Antwort bleibt in beiden Fällen der ursprüngliche Fehler). **Zweite Prüfung**: Danach liest die API den Dateianfang noch einmal und prüft PNG-Kopf und Maße erneut. Jetzt kann der Hochlader die Datei nicht mehr ändern; so lässt sich der Inhalt nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen (Löschen und Neuanlegen der Datei). Fällt die Datei jetzt durch oder fehlt sie, löscht die API die Datei mit dem Server-Key und die Zeile (beides wird versucht, auch wenn das erste scheitert) und antwortet `400` `logoFormat` bzw. `fileMissing`; Log: `logo removed after upload check ()`, bleibt etwas stehen `rollback failed for logo (file )`. Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (`502`, Zeile zurückgenommen, die Datei bekommt `delete("user:")` zurück). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest. **Sperre des Logos beim Hochladen**: Die Zeile steht ab dem Anlegen öffentlich in der Liste; ein Herz, eine Meldung oder ein Löschen, das bis zum Ende der zweiten Prüfung eintrifft, wartet auf die Sperre und sieht danach den Endzustand (das fertige Logo oder `404`, wenn die Prüfung es entfernt hat). So bleibt keine Herz- oder Meldungszeile ohne Logo zurück. +- **Logo-Datei und Reihenfolge**: Die Datei liegt im Bucket `logo-images`, trägt `delete("user:")` und `read("any")`, ist `image/png`, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (`401`) → Benutzername (`403` `usernameRequired`) → Body (`400`) → Datei da und im richtigen Bucket (`400`/`403`) → je Konto nacheinander (Sperre `logo-owner:`): schon 30 Logos (`429`, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (`409`: Zeile mit dieser Zeilen-ID oder mit dieser `fileId`, also auch ein Altlogo) → Besitz (`403`: kein `delete("user:")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → ab hier zusätzlich die Sperre des Logos `logo:` (immer erst Konto, dann Logo) bis zur zweiten Prüfung unten: Zeile anlegen (`409`, wenn die ID schon vergeben ist) → Datei auf genau `read("any")` setzen (**kein `delete("user:")` 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:")` zurück (scheitert schon das Zurücknehmen, steht `rollback failed for logo (file )` im Log; scheitert danach die Rückgabe von `delete("user:")`, bleibt die Datei ohne Zeile und ohne dieses Recht liegen und `restore failed for logo (file )` steht im Log, die Antwort bleibt in beiden Fällen der ursprüngliche Fehler). **Zweite Prüfung**: Danach liest die API den Dateianfang noch einmal und prüft PNG-Kopf und Maße erneut. Jetzt kann der Hochlader die Datei nicht mehr ändern; so lässt sich der Inhalt nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen (Löschen und Neuanlegen der Datei). Fällt die Datei jetzt durch oder fehlt sie, löscht die API die Datei mit dem Server-Key und die Zeile (beides wird versucht, auch wenn das erste scheitert) und antwortet `400` `logoFormat` bzw. `fileMissing`; Log: `logo removed after upload check ()`, bleibt etwas stehen `rollback failed for logo (file )`. Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (`502`, Zeile zurückgenommen, die Datei bekommt `delete("user:")` zurück). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest. **Sperre des Logos beim Hochladen**: Die Zeile steht ab dem Anlegen öffentlich in der Liste; ein Herz, eine Meldung oder ein Löschen, das bis zum Ende der zweiten Prüfung eintrifft, wartet auf die Sperre und sieht danach den Endzustand (das fertige Logo oder `404`, wenn die Prüfung es entfernt hat). So bleibt keine Herz- oder Meldungszeile ohne Logo zurück. - **Herzen** (`/api/logos/herz`): ein Herz je Konto und Logo, Zeilen-ID `sha256(|)` (erste 32 Hex), Recht `read("user:")`. `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:`, auch gegen ein gerade laufendes Hochladen dieses Logos). Herzen werden nicht geloggt. - **Logo melden** (`/api/logos/melden`): wie `/api/melden`, mit denselben Gründen, demselben Melder (Netz, gehasht) und **demselben Rate-Limit** (ein gemeinsamer Zähler je Anschluss für Karten und Logos: 6 Karten- und 5 Logo-Meldungen vom selben Anschluss ergeben bei der 11. ein `429`). Die Zeilen-ID der Meldung ist `sha256(logo||)`, `reportCount` ist `countRows` der Meldungen des Logos, ab 3 wird ausgeblendet (Datei und Zeile auf `read("user:")` und `read("label:admin")`). - **Logo löschen und Sichtbarkeit**: Löschen (Hochlader oder Admin) blendet das Logo zuerst aus (Datei, dann Zeile auf die ausgeblendeten Rechte mit `hidden: true`; ein schon ausgeblendetes Logo überspringt das) und entfernt dann Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). Das Ausblenden zuerst verhindert, dass nach einem Abbruch (502) eine öffentliche Zeile mit gelöschter Datei stehen bleibt: unter deren Datei-ID könnte jedes Konto eine neue Datei anlegen, und alle gespeicherten Karten zeigten sie. Es bleibt höchstens eine unsichtbare Zeile (nur Hochlader und Admins lesen sie); die Wiederholung vollendet das Löschen. Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (`hidden: true`), Einblenden setzt sie auf `read("any")`, löscht die Meldungen und setzt `reportCount` auf 0 (die Herzen bleiben). - **Doppelt veröffentlichen**: Die Zeilen-ID der Galerie ist `sha256(ownerId|sourceCardId)` (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei. - **Melden**: Rate-Limit 10 pro Stunde und Anschluss (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge; Schlüssel: gehashte IPv4, IPv6 auf /64 gekürzt, IPv4-gemappte IPv6 als IPv4). Der Melder-Hash ist `HMAC-SHA256(salt, netz)` mit dem **Netz** der Adresse (IPv4 → `/24`, IPv6 → `/48`, IPv4-gemappte IPv6 wie IPv4): drei Adressen aus einem Netz sind ein Melder und blenden eine Karte nicht aus (sonst genügten drei VPN-/Mobilfunk-Adressen). Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig. - **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst veröffentlicht nie `ports:`, ist aber im Docker-Netz `webklar-network` für andere Container erreichbar, und dort gilt `X-Real-Ip` als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den `clientIp` prüft, oder ein eigenes Netz nur für Traefik und `aircard-api`. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen. -- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:")`, `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:")` und `read("label:admin")`, hochgeladen `read("any")` und `delete("user:")`); Herzen tragen `read("user:")` des Kontos. +- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:")`, `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:")` und `read("label:admin")`, hochgeladen `read("any")` und `delete("user:")`); Herzen tragen `read("user:")` des Kontos; Profile (Benutzernamen) tragen genau `read("any")`. +- **Benutzernamen** (`/api/profil`, `profiles.js`): `username` muss `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$` erfüllen (3–20 Zeichen, nur ASCII, kein Trimmen: Leerzeichen sind ungültig), sonst `400` `usernameInvalid` (auch wenn es keine Zeichenkette ist). Eindeutig ist der **Schlüssel** `usernameKey` = der Name klein geschrieben („Kenso“ und „kenso“ sind derselbe Name); gespeichert und angezeigt wird der Name, wie er eingegeben wurde. **Gesperrte Schlüssel** (`400` `usernameReserved`, in jeder Schreibweise): `admin`, `administrator`, `root`, `system`, `support`, `moderator`, `mod`, `aircard`, `aircardios`, `aircard-ios`, `aircard.ios`, `aircard_ios`, `webklar`, `anonym`, `anonymous`, `null`, `undefined`, `team`, `staff`, `official`, `offiziell`, `hilfe`, `help`, `kontakt`, `contact`, `info`. Die Zeile steht in der Tabelle `profiles` (Spalten `username`, `usernameKey`, je höchstens 20 Zeichen): Zeilen-ID = Konto-ID (ein Name je Konto, das Konto kommt aus dem JWT, nie aus dem Body), Recht genau `read("any")` (öffentlich lesbar; der Client liest die Namen direkt), Tabellenrechte keine. Ablauf je Konto nacheinander (Sperre `profile:`): Gehört der Schlüssel einem **anderen** Konto (Abfrage `equal usernameKey`), kommt `409` `usernameTaken`; die eigene Zeile zählt nicht, die Schreibweise des eigenen Namens zu ändern („kenso“ → „Kenso“) oder denselben Namen nochmal zu senden geht also. Sonst wird die Zeile des Kontos geändert (der alte Name ist damit sofort frei) oder, wenn es noch keine gibt, angelegt. **Gleichzeitigkeit**: Die Vorabprüfung ist nur der Normalfall mit klarer Antwort; entscheiden tut der Unique-Index `username_key` auf `usernameKey` (Schema-Skript). Wollen zwei Konten gleichzeitig denselben Namen, meldet Appwrite dem zweiten beim Anlegen bzw. Ändern `409`, und das wird ebenfalls `usernameTaken`: genau ein Konto bekommt den Namen. **Pflicht**: `/api/veroeffentlichen` und `/api/logos/hochladen` antworten `403` `usernameRequired`, wenn das Konto keine Zeile in `profiles` hat (`hasProfile`, ein `GET` der Zeile mit der Konto-ID). Die Prüfung läuft nach der Anmeldung und vor allen anderen Prüfungen (Body, Dateien, Karte), es wird nichts angelegt oder angefasst; bestehende Logos und Karten bleiben unberührt. Fehlt die Tabelle `profiles` (Schema nicht eingespielt), antworten beide Endpunkte `500` „Interner Fehler.“. - **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; Logo löschen blendet zuerst aus und löscht dann Datei, Herzen, Meldungen, Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch. -- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen oder nach der zweiten Prüfung entfernt, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen oder Wiederherstellen von `delete(owner)`; keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID (bei einem Fehler beim Aufräumen eines Logos auch die Datei-ID) und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien). +- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen oder nach der zweiten Prüfung entfernt, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen oder Wiederherstellen von `delete(owner)`, `profile created` bzw. `profile updated` (Benutzername angelegt bzw. geändert, ohne Namen und Konto-ID); keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID (bei einem Fehler beim Aufräumen eines Logos auch die Datei-ID) und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien). ## Tests @@ -88,4 +91,4 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel node --test "deploy/api/*.test.js" ``` -Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen) und greifen nie auf das echte Appwrite zu. +Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen und dem Unique-Index von `profiles`, der eine zweite Zeile mit demselben `usernameKey` mit `409` abweist) und greifen nie auf das echte Appwrite zu. diff --git a/deploy/api/app.js b/deploy/api/app.js index fb39ee2..5c2e1e3 100644 --- a/deploy/api/app.js +++ b/deploy/api/app.js @@ -2,12 +2,13 @@ /** * Routen und Logik des API-Dienstes `aircard-api` (Node 22, nur Bordmittel). Alle Schreibzugriffe auf die - * 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. + * Community-Galerie, die Community-Logos und die Benutzernamen laufen hier über den Appwrite-Server-Key (siehe appwrite.js). + * Gemeinsame Helfer, Fehlertexte und -codes: common.js. Die Logo-Endpunkte stehen in logos.js, der Endpunkt der Benutzernamen in + * profiles.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 / + * 401 kein/ungültiges JWT · 403 `usernameRequired` (das Konto hat noch keinen Benutzernamen; vor allen anderen Prüfungen) · + * 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine / * falscher Bucket / Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet / Karte nicht * meine (`cards`) · 409 schon veröffentlicht · 429 schon 20 Veröffentlichungen · 201 {id} * POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id} @@ -18,12 +19,14 @@ * 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 + * POST /api/profil siehe profiles.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. + * Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> Benutzername (403, nur Veröffentlichen und + * Logo hochladen) -> JSON und Felder (400) -> Logik. * Fehlerantworten sind immer {error: , code: } (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`). @@ -60,6 +63,7 @@ const { checkFileOwnership, } = require('./common.js'); const { createLogoRoutes } = require('./logos.js'); +const { createProfileRoutes, hasProfile } = require('./profiles.js'); const TABLE_GALLERY = 'gallery'; const TABLE_REPORTS = 'reports'; @@ -417,6 +421,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { // ---- Endpunkte async function publish({ req, raw }) { const user = await requireUser(req); + // Ohne Benutzernamen geht nichts weiter: vor allen anderen Prüfungen, es wird nichts angelegt oder angefasst. + if (!(await hasProfile(appwrite, user.id))) throw new HttpError(403, MSG.usernameRequired); const validation = validatePublish(parseJsonObject(raw)); if (!validation.ok) throw new HttpError(400, validation.message); const data = validation.data; @@ -572,6 +578,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { ['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 }), + // Benutzernamen (profiles.js): bekommen Anmeldung, Sperre je Schlüssel und das Log von hier. + ...createProfileRoutes({ appwrite, say, withLock, requireUser }), ]); async function handle(req, res) { diff --git a/deploy/api/app.test.js b/deploy/api/app.test.js index 476fb4a..507fefe 100644 --- a/deploy/api/app.test.js +++ b/deploy/api/app.test.js @@ -33,9 +33,12 @@ const SALT = 'test-salt-0123456789abcdef0123456789abcdef'; const ME = 'user-me'; const OTHER = 'user-other'; const ADMIN = 'user-admin'; +/** Konto ohne Benutzernamen (alle anderen haben aus `setup` ein Profil): Veröffentlichen geht erst mit Profil. */ +const NEWBIE = 'user-new'; const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me'; const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other'; const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin'; +const JWT_NEW = 'eyJhbGciOiJI.payload-new.signature-new'; const MIB5 = 5 * 1024 * 1024; const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.'; const NOT_CARD_FORMAT = 'Das Bild hat nicht das Kartenformat.'; @@ -54,6 +57,9 @@ async function setup() { fake.addUser(JWT_ME, { id: ME }); fake.addUser(JWT_OTHER, { id: OTHER }); fake.addUser(JWT_ADMIN, { id: ADMIN, labels: ['admin'] }); + fake.addUser(JWT_NEW, { id: NEWBIE }); + // Veröffentlichen verlangt einen Benutzernamen (Tabelle `profiles`): ME, OTHER und ADMIN haben einen, NEWBIE nicht. + for (const id of [ME, OTHER, ADMIN]) fake.addProfile(id); const logs = []; const clock = { value: 1_700_000_000_000 }; @@ -406,6 +412,50 @@ test('Veröffentlichen ohne oder mit ungültigem JWT: 401 und nichts angelegt', assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst'); }); +test('Veröffentlichen ohne Benutzernamen: 403 usernameRequired noch vor jeder anderen Prüfung (auch vor Body, Dateien und Karte), nichts angelegt', async () => { + const { post, fake } = await setup(); + seedUploads(fake, NEWBIE); + const publish = (body) => post('/api/veroeffentlichen', body, JWT_NEW); + + expectError(await publish(publishBody()), 403, 'usernameRequired', 'gültiger Body'); + expectError(await publish({}), 403, 'usernameRequired', 'ungültiger Body (kein Titel, keine Dateien)'); + expectError(await publish(publishBody({ title: '' })), 403, 'usernameRequired', 'Titel fehlt'); + expectError(await publish(publishBody({ imageFileId: 'gibts-nicht' })), 403, 'usernameRequired', 'Datei gibt es nicht'); + expectError(await publish(publishBody({ sourceCardId: 'fremde-karte' })), 403, 'usernameRequired', 'Karte gibt es nicht'); + expectError(await publish(null), 403, 'usernameRequired', 'kein Body'); + + assert.equal(fake.rows('gallery').length, 0); + assert.equal(storageCalls(fake).length, 0, 'keine Datei wird angefasst'); + assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-new")'], 'die Dateien bleiben, wie sie waren'); + // Appwrite sieht je Versuch nur die Prüfung des JWT und die Suche nach dem Profil (Zeilen-ID = Konto-ID) + assert.deepEqual( + fake.calls.filter((c) => c.path !== '/v1/account').map((c) => `${c.method} ${c.path}`), + Array(6).fill('GET /v1/tablesdb/aircard/tables/profiles/rows/user-new'), + ); +}); + +test('Veröffentlichen: das Profil gehört dem Konto – ein Profil der anderen Konten hilft nicht, mit eigenem Profil (über /api/profil) geht es', async () => { + const { post, fake } = await setup(); + seedUploads(fake, NEWBIE); + expectError(await post('/api/veroeffentlichen', publishBody(), JWT_NEW), 403, 'usernameRequired'); + + const named = await post('/api/profil', { username: 'Neuling' }, JWT_NEW); + assert.deepEqual(named.json, { username: 'Neuling' }); + const res = await post('/api/veroeffentlichen', publishBody(), JWT_NEW); + assert.equal(res.status, 201); + assert.equal(fake.row('gallery', res.json.id).ownerId, NEWBIE); +}); + +test('Veröffentlichen: lässt sich das Profil nicht prüfen (Appwrite-Fehler), 502 und nichts wird angelegt; der nächste Versuch gelingt', async () => { + const { post, fake } = await setup(); + seedUploads(fake); + fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me'), { status: 500 }); + expectError(await post('/api/veroeffentlichen', publishBody(), JWT_ME), 502, 'unavailable'); + assert.equal(fake.rows('gallery').length, 0); + assert.equal(storageCalls(fake).length, 0); + assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201); +}); + test('Veröffentlichen mit fremder Datei: 403 und nichts angelegt', async () => { const { post, fake } = await setup(); fake.addFile({ id: 'img1', owner: OTHER }); diff --git a/deploy/api/common.js b/deploy/api/common.js index f573c6d..e0c14b3 100644 --- a/deploy/api/common.js +++ b/deploy/api/common.js @@ -1,8 +1,9 @@ '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. + * Gemeinsame Helfer des API-Dienstes `aircard-api` für die Karten (app.js), die Logos (logos.js) und die Benutzernamen + * (profiles.js): Fehlertexte und -codes, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Muster und Sperrliste der Benutzernamen. + * Nur reine Funktionen und Konstanten, kein Zustand. * * Fehlerantworten sind immer {error: , code: }. 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`. @@ -23,6 +24,41 @@ 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}$/; +/** Benutzername: 3-20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich (nur ASCII). */ +const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/; +/** + * Gesperrte Benutzernamen als Schlüssel (klein geschrieben, siehe `usernameKey`): Rollen, der Dienst selbst und Platzhalter, die nach + * einem Konto aussehen. Jeder Eintrag passt zu USERNAME_PATTERN (sonst wäre er wirkungslos: der Name scheiterte schon als ungültig). + */ +const RESERVED_USERNAMES = Object.freeze([ + 'admin', + 'administrator', + 'root', + 'system', + 'support', + 'moderator', + 'mod', + 'aircard', + 'aircardios', + 'aircard-ios', + 'aircard.ios', + 'aircard_ios', + 'webklar', + 'anonym', + 'anonymous', + 'null', + 'undefined', + 'team', + 'staff', + 'official', + 'offiziell', + 'hilfe', + 'help', + 'kontakt', + 'contact', + 'info', +]); +const RESERVED_USERNAME_SET = new Set(RESERVED_USERNAMES); // 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]/; @@ -63,6 +99,12 @@ const MSG = { 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“.', + // Benutzernamen + usernameInvalid: + 'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.', + usernameReserved: 'Dieser Benutzername ist nicht erlaubt.', + usernameTaken: 'Dieser Benutzername ist schon vergeben.', + usernameRequired: 'Bitte wähle zuerst einen Benutzernamen.', tooLarge: 'Die Anfrage ist zu groß.', jsonOnly: 'Nur application/json wird unterstützt.', unavailable: 'Der Dienst ist gerade nicht erreichbar.', @@ -97,6 +139,19 @@ function isId(value) { return typeof value === 'string' && ID_PATTERN.test(value); } +/** + * Der Schlüssel eines Benutzernamens (Spalte `usernameKey`): der Name klein geschrieben. Über ihn ist ein Name nur einmal + * vergeben, egal wie er geschrieben ist („Kenso“ und „kenso“ sind derselbe). Für Zeichenketten, die schon USERNAME_PATTERN erfüllen. + */ +function usernameKey(name) { + return name.toLowerCase(); +} + +/** Steht der Schlüssel dieses Namens auf der Sperrliste? (In jeder Schreibweise: „Admin“, „AirCard-IOS“.) */ +function isReservedUsername(name) { + return RESERVED_USERNAME_SET.has(usernameKey(name)); +} + function assertOwner(owner) { if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID'); return owner; @@ -309,11 +364,15 @@ module.exports = { IMAGE_HEAD_BYTES, REASONS, ID_PATTERN, + USERNAME_PATTERN, + RESERVED_USERNAMES, CONTROL_CHARACTERS, MSG, codeFor, HttpError, isId, + usernameKey, + isReservedUsername, isPlainObject, isDuplicate, parseJsonObject, diff --git a/deploy/api/fake-appwrite.js b/deploy/api/fake-appwrite.js index b874962..4adab74 100644 --- a/deploy/api/fake-appwrite.js +++ b/deploy/api/fake-appwrite.js @@ -10,7 +10,10 @@ * 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). Es gibt außerdem die Tabellen - * `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports` und die Buckets `gallery-images`, `card-images`, `logo-images`. + * `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` und die Buckets `gallery-images`, `card-images`, + * `logo-images`. `profiles` hat wie in Appwrite einen Unique-Index auf `usernameKey` (UNIQUE_INDEXES): eine zweite Zeile mit + * demselben Schlüssel, angelegt oder per PATCH geändert, antwortet 409 `row_already_exists` (so meldet Appwrite auch eine + * Verletzung des Unique-Index); Testhilfe: `addProfile`. * * Abfragen: `queries[]=` 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`). @@ -18,8 +21,8 @@ * 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. - * Testhilfen: failNext/hangNext/delayNext (Aufruf scheitert, hängt, wird verzögert) und swapContentAfterPermissions - * (Dateiinhalt ändert sich nach dem nächsten Rechte-Update). + * Testhilfen: failNext/hangNext/delayNext/holdNext (Aufruf scheitert, hängt, wird verzögert, wartet auf die Freigabe des Tests) + * und swapContentAfterPermissions (Dateiinhalt ändert sich nach dem nächsten Rechte-Update). */ const http = require('node:http'); @@ -105,6 +108,16 @@ const COLUMNS = { reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true }, reporterHash: { size: 64, required: true }, }, + // Benutzernamen: Zeilen-ID = Konto-ID, `usernameKey` = der Name klein geschrieben (Unique-Index) + profiles: { + username: { size: 20, required: true }, + usernameKey: { size: 20, required: true }, + }, +}; + +/** Unique-Indizes wie in web/scripts/appwrite-setup.mjs: je Tabelle die Spalten, deren Werte nur in einer Zeile vorkommen dürfen. */ +const UNIQUE_INDEXES = { + profiles: ['usernameKey'], }; /** Standardwerte der Spalten, die Appwrite beim Anlegen einsetzt, wenn die Anfrage sie weglässt. */ @@ -121,6 +134,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret logos: new Map(), logo_likes: new Map(), logo_reports: new Map(), + profiles: new Map(), }; const files = new Map(); const contents = new Map(); @@ -162,6 +176,11 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret return addRow('cards', id, { name: 'Karte' }, permissions || [`read("user:${owner}")`, `update("user:${owner}")`, `delete("user:${owner}")`]); } + /** Benutzername eines Kontos (Tabelle `profiles`) mit den Rechten, die die API vergibt: Zeilen-ID = Konto-ID, öffentlich lesbar. */ + function addProfile(userId, username = userId) { + return addRow('profiles', userId, { username, usernameKey: username.toLowerCase() }, ['read("any")']); + } + function addRow(table, id, data, permissions) { const row = { $id: id, $createdAt: new Date(1_700_000_000_000 + (++sequence) * 1000).toISOString(), $permissions: permissions, ...data }; tables[table].set(id, row); @@ -183,6 +202,20 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret injections.push({ predicate, delayMs }); } + /** + * Nächster passender Aufruf wartet, bis die zurückgegebene Funktion aufgerufen wird (er steht dann schon in `calls`). So erzwingt + * ein Test eine Reihenfolge ohne Zeitannahmen, etwa: beide Anfragen haben vorab geprüft, bevor eine von ihnen schreibt. + * @returns {() => void} gibt den Aufruf frei + */ + function holdNext(predicate) { + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + injections.push({ predicate, gate }); + return release; + } + /** * Nach dem nächsten Rechte-Update (PUT) der Datei `id` hat sie den Inhalt `content` (einmalig). So lässt sich eine Datei * nachstellen, die zwischen der ersten Prüfung und dem Festlegen der Rechte ausgetauscht wurde: der Anfang ist beim ersten @@ -213,6 +246,13 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret return null; } + /** Hat eine andere Zeile der Tabelle schon diese Werte in den Spalten eines Unique-Index? (`values` = die Zeile, wie sie danach aussähe.) */ + function violatesUnique(table, id, values) { + const columns = UNIQUE_INDEXES[table]; + if (!columns) return false; + return [...tables[table].values()].some((row) => row.$id !== id && columns.every((name) => row[name] === values[name])); + } + const server = http.createServer((req, res) => { const chunks = []; req.on('data', (chunk) => chunks.push(chunk)); @@ -244,7 +284,9 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret if (injectionIndex !== -1) { const [entry] = injections.splice(injectionIndex, 1); if (entry.hang) return; - if (entry.delayMs) { + if (entry.gate) { + await entry.gate; + } else if (entry.delayMs) { await new Promise((resolve) => setTimeout(resolve, entry.delayMs)); } else { return error(entry.status, entry.type); @@ -279,7 +321,9 @@ 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 row = addRow(table, body.rowId, { ...DEFAULTS[table], ...data }, body.permissions || []); + const values = { ...DEFAULTS[table], ...data }; + if (violatesUnique(table, body.rowId, values)) return error(409, 'row_already_exists'); + const row = addRow(table, body.rowId, values, body.permissions || []); return reply(201, row); } @@ -302,6 +346,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret const data = body.data || {}; const problem = validateData(table, data, { partial: true }); if (problem) return error(400, 'document_invalid_structure', problem); + if (violatesUnique(table, segments[6], { ...row, ...data })) return error(409, 'row_already_exists'); Object.assign(row, data); if (Array.isArray(body.permissions)) row.$permissions = body.permissions; return reply(200, row); @@ -360,10 +405,12 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret addRawUser, addFile, addCard, + addProfile, addRow, failNext, hangNext, delayNext, + holdNext, swapContentAfterPermissions, row: (table, id) => tables[table].get(id) || null, rows: (table) => [...tables[table].values()], diff --git a/deploy/api/logos.js b/deploy/api/logos.js index c9a9321..fc04170 100644 --- a/deploy/api/logos.js +++ b/deploy/api/logos.js @@ -6,7 +6,8 @@ * 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 + * 401 · 403 `usernameRequired` (das Konto hat noch keinen Benutzernamen; vor allen anderen Prüfungen) · 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 = Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID) * Nach dem Festlegen der Datei auf read("any") wird ihr Anfang noch einmal geprüft (der Hochlader kann sie dann nicht @@ -57,6 +58,7 @@ const { checkFileExists, checkFileOwnership, } = require('./common.js'); +const { hasProfile } = require('./profiles.js'); const TABLE_LOGOS = 'logos'; const TABLE_LIKES = 'logo_likes'; @@ -230,6 +232,8 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse async function uploadLogo({ req, raw }) { const user = await requireUser(req); + // Ohne Benutzernamen geht nichts weiter: vor allen anderen Prüfungen, es wird nichts angelegt oder angefasst. + if (!(await hasProfile(appwrite, user.id))) throw new HttpError(403, MSG.usernameRequired); const validation = validateLogoUpload(parseJsonObject(raw)); if (!validation.ok) throw new HttpError(400, validation.message); const data = validation.data; diff --git a/deploy/api/logos.test.js b/deploy/api/logos.test.js index ff33a4a..d659980 100644 --- a/deploy/api/logos.test.js +++ b/deploy/api/logos.test.js @@ -27,9 +27,12 @@ const SALT = 'test-salt-0123456789abcdef0123456789abcdef'; const ME = 'user-me'; const OTHER = 'user-other'; const ADMIN = 'user-admin'; +/** Konto ohne Benutzernamen (alle anderen haben aus `setup` ein Profil): Hochladen geht erst mit Profil. */ +const NEWBIE = 'user-new'; const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me'; const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other'; const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin'; +const JWT_NEW = 'eyJhbGciOiJI.payload-new.signature-new'; const MIB = 1024 * 1024; const LOGO_BUCKET = 'logo-images'; /** Rechte einer frisch hochgeladenen Logo-Datei von ME. */ @@ -48,6 +51,9 @@ async function setup() { fake.addUser(JWT_ME, { id: ME }); fake.addUser(JWT_OTHER, { id: OTHER }); fake.addUser(JWT_ADMIN, { id: ADMIN, labels: ['admin'] }); + fake.addUser(JWT_NEW, { id: NEWBIE }); + // Hochladen verlangt einen Benutzernamen (Tabelle `profiles`): ME, OTHER und ADMIN haben einen, NEWBIE nicht. + for (const id of [ME, OTHER, ADMIN]) fake.addProfile(id); const logs = []; const clock = { value: 1_700_000_000_000 }; @@ -159,6 +165,8 @@ function seedReports(fake, logoId, hashes) { const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32); const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account'); +/** Die Aufrufe bei Appwrite außer dem Konto und der Suche nach dem Profil von ME (die macht jede Anfrage zum Hochladen als Erstes). */ +const callsBeyondProfile = (fake) => nonAccountCalls(fake).filter((c) => !(c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me'))); const filePuts = (fake, id) => fake.calls.filter((c) => c.method === 'PUT' && c.path.endsWith(`/files/${id}`)); const fileReads = (fake, id) => fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith(`/files/${id}/view`)); @@ -319,6 +327,47 @@ test('Logo hochladen ohne oder mit ungültigem JWT: 401, nichts angelegt, die Da assert.equal(fake.calls.filter((c) => c.path === '/v1/account').length, 1, 'fehlendes oder kaputtes JWT erreicht Appwrite gar nicht erst'); }); +test('Logo hochladen ohne Benutzernamen: 403 usernameRequired noch vor jeder anderen Prüfung (auch vor Body und Datei), nichts angelegt, Datei unberührt', async () => { + const { upload, post, fake } = await setup(); + logoFile(fake, 'logo1', NEWBIE); + expectError(await upload(uploadBody(), JWT_NEW), 403, 'usernameRequired', 'gültiger Body'); + expectError(await upload({}, JWT_NEW), 403, 'usernameRequired', 'ungültiger Body (kein Name, keine Datei)'); + expectError(await upload(uploadBody({ fileId: 'gibts-nicht' }), JWT_NEW), 403, 'usernameRequired', 'Datei gibt es nicht'); + expectError(await upload(uploadBody({ fileId: 'logo1', mono: 'ja' }), JWT_NEW), 403, 'usernameRequired', 'mono ungültig'); + expectError(await post('/api/logos/hochladen', null, JWT_NEW), 403, 'usernameRequired', 'kein Body'); + + assert.equal(fake.rows('logos').length, 0); + assert.deepEqual(fake.file('logo1').$permissions, ['read("any")', 'delete("user:user-new")'], 'die Datei bleibt, wie sie war'); + assert.equal(fake.calls.filter((c) => c.path.startsWith('/v1/storage/')).length, 0, 'keine Datei wird angefasst'); + // Appwrite sieht je Versuch nur die Prüfung des JWT und die Suche nach dem Profil (Zeilen-ID = Konto-ID) + assert.deepEqual( + nonAccountCalls(fake).map((c) => `${c.method} ${c.path}`), + Array(5).fill('GET /v1/tablesdb/aircard/tables/profiles/rows/user-new'), + ); +}); + +test('Logo hochladen: das Profil gehört dem Konto – ein Profil der anderen Konten hilft nicht, mit eigenem Profil (über /api/profil) geht es', async () => { + const { upload, post, fake } = await setup(); + logoFile(fake, 'logo1', NEWBIE); + expectError(await upload(uploadBody(), JWT_NEW), 403, 'usernameRequired'); + + const named = await post('/api/profil', { username: 'Neuling' }, JWT_NEW); + assert.deepEqual(named.json, { username: 'Neuling' }); + const res = await upload(uploadBody(), JWT_NEW); + assert.equal(res.status, 201); + assert.equal(fake.row('logos', rowOf('logo1', NEWBIE)).ownerId, NEWBIE); +}); + +test('Logo hochladen: lässt sich das Profil nicht prüfen (Appwrite-Fehler), 502 und nichts wird angelegt; der nächste Versuch gelingt', async () => { + const { upload, fake } = await setup(); + logoFile(fake, 'logo1'); + fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-me'), { status: 500 }); + expectError(await upload(uploadBody()), 502, 'unavailable'); + assert.equal(fake.rows('logos').length, 0); + assert.deepEqual(fake.file('logo1').$permissions, UPLOAD_PERMISSIONS); + assert.equal((await upload(uploadBody())).status, 201); +}); + test('Logo hochladen mit fremder, falscher oder gesperrter Datei: 403, keine Zeile, Datei unverändert, Inhalt wird nicht gelesen', async () => { const { upload, fake } = await setup(); logoFile(fake, 'fremd', OTHER); @@ -698,7 +747,7 @@ test('Logo hochladen: scheitert das Aufräumen nach einer Beanstandung, steht `r assert.equal(fake.row('logos', rowOf('logo2')), null); }); -test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer dem Konto) nicht an', async () => { +test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer Konto und Profil) nicht an', async () => { const { upload, fake } = await setup(); logoFile(fake, 'logo1'); @@ -741,7 +790,7 @@ test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst A } assert.equal(fake.rows('logos').length, 0); - assert.equal(nonAccountCalls(fake).length, 0, 'ungültige Anfragen erreichen Appwrite (außer dem Konto) nie'); + assert.equal(callsBeyondProfile(fake).length, 0, 'ungültige Anfragen erreichen Appwrite (außer Konto und Profil) nie'); }); test('Logo hochladen: kaputtes JSON, Array oder leerer Body ist 400 badRequest', async () => { @@ -750,7 +799,7 @@ test('Logo hochladen: kaputtes JSON, Array oder leerer Body ist 400 badRequest', const res = await call('POST', '/api/logos/hochladen', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } }); expectError(res, 400, 'badRequest', JSON.stringify(raw)); } - assert.equal(nonAccountCalls(fake).length, 0); + assert.equal(callsBeyondProfile(fake).length, 0); }); test('Logo hochladen: gleichzeitig dieselbe Datei oder zwei Dateien am Limit, genau eine Anfrage gelingt (Sperre je Konto)', async () => { diff --git a/deploy/api/profiles.js b/deploy/api/profiles.js new file mode 100644 index 0000000..44173a9 --- /dev/null +++ b/deploy/api/profiles.js @@ -0,0 +1,88 @@ +'use strict'; + +/** + * Benutzernamen im API-Dienst `aircard-api` (Node 22, nur Bordmittel): ein Name je Konto, eindeutig ohne Rücksicht auf Groß- und + * Kleinschreibung. app.js hängt die Route an und gibt Anmeldung, Sperre je Schlüssel und das Log mit (wie bei logos.js). + * + * POST /api/profil Nutzer-JWT {username} + * 401 · 400 `usernameInvalid` (Muster) · 400 `usernameReserved` (Sperrliste) · 409 `usernameTaken` (ein anderes Konto hat den + * Namen) · 200 {username} + * Legt die Zeile des Kontos an (Zeilen-ID = Konto-ID, Recht genau read("any")) oder ändert sie: der alte Name ist damit sofort + * wieder frei. Die eigene Schreibweise zu ändern („kenso“ → „Kenso“) ist erlaubt. + * + * Tabelle `profiles`: `username` (der Name, wie eingegeben) und `usernameKey` (klein geschrieben). Eindeutig ist `usernameKey`: die + * Vorabprüfung fängt den Normalfall mit einer klaren Antwort ab, bei einem Rennen zweier Konten entscheidet der Unique-Index + * `username_key` (Appwrite antwortet 409, das wird ebenfalls `usernameTaken`). Sperre je Konto: `profile:`. + * Hochladen und Veröffentlichen verlangen ein Profil (`hasProfile`, 403 `usernameRequired`), siehe logos.js und app.js. + * Logs nennen nie Namen, Schlüssel, Konto-IDs oder JWTs: nur „profile created“ bzw. „profile updated“. + */ + +const { queryEqual, queryLimit } = require('./appwrite.js'); +const { + MSG, + USERNAME_PATTERN, + HttpError, + isDuplicate, + parseJsonObject, + visibleRowPermissions, + usernameKey, + isReservedUsername, +} = require('./common.js'); + +const TABLE_PROFILES = 'profiles'; + +/** + * Hat das Konto einen Benutzernamen? Die Zeilen-ID des Profils ist die Konto-ID. Ein Appwrite-Fehler wird nicht verschluckt + * (auch eine fehlende Tabelle nicht): der Aufrufer antwortet dann mit 502 bzw. 500, nie mit „kein Profil“. + * @param {object} appwrite Objekt aus createAppwrite + * @param {string} userId Konto-ID + * @returns {Promise} + */ +async function hasProfile(appwrite, userId) { + return (await appwrite.getRow(TABLE_PROFILES, userId)) !== null; +} + +/** + * @param {object} options + * @param {object} options.appwrite Objekt aus createAppwrite + * @param {(message: string) => void} options.say Ereigniszeile ins Log (mit Zeilenbudget) + * @param {(key: string, fn: () => Promise) => Promise} options.withLock Je Schlüssel nacheinander + * @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 createProfileRoutes({ appwrite, say, withLock, requireUser }) { + async function saveProfile({ req, raw }) { + const user = await requireUser(req); + const body = parseJsonObject(raw); + // Kein Trimmen: das Muster lässt keine Leerzeichen zu, der Client schickt den Namen schon bereinigt. + const { username } = body; + if (typeof username !== 'string' || !USERNAME_PATTERN.test(username)) throw new HttpError(400, MSG.usernameInvalid); + if (isReservedUsername(username)) throw new HttpError(400, MSG.usernameReserved); + const key = usernameKey(username); + + // Je Konto nacheinander: Nachsehen, ob es schon eine Zeile gibt, und Anlegen bzw. Ändern dürfen sich nicht überholen + // (zwei Anfragen desselben Kontos würden sonst beide anlegen wollen und die zweite fälschlich „vergeben“ melden). + return withLock(`profile:${user.id}`, async () => { + // Vorabprüfung: hat ein anderes Konto diesen Schlüssel? Der eigene Eintrag zählt nicht (nur die Schreibweise ändern ist erlaubt). + const holders = await appwrite.listRows(TABLE_PROFILES, [queryEqual('usernameKey', [key]), queryLimit(1)]); + if (holders.some((row) => row.$id !== user.id)) throw new HttpError(409, MSG.usernameTaken); + + const own = await appwrite.getRow(TABLE_PROFILES, user.id); + try { + if (own) await appwrite.updateRow(TABLE_PROFILES, user.id, { username, usernameKey: key }); + else await appwrite.createRow(TABLE_PROFILES, user.id, { username, usernameKey: key }, visibleRowPermissions()); + } catch (err) { + // Ein anderes Konto war schneller: der Unique-Index auf usernameKey meldet 409, beim Anlegen wie beim Ändern. Ein 409 wegen der + // eigenen Zeilen-ID gibt es nicht: unter der Sperre des Kontos gibt es die Zeile jetzt (dann ändern wir) oder nicht (dann legen wir an). + if (isDuplicate(err)) throw new HttpError(409, MSG.usernameTaken); + throw err; + } + say(own ? 'profile updated' : 'profile created'); + return { status: 200, body: { username } }; + }); + } + + return [['POST /api/profil', { name: 'profil', json: true, run: saveProfile }]]; +} + +module.exports = { createProfileRoutes, hasProfile }; diff --git a/deploy/api/profiles.test.js b/deploy/api/profiles.test.js new file mode 100644 index 0000000..95bd5d7 --- /dev/null +++ b/deploy/api/profiles.test.js @@ -0,0 +1,496 @@ +'use strict'; + +// Tests für deploy/api/profiles.js (Benutzernamen): echte HTTP-Anfragen auf Port 0 gegen createApp, dahinter ein Fake-Appwrite im +// Speicher (fake-appwrite.js, Tabelle `profiles` mit Unique-Index auf `usernameKey`) und das echte appwrite.js. +// Aufruf: node --test "deploy/api/*.test.js" + +const { test, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); + +const { createAppwrite, AppwriteError } = require('./appwrite.js'); +const { startFakeAppwrite } = require('./fake-appwrite.js'); +const { createApp } = require('./app.js'); +const { createProfileRoutes, hasProfile } = require('./profiles.js'); +const { MSG, USERNAME_PATTERN, RESERVED_USERNAMES, usernameKey, isReservedUsername } = require('./common.js'); + +const SALT = 'test-salt-0123456789abcdef0123456789abcdef'; +const ME = 'user-me'; +const OTHER = 'user-other'; +const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me'; +const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other'; + +/** Die Sperrliste der Spec (§ 2), wörtlich: der Test hält sie fest, damit eine Änderung auffällt. */ +const SPEC_RESERVED = [ + 'admin', + 'administrator', + 'root', + 'system', + 'support', + 'moderator', + 'mod', + 'aircard', + 'aircardios', + 'aircard-ios', + 'aircard.ios', + 'aircard_ios', + 'webklar', + 'anonym', + 'anonymous', + 'null', + 'undefined', + 'team', + 'staff', + 'official', + 'offiziell', + 'hilfe', + 'help', + 'kontakt', + 'contact', + 'info', +]; + +// ---- Aufbau --------------------------------------------------------------------------------- + +const cleanups = []; +afterEach(async () => { + for (const fn of cleanups.splice(0).reverse()) await fn(); +}); + +async function setup() { + const fake = await startFakeAppwrite(); + fake.addUser(JWT_ME, { id: ME }); + fake.addUser(JWT_OTHER, { id: OTHER }); + + const logs = []; + const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key }); + const app = createApp({ appwrite, salt: SALT, log: (line) => logs.push(String(line)) }); + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + + cleanups.push(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await fake.close(); + }); + + const call = (method, path, options) => request(port, method, path, options); + const post = (path, body, jwt) => call('POST', path, { body, headers: jwt ? { 'X-Aircard-Jwt': jwt } : {} }); + /** POST /api/profil mit dem Namen (Standard: als ME). */ + const profile = (username, jwt = JWT_ME) => post('/api/profil', { username }, jwt); + + return { fake, appwrite, logs, call, post, profile }; +} + +function request(port, method, path, { headers = {}, body, raw, contentType = 'application/json' } = {}) { + return new Promise((resolve, reject) => { + const payload = raw !== undefined ? raw : body === undefined ? undefined : JSON.stringify(body); + const requestHeaders = { ...headers }; + if (payload !== undefined && contentType) requestHeaders['Content-Type'] = contentType; + if (payload !== undefined) requestHeaders['Content-Length'] = Buffer.byteLength(payload); + const req = http.request({ host: '127.0.0.1', port, method, path, headers: requestHeaders }, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + let json = null; + try { + json = JSON.parse(text); + } catch { + // kein JSON + } + resolve({ status: res.statusCode, headers: res.headers, text, json }); + }); + }); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +/** Erwartet Status und genau {error: , code: }. */ +function expectError(res, status, code, label = code) { + assert.equal(res.status, status, `${label}: Status`); + assert.deepEqual(res.json, { error: MSG[code], code }, label); +} + +const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account'); +/** Aufrufe bei Appwrite auf `profiles`: Zeile anlegen, per Abfrage suchen (die Vorabprüfung), Zeile ändern. */ +const isCreate = (c) => c.method === 'POST' && c.path.endsWith('/tables/profiles/rows'); +const isList = (c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows'); +const isUpdate = (c) => c.method === 'PATCH' && c.path.includes('/tables/profiles/rows/'); + +/** Wartet (höchstens etwa 2 s), bis `condition()` stimmt: so fängt ein Test den Moment ab, in dem ein Aufruf bei Appwrite noch läuft. */ +async function until(condition) { + for (let i = 0; i < 400 && !condition(); i += 1) await new Promise((resolve) => setTimeout(resolve, 5)); + assert.ok(condition(), 'der erwartete Zustand wurde nicht erreicht'); +} + +/** + * Zwei Anfragen, die beide vorab geprüft haben („Name frei“), bevor eine davon schreibt: das Schreiben (`isWrite`) wird festgehalten, bis + * beide Abfragen bei Appwrite angekommen sind. So entscheidet erst der Unique-Index, nicht die Vorabprüfung. + */ +async function raceTwo(fake, isWrite, first, second) { + const releases = [fake.holdNext(isWrite), fake.holdNext(isWrite)]; + const pending = Promise.all([first(), second()]); + await until(() => fake.calls.filter(isList).length === 2); + for (const release of releases) release(); + return pending; +} + +// ---- Reine Helfer (common.js) ----------------------------------------------------------------------- + +test('USERNAME_PATTERN: 3–20 Zeichen, erstes ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich', () => { + assert.equal(USERNAME_PATTERN.source, '^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$'); + for (const ok of ['abc', 'Kenso', 'k3nso', '007', 'a.b', 'a_b', 'a-b', 'A.b_c-d9', 'a'.repeat(20), 'Z9.']) { + assert.ok(USERNAME_PATTERN.test(ok), ok); + } + const bad = ['', 'a', 'ab', '_abc', '.abc', '-abc', 'a'.repeat(21), 'a b', ' abc', 'abc ', 'abc\n', 'a\nbc', 'Jürgen', 'abc', 'a@b', 'a/b', 'a+b', 'abc😀']; + for (const name of bad) assert.ok(!USERNAME_PATTERN.test(name), JSON.stringify(name)); +}); + +test('usernameKey: der Name klein geschrieben, sonst unverändert', () => { + assert.equal(usernameKey('Kenso'), 'kenso'); + assert.equal(usernameKey('AirCard-IOS'), 'aircard-ios'); + assert.equal(usernameKey('a.B_c-D9'), 'a.b_c-d9'); + assert.equal(usernameKey('kenso'), 'kenso'); +}); + +test('Sperrliste: genau die Schlüssel der Spec (§ 2), gesperrt in jeder Schreibweise; andere Namen sind frei', () => { + assert.deepEqual([...RESERVED_USERNAMES].sort(), [...SPEC_RESERVED].sort()); + for (const key of SPEC_RESERVED) { + assert.ok(isReservedUsername(key), key); + assert.ok(isReservedUsername(key.toUpperCase()), key.toUpperCase()); + assert.ok(isReservedUsername(`${key[0].toUpperCase()}${key.slice(1)}`), `${key} mit großem Anfang`); + // jeder gesperrte Name passt zum Muster (sonst wäre der Eintrag wirkungslos: er scheiterte schon als „ungültig“) + assert.match(key, USERNAME_PATTERN); + } + for (const free of ['kenso', 'admins', 'admin1', 'root_', 'air-card-ios', 'aircard-ios2', 'team.x', 'xinfo', 'helpme']) { + assert.ok(!isReservedUsername(free), free); + } +}); + +test('MSG: die vier Benutzernamen-Texte stehen wörtlich fest (Spec § 4)', () => { + assert.equal( + MSG.usernameInvalid, + 'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.', + ); + assert.equal(MSG.usernameReserved, 'Dieser Benutzername ist nicht erlaubt.'); + assert.equal(MSG.usernameTaken, 'Dieser Benutzername ist schon vergeben.'); + assert.equal(MSG.usernameRequired, 'Bitte wähle zuerst einen Benutzernamen.'); +}); + +test('createProfileRoutes liefert genau die Route POST /api/profil mit Name, json und run', () => { + const routes = createProfileRoutes({ + appwrite: {}, + say: () => {}, + withLock: (key, fn) => fn(), + requireUser: async () => ({ id: ME, isAdmin: false }), + }); + assert.deepEqual( + routes.map(([key]) => key), + ['POST /api/profil'], + ); + const [[, route]] = routes; + assert.equal(route.name, 'profil'); + assert.equal(route.json, true); + assert.equal(typeof route.run, 'function'); +}); + +test('hasProfile: true mit Zeile (Zeilen-ID = Konto-ID), false ohne; ein Appwrite-Fehler wird nicht verschluckt', async () => { + const { fake, appwrite } = await setup(); + assert.equal(await hasProfile(appwrite, ME), false); + fake.addProfile(ME, 'Kenso'); + assert.equal(await hasProfile(appwrite, ME), true); + assert.equal(await hasProfile(appwrite, OTHER), false, 'ein anderes Konto hat nichts davon'); + + fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-other'), { status: 500 }); + await assert.rejects(hasProfile(appwrite, OTHER), (err) => err instanceof AppwriteError && err.status === 500); + // fehlt die Tabelle ganz (Schema nicht eingespielt), ist das ein Fehler und kein „kein Profil“ + fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-other'), { status: 404, type: 'table_not_found' }); + await assert.rejects(hasProfile(appwrite, OTHER), (err) => err instanceof AppwriteError && err.status === 404); +}); + +test('Fake-Appwrite: der Unique-Index auf usernameKey weist eine zweite Zeile (Anlegen und Ändern) mit 409 ab', async () => { + const { fake, appwrite } = await setup(); + const isConflict = (err) => err instanceof AppwriteError && err.status === 409; + await appwrite.createRow('profiles', 'a', { username: 'Kenso', usernameKey: 'kenso' }, ['read("any")']); + await assert.rejects(appwrite.createRow('profiles', 'b', { username: 'KENSO', usernameKey: 'kenso' }, ['read("any")']), isConflict); + assert.equal(fake.row('profiles', 'b'), null); + + await appwrite.createRow('profiles', 'b', { username: 'Other', usernameKey: 'other' }, ['read("any")']); + await assert.rejects(appwrite.updateRow('profiles', 'b', { username: 'Kenso', usernameKey: 'kenso' }), isConflict); + assert.equal(fake.row('profiles', 'b').usernameKey, 'other'); + // die eigene Zeile ändern (nur die Schreibweise) ist kein Verstoß + await appwrite.updateRow('profiles', 'a', { username: 'KENSO', usernameKey: 'kenso' }); + assert.equal(fake.row('profiles', 'a').username, 'KENSO'); + // Spaltenlängen wie in der echten Tabelle (username und usernameKey: 20) + await assert.rejects(appwrite.createRow('profiles', 'c', { username: 'x'.repeat(21), usernameKey: 'x' }, []), (err) => err.status === 400); +}); + +// ---- Anlegen und Ändern ---------------------------------------------------------------------------- + +test('Profil anlegen: 200 {username}, Zeilen-ID = Konto-ID, Rechte genau read("any"), usernameKey klein, der Name wie eingegeben', async () => { + const { profile, fake } = await setup(); + const res = await profile('Kenso.IOS'); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { username: 'Kenso.IOS' }); + + assert.equal(fake.rows('profiles').length, 1); + const row = fake.row('profiles', ME); + assert.equal(row.username, 'Kenso.IOS'); + assert.equal(row.usernameKey, 'kenso.ios'); + assert.deepEqual(row.$permissions, ['read("any")']); + + // der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf + assert.equal(fake.calls.find((c) => c.path === '/v1/account').headers['x-appwrite-jwt'], JWT_ME); + for (const c of nonAccountCalls(fake)) { + assert.equal(c.headers['x-appwrite-key'], fake.key); + assert.equal(c.headers['x-appwrite-jwt'], undefined); + } +}); + +test('Profil anlegen: die Zeile gehört dem Konto aus dem JWT, andere Felder im Body (Konto-ID, Schlüssel, Rechte) werden ignoriert', async () => { + const { post, fake } = await setup(); + const res = await post( + '/api/profil', + { username: 'Kenso', usernameKey: 'anders', userId: OTHER, ownerId: OTHER, $id: OTHER, $permissions: ['read("any")', 'update("any")'] }, + JWT_ME, + ); + assert.deepEqual(res.json, { username: 'Kenso' }); + assert.equal(fake.row('profiles', OTHER), null); + const row = fake.row('profiles', ME); + assert.equal(row.usernameKey, 'kenso'); + assert.deepEqual(row.$permissions, ['read("any")']); + assert.deepEqual(Object.keys(row).filter((key) => !key.startsWith('$')).sort(), ['username', 'usernameKey']); +}); + +test('Profil anlegen an den Grenzen: 3 und 20 Zeichen gehen', async () => { + const { profile, fake } = await setup(); + assert.deepEqual((await profile('abc')).json, { username: 'abc' }); + assert.deepEqual((await profile('X'.repeat(20), JWT_OTHER)).json, { username: 'X'.repeat(20) }); + assert.equal(fake.row('profiles', OTHER).usernameKey, 'x'.repeat(20)); +}); + +test('Profil ändern: dieselbe Zeile wird aktualisiert, der alte Schlüssel ist sofort für ein anderes Konto frei, der neue vergeben', async () => { + const { profile, fake } = await setup(); + assert.equal((await profile('Kenso')).status, 200); + expectError(await profile('kenso', JWT_OTHER), 409, 'usernameTaken', 'solange der Name vergeben ist'); + + const res = await profile('Kenso.neu'); + assert.equal(res.status, 200); + assert.deepEqual(res.json, { username: 'Kenso.neu' }); + assert.equal(fake.rows('profiles').length, 1, 'eine Zeile je Konto'); + assert.equal(fake.row('profiles', ME).username, 'Kenso.neu'); + assert.equal(fake.row('profiles', ME).usernameKey, 'kenso.neu'); + assert.deepEqual(fake.row('profiles', ME).$permissions, ['read("any")']); + assert.equal(fake.calls.filter(isCreate).length, 1, 'beim Ändern wird nichts neu angelegt'); + assert.equal(fake.calls.filter(isUpdate).length, 1); + + // der alte Name ist frei: ein anderes Konto nimmt ihn, das alte Konto bekommt ihn nicht mehr zurück + assert.deepEqual((await profile('kenso', JWT_OTHER)).json, { username: 'kenso' }); + expectError(await profile('Kenso'), 409, 'usernameTaken', 'zurück'); + assert.equal(fake.row('profiles', ME).username, 'Kenso.neu', 'nach dem Fehlschlag bleibt das Profil, wie es war'); + assert.equal(fake.row('profiles', OTHER).usernameKey, 'kenso'); +}); + +test('„kenso“ → „Kenso“ beim selben Konto ist erlaubt (nur die Schreibweise), ebenso derselbe Name nochmal', async () => { + const { profile, fake } = await setup(); + assert.deepEqual((await profile('kenso')).json, { username: 'kenso' }); + assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' }); + assert.equal(fake.row('profiles', ME).username, 'Kenso'); + assert.equal(fake.row('profiles', ME).usernameKey, 'kenso'); + assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' }, 'derselbe Name nochmal'); + assert.deepEqual((await profile('KENSO')).json, { username: 'KENSO' }); + assert.equal(fake.rows('profiles').length, 1); + assert.equal(fake.row('profiles', ME).username, 'KENSO'); +}); + +test('vergebener Name (in jeder Schreibweise): 409 usernameTaken, es wird nichts angelegt oder geändert', async () => { + const { profile, fake } = await setup(); + fake.addProfile(OTHER, 'Kenso'); + for (const name of ['kenso', 'KENSO', 'Kenso', 'kEnSo']) expectError(await profile(name), 409, 'usernameTaken', name); + assert.equal(fake.row('profiles', ME), null, 'kein Profil angelegt'); + assert.equal(fake.rows('profiles').length, 1); + + // wer schon ein Profil hat, behält es + fake.addProfile(ME, 'Me.me'); + expectError(await profile('kenso'), 409, 'usernameTaken'); + assert.equal(fake.row('profiles', ME).username, 'Me.me'); + assert.equal(fake.row('profiles', OTHER).username, 'Kenso', 'das andere Profil ist unberührt'); +}); + +// ---- Gleichzeitigkeit -------------------------------------------------------------------------------- + +test('Rennen: zwei Konten wollen gleichzeitig denselben Namen – genau eines bekommt ihn, das andere 409 (Unique-Index, nicht nur die Vorabprüfung)', async () => { + const { profile, fake } = await setup(); + const results = await raceTwo( + fake, + isCreate, + () => profile('Rennen', JWT_ME), + () => profile('rennen', JWT_OTHER), + ); + + assert.deepEqual(results.map((res) => res.status).sort(), [200, 409]); + const winner = results.findIndex((res) => res.status === 200); + const loser = results.find((res) => res.status === 409); + assert.deepEqual(loser.json, { error: MSG.usernameTaken, code: 'usernameTaken' }); + + assert.equal(fake.rows('profiles').length, 1, 'genau eine Zeile'); + const [row] = fake.rows('profiles'); + assert.equal(row.$id, winner === 0 ? ME : OTHER); + assert.equal(row.usernameKey, 'rennen'); + assert.equal(results[winner].json.username, row.username); + // beide Vorabprüfungen haben den Namen für frei gehalten und beide haben angelegt: erst der Unique-Index hat entschieden + assert.equal(fake.calls.filter(isCreate).length, 2); +}); + +test('Rennen beim Umbenennen: zwei Konten wollen denselben neuen Namen – genau eines bekommt ihn, das andere behält seinen alten', async () => { + const { profile, fake } = await setup(); + fake.addProfile(ME, 'Alt.eins'); + fake.addProfile(OTHER, 'Alt.zwei'); + const results = await raceTwo( + fake, + isUpdate, + () => profile('Gemeinsam', JWT_ME), + () => profile('GEMEINSAM', JWT_OTHER), + ); + + assert.deepEqual(results.map((res) => res.status).sort(), [200, 409]); + assert.deepEqual(results.find((res) => res.status === 409).json, { error: MSG.usernameTaken, code: 'usernameTaken' }); + assert.equal(fake.calls.filter(isUpdate).length, 2, 'beide haben ändern wollen: erst der Unique-Index hat entschieden'); + const [winner, loser] = results[0].status === 200 ? [ME, OTHER] : [OTHER, ME]; + assert.equal(fake.row('profiles', winner).usernameKey, 'gemeinsam'); + assert.equal(fake.row('profiles', loser).username, loser === ME ? 'Alt.eins' : 'Alt.zwei', 'wer verliert, behält den alten Namen'); + assert.equal(fake.rows('profiles').length, 2); +}); + +test('Sperre je Konto (profile:): zwei gleichzeitige Anfragen desselben Kontos laufen nacheinander, beide gelingen, am Ende gilt die zweite', async () => { + const { profile, fake } = await setup(); + const release = fake.holdNext(isCreate); + const first = profile('Alpha'); + await until(() => fake.calls.some(isCreate)); // die erste steht beim Anlegen + const second = profile('Beta'); + await until(() => fake.calls.filter((c) => c.path === '/v1/account').length === 2); // die zweite ist angemeldet ... + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(fake.calls.filter(isList).length, 1, '... wartet aber auf die Sperre, statt schon abzufragen'); + + release(); + assert.deepEqual((await first).json, { username: 'Alpha' }); + assert.deepEqual((await second).json, { username: 'Beta' }); + assert.equal(fake.rows('profiles').length, 1); + assert.equal(fake.row('profiles', ME).username, 'Beta'); + assert.equal(fake.row('profiles', ME).usernameKey, 'beta'); + assert.equal(fake.calls.filter(isCreate).length, 1, 'die zweite hat die Zeile der ersten geändert, nicht noch einmal angelegt'); + assert.equal(fake.calls.filter(isUpdate).length, 1); +}); + +// ---- Prüfungen ----------------------------------------------------------------------------------------- + +test('gesperrte Namen (Spec § 2, in jeder Schreibweise): 400 usernameReserved, ohne Abfrage bei Appwrite', async () => { + const { profile, fake } = await setup(); + for (const name of ['Admin', 'AirCard-IOS', 'AIRCARD.IOS', 'Aircard_Ios', 'ROOT', 'Null', ...SPEC_RESERVED]) { + expectError(await profile(name), 400, 'usernameReserved', name); + } + assert.equal(fake.rows('profiles').length, 0); + assert.equal(nonAccountCalls(fake).length, 0, 'die Prüfung braucht Appwrite nur für das JWT'); +}); + +test('ungültige Namen (Muster): 400 usernameInvalid, nichts angelegt, ohne Abfrage bei Appwrite', async () => { + const { profile, fake } = await setup(); + const cases = [ + ['zu kurz', 'ab'], + ['ein Zeichen', 'a'], + ['leer', ''], + ['Unterstrich vorn', '_abc'], + ['Punkt vorn', '.abc'], + ['Bindestrich vorn', '-abc'], + ['21 Zeichen', 'a'.repeat(21)], + ['Leerzeichen', 'a b'], + ['Leerzeichen vorn', ' abc'], + ['Leerzeichen hinten', 'abc '], + ['Zeilenumbruch', 'abc\n'], + ['Umlaut', 'Jürgen'], + ['Emoji', 'abc😀'], + ['Klammeraffe', '@kenso'], + ['Schrägstrich', 'a/b'], + ]; + for (const [label, name] of cases) expectError(await profile(name), 400, 'usernameInvalid', label); + assert.equal(fake.rows('profiles').length, 0); + assert.equal(nonAccountCalls(fake).length, 0); +}); + +test('kein Text als Name (fehlt, null, Zahl, Liste, Objekt, Wahrheitswert): 400 usernameInvalid; kein JSON-Objekt: 400 badRequest', async () => { + const { post, call, fake } = await setup(); + for (const body of [{}, { username: null }, { username: 7 }, { username: ['abc'] }, { username: { a: 1 } }, { username: true }, { name: 'Kenso' }]) { + expectError(await post('/api/profil', body, JWT_ME), 400, 'usernameInvalid', JSON.stringify(body)); + } + for (const raw of ['[]', '"abc"', '7', '{kaputt']) { + const res = await call('POST', '/api/profil', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } }); + expectError(res, 400, 'badRequest', raw); + } + assert.equal(fake.rows('profiles').length, 0); +}); + +test('ohne oder mit ungültigem JWT: 401, noch vor der Prüfung des Namens, nichts angelegt', async () => { + const { post, profile, fake } = await setup(); + expectError(await post('/api/profil', { username: 'Kenso' }), 401, 'unauthorized', 'ohne JWT'); + expectError(await post('/api/profil', {}), 401, 'unauthorized', 'ohne JWT, ungültiger Body'); + expectError(await profile('Kenso', 'xxx.yyy.zzz'), 401, 'unauthorized', 'unbekanntes JWT'); + expectError(await profile('Kenso', 'zwei teile'), 401, 'unauthorized', 'unzulässige Zeichen'); + expectError(await profile('Admin', ''), 401, 'unauthorized', 'leeres JWT'); + assert.equal(fake.rows('profiles').length, 0); + assert.equal(nonAccountCalls(fake).length, 0, 'ohne gültiges JWT wird nichts abgefragt oder geschrieben'); +}); + +test('nur POST: GET /api/profil gibt es nicht (404)', async () => { + const { call } = await setup(); + expectError(await call('GET', '/api/profil'), 404, 'notFound'); +}); + +// ---- Fehler bei Appwrite ----------------------------------------------------------------------------------- + +test('Appwrite antwortet mit Fehler: 502 unavailable ohne halben Zustand, der nächste Versuch gelingt', async () => { + const { profile, fake } = await setup(); + // die Vorabprüfung scheitert + fake.failNext(isList, { status: 500 }); + expectError(await profile('Kenso'), 502, 'unavailable', 'Vorabprüfung'); + assert.equal(fake.rows('profiles').length, 0); + // das Anlegen scheitert + fake.failNext(isCreate, { status: 503 }); + expectError(await profile('Kenso'), 502, 'unavailable', 'Anlegen'); + assert.equal(fake.rows('profiles').length, 0); + assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' }); + // das Ändern scheitert: das alte Profil bleibt + fake.failNext(isUpdate, { status: 500 }); + expectError(await profile('Neu.name'), 502, 'unavailable', 'Ändern'); + assert.equal(fake.row('profiles', ME).username, 'Kenso'); + assert.deepEqual((await profile('Neu.name')).json, { username: 'Neu.name' }); + assert.equal(fake.row('profiles', ME).usernameKey, 'neu.name'); +}); + +test('fehlt die Tabelle `profiles` (Schema nicht eingespielt): 500 internal, kein Absturz', async () => { + const { profile, fake } = await setup(); + fake.failNext(isList, { status: 404, type: 'table_not_found' }); + expectError(await profile('Kenso'), 500, 'internal'); +}); + +// ---- Logs ----------------------------------------------------------------------------------------------------- + +test('Logs: eine Ereigniszeile je Anlegen und Ändern, nie Name, Konto-ID, Schlüssel oder JWT', async () => { + const { profile, logs, fake } = await setup(); + await profile('GEHEIMER.Name'); // anlegen + await profile('GEHEIMER.Neu'); // ändern + await profile('ab'); // 400 + await profile('Admin'); // 400 + await profile('geheimer.neu', JWT_OTHER); // 409 + await profile('x', 'xxx.yyy.zzz'); // 401 + + const lines = logs.map((line) => line.replace(/^\[aircard-api\] \S+ /, '')); + assert.deepEqual(lines, ['profile created', 'profile updated']); + assert.ok(logs.every((line) => line.startsWith('[aircard-api] '))); + for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, 'GEHEIMER', 'geheimer', ME, OTHER]) { + assert.ok(!logs.join('\n').includes(secret), `Log enthält „${secret}“`); + } +}); diff --git a/web/scripts/appwrite-setup.mjs b/web/scripts/appwrite-setup.mjs index a6a23f7..3183400 100644 --- a/web/scripts/appwrite-setup.mjs +++ b/web/scripts/appwrite-setup.mjs @@ -3,9 +3,9 @@ * AirCard – Appwrite-Schema-Setup (idempotent) * * Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`, - * die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes` und - * `logo_reports` (TablesDB, mit Spalten und Indizes) und die Buckets - * `card-images`, `gallery-images` und `logo-images` an. + * die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`, + * `logo_reports` und `profiles` (TablesDB, mit Spalten und Indizes) und die + * Buckets `card-images`, `gallery-images` und `logo-images` an. * Reines `fetch`, keine Abhängigkeiten, Node 22+. * * Aufruf (vom Repo-Wurzelverzeichnis): @@ -41,7 +41,7 @@ const str = (key, size, required) => ({ key, type: 'string', size, required }); export const DESIRED = { database: { id: 'aircard', name: 'AirCard' }, - // Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports (je Tabelle: Spalten, dann Indizes). + // Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports, profiles (je Tabelle: Spalten, dann Indizes). tables: [ { id: 'cards', @@ -137,6 +137,16 @@ export const DESIRED = { ], indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }], }, + { + // Benutzernamen: eine Zeile je Konto, Zeilen-ID = Konto-ID, öffentlich lesbar (Zeilenrecht read("any") setzt die API); geschrieben wird nur + // über die API. `usernameKey` ist der Name klein geschrieben: der Unique-Index macht ihn auch bei gleichzeitigen Anfragen nur einmal vergebbar. + id: 'profiles', + name: 'Profiles', + permissions: [], + rowSecurity: true, + columns: [str('username', 20, true), str('usernameKey', 20, true)], + indexes: [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }], + }, ], buckets: [ { diff --git a/web/scripts/appwrite-setup.test.mjs b/web/scripts/appwrite-setup.test.mjs index b051490..9f734d5 100644 --- a/web/scripts/appwrite-setup.test.mjs +++ b/web/scripts/appwrite-setup.test.mjs @@ -270,9 +270,9 @@ describe('DESIRED', () => { const bucket = (id) => DESIRED.buckets.find((b) => b.id === id); const str = (key, size, required) => ({ key, type: 'string', size, required }); - test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports; Buckets card-images, gallery-images, logo-images', () => { + test('Reihenfolge: cards zuerst, dann gallery, reports, logos, logo_likes, logo_reports, profiles; Buckets card-images, gallery-images, logo-images', () => { assert.deepEqual(DESIRED.database, { id: 'aircard', name: 'AirCard' }); - assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports']); + assert.deepEqual(DESIRED.tables.map((t) => t.id), ['cards', 'gallery', 'reports', 'logos', 'logo_likes', 'logo_reports', 'profiles']); assert.deepEqual(DESIRED.buckets.map((b) => b.id), ['card-images', 'gallery-images', 'logo-images']); }); @@ -393,6 +393,16 @@ describe('DESIRED', () => { assert.deepEqual(t.indexes, [{ key: 'logo', type: 'key', columns: ['logoId'] }]); }); + test('profiles entspricht Benutzernamen-Spec §3 (Unique-Index username_key auf usernameKey)', () => { + const t = table('profiles'); + assert.equal(t.name, 'Profiles'); + assert.deepEqual(t.permissions, []); + assert.equal(t.rowSecurity, true); + assert.deepEqual(t.columns, [str('username', 20, true), str('usernameKey', 20, true)]); + // unique: nur so kann bei Gleichzeitigkeit kein Name doppelt vergeben werden + assert.deepEqual(t.indexes, [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }]); + }); + test('logo-images entspricht Logo-Spec §8', () => { assert.deepEqual(bucket('logo-images'), { id: 'logo-images', @@ -622,6 +632,19 @@ describe('compareIndex', () => { }); }); +describe('compareIndex (unique)', () => { + const usernameKey = DESIRED.tables.find((t) => t.id === 'profiles').indexes[0]; + const ok = () => ({ key: 'username_key', type: 'unique', columns: ['usernameKey'], lengths: [], orders: [], status: 'available' }); + + test('Unique-Index profiles.username_key: Typ, Spalte und Status werden verglichen', () => { + assert.deepEqual(compareIndex(ok(), usernameKey), []); + // ein normaler Index verhindert keine doppelten Namen: das muss auffallen + assert.deepEqual(compareIndex({ ...ok(), type: 'key' }, usernameKey), ['type ist key, erwartet unique']); + assert.deepEqual(compareIndex({ ...ok(), columns: ['username'] }, usernameKey), ['columns ist [username], erwartet [usernameKey]']); + assert.deepEqual(compareIndex({ ...ok(), status: 'processing' }, usernameKey), ['status ist processing, erwartet available']); + }); +}); + describe('compareBucket', () => { const CARD_IMAGES = DESIRED.buckets[0]; const ok = () => ({ @@ -741,6 +764,10 @@ describe('main gegen Fake-Server', () => { 'Spalte logo_reports.reason', 'Spalte logo_reports.reporterHash', 'Index logo_reports.logo', + 'Tabelle profiles', + 'Spalte profiles.username', + 'Spalte profiles.usernameKey', + 'Index profiles.username_key', 'Bucket card-images', 'Bucket gallery-images', 'Bucket logo-images', @@ -748,6 +775,8 @@ describe('main gegen Fake-Server', () => { // Erweiterung um die Galerie (gallery, reports, gallery-images) bzw. um die Logos (logos, logo_likes, logo_reports, logo-images) const GALLERY_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) (gallery|reports)(\.|$)|^Bucket gallery-images$/.test(o)); const LOGO_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) logo(s|_likes|_reports)(\.|$)|^Bucket logo-images$/.test(o)); + // Erweiterung um die Benutzernamen (profiles) + const PROFILE_OBJECTS = EXPECTED_OBJECTS.filter((o) => /^(Tabelle|Spalte|Index) profiles(\.|$)/.test(o)); const T = '/v1/tablesdb/aircard/tables'; const str = (table, key, size, required) => [`${T}/${table}/columns/string`, { key, size, required }]; @@ -805,6 +834,11 @@ describe('main gegen Fake-Server', () => { }], str('logo_reports', 'reporterHash', 64, true), [`${T}/logo_reports/indexes`, { key: 'logo', type: 'key', columns: ['logoId'] }], + [T, { tableId: 'profiles', name: 'Profiles', permissions: [], rowSecurity: true }], + str('profiles', 'username', 20, true), + str('profiles', 'usernameKey', 20, true), + // unique: das Skript legt den Index mit dem Typ `unique` an (Appwrite meldet dann 409 statt eine zweite Zeile mit demselben Schlüssel anzulegen) + [`${T}/profiles/indexes`, { key: 'username_key', type: 'unique', columns: ['usernameKey'] }], ['/v1/storage/buckets', { bucketId: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], @@ -822,15 +856,16 @@ describe('main gegen Fake-Server', () => { }], ]; const isLogoPost = ([path, body]) => /\/tables\/logo/.test(path) || /^logo/.test(body.tableId ?? body.bucketId ?? ''); + const isProfilePost = ([path, body]) => /\/tables\/profiles(\/|$)/.test(path) || body.tableId === 'profiles'; - test('--check auf leerem Projekt: Exit 1, alle 51 Objekte FEHLT, nichts angelegt', async () => { + test('--check auf leerem Projekt: Exit 1, alle 55 Objekte FEHLT, nichts angelegt', async () => { await withFake({}, async (fake) => { const { code, lines } = await run(fake, { check: true }); assert.equal(code, 1); - assert.equal(EXPECTED_OBJECTS.length, 51); + assert.equal(EXPECTED_OBJECTS.length, 55); assert.deepEqual(startsWith(lines, 'FEHLT'), EXPECTED_OBJECTS.map((o) => `FEHLT: ${o}`)); assert.equal(startsWith(lines, 'OK').length, 0); - assert.ok(lines.includes('UNVOLLSTÄNDIG: 51 fehlend.'), lines.join('\n')); + assert.ok(lines.includes('UNVOLLSTÄNDIG: 55 fehlend.'), lines.join('\n')); assert.equal(fake.posts().length, 0); assert.equal(fake.otherWrites().length, 0); }); @@ -859,8 +894,8 @@ describe('main gegen Fake-Server', () => { assert.ok(!first.lines.join('\n').includes(KEY)); // Alle Spalten und Indizes wurden vor dem Ende ausgewartet (Status available) - assert.equal(fake.state.columns.size, 31); - assert.equal(fake.state.indexes.size, 10); + assert.equal(fake.state.columns.size, 33); + assert.equal(fake.state.indexes.size, 11); for (const col of fake.state.columns.values()) assert.ok(col.gets >= 2, `Spalte ${col.key} nicht bis available geprüft`); for (const idx of fake.state.indexes.values()) assert.ok(idx.gets >= 2, `Index ${idx.key} nicht bis available geprüft`); @@ -868,6 +903,7 @@ describe('main gegen Fake-Server', () => { assert.deepEqual(fake.state.indexPosts.map((p) => `${p.table}.${p.key}`), [ 'gallery.search_text', 'gallery.owner', 'gallery.hidden', 'reports.gallery', 'logos.owner', 'logos.hidden', 'logos.likes', 'logo_likes.logo', 'logo_likes.user_logo', 'logo_reports.logo', + 'profiles.username_key', ]); assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable), JSON.stringify(fake.state.indexPosts)); @@ -920,7 +956,7 @@ describe('main gegen Fake-Server', () => { dropFromFake(fake, { tables: ['logos', 'logo_likes', 'logo_reports'], buckets: ['logo-images'] }); const kept = EXPECTED_OBJECTS.filter((o) => !LOGO_OBJECTS.includes(o)); assert.equal(LOGO_OBJECTS.length, 23); - assert.equal(kept.length, 28); + assert.equal(kept.length, 32); const requestsBefore = fake.state.requests.length; const postsBefore = fake.posts().length; @@ -947,8 +983,8 @@ describe('main gegen Fake-Server', () => { assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable)); // Alles Bestehende wurde nur gelesen assert.equal(fake.otherWrites().length, 0); - assert.equal(fake.state.columns.size, 31); - assert.equal(fake.state.indexes.size, 10); + assert.equal(fake.state.columns.size, 33); + assert.equal(fake.state.indexes.size, 11); // danach ist das Schema vollständig const again = await run(fake, { check: true }); @@ -956,6 +992,42 @@ describe('main gegen Fake-Server', () => { }); }); + test('Bestehendes Schema ohne `profiles` wird um die Tabelle samt Unique-Index ergänzt, alles Bestehende bleibt unberührt', async () => { + await withFake({}, async (fake) => { + assert.equal((await run(fake)).code, 0); + // Stand vor den Benutzernamen nachstellen: alles außer profiles + dropFromFake(fake, { tables: ['profiles'] }); + const kept = EXPECTED_OBJECTS.filter((o) => !PROFILE_OBJECTS.includes(o)); + assert.equal(PROFILE_OBJECTS.length, 4); + assert.equal(kept.length, 51); + const requestsBefore = fake.state.requests.length; + const postsBefore = fake.posts().length; + + // --check: genau die vier neuen Objekte fehlen, nichts wird angelegt + const check = await run(fake, { check: true }); + assert.equal(check.code, 1); + assert.deepEqual(startsWith(check.lines, 'FEHLT'), PROFILE_OBJECTS.map((o) => `FEHLT: ${o}`)); + assert.equal(startsWith(check.lines, 'ABWEICHUNG').length, 0); + assert.deepEqual(startsWith(check.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`)); + assert.ok(check.lines.includes('UNVOLLSTÄNDIG: 4 fehlend.'), check.lines.join('\n')); + assert.equal(fake.posts().length, postsBefore); + + // Anlegen: Tabelle, beide Spalten, dann der Unique-Index (erst wenn die Spalten available sind) + const apply = await run(fake); + assert.equal(apply.code, 0, apply.lines.join('\n')); + assert.deepEqual(startsWith(apply.lines, 'ANGELEGT'), PROFILE_OBJECTS.map((o) => `ANGELEGT: ${o}`)); + assert.deepEqual(startsWith(apply.lines, 'OK: ').filter((l) => l !== 'OK: Schema vollständig'), kept.map((o) => `OK: ${o}`)); + const newPosts = fake.state.requests.slice(requestsBefore).filter((r) => r.method === 'POST'); + assert.deepEqual(newPosts.map((r) => [r.path, r.body]), EXPECTED_POSTS.filter(isProfilePost)); + assert.deepEqual(newPosts.at(-1).body, { key: 'username_key', type: 'unique', columns: ['usernameKey'] }); + assert.equal(fake.state.indexes.get('aircard/profiles/username_key').type, 'unique'); + assert.ok(fake.state.indexPosts.every((p) => p.allColumnsAvailable)); + assert.equal(fake.otherWrites().length, 0); + + assert.equal((await run(fake, { check: true })).code, 0); + }); + }); + test('Fehlende Indizes bei vorhandener Tabelle: --check meldet FEHLT, Anlegen legt nur sie an', async () => { await withFake({}, async (fake) => { assert.equal((await run(fake)).code, 0); @@ -1118,6 +1190,33 @@ describe('main gegen Fake-Server', () => { }); }); + test('Abweichende Benutzernamen-Tabelle (Rechte, Länge, Pflicht, Index nur `key` statt `unique`) wird gemeldet, aber nicht verändert (Exit 1)', async () => { + await withFake({}, async (fake) => { + assert.equal((await run(fake)).code, 0); + fake.state.tables.get('aircard/profiles').$permissions = ['read("any")']; + fake.state.tables.get('aircard/profiles').rowSecurity = false; + fake.state.columns.get('aircard/profiles/username').size = 30; + fake.state.columns.get('aircard/profiles/usernameKey').required = false; + fake.state.indexes.get('aircard/profiles/username_key').type = 'key'; + const requestsBefore = fake.state.requests.length; + + for (const check of [true, false]) { + const { code, lines } = await run(fake, { check }); + assert.equal(code, 1); + assert.deepEqual(startsWith(lines, 'ABWEICHUNG'), [ + 'ABWEICHUNG: profiles.$permissions ist [read("any")], erwartet []', + 'ABWEICHUNG: profiles.rowSecurity ist false, erwartet true', + 'ABWEICHUNG: profiles.username.size ist 30, erwartet 20', + 'ABWEICHUNG: profiles.usernameKey.required ist false, erwartet true', + 'ABWEICHUNG: profiles.username_key.type ist key, erwartet unique', + ]); + assert.equal(startsWith(lines, 'ANGELEGT').length, 0); + assert.ok(!lines.includes('OK: Schema vollständig')); + } + assert.ok(fake.state.requests.slice(requestsBefore).every((r) => r.method === 'GET')); + }); + }); + test('Integer-Spalte mit riesigen Standard-min/max der Antwort gilt als in Ordnung', async () => { await withFake({}, async (fake) => { assert.equal((await run(fake)).code, 0); @@ -1303,6 +1402,8 @@ describe('CLI', () => { assert.match(stdout, /FEHLT: Tabelle logos/); assert.match(stdout, /FEHLT: Index logo_likes\.user_logo/); assert.match(stdout, /FEHLT: Tabelle logo_reports/); + assert.match(stdout, /FEHLT: Tabelle profiles/); + assert.match(stdout, /FEHLT: Index profiles\.username_key/); assert.match(stdout, /FEHLT: Bucket logo-images/); assert.ok(!(stdout + stderr).includes(KEY)); assert.equal(fake.posts().length, 0); diff --git a/web/src/i18n/api-messages.ts b/web/src/i18n/api-messages.ts index 25bb367..75f8ee2 100644 --- a/web/src/i18n/api-messages.ts +++ b/web/src/i18n/api-messages.ts @@ -35,6 +35,12 @@ export const API_MESSAGES: Readonly> = { tooManyLogos: msg('Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.'), badHeart: msg('Ungültige Angabe zum Herz.'), badMono: msg('Ungültige Angabe zu „einfarbig“.'), + usernameInvalid: msg( + 'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.', + ), + usernameReserved: msg('Dieser Benutzername ist nicht erlaubt.'), + usernameTaken: msg('Dieser Benutzername ist schon vergeben.'), + usernameRequired: msg('Bitte wähle zuerst einen Benutzernamen.'), tooLarge: msg('Die Anfrage ist zu groß.'), jsonOnly: msg('Nur application/json wird unterstützt.'), unavailable: msg('Der Dienst ist gerade nicht erreichbar.'), diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8d585cd..d3d905a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -361,6 +361,11 @@ export const EN: Readonly> = { 'You’ve already uploaded 30 logos. Delete one to upload a new one.', 'Ungültige Angabe zum Herz.': 'Invalid heart value.', 'Ungültige Angabe zu „einfarbig“.': 'Invalid value for “single color”.', + 'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.': + 'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.', + 'Dieser Benutzername ist nicht erlaubt.': 'This username is not allowed.', + 'Dieser Benutzername ist schon vergeben.': 'This username is already taken.', + 'Bitte wähle zuerst einen Benutzernamen.': 'Please choose a username first.', 'Die Anfrage ist zu groß.': 'The request is too large.', 'Nur application/json wird unterstützt.': 'Only application/json is supported.', 'Der Dienst ist gerade nicht erreichbar.': 'The service is currently unavailable.',