Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cdebb7102 | ||
|
|
4e9659ca85 | ||
|
|
dc6bd3362b | ||
|
|
56cb67be4b | ||
|
|
3440eb46c3 | ||
|
|
53bcecf3cb | ||
|
|
7c55912ebe | ||
|
|
f46a69df68 | ||
|
|
04404ffdae | ||
|
|
0ad51bf7c6 | ||
|
|
151e340fdd | ||
|
|
3e4bc21f00 | ||
|
|
19c8151c7e | ||
|
|
a65c075004 | ||
|
|
13b46da2e0 | ||
|
|
6e5fe3e199 | ||
|
|
691b28df19 | ||
|
|
e0f11f905e | ||
|
|
0e4759a05a | ||
|
|
02b01c9deb | ||
|
|
8167a18857 | ||
|
|
29c9b2efaf | ||
|
|
76c3c3aea2 | ||
|
|
1fd08d6373 | ||
|
|
0bcdfe258d | ||
|
|
af05e5f1f1 | ||
|
|
7cb1381566 | ||
|
|
6134fd26d7 | ||
|
|
738358fcac | ||
|
|
6d152c11b3 | ||
|
|
ec0b51a949 | ||
|
|
d85cce0bf3 | ||
|
|
7fec92c20a | ||
|
|
4232afdf35 | ||
|
|
df1cf29d42 | ||
|
|
4aedb1d322 | ||
|
|
b3c6e65801 | ||
|
|
ec4e80c62c | ||
|
|
202254c09e | ||
|
|
7e66cd0f0f | ||
|
|
1ff1873b27 | ||
|
|
a75be2d44c | ||
|
|
4cf9fe192d | ||
|
|
e22919f4dc | ||
|
|
56d841b8e2 | ||
|
|
0f66136d82 | ||
|
|
8205f7c37f | ||
|
|
0be5abd954 | ||
|
|
3051b3b3f9 | ||
|
|
fc9e5f5577 | ||
|
|
dc5de99611 | ||
|
|
88abc2061d | ||
|
|
e1fb632cdd | ||
|
|
9455013833 | ||
|
|
562e760191 | ||
|
|
067e428340 | ||
|
|
8fb28eb1d7 | ||
|
|
a3523d3316 | ||
|
|
f6b9917b07 | ||
|
|
aa9f9548ad | ||
|
|
c62075f2d4 | ||
|
|
5500495443 | ||
|
|
4c90ee49e8 | ||
|
|
87b71ec71f | ||
|
|
f6b9ee61f4 | ||
|
|
be440bc8e9 | ||
|
|
3cec7151a6 | ||
|
|
c4a66f674f | ||
|
|
ae73dc4a0b | ||
|
|
89c86fd578 | ||
|
|
7bbe0a9fe6 | ||
|
|
cd55d4c891 | ||
|
|
dd1597862a | ||
|
|
9e4f4ffacc | ||
|
|
25ff496548 | ||
|
|
1a64e12bc5 | ||
|
|
21e1db1858 | ||
|
|
23463f7d29 | ||
|
|
970e68e7fc | ||
|
|
562fac05a5 | ||
|
|
e7acd79225 | ||
|
|
9c4e80ec9a | ||
|
|
144181f2c9 | ||
|
|
436ea4f4a5 | ||
|
|
0c51689dfd | ||
|
|
09de06e797 | ||
|
|
09832c2cf4 |
No files matched your search
@@ -1,15 +1,107 @@
|
||||
# AirCard-IOS
|
||||
|
||||
Inoffizielles Hobbyprojekt: AirCard-IOS ist ein Editor, mit dem man eigene Kartenmotive für https://aircard-ios.org gestalten kann. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover.
|
||||
Inoffizielles Hobbyprojekt, mit dem man eigene Kartenmotive für Apple Wallet unter https://aircard-ios.org gestalten kann. Bild hochladen, Vorlage und Community-Logo wählen, als PNG herunterladen – oder mit Konto speichern und in der Community-Galerie veröffentlichen. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover; Logos und Motive stammen von Nutzern, alle Marken gehören ihren Inhabern.
|
||||
|
||||
Die Website liegt in `web/` (statisches Vite + TypeScript, ohne UI-Framework). Die Design-Spec steht in `docs/superpowers/specs/2026-10-04-aircard-website-design.md`.
|
||||
## Funktionen
|
||||
|
||||
## Entwicklung
|
||||
- **Editor** (`/gestalten`): Bild hochladen, Vorlage, Ausschnitt, Details (Hell/Dunkel) und das Logo oben links (AirCard-IOS) wählen, als PNG herunterladen oder mit Konto speichern („Meine Karten“, `/karten`).
|
||||
- **Community-Logos** unten rechts auf der Karte: Angemeldete laden Logos hoch (PNG, JPG, WebP oder SVG; der Browser schneidet sie zu, verkleinert sie auf höchstens 720 × 250 px und lädt ein PNG hoch), alle können sie wählen. Eingebaute Marken-Logos gibt es nicht. Je Konto und Logo gibt es ein Herz (nur angemeldet); die Herzen bestimmen die Sortierung „Beliebt“. Logos lassen sich melden (ab 3 Meldern aus verschiedenen Netzen werden sie ausgeblendet) und vom Hochlader löschen; Admins (Label `admin`) sehen zusätzlich „Gemeldet“ und „Ausgeblendet“.
|
||||
- **Community-Galerie** (Startseite `/` und `/community/<id>`): Karten veröffentlichen, ansehen, melden und zurückziehen.
|
||||
- **Deutsch und Englisch**: Die Sprache folgt der Browsersprache; der Sprachknopf in der Kopfzeile schaltet ohne Neuladen um und merkt sich die Wahl. Daneben steht ein GitHub-Link zum Original-Projekt [AirCard-iOS](https://github.com/Mak5er/AirCard-iOS).
|
||||
|
||||
## Aufbau
|
||||
|
||||
**`web/`** – Vite + TypeScript ohne Framework. Frontend-Module in `src/`:
|
||||
- `card/` – Geometrie, Zuschnitt, Canvas-Rendering (keine DOM-Abhängigkeiten, einzeln testbar)
|
||||
- `editor/` – UI (Vorlagen, Vorschau, Download, Speichern)
|
||||
- `logos/` – Community-Logos: Lesen aus Appwrite, Auswahl mit Herzen und Infozeile (Melden, Löschen, Admin), Hochladen mit Aufbereitung im Browser (Zuschnitt, Verkleinern, PNG)
|
||||
- `community/` – Galerie: Lesen, Veröffentlichen, Melden, Aufrufe des API-Dienstes
|
||||
- `account/` – Appwrite-Client, Auth, Kartenverwaltung
|
||||
- `i18n/` – Deutsch und Englisch: `t('Deutscher Text')` und `msg('…')`, Wörterbuch Deutsch → Englisch in `en.ts` (ein Test verlangt für jeden Text einen Eintrag)
|
||||
- `pages/` – Routen (`/`, `/gestalten`, `/karten`, `/community/<id>`, `/passwort-neu` via History-API)
|
||||
- `shell/` – Kopfzeile (GitHub-Link, Sprachknopf, Hell/Dunkel), Fußzeile, Spenden-Banner
|
||||
|
||||
**`deploy/`** – Webhook-Router (Port 8087), Build-Skript (`deploy.sh`), nginx-Config, Traefik-Route für Appwrite.
|
||||
|
||||
**`docs/`** – Spec, Plan, Appwrite-Konfiguration.
|
||||
|
||||
## Lokal entwickeln
|
||||
|
||||
```sh
|
||||
cd web
|
||||
npm install
|
||||
npm run dev # Entwicklungsserver
|
||||
npm test # Tests (Vitest)
|
||||
npm run build # Typprüfung + Produktions-Build nach web/dist
|
||||
npm ci
|
||||
npm run dev # Entwicklungsserver http://localhost:5173
|
||||
```
|
||||
|
||||
Login und Speichern brauchen die Appwrite-API. Ohne Einstellung gilt als Endpoint `${location.origin}/v1` (der Entwicklungsserver hat keinen Proxy dafür, es würde also der Vite-Server selbst gefragt). Für die lokale Entwicklung deshalb in `web/.env.local` (steht über `.env*` in `.gitignore`) den Endpoint setzen:
|
||||
|
||||
```sh
|
||||
VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
|
||||
```
|
||||
|
||||
Die Projekt-ID ist als Standard im Code hinterlegt (`6ac2130a00289343d6d5`, `VITE_APPWRITE_PROJECT_ID` überschreibt sie). Damit Appwrite den lokalen Host akzeptiert, muss die Web-Plattform `localhost` in der Appwrite-Console eingetragen sein (Projekt → Overview → Integrations → Platforms). Der Produktions-Build bekommt `VITE_APPWRITE_ENDPOINT=https://aircard-ios.org/v1` aus der Container-Umgebung (`deploy/compose-snippet.yml`).
|
||||
|
||||
## Tests
|
||||
|
||||
Im Frontend (`web/`):
|
||||
|
||||
```sh
|
||||
cd web
|
||||
npx vitest run # Unit-Tests (alle Module unter src/)
|
||||
npx tsc --noEmit # TypeScript-Prüfung, auch der Tests
|
||||
npm run build # Produktions-Build; die Prüfung davor nutzt tsconfig.build.json (ohne Tests)
|
||||
```
|
||||
|
||||
Im Repo-Wurzelverzeichnis:
|
||||
|
||||
```sh
|
||||
node --test deploy/webhook-server.test.js # Webhook-Server (deploy.sh wird dort durch einen Stub ersetzt)
|
||||
node --test deploy/deploy.test.js # deploy.sh (echtes Skript, lokaler git-Remote, npm-Stub)
|
||||
node --test "deploy/api/*.test.js" # API-Dienst (Glob in Anführungszeichen)
|
||||
node --test web/scripts/appwrite-setup.test.mjs # Appwrite-Schema-Skript
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
**Automatisch:** Push auf `main` → Gitea-Webhook → Container `webklar-aircard-build` führt `npm ci` und `npm run build` (Typprüfung ohne Tests, dann `vite build`) aus → Ergebnis nach `out/` → nginx `webklar-aircard` liefert.
|
||||
|
||||
**Reihenfolge, wenn eine Änderung auch Schema und API braucht** (wie die Community-Logos): erst das Schema anlegen, dann die API-Dateien ersetzen und neu starten, zuletzt `main` pushen. Die Schritte stehen in [deploy/README.md](deploy/README.md), Abschnitt „Reihenfolge bei Änderungen an Schema, API und Seite“.
|
||||
|
||||
**Manuell:**
|
||||
```sh
|
||||
docker exec webklar-aircard-build sh /deploy.sh
|
||||
```
|
||||
|
||||
Starten nur mit:
|
||||
```sh
|
||||
docker compose up -d aircard-build aircard aircard-api
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
## Appwrite
|
||||
|
||||
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) 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:**
|
||||
```sh
|
||||
node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check
|
||||
```
|
||||
|
||||
**Schema erstmalig anlegen:**
|
||||
```sh
|
||||
node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs
|
||||
```
|
||||
|
||||
Umgebungsvariablen (in `/home/webklar/apps/.env`):
|
||||
- `AIRCARD_APPWRITE_PROJECT_ID` – Projekt-ID (idempotent im Skript lesbar)
|
||||
- `AIRCARD_APPWRITE_API_KEY` – API-Key `aircard-provision` (nur für Setup)
|
||||
|
||||
## Hinweis
|
||||
|
||||
Inoffizielles Hobbyprojekt. Alle Marken gehören ihren Inhabern.
|
||||
@@ -10,7 +10,8 @@ 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/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build` und `aircard` |
|
||||
| `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).
|
||||
|
||||
@@ -20,10 +21,11 @@ Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repo
|
||||
APP=/home/webklar/apps
|
||||
SRC=$APP/aircard-ios.org/dev/deploy
|
||||
|
||||
mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx
|
||||
mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx $APP/aircard-ios.org/api
|
||||
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/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
|
||||
@@ -33,17 +35,49 @@ cd $APP && docker compose config -q; echo $? # 0; einzige erlaubte Ausgabe: W
|
||||
cp $SRC/traefik/aircard.yml $APP/traefik/dynamic/aircard.yml.tmp && mv $APP/traefik/dynamic/aircard.yml.tmp $APP/traefik/dynamic/aircard.yml
|
||||
```
|
||||
|
||||
Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten. Beide Werte gehören nie in Logs, Chats oder Commits.
|
||||
Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten, für den API-Dienst außerdem `AIRCARD_APPWRITE_PROJECT_ID`, `AIRCARD_API_KEY` und `AIRCARD_API_SALT`. Diese Werte gehören nie in Logs, Chats oder Commits. Das Salz entsteht, ohne dass der Wert auf dem Bildschirm erscheint:
|
||||
|
||||
```sh
|
||||
printf 'AIRCARD_API_SALT=%s\n' "$(openssl rand -hex 32)" >> /home/webklar/apps/.env
|
||||
```
|
||||
|
||||
Den Key `AIRCARD_API_KEY` legt Kenso nach [docs/appwrite-console.md](../docs/appwrite-console.md) (Abschnitt 7.3) an (Scopes `documents.read`, `documents.write`, `files.read`, `files.write`, so beschriftet die Console die Zeilen-Scopes) und trägt ihn mit dem verdeckten `read -rs`-Befehl ein (leere Eingabe schreibt nichts).
|
||||
|
||||
## Starten
|
||||
|
||||
```sh
|
||||
cd /home/webklar/apps && docker compose up -d aircard-build aircard
|
||||
cd /home/webklar/apps && docker compose up -d aircard-build aircard aircard-api
|
||||
```
|
||||
|
||||
Nur diese beiden Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`).
|
||||
Nur diese drei Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`).
|
||||
|
||||
Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen.
|
||||
Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen. Ebenso beendet sich `aircard-api` mit Exit-Code 1, wenn `AIRCARD_API_KEY`, `AIRCARD_API_SALT` oder `AIRCARD_APPWRITE_PROJECT_ID` fehlen (die Meldung nennt nur den Namen der Variable).
|
||||
|
||||
## API-Dienst `aircard-api`
|
||||
|
||||
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 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.
|
||||
|
||||
## Reihenfolge bei Änderungen an Schema, API und Seite
|
||||
|
||||
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`, 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); 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
|
||||
|
||||
@@ -53,6 +87,8 @@ docker exec webklar-aircard-build sh /deploy.sh
|
||||
|
||||
Ohne `DEPLOY_TRIGGER` baut das Skript immer neu, auch wenn sich der Commit nicht geändert hat.
|
||||
|
||||
Es läuft immer nur ein Deployment: Ist schon eines aktiv (Sperrverzeichnis `build/.git/aircard-deploy.lock`), schreibt das Skript „Deployment läuft bereits“ und endet mit Exit-Code 0. Eine verwaiste Sperre (Prozess weg oder älter als 60 Minuten) übernimmt der nächste Lauf selbst. `npm ci` und der Build brechen nach 15 Minuten ab (`timeout 900`).
|
||||
|
||||
## Gitea-Webhook
|
||||
|
||||
Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
|
||||
@@ -77,12 +113,13 @@ Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
|
||||
- **Gemerkter Commit**: `build/.git/aircard-last-deployed-commit` enthält den zuletzt **versuchten** Commit, geschrieben vor dem Build. Ein kaputter Build wird deshalb nicht alle 15 Minuten wiederholt; die alte Seite bleibt online, bis ein neuer Commit oder ein Webhook-/manueller Lauf kommt.
|
||||
- **Fehler**: Ein fehlgeschlagener Lauf wird mit `[webhook] … deployment failed` protokolliert und beendet den Server nicht. Fehlerzeilen des Skripts beginnen mit `ERROR:`.
|
||||
- **Build-Variablen**: `VITE_APPWRITE_ENDPOINT` und `VITE_APPWRITE_PROJECT_ID` kommen aus der Container-Umgebung (compose); `AIRCARD_APPWRITE_PROJECT_ID` wird aus `/home/webklar/apps/.env` gelesen. Vite liest sie direkt.
|
||||
- **.env-Variablen**: `AIRCARD_APPWRITE_PROJECT_ID` (Projekt-ID `6ac2130a00289343d6d5`) und `AIRCARD_APPWRITE_API_KEY` (für das Setup-Skript) müssen in `/home/webklar/apps/.env` eingetragen sein.
|
||||
- **.env-Variablen**: `AIRCARD_APPWRITE_PROJECT_ID` (Projekt-ID `6ac2130a00289343d6d5`) und `AIRCARD_APPWRITE_API_KEY` (für das Setup-Skript) müssen in `/home/webklar/apps/.env` eingetragen sein; für `aircard-api` zusätzlich `AIRCARD_API_KEY` (Key `aircard-api`) und `AIRCARD_API_SALT`.
|
||||
|
||||
## Änderungen später
|
||||
|
||||
- `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 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.
|
||||
|
||||
@@ -93,7 +130,8 @@ Solange `dig +short www.aircard-ios.org @8.8.8.8` nichts liefert, gibt es nur di
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
node --test deploy/webhook-server.test.js
|
||||
node --test deploy/webhook-server.test.js deploy/deploy.test.js
|
||||
node --test "deploy/api/*.test.js"
|
||||
```
|
||||
|
||||
Die Tests brauchen nur Node und starten die Server auf Port 0.
|
||||
Die Tests brauchen nur Node und starten die Server auf Port 0. Die API-Tests laufen gegen einen Fake-Appwrite im Speicher (`deploy/api/fake-appwrite.js`); das echte Appwrite wird nie angesprochen. Das Glob muss in Anführungszeichen stehen.
|
||||
@@ -0,0 +1,94 @@
|
||||
# aircard-api
|
||||
|
||||
Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie, die Community-Logos und die Benutzernamen von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: bei den Karten Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit, bei den Logos Hochladen, Herz setzen, Melden, Löschen und die Admin-Sichtbarkeit, bei den Benutzernamen Anlegen und Ändern. Hochladen und Veröffentlichen gehen nur mit Benutzernamen. Die Website liest Galerie, Logos und Benutzernamen direkt aus Appwrite; nur das Schreiben läuft hierüber.
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11 (Karten) und `docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md` §6, §8, §9 (Logos) und `docs/superpowers/specs/2026-10-05-aircard-benutzernamen-design.md` §2–4 (Benutzernamen). Deployment: [../README.md](../README.md).
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Aufgabe |
|
||||
|---|---|
|
||||
| `server.js` | Start: liest die Umgebung, `http.createServer`, Zeitlimits, sauberes Beenden |
|
||||
| `app.js` | HTTP-Anbindung (Routing, Body, Anmeldung, Rate-Limit, Log, Fehlerantworten) und die Karten-Endpunkte (`createApp`) |
|
||||
| `logos.js` | die fünf Logo-Endpunkte (`createLogoRoutes`); bekommt Anmeldung, Sperre je Schlüssel, Rate-Limit und Log von `app.js` |
|
||||
| `profiles.js` | der Benutzernamen-Endpunkt (`createProfileRoutes`) und `hasProfile`, das `app.js` und `logos.js` vor dem Veröffentlichen und Hochladen fragen; bekommt Anmeldung, Sperre je Schlüssel und Log von `app.js` |
|
||||
| `common.js` | gemeinsame Helfer: `MSG` (alle Fehlertexte, Schlüssel = `code`), `HttpError`, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Namensmuster und Sperrliste der Benutzernamen |
|
||||
| `appwrite.js` | REST-Client mit Server-Key (`createAppwrite`, auch `countRows`) |
|
||||
| `*.test.js`, `fake-appwrite.js` | Tests und Fake-Appwrite im Speicher (gehören nicht auf den Server) |
|
||||
|
||||
Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur die sechs Dateien `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js` und `server.js`.
|
||||
|
||||
## Umgebung
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `AIRCARD_API_KEY` | – (Pflicht) | Appwrite-Server-Key `aircard-api`. Scopes (so beschriftet die Console): `documents.read`, `documents.write`, `files.read`, `files.write`; `documents.*` entspricht den `rows.*` der API (TablesDB) |
|
||||
| `AIRCARD_API_SALT` | – (Pflicht) | Salz für die Melder-Hashes, 32 Byte hex |
|
||||
| `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) |
|
||||
| `AIRCARD_APPWRITE_ENDPOINT` | `http://webklar-appwrite/v1` | interne Appwrite-Adresse im Docker-Netz |
|
||||
| `AIRCARD_API_PORT` | `8088` | Port des Dienstes |
|
||||
|
||||
Fehlt eine Pflichtvariable, nennt die Meldung nur ihren Namen und der Prozess endet mit Exit-Code 1. Wer das Salz ändert, macht alle bisherigen Melder-Hashes ungültig: dieselben Personen können dieselbe Karte dann nochmal melden (die Karten selbst bleiben unberührt).
|
||||
|
||||
## Endpunkte
|
||||
|
||||
Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>", "code": "<Schlüssel>"}` (Codes siehe unten). Das Nutzer-JWT kommt im Header `X-Aircard-Jwt` und wird bei Appwrite (`GET /account`) geprüft; Admin = Label `admin`. Alles andere (auch falsche Methoden) ist `404`; `POST` nur mit `Content-Type: application/json` (sonst `415`), Body höchstens 8 KB (sonst `413`).
|
||||
|
||||
| Endpunkt | Auth | Antworten |
|
||||
|---|---|---|
|
||||
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `403` Konto ohne Benutzernamen („Bitte wähle zuerst einen Benutzernamen.”, vor allen anderen Prüfungen) · `400` Validierung, Datei fehlt („Bild nicht gefunden.”), falscher Typ oder > 5 MiB, falsche Bildmaße („Das Bild hat nicht das Kartenformat.”) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.”), Datei schon veröffentlicht oder gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.”), Karte nicht meine („Diese Karte gehört nicht zu deinem Konto.”) · `409` „Diese Karte ist schon veröffentlicht.” · `429` „Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.” |
|
||||
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
|
||||
| `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) |
|
||||
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
|
||||
| `POST /api/logos/hochladen` `{name, mono?, fileId}` | Nutzer-JWT | `201 {id}` (Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID) · `401` · `403` Konto ohne Benutzernamen (wie beim Veröffentlichen) · `400` Validierung, Datei fehlt („Bild nicht gefunden.”), kein PNG („Das Logo muss eine PNG-Datei sein.”), größer als 1 MiB („Das Logo ist größer als 1 MB.”), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).”) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.”), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.”) · `409` „Diese Datei wird schon für ein Logo verwendet.” · `429` „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.” |
|
||||
| `POST /api/logos/herz` `{id, herz}` | Nutzer-JWT | `200 {herz, likeCount}` · `401` · `400` ID ungültig oder `herz` kein Boolean · `404` fehlt oder ausgeblendet („Dieses Logo ist nicht (mehr) verfügbar.“) |
|
||||
| `POST /api/logos/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`; dasselbe Limit wie `/api/melden`) |
|
||||
| `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
|
||||
| `POST /api/admin/logo-sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
|
||||
| `POST /api/profil` `{username}` | Nutzer-JWT | `200 {username}` (Name angelegt oder geändert) · `401` · `400` Muster („Der Benutzername muss 3–20 Zeichen lang sein, …“) oder gesperrter Name („Dieser Benutzername ist nicht erlaubt.“) · `409` „Dieser Benutzername ist schon vergeben.“ |
|
||||
| `GET /api/gesundheit` | keine | `200 {ok:true}` |
|
||||
|
||||
Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“ (`code` `unavailable`). Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist `500` „Interner Fehler.“ (`code` `internal`). Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ `row_not_found` bzw. `storage_file_not_found` als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“ oder „Logo nicht verfügbar“.
|
||||
|
||||
### Fehlercodes
|
||||
|
||||
Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel in `MSG` (`common.js`), dessen Text genau dem `error` entspricht. Die Texte in `MSG` sind paarweise verschieden (ein Test prüft das), der Code ist also eindeutig. Dynamische Prüftexte ohne festen Schlüssel („Der Titel ist zu lang (höchstens 60 Zeichen).“, „Ungültige Bild-ID.“, „Bitte gib höchstens 5 Tags an.“, „Der Name ist zu lang (höchstens 40 Zeichen).“, …) haben bei Status `400` den `code` `badRequest`, sonst `error` (kommt derzeit nicht vor). Der `code` ist die stabile Schnittstelle für die Clients (z. B. um Meldungen zu übersetzen); `MSG` ist dafür ein schlichtes Objekt aus Texten, das `common.js` unverändert exportiert.
|
||||
|
||||
| Status | `code` · Text |
|
||||
|---|---|
|
||||
| `400` | `badRequest` „Ungültige Anfrage.“ (auch jeder dynamische Prüftext) · `badCardId` „Ungültige Karten-ID.“ · `badLogoId` „Ungültige Logo-ID.“ · `badReason` „Bitte wähle einen gültigen Grund.“ · `badVisibility` „Ungültige Angabe zur Sichtbarkeit.“ · `badHeart` „Ungültige Angabe zum Herz.“ · `badMono` „Ungültige Angabe zu „einfarbig“.“ · `logoNameMissing` „Bitte gib einen Namen an.“ · `fileMissing` „Bild nicht gefunden.“ · `fileType` „Das Bild muss eine PNG- oder WebP-Datei sein.“ · `fileSize` „Das Bild ist größer als 5 MB.“ · `imageFormat` „Das Bild hat nicht das Kartenformat.“ · `logoFileType` „Das Logo muss eine PNG-Datei sein.“ · `logoFileSize` „Das Logo ist größer als 1 MB.“ · `logoFormat` „Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“ · `usernameInvalid` „Der Benutzername muss 3–20 Zeichen lang sein, …“ · `usernameReserved` „Dieser Benutzername ist nicht erlaubt.“ |
|
||||
| `401` | `unauthorized` „Bitte melde dich an.“ |
|
||||
| `403` | `forbidden` „Dazu hast du keine Berechtigung.“ · `adminOnly` „Nur für Admins.“ · `foreignFile` „Diese Datei gehört dir nicht.“ · `filesTaken` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ · `notYourCard` „Diese Karte gehört nicht zu deinem Konto.“ · `usernameRequired` „Bitte wähle zuerst einen Benutzernamen.“ |
|
||||
| `404` | `notFound` „Nicht gefunden.“ · `cardGone` „Diese Karte ist nicht (mehr) verfügbar.“ · `logoGone` „Dieses Logo ist nicht (mehr) verfügbar.“ |
|
||||
| `409` | `alreadyPublished` „Diese Karte ist schon veröffentlicht.“ · `logoTaken` „Diese Datei wird schon für ein Logo verwendet.“ · `usernameTaken` „Dieser Benutzername ist schon vergeben.“ |
|
||||
| `413` `415` | `tooLarge` „Die Anfrage ist zu groß.“ · `jsonOnly` „Nur application/json wird unterstützt.“ |
|
||||
| `429` | `tooManyPublications` „Du hast schon 20 Karten veröffentlicht. …“ · `tooManyLogos` „Du hast schon 30 Logos hochgeladen. …“ · `tooManyReports` „Zu viele Meldungen – bitte später nochmal.“ |
|
||||
| `500` `502` | `internal` „Interner Fehler.“ · `unavailable` „Der Dienst ist gerade nicht erreichbar.“ |
|
||||
|
||||
### Regeln im Einzelnen
|
||||
|
||||
- **IDs**: Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen (nur Zeichenketten), sonst `400`. `appwrite.js` kodiert zusätzlich jedes Pfadsegment und lehnt `.` und `..` ab.
|
||||
- **Felder**: Titel 1–60, Herausgeber ≤ 40 Zeichen (getrimmt). Nicht erlaubt in Titel, Herausgeber und Tags: Steuerzeichen (U+0000–001F, U+007F–009F), Nullbreiten- und Bidi-Zeichen (U+200B–200F, U+202A–202E, U+2060–206F) und das BOM (U+FEFF); sie machen Titel unsichtbar oder ordnen Text um. Dasselbe Muster prüft der Veröffentlichen-Dialog. `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Gespeichert wird die Spalte `tags` als `,tag1,tag2,` (Komma an beiden Enden, leer bei keinen Tags): der Tag-Filter der Website sucht `,<tag>,` und trifft so nur ganze Tags. Bild und Vorschau müssen verschiedene Dateien sein.
|
||||
- **Quellkarte**: `sourceCardId` muss eine Zeile der Tabelle `cards` sein, deren `$permissions` `read("user:<ich>")` enthalten, sonst `403` „Diese Karte gehört nicht zu deinem Konto.“ (auch wenn es die Zeile nicht gibt).
|
||||
- **Limit**: höchstens 20 Veröffentlichungen je Konto (Zeilen mit `ownerId` = ich, auch ausgeblendete); ab da `429` mit dem Text aus der Tabelle. Zurückziehen gibt einen Platz frei. Das Limit liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index) mit `limit(20)`.
|
||||
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403` (Typ und Größe fremder Dateien nennt die API nicht). **Bildmaße**: Die API liest die ersten 64 Bytes beider Dateien (`GET …/files/<id>/view` mit Server-Key, der Rest der Übertragung wird abgebrochen) und liest PNG-IHDR bzw. WebP (`VP8 `, `VP8L`, `VP8X`) aus. Das Bild muss genau 1536 × 969 sein, die Vorschau 480 breit und 302–304 hoch (die Karte ist 1536 × 969, die Vorschau 480 × 303), sonst `400` „Das Bild hat nicht das Kartenformat.“ Das ist Defense in Depth gegen Dekompressionsbomben; die maßgebliche Prüfung beim Anzeigen macht der Client (Pixelgrenze 4 Megapixel). **Keine Wiederverwendung**: Beide Dateien müssen `read("any")` tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst `403` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen `sourceCardId` neu einstellen. Die Prüfung läuft zusammen mit Limit, Quellkarte und Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")` (**kein `delete("user:<ich>")` mehr**, kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen und schon festgelegte Dateien bekommen `delete("user:<ich>")` zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht `rollback failed for card <id>` im Log). Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“
|
||||
- **Logo-Felder** (`/api/logos/hochladen`): `name` 1–40 Zeichen (getrimmt; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. **Zeilen-ID des Logos** = `sha256(<ownerId>|<fileId>)` (erste 32 Hex), **nicht** die Datei-ID; die Antwort `201 {id}` trägt diese ID. Dieselbe Datei desselben Kontos ergibt dieselbe ID (`409`). Die ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten; wäre sie die Datei-ID, könnte jeder eine Datei mit genau dieser ID anlegen, als Logo hochladen und so die alte ID neu belegen (gespeicherte Karten anderer zeigten dann sein Bild). Mit dem Konto im Hash geht das nicht. Dass eine Datei nie zwei Logos trägt, sichert der Besitz: nach dem Anlegen hat die Datei kein `delete("user:<ich>")` mehr, ein anderes Konto bekommt sie nicht (`403`). **Altlogos** (vor dieser Regel angelegt) haben noch Zeilen-ID = Datei-ID: Herz, Melden, Löschen und Sichtbarkeit adressieren die Zeile nur über ihre ID und die Datei über das Feld `fileId`, darum gehen beide Arten; dieselbe Datei eines Altlogos noch einmal hochzuladen ist ebenfalls `409`.
|
||||
- **Logo-Datei und Reihenfolge**: Die Datei liegt im Bucket `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist `image/png`, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (`401`) → Benutzername (`403` `usernameRequired`) → Body (`400`) → Datei da und im richtigen Bucket (`400`/`403`) → je Konto nacheinander (Sperre `logo-owner:<id>`): schon 30 Logos (`429`, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (`409`: Zeile mit dieser Zeilen-ID oder mit dieser `fileId`, also auch ein Altlogo) → Besitz (`403`: kein `delete("user:<ich>")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → ab hier zusätzlich die Sperre des Logos `logo:<Zeilen-ID>` (immer erst Konto, dann Logo) bis zur zweiten Prüfung unten: Zeile anlegen (`409`, wenn die ID schon vergeben ist) → Datei auf genau `read("any")` setzen (**kein `delete("user:<ich>")` mehr**, sonst ließe sich das Bild unter derselben ID tauschen). Scheitert das Festlegen, wird die Zeile zurückgenommen und nur dann bekommt die Datei `delete("user:<ich>")` zurück (scheitert schon das Zurücknehmen, steht `rollback failed for logo <id> (file <Datei-ID>)` im Log; scheitert danach die Rückgabe von `delete("user:<ich>")`, bleibt die Datei ohne Zeile und ohne dieses Recht liegen und `restore failed for logo <id> (file <Datei-ID>)` steht im Log, die Antwort bleibt in beiden Fällen der ursprüngliche Fehler). **Zweite Prüfung**: Danach liest die API den Dateianfang noch einmal und prüft PNG-Kopf und Maße erneut. Jetzt kann der Hochlader die Datei nicht mehr ändern; so lässt sich der Inhalt nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen (Löschen und Neuanlegen der Datei). Fällt die Datei jetzt durch oder fehlt sie, löscht die API die Datei mit dem Server-Key und die Zeile (beides wird versucht, auch wenn das erste scheitert) und antwortet `400` `logoFormat` bzw. `fileMissing`; Log: `logo <id> removed after upload check (<code>)`, bleibt etwas stehen `rollback failed for logo <id> (file <Datei-ID>)`. Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (`502`, Zeile zurückgenommen, die Datei bekommt `delete("user:<ich>")` zurück). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest. **Sperre des Logos beim Hochladen**: Die Zeile steht ab dem Anlegen öffentlich in der Liste; ein Herz, eine Meldung oder ein Löschen, das bis zum Ende der zweiten Prüfung eintrifft, wartet auf die Sperre und sieht danach den Endzustand (das fertige Logo oder `404`, wenn die Prüfung es entfernt hat). So bleibt keine Herz- oder Meldungszeile ohne Logo zurück.
|
||||
- **Herzen** (`/api/logos/herz`): ein Herz je Konto und Logo, Zeilen-ID `sha256(<logoId>|<userId>)` (erste 32 Hex), Recht `read("user:<id>")`. `herz: true` legt die Zeile an (`409` = schon da, kein Fehler), `false` löscht sie (schon weg = kein Fehler). Danach ist `likeCount` immer `countRows` aller Herzen des Logos (neu gezählt, nie hoch- oder runtergezählt) und wird nur geschrieben, wenn er abweicht. Fehlendes oder ausgeblendetes Logo: `404`, auch beim Entfernen. Pro Logo läuft immer nur eine Änderung gleichzeitig (Sperre `logo:<id>`, auch gegen ein gerade laufendes Hochladen dieses Logos). Herzen werden nicht geloggt.
|
||||
- **Logo melden** (`/api/logos/melden`): wie `/api/melden`, mit denselben Gründen, demselben Melder (Netz, gehasht) und **demselben Rate-Limit** (ein gemeinsamer Zähler je Anschluss für Karten und Logos: 6 Karten- und 5 Logo-Meldungen vom selben Anschluss ergeben bei der 11. ein `429`). Die Zeilen-ID der Meldung ist `sha256(logo|<logoId>|<reporterHash>)`, `reportCount` ist `countRows` der Meldungen des Logos, ab 3 wird ausgeblendet (Datei und Zeile auf `read("user:<owner>")` und `read("label:admin")`).
|
||||
- **Logo löschen und Sichtbarkeit**: Löschen (Hochlader oder Admin) blendet das Logo zuerst aus (Datei, dann Zeile auf die ausgeblendeten Rechte mit `hidden: true`; ein schon ausgeblendetes Logo überspringt das) und entfernt dann Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). Das Ausblenden zuerst verhindert, dass nach einem Abbruch (502) eine öffentliche Zeile mit gelöschter Datei stehen bleibt: unter deren Datei-ID könnte jedes Konto eine neue Datei anlegen, und alle gespeicherten Karten zeigten sie. Es bleibt höchstens eine unsichtbare Zeile (nur Hochlader und Admins lesen sie); die Wiederholung vollendet das Löschen. Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (`hidden: true`), Einblenden setzt sie auf `read("any")`, löscht die Meldungen und setzt `reportCount` auf 0 (die Herzen bleiben).
|
||||
- **Doppelt veröffentlichen**: Die Zeilen-ID der Galerie ist `sha256(ownerId|sourceCardId)` (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei.
|
||||
- **Melden**: Rate-Limit 10 pro Stunde und Anschluss (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge; Schlüssel: gehashte IPv4, IPv6 auf /64 gekürzt, IPv4-gemappte IPv6 als IPv4). Der Melder-Hash ist `HMAC-SHA256(salt, netz)` mit dem **Netz** der Adresse (IPv4 → `/24`, IPv6 → `/48`, IPv4-gemappte IPv6 wie IPv4): drei Adressen aus einem Netz sind ein Melder und blenden eine Karte nicht aus (sonst genügten drei VPN-/Mobilfunk-Adressen). Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig.
|
||||
- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst veröffentlicht nie `ports:`, ist aber im Docker-Netz `webklar-network` für andere Container erreichbar, und dort gilt `X-Real-Ip` als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den `clientIp` prüft, oder ein eigenes Netz nur für Traefik und `aircard-api`. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
|
||||
- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). Für Logos gilt dasselbe (Zeile und Datei sichtbar `read("any")`, ausgeblendet `read("user:<owner>")` und `read("label:admin")`, hochgeladen `read("any")` und `delete("user:<owner>")`); Herzen tragen `read("user:<id>")` des Kontos; Profile (Benutzernamen) tragen genau `read("any")`.
|
||||
- **Benutzernamen** (`/api/profil`, `profiles.js`): `username` muss `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$` erfüllen (3–20 Zeichen, nur ASCII, kein Trimmen: Leerzeichen sind ungültig), sonst `400` `usernameInvalid` (auch wenn es keine Zeichenkette ist). Eindeutig ist der **Schlüssel** `usernameKey` = der Name klein geschrieben („Kenso“ und „kenso“ sind derselbe Name); gespeichert und angezeigt wird der Name, wie er eingegeben wurde. **Gesperrte Schlüssel** (`400` `usernameReserved`, in jeder Schreibweise): `admin`, `administrator`, `root`, `system`, `support`, `moderator`, `mod`, `aircard`, `aircardios`, `aircard-ios`, `aircard.ios`, `aircard_ios`, `webklar`, `anonym`, `anonymous`, `null`, `undefined`, `team`, `staff`, `official`, `offiziell`, `hilfe`, `help`, `kontakt`, `contact`, `info`. Die Zeile steht in der Tabelle `profiles` (Spalten `username`, `usernameKey`, je höchstens 20 Zeichen): Zeilen-ID = Konto-ID (ein Name je Konto, das Konto kommt aus dem JWT, nie aus dem Body), Recht genau `read("any")` (öffentlich lesbar; der Client liest die Namen direkt), Tabellenrechte keine. Ablauf je Konto nacheinander (Sperre `profile:<Konto-ID>`): Gehört der Schlüssel einem **anderen** Konto (Abfrage `equal usernameKey`), kommt `409` `usernameTaken`; die eigene Zeile zählt nicht, die Schreibweise des eigenen Namens zu ändern („kenso“ → „Kenso“) oder denselben Namen nochmal zu senden geht also. Sonst wird die Zeile des Kontos geändert (der alte Name ist damit sofort frei) oder, wenn es noch keine gibt, angelegt. **Gleichzeitigkeit**: Die Vorabprüfung ist nur der Normalfall mit klarer Antwort; entscheiden tut der Unique-Index `username_key` auf `usernameKey` (Schema-Skript). Wollen zwei Konten gleichzeitig denselben Namen, meldet Appwrite dem zweiten beim Anlegen bzw. Ändern `409`, und das wird ebenfalls `usernameTaken`: genau ein Konto bekommt den Namen. **Pflicht**: `/api/veroeffentlichen` und `/api/logos/hochladen` antworten `403` `usernameRequired`, wenn das Konto keine Zeile in `profiles` hat (`hasProfile`, ein `GET` der Zeile mit der Konto-ID). Die Prüfung läuft nach der Anmeldung und vor allen anderen Prüfungen (Body, Dateien, Karte), es wird nichts angelegt oder angefasst; bestehende Logos und Karten bleiben unberührt. Fehlt die Tabelle `profiles` (Schema nicht eingespielt), antworten beide Endpunkte `500` „Interner Fehler.“.
|
||||
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; Logo löschen blendet zuerst aus und löscht dann Datei, Herzen, Meldungen, Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
|
||||
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen oder nach der zweiten Prüfung entfernt, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen oder Wiederherstellen von `delete(owner)`, `profile created` bzw. `profile updated` (Benutzername angelegt bzw. geändert, ohne Namen und Konto-ID); keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID (bei einem Fehler beim Aufräumen eines Logos auch die Datei-ID) und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
node --test "deploy/api/*.test.js"
|
||||
```
|
||||
|
||||
Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen und dem Unique-Index von `profiles`, der eine zweite Zeile mit demselben `usernameKey` mit `409` abweist) und greifen nie auf das echte Appwrite zu.
|
||||
@@ -0,0 +1,662 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Routen und Logik des API-Dienstes `aircard-api` (Node 22, nur Bordmittel). Alle Schreibzugriffe auf die
|
||||
* 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 · 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}
|
||||
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
|
||||
* POST /api/melden ohne Anmeldung {id, reason} (Melder = IPv4-/24 bzw. IPv6-/48, gehasht)
|
||||
* 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
|
||||
* POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar}
|
||||
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
|
||||
* POST /api/logos/hochladen · /api/logos/herz · /api/logos/melden · /api/logos/loeschen · /api/admin/logo-sichtbarkeit
|
||||
* siehe logos.js
|
||||
* 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) -> Benutzername (403, nur Veröffentlichen und
|
||||
* Logo hochladen) -> JSON und Felder (400) -> Logik.
|
||||
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>} (siehe common.js).
|
||||
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx; code `unavailable`) bzw. 500 (alle anderen, z. B. falscher Key oder
|
||||
* fehlende Tabelle; code `internal`).
|
||||
* Logs: kein Zugriffsprotokoll je Anfrage (sonst steuert ein Angreifer das Log-Volumen), nur Ereigniszeilen
|
||||
* (veröffentlicht, gemeldet, ausgeblendet, …) und Serverfehler, zusammen höchstens 120 Zeilen pro Minute.
|
||||
*/
|
||||
|
||||
const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
|
||||
const {
|
||||
MAX_PUBLICATIONS,
|
||||
HIDE_AT_REPORTS,
|
||||
IMAGE_HEAD_BYTES,
|
||||
REASONS,
|
||||
CONTROL_CHARACTERS,
|
||||
MSG,
|
||||
HttpError,
|
||||
isId,
|
||||
isPlainObject,
|
||||
isDuplicate,
|
||||
parseJsonObject,
|
||||
readImageSize,
|
||||
hash32,
|
||||
hmacHex,
|
||||
reporterHash,
|
||||
reporterNetwork,
|
||||
normalizeIp,
|
||||
clientIp,
|
||||
visibleRowPermissions,
|
||||
hiddenRowPermissions,
|
||||
uploadFilePermissions,
|
||||
visibleFilePermissions,
|
||||
hiddenFilePermissions,
|
||||
checkFileExists,
|
||||
checkFileOwnership,
|
||||
} = require('./common.js');
|
||||
const { createLogoRoutes } = require('./logos.js');
|
||||
const { createProfileRoutes, hasProfile } = require('./profiles.js');
|
||||
|
||||
const TABLE_GALLERY = 'gallery';
|
||||
const TABLE_REPORTS = 'reports';
|
||||
const TABLE_CARDS = 'cards';
|
||||
const BUCKET = 'gallery-images';
|
||||
|
||||
const MAX_BODY_BYTES = 8 * 1024;
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']);
|
||||
const REPORT_LIST_LIMIT = 100;
|
||||
const REPORT_DELETE_ROUNDS = 50;
|
||||
/** Kartenmaße wie web/src/card/geometry.ts (CARD_W, CARD_H, PREVIEW_WIDTH; Vorschauhöhe 303 plus/minus 1). */
|
||||
const IMAGE_WIDTH = 1536;
|
||||
const IMAGE_HEIGHT = 969;
|
||||
const PREVIEW_WIDTH = 480;
|
||||
const PREVIEW_HEIGHT_MIN = 302;
|
||||
const PREVIEW_HEIGHT_MAX = 304;
|
||||
const RATE_LIMIT = 10;
|
||||
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
||||
const RATE_SWEEP_MS = 60 * 1000;
|
||||
const RATE_MAX_KEYS = 50_000;
|
||||
const LOG_BUDGET_PER_MINUTE = 120;
|
||||
const LOG_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const JWT_CHARACTERS = /^[\x21-\x7e]+$/;
|
||||
|
||||
// ---- Reine Helfer ----------------------------------------------------------------------------------
|
||||
|
||||
/** Feste Zeilen-ID je Autor und private Karte: Appwrite-409 ersetzt jede Doppelt-Prüfung (auch bei Gleichzeitigkeit). */
|
||||
function galleryRowId(ownerId, sourceCardId) {
|
||||
return hash32(`${ownerId}|${sourceCardId}`);
|
||||
}
|
||||
|
||||
/** Feste Zeilen-ID je Karte und Melder: eine Meldung pro Melder und Karte. */
|
||||
function reportRowId(galleryId, hash) {
|
||||
return hash32(`${galleryId}|${hash}`);
|
||||
}
|
||||
|
||||
/** Fehlername oder -typ fürs Log: nur Wortzeichen, Punkt und Bindestrich, höchstens 60 Zeichen. */
|
||||
function safeToken(value, fallback = 'unknown') {
|
||||
const text = String(value === undefined || value === null ? '' : value).replace(/[^\w.-]/g, '').slice(0, 60);
|
||||
return text === '' ? fallback : text;
|
||||
}
|
||||
|
||||
const isCardImageSize = (size) => size !== null && size.width === IMAGE_WIDTH && size.height === IMAGE_HEIGHT;
|
||||
const isCardPreviewSize = (size) =>
|
||||
size !== null && size.width === PREVIEW_WIDTH && size.height >= PREVIEW_HEIGHT_MIN && size.height <= PREVIEW_HEIGHT_MAX;
|
||||
|
||||
const TEXT_FIELDS = [
|
||||
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
|
||||
{ key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false },
|
||||
{ key: 'displayName', article: 'Der', name: 'Anzeigename', max: 30, required: false },
|
||||
];
|
||||
|
||||
function readText(raw, field) {
|
||||
if (raw === undefined || raw === null) {
|
||||
return field.required ? { message: 'Bitte gib einen Titel an.' } : { value: '' };
|
||||
}
|
||||
if (typeof raw !== 'string') return { message: `${field.article} ${field.name} ist ungültig.` };
|
||||
const value = raw.trim();
|
||||
if (value === '' && field.required) return { message: 'Bitte gib einen Titel an.' };
|
||||
if (CONTROL_CHARACTERS.test(value)) return { message: `${field.article} ${field.name} enthält ungültige Zeichen.` };
|
||||
if (value.length > field.max) return { message: `${field.article} ${field.name} ist zu lang (höchstens ${field.max} Zeichen).` };
|
||||
return { value };
|
||||
}
|
||||
|
||||
/** Tags: string[], je getrimmt und klein, Leere raus, Doppelte raus, höchstens 5 mit je höchstens 20 Zeichen. */
|
||||
function readTags(raw) {
|
||||
if (raw === undefined || raw === null) return { tags: [] };
|
||||
if (!Array.isArray(raw)) return { message: 'Tags müssen als Liste gesendet werden.' };
|
||||
const tags = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry !== 'string') return { message: 'Ungültige Tags.' };
|
||||
const tag = entry.trim().toLowerCase();
|
||||
if (tag === '') continue;
|
||||
if (tag.includes(',')) return { message: 'Tags dürfen kein Komma enthalten.' };
|
||||
if (CONTROL_CHARACTERS.test(tag)) return { message: 'Tags enthalten ungültige Zeichen.' };
|
||||
if (tag.length > 20) return { message: 'Ein Tag darf höchstens 20 Zeichen lang sein.' };
|
||||
if (!tags.includes(tag)) tags.push(tag);
|
||||
}
|
||||
if (tags.length > 5) return { message: 'Bitte gib höchstens 5 Tags an.' };
|
||||
return { tags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft den Veröffentlichen-Body (Längen aus Spec §6, IDs nach ID_PATTERN).
|
||||
* @returns {{ok: true, data: object} | {ok: false, message: string}}
|
||||
*/
|
||||
function validatePublish(body) {
|
||||
if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest };
|
||||
|
||||
const data = {};
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const result = readText(body[field.key], field);
|
||||
if (result.message) return { ok: false, message: result.message };
|
||||
data[field.key] = result.value;
|
||||
}
|
||||
const tags = readTags(body.tags);
|
||||
if (tags.message) return { ok: false, message: tags.message };
|
||||
data.tags = tags.tags;
|
||||
|
||||
for (const [key, message] of [
|
||||
['imageFileId', 'Ungültige Bild-ID.'],
|
||||
['previewFileId', 'Ungültige Vorschau-ID.'],
|
||||
['sourceCardId', MSG.badCardId],
|
||||
]) {
|
||||
if (!isId(body[key])) return { ok: false, message };
|
||||
data[key] = body[key];
|
||||
}
|
||||
if (data.imageFileId === data.previewFileId) {
|
||||
return { ok: false, message: 'Bild und Vorschau müssen verschiedene Dateien sein.' };
|
||||
}
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
/** `title issuer tags` klein mit Leerzeichen, höchstens 300 Zeichen (Volltext-Index der Galerie). */
|
||||
function buildSearchText({ title = '', issuer = '', tags = [] }) {
|
||||
const tagList = Array.isArray(tags) ? tags : String(tags).split(',');
|
||||
return [title, issuer, ...tagList]
|
||||
.map((part) => String(part).trim())
|
||||
.filter((part) => part !== '')
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.slice(0, 300);
|
||||
}
|
||||
|
||||
// ---- HTTP-Hilfen ------------------------------------------------------------------------------------
|
||||
|
||||
function sendJson(res, status, payload, headers = {}) {
|
||||
const body = JSON.stringify(payload);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
...headers,
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
const JSON_CONTENT_TYPE = /^application\/json\s*(;|$)/i;
|
||||
|
||||
/** Liest den Rohbody mit Obergrenze; bei Überschreitung wird der Rest verworfen und die Verbindung geschlossen. */
|
||||
function readRawBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tooLarge = () => new HttpError(413, MSG.tooLarge, { Connection: 'close' });
|
||||
const declared = Number(req.headers['content-length']);
|
||||
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
||||
req.resume();
|
||||
reject(tooLarge());
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
let settled = false;
|
||||
const settle = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
chunks.length = 0;
|
||||
fn(value);
|
||||
};
|
||||
|
||||
req.on('data', (chunk) => {
|
||||
if (settled) return; // Rest verwerfen, Verbindung bleibt sauber
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY_BYTES) settle(reject, tooLarge());
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (settled) return;
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
settle(resolve, raw);
|
||||
});
|
||||
req.on('error', () => settle(reject, new HttpError(400, MSG.badRequest)));
|
||||
req.on('close', () => settle(reject, new HttpError(400, MSG.badRequest)));
|
||||
});
|
||||
}
|
||||
|
||||
/** Je Schlüssel nacheinander abarbeiten (z. B. je Karten-ID), damit Zähler und Rechte nie durcheinanderlaufen. */
|
||||
function createKeyedLock() {
|
||||
const tails = new Map();
|
||||
return async function withLock(key, fn) {
|
||||
const previous = tails.get(key) || Promise.resolve();
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const tail = previous.then(() => gate);
|
||||
tails.set(key, tail);
|
||||
await previous;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
if (tails.get(key) === tail) tails.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Die App ---------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} options.appwrite Objekt aus createAppwrite
|
||||
* @param {string} options.salt Salz für die Melder-Hashes
|
||||
* @param {() => number} [options.now] Uhr in ms (für das Rate-Limit)
|
||||
* @param {(line: string) => void} [options.log] bekommt fertige Zeilen mit Präfix [aircard-api]
|
||||
* @returns {(req, res) => Promise<void>} Handler für http.createServer, mit `rateLimitEntries()` für Tests
|
||||
*/
|
||||
function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
if (!appwrite || typeof appwrite !== 'object') throw new TypeError('appwrite fehlt');
|
||||
if (typeof salt !== 'string' || salt === '') throw new TypeError('salt fehlt');
|
||||
|
||||
// Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten- und Logo-IDs, Zahlen und Fehlertypen. Zusätzlich gilt ein
|
||||
// Zeilenbudget je Minute; was darüber liegt, wird gezählt und als eine Sammelzeile nachgetragen.
|
||||
const emit = (message) => log(`[aircard-api] ${new Date().toISOString()} ${message}`);
|
||||
let logWindowStart = 0;
|
||||
let logCount = 0;
|
||||
let logSuppressed = 0;
|
||||
function say(message) {
|
||||
const t = now();
|
||||
if (t - logWindowStart >= LOG_WINDOW_MS) {
|
||||
if (logSuppressed > 0) emit(`${logSuppressed} log lines suppressed (limit ${LOG_BUDGET_PER_MINUTE} per minute)`);
|
||||
logWindowStart = t;
|
||||
logCount = 0;
|
||||
logSuppressed = 0;
|
||||
}
|
||||
if (logCount >= LOG_BUDGET_PER_MINUTE) {
|
||||
logSuppressed += 1;
|
||||
return;
|
||||
}
|
||||
logCount += 1;
|
||||
emit(message);
|
||||
}
|
||||
const withLock = createKeyedLock();
|
||||
|
||||
// ---- Rate-Limit je Anschluss (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports` und `logo_reports`).
|
||||
// Ein Zähler für Karten- und Logo-Meldungen: logos.js bekommt `rateLimit` von hier.
|
||||
const hits = new Map();
|
||||
let lastSweep = 0;
|
||||
|
||||
function sweepRateLimit(t) {
|
||||
if (t - lastSweep < RATE_SWEEP_MS) return;
|
||||
lastSweep = t;
|
||||
for (const [key, list] of hits) {
|
||||
if (t - list[list.length - 1] >= RATE_WINDOW_MS) hits.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {{allowed: boolean, retryAfter?: number}} zählt die Meldung mit, wenn erlaubt */
|
||||
function rateLimit(key) {
|
||||
const t = now();
|
||||
sweepRateLimit(t);
|
||||
const list = (hits.get(key) || []).filter((stamp) => t - stamp < RATE_WINDOW_MS);
|
||||
if (list.length >= RATE_LIMIT) {
|
||||
hits.set(key, list);
|
||||
return { allowed: false, retryAfter: Math.max(1, Math.ceil((list[0] + RATE_WINDOW_MS - t) / 1000)) };
|
||||
}
|
||||
list.push(t);
|
||||
hits.delete(key); // neu einfügen: Reihenfolge = zuletzt benutzt
|
||||
hits.set(key, list);
|
||||
while (hits.size > RATE_MAX_KEYS) hits.delete(hits.keys().next().value);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// ---- Anmeldung
|
||||
async function authenticate(req) {
|
||||
const jwt = req.headers['x-aircard-jwt'];
|
||||
if (typeof jwt !== 'string' || jwt.length > 4096 || !JWT_CHARACTERS.test(jwt)) return null;
|
||||
const account = await appwrite.accountFromJwt(jwt);
|
||||
if (!account || !isId(account.$id)) return null;
|
||||
return { id: account.$id, isAdmin: Array.isArray(account.labels) && account.labels.includes('admin') };
|
||||
}
|
||||
|
||||
async function requireUser(req) {
|
||||
const user = await authenticate(req);
|
||||
if (!user) throw new HttpError(401, MSG.unauthorized);
|
||||
return user;
|
||||
}
|
||||
|
||||
// ---- Dateien und Zeilen
|
||||
function checkFileContent(file) {
|
||||
if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType);
|
||||
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize);
|
||||
}
|
||||
|
||||
/** Liest den Anfang beider Dateien und verlangt Kartenformat: Bild 1536 x 969, Vorschau 480 x 302-304. */
|
||||
async function checkImageSizes(data) {
|
||||
const [image, preview] = await Promise.all([
|
||||
appwrite.readFileHead(BUCKET, data.imageFileId, IMAGE_HEAD_BYTES),
|
||||
appwrite.readFileHead(BUCKET, data.previewFileId, IMAGE_HEAD_BYTES),
|
||||
]);
|
||||
if (image === null || preview === null) throw new HttpError(400, MSG.fileMissing);
|
||||
if (!isCardImageSize(readImageSize(image)) || !isCardPreviewSize(readImageSize(preview))) {
|
||||
throw new HttpError(400, MSG.imageFormat);
|
||||
}
|
||||
}
|
||||
|
||||
const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId);
|
||||
|
||||
/**
|
||||
* Die Galerie-Zeilen dieses Autors (auch ausgeblendete), höchstens MAX_PUBLICATIONS (mehr braucht der Aufrufer nicht:
|
||||
* ab dort ist das Limit erreicht). Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId`
|
||||
* nicht). Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
|
||||
*/
|
||||
function ownerRows(ownerId) {
|
||||
return appwrite.listRows(TABLE_GALLERY, [queryEqual('ownerId', [ownerId]), queryLimit(MAX_PUBLICATIONS)]);
|
||||
}
|
||||
|
||||
/** Die private Karte muss es geben und dem Konto gehören (Appwrite gibt dem Besitzer read("user:<id>") auf seine Zeile). */
|
||||
async function ownsSourceCard(userId, cardId) {
|
||||
const card = await appwrite.getRow(TABLE_CARDS, cardId);
|
||||
return card !== null && Array.isArray(card.$permissions) && card.$permissions.includes(`read("user:${userId}")`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Rechte beider Dateien. Wartet auf beide Aufrufe, auch wenn einer scheitert (die Sperre der Karte darf
|
||||
* nicht freigegeben werden, solange noch ein PUT unterwegs ist), und wirft danach den ersten Fehler.
|
||||
* @returns {Promise<Array>} je Datei das Ergebnis; null heißt: Datei gibt es nicht (mehr)
|
||||
*/
|
||||
async function setFilePermissions(row, permissions) {
|
||||
const results = await Promise.allSettled(fileIdsOf(row).map((fileId) => appwrite.updateFilePermissions(BUCKET, fileId, permissions)));
|
||||
const failed = results.find((result) => result.status === 'rejected');
|
||||
if (failed) throw failed.reason;
|
||||
return results.map((result) => result.value);
|
||||
}
|
||||
|
||||
/** Dateien zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
|
||||
async function hideCard(id, row, extra = {}) {
|
||||
await setFilePermissions(row, hiddenFilePermissions(row.ownerId));
|
||||
await appwrite.updateRow(TABLE_GALLERY, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId));
|
||||
}
|
||||
|
||||
async function deleteReports(galleryId) {
|
||||
for (let round = 0; round < REPORT_DELETE_ROUNDS; round += 1) {
|
||||
const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [galleryId]), queryLimit(REPORT_LIST_LIMIT)]);
|
||||
if (reports.length === 0) return;
|
||||
for (const report of reports) await appwrite.deleteRow(TABLE_REPORTS, report.$id);
|
||||
}
|
||||
}
|
||||
|
||||
/** reportCount = Anzahl Meldungszeilen der Karte; ab 3 wird ausgeblendet. */
|
||||
async function syncReports(id, row) {
|
||||
const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [id]), queryLimit(REPORT_LIST_LIMIT)]);
|
||||
const count = reports.length;
|
||||
if (count >= HIDE_AT_REPORTS) {
|
||||
await hideCard(id, row, { reportCount: count });
|
||||
say(`card ${id} hidden automatically (${count} reports)`);
|
||||
} else if (row.reportCount !== count) {
|
||||
await appwrite.updateRow(TABLE_GALLERY, id, { reportCount: count });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
|
||||
const [image, preview] = await Promise.all([
|
||||
appwrite.getFile(BUCKET, data.imageFileId),
|
||||
appwrite.getFile(BUCKET, data.previewFileId),
|
||||
]);
|
||||
checkFileExists(image, BUCKET);
|
||||
checkFileExists(preview, BUCKET);
|
||||
|
||||
// Je Autor nacheinander: Limit, Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
|
||||
return withLock(`owner:${user.id}`, async () => {
|
||||
const rows = await ownerRows(user.id);
|
||||
if (rows.length >= MAX_PUBLICATIONS) throw new HttpError(429, MSG.tooManyPublications);
|
||||
if (!(await ownsSourceCard(user.id, data.sourceCardId))) throw new HttpError(403, MSG.notYourCard);
|
||||
|
||||
const used = new Set(rows.flatMap(fileIdsOf));
|
||||
if (used.has(data.imageFileId) || used.has(data.previewFileId)) throw new HttpError(403, MSG.filesTaken);
|
||||
for (const file of [image, preview]) checkFileOwnership(file, user.id);
|
||||
for (const file of [image, preview]) checkFileContent(file);
|
||||
await checkImageSizes(data);
|
||||
|
||||
const id = galleryRowId(user.id, data.sourceCardId);
|
||||
try {
|
||||
await appwrite.createRow(
|
||||
TABLE_GALLERY,
|
||||
id,
|
||||
{
|
||||
title: data.title,
|
||||
issuer: data.issuer,
|
||||
// ",a,b," statt "a,b": der Tag-Filter der Galerie sucht ",<tag>," und trifft so nur ganze Tags.
|
||||
tags: data.tags.length > 0 ? `,${data.tags.join(',')},` : '',
|
||||
displayName: data.displayName,
|
||||
imageFileId: data.imageFileId,
|
||||
previewFileId: data.previewFileId,
|
||||
sourceCardId: data.sourceCardId,
|
||||
ownerId: user.id,
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
searchText: buildSearchText(data),
|
||||
},
|
||||
visibleRowPermissions(),
|
||||
);
|
||||
} catch (err) {
|
||||
if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Kanonischer Zustand: Beide Dateien tragen genau read("any") (kein update/delete, keine fremden Rollen).
|
||||
// Erst nach dem Anlegen der Zeile: ein 409 (dieselbe private Karte nochmal) fasst die Dateien nie an.
|
||||
// Scheitert das Festlegen, wird die Zeile zurückgenommen.
|
||||
try {
|
||||
const pinned = await setFilePermissions(data, visibleFilePermissions());
|
||||
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
|
||||
} catch (err) {
|
||||
let rolledBack = true;
|
||||
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {
|
||||
rolledBack = false;
|
||||
say(`rollback failed for card ${id}`);
|
||||
});
|
||||
// Nur wenn keine Zeile mehr auf die Dateien zeigt: schon festgelegte Dateien bekommen delete(owner) zurück,
|
||||
// damit der Client den Fehlversuch aufräumen kann (sonst blieben sie öffentlich und unlöschbar liegen).
|
||||
if (rolledBack) await setFilePermissions(data, uploadFilePermissions(user.id)).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
say(`card ${id} published`);
|
||||
return { status: 201, body: { id } };
|
||||
});
|
||||
}
|
||||
|
||||
async function withdraw({ req, raw }) {
|
||||
const user = await requireUser(req);
|
||||
const body = parseJsonObject(raw);
|
||||
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
|
||||
const id = body.id;
|
||||
|
||||
return withLock(id, async () => {
|
||||
const row = await appwrite.getRow(TABLE_GALLERY, id);
|
||||
if (!row) throw new HttpError(404, MSG.cardGone);
|
||||
if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden);
|
||||
|
||||
// Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen.
|
||||
for (const fileId of fileIdsOf(row)) await appwrite.deleteFile(BUCKET, fileId);
|
||||
await deleteReports(id);
|
||||
await appwrite.deleteRow(TABLE_GALLERY, id);
|
||||
say(`card ${id} withdrawn${row.ownerId === user.id ? '' : ' by admin'}`);
|
||||
return { status: 200, body: { ok: true } };
|
||||
});
|
||||
}
|
||||
|
||||
async function reportCard({ req, raw }) {
|
||||
const body = parseJsonObject(raw);
|
||||
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
|
||||
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
|
||||
const id = body.id;
|
||||
|
||||
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt weiter je Anschluss (IPv4 voll, IPv6 /64).
|
||||
const ip = clientIp(req);
|
||||
const reporter = reporterHash(salt, ip);
|
||||
const limit = rateLimit(hmacHex(salt, normalizeIp(ip)));
|
||||
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
|
||||
|
||||
return withLock(id, async () => {
|
||||
const row = await appwrite.getRow(TABLE_GALLERY, id);
|
||||
if (!row || row.hidden === true) throw new HttpError(404, MSG.cardGone);
|
||||
|
||||
let isNew = true;
|
||||
try {
|
||||
await appwrite.createRow(TABLE_REPORTS, reportRowId(id, reporter), { galleryId: id, reason: body.reason, reporterHash: reporter }, []);
|
||||
} catch (err) {
|
||||
if (!isDuplicate(err)) throw err;
|
||||
isNew = false;
|
||||
}
|
||||
// Auch bei einer doppelten Meldung neu zählen: heilt einen früher steckengebliebenen Zählerstand.
|
||||
await syncReports(id, row);
|
||||
if (isNew) say(`card ${id} reported (${body.reason})`);
|
||||
return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } };
|
||||
});
|
||||
}
|
||||
|
||||
async function setVisibility({ req, raw }) {
|
||||
const user = await requireUser(req);
|
||||
if (!user.isAdmin) throw new HttpError(403, MSG.adminOnly);
|
||||
const body = parseJsonObject(raw);
|
||||
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
|
||||
if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility);
|
||||
const id = body.id;
|
||||
|
||||
return withLock(id, async () => {
|
||||
const row = await appwrite.getRow(TABLE_GALLERY, id);
|
||||
if (!row) throw new HttpError(404, MSG.cardGone);
|
||||
|
||||
if (body.sichtbar) {
|
||||
// Dateien, dann Meldungen, Zeile zuletzt: sichtbar wird die Karte erst, wenn alles andere steht.
|
||||
await setFilePermissions(row, visibleFilePermissions(row.ownerId));
|
||||
await deleteReports(id);
|
||||
await appwrite.updateRow(TABLE_GALLERY, id, { hidden: false, reportCount: 0 }, visibleRowPermissions());
|
||||
say(`card ${id} made visible by admin`);
|
||||
} else {
|
||||
await hideCard(id, row);
|
||||
say(`card ${id} hidden by admin`);
|
||||
}
|
||||
return { status: 200, body: { ok: true } };
|
||||
});
|
||||
}
|
||||
|
||||
const ROUTES = new Map([
|
||||
['GET /api/gesundheit', { name: 'gesundheit', run: async () => ({ status: 200, body: { ok: true } }) }],
|
||||
['POST /api/veroeffentlichen', { name: 'veroeffentlichen', json: true, run: publish }],
|
||||
['POST /api/zurueckziehen', { name: 'zurueckziehen', json: true, run: withdraw }],
|
||||
['POST /api/melden', { name: 'melden', json: true, run: reportCard }],
|
||||
['POST /api/admin/sichtbarkeit', { name: 'admin/sichtbarkeit', json: true, run: setVisibility }],
|
||||
// Logos (logos.js): bekommen Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log von hier.
|
||||
...createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }),
|
||||
// Benutzernamen (profiles.js): bekommen Anmeldung, Sperre je Schlüssel und das Log von hier.
|
||||
...createProfileRoutes({ appwrite, say, withLock, requireUser }),
|
||||
]);
|
||||
|
||||
async function handle(req, res) {
|
||||
const pathname = (req.url || '').split('?')[0];
|
||||
const route = ROUTES.get(`${req.method} ${pathname}`);
|
||||
let status = 500;
|
||||
|
||||
try {
|
||||
if (!route) throw new HttpError(404, MSG.notFound);
|
||||
|
||||
let raw = '';
|
||||
if (route.json) {
|
||||
if (!JSON_CONTENT_TYPE.test(req.headers['content-type'] || '')) {
|
||||
req.resume();
|
||||
throw new HttpError(415, MSG.jsonOnly);
|
||||
}
|
||||
raw = await readRawBody(req);
|
||||
}
|
||||
|
||||
const result = await route.run({ req, raw });
|
||||
status = result.status;
|
||||
sendJson(res, result.status, result.body);
|
||||
} catch (err) {
|
||||
let headers = {};
|
||||
let message = MSG.internal;
|
||||
let code = 'internal';
|
||||
if (err instanceof HttpError) {
|
||||
status = err.status;
|
||||
message = err.message;
|
||||
code = err.code;
|
||||
headers = err.headers;
|
||||
} else if (err instanceof AppwriteError) {
|
||||
// Keine Antwort, Appwrite überlastet (429) oder Serverfehler: nicht erreichbar (502). Alles andere
|
||||
// (falscher Key, fehlende Tabelle, ungültige Struktur) ist ein Fehler hier (500).
|
||||
const unavailable = err.status === 0 || err.status === 429 || err.status >= 500;
|
||||
status = unavailable ? 502 : 500;
|
||||
message = unavailable ? MSG.unavailable : MSG.internal;
|
||||
code = unavailable ? 'unavailable' : 'internal';
|
||||
say(`${req.method} /api/${route.name} -> ${status}: appwrite error status=${err.status} type=${safeToken(err.type)}`);
|
||||
} else {
|
||||
status = 500;
|
||||
say(`${req.method} /api/${route ? route.name : '-'} -> 500: unexpected error ${safeToken(err && err.name)}`);
|
||||
}
|
||||
if (!res.headersSent) sendJson(res, status, { error: message, code }, headers);
|
||||
}
|
||||
}
|
||||
|
||||
function handler(req, res) {
|
||||
return handle(req, res).catch((err) => {
|
||||
// handle() fängt alles selbst ab; das hier sichert nur gegen Fehler beim Senden.
|
||||
say(`unexpected error ${safeToken(err && err.name)}`);
|
||||
try {
|
||||
res.destroy();
|
||||
} catch {
|
||||
// schon geschlossen
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handler.rateLimitEntries = () => hits.size;
|
||||
return handler;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createApp,
|
||||
visibleRowPermissions,
|
||||
hiddenRowPermissions,
|
||||
uploadFilePermissions,
|
||||
visibleFilePermissions,
|
||||
hiddenFilePermissions,
|
||||
reporterHash,
|
||||
reporterNetwork,
|
||||
clientIp,
|
||||
normalizeIp,
|
||||
readImageSize,
|
||||
validatePublish,
|
||||
buildSearchText,
|
||||
galleryRowId,
|
||||
reportRowId,
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Schmaler REST-Client für Appwrite 1.8 (Node 22, nur Bordmittel) mit dem Server-Key des API-Dienstes.
|
||||
*
|
||||
* createAppwrite({ endpoint, project, key, fetchImpl?, database?, timeoutMs? })
|
||||
* getRow(table, id) Zeile oder null (404 row_not_found)
|
||||
* listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit, queryCursorAfter);
|
||||
* eine Antwort ohne `rows`-Array ist ein Fehler (kein stilles [])
|
||||
* countRows(table, queries) Anzahl aller Treffer (`total` einer Abfrage mit limit(1), ohne eigenes limit
|
||||
* in `queries`); eine Antwort ohne gültiges `total` ist ein Fehler
|
||||
* createRow(table, rowId, data, permissions) doppelte ID -> AppwriteError mit status 409
|
||||
* updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben
|
||||
* deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar)
|
||||
* getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null
|
||||
* readFileHead(bucket, id, maxBytes) die ersten Bytes des Dateiinhalts (Buffer) oder null; liest nie mehr als
|
||||
* nötig und bricht den Rest der Übertragung ab
|
||||
* updateFilePermissions(bucket, id, permissions) Datei oder null (404 storage_file_not_found)
|
||||
* deleteFile(bucket, id) true, false bei 404 storage_file_not_found
|
||||
* accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
|
||||
*
|
||||
* Als „fehlt“ gilt nur ein 404 mit dem erwarteten Typ (row_not_found, storage_file_not_found). Jedes andere 404
|
||||
* (table_not_found, database_not_found, storage_bucket_not_found, unbekannte Route, 404 ohne JSON) ist ein Fehler:
|
||||
* so verschwindet ein Konfigurationsfehler nicht als „Karte nicht gefunden“.
|
||||
* Redirects werden nie verfolgt (`redirect: 'error'`), damit der Server-Key nicht an eine andere Adresse geht.
|
||||
* Jeder Aufruf bricht nach `timeoutMs` (10 s) ab. Jedes Pfadsegment wird mit encodeURIComponent kodiert;
|
||||
* "." und ".." lehnt der Client ab, weil encodeURIComponent sie nicht verändert und die URL-Normalisierung
|
||||
* sie sonst als Verzeichniswechsel auslegen würde.
|
||||
*
|
||||
* Fehler: AppwriteError mit `status` (0 = keine Antwort), `type` (Appwrite-Typ, "timeout" oder "unreachable").
|
||||
* Der Text enthält nie Key oder JWT.
|
||||
*/
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_HEAD_BYTES = 64;
|
||||
const ROW_NOT_FOUND = 'row_not_found';
|
||||
const FILE_NOT_FOUND = 'storage_file_not_found';
|
||||
|
||||
class AppwriteError extends Error {
|
||||
constructor(status, type, message) {
|
||||
super(message);
|
||||
this.name = 'AppwriteError';
|
||||
this.status = status;
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
/** Appwrite-Abfrage als JSON-String. */
|
||||
function queryEqual(attribute, values) {
|
||||
return JSON.stringify({ method: 'equal', attribute, values });
|
||||
}
|
||||
|
||||
function queryLimit(count) {
|
||||
return JSON.stringify({ method: 'limit', values: [count] });
|
||||
}
|
||||
|
||||
/** Nächste Seite nach der Zeile mit dieser ID (Reihenfolge: Anlegereihenfolge). */
|
||||
function queryCursorAfter(id) {
|
||||
return JSON.stringify({ method: 'cursorAfter', values: [id] });
|
||||
}
|
||||
|
||||
/** Ein Pfadsegment: nur nichtleere Zeichenketten, nie "." oder "..", immer kodiert. */
|
||||
function segment(value) {
|
||||
if (typeof value !== 'string' || value === '' || value === '.' || value === '..') {
|
||||
throw new TypeError('Ungültiges Pfadsegment');
|
||||
}
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, database = 'aircard', timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
||||
if (typeof endpoint !== 'string' || endpoint === '') throw new TypeError('endpoint fehlt');
|
||||
if (typeof project !== 'string' || project === '') throw new TypeError('project fehlt');
|
||||
if (typeof key !== 'string' || key === '') throw new TypeError('key fehlt');
|
||||
if (typeof fetchImpl !== 'function') throw new TypeError('fetch fehlt');
|
||||
|
||||
const base = endpoint.replace(/\/+$/, '');
|
||||
const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`;
|
||||
const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`;
|
||||
const rowsUrl = (table, queries) => {
|
||||
const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&');
|
||||
return `${rowsPath(table)}${search === '' ? '' : `?${search}`}`;
|
||||
};
|
||||
|
||||
/** Liest höchstens `limit` Bytes vom Anfang der Antwort und bricht den Rest ab (Dateien können 5 MiB groß sein). */
|
||||
async function readHead(response, limit) {
|
||||
if (!response.body) return Buffer.from(await response.arrayBuffer()).subarray(0, limit);
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
try {
|
||||
while (size < limit) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(Buffer.from(value));
|
||||
size += value.length;
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
return Buffer.concat(chunks).subarray(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er.
|
||||
* Mit `headBytes` ist `body` bei 2xx ein Buffer mit den ersten Bytes der Antwort statt geparstem JSON.
|
||||
*/
|
||||
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [], headBytes = 0 } = {}) {
|
||||
const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
|
||||
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
|
||||
const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) };
|
||||
if (body !== undefined) {
|
||||
requestHeaders['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let response;
|
||||
let text;
|
||||
try {
|
||||
response = await fetchImpl(`${base}${path}`, init);
|
||||
if (headBytes > 0 && response.status >= 200 && response.status < 300) {
|
||||
return { status: response.status, body: await readHead(response, headBytes) };
|
||||
}
|
||||
text = await response.text();
|
||||
} catch (err) {
|
||||
// Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten).
|
||||
const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
||||
throw timedOut
|
||||
? new AppwriteError(0, 'timeout', 'Appwrite antwortet nicht rechtzeitig')
|
||||
: new AppwriteError(0, 'unreachable', 'Appwrite nicht erreichbar');
|
||||
}
|
||||
|
||||
let parsed = null;
|
||||
if (text !== '') {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
if (response.status >= 200 && response.status < 300) return { status: response.status, body: parsed };
|
||||
const type = parsed && typeof parsed.type === 'string' ? parsed.type : 'unknown';
|
||||
if (response.status === 404 && missingTypes.includes(type)) return { status: 404, body: null };
|
||||
const message = parsed && typeof parsed.message === 'string' ? parsed.message : `HTTP ${response.status}`;
|
||||
throw new AppwriteError(response.status, type, message);
|
||||
}
|
||||
|
||||
return {
|
||||
async getRow(table, id) {
|
||||
const result = await send('GET', `${rowsPath(table)}/${segment(id)}`, { missingTypes: [ROW_NOT_FOUND] });
|
||||
return result.status === 404 ? null : result.body;
|
||||
},
|
||||
|
||||
async listRows(table, queries = []) {
|
||||
const result = await send('GET', rowsUrl(table, queries));
|
||||
if (!result.body || !Array.isArray(result.body.rows)) {
|
||||
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
|
||||
}
|
||||
return result.body.rows;
|
||||
},
|
||||
|
||||
/** Zählt die Treffer einer Abfrage über `total` (Appwrite zählt bis 5000): es wird höchstens eine Zeile übertragen. */
|
||||
async countRows(table, queries = []) {
|
||||
const result = await send('GET', rowsUrl(table, [...queries, queryLimit(1)]));
|
||||
const total = result.body && result.body.total;
|
||||
if (!Number.isInteger(total) || total < 0) {
|
||||
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
|
||||
}
|
||||
return total;
|
||||
},
|
||||
|
||||
async createRow(table, rowId, data, permissions) {
|
||||
const result = await send('POST', rowsPath(table), { body: { rowId, data, permissions } });
|
||||
return result.body;
|
||||
},
|
||||
|
||||
async updateRow(table, id, data, permissions) {
|
||||
const body = { data };
|
||||
if (Array.isArray(permissions)) body.permissions = permissions;
|
||||
const result = await send('PATCH', `${rowsPath(table)}/${segment(id)}`, { body });
|
||||
return result.body;
|
||||
},
|
||||
|
||||
async deleteRow(table, id) {
|
||||
const result = await send('DELETE', `${rowsPath(table)}/${segment(id)}`, { missingTypes: [ROW_NOT_FOUND] });
|
||||
return result.status !== 404;
|
||||
},
|
||||
|
||||
async getFile(bucket, id) {
|
||||
const result = await send('GET', filePath(bucket, id), { missingTypes: [FILE_NOT_FOUND] });
|
||||
return result.status === 404 ? null : result.body;
|
||||
},
|
||||
|
||||
async readFileHead(bucket, id, maxBytes = DEFAULT_HEAD_BYTES) {
|
||||
const result = await send('GET', `${filePath(bucket, id)}/view`, { missingTypes: [FILE_NOT_FOUND], headBytes: maxBytes });
|
||||
return result.status === 404 ? null : result.body;
|
||||
},
|
||||
|
||||
async updateFilePermissions(bucket, id, permissions) {
|
||||
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] });
|
||||
return result.status === 404 ? null : result.body;
|
||||
},
|
||||
|
||||
async deleteFile(bucket, id) {
|
||||
const result = await send('DELETE', filePath(bucket, id), { missingTypes: [FILE_NOT_FOUND] });
|
||||
return result.status !== 404;
|
||||
},
|
||||
|
||||
/** Prüft ein Nutzer-JWT: Konto aus GET /account (ohne Server-Key), null bei 401. */
|
||||
async accountFromJwt(jwt) {
|
||||
try {
|
||||
const result = await send('GET', '/account', { headers: { 'X-Appwrite-JWT': jwt }, withKey: false });
|
||||
return result.body;
|
||||
} catch (err) {
|
||||
if (err instanceof AppwriteError && err.status === 401) return null;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createAppwrite, AppwriteError, queryEqual, queryLimit, queryCursorAfter };
|
||||
@@ -0,0 +1,418 @@
|
||||
'use strict';
|
||||
|
||||
// Tests für deploy/api/appwrite.js gegen einen Fake-Appwrite im Speicher (fake-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, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js');
|
||||
const { startFakeAppwrite } = require('./fake-appwrite.js');
|
||||
|
||||
const fakes = [];
|
||||
afterEach(async () => {
|
||||
for (const fake of fakes.splice(0)) await fake.close();
|
||||
});
|
||||
|
||||
async function setup(options = {}) {
|
||||
const fake = await startFakeAppwrite();
|
||||
fakes.push(fake);
|
||||
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, ...options });
|
||||
return { fake, appwrite };
|
||||
}
|
||||
|
||||
const lastCall = (fake) => fake.calls[fake.calls.length - 1];
|
||||
|
||||
test('Zeilen: anlegen, lesen, ändern, löschen mit Projekt- und Key-Header', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
|
||||
const created = await appwrite.createRow('gallery', 'row1', { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, ['read("any")']);
|
||||
assert.equal(created.$id, 'row1');
|
||||
assert.equal(lastCall(fake).method, 'POST');
|
||||
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows');
|
||||
assert.deepEqual(lastCall(fake).body, {
|
||||
rowId: 'row1',
|
||||
data: { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' },
|
||||
permissions: ['read("any")'],
|
||||
});
|
||||
assert.equal(lastCall(fake).headers['x-appwrite-project'], fake.project);
|
||||
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
|
||||
assert.match(lastCall(fake).headers['content-type'], /^application\/json/);
|
||||
|
||||
assert.equal((await appwrite.getRow('gallery', 'row1')).title, 'T');
|
||||
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/row1');
|
||||
|
||||
await appwrite.updateRow('gallery', 'row1', { hidden: true });
|
||||
assert.equal(lastCall(fake).method, 'PATCH');
|
||||
assert.deepEqual(lastCall(fake).body, { data: { hidden: true } }, 'ohne permissions-Feld, wenn keine übergeben werden');
|
||||
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("any")']);
|
||||
|
||||
await appwrite.updateRow('gallery', 'row1', { reportCount: 3 }, ['read("user:o")']);
|
||||
assert.deepEqual(lastCall(fake).body, { data: { reportCount: 3 }, permissions: ['read("user:o")'] });
|
||||
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("user:o")']);
|
||||
|
||||
assert.equal(await appwrite.deleteRow('gallery', 'row1'), true);
|
||||
assert.equal(fake.row('gallery', 'row1'), null);
|
||||
});
|
||||
|
||||
test('Fehlende Zeile/Datei: getRow und getFile liefern null, deleteRow/deleteFile/updateFilePermissions sind tolerant', async () => {
|
||||
const { appwrite } = await setup();
|
||||
assert.equal(await appwrite.getRow('gallery', 'nope'), null);
|
||||
assert.equal(await appwrite.getFile('gallery-images', 'nope'), null);
|
||||
assert.equal(await appwrite.deleteRow('gallery', 'nope'), false);
|
||||
assert.equal(await appwrite.deleteFile('gallery-images', 'nope'), false);
|
||||
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'nope', ['read("any")']), null);
|
||||
});
|
||||
|
||||
test('Doppelte Zeilen-ID wirft AppwriteError mit Status 409 und Typ row_already_exists', async () => {
|
||||
const { appwrite } = await setup();
|
||||
const data = { galleryId: 'g', reason: 'spam', reporterHash: 'h' };
|
||||
await appwrite.createRow('reports', 'r1', data, []);
|
||||
await assert.rejects(
|
||||
() => appwrite.createRow('reports', 'r1', data, []),
|
||||
(err) => err instanceof AppwriteError && err.status === 409 && err.type === 'row_already_exists',
|
||||
);
|
||||
});
|
||||
|
||||
test('Andere Fehler (400, 401, 500) werden als AppwriteError geworfen', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
await assert.rejects(
|
||||
() => appwrite.createRow('gallery', 'x', { title: 'x'.repeat(61), imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, []),
|
||||
(err) => err instanceof AppwriteError && err.status === 400 && err.type === 'document_invalid_structure',
|
||||
);
|
||||
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
|
||||
await assert.rejects(
|
||||
() => appwrite.getRow('gallery', 'x'),
|
||||
(err) => err instanceof AppwriteError && err.status === 500,
|
||||
);
|
||||
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
|
||||
await assert.rejects(
|
||||
() => wrongKey.getRow('gallery', 'x'),
|
||||
(err) => err instanceof AppwriteError && err.status === 401,
|
||||
);
|
||||
});
|
||||
|
||||
test('listRows schickt die Queries als queries[]-Parameter und liefert die Zeilen', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
for (const id of ['a', 'b', 'c']) {
|
||||
await appwrite.createRow('reports', id, { galleryId: id === 'c' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
|
||||
}
|
||||
const rows = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(100)]);
|
||||
assert.deepEqual(rows.map((row) => row.$id).sort(), ['a', 'b']);
|
||||
|
||||
const { search } = lastCall(fake);
|
||||
const params = search.split('&').map((part) => decodeURIComponent(part));
|
||||
assert.deepEqual(params, [
|
||||
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
|
||||
'queries[]={"method":"limit","values":[100]}',
|
||||
]);
|
||||
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
|
||||
});
|
||||
|
||||
test('queryEqual, queryLimit und queryCursorAfter liefern Appwrite-JSON-Strings', () => {
|
||||
assert.equal(queryEqual('galleryId', ['x']), '{"method":"equal","attribute":"galleryId","values":["x"]}');
|
||||
assert.equal(queryLimit(100), '{"method":"limit","values":[100]}');
|
||||
assert.equal(queryCursorAfter('abc'), '{"method":"cursorAfter","values":["abc"]}');
|
||||
});
|
||||
|
||||
test('listRows blättert mit cursorAfter', async () => {
|
||||
const { appwrite } = await setup();
|
||||
for (const id of ['a', 'b', 'c', 'd', 'e']) {
|
||||
await appwrite.createRow('reports', id, { galleryId: 'g1', reason: 'spam', reporterHash: id }, []);
|
||||
}
|
||||
const first = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2)]);
|
||||
assert.deepEqual(first.map((row) => row.$id), ['a', 'b']);
|
||||
const second = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('b')]);
|
||||
assert.deepEqual(second.map((row) => row.$id), ['c', 'd']);
|
||||
const last = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('d')]);
|
||||
assert.deepEqual(last.map((row) => row.$id), ['e']);
|
||||
});
|
||||
|
||||
test('countRows: liefert das total der Abfrage (nicht die Länge der Seite) und schickt limit(1) mit', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
for (const id of ['a', 'b', 'c', 'd']) {
|
||||
await appwrite.createRow('reports', id, { galleryId: id === 'd' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
|
||||
}
|
||||
assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['g1'])]), 3);
|
||||
const call = lastCall(fake);
|
||||
assert.equal(call.method, 'GET');
|
||||
assert.equal(call.path, '/v1/tablesdb/aircard/tables/reports/rows');
|
||||
assert.deepEqual(call.search.split('&').map((part) => decodeURIComponent(part)), [
|
||||
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
|
||||
'queries[]={"method":"limit","values":[1]}',
|
||||
]);
|
||||
assert.equal(call.headers['x-appwrite-key'], fake.key);
|
||||
assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['nix'])]), 0);
|
||||
assert.equal(await appwrite.countRows('reports'), 4, 'ohne Abfragen zählt es die ganze Tabelle');
|
||||
});
|
||||
|
||||
test('countRows: fehlendes oder ungültiges total ist ein AppwriteError (502, invalid_response), Appwrite-Fehler werden weitergereicht', async () => {
|
||||
for (const payload of [{ rows: [] }, { total: '3', rows: [] }, { total: null, rows: [] }, { total: -1, rows: [] }, { total: 1.5, rows: [] }, [], 'text', null]) {
|
||||
const appwrite = createAppwrite({
|
||||
endpoint: 'http://appwrite.invalid/v1',
|
||||
project: 'p',
|
||||
key: 'k',
|
||||
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => appwrite.countRows('reports', []),
|
||||
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
}
|
||||
const zero = createAppwrite({ endpoint: 'http://appwrite.invalid/v1', project: 'p', key: 'k', fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }) });
|
||||
assert.equal(await zero.countRows('reports', []), 0, 'total 0 ist eine gültige Zahl');
|
||||
|
||||
const { fake, appwrite } = await setup();
|
||||
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
|
||||
await assert.rejects(() => appwrite.countRows('reports', []), (err) => err instanceof AppwriteError && err.status === 500);
|
||||
await assert.rejects(() => appwrite.countRows('keine-tabelle', []), (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'table_not_found');
|
||||
});
|
||||
|
||||
test('Dateien: lesen, Rechte setzen, löschen', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
fake.addFile({ id: 'f1', owner: 'u1', mimeType: 'image/webp', size: 1234 });
|
||||
|
||||
const file = await appwrite.getFile('gallery-images', 'f1');
|
||||
assert.equal(file.mimeType, 'image/webp');
|
||||
assert.equal(file.sizeOriginal, 1234);
|
||||
assert.equal(file.bucketId, 'gallery-images');
|
||||
assert.deepEqual(file.$permissions, ['read("any")', 'delete("user:u1")']);
|
||||
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/f1');
|
||||
|
||||
await appwrite.updateFilePermissions('gallery-images', 'f1', ['read("user:u1")']);
|
||||
assert.equal(lastCall(fake).method, 'PUT');
|
||||
assert.deepEqual(lastCall(fake).body, { permissions: ['read("user:u1")'] });
|
||||
assert.deepEqual(fake.file('f1').$permissions, ['read("user:u1")']);
|
||||
|
||||
assert.equal(await appwrite.deleteFile('gallery-images', 'f1'), true);
|
||||
assert.equal(fake.file('f1'), null);
|
||||
});
|
||||
|
||||
test('readFileHead: liefert nur die ersten Bytes, mit Server-Key, auch bei einer großen Datei', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
const content = Buffer.concat([Buffer.from('HEADBYTES'), Buffer.alloc(2 * 1024 * 1024, 7)]);
|
||||
fake.addFile({ id: 'big', owner: 'u1', content, size: content.length });
|
||||
|
||||
const head = await appwrite.readFileHead('gallery-images', 'big', 9);
|
||||
assert.ok(Buffer.isBuffer(head));
|
||||
assert.equal(head.toString('latin1'), 'HEADBYTES');
|
||||
assert.equal(lastCall(fake).method, 'GET');
|
||||
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/big/view');
|
||||
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
|
||||
|
||||
const sixtyFour = await appwrite.readFileHead('gallery-images', 'big');
|
||||
assert.equal(sixtyFour.length, 64, 'Standard: 64 Bytes');
|
||||
|
||||
fake.addFile({ id: 'small', owner: 'u1', content: Buffer.from('abc'), size: 3 });
|
||||
assert.equal((await appwrite.readFileHead('gallery-images', 'small', 64)).toString(), 'abc', 'kürzere Datei: alles');
|
||||
});
|
||||
|
||||
test('readFileHead: fehlende Datei ist null, andere Fehler werfen AppwriteError', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
assert.equal(await appwrite.readFileHead('gallery-images', 'nope'), null);
|
||||
fake.addFile({ id: 'f1', owner: 'u1' });
|
||||
fake.failNext((c) => c.path.endsWith('/files/f1/view'), { status: 500 });
|
||||
await assert.rejects(() => appwrite.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 500);
|
||||
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
|
||||
await assert.rejects(() => wrongKey.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 401);
|
||||
await assert.rejects(() => appwrite.readFileHead('gallery-images', '..'), TypeError);
|
||||
});
|
||||
|
||||
test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
|
||||
|
||||
const user = await appwrite.accountFromJwt('aaa.bbb.ccc');
|
||||
assert.equal(user.$id, 'u1');
|
||||
assert.deepEqual(user.labels, ['admin']);
|
||||
const call = lastCall(fake);
|
||||
assert.equal(call.path, '/v1/account');
|
||||
assert.equal(call.headers['x-appwrite-jwt'], 'aaa.bbb.ccc');
|
||||
assert.equal(call.headers['x-appwrite-project'], fake.project);
|
||||
assert.equal(call.headers['x-appwrite-key'], undefined, 'das Konto wird nie mit dem Server-Key abgefragt');
|
||||
|
||||
assert.equal(await appwrite.accountFromJwt('xxx.yyy.zzz'), null);
|
||||
|
||||
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
|
||||
await assert.rejects(() => appwrite.accountFromJwt('aaa.bbb.ccc'), (err) => err instanceof AppwriteError && err.status === 500);
|
||||
});
|
||||
|
||||
test('Pfadsegmente: jede ID wird kodiert, "." und ".." werden abgelehnt, bevor eine Anfrage rausgeht', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
|
||||
await appwrite.getRow('gallery', '../x');
|
||||
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/..%2Fx');
|
||||
await appwrite.getFile('gallery-images', 'a b/c');
|
||||
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/a%20b%2Fc');
|
||||
|
||||
const before = fake.calls.length;
|
||||
for (const bad of ['..', '.', '', 42, null, undefined]) {
|
||||
await assert.rejects(() => appwrite.getRow('gallery', bad), /Pfadsegment/);
|
||||
await assert.rejects(() => appwrite.deleteFile('gallery-images', bad), /Pfadsegment/);
|
||||
}
|
||||
await assert.rejects(() => appwrite.getRow('..', 'x'), /Pfadsegment/);
|
||||
assert.equal(fake.calls.length, before, 'es ging keine Anfrage raus');
|
||||
});
|
||||
|
||||
test('Zeitlimit: Appwrite-Aufrufe tragen standardmäßig AbortSignal.timeout(10000)', async () => {
|
||||
let seenSignal = null;
|
||||
const appwrite = createAppwrite({
|
||||
endpoint: 'http://appwrite.invalid/v1',
|
||||
project: 'p',
|
||||
key: 'k',
|
||||
fetchImpl: async (url, init) => {
|
||||
seenSignal = init.signal;
|
||||
return new Response(JSON.stringify({ $id: 'x' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
},
|
||||
});
|
||||
await appwrite.getRow('gallery', 'x');
|
||||
assert.ok(seenSignal instanceof AbortSignal);
|
||||
assert.equal(seenSignal.aborted, false);
|
||||
});
|
||||
|
||||
test('Zeitlimit: hängender Appwrite-Aufruf endet mit AppwriteError (timeout)', async () => {
|
||||
const { fake, appwrite } = await setup({ timeoutMs: 80 });
|
||||
fake.hangNext((call) => call.path.endsWith('/rows/slow'));
|
||||
await assert.rejects(
|
||||
() => appwrite.getRow('gallery', 'slow'),
|
||||
(err) => err instanceof AppwriteError && err.type === 'timeout' && err.status === 0,
|
||||
);
|
||||
});
|
||||
|
||||
test('Netzfehler: AppwriteError (unreachable) ohne Key im Text', async () => {
|
||||
const appwrite = createAppwrite({
|
||||
endpoint: 'http://appwrite.invalid/v1',
|
||||
project: 'p',
|
||||
key: 'GEHEIMER-KEY-123',
|
||||
fetchImpl: async () => {
|
||||
throw new TypeError('fetch failed GEHEIMER-KEY-123');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => appwrite.getRow('gallery', 'x'),
|
||||
(err) => err instanceof AppwriteError && err.type === 'unreachable' && !String(err.message).includes('GEHEIMER-KEY-123'),
|
||||
);
|
||||
});
|
||||
|
||||
test('404 gilt nur mit dem erwarteten Typ als „fehlt“, jedes andere 404 ist ein Fehler', async () => {
|
||||
const { fake, appwrite } = await setup();
|
||||
const rejectsWith404 = (promise, type) =>
|
||||
assert.rejects(promise, (err) => err instanceof AppwriteError && err.status === 404 && err.type === type);
|
||||
|
||||
// erwartete Typen: fehlt
|
||||
assert.equal(await appwrite.getRow('gallery', 'x'), null);
|
||||
assert.equal(await appwrite.deleteRow('gallery', 'x'), false);
|
||||
assert.equal(await appwrite.getFile('gallery-images', 'x'), null);
|
||||
assert.equal(await appwrite.deleteFile('gallery-images', 'x'), false);
|
||||
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'x', []), null);
|
||||
|
||||
// unbekannte Tabelle / Datenbank / Bucket: Fehler
|
||||
await rejectsWith404(appwrite.getRow('keine-tabelle', 'x'), 'table_not_found');
|
||||
await rejectsWith404(appwrite.deleteRow('keine-tabelle', 'x'), 'table_not_found');
|
||||
await rejectsWith404(appwrite.listRows('keine-tabelle', []), 'table_not_found');
|
||||
await rejectsWith404(appwrite.getFile('kein-bucket', 'x'), 'storage_bucket_not_found');
|
||||
await rejectsWith404(appwrite.updateFilePermissions('kein-bucket', 'x', []), 'storage_bucket_not_found');
|
||||
await rejectsWith404(appwrite.deleteFile('kein-bucket', 'x'), 'storage_bucket_not_found');
|
||||
const otherDatabase = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, database: 'andere-db' });
|
||||
await rejectsWith404(otherDatabase.getRow('gallery', 'x'), 'database_not_found');
|
||||
|
||||
// Typ von der falschen Art (Zeile statt Datei und umgekehrt) und unbekannte Route
|
||||
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'storage_file_not_found' });
|
||||
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'storage_file_not_found');
|
||||
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'row_not_found' });
|
||||
await rejectsWith404(appwrite.getFile('gallery-images', 'x'), 'row_not_found');
|
||||
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'general_route_not_found' });
|
||||
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'general_route_not_found');
|
||||
});
|
||||
|
||||
test('404 ohne JSON-Antwort (z. B. von einem Proxy) ist ein Fehler, kein „fehlt“', async () => {
|
||||
const appwrite = createAppwrite({
|
||||
endpoint: 'http://appwrite.invalid/v1',
|
||||
project: 'p',
|
||||
key: 'k',
|
||||
fetchImpl: async () => new Response('404 page not found', { status: 404 }),
|
||||
});
|
||||
for (const call of [
|
||||
() => appwrite.getRow('gallery', 'x'),
|
||||
() => appwrite.getFile('gallery-images', 'x'),
|
||||
() => appwrite.deleteRow('gallery', 'x'),
|
||||
() => appwrite.deleteFile('gallery-images', 'x'),
|
||||
() => appwrite.updateFilePermissions('gallery-images', 'x', []),
|
||||
]) {
|
||||
await assert.rejects(call, (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'unknown');
|
||||
}
|
||||
});
|
||||
|
||||
test('listRows: eine Antwort ohne rows-Array wirft einen AppwriteError statt still [] zu liefern', async () => {
|
||||
for (const payload of [{ total: 0 }, { rows: 'x' }, { rows: null }, [], 'text', null]) {
|
||||
const appwrite = createAppwrite({
|
||||
endpoint: 'http://appwrite.invalid/v1',
|
||||
project: 'p',
|
||||
key: 'k',
|
||||
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => appwrite.listRows('reports', []),
|
||||
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
|
||||
JSON.stringify(payload),
|
||||
);
|
||||
}
|
||||
const empty = createAppwrite({
|
||||
endpoint: 'http://appwrite.invalid/v1',
|
||||
project: 'p',
|
||||
key: 'k',
|
||||
fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }),
|
||||
});
|
||||
assert.deepEqual(await empty.listRows('reports', []), [], 'eine echte leere Liste bleibt eine leere Liste');
|
||||
});
|
||||
|
||||
test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner Weiterleitung)', async () => {
|
||||
const seen = [];
|
||||
const appwrite = createAppwrite({
|
||||
endpoint: 'http://appwrite.invalid/v1',
|
||||
project: 'p',
|
||||
key: 'k',
|
||||
fetchImpl: async (url, init) => {
|
||||
seen.push(init.redirect);
|
||||
return new Response(JSON.stringify({ total: 0, rows: [], $id: 'x' }), { status: 200 });
|
||||
},
|
||||
});
|
||||
await appwrite.getRow('gallery', 'x');
|
||||
await appwrite.listRows('gallery', []);
|
||||
await appwrite.countRows('gallery', []);
|
||||
await appwrite.createRow('gallery', 'x', {}, []);
|
||||
await appwrite.updateRow('gallery', 'x', {});
|
||||
await appwrite.deleteRow('gallery', 'x');
|
||||
await appwrite.getFile('gallery-images', 'x');
|
||||
await appwrite.updateFilePermissions('gallery-images', 'x', []);
|
||||
await appwrite.deleteFile('gallery-images', 'x');
|
||||
await appwrite.accountFromJwt('a.b.c');
|
||||
assert.equal(seen.length, 10);
|
||||
assert.ok(seen.every((value) => value === 'error'));
|
||||
});
|
||||
|
||||
test('Eine Weiterleitung von Appwrite wird nicht verfolgt (echter Server antwortet 302)', async () => {
|
||||
const target = http.createServer((req, res) => {
|
||||
target.hits = (target.hits || 0) + 1;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('{}');
|
||||
});
|
||||
await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve));
|
||||
const redirector = http.createServer((req, res) => {
|
||||
res.writeHead(302, { Location: `http://127.0.0.1:${target.address().port}/gesammelt` });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((resolve) => redirector.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const appwrite = createAppwrite({ endpoint: `http://127.0.0.1:${redirector.address().port}/v1`, project: 'p', key: 'GEHEIMER-KEY' });
|
||||
await assert.rejects(() => appwrite.getRow('gallery', 'x'), (err) => err instanceof AppwriteError && err.type === 'unreachable');
|
||||
assert.equal(target.hits || 0, 0, 'das Ziel der Weiterleitung wurde nie angefragt');
|
||||
} finally {
|
||||
redirector.closeAllConnections();
|
||||
target.closeAllConnections();
|
||||
await new Promise((resolve) => redirector.close(resolve));
|
||||
await new Promise((resolve) => target.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 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: <deutscher Text>, code: <Schlüssel in MSG>}. Texte ohne festen Schlüssel (dynamische
|
||||
* Prüftexte wie „Der Titel ist zu lang (höchstens 60 Zeichen).“) haben bei Status 400 den code `badRequest`, sonst `error`.
|
||||
*/
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const net = require('node:net');
|
||||
const { AppwriteError } = require('./appwrite.js');
|
||||
|
||||
/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
|
||||
const MAX_PUBLICATIONS = 20;
|
||||
/** Höchstens so viele Logos je Konto (ausgeblendete zählen mit); Löschen gibt einen Platz frei. */
|
||||
const MAX_LOGOS = 30;
|
||||
/** Ab so vielen verschiedenen Meldern wird eine Karte bzw. ein Logo ausgeblendet. */
|
||||
const HIDE_AT_REPORTS = 3;
|
||||
/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */
|
||||
const IMAGE_HEAD_BYTES = 64;
|
||||
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
/** 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]/;
|
||||
|
||||
/**
|
||||
* Alle Fehlertexte der API, Schlüssel = `code` der Fehlerantwort. Die Texte sind paarweise verschieden (ein Test prüft das):
|
||||
* `codeFor` findet den Schlüssel über den Text. Die Clients übersetzen Meldungen über den Schlüssel; darum ist MSG ein
|
||||
* schlichtes Objekt aus Texten, das dieses Modul unverändert exportiert.
|
||||
*/
|
||||
const MSG = {
|
||||
unauthorized: 'Bitte melde dich an.',
|
||||
notFound: 'Nicht gefunden.',
|
||||
cardGone: 'Diese Karte ist nicht (mehr) verfügbar.',
|
||||
forbidden: 'Dazu hast du keine Berechtigung.',
|
||||
adminOnly: 'Nur für Admins.',
|
||||
foreignFile: 'Diese Datei gehört dir nicht.',
|
||||
fileMissing: 'Bild nicht gefunden.',
|
||||
fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.',
|
||||
fileSize: 'Das Bild ist größer als 5 MB.',
|
||||
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
|
||||
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
|
||||
imageFormat: 'Das Bild hat nicht das Kartenformat.',
|
||||
notYourCard: 'Diese Karte gehört nicht zu deinem Konto.',
|
||||
tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`,
|
||||
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
|
||||
badRequest: 'Ungültige Anfrage.',
|
||||
badCardId: 'Ungültige Karten-ID.',
|
||||
badReason: 'Bitte wähle einen gültigen Grund.',
|
||||
badVisibility: 'Ungültige Angabe zur Sichtbarkeit.',
|
||||
// Logos
|
||||
logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.',
|
||||
badLogoId: 'Ungültige Logo-ID.',
|
||||
logoNameMissing: 'Bitte gib einen Namen an.',
|
||||
logoFileType: 'Das Logo muss eine PNG-Datei sein.',
|
||||
logoFileSize: 'Das Logo ist größer als 1 MB.',
|
||||
logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).',
|
||||
logoTaken: 'Diese Datei wird schon für ein Logo verwendet.',
|
||||
tooManyLogos: `Du hast schon ${MAX_LOGOS} Logos hochgeladen. Lösche eins, um ein neues hochzuladen.`,
|
||||
badHeart: 'Ungültige Angabe zum Herz.',
|
||||
badMono: 'Ungültige Angabe zu „einfarbig“.',
|
||||
// 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.',
|
||||
internal: 'Interner Fehler.',
|
||||
};
|
||||
|
||||
// ---- Fehler ----------------------------------------------------------------------------------------
|
||||
|
||||
/** Text -> Schlüssel (die Texte in MSG sind paarweise verschieden, sonst wäre der Code nicht eindeutig). */
|
||||
const CODE_BY_TEXT = new Map(Object.entries(MSG).map(([key, text]) => [text, key]));
|
||||
|
||||
/**
|
||||
* Der `code` einer Fehlerantwort: der MSG-Schlüssel mit genau diesem Text, sonst `badRequest` bei Status 400 und
|
||||
* `error` bei jedem anderen Status.
|
||||
*/
|
||||
function codeFor(status, message) {
|
||||
return CODE_BY_TEXT.get(message) || (status === 400 ? 'badRequest' : 'error');
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(status, message, headers = {}, code = codeFor(status, message)) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reine Helfer ----------------------------------------------------------------------------------
|
||||
|
||||
function isId(value) {
|
||||
return typeof value === 'string' && ID_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Sichtbare Zeile (Karte oder Logo): lesen darf jeder. */
|
||||
function visibleRowPermissions() {
|
||||
return ['read("any")'];
|
||||
}
|
||||
|
||||
/** Ausgeblendete Zeile: nur Autor und Admins. */
|
||||
function hiddenRowPermissions(owner) {
|
||||
assertOwner(owner);
|
||||
return [`read("user:${owner}")`, 'read("label:admin")'];
|
||||
}
|
||||
|
||||
/** Herz (Zeile in `logo_likes`): lesen darf nur das Konto selbst. */
|
||||
function likeRowPermissions(userId) {
|
||||
assertOwner(userId);
|
||||
return [`read("user:${userId}")`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit
|
||||
* Fehlversuche auf). Nur so kommen Dateien in `publish` (Karten) und `uploadLogo` (Logos); die API setzt sie danach auf
|
||||
* `visibleFilePermissions`.
|
||||
*/
|
||||
function uploadFilePermissions(owner) {
|
||||
assertOwner(owner);
|
||||
return ['read("any")', `delete("user:${owner}")`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die
|
||||
* Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen
|
||||
* löscht die Dateien serverseitig mit dem Key.
|
||||
*/
|
||||
function visibleFilePermissions() {
|
||||
return ['read("any")'];
|
||||
}
|
||||
|
||||
/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */
|
||||
function hiddenFilePermissions(owner) {
|
||||
assertOwner(owner);
|
||||
return [`read("user:${owner}")`, 'read("label:admin")'];
|
||||
}
|
||||
|
||||
function hash32(text) {
|
||||
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
function expandIpv6(address) {
|
||||
let text = address;
|
||||
const lastColon = text.lastIndexOf(':');
|
||||
const tail = text.slice(lastColon + 1);
|
||||
if (tail.includes('.')) {
|
||||
const o = tail.split('.').map(Number);
|
||||
text = `${text.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
|
||||
}
|
||||
const [head, rest] = text.split('::');
|
||||
const headGroups = head === '' ? [] : head.split(':');
|
||||
const restGroups = rest === undefined || rest === '' ? [] : rest.split(':');
|
||||
const groups = rest === undefined ? headGroups : [...headGroups, ...Array(8 - headGroups.length - restGroups.length).fill('0'), ...restGroups];
|
||||
return groups.map((group) => Number.parseInt(group, 16));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
|
||||
* { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
|
||||
*/
|
||||
function parseIp(ip) {
|
||||
if (typeof ip !== 'string') return { kind: 'none' };
|
||||
let text = ip.trim();
|
||||
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
|
||||
const zone = text.indexOf('%');
|
||||
if (zone !== -1) text = text.slice(0, zone);
|
||||
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
|
||||
if (withPort) text = withPort[1];
|
||||
|
||||
if (net.isIPv4(text)) return { kind: 'v4', text };
|
||||
if (net.isIPv6(text)) {
|
||||
const groups = expandIpv6(text);
|
||||
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
|
||||
return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` };
|
||||
}
|
||||
return { kind: 'v6', groups };
|
||||
}
|
||||
return { kind: 'other', text: text.toLowerCase().slice(0, 64) };
|
||||
}
|
||||
|
||||
const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':');
|
||||
|
||||
/**
|
||||
* Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4,
|
||||
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
|
||||
*/
|
||||
function normalizeIp(ip) {
|
||||
const parsed = parseIp(ip);
|
||||
if (parsed.kind === 'none') return '';
|
||||
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`;
|
||||
return parsed.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4).
|
||||
* So zählen drei Adressen aus demselben Netz als ein Melder und blenden keine Karte und kein Logo aus.
|
||||
*/
|
||||
function reporterNetwork(ip) {
|
||||
const parsed = parseIp(ip);
|
||||
if (parsed.kind === 'none') return '';
|
||||
if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`;
|
||||
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`;
|
||||
return parsed.text;
|
||||
}
|
||||
|
||||
function firstHeaderValue(req, name) {
|
||||
const value = req.headers && req.headers[name];
|
||||
const text = Array.isArray(value) ? value[0] : value;
|
||||
if (typeof text !== 'string') return '';
|
||||
return text.split(',')[0].trim();
|
||||
}
|
||||
|
||||
/** Echte Client-IP: X-Real-Ip (setzt Traefik), sonst erster X-Forwarded-For-Eintrag, sonst Socket. */
|
||||
function clientIp(req) {
|
||||
return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || '';
|
||||
}
|
||||
|
||||
function hmacHex(salt, text) {
|
||||
return crypto.createHmac('sha256', salt).update(text).digest('hex');
|
||||
}
|
||||
|
||||
/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */
|
||||
function reporterHash(salt, ip) {
|
||||
return hmacHex(salt, reporterNetwork(ip));
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
/**
|
||||
* Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang).
|
||||
* @returns {{width: number, height: number} | null}
|
||||
*/
|
||||
function readImageSize(bytes) {
|
||||
if (!Buffer.isBuffer(bytes)) return null;
|
||||
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') {
|
||||
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
||||
}
|
||||
if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') {
|
||||
const kind = bytes.toString('latin1', 12, 16);
|
||||
if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
|
||||
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
|
||||
}
|
||||
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
|
||||
const bits = bytes.readUInt32LE(21);
|
||||
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
|
||||
}
|
||||
if (kind === 'VP8X' && bytes.length >= 30) {
|
||||
return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonObject(raw) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new HttpError(400, MSG.badRequest);
|
||||
}
|
||||
if (!isPlainObject(parsed)) throw new HttpError(400, MSG.badRequest);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isDuplicate(err) {
|
||||
return err instanceof AppwriteError && err.status === 409;
|
||||
}
|
||||
|
||||
// ---- Dateiprüfungen (Karten und Logos) -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im erwarteten Bucket. Metadaten wie Typ und
|
||||
* Größe prüft der Aufrufer erst nach `checkFileOwnership`, damit fremde Dateien nichts darüber verraten.
|
||||
*/
|
||||
function checkFileExists(file, bucket) {
|
||||
if (!file) throw new HttpError(400, MSG.fileMissing);
|
||||
if (file.bucketId !== bucket) throw new HttpError(403, MSG.foreignFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") und read("any"). Eine Datei ohne delete(ich) gehört
|
||||
* jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Dateien schon
|
||||
* verwendeter Zeilen hat der Aufrufer vorher als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet.
|
||||
*/
|
||||
function checkFileOwnership(file, userId) {
|
||||
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
|
||||
if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
|
||||
// Frisch hochgeladene Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
|
||||
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_PUBLICATIONS,
|
||||
MAX_LOGOS,
|
||||
HIDE_AT_REPORTS,
|
||||
IMAGE_HEAD_BYTES,
|
||||
REASONS,
|
||||
ID_PATTERN,
|
||||
USERNAME_PATTERN,
|
||||
RESERVED_USERNAMES,
|
||||
CONTROL_CHARACTERS,
|
||||
MSG,
|
||||
codeFor,
|
||||
HttpError,
|
||||
isId,
|
||||
usernameKey,
|
||||
isReservedUsername,
|
||||
isPlainObject,
|
||||
isDuplicate,
|
||||
parseJsonObject,
|
||||
readImageSize,
|
||||
hash32,
|
||||
hmacHex,
|
||||
reporterHash,
|
||||
reporterNetwork,
|
||||
normalizeIp,
|
||||
clientIp,
|
||||
visibleRowPermissions,
|
||||
hiddenRowPermissions,
|
||||
likeRowPermissions,
|
||||
uploadFilePermissions,
|
||||
visibleFilePermissions,
|
||||
hiddenFilePermissions,
|
||||
checkFileExists,
|
||||
checkFileOwnership,
|
||||
};
|
||||
@@ -0,0 +1,478 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Nur für Tests: Appwrite im Speicher (Teilmenge der REST-API von 1.8.1) als echter HTTP-Server auf Port 0.
|
||||
*
|
||||
* GET /v1/account Header X-Appwrite-JWT (+ Projekt, ohne Key)
|
||||
* GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409
|
||||
* GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?}
|
||||
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions}
|
||||
* GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`)
|
||||
*
|
||||
* Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an). Es gibt außerdem die Tabellen
|
||||
* `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[]=<json>` mit den Methoden equal, cursorAfter und limit. Die Antwort enthält wie bei Appwrite `total`:
|
||||
* die Zahl aller Treffer der equal-Abfragen, unabhängig von limit und cursorAfter (darauf baut `countRows`).
|
||||
* Zeilen werden gegen die Spaltenlängen der echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im
|
||||
* Test auffallen.
|
||||
* Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht),
|
||||
* so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen.
|
||||
* 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');
|
||||
|
||||
const BUCKETS = ['gallery-images', 'card-images', 'logo-images'];
|
||||
|
||||
/** PNG-Anfang (Signatur + IHDR) mit den Maßen; reicht für die Größenprüfung der API. */
|
||||
function pngHeader(width, height) {
|
||||
const head = Buffer.alloc(33);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(head, 0);
|
||||
head.writeUInt32BE(13, 8);
|
||||
head.write('IHDR', 12, 'latin1');
|
||||
head.writeUInt32BE(width, 16);
|
||||
head.writeUInt32BE(height, 20);
|
||||
head[24] = 8; // Bit-Tiefe
|
||||
head[25] = 6; // RGBA
|
||||
return head;
|
||||
}
|
||||
|
||||
/** WebP-Anfang mit den Maßen; kind: 'VP8 ' (verlustbehaftet), 'VP8L' (verlustfrei) oder 'VP8X' (erweitert). */
|
||||
function webpHeader(kind, width, height) {
|
||||
const head = Buffer.alloc(40);
|
||||
head.write('RIFF', 0, 'latin1');
|
||||
head.writeUInt32LE(32, 4);
|
||||
head.write('WEBP', 8, 'latin1');
|
||||
head.write(kind, 12, 'latin1');
|
||||
if (kind === 'VP8 ') {
|
||||
head.writeUInt32LE(10, 16);
|
||||
Buffer.from([0x30, 0x01, 0x00, 0x9d, 0x01, 0x2a]).copy(head, 20);
|
||||
head.writeUInt16LE(width & 0x3fff, 26);
|
||||
head.writeUInt16LE(height & 0x3fff, 28);
|
||||
} else if (kind === 'VP8L') {
|
||||
head.writeUInt32LE(5, 16);
|
||||
head[20] = 0x2f;
|
||||
head.writeUInt32LE(((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14), 21);
|
||||
} else if (kind === 'VP8X') {
|
||||
head.writeUInt32LE(10, 16);
|
||||
head[20] = 0x10; // Alpha-Flag
|
||||
head.writeUIntLE(width - 1, 24, 3);
|
||||
head.writeUIntLE(height - 1, 27, 3);
|
||||
} else {
|
||||
throw new Error(`Unbekannte WebP-Art: ${kind}`);
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
const COLUMNS = {
|
||||
gallery: {
|
||||
title: { size: 60, required: true },
|
||||
issuer: { size: 40 },
|
||||
tags: { size: 120 },
|
||||
displayName: { size: 30 },
|
||||
imageFileId: { size: 36, required: true },
|
||||
previewFileId: { size: 36, required: true },
|
||||
sourceCardId: { size: 36 },
|
||||
ownerId: { size: 36, required: true },
|
||||
hidden: { type: 'boolean' },
|
||||
reportCount: { type: 'integer' },
|
||||
searchText: { size: 300 },
|
||||
},
|
||||
reports: {
|
||||
galleryId: { size: 36, required: true },
|
||||
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
|
||||
reporterHash: { size: 64, required: true },
|
||||
},
|
||||
// Spalten wie in web/scripts/appwrite-setup.mjs (Community-Logos)
|
||||
logos: {
|
||||
name: { size: 40, required: true },
|
||||
displayName: { size: 30 },
|
||||
fileId: { size: 36, required: true },
|
||||
ownerId: { size: 36, required: true },
|
||||
mono: { type: 'boolean' },
|
||||
likeCount: { type: 'integer', min: 0 },
|
||||
hidden: { type: 'boolean' },
|
||||
reportCount: { type: 'integer', min: 0 },
|
||||
},
|
||||
logo_likes: {
|
||||
logoId: { size: 36, required: true },
|
||||
userId: { size: 36, required: true },
|
||||
},
|
||||
logo_reports: {
|
||||
logoId: { size: 36, required: true },
|
||||
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
|
||||
reporterHash: { size: 64, required: true },
|
||||
},
|
||||
// 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. */
|
||||
const DEFAULTS = {
|
||||
gallery: { hidden: false, reportCount: 0 },
|
||||
logos: { mono: false, likeCount: 0, hidden: false, reportCount: 0 },
|
||||
};
|
||||
|
||||
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) {
|
||||
const tables = {
|
||||
gallery: new Map(),
|
||||
reports: new Map(),
|
||||
cards: new Map(),
|
||||
logos: new Map(),
|
||||
logo_likes: new Map(),
|
||||
logo_reports: new Map(),
|
||||
profiles: new Map(),
|
||||
};
|
||||
const files = new Map();
|
||||
const contents = new Map();
|
||||
const users = new Map();
|
||||
const calls = [];
|
||||
const injections = [];
|
||||
const swaps = new Map();
|
||||
let sequence = 0;
|
||||
|
||||
function addUser(jwt, { id, labels = [] }) {
|
||||
users.set(jwt, { $id: id, email: `${id}@example.test`, labels });
|
||||
}
|
||||
|
||||
/** Beliebige Konto-Antwort für `GET /account` (z. B. ohne labels oder mit ungültiger ID). */
|
||||
function addRawUser(jwt, account) {
|
||||
users.set(jwt, account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei mit Metadaten und Inhalt. `role` bestimmt den Standardinhalt: 'image' = Kartenbild 1536 x 969, 'preview' =
|
||||
* Vorschau 480 x 303 (Standard: WebP ist die Vorschau, alles andere das Bild); `content` überschreibt ihn.
|
||||
*/
|
||||
function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000, role, content }) {
|
||||
const kind = role || (mimeType === 'image/webp' ? 'preview' : 'image');
|
||||
const [width, height] = kind === 'preview' ? [480, 303] : [1536, 969];
|
||||
files.set(id, {
|
||||
$id: id,
|
||||
bucketId: bucket,
|
||||
name: `${id}.png`,
|
||||
$permissions: permissions || ['read("any")', `delete("user:${owner}")`],
|
||||
mimeType,
|
||||
sizeOriginal: size,
|
||||
});
|
||||
contents.set(id, content || (mimeType === 'image/webp' ? webpHeader('VP8 ', width, height) : pngHeader(width, height)));
|
||||
}
|
||||
|
||||
/** Private Karte (Tabelle `cards`) mit den Rechten, die Appwrite dem Besitzer beim Anlegen gibt. */
|
||||
function addCard(id, owner, permissions) {
|
||||
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);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Nächster passender Aufruf (predicate(call) -> boolean) scheitert mit Status/Typ. */
|
||||
function failNext(predicate, { status = 500, type = 'general_server_error' } = {}) {
|
||||
injections.push({ predicate, status, type, hang: false });
|
||||
}
|
||||
|
||||
/** Nächster passender Aufruf bekommt nie eine Antwort. */
|
||||
function hangNext(predicate) {
|
||||
injections.push({ predicate, hang: true });
|
||||
}
|
||||
|
||||
/** Nächster passender Aufruf wird erst nach `delayMs` bearbeitet (Zustand ändert sich erst dann). */
|
||||
function delayNext(predicate, delayMs) {
|
||||
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
|
||||
* Lesen gültig und danach ein anderer.
|
||||
*/
|
||||
function swapContentAfterPermissions(id, content) {
|
||||
swaps.set(id, content);
|
||||
}
|
||||
|
||||
function validateData(table, data, { partial }) {
|
||||
const columns = COLUMNS[table];
|
||||
for (const [name, value] of Object.entries(data)) {
|
||||
const column = columns[name];
|
||||
if (!column) return `Unknown attribute: ${name}`;
|
||||
if (column.type === 'boolean' && typeof value !== 'boolean') return `Invalid ${name}`;
|
||||
if (column.type === 'integer' && (!Number.isInteger(value) || (column.min !== undefined && value < column.min))) return `Invalid ${name}`;
|
||||
if (column.enum && !column.enum.includes(value)) return `Invalid ${name}`;
|
||||
if (column.size !== undefined) {
|
||||
if (typeof value !== 'string') return `Invalid ${name}`;
|
||||
if (value.length > column.size) return `Invalid ${name}: too long`;
|
||||
}
|
||||
}
|
||||
if (!partial) {
|
||||
for (const [name, column] of Object.entries(columns)) {
|
||||
if (column.required && data[name] === undefined) return `Missing required attribute: ${name}`;
|
||||
}
|
||||
}
|
||||
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));
|
||||
req.on('end', async () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
const [pathname, search = ''] = req.url.split('?');
|
||||
const call = {
|
||||
method: req.method,
|
||||
path: pathname,
|
||||
search,
|
||||
headers: req.headers,
|
||||
body: raw === '' ? null : safeParse(raw),
|
||||
};
|
||||
calls.push(call);
|
||||
|
||||
const reply = (status, payload) => {
|
||||
if (status === 204) {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const text = JSON.stringify(payload);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
|
||||
res.end(text);
|
||||
};
|
||||
const error = (status, type, message = type) => reply(status, { message, code: status, type });
|
||||
|
||||
const injectionIndex = injections.findIndex((entry) => entry.predicate(call));
|
||||
if (injectionIndex !== -1) {
|
||||
const [entry] = injections.splice(injectionIndex, 1);
|
||||
if (entry.hang) return;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
|
||||
if (segments[0] !== 'v1' || req.headers['x-appwrite-project'] !== project) {
|
||||
return error(401, 'general_unauthorized_scope', 'Missing or wrong project');
|
||||
}
|
||||
|
||||
// GET /v1/account (JWT, ohne Key)
|
||||
if (segments.length === 2 && segments[1] === 'account' && req.method === 'GET') {
|
||||
const user = users.get(req.headers['x-appwrite-jwt']);
|
||||
if (!user) return error(401, 'user_jwt_invalid', 'Invalid JWT');
|
||||
return reply(200, user);
|
||||
}
|
||||
|
||||
if (req.headers['x-appwrite-key'] !== key) return error(401, 'general_unauthorized_scope', 'Missing or wrong key');
|
||||
|
||||
// Zeilen
|
||||
if (segments[1] === 'tablesdb' && segments[3] === 'tables' && segments[5] === 'rows') {
|
||||
const table = segments[4];
|
||||
if (segments[2] !== database) return error(404, 'database_not_found');
|
||||
if (!tables[table]) return error(404, 'table_not_found');
|
||||
const store = tables[table];
|
||||
|
||||
if (segments.length === 6 && req.method === 'POST') {
|
||||
const body = call.body || {};
|
||||
if (typeof body.rowId !== 'string' || body.rowId === '') return error(400, 'general_argument_invalid', 'rowId is required');
|
||||
if (store.has(body.rowId)) return error(409, 'row_already_exists');
|
||||
const data = body.data || {};
|
||||
const problem = validateData(table, data, { partial: false });
|
||||
if (problem) return error(400, 'document_invalid_structure', problem);
|
||||
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);
|
||||
}
|
||||
|
||||
if (segments.length === 6 && req.method === 'GET') {
|
||||
const found = runQueries([...store.values()], search);
|
||||
if (found === null) return error(400, 'general_query_invalid');
|
||||
return reply(200, found);
|
||||
}
|
||||
|
||||
if (segments.length === 7) {
|
||||
const row = store.get(segments[6]);
|
||||
if (!row) return error(404, 'row_not_found');
|
||||
if (req.method === 'GET') return reply(200, row);
|
||||
if (req.method === 'DELETE') {
|
||||
store.delete(segments[6]);
|
||||
return reply(204);
|
||||
}
|
||||
if (req.method === 'PATCH') {
|
||||
const body = call.body || {};
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dateiinhalt: GET .../files/{id}/view
|
||||
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 7 && segments[6] === 'view' && req.method === 'GET') {
|
||||
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
|
||||
const file = files.get(segments[5]);
|
||||
if (!file) return error(404, 'storage_file_not_found');
|
||||
const content = contents.get(segments[5]);
|
||||
res.writeHead(200, { 'Content-Type': file.mimeType, 'Content-Length': content.length });
|
||||
res.end(content);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dateien
|
||||
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) {
|
||||
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
|
||||
const file = files.get(segments[5]);
|
||||
if (!file) return error(404, 'storage_file_not_found');
|
||||
if (req.method === 'GET') return reply(200, file);
|
||||
if (req.method === 'DELETE') {
|
||||
files.delete(segments[5]);
|
||||
contents.delete(segments[5]);
|
||||
return reply(204);
|
||||
}
|
||||
if (req.method === 'PUT') {
|
||||
const body = call.body || {};
|
||||
if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid');
|
||||
file.$permissions = body.permissions;
|
||||
if (swaps.has(segments[5])) {
|
||||
contents.set(segments[5], swaps.get(segments[5]));
|
||||
swaps.delete(segments[5]);
|
||||
}
|
||||
return reply(200, file);
|
||||
}
|
||||
}
|
||||
|
||||
return error(404, 'general_route_not_found');
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
endpoint: `http://127.0.0.1:${port}/v1`,
|
||||
project,
|
||||
key,
|
||||
calls,
|
||||
addUser,
|
||||
addRawUser,
|
||||
addFile,
|
||||
addCard,
|
||||
addProfile,
|
||||
addRow,
|
||||
failNext,
|
||||
hangNext,
|
||||
delayNext,
|
||||
holdNext,
|
||||
swapContentAfterPermissions,
|
||||
row: (table, id) => tables[table].get(id) || null,
|
||||
rows: (table) => [...tables[table].values()],
|
||||
file: (id) => files.get(id) || null,
|
||||
close: async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSegment(segment) {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
function safeParse(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { _unparsable: text };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage.
|
||||
* Liefert {total, rows}: `total` zählt alle Treffer der equal-Abfragen, `rows` ist die Seite nach cursorAfter und limit.
|
||||
*/
|
||||
function runQueries(rows, search) {
|
||||
let result = rows;
|
||||
let limit = 25;
|
||||
let cursor = null;
|
||||
for (const part of search.split('&').filter(Boolean)) {
|
||||
const eq = part.indexOf('=');
|
||||
const name = decodeURIComponent(part.slice(0, eq));
|
||||
if (name !== 'queries[]') return null;
|
||||
let query;
|
||||
try {
|
||||
query = JSON.parse(decodeURIComponent(part.slice(eq + 1)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (query.method === 'equal') {
|
||||
result = result.filter((row) => query.values.includes(row[query.attribute]));
|
||||
} else if (query.method === 'limit') {
|
||||
limit = query.values[0];
|
||||
} else if (query.method === 'cursorAfter') {
|
||||
cursor = query.values[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const total = result.length;
|
||||
if (cursor !== null) {
|
||||
const index = result.findIndex((row) => row.$id === cursor);
|
||||
if (index === -1) return null;
|
||||
result = result.slice(index + 1);
|
||||
}
|
||||
return { total, rows: result.slice(0, limit) };
|
||||
}
|
||||
|
||||
module.exports = { startFakeAppwrite, pngHeader, webpHeader };
|
||||
@@ -0,0 +1,437 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Endpunkte der Community-Logos im API-Dienst `aircard-api` (Node 22, nur Bordmittel). Sie folgen den Karten-Endpunkten
|
||||
* in app.js: gleiche Reihenfolge der Prüfungen, gleiche Rücknahme beim Hochladen, gleiche Melde- und Ausblend-Logik.
|
||||
* app.js hängt die Routen an und gibt Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log mit.
|
||||
*
|
||||
* POST /api/logos/hochladen Nutzer-JWT {name, displayName?, mono?, fileId}
|
||||
* 401 · 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
|
||||
* mehr ändern): fällt sie durch oder fehlt sie, werden Datei und Zeile gelöscht und die Antwort ist 400.
|
||||
* POST /api/logos/herz Nutzer-JWT {id, herz}
|
||||
* 401 · 400 · 404 fehlt oder ausgeblendet · 200 {herz, likeCount}
|
||||
* POST /api/logos/melden ohne Anmeldung {id, reason} (wie /api/melden, Rate-Limit gemeinsam)
|
||||
* 400 · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
|
||||
* POST /api/logos/loeschen Nutzer-JWT (Hochlader oder Admin) {id}
|
||||
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
|
||||
* Blendet das Logo zuerst aus (Datei und Zeile), löscht dann Datei, Herzen, Meldungen und zuletzt die Zeile: ein Abbruch
|
||||
* hinterlässt höchstens eine unsichtbare Zeile, nie eine öffentliche mit gelöschter Datei.
|
||||
* POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar}
|
||||
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
|
||||
*
|
||||
* Tabellen `logos` (Zeilen-ID = hash32(`<Konto>|<Datei>`), siehe logoRowId; Altlogos haben noch Zeilen-ID = Datei-ID), `logo_likes`,
|
||||
* `logo_reports`, Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows),
|
||||
* nie hoch- oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo (auch beim Hochladen,
|
||||
* vom Anlegen der Zeile bis zur zweiten Prüfung, innerhalb der Sperre des Kontos).
|
||||
* Logs nennen nur Logo-IDs und Zahlen, nie Namen, IPs, Hashes oder JWTs; Herzen werden nicht geloggt.
|
||||
*/
|
||||
|
||||
const { queryEqual, queryLimit } = require('./appwrite.js');
|
||||
const {
|
||||
MAX_LOGOS,
|
||||
HIDE_AT_REPORTS,
|
||||
IMAGE_HEAD_BYTES,
|
||||
REASONS,
|
||||
CONTROL_CHARACTERS,
|
||||
MSG,
|
||||
HttpError,
|
||||
isId,
|
||||
isPlainObject,
|
||||
isDuplicate,
|
||||
parseJsonObject,
|
||||
readImageSize,
|
||||
hash32,
|
||||
hmacHex,
|
||||
reporterHash,
|
||||
normalizeIp,
|
||||
clientIp,
|
||||
visibleRowPermissions,
|
||||
hiddenRowPermissions,
|
||||
likeRowPermissions,
|
||||
uploadFilePermissions,
|
||||
visibleFilePermissions,
|
||||
hiddenFilePermissions,
|
||||
checkFileExists,
|
||||
checkFileOwnership,
|
||||
} = require('./common.js');
|
||||
const { hasProfile } = require('./profiles.js');
|
||||
|
||||
const TABLE_LOGOS = 'logos';
|
||||
const TABLE_LIKES = 'logo_likes';
|
||||
const TABLE_REPORTS = 'logo_reports';
|
||||
const BUCKET = 'logo-images';
|
||||
|
||||
const MAX_LOGO_BYTES = 1024 * 1024;
|
||||
/** Maße des fertigen Logos (wie die Verarbeitung im Editor): 8 × 8 bis 720 × 250 Pixel. */
|
||||
const LOGO_MIN_SIDE = 8;
|
||||
const LOGO_MAX_WIDTH = 720;
|
||||
const LOGO_MAX_HEIGHT = 250;
|
||||
/** Herzen und Meldungen eines Logos werden in Runden zu 100 gelöscht, höchstens 50 Runden. */
|
||||
const ROW_BATCH = 100;
|
||||
const ROW_ROUNDS = 50;
|
||||
|
||||
const LOGO_TEXT_FIELDS = [
|
||||
{ key: 'name', label: 'Der Name', max: 40, required: true },
|
||||
{ key: 'displayName', label: 'Der Anzeigename', max: 30, required: false },
|
||||
];
|
||||
|
||||
// ---- Reine Helfer ----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Feste Zeilen-ID je Konto und Datei: dieselbe Datei desselben Kontos ergibt dieselbe ID (Appwrite-409 beim Anlegen). Die ID ist
|
||||
* nicht die Datei-ID: die Zeilen-ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten, und jeder
|
||||
* könnte eine Datei mit genau dieser ID anlegen und als Logo hochladen. Mit dem Konto im Hash lässt sich die alte ID so nicht neu belegen.
|
||||
* Ältere Logos haben noch Zeilen-ID = Datei-ID; alle anderen Endpunkte adressieren die Zeile nur über ihre ID und lesen die Datei
|
||||
* aus `fileId`, daher gehen beide Arten.
|
||||
*/
|
||||
function logoRowId(ownerId, fileId) {
|
||||
return hash32(`${ownerId}|${fileId}`);
|
||||
}
|
||||
|
||||
/** Feste Zeilen-ID je Logo und Nutzer: ein Herz pro Konto, Appwrite-409 ersetzt jede Doppelt-Prüfung. */
|
||||
function logoLikeId(logoId, userId) {
|
||||
return hash32(`${logoId}|${userId}`);
|
||||
}
|
||||
|
||||
/** Feste Zeilen-ID je Logo und Melder (`logo|` trennt sie von den Karten-Meldungen): eine Meldung pro Melder und Logo. */
|
||||
function logoReportId(logoId, reporter) {
|
||||
return hash32(`logo|${logoId}|${reporter}`);
|
||||
}
|
||||
|
||||
function readLogoText(raw, field) {
|
||||
if (raw === undefined || raw === null) return field.required ? { message: MSG.logoNameMissing } : { value: '' };
|
||||
if (typeof raw !== 'string') return { message: `${field.label} ist ungültig.` };
|
||||
const value = raw.trim();
|
||||
if (value === '' && field.required) return { message: MSG.logoNameMissing };
|
||||
if (CONTROL_CHARACTERS.test(value)) return { message: `${field.label} enthält ungültige Zeichen.` };
|
||||
if (value.length > field.max) return { message: `${field.label} ist zu lang (höchstens ${field.max} Zeichen).` };
|
||||
return { value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft den Hochladen-Body: Name 1-40, Anzeigename ≤ 30 (getrimmt, ohne Steuer- und Bidi-Zeichen), `mono` fehlt = false
|
||||
* (sonst muss es ein Boolean sein), `fileId` nach ID_PATTERN.
|
||||
* @returns {{ok: true, data: {name: string, displayName: string, mono: boolean, fileId: string}} | {ok: false, message: string}}
|
||||
*/
|
||||
function validateLogoUpload(body) {
|
||||
if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest };
|
||||
|
||||
const data = {};
|
||||
for (const field of LOGO_TEXT_FIELDS) {
|
||||
const result = readLogoText(body[field.key], field);
|
||||
if (result.message) return { ok: false, message: result.message };
|
||||
data[field.key] = result.value;
|
||||
}
|
||||
if (body.mono !== undefined && typeof body.mono !== 'boolean') return { ok: false, message: MSG.badMono };
|
||||
data.mono = body.mono === true;
|
||||
if (!isId(body.fileId)) return { ok: false, message: 'Ungültige Datei-ID.' };
|
||||
data.fileId = body.fileId;
|
||||
return { ok: true, data };
|
||||
}
|
||||
|
||||
/** Typ und Größe laut Appwrite (erst nach der Besitzprüfung, damit fremde Dateien nichts darüber verraten). */
|
||||
function checkLogoContent(file) {
|
||||
if (file.mimeType !== 'image/png') throw new HttpError(400, MSG.logoFileType);
|
||||
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_LOGO_BYTES) throw new HttpError(400, MSG.logoFileSize);
|
||||
}
|
||||
|
||||
function isLogoSize(head) {
|
||||
// readImageSize kennt auch WebP: ein Logo muss einen PNG-Kopf haben, nicht nur den Dateityp laut Appwrite
|
||||
if (head.toString('latin1', 1, 4) !== 'PNG') return false;
|
||||
const size = readImageSize(head);
|
||||
return (
|
||||
size !== null &&
|
||||
size.width >= LOGO_MIN_SIDE &&
|
||||
size.height >= LOGO_MIN_SIDE &&
|
||||
size.width <= LOGO_MAX_WIDTH &&
|
||||
size.height <= LOGO_MAX_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Die Routen ------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} options.appwrite Objekt aus createAppwrite
|
||||
* @param {string} options.salt Salz für die Melder-Hashes
|
||||
* @param {(message: string) => void} options.say Ereigniszeile ins Log (mit Zeilenbudget)
|
||||
* @param {(key: string, fn: () => Promise<any>) => Promise<any>} options.withLock Je Schlüssel nacheinander
|
||||
* @param {(key: string) => {allowed: boolean, retryAfter?: number}} options.rateLimit das Rate-Limit der Meldungen (gemeinsam mit den Karten)
|
||||
* @param {(req) => Promise<{id: string, isAdmin: boolean}>} options.requireUser wirft 401 ohne gültiges JWT
|
||||
* @returns {Array<[string, {name: string, json: true, run: (ctx: {req, raw: string}) => Promise<{status: number, body: object}>}]>}
|
||||
*/
|
||||
function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) {
|
||||
const withLogoLock = (id, fn) => withLock(`logo:${id}`, fn);
|
||||
|
||||
/** Setzt die Rechte der Logo-Datei; null heißt: die Datei gibt es nicht (mehr). */
|
||||
function setFilePermissions(row, permissions) {
|
||||
return isId(row.fileId) ? appwrite.updateFilePermissions(BUCKET, row.fileId, permissions) : Promise.resolve(null);
|
||||
}
|
||||
|
||||
/** Datei zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
|
||||
async function hideLogo(id, row, extra = {}) {
|
||||
await setFilePermissions(row, hiddenFilePermissions(row.ownerId));
|
||||
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId));
|
||||
}
|
||||
|
||||
/** Löscht alle Zeilen der Tabelle zu diesem Logo, in Runden (die Zeile des Logos selbst bleibt der Anker für eine Wiederholung). */
|
||||
async function deleteRowsOf(table, logoId) {
|
||||
for (let round = 0; round < ROW_ROUNDS; round += 1) {
|
||||
const rows = await appwrite.listRows(table, [queryEqual('logoId', [logoId]), queryLimit(ROW_BATCH)]);
|
||||
if (rows.length === 0) return;
|
||||
for (const row of rows) await appwrite.deleteRow(table, row.$id);
|
||||
}
|
||||
}
|
||||
|
||||
/** reportCount = Anzahl Meldungszeilen des Logos (neu gezählt); ab 3 wird ausgeblendet. */
|
||||
async function syncReports(id, row) {
|
||||
const count = await appwrite.countRows(TABLE_REPORTS, [queryEqual('logoId', [id])]);
|
||||
if (count >= HIDE_AT_REPORTS) {
|
||||
await hideLogo(id, row, { reportCount: count });
|
||||
say(`logo ${id} hidden automatically (${count} reports)`);
|
||||
} else if (row.reportCount !== count) {
|
||||
await appwrite.updateRow(TABLE_LOGOS, id, { reportCount: count });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Anfang der Datei und verlangt einen PNG-Kopf mit Maßen von 8 × 8 bis 720 × 250.
|
||||
* @returns {Promise<HttpError | null>} das Urteil (400 fileMissing bzw. logoFormat), null = in Ordnung; Appwrite-Fehler werfen
|
||||
*/
|
||||
async function logoSizeProblem(fileId) {
|
||||
const head = await appwrite.readFileHead(BUCKET, fileId, IMAGE_HEAD_BYTES);
|
||||
if (head === null) return new HttpError(400, MSG.fileMissing);
|
||||
return isLogoSize(head) ? null : new HttpError(400, MSG.logoFormat);
|
||||
}
|
||||
|
||||
async function checkLogoSize(fileId) {
|
||||
const problem = await logoSizeProblem(fileId);
|
||||
if (problem !== null) throw problem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Logo ist nach dem Festlegen der Rechte durchgefallen (Inhalt ausgetauscht oder Datei weg): die Datei mit dem
|
||||
* Server-Key löschen, dann die Zeile. Beides wird versucht, auch wenn das erste scheitert: die Zeile macht das Logo für
|
||||
* andere sichtbar. Bleibt etwas stehen, steht `rollback failed for logo <id> (file <id>)` im Log (mit der Datei-ID: sie
|
||||
* folgt nicht aus der Zeilen-ID, und bleibt nur die Datei liegen, gibt es keine Zeile mehr, an der man sie fände).
|
||||
*/
|
||||
async function removeRejectedLogo(rowId, fileId, problem) {
|
||||
let clean = true;
|
||||
await appwrite.deleteFile(BUCKET, fileId).catch(() => {
|
||||
clean = false;
|
||||
});
|
||||
await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
|
||||
clean = false;
|
||||
});
|
||||
say(clean ? `logo ${rowId} removed after upload check (${problem.code})` : `rollback failed for logo ${rowId} (file ${fileId})`);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const file = await appwrite.getFile(BUCKET, data.fileId);
|
||||
checkFileExists(file, BUCKET);
|
||||
const rowId = logoRowId(user.id, data.fileId);
|
||||
|
||||
// Je Konto nacheinander: Limit, Prüfung „Datei schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
|
||||
return withLock(`logo-owner:${user.id}`, async () => {
|
||||
// Die Zeilen dieses Kontos (auch ausgeblendete), höchstens MAX_LOGOS: ab dort ist das Limit erreicht.
|
||||
const rows = await appwrite.listRows(TABLE_LOGOS, [queryEqual('ownerId', [user.id]), queryLimit(MAX_LOGOS)]);
|
||||
if (rows.length >= MAX_LOGOS) throw new HttpError(429, MSG.tooManyLogos);
|
||||
// Dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung): entweder unter dieser
|
||||
// Zeilen-ID oder, bei einem Altlogo mit Zeilen-ID = Datei-ID, über die `fileId` der Zeile.
|
||||
if (rows.some((row) => row.$id === rowId || row.fileId === data.fileId)) throw new HttpError(409, MSG.logoTaken);
|
||||
checkFileOwnership(file, user.id);
|
||||
checkLogoContent(file);
|
||||
await checkLogoSize(data.fileId);
|
||||
|
||||
// Ab dem Anlegen steht die Zeile öffentlich in der Liste. Solange die Prüfung das Logo noch entfernen kann, dürfen Herz, Meldung
|
||||
// und Löschen nicht dazwischenkommen (ihre Zeilen blieben ohne Logo zurück): der Upload hält die Sperre des Logos bis zum Ende.
|
||||
// Nur das Hochladen nimmt zwei Sperren, immer erst die des Kontos, dann die des Logos; alle anderen Anfragen nehmen nur die des
|
||||
// Logos, so kann sich nichts verklemmen.
|
||||
return withLogoLock(rowId, async () => {
|
||||
try {
|
||||
await appwrite.createRow(
|
||||
TABLE_LOGOS,
|
||||
rowId,
|
||||
{
|
||||
name: data.name,
|
||||
displayName: data.displayName,
|
||||
fileId: data.fileId,
|
||||
ownerId: user.id,
|
||||
mono: data.mono,
|
||||
likeCount: 0,
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
},
|
||||
visibleRowPermissions(),
|
||||
);
|
||||
} catch (err) {
|
||||
// Eine Zeile mit dieser ID gibt es schon, steht aber nicht in der Liste des Kontos (ein Rest): das Logo ist vergeben.
|
||||
if (isDuplicate(err)) throw new HttpError(409, MSG.logoTaken);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen).
|
||||
// Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen.
|
||||
let problem = null;
|
||||
try {
|
||||
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
|
||||
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
|
||||
// Jetzt hat der Hochlader kein delete mehr und kann die Datei nicht mehr ändern: den Anfang noch einmal prüfen. So
|
||||
// lässt sich die Datei nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen. Ein
|
||||
// Appwrite-Fehler hier wird wie ein Fehler beim Festlegen behandelt (Rücknahme unten, nichts bleibt veröffentlicht).
|
||||
problem = await logoSizeProblem(data.fileId);
|
||||
} catch (err) {
|
||||
let rolledBack = true;
|
||||
await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
|
||||
rolledBack = false;
|
||||
say(`rollback failed for logo ${rowId} (file ${data.fileId})`);
|
||||
});
|
||||
// Nur wenn keine Zeile mehr auf die Datei zeigt, bekommt sie delete(owner) zurück (der Client räumt damit auf). Scheitert
|
||||
// auch das, bleibt die Datei ohne Zeile und ohne delete(owner) liegen: die Logzeile nennt ihre ID, nur so findet man sie.
|
||||
if (rolledBack) {
|
||||
await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {
|
||||
say(`restore failed for logo ${rowId} (file ${data.fileId})`);
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (problem !== null) {
|
||||
await removeRejectedLogo(rowId, data.fileId, problem);
|
||||
throw problem;
|
||||
}
|
||||
say(`logo ${rowId} uploaded`);
|
||||
return { status: 201, body: { id: rowId } };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function setHeart({ req, raw }) {
|
||||
const user = await requireUser(req);
|
||||
const body = parseJsonObject(raw);
|
||||
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
|
||||
if (typeof body.herz !== 'boolean') throw new HttpError(400, MSG.badHeart);
|
||||
const id = body.id;
|
||||
|
||||
return withLogoLock(id, async () => {
|
||||
const row = await appwrite.getRow(TABLE_LOGOS, id);
|
||||
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
|
||||
|
||||
const likeId = logoLikeId(id, user.id);
|
||||
if (body.herz) {
|
||||
try {
|
||||
await appwrite.createRow(TABLE_LIKES, likeId, { logoId: id, userId: user.id }, likeRowPermissions(user.id));
|
||||
} catch (err) {
|
||||
if (!isDuplicate(err)) throw err; // schon da: kein Fehler
|
||||
}
|
||||
} else {
|
||||
await appwrite.deleteRow(TABLE_LIKES, likeId); // schon weg (false): kein Fehler
|
||||
}
|
||||
// Immer neu zählen statt hoch- oder runterzählen: das heilt einen früher steckengebliebenen Zählerstand.
|
||||
const likeCount = await appwrite.countRows(TABLE_LIKES, [queryEqual('logoId', [id])]);
|
||||
if (row.likeCount !== likeCount) await appwrite.updateRow(TABLE_LOGOS, id, { likeCount });
|
||||
return { status: 200, body: { herz: body.herz, likeCount } };
|
||||
});
|
||||
}
|
||||
|
||||
async function reportLogo({ req, raw }) {
|
||||
const body = parseJsonObject(raw);
|
||||
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
|
||||
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
|
||||
const id = body.id;
|
||||
|
||||
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt je Anschluss und gilt für Karten und Logos zusammen.
|
||||
const ip = clientIp(req);
|
||||
const reporter = reporterHash(salt, ip);
|
||||
const limit = rateLimit(hmacHex(salt, normalizeIp(ip)));
|
||||
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
|
||||
|
||||
return withLogoLock(id, async () => {
|
||||
const row = await appwrite.getRow(TABLE_LOGOS, id);
|
||||
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
|
||||
|
||||
let isNew = true;
|
||||
try {
|
||||
await appwrite.createRow(TABLE_REPORTS, logoReportId(id, reporter), { logoId: id, reason: body.reason, reporterHash: reporter }, []);
|
||||
} catch (err) {
|
||||
if (!isDuplicate(err)) throw err;
|
||||
isNew = false;
|
||||
}
|
||||
// Auch bei einer doppelten Meldung neu zählen: heilt einen früher steckengebliebenen Zählerstand.
|
||||
await syncReports(id, row);
|
||||
if (isNew) say(`logo ${id} reported (${body.reason})`);
|
||||
return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } };
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteLogo({ req, raw }) {
|
||||
const user = await requireUser(req);
|
||||
const body = parseJsonObject(raw);
|
||||
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
|
||||
const id = body.id;
|
||||
|
||||
return withLogoLock(id, async () => {
|
||||
const row = await appwrite.getRow(TABLE_LOGOS, id);
|
||||
if (!row) throw new HttpError(404, MSG.logoGone);
|
||||
if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden);
|
||||
|
||||
// Zuerst nicht mehr öffentlich (wie beim Ausblenden: Datei, dann Zeile). Bricht das Löschen ab, nachdem die Datei weg ist, bliebe
|
||||
// sonst eine öffentliche Zeile mit einer Datei-ID zurück, unter der jedes Konto eine neue Datei anlegen könnte: alle gespeicherten
|
||||
// Karten zeigten dann deren Bild. So bleibt höchstens eine unsichtbare Zeile; die Wiederholung (Hochlader oder Admin) vollendet es.
|
||||
if (row.hidden !== true) await hideLogo(id, row);
|
||||
// Dann Datei, Herzen, Meldungen, die Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen.
|
||||
if (isId(row.fileId)) await appwrite.deleteFile(BUCKET, row.fileId);
|
||||
await deleteRowsOf(TABLE_LIKES, id);
|
||||
await deleteRowsOf(TABLE_REPORTS, id);
|
||||
await appwrite.deleteRow(TABLE_LOGOS, id);
|
||||
say(`logo ${id} deleted${row.ownerId === user.id ? '' : ' by admin'}`);
|
||||
return { status: 200, body: { ok: true } };
|
||||
});
|
||||
}
|
||||
|
||||
async function setVisibility({ req, raw }) {
|
||||
const user = await requireUser(req);
|
||||
if (!user.isAdmin) throw new HttpError(403, MSG.adminOnly);
|
||||
const body = parseJsonObject(raw);
|
||||
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
|
||||
if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility);
|
||||
const id = body.id;
|
||||
|
||||
return withLogoLock(id, async () => {
|
||||
const row = await appwrite.getRow(TABLE_LOGOS, id);
|
||||
if (!row) throw new HttpError(404, MSG.logoGone);
|
||||
|
||||
if (body.sichtbar) {
|
||||
// Datei, dann Meldungen, Zeile zuletzt: sichtbar wird das Logo erst, wenn alles andere steht.
|
||||
await setFilePermissions(row, visibleFilePermissions());
|
||||
await deleteRowsOf(TABLE_REPORTS, id);
|
||||
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: false, reportCount: 0 }, visibleRowPermissions());
|
||||
say(`logo ${id} made visible by admin`);
|
||||
} else {
|
||||
await hideLogo(id, row);
|
||||
say(`logo ${id} hidden by admin`);
|
||||
}
|
||||
return { status: 200, body: { ok: true } };
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
['POST /api/logos/hochladen', { name: 'logos/hochladen', json: true, run: uploadLogo }],
|
||||
['POST /api/logos/herz', { name: 'logos/herz', json: true, run: setHeart }],
|
||||
['POST /api/logos/melden', { name: 'logos/melden', json: true, run: reportLogo }],
|
||||
['POST /api/logos/loeschen', { name: 'logos/loeschen', json: true, run: deleteLogo }],
|
||||
['POST /api/admin/logo-sichtbarkeit', { name: 'admin/logo-sichtbarkeit', json: true, run: setVisibility }],
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = { createLogoRoutes, logoRowId, logoLikeId, logoReportId, validateLogoUpload };
|
||||
@@ -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:<Konto>`.
|
||||
* 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<boolean>}
|
||||
*/
|
||||
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<any>) => Promise<any>} 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 };
|
||||
@@ -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: <Text des MSG-Schlüssels>, code: <Schlüssel>}. */
|
||||
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:<id>): 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}“`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Start des API-Dienstes `aircard-api` (Node 22, nur Bordmittel): liest die Umgebung, baut createApp und
|
||||
* lauscht auf einem Port. Beschreibung der Endpunkte: app.js und README.md.
|
||||
*
|
||||
* Umgebung:
|
||||
* AIRCARD_API_KEY Pflicht Appwrite-Server-Key `aircard-api` (Console-Scopes documents.read/write = rows.* der API, files.read/write)
|
||||
* AIRCARD_API_SALT Pflicht Salz für die Melder-Hashes (32 Byte hex)
|
||||
* AIRCARD_APPWRITE_PROJECT_ID Pflicht Projekt-ID
|
||||
* AIRCARD_APPWRITE_ENDPOINT http://webklar-appwrite/v1
|
||||
* AIRCARD_API_PORT 8088
|
||||
* Fehlt etwas Pflichtiges, nennt die Meldung nur den Namen der Variable (nie Werte) und der Prozess endet mit Exit-Code 1.
|
||||
*/
|
||||
|
||||
const http = require('node:http');
|
||||
const { createAppwrite } = require('./appwrite.js');
|
||||
const { createApp } = require('./app.js');
|
||||
|
||||
const DEFAULT_PORT = 8088;
|
||||
const DEFAULT_ENDPOINT = 'http://webklar-appwrite/v1';
|
||||
|
||||
/**
|
||||
* Prüft die Umgebung, ohne etwas zu starten.
|
||||
* @returns {{ok: true, config: object, warnings: string[]} | {ok: false, error: string}}
|
||||
*/
|
||||
function loadConfig(env) {
|
||||
const read = (name) => (typeof env[name] === 'string' ? env[name].trim() : '');
|
||||
|
||||
const missing = ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID'].filter((name) => read(name) === '');
|
||||
if (missing.length > 0) {
|
||||
return { ok: false, error: `${missing.join(', ')} ${missing.length > 1 ? 'sind' : 'ist'} nicht gesetzt - Abbruch` };
|
||||
}
|
||||
|
||||
const endpoint = read('AIRCARD_APPWRITE_ENDPOINT') || DEFAULT_ENDPOINT;
|
||||
if (!/^https?:\/\/\S+$/.test(endpoint)) {
|
||||
return { ok: false, error: 'AIRCARD_APPWRITE_ENDPOINT muss mit http:// oder https:// beginnen - Abbruch' };
|
||||
}
|
||||
|
||||
const portText = read('AIRCARD_API_PORT');
|
||||
const port = portText === '' ? DEFAULT_PORT : Number(portText);
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
return { ok: false, error: 'AIRCARD_API_PORT ist keine gültige Portnummer - Abbruch' };
|
||||
}
|
||||
|
||||
const warnings = [];
|
||||
if (read('AIRCARD_API_SALT').length < 16) warnings.push('AIRCARD_API_SALT ist sehr kurz (empfohlen: 32 Byte hex)');
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
warnings,
|
||||
config: {
|
||||
port,
|
||||
endpoint,
|
||||
project: read('AIRCARD_APPWRITE_PROJECT_ID'),
|
||||
key: read('AIRCARD_API_KEY'),
|
||||
salt: read('AIRCARD_API_SALT'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const say = (message) => console.log(`[aircard-api] ${new Date().toISOString()} ${message}`);
|
||||
|
||||
const loaded = loadConfig(process.env);
|
||||
if (!loaded.ok) {
|
||||
console.error(`[aircard-api] ${loaded.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
for (const warning of loaded.warnings) say(`Warnung: ${warning}`);
|
||||
const { port, endpoint, project, key, salt } = loaded.config;
|
||||
|
||||
const appwrite = createAppwrite({ endpoint, project, key });
|
||||
const app = createApp({ appwrite, salt, log: console.log });
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Langsame oder hängende Clients nicht ewig halten; Keep-Alive länger als Traefiks Leerlauf (90 s),
|
||||
// damit Traefik keine Verbindung wiederverwendet, die Node gerade schließt.
|
||||
server.headersTimeout = 10_000;
|
||||
server.requestTimeout = 15_000;
|
||||
server.keepAliveTimeout = 95_000;
|
||||
|
||||
server.listen(port, () => say(`listening on :${server.address().port} (Appwrite: ${endpoint})`));
|
||||
|
||||
const shutdown = (signal) => {
|
||||
say(`${signal} received, shutting down`);
|
||||
server.close(() => process.exit(0));
|
||||
server.closeIdleConnections();
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
};
|
||||
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.once('SIGINT', () => shutdown('SIGINT'));
|
||||
}
|
||||
|
||||
module.exports = { loadConfig };
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
|
||||
// Tests für deploy/api/server.js: Umgebungsprüfung und ein Start als echter Prozess gegen einen Fake-Appwrite.
|
||||
// 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 path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const { loadConfig } = require('./server.js');
|
||||
const { startFakeAppwrite } = require('./fake-appwrite.js');
|
||||
|
||||
const SERVER = path.join(__dirname, 'server.js');
|
||||
const KEY = 'KEY-test-secret-value';
|
||||
const SALT = 'salt-test-secret-0123456789abcdef0123456789';
|
||||
|
||||
const children = [];
|
||||
const fakes = [];
|
||||
afterEach(async () => {
|
||||
for (const child of children.splice(0)) {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL');
|
||||
await new Promise((resolve) => child.once('exit', resolve));
|
||||
}
|
||||
}
|
||||
for (const fake of fakes.splice(0)) await fake.close();
|
||||
});
|
||||
|
||||
function validEnv(extra = {}) {
|
||||
return {
|
||||
AIRCARD_API_KEY: KEY,
|
||||
AIRCARD_API_SALT: SALT,
|
||||
AIRCARD_APPWRITE_PROJECT_ID: 'proj-test',
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function startProcess(env) {
|
||||
const child = spawn(process.execPath, [SERVER], { env: { PATH: process.env.PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
children.push(child);
|
||||
const output = { text: '' };
|
||||
child.stdout.on('data', (chunk) => (output.text += chunk));
|
||||
child.stderr.on('data', (chunk) => (output.text += chunk));
|
||||
const exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal })));
|
||||
return { child, output, exited };
|
||||
}
|
||||
|
||||
async function waitFor(condition, what, timeoutMs = 5000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
}
|
||||
|
||||
function httpRequest(port, method, urlPath, { headers = {}, body } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
method,
|
||||
path: urlPath,
|
||||
headers: payload === undefined ? headers : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...headers },
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') }));
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (payload !== undefined) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('loadConfig: Standardwerte und gelesene Werte', () => {
|
||||
const result = loadConfig(validEnv());
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.config, {
|
||||
port: 8088,
|
||||
endpoint: 'http://webklar-appwrite/v1',
|
||||
project: 'proj-test',
|
||||
key: KEY,
|
||||
salt: SALT,
|
||||
});
|
||||
assert.deepEqual(result.warnings, []);
|
||||
|
||||
const custom = loadConfig(validEnv({ AIRCARD_API_PORT: '9100', AIRCARD_APPWRITE_ENDPOINT: 'https://appwrite.example/v1' }));
|
||||
assert.equal(custom.config.port, 9100);
|
||||
assert.equal(custom.config.endpoint, 'https://appwrite.example/v1');
|
||||
});
|
||||
|
||||
test('loadConfig: fehlender Key, Salz oder Projekt wird mit dem Namen der Variable gemeldet, nie mit Werten', () => {
|
||||
for (const name of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID']) {
|
||||
for (const value of [undefined, '', ' ']) {
|
||||
const env = validEnv();
|
||||
if (value === undefined) delete env[name];
|
||||
else env[name] = value;
|
||||
const result = loadConfig(env);
|
||||
assert.equal(result.ok, false, `${name}=${JSON.stringify(value)}`);
|
||||
assert.ok(result.error.includes(name));
|
||||
assert.ok(!result.error.includes(KEY) && !result.error.includes(SALT));
|
||||
}
|
||||
}
|
||||
const none = loadConfig({});
|
||||
assert.equal(none.ok, false);
|
||||
assert.ok(none.error.includes('AIRCARD_API_KEY') && none.error.includes('AIRCARD_API_SALT'));
|
||||
});
|
||||
|
||||
test('loadConfig: ungültiger Port oder Endpunkt, kurzes Salz warnt', () => {
|
||||
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: 'abc' })).ok, false);
|
||||
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '70000' })).ok, false);
|
||||
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '-1' })).ok, false);
|
||||
assert.equal(loadConfig(validEnv({ AIRCARD_APPWRITE_ENDPOINT: 'webklar-appwrite/v1' })).ok, false);
|
||||
const short = loadConfig(validEnv({ AIRCARD_API_SALT: 'xyz-9' }));
|
||||
assert.equal(short.ok, true);
|
||||
assert.equal(short.warnings.length, 1);
|
||||
assert.ok(short.warnings[0].includes('AIRCARD_API_SALT') && !short.warnings[0].includes('xyz-9'));
|
||||
});
|
||||
|
||||
test('Start ohne Key oder ohne Salz: Exit-Code 1, Meldung ohne Werte', async () => {
|
||||
for (const missing of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT']) {
|
||||
const env = validEnv({ AIRCARD_API_PORT: '0' });
|
||||
delete env[missing];
|
||||
const { output, exited } = startProcess(env);
|
||||
const { code } = await exited;
|
||||
assert.equal(code, 1, missing);
|
||||
assert.ok(output.text.includes(missing));
|
||||
assert.ok(output.text.includes('[aircard-api]'));
|
||||
assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT));
|
||||
}
|
||||
});
|
||||
|
||||
test('Start als Prozess: Gesundheit, Melden über Fake-Appwrite mit Server-Key, sauberes Beenden bei SIGTERM', async () => {
|
||||
const fake = await startFakeAppwrite({ project: 'proj-test', key: KEY });
|
||||
fakes.push(fake);
|
||||
fake.addRow(
|
||||
'gallery',
|
||||
'card-1',
|
||||
{ title: 'Karte', imageFileId: 'i', previewFileId: 'p', ownerId: 'user-me', hidden: false, reportCount: 0 },
|
||||
['read("any")'],
|
||||
);
|
||||
|
||||
const { child, output, exited } = startProcess(validEnv({ AIRCARD_API_PORT: '0', AIRCARD_APPWRITE_ENDPOINT: fake.endpoint }));
|
||||
await waitFor(() => /listening on :\d+/.test(output.text), 'Startmeldung');
|
||||
const port = Number(/listening on :(\d+)/.exec(output.text)[1]);
|
||||
|
||||
const health = await httpRequest(port, 'GET', '/api/gesundheit');
|
||||
assert.deepEqual(health.json, { ok: true });
|
||||
|
||||
const reported = await httpRequest(port, 'POST', '/api/melden', {
|
||||
body: { id: 'card-1', reason: 'spam' },
|
||||
headers: { 'X-Real-Ip': '203.0.113.5' },
|
||||
});
|
||||
assert.equal(reported.status, 200);
|
||||
assert.deepEqual(reported.json, { ok: true });
|
||||
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
|
||||
assert.ok(fake.calls.length > 0);
|
||||
for (const call of fake.calls) assert.equal(call.headers['x-appwrite-key'], KEY);
|
||||
|
||||
child.kill('SIGTERM');
|
||||
const { code } = await exited;
|
||||
assert.equal(code, 0);
|
||||
assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT), 'Ausgabe enthält weder Key noch Salz');
|
||||
assert.ok(!output.text.includes('203.0.113.5'), 'Ausgabe enthält keine IP');
|
||||
for (const line of output.text.trim().split('\n')) assert.ok(line.startsWith('[aircard-api]'), line);
|
||||
});
|
||||
@@ -3,10 +3,12 @@
|
||||
# URL: aircard-ios.org
|
||||
# Repository: https://git.webklar.com/knso/AirCardIOS
|
||||
# Einfügen unter `services:` direkt nach dem eship-Block (siehe deploy/README.md).
|
||||
# Starten NUR mit: docker compose up -d aircard-build aircard
|
||||
# Starten NUR mit: docker compose up -d aircard-build aircard aircard-api
|
||||
# (ein nacktes `up -d` würde andere Dienste wieder anlegen)
|
||||
# Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml.
|
||||
# AIRCARD_APPWRITE_PROJECT_ID kommt aus /home/webklar/apps/.env.
|
||||
# Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml (Router `aircard-api@file`, Pfad /v1).
|
||||
# Der API-Dienst `aircard-api` hängt per Label am Pfad /api (Router `aircard-gallery-api`, ein anderer Name,
|
||||
# damit er nicht mit der Datei-Route kollidiert).
|
||||
# AIRCARD_APPWRITE_PROJECT_ID, AIRCARD_API_KEY und AIRCARD_API_SALT kommen aus /home/webklar/apps/.env.
|
||||
# ==============================================
|
||||
aircard-build:
|
||||
image: node:22-alpine
|
||||
@@ -80,3 +82,46 @@
|
||||
# - "traefik.http.middlewares.aircard-www.redirectregex.regex=^https?://www\\.aircard-ios\\.org(.*)"
|
||||
# - "traefik.http.middlewares.aircard-www.redirectregex.replacement=https://aircard-ios.org$${1}"
|
||||
# - "traefik.http.middlewares.aircard-www.redirectregex.permanent=true"
|
||||
|
||||
# --- API-Dienst der Community-Galerie (deploy/api/, siehe deploy/api/README.md) ---
|
||||
# Ohne AIRCARD_API_KEY oder AIRCARD_API_SALT in der .env beendet sich der Dienst mit Exit-Code 1.
|
||||
# Kein `ports:` (veröffentlicht wird der Dienst nur über Traefik). Im Docker-Netz erreichen ihn andere Container, und er
|
||||
# vertraut dort dem Header X-Real-Ip: akzeptiertes Risiko, siehe deploy/api/README.md (Client-IP).
|
||||
aircard-api:
|
||||
image: node:22-alpine
|
||||
container_name: webklar-aircard-api
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
mem_limit: 128m
|
||||
user: node
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
working_dir: /srv
|
||||
# Log-Größe deckeln: /api/melden ist ohne Anmeldung erreichbar, der Docker-Daemon hat keine eigenen Log-Grenzen.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "5m"
|
||||
max-file: "2"
|
||||
environment:
|
||||
- AIRCARD_API_PORT=8088
|
||||
- AIRCARD_APPWRITE_ENDPOINT=http://webklar-appwrite/v1
|
||||
- AIRCARD_APPWRITE_PROJECT_ID=${AIRCARD_APPWRITE_PROJECT_ID}
|
||||
- AIRCARD_API_KEY=${AIRCARD_API_KEY}
|
||||
- AIRCARD_API_SALT=${AIRCARD_API_SALT}
|
||||
- NODE_OPTIONS=--max-old-space-size=64
|
||||
volumes:
|
||||
- ./aircard-ios.org/api:/srv:ro
|
||||
networks:
|
||||
- webklar-network
|
||||
command: node /srv/server.js
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.aircard-gallery-api.rule=Host(`aircard-ios.org`) && PathPrefix(`/api`)"
|
||||
- "traefik.http.routers.aircard-gallery-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.aircard-gallery-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.routers.aircard-gallery-api.priority=200"
|
||||
- "traefik.http.services.aircard-gallery-api.loadbalancer.server.port=8088"
|
||||
@@ -13,17 +13,32 @@
|
||||
# DEPLOY_TRIGGER (leer = manual)
|
||||
#
|
||||
# Fehlerzeilen beginnen mit "ERROR:"; der letzte Satz eines erfolgreichen Builds ist
|
||||
# "Deployment abgeschlossen".
|
||||
# "Deployment abgeschlossen". Läuft schon ein Deployment (Sperrverzeichnis in .git), meldet das
|
||||
# Skript "läuft bereits" und endet mit 0. npm ci und der Build sind auf 15 Minuten begrenzt (timeout).
|
||||
set -e
|
||||
|
||||
# Jeder Abbruch soll eine ERROR-Zeile hinterlassen, auch wenn set -e ihn auslöst.
|
||||
# Die eigene Sperre (LOCK_HELD) wird bei jedem Ende freigegeben, eine fremde nie.
|
||||
ERROR_PRINTED=""
|
||||
LOCK_HELD=""
|
||||
LOCK_DIR=""
|
||||
fail() {
|
||||
echo "ERROR: $*"
|
||||
ERROR_PRINTED=1
|
||||
exit 1
|
||||
}
|
||||
trap 'status=$?; if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})"; fi' EXIT
|
||||
cleanup() {
|
||||
status=$?
|
||||
if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then
|
||||
echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})"
|
||||
fi
|
||||
if [ -n "${LOCK_HELD}" ] && [ -n "${LOCK_DIR}" ]; then
|
||||
rm -rf "${LOCK_DIR}"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
# Ein Signal (z. B. Zeitlimit des Webhook-Servers) soll ebenfalls über den EXIT-Trap aufräumen.
|
||||
trap 'exit 143' HUP INT TERM
|
||||
|
||||
REPO_DIR="${REPO_DIR:-/app}"
|
||||
OUT_DIR="${OUT_DIR:-/out}"
|
||||
@@ -78,6 +93,33 @@ fi
|
||||
|
||||
cd "${REPO_DIR}" || fail "Verzeichnis ${REPO_DIR} nicht erreichbar"
|
||||
|
||||
# Nur ein Deployment gleichzeitig (Webhook, Polling und der manuelle Aufruf teilen sich REPO_DIR und OUT_DIR).
|
||||
# mkdir ist atomar. Eine verwaiste Sperre (Prozess weg oder über 60 Minuten alt) wird übernommen.
|
||||
LOCK_DIR="${REPO_DIR}/.git/aircard-deploy.lock"
|
||||
acquire_lock() {
|
||||
mkdir "${LOCK_DIR}" 2>/dev/null || return 1
|
||||
echo "$$" >"${LOCK_DIR}/pid"
|
||||
LOCK_HELD=1
|
||||
}
|
||||
if ! acquire_lock; then
|
||||
_lock_pid="$(cat "${LOCK_DIR}/pid" 2>/dev/null || true)"
|
||||
_lock_stale=""
|
||||
if [ -n "${_lock_pid}" ]; then
|
||||
kill -0 "${_lock_pid}" 2>/dev/null || _lock_stale=1
|
||||
fi
|
||||
if [ -n "$(find "${LOCK_DIR}" -maxdepth 0 -mmin +60 2>/dev/null)" ]; then
|
||||
_lock_stale=1
|
||||
fi
|
||||
if [ -n "${_lock_stale}" ]; then
|
||||
echo "Verwaiste Sperre gefunden, übernehme sie."
|
||||
rm -rf "${LOCK_DIR}"
|
||||
acquire_lock || { echo "Deployment läuft bereits, dieser Lauf wird übersprungen."; exit 0; }
|
||||
else
|
||||
echo "Deployment läuft bereits, dieser Lauf wird übersprungen."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Aktualisiere Repository..."
|
||||
# Remote-URL bei jedem Lauf neu setzen, damit ein getauschtes Token greift.
|
||||
git remote set-url origin "${REPO_URL}" || fail "git remote set-url fehlgeschlagen"
|
||||
@@ -111,18 +153,29 @@ echo "${CURRENT_COMMIT}" >"${MARKER}" || fail "Marker ${MARKER} nicht schreibbar
|
||||
[ -d "${REPO_DIR}/${APP_SUBDIR}" ] || fail "Ordner ${APP_SUBDIR} fehlt im Repository"
|
||||
cd "${REPO_DIR}/${APP_SUBDIR}"
|
||||
|
||||
# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen.
|
||||
unset GITEA_TOKEN REPO_URL
|
||||
# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen (auch das Webhook-Geheimnis steht im
|
||||
# Container und käme sonst beim manuellen Aufruf `docker exec ... sh /deploy.sh` bei npm an).
|
||||
unset GITEA_TOKEN REPO_URL AIRCARD_WEBHOOK_TOKEN
|
||||
|
||||
# Ein hängender Build soll nicht ewig laufen (und alle späteren Deployments sperren): 15 Minuten.
|
||||
# `timeout` gibt es in busybox (node:22-alpine); fehlt es, läuft der Build ohne Limit.
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
LIMIT="timeout 900"
|
||||
else
|
||||
LIMIT=""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Installiere Abhängigkeiten..."
|
||||
npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen"
|
||||
# shellcheck disable=SC2086 # LIMIT besteht absichtlich aus zwei Wörtern
|
||||
${LIMIT} npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen (oder das Zeitlimit von 15 Minuten ist erreicht)"
|
||||
|
||||
echo ""
|
||||
echo "Baue die Seite..."
|
||||
# VITE_*-Variablen kommen direkt aus der Container-Umgebung (compose).
|
||||
rm -rf dist
|
||||
npm run build || fail "Build fehlgeschlagen"
|
||||
# shellcheck disable=SC2086
|
||||
${LIMIT} npm run build || fail "Build fehlgeschlagen (oder das Zeitlimit von 15 Minuten ist erreicht)"
|
||||
[ -f dist/index.html ] || fail "dist/index.html fehlt nach dem Build"
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
'use strict';
|
||||
|
||||
// Tests für deploy/deploy.sh (das echte Skript, mit einem lokalen git-Remote und einem npm-Stub).
|
||||
// Aufruf: node --test deploy/deploy.test.js
|
||||
// Voraussetzung: git und sh auf dem Host. Der Stub-npm legt dist/index.html an und schreibt seine Umgebung weg.
|
||||
|
||||
const { test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
|
||||
const DEPLOY_SH = path.join(__dirname, 'deploy.sh');
|
||||
// Standard: das sh des Hosts. Zum Gegenprüfen mit busybox (wie im node:alpine-Container): DEPLOY_TEST_SH="busybox sh".
|
||||
const [SHELL_CMD, ...SHELL_ARGS] = (process.env.DEPLOY_TEST_SH || 'sh').split(' ');
|
||||
const GIT_ENV = {
|
||||
GIT_AUTHOR_NAME: 't',
|
||||
GIT_AUTHOR_EMAIL: 't@example.invalid',
|
||||
GIT_COMMITTER_NAME: 't',
|
||||
GIT_COMMITTER_EMAIL: 't@example.invalid',
|
||||
};
|
||||
|
||||
const tmpDirs = [];
|
||||
const children = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const child of children.splice(0)) {
|
||||
// Prozessgruppe beenden: auch der wartende npm-Stub unter dem Skript.
|
||||
if (child.exitCode === null && child.signalCode === null) process.kill(-child.pid, 'SIGKILL');
|
||||
}
|
||||
for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function waitFor(condition, what, timeoutMs = 5000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!condition()) {
|
||||
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`);
|
||||
await pause(20);
|
||||
}
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
const res = spawnSync('git', args, { cwd, env: { ...process.env, ...GIT_ENV }, encoding: 'utf8' });
|
||||
assert.equal(res.status, 0, `git ${args.join(' ')}: ${res.stderr}`);
|
||||
return res.stdout;
|
||||
}
|
||||
|
||||
/** Ein Arbeitsverzeichnis mit Remote (file://), Stub-npm und optional Stub-timeout. */
|
||||
function setup({ withTimeoutStub = false } = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aircard-deploy-test-'));
|
||||
tmpDirs.push(dir);
|
||||
|
||||
const bare = path.join(dir, 'remote', 'u', 'r.git');
|
||||
fs.mkdirSync(bare, { recursive: true });
|
||||
git(['init', '--bare', '--quiet', '-b', 'main', bare]);
|
||||
const work = path.join(dir, 'seed');
|
||||
git(['clone', '--quiet', bare, work]);
|
||||
fs.mkdirSync(path.join(work, 'web'));
|
||||
fs.writeFileSync(path.join(work, 'web', 'package.json'), '{}\n');
|
||||
git(['add', '-A'], work);
|
||||
git(['commit', '--quiet', '-m', 'seed'], work);
|
||||
git(['push', '--quiet', 'origin', 'HEAD:main'], work);
|
||||
|
||||
const bin = path.join(dir, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
const npmLog = path.join(dir, 'npm.log');
|
||||
const release = path.join(dir, 'release');
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'npm'),
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`echo "npm $* | WEBHOOK=\${AIRCARD_WEBHOOK_TOKEN-unset} GITEA=\${GITEA_TOKEN-unset} VITE=\${VITE_TEST_VAR-unset}" >> "${npmLog}"`,
|
||||
// Blockiert, bis die Testdatei "release" existiert (für den Test mit zwei gleichzeitigen Läufen).
|
||||
`if [ -n "\${STUB_NPM_BLOCK:-}" ]; then i=0; while [ ! -f "${release}" ] && [ "$i" -lt 400 ]; do sleep 0.05; i=$((i+1)); done; fi`,
|
||||
'if [ -n "${STUB_NPM_FAIL:-}" ]; then echo "npm kaputt" >&2; exit 1; fi',
|
||||
'if [ "$1" = run ]; then mkdir -p dist && echo "<html>ok</html>" > dist/index.html; fi',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const timeoutLog = path.join(dir, 'timeout.log');
|
||||
if (withTimeoutStub) {
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'timeout'),
|
||||
['#!/bin/sh', `echo "timeout $*" >> "${timeoutLog}"`, 'shift', 'exec "$@"', ''].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
}
|
||||
|
||||
const repoDir = path.join(dir, 'app');
|
||||
const outDir = path.join(dir, 'out');
|
||||
fs.mkdirSync(repoDir);
|
||||
fs.mkdirSync(outDir);
|
||||
const env = {
|
||||
...process.env,
|
||||
...GIT_ENV,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
GITEA_URL: `file://${path.join(dir, 'remote')}`,
|
||||
GITEA_USER: 'u',
|
||||
GITEA_REPO: 'r',
|
||||
GITEA_TOKEN: '',
|
||||
REPO_DIR: repoDir,
|
||||
OUT_DIR: outDir,
|
||||
DEPLOY_BRANCH: 'main',
|
||||
DEPLOY_TRIGGER: 'webhook',
|
||||
AIRCARD_WEBHOOK_TOKEN: 'geheim-webhook',
|
||||
VITE_TEST_VAR: 'bleibt',
|
||||
};
|
||||
const lockDir = path.join(repoDir, '.git', 'aircard-deploy.lock');
|
||||
const npmCalls = () => (fs.existsSync(npmLog) ? fs.readFileSync(npmLog, 'utf8').trim().split('\n') : []);
|
||||
const timeoutCalls = () => (fs.existsSync(timeoutLog) ? fs.readFileSync(timeoutLog, 'utf8').trim().split('\n') : []);
|
||||
return { dir, env, repoDir, outDir, lockDir, release, npmCalls, timeoutCalls };
|
||||
}
|
||||
|
||||
function runSync(ctx, extraEnv = {}) {
|
||||
const res = spawnSync(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], { env: { ...ctx.env, ...extraEnv }, encoding: 'utf8', timeout: 60000 });
|
||||
return { status: res.status, out: `${res.stdout}${res.stderr}` };
|
||||
}
|
||||
|
||||
function runAsync(ctx, extraEnv = {}) {
|
||||
const child = spawn(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], {
|
||||
env: { ...ctx.env, ...extraEnv },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
children.push(child);
|
||||
const result = { out: '', status: null, done: null };
|
||||
child.stdout.on('data', (d) => (result.out += d));
|
||||
child.stderr.on('data', (d) => (result.out += d));
|
||||
result.done = new Promise((resolve) =>
|
||||
child.once('exit', (code) => {
|
||||
result.status = code;
|
||||
resolve(result);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
test('ein Lauf baut, kopiert nach out/ und gibt das Webhook-Geheimnis nicht an npm weiter', () => {
|
||||
const ctx = setup();
|
||||
const res = runSync(ctx);
|
||||
assert.equal(res.status, 0, res.out);
|
||||
assert.match(res.out, /Deployment abgeschlossen/);
|
||||
assert.equal(fs.readFileSync(path.join(ctx.outDir, 'index.html'), 'utf8').trim(), '<html>ok</html>');
|
||||
const calls = ctx.npmCalls();
|
||||
assert.equal(calls.length, 2, calls.join('\n'));
|
||||
for (const call of calls) {
|
||||
assert.match(call, /WEBHOOK=unset/, `AIRCARD_WEBHOOK_TOKEN darf npm nicht erreichen: ${call}`);
|
||||
assert.match(call, /GITEA=unset/);
|
||||
assert.match(call, /VITE=bleibt/, 'andere Variablen (VITE_*) bleiben erhalten');
|
||||
}
|
||||
assert.ok(!res.out.includes('geheim-webhook'));
|
||||
});
|
||||
|
||||
test('das Sperrverzeichnis wird nach einem erfolgreichen Lauf entfernt', () => {
|
||||
const ctx = setup();
|
||||
assert.equal(runSync(ctx).status, 0);
|
||||
assert.equal(fs.existsSync(ctx.lockDir), false);
|
||||
});
|
||||
|
||||
test('das Sperrverzeichnis wird auch nach einem fehlgeschlagenen Build entfernt', () => {
|
||||
const ctx = setup();
|
||||
const res = runSync(ctx, { STUB_NPM_FAIL: '1' });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.out, /ERROR: /);
|
||||
assert.equal(fs.existsSync(ctx.lockDir), false);
|
||||
});
|
||||
|
||||
test('ein zweiter gleichzeitiger Lauf meldet „läuft bereits“, beendet mit 0 und baut nicht', async () => {
|
||||
const ctx = setup();
|
||||
const first = runAsync(ctx, { STUB_NPM_BLOCK: '1' });
|
||||
await waitFor(() => ctx.npmCalls().length >= 1, 'npm des ersten Laufs');
|
||||
assert.equal(fs.existsSync(ctx.lockDir), true);
|
||||
|
||||
const second = runSync(ctx);
|
||||
assert.equal(second.status, 0, second.out);
|
||||
assert.match(second.out, /läuft bereits/);
|
||||
assert.doesNotMatch(second.out, /Deployment abgeschlossen/);
|
||||
assert.equal(ctx.npmCalls().length, 1, 'der zweite Lauf darf npm nicht starten');
|
||||
assert.equal(fs.existsSync(ctx.lockDir), true, 'der zweite Lauf darf die Sperre des ersten nicht freigeben');
|
||||
|
||||
fs.writeFileSync(ctx.release, '');
|
||||
await first.done;
|
||||
assert.equal(first.status, 0, first.out);
|
||||
assert.match(first.out, /Deployment abgeschlossen/);
|
||||
assert.equal(fs.existsSync(ctx.lockDir), false);
|
||||
});
|
||||
|
||||
test('eine verwaiste Sperre (Prozess gibt es nicht mehr) wird übernommen', () => {
|
||||
const ctx = setup();
|
||||
// Erster Lauf legt das Repo an.
|
||||
assert.equal(runSync(ctx).status, 0);
|
||||
fs.mkdirSync(ctx.lockDir);
|
||||
fs.writeFileSync(path.join(ctx.lockDir, 'pid'), '2147483646\n');
|
||||
const res = runSync(ctx);
|
||||
assert.equal(res.status, 0, res.out);
|
||||
assert.match(res.out, /Deployment abgeschlossen/);
|
||||
assert.equal(fs.existsSync(ctx.lockDir), false);
|
||||
});
|
||||
|
||||
test('eine sehr alte Sperre ohne lebenden Besitzer-Prozess-Nachweis gilt als verwaist', () => {
|
||||
const ctx = setup();
|
||||
assert.equal(runSync(ctx).status, 0);
|
||||
fs.mkdirSync(ctx.lockDir);
|
||||
// Kein pid-Eintrag, aber alt genug (über 60 Minuten).
|
||||
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
fs.utimesSync(ctx.lockDir, old, old);
|
||||
const res = runSync(ctx);
|
||||
assert.equal(res.status, 0, res.out);
|
||||
assert.match(res.out, /Deployment abgeschlossen/);
|
||||
});
|
||||
|
||||
const busyboxApplets = SHELL_CMD === 'busybox' && 'busybox löst timeout als eigenes Applet auf, der Stub wird nicht gerufen';
|
||||
test('npm ci und npm run build laufen unter timeout 900, wenn timeout verfügbar ist', { skip: busyboxApplets }, () => {
|
||||
const ctx = setup({ withTimeoutStub: true });
|
||||
const res = runSync(ctx);
|
||||
assert.equal(res.status, 0, res.out);
|
||||
const calls = ctx.timeoutCalls();
|
||||
assert.equal(calls.length, 2, calls.join('\n'));
|
||||
assert.match(calls[0], /^timeout 900 npm ci /);
|
||||
assert.match(calls[1], /^timeout 900 npm run build/);
|
||||
});
|
||||
|
||||
test('ein Build-Fehler unter timeout bleibt ein ERROR mit Exit-Code 1', () => {
|
||||
const ctx = setup({ withTimeoutStub: true });
|
||||
const res = runSync(ctx, { STUB_NPM_FAIL: '1' });
|
||||
assert.equal(res.status, 1);
|
||||
assert.match(res.out, /ERROR: npm ci fehlgeschlagen/);
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
server_tokens off;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
@@ -15,6 +16,8 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
# TLS endet bei Traefik; der Header läuft durch und gilt für den Browser (kein includeSubDomains).
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@@ -39,11 +42,22 @@ server {
|
||||
}
|
||||
|
||||
# Die Appwrite-API liegt nur über Traefik (Header X-Appwrite-Project) auf /v1.
|
||||
# Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern.
|
||||
location ^~ /v1/ {
|
||||
# Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern, auch nicht für genau /v1.
|
||||
# Regex-Block: steht vor den anderen Regex-Blöcken (die erste passende Regel gewinnt, /v1/x.png
|
||||
# soll also 404 liefern); /assets/ bleibt durch das ^~ oben unberührt.
|
||||
location ~ ^/v1(/|$) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Die Community-API (aircard-api) liegt ebenfalls nur über Traefik auf /api. Fehlt der Dienst (Container weg, damit
|
||||
# auch sein Traefik-Router), darf /api/* nie die App ausliefern: der Client bekäme HTML mit 200 statt eines Fehlers.
|
||||
# Stattdessen 503 mit JSON-Text, den die Seite wie eine API-Antwort anzeigt. Gleiche Reihenfolge-Regel wie bei /v1.
|
||||
# (Kein add_header: sonst entfallen die Sicherheits-Header des server-Blocks; default_type setzt den Content-Type.)
|
||||
location ~ ^/api(/|$) {
|
||||
default_type application/json;
|
||||
return 503 '{"error":"Der Dienst ist gerade nicht erreichbar."}';
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
|
||||
Klickliste für Kenso. Dauer: etwa 5 Minuten. Console: <https://appwrite.webklar.com/console>
|
||||
|
||||
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabelle, Bucket) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
|
||||
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` mit Spalten und Indizes, Buckets `card-images`, `gallery-images` und `logo-images`) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
|
||||
|
||||
**Hinweis:** Das Projekt wird mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt und dort in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID` gespeichert. Das Skript liest diese ID aus der Umgebungsvariablen; der Wert muss beim Aufruf verfügbar sein.
|
||||
**Hinweis:** Das Projekt wurde mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt (nicht mit einer selbst gewählten ID). Diese ID steht in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID`; das Skript liest sie von dort, der Wert muss beim Aufruf verfügbar sein.
|
||||
|
||||
**Stand der Einstellungen:** Schritt 2 (Web-Plattform `aircard-ios.org`) ist Voraussetzung, ohne sie lehnt Appwrite die Anfragen der Seite ab. Die Schritte 3 und 4 sind empfohlene Härtung. Ob sie in der Console umgesetzt sind, prüft kein Skript (`--check` deckt nur das Schema ab: Tabellen, Spalten, Indizes, Buckets). Solange du sie nicht selbst gesetzt hast, läuft das Live-Projekt mit den Appwrite-Standards für Auth und Dienste. Das Schema und die Zeilen-/Dateirechte sind davon unabhängig.
|
||||
|
||||
## 1. Projekt anlegen
|
||||
|
||||
1. In der Console anmelden und die bestehende Organisation öffnen.
|
||||
2. **Create project** klicken.
|
||||
3. Name: `AirCard`.
|
||||
4. Beim Feld **Project ID** auf das Stift-Symbol (bzw. „Edit“) klicken und `aircard` eintragen. Die ID lässt sich später nicht mehr ändern; sie muss genau `aircard` heißen.
|
||||
4. Das Feld **Project ID** unverändert lassen: Appwrite erzeugt die ID selbst. Nach dem Anlegen steht sie unter **Settings** (und in der Adresszeile der Console). Sie ist öffentlich, steckt im JavaScript der Seite.
|
||||
5. **Create** klicken.
|
||||
|
||||
Bei einem Neuaufbau bekommt das neue Projekt eine neue ID. Dann müssen drei Stellen dazu passen: die Traefik-Regel (`deploy/traefik/aircard.yml`, dort steht die ID im `Header(...)`), `AIRCARD_APPWRITE_PROJECT_ID` in `/home/webklar/apps/.env` und der Standardwert in `web/src/account/appwrite.ts`.
|
||||
|
||||
## 2. Web-Plattformen eintragen
|
||||
|
||||
Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **Add platform** → **Web**.
|
||||
@@ -21,11 +25,11 @@ Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **
|
||||
1. Name `AirCard Website`, Hostname `aircard-ios.org` → **Create**.
|
||||
2. Noch einmal **Add platform** → **Web**: Name `Entwicklung`, Hostname `localhost` → **Create**.
|
||||
|
||||
## 3. Auth-Einstellungen
|
||||
## 3. Auth-Einstellungen (empfohlene Härtung, keine Voraussetzung für den Betrieb)
|
||||
|
||||
Linke Leiste → **Auth** → **Settings**.
|
||||
Linke Leiste → **Auth** → **Settings**. Registrieren und Anmelden funktionieren auch mit den Standardwerten; die folgenden Einstellungen begrenzen nur, was Besucher mit dem Projekt anstellen können.
|
||||
|
||||
1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous**, **JWT** (steht dort auch **Invites**: ebenfalls aus).
|
||||
1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous** (steht dort auch **Invites**: ebenfalls aus). **JWT** bleibt **an** – ohne JWT kann die Community-API (Abschnitt „Community“) die Nutzer nicht prüfen.
|
||||
2. **Session duration**: `30` Tage → **Update**.
|
||||
3. **Max sessions** (Sitzungen pro Nutzer): `5` → **Update**.
|
||||
4. **Limit** (Nutzerlimit): `2000` → **Update**.
|
||||
@@ -33,11 +37,11 @@ Linke Leiste → **Auth** → **Settings**.
|
||||
|
||||
Keine E-Mail-Bestätigung nötig, Mindestlänge 8 Zeichen ist Standard – dafür ist nichts einzustellen. Die „Passwort vergessen“-Mail geht über den globalen Server-SMTP (Absender WEBklar), auch dafür ist nichts einzustellen.
|
||||
|
||||
## 4. Nicht benötigte Dienste abschalten
|
||||
## 4. Nicht benötigte Dienste abschalten (optional)
|
||||
|
||||
Linke Leiste → **Settings** → Reiter **Services**.
|
||||
Linke Leiste → **Settings** → Reiter **Services**. Das ist optionale Härtung: Die Website braucht diese Dienste nicht, schadet aber auch nicht, wenn sie an bleiben. Solange du das nicht gemacht hast, sind sie am Live-Projekt an (z. B. antwortet GraphQL).
|
||||
|
||||
1. Ausschalten: **Functions**, **Sites**, **Messaging**, **Teams**, **GraphQL**.
|
||||
1. Ausschalten, soweit die Console es dort anbietet: **Functions**, **Sites**, **Messaging**, **Teams**, **GraphQL**.
|
||||
2. Alles andere (Account, Avatars, Databases, Locale, Storage …) bleibt an.
|
||||
|
||||
## 5. API-Key `aircard-provision` anlegen
|
||||
@@ -54,20 +58,58 @@ Dieser Key gehört nur dem Setup-Skript. Die Website selbst braucht ihn nicht.
|
||||
4. **Create** klicken.
|
||||
5. Auf der Seite des neuen Keys den **Secret**-Wert kopieren (Auge-Symbol zum Einblenden, dann kopieren).
|
||||
|
||||
Die Indizes der Tabellen `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` legt das Skript mit denselben Scopes an; ein eigener Index-Scope ist nicht nötig.
|
||||
|
||||
## 6. Key auf dem Server eintragen
|
||||
|
||||
Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
|
||||
|
||||
1. Auf dem Server die Datei öffnen, z. B. `nano /home/webklar/apps/.env`.
|
||||
2. Ans Ende springen und eine neue Zeile anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=`:
|
||||
2. Ans Ende springen. Steht der Cursor am Ende der letzten Zeile, einmal Enter drücken, damit die neuen Zeilen in eigenen Zeilen stehen (sonst klebt der erste neue Eintrag an einem bestehenden Wert und macht ihn kaputt). Dann neue Zeilen anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=` (die Projekt-ID ist die in Schritt 1 notierte, siehe Hinweis oben):
|
||||
|
||||
```
|
||||
AIRCARD_APPWRITE_PROJECT_ID=<Projekt-ID>
|
||||
AIRCARD_APPWRITE_API_KEY=<hier den kopierten Secret-Wert einfügen>
|
||||
```
|
||||
|
||||
3. Speichern (in `nano`: Strg+O, Enter, Strg+X). Andere Zeilen nicht verändern.
|
||||
4. Den Key **nicht im Chat teilen**, nicht in Screenshots zeigen und nirgends einchecken. Claude liest ihn nie selbst, das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`.
|
||||
4. Den Key möglichst nicht im Chat teilen (wer ihn dort einfügt, gibt ihn in den Verlauf), nicht in Screenshots zeigen und nirgends einchecken. Das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`, dafür muss ihn niemand sonst sehen.
|
||||
|
||||
## 7. Bescheid geben
|
||||
## 7. Community
|
||||
|
||||
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, Tabelle `cards` und Bucket `card-images` mit dem Skript an und prüft sie.
|
||||
Für die Community-Galerie und die Community-Logos (Tabellen `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports`, Buckets `gallery-images` und `logo-images`, API-Dienst `aircard-api`) sind drei Dinge in der Console nötig. Das Schema dazu legt dasselbe Skript an (`node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs`, vorher mit `--check` ansehen, was fehlt).
|
||||
|
||||
### 7.1 JWT muss an sein
|
||||
|
||||
JWT muss **an** sein – falls in Schritt 3 ausgeschaltet, wieder einschalten. Auth → **Settings** → **Auth methods** → **JWT** einschalten. Die API prüft damit, wer Veröffentlichen, Zurückziehen oder eine Admin-Aktion auslöst.
|
||||
|
||||
### 7.2 Label `admin` am eigenen Konto
|
||||
|
||||
Das Label `admin` macht dich zum Admin der Galerie und der Community-Logos: Die Website zeigt dir dann Ein-/Ausblenden-Knöpfe und die Filter „Gemeldet“/„Ausgeblendet“ (in der Galerie und im Logo-Bereich des Editors).
|
||||
|
||||
1. Linke Leiste → **Auth** → **Users** → dein Konto öffnen.
|
||||
2. Bei **Labels** den Wert `admin` eintragen → **Update**.
|
||||
|
||||
### 7.3 API-Key `aircard-api` anlegen
|
||||
|
||||
Dieser Key gehört nur dem API-Dienst `aircard-api`, nicht dem Setup-Skript und nicht der Website.
|
||||
|
||||
1. **Overview** → **Integrations** → **API keys** → **Create API key**.
|
||||
2. Name: `aircard-api`. Ablauf (Expiration): **Never**.
|
||||
3. Unter **Scopes** genau diese vier ankreuzen:
|
||||
- `documents.read` und `documents.write`
|
||||
- `files.read` und `files.write`
|
||||
|
||||
Die Console beschriftet die Zeilen-Scopes mit `documents.*`; das sind dieselben wie die `rows.read`/`rows.write` der Appwrite-API (Tabellen heißen dort Zeilen). Einen Eintrag `rows.*` gibt es in der Console nicht. Dieselben vier Scopes genügen für die Logo-Endpunkte (Tabellen `logos`, `logo_likes`, `logo_reports`, Bucket `logo-images`).
|
||||
4. **Create** klicken und auf der Seite des Keys den **Secret**-Wert kopieren.
|
||||
5. Auf dem Server den Wert verdeckt in die `.env` schreiben. Der Befehl fragt den Wert ohne Anzeige ab (`read -rs`), schreibt bei leerer Eingabe nichts, beginnt mit einem Zeilenumbruch (so klebt die Zeile nie an einer letzten Zeile ohne Umbruch) und zeigt den Key nirgends an:
|
||||
|
||||
```
|
||||
read -rs -p "AIRCARD_API_KEY: " K; echo; if [ -n "$K" ]; then printf '\nAIRCARD_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; else echo "Leer – nichts geschrieben."; fi; unset K
|
||||
```
|
||||
|
||||
Den Wert nicht im Chat teilen. Wurde der Provisioning-Key aus Schritt 5 einmal im Chat geteilt, ihn in der Console unter **API keys** löschen und bei Bedarf neu anlegen.
|
||||
|
||||
## 8. Bescheid geben
|
||||
|
||||
Schreib Claude „Console fertig”. Danach legt Claude Datenbank `aircard`, die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` (mit Spalten und Indizes) und die Buckets `card-images`, `gallery-images`, `logo-images` mit dem Skript an und prüft sie. Für die Community-Galerie schreib zusätzlich „Label und Key sind gesetzt”.
|
||||
@@ -0,0 +1,188 @@
|
||||
# AirCard-IOS Startseite & Community-Galerie – Implementierungsplan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Kompakte Startseite mit Community-Galerie, Veröffentlichen/Melden/Admin, Spenden-Banner und Hell/Dunkel-Schalter für https://aircard-ios.org.
|
||||
|
||||
**Architecture:** Der bestehende Vite-+-TS-Client bekommt neue Routen und Module (`web/src/community/*`, `web/src/shell/*`). Lesen der Galerie direkt aus Appwrite (öffentliche Zeilen), alle Galerie-Schreibvorgänge über einen neuen kleinen Node-Dienst `aircard-api` (`deploy/api/`) mit Server-Key, erreichbar unter `/api` per Traefik. Schema-Erweiterung über das bestehende Setup-Skript.
|
||||
|
||||
**Tech Stack:** wie bisher (TypeScript 6.0.3, Vite 8.3.2, Vitest 5.0.3, happy-dom, appwrite 22.4.1), Node 22 für `aircard-api` (nur Built-ins), Appwrite 1.8.1 TablesDB, Traefik v3.6.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-04-aircard-community-design.md` (Commit 970e68e). Basis: `docs/superpowers/specs/2026-10-04-aircard-website-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Arbeitskopie `/home/webklar/apps/aircard-ios.org/dev`, Branch `main`, direkt committen, **nicht pushen** (Controller pusht).
|
||||
- Versionen unverändert gepinnt; keine neuen npm-Abhängigkeiten im Client; `deploy/api/` ohne Abhängigkeiten.
|
||||
- Alle sichtbaren Texte Deutsch, Wortlaut aus der Spec.
|
||||
- Appwrite: Projekt `6ac2130a00289343d6d5`; Datenbank `aircard`; neue Tabellen `gallery`, `reports`; neuer Bucket `gallery-images`; bestehende `cards`/`card-images` unverändert.
|
||||
- Galerie-Zeilenrechte: sichtbar genau `['read("any")']`; ausgeblendet genau `['read("user:<owner>")','read("label:admin")']`. Galerie-Dateirechte sichtbar `['read("any")','delete("user:<owner>")']`, ausgeblendet `['read("user:<owner>")','read("label:admin")','delete("user:<owner>")']`.
|
||||
- Auto-Ausblenden ab **3** verschiedenen Meldern; Rate-Limit **10** Meldungen pro Stunde und IP; Seiten zu **24** Karten.
|
||||
- Admin = Appwrite-Label `admin` (`account.get().labels`).
|
||||
- `DONATE_URL` in `web/src/config.ts`; leer ⇒ Banner und Spenden-Knopf unsichtbar.
|
||||
- Theme: `data-theme` auf `<html>`, `localStorage` `aircard.theme`; Banner `localStorage` `aircard.donateBannerClosed`. Jeder `localStorage`-Zugriff in try/catch.
|
||||
- Hero: bei 1440 × 900 und 390 × 844 müssen „Community-Karten“-Überschrift und der obere Rand der ersten Kartenreihe im ersten Bildschirm sichtbar sein.
|
||||
- Keine Secrets in Ausgaben, Logs, Commits; `.env` nie öffnen (Ausnahme: Controller-Ops).
|
||||
- Tests: `cd web && npx vitest run`, `npx tsc --noEmit`, `npm run build`; `node --test deploy/api/*.test.js` (Glob in Anführungszeichen), bestehende node-Tests grün halten.
|
||||
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Subagents: `sonnet` (Implementierung/Review), `haiku` für kleine Nachprüfungen; nie Subagents aus Subagents.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- **Fremdes Veröffentlichen**: Nutzer schickt `imageFileId` einer fremden Datei an `/api/veroeffentlichen` → 403, nichts angelegt. Test in Task 3.
|
||||
- **Meldungs-Flut / Selbst-Ausblenden**: dieselbe IP meldet 5× → zählt 1; 11. Meldung in einer Stunde → 429. Test in Task 3.
|
||||
- **Ausgeblendete Karte per Direkt-Link**: Gast öffnet `/community/<id>` einer ausgeblendeten Karte → „nicht (mehr) verfügbar“, kein Bild. Test in Task 6 (Client) und Task 3 (Rechte).
|
||||
- **Leere Galerie / leeres `DONATE_URL`**: Startseite ohne Karten zeigt Beispielmotiv und Leertext; ohne Link kein toter Spenden-Knopf. Tests in Tasks 1 und 5.
|
||||
- **Theme-Wechsel mit offenem Editor**: Umschalten ändert nur UI-Tokens, nicht die Kartenfarben/PNG. Test in Task 1 (render unabhängig von Theme).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shell – Routen, Kopfzeile, Theme, Spenden-Banner
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/config.ts`, `web/src/shell/theme.ts`, `web/src/shell/donate-banner.ts`, `web/src/shell/header.ts`, `web/src/shell/shell.css`, `web/src/pages/home-page.ts` (Platzhalter mit Überschrift, Task 5 füllt), `web/src/pages/community-page.ts` (Platzhalter, Task 6 füllt)
|
||||
- Modify: `web/index.html` (Kopfzeilen-Gerüst, Banner-Slot `#donate-banner`, Theme-Init-Skript gegen Aufblitzen), `web/src/pages/router.ts`, `web/src/main.ts`, `web/src/styles.css` (helle Tokens), `web/src/account/account-menu.ts` (Avatar-Kreis + Menü), `web/src/account/auth.ts` (`User.labels`, `isAdmin`), `web/src/editor/card-url.ts` und `web/src/pages/cards-page.ts` (Pfad `/gestalten?karte=`)
|
||||
- Test: `web/src/pages/router.test.ts`, `web/src/shell/theme.test.ts`, `web/src/shell/donate-banner.test.ts`, `web/src/shell/header.test.ts`, `web/src/account/auth.test.ts` (labels)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```ts
|
||||
// router.ts (ersetzt RouteId)
|
||||
type RouteId = 'home' | 'gestalten' | 'cards' | 'community' | 'reset'
|
||||
interface Route { id: RouteId; params: { id?: string } }
|
||||
matchRoute(pathname: string): Route // '/' home, '/gestalten' gestalten, '/karten' cards, '/community/<id>' community {id}, '/passwort-neu' reset, sonst home
|
||||
legacyRedirect(url: URL): string | null // '/?karte=X' → '/gestalten?karte=X', sonst null
|
||||
navigate(path: string): void // pushState, render, scrollTo(0,0) außer bei Hash-Ziel (dann scrollIntoView des Ankers)
|
||||
startRouter(render: (route: Route, root: HTMLElement) => void | (() => void)): void
|
||||
ROUTE_TITLES / titleFor(route: Route, detailTitle?: string): string // Wortlaut Spec §2
|
||||
// config.ts
|
||||
export const DONATE_URL: string = '' // Kenso liefert den Link
|
||||
// theme.ts
|
||||
type Theme = 'light' | 'dark'
|
||||
initialTheme(stored: string | null, prefersDark: boolean): Theme
|
||||
applyTheme(t: Theme): void; toggleTheme(): Theme // speichert in localStorage (try/catch)
|
||||
// donate-banner.ts
|
||||
mountDonateBanner(slot: HTMLElement, url: string): void // nichts rendern wenn url leer oder geschlossen
|
||||
// header.ts
|
||||
mountHeader(el: HTMLElement, opts: { donateUrl: string }): () => void // Navigation, Suchfeld (Enter → navigate('/?q=…#community')), ☕-Knopf, Theme-Schalter, Menü-Icon < 900 px
|
||||
// auth.ts
|
||||
type User = { $id: string; email: string; labels: string[] }
|
||||
isAdmin(user: User | null): boolean
|
||||
```
|
||||
- Kopfzeile: Logo `/favicon.svg?v=2` + „AirCard-IOS“ (→ `/`), Links „Community“ (`/#community`), „Gestalten“ (`/gestalten`), „Meine Karten“ (`/karten`), Suchfeld Platzhalter „Titel, Tags oder Herausgeber“, Knopf „☕ Unterstützen“, Theme-Knopf (`aria-label` „Hell/Dunkel umschalten“), Konto-Slot. Banner-Text exakt aus Spec §3.
|
||||
- `index.html`: kleines Inline-Skript im `<head>` setzt `data-theme` aus `localStorage`/System vor dem ersten Paint (in try/catch).
|
||||
|
||||
- **Ergänzungen aus Plan-Review (verbindlich):** Helles Theme: Tokens `--danger-text`, `--warn-text`, `--ok-text` in beiden Themes; die harten Farben (#ff9a9d, #f2c66d, #8fd9a8) in `web/src/pages/cards.css`, `web/src/editor/editor.css`, `web/src/account/account.css` durch diese Tokens ersetzen; `color-scheme` je Theme. `User.labels` optional (`labels?: string[]`), `getCurrentUser` setzt `labels ?? []`, `isAdmin` nutzt `?? []`; betroffene Tests anpassen (auth.test.ts `toEqual`, cards-page.test.ts erwartet `navigate('/gestalten?karte=a')`). `cards-page.ts` „Zum Editor“-Link → `/gestalten`. Marken-Link bleibt statisch in `index.html` (header.ts ergänzt nur Navigation/Suche/Knöpfe drumherum); statischer `<title>` wird „AirCard-IOS – Kartendesigns für Apple Wallet“, `branding.test.ts` entsprechend anpassen. Platzhalter-Signaturen: `mountHomePage(root)`, `mountCommunityPage(root, id)`.
|
||||
|
||||
- [ ] **Step 1: Tests schreiben** – router: alle Routen inkl. `/community/abc123` → `{id:'community', params:{id:'abc123'}}`, `/community/` → home, unbekannt → home; `legacyRedirect(new URL('https://x/?karte=K1'))` → `/gestalten?karte=K1`, ohne `karte` → null; `navigate('/karten')` ruft `scrollTo(0,0)`; Titel je Route. theme: `initialTheme(null,true)` → dark, `('light',true)` → light, ungültiger Wert → System; `toggleTheme` setzt `data-theme` und speichert; `localStorage` wirft → kein Fehler. donate-banner: leere URL → nichts gerendert; Schließen setzt Flag und entfernt; Flag gesetzt → nichts. header: Suche Enter mit „ visa “ → `navigate('/?q=visa#community')`; leere Eingabe → nichts; kein ☕-Knopf bei leerem `donateUrl`. auth: `getCurrentUser` übernimmt `labels` (fehlend → `[]`), `isAdmin` true nur mit `'admin'`.
|
||||
- [ ] **Step 2: Tests laufen lassen** → FAIL.
|
||||
- [ ] **Step 3: Implementieren**; `main.ts`: Legacy-Umleitung vor `startRouter` (replaceState), Routen auf die Mount-Funktionen; Editor jetzt unter `gestalten`; `document.title` per `titleFor`.
|
||||
- [ ] **Step 4: Prüfen** – Suite grün, `tsc`, `build`.
|
||||
- [ ] **Step 5: Commit** – `feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner`
|
||||
|
||||
### Task 2: Schema-Erweiterung (Setup-Skript)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/scripts/appwrite-setup.mjs`, `web/scripts/appwrite-setup.test.mjs`, `docs/appwrite-console.md`
|
||||
|
||||
**Interfaces:**
|
||||
- `DESIRED` bekommt: Tabelle `gallery` (rowSecurity true, Tabellenrechte `[]`), Spalten gemäß Spec §7 (Typen string/boolean/integer, Standardwerte `hidden=false`, `reportCount=0`), Indizes `search_text` (fulltext, `[searchText]`), `owner` (key, `[ownerId]`), `hidden` (key, `[hidden]`); Tabelle `reports` (rowSecurity false, Tabellenrechte `['read("label:admin")']`), Spalten `galleryId` string 36 Pflicht, `reason` enum `[unangemessen, kartendaten, urheberrecht, spam, sonstiges]` Pflicht, `reporterHash` string 64 Pflicht, Index `gallery` (key, `[galleryId]`); Bucket `gallery-images` (Rechte `['create("users")']`, fileSecurity true, maximumFileSize 5242880, Endungen `[png, webp]`, compression none, antivirus false).
|
||||
- REST: `POST …/columns/boolean {key, required:false, default:false}`, `…/columns/integer {key, required:false, default:0, min:0}`, `…/columns/enum {key, elements, required:true}`, Indizes `POST /tablesdb/{db}/tables/{t}/indexes {key, type: 'fulltext'|'key', columns}` erst nachdem alle Spalten `available`; Index-Status ebenfalls auf `available` warten. Vergleich (`--check`) deckt Spaltentyp, Standard, Enum-Elemente, Index-Typ/Spalten ab.
|
||||
- `docs/appwrite-console.md`: Abschnitt „Community“: JWT muss **an** bleiben; Label `admin` setzen; Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) + `read -s`-Befehl für `AIRCARD_API_KEY`; Hinweis, dass der Provisioning-Key für Indizes zusätzlich `indexes.read/write` braucht.
|
||||
|
||||
- **Ergänzungen aus Plan-Review (verbindlich):** `DESIRED` umbauen auf `tables: [...]` (cards zuerst, dann gallery, reports) und `buckets: [...]` (card-images, gallery-images); `ensureTable/ensureColumns/waitAvailable/ensureBucket` parametrisieren, betroffene Tests umschreiben. `compareColumn` je Typ: string → size/required; boolean → required/default; integer → required/default (min/max der Antwort NICHT vergleichen); enum → elements (sortiert)/required. Index-Routen brauchen nur `tables.*`-Scopes (kein `indexes`-Scope) – in den Docs nicht verlangen. Docs: „JWT muss **an** sein – falls in Schritt 3 ausgeschaltet, wieder einschalten.“
|
||||
|
||||
- [ ] **Step 1: Tests** – Fake-Server um boolean/integer/enum/index-Routen erweitern; leerer Zustand → `--check` meldet alle neuen Objekte als FEHLT; Anlegen → alles da, Indizes erst nach `available`-Spalten; zweiter Lauf legt nichts an; abweichender Index-Typ → ABWEICHUNG.
|
||||
- [ ] **Step 2: FAIL**, **Step 3: Implementieren**, **Step 4:** `node --test web/scripts/appwrite-setup.test.mjs` grün, **Step 5: Commit** `feat(appwrite): Schema für Galerie, Meldungen und Galerie-Bilder`.
|
||||
|
||||
### Task 3: API-Dienst `aircard-api`
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/api/appwrite.js` (REST-Helfer mit Server-Key), `deploy/api/app.js` (Routen, Validierung, Logik), `deploy/api/server.js` (Start: Env lesen, `http.createServer`), `deploy/api/app.test.js`, `deploy/api/README.md`
|
||||
- Modify: `deploy/compose-snippet.yml` (Service `aircard-api`), `deploy/README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- `appwrite.js`: `createAppwrite({ endpoint, project, key, fetchImpl })` → `{ getRow, listRows(table, queries), createRow, updateRow(table, id, data, permissions?), deleteRow, getFile(bucket, id), updateFilePermissions(bucket, id, permissions), deleteFile, accountFromJwt(jwt) }` (letzteres `GET /account` mit `X-Appwrite-JWT`, ohne Key; der Client schickt das JWT im Header `X-Aircard-Jwt`). Queries als Appwrite-JSON-Strings (`{"method":"equal","attribute":"…","values":[…]}`).
|
||||
- `app.js`: `createApp({ appwrite, salt, now = Date.now, log })` → `(req, res) => Promise<void>`; reine Helfer exportiert: `visibleRowPermissions()`, `hiddenRowPermissions(owner)`, `visibleFilePermissions(owner)`, `hiddenFilePermissions(owner)`, `reporterHash(salt, ip)` (HMAC-SHA256 hex), `clientIp(req)` (`x-real-ip`, sonst erstes `x-forwarded-for`, sonst Socket), `validatePublish(body)` → `{ok, data|message}` (Längen Spec §6, Tags normalisiert: trim, klein, leer raus, max. 5, je ≤ 20), `buildSearchText({title, issuer, tags})` (klein, ≤ 300).
|
||||
- Endpunkte, Statuscodes und Texte exakt Spec §8/§11: `POST /api/veroeffentlichen` (401 ohne/ungültiges JWT, 400 Validierung, 403 Datei nicht meine/falscher Bucket, 409 „Diese Karte ist schon veröffentlicht.“, 201 `{id}`), `POST /api/zurueckziehen` (403 fremd, 404 fehlt, 200), `POST /api/melden` (400 Grund, 404 fehlt/ausgeblendet, 200 `{ok:true}` bzw. `{schonGemeldet:true}`, 429 „Zu viele Meldungen – bitte später nochmal.“), `POST /api/admin/sichtbarkeit` (403 kein Admin, 200), `GET /api/gesundheit`. Body max. 8 KB (413), nur `application/json` (415), sonst 404.
|
||||
- `server.js`: Env `AIRCARD_API_PORT` (8088), `AIRCARD_APPWRITE_ENDPOINT` (`http://webklar-appwrite/v1`), `AIRCARD_APPWRITE_PROJECT_ID`, `AIRCARD_API_KEY`, `AIRCARD_API_SALT`; fehlt Key oder Salt → Fehlermeldung ohne Wert, `exit(1)`.
|
||||
- Compose-Service: `aircard-api` = `node:22-alpine`, `container_name webklar-aircard-api`, `init: true`, `mem_limit 128m`, `restart unless-stopped`, Volume `./aircard-ios.org/api:/srv:ro`, Command `node /srv/server.js`, Env wie oben aus `${…}`, Netz `webklar-network`, Labels Router `aircard-api` ``Host(`aircard-ios.org`) && PathPrefix(`/api`)`` websecure/letsencrypt Priorität 200, Port 8088.
|
||||
|
||||
- **Ergänzungen aus Plan-Review (verbindlich):** (a) Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen, sonst 400; in `appwrite.js` jedes Pfadsegment `encodeURIComponent`; Tests mit `../x`, `..`, Zahl statt String. (b) Deterministische Zeilen-IDs: Galerie `rowId` = erste 32 Hex von `sha256(ownerId + '|' + sourceCardId)` → Appwrite-409 (doppelt) → API 409 „Diese Karte ist schon veröffentlicht.“; Meldung `rowId` = erste 32 Hex von `sha256(galleryId + '|' + reporterHash)` → 409 → `schonGemeldet`; `reportCount` = Anzahl Meldungszeilen der Karte (limit 100). (c) Dateien: fehlt → 400 „Bild nicht gefunden.“; anderer Bucket oder fremd → 403; `mimeType` ∈ {image/png, image/webp} und `sizeOriginal` ≤ 5 MiB für beide (Vorschau darf PNG sein). (d) Body: `tags` ist `string[]` (Server normalisiert erneut, > 5 oder > 20 Zeichen → 400); Admin-Body `{id, sichtbar}`. (e) IPv6-Adressen vor dem Hashen auf /64 kürzen; abgelaufene Rate-Limit-Einträge entfernen; Appwrite-Aufrufe mit `AbortSignal.timeout(10000)`. (f) Traefik-Router heißt `aircard-gallery-api`; Kommentare „Starten NUR mit …“ in compose-snippet.yml/README um `aircard-api` ergänzen.
|
||||
|
||||
- [ ] **Step 1: Tests** (`node:test`, Fake-Appwrite im Speicher, echte HTTP-Requests auf Port 0): Veröffentlichen ok (Zeile mit sichtbaren Rechten, `searchText`, `ownerId`); ohne JWT 401; fremde Datei (Rechte ohne `delete("user:me")`) 403; Datei in falschem Bucket 403; doppelt 409; Validierung (Titel leer/zu lang, 6 Tags) 400. Melden: 3 verschiedene IPs → `hidden=true`, Zeilen- und Dateirechte = ausgeblendet; dieselbe IP 5× → `reportCount` 1, Antworten `schonGemeldet`; 11. Meldung derselben IP in 1 h → 429, nach `now`+1 h wieder erlaubt; ausgeblendete Karte melden → 404. Zurückziehen: Autor ok (Zeile, 2 Dateien, Meldungen weg), Admin ok, fremd 403. Admin: Nicht-Admin 403; Einblenden setzt sichtbare Rechte, `hidden=false`, `reportCount=0`, löscht Meldungen. Body > 8 KB → 413; Log enthält nie Key/Salt/JWT.
|
||||
- [ ] **Step 2: FAIL** (`node --test "deploy/api/*.test.js"`), **Step 3: Implementieren**, **Step 4: PASS**, **Step 5: Commit** `feat(api): aircard-api für Veröffentlichen, Melden und Admin`.
|
||||
|
||||
### Task 4: Client-Datenschicht Community
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/community/gallery.ts`, `web/src/community/api.ts`, `web/src/community/publish.ts`
|
||||
- Modify: `web/src/account/appwrite.ts` (`IDS.gallery='gallery'`, `IDS.reports='reports'`, `IDS.galleryBucket='gallery-images'`), `web/src/account/cards.ts` (`fetchFile(bucketId, fileId)` verallgemeinern, bestehende Aufrufer anpassen)
|
||||
- Test: `web/src/community/gallery.test.ts`, `api.test.ts`, `publish.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- `gallery.ts`: `interface GalleryCard { id; title; issuer; tags: string[]; displayName; imageFileId; previewFileId; sourceCardId; ownerId; hidden: boolean; reportCount: number; createdAt: string }`; `toGalleryCard(row)`; `galleryQueries(opts: { q?: string; cursor?: string; filter?: 'all'|'reported'|'hidden' }): string[]` (öffentlich immer `equal('hidden', false)`, `orderDesc('$createdAt')`, `limit(24)`, Suche `search('searchText', q)`, Admin-Filter laut Spec §7); `listGallery(opts)` → `{ cards, nextCursor | null }`; `getGalleryCard(id)` → `GalleryCard | null` (404 → null); `myPublications(userId)` → `Map<sourceCardId, GalleryCard>`; `listReports(galleryId)` (Admin); `loadGalleryImage(card, 'full'|'preview')` → Blob (über `fetchFile`).
|
||||
- `api.ts`: `apiPost(path, body, { auth: boolean })` → JSON; mit `auth` holt `account.createJWT()` (Cache 10 min); Fehler als `ApiError { status, message }` mit Server-Text; Netzfehler → „Der Dienst ist gerade nicht erreichbar.“; `publishCard(input)`, `withdrawCard(id)`, `reportCard(id, reason)`, `setVisibility(id, visible)`.
|
||||
- `publish.ts`: `publishSavedCard(card: SavedCard, form: { title; issuer; tags; displayName }, deps)` → `{ id }`: Bild laden, rendern (PNG 1536 + WebP 480 via `exportCard`), zwei Uploads in `gallery-images` mit `[Permission.read(Role.any()), Permission.delete(Role.user(uid))]`, dann `publishCard`; bei Fehler beide Dateien löschen (best effort) und Fehler weiterwerfen. `normalizeTags(input: string): string[]` (wie Server).
|
||||
|
||||
- **Ergänzungen aus Plan-Review (verbindlich):** JWT-Cache je Nutzer-ID, geleert bei `onAuthChange`/`onLogout`; API-401 → neues JWT holen, genau einmal wiederholen; kein `duration`. `ApiError` hat numerisches `code` (= HTTP-Status) und `message` (Servertext), damit `isUnauthorized`/`withLogin`/`errorMessage` greifen; Nicht-JSON, 502/503/504 oder HTML-200 → `code 503` „Der Dienst ist gerade nicht erreichbar.“; `createJWT`-Fehler `user_auth_method_unsupported` → „Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.“ In `cards.ts` eine eigenständige exportierte `fetchFile(bucketId, fileId)`; `CardsApi.fetchFile(fileId)` bleibt als Hülle über `IDS.bucket` (bestehende Tests unverändert). Tags an die API als `string[]`. PNG > 5 MiB vor dem Upload → „Das Kartenbild ist zu groß für die Galerie (max. 5 MB).“; Vorschau-Datei nach echtem Blob-Typ benennen. `myPublications`/`listReports` mit `limit(100)`. `nextCursor` = `cards.length === 24 ? letzte id : null`. Admin-Filter „Gemeldet“ = `greaterThan('reportCount', 0)` (ohne hidden-Bedingung). `getGalleryCard` mit ungültiger ID (Muster wie API) → `null` ohne Anfrage.
|
||||
|
||||
- [ ] **Step 1: Tests** – Queries exakt (inkl. Suche + Cursor + Admin-Filter); `toGalleryCard` mit fehlenden Feldern; `getGalleryCard` 404 → null; `apiPost` mit `auth` sendet Header `X-Aircard-Jwt: <jwt>`; JWT-Cache (zweiter Aufruf innerhalb 10 min ohne neues `createJWT`); Fehlertext aus JSON `message`; Netzfehler-Text. `publishSavedCard`: Reihenfolge Upload, Upload, API; API-Fehler → 2× `deleteFile`, Fehler kommt an; zweiter Upload scheitert → erster gelöscht.
|
||||
- [ ] **Step 2: FAIL**, **Step 3: Implementieren**, **Step 4: PASS + tsc + build**, **Step 5: Commit** `feat(community): Datenschicht für Galerie, API und Veröffentlichen`.
|
||||
|
||||
### Task 5: Startseite (Hero + Galerie)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/home-page.ts` (Platzhalter aus Task 1 ersetzen)
|
||||
- Create: `web/src/community/gallery-grid.ts`, `web/src/community/community.css`, `web/public/hero-beispiel.png` (1536 × 969, eigenes markenfreies Motiv: lila/blauer Verlauf mit abstrakten Wellen + AirCard-IOS-Symbol, erzeugt mit einem Skript im Scratchpad)
|
||||
- Test: `web/src/pages/home-page.test.ts`, `web/src/community/gallery-grid.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- `mountHomePage(root, deps = defaultDeps)` – Hero laut Spec §4 (Texte exakt), Hero-Karte = Vollbild der neuesten sichtbaren Karte oder `/hero-beispiel.png`; Galerie-Abschnitt `id="community"`; liest `?q=` aus der URL; „Mehr laden“ hängt an; Leer-/Kein-Treffer-Texte exakt; Admin sieht Filter-Chips „Alle/Gemeldet/Ausgeblendet“.
|
||||
- `mountGalleryGrid(el, { load, onTag })` – Kacheln (Vorschau per Object-URL, beim Cleanup freigeben), Titel, Herausgeber, ≤ 3 Tags als Knöpfe; Kachel verlinkt `/community/<id>` (`data-link`).
|
||||
- CSS: Hero `min-height: auto; max-height: 60vh`, Raster `repeat(auto-fill, minmax(260px, 1fr))`; Hero-Karte schwebt (`@media (prefers-reduced-motion: no-preference)`).
|
||||
|
||||
- **Ergänzungen aus Plan-Review (verbindlich):** Tag-Klick sucht nicht per Volltext (min. 3 Zeichen, Stoppwörter), sondern navigiert zu `/?tag=<tag>#community` → Query `contains('tags', tag)`; Suchfeld nutzt Volltext ab 3 Zeichen, kürzere Begriffe → `contains('searchText', q)`. Mehrwort-Suche = ODER (akzeptiert).
|
||||
|
||||
- [ ] **Step 1: Tests** – leere Galerie → Beispielbild + Leertext; Suche aus `?q=` landet in Queries und zeigt aktiven Begriff mit „ד; „Mehr laden“ nur bei `nextCursor`; Tag-Klick navigiert zu `/?q=<tag>#community`; Object-URLs werden beim Cleanup freigegeben.
|
||||
- [ ] **Step 2: FAIL**, **Step 3: Implementieren** (inkl. Hero-Bild), **Step 4: PASS + build**; Sichtprüfung headless Chromium (`npx vite preview`) bei 1440 × 900 und 390 × 844 mit gemockter Galerie (Playwright `page.route` beantwortet `/v1/tablesdb/…/rows` mit 30 Fake-Zeilen und `/v1/storage/…/view` mit einem PNG) – Überschrift „Community-Karten“ und Oberkante der ersten Reihe im Viewport; Screenshots im Scratchpad. **Step 5: Commit** `feat(home): kompakter Hero und Community-Galerie`.
|
||||
|
||||
### Task 6: Detailseite `/community/<id>` und „Als Vorlage“
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/pages/community-page.ts` (Platzhalter aus Task 1 ersetzen), `web/src/editor/editor-page.ts` (`?vorlage=<id>`)
|
||||
- Create: `web/src/community/report-dialog.ts`
|
||||
- Test: `web/src/pages/community-page.test.ts`, `web/src/community/report-dialog.test.ts`, Ergänzung `web/src/editor/editor-page.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Detailseite laut Spec §5 (Texte exakt): Herunterladen (Blob → `aircard-<slug>.png`), „Als Vorlage gestalten“ → `navigate('/gestalten?vorlage=<id>')`, Melden-Dialog (Gründe exakt, Ergebnis-Texte), Autor „Zurückziehen“ (Bestätigung via `confirmDialog`, danach `navigate('/')`), Admin „Ausblenden/Einblenden“, „Löschen“, „<n> Meldungen“ + Gründe (`listReports`). `document.title` = `titleFor(route, card.title)`.
|
||||
- Editor `?vorlage=<id>`: Galerie-Vollbild laden → `setImage`, Vorlage `image`, kein `saved`; ungespeicherte Arbeit → bestehende Rückfrage; Fehler → Hinweis „Diese Vorlage ist nicht (mehr) verfügbar.“
|
||||
|
||||
- **Ergänzungen aus Plan-Review (verbindlich):** Nach dem `?vorlage=`-Import `history.replaceState` auf `/gestalten` (kein erneuter Import bei Zurück) und `saved`, `savedConfig`, `cardName` ausdrücklich zurücksetzen (sonst überschreibt Speichern eine zuvor geöffnete Karte).
|
||||
|
||||
- [ ] **Step 1: Tests** – nicht gefunden/ausgeblendet für Gast → Text + kein Bild; Autor sieht Zurückziehen, Fremder nicht, Admin sieht Admin-Knöpfe + Meldungszahl; Melden: doppelt → „Danke, du hast diese Karte schon gemeldet.“, 429 → Rate-Text; Vorlage setzt Template `image` und lässt `saved` null.
|
||||
- [ ] **Step 2–5** wie üblich; Commit `feat(community): Detailseite, Melden, Admin-Aktionen, Vorlage`.
|
||||
|
||||
### Task 7: Veröffentlichen aus „Meine Karten“
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/community/publish-dialog.ts`
|
||||
- Modify: `web/src/pages/cards-page.ts`, `web/src/pages/cards.css`
|
||||
- Test: `web/src/community/publish-dialog.test.ts`, Ergänzung `web/src/pages/cards-page.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Dialog laut Spec §6 (Felder, Grenzen, Pflicht-Checkbox-Text exakt), Absenden über `publishSavedCard`; Kachel-Status aus `myPublications(uid)`: „Veröffentlicht“ + „Ansehen“ (`/community/<id>`) + „Zurückziehen“ bzw. „Ausgeblendet (gemeldet)“; nicht veröffentlicht → „Veröffentlichen“. Nicht angemeldet → bestehendes `withLogin`.
|
||||
|
||||
- **Ergänzungen aus Plan-Review (verbindlich):** Ausgeblendete Kacheln behalten „Zurückziehen“ (Autor darf aufräumen und neu veröffentlichen).
|
||||
|
||||
- [ ] **Step 1: Tests** – Validierung im Dialog (Titel leer, > 60, 6 Tags, Checkbox fehlt); Erfolg → Badge + Ansehen-Link; 409 → Text; Zurückziehen → Status zurück.
|
||||
- [ ] **Step 2–5**; Commit `feat(community): Veröffentlichen aus Meine Karten`.
|
||||
|
||||
### Task 8: Betrieb (Controller) und Abnahme
|
||||
|
||||
- **Ergänzungen aus Plan-Review (verbindlich):** Kenso prüft, dass JWT in den Auth-Methoden **an** ist (Live-Check: Veröffentlichen klappt). Kein `indexes`-Scope nötig. Nach erfolgreichem Schema-Lauf den alten, im Chat geposteten Provisioning-Key löschen lassen. Spoofing-Test: zweimal `/api/melden` über die öffentliche URL mit unterschiedlichen gefälschten `X-Real-Ip` → zweite Antwort `schonGemeldet` (Traefik überschreibt den Header).
|
||||
|
||||
- [ ] Kenso: Label `admin`, Key `aircard-api` per `read -s` als `AIRCARD_API_KEY` in `.env` (Controller liefert den Befehl); Provisioning-Key um `indexes.read/write` ergänzen oder Controller legt Indizes nach Kensos OK an.
|
||||
- [ ] Controller: `AIRCARD_API_SALT` (32 Byte hex) in `.env` (Backup), Setup-Skript `--check` → anlegen → `--check` OK; `/home/webklar/apps/aircard-ios.org/api/` mit `deploy/api/*.js` füllen (`cp`); Compose-Backup, Service einfügen, `docker compose config -q`, `docker compose up -d aircard-api`; `curl https://aircard-ios.org/api/gesundheit` → `{"ok":true}`; Push → Webhook-Build.
|
||||
- [ ] Live-Abnahme (Claude in Chrome, Kensos Sitzung): Startseite 1440 × 900 Karten angeschnitten; Hell/Dunkel; Veröffentlichen einer Testkarte; Suche nach Tag; Detailseite, Herunterladen, Als Vorlage; Melden (3 verschiedene `X-Real-Ip` per `docker exec webklar-aircard-api` lokal gegen `127.0.0.1:8088`) → ausgeblendet, Gast sieht „nicht (mehr) verfügbar“; Admin Einblenden; Zurückziehen.
|
||||
- [ ] Final-Review (Workflow, 3 Linsen) → eine Fix-Welle → Push; Memory aktualisieren; Bericht an Kenso.
|
||||
@@ -0,0 +1,436 @@
|
||||
# AirCard-IOS Community-Logos, Englisch und GitHub-Link – Implementierungsplan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Eingebaute Marken-Logos durch Community-Logos (Hochladen, Herzen, Melden) ersetzen, die Seite zweisprachig (DE/EN) machen und GitHub-Link + Sprachknopf in die Kopfzeile setzen.
|
||||
|
||||
**Architecture:** Der Vite-+-TS-Client bekommt ein kleines i18n-Modul im gettext-Stil (`t('Deutscher Text')`, Wörterbuch Deutsch → Englisch) und neue Module unter `web/src/logos/`. Gelesen wird direkt aus Appwrite (öffentliche Zeilen), geschrieben nur über den Node-Dienst `aircard-api`, der ein neues Modul `deploy/api/logos.js` bekommt; gemeinsame Helfer wandern nach `deploy/api/common.js`. Das Schema wächst über das bestehende Setup-Skript.
|
||||
|
||||
**Tech Stack:** wie bisher (TypeScript 6.0.3, Vite 8.3.2, Vitest 5.0.3, happy-dom, appwrite 22.4.1), Node 22 nur mit Built-ins für `aircard-api`, Appwrite 1.8.1 TablesDB.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md` (Commit 067e428). Basis: die Website- und Community-Specs im selben Ordner.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Arbeitskopie `/home/webklar/apps/aircard-ios.org/dev`, Branch `main`, direkt committen, **nicht pushen** (Controller pusht).
|
||||
- Keine neuen npm-Abhängigkeiten; `deploy/api/` bleibt ohne Abhängigkeiten. Versionen unverändert.
|
||||
- Kommentare und Bezeichner im Stil des Bestands (deutsche Kommentare, gleiche Idiome, `el()`/`icon()` aus `editor/dom.ts`).
|
||||
- Ab Task 1 gehen **alle neuen sichtbaren Texte** (Text, `aria-label`, `title`, Platzhalter, Meldungen) durch `t()`; Konstanten werden mit `msg()` markiert und beim Anzeigen mit `t()` übersetzt. Jeder neue Text bekommt seinen Eintrag in `web/src/i18n/en.ts` (kurz, freundlich, US-Englisch). Deutscher Wortlaut aus der Spec.
|
||||
- In Tests ist die Sprache Deutsch (`web/src/test-setup.ts`); bestehende Tests bleiben unverändert grün, außer wo der Task sie ausdrücklich anpasst.
|
||||
- Appwrite: Projekt `6ac2130a00289343d6d5`, Datenbank `aircard`; neue Tabellen `logos`, `logo_likes`, `logo_reports`; neuer Bucket `logo-images`; Bestehendes unverändert.
|
||||
- Logo-Zeilenrechte: sichtbar genau `['read("any")']`, ausgeblendet genau `['read("user:<owner>")','read("label:admin")']`. Logo-Dateirechte: beim Hochladen `['read("any")','delete("user:<owner>")']`, nach dem Anlegen genau `['read("any")']`, ausgeblendet `['read("user:<owner>")','read("label:admin")']`. Like-Zeilen: ID `hash32("<logoId>|<userId>")`, Rechte `['read("user:<userId>")']`.
|
||||
- Grenzen: 30 Logos je Konto; Logo-PNG höchstens 1 MiB (1048576 Byte), 8 × 8 bis 720 × 250 px; Name ≤ 40, Anzeigename ≤ 30 Zeichen; Seiten zu 24; Ausblenden ab 3 Meldern; Rate-Limit 10 Meldungen/Stunde je Anschluss, gemeinsam für Karten und Logos.
|
||||
- `localStorage`: `aircard.lang` (`de`|`en`), `aircard.displayName`; jeder Zugriff in try/catch.
|
||||
- GitHub-Ziel `https://github.com/Mak5er/AirCard-iOS` (Konstante `GITHUB_URL` in `web/src/config.ts`).
|
||||
- Tests: `cd web && npx vitest run`, `npx tsc --noEmit`, `npm run build`; `node --test "deploy/api/*.test.js"` (Glob in Anführungszeichen); `node --test web/scripts/appwrite-setup.test.mjs`.
|
||||
- Keine Secrets in Ausgaben, Logs, Commits; `.env` nie öffnen (Ausnahme: Controller-Betrieb in Task 12).
|
||||
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Subagents: `sonnet` für Umsetzung und Review, `haiku` für kleine Nachprüfungen; nie Subagents aus Subagents.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- **Fremde oder falsche Datei beim Hochladen**: `fileId` einer fremden Logo-Datei, einer Datei aus `gallery-images` oder einer schon veröffentlichten Logo-Datei an `/api/logos/hochladen` → 403, keine Zeile, Datei unverändert. Test in Task 3.
|
||||
- **Gespeicherte Karte mit gelöschtem/ausgeblendetem Logo**: Öffnen zeichnet die Karte ohne Logo, zeigt „Das gewählte Logo ist nicht mehr verfügbar.“, kein Fehler-Loop, Download funktioniert; Veröffentlichen bricht mit dem Text aus Spec § 2 ab. Tests in Task 5.
|
||||
- **Sprachwechsel mit ungespeichertem Editor-Entwurf**: Bild, Ausschnitt und Einstellungen bleiben nach dem Umschalten erhalten, die Seite wird neu aufgebaut, nicht neu geladen. Test in Task 1 (Router baut neu auf, Store bleibt) und Live in Task 12.
|
||||
- **Herz doppelt geklickt / Gast bricht Login ab**: Solange eine Herz-Anfrage für ein Logo läuft, sind weitere Klicks auf dieses Herz wirkungslos; bricht ein Gast den Login ab, geht keine Anfrage raus und das Herz bleibt aus, ohne Fehlertext. Tests in Task 8.
|
||||
- **SVG ohne Größe oder mit riesiger viewBox**: Das Seitenverhältnis kommt aus der viewBox (ohne brauchbare viewBox: quadratisch, entspricht den 1000 × 1000 der Spec); gerastert wird immer mit genau 2048 px an der längsten Seite, nie größer; Skripte im SVG laufen nie (Laden nur als Bild). Test in Task 6 (`svgRasterSize`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: i18n-Kern, Kopfzeile (GitHub + Sprache), Fußzeile, Sprachwechsel ohne Neuladen
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/i18n/i18n.ts`, `web/src/i18n/en.ts`, `web/src/i18n/i18n.test.ts`, `web/src/i18n/coverage.test.ts`, `web/src/test-setup.ts`, `web/src/shell/footer.ts`, `web/src/shell/footer.test.ts`
|
||||
- Modify: `web/vite.config.ts` (`test.setupFiles: ['src/test-setup.ts']`), `web/index.html`, `web/src/config.ts`, `web/src/editor/dom.ts` (Icons `github`, `globe`), `web/src/shell/header.ts`, `web/src/shell/shell.css`, `web/src/shell/donate-banner.ts`, `web/src/pages/router.ts`, `web/src/main.ts`, `web/src/branding.test.ts` (falls es den Fußzeilentext oder `<title>` prüft)
|
||||
- Test: `web/src/shell/header.test.ts`, `web/src/pages/router.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```ts
|
||||
// web/src/i18n/i18n.ts
|
||||
export type Lang = 'de' | 'en';
|
||||
export const LANG_KEY = 'aircard.lang';
|
||||
export function initialLang(stored: string | null, browserLanguages: readonly string[]): Lang;
|
||||
export function currentLang(): Lang; // beim ersten Aufruf: initialLang(localStorage, navigator.languages bzw. [navigator.language])
|
||||
export function setLang(lang: Lang, opts?: { persist?: boolean }): void; // persist Standard true; setzt <html lang>; meldet nur bei Wechsel
|
||||
export function onLangChange(fn: (lang: Lang) => void): () => void;
|
||||
export function t(text: string, params?: Readonly<Record<string, string | number>>): string;
|
||||
export function msg<T extends string>(text: T): T; // Identität, markiert Konstanten für den Abdeckungstest
|
||||
// web/src/i18n/en.ts
|
||||
export const EN: Readonly<Record<string, string>>; // Deutsch → Englisch
|
||||
// web/src/config.ts
|
||||
export const GITHUB_URL = 'https://github.com/Mak5er/AirCard-iOS';
|
||||
// web/src/pages/router.ts
|
||||
export function rerender(): void; // räumt die aktuelle Seite auf und baut sie neu (ohne Scrollen); ohne Router: nichts
|
||||
// web/src/shell/footer.ts
|
||||
export function mountFooter(footer: HTMLElement): void; // ersetzt den Inhalt durch <p> mit dem Fußzeilentext der aktuellen Sprache
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests schreiben** (`i18n.test.ts`, `footer.test.ts`, Ergänzungen in `header.test.ts` und `router.test.ts`):
|
||||
- `initialLang(null, ['de-DE','en'])` → `'de'`; `(null, ['en-US','de'])` → `'en'`; `('en', ['de-DE'])` → `'en'`; `('xx', ['de-AT'])` → `'de'`; `(null, [])` → `'en'`.
|
||||
- `setLang('en')`: `t('Gestalten')` → `'Create'`; unbekannter Text bleibt deutsch; `t('Nichts gefunden für „{q}“', { q: 'x' })` ersetzt den Platzhalter in beiden Sprachen; unbekannter Platzhalter bleibt `{name}` stehen.
|
||||
- `setLang` setzt `document.documentElement.lang`, schreibt mit `persist: false` nichts in `localStorage`, ruft Listener genau einmal je echtem Wechsel.
|
||||
- Fußzeile: deutsch der Spec-Text (§ 7); nach `setLang('en')` und erneutem `mountFooter` der englische.
|
||||
- Kopfzeile: Link mit `href` = `GITHUB_URL`, `target="_blank"`, `rel="noopener noreferrer"`, `title` „AirCard-iOS auf GitHub“; Sprachknopf zeigt bei Deutsch „EN“ mit `aria-label` „Switch to English“ und `lang="en"`, Klick → `currentLang() === 'en'`; danach (neu gemountet) „DE“ mit „Auf Deutsch umschalten“; das Menü enthält einen zweiten GitHub-Link (Klasse `nav-github`).
|
||||
- Router: `rerender()` ruft das Aufräumen der aktuellen Seite und dann `render` mit derselben Route erneut auf; `scrollTo` wird nicht aufgerufen; nach `setLang('en')` liefert `titleFor({id:'gestalten',params:{}})` „Create – AirCard-IOS“.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/i18n src/shell src/pages/router.test.ts` → FAIL (Module fehlen).
|
||||
- [ ] **Step 3: Umsetzen.**
|
||||
- `t()`: Basistext = bei `en` `EN[text] ?? text`, sonst `text`; dann `{name}` durch `String(params[name])` ersetzen, wenn vorhanden.
|
||||
- `coverage.test.ts` (Muster wie `single-sources.test.ts`): sammelt in allen Nicht-Test-`.ts` unter `src/` die String-Literale aus `t(…)`/`msg(…)` (`'…'`, `"…"`, `` `…` `` ohne `${`) und prüft: (a) jedes hat einen Eintrag in `EN`, (b) jeder `EN`-Eintrag wird benutzt, (c) Platzhalter `{…}` in Schlüssel und Übersetzung sind dieselbe Menge, (d) kein `t(`/`msg(` mit Template-Literal, das `${` enthält.
|
||||
- `test-setup.ts`: `beforeEach(() => setLang('de', { persist: false }))`.
|
||||
- `index.html`: `<title>AirCard-IOS – Card designs for Apple Wallet</title>`, `<meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG.">`; im bestehenden Inline-Skript zusätzlich `lang` setzen (gleiche Regel wie `initialLang`, Kommentar verweist darauf); `<footer id="app-footer"></footer>` leer (Text kommt aus `mountFooter`).
|
||||
- Icons in `dom.ts` (Lucide, Strich): `github: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>'`, `globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>'`.
|
||||
- Kopfzeile: Reihenfolge in `.header-tools`: GitHub (`a.header-github` mit Icon + `span.header-github-text` „GitHub“), Sprachknopf (`button.header-btn.lang-btn`), Spenden, Hell/Dunkel, Menü. CSS: zwischen 600 und 1179 px `.header-github-text` ausblenden (wie `.header-donate-text`); unter 600 px `.header-github` ausblenden und `.nav-github` im Menü zeigen (sonst `.nav-github` ausgeblendet). Alle übrigen Kopfzeilen- und Banner-Texte durch `t()`/`msg()`.
|
||||
- Router: `ROUTE_TITLES`-Werte mit `msg()`, `titleFor` liefert `t(…)`; `rerender()` ruft die interne `renderCurrent` auf.
|
||||
- `main.ts`: beim Start `setLang(currentLang(), { persist: false })` und `client.setLocale(lang)`; `onLangChange(lang => …)`: `client.setLocale(lang)`, Kopfzeile aufräumen und neu mounten, `mountDonateBanner` erneut, Konto-Menü `dispose()` + neu mounten (+ `ready()`, wenn der Anmeldestatus schon geholt war), `mountFooter`, dann `rerender()`. Der Editor-Store bleibt dasselbe Objekt.
|
||||
- Englische Einträge für alle neuen `t()`/`msg()`-Texte in `en.ts` (Seitentitel: „Card designs for Apple Wallet“, „Create“, „My cards“, „Community“, „Set new password“).
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → alles grün.
|
||||
- [ ] **Step 5:** Commit `feat(i18n): Deutsch/Englisch, Sprachknopf und GitHub-Link in der Kopfzeile`.
|
||||
|
||||
### Task 2: Schema für Logos (Setup-Skript)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/scripts/appwrite-setup.mjs` (`DESIRED`, Kopfkommentar), `web/scripts/appwrite-setup.test.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `DESIRED.tables` zusätzlich (in dieser Reihenfolge nach `reports`) und `DESIRED.buckets` zusätzlich:
|
||||
```js
|
||||
{ id: 'logos', name: 'Logos', permissions: [], rowSecurity: true,
|
||||
columns: [str('name', 40, true), str('displayName', 30, false), str('fileId', 36, true), str('ownerId', 36, true),
|
||||
{ key: 'mono', type: 'boolean', required: false, default: false },
|
||||
{ key: 'likeCount', type: 'integer', required: false, default: 0, min: 0 },
|
||||
{ key: 'hidden', type: 'boolean', required: false, default: false },
|
||||
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 }],
|
||||
indexes: [{ key: 'owner', type: 'key', columns: ['ownerId'] }, { key: 'hidden', type: 'key', columns: ['hidden'] },
|
||||
{ key: 'likes', type: 'key', columns: ['likeCount'] }] },
|
||||
{ id: 'logo_likes', name: 'Logo Likes', permissions: [], rowSecurity: true,
|
||||
columns: [str('logoId', 36, true), str('userId', 36, true)],
|
||||
indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }, { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] }] },
|
||||
{ id: 'logo_reports', name: 'Logo Reports', permissions: ['read("label:admin")'], rowSecurity: false,
|
||||
columns: [str('logoId', 36, true),
|
||||
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
|
||||
str('reporterHash', 64, true)],
|
||||
indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }] },
|
||||
// Bucket
|
||||
{ id: 'logo-images', name: 'Logo Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 1048576,
|
||||
allowedFileExtensions: ['png'], compression: 'none', antivirus: false },
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests** nach dem Muster der bestehenden: `DESIRED` enthält die drei Tabellen und den Bucket mit genau diesen Werten; ein Lauf gegen den Test-Server legt sie samt Indizes an (Mehrspalten-Index `user_logo` mit `columns: ['userId','logoId']` im Request); `--check` meldet `FEHLT` für jedes fehlende neue Objekt und Exit 1; bestehende Tabellen bleiben unberührt.
|
||||
- [ ] **Step 2:** `node --test web/scripts/appwrite-setup.test.mjs` → FAIL.
|
||||
- [ ] **Step 3:** `DESIRED` ergänzen; Kopfkommentar nennt die neuen Objekte. Falls der Index-Request heute nur eine Spalte unterstützt, auf Listen erweitern.
|
||||
- [ ] **Step 4:** `node --test web/scripts/appwrite-setup.test.mjs` → PASS.
|
||||
- [ ] **Step 5:** Commit `feat(appwrite): Schema für Community-Logos, Herzen und Logo-Meldungen`.
|
||||
|
||||
### Task 3: API – Fehlercodes, gemeinsame Helfer, Logo-Endpunkte
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/api/common.js` (aus `app.js` herausgelöst: `MSG`, `HttpError`, `isId`, `isPlainObject`, `parseJsonObject`, `readImageSize`, `hash32`, `hmacHex`, `reporterHash`, `reporterNetwork`, `normalizeIp`, `clientIp`, `CONTROL_CHARACTERS`, `ID_PATTERN`, `isDuplicate`, `visibleRowPermissions`, `hiddenRowPermissions`, `uploadFilePermissions`, `visibleFilePermissions`, `hiddenFilePermissions`, `REASONS`), `deploy/api/logos.js`, `deploy/api/logos.test.js`
|
||||
- Modify: `deploy/api/app.js` (importiert aus `common.js`, exportiert weiter alles wie bisher, hängt die Logo-Routen an, sendet `code`), `deploy/api/appwrite.js` (+ `countRows`), `deploy/api/fake-appwrite.js` (Tabellen `logos`, `logo_likes`, `logo_reports` mit Spaltenlängen, Bucket `logo-images`, `total` in Listen-Antworten), `deploy/api/app.test.js`, `deploy/api/appwrite.test.js`, `deploy/api/README.md`, `deploy/README.md` (Betrieb kopiert fünf Dateien: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```js
|
||||
// common.js
|
||||
class HttpError extends Error { constructor(status, message, headers = {}, code = codeFor(status, message)) } // .status .headers .code
|
||||
function codeFor(status, message) // Schlüssel aus MSG mit genau diesem Text, sonst status 400 → 'badRequest', sonst 'error'
|
||||
// appwrite.js
|
||||
countRows(table, queries = []) // GET rows mit queries + limit(1), liefert body.total (Zahl), sonst AppwriteError(502, 'invalid_response')
|
||||
// logos.js
|
||||
function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) // → Array<[routeKey, {name, json: true, run}]>
|
||||
function logoLikeId(logoId, userId) // hash32(`${logoId}|${userId}`)
|
||||
function logoReportId(logoId, reporter) // hash32(`logo|${logoId}|${reporter}`)
|
||||
function validateLogoUpload(body) // {ok:true, data:{name, displayName, mono, fileId}} | {ok:false, message}
|
||||
```
|
||||
- Neue `MSG`-Einträge (Schlüssel = späterer `code`, Texte genau so): `logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.'`, `badLogoId: 'Ungültige Logo-ID.'`, `logoNameMissing: 'Bitte gib einen Namen an.'`, `logoFileType: 'Das Logo muss eine PNG-Datei sein.'`, `logoFileSize: 'Das Logo ist größer als 1 MB.'`, `logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).'`, `logoTaken: 'Diese Datei wird schon für ein Logo verwendet.'`, `tooManyLogos: 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.'`, `badHeart: 'Ungültige Angabe zum Herz.'`, `badMono: 'Ungültige Angabe zu „einfarbig“.'`. Alle `MSG`-Texte bleiben paarweise verschieden.
|
||||
- Routen: `POST /api/logos/hochladen`, `POST /api/logos/herz`, `POST /api/logos/melden`, `POST /api/logos/loeschen`, `POST /api/admin/logo-sichtbarkeit`. Verhalten, Statuscodes, Reihenfolge der Schritte und Rücknahme genau wie Spec § 9 und die bestehenden Karten-Endpunkte (`publish`, `reportCard`, `withdraw`, `setVisibility` sind das Vorbild). Sperren: `logo-owner:<userId>` beim Hochladen, `logo:<id>` für alles je Logo. Gelöscht werden Herzen und Meldungen in Runden (`listRows` mit `equal logoId`, `limit 100`, höchstens 50 Runden). `likeCount` und `reportCount` immer aus `countRows`.
|
||||
|
||||
- [ ] **Step 1: Tests schreiben** (`logos.test.js` im Stil von `app.test.js`; dort zusätzlich die `code`-Fälle):
|
||||
- Hochladen: gültig → 201 `{id: fileId}`, Zeile sichtbar mit `likeCount 0`, Datei-Rechte danach genau `['read("any")']`; fremde Datei → 403 `code:'foreignFile'`; Datei aus `gallery-images` → 403; Datei ohne `read("any")` → 403 `filesTaken`; WebP → 400 `logoFileType`; PNG 721 × 100 oder 7 × 7 → 400 `logoFormat`; > 1 MiB → 400 `logoFileSize`; dieselbe Datei zweimal → 409 `logoTaken`; 31. Logo → 429 `tooManyLogos`; scheitert das Setzen der Dateirechte → Zeile gelöscht, Datei hat wieder `delete("user:<ich>")`; ohne JWT → 401 `unauthorized`; Name leer → 400 `logoNameMissing`; `mono: 'ja'` → 400 `badMono`.
|
||||
- Herz: an → `{herz:true, likeCount:1}`, nochmal an → weiter 1; aus → 0, nochmal aus → 0; zwei Nutzer → 2; ausgeblendetes oder fehlendes Logo → 404 `logoGone`; `herz` kein Boolean → 400 `badHeart`; Like-Zeile hat Rechte `['read("user:<id>")']` und ID `logoLikeId`.
|
||||
- Melden: Dedup je Netz (`{schonGemeldet:true}`), 3 Netze → `hidden: true`, Zeile/Datei mit ausgeblendeten Rechten; Rate-Limit greift auch, wenn 6 Karten- und 5 Logo-Meldungen vom selben Anschluss kommen (11. → 429).
|
||||
- Löschen: Hochlader → 200, Datei, Herzen, Meldungen, Zeile weg; Admin → 200; Fremder → 403 `forbidden`; fehlt → 404 `logoGone`.
|
||||
- Admin-Sichtbarkeit: ausblenden/einblenden (Einblenden: Meldungen weg, `reportCount 0`, Rechte sichtbar); Nicht-Admin → 403 `adminOnly`.
|
||||
- Bestehende Endpunkte: jede Fehlerantwort hat `code` (z. B. `/api/melden` mit unbekannter ID → `{error:'Diese Karte ist nicht (mehr) verfügbar.', code:'cardGone'}`); `MSG`-Texte sind eindeutig.
|
||||
- `countRows` in `appwrite.test.js`: liefert `total`, wirft bei fehlendem `total`.
|
||||
- [ ] **Step 2:** `node --test "deploy/api/*.test.js"` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen: `common.js` herauslösen (reine Verschiebung, Verhalten gleich), `handle()` sendet `{error: err.message, code: err.code}` (Appwrite-Fehler: `code` `unavailable` bzw. `internal`), Logo-Modul, Fake-Erweiterung, Doku (Endpunkt-Tabelle in `api/README.md`, Kopierbefehl mit fünf Dateien in `deploy/README.md`).
|
||||
- [ ] **Step 4:** `node --test "deploy/api/*.test.js"` → PASS (alte und neue Tests).
|
||||
- [ ] **Step 5:** Commit `feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer`.
|
||||
|
||||
### Task 4: Client-Datenschicht für Logos und übersetzbare API-Fehler
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/logos/logos.ts`, `web/src/logos/logos.test.ts`, `web/src/logos/logo-api.ts`, `web/src/logos/logo-api.test.ts`, `web/src/i18n/api-messages.ts`, `web/src/i18n/api-messages.test.ts`
|
||||
- Modify: `web/src/account/appwrite.ts` (`IDS`), `web/src/community/api.ts` (+ Test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `t`, `msg`, `currentLang` (Task 1); API-Routen und `code` (Task 3); `fetchFile`, `ID_PATTERN` aus `account/cards.ts`; `apiPost`, `ApiError`, `isDefinitiveFailure` aus `community/api.ts`.
|
||||
- Produces:
|
||||
```ts
|
||||
// account/appwrite.ts – IDS zusätzlich
|
||||
logos: 'logos', logoLikes: 'logo_likes', logoReports: 'logo_reports', logoBucket: 'logo-images'
|
||||
// logos/logos.ts
|
||||
export interface CommunityLogo { id: string; name: string; displayName: string; fileId: string; ownerId: string;
|
||||
mono: boolean; likeCount: number; hidden: boolean; reportCount: number; createdAt: string }
|
||||
export type LogoSort = 'popular' | 'new';
|
||||
export type LogoFilter = 'all' | 'reported' | 'hidden';
|
||||
export interface LogoListOptions { sort: LogoSort; filter?: LogoFilter; cursor?: string }
|
||||
export interface LogoReport { id: string; logoId: string; reason: string; createdAt: string }
|
||||
export const LOGO_PAGE_SIZE = 24;
|
||||
export function toLogo(row: Record<string, unknown>): CommunityLogo;
|
||||
export function logoQueries(opts: LogoListOptions): string[];
|
||||
export function listLogos(opts: LogoListOptions): Promise<{ logos: CommunityLogo[]; nextCursor: string | null }>;
|
||||
export function getLogo(id: string): Promise<CommunityLogo | null>; // ungültige ID oder 404 → null
|
||||
export function myHearts(userId: string, logoIds: readonly string[]): Promise<Set<string>>;
|
||||
export function listLogoReports(logoId: string): Promise<LogoReport[]>; // neueste zuerst, limit 100
|
||||
export function loadLogoBlob(logo: CommunityLogo): Promise<Blob>; // fetchFile(IDS.logoBucket, logo.fileId)
|
||||
// logos/logo-api.ts
|
||||
export interface LogoUploadInput { name: string; displayName: string; mono: boolean; fileId: string }
|
||||
export function uploadLogo(input: LogoUploadInput): Promise<{ id: string }>;
|
||||
export function setHeart(id: string, herz: boolean): Promise<{ herz: boolean; likeCount: number }>;
|
||||
export function reportLogo(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>;
|
||||
export function deleteLogo(id: string): Promise<void>;
|
||||
export function setLogoVisibility(id: string, visible: boolean): Promise<void>; // Body {id, sichtbar}
|
||||
// community/api.ts
|
||||
class ApiError { constructor(code: number, message: string, definitive?: boolean, serverCode?: string | null); readonly serverCode: string | null }
|
||||
export function apiErrorText(err: ApiError): string; // = apiMessage(err.serverCode, err.code, err.message)
|
||||
// i18n/api-messages.ts
|
||||
export const API_MESSAGES: Readonly<Record<string, string>>; // code → msg('deutscher Servertext'); dazu Client-Codes 'generic', 'jwtUnsupported'
|
||||
export function apiMessage(serverCode: string | null, status: number, serverText: string): string;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests schreiben:**
|
||||
- `logoQueries({sort:'popular'})` = `[equal('hidden',false), orderDesc('likeCount'), orderDesc('$createdAt'), limit(24)]` (als Query-Strings des SDK); `'new'` ohne `likeCount`-Sortierung; `filter:'reported'` → `greaterThan('reportCount',0)`, `'hidden'` → `equal('hidden',true)`; mit `cursor` zusätzlich `cursorAfter`.
|
||||
- `toLogo` macht aus kaputten Feldern leere Werte, `likeCount`/`reportCount` < 0 oder kein Zahlwert → 0, `mono`/`hidden` nur bei `true`.
|
||||
- `listLogos`: `nextCursor` nur bei voller Seite; `getLogo('kaputt id')` → null ohne Anfrage, 404 → null, 500 → wirft.
|
||||
- `myHearts('u', [])` → leere Menge ohne Anfrage; sonst Abfrage `equal('userId','u')`, `equal('logoId',[…])`, `limit(100)`.
|
||||
- `logo-api`: richtige Pfade und Bodies (`/api/logos/herz` mit `{id, herz}`, `/api/admin/logo-sichtbarkeit` mit `{id, sichtbar}`); Antwort ohne `id` bei `uploadLogo` → `ApiError` 503, nicht eindeutig.
|
||||
- `ApiError.serverCode` wird aus `{error, code}` gelesen; Client-Fehler tragen `unavailable`, `generic`, `jwtUnsupported`, `alreadyPublished`, `filesTaken`, `tooManyPublications` passend.
|
||||
- `apiMessage`: bekannter Code → `t(Text)` (deutsch und englisch); unbekannter Code deutsch → Servertext; unbekannter Code englisch → „Invalid request.“ (400), „You are not allowed to do that.“ (403), „Not found.“ (404), sonst „Something went wrong. Please try again.“
|
||||
- **Gleichlauf**: `api-messages.test.ts` lädt `MSG` aus `deploy/api/common.js` (`createRequire(import.meta.url)`) und prüft für jeden Schlüssel `API_MESSAGES[key] === MSG[key]`.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/logos src/i18n src/community/api.test.ts` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen nach dem Muster von `community/gallery.ts` und `community/api.ts`; `en.ts` um alle `API_MESSAGES`-Texte ergänzen.
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün.
|
||||
- [ ] **Step 5:** Commit `feat(logos): Datenschicht, API-Aufrufe und übersetzbare API-Fehler`.
|
||||
|
||||
### Task 5: Kartenmodell ohne Marken-Logos, Renderer mit Community-Logo
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/logos/logo-asset.ts`, `web/src/logos/logo-asset.test.ts`
|
||||
- Modify: `web/src/card/config.ts`, `web/src/card/marks.ts`, `web/src/card/geometry.ts`, `web/src/card/render.ts` (+ ihre Tests), `web/src/editor/options-panel.ts`, `web/src/editor/editor-page.ts` (+ Test), `web/src/community/publish.ts` (+ Test), `web/src/branding.test.ts`, `web/src/single-sources.test.ts` (falls betroffen)
|
||||
- Delete: in `web/src/assets/marks/` alle SVGs außer `aircards-symbol.svg` und `aircards-text.svg` (19 Dateien, `git rm`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getLogo`, `loadLogoBlob`, `CommunityLogo` (Task 4); `t`, `msg` (Task 1).
|
||||
- Produces:
|
||||
```ts
|
||||
// card/config.ts
|
||||
export type BrandId = 'aircards-symbol' | 'aircards-text' | 'none';
|
||||
export interface CardConfig { v: 1; template: TemplateId; logo: string | null; details: DetailsTone; brand: BrandId; crop: Crop }
|
||||
// card/geometry.ts (ersetzt fitNetwork und Layout.network)
|
||||
export const LOGO_MAX_H = 125;
|
||||
export function fitLogo(aspect: number, maxH?: number): Box; // rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch
|
||||
// Layout.logo: boolean (image: false; minimal, classic, signature: true)
|
||||
// card/render.ts
|
||||
export interface LogoAsset { source: CanvasImageSource; w: number; h: number }
|
||||
export interface CardAssets { image: CardImage | null; logo: LogoAsset | null; brand: CanvasImageSource | null }
|
||||
export interface LoadedAssets extends CardAssets { logoMissing: boolean }
|
||||
export function loadCardAssets(config: CardConfig, image: CardImage | null,
|
||||
loadLogo?: (id: string, tone: DetailsTone) => Promise<LogoAsset | null>): Promise<LoadedAssets>; // Standard: loadLogoAsset
|
||||
// logos/logo-asset.ts
|
||||
export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.');
|
||||
export function loadLogoAsset(id: string, tone: DetailsTone): Promise<LogoAsset | null>;
|
||||
export function rememberLogo(logo: CommunityLogo): void; // legt die Zeile in den Cache (spart getLogo)
|
||||
export function forgetLogo(id: string): void; // nach Löschen/Ausblenden: Zeile und Bilder aus dem Cache
|
||||
// editor/options-panel.ts
|
||||
export interface OptionsHandlers { onDetails(tone: DetailsTone): void; onBrand(id: BrandId): void }
|
||||
export interface OptionsPanel { el: HTMLElement; select(config: CardConfig): void; logoSlot: HTMLElement } // logoSlot: leerer Platz oben, Task 8 füllt ihn
|
||||
// community/publish.ts
|
||||
export const LOGO_MISSING_PUBLISH = msg('Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.');
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests schreiben:**
|
||||
- `parseConfig('{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}')` → `logo: null`, `brand: 'aircards-symbol'`, Rest übernommen; `logo: 'abc123'` bleibt; `logo: 'kaputt id'`/Zahl → null; `serializeConfig` schreibt genau `{v,template,logo,details,brand,crop}` in dieser Reihenfolge.
|
||||
- `fitLogo(2, 125)` → `{x: 1230, y: 788, w: 250, h: 125}`; `fitLogo(10, 125)` → Breite 360.
|
||||
- Renderer: mit `assets.logo` (w 200, h 100) und Vorlage `classic` wird `drawImage(source, fitLogo(2).x, …)` aufgerufen; mit `image` nicht; ohne `assets.logo` nicht.
|
||||
- `loadCardAssets`: `logo: null` → `logoMissing false` ohne Loader-Aufruf; Loader liefert null → `logo null, logoMissing true`; Loader wirft → `loadCardAssets` wirft.
|
||||
- `loadLogoAsset` (Fakes für `getLogo`, `loadLogoBlob`, Bilddekodierung und Canvas über Parameter oder Modul-Mocks): fehlende Zeile oder 404-Datei → null; einfarbig → Canvas mit `globalCompositeOperation 'source-in'` und `fillStyle` `#FFFFFF` (light) bzw. `#111111` (dark); gleiche (id, tone) lädt nur einmal; nach `forgetLogo` wird neu geladen; ein fehlgeschlagener Ladevorgang bleibt nicht im Cache.
|
||||
- Editor: gespeicherte Karte mit Logo, Loader liefert null → Vorschau-Warnung `LOGO_MISSING`, Karte wird gezeichnet; Wechsel auf ein vorhandenes Logo → Warnung weg; kein `LOGO_ERROR` bei fehlendem Logo (nur bei echtem Ladefehler).
|
||||
- Veröffentlichen: `render` mit `logoMissing` → Fehler mit Text `LOGO_MISSING_PUBLISH`, keine Datei hochgeladen.
|
||||
- Options-Panel: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit drei Kacheln (Symbol, Text, Kein Logo), kein Apple.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/card src/logos src/editor src/community/publish.test.ts` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen. `logoKey` im Editor = `` `${c.logo ?? ''}|${c.details}|${c.brand}` ``; der Logo-Zustand hält zusätzlich `missing`. Bilddekodierung in `logo-asset.ts`: `createImageBitmap(blob)`, sonst `Image` über Object-URL; einfarbig = eigenes Canvas in Bildgröße, Bild zeichnen, `source-in`, mit `TONE_COLOR[tone]` füllen. `logo-asset.ts` importiert `LogoAsset` nur als `import type` aus `card/render.ts` (kein Laufzeit-Kreis). `marks.ts` behält nur die AirCard-Marken (`BRAND_MARKS`, `getBrandMark`, `svgAspect`, `tintSvg`, `loadMarkImage`). Neue Texte in `en.ts`.
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün; `grep -rn "visa\|mastercard\|unionpay\|amex\|discover\|jcb\|apple.svg" web/src` findet nur noch Kommentare/Tests ohne Dateibezug oder nichts.
|
||||
- [ ] **Step 5:** Commit `feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer`.
|
||||
|
||||
### Task 6: Logo verarbeiten und hochladen (Ablauf ohne Oberfläche)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/logos/prepare-logo.ts`, `web/src/logos/prepare-logo.test.ts`, `web/src/logos/upload-logo.ts`, `web/src/logos/upload-logo.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `uploadLogo`, `LogoUploadInput`, `CommunityLogo` (Task 4); `isDefinitiveFailure` aus `community/api.ts`; `storage`, `IDS` aus `account/appwrite.ts`; `requireUserId` aus `account/cards.ts`.
|
||||
- Produces:
|
||||
```ts
|
||||
// logos/prepare-logo.ts
|
||||
export const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,.svg';
|
||||
export const LOGO_MAX_W = 720, LOGO_MAX_H = 250, LOGO_MIN = 8, ALPHA_EMPTY = 8, SVG_FALLBACK_SIZE = 1000, SVG_RASTER_MAX = 2048;
|
||||
export const LOGO_SOURCE_MAX_BYTES = 5_242_880, LOGO_SOURCE_MAX_PIXELS = 40_000_000;
|
||||
export interface PreparedLogo { png: Blob; width: number; height: number; hasTransparency: boolean }
|
||||
export function validateLogoFile(f: { type: string; size: number; name: string }): { ok: true } | { ok: false; message: string };
|
||||
export function trimBox(data: Uint8ClampedArray, w: number, h: number, threshold?: number): { x: number; y: number; w: number; h: number } | null;
|
||||
export function fitWithin(w: number, h: number, maxW: number, maxH: number): { w: number; h: number }; // nie > 1, gerundet, mind. 1
|
||||
export function svgRasterSize(aspect: number): { w: number; h: number }; // längste Seite genau 2048; ungültiges Seitenverhältnis → 2048 × 2048
|
||||
export function hasTransparency(data: Uint8ClampedArray): boolean;
|
||||
export function prepareLogo(file: File): Promise<PreparedLogo>; // wirft Error mit übersetztem Text (Spec § 4/§ 11)
|
||||
// logos/upload-logo.ts
|
||||
export interface LogoForm { name: string; displayName: string; mono: boolean }
|
||||
export interface UploadLogoDeps { userId(): string; createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
|
||||
deleteFile(fileId: string): Promise<void>; uploadLogo(input: LogoUploadInput): Promise<{ id: string }> }
|
||||
export function uploadPreparedLogo(prepared: PreparedLogo, form: LogoForm, deps?: UploadLogoDeps): Promise<CommunityLogo>;
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests schreiben:**
|
||||
- `validateLogoFile`: `image/gif` → „Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.“; leerer Typ + Name `logo.SVG` → ok; 5 242 881 Byte → „Die Datei ist größer als 5 MB.“
|
||||
- `trimBox`: 4 × 3-Bild nur mit einem Pixel (2,1) Alpha 9 → `{x:2,y:1,w:1,h:1}`; Alpha ≤ 8 überall → null; volles Bild → ganze Fläche.
|
||||
- `fitWithin(1440, 500, 720, 250)` → `{w:720,h:250}`; `(300, 100, 720, 250)` → unverändert; `(2000, 20, 720, 250)` → `{w:720,h:7}`.
|
||||
- `svgRasterSize(1)` → `{w:2048,h:2048}`; `(2)` → `{w:2048,h:1024}`; `(3)` → `{w:2048,h:683}`; `(0.5)` → `{w:1024,h:2048}`; `(NaN)`/`(0)` → `{w:2048,h:2048}`.
|
||||
- `hasTransparency`: alles 255 → false; ein Alpha 254 → true.
|
||||
- `uploadPreparedLogo`: `createFile` mit Dateiname `logo.png`, Typ `image/png`, Rechten `['read("any")','delete("user:u1")']`; `uploadLogo` bekommt getrimmten Namen/Anzeigenamen, `mono` nur `true`, wenn `hasTransparency`; Erfolg → `CommunityLogo` mit `id`, `fileId = id`, `ownerId u1`, `likeCount 0`, keine Löschung; eindeutige Ablehnung (403) → Datei gelöscht, Fehler weitergeworfen; offener Ausgang (503) → Datei bleibt, Fehler weitergeworfen; Fehler beim Löschen überdeckt nie den eigentlichen Fehler.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/logos/prepare-logo.test.ts src/logos/upload-logo.test.ts` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen. `prepareLogo`: Rasterbilder mit `createImageBitmap(file)` (Maße vor dem Zeichnen gegen `LOGO_SOURCE_MAX_PIXELS` prüfen, sonst „Das Bild ist zu groß (max. 40 Megapixel).“), SVG: Text lesen, Seitenverhältnis mit `svgAspect` aus `card/marks.ts` (viewBox), `width`/`height` aus `svgRasterSize` ins Wurzelelement schreiben (wie `withIntrinsicSize` in `marks.ts`), dann über `Image` aus einer Object-URL laden (nur als Bild); zeichnen, `getImageData`, `trimBox` (null → „Das Logo ist leer.“), ausschneiden, `fitWithin(…, 720, 250)`, unter 8 × 8 → „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“, `toBlob('image/png')`, `hasTransparency` auf den Endpixeln; nicht lesbar → „Das Logo konnte nicht gelesen werden.“ Neue Texte in `en.ts`. (`prepareLogo` selbst prüft Task 12 live; die reinen Helfer tragen die Tests.)
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün.
|
||||
- [ ] **Step 5:** Commit `feat(logos): Logo zuschneiden, verkleinern und hochladen`.
|
||||
|
||||
### Task 7: Dialog „Logo hochladen“
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/logos/logo-dialog.ts`, `web/src/logos/logo-dialog.test.ts`, `web/src/logos/logos.css`, `web/src/account/display-name.ts`, `web/src/account/display-name.test.ts`
|
||||
- Modify: `web/src/community/publish-dialog.ts` (+ Test: Vorbelegung und Speichern des Anzeigenamens)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `prepareLogo`, `PreparedLogo`, `validateLogoFile`, `LOGO_ACCEPT`, `uploadPreparedLogo`, `LogoForm` (Task 6); `CommunityLogo` (Task 4); `apiErrorText`, `ApiError` (Task 4); `errorMessage` aus `account/errors.ts`.
|
||||
- Produces:
|
||||
```ts
|
||||
// account/display-name.ts
|
||||
export const DISPLAY_NAME_KEY = 'aircard.displayName';
|
||||
export function rememberedDisplayName(): string; // '' bei Fehler/leer
|
||||
export function rememberDisplayName(name: string): void; // leer → Schlüssel entfernen
|
||||
// logos/logo-dialog.ts
|
||||
export interface LogoDialogOptions { signal?: AbortSignal;
|
||||
prepare?: (file: File) => Promise<PreparedLogo>; upload?: (p: PreparedLogo, form: LogoForm) => Promise<CommunityLogo> }
|
||||
export function validateLogoForm(v: { hasFile: boolean; name: string; displayName: string; consent: boolean }):
|
||||
{ field: 'file' | 'name' | 'displayName' | 'consent'; message: string } | null;
|
||||
export function logoUploadDialog(opts?: LogoDialogOptions): Promise<CommunityLogo | null>; // null = abgebrochen
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests schreiben:**
|
||||
- `validateLogoForm`: ohne Datei → `file` „Bitte wähle eine Datei.“; Name leer → „Bitte gib einen Namen an.“; 41 Zeichen → „Der Name ist zu lang (höchstens 40 Zeichen).“; Steuerzeichen → „Der Name enthält ungültige Zeichen.“; Anzeigename 31 → „Der Anzeigename ist zu lang (höchstens 30 Zeichen).“; ohne Häkchen → „Bitte bestätige, dass du das Logo teilen darfst.“
|
||||
- Dialog: Datei wählen ruft `prepare`, zeigt zwei Vorschauen (dunkel/hell); Name wird mit dem Dateinamen ohne Endung vorbelegt (gekürzt auf 40); Anzeigename aus `rememberedDisplayName()`; „Einfarbig“ ist deaktiviert, wenn `hasTransparency` false; Absenden mit gültigen Werten ruft `upload` genau einmal (Doppelklick sendet nicht zweimal), speichert den Anzeigenamen und löst mit dem Logo auf; Fehler → „Hochladen fehlgeschlagen – <Text>“ (bei `ApiError` über `apiErrorText`), Dialog bleibt offen; während des Sendens sind Abbrechen/Esc wirkungslos; `signal` schließt immer (→ null).
|
||||
- Veröffentlichen-Dialog: Anzeigename vorbelegt aus `aircard.displayName`, nach Erfolg gespeichert.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/logos/logo-dialog.test.ts src/account/display-name.test.ts src/community/publish-dialog.test.ts` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen nach dem Muster von `publish-dialog.ts` (eigenes `<dialog>`, `showModal`, Fokus zurück an den Auslöser, Texte nur als Text). Vorschau: PNG als Object-URL in `<img>`; bei „Einfarbig“ stattdessen ein Element mit `mask-image`/`-webkit-mask-image` auf dieselbe URL und Hintergrundfarbe Weiß (dunkle Fläche) bzw. `#111111` (helle Fläche). Object-URLs beim Schließen freigeben. Pflicht-Häkchen-Text und alle Texte aus Spec § 4/§ 11; neue Texte in `en.ts`.
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün.
|
||||
- [ ] **Step 5:** Commit `feat(logos): Dialog „Logo hochladen“ und gemerkter Anzeigename`.
|
||||
|
||||
### Task 8: Editor – Bereich „Logo unten rechts“ mit Raster, Sortierung, Auswahl und Herzen
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/logos/logo-picker.ts`, `web/src/logos/logo-picker.test.ts`
|
||||
- Modify: `web/src/logos/logos.css`, `web/src/editor/dom.ts` (Icons `heart`, `plus`), `web/src/editor/editor-page.ts` (+ Test), `web/src/editor/options-panel.ts` (nur falls `logoSlot` angepasst werden muss)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 4–7 (`listLogos`, `getLogo`, `myHearts`, `loadLogoBlob`, `setHeart`, `rememberLogo`, `logoUploadDialog`, `CommunityLogo`, `LogoSort`, `LogoFilter`); `currentUser`, `currentUserId`, `isAdmin`, `onAuthChange` aus `account/auth.ts`; `requireLogin` aus `account/login-dialog.ts`.
|
||||
- Produces:
|
||||
```ts
|
||||
export interface LogoPickerDeps { listLogos; getLogo; myHearts; setHeart; loadLogoBlob; // Typen wie in Task 4
|
||||
userId(): string | null; isAdmin(): boolean; requireLogin(): Promise<boolean>;
|
||||
onAuthChange(fn: () => void): () => void; uploadDialog(): Promise<CommunityLogo | null> }
|
||||
export interface LogoPicker {
|
||||
el: HTMLElement;
|
||||
select(logoId: string | null): void; // markiert die Auswahl, holt ein nicht geladenes Logo per getLogo und heftet es an
|
||||
/** Die Zeile des gewählten Logos, sobald bekannt (für die Infozeile in Task 9); null = kein Logo oder unbekannt. */
|
||||
selected(): CommunityLogo | null;
|
||||
dispose(): void;
|
||||
}
|
||||
export function createLogoPicker(handlers: { onSelect(id: string | null): void }, deps?: LogoPickerDeps): LogoPicker;
|
||||
```
|
||||
- Icons: `heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>'`, `plus: '<path d="M5 12h14"/><path d="M12 5v14"/>'`.
|
||||
|
||||
- [ ] **Step 1: Tests schreiben** (alles mit Fake-Deps):
|
||||
- Aufbau: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“, Satz „Logos werden von der Community hochgeladen.“, Umschalter „Beliebt“ (gedrückt) / „Neu“; erste Kachel „Kein Logo“; je Logo eine Kachel mit Name, „von <Anzeigename>“ bzw. „von Anonym“ und Herz-Knopf „<Zahl>“ (eigener `button`, nicht im Auswahlknopf).
|
||||
- Sortierung: Klick auf „Neu“ lädt mit `sort:'new'` neu; Admin sieht zusätzlich „Gemeldet“/„Ausgeblendet“ (`filter`), Nicht-Admin nicht.
|
||||
- „Mehr laden“ erscheint nur bei `nextCursor` und hängt die nächste Seite an.
|
||||
- Auswahl: Klick auf eine Kachel ruft `onSelect(id)`; „Kein Logo“ → `onSelect(null)`; `select('x')` mit nicht geladenem Logo → `getLogo('x')` und Kachel direkt nach „Kein Logo“; `aria-pressed` folgt der Auswahl.
|
||||
- Herzen: angemeldet → Klick schaltet sofort (`aria-pressed`, Zahl ± 1), ruft `setHeart(id, true)`, übernimmt `likeCount` der Antwort; Fehler → Rücksprung + Text im Statusbereich (`role="alert"`); zweiter Klick während laufender Anfrage → kein zweiter Aufruf; Gast → `requireLogin()`; `false` → kein `setHeart`, Herz aus, kein Fehlertext; `true` → `setHeart(id, true)`; nach dem Laden einer Seite (angemeldet) `myHearts` mit genau den IDs der Seite; Abmelden → alle Herzen aus.
|
||||
- Leer → „Noch keine Community-Logos – lade das erste hoch.“; Ladefehler → „Die Logos konnten nicht geladen werden.“ + „Nochmal versuchen“ (lädt neu).
|
||||
- Hochladen: Gast → zuerst `requireLogin()`; danach `uploadDialog()`; Ergebnis-Logo steht als erste Logo-Kachel, `rememberLogo` und `onSelect(id)` werden gerufen.
|
||||
- Editor: Picker sitzt in `options.logoSlot`; Auswahl setzt `config.logo`; `sync` ruft `picker.select(config.logo)`; Seite verlassen ruft `dispose`.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/logos/logo-picker.test.ts src/editor/editor-page.test.ts` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen. Kachelbild: Object-URL aus `loadLogoBlob`; mehrfarbig `<img>`, einfarbig Element mit `mask-image` in Weiß; Bildfläche immer auf dunklem Kartengrund `#1b2738` (beide Themes, wie bisher `.mark-img` im hellen Theme). Raster 2 Spalten, unter 900 px `repeat(auto-fill, minmax(140px, 1fr))`. Herz gefüllt, wenn gedrückt (`fill: currentColor`). Object-URLs bei `dispose` freigeben. Alle Texte mit `t()`, Einträge in `en.ts`.
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün.
|
||||
- [ ] **Step 5:** Commit `feat(editor): Community-Logos unten rechts mit Herzen und Sortierung`.
|
||||
|
||||
### Task 9: Editor – Infozeile, Melden, Löschen und Admin für Logos
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/logos/logo-picker.ts` (+ Test), `web/src/logos/logos.css`, `web/src/community/report-dialog.ts` (+ Test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `reportLogo`, `deleteLogo`, `setLogoVisibility`, `listLogoReports` (Task 4); `forgetLogo` (Task 5); `confirmDialog` aus `editor/confirm-dialog.ts`; `apiErrorText` (Task 4).
|
||||
- Produces:
|
||||
```ts
|
||||
// community/report-dialog.ts – Erweiterung, Standard bleibt die Karte
|
||||
export interface ReportDialogOptions { send; cardTitle?; signal?; subject?: 'card' | 'logo' }
|
||||
// logo: Gründe ohne „Echte Kartendaten“; Danke-Text bei Duplikat „Danke, du hast dieses Logo schon gemeldet.“; Einleitung nennt „dieses Logo“
|
||||
// LogoPickerDeps zusätzlich: reportLogo, deleteLogo, setLogoVisibility, listLogoReports, confirm(opts): Promise<boolean>, reportDialog(opts): Promise<ReportOutcome>
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests schreiben:**
|
||||
- Infozeile für das gewählte Logo: „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ (auch für Gäste); ohne Auswahl leer; gewähltes Logo nicht auffindbar → „Das gewählte Logo ist nicht mehr verfügbar.“
|
||||
- Melden öffnet den Dialog mit `subject:'logo'` und sendet `reportLogo(id, reason)`; der Logo-Dialog zeigt keine Option „Echte Kartendaten“.
|
||||
- Hochlader (`ownerId === userId()`): „Löschen“ → Rückfrage; bestätigt → `deleteLogo(id)`, `forgetLogo(id)`, Kachel weg, `onSelect(null)`; abgelehnt → nichts; Fehler → Text im Statusbereich.
|
||||
- Admin: „<n> Meldungen“ (`listLogoReports`, Gründe als Text), „Ausblenden“ bzw. bei `hidden` „Einblenden“ → `setLogoVisibility(id, false|true)` und Anzeige aktualisiert, „Löschen“ wie oben; Nicht-Admin und Nicht-Hochlader sehen weder Löschen noch Admin-Knöpfe.
|
||||
- Karten-Meldedialog unverändert (bestehende Tests grün).
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/logos/logo-picker.test.ts src/community/report-dialog.test.ts` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen; Texte mit `t()`, Einträge in `en.ts`.
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün.
|
||||
- [ ] **Step 5:** Commit `feat(logos): Infozeile mit Melden, Löschen und Admin-Aktionen`.
|
||||
|
||||
### Task 10: Übersetzung des Bestands, Teil A – Konto, Editor, Karte, Passwort-Seite
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/account/*.ts` (ohne Tests), `web/src/editor/*.ts` (ohne Tests), `web/src/card/*.ts` (ohne Tests), `web/src/pages/reset-page.ts`, `web/src/i18n/en.ts`; Tests nur, wo ein Test einen jetzt mit `msg()` markierten Export anders erwartet
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `t`, `msg` (Task 1). Exportierte Text-Konstanten bleiben deutsche Strings mit gleichem Namen (jetzt `msg('…')`), damit Vergleiche und Tests weiter funktionieren; übersetzt wird an der Stelle der Anzeige mit `t(KONSTANTE)`.
|
||||
|
||||
- [ ] **Step 1: Test schreiben** in `web/src/i18n/coverage.test.ts`: Für die Dateien dieses Tasks (`account/`, `editor/`, `card/`, `pages/reset-page.ts`) steht kein deutsches Text-Literal mehr außerhalb von `t()`/`msg()` – Heuristik: String-Literale mit Umlaut/ß oder mit mindestens zwei Wörtern aus Kleinbuchstaben, ausgenommen Kommentare, Import-Pfade, CSS-Klassen, `data-`/`aria-`-Attributnamen, Appwrite-Fehlertypen und `console.*`-Aufrufe; eine kleine Ausnahmeliste im Test ist erlaubt (je Eintrag mit Grund).
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/i18n/coverage.test.ts` → FAIL (zeigt die noch offenen Texte).
|
||||
- [ ] **Step 3:** Alle sichtbaren Texte der genannten Dateien durch `t()`/`msg()`; englische Einträge in `en.ts`. Seitentitel/`aria-label`/Platzhalter/`title` eingeschlossen. Fehlermeldungen in `errors.ts` werden beim Zurückgeben übersetzt.
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün.
|
||||
- [ ] **Step 5:** Commit `feat(i18n): Konto, Editor und Karte auf Englisch`.
|
||||
|
||||
### Task 11: Übersetzung des Bestands, Teil B – Community, Startseite, Detailseite, Meine Karten, API-Fehler
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/community/*.ts` (ohne Tests), `web/src/pages/home-page.ts`, `web/src/pages/community-page.ts`, `web/src/pages/cards-page.ts`, `web/src/pages/format.ts` (+ `format.test.ts`), `web/src/i18n/en.ts`, `web/src/i18n/coverage.test.ts` (Heuristik aus Task 10 auf diese Dateien und `shell/`, `logos/` ausweiten)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `t`, `msg`, `currentLang` (Task 1); `apiErrorText` (Task 4).
|
||||
- Produces: `formatDate(iso)` – Deutsch `dd.mm.yyyy` (wie bisher), Englisch `Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' })` („Oct 4, 2026“); `reportsLabel(count)` übersetzt („1 report“, „3 reports“).
|
||||
|
||||
- [ ] **Step 1: Tests schreiben:** `formatDate('2026-10-04T12:00:00Z')` → `04.10.2026` (de) und `Oct 4, 2026` (en); `reportsLabel(1)`/`(3)` in beiden Sprachen; Anzeige eines `ApiError` mit `serverCode:'alreadyPublished'` im Veröffentlichen-Dialog bei Englisch → „Publishing failed – This card is already published.“ (Wortlaut aus `en.ts`); Heuristik-Test aus Task 10 deckt jetzt auch diese Dateien ab.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/pages/format.test.ts src/i18n src/community` → FAIL.
|
||||
- [ ] **Step 3:** Alle sichtbaren Texte durch `t()`/`msg()`; jede Anzeige eines `ApiError`-Textes (`err.message`) durch `apiErrorText(err)` ersetzen (Logik, die `err.message` vergleicht, bleibt beim deutschen Text); englische Einträge in `en.ts`.
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün.
|
||||
- [ ] **Step 5:** Commit `feat(i18n): Community, Startseite und Meine Karten auf Englisch`.
|
||||
|
||||
### Task 12: Betrieb und Abnahme (Controller)
|
||||
|
||||
- [ ] **Schema:** `cd /home/webklar/apps/aircard-ios.org/dev && node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check` (erwartet FEHLT für die neuen Objekte) → ohne `--check` anlegen → `--check` Exit 0.
|
||||
- [ ] **API:** Backup `aircard-ios.org/api/` → die fünf Dateien kopieren → `cd /home/webklar/apps && docker compose restart aircard-api` → `curl -s https://aircard-ios.org/api/gesundheit` = `{"ok":true}`; `curl` auf `/api/logos/melden` mit unbekannter ID → 404 mit `code:"logoGone"`; `/api/logos/herz` ohne JWT → 401 `code:"unauthorized"`.
|
||||
- [ ] **Web:** Push `main` → Webhook-Build → neue Bundles live (Hash im HTML geändert).
|
||||
- [ ] **Live-Abnahme** (Claude in Chrome in Kensos Sitzung; Konten/Passwörter gibt nur Kenso ein): Logo hochladen (PNG transparent + einfarbig, SVG, JPG) → Kachel, Karte, Download-PNG enthält Logo; Gast (zweiter Tab ohne Sitzung bzw. `curl` auf die Tabelle) sieht das Logo; Herz an/aus, Zahl und Sortierung „Beliebt“; Melden aus 3 Netzen (per `docker exec webklar-aircard-api` gegen `127.0.0.1:8088` mit `X-Real-Ip`) → ausgeblendet; Löschen; Sprachwechsel mit offenem Entwurf (bleibt), englische Startsprache bei englischem Browser, `<html lang>`, Titel; GitHub-Link; 1440 × 900 und 390 × 844, Hell/Dunkel; keine Marken-Logos mehr im Editor.
|
||||
- [ ] **Abschluss:** Whole-Branch-Review (ein `sonnet`-Reviewer), eine Fix-Welle nur für Critical/Important, Push, `README.md` (Funktionsliste, Sprachen, Logos), Memory aktualisieren, Bericht an Kenso.
|
||||
@@ -0,0 +1,113 @@
|
||||
# AirCard-IOS Benutzernamen – Implementierungsplan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Eindeutige Benutzernamen pro Konto (Pflicht, änderbar), angezeigt als „von @name“ an Logos und Community-Karten.
|
||||
|
||||
**Architecture:** Neue öffentliche Tabelle `profiles` (Zeile = Konto, Unique-Index auf dem kleingeschriebenen Namen), geschrieben nur über `aircard-api` (`POST /api/profil`, neues Modul `deploy/api/profiles.js`); Hochladen/Veröffentlichen verlangen serverseitig ein Profil. Der Client liest Profile direkt aus Appwrite, fragt den Namen beim Registrieren bzw. nach dem Anmelden ab und zeigt ihn statt des alten Anzeigenamens.
|
||||
|
||||
**Tech Stack:** wie bisher (TypeScript, Vite, Vitest + happy-dom, appwrite 22.4.1; Node 22 ohne Abhängigkeiten; Appwrite 1.8.1 TablesDB).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-05-aircard-benutzernamen-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Arbeitskopie `/home/webklar/apps/aircard-ios.org/dev`, Branch `main`, direkt committen, **nicht pushen** (Controller pusht).
|
||||
- Keine neuen Abhängigkeiten; Versionen unverändert; deutsche Kommentare im Stil des Bestands.
|
||||
- Alle neuen sichtbaren Texte über `t()`/`msg()` mit Eintrag in `web/src/i18n/en.ts` (Schlüssel eindeutig, Vorhandenes wiederverwenden); Tests laufen auf Deutsch; `coverage.test.ts` (Abdeckung, Heuristik, AST-Wache) bleibt grün.
|
||||
- Namensmuster genau `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$`; Schlüssel = `toLowerCase()`; Sperrliste genau wie Spec § 2.
|
||||
- Tabelle `profiles`: Zeilen-ID = Nutzer-ID, Spalten `username` (string 20, Pflicht), `usernameKey` (string 20, Pflicht), Index `username_key` unique auf `usernameKey`, rowSecurity an, keine Tabellenrechte, Zeilenrecht genau `['read("any")']`.
|
||||
- API-Codes und Texte genau wie Spec § 4 (`usernameInvalid`, `usernameReserved`, `usernameTaken`, `usernameRequired`); Client-Tabelle `API_MESSAGES` bleibt im Gleichlauf mit `deploy/api/common.js` `MSG`.
|
||||
- `sessionStorage`-Schlüssel `aircard.usernamePrompted`; jeder Storage-Zugriff in try/catch.
|
||||
- Tests: `cd web && npx vitest run && npx tsc --noEmit && npm run build`; `node --test "deploy/api/*.test.js"`; `node --test web/scripts/appwrite-setup.test.mjs`.
|
||||
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Subagents: `sonnet` (Umsetzung/Review), `haiku` für Kleinstes; nie Subagents aus Subagents; keine Browser-/Headless-Prüfungen (macht der Controller live).
|
||||
|
||||
## Review Focus
|
||||
|
||||
- **Zwei Konten wollen gleichzeitig denselben Namen**: genau eines bekommt ihn, das andere 409 `usernameTaken` (Unique-Index, nicht nur Vorabprüfung). Test in Task 1.
|
||||
- **Registrierung mit vergebenem Namen**: Fehler am Feld, **bevor** das Konto angelegt wird (kein halbes Konto). Test in Task 2.
|
||||
- **Altes Konto ohne Namen lädt hoch / veröffentlicht**: erst der Namensdialog; „Abbrechen“ → nichts passiert; Server würde ohne Profil 403 liefern. Tests in Tasks 1 und 3.
|
||||
- **Umbenennen**: alter Name sofort für andere frei, eigene Kacheln/Infozeile/Detailseite zeigen sofort den neuen. Tests in Tasks 1 und 3.
|
||||
- **Profil nicht lesbar / gelöscht / Netzfehler bei der Anzeige**: Fallback auf alten Anzeigenamen bzw. „Anonym“, keine Fehlermeldung, kein Absturz. Test in Task 3.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Server – Schema `profiles`, `POST /api/profil`, Pflicht beim Hochladen/Veröffentlichen
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/scripts/appwrite-setup.mjs` (+ Test): Tabelle `profiles` nach `logo_reports` mit Spalten und Unique-Index aus den Global Constraints.
|
||||
- Create: `deploy/api/profiles.js`, `deploy/api/profiles.test.js`
|
||||
- Modify: `deploy/api/common.js` (`MSG` + 4 Texte, Muster/Sperrliste/Schlüssel-Helfer), `deploy/api/app.js` (Routen einhängen; `publish` prüft Profil), `deploy/api/logos.js` (`hochladen` prüft Profil), `deploy/api/fake-appwrite.js` (Tabelle `profiles`, Unique-Index → 409 bei doppeltem `usernameKey`), `deploy/api/app.test.js`/`logos.test.js` (Profil in bestehenden Hochladen-/Veröffentlichen-Tests anlegen; neue 403-Tests), `deploy/api/README.md`, `deploy/README.md` (sechs Dateien kopieren)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```js
|
||||
// common.js
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
|
||||
function usernameKey(name) // name.toLowerCase()
|
||||
function isReservedUsername(name) // Schlüssel in der Sperrliste
|
||||
// profiles.js
|
||||
function createProfileRoutes({ appwrite, say, withLock, requireUser }) // → [['POST /api/profil', {name:'profil', json:true, run}]]
|
||||
async function hasProfile(appwrite, userId) // getRow('profiles', userId) !== null — von logos.js und app.js genutzt
|
||||
```
|
||||
|
||||
- [ ] **Step 1: Tests schreiben:** anlegen → 200 `{username}`, Zeile ID = Nutzer-ID, Rechte genau `['read("any")']`, `usernameKey` klein; ändern → alter Schlüssel frei (anderes Konto kann ihn danach nehmen); „kenso“ → „Kenso“ beim selben Konto erlaubt; vergeben → 409 `usernameTaken`; Rennen zweier Konten (Verzögerung im Fake) → genau ein 200, ein 409; gesperrt (`Admin`, `AirCard-IOS`) → 400 `usernameReserved`; Muster (`ab`, `_abc`, 21 Zeichen, `a b`, Umlaut) → 400 `usernameInvalid`; ohne JWT 401; `hochladen` und `veroeffentlichen` ohne Profil → 403 `usernameRequired` (vor allen anderen Prüfungen, nichts angelegt); Setup-Skript: `profiles` samt Unique-Index wird angelegt/geprüft.
|
||||
- [ ] **Step 2:** `node --test "deploy/api/*.test.js"` und `node --test web/scripts/appwrite-setup.test.mjs` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen.
|
||||
- [ ] **Step 4:** beide Suiten grün; `cd web && npx vitest run` grün (Gleichlauftest `API_MESSAGES` erst in Task 2 — falls er hier rot wird, die vier Texte in `web/src/i18n/api-messages.ts` + `en.ts` gleich mit ergänzen).
|
||||
- [ ] **Step 5:** Commit `feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)`.
|
||||
|
||||
### Task 2: Client – Profil-Daten, Dialog „Benutzername wählen“, Registrieren, Konto-Menü
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/account/profile.ts` (+ Test), `web/src/account/username-dialog.ts` (+ Test)
|
||||
- Modify: `web/src/account/appwrite.ts` (`IDS.profiles = 'profiles'`), `web/src/account/login-dialog.ts` (+ Test), `web/src/account/account-menu.ts` (+ Test), `web/src/main.ts` (+ Test: Abfrage einmal pro Sitzung nach Start/Anmeldung), `web/src/i18n/api-messages.ts`, `web/src/i18n/en.ts`, `web/src/account/account.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
```ts
|
||||
// account/profile.ts
|
||||
export const USERNAME_PATTERN: RegExp;
|
||||
export function usernameKey(name: string): string;
|
||||
export function validateUsername(name: string): string | null; // übersetzter Text oder null (Muster, Sperrliste)
|
||||
export function getUsername(userId: string): Promise<string | null>; // Profil lesen, 404 → null
|
||||
export function usernamesFor(userIds: readonly string[]): Promise<Map<string, string>>; // gesammelt, zwischengespeichert, Fehler → leere Map
|
||||
export function isUsernameTaken(name: string): Promise<boolean>; // Abfrage equal('usernameKey', key)
|
||||
export function saveUsername(name: string): Promise<string>; // POST /api/profil, aktualisiert Cache + meldet Änderung
|
||||
export function myUsername(): string | null; // Cache des angemeldeten Kontos (null = keiner/unbekannt)
|
||||
export function loadMyUsername(): Promise<string | null>; // nach Anmeldung/Start
|
||||
export function onUsernameChange(fn: (userId: string, username: string) => void): () => void;
|
||||
// account/username-dialog.ts
|
||||
export function usernameDialog(opts: { mode: 'prompt' | 'required' | 'change'; signal?: AbortSignal }): Promise<string | null>; // null = Später/Abbrechen
|
||||
export function requireUsername(): Promise<boolean>; // hat Namen → true; sonst Dialog 'required'
|
||||
```
|
||||
- [ ] **Step 1: Tests:** `validateUsername` (Grenzen 3/20, erstes Zeichen, erlaubte Zeichen, Sperrliste case-insensitiv); `usernamesFor` sammelt IDs, eine Abfrage pro Aufruf, Cache, Fehler → leere Map; Registrieren: Feld „Benutzername“ Pflicht, Vorabprüfung (Muster, Sperre, `isUsernameTaken`) **vor** `register()`, Fehler am Feld; nach erfolgreichem `register` → `saveUsername`; scheitert das, schließt der Login-Dialog trotzdem erfolgreich und der Namensdialog erscheint; Dialog: Modi prompt („Später“), required („Abbrechen“), change (vorbelegt), Speichern ruft `saveUsername`, 409 → Text am Feld, Busy-Sperre; Konto-Menü: „@name“ + E-Mail klein, Eintrag „Benutzername ändern“ bzw. „Benutzername wählen“; `main.ts`: nach Start mit Sitzung bzw. nach Anmeldung ohne Namen genau einmal pro Sitzung Dialog `prompt`.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/account src/main.test.ts` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen (Dialog nach dem Muster von `logo-dialog.ts`/`publish-dialog.ts`, Texte nur als Text).
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` grün.
|
||||
- [ ] **Step 5:** Commit `feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage`.
|
||||
|
||||
### Task 3: Client – Anzeige „von @name“, Anzeigename-Felder weg, Pflicht vor Hochladen/Veröffentlichen
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/logos/logo-picker.ts`, `web/src/logos/logo-info.ts`, `web/src/pages/community-page.ts`, `web/src/logos/logo-dialog.ts`, `web/src/logos/upload-logo.ts`, `web/src/community/publish-dialog.ts`, `web/src/community/publish.ts`, `web/src/pages/cards-page.ts` (Veröffentlichen-Aufruf), Tests dazu; `web/src/i18n/en.ts`
|
||||
- Delete: `web/src/account/display-name.ts` (+ Test)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2 (`usernamesFor`, `myUsername`, `onUsernameChange`, `requireUsername`, `currentUserId`).
|
||||
- Byline-Regel (eine Hilfsfunktion, z. B. in `profile.ts`): `byline(ownerId, legacyDisplayName, names)` → `t('von @{name}', {name})` wenn Profil, sonst `t('von {name}', {name: legacy})` wenn Altname, sonst `t('von {name}', {name: t('Anonym')})`.
|
||||
|
||||
- [ ] **Step 1: Tests:** Kacheln/Infozeile/Detailseite zeigen „von @name“ (eine `usernamesFor`-Abfrage pro geladener Seite), Fallback Altname bzw. „Anonym“, Fehler beim Nachschlagen → Fallback ohne Fehlertext; eigene Umbenennung (`onUsernameChange`) aktualisiert eigene Einträge sofort; Dialoge „Logo hochladen“ und „Veröffentlichen“ ohne Anzeigename-Feld, senden `displayName: ''`; vor Hochladen bzw. Veröffentlichen `requireUsername()` → false = nichts passiert; Serverantwort 403 `usernameRequired` → Namensdialog `required`, danach erneuter Versuch möglich; `display-name.ts` und `aircard.displayName` nirgends mehr genutzt.
|
||||
- [ ] **Step 2:** `cd web && npx vitest run src/logos src/community src/pages` → FAIL.
|
||||
- [ ] **Step 3:** Umsetzen.
|
||||
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` grün.
|
||||
- [ ] **Step 5:** Commit `feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen`.
|
||||
|
||||
### Task 4: Betrieb und Abnahme (Controller)
|
||||
|
||||
- [ ] Schema: Setup-Skript `--check` → anlegen → `--check` Exit 0 (Unique-Index `username_key`).
|
||||
- [ ] API: Backup, sechs Dateien kopieren, `docker compose restart aircard-api`, Gesundheit + `POST /api/profil` ohne JWT = 401 `unauthorized`.
|
||||
- [ ] Push `main` → Build → neues Bundle live.
|
||||
- [ ] Live als Gast: Kacheln/Detailseite zeigen „von …“ (Altdaten „Anonym“). Kenso: registrieren bzw. Abfrage nach Anmeldung, Name ändern, Name am eigenen Logo sehen.
|
||||
- [ ] Review (ein `sonnet`-Reviewer über alles), Fix-Welle nur für Critical/Important, Memory, Bericht.
|
||||
@@ -0,0 +1,131 @@
|
||||
# AirCard-IOS – Startseite & Community-Galerie – Design
|
||||
|
||||
Stand: 2026-10-04 · Status: zur Freigabe durch Kenso · baut auf `2026-10-04-aircard-website-design.md` auf (live, main 562fac0)
|
||||
|
||||
## 1. Ziel und Rahmen
|
||||
|
||||
Die Website bekommt eine Startseite im Stil von Kensos Vorbild-Screenshot (bestehende „AirCards“-Seite), aber im eigenen AirCard-IOS-Look:
|
||||
kompakter Hero, darunter sofort die **Community-Galerie**, Spenden-Banner und -Knopf, Hell/Dunkel-Schalter.
|
||||
Nutzer können ihre gespeicherten Karten **sofort öffentlich veröffentlichen**; Besucher können melden; ab 3 Meldungen wird eine Karte automatisch ausgeblendet; Kenso moderiert als Admin.
|
||||
|
||||
| Punkt | Entscheidung (Kenso) |
|
||||
|---|---|
|
||||
| Umfang | Startseite + Community-Galerie; keine „Offiziellen Karten“ (echte Bank-Kartenmotive), keine Texte/Bilder vom Vorbild |
|
||||
| Galerie-Inhalt | nur Community-Karten |
|
||||
| Freigabe | sofort öffentlich + „Melden“; Auto-Ausblenden ab 3 verschiedenen Meldern |
|
||||
| Extras | nur Hell/Dunkel-Schalter (kein Englisch, keine Anleitung-Seite, keine Highlights) |
|
||||
| Hero | **nicht** Vollbild: erste Reihe Community-Karten muss im ersten Bildschirm anschneiden |
|
||||
| Spenden | Buy Me a Coffee; öffentlicher Link kommt noch (Konfig-Wert) |
|
||||
| Technik | Ansatz A: Appwrite + kleiner eigener API-Dienst `aircard-api` |
|
||||
|
||||
## 2. Seiten und Routen
|
||||
|
||||
| Route | Inhalt |
|
||||
|---|---|
|
||||
| `/` | Startseite: Hero + Community-Galerie (Suche über `?q=`, Anker `#community`) |
|
||||
| `/gestalten` | der bisherige Editor (`?karte=<id>` öffnet eine gespeicherte Karte, `?vorlage=<galleryId>` übernimmt ein Community-Bild als „Nur Bild“) |
|
||||
| `/karten` | Meine Karten (+ Veröffentlichen / Veröffentlicht-Status / Zurückziehen) |
|
||||
| `/community/<id>` | Detailansicht einer Community-Karte |
|
||||
| `/passwort-neu` | unverändert |
|
||||
|
||||
Alte Links `/?karte=<id>` leiten clientseitig auf `/gestalten?karte=<id>` um. `document.title` je Route („AirCard-IOS – Kartendesigns für Apple Wallet“, „Gestalten – AirCard-IOS“, „Meine Karten – AirCard-IOS“, „<Titel> – AirCard-IOS“).
|
||||
|
||||
## 3. Kopfzeile, Banner, Theme
|
||||
|
||||
- **Spenden-Banner** (ganz oben, schließbar, Zustand in `localStorage` `aircard.donateBannerClosed`): „☕ Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.“ + Link „Unterstützen →“. Ziel = `DONATE_URL` aus `web/src/config.ts`. **Ist `DONATE_URL` leer, werden Banner und Spenden-Knopf nicht angezeigt** (bis Kenso den öffentlichen Link `buymeacoffee.com/<name>` liefert).
|
||||
- **Kopfzeile**: Logo + „AirCard-IOS“ (→ `/`), Navigation „Community“ (→ `/#community`), „Gestalten“, „Meine Karten“; Suchfeld „Titel, Tags oder Herausgeber“ (Enter → `/?q=…#community`); Knopf „☕ Unterstützen“ (wenn `DONATE_URL`); Hell/Dunkel-Schalter (Sonne/Mond-Icon); Konto: abgemeldet „Anmelden“, angemeldet Avatar-Kreis mit Initiale (Menü: E-Mail, „Meine Karten“, „Abmelden“).
|
||||
- Unter 900 px: Navigation in ein Menü-Icon, Suchfeld als eigene Zeile unter der Kopfzeile.
|
||||
- **Theme**: `data-theme="light|dark"` auf `<html>`; Standard = System (`prefers-color-scheme`); Schalter speichert in `localStorage` `aircard.theme`. Helle Tokens: `--bg #F4F6FB; --panel #FFFFFF; --panel-2 #EEF1F7; --line #D9DFEA; --line-strong #B8C2D3; --sel-bg #E3ECFB; --sel-line #3A6FD8; --text #0F1828; --muted #5B6B82; --primary #2A4566; --primary-hover #34557C`. Die Kartenvorschau selbst bleibt unverändert (Kartenfarben hängen nicht vom Theme ab).
|
||||
|
||||
## 4. Startseite
|
||||
|
||||
- **Hero (kompakt)**: links Zeile „Ein bisschen Kunst für dein Wallet“, Überschrift „Kartendesigns für Apple Wallet“, ein Satz „Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.“, Knöpfe **„Karten entdecken ↓“** (scrollt zu `#community`) und **„Gestalten“**. Rechts eine große Karte (neueste sichtbare Community-Karte; ist die Galerie leer: eigenes Beispielmotiv `web/public/hero-beispiel.png`, markenfrei, von Claude gestaltet) mit dezenter Schwebe-Animation (`prefers-reduced-motion` respektieren).
|
||||
- **Höhe**: Hero höchstens 60 vh (Desktop) bzw. so, dass bei 1440 × 900 und 390 × 844 die Überschrift „Community-Karten“ **und** der obere Rand der ersten Kartenreihe im ersten Bildschirm sichtbar sind (Abnahmekriterium).
|
||||
- **Community-Galerie** (`#community`): Überschrift „Community-Karten“, Untertitel „Entdecke Motive der Community – und veröffentliche deine eigenen.“, Knopf „Gestalten & veröffentlichen“. Suchzeile mit aktivem Suchbegriff + „ד. Raster aus Kacheln (Kartenformat 1536:969, abgerundet): Vorschau, Titel, Herausgeber, bis 3 Tag-Chips (Klick = Suche). Sortierung neueste zuerst, 24 pro Seite, „Mehr laden“ (Cursor). Leer: „Noch keine Community-Karten – sei die erste!“ + „Gestalten“. Keine Treffer: „Nichts gefunden für „…““.
|
||||
|
||||
## 5. Detailseite `/community/<id>`
|
||||
|
||||
Große Karte, Titel, Herausgeber, Tags, Datum (`dd.mm.yyyy`), Anzeigename („von <Name>“ bzw. „von Anonym“). Knöpfe: **Herunterladen** (PNG 1536 × 969, Dateiname `aircard-<titel-slug>.png`), **Als Vorlage gestalten** (→ `/gestalten?vorlage=<id>`), **Melden** (Dialog mit Grund: „Unangemessen“, „Echte Kartendaten“, „Urheberrecht/Marke“, „Spam“, „Sonstiges“).
|
||||
Autor (angemeldet, `ownerId` = ich): zusätzlich **Zurückziehen**. Admin (Label `admin`): **Ausblenden/Einblenden**, **Löschen**, Anzeige „<n> Meldungen“ und Liste der Gründe.
|
||||
Nicht vorhanden/ausgeblendet für mich: „Diese Karte ist nicht (mehr) verfügbar.“ + Link zur Galerie.
|
||||
|
||||
## 6. Veröffentlichen (aus „Meine Karten“)
|
||||
|
||||
Kachel-Aktion **„Veröffentlichen“** → Dialog: Titel (Pflicht, ≤ 60), Herausgeber/Bank (optional, ≤ 40), Tags (optional, bis 5, je ≤ 20, Komma-getrennt, kleingeschrieben gespeichert), Anzeigename (optional, ≤ 30, Standard leer → „Anonym“; **nie** die E-Mail), Pflicht-Checkbox „Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.“
|
||||
Ablauf: Karte rendern (PNG 1536 × 969 + WebP-Vorschau 480) → beide Dateien in Bucket `gallery-images` hochladen (Rechte `read("any")`, `delete("user:<ich>")`) → `POST /api/veroeffentlichen` → Zeile entsteht. Scheitert die API, löscht der Client die zwei Dateien wieder.
|
||||
Danach zeigt die Kachel „Veröffentlicht“ + „Ansehen“ + „Zurückziehen“; ist die Karte ausgeblendet: „Ausgeblendet (gemeldet)“. Eine private Karte kann **einmal** veröffentlicht sein; „Erneut veröffentlichen“ = Zurückziehen + Veröffentlichen.
|
||||
|
||||
## 7. Daten (Appwrite, Projekt `6ac2130a00289343d6d5`, Setup-Skript erweitert)
|
||||
|
||||
**Tabelle `gallery`** (rowSecurity an, **keine** Tabellenrechte für Clients):
|
||||
|
||||
| Spalte | Typ | Inhalt |
|
||||
|---|---|---|
|
||||
| `title` | string 60, Pflicht | Titel |
|
||||
| `issuer` | string 40 | Herausgeber/Bank |
|
||||
| `tags` | string 120 | Tags, Komma-getrennt, klein |
|
||||
| `displayName` | string 30 | Anzeigename oder leer |
|
||||
| `imageFileId` | string 36, Pflicht | PNG im Bucket `gallery-images` |
|
||||
| `previewFileId` | string 36, Pflicht | WebP-Vorschau |
|
||||
| `sourceCardId` | string 36 | private Karte, aus der veröffentlicht wurde |
|
||||
| `ownerId` | string 36, Pflicht | Nutzer-ID des Autors |
|
||||
| `hidden` | boolean, Standard false | ausgeblendet |
|
||||
| `reportCount` | integer, Standard 0 | Anzahl verschiedener Melder |
|
||||
| `searchText` | string 300 | `title + issuer + tags`, klein; **Volltext-Index** |
|
||||
|
||||
Indizes: Volltext auf `searchText`; Key-Index auf `ownerId`; Key-Index auf `hidden`.
|
||||
Zeilenrechte (setzt nur die API): sichtbar `["read(\"any\")"]`; ausgeblendet `["read(\"user:<owner>\")","read(\"label:admin\")"]`.
|
||||
|
||||
**Bucket `gallery-images`**: Bucket-Recht `create("users")`, fileSecurity an, max. 5 MiB, Endungen png/webp. Dateirechte beim Hochladen `read("any")`, `delete("user:<owner>")`; beim Ausblenden setzt die API `read("user:<owner>")`, `read("label:admin")`, `delete("user:<owner>")`.
|
||||
|
||||
**Tabelle `reports`** (rowSecurity aus, Tabellenrecht `read("label:admin")`, Schreiben nur API): `galleryId` string 36, `reason` enum (unangemessen, kartendaten, urheberrecht, spam, sonstiges), `reporterHash` string 64 (HMAC-SHA256 der IP mit Server-Salz), Index auf `galleryId`.
|
||||
|
||||
**Lesen im Client**: Galerie `listRows(gallery, [equal('hidden', false), orderDesc('$createdAt'), limit(24), cursorAfter])`, Suche zusätzlich `search('searchText', q)`; eigene Veröffentlichungen `equal('ownerId', me)`; Admin-Filter „Gemeldet/Ausgeblendet“ `greaterThan('reportCount', 0)` bzw. `equal('hidden', true)`.
|
||||
|
||||
## 8. API-Dienst `aircard-api`
|
||||
|
||||
Node 22, ohne Abhängigkeiten, Container `webklar-aircard-api` (`node:22-alpine`, `init: true`, `mem_limit 128m`), Traefik-Router ``Host(`aircard-ios.org`) && PathPrefix(`/api`)`` Priorität 200, Port 8088. Spricht Appwrite intern (`http://webklar-appwrite/v1`, Header `X-Appwrite-Project`, `X-Appwrite-Key` = `AIRCARD_API_KEY`). Secrets aus `.env`: `AIRCARD_API_KEY` (neuer Key `aircard-api`, Scopes `rows.read/write`, `files.read/write`), `AIRCARD_API_SALT` (zufällig, 32 Byte hex). Antworten JSON, Fehlertexte deutsch.
|
||||
|
||||
| Endpunkt | Auth | Wirkung |
|
||||
|---|---|---|
|
||||
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | prüft Nutzer, Feldlängen, dass beide Dateien existieren, im Bucket `gallery-images` liegen und `delete("user:<ich>")` tragen, und dass für `sourceCardId` noch keine Veröffentlichung existiert → legt Zeile an (sichtbar) → `201 {id}` |
|
||||
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor **oder** Admin) | löscht Zeile, beide Dateien und ihre Meldungen |
|
||||
| `POST /api/melden` `{id, reason}` | keine | Rate-Limit 10/Stunde je IP (Speicher), ein Bericht je Melder-Hash und Karte (sonst `200 {schonGemeldet:true}`), speichert Meldung, setzt `reportCount` = Anzahl verschiedener Melder; ab **3** → Ausblenden (Zeilen- und Dateirechte, `hidden=true`) |
|
||||
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | Einblenden (öffentliche Rechte, `hidden=false`, `reportCount=0`, Meldungen löschen) oder Ausblenden |
|
||||
| `GET /api/gesundheit` | keine | `{ok:true}` |
|
||||
|
||||
JWT-Prüfung: `GET /v1/account` mit `X-Appwrite-JWT` → Nutzer (`$id`, `labels`); Admin = `labels` enthält `admin`. Echte Client-IP aus `X-Real-Ip` (Traefik). Körper max. 8 KB. Alles andere 404.
|
||||
**Wichtig**: JWT muss in den Auth-Methoden des Projekts **an** bleiben (die frühere Klickliste sagte „aus“ – wird korrigiert).
|
||||
|
||||
## 9. Admin (Kenso)
|
||||
|
||||
Kenso bekommt in der Appwrite-Console das Label `admin` (Auth → Users → Konto → Labels). Die Website erkennt es über `account.get().labels` und zeigt Admin-Knöpfe und den Galerie-Filter „Gemeldet“/„Ausgeblendet“.
|
||||
|
||||
## 10. Was Kenso selbst erledigt
|
||||
|
||||
1. Label `admin` am eigenen Konto setzen.
|
||||
2. Neuen API-Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) anlegen und mit dem verdeckten `read -s`-Befehl in `.env` eintragen (nicht im Chat).
|
||||
3. Danach den alten, im Chat geposteten Key löschen (Setup-Skript nutzt ab dann `aircard-api` nicht – es bekommt bei Bedarf einen neuen Provisioning-Key).
|
||||
4. Öffentlichen Buy-Me-a-Coffee-Link schicken.
|
||||
|
||||
## 11. Fehlerbehandlung
|
||||
|
||||
Veröffentlichen scheitert (Netz/API/Validierung) → hochgeladene Dateien löschen, Meldung im Dialog („Veröffentlichen fehlgeschlagen – …“). Doppelt veröffentlicht → „Diese Karte ist schon veröffentlicht.“ Melden doppelt → „Danke, du hast diese Karte schon gemeldet.“ Rate-Limit → „Zu viele Meldungen – bitte später nochmal.“ Nicht angemeldet bei Veröffentlichen → Login-Dialog (bestehendes `withLogin`). API nicht erreichbar → „Der Dienst ist gerade nicht erreichbar.“ Galerie-Ladefehler → Hinweis + „Nochmal versuchen“.
|
||||
|
||||
## 12. Tests
|
||||
|
||||
- Vitest: Router (neue Routen, Umleitung `/?karte=`), Theme (System/Speichern), Banner (ausblenden, leeres `DONATE_URL`), Galerie-Abfragen (Queries für Liste/Suche/Admin-Filter), Veröffentlichen-Ablauf mit Fake-API (Aufräumen bei Fehler), Such-Text-Bildung, Tags-Normalisierung.
|
||||
- node:test für `aircard-api`: JWT-Prüfung (Fake-Appwrite), Validierung, Datei-Eigentum, doppelte Veröffentlichung, Melden (Dedup, Rate-Limit, Auto-Ausblenden bei 3, Rechte-Strings), Admin ein/aus, Zurückziehen (Autor/Admin/fremd → 403).
|
||||
- Setup-Skript: `--check` deckt `gallery`, `reports`, `gallery-images` inkl. Indizes ab.
|
||||
- Live: Veröffentlichen, Suche, Detailseite, Herunterladen, Als Vorlage, Melden aus 3 verschiedenen Netzen/Fake-IPs (über die API mit `X-Real-Ip`-Test im Container), Admin aus-/einblenden, Startseite bei 1440 × 900 und 390 × 844 (erste Kartenreihe angeschnitten), Hell/Dunkel.
|
||||
|
||||
## 13. Nicht im Umfang
|
||||
|
||||
Offizielle/Bank-Karten, Likes/„Beliebt“-Sortierung, Kommentare, Englisch, Anleitung-Seite, Highlights, öffentliche Profilseiten, Bearbeiten veröffentlichter Karten (nur zurückziehen + neu), E-Mail-Benachrichtigung an Kenso bei Meldungen.
|
||||
|
||||
## 14. Risiken
|
||||
|
||||
- **Öffentliche Inhalte ohne Vorab-Freigabe**: Kenso haftet ab Kenntnis; Melden + Auto-Ausblenden + Admin-Löschen mildern. Ohne Kontaktadresse/Impressum fehlt ein Weg für Beschwerden von außen (Kenso-Entscheidung, hier vermerkt).
|
||||
- **Marken/Urheberrecht**: Nutzer können Bank-Kartenmotive hochladen; Pflicht-Checkbox + Melden-Grund „Urheberrecht/Marke“.
|
||||
- **Speicher**: keine Kontingente je Nutzer; Bucket 5 MiB je Datei. Missbrauch → Admin löscht; Auth-Limits in der Console (Kensos Schritt 3) empfohlen.
|
||||
- **Rate-Limit im Speicher** geht bei Neustart verloren (Dedup über `reports` bleibt).
|
||||
@@ -0,0 +1,275 @@
|
||||
# AirCard-IOS – Community-Logos, Englisch und GitHub-Link – Design
|
||||
|
||||
Stand: 2026-10-04 · Status: Entwurf im Chat von Kenso freigegeben („ja“), Spec + Plan + Umsetzung ohne weitere Rückfrage · baut auf `2026-10-04-aircard-website-design.md` und `2026-10-04-aircard-community-design.md` auf (live, main 8fb28eb)
|
||||
|
||||
## 1. Ziel und Rahmen
|
||||
|
||||
AirCard-IOS soll in die internationale Community der App [AirCard-iOS](https://github.com/Mak5er/AirCard-iOS), ohne dass Kenso fremde Marken-Logos selbst anbietet. Dafür:
|
||||
|
||||
1. **Keine eingebauten Marken-Logos mehr.** Stattdessen laden Nutzer Logos hoch (Community-Logos), alle können sie benutzen, Herzen zeigen die beliebtesten.
|
||||
2. **Die Seite gibt es auf Deutsch und Englisch.**
|
||||
3. **GitHub-Link zum Original-Repo** der App, daneben der Sprachknopf.
|
||||
|
||||
| Punkt | Entscheidung (Kenso) |
|
||||
|---|---|
|
||||
| Eingebaute Logos | Alle 18 Netzwerk-Logos (Visa ×4, Mastercard ×5 inkl. „Klassisch“, UnionPay ×4, American Express ×3, JCB, Discover) und das Apple-Logo entfallen, auch ihre SVG-Dateien. |
|
||||
| „Klassik kann so bleiben“ | Gemeint ist die **Vorlage „Klassisch“** (Chip, Kontaktlos-Zeichen, Nummer, PLATINUM). Sie bleibt unverändert. |
|
||||
| Logo oben links | Bleibt: AirCard-IOS Symbol, AirCard-IOS Text, Kein Logo (ohne Apple). |
|
||||
| Platz der Community-Logos | **Unten rechts**, wo bisher das Kartennetzwerk saß. |
|
||||
| Hochladen | Nur angemeldet; sofort öffentlich; Melden, Auto-Ausblenden ab 3 Meldern, Admin. |
|
||||
| Herzen | **Nur angemeldet**, eins pro Konto und Logo, wieder entfernbar; Sortierung „Beliebt“ zuerst. |
|
||||
| Sprache | **Automatisch** nach Browsersprache, Umschalter merkt sich die Wahl. |
|
||||
| GitHub | Link `https://github.com/Mak5er/AirCard-iOS` in der Kopfzeile, Sprachknopf direkt daneben. |
|
||||
|
||||
Live-Stand beim Schreiben: 0 veröffentlichte Community-Karten, 1 gespeicherte Testkarte. Es gibt keinen Altbestand mit Marken-Logos, der aufgeräumt werden müsste.
|
||||
|
||||
## 2. Karte und Konfiguration
|
||||
|
||||
- `CardConfig`: Das Feld `network` entfällt. Neu ist `logo: string | null`, die ID eines Community-Logos (Muster `ID_PATTERN`). `brand` ist nur noch `'aircards-symbol' | 'aircards-text' | 'none'`. `v` bleibt `1`.
|
||||
- `parseConfig`: Ein altes `network` wird ignoriert. Ein fehlendes oder ungültiges `logo` ergibt `null`. `brand: 'apple'` und andere Unbekannte ergeben den Standard (`aircards-symbol`). `serializeConfig` schreibt `{v, template, logo, details, brand, crop}`.
|
||||
- `DEFAULT_CONFIG.logo = null`, eine neue Karte hat also kein Logo unten rechts.
|
||||
- In den Vorlagen-Layouts heißt `network` jetzt `logo`: „Nur Bild“ ohne, „Minimalistisch“, „Klassisch“ und „Signature“ mit Logo.
|
||||
- Das Logo sitzt wie bisher das Netzwerk-Logo: rechte Kante 1480, Unterkante 913, höchstens 360 px breit und 125 px hoch (Seitenverhältnis des Bildes bleibt).
|
||||
- **Einfarbige Logos** (`mono = true`) werden beim Zeichnen in der Details-Farbe gefüllt (Hell `#FFFFFF`, Dunkel `#111111`). Die Form kommt aus dem Alphakanal des PNG. Mehrfarbige Logos erscheinen unverändert.
|
||||
- **Gelöscht** werden 19 SVGs in `web/src/assets/marks/` (alle außer `aircards-symbol.svg` und `aircards-text.svg`) sowie alles zu Netzwerk-Marken in `marks.ts` und `config.ts`.
|
||||
- **Fehlt ein Logo** (gelöscht oder ausgeblendet), zeichnet der Editor die Karte ohne Logo und zeigt den Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“. Die Auswahl bleibt, bis der Nutzer ein anderes Logo wählt. Beim Veröffentlichen aus „Meine Karten“ führt ein fehlendes Logo zum Abbruch mit „Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.“.
|
||||
|
||||
## 3. Editor: Bereich „Logo unten rechts“
|
||||
|
||||
Die rechte Spalte „Individuell gestalten“ zeigt von oben nach unten: **Logo unten rechts**, Details (Hell/Dunkel), **Logo oben links** (bisher „Logo“).
|
||||
|
||||
Der Bereich „Logo unten rechts“ besteht aus:
|
||||
|
||||
- **Kopf:** Überschrift „Logo unten rechts“, Plakette „Community“ und der Knopf „+ Hochladen“. Darunter ein kleiner Satz: „Logos werden von der Community hochgeladen.“
|
||||
- **Sortierung:** Umschalter „Beliebt“ (Standard: meiste Herzen, bei Gleichstand neueste zuerst) und „Neu“ (neueste zuerst). Admins sehen zusätzlich „Gemeldet“ und „Ausgeblendet“.
|
||||
- **Raster** (Desktop 2 Spalten):
|
||||
- Erste Kachel „Kein Logo“.
|
||||
- Jede Logo-Kachel zeigt das Logo so, wie es auf der Karte erscheint. Einfarbige Logos stehen wie die bisherigen Kacheln in Weiß auf dunklem Grund.
|
||||
- Darunter folgen der Name (eine Zeile, gekürzt) und „von <Anzeigename>“ bzw. „von Anonym“ (klein, gedämpft).
|
||||
- Der Herz-Knopf „♥ <Zahl>“ ist ein eigener Knopf, nicht in den Auswahlknopf verschachtelt. `aria-pressed` zeigt, ob ich das Logo mit einem Herz markiert habe.
|
||||
- Ein Klick auf die Kachel setzt `config.logo`. Karte und Vorlagen-Miniaturen zeigen das Logo sofort.
|
||||
- Ist das gewählte Logo nicht in der geladenen Liste (z. B. nach dem Öffnen einer gespeicherten Karte), steht es als zweite Kachel direkt nach „Kein Logo“.
|
||||
- 24 Logos je Seite, darunter „Mehr laden“ (Cursor).
|
||||
- **Infozeile zum gewählten Logo** unter dem Raster: „<Name> · von <Anzeigename> · ♥ <Zahl>“ und der Knopf „Melden“.
|
||||
- Wer das Logo hochgeladen hat, sieht zusätzlich „Löschen“ (mit Rückfrage).
|
||||
- Admins sehen „<n> Meldungen“ mit den Gründen sowie „Ausblenden“/„Einblenden“ und „Löschen“.
|
||||
- **Leer:** „Noch keine Community-Logos – lade das erste hoch.“
|
||||
- **Ladefehler:** „Die Logos konnten nicht geladen werden.“ mit „Nochmal versuchen“.
|
||||
- **Herz ohne Anmeldung:** Ein Klick öffnet den Login-Dialog, danach wird das Herz gesetzt. Das Herz schaltet sofort um (optimistisch). Scheitert die Anfrage, springt es zurück, und die Infozeile zeigt die Fehlermeldung.
|
||||
- **Handy (unter 900 px):** Das Raster nutzt die volle Breite (`auto-fill`, mindestens 140 px je Kachel).
|
||||
|
||||
## 4. Logo hochladen
|
||||
|
||||
Der Dialog „Logo hochladen“ (eigenes `<dialog>` wie die bisherigen Dialoge, alle Texte als Text, nie als HTML) enthält:
|
||||
|
||||
- **Datei:** PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.
|
||||
- **Vorschau:** das fertig verarbeitete Logo, einmal auf dunklem und einmal auf hellem Grund. Bei „Einfarbig“ ist es in Weiß bzw. Schwarz gefüllt.
|
||||
- **Name:** Pflicht, höchstens 40 Zeichen.
|
||||
- **Anzeigename:** optional, höchstens 30 Zeichen. Leer bedeutet „Anonym“, die E-Mail erscheint nie. Vorbelegt mit dem zuletzt benutzten Namen aus `localStorage` (`aircard.displayName`; Zugriffe in try/catch). Der Veröffentlichen-Dialog liest und schreibt denselben Schlüssel.
|
||||
- **Häkchen „Einfarbig – passt sich Hell/Dunkel an“:** nur wählbar, wenn das Logo transparente Bereiche hat.
|
||||
- **Pflicht-Häkchen:** „Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.“
|
||||
- **Knöpfe:** „Hochladen“ und „Abbrechen“.
|
||||
|
||||
**Verarbeitung im Browser** (der Server prüft trotzdem):
|
||||
|
||||
1. Datei dekodieren. SVG wird als Bild geladen, also ohne Skripte. Hat ein SVG keine eigene Größe, gilt 1000 × 1000.
|
||||
2. Transparente Ränder abschneiden (Alpha ≤ 8 gilt als leer).
|
||||
3. Auf höchstens 720 × 250 px verkleinern, nie vergrößern. SVG wird direkt in der Zielgröße gerastert.
|
||||
4. Als PNG ausgeben.
|
||||
5. Abbruch mit „Das Logo ist leer.“, wenn nichts Sichtbares bleibt, und mit „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“ unter 8 × 8 px.
|
||||
|
||||
**Ablauf:**
|
||||
|
||||
1. Das PNG landet im Bucket `logo-images` mit den Rechten `read("any")` und `delete("user:<ich>")`.
|
||||
2. `POST /api/logos/hochladen` wird aufgerufen.
|
||||
3. Scheitert der Aufruf eindeutig, löscht der Client die Datei wieder (gleiche Regel wie beim Veröffentlichen). Bleibt der Ausgang offen, bleibt die Datei liegen.
|
||||
4. Danach ist das Logo für alle sichtbar, wird gewählt (`config.logo`) und steht als erste Logo-Kachel.
|
||||
|
||||
Pro Konto sind höchstens **30 Logos** erlaubt, ausgeblendete zählen mit. Darüber hinaus erscheint: „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.“
|
||||
|
||||
## 5. Herzen
|
||||
|
||||
- Die Tabelle `logo_likes` hat eine Zeile je Konto und Logo (feste Zeilen-ID aus Logo und Nutzer). Der Zähler `likeCount` in `logos` wird nur von der API gesetzt, und zwar immer als Anzahl der Zeilen (neu gezählt, nicht hoch- oder runtergezählt).
|
||||
- Der Client lädt den Herz-Zustand für die angezeigten Logos mit `equal('userId', me)` und `equal('logoId', [ids])`. Beim An- oder Abmelden wird er neu geladen bzw. geleert.
|
||||
- Für ausgeblendete oder gelöschte Logos gibt es kein Herz (404 „Dieses Logo ist nicht (mehr) verfügbar.“).
|
||||
|
||||
## 6. Melden, Löschen, Admin
|
||||
|
||||
- **Melden** funktioniert wie bei Karten (gleicher Dialog, Text „dieses Logo“).
|
||||
- Gründe für Logos: „Unangemessen“, „Urheberrecht/Marke“, „Spam“, „Sonstiges“.
|
||||
- Ein Melder ist ein IP-Netz (IPv4 /24, IPv6 /48), gespeichert nur als Hash.
|
||||
- Rate-Limit: 10 Meldungen pro Stunde je Anschluss, gemeinsam mit den Karten-Meldungen.
|
||||
- Ab **3** verschiedenen Meldern wird das Logo ausgeblendet. Zeile und Datei sind dann nur noch für den Hochlader und Admins lesbar.
|
||||
- **Löschen** (Hochlader oder Admin) blendet das Logo zuerst aus (Datei und Zeile), damit nach einem Abbruch keine öffentliche Zeile mit gelöschter Datei zurückbleibt, und entfernt dann Datei, Herzen, Meldungen und zuletzt die Zeile. Gespeicherte Karten zeigen das Logo danach nicht mehr (Hinweis siehe § 2). Bereits veröffentlichte Kartenbilder bleiben unverändert, weil das Logo im Bild steckt.
|
||||
- **Admin** (Label `admin`):
|
||||
- Filter „Gemeldet“ (`reportCount > 0`) und „Ausgeblendet“.
|
||||
- Ein- und Ausblenden. Einblenden löscht die Meldungen und setzt `reportCount` auf 0.
|
||||
- Löschen.
|
||||
|
||||
## 7. Sprache und Kopfzeile
|
||||
|
||||
**Kopfzeile, rechte Seite:** GitHub · Sprache · ☕ Unterstützen · Hell/Dunkel · Menü.
|
||||
|
||||
- **GitHub:** GitHub-Zeichen und der Text „GitHub“.
|
||||
- Ziel ist `https://github.com/Mak5er/AirCard-iOS` mit `target="_blank" rel="noopener noreferrer"`. Der Title lautet „AirCard-iOS auf GitHub“ bzw. „AirCard-iOS on GitHub“.
|
||||
- Zwischen 600 und 1179 px zeigt der Link nur das Zeichen (wie der Spenden-Knopf).
|
||||
- Unter 600 px steht der Link im aufgeklappten Menü statt in der Kopfzeile.
|
||||
- **Sprachknopf:** Globus-Zeichen plus das Kürzel der Zielsprache, also „EN“ auf der deutschen und „DE“ auf der englischen Seite.
|
||||
- `aria-label` und `title` stehen in der Zielsprache („Switch to English“ bzw. „Auf Deutsch umschalten“), dazu ein passendes `lang` am Knopf.
|
||||
- Der Knopf bleibt bei jeder Breite in der Kopfzeile.
|
||||
|
||||
**Startsprache:** zuerst `localStorage` `aircard.lang` (`de` oder `en`). Sonst Deutsch, wenn die erste bevorzugte Browsersprache (`navigator.languages[0]`, ersatzweise `navigator.language`) mit `de` beginnt, andernfalls Englisch. `<html lang>` folgt der Sprache und wird wie das Theme vor dem ersten Paint per Inline-Skript gesetzt.
|
||||
|
||||
**Umschalten** geht ohne Neuladen:
|
||||
|
||||
- Kopfzeile, Spenden-Banner, Konto-Menü, Fußzeile und die aktuelle Seite werden neu aufgebaut. Der Editor-Entwurf bleibt erhalten, weil er im Store liegt.
|
||||
- `document.title` wird neu gesetzt.
|
||||
- Der Appwrite-Client bekommt die Sprache (`client.setLocale`), damit Passwort-Mails in dieser Sprache kommen.
|
||||
|
||||
**Übersetzt** wird alles Sichtbare: Texte, `aria-label`, Platzhalter, `title`, Seitentitel, Fehlermeldungen (Client, Appwrite, API) und Datumsangaben (Deutsch `04.10.2026`, Englisch `Oct 4, 2026`).
|
||||
|
||||
**Nicht übersetzt** werden:
|
||||
|
||||
- Nutzerinhalte: Titel, Herausgeber, Tags, Logo-Namen, Anzeigenamen.
|
||||
- Routen: Sie bleiben `/gestalten`, `/karten`, `/community/<id>`, `/passwort-neu`.
|
||||
- Download-Dateinamen, README und Dokumentation.
|
||||
- Die Werte der Melde-Gründe in der API.
|
||||
|
||||
**Technik (gettext-Stil):**
|
||||
|
||||
- `t('Deutscher Text', { name })` übersetzt beim Aufruf. Platzhalter stehen als `{name}` im Text.
|
||||
- `msg('…')` markiert Texte, die vorab in Konstanten stehen. Sie gehen erst beim Anzeigen durch `t()`.
|
||||
- Das Wörterbuch `web/src/i18n/en.ts` ordnet Deutsch → Englisch zu. Fehlt ein Eintrag, bleibt der deutsche Text.
|
||||
- Ein Test sammelt alle Literale aus `t()`/`msg()` in `web/src/**` (ohne Tests). Er prüft, dass jedes eine englische Übersetzung hat, dass `en.ts` keine ungenutzten Einträge enthält und dass kein `t()` mit `${…}` aufgerufen wird.
|
||||
- In Tests ist die Sprache Deutsch (Vitest-Setup).
|
||||
- Englisch: kurz, freundlich, US-Schreibweise. „AirCard-IOS“ bleibt der Name der Seite, „AirCard-iOS“ der Name der App.
|
||||
|
||||
**Fehler der API:** Antworten tragen zusätzlich `code`, den Schlüssel aus `MSG`, z. B. `{"error":"…","code":"alreadyPublished"}`. Der Client übersetzt über den Code. Ohne bekannten Code zeigt er auf Deutsch den Servertext und auf Englisch eine allgemeine Meldung je Status (400 „Invalid request.“, 403 „You are not allowed to do that.“, 404 „Not found.“, sonst „Something went wrong. Please try again.“).
|
||||
|
||||
**Statisches `index.html`:** `<title>AirCard-IOS – Card designs for Apple Wallet</title>` und `<meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG.">`, damit Link-Vorschauen englisch sind. Der Router setzt danach den Titel in der gewählten Sprache.
|
||||
|
||||
**Fußzeile:**
|
||||
|
||||
- Deutsch: „Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.“
|
||||
- Englisch: „Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.“
|
||||
|
||||
## 8. Daten (Appwrite, Projekt `6ac2130a00289343d6d5`, Datenbank `aircard`, Setup-Skript erweitert)
|
||||
|
||||
**Tabelle `logos`** (rowSecurity an, **keine** Tabellenrechte für Clients):
|
||||
|
||||
| Spalte | Typ | Inhalt |
|
||||
|---|---|---|
|
||||
| `name` | string 40, Pflicht | Name des Logos |
|
||||
| `displayName` | string 30 | Anzeigename oder leer |
|
||||
| `fileId` | string 36, Pflicht | PNG im Bucket `logo-images` |
|
||||
| `ownerId` | string 36, Pflicht | Nutzer-ID des Hochladers |
|
||||
| `mono` | boolean, Standard false | einfarbig |
|
||||
| `likeCount` | integer, Standard 0, min 0 | Anzahl Herzen |
|
||||
| `hidden` | boolean, Standard false | ausgeblendet |
|
||||
| `reportCount` | integer, Standard 0, min 0 | Anzahl verschiedener Melder |
|
||||
|
||||
Indizes: `owner` (key, `ownerId`), `hidden` (key, `hidden`), `likes` (key, `likeCount`).
|
||||
|
||||
Zeilen-ID = `hash32("<ownerId>|<fileId>")` (seit 2026-10-05; zuerst war sie die Datei-ID, die Altlogos behalten sie). Dieselbe Datei desselben Kontos ergibt dieselbe ID, ein zweiter Versuch also 409. Die ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten und lässt sich so nicht mit einer Datei gleicher ID neu belegen. Dass eine Datei nie zwei Logos trägt, sichert der Besitz: nach dem Anlegen hat sie kein `delete(owner)` mehr.
|
||||
|
||||
Zeilenrechte (setzt nur die API): sichtbar `['read("any")']`, ausgeblendet `['read("user:<owner>")','read("label:admin")']`.
|
||||
|
||||
**Tabelle `logo_likes`** (rowSecurity an, keine Tabellenrechte):
|
||||
|
||||
- Spalten: `logoId` string 36 Pflicht, `userId` string 36 Pflicht.
|
||||
- Zeilen-ID = `hash32("<logoId>|<userId>")`.
|
||||
- Zeilenrecht `['read("user:<userId>")']`.
|
||||
- Indizes: `logo` (key, `logoId`), `user_logo` (key, `userId`, `logoId`).
|
||||
|
||||
**Tabelle `logo_reports`** (rowSecurity aus, Tabellenrecht `read("label:admin")`, Schreiben nur API):
|
||||
|
||||
- Spalten: `logoId` string 36 Pflicht, `reason` enum (unangemessen, kartendaten, urheberrecht, spam, sonstiges) Pflicht, `reporterHash` string 64 Pflicht.
|
||||
- Zeilen-ID = `hash32("logo|<logoId>|<reporterHash>")`.
|
||||
- Index `logo` (key, `logoId`).
|
||||
|
||||
**Bucket `logo-images`:**
|
||||
|
||||
- Bucket-Recht `create("users")`, fileSecurity an, höchstens 1 MiB, Endung nur `png`, ohne Kompression und ohne Virenscan.
|
||||
- Dateirechte beim Hochladen: `read("any")`, `delete("user:<owner>")`.
|
||||
- Nach dem Anlegen setzt die API genau `read("any")`. Der Hochlader verliert damit `delete`, sonst könnte er das Bild unter derselben ID tauschen.
|
||||
- Ausgeblendet: `read("user:<owner>")`, `read("label:admin")`.
|
||||
|
||||
Bestehende Tabellen und Buckets bleiben unverändert.
|
||||
|
||||
## 9. API-Dienst `aircard-api` (Erweiterung)
|
||||
|
||||
Die neuen Endpunkte stehen in einem eigenen Modul `deploy/api/logos.js`. Reine gemeinsame Helfer (Meldetexte, Prüfungen, Rechte, Melder-Hash) wandern nach `deploy/api/common.js`. `app.js` reicht dem Logo-Modul seine laufenden Helfer (Anmeldung, Sperre je Schlüssel, Rate-Limit, Log) und hängt dessen Routen an. Der Betrieb kopiert künftig fünf Dateien (`appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`). `appwrite.js` bekommt `countRows(table, queries)`, das `total` einer Abfrage mit `limit(1)` liefert.
|
||||
|
||||
| Endpunkt | Auth | Wirkung |
|
||||
|---|---|---|
|
||||
| `POST /api/logos/hochladen` `{name, displayName?, mono, fileId}` | Nutzer-JWT | Prüft Felder; die Datei existiert, liegt in `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist PNG, höchstens 1 MiB, Maße zwischen 8 × 8 und 720 × 250 (aus dem PNG-Kopf); höchstens 30 Logos je Konto. Legt die Zeile an (sichtbar), setzt die Datei auf `read("any")` und nimmt die Zeile zurück, wenn das scheitert. → `201 {id}`; 409 bei schon verwendeter Datei; 429 bei Limit. |
|
||||
| `POST /api/logos/herz` `{id, herz}` | Nutzer-JWT | 404 bei fehlendem oder ausgeblendetem Logo. `herz: true` legt die Like-Zeile an (409 = schon da, kein Fehler), `false` löscht sie (404 = schon weg, kein Fehler). Danach `likeCount = countRows`. → `200 {herz, likeCount}` |
|
||||
| `POST /api/logos/melden` `{id, reason}` | keine | Wie `/api/melden`: Rate-Limit (gemeinsam), Melder-Hash, Duplikat → `200 {schonGemeldet:true}`, `reportCount` = Zeilen, ab 3 ausblenden. 404 bei fehlendem oder ausgeblendetem Logo. |
|
||||
| `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader **oder** Admin) | Blendet das Logo zuerst aus, löscht dann Datei, Herzen, Meldungen und zuletzt die Zeile. 403 bei fremdem Logo, 404 bei fehlendem. → `200 {ok:true}` |
|
||||
| `POST /api/admin/logo-sichtbarkeit` `{id, sichtbar}` | Admin-JWT | Wie `/api/admin/sichtbarkeit` für Logos (Datei- und Zeilenrechte, `hidden`; Einblenden löscht Meldungen, `reportCount = 0`). |
|
||||
|
||||
Alle Fehlerantworten (auch die bestehenden) haben die Form `{error: <deutscher Text>, code: <MSG-Schlüssel>}`. Dynamische Prüftexte ohne festen Schlüssel bekommen `code: 'badRequest'`.
|
||||
|
||||
Body höchstens 8 KB, nur `application/json`, sonst unverändert wie in der Community-Spec § 8.
|
||||
|
||||
## 10. Was Kenso tun muss
|
||||
|
||||
Nichts Pflicht. Der vorhandene Provisioning-Key (`AIRCARD_APPWRITE_API_KEY`) legt das Schema an. Der API-Key `aircard-api` hat die nötigen Scopes (Zeilen und Dateien). Fürs Testen der Admin-Knöpfe braucht Kensos Konto weiterhin das Label `admin` (steht noch aus der Community-Runde offen).
|
||||
|
||||
## 11. Fehlerbehandlung
|
||||
|
||||
| Fall | Meldung |
|
||||
|---|---|
|
||||
| Datei falscher Typ / zu groß / zu viele Pixel | „Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.“ / „Die Datei ist größer als 5 MB.“ / „Das Bild ist zu groß (max. 40 Megapixel).“ |
|
||||
| Datei nicht lesbar | „Das Logo konnte nicht gelesen werden.“ |
|
||||
| Logo leer / zu klein | siehe § 4 |
|
||||
| Name fehlt / zu lang | „Bitte gib einen Namen an.“ / „Der Name ist zu lang (höchstens 40 Zeichen).“ |
|
||||
| Häkchen fehlt | „Bitte bestätige, dass du das Logo teilen darfst.“ |
|
||||
| Limit 30 | siehe § 4 |
|
||||
| Hochladen gescheitert | „Hochladen fehlgeschlagen – <Grund>“, Datei wird bei eindeutigem Fehler gelöscht |
|
||||
| Herz/Melden für weg/ausgeblendet | „Dieses Logo ist nicht (mehr) verfügbar.“ |
|
||||
| Melden doppelt / Rate-Limit | „Danke, du hast dieses Logo schon gemeldet.“ / „Zu viele Meldungen – bitte später nochmal.“ |
|
||||
| API nicht erreichbar | „Der Dienst ist gerade nicht erreichbar.“ |
|
||||
| Nicht angemeldet (Hochladen, Herz) | Login-Dialog (`withLogin`/`requireLogin`), Abbruch → nichts passiert |
|
||||
|
||||
Alle Texte gibt es auf Deutsch und Englisch.
|
||||
|
||||
## 12. Tests
|
||||
|
||||
- **Vitest:**
|
||||
- i18n: Startsprache, Umschalten, Platzhalter, Abdeckung aller `t()`/`msg()`-Texte, Datum.
|
||||
- Konfiguration: alte Konfiguration mit `network`/`apple`, `logo`.
|
||||
- Renderer: Logo-Box, einfarbig, ohne Logo.
|
||||
- Logo-Verarbeitung: Rand-Zuschnitt, Skalierung, leer, zu klein, Transparenz-Erkennung.
|
||||
- Logo-Abfragen: Beliebt/Neu/Admin-Filter, Cursor, eigene Herzen.
|
||||
- Hochladen-Ablauf mit Fakes: Aufräumen nur bei eindeutigem Fehler.
|
||||
- Logo-Bereich: Kacheln, Auswahl, Herz optimistisch und Rücksprung, Login-Pflicht, angeheftete Auswahl, Infozeile je Rolle.
|
||||
- Kopfzeile: GitHub-Link, Sprachknopf.
|
||||
- Bestehende Tests bleiben grün (Sprache im Test = Deutsch).
|
||||
- **node:test (`aircard-api`):**
|
||||
- Hochladen: fremde Datei 403, falsches Format/Maße 400, Limit 429, doppelt 409, Rechte nachher genau `read("any")`, Rücknahme bei Fehler.
|
||||
- Herz: an/aus idempotent, Zähler = Zeilen, ausgeblendet 404.
|
||||
- Melden: Dedup, gemeinsames Rate-Limit, ab 3 ausgeblendet.
|
||||
- Löschen: Hochlader/Admin/fremd.
|
||||
- Admin-Sichtbarkeit.
|
||||
- `code` in allen Fehlerantworten.
|
||||
- **Setup-Skript:** `--check` deckt `logos`, `logo_likes`, `logo_reports` und `logo-images` inkl. Indizes ab.
|
||||
- **Live (aircard-ios.org):**
|
||||
- Hochladen (PNG transparent, einfarbig; SVG; JPG ohne Transparenz). Das Logo ist für Gäste sichtbar und sitzt auf der Karte; Download-PNG enthält es.
|
||||
- Herz an/aus (Zahl, Sortierung).
|
||||
- Melden über 3 Netze → ausgeblendet; Gast sieht es nicht mehr.
|
||||
- Löschen.
|
||||
- Sprachwechsel mit offenem Editor-Entwurf (bleibt erhalten).
|
||||
- Englisch nach Browsersprache; GitHub-Link.
|
||||
- Breiten 1440 und 390 px, Hell/Dunkel.
|
||||
|
||||
## 13. Nicht im Umfang
|
||||
|
||||
Logo-Suche, Community-Logos oben links, Herzen für Community-Karten, Zähler „wie oft benutzt“, Logos bearbeiten (nur löschen und neu hochladen), englische Routen, weitere Sprachen, Mail an Kenso bei Meldungen, SVG-Speicherung (gespeichert wird immer PNG).
|
||||
|
||||
## 14. Risiken
|
||||
|
||||
- **Marken über Nutzer:** Nutzer können Visa & Co. selbst hochladen. Dagegen stehen das Pflicht-Häkchen, der Melde-Grund „Urheberrecht/Marke“, das Auto-Ausblenden und der Admin. Kenso haftet ab Kenntnis, sollte also gemeldete Logos regelmäßig ansehen. Ohne Kontaktadresse/Impressum fehlt ein Beschwerdeweg außerhalb von „Melden“ (Kenso-Entscheidung aus der Website-Runde, bekannt).
|
||||
- **Herzen:** Konten sind kostenlos, viele Konten ergeben viele Herzen (akzeptiert). Auth-Limits in der Console bleiben Kensos offener Schritt.
|
||||
- **Zählung:** `countRows` nutzt `total` von Appwrite (zählt bis 5000). Das reicht.
|
||||
- **Übersetzungslücken:** Der Abdeckungstest fängt fehlende Einträge. Server-Prüftexte ohne Code erscheinen auf Englisch als allgemeine Meldung.
|
||||
@@ -0,0 +1,109 @@
|
||||
# AirCard-IOS – Benutzernamen – Design
|
||||
|
||||
Stand: 2026-10-05 · Status: Entwurf im Chat von Kenso freigegeben („ja passt, mach weiter“), Spec + Plan + Umsetzung ohne weitere Rückfrage · baut auf `2026-10-04-aircard-logos-sprache-design.md` auf (live, main 13b46da)
|
||||
|
||||
## 1. Ziel und Rahmen
|
||||
|
||||
Jedes Konto bekommt einen eindeutigen Benutzernamen. Logos und Community-Karten zeigen „von @name“ statt „Anonym“, damit man Leute wiedererkennt.
|
||||
|
||||
| Punkt | Entscheidung (Kenso) |
|
||||
|---|---|
|
||||
| Eindeutig | Ja, jeder Name nur einmal; Groß-/Kleinschreibung zählt beim Vergleich nicht. |
|
||||
| Pflicht | Ja, auch für bestehende Konten. Registrieren nur mit Name; alte Konten werden nach dem Anmelden einmal gefragt; Hochladen und Veröffentlichen gehen erst mit Name. |
|
||||
| Anzeige | Immer der Benutzername; das Feld „Anzeigename“ fällt weg; gilt auch für vorhandene Einträge des Kontos. |
|
||||
| Ändern | Ja, im Konto-Menü; danach überall der neue Name, der alte wird frei. |
|
||||
|
||||
## 2. Regeln für den Namen
|
||||
|
||||
- Muster: `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$`. Das sind 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`.
|
||||
- Schlüssel für die Eindeutigkeit: der Name in Kleinbuchstaben (`usernameKey`). Gespeichert und angezeigt wird der Name, wie er eingegeben wurde.
|
||||
- Gesperrte Schlüssel: `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`.
|
||||
- Anzeige mit `@` davor: „von @name“ bzw. „by @name“.
|
||||
|
||||
## 3. Daten (Appwrite, Datenbank `aircard`, Setup-Skript erweitert)
|
||||
|
||||
**Tabelle `profiles`** (rowSecurity an, keine Tabellenrechte für Clients):
|
||||
|
||||
| Spalte | Typ | Inhalt |
|
||||
|---|---|---|
|
||||
| `username` | string 20, Pflicht | Name wie eingegeben |
|
||||
| `usernameKey` | string 20, Pflicht | Name klein geschrieben |
|
||||
|
||||
- Zeilen-ID = Nutzer-ID (eine Zeile pro Konto).
|
||||
- Index `username_key`: **unique** auf `usernameKey`. Damit kann auch bei Gleichzeitigkeit kein Name doppelt vergeben werden.
|
||||
- Zeilenrecht genau `['read("any")']`, öffentlich lesbar. Schreiben nur über die API.
|
||||
|
||||
## 4. API-Dienst `aircard-api`
|
||||
|
||||
Eigenes Modul `deploy/api/profiles.js`, eingebunden wie `logos.js`. Der Betrieb kopiert künftig sechs Dateien: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`.
|
||||
|
||||
`POST /api/profil` `{username}`, Nutzer-JWT:
|
||||
|
||||
- 401 ohne Anmeldung.
|
||||
- 400 `usernameInvalid` bei falschem Muster.
|
||||
- 400 `usernameReserved` bei gesperrtem Schlüssel.
|
||||
- 409 `usernameTaken`, wenn ein anderes Konto den Schlüssel hat. Das wird vorab per Abfrage geprüft; bei einem Rennen meldet Appwrite 409 auf dem Unique-Index, das wird ebenfalls zu `usernameTaken`.
|
||||
- Sonst: Hat das Konto schon eine Zeile, wird sie aktualisiert (der alte Name ist damit frei); sonst wird sie mit ID = Nutzer-ID und `read("any")` angelegt. Antwort `200 {username}`.
|
||||
- Groß-/Kleinschreibung des eigenen Namens ändern („kenso“ → „Kenso“) ist erlaubt.
|
||||
- Sperre je Konto (`profile:<userId>`).
|
||||
|
||||
**Pflicht serverseitig:** `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten **403 `usernameRequired`**, wenn das Konto keine Zeile in `profiles` hat. Die Prüfung läuft nach der Anmeldung und vor allen anderen Prüfungen. Bestehende Logos und Karten bleiben unberührt.
|
||||
|
||||
Neue `MSG`-Texte:
|
||||
|
||||
- `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.“
|
||||
|
||||
## 5. Oberfläche
|
||||
|
||||
- **Registrieren** (Login-Dialog, Reiter „Registrieren“): neues Pflichtfeld „Benutzername“ vor der E-Mail, `autocomplete="nickname"` (angemeldet wird per E-Mail; so speichern Passwortmanager weiter die E-Mail), Hinweis „3–20 Zeichen: Buchstaben, Zahlen, . _ -“.
|
||||
- Vor dem Anlegen des Kontos werden Muster, Sperrliste und Verfügbarkeit geprüft (Abfrage auf `profiles`). Ein Fehler steht am Feld.
|
||||
- Danach: Konto anlegen → anmelden → `POST /api/profil`.
|
||||
- Scheitert dieser letzte Schritt (Rennen, Netz), ist man trotzdem angemeldet, und der Dialog „Benutzername wählen“ erscheint.
|
||||
- **Dialog „Benutzername wählen“** mit Feld, „Speichern“ und „Später“ bzw. „Abbrechen“:
|
||||
- Nach dem Anmelden oder beim Seitenstart mit Sitzung, wenn das Konto keinen Namen hat. Einmal pro Sitzung (Merker in `sessionStorage` `aircard.usernamePrompted`, Zugriffe in try/catch), mit „Später“.
|
||||
- Vor dem Hochladen eines Logos und vor dem Veröffentlichen, wenn kein Name da ist. Hier gibt es „Abbrechen“ statt „Später“; ohne Namen geht es nicht weiter.
|
||||
- Antwortet der Server mit `usernameRequired`, erscheint der Dialog ebenfalls.
|
||||
- **Konto-Menü**: Statt der E-Mail steht „@name“ oben (die E-Mail darunter klein). Neuer Eintrag „Benutzername ändern“, der den Dialog vorbelegt öffnet. Ohne Namen heißt der Eintrag „Benutzername wählen“.
|
||||
- **Anzeige „von @name“**: Logo-Kacheln, Logo-Infozeile, Detailseite einer Community-Karte.
|
||||
- Der Name wird über `ownerId` in `profiles` nachgeschlagen, gesammelt je Seite (`equal('$id', ids)`) und zwischengespeichert.
|
||||
- Ohne Profil: der früher gespeicherte `displayName` (ohne `@`), sonst „Anonym“.
|
||||
- Nach einer eigenen Namensänderung aktualisieren Kacheln, Infozeile und Detailseite die eigenen Einträge sofort.
|
||||
- **Weg fällt** das Feld „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ samt `account/display-name.ts` und dem `localStorage`-Schlüssel `aircard.displayName`. Der Client schickt `displayName: ''`; die API nimmt das Feld weiter an (Altbestand).
|
||||
|
||||
Alle Texte gibt es auf Deutsch und Englisch.
|
||||
|
||||
## 6. Fehlerbehandlung
|
||||
|
||||
| Fall | Meldung |
|
||||
|---|---|
|
||||
| Muster falsch / gesperrt / vergeben | die drei `MSG`-Texte aus § 4, am Feld |
|
||||
| Server meldet `usernameRequired` | Dialog „Benutzername wählen“ |
|
||||
| Netz/API weg beim Speichern | „Der Dienst ist gerade nicht erreichbar.“, Dialog bleibt offen |
|
||||
| Profil lässt sich nicht lesen (Anzeige) | Fallback `displayName` bzw. „Anonym“, kein Fehlertext |
|
||||
|
||||
## 7. Tests
|
||||
|
||||
- **node:test:**
|
||||
- `/api/profil`: anlegen, ändern, Groß/klein am eigenen Namen, vergeben 409, gesperrt 400, Muster 400, ohne JWT 401.
|
||||
- Rennen mit zwei Konten: genau eines bekommt den Namen.
|
||||
- Zeilenrechte genau `read("any")`.
|
||||
- Hochladen und Veröffentlichen ohne Profil → 403 `usernameRequired`.
|
||||
- Setup-Skript deckt `profiles` mit Unique-Index ab.
|
||||
- **Vitest:**
|
||||
- Prüfung des Namens (Muster, Sperrliste, Schlüssel).
|
||||
- Registrieren mit Vorabprüfung und Fehlern am Feld.
|
||||
- Dialog: Später, Abbrechen, Speichern, Fehler.
|
||||
- Einmal-pro-Sitzung-Abfrage.
|
||||
- Konto-Menü mit „@name“ und Ändern.
|
||||
- Anzeige „von @name“ mit Fallbacks; sofortige Aktualisierung nach Umbenennen.
|
||||
- Dialoge ohne „Anzeigename“.
|
||||
- Hochladen und Veröffentlichen fragen vorher nach dem Namen.
|
||||
- Gleichlauf der API-Codes, i18n-Abdeckung.
|
||||
- **Live:** Gast sieht „von …“-Zeilen. Kenso registriert bzw. wird gefragt, ändert den Namen und sieht ihn an seinem Logo.
|
||||
|
||||
## 8. Nicht im Umfang
|
||||
|
||||
Profilseiten, Suche nach Benutzernamen, Moderation von Namen, Konto löschen, Avatar-Bilder, Benutzername als Anmeldename (angemeldet wird weiter per E-Mail).
|
||||
@@ -1,27 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AirCard-IOS – Kartenmotiv gestalten</title>
|
||||
<title>AirCard-IOS – Card designs for Apple Wallet</title>
|
||||
<meta name="description" content="Explore community card designs for AirCard-iOS, create your own and download it as PNG." />
|
||||
<meta name="theme-color" content="#0F1828" />
|
||||
<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />
|
||||
<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
|
||||
<script>
|
||||
// Hell/Dunkel und Sprache (<html lang>) vor dem ersten Paint setzen (kein Aufblitzen); dieselben Regeln wie
|
||||
// initialTheme() in src/shell/theme.ts und initialLang() in src/i18n/i18n.ts.
|
||||
// In einer sofort aufgerufenen Funktion, damit keine globalen Variablen `theme` und `lang` entstehen.
|
||||
(function () {
|
||||
try {
|
||||
var theme = null;
|
||||
try {
|
||||
theme = localStorage.getItem('aircard.theme');
|
||||
} catch (e) {}
|
||||
if (theme !== 'light' && theme !== 'dark') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (e) {}
|
||||
try {
|
||||
var lang = null;
|
||||
try {
|
||||
lang = localStorage.getItem('aircard.lang');
|
||||
} catch (e) {}
|
||||
if (lang !== 'de' && lang !== 'en') {
|
||||
var browser = window.navigator;
|
||||
var first = (browser.languages && browser.languages[0]) || browser.language || '';
|
||||
lang = /^de/i.test(first) ? 'de' : 'en';
|
||||
}
|
||||
document.documentElement.setAttribute('lang', lang);
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="donate-banner"></div>
|
||||
<header id="app-header">
|
||||
<a class="brand" href="/" data-link><img src="/favicon.svg" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>
|
||||
<nav class="app-nav" aria-label="Hauptnavigation">
|
||||
<a href="/karten" data-link>Meine Karten</a>
|
||||
</nav>
|
||||
<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>
|
||||
<div id="account-slot"></div>
|
||||
</header>
|
||||
<main id="app-root"></main>
|
||||
<footer id="app-footer">
|
||||
<p>Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.</p>
|
||||
</footer>
|
||||
<footer id="app-footer"></footer>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build": "tsc --noEmit -p tsconfig.build.json && vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 403 KiB |
@@ -2,8 +2,10 @@
|
||||
/**
|
||||
* AirCard – Appwrite-Schema-Setup (idempotent)
|
||||
*
|
||||
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle
|
||||
* `cards` (TablesDB) mit vier Spalten und den Bucket `card-images` an.
|
||||
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
|
||||
* 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):
|
||||
@@ -13,8 +15,9 @@
|
||||
* AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht)
|
||||
* AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1
|
||||
*
|
||||
* Ohne --check: fehlende Objekte werden angelegt. Bestehende Objekte werden
|
||||
* nur verglichen – Abweichungen werden gemeldet, nie geändert.
|
||||
* Ohne --check: fehlende Objekte werden angelegt (Indizes erst, wenn alle Spalten
|
||||
* der Tabelle `available` sind). Bestehende Objekte werden nur verglichen –
|
||||
* Abweichungen werden gemeldet, nie geändert.
|
||||
* Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht.
|
||||
*
|
||||
* Ausgabe: eine Zeile je Objekt –
|
||||
@@ -34,30 +37,150 @@ export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1';
|
||||
// Soll-Zustand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const str = (key, size, required) => ({ key, type: 'string', size, required });
|
||||
|
||||
export const DESIRED = {
|
||||
database: { id: 'aircard', name: 'AirCard' },
|
||||
table: {
|
||||
id: 'cards',
|
||||
name: 'Cards',
|
||||
permissions: ['create("users")'],
|
||||
rowSecurity: true,
|
||||
columns: [
|
||||
{ key: 'name', size: 80, required: true },
|
||||
{ key: 'config', size: 2000, required: true },
|
||||
{ key: 'imageFileId', size: 36, required: true },
|
||||
{ key: 'previewFileId', size: 36, required: true },
|
||||
],
|
||||
},
|
||||
bucket: {
|
||||
id: 'card-images',
|
||||
name: 'Card Images',
|
||||
permissions: ['create("users")'],
|
||||
fileSecurity: true,
|
||||
maximumFileSize: 5242880,
|
||||
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
compression: 'none',
|
||||
antivirus: false,
|
||||
},
|
||||
// Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports, profiles (je Tabelle: Spalten, dann Indizes).
|
||||
tables: [
|
||||
{
|
||||
id: 'cards',
|
||||
name: 'Cards',
|
||||
permissions: ['create("users")'],
|
||||
rowSecurity: true,
|
||||
columns: [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)],
|
||||
indexes: [],
|
||||
},
|
||||
{
|
||||
// Zeilenrechte setzt nur die API (aircard-api), Clients dürfen nichts auf Tabellenebene.
|
||||
id: 'gallery',
|
||||
name: 'Gallery',
|
||||
permissions: [],
|
||||
rowSecurity: true,
|
||||
columns: [
|
||||
str('title', 60, true),
|
||||
str('issuer', 40, false),
|
||||
str('tags', 120, false),
|
||||
str('displayName', 30, false),
|
||||
str('imageFileId', 36, true),
|
||||
str('previewFileId', 36, true),
|
||||
str('sourceCardId', 36, false),
|
||||
str('ownerId', 36, true),
|
||||
{ key: 'hidden', type: 'boolean', required: false, default: false },
|
||||
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
|
||||
str('searchText', 300, false),
|
||||
],
|
||||
indexes: [
|
||||
{ key: 'search_text', type: 'fulltext', columns: ['searchText'] },
|
||||
{ key: 'owner', type: 'key', columns: ['ownerId'] },
|
||||
{ key: 'hidden', type: 'key', columns: ['hidden'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
|
||||
id: 'reports',
|
||||
name: 'Reports',
|
||||
permissions: ['read("label:admin")'],
|
||||
rowSecurity: false,
|
||||
columns: [
|
||||
str('galleryId', 36, true),
|
||||
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
|
||||
str('reporterHash', 64, true),
|
||||
],
|
||||
indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }],
|
||||
},
|
||||
{
|
||||
// Community-Logos. Wie `gallery`: Zeilenrechte setzt nur die API, Clients dürfen nichts auf Tabellenebene.
|
||||
id: 'logos',
|
||||
name: 'Logos',
|
||||
permissions: [],
|
||||
rowSecurity: true,
|
||||
columns: [
|
||||
str('name', 40, true),
|
||||
str('displayName', 30, false),
|
||||
str('fileId', 36, true),
|
||||
str('ownerId', 36, true),
|
||||
{ key: 'mono', type: 'boolean', required: false, default: false },
|
||||
{ key: 'likeCount', type: 'integer', required: false, default: 0, min: 0 },
|
||||
{ key: 'hidden', type: 'boolean', required: false, default: false },
|
||||
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
|
||||
],
|
||||
indexes: [
|
||||
{ key: 'owner', type: 'key', columns: ['ownerId'] },
|
||||
{ key: 'hidden', type: 'key', columns: ['hidden'] },
|
||||
{ key: 'likes', type: 'key', columns: ['likeCount'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Ein Herz je Konto und Logo: Zeilen-ID hash32("<logoId>|<userId>"), lesen darf nur das Konto selbst; geschrieben wird nur über die API.
|
||||
id: 'logo_likes',
|
||||
name: 'Logo Likes',
|
||||
permissions: [],
|
||||
rowSecurity: true,
|
||||
columns: [str('logoId', 36, true), str('userId', 36, true)],
|
||||
indexes: [
|
||||
{ key: 'logo', type: 'key', columns: ['logoId'] },
|
||||
// Mehrspalten-Index: die Reihenfolge der Spalten zählt (und wird verglichen).
|
||||
{ key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
|
||||
id: 'logo_reports',
|
||||
name: 'Logo Reports',
|
||||
permissions: ['read("label:admin")'],
|
||||
rowSecurity: false,
|
||||
columns: [
|
||||
str('logoId', 36, true),
|
||||
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
|
||||
str('reporterHash', 64, true),
|
||||
],
|
||||
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: [
|
||||
{
|
||||
id: 'card-images',
|
||||
name: 'Card Images',
|
||||
permissions: ['create("users")'],
|
||||
fileSecurity: true,
|
||||
maximumFileSize: 5242880,
|
||||
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
compression: 'none',
|
||||
antivirus: false,
|
||||
},
|
||||
{
|
||||
id: 'gallery-images',
|
||||
name: 'Gallery Images',
|
||||
permissions: ['create("users")'],
|
||||
fileSecurity: true,
|
||||
maximumFileSize: 5242880,
|
||||
allowedFileExtensions: ['png', 'webp'],
|
||||
compression: 'none',
|
||||
antivirus: false,
|
||||
},
|
||||
{
|
||||
// Logo-PNG (höchstens 1 MiB); die Dateirechte setzt die API je Datei.
|
||||
id: 'logo-images',
|
||||
name: 'Logo Images',
|
||||
permissions: ['create("users")'],
|
||||
fileSecurity: true,
|
||||
maximumFileSize: 1048576,
|
||||
allowedFileExtensions: ['png'],
|
||||
compression: 'none',
|
||||
antivirus: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,11 +217,35 @@ export function compareTable(actual, desired) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Vergleich einer Spalte: `size`, `required`, `status` (erwartet `available`). */
|
||||
/**
|
||||
* Vergleich einer Spalte, je nach Typ:
|
||||
* string → type, size, required
|
||||
* boolean → type, required, default
|
||||
* integer → type, required, default (min/max der Antwort sind riesige Standardwerte, werden nicht verglichen)
|
||||
* enum → elements (sortiert), required (Appwrite meldet Enum-Spalten als type `string` + format `enum`)
|
||||
* Außerdem immer `status` (erwartet `available`).
|
||||
*/
|
||||
export function compareColumn(actual, desired) {
|
||||
const out = [];
|
||||
if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
|
||||
if (desired.type !== 'enum' && actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
|
||||
if (desired.type === 'string' && actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
|
||||
if (desired.type === 'enum' && !sameList(actual.elements, desired.elements)) {
|
||||
out.push(mismatch('elements', sorted(actual.elements), sorted(desired.elements)));
|
||||
}
|
||||
if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required));
|
||||
if ((desired.type === 'boolean' || desired.type === 'integer') && actual.default !== desired.default) {
|
||||
out.push(mismatch('default', actual.default, desired.default));
|
||||
}
|
||||
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Vergleich eines Index: `type`, `columns` (ältere Antworten: `attributes`; Reihenfolge zählt), `status`. */
|
||||
export function compareIndex(actual, desired) {
|
||||
const out = [];
|
||||
const columns = Array.isArray(actual.columns) ? actual.columns : (actual.attributes ?? []);
|
||||
if (actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
|
||||
if (JSON.stringify(columns) !== JSON.stringify(desired.columns)) out.push(mismatch('columns', columns, desired.columns));
|
||||
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
|
||||
return out;
|
||||
}
|
||||
@@ -197,7 +344,9 @@ async function createObject(ctx, path, body, what) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DB_ID = DESIRED.database.id;
|
||||
const columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${key}`;
|
||||
const tablePath = (tableId) => `/tablesdb/${DB_ID}/tables/${tableId}`;
|
||||
const columnPath = (tableId, key) => `${tablePath(tableId)}/columns/${key}`;
|
||||
const indexPath = (tableId, key) => `${tablePath(tableId)}/indexes/${key}`;
|
||||
|
||||
/** Gibt zurück, ob die Datenbank (jetzt) existiert. */
|
||||
export async function ensureDatabase(ctx) {
|
||||
@@ -217,13 +366,12 @@ export async function ensureDatabase(ctx) {
|
||||
}
|
||||
|
||||
/** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */
|
||||
export async function ensureTable(ctx, parentExists) {
|
||||
const t = DESIRED.table;
|
||||
const label = `Tabelle ${t.id}`;
|
||||
const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null;
|
||||
export async function ensureTable(ctx, table, parentExists) {
|
||||
const label = `Tabelle ${table.id}`;
|
||||
const actual = parentExists ? await getObject(ctx, tablePath(table.id), label) : null;
|
||||
if (actual) {
|
||||
const deviations = compareTable(actual, t);
|
||||
if (deviations.length) ctx.deviations(t.id, deviations);
|
||||
const deviations = compareTable(actual, table);
|
||||
if (deviations.length) ctx.deviations(table.id, deviations);
|
||||
else ctx.ok(label);
|
||||
return true;
|
||||
}
|
||||
@@ -234,31 +382,39 @@ export async function ensureTable(ctx, parentExists) {
|
||||
await createObject(
|
||||
ctx,
|
||||
`/tablesdb/${DB_ID}/tables`,
|
||||
{ tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity },
|
||||
{ tableId: table.id, name: table.name, permissions: table.permissions, rowSecurity: table.rowSecurity },
|
||||
label,
|
||||
);
|
||||
ctx.created(label);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Spalten und Indizes entstehen asynchron (`processing`) und werden über ihren eigenen Pfad abgefragt. */
|
||||
const CHILD_KINDS = {
|
||||
column: { noun: 'Spalte', plural: 'Spalte(n)', path: columnPath },
|
||||
index: { noun: 'Index', plural: 'Index(e)', path: indexPath },
|
||||
};
|
||||
|
||||
/**
|
||||
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind.
|
||||
* Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück.
|
||||
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten (`kind` = 'column')
|
||||
* bzw. Indizes (`kind` = 'index') der Tabelle `available` sind.
|
||||
* Gibt eine Map Schlüssel → aktueller Zustand zurück.
|
||||
*/
|
||||
export async function waitAvailable(ctx, tableId, keys) {
|
||||
export async function waitAvailable(ctx, tableId, keys, kind = 'column') {
|
||||
const { noun, plural, path } = CHILD_KINDS[kind];
|
||||
const deadline = Date.now() + ctx.timeoutMs;
|
||||
const done = new Map();
|
||||
let pending = [...keys];
|
||||
for (;;) {
|
||||
const still = [];
|
||||
for (const key of pending) {
|
||||
const label = `Spalte ${tableId}.${key}`;
|
||||
const actual = await getObject(ctx, columnPath(key), label);
|
||||
const label = `${noun} ${tableId}.${key}`;
|
||||
const actual = await getObject(ctx, path(tableId, key), label);
|
||||
if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`);
|
||||
if (actual.status === 'available') {
|
||||
done.set(key, actual);
|
||||
} else if (actual.status === 'failed' || actual.status === 'stuck') {
|
||||
throw new SetupError(`${label}: Status „${actual.status}“. Spalte in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
|
||||
throw new SetupError(`${label}: Status „${actual.status}“. ${noun} in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
|
||||
} else {
|
||||
still.push(key);
|
||||
}
|
||||
@@ -266,7 +422,7 @@ export async function waitAvailable(ctx, tableId, keys) {
|
||||
if (!still.length) return done;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new SetupError(
|
||||
`Spalte(n) ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
|
||||
`${plural} ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
|
||||
);
|
||||
}
|
||||
pending = still;
|
||||
@@ -274,49 +430,102 @@ export async function waitAvailable(ctx, tableId, keys) {
|
||||
}
|
||||
}
|
||||
|
||||
/** REST-Pfad und Body zum Anlegen einer Spalte (je Typ eine eigene Route). */
|
||||
function columnRequest(table, col) {
|
||||
const path = `${tablePath(table.id)}/columns/${col.type}`;
|
||||
switch (col.type) {
|
||||
case 'string':
|
||||
return { path, body: { key: col.key, size: col.size, required: col.required } };
|
||||
case 'boolean':
|
||||
return { path, body: { key: col.key, required: col.required, default: col.default } };
|
||||
case 'integer':
|
||||
return { path, body: { key: col.key, required: col.required, default: col.default, min: col.min } };
|
||||
case 'enum':
|
||||
return { path, body: { key: col.key, elements: col.elements, required: col.required } };
|
||||
default:
|
||||
throw new SetupError(`Unbekannter Spaltentyp „${col.type}“ für ${table.id}.${col.key}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
|
||||
* sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden.
|
||||
* Gemeinsamer Ablauf für Spalten und Indizes einer Tabelle: vorhandene lesen,
|
||||
* fehlende nacheinander anlegen, danach auf `available` warten (nur ohne
|
||||
* --check) und alles mit dem Soll vergleichen. Gibt die Einträge zurück
|
||||
* (`actual` ist dort gesetzt, wo das Objekt existiert).
|
||||
* `canCreate: false` → fehlende Objekte werden nur gemeldet, auch ohne --check.
|
||||
*/
|
||||
export async function ensureColumns(ctx, parentExists) {
|
||||
const t = DESIRED.table;
|
||||
async function ensureChildren(ctx, table, parentExists, { kind, items, request, compare, canCreate = true }) {
|
||||
const { noun, path } = CHILD_KINDS[kind];
|
||||
const entries = [];
|
||||
for (const col of t.columns) {
|
||||
const label = `Spalte ${t.id}.${col.key}`;
|
||||
const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null;
|
||||
for (const item of items) {
|
||||
const label = `${noun} ${table.id}.${item.key}`;
|
||||
const actual = parentExists ? await getObject(ctx, path(table.id, item.key), label) : null;
|
||||
if (actual) {
|
||||
entries.push({ col, label, actual, created: false });
|
||||
} else if (ctx.check) {
|
||||
entries.push({ item, label, actual, created: false });
|
||||
} else if (ctx.check || !canCreate) {
|
||||
ctx.missing(label);
|
||||
} else {
|
||||
await createObject(
|
||||
ctx,
|
||||
`/tablesdb/${DB_ID}/tables/${t.id}/columns/string`,
|
||||
{ key: col.key, size: col.size, required: col.required },
|
||||
label,
|
||||
);
|
||||
const { path: createPath, body } = request(item);
|
||||
await createObject(ctx, createPath, body, label);
|
||||
ctx.created(label);
|
||||
entries.push({ col, label, actual: null, created: true });
|
||||
entries.push({ item, label, actual: null, created: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (!ctx.check) {
|
||||
const waiting = entries.filter((e) => e.created || e.actual.status === 'processing');
|
||||
if (waiting.length) {
|
||||
const ready = await waitAvailable(ctx, t.id, waiting.map((e) => e.col.key));
|
||||
for (const e of waiting) e.actual = ready.get(e.col.key);
|
||||
const ready = await waitAvailable(ctx, table.id, waiting.map((e) => e.item.key), kind);
|
||||
for (const e of waiting) e.actual = ready.get(e.item.key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const deviations = compareColumn(e.actual, e.col);
|
||||
if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations);
|
||||
const deviations = compare(e.actual, e.item);
|
||||
if (deviations.length) ctx.deviations(`${table.id}.${e.item.key}`, deviations);
|
||||
else if (!e.created) ctx.ok(e.label);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function ensureBucket(ctx) {
|
||||
const b = DESIRED.bucket;
|
||||
/**
|
||||
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
|
||||
* sie bereit, bevor jemand schreibt (und bevor Indizes angelegt werden).
|
||||
* `parentExists`: Tabelle vorhanden. Gibt zurück, ob alle Spalten vorhanden
|
||||
* und `available` sind (Voraussetzung für die Indizes).
|
||||
*/
|
||||
export async function ensureColumns(ctx, table, parentExists) {
|
||||
const entries = await ensureChildren(ctx, table, parentExists, {
|
||||
kind: 'column',
|
||||
items: table.columns,
|
||||
request: (col) => columnRequest(table, col),
|
||||
compare: compareColumn,
|
||||
});
|
||||
return entries.length === table.columns.length && entries.every((e) => e.actual.status === 'available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Indizes der Tabelle sicherstellen. Angelegt wird erst, wenn alle Spalten
|
||||
* `available` sind (`columnsReady`), sonst wird der Index nur als fehlend
|
||||
* gemeldet (die Spalte steht dann schon als ABWEICHUNG/FEHLT in der Ausgabe).
|
||||
*/
|
||||
export async function ensureIndexes(ctx, table, parentExists, columnsReady = true) {
|
||||
const indexes = table.indexes ?? [];
|
||||
if (!indexes.length) return;
|
||||
await ensureChildren(ctx, table, parentExists, {
|
||||
kind: 'index',
|
||||
items: indexes,
|
||||
request: (idx) => ({
|
||||
path: `${tablePath(table.id)}/indexes`,
|
||||
body: { key: idx.key, type: idx.type, columns: idx.columns },
|
||||
}),
|
||||
compare: compareIndex,
|
||||
canCreate: columnsReady,
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureBucket(ctx, bucket) {
|
||||
const b = bucket;
|
||||
const label = `Bucket ${b.id}`;
|
||||
const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label);
|
||||
if (actual) {
|
||||
@@ -391,9 +600,12 @@ export async function main({
|
||||
log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`);
|
||||
try {
|
||||
const databaseExists = await ensureDatabase(ctx);
|
||||
const tableExists = await ensureTable(ctx, databaseExists);
|
||||
await ensureColumns(ctx, tableExists);
|
||||
await ensureBucket(ctx);
|
||||
for (const table of DESIRED.tables) {
|
||||
const tableExists = await ensureTable(ctx, table, databaseExists);
|
||||
const columnsReady = await ensureColumns(ctx, table, tableExists);
|
||||
await ensureIndexes(ctx, table, tableExists, columnsReady);
|
||||
}
|
||||
for (const bucket of DESIRED.buckets) await ensureBucket(ctx, bucket);
|
||||
} catch (err) {
|
||||
if (!(err instanceof SetupError)) throw err;
|
||||
log(`ABBRUCH: ${err.message}`);
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type TestUser = { $id: string; email: string; labels?: string[] };
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
user: null as TestUser | null,
|
||||
listeners: new Set<(user: TestUser | null) => void>(),
|
||||
logout: vi.fn(),
|
||||
requireLogin: vi.fn(),
|
||||
/** Der Benutzername des angemeldeten Kontos laut Zwischenspeicher (null: keiner oder unbekannt). */
|
||||
name: null as string | null,
|
||||
nameListeners: new Set<(userId: string, username: string) => void>(),
|
||||
usernameDialog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./auth', () => ({
|
||||
currentUser: () => mocks.user,
|
||||
logout: mocks.logout,
|
||||
onAuthChange: (fn: (user: TestUser | null) => void) => {
|
||||
mocks.listeners.add(fn);
|
||||
return () => mocks.listeners.delete(fn);
|
||||
},
|
||||
}));
|
||||
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
|
||||
vi.mock('./profile', () => ({
|
||||
myUsername: () => mocks.name,
|
||||
onUsernameChange: (fn: (userId: string, username: string) => void) => {
|
||||
mocks.nameListeners.add(fn);
|
||||
return () => mocks.nameListeners.delete(fn);
|
||||
},
|
||||
}));
|
||||
vi.mock('./username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
|
||||
|
||||
import { mountAccountMenu, type AccountMenu } from './account-menu';
|
||||
|
||||
let slot: HTMLElement;
|
||||
let menu: AccountMenu;
|
||||
|
||||
const avatar = () => slot.querySelector<HTMLButtonElement>('button.account-avatar');
|
||||
const panel = () => slot.querySelector<HTMLElement>('.account-dropdown');
|
||||
const buttonByText = (text: string) => [...slot.querySelectorAll('button')].find((b) => b.textContent === text);
|
||||
|
||||
function setUser(user: TestUser | null) {
|
||||
mocks.user = user;
|
||||
for (const fn of [...mocks.listeners]) fn(user);
|
||||
}
|
||||
|
||||
/** Der Name des Kontos ändert sich (eigene Änderung oder nach dem Lesen), wie `onUsernameChange` es meldet. */
|
||||
function setName(userId: string, name: string) {
|
||||
mocks.name = name;
|
||||
for (const fn of [...mocks.nameListeners]) fn(userId, name);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.user = null;
|
||||
mocks.name = null;
|
||||
mocks.listeners.clear();
|
||||
mocks.nameListeners.clear();
|
||||
mocks.logout.mockReset();
|
||||
mocks.requireLogin.mockReset();
|
||||
mocks.usernameDialog.mockReset();
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
document.body.innerHTML = '<div id="account-slot"></div>';
|
||||
slot = document.getElementById('account-slot') as HTMLElement;
|
||||
menu = mountAccountMenu(slot);
|
||||
});
|
||||
|
||||
describe('Konto-Menü', () => {
|
||||
it('zeigt vor ready() nichts (kein Aufblitzen von „Anmelden“)', () => {
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
});
|
||||
|
||||
it('abgemeldet: „Anmelden“ öffnet den Login-Dialog', () => {
|
||||
menu.ready();
|
||||
buttonByText('Anmelden')!.click();
|
||||
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
|
||||
expect(avatar()).toBeNull();
|
||||
});
|
||||
|
||||
it('angemeldet: Avatar-Kreis mit Initiale, Menü zunächst zu', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
expect(avatar()?.textContent).toBe('K');
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(avatar()?.getAttribute('aria-label')).toBe('Konto-Menü');
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('Klick öffnet das Menü: E-Mail, „Meine Karten“, „Abmelden“', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
expect(panel()?.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
|
||||
const link = panel()!.querySelector<HTMLAnchorElement>('a[data-link]')!;
|
||||
expect(link.textContent).toBe('Meine Karten');
|
||||
expect(link.getAttribute('href')).toBe('/karten');
|
||||
expect(buttonByText('Abmelden')).toBeDefined();
|
||||
});
|
||||
|
||||
it('Escape schließt das Menü und gibt den Fokus an den Avatar', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(document.activeElement).toBe(avatar());
|
||||
});
|
||||
|
||||
it('Klick außerhalb oder auf „Meine Karten“ schließt das Menü', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
|
||||
avatar()!.click();
|
||||
panel()!.querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('Klick im Menü selbst (auf die E-Mail) schließt es nicht', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
panel()!.querySelector('.account-email')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('Abmelden ruft logout; bei einem Fehler bleibt das Menü offen und zeigt die Meldung', async () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
mocks.logout.mockRejectedValueOnce(new TypeError('offline'));
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
const signOut = buttonByText('Abmelden')!;
|
||||
signOut.click();
|
||||
expect(mocks.logout).toHaveBeenCalledOnce();
|
||||
expect(signOut.disabled).toBe(true);
|
||||
await vi.waitFor(() => expect(signOut.disabled).toBe(false));
|
||||
expect(slot.querySelector('[role="alert"]')?.textContent).toBe('Keine Verbindung zum Server.');
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('nach dem Abmelden (Auth-Änderung) steht wieder „Anmelden“ da', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
setUser(null);
|
||||
expect(avatar()).toBeNull();
|
||||
expect(buttonByText('Anmelden')).toBeDefined();
|
||||
});
|
||||
|
||||
it('dispose leert den Slot und meldet die Listener ab', () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
menu.ready();
|
||||
menu.dispose();
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
expect(mocks.listeners.size).toBe(0);
|
||||
expect(mocks.nameListeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Konto-Menü: Benutzername', () => {
|
||||
const signedIn = () => {
|
||||
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
|
||||
};
|
||||
const head = () => panel()!.querySelector<HTMLElement>('.account-head')!;
|
||||
const nameLine = () => panel()!.querySelector('.account-name');
|
||||
const items = () => [...panel()!.querySelectorAll('.account-item')].map((item) => item.textContent);
|
||||
|
||||
it('mit Namen steht „@name“ oben und die E-Mail klein darunter', () => {
|
||||
signedIn();
|
||||
mocks.name = 'Kenso';
|
||||
menu.ready();
|
||||
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||
expect(panel()!.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
|
||||
expect([...head().children].map((child) => child.className)).toEqual(['account-name', 'account-email']); // Name zuerst
|
||||
expect(head().parentElement).toBe(panel());
|
||||
expect(head().nextElementSibling?.textContent).toBe('Meine Karten'); // die Kopfzeile steht vor den Einträgen
|
||||
});
|
||||
|
||||
it('der Avatar bleibt bei der Initiale der E-Mail', () => {
|
||||
signedIn();
|
||||
mocks.name = 'Zebra';
|
||||
menu.ready();
|
||||
expect(avatar()?.textContent).toBe('K');
|
||||
});
|
||||
|
||||
it('ohne Namen steht nur die E-Mail oben (wie bisher)', () => {
|
||||
signedIn();
|
||||
menu.ready();
|
||||
expect(nameLine()).toBeNull();
|
||||
expect(head().textContent).toBe('kenso@example.com');
|
||||
});
|
||||
|
||||
it('Name und E-Mail erscheinen als Text, nie als HTML', () => {
|
||||
mocks.user = { $id: 'u1', email: '<i>x</i>@example.com' };
|
||||
mocks.name = '<b>Kenso</b>';
|
||||
menu.ready();
|
||||
expect(head().querySelector('b, i')).toBeNull();
|
||||
expect(nameLine()?.textContent).toBe('@<b>Kenso</b>');
|
||||
expect(nameLine()?.getAttribute('title')).toBe('@<b>Kenso</b>');
|
||||
});
|
||||
|
||||
it('Eintrag „Benutzername ändern“ mit Namen, „Benutzername wählen“ ohne – zwischen „Meine Karten“ und „Abmelden“', () => {
|
||||
signedIn();
|
||||
mocks.name = 'Kenso';
|
||||
menu.ready();
|
||||
expect(items()).toEqual(['Meine Karten', 'Benutzername ändern', 'Abmelden']);
|
||||
setUser(null);
|
||||
mocks.name = null;
|
||||
setUser({ $id: 'u1', email: 'kenso@example.com' });
|
||||
expect(items()).toEqual(['Meine Karten', 'Benutzername wählen', 'Abmelden']);
|
||||
});
|
||||
|
||||
it('der Eintrag öffnet den Dialog im Modus „change“ und schließt das Menü', () => {
|
||||
signedIn();
|
||||
mocks.name = 'Kenso';
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
buttonByText('Benutzername ändern')!.click();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
|
||||
expect(panel()?.hidden).toBe(true);
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('ohne Namen öffnet „Benutzername wählen“ denselben Dialog', () => {
|
||||
signedIn();
|
||||
menu.ready();
|
||||
buttonByText('Benutzername wählen')!.click();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
|
||||
});
|
||||
|
||||
it('nach dem Dialog geht der Fokus zurück an den Avatar (der Eintrag im geschlossenen Menü kann ihn nicht halten)', async () => {
|
||||
signedIn();
|
||||
mocks.name = 'Kenso';
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
buttonByText('Benutzername ändern')!.click();
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
|
||||
});
|
||||
|
||||
it('ändert sich der Name (Speichern im Dialog), zeigt das Menü sofort den neuen', () => {
|
||||
signedIn();
|
||||
mocks.name = 'Alt';
|
||||
menu.ready();
|
||||
expect(nameLine()?.textContent).toBe('@Alt');
|
||||
setName('u1', 'Neu');
|
||||
expect(nameLine()?.textContent).toBe('@Neu');
|
||||
expect(items()).toContain('Benutzername ändern');
|
||||
});
|
||||
|
||||
it('ein erst später gelesener Name erscheint ohne weiteres Zutun (Menü steht schon, der Name kommt nach)', () => {
|
||||
signedIn();
|
||||
menu.ready();
|
||||
expect(nameLine()).toBeNull();
|
||||
expect(buttonByText('Benutzername wählen')).toBeDefined();
|
||||
setName('u1', 'Kenso');
|
||||
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||
expect(buttonByText('Benutzername ändern')).toBeDefined();
|
||||
});
|
||||
|
||||
it('meldet onUsernameChange denselben Namen noch einmal (Lesen des eigenen Profils nach dem Start), bleibt das Menü unangetastet: offen und mit dem Fokus', () => {
|
||||
signedIn();
|
||||
mocks.name = 'Kenso';
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
const before = { avatar: avatar(), panel: panel(), head: head() };
|
||||
const signOut = buttonByText('Abmelden')!;
|
||||
signOut.focus();
|
||||
setName('u1', 'Kenso');
|
||||
expect(avatar()).toBe(before.avatar); // dieselben Elemente, nichts wurde neu gebaut
|
||||
expect(panel()).toBe(before.panel);
|
||||
expect(head()).toBe(before.head);
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(document.activeElement).toBe(signOut);
|
||||
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||
});
|
||||
|
||||
it('ein anderer Name oder nur eine andere Schreibweise zeichnet dagegen neu', () => {
|
||||
signedIn();
|
||||
mocks.name = 'kenso';
|
||||
menu.ready();
|
||||
const before = avatar();
|
||||
setName('u1', 'Kenso'); // „kenso“ → „Kenso“ ist erlaubt (Spec § 4) und soll zu sehen sein
|
||||
expect(avatar()).not.toBe(before);
|
||||
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||
const second = avatar();
|
||||
setName('u1', 'Mia');
|
||||
expect(avatar()).not.toBe(second);
|
||||
expect(nameLine()?.textContent).toBe('@Mia');
|
||||
});
|
||||
|
||||
it('die Namensmeldung eines anderen Kontos ändert nichts am Menü des angemeldeten', () => {
|
||||
signedIn();
|
||||
mocks.name = 'Kenso'; // der Name des angemeldeten Kontos bleibt
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
const before = panel();
|
||||
for (const fn of [...mocks.nameListeners]) fn('u2', 'Mia'); // das Konto u2 hat sich umbenannt (etwa in einem eben beendeten Aufruf)
|
||||
expect(panel()).toBe(before);
|
||||
expect(panel()?.hidden).toBe(false);
|
||||
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||
});
|
||||
|
||||
it('abgemeldet bleibt der Anmelden-Knopf stehen, auch wenn ein Name gemeldet wird (das Konto, das speicherte, ist nicht mehr angemeldet)', () => {
|
||||
menu.ready();
|
||||
const signIn = buttonByText('Anmelden');
|
||||
for (const fn of [...mocks.nameListeners]) fn('u2', 'Mia');
|
||||
expect(buttonByText('Anmelden')).toBe(signIn);
|
||||
});
|
||||
|
||||
it('nach dem Abmelden ist das Menü unabhängig vom Namen wieder „Anmelden“', () => {
|
||||
signedIn();
|
||||
mocks.name = 'Kenso';
|
||||
menu.ready();
|
||||
setUser(null);
|
||||
expect(slot.querySelector('.account-name')).toBeNull();
|
||||
expect(buttonByText('Anmelden')).toBeDefined();
|
||||
});
|
||||
|
||||
it('vor ready() und nach dispose zeichnet auch eine Namensänderung nichts', () => {
|
||||
signedIn();
|
||||
setName('u1', 'Kenso');
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
menu.ready();
|
||||
menu.dispose();
|
||||
setName('u1', 'Neu');
|
||||
expect(slot.childElementCount).toBe(0);
|
||||
});
|
||||
|
||||
it('lässt sich der Dialog nicht öffnen, bleibt es bei einer Meldung in der Konsole (kein unbehandelter Fehler), der Fokus geht trotzdem zurück', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
|
||||
signedIn();
|
||||
menu.ready();
|
||||
avatar()!.click();
|
||||
buttonByText('Benutzername wählen')!.click();
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it('auf Englisch: Einträge in der Sprache der Seite', async () => {
|
||||
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||
signedIn();
|
||||
mocks.name = 'Kenso';
|
||||
menu.ready();
|
||||
expect(items()).toEqual(['My cards', 'Change username', 'Sign out']);
|
||||
expect(nameLine()?.textContent).toBe('@Kenso');
|
||||
mocks.name = null;
|
||||
setUser({ $id: 'u1', email: 'kenso@example.com' });
|
||||
expect(items()).toEqual(['My cards', 'Choose a username', 'Sign out']);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet E-Mail und „Abmelden“.
|
||||
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale und einem Menü (Benutzername und E-Mail,
|
||||
// „Meine Karten“, „Benutzername ändern“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
|
||||
// baut main.ts das Menü neu auf.
|
||||
import './account.css';
|
||||
import { el } from '../editor/dom';
|
||||
import { t } from '../i18n/i18n';
|
||||
import { currentUser, logout, onAuthChange } from './auth';
|
||||
import { errorMessage } from './errors';
|
||||
import { requireLogin } from './login-dialog';
|
||||
import { myUsername, onUsernameChange } from './profile';
|
||||
import { usernameDialog } from './username-dialog';
|
||||
|
||||
export interface AccountMenu {
|
||||
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */
|
||||
@@ -13,22 +18,91 @@ export interface AccountMenu {
|
||||
|
||||
export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
let isReady = false;
|
||||
// Hebt die Dokument-Listener des gerade offenen Menüs wieder auf (null: kein Menü gezeichnet).
|
||||
let detach: (() => void) | null = null;
|
||||
// Der Name, den das gezeichnete Menü zeigt (null: keiner oder gar kein Konto-Menü). Meldet `onUsernameChange` denselben Namen noch einmal
|
||||
// (etwa das Lesen des eigenen Profils nach dem Start), bleibt das Menü stehen: ein neu gebautes Dropdown wäre zu und verlöre den Fokus.
|
||||
let shownName: string | null = null;
|
||||
|
||||
function render(): void {
|
||||
detach?.();
|
||||
detach = null;
|
||||
shownName = null;
|
||||
if (!isReady) {
|
||||
slot.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const user = currentUser();
|
||||
if (!user) {
|
||||
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Anmelden');
|
||||
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, t('Anmelden'));
|
||||
signIn.addEventListener('click', () => void requireLogin('login'));
|
||||
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
|
||||
return;
|
||||
}
|
||||
|
||||
const initial = Array.from(user.email)[0]?.toUpperCase() ?? '?';
|
||||
const avatar = el(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'account-avatar',
|
||||
'aria-label': t('Konto-Menü'),
|
||||
'aria-expanded': 'false',
|
||||
'aria-controls': 'account-dropdown',
|
||||
},
|
||||
initial,
|
||||
);
|
||||
// Mit Benutzernamen steht „@name“ oben und die E-Mail klein darunter; ohne Namen (noch nicht gelesen oder nicht gewählt) nur die E-Mail.
|
||||
const name = myUsername();
|
||||
shownName = name;
|
||||
const email = el('span', { class: 'account-email', title: user.email }, user.email);
|
||||
const signOut = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abmelden');
|
||||
const head = el(
|
||||
'div',
|
||||
{ class: 'account-head' },
|
||||
...(name ? [el('span', { class: 'account-name', title: `@${name}` }, `@${name}`)] : []),
|
||||
email,
|
||||
);
|
||||
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten'));
|
||||
const changeName = el('button', { type: 'button', class: 'account-item' }, name ? t('Benutzername ändern') : t('Benutzername wählen'));
|
||||
const signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden'));
|
||||
const error = el('span', { class: 'account-error', role: 'alert' });
|
||||
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, head, myCards, changeName, signOut, error);
|
||||
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown);
|
||||
|
||||
let isOpen = false;
|
||||
function setOpen(open: boolean): void {
|
||||
isOpen = open;
|
||||
dropdown.hidden = !open;
|
||||
avatar.setAttribute('aria-expanded', String(open));
|
||||
}
|
||||
avatar.addEventListener('click', () => setOpen(!isOpen));
|
||||
|
||||
const onDocumentClick = (event: MouseEvent) => {
|
||||
if (!isOpen) return;
|
||||
const target = event.target as Element | null;
|
||||
if (target && wrap.contains(target) && !target.closest('a')) return; // nur Links schließen das Menü selbst
|
||||
setOpen(false);
|
||||
};
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || !isOpen) return;
|
||||
setOpen(false);
|
||||
avatar.focus();
|
||||
};
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
detach = () => {
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
};
|
||||
|
||||
changeName.addEventListener('click', () => {
|
||||
setOpen(false);
|
||||
// Der Eintrag im geschlossenen (und nach dem Speichern neu gezeichneten) Menü kann den Fokus nicht halten: er geht an den Avatar.
|
||||
usernameDialog({ mode: 'change' })
|
||||
.catch((err: unknown) => console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err))
|
||||
.finally(() => slot.querySelector<HTMLElement>('.account-avatar')?.focus());
|
||||
});
|
||||
|
||||
signOut.addEventListener('click', () => {
|
||||
signOut.disabled = true;
|
||||
error.textContent = '';
|
||||
@@ -38,17 +112,15 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
signOut.disabled = false;
|
||||
});
|
||||
});
|
||||
slot.replaceChildren(
|
||||
el(
|
||||
'div',
|
||||
{ class: 'account-menu', role: 'group', 'aria-label': 'Konto' },
|
||||
el('div', { class: 'account-row' }, email, signOut),
|
||||
error,
|
||||
),
|
||||
);
|
||||
slot.replaceChildren(wrap);
|
||||
}
|
||||
|
||||
const off = onAuthChange(render);
|
||||
// Der Name steht fest oder ändert sich (gelesen nach dem Start, gespeichert im Dialog): das Menü zeigt gleich den neuen.
|
||||
const offName = onUsernameChange(() => {
|
||||
if (myUsername() === shownName) return; // nichts Neues zu zeigen (derselbe Name, oder ein anderes Konto hat gespeichert)
|
||||
render();
|
||||
});
|
||||
render();
|
||||
|
||||
return {
|
||||
@@ -58,6 +130,9 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
|
||||
},
|
||||
dispose() {
|
||||
off();
|
||||
offName();
|
||||
detach?.();
|
||||
detach = null;
|
||||
slot.replaceChildren();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
.auth-btn-primary {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -35,7 +36,7 @@
|
||||
|
||||
.auth-btn-danger {
|
||||
border-color: var(--danger);
|
||||
color: #ff9a9d;
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.auth-btn-danger:hover:not(:disabled) {
|
||||
@@ -70,43 +71,124 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Konto-Menü im Seitenkopf */
|
||||
|
||||
#account-slot {
|
||||
min-width: 0;
|
||||
}
|
||||
/* Konto-Menü im Seitenkopf: „Anmelden“ bzw. Avatar-Kreis mit aufklappbarem Menü */
|
||||
|
||||
.account-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
.account-avatar {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font: inherit;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.account-avatar:hover,
|
||||
.account-avatar[aria-expanded='true'] {
|
||||
border-color: var(--sel-line);
|
||||
}
|
||||
|
||||
.account-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 220px;
|
||||
max-width: min(320px, calc(100vw - 32px));
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 14px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.account-dropdown[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Kopf des Menüs: „@name“ (falls das Konto einen hat) und darunter die E-Mail */
|
||||
|
||||
.account-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
margin-bottom: 4px;
|
||||
padding: 6px 12px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.account-name {
|
||||
min-width: 0;
|
||||
max-width: 28ch;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-name + .account-email {
|
||||
font-size: 0.8rem; /* mit Namen steht die E-Mail klein darunter */
|
||||
}
|
||||
|
||||
.account-item {
|
||||
appearance: none;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.account-item:hover:not(:disabled) {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.account-item:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.account-error {
|
||||
color: #ff9a9d;
|
||||
padding: 4px 12px 0;
|
||||
color: var(--danger-text);
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.account-error:empty {
|
||||
@@ -124,11 +206,11 @@
|
||||
border-radius: 24px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.auth-dialog::backdrop {
|
||||
background: rgb(6 11 20 / 0.72);
|
||||
background: var(--backdrop);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
@@ -256,16 +338,25 @@
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Leer bleibt die Zeile gerendert (Live-Region, role alert/status), nimmt aber keinen Platz ein: display: none nähme sie aus
|
||||
dem Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
|
||||
.auth-msg:empty {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-msg-error {
|
||||
color: #ff9a9d;
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.auth-msg-ok {
|
||||
color: #8fd9a8;
|
||||
color: var(--ok-text);
|
||||
}
|
||||
|
||||
.auth-aside {
|
||||
@@ -275,6 +366,25 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Dialog „Benutzername wählen“: Hülle, Felder, Hinweise, Meldungen und Knöpfe wie der Login-Dialog */
|
||||
|
||||
.username-dialog .auth-title {
|
||||
margin-right: 0; /* kein Schließen-Knopf in der Ecke */
|
||||
}
|
||||
|
||||
.username-text {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.username-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Seite /passwort-neu */
|
||||
|
||||
.reset-page {
|
||||
@@ -289,29 +399,15 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Schmale Bildschirme: Konto-Menü rutscht bei Bedarf in eine zweite Kopfzeile */
|
||||
|
||||
@media (max-width: 599px) {
|
||||
#app-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
}
|
||||
|
||||
#account-slot {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.account-email {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: 24px 20px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.auth-btn {
|
||||
.auth-btn,
|
||||
.account-avatar {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,25 @@ import { Account, Client, Storage, TablesDB } from 'appwrite';
|
||||
export const APPWRITE_ENDPOINT: string = import.meta.env.VITE_APPWRITE_ENDPOINT || `${location.origin}/v1`;
|
||||
export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID || '6ac2130a00289343d6d5';
|
||||
|
||||
/** Datenbank, Tabelle und Bucket des Projekts (siehe web/scripts/appwrite-setup.mjs). */
|
||||
export const IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const;
|
||||
/**
|
||||
* Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs).
|
||||
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie,
|
||||
* `logos`/`logoLikes`/`logoReports`/`logoBucket` zu den Community-Logos (Logos, Herzen, Meldungen, Logo-Dateien),
|
||||
* `profiles` zu den Benutzernamen (eine öffentlich lesbare Zeile je Konto; geschrieben wird nur über den API-Dienst).
|
||||
*/
|
||||
export const IDS = {
|
||||
database: 'aircard',
|
||||
table: 'cards',
|
||||
bucket: 'card-images',
|
||||
gallery: 'gallery',
|
||||
reports: 'reports',
|
||||
galleryBucket: 'gallery-images',
|
||||
logos: 'logos',
|
||||
logoLikes: 'logo_likes',
|
||||
logoReports: 'logo_reports',
|
||||
logoBucket: 'logo-images',
|
||||
profiles: 'profiles',
|
||||
} as const;
|
||||
|
||||
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
|
||||
export const account: Account = new Account(client);
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('getCurrentUser', () => {
|
||||
it('liefert den Nutzer und merkt sich die ID', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' });
|
||||
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('getCurrentUser', () => {
|
||||
await auth.getCurrentUser();
|
||||
await auth.getCurrentUser();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de' });
|
||||
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
||||
await auth.getCurrentUser();
|
||||
await auth.getCurrentUser();
|
||||
@@ -80,6 +80,52 @@ describe('getCurrentUser', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Labels und isAdmin', () => {
|
||||
it('getCurrentUser übernimmt die Labels; fehlen sie (oder sind sie kein Array aus Texten), sind es []', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
|
||||
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: 'admin' });
|
||||
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 7, null] });
|
||||
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: ['admin'] });
|
||||
});
|
||||
|
||||
it('login liefert die Labels ebenfalls', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toMatchObject({ labels: ['admin'] });
|
||||
expect(auth.currentUser()?.labels).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('eine Änderung nur der Labels benachrichtigt die Abonnenten', async () => {
|
||||
const auth = await freshAuth();
|
||||
const listener = vi.fn();
|
||||
auth.onAuthChange(listener);
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
await auth.getCurrentUser();
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
await auth.getCurrentUser();
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
|
||||
await auth.getCurrentUser();
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
|
||||
});
|
||||
|
||||
it('isAdmin ist nur mit dem Label „admin“ wahr', async () => {
|
||||
const auth = await freshAuth();
|
||||
expect(auth.isAdmin(null)).toBe(false);
|
||||
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de' })).toBe(false);
|
||||
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: [] })).toBe(false);
|
||||
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['administrator', 'Admin'] })).toBe(false);
|
||||
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['x', 'admin'] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
|
||||
const auth = await freshAuth();
|
||||
@@ -87,10 +133,10 @@ describe('login', () => {
|
||||
auth.onAuthChange(listener);
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
|
||||
expect(auth.currentUserId()).toBe('u1');
|
||||
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de' });
|
||||
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
});
|
||||
|
||||
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
|
||||
@@ -114,7 +160,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
|
||||
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
||||
mocks.deleteSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
|
||||
@@ -127,7 +173,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
|
||||
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
||||
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
|
||||
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
|
||||
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -177,7 +223,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
|
||||
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
|
||||
mocks.deleteSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
|
||||
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
|
||||
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -188,7 +234,7 @@ describe('register', () => {
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
|
||||
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
|
||||
const params = mocks.create.mock.calls[0][0];
|
||||
expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' });
|
||||
expect(typeof params.userId).toBe('string');
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Anmelden, Registrieren, Abmelden, Passwort zurücksetzen. Hält den angemeldeten Nutzer im Speicher
|
||||
// und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite).
|
||||
import { ID } from 'appwrite';
|
||||
import { t } from '../i18n/i18n';
|
||||
import { account } from './appwrite';
|
||||
import { errorType, isUnauthorized } from './errors';
|
||||
|
||||
export type User = { $id: string; email: string };
|
||||
export type User = { $id: string; email: string; labels?: string[] };
|
||||
|
||||
const PASSWORD_MIN = 8;
|
||||
const PASSWORD_MAX = 256;
|
||||
@@ -27,10 +28,16 @@ function setUser(user: User | null): void {
|
||||
function toUser(res: unknown): User {
|
||||
const data = (typeof res === 'object' && res !== null ? res : {}) as Record<string, unknown>;
|
||||
if (typeof data.$id !== 'string' || typeof data.email !== 'string') throw new Error('Unerwartete Antwort');
|
||||
return { $id: data.$id, email: data.email };
|
||||
const labels = Array.isArray(data.labels) ? data.labels.filter((l): l is string => typeof l === 'string') : [];
|
||||
return { $id: data.$id, email: data.email, labels };
|
||||
}
|
||||
|
||||
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen. */
|
||||
/** Wahr nur für Konten mit dem Appwrite-Label `admin` (die Rechte prüft der Server; das hier steuert nur die Oberfläche). */
|
||||
export function isAdmin(user: User | null): boolean {
|
||||
return (user?.labels ?? []).includes('admin');
|
||||
}
|
||||
|
||||
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen (auch der Labels). */
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
let next: User | null;
|
||||
try {
|
||||
@@ -39,7 +46,11 @@ export async function getCurrentUser(): Promise<User | null> {
|
||||
if (!isUnauthorized(err)) throw err;
|
||||
next = null;
|
||||
}
|
||||
if (next?.$id !== cachedUser?.$id || next?.email !== cachedUser?.email) setUser(next);
|
||||
const same =
|
||||
next?.$id === cachedUser?.$id &&
|
||||
next?.email === cachedUser?.email &&
|
||||
(next?.labels ?? []).join('\n') === (cachedUser?.labels ?? []).join('\n');
|
||||
if (!same) setUser(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -122,9 +133,9 @@ export function onLogout(fn: () => void): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer. */
|
||||
/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer (in der Sprache der Seite). */
|
||||
export function validatePassword(pw: string): string | null {
|
||||
return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX
|
||||
? null
|
||||
: `Das Passwort muss ${PASSWORD_MIN} bis ${PASSWORD_MAX} Zeichen lang sein.`;
|
||||
: t('Das Passwort muss {min} bis {max} Zeichen lang sein.', { min: PASSWORD_MIN, max: PASSWORD_MAX });
|
||||
}
|
||||
@@ -32,6 +32,7 @@ vi.mock('./appwrite', () => ({
|
||||
import {
|
||||
appwriteCardsApi,
|
||||
createCardsService,
|
||||
fetchFile,
|
||||
normalizeName,
|
||||
ownerPermissions,
|
||||
toUploadFile,
|
||||
@@ -147,6 +148,9 @@ const newInput = (patch: Partial<SaveInput> = {}): SaveInput => ({
|
||||
const service = (fake: ReturnType<typeof fakeApi>, userId: () => string = () => 'u1') =>
|
||||
createCardsService(fake.api, userId);
|
||||
|
||||
/** Der Server hat die Anfrage eindeutig abgelehnt (4xx): es wurde nichts geschrieben. */
|
||||
const rejected = (code = 400) => new AppwriteException('Abgelehnt', code, 'document_invalid_structure');
|
||||
|
||||
describe('normalizeName', () => {
|
||||
it('trimmt und fasst Leerraum zusammen', () => {
|
||||
expect(normalizeName(' Mein Urlaub ')).toBe('Mein Urlaub');
|
||||
@@ -240,14 +244,60 @@ describe('save: neue Karte', () => {
|
||||
expect((fake.methods('createFile')[1].args[0] as File).name).toBe('preview.png');
|
||||
});
|
||||
|
||||
it('scheitert createRow, werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => {
|
||||
it('lehnt der Server createRow eindeutig ab (4xx), werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('createRow');
|
||||
const boom = rejected(400);
|
||||
fake.failOnce.set('createRow', boom);
|
||||
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||
});
|
||||
|
||||
it('auch 401 (Sitzung weg) und andere 4xx gelten als eindeutig abgelehnt', async () => {
|
||||
for (const code of [401, 403, 404, 409, 413]) {
|
||||
const fake = fakeApi();
|
||||
fake.failOnce.set('createRow', rejected(code));
|
||||
await expect(service(fake).save(newInput())).rejects.toThrow();
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||
}
|
||||
});
|
||||
|
||||
describe('unklarer Ausgang beim Schreiben der Zeile: Uploads bleiben liegen', () => {
|
||||
const ambiguous: [string, () => unknown][] = [
|
||||
['Netzfehler (TypeError)', () => new TypeError('Failed to fetch')],
|
||||
['503', () => new AppwriteException('Server', 503, 'general_server_error')],
|
||||
['500', () => new AppwriteException('Server', 500, 'general_server_error')],
|
||||
['429 (zu viele Anfragen)', () => new AppwriteException('Rate', 429, 'general_rate_limit_exceeded')],
|
||||
['408 (Zeitüberschreitung)', () => new AppwriteException('Timeout', 408, 'general_timeout')],
|
||||
['ein Fehler ohne Statuscode', () => new Error('unbekannt')],
|
||||
];
|
||||
|
||||
for (const [label, make] of ambiguous) {
|
||||
it(`neue Karte, createRow: ${label}`, async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = make();
|
||||
fake.failOnce.set('createRow', boom);
|
||||
await expect(service(fake).save(newInput())).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
it('bestehende Karte, updateRow: Netzfehler löscht weder neue noch alte Dateien', async () => {
|
||||
const fake = fakeApi([{ $id: 'row1', $updatedAt: NOW, name: 'Alt', config: serializeConfig(config()), imageFileId: 'oldImage', previewFileId: 'oldPreview' }]);
|
||||
const boom = new TypeError('Failed to fetch');
|
||||
fake.failOnce.set('updateRow', boom);
|
||||
await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Zeile verschwunden, dann Netzfehler beim Anlegen: nichts wird gelöscht', async () => {
|
||||
const fake = fakeApi();
|
||||
fake.failOnce.set('updateRow', new AppwriteException('Row not found', 404, 'row_not_found'));
|
||||
fake.failOnce.set('createRow', new TypeError('Failed to fetch'));
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow(TypeError);
|
||||
expect(fake.methods('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('scheitert die Vorschau, wird das Bild gelöscht und keine Zeile angelegt', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('preview');
|
||||
@@ -259,7 +309,7 @@ describe('save: neue Karte', () => {
|
||||
|
||||
it('Fehler beim Aufräumen überdecken den Ursprungsfehler nicht', async () => {
|
||||
const fake = fakeApi();
|
||||
const boom = new Error('createRow');
|
||||
const boom = rejected(400);
|
||||
fake.failOnce.set('createRow', boom);
|
||||
const original = fake.api.deleteFile;
|
||||
fake.api.deleteFile = async (id) => {
|
||||
@@ -300,7 +350,7 @@ describe('save: bestehende Karte', () => {
|
||||
|
||||
it('ohne Bildwechsel: nur neue Vorschau, Update, dann alte Vorschau löschen', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const c = config({ network: 'jcb' });
|
||||
const c = config({ logo: 'abc123' });
|
||||
const saved = await service(fake).save(
|
||||
newInput({ name: 'Neu', config: c, imageChanged: false, previous: previous() }),
|
||||
);
|
||||
@@ -309,15 +359,30 @@ describe('save: bestehende Karte', () => {
|
||||
const [file] = fake.methods('createFile');
|
||||
expect((file.args[0] as File).name).toBe('preview.webp');
|
||||
expect(file.args[1]).toEqual(OWNER);
|
||||
// Ohne Bildwechsel steht kein imageFileId im Update: ein veralteter Schnappschuss darf die Zeile nicht auf eine
|
||||
// inzwischen gelöschte Bilddatei zurücksetzen.
|
||||
expect(fake.methods('updateRow')[0].args).toEqual([
|
||||
'row1',
|
||||
{ name: 'Neu', config: serializeConfig(c), imageFileId: 'oldImage', previewFileId: 'file1' },
|
||||
{ name: 'Neu', config: serializeConfig(c), previewFileId: 'file1' },
|
||||
]);
|
||||
expect(fake.methods('updateRow')[0].args[1]).not.toHaveProperty('imageFileId');
|
||||
expect(fake.methods('deleteFile')[0].args).toEqual(['oldPreview']);
|
||||
expect(saved.id).toBe('row1');
|
||||
expect(saved.imageFileId).toBe('oldImage');
|
||||
expect(saved.previewFileId).toBe('file1');
|
||||
expect(saved.config.network).toBe('jcb');
|
||||
expect(saved.config.logo).toBe('abc123');
|
||||
});
|
||||
|
||||
it('veralteter Schnappschuss (Bild in anderem Tab ersetzt): das Update schickt kein altes imageFileId, die Zeile behält das neue Bild', async () => {
|
||||
// Der Server kennt schon das neue Bild; der Editor hält noch den Stand von vorher.
|
||||
const fake = fakeApi([{ ...existing(), imageFileId: 'serverImage', previewFileId: 'serverPreview' }]);
|
||||
const saved = await service(fake).save(
|
||||
newInput({ imageChanged: false, previous: previous({ imageFileId: 'deletedImage', previewFileId: 'stalePreview' }) }),
|
||||
);
|
||||
expect(fake.methods('updateRow')[0].args[1]).not.toHaveProperty('imageFileId');
|
||||
expect(saved.imageFileId).toBe('serverImage');
|
||||
// Gelöscht wird nur die Vorschau des Schnappschusses, nie die Bilddatei.
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['stalePreview']);
|
||||
});
|
||||
|
||||
it('mit Bildwechsel: zwei neue Dateien, nach dem Update werden beide alten gelöscht', async () => {
|
||||
@@ -331,9 +396,9 @@ describe('save: bestehende Karte', () => {
|
||||
expect(saved.imageFileId).toBe('file1');
|
||||
});
|
||||
|
||||
it('scheitert updateRow, werden nur die neuen Dateien gelöscht', async () => {
|
||||
it('lehnt der Server updateRow ab, werden nur die neuen Dateien gelöscht', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const boom = new Error('updateRow');
|
||||
const boom = rejected(400);
|
||||
fake.failOnce.set('updateRow', boom);
|
||||
await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||
@@ -341,7 +406,7 @@ describe('save: bestehende Karte', () => {
|
||||
|
||||
it('scheitert updateRow ohne Bildwechsel, wird nur die neue Vorschau gelöscht', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
fake.failOnce.set('updateRow', new Error('updateRow'));
|
||||
fake.failOnce.set('updateRow', rejected(400));
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow();
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['file1']);
|
||||
});
|
||||
@@ -401,7 +466,7 @@ describe('save: bestehende Karte', () => {
|
||||
|
||||
it('scheitert auch das Anlegen, werden alle neuen Dateien gelöscht, die alten bleiben, der Fehler kommt an', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const boom = new Error('createRow');
|
||||
const boom = rejected(400);
|
||||
fake.failOnce.set('updateRow', gone());
|
||||
fake.failOnce.set('createRow', boom);
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toBe(boom);
|
||||
@@ -647,3 +712,52 @@ describe('appwriteCardsApi', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFile (eigenständig, beliebiger Bucket)', () => {
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const prepare = () =>
|
||||
mocks.prepareRequest.mockImplementation((method: string, url: URL) => ({
|
||||
uri: url.toString(),
|
||||
options: { method, headers: { 'X-Appwrite-Project': 'proj' }, credentials: 'include' },
|
||||
}));
|
||||
|
||||
it('holt die Datei aus dem übergebenen Bucket, mit Projekt-Header und Cookie', async () => {
|
||||
prepare();
|
||||
const fetchMock = vi.fn(async () => new Response(png(), { status: 200, headers: { 'content-type': 'image/png' } }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const result = await fetchFile('gallery-images', 'f1');
|
||||
expect(result.type).toBe('image/png');
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://aircard.test/v1/storage/buckets/gallery-images/files/f1/view', {
|
||||
method: 'GET',
|
||||
headers: { 'X-Appwrite-Project': 'proj' },
|
||||
credentials: 'include',
|
||||
});
|
||||
});
|
||||
|
||||
it('maskiert Bucket- und Datei-ID im Pfad', async () => {
|
||||
prepare();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
|
||||
await fetchFile('a/b', '../x?y');
|
||||
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/a%2Fb/files/..%2Fx%3Fy/view');
|
||||
});
|
||||
|
||||
it('Fehlerantwort: AppwriteException mit Status und Typ des Servers', async () => {
|
||||
prepare();
|
||||
const body = JSON.stringify({ message: 'File not found', type: 'storage_file_not_found', code: 404 });
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 404, statusText: 'Not Found' })));
|
||||
const err = await fetchFile('gallery-images', 'f1').catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(AppwriteException);
|
||||
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' });
|
||||
});
|
||||
|
||||
it('CardsApi.fetchFile bleibt die Hülle über den Karten-Bucket', async () => {
|
||||
prepare();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
|
||||
await appwriteCardsApi().fetchFile('f1');
|
||||
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/bucket/files/f1/view');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
// Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite.
|
||||
// Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen.
|
||||
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
|
||||
import { parseConfig, serializeConfig, type CardConfig } from '../card/config';
|
||||
import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config';
|
||||
import type { SavedCardRef } from '../editor/state';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
|
||||
import { currentUserId } from './auth';
|
||||
import { errorType } from './errors';
|
||||
@@ -44,16 +45,16 @@ export interface SaveInput {
|
||||
previous: SavedCardRef | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_CARD_NAME = 'Meine Karte';
|
||||
export const DEFAULT_CARD_NAME = msg('Meine Karte');
|
||||
const NAME_MAX = 80;
|
||||
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
|
||||
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
/** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */
|
||||
export { ID_PATTERN };
|
||||
|
||||
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
|
||||
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“ (in der Sprache der Seite). */
|
||||
export function normalizeName(name: string): string {
|
||||
const collapsed = name.replace(/\s+/g, ' ').trim();
|
||||
const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim();
|
||||
return cut || DEFAULT_CARD_NAME;
|
||||
return cut || t(DEFAULT_CARD_NAME);
|
||||
}
|
||||
|
||||
/** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */
|
||||
@@ -87,6 +88,15 @@ async function deleteQuietly(api: CardsApi, fileIds: string[]): Promise<void> {
|
||||
await Promise.allSettled(fileIds.map((id) => api.deleteFile(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Server hat eine Anfrage eindeutig abgelehnt (4xx außer Zeitüberschreitung 408 und Drosselung 429): es wurde nichts
|
||||
* geschrieben. Netzfehler, 5xx und alles ohne Statuscode lassen offen, ob die Zeile doch angekommen ist.
|
||||
*/
|
||||
function isDefinitiveFailure(err: unknown): boolean {
|
||||
const code = typeof err === 'object' && err !== null ? (err as { code?: unknown }).code : undefined;
|
||||
return typeof code === 'number' && code >= 400 && code < 500 && code !== 408 && code !== 429;
|
||||
}
|
||||
|
||||
export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
async function save(input: SaveInput): Promise<SavedCard> {
|
||||
// Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird.
|
||||
@@ -109,25 +119,36 @@ export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
let row: CardRow;
|
||||
// Die Zeile gab es nicht mehr (anderer Tab, anderes Konto): dann wurde eine neue Karte angelegt, die alten Dateien sind Müll.
|
||||
let replacedMissingCard = false;
|
||||
// Scheitert ein Upload, hängt noch keine Zeile an den Dateien. Beim Schreiben der Zeile ist der Ausgang bei
|
||||
// Netzfehler/5xx dagegen offen (sie kann angekommen sein): dann bleiben die Uploads lieber liegen, als dass
|
||||
// eine Zeile auf gelöschte Dateien zeigt.
|
||||
let writingRow = false;
|
||||
try {
|
||||
const imageFileId = imageFile ? await upload(imageFile) : (prev as SavedCardRef).imageFileId;
|
||||
const newImageId = imageFile ? await upload(imageFile) : null;
|
||||
const previewFileId = await upload(previewFile);
|
||||
const data: CardRowData = { name, config, imageFileId, previewFileId };
|
||||
writingRow = true;
|
||||
if (!prev) {
|
||||
row = await api.createRow(data, permissions);
|
||||
// Ohne vorherige Karte wird immer ein Bild hochgeladen (uploadImage).
|
||||
row = await api.createRow({ name, config, imageFileId: newImageId as string, previewFileId }, permissions);
|
||||
} else {
|
||||
// Ohne Bildwechsel steht kein imageFileId im Update: der Schnappschuss des Editors kann veraltet sein
|
||||
// (Bild in einem anderen Tab ersetzt) und würde die Zeile auf eine gelöschte Datei zurücksetzen.
|
||||
const update: Partial<CardRowData> = { name, config, previewFileId };
|
||||
if (newImageId) update.imageFileId = newImageId;
|
||||
try {
|
||||
row = await api.updateRow(prev.id, data);
|
||||
row = await api.updateRow(prev.id, update);
|
||||
} catch (err) {
|
||||
if (errorType(err) !== 'row_not_found') throw err;
|
||||
replacedMissingCard = true;
|
||||
// Ohne Bildwechsel zeigt `data` auf die Datei der verschwundenen Karte; die neue Karte bekommt ein eigenes Bild.
|
||||
const ownImageId = imageFile ? imageFileId : await upload(toUploadFile(input.image, 'image'));
|
||||
row = await api.createRow({ ...data, imageFileId: ownImageId }, permissions);
|
||||
// Ohne Bildwechsel gibt es noch keine Bilddatei der neuen Karte; die der verschwundenen gehört ihr nicht.
|
||||
writingRow = false;
|
||||
const ownImageId = newImageId ?? (await upload(toUploadFile(input.image, 'image')));
|
||||
writingRow = true;
|
||||
row = await api.createRow({ name, config, imageFileId: ownImageId, previewFileId }, permissions);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await deleteQuietly(api, uploaded);
|
||||
if (!writingRow || isDefinitiveFailure(err)) await deleteQuietly(api, uploaded);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -173,11 +194,13 @@ export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
|
||||
export type CardsService = ReturnType<typeof createCardsService>;
|
||||
|
||||
/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
|
||||
async function fetchFile(fileId: string): Promise<Blob> {
|
||||
/** Lädt eine Bilddatei aus einem Bucket mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
|
||||
export async function fetchFile(bucketId: string, fileId: string): Promise<Blob> {
|
||||
// Bewusst kein storage.getFileView(): das hängt das Projekt als Query-Parameter an die URL und schickt keinen
|
||||
// Header; so trifft die Anfrage die Traefik-Route für /v1 mit Header nicht. prepareRequest setzt Header und Cookie.
|
||||
const url = new URL(`${APPWRITE_ENDPOINT}/storage/buckets/${IDS.bucket}/files/${encodeURIComponent(fileId)}/view`);
|
||||
const url = new URL(
|
||||
`${APPWRITE_ENDPOINT}/storage/buckets/${encodeURIComponent(bucketId)}/files/${encodeURIComponent(fileId)}/view`,
|
||||
);
|
||||
const { uri, options } = client.prepareRequest('GET', url);
|
||||
const res = await fetch(uri, options);
|
||||
if (!res.ok) {
|
||||
@@ -211,7 +234,8 @@ export function appwriteCardsApi(): CardsApi {
|
||||
deleteFile: async (fileId) => {
|
||||
await storage.deleteFile({ bucketId: IDS.bucket, fileId });
|
||||
},
|
||||
fetchFile,
|
||||
// Die privaten Karten liegen im Karten-Bucket; die Galerie holt ihre Bilder über `fetchFile` mit eigenem Bucket.
|
||||
fetchFile: (fileId) => fetchFile(IDS.bucket, fileId),
|
||||
createRow: (data, permissions) =>
|
||||
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
|
||||
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { errorMessage, errorType, isInvalidLinkError, isUnauthorized, type ErrorContext } from './errors';
|
||||
import { errorMessage, errorType, isInvalidLinkError, isRowMissing, isUnauthorized, type ErrorContext } from './errors';
|
||||
|
||||
/** Fehler wie vom SDK (AppwriteException): Felder code, type, message. */
|
||||
const fail = (code: number, type: string, message = '') => ({ code, type, message });
|
||||
@@ -149,6 +149,33 @@ describe('errorType', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRowMissing', () => {
|
||||
it('404 mit row_not_found heißt: die Zeile gibt es nicht (oder nicht für mich)', () => {
|
||||
expect(isRowMissing(fail(404, 'row_not_found'))).toBe(true);
|
||||
});
|
||||
|
||||
it('404 ohne Typ (leer oder fehlend) zählt ebenso', () => {
|
||||
expect(isRowMissing(fail(404, ''))).toBe(true);
|
||||
expect(isRowMissing({ code: 404 })).toBe(true);
|
||||
});
|
||||
|
||||
it('eine fehlende Tabelle, Datenbank oder Datei ist ein echter Fehler, kein „Zeile weg“', () => {
|
||||
for (const type of ['table_not_found', 'database_not_found', 'storage_file_not_found']) {
|
||||
expect(isRowMissing(fail(404, type)), type).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('andere Status und Nicht-Fehler nicht', () => {
|
||||
expect(isRowMissing(fail(401, 'row_not_found'))).toBe(false);
|
||||
expect(isRowMissing(fail(500, 'row_not_found'))).toBe(false);
|
||||
expect(isRowMissing({ code: '404', type: 'row_not_found' })).toBe(false);
|
||||
expect(isRowMissing(new TypeError('Failed to fetch'))).toBe(false);
|
||||
expect(isRowMissing('row_not_found')).toBe(false);
|
||||
expect(isRowMissing(null)).toBe(false);
|
||||
expect(isRowMissing(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInvalidLinkError (Passwort-neu-Seite)', () => {
|
||||
it('Link-Fehler', () => {
|
||||
expect(isInvalidLinkError(fail(401, 'user_invalid_token'))).toBe(true);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Deutsche Fehlermeldungen für Appwrite-Fehler. Rein und ohne Appwrite-Import, damit gut testbar.
|
||||
// Fehlermeldungen für Appwrite-Fehler in der Sprache der Seite: Die deutschen Texte sind die Schlüssel für t() (i18n/en.ts).
|
||||
// Rein und ohne Appwrite-Import, damit gut testbar.
|
||||
// Ein AppwriteException trägt `code` (HTTP-Status), `type` (z. B. user_invalid_credentials) und `message`.
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
|
||||
export type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load';
|
||||
|
||||
const LINK_INVALID = 'Der Link ist ungültig oder abgelaufen.';
|
||||
const WRONG_CREDENTIALS = 'E-Mail oder Passwort ist falsch.';
|
||||
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
|
||||
const LINK_INVALID = msg('Der Link ist ungültig oder abgelaufen.');
|
||||
const WRONG_CREDENTIALS = msg('E-Mail oder Passwort ist falsch.');
|
||||
const GENERIC = msg('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
|
||||
function field(err: unknown, key: 'code' | 'type' | 'message'): unknown {
|
||||
return typeof err === 'object' && err !== null ? (err as Record<string, unknown>)[key] : undefined;
|
||||
@@ -22,6 +24,15 @@ export function isUnauthorized(err: unknown): boolean {
|
||||
return field(err, 'code') === 401;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Die Zeile gibt es nicht (oder nicht für mich)“: 404 `row_not_found` (auch ein 404 ohne Typ). Eine fehlende Tabelle, Datenbank oder
|
||||
* Datei ist dagegen ein echter Fehler. Die Einzel-Abfragen der Tabellen (`getGalleryCard`, `getLogo`, `getUsername`) werten damit 404 als „nicht da“.
|
||||
*/
|
||||
export function isRowMissing(err: unknown): boolean {
|
||||
const type = field(err, 'type');
|
||||
return field(err, 'code') === 404 && (type === 'row_not_found' || type === undefined || type === '');
|
||||
}
|
||||
|
||||
/** Bei `general_argument_invalid` nennt die Nachricht den Parameter: „Invalid `email` param: …“. */
|
||||
function invalidParam(err: unknown): string | null {
|
||||
const message = field(err, 'message');
|
||||
@@ -40,46 +51,46 @@ export function isInvalidLinkError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
function passwordMessage(err: unknown, ctx: ErrorContext): string {
|
||||
if (ctx === 'login') return WRONG_CREDENTIALS;
|
||||
if (ctx === 'login') return t(WRONG_CREDENTIALS);
|
||||
const message = field(err, 'message');
|
||||
if (typeof message === 'string' && message.includes('commonly used')) {
|
||||
return 'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.';
|
||||
return t('Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.');
|
||||
}
|
||||
return 'Das Passwort muss 8 bis 256 Zeichen lang sein.';
|
||||
return t('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
/** Meldung für Nutzer: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
|
||||
/** Meldung für Nutzer in der Sprache der Seite: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
|
||||
export function errorMessage(err: unknown, ctx: ErrorContext): string {
|
||||
switch (errorType(err)) {
|
||||
case 'user_invalid_credentials':
|
||||
return WRONG_CREDENTIALS;
|
||||
return t(WRONG_CREDENTIALS);
|
||||
case 'user_already_exists':
|
||||
return 'Diese E-Mail ist schon registriert. Melde dich an.';
|
||||
return t('Diese E-Mail ist schon registriert. Melde dich an.');
|
||||
case 'user_invalid_token':
|
||||
return LINK_INVALID;
|
||||
return t(LINK_INVALID);
|
||||
case 'user_not_found':
|
||||
if (ctx === 'recover') return 'Zu dieser E-Mail gibt es kein Konto.';
|
||||
if (ctx === 'reset') return LINK_INVALID;
|
||||
if (ctx === 'recover') return t('Zu dieser E-Mail gibt es kein Konto.');
|
||||
if (ctx === 'reset') return t(LINK_INVALID);
|
||||
break;
|
||||
case 'general_argument_invalid': {
|
||||
const param = invalidParam(err);
|
||||
if (param === 'email') return 'Bitte gib eine gültige E-Mail-Adresse ein.';
|
||||
if (param === 'email') return t('Bitte gib eine gültige E-Mail-Adresse ein.');
|
||||
if (param === 'password') return passwordMessage(err, ctx);
|
||||
if (param === 'userId' || param === 'secret') return LINK_INVALID;
|
||||
if (param === 'userId' || param === 'secret') return t(LINK_INVALID);
|
||||
break;
|
||||
}
|
||||
case 'row_not_found':
|
||||
return 'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.';
|
||||
return t('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.');
|
||||
case 'storage_file_not_found':
|
||||
return 'Das Bild dieser Karte fehlt.';
|
||||
return t('Das Bild dieser Karte fehlt.');
|
||||
case 'general_unknown_origin':
|
||||
return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.';
|
||||
return t('Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.');
|
||||
case 'user_session_already_exists':
|
||||
return 'Du bist schon angemeldet. Bitte lade die Seite neu.';
|
||||
return t('Du bist schon angemeldet. Bitte lade die Seite neu.');
|
||||
case 'user_count_exceeded':
|
||||
return 'Zurzeit sind keine neuen Konten möglich.';
|
||||
return t('Zurzeit sind keine neuen Konten möglich.');
|
||||
}
|
||||
if (field(err, 'code') === 429) return 'Zu viele Versuche – bitte kurz warten.';
|
||||
if (err instanceof TypeError) return 'Keine Verbindung zum Server.';
|
||||
return GENERIC;
|
||||
if (field(err, 'code') === 429) return t('Zu viele Versuche – bitte kurz warten.');
|
||||
if (err instanceof TypeError) return t('Keine Verbindung zum Server.');
|
||||
return t(GENERIC);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
@@ -9,9 +9,22 @@ const mocks = vi.hoisted(() => ({
|
||||
updateRecovery: vi.fn(),
|
||||
}));
|
||||
|
||||
// Die Namensprüfung im Netz und der Namensdialog sind ersetzt; Muster, Sperrliste und der Merker „schon gefragt“ bleiben echt
|
||||
// (profile.ts wird nach jedem Zurücksetzen der Module mit `doMock` neu ersetzt, siehe beforeEach).
|
||||
const profileMocks = vi.hoisted(() => ({
|
||||
isUsernameTaken: vi.fn(),
|
||||
saveUsername: vi.fn(),
|
||||
usernameDialog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./appwrite', () => ({ account: mocks }));
|
||||
vi.mock('./username-dialog', () => ({ usernameDialog: profileMocks.usernameDialog }));
|
||||
|
||||
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
|
||||
const INVALID =
|
||||
'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.';
|
||||
const TAKEN = 'Dieser Benutzername ist schon vergeben.';
|
||||
const PROMPTED_KEY = 'aircard.usernamePrompted';
|
||||
|
||||
type Dialog = typeof import('./login-dialog');
|
||||
let requireLogin: Dialog['requireLogin'];
|
||||
@@ -19,7 +32,17 @@ let opener: HTMLButtonElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
for (const fn of Object.values(profileMocks)) fn.mockReset();
|
||||
profileMocks.isUsernameTaken.mockResolvedValue(false); // der Name ist frei
|
||||
profileMocks.saveUsername.mockImplementation(async (name: string) => name);
|
||||
profileMocks.usernameDialog.mockResolvedValue(null);
|
||||
sessionStorage.clear();
|
||||
vi.resetModules();
|
||||
vi.doMock('./profile', async () => ({
|
||||
...(await vi.importActual<typeof import('./profile')>('./profile')),
|
||||
isUsernameTaken: profileMocks.isUsernameTaken,
|
||||
saveUsername: profileMocks.saveUsername,
|
||||
}));
|
||||
document.body.innerHTML = '<div id="account-slot"></div><button id="opener">Speichern</button>';
|
||||
opener = document.getElementById('opener') as HTMLButtonElement;
|
||||
opener.focus();
|
||||
@@ -51,6 +74,7 @@ describe('requireLogin: Öffnen und Schließen', () => {
|
||||
it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => {
|
||||
void requireLogin('register');
|
||||
expect(title()).toBe('Registrieren');
|
||||
expect(input('username').autocomplete).toBe('nickname');
|
||||
expect(input('password').autocomplete).toBe('new-password');
|
||||
expect(input('password-repeat').autocomplete).toBe('new-password');
|
||||
});
|
||||
@@ -213,13 +237,14 @@ describe('Registrieren', () => {
|
||||
|
||||
it('prüft vor dem Senden: Abgleich, dann Länge', () => {
|
||||
void requireLogin('register');
|
||||
fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
|
||||
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
|
||||
submit();
|
||||
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
|
||||
fill({ password: 'kurz', 'password-repeat': 'kurz' });
|
||||
submit();
|
||||
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled(); // erst die Eingaben, die ohne Netz zu prüfen sind
|
||||
});
|
||||
|
||||
it('legt das Konto an, meldet an und löst mit true auf', async () => {
|
||||
@@ -227,7 +252,7 @@ describe('Registrieren', () => {
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||
const result = requireLogin('register');
|
||||
fill({ email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||
fill({ username: 'Neu.Konto', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||
@@ -237,7 +262,7 @@ describe('Registrieren', () => {
|
||||
it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => {
|
||||
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||
void requireLogin('register');
|
||||
fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.'));
|
||||
const link = dialogEl().querySelector<HTMLButtonElement>('[role="alert"] button')!;
|
||||
@@ -248,6 +273,330 @@ describe('Registrieren', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Registrieren: Benutzername', () => {
|
||||
/** Meldet das Konto an wie ein erfolgreicher Server: anlegen, Sitzung, Nutzer. */
|
||||
function serverAccepts(): void {
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||
}
|
||||
const fields = () => ({ username: 'Kenso', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||
const names = () => [...dialogEl().querySelectorAll('input')].map((i) => i.name);
|
||||
|
||||
it('das Pflichtfeld „Benutzername“ steht vor der E-Mail, mit Hinweis, und hat zuerst den Fokus', () => {
|
||||
void requireLogin('register');
|
||||
expect(names()).toEqual(['username', 'email', 'password', 'password-repeat']);
|
||||
const field = input('username');
|
||||
expect(field.type).toBe('text');
|
||||
expect(field.required).toBe(true);
|
||||
expect(field.autocomplete).toBe('nickname');
|
||||
expect(field.getAttribute('autocapitalize')).toBe('none');
|
||||
expect(field.getAttribute('spellcheck')).toBe('false');
|
||||
expect(field.labels![0].textContent).toContain('Benutzername');
|
||||
const hint = dialogEl().querySelector(`#${field.getAttribute('aria-describedby')}`)!;
|
||||
expect(hint.textContent).toBe('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
|
||||
expect(document.activeElement).toBe(field);
|
||||
});
|
||||
|
||||
it('kein Feld trägt autocomplete „username“: angemeldet wird per E-Mail, für Passwortmanager bleibt sie der Anmeldename', () => {
|
||||
const tokens = () => [...dialogEl().querySelectorAll('input')].map((i) => i.autocomplete);
|
||||
void requireLogin('register');
|
||||
expect(tokens()).toEqual(['nickname', 'email', 'new-password', 'new-password']);
|
||||
buttonByText('Anmelden').click();
|
||||
expect(tokens()).toEqual(['email', 'current-password']);
|
||||
buttonByText('Passwort vergessen?').click();
|
||||
expect(tokens()).toEqual(['email']);
|
||||
});
|
||||
|
||||
it('die Anmelden-Ansicht hat kein Namensfeld', () => {
|
||||
void requireLogin('login');
|
||||
expect(names()).toEqual(['email', 'password']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['leer', '', INVALID],
|
||||
['nur Leerzeichen', ' ', INVALID],
|
||||
['zu kurz', 'ab', INVALID],
|
||||
['zu lang', 'x'.repeat(21), INVALID],
|
||||
['falsches erstes Zeichen', '_abc', INVALID],
|
||||
['Leerzeichen im Namen', 'ke nso', INVALID],
|
||||
['Umlaut', 'Käse', INVALID],
|
||||
['gesperrt', 'Admin', 'Dieser Benutzername ist nicht erlaubt.'],
|
||||
['gesperrt in anderer Schreibweise', 'AirCard-IOS', 'Dieser Benutzername ist nicht erlaubt.'],
|
||||
])('%s: der Fehler steht am Feld, es gibt keine Anfrage und kein Konto', (_label, name, message) => {
|
||||
void requireLogin('register');
|
||||
fill({ ...fields(), username: name });
|
||||
submit();
|
||||
expect(alertText()).toBe(message);
|
||||
const field = input('username');
|
||||
expect(field.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(field.getAttribute('aria-describedby')!.split(' ')).toContain(dialogEl().querySelector('[role="alert"]')!.id);
|
||||
expect(document.activeElement).toBe(field);
|
||||
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled();
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
expect(dialogEl().open).toBe(true);
|
||||
});
|
||||
|
||||
it('der Name wird vor den Passwörtern geprüft (Reihenfolge der Felder)', () => {
|
||||
void requireLogin('register');
|
||||
fill({ ...fields(), username: 'x', 'password-repeat': 'anders1234' });
|
||||
submit();
|
||||
expect(alertText()).toBe(INVALID);
|
||||
expect(document.activeElement).toBe(input('username'));
|
||||
});
|
||||
|
||||
it('die Markierung des Feldes fällt beim nächsten Absenden weg; der Hinweis bleibt verknüpft', async () => {
|
||||
serverAccepts();
|
||||
void requireLogin('register');
|
||||
fill({ ...fields(), username: 'x' });
|
||||
submit();
|
||||
expect(input('username').getAttribute('aria-describedby')!.split(' ')).toHaveLength(2);
|
||||
fill({ username: 'Kenso' });
|
||||
submit();
|
||||
expect(input('username').hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(input('username').getAttribute('aria-describedby')!.split(' ')).toHaveLength(1);
|
||||
await vi.waitFor(() => expect(mocks.create).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('vergebener Name (Review-Fokus): Fehler am Feld, bevor ein Konto angelegt wird – kein halbes Konto', async () => {
|
||||
profileMocks.isUsernameTaken.mockResolvedValue(true);
|
||||
void requireLogin('register');
|
||||
fill({ ...fields(), username: ' kenso ' });
|
||||
dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.focus(); // der Nutzer hat den Knopf gedrückt
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
|
||||
expect(profileMocks.isUsernameTaken).toHaveBeenCalledExactlyOnceWith('kenso'); // getrimmt, so wie er gespeichert würde
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
expect(mocks.createEmailPasswordSession).not.toHaveBeenCalled();
|
||||
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
|
||||
const field = input('username');
|
||||
expect(field.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(field.getAttribute('aria-describedby')!.split(' ')).toContain(dialogEl().querySelector('[role="alert"]')!.id);
|
||||
expect(document.activeElement).toBe(field); // der Fokus geht an das Feld, nicht zurück auf den Knopf
|
||||
expect(dialogEl().open).toBe(true);
|
||||
// das Formular ist wieder bedienbar
|
||||
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
expect(field.readOnly).toBe(false);
|
||||
expect(input('password').value).toBe('geheim123'); // nichts wurde verworfen
|
||||
});
|
||||
|
||||
it('nach „vergeben“ geht ein anderer Name: dann wird das Konto angelegt', async () => {
|
||||
serverAccepts();
|
||||
profileMocks.isUsernameTaken.mockResolvedValueOnce(true);
|
||||
const result = requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
|
||||
fill({ username: 'Kenso2' });
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(profileMocks.isUsernameTaken).toHaveBeenLastCalledWith('Kenso2');
|
||||
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||
expect(profileMocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso2');
|
||||
});
|
||||
|
||||
it('lässt sich die Verfügbarkeit nicht prüfen (kein Netz), wird kein Konto angelegt und der Fehler erklärt', async () => {
|
||||
profileMocks.isUsernameTaken.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
let settled = false;
|
||||
void requireLogin('register').then(() => (settled = true));
|
||||
fill(fields());
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.'));
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
expect(settled).toBe(false);
|
||||
expect(dialogEl().open).toBe(true);
|
||||
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('der Ablauf: Verfügbarkeit prüfen, Konto anlegen, anmelden, Namen speichern – erst dann ist der Dialog fertig', async () => {
|
||||
serverAccepts();
|
||||
const order: string[] = [];
|
||||
profileMocks.isUsernameTaken.mockImplementation(async () => (order.push('prüfen'), false));
|
||||
mocks.create.mockImplementation(async () => (order.push('anlegen'), {}));
|
||||
mocks.createEmailPasswordSession.mockImplementation(async () => (order.push('anmelden'), {}));
|
||||
profileMocks.saveUsername.mockImplementation(async (name: string) => (order.push(`speichern ${name}`), name));
|
||||
const result = requireLogin('register');
|
||||
fill({ ...fields(), username: ' Kenso ' });
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(order).toEqual(['prüfen', 'anlegen', 'anmelden', 'speichern Kenso']);
|
||||
expect(mocks.create.mock.calls[0][0]).toMatchObject({ email: 'neu@b.de', password: 'geheim123' });
|
||||
expect(dialogEl().open).toBe(false);
|
||||
expect(profileMocks.usernameDialog).not.toHaveBeenCalled(); // Name gespeichert: nichts mehr zu fragen
|
||||
});
|
||||
|
||||
it('während geprüft und angelegt wird, ist das Formular gesperrt und ein zweites Absenden wird ignoriert', async () => {
|
||||
serverAccepts();
|
||||
let finishCheck!: (taken: boolean) => void;
|
||||
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
|
||||
const result = requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
submit();
|
||||
expect(profileMocks.isUsernameTaken).toHaveBeenCalledTimes(1);
|
||||
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
|
||||
expect(input('username').readOnly).toBe(true);
|
||||
finishCheck(false);
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('scheitert das Speichern des Namens (Rennen, Netz), ist man trotzdem angemeldet: der Dialog schließt mit true, der Namensdialog erscheint', async () => {
|
||||
serverAccepts();
|
||||
const failure = new Error('409');
|
||||
profileMocks.saveUsername.mockRejectedValue(failure);
|
||||
const result = requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(dialogEl().open).toBe(false);
|
||||
expect(mocks.get).toHaveBeenCalled(); // angemeldet
|
||||
// Der Namensdialog öffnet mit dem getippten Namen und dem Grund (er steht im Dialog), nicht leer und stumm.
|
||||
expect(profileMocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'prompt', initial: 'Kenso', failure });
|
||||
});
|
||||
|
||||
it('der getippte Name geht getrimmt in den Namensdialog, genau der Fehler des Speicherns als Grund', async () => {
|
||||
serverAccepts();
|
||||
const taken = Object.assign(new Error('vergeben'), { code: 409 });
|
||||
profileMocks.saveUsername.mockRejectedValue(taken);
|
||||
const result = requireLogin('register');
|
||||
fill({ ...fields(), username: ' Mia.K ' });
|
||||
submit();
|
||||
await result;
|
||||
expect(profileMocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Mia.K');
|
||||
expect(profileMocks.usernameDialog.mock.calls[0][0]).toMatchObject({ initial: 'Mia.K' });
|
||||
expect(profileMocks.usernameDialog.mock.calls[0][0].failure).toBe(taken);
|
||||
});
|
||||
|
||||
it('der Namensdialog kommt erst, wenn der Login-Dialog zu ist, und ein Fehler beim Öffnen bleibt ohne Folgen', async () => {
|
||||
serverAccepts();
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
profileMocks.saveUsername.mockRejectedValue(new Error('offline'));
|
||||
let loginOpenWhenAsked: boolean | undefined;
|
||||
profileMocks.usernameDialog.mockImplementation(async () => {
|
||||
loginOpenWhenAsked = dialogEl().open;
|
||||
throw new Error('showModal kaputt');
|
||||
});
|
||||
const result = requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
expect(loginOpenWhenAsked).toBe(false);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it('das Konto-Anlegen scheitert (E-Mail vergeben): kein Namensdialog, kein Speichern', async () => {
|
||||
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||
void requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert.'));
|
||||
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
|
||||
expect(profileMocks.usernameDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Merker „in dieser Sitzung schon gefragt“: gesetzt, bevor die Anmeldung läuft (sie löst die einmalige Abfrage aus), und danach noch da', async () => {
|
||||
serverAccepts();
|
||||
let flagWhenAccountCreated: string | null | undefined;
|
||||
mocks.create.mockImplementation(async () => {
|
||||
flagWhenAccountCreated = sessionStorage.getItem(PROMPTED_KEY);
|
||||
return {};
|
||||
});
|
||||
const result = requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(flagWhenAccountCreated).toBe('1');
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
|
||||
});
|
||||
|
||||
it('scheitert das Anlegen des Kontos, nimmt der Dialog den Merker wieder weg (wer sich danach anmeldet, wird noch gefragt)', async () => {
|
||||
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||
void requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toContain('schon registriert'));
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('war schon gefragt worden, bleibt der Merker auch nach einem gescheiterten Anlegen', async () => {
|
||||
sessionStorage.setItem(PROMPTED_KEY, '1');
|
||||
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
|
||||
void requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toContain('schon registriert'));
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
|
||||
});
|
||||
|
||||
it('wird der Dialog während der Prüfung geschlossen, entsteht kein Konto', async () => {
|
||||
serverAccepts();
|
||||
let finishCheck!: (taken: boolean) => void;
|
||||
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
|
||||
const result = requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
|
||||
await expect(result).resolves.toBe(false);
|
||||
finishCheck(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('wird der Dialog während der Prüfung geschlossen und der Name ist vergeben, erscheint nichts mehr', async () => {
|
||||
let finishCheck!: (taken: boolean) => void;
|
||||
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
|
||||
const result = requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
|
||||
await expect(result).resolves.toBe(false);
|
||||
finishCheck(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(dialogEl().querySelector('[role="alert"]')?.textContent ?? '').toBe('');
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('der getippte Name wandert beim Wechsel der Reiter mit (wie die E-Mail), Passwörter nie', () => {
|
||||
void requireLogin('register');
|
||||
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123' });
|
||||
buttonByText('Anmelden').click();
|
||||
expect(title()).toBe('Anmelden');
|
||||
expect(input('email').value).toBe('a@b.de');
|
||||
buttonByText('Registrieren').click();
|
||||
expect(input('username').value).toBe('Kenso');
|
||||
expect(input('email').value).toBe('a@b.de');
|
||||
expect(input('password').value).toBe('');
|
||||
});
|
||||
|
||||
it('nach dem Schließen sind Name und E-Mail weg, ein neuer Aufruf beginnt leer', async () => {
|
||||
const first = requireLogin('register');
|
||||
fill({ username: 'Kenso', email: 'a@b.de' });
|
||||
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
|
||||
await first;
|
||||
void requireLogin('register');
|
||||
expect(input('username').value).toBe('');
|
||||
expect(input('email').value).toBe('');
|
||||
expect(document.activeElement).toBe(input('username'));
|
||||
});
|
||||
|
||||
it('die anderen Ansichten bleiben unberührt: Anmelden meldet an, ohne Name zu prüfen oder zu speichern', async () => {
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
|
||||
const result = requireLogin();
|
||||
fill({ email: 'a@b.de', password: 'geheim123' });
|
||||
submit();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled();
|
||||
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
|
||||
expect(profileMocks.usernameDialog).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull(); // die einmalige Abfrage ist Sache von main.ts
|
||||
});
|
||||
});
|
||||
|
||||
describe('Passwort vergessen', () => {
|
||||
it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => {
|
||||
mocks.createRecovery.mockResolvedValue({});
|
||||
@@ -277,3 +626,57 @@ describe('Passwort vergessen', () => {
|
||||
expect(title()).toBe('Anmelden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Registrieren: die Fehlerzeile bleibt im Sichtbaren', () => {
|
||||
const fields = () => ({ username: 'Kenso', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
|
||||
const errorLine = () => dialogEl().querySelector('[role="alert"]');
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('ein Fehler am Namensfeld scrollt die gemeinsame Fehlerzeile (sie steht unter allen Feldern) ins Sichtbare, nur so weit wie nötig', () => {
|
||||
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
|
||||
void requireLogin('register');
|
||||
fill({ ...fields(), username: 'x' });
|
||||
submit();
|
||||
expect(alertText()).toBe(INVALID);
|
||||
expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' });
|
||||
expect(scroll.mock.contexts[0]).toBe(errorLine());
|
||||
expect(document.activeElement).toBe(input('username')); // der Fokus bleibt am Feld
|
||||
});
|
||||
|
||||
it('ein gesperrter Name ebenso, jeder neue Fehler scrollt wieder', () => {
|
||||
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
|
||||
void requireLogin('register');
|
||||
fill({ ...fields(), username: 'Admin' });
|
||||
submit();
|
||||
fill({ username: 'ab' });
|
||||
submit();
|
||||
expect(scroll).toHaveBeenCalledTimes(2);
|
||||
expect(scroll.mock.contexts.every((node) => node === errorLine())).toBe(true);
|
||||
});
|
||||
|
||||
it('„vergeben“ (nach der Abfrage am Dienst) ebenso', async () => {
|
||||
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
|
||||
profileMocks.isUsernameTaken.mockResolvedValue(true);
|
||||
void requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
|
||||
expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' });
|
||||
expect(scroll.mock.contexts[0]).toBe(errorLine());
|
||||
});
|
||||
|
||||
it('ohne Fehler am Feld wird nicht gescrollt (gültige Eingabe, Anmelden-Ansicht)', async () => {
|
||||
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
|
||||
mocks.create.mockResolvedValue({});
|
||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
|
||||
const registered = requireLogin('register');
|
||||
fill(fields());
|
||||
submit();
|
||||
await registered;
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,33 @@
|
||||
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
|
||||
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren (mit Benutzernamen) und „Passwort vergessen“.
|
||||
import './account.css';
|
||||
import { el } from '../editor/dom';
|
||||
import { el, setFieldInvalid } from '../editor/dom';
|
||||
import { API_MESSAGES } from '../i18n/api-messages';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { login, register, requestPasswordReset, validatePassword } from './auth';
|
||||
import { errorMessage, errorType, type ErrorContext } from './errors';
|
||||
import {
|
||||
USERNAME_HINT,
|
||||
clearUsernamePrompted,
|
||||
isUsernameTaken,
|
||||
markUsernamePrompted,
|
||||
saveUsername,
|
||||
usernamePrompted,
|
||||
validateUsername,
|
||||
} from './profile';
|
||||
import { usernameDialog } from './username-dialog';
|
||||
|
||||
export type LoginView = 'login' | 'register' | 'forgot';
|
||||
|
||||
const TITLES: Record<LoginView, string> = {
|
||||
login: 'Anmelden',
|
||||
register: 'Registrieren',
|
||||
forgot: 'Passwort vergessen',
|
||||
login: msg('Anmelden'),
|
||||
register: msg('Registrieren'),
|
||||
forgot: msg('Passwort vergessen'),
|
||||
};
|
||||
|
||||
const MISMATCH = 'Die Passwörter stimmen nicht überein.';
|
||||
const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.';
|
||||
const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
|
||||
const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.');
|
||||
/** Die Meldungszeile des Dialogs: ein Feld mit Fehler verweist mit `aria-describedby` auf sie. */
|
||||
const ERROR_ID = 'auth-error';
|
||||
|
||||
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
|
||||
|
||||
@@ -28,6 +42,7 @@ type Panel = {
|
||||
function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
let view: LoginView = 'login';
|
||||
let draftEmail = '';
|
||||
let draftUsername = '';
|
||||
let busy = false;
|
||||
// Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen.
|
||||
let session = 0;
|
||||
@@ -37,7 +52,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
|
||||
const closeButton = el(
|
||||
'button',
|
||||
{ type: 'button', class: 'auth-close', 'aria-label': 'Schließen' },
|
||||
{ type: 'button', class: 'auth-close', 'aria-label': t('Schließen') },
|
||||
el('span', { 'aria-hidden': 'true' }, '×'),
|
||||
);
|
||||
const title = el('h2', { id: 'auth-title', class: 'auth-title' });
|
||||
@@ -89,7 +104,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
function field(
|
||||
labelText: string,
|
||||
name: string,
|
||||
type: 'email' | 'password',
|
||||
type: 'text' | 'email' | 'password',
|
||||
autocomplete: string,
|
||||
value = '',
|
||||
describedBy?: string,
|
||||
@@ -101,8 +116,9 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
autocomplete,
|
||||
value,
|
||||
required: true,
|
||||
autocapitalize: type === 'email' ? 'none' : null,
|
||||
spellcheck: type === 'email' ? 'false' : null,
|
||||
// E-Mail-Adresse und Benutzername: keine automatische Großschreibung, keine Rechtschreibprüfung
|
||||
autocapitalize: type === 'password' ? null : 'none',
|
||||
spellcheck: type === 'password' ? null : 'false',
|
||||
'aria-describedby': describedBy,
|
||||
});
|
||||
const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input);
|
||||
@@ -135,7 +151,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
'aria-controls': 'auth-panel',
|
||||
tabindex: selected ? 0 : -1,
|
||||
},
|
||||
TITLES[tabView],
|
||||
t(TITLES[tabView]),
|
||||
);
|
||||
tab.addEventListener('click', () => select(tabView));
|
||||
tab.addEventListener('keydown', (event) => {
|
||||
@@ -145,7 +161,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
});
|
||||
return tab;
|
||||
});
|
||||
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': 'Konto' }, ...tabs);
|
||||
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': t('Konto') }, ...tabs);
|
||||
}
|
||||
|
||||
/** Setzt oder sperrt alle Bedienelemente, solange eine Anfrage läuft; Eingabefelder bleiben fokussierbar. */
|
||||
@@ -161,25 +177,40 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
panel.errorEl.replaceChildren(text, ...(extra ? [' ', extra] : []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Fehler am Eingabefeld, das ihn verursacht: der Text steht in der Meldungszeile, das Feld gilt als ungültig und bekommt den Fokus.
|
||||
* Die Meldungszeile steht unter allen Feldern, bei einem Fehler am ersten (dem Namen) kann sie auf einem kleinen Bildschirm außerhalb
|
||||
* liegen: sie wird ins Sichtbare gerückt, nur so weit wie nötig.
|
||||
*/
|
||||
function showFieldError(panel: Panel, input: HTMLInputElement, text: string): void {
|
||||
showError(panel, text);
|
||||
setFieldInvalid(input, ERROR_ID, true);
|
||||
input.focus();
|
||||
panel.errorEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function showOk(panel: Panel, text: string): void {
|
||||
panel.errorEl.textContent = '';
|
||||
panel.okEl.textContent = text;
|
||||
}
|
||||
|
||||
/** Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück. */
|
||||
/**
|
||||
* Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück: an das Feld, das die Aktion nennt
|
||||
* (sie hat dort einen Fehler gezeigt), sonst dorthin, wo er vorher war.
|
||||
*/
|
||||
async function run(
|
||||
panel: Panel,
|
||||
ctx: ErrorContext,
|
||||
action: () => Promise<void>,
|
||||
action: () => Promise<HTMLElement | void>,
|
||||
onError?: (err: unknown) => Node | undefined,
|
||||
): Promise<void> {
|
||||
const mine = session;
|
||||
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
let focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
setBusy(panel, true);
|
||||
panel.errorEl.textContent = '';
|
||||
panel.okEl.textContent = '';
|
||||
try {
|
||||
await action();
|
||||
focused = (await action()) ?? focused;
|
||||
} catch (err) {
|
||||
if (mine !== session) return;
|
||||
showError(panel, errorMessage(err, ctx), onError?.(err));
|
||||
@@ -189,17 +220,63 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
if (focused?.isConnected && dialog.contains(focused)) focused.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrieren mit Benutzernamen (Spec § 5): erst die Verfügbarkeit des Namens, dann Konto anlegen und anmelden, zuletzt den Namen speichern.
|
||||
* Ist der Name vergeben, steht der Fehler am Feld, bevor ein Konto entsteht (sonst bliebe ein Konto ohne Namen zurück). Scheitert nur das
|
||||
* Speichern (Rennen um den Namen, Netz weg), ist man trotzdem angemeldet: der Dialog schließt, und der Namensdialog fragt noch einmal.
|
||||
*/
|
||||
async function registerWithName(
|
||||
panel: Panel,
|
||||
nameInput: HTMLInputElement,
|
||||
name: string,
|
||||
address: string,
|
||||
password: string,
|
||||
): Promise<HTMLElement | void> {
|
||||
const mine = session;
|
||||
const taken = await isUsernameTaken(name);
|
||||
if (mine !== session) return; // der Dialog wurde inzwischen geschlossen: nichts mehr anlegen, nichts mehr zeigen
|
||||
if (taken) {
|
||||
showFieldError(panel, nameInput, t(API_MESSAGES.usernameTaken));
|
||||
return nameInput;
|
||||
}
|
||||
// Schon die Anmeldung löst in main.ts die einmalige Abfrage nach dem Namen aus; hier wurde er gerade erst eingegeben. Darum steht der
|
||||
// Merker „in dieser Sitzung schon gefragt“ vor dem Anlegen. Scheitert es, nimmt der Dialog ihn wieder weg (wer sich danach anmeldet, wird gefragt).
|
||||
const asked = usernamePrompted();
|
||||
markUsernamePrompted();
|
||||
try {
|
||||
await register(address, password);
|
||||
} catch (err) {
|
||||
if (!asked) clearUsernamePrompted();
|
||||
throw err;
|
||||
}
|
||||
let failure: unknown;
|
||||
let saved = true;
|
||||
try {
|
||||
await saveUsername(name);
|
||||
} catch (err) {
|
||||
saved = false;
|
||||
failure = err;
|
||||
}
|
||||
finish(true);
|
||||
if (!saved) {
|
||||
// Der Namensdialog beginnt mit dem getippten Namen und nennt den Grund (vergeben, kein Netz …), statt leer und stumm zu erscheinen.
|
||||
usernameDialog({ mode: 'prompt', initial: name, failure }).catch((err: unknown) => {
|
||||
console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildPanel(forView: LoginView): Panel {
|
||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
|
||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
|
||||
const okEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
|
||||
const form = el('form', { class: 'auth-form' });
|
||||
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
|
||||
|
||||
if (forView === 'login') {
|
||||
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
|
||||
const password = field('Passwort', 'password', 'password', 'current-password');
|
||||
const forgot = linkButton('Passwort vergessen?', () => showView('forgot'));
|
||||
form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, forgot));
|
||||
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
|
||||
const password = field(t('Passwort'), 'password', 'password', 'current-password');
|
||||
const forgot = linkButton(t('Passwort vergessen?'), () => showView('forgot'));
|
||||
form.append(email.label, password.label, errorEl, okEl, submit(t('Anmelden')), el('p', { class: 'auth-aside' }, forgot));
|
||||
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
@@ -214,41 +291,52 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
}
|
||||
|
||||
if (forView === 'register') {
|
||||
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
|
||||
const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint');
|
||||
const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password');
|
||||
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.');
|
||||
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit('Registrieren'));
|
||||
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
|
||||
// `nickname`, nicht `username`: angemeldet wird per E-Mail, und ein Passwortmanager soll das E-Mail-Feld als Anmeldenamen behalten.
|
||||
const username = field(t('Benutzername'), 'username', 'text', 'nickname', draftUsername, 'auth-name-hint');
|
||||
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
|
||||
const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
|
||||
const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password');
|
||||
const nameHint = el('p', { class: 'auth-hint', id: 'auth-name-hint' }, t(USERNAME_HINT));
|
||||
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.'));
|
||||
form.append(username.label, nameHint, email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
|
||||
// Der Fokus geht an das erste Feld, das noch leer ist.
|
||||
const first = !draftUsername ? username.input : !draftEmail ? email.input : password.input;
|
||||
const panel: Panel = { form, first, errorEl, okEl };
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setFieldInvalid(username.input, ERROR_ID, false);
|
||||
const name = username.input.value.trim(); // der Dienst trimmt nicht
|
||||
const address = email.input.value.trim();
|
||||
const problem = password.input.value !== repeat.input.value ? MISMATCH : validatePassword(password.input.value);
|
||||
// Der Fehler liegt am Feld, das ihn verursacht (in der Reihenfolge der Felder); verglichen wird nie mit dem (übersetzten) Text.
|
||||
const nameProblem = validateUsername(name);
|
||||
if (nameProblem) {
|
||||
showFieldError(panel, username.input, nameProblem);
|
||||
return;
|
||||
}
|
||||
const mismatch = password.input.value !== repeat.input.value;
|
||||
const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value);
|
||||
if (problem) {
|
||||
showError(panel, problem);
|
||||
(problem === MISMATCH ? repeat : password).input.focus();
|
||||
(mismatch ? repeat : password).input.focus();
|
||||
return;
|
||||
}
|
||||
void run(
|
||||
panel,
|
||||
'register',
|
||||
async () => {
|
||||
await register(address, password.input.value);
|
||||
finish(true);
|
||||
},
|
||||
() => registerWithName(panel, username.input, name, address, password.input.value),
|
||||
(err) => {
|
||||
if (errorType(err) !== 'user_already_exists') return undefined;
|
||||
return linkButton('Anmelden', () => showView('login'));
|
||||
return linkButton(t('Anmelden'), () => showView('login'));
|
||||
},
|
||||
);
|
||||
});
|
||||
return panel;
|
||||
}
|
||||
|
||||
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
|
||||
const back = linkButton('Zurück zur Anmeldung', () => showView('login'));
|
||||
form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back));
|
||||
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
|
||||
const back = linkButton(t('Zurück zur Anmeldung'), () => showView('login'));
|
||||
form.append(email.label, errorEl, okEl, submit(t('Link senden')), el('p', { class: 'auth-aside' }, back));
|
||||
const panel: Panel = { form, first: email.input, errorEl, okEl };
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
@@ -256,7 +344,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
const address = email.input.value.trim();
|
||||
void run(panel, 'recover', async () => {
|
||||
await requestPasswordReset(address);
|
||||
showOk(panel, RESET_SENT);
|
||||
showOk(panel, t(RESET_SENT));
|
||||
});
|
||||
});
|
||||
return panel;
|
||||
@@ -264,11 +352,13 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
|
||||
/** Zeigt eine Ansicht; `focus` bestimmt, ob danach das erste Feld oder der gewählte Reiter den Fokus bekommt. */
|
||||
function showView(next: LoginView, focus: 'input' | 'tab' = 'input'): void {
|
||||
// Die eingegebene E-Mail wandert beim Wechsel in die neue Ansicht mit; Passwörter nie.
|
||||
// Die eingegebene E-Mail und der Benutzername wandern beim Wechsel in die neue Ansicht mit; Passwörter nie.
|
||||
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
|
||||
if (typed) draftEmail = typed.value.trim();
|
||||
const typedName = content.querySelector<HTMLInputElement>('input[name="username"]');
|
||||
if (typedName) draftUsername = typedName.value.trim();
|
||||
view = next;
|
||||
title.textContent = TITLES[next];
|
||||
title.textContent = t(TITLES[next]);
|
||||
const panel = buildPanel(next);
|
||||
if (next === 'forgot') {
|
||||
content.replaceChildren(panel.form);
|
||||
@@ -284,10 +374,13 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
||||
if (pending) return pending;
|
||||
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
draftEmail = '';
|
||||
draftUsername = '';
|
||||
busy = false;
|
||||
pending = new Promise<boolean>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
// Der Dialog bleibt zwischen den Aufrufen im DOM; die Sprache der Seite kann sich seit dem Bauen geändert haben.
|
||||
closeButton.setAttribute('aria-label', t('Schließen'));
|
||||
showView(initial);
|
||||
try {
|
||||
dialog.showModal();
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { Query } from 'appwrite';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listRows: vi.fn(),
|
||||
getRow: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
userId: null as string | null,
|
||||
}));
|
||||
|
||||
vi.mock('./appwrite', () => ({
|
||||
IDS: { database: 'db', profiles: 'profiles-table' },
|
||||
tables: { listRows: mocks.listRows, getRow: mocks.getRow },
|
||||
account: {},
|
||||
client: {},
|
||||
storage: {},
|
||||
APPWRITE_ENDPOINT: '',
|
||||
}));
|
||||
vi.mock('./auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||||
// Nur der Netzaufruf ist ersetzt; ApiError und unavailableError bleiben echt.
|
||||
vi.mock('../community/api', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../community/api')>()),
|
||||
apiPost: mocks.apiPost,
|
||||
}));
|
||||
|
||||
// Muster, Sperrliste und Schlüssel liegen im Dienst (deploy/api/common.js, CommonJS ohne Abhängigkeiten); der Test lädt sie von dort.
|
||||
const server = createRequire(import.meta.url)('../../../deploy/api/common.js') as {
|
||||
USERNAME_PATTERN: RegExp;
|
||||
RESERVED_USERNAMES: readonly string[];
|
||||
usernameKey: (name: string) => string;
|
||||
isReservedUsername: (name: string) => boolean;
|
||||
};
|
||||
|
||||
const INVALID = '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.';
|
||||
const RESERVED = 'Dieser Benutzername ist nicht erlaubt.';
|
||||
|
||||
const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type });
|
||||
|
||||
type Profile = typeof import('./profile');
|
||||
let profile: Profile;
|
||||
/** Das Modul hält den Zwischenspeicher der Namen: jeder Test bekommt eine frische Instanz (samt i18n und ApiError). */
|
||||
let ApiError: typeof import('../community/api').ApiError;
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const fn of [mocks.listRows, mocks.getRow, mocks.apiPost]) fn.mockReset();
|
||||
mocks.userId = null;
|
||||
sessionStorage.clear();
|
||||
vi.resetModules();
|
||||
profile = await import('./profile');
|
||||
({ ApiError } = await import('../community/api'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Regeln im Gleichlauf mit dem API-Dienst (deploy/api/common.js)', () => {
|
||||
it('der Dienst liefert seine Regeln (sonst bestünde der Vergleich leer)', () => {
|
||||
expect(server.RESERVED_USERNAMES.length).toBeGreaterThan(20);
|
||||
expect(server.USERNAME_PATTERN.test('Kenso')).toBe(true);
|
||||
});
|
||||
|
||||
it('das Muster ist dasselbe: genau ^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$', () => {
|
||||
expect(profile.USERNAME_PATTERN.source).toBe('^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$');
|
||||
expect(profile.USERNAME_PATTERN.source).toBe(server.USERNAME_PATTERN.source);
|
||||
expect(profile.USERNAME_PATTERN.flags).toBe(server.USERNAME_PATTERN.flags);
|
||||
});
|
||||
|
||||
it('die Sperrliste ist dieselbe (Einträge und Reihenfolge)', () => {
|
||||
expect([...profile.RESERVED_USERNAMES]).toEqual([...server.RESERVED_USERNAMES]);
|
||||
});
|
||||
|
||||
it('jeder gesperrte Schlüssel passt zum Muster (sonst wäre er wirkungslos: der Name scheiterte schon als ungültig)', () => {
|
||||
const unusable = profile.RESERVED_USERNAMES.filter((key) => !profile.USERNAME_PATTERN.test(key));
|
||||
expect(unusable).toEqual([]);
|
||||
});
|
||||
|
||||
it('Schlüssel und Sperre urteilen wie der Dienst', () => {
|
||||
for (const name of ['Kenso', 'KENSO', 'Admin', 'AirCard-IOS', 'aircard.ios', 'Mod', 'modx', 'a.b_c-d', 'Hilfe']) {
|
||||
expect(profile.usernameKey(name)).toBe(server.usernameKey(name));
|
||||
expect(profile.validateUsername(name) === RESERVED).toBe(server.isReservedUsername(name));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('usernameKey', () => {
|
||||
it('der Name in Kleinbuchstaben', () => {
|
||||
expect(profile.usernameKey('Kenso')).toBe('kenso');
|
||||
expect(profile.usernameKey('AirCard-IOS')).toBe('aircard-ios');
|
||||
expect(profile.usernameKey('a.B_c-9')).toBe('a.b_c-9');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateUsername', () => {
|
||||
it.each(['abc', 'a1b', 'Kenso', 'k.e_n-so', '0abc', '9..', 'x'.repeat(20), 'ABC', 'admin1', 'administrators', 'momo'])('gültig: %s', (name) => {
|
||||
expect(profile.validateUsername(name)).toBeNull();
|
||||
});
|
||||
|
||||
it('Grenzen: 3 Zeichen sind das Minimum, 20 das Maximum', () => {
|
||||
expect(profile.validateUsername('ab')).toBe(INVALID);
|
||||
expect(profile.validateUsername('abc')).toBeNull();
|
||||
expect(profile.validateUsername('x'.repeat(20))).toBeNull();
|
||||
expect(profile.validateUsername('x'.repeat(21))).toBe(INVALID);
|
||||
});
|
||||
|
||||
it('das erste Zeichen ist ein Buchstabe oder eine Zahl, kein Satzzeichen', () => {
|
||||
for (const name of ['_abc', '.abc', '-abc']) expect(profile.validateUsername(name)).toBe(INVALID);
|
||||
for (const name of ['a_c', 'a.c', 'a-c', '1bc']) expect(profile.validateUsername(name)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['', ' ', 'ab c', ' abc', 'abc ', 'abc\n', 'ab\tc', 'ab@c', 'ab/c', 'Käse', 'ünder', 'naïve', 'na😀me', 'ab:c', 'abc'])(
|
||||
'ungültig (Muster): %j',
|
||||
(name) => {
|
||||
expect(profile.validateUsername(name)).toBe(INVALID);
|
||||
},
|
||||
);
|
||||
|
||||
it('gesperrt, in jeder Schreibweise', () => {
|
||||
for (const key of profile.RESERVED_USERNAMES) {
|
||||
expect(profile.validateUsername(key), key).toBe(RESERVED);
|
||||
expect(profile.validateUsername(key.toUpperCase()), key).toBe(RESERVED);
|
||||
}
|
||||
expect(profile.validateUsername('Admin')).toBe(RESERVED);
|
||||
expect(profile.validateUsername('AirCard-IOS')).toBe(RESERVED);
|
||||
expect(profile.validateUsername('aIrCaRd.IoS')).toBe(RESERVED);
|
||||
});
|
||||
|
||||
it('das Muster geht vor der Sperrliste', () => {
|
||||
expect(profile.validateUsername('_admin')).toBe(INVALID);
|
||||
});
|
||||
|
||||
it('der Text folgt der Sprache der Seite', async () => {
|
||||
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||
expect(profile.validateUsername('a')).toBe(
|
||||
'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.',
|
||||
);
|
||||
expect(profile.validateUsername('Admin')).toBe('This username is not allowed.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IDS', () => {
|
||||
it('kennt die Tabelle profiles (Name wie im Setup-Skript)', async () => {
|
||||
vi.stubEnv('VITE_APPWRITE_ENDPOINT', 'https://aircard.test/v1');
|
||||
try {
|
||||
const { IDS } = await vi.importActual<typeof import('./appwrite')>('./appwrite');
|
||||
expect(IDS.profiles).toBe('profiles');
|
||||
expect(IDS.database).toBe('aircard'); // und das Bestehende bleibt
|
||||
expect(IDS.logos).toBe('logos');
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsername', () => {
|
||||
it('liest die Zeile mit der Konto-ID als Zeilen-ID', async () => {
|
||||
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso', usernameKey: 'kenso' });
|
||||
await expect(profile.getUsername('u1')).resolves.toBe('Kenso');
|
||||
expect(mocks.getRow).toHaveBeenCalledExactlyOnceWith({ databaseId: 'db', tableId: 'profiles-table', rowId: 'u1' });
|
||||
});
|
||||
|
||||
it('kein Profil (404) ergibt null', async () => {
|
||||
mocks.getRow.mockRejectedValue(notFound());
|
||||
await expect(profile.getUsername('u1')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('eine Zeile ohne lesbaren Namen ergibt null', async () => {
|
||||
mocks.getRow.mockResolvedValue({ $id: 'u1' });
|
||||
await expect(profile.getUsername('u1')).resolves.toBeNull();
|
||||
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 42 });
|
||||
await expect(profile.getUsername('u1')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage', async () => {
|
||||
for (const id of ['', '../x', 'a b', 'x'.repeat(40)]) await expect(profile.getUsername(id)).resolves.toBeNull();
|
||||
expect(mocks.getRow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('andere Fehler (Netz, 500, fehlende Tabelle) gehen durch', async () => {
|
||||
const offline = new TypeError('Failed to fetch');
|
||||
mocks.getRow.mockRejectedValueOnce(offline);
|
||||
await expect(profile.getUsername('u1')).rejects.toBe(offline);
|
||||
const server500 = Object.assign(new Error('boom'), { code: 500, type: 'general_server_error' });
|
||||
mocks.getRow.mockRejectedValueOnce(server500);
|
||||
await expect(profile.getUsername('u1')).rejects.toBe(server500);
|
||||
const noTable = notFound('table_not_found');
|
||||
mocks.getRow.mockRejectedValueOnce(noTable);
|
||||
await expect(profile.getUsername('u1')).rejects.toBe(noTable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usernamesFor', () => {
|
||||
const row = (id: string, username: string) => ({ $id: id, username, usernameKey: username.toLowerCase() });
|
||||
|
||||
it('sammelt die IDs in einer einzigen Abfrage (equal auf $id)', async () => {
|
||||
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso'), row('u2', 'Mia')] });
|
||||
const names = await profile.usernamesFor(['u1', 'u2']);
|
||||
expect([...names]).toEqual([
|
||||
['u1', 'Kenso'],
|
||||
['u2', 'Mia'],
|
||||
]);
|
||||
expect(mocks.listRows).toHaveBeenCalledExactlyOnceWith({
|
||||
databaseId: 'db',
|
||||
tableId: 'profiles-table',
|
||||
queries: [Query.equal('$id', ['u1', 'u2']), Query.limit(2)],
|
||||
});
|
||||
});
|
||||
|
||||
it('doppelte IDs zählen einmal, Unbrauchbares wird gar nicht erst gefragt', async () => {
|
||||
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso')] });
|
||||
await profile.usernamesFor(['u1', 'u1', '', '../x', 'u1']);
|
||||
expect(mocks.listRows.mock.calls[0][0].queries).toEqual([Query.equal('$id', ['u1']), Query.limit(1)]);
|
||||
});
|
||||
|
||||
it('nichts zu fragen: leere Map ohne Anfrage', async () => {
|
||||
expect((await profile.usernamesFor([])).size).toBe(0);
|
||||
expect((await profile.usernamesFor(['', 'a b'])).size).toBe(0);
|
||||
expect(mocks.listRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merkt sich die Namen: dieselben IDs fragen nicht noch einmal, neue IDs allein', async () => {
|
||||
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso'), row('u2', 'Mia')] });
|
||||
await profile.usernamesFor(['u1', 'u2']);
|
||||
|
||||
const again = await profile.usernamesFor(['u2', 'u1']);
|
||||
expect([...again].sort()).toEqual([
|
||||
['u1', 'Kenso'],
|
||||
['u2', 'Mia'],
|
||||
]);
|
||||
expect(mocks.listRows).toHaveBeenCalledTimes(1);
|
||||
|
||||
mocks.listRows.mockResolvedValueOnce({ rows: [row('u3', 'Lea')] });
|
||||
const more = await profile.usernamesFor(['u1', 'u3']);
|
||||
expect(mocks.listRows).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.listRows.mock.calls[1][0].queries).toEqual([Query.equal('$id', ['u3']), Query.limit(1)]);
|
||||
expect([...more].sort()).toEqual([
|
||||
['u1', 'Kenso'],
|
||||
['u3', 'Lea'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('Konten ohne Profil fehlen in der Map und werden beim nächsten Mal wieder gefragt (sie können sich später einen Namen wählen)', async () => {
|
||||
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso')] });
|
||||
const names = await profile.usernamesFor(['u1', 'u9']);
|
||||
expect(names.has('u9')).toBe(false);
|
||||
await profile.usernamesFor(['u9']);
|
||||
expect(mocks.listRows).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Zeilen ohne lesbaren Namen zählen nicht', async () => {
|
||||
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u1' }, { $id: 'u2', username: '' }, { $id: 'u3', username: 7 }, row('u4', 'Mia')] });
|
||||
const names = await profile.usernamesFor(['u1', 'u2', 'u3', 'u4']);
|
||||
expect([...names]).toEqual([['u4', 'Mia']]);
|
||||
});
|
||||
|
||||
it('Fehler ergeben eine leere Map statt eines Fehlers und merken nichts', async () => {
|
||||
mocks.listRows.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
const names = await profile.usernamesFor(['u1', 'u2']);
|
||||
expect(names).toBeInstanceOf(Map);
|
||||
expect(names.size).toBe(0);
|
||||
|
||||
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso')] });
|
||||
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Kenso']]); // der nächste Versuch fragt wieder
|
||||
});
|
||||
|
||||
it('bei einem Fehler bleiben die schon bekannten Namen erhalten', async () => {
|
||||
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso')] });
|
||||
await profile.usernamesFor(['u1']);
|
||||
mocks.listRows.mockRejectedValueOnce(Object.assign(new Error('boom'), { code: 500 }));
|
||||
const names = await profile.usernamesFor(['u1', 'u2']);
|
||||
expect([...names]).toEqual([['u1', 'Kenso']]);
|
||||
});
|
||||
|
||||
it('viele IDs gehen in Gruppen zu je 100 (eine Gruppe pro Abfrage)', async () => {
|
||||
const ids = Array.from({ length: 250 }, (_, i) => `u${i}`);
|
||||
mocks.listRows.mockImplementation(async ({ queries }: { queries: string[] }) => {
|
||||
const asked = (JSON.parse(queries[0]) as { values: string[] }).values;
|
||||
return { rows: asked.map((id) => row(id, `Name${id}`)) };
|
||||
});
|
||||
const names = await profile.usernamesFor(ids);
|
||||
expect(names.size).toBe(250);
|
||||
expect(mocks.listRows.mock.calls.map((call) => (JSON.parse(call[0].queries[0]) as { values: string[] }).values.length)).toEqual([100, 100, 50]);
|
||||
expect(mocks.listRows.mock.calls.map((call) => call[0].queries[1])).toEqual([Query.limit(100), Query.limit(100), Query.limit(50)]);
|
||||
});
|
||||
|
||||
it('ein Name, der während der Abfrage durch Umbenennen im Speicher landet, ist neuer als die Antwort', async () => {
|
||||
mocks.userId = 'u1';
|
||||
let answer!: (value: unknown) => void;
|
||||
mocks.listRows.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const pending = profile.usernamesFor(['u1']);
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
|
||||
await profile.saveUsername('Neu');
|
||||
answer({ rows: [row('u1', 'Alt')] });
|
||||
expect([...(await pending)]).toEqual([['u1', 'Neu']]);
|
||||
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Neu']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUsernameTaken', () => {
|
||||
it('fragt den Schlüssel (klein geschrieben) mit equal ab, eine Zeile genügt', async () => {
|
||||
mocks.listRows.mockResolvedValue({ rows: [] });
|
||||
await profile.isUsernameTaken('KenSo');
|
||||
expect(mocks.listRows).toHaveBeenCalledExactlyOnceWith({
|
||||
databaseId: 'db',
|
||||
tableId: 'profiles-table',
|
||||
queries: [Query.equal('usernameKey', 'kenso'), Query.limit(1)],
|
||||
});
|
||||
});
|
||||
|
||||
it('vergeben, wenn ein Profil den Schlüssel hat', async () => {
|
||||
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u2', username: 'kenso', usernameKey: 'kenso' }] });
|
||||
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('frei, wenn keine Zeile passt', async () => {
|
||||
mocks.listRows.mockResolvedValue({ rows: [] });
|
||||
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('der eigene Eintrag zählt nicht (nur die Schreibweise des eigenen Namens ändern ist erlaubt)', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u1', username: 'kenso', usernameKey: 'kenso' }] });
|
||||
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(false);
|
||||
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u2', username: 'kenso', usernameKey: 'kenso' }] });
|
||||
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('Fehler gehen durch (die Registrierung zeigt sie am Dialog)', async () => {
|
||||
const offline = new TypeError('Failed to fetch');
|
||||
mocks.listRows.mockRejectedValue(offline);
|
||||
await expect(profile.isUsernameTaken('Kenso')).rejects.toBe(offline);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveUsername', () => {
|
||||
beforeEach(() => {
|
||||
mocks.userId = 'u1';
|
||||
});
|
||||
|
||||
it('sendet POST /api/profil mit Anmeldung und gibt den Namen der Antwort zurück', async () => {
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
|
||||
expect(mocks.apiPost).toHaveBeenCalledExactlyOnceWith('/api/profil', { username: 'Kenso' }, { auth: true });
|
||||
});
|
||||
|
||||
it('aktualisiert den Zwischenspeicher: myUsername und usernamesFor kennen den Namen sofort, ohne Abfrage', async () => {
|
||||
expect(profile.myUsername()).toBeNull();
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||
await profile.saveUsername('Kenso');
|
||||
expect(profile.myUsername()).toBe('Kenso');
|
||||
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Kenso']]);
|
||||
expect(mocks.listRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('meldet die Änderung mit Konto-ID und Name an alle Hörer; abgemeldete Hörer hören nichts mehr', async () => {
|
||||
const first = vi.fn();
|
||||
const second = vi.fn();
|
||||
const off = profile.onUsernameChange(first);
|
||||
profile.onUsernameChange(second);
|
||||
mocks.apiPost.mockResolvedValueOnce({ username: 'Kenso' });
|
||||
await profile.saveUsername('Kenso');
|
||||
expect(first).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
|
||||
expect(second).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
|
||||
|
||||
off();
|
||||
mocks.apiPost.mockResolvedValueOnce({ username: 'Neu' });
|
||||
await profile.saveUsername('Neu');
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenLastCalledWith('u1', 'Neu');
|
||||
expect(profile.myUsername()).toBe('Neu');
|
||||
});
|
||||
|
||||
it('die Schreibweise der Antwort gilt (der Dienst speichert den Namen, wie er eingegeben wurde)', async () => {
|
||||
mocks.apiPost.mockResolvedValue({ username: 'KenSo' });
|
||||
await expect(profile.saveUsername('kenso')).resolves.toBe('KenSo');
|
||||
expect(profile.myUsername()).toBe('KenSo');
|
||||
});
|
||||
|
||||
it('eine Antwort ohne Namen ist ein unklarer Ausgang: Fehler 503, nichts gemerkt, nichts gemeldet', async () => {
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
for (const body of [{}, { username: '' }, { username: 5 }]) {
|
||||
mocks.apiPost.mockResolvedValueOnce(body);
|
||||
const err = await profile.saveUsername('Kenso').catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect((err as InstanceType<typeof ApiError>).serverCode).toBe('unavailable');
|
||||
}
|
||||
expect(profile.myUsername()).toBeNull();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Fehler des Dienstes gehen unverändert durch, der Zwischenspeicher bleibt', async () => {
|
||||
mocks.apiPost.mockResolvedValueOnce({ username: 'Kenso' });
|
||||
await profile.saveUsername('Kenso');
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
const taken = new ApiError(409, 'Dieser Benutzername ist schon vergeben.', undefined, 'usernameTaken');
|
||||
mocks.apiPost.mockRejectedValueOnce(taken);
|
||||
await expect(profile.saveUsername('Mia')).rejects.toBe(taken);
|
||||
expect(profile.myUsername()).toBe('Kenso');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein Hörer, der wirft, stört weder die anderen noch das Ergebnis', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const later = vi.fn();
|
||||
profile.onUsernameChange(() => {
|
||||
throw new Error('kaputt');
|
||||
});
|
||||
profile.onUsernameChange(later);
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
|
||||
expect(later).toHaveBeenCalledOnce();
|
||||
expect(error).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ohne bekanntes Konto geht der Aufruf durch, gemerkt und gemeldet wird nichts', async () => {
|
||||
mocks.userId = null;
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(profile.myUsername()).toBeNull();
|
||||
});
|
||||
|
||||
it('gemerkt wird für das Konto, das den Aufruf gestartet hat, auch wenn inzwischen ein anderes angemeldet ist', async () => {
|
||||
let answer!: (value: unknown) => void;
|
||||
mocks.apiPost.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const saving = profile.saveUsername('Kenso');
|
||||
mocks.userId = 'u2'; // Kontowechsel, während die Antwort unterwegs ist
|
||||
answer({ username: 'Kenso' });
|
||||
await saving;
|
||||
expect(profile.myUsername()).toBeNull(); // u2 hat (noch) keinen Namen
|
||||
mocks.userId = 'u1';
|
||||
expect(profile.myUsername()).toBe('Kenso');
|
||||
});
|
||||
});
|
||||
|
||||
describe('myUsername', () => {
|
||||
it('null ohne Konto und null, solange der Name nicht bekannt ist', () => {
|
||||
expect(profile.myUsername()).toBeNull();
|
||||
mocks.userId = 'u1';
|
||||
expect(profile.myUsername()).toBeNull();
|
||||
});
|
||||
|
||||
it('der Name gehört zum angemeldeten Konto: bei einem Wechsel zeigt myUsername den des anderen (oder keinen)', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso' });
|
||||
await profile.loadMyUsername();
|
||||
expect(profile.myUsername()).toBe('Kenso');
|
||||
mocks.userId = 'u2';
|
||||
expect(profile.myUsername()).toBeNull();
|
||||
mocks.userId = null;
|
||||
expect(profile.myUsername()).toBeNull();
|
||||
mocks.userId = 'u1';
|
||||
expect(profile.myUsername()).toBe('Kenso');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadMyUsername', () => {
|
||||
it('ohne Konto null und keine Anfrage', async () => {
|
||||
await expect(profile.loadMyUsername()).resolves.toBeNull();
|
||||
expect(mocks.getRow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('liest das Profil des angemeldeten Kontos, merkt den Namen und meldet ihn', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso' });
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
await expect(profile.loadMyUsername()).resolves.toBe('Kenso');
|
||||
expect(mocks.getRow).toHaveBeenCalledExactlyOnceWith({ databaseId: 'db', tableId: 'profiles-table', rowId: 'u1' });
|
||||
expect(profile.myUsername()).toBe('Kenso');
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
|
||||
});
|
||||
|
||||
it('liest bei jedem Aufruf neu (der Name kann auf einem anderen Gerät geändert worden sein)', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.getRow.mockResolvedValueOnce({ $id: 'u1', username: 'Alt' }).mockResolvedValueOnce({ $id: 'u1', username: 'Neu' });
|
||||
await profile.loadMyUsername();
|
||||
await expect(profile.loadMyUsername()).resolves.toBe('Neu');
|
||||
expect(profile.myUsername()).toBe('Neu');
|
||||
});
|
||||
|
||||
it('ohne Profil (404) ist das Ergebnis null, nichts wird gemeldet', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.getRow.mockRejectedValue(notFound());
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
await expect(profile.loadMyUsername()).resolves.toBeNull();
|
||||
expect(profile.myUsername()).toBeNull();
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein Name, der inzwischen gespeichert wurde, bleibt, auch wenn die Antwort „kein Profil“ sagt (Registrierung)', async () => {
|
||||
mocks.userId = 'u1';
|
||||
let answer!: (error: unknown) => void;
|
||||
mocks.getRow.mockReturnValue(new Promise((_, reject) => (answer = reject)));
|
||||
const loading = profile.loadMyUsername();
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||
await profile.saveUsername('Kenso'); // das Konto wurde gerade angelegt: die Abfrage war schneller als das Speichern
|
||||
answer(notFound());
|
||||
await expect(loading).resolves.toBe('Kenso');
|
||||
expect(profile.myUsername()).toBe('Kenso');
|
||||
});
|
||||
|
||||
it('andere Fehler gehen durch (der Aufrufer entscheidet; „unbekannt“ ist nicht „kein Name“)', async () => {
|
||||
mocks.userId = 'u1';
|
||||
const offline = new TypeError('Failed to fetch');
|
||||
mocks.getRow.mockRejectedValue(offline);
|
||||
await expect(profile.loadMyUsername()).rejects.toBe(offline);
|
||||
});
|
||||
|
||||
it('eine Antwort, die vor dem Umbenennen abgeschickt wurde, meldet den alten Namen nicht noch einmal: der neuere Name gilt', async () => {
|
||||
mocks.userId = 'u1';
|
||||
let answer!: (row: unknown) => void;
|
||||
mocks.getRow.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
const loading = profile.loadMyUsername(); // das Lesen läuft (Stand vor dem Umbenennen)
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
|
||||
await profile.saveUsername('Neu'); // währenddessen umbenannt
|
||||
answer({ $id: 'u1', username: 'Alt' }); // die Antwort ist älter als das Umbenennen
|
||||
await expect(loading).resolves.toBe('Neu');
|
||||
expect(profile.myUsername()).toBe('Neu');
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Neu'); // „Alt“ wurde nie gemeldet
|
||||
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Neu']]);
|
||||
});
|
||||
|
||||
it('dasselbe gilt für eine Antwort „kein Profil“, die vor dem Speichern abgeschickt wurde (Registrierung)', async () => {
|
||||
mocks.userId = 'u1';
|
||||
let answer!: (error: unknown) => void;
|
||||
mocks.getRow.mockReturnValue(new Promise((_, reject) => (answer = reject)));
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
const loading = profile.loadMyUsername();
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
|
||||
await profile.saveUsername('Kenso');
|
||||
answer(notFound());
|
||||
await expect(loading).resolves.toBe('Kenso');
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
|
||||
});
|
||||
|
||||
it('ein Lesen, das erst nach dem Umbenennen beginnt, gilt wieder (der Name kann auf einem anderen Gerät geändert worden sein)', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
|
||||
await profile.saveUsername('Neu');
|
||||
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Anderswo' });
|
||||
const listener = vi.fn();
|
||||
profile.onUsernameChange(listener);
|
||||
await expect(profile.loadMyUsername()).resolves.toBe('Anderswo');
|
||||
expect(profile.myUsername()).toBe('Anderswo');
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Anderswo');
|
||||
});
|
||||
|
||||
it('das Umbenennen eines anderen Kontos macht ein laufendes Lesen nicht hinfällig', async () => {
|
||||
mocks.userId = 'u1';
|
||||
let answer!: (row: unknown) => void;
|
||||
mocks.getRow.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const loading = profile.loadMyUsername();
|
||||
mocks.userId = 'u2'; // der Aufruf gehört einem anderen Konto
|
||||
mocks.apiPost.mockResolvedValue({ username: 'Mia' });
|
||||
await profile.saveUsername('Mia');
|
||||
mocks.userId = 'u1';
|
||||
answer({ $id: 'u1', username: 'Kenso' });
|
||||
await expect(loading).resolves.toBe('Kenso');
|
||||
expect(profile.myUsername()).toBe('Kenso');
|
||||
});
|
||||
});
|
||||
|
||||
describe('byline („von @name“)', () => {
|
||||
const names = new Map([
|
||||
['u1', 'Kenso'],
|
||||
['u2', 'Mia.K'],
|
||||
]);
|
||||
|
||||
it('mit Profil: „von @name“ (der Name, wie er gespeichert ist), der alte Anzeigename zählt dann nicht', () => {
|
||||
expect(profile.byline('u1', '', names)).toBe('von @Kenso');
|
||||
expect(profile.byline('u2', 'Alter Name', names)).toBe('von @Mia.K');
|
||||
});
|
||||
|
||||
it('ohne Profil: der früher gespeicherte Anzeigename, ohne @', () => {
|
||||
expect(profile.byline('u9', 'Anna', names)).toBe('von Anna');
|
||||
expect(profile.byline('u9', 'Anna', new Map())).toBe('von Anna');
|
||||
});
|
||||
|
||||
it('ohne Profil und ohne Anzeigenamen: „von Anonym“', () => {
|
||||
expect(profile.byline('u9', '', names)).toBe('von Anonym');
|
||||
expect(profile.byline('u9', '', new Map())).toBe('von Anonym');
|
||||
});
|
||||
|
||||
it('ein Eintrag ohne Konto-ID (Altbestand) fällt ebenso zurück, auch wenn die Map einen leeren Schlüssel hätte', () => {
|
||||
expect(profile.byline('', 'Anna', names)).toBe('von Anna');
|
||||
expect(profile.byline('', '', names)).toBe('von Anonym');
|
||||
expect(profile.byline('', '', new Map([['', 'Spuk']]))).toBe('von Anonym');
|
||||
});
|
||||
|
||||
it('ein leerer Name in der Map zählt nicht als Profil', () => {
|
||||
expect(profile.byline('u3', 'Anna', new Map([['u3', '']]))).toBe('von Anna');
|
||||
});
|
||||
|
||||
it('Werte mit geschweiften Klammern oder Markup bleiben unverändert (der eingesetzte Text wird nicht noch einmal ausgewertet)', () => {
|
||||
expect(profile.byline('u9', '{name}', new Map())).toBe('von {name}');
|
||||
expect(profile.byline('u9', '<b>fett</b>', new Map())).toBe('von <b>fett</b>');
|
||||
});
|
||||
|
||||
it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => {
|
||||
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||
expect(profile.byline('u1', '', names)).toBe('by @Kenso');
|
||||
expect(profile.byline('u9', 'Anna', names)).toBe('by Anna');
|
||||
expect(profile.byline('u9', '', names)).toBe('by Anonymous');
|
||||
});
|
||||
|
||||
it('liest nur die übergebene Map: eine Änderung dort zeigt die nächste Zeile sofort (eigene Umbenennung)', () => {
|
||||
const own = new Map([['u1', 'Alt']]);
|
||||
expect(profile.byline('u1', '', own)).toBe('von @Alt');
|
||||
own.set('u1', 'Neu');
|
||||
expect(profile.byline('u1', '', own)).toBe('von @Neu');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Merker „in dieser Sitzung schon gefragt“', () => {
|
||||
it('beginnt leer; markieren schreibt aircard.usernamePrompted in sessionStorage', () => {
|
||||
expect(profile.usernamePrompted()).toBe(false);
|
||||
profile.markUsernamePrompted();
|
||||
expect(profile.usernamePrompted()).toBe(true);
|
||||
expect(sessionStorage.getItem('aircard.usernamePrompted')).toBe('1');
|
||||
});
|
||||
|
||||
it('gilt für die ganze Sitzung: auch eine frisch geladene Seite sieht ihn', async () => {
|
||||
profile.markUsernamePrompted();
|
||||
vi.resetModules();
|
||||
const reloaded = await import('./profile');
|
||||
expect(reloaded.usernamePrompted()).toBe(true);
|
||||
});
|
||||
|
||||
it('löschen nimmt ihn wieder weg', () => {
|
||||
profile.markUsernamePrompted();
|
||||
profile.clearUsernamePrompted();
|
||||
expect(profile.usernamePrompted()).toBe(false);
|
||||
expect(sessionStorage.getItem('aircard.usernamePrompted')).toBeNull();
|
||||
});
|
||||
|
||||
it('ist der Speicher gesperrt, wirft nichts und der Merker gilt bis zum Neuladen der Seite', () => {
|
||||
const denied = () => {
|
||||
throw new DOMException('blockiert', 'SecurityError');
|
||||
};
|
||||
vi.stubGlobal('sessionStorage', { getItem: denied, setItem: denied, removeItem: denied });
|
||||
try {
|
||||
expect(profile.usernamePrompted()).toBe(false);
|
||||
expect(() => profile.markUsernamePrompted()).not.toThrow();
|
||||
expect(profile.usernamePrompted()).toBe(true);
|
||||
expect(() => profile.clearUsernamePrompted()).not.toThrow();
|
||||
expect(profile.usernamePrompted()).toBe(false);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
// Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine
|
||||
// öffentlich lesbare Zeile je Konto, Zeilen-ID = Konto-ID) und den eigenen Namen speichern (`POST /api/profil` des Dienstes
|
||||
// `aircard-api`). Der Browser prüft nur vor; was gilt, entscheidet der Dienst: Er prüft Muster und Sperrliste noch einmal, und der
|
||||
// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal. Dazu die Zeile „von @name“ für
|
||||
// Logos und Karten (`byline`).
|
||||
import { Query, type Models } from 'appwrite';
|
||||
import { ID_PATTERN } from '../card/config';
|
||||
import { apiPost, unavailableError } from '../community/api';
|
||||
import { API_MESSAGES } from '../i18n/api-messages';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { IDS, tables } from './appwrite';
|
||||
import { currentUserId } from './auth';
|
||||
import { isRowMissing } from './errors';
|
||||
|
||||
/** Wie im Dienst (deploy/api/common.js): 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`. */
|
||||
export const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
|
||||
|
||||
/**
|
||||
* Gesperrte Namen als Schlüssel (klein geschrieben, Spec § 2): Rollen, der Dienst selbst und Platzhalter, die nach einem Konto aussehen.
|
||||
* Genau die Liste des Dienstes (deploy/api/common.js, RESERVED_USERNAMES); profile.test.ts vergleicht beide.
|
||||
*/
|
||||
export const RESERVED_USERNAMES: readonly string[] = [
|
||||
'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 = new Set(RESERVED_USERNAMES);
|
||||
|
||||
/** Hinweis unter dem Feld „Benutzername“ (Registrieren und Namensdialog); angezeigt wird er mit `t(USERNAME_HINT)`. */
|
||||
export const USERNAME_HINT = msg('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
|
||||
|
||||
/** Obergrenze der Konten-IDs in einer Abfrage (so viele Werte nimmt ein `equal` sicher an). */
|
||||
const LOOKUP_MAX = 100;
|
||||
|
||||
type TableRow = Models.Row & Record<string, unknown>;
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/** Der Schlüssel eines Namens (Spalte `usernameKey`): klein geschrieben. Über ihn ist ein Name nur einmal vergeben („Kenso“ = „kenso“). */
|
||||
export function usernameKey(name: string): string {
|
||||
return name.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft einen Namen vor dem Senden: null, wenn er passt, sonst der Text für den Nutzer in der Sprache der Seite (Muster, dann Sperrliste).
|
||||
* Geprüft wird genau die Zeichenkette, die übergeben wird; wer sie vom Nutzer hat, trimmt sie vorher (der Dienst tut es nicht).
|
||||
*/
|
||||
export function validateUsername(name: string): string | null {
|
||||
if (!USERNAME_PATTERN.test(name)) return t(API_MESSAGES.usernameInvalid);
|
||||
if (RESERVED.has(usernameKey(name))) return t(API_MESSAGES.usernameReserved);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Zeile „von @name“ zu einem Eintrag (Logo-Kachel, Infozeile, Detailseite einer Karte): der Benutzername des Kontos `ownerId`, soweit
|
||||
* `names` (aus `usernamesFor`) ihn kennt. Ohne Profil gilt der früher gespeicherte Anzeigename (ohne @), sonst „Anonym“. In der Sprache der Seite.
|
||||
*/
|
||||
export function byline(ownerId: string, legacyDisplayName: string, names: ReadonlyMap<string, string>): string {
|
||||
const name = ownerId === '' ? '' : (names.get(ownerId) ?? '');
|
||||
if (name !== '') return t('von @{name}', { name });
|
||||
return t('von {name}', { name: legacyDisplayName !== '' ? legacyDisplayName : t('Anonym') });
|
||||
}
|
||||
|
||||
// ---- Namen merken und melden ------------------------------------------------------------------------------
|
||||
|
||||
/** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */
|
||||
const names = new Map<string, string>();
|
||||
/** Zählt die Namen, die diese Seite gespeichert hat; `savedAt` merkt je Konto den Stand beim letzten Speichern (siehe `loadMyUsername`). */
|
||||
let saves = 0;
|
||||
const savedAt = new Map<string, number>();
|
||||
const listeners = new Set<(userId: string, username: string) => void>();
|
||||
|
||||
/** Merkt den Namen eines Kontos, dessen Stand diese Seite gerade selbst erfahren hat (Speichern, Lesen des eigenen Profils), und meldet ihn. */
|
||||
function announce(userId: string, username: string): void {
|
||||
names.set(userId, username);
|
||||
// Kopie, damit sich ein Hörer beim Melden abmelden darf; ein werfender Hörer stört weder die anderen noch das Ergebnis.
|
||||
for (const fn of [...listeners]) {
|
||||
try {
|
||||
fn(userId, username);
|
||||
} catch (err) {
|
||||
console.error('Ein Hörer der Namensänderung ist gescheitert', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet, wenn der Name des angemeldeten Kontos feststeht oder sich ändert: nach dem Speichern und nach dem Lesen des eigenen Profils
|
||||
* (`loadMyUsername`). Nicht gemeldet wird, was `usernamesFor` über andere erfährt. Gibt die Abmelde-Funktion zurück.
|
||||
*/
|
||||
export function onUsernameChange(fn: (userId: string, username: string) => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => {
|
||||
listeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/** Der Name des angemeldeten Kontos laut Zwischenspeicher; null, wenn es keinen hat, keiner angemeldet ist oder er noch nicht gelesen wurde. */
|
||||
export function myUsername(): string | null {
|
||||
const userId = currentUserId();
|
||||
return userId ? (names.get(userId) ?? null) : null;
|
||||
}
|
||||
|
||||
// ---- Lesen --------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Der Name eines Kontos. Hat es kein Profil, ist das Ergebnis null (Appwrite antwortet mit 404); eine ID, die keine Appwrite-ID sein
|
||||
* kann, ergibt null ohne Anfrage. Andere Fehler (Netz, Server) gehen durch.
|
||||
*/
|
||||
export async function getUsername(userId: string): Promise<string | null> {
|
||||
if (!ID_PATTERN.test(userId)) return null;
|
||||
try {
|
||||
const row = await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.profiles, rowId: userId });
|
||||
return text(row.username) || null;
|
||||
} catch (err) {
|
||||
if (isRowMissing(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Namen der Konten für die Anzeige „von @name“: eine Abfrage für alle IDs, die noch nicht bekannt sind (Konten ohne Profil fehlen
|
||||
* in der Map). Gefundene Namen werden gemerkt. Die Anzeige braucht den Namen nur als Beiwerk: schlägt die Abfrage fehl, ist das kein
|
||||
* Fehler, es bleibt bei den schon bekannten Namen (ohne die ist die Map leer), und die Aufrufer greifen auf ihre Ersatznamen zurück.
|
||||
*/
|
||||
export async function usernamesFor(userIds: readonly string[]): Promise<Map<string, string>> {
|
||||
const found = new Map<string, string>();
|
||||
const missing: string[] = [];
|
||||
for (const id of new Set(userIds)) {
|
||||
if (!ID_PATTERN.test(id)) continue;
|
||||
const known = names.get(id);
|
||||
if (known === undefined) missing.push(id);
|
||||
else found.set(id, known);
|
||||
}
|
||||
try {
|
||||
for (let from = 0; from < missing.length; from += LOOKUP_MAX) {
|
||||
const ids = missing.slice(from, from + LOOKUP_MAX);
|
||||
const list = await tables.listRows<TableRow>({
|
||||
databaseId: IDS.database,
|
||||
tableId: IDS.profiles,
|
||||
queries: [Query.equal('$id', ids), Query.limit(ids.length)],
|
||||
});
|
||||
for (const row of list.rows) {
|
||||
const name = text(row.username);
|
||||
if (name === '') continue;
|
||||
// Steht für das Konto inzwischen ein Name im Speicher (es wurde während der Abfrage umbenannt), ist der neuer als die Antwort.
|
||||
const current = names.get(row.$id) ?? name;
|
||||
names.set(row.$id, current);
|
||||
found.set(row.$id, current);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// nur Beiwerk der Anzeige, siehe oben
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist der Name schon an ein anderes Konto vergeben? Gefragt wird der Schlüssel (klein geschrieben), der eigene Eintrag des angemeldeten
|
||||
* Kontos zählt nicht. Das ist die Vorabprüfung der Registrierung; bei zwei gleichzeitigen Anfragen entscheidet der Dienst.
|
||||
* Fehler (Netz, Server) gehen durch.
|
||||
*/
|
||||
export async function isUsernameTaken(name: string): Promise<boolean> {
|
||||
const list = await tables.listRows<TableRow>({
|
||||
databaseId: IDS.database,
|
||||
tableId: IDS.profiles,
|
||||
queries: [Query.equal('usernameKey', usernameKey(name)), Query.limit(1)],
|
||||
});
|
||||
const own = currentUserId();
|
||||
return list.rows.some((row) => row.$id !== own);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Namen des angemeldeten Kontos (nach dem Start und nach der Anmeldung), merkt und meldet ihn. Ergebnis: der Name, null ohne
|
||||
* Profil oder ohne angemeldetes Konto. Lässt sich das Profil nicht lesen (Netz, Server), wirft der Aufruf: „unbekannt“ ist nicht „kein
|
||||
* Name“, und nur bei „kein Name“ wird gefragt.
|
||||
*/
|
||||
export async function loadMyUsername(): Promise<string | null> {
|
||||
const userId = currentUserId();
|
||||
if (!userId) return null;
|
||||
const started = saves;
|
||||
const name = await getUsername(userId);
|
||||
// Wurde der Name währenddessen gespeichert (Umbenennen, Registrierung: die Abfrage war schneller als das Speichern), ist die Antwort
|
||||
// älter als der gemerkte Name: er gilt, und der alte wird nicht noch einmal gemeldet. Kein Profil heißt sonst ebenfalls „der gemerkte“.
|
||||
if (name === null || (savedAt.get(userId) ?? 0) > started) return names.get(userId) ?? null;
|
||||
announce(userId, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
// ---- Speichern ----------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Speichert den eigenen Namen (`POST /api/profil`): legt das Profil an oder ändert es, der alte Name ist danach frei. Ergebnis ist der
|
||||
* Name, wie der Dienst ihn gespeichert hat. Der Zwischenspeicher gilt sofort, und alle Hörer von `onUsernameChange` erfahren es.
|
||||
* Fehler sind `ApiError`: 400 `usernameInvalid`/`usernameReserved`, 409 `usernameTaken`, sonst Anmeldung, Netz, Dienst.
|
||||
*/
|
||||
export async function saveUsername(name: string): Promise<string> {
|
||||
const userId = currentUserId(); // das Konto, das den Aufruf stellt, auch wenn inzwischen ein anderes angemeldet ist
|
||||
const res = await apiPost('/api/profil', { username: name }, { auth: true });
|
||||
const saved = res.username;
|
||||
// Eine Antwort ohne Namen ist keine Antwort des Dienstes (Proxy, Fehlseite): der Ausgang ist offen.
|
||||
if (typeof saved !== 'string' || saved === '') throw unavailableError();
|
||||
if (userId) {
|
||||
savedAt.set(userId, ++saves);
|
||||
announce(userId, saved);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
// ---- „In dieser Sitzung schon gefragt“ ---------------------------------------------------------------------
|
||||
|
||||
/** Merker in sessionStorage (Spec § 5): gilt für die Sitzung des Tabs, nicht für ein Konto. */
|
||||
const PROMPTED_KEY = 'aircard.usernamePrompted';
|
||||
/** Zusätzlich im Speicher: ist sessionStorage gesperrt, gilt „schon gefragt“ wenigstens bis die Seite neu geladen wird. */
|
||||
let promptedInMemory = false;
|
||||
|
||||
/** Wurde in dieser Sitzung schon nach einem Benutzernamen gefragt? (Jeder Zugriff auf den Speicher steht in try/catch.) */
|
||||
export function usernamePrompted(): boolean {
|
||||
if (promptedInMemory) return true;
|
||||
try {
|
||||
return sessionStorage.getItem(PROMPTED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Merkt, dass in dieser Sitzung nach einem Benutzernamen gefragt wird. */
|
||||
export function markUsernamePrompted(): void {
|
||||
promptedInMemory = true;
|
||||
try {
|
||||
sessionStorage.setItem(PROMPTED_KEY, '1');
|
||||
} catch {
|
||||
// gesperrt oder voll: es bleibt beim Merker im Speicher
|
||||
}
|
||||
}
|
||||
|
||||
/** Nimmt den Merker zurück (die Registrierung hat ihn gesetzt, ist dann aber gescheitert). */
|
||||
export function clearUsernamePrompted(): void {
|
||||
promptedInMemory = false;
|
||||
try {
|
||||
sessionStorage.removeItem(PROMPTED_KEY);
|
||||
} catch {
|
||||
// gesperrt: nichts zu entfernen
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { germanLeft } from '../i18n/english-check';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
saveUsername: vi.fn(),
|
||||
loadMyUsername: vi.fn(),
|
||||
/** Der Name des angemeldeten Kontos laut Zwischenspeicher (myUsername). */
|
||||
name: null as string | null,
|
||||
userId: null as string | null,
|
||||
}));
|
||||
|
||||
vi.mock('./appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
vi.mock('./auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||||
|
||||
const INVALID =
|
||||
'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.';
|
||||
const TAKEN = 'Dieser Benutzername ist schon vergeben.';
|
||||
|
||||
type Dialog = typeof import('./username-dialog');
|
||||
let dialog: Dialog;
|
||||
/** Der Dialog hält einen Zustand (nur einer zur Zeit): jeder Test bekommt frische Module; ApiError stammt aus derselben Kopie. */
|
||||
let ApiError: typeof import('../community/api').ApiError;
|
||||
let opener: HTMLButtonElement;
|
||||
|
||||
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.username-dialog');
|
||||
const input = () => dialogEl()!.querySelector<HTMLInputElement>('input[name="username"]')!;
|
||||
const form = () => dialogEl()!.querySelector('form')!;
|
||||
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
const buttonByText = (text: string) =>
|
||||
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
|
||||
const errorEl = () => dialogEl()!.querySelector<HTMLElement>('[role="alert"]')!;
|
||||
const errorText = () => errorEl().textContent;
|
||||
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
|
||||
const heading = () => dialogEl()!.querySelector('h2')!.textContent;
|
||||
const intro = () => dialogEl()!.querySelector('.username-text')!.textContent;
|
||||
/** Die IDs in `aria-describedby` des Feldes. */
|
||||
const describedBy = () => (input().getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mocks.saveUsername.mockReset();
|
||||
mocks.loadMyUsername.mockReset();
|
||||
mocks.name = null;
|
||||
mocks.userId = null;
|
||||
document.body.innerHTML = '<button id="opener">Konto</button>';
|
||||
opener = document.getElementById('opener') as HTMLButtonElement;
|
||||
opener.focus();
|
||||
vi.resetModules();
|
||||
// Nur Netzzugriff und Zwischenspeicher sind ersetzt; Muster, Sperrliste und die Texte bleiben echt. `doMock` nach jedem Zurücksetzen:
|
||||
// ein einmal berechnetes `vi.mock` bliebe über `resetModules` hinweg bei der ersten Kopie von profile.ts und i18n.ts stehen.
|
||||
vi.doMock('./profile', async () => ({
|
||||
...(await vi.importActual<typeof import('./profile')>('./profile')),
|
||||
saveUsername: mocks.saveUsername,
|
||||
loadMyUsername: mocks.loadMyUsername,
|
||||
myUsername: () => mocks.name,
|
||||
}));
|
||||
dialog = await import('./username-dialog');
|
||||
({ ApiError } = await import('../community/api'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.doUnmock('./profile');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Dialog „Benutzername wählen“: Aufbau', () => {
|
||||
it('prompt: Überschrift, leeres und fokussiertes Feld, Hinweis, „Später“ und „Speichern“', () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(heading()).toBe('Benutzername wählen');
|
||||
expect(intro()).toBe('Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.');
|
||||
expect(input().value).toBe('');
|
||||
expect(document.activeElement).toBe(input());
|
||||
expect(dialogEl()!.querySelector('label')!.textContent).toBe('Benutzername');
|
||||
expect(dialogEl()!.querySelector('.auth-hint')!.textContent).toBe('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
|
||||
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Später', 'Speichern']);
|
||||
expect(buttonByText('Speichern')!.type).toBe('submit');
|
||||
expect(buttonByText('Später')!.type).toBe('button');
|
||||
});
|
||||
|
||||
it('das Feld ist ein Spitzname, kein Anmeldename (autocomplete „nickname“, nie „username“): keine Großschreibung, keine Rechtschreibprüfung', () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
expect(input().autocomplete).toBe('nickname');
|
||||
expect(input().getAttribute('autocapitalize')).toBe('none');
|
||||
expect(input().getAttribute('spellcheck')).toBe('false');
|
||||
expect(input().type).toBe('text');
|
||||
});
|
||||
|
||||
it('required: „Abbrechen“ statt „Später“ und der Hinweis, dass zuerst ein Name nötig ist', () => {
|
||||
void dialog.usernameDialog({ mode: 'required' });
|
||||
expect(heading()).toBe('Benutzername wählen');
|
||||
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
|
||||
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Abbrechen', 'Speichern']);
|
||||
expect(input().value).toBe('');
|
||||
});
|
||||
|
||||
it('change: das Feld ist mit dem aktuellen Namen vorbelegt (als Wert, nie als HTML), „Abbrechen“, Überschrift „Benutzername ändern“', () => {
|
||||
mocks.name = '<b>Kenso</b>';
|
||||
void dialog.usernameDialog({ mode: 'change' });
|
||||
expect(heading()).toBe('Benutzername ändern');
|
||||
expect(input().value).toBe('<b>Kenso</b>');
|
||||
expect(dialogEl()!.querySelector('b')).toBeNull();
|
||||
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Abbrechen', 'Speichern']);
|
||||
expect(document.activeElement).toBe(input());
|
||||
});
|
||||
|
||||
it('change ohne bekannten Namen: wie „wählen“ mit „Abbrechen“, Feld leer', () => {
|
||||
void dialog.usernameDialog({ mode: 'change' });
|
||||
expect(heading()).toBe('Benutzername wählen');
|
||||
expect(input().value).toBe('');
|
||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||
expect(buttonByText('Später')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prompt und required bleiben leer, auch wenn ein Name bekannt ist (vorbelegt wird nur beim Ändern)', () => {
|
||||
mocks.name = 'Kenso';
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
expect(input().value).toBe('');
|
||||
});
|
||||
|
||||
it('Beschriftung und Verknüpfungen: Label gehört zum Feld, der Hinweis ist verknüpft, alle IDs sind eindeutig, die Überschrift benennt den Dialog', () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
const label = dialogEl()!.querySelector('label')!;
|
||||
expect(label.control).toBe(input());
|
||||
expect(input().labels).toHaveLength(1);
|
||||
const hint = dialogEl()!.querySelector('.auth-hint')!;
|
||||
expect(describedBy()).toEqual([hint.id]);
|
||||
expect(input().getAttribute('aria-required')).toBe('true');
|
||||
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(dialogEl()!.querySelector('h2')!.id);
|
||||
expect(errorEl().getAttribute('role')).toBe('alert');
|
||||
expect(form().hasAttribute('novalidate')).toBe(true); // geprüft wird selbst, mit Texten der Seite
|
||||
});
|
||||
});
|
||||
|
||||
describe('Später, Abbrechen, Schließen', () => {
|
||||
it('„Später“ löst mit null auf, speichert nichts, entfernt den Dialog und gibt den Fokus zurück', async () => {
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'Kenso'; // auch ein getippter Name wird dann nicht gespeichert
|
||||
buttonByText('Später')!.click();
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(mocks.saveUsername).not.toHaveBeenCalled();
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
it.each(['required', 'change'] as const)('%s: „Abbrechen“ löst mit null auf und speichert nichts', async (mode) => {
|
||||
const result = dialog.usernameDialog({ mode });
|
||||
buttonByText('Abbrechen')!.click();
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(mocks.saveUsername).not.toHaveBeenCalled();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('Esc (close-Ereignis des Dialogs) löst mit null auf', async () => {
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||
dialogEl()!.close();
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ein Klick auf den Hintergrund schließt, ein Klick im Formular oder Markieren, das außerhalb endet, nicht', async () => {
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
input().click();
|
||||
dialogEl()!.click(); // mousedown im Feld, click auf dem Dialog: Markieren
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialogEl()!.click();
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('signal: ein Abbruch schließt den Dialog mit null; ein schon abgebrochenes Signal öffnet gar nichts', async () => {
|
||||
const controller = new AbortController();
|
||||
const result = dialog.usernameDialog({ mode: 'prompt', signal: controller.signal });
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
controller.abort();
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(dialogEl()).toBeNull();
|
||||
|
||||
controller.abort();
|
||||
await expect(dialog.usernameDialog({ mode: 'prompt', signal: controller.signal })).resolves.toBeNull();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('schlägt showModal fehl, wird aufgeräumt und der Fehler gemeldet; der nächste Aufruf öffnet normal', async () => {
|
||||
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementationOnce(() => {
|
||||
throw new Error('showModal kaputt');
|
||||
});
|
||||
await expect(dialog.usernameDialog({ mode: 'prompt' })).rejects.toThrow('showModal kaputt');
|
||||
expect(dialogEl()).toBeNull();
|
||||
spy.mockRestore();
|
||||
const again = dialog.usernameDialog({ mode: 'prompt' });
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
buttonByText('Später')!.click();
|
||||
await expect(again).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Speichern', () => {
|
||||
it('speichert den getrimmten Namen, löst mit dem Namen des Dienstes auf und schließt', async () => {
|
||||
mocks.saveUsername.mockResolvedValue('KenSo');
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = ' Kenso ';
|
||||
send();
|
||||
await expect(result).resolves.toBe('KenSo');
|
||||
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso');
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
|
||||
it('der Knopf „Speichern“ löst das Absenden aus (Enter im Feld ebenso: es ist ein Formular)', async () => {
|
||||
mocks.saveUsername.mockResolvedValue('Kenso');
|
||||
const result = dialog.usernameDialog({ mode: 'change' });
|
||||
input().value = 'Kenso';
|
||||
expect(buttonByText('Speichern')!.closest('form')).toBe(form());
|
||||
send();
|
||||
await expect(result).resolves.toBe('Kenso');
|
||||
});
|
||||
|
||||
it('prüft vor dem Senden: Muster und Sperrliste, Text am Feld, kein Aufruf', async () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
const invalid = async (value: string, message: string) => {
|
||||
input().value = value;
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(message);
|
||||
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||
expect(describedBy()).toContain(errorEl().id); // das Feld verweist auf die Fehlerzeile
|
||||
expect(document.activeElement).toBe(input());
|
||||
expect(mocks.saveUsername).not.toHaveBeenCalled();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
};
|
||||
await invalid('', INVALID);
|
||||
await invalid(' ', INVALID);
|
||||
await invalid('ab', INVALID);
|
||||
await invalid('x'.repeat(21), INVALID);
|
||||
await invalid('_abc', INVALID);
|
||||
await invalid('na me', INVALID);
|
||||
await invalid('Käse', INVALID);
|
||||
await invalid('Admin', 'Dieser Benutzername ist nicht erlaubt.');
|
||||
await invalid('AirCard-IOS', 'Dieser Benutzername ist nicht erlaubt.');
|
||||
});
|
||||
|
||||
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden; der Hinweis bleibt verknüpft', async () => {
|
||||
mocks.saveUsername.mockResolvedValue('Kenso');
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'x';
|
||||
send();
|
||||
expect(describedBy()).toHaveLength(2);
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(errorText()).toBe('');
|
||||
expect(describedBy()).toHaveLength(1);
|
||||
await result;
|
||||
});
|
||||
|
||||
it('vergeben (409 usernameTaken): der Text steht am Feld, der Dialog bleibt offen, ein neuer Name geht', async () => {
|
||||
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(TAKEN);
|
||||
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||
expect(describedBy()).toContain(errorEl().id);
|
||||
expect(document.activeElement).toBe(input());
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(input().value).toBe('Kenso');
|
||||
expect(buttonByText('Speichern')!.disabled).toBe(false);
|
||||
expect(buttonByText('Später')!.disabled).toBe(false);
|
||||
expect(input().readOnly).toBe(false);
|
||||
|
||||
mocks.saveUsername.mockResolvedValueOnce('Kenso2');
|
||||
input().value = 'Kenso2';
|
||||
send();
|
||||
await expect(result).resolves.toBe('Kenso2');
|
||||
expect(mocks.saveUsername).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Muster und Sperrliste des Dienstes (400) stehen ebenfalls am Feld', async () => {
|
||||
mocks.saveUsername.mockRejectedValueOnce(new ApiError(400, 'x', undefined, 'usernameReserved'));
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Dieser Benutzername ist nicht erlaubt.');
|
||||
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||
|
||||
mocks.saveUsername.mockRejectedValueOnce(new ApiError(400, 'x', undefined, 'usernameInvalid'));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(INVALID);
|
||||
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||
});
|
||||
|
||||
it('Dienst nicht erreichbar: der Text steht im Dialog, das Feld gilt nicht als ungültig, der Dialog bleibt für einen neuen Versuch offen', async () => {
|
||||
mocks.saveUsername.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(describedBy()).not.toContain(errorEl().id);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(input().value).toBe('Kenso');
|
||||
expect(statusText()).toBe('');
|
||||
|
||||
mocks.saveUsername.mockResolvedValueOnce('Kenso');
|
||||
send();
|
||||
await expect(result).resolves.toBe('Kenso');
|
||||
});
|
||||
|
||||
it('andere Fehler: kein Netz, abgelaufene Anmeldung (401) und alles Unbekannte werden erklärt statt verschluckt', async () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'Kenso';
|
||||
|
||||
mocks.saveUsername.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Keine Verbindung zum Server.');
|
||||
|
||||
mocks.saveUsername.mockRejectedValueOnce(Object.assign(new Error('unauthorized'), { code: 401, type: 'general_unauthorized_scope' }));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Bitte melde dich an.');
|
||||
|
||||
mocks.saveUsername.mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.', undefined, 'unauthorized'));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Bitte melde dich an.');
|
||||
|
||||
mocks.saveUsername.mockRejectedValueOnce(new Error('???'));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('der Fehler eines früheren Versuchs verschwindet beim nächsten', async () => {
|
||||
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
await settle();
|
||||
mocks.saveUsername.mockResolvedValueOnce('Kenso2');
|
||||
input().value = 'Kenso2';
|
||||
send();
|
||||
expect(errorText()).toBe('');
|
||||
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||
await result;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vorbelegung und Grund beim Öffnen (der Rückfall nach einer Registrierung, deren letzter Schritt scheiterte)', () => {
|
||||
it('`initial` belegt das Feld vor, auch im Modus „prompt“ (Überschrift „wählen“, „Später“), als Wert und nie als HTML; der Text ist markiert', () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt', initial: '<b>Kenso</b>' });
|
||||
expect(input().value).toBe('<b>Kenso</b>');
|
||||
expect(dialogEl()!.querySelector('b')).toBeNull();
|
||||
expect(heading()).toBe('Benutzername wählen');
|
||||
expect(buttonByText('Später')).toBeTruthy();
|
||||
expect(document.activeElement).toBe(input());
|
||||
expect(input().selectionStart).toBe(0);
|
||||
expect(input().selectionEnd).toBe('<b>Kenso</b>'.length);
|
||||
expect(errorText()).toBe(''); // ohne `failure` keine Meldung
|
||||
});
|
||||
|
||||
it('`initial` geht dem Namen des Kontos im Modus „change“ vor', () => {
|
||||
mocks.name = 'Alt';
|
||||
void dialog.usernameDialog({ mode: 'change', initial: 'Getippt' });
|
||||
expect(input().value).toBe('Getippt');
|
||||
expect(heading()).toBe('Benutzername ändern');
|
||||
});
|
||||
|
||||
it('`failure` mit einem Fehler am Namen (vergeben): der Grund steht schon da, am Feld, mit Markierung und Fokus', () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
|
||||
expect(errorText()).toBe(TAKEN);
|
||||
expect(input().getAttribute('aria-invalid')).toBe('true');
|
||||
expect(describedBy()).toContain(errorEl().id);
|
||||
expect(document.activeElement).toBe(input());
|
||||
expect(input().value).toBe('Kenso');
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
});
|
||||
|
||||
it('`failure` ohne Bezug zum Namen (Netz, Dienst): der Grund steht als Text, das Feld gilt nicht als ungültig, der Name bleibt zum Wiederholen', () => {
|
||||
void dialog.usernameDialog({
|
||||
mode: 'prompt',
|
||||
initial: 'Kenso',
|
||||
failure: new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'),
|
||||
});
|
||||
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(describedBy()).not.toContain(errorEl().id);
|
||||
expect(input().value).toBe('Kenso');
|
||||
expect(document.activeElement).toBe(input());
|
||||
});
|
||||
|
||||
it('andere Fehler als `failure` werden wie beim Speichern erklärt (kein Netz, abgelaufene Anmeldung, Unbekanntes)', async () => {
|
||||
void dialog.usernameDialog({ mode: 'prompt', failure: new TypeError('Failed to fetch') });
|
||||
expect(errorText()).toBe('Keine Verbindung zum Server.');
|
||||
buttonByText('Später')!.click();
|
||||
await settle();
|
||||
void dialog.usernameDialog({ mode: 'prompt', failure: Object.assign(new Error('x'), { code: 401 }) });
|
||||
expect(errorText()).toBe('Bitte melde dich an.');
|
||||
buttonByText('Später')!.click();
|
||||
await settle();
|
||||
void dialog.usernameDialog({ mode: 'prompt', failure: new Error('???') });
|
||||
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
});
|
||||
|
||||
it('der Grund verschwindet beim nächsten Speichern; ein anderer Name geht durch', async () => {
|
||||
mocks.saveUsername.mockResolvedValue('Kenso2');
|
||||
const result = dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
|
||||
input().value = 'Kenso2';
|
||||
send();
|
||||
expect(errorText()).toBe('');
|
||||
expect(input().hasAttribute('aria-invalid')).toBe(false);
|
||||
await expect(result).resolves.toBe('Kenso2');
|
||||
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso2');
|
||||
});
|
||||
|
||||
it('beides gilt nur für diesen Aufruf: der nächste Dialog beginnt leer und ohne Meldung', async () => {
|
||||
const first = dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new TypeError('offline') });
|
||||
buttonByText('Später')!.click();
|
||||
await first;
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
expect(input().value).toBe('');
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
it('Englisch: der Grund erscheint in der Sprache der Seite', async () => {
|
||||
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||
void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
|
||||
expect(errorText()).toBe('This username is already taken.');
|
||||
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Busy-Sperre beim Speichern', () => {
|
||||
it('während des Speicherns: Knöpfe und Feld gesperrt, Status sichtbar, ein zweites Absenden wird ignoriert', async () => {
|
||||
let finish!: (name: string) => void;
|
||||
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
expect(buttonByText('Speichern')!.disabled).toBe(true);
|
||||
expect(buttonByText('Später')!.disabled).toBe(true);
|
||||
expect(input().readOnly).toBe(true);
|
||||
expect(form().getAttribute('aria-busy')).toBe('true');
|
||||
expect(statusText()).toBe('Wird gespeichert …');
|
||||
send();
|
||||
expect(mocks.saveUsername).toHaveBeenCalledOnce();
|
||||
finish('Kenso');
|
||||
await expect(result).resolves.toBe('Kenso');
|
||||
});
|
||||
|
||||
it('Esc und ein Klick auf den Hintergrund schließen den Dialog währenddessen nicht (Abbrechen wirkte sonst nur scheinbar)', async () => {
|
||||
let finish!: (name: string) => void;
|
||||
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
|
||||
const result = dialog.usernameDialog({ mode: 'required' });
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
const esc = new Event('cancel', { cancelable: true });
|
||||
dialogEl()!.dispatchEvent(esc);
|
||||
expect(esc.defaultPrevented).toBe(true);
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
finish('Kenso');
|
||||
await expect(result).resolves.toBe('Kenso');
|
||||
});
|
||||
|
||||
it('schließt der Browser den Dialog trotzdem, geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
|
||||
let finish!: (name: string) => void;
|
||||
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
|
||||
let resolved: string | null | undefined;
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' }).then((name) => (resolved = name));
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet
|
||||
await settle();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
dialogEl()!.dispatchEvent(new Event('close'));
|
||||
await settle();
|
||||
expect(resolved).toBeUndefined();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
finish('Kenso');
|
||||
await result;
|
||||
expect(resolved).toBe('Kenso');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('scheitert das Speichern, nachdem der Dialog zwischendurch zuging, ist er wieder offen und zeigt den Fehler', async () => {
|
||||
let fail!: (error: Error) => void;
|
||||
mocks.saveUsername.mockReturnValue(new Promise<string>((_, reject) => (fail = reject)));
|
||||
void dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
dialogEl()!.close();
|
||||
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||
await settle();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||
});
|
||||
|
||||
it('nur `signal` schließt immer; eine danach eintreffende Antwort ändert nichts mehr', async () => {
|
||||
let finish!: (name: string) => void;
|
||||
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
|
||||
const controller = new AbortController();
|
||||
const result = dialog.usernameDialog({ mode: 'prompt', signal: controller.signal });
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
controller.abort();
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(dialogEl()).toBeNull();
|
||||
finish('Kenso');
|
||||
await settle();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ein Dialog zur Zeit', () => {
|
||||
it('ein zweiter Aufruf, während einer offen ist, teilt sich Dialog und Ergebnis (keine zwei Dialoge übereinander)', async () => {
|
||||
mocks.saveUsername.mockResolvedValue('Kenso');
|
||||
const first = dialog.usernameDialog({ mode: 'prompt' });
|
||||
const second = dialog.usernameDialog({ mode: 'required' });
|
||||
expect(second).toBe(first);
|
||||
expect(document.querySelectorAll('dialog.username-dialog')).toHaveLength(1);
|
||||
expect(heading()).toBe('Benutzername wählen');
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
await expect(second).resolves.toBe('Kenso');
|
||||
await expect(first).resolves.toBe('Kenso');
|
||||
});
|
||||
|
||||
it('nach dem Schließen öffnet der nächste Aufruf neu, ohne Reste', async () => {
|
||||
const first = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'Kenso';
|
||||
buttonByText('Später')!.click();
|
||||
await first;
|
||||
const second = dialog.usernameDialog({ mode: 'required' });
|
||||
expect(second).not.toBe(first);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(input().value).toBe('');
|
||||
expect(errorText()).toBe('');
|
||||
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
|
||||
buttonByText('Abbrechen')!.click();
|
||||
await second;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Englisch', () => {
|
||||
beforeEach(async () => {
|
||||
(await import('../i18n/i18n')).setLang('en', { persist: false });
|
||||
});
|
||||
|
||||
it('alle Texte des Dialogs sind englisch, in jedem Modus', async () => {
|
||||
mocks.name = 'Kenso';
|
||||
const texts = () => [heading(), intro(), dialogEl()!.querySelector('label')!.textContent, dialogEl()!.querySelector('.auth-hint')!.textContent];
|
||||
const labels = () => [...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent);
|
||||
|
||||
const prompt = dialog.usernameDialog({ mode: 'prompt' });
|
||||
expect(texts()).toEqual([
|
||||
'Choose a username',
|
||||
'Your username is public and shown on your logos and cards.',
|
||||
'Username',
|
||||
'3–20 characters: letters, numbers, . _ -',
|
||||
]);
|
||||
expect(labels()).toEqual(['Later', 'Save']);
|
||||
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||
buttonByText('Later')!.click();
|
||||
await prompt;
|
||||
|
||||
const required = dialog.usernameDialog({ mode: 'required' });
|
||||
expect(intro()).toBe('Please choose a username first.');
|
||||
expect(labels()).toEqual(['Cancel', 'Save']);
|
||||
buttonByText('Cancel')!.click();
|
||||
await required;
|
||||
|
||||
const change = dialog.usernameDialog({ mode: 'change' });
|
||||
expect(heading()).toBe('Change username');
|
||||
expect(input().value).toBe('Kenso');
|
||||
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||
buttonByText('Cancel')!.click();
|
||||
await change;
|
||||
});
|
||||
|
||||
it('Fehler und Status ebenso', async () => {
|
||||
let finish!: (name: string) => void;
|
||||
mocks.saveUsername.mockReturnValueOnce(new Promise<string>((resolve) => (finish = resolve)));
|
||||
const result = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'x';
|
||||
send();
|
||||
expect(errorText()).toBe(
|
||||
'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.',
|
||||
);
|
||||
input().value = 'Admin';
|
||||
send();
|
||||
expect(errorText()).toBe('This username is not allowed.');
|
||||
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
expect(statusText()).toBe('Saving …');
|
||||
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||
finish('Kenso');
|
||||
await result;
|
||||
|
||||
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
|
||||
const again = dialog.usernameDialog({ mode: 'prompt' });
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('This username is already taken.');
|
||||
buttonByText('Later')!.click();
|
||||
await again;
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireUsername', () => {
|
||||
it('ohne angemeldetes Konto gibt es nichts zu fordern: true, kein Dialog, keine Anfrage (die Anmeldung kommt mit der Aktion selbst)', async () => {
|
||||
await expect(dialog.requireUsername()).resolves.toBe(true);
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein bekannter Name genügt: true ohne Dialog und ohne Anfrage', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.name = 'Kenso';
|
||||
await expect(dialog.requireUsername()).resolves.toBe(true);
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ist der Name noch nicht gelesen, wird er gelesen; hat das Konto einen, ist es true ohne Dialog', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockResolvedValue('Kenso');
|
||||
await expect(dialog.requireUsername()).resolves.toBe(true);
|
||||
expect(mocks.loadMyUsername).toHaveBeenCalledOnce();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ohne Namen erscheint der Dialog „required“; nach dem Speichern ist es true', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockResolvedValue(null);
|
||||
mocks.saveUsername.mockResolvedValue('Kenso');
|
||||
const result = dialog.requireUsername();
|
||||
await settle();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
|
||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('„Abbrechen“ ergibt false: ohne Namen geht es nicht weiter', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockResolvedValue(null);
|
||||
const result = dialog.requireUsername();
|
||||
await settle();
|
||||
buttonByText('Abbrechen')!.click();
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(mocks.saveUsername).not.toHaveBeenCalled();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('Esc ergibt ebenfalls false', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockResolvedValue(null);
|
||||
const result = dialog.requireUsername();
|
||||
await settle();
|
||||
dialogEl()!.close();
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('lässt sich das Profil nicht lesen, entscheidet der Dienst (er antwortet ohne Profil mit 403): true, kein Dialog', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
await expect(dialog.requireUsername()).resolves.toBe(true);
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('das Signal geht an den Dialog: das Verlassen der Seite schließt ihn, das Ergebnis ist false', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockResolvedValue(null);
|
||||
const controller = new AbortController();
|
||||
const result = dialog.requireUsername(controller.signal);
|
||||
await settle();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
controller.abort();
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(mocks.saveUsername).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein schon abgebrochenes Signal öffnet keinen Dialog (false); mit bekanntem Namen ist es trotzdem true', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockResolvedValue(null);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(dialog.requireUsername(controller.signal)).resolves.toBe(false);
|
||||
expect(dialogEl()).toBeNull();
|
||||
mocks.name = 'Kenso';
|
||||
await expect(dialog.requireUsername(controller.signal)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('läuft schon ein Namensdialog (etwa die einmalige Abfrage), teilt sich requireUsername ihn', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockResolvedValue(null);
|
||||
mocks.saveUsername.mockResolvedValue('Kenso');
|
||||
const prompt = dialog.usernameDialog({ mode: 'prompt' });
|
||||
const required = dialog.requireUsername();
|
||||
await settle();
|
||||
expect(document.querySelectorAll('dialog.username-dialog')).toHaveLength(1);
|
||||
input().value = 'Kenso';
|
||||
send();
|
||||
await expect(required).resolves.toBe(true);
|
||||
await expect(prompt).resolves.toBe('Kenso');
|
||||
});
|
||||
|
||||
it('„Später“ in einem geteilten Dialog ergibt für requireUsername false', async () => {
|
||||
mocks.userId = 'u1';
|
||||
mocks.loadMyUsername.mockResolvedValue(null);
|
||||
const prompt = dialog.usernameDialog({ mode: 'prompt' });
|
||||
const required = dialog.requireUsername();
|
||||
await settle();
|
||||
buttonByText('Später')!.click();
|
||||
await expect(prompt).resolves.toBeNull();
|
||||
await expect(required).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
// Dialog „Benutzername wählen“ (Spec § 5): ein Feld, „Speichern“ und „Später“ bzw. „Abbrechen“. Drei Anlässe: die einmalige Abfrage nach
|
||||
// dem Anmelden (`prompt`, mit „Später“), die Pflicht vor dem Hochladen und Veröffentlichen (`required`, nur „Abbrechen“) und das Ändern
|
||||
// aus dem Konto-Menü (`change`, mit dem aktuellen Namen vorbelegt). Eigenes <dialog> nach dem Muster von publish-dialog.ts; alle Texte
|
||||
// gehen als Text in den DOM, nie als HTML.
|
||||
import './account.css';
|
||||
import { ApiError, apiErrorText } from '../community/api';
|
||||
import { el, setFieldInvalid } from '../editor/dom';
|
||||
import { API_MESSAGES } from '../i18n/api-messages';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { currentUserId } from './auth';
|
||||
import { errorMessage, isUnauthorized } from './errors';
|
||||
import { USERNAME_HINT, loadMyUsername, myUsername, saveUsername, validateUsername } from './profile';
|
||||
|
||||
const RUNNING = msg('Wird gespeichert …');
|
||||
const PUBLIC_NOTE = msg('Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.');
|
||||
|
||||
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
|
||||
const ERROR_ID = 'username-error';
|
||||
/** Fehlercodes des Dienstes, die am Namen selbst liegen: ihr Text steht am Feld, und das Feld gilt als ungültig. */
|
||||
const FIELD_CODES: ReadonlySet<string> = new Set(['usernameInvalid', 'usernameReserved', 'usernameTaken']);
|
||||
|
||||
/** `prompt`: einmalige Abfrage („Später“); `required`: ohne Namen geht es nicht weiter („Abbrechen“); `change`: aus dem Konto-Menü, vorbelegt. */
|
||||
export type UsernameDialogMode = 'prompt' | 'required' | 'change';
|
||||
|
||||
export interface UsernameDialogOptions {
|
||||
mode: UsernameDialogMode;
|
||||
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
|
||||
signal?: AbortSignal;
|
||||
/** Der Name, mit dem das Feld beginnt (geht dem aktuellen Namen im Modus `change` vor), etwa der zuvor getippte nach einem Fehler. */
|
||||
initial?: string;
|
||||
/** Ein Fehler, dessen Grund beim Öffnen schon im Dialog steht, als hätte das Speichern ihn eben gemeldet. */
|
||||
failure?: unknown;
|
||||
}
|
||||
|
||||
/** Der Text zu einem fehlgeschlagenen Speichern in der Sprache der Seite; Fehler des Dienstes über ihren Code (`apiErrorText`). */
|
||||
function failureText(err: unknown): string {
|
||||
if (err instanceof ApiError) return apiErrorText(err);
|
||||
if (isUnauthorized(err)) return t(API_MESSAGES.unauthorized); // die Anmeldung ist abgelaufen (schon der Abruf des JWT scheitert)
|
||||
return errorMessage(err, 'save');
|
||||
}
|
||||
|
||||
function build(opts: UsernameDialogOptions): Promise<string | null> {
|
||||
return new Promise<string | null>((resolve, reject) => {
|
||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
// Vorbelegt wird nur beim Ändern; kennt das Konto noch keinen Namen, ist es ein gewöhnliches „Wählen“.
|
||||
const current = opts.mode === 'change' ? myUsername() : null;
|
||||
let result: string | null = null;
|
||||
let closed = false;
|
||||
let busy = false;
|
||||
|
||||
const input = el('input', {
|
||||
class: 'auth-input',
|
||||
type: 'text',
|
||||
name: 'username',
|
||||
id: 'username-name',
|
||||
// `nickname`, nicht `username`: der Name ist ein Spitzname, kein Anmeldename (angemeldet wird per E-Mail); so bleibt für Passwortmanager
|
||||
// das E-Mail-Feld der Anmeldung der Anmeldename, und der Browser schlägt hier nicht die gespeicherte Anmeldung vor.
|
||||
autocomplete: 'nickname',
|
||||
autocapitalize: 'none',
|
||||
spellcheck: 'false',
|
||||
'aria-required': 'true',
|
||||
'aria-describedby': 'username-name-hint',
|
||||
});
|
||||
input.value = opts.initial ?? current ?? '';
|
||||
const field = el(
|
||||
'div',
|
||||
{ class: 'auth-field' },
|
||||
el('label', { class: 'auth-label', for: input.id }, t('Benutzername')),
|
||||
input,
|
||||
el('p', { class: 'auth-hint', id: 'username-name-hint' }, t(USERNAME_HINT)),
|
||||
);
|
||||
const intro = el('p', { class: 'username-text' }, opts.mode === 'required' ? t(API_MESSAGES.usernameRequired) : t(PUBLIC_NOTE));
|
||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
|
||||
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
|
||||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, opts.mode === 'prompt' ? t('Später') : t('Abbrechen'));
|
||||
const save = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Speichern'));
|
||||
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
|
||||
const form = el(
|
||||
'form',
|
||||
{ class: 'auth-form', novalidate: true },
|
||||
intro,
|
||||
field,
|
||||
errorEl,
|
||||
statusEl,
|
||||
el('div', { class: 'username-actions' }, cancel, save),
|
||||
);
|
||||
const dialog = el(
|
||||
'dialog',
|
||||
{ class: 'auth-dialog username-dialog', 'aria-labelledby': 'username-heading' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'auth-card' },
|
||||
el('h2', { id: 'username-heading', class: 'auth-title' }, current ? t('Benutzername ändern') : t('Benutzername wählen')),
|
||||
form,
|
||||
),
|
||||
);
|
||||
|
||||
const finish = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
opts.signal?.removeEventListener('abort', finish);
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
if (opener?.isConnected) opener.focus();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
/** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */
|
||||
function reopen(): boolean {
|
||||
if (dialog.open) return true;
|
||||
try {
|
||||
dialog.showModal();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setBusy(value: boolean): void {
|
||||
busy = value;
|
||||
form.setAttribute('aria-busy', String(value));
|
||||
input.readOnly = value; // bleibt fokussierbar, ein Screenreader verliert den Fokus nicht
|
||||
save.disabled = value;
|
||||
cancel.disabled = value;
|
||||
statusEl.textContent = value ? t(RUNNING) : '';
|
||||
}
|
||||
|
||||
/** Ein Problem mit dem Namen: der Text steht in der Fehlerzeile, das Feld gilt als ungültig und bekommt den Fokus. */
|
||||
function showFieldProblem(text: string): void {
|
||||
errorEl.textContent = text;
|
||||
setFieldInvalid(input, ERROR_ID, true);
|
||||
input.focus();
|
||||
}
|
||||
|
||||
/** Der Grund eines gescheiterten Speicherns: am Namen (vergeben, gesperrt, ungültig) am Feld, sonst (Netz, Dienst, Anmeldung) nur als Text. */
|
||||
function showFailure(err: unknown): void {
|
||||
if (err instanceof ApiError && err.serverCode !== null && FIELD_CODES.has(err.serverCode)) {
|
||||
showFieldProblem(failureText(err));
|
||||
} else {
|
||||
errorEl.textContent = failureText(err); // der Name kann richtig sein
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
setFieldInvalid(input, ERROR_ID, false);
|
||||
errorEl.textContent = '';
|
||||
const name = input.value.trim(); // der Dienst trimmt nicht
|
||||
const problem = validateUsername(name);
|
||||
if (problem) {
|
||||
showFieldProblem(problem);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
result = await saveUsername(name);
|
||||
} catch (err) {
|
||||
if (closed) return;
|
||||
setBusy(false);
|
||||
showFailure(err);
|
||||
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||||
return;
|
||||
}
|
||||
if (closed) return;
|
||||
finish();
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (busy || closed) return;
|
||||
void submit();
|
||||
});
|
||||
cancel.addEventListener('click', finish);
|
||||
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Speichern darf der Dialog nicht zugehen.
|
||||
dialog.addEventListener('cancel', (event) => {
|
||||
if (busy) event.preventDefault();
|
||||
});
|
||||
// Schließt der Browser den Dialog trotzdem mitten im Speichern, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis.
|
||||
dialog.addEventListener('close', () => {
|
||||
if (busy && !closed) {
|
||||
reopen();
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
});
|
||||
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
|
||||
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
|
||||
let pressedOnBackdrop = false;
|
||||
dialog.addEventListener('mousedown', (event) => {
|
||||
pressedOnBackdrop = event.target === dialog;
|
||||
});
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target === dialog && pressedOnBackdrop && !busy) finish();
|
||||
pressedOnBackdrop = false;
|
||||
});
|
||||
opts.signal?.addEventListener('abort', finish);
|
||||
|
||||
document.body.append(dialog);
|
||||
try {
|
||||
dialog.showModal();
|
||||
} catch (err) {
|
||||
closed = true;
|
||||
opts.signal?.removeEventListener('abort', finish);
|
||||
dialog.remove();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
input.focus();
|
||||
if (input.value !== '') input.select(); // zum Ändern (oder nach einem Fehler): gleich überschreiben können
|
||||
if (opts.failure !== undefined) showFailure(opts.failure);
|
||||
});
|
||||
}
|
||||
|
||||
/** Der offene Dialog; es gibt höchstens einen. */
|
||||
let running: Promise<string | null> | null = null;
|
||||
|
||||
/**
|
||||
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem gespeicherten Namen (so, wie der Dienst ihn gespeichert hat), sonst mit
|
||||
* null („Später“, „Abbrechen“, Esc, Hintergrund, `signal`). Fehler erscheinen im Dialog; er bleibt dann für einen neuen Versuch offen.
|
||||
* Während des Speicherns lässt er sich nicht schließen (Esc und Hintergrund wirkungslos), damit niemand glaubt, etwas abgebrochen zu haben,
|
||||
* das dann doch gespeichert wird; nur `signal` schließt immer.
|
||||
* Läuft schon ein Namensdialog (etwa die einmalige Abfrage, wenn gleichzeitig der Dienst „Name nötig“ meldet), teilt sich der Aufruf ihn
|
||||
* samt Ergebnis, statt einen zweiten darüberzulegen (seine Angaben `signal`, `initial` und `failure` gelten dann nicht); für einen
|
||||
* Aufrufer mit „Name nötig“ ist null dann ebenso ein „nicht weiter“.
|
||||
*/
|
||||
export function usernameDialog(opts: UsernameDialogOptions): Promise<string | null> {
|
||||
if (opts.signal?.aborted) return Promise.resolve(null);
|
||||
if (running) return running;
|
||||
const promise: Promise<string | null> = build(opts).finally(() => {
|
||||
if (running === promise) running = null;
|
||||
});
|
||||
running = promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hat das angemeldete Konto einen Benutzernamen? Wenn nicht, fragt der Dialog `required` danach (Spec § 5: vor dem Hochladen eines Logos und
|
||||
* vor dem Veröffentlichen). true: es kann weitergehen; false: der Nutzer hat „Abbrechen“ gewählt, es geschieht nichts.
|
||||
* Ohne angemeldetes Konto gibt es nichts zu fordern (true): die Anmeldung kommt mit der Aktion selbst, und fehlt dem Konto dann der Name,
|
||||
* antwortet der Dienst mit 403 `usernameRequired` (dann zeigt der Aufrufer den Dialog). Dasselbe gilt, wenn sich das Profil nicht lesen lässt:
|
||||
* der Dienst entscheidet. `signal` schließt den Dialog (Seite verlassen); dann ist das Ergebnis false.
|
||||
*/
|
||||
export async function requireUsername(signal?: AbortSignal): Promise<boolean> {
|
||||
if (currentUserId() === null) return true;
|
||||
let name = myUsername();
|
||||
if (name === null) {
|
||||
try {
|
||||
name = await loadMyUsername();
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (name !== null) return true;
|
||||
return (await usernameDialog({ mode: 'required', signal })) !== null;
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { LoginCancelled, withLogin } from './with-login';
|
||||
|
||||
const unauthorized = () => Object.assign(new Error('Unauthorized'), { code: 401, type: 'user_unauthorized' });
|
||||
|
||||
// Appwrite antwortet Gästen auf Lesezugriffe mit 404, nicht mit 401 (Zeilen- und Dateirechte).
|
||||
const rowNotFound = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' });
|
||||
const fileNotFound = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
|
||||
const user = { $id: 'u1', email: 'a@b.de' };
|
||||
|
||||
describe('withLogin', () => {
|
||||
it('gibt das Ergebnis zurück, ohne den Login-Dialog zu öffnen', async () => {
|
||||
const fn = vi.fn().mockResolvedValue('ok');
|
||||
@@ -76,4 +81,78 @@ describe('withLogin', () => {
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ok');
|
||||
expect(requireLogin).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe('404 für Gäste (row_not_found / storage_file_not_found)', () => {
|
||||
it.each([
|
||||
['row_not_found', rowNotFound],
|
||||
['storage_file_not_found', fileNotFound],
|
||||
])('%s ohne Sitzung: wie eine abgelaufene Sitzung behandeln (anmelden, einmal wiederholen)', async (_name, make) => {
|
||||
const order: string[] = [];
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
order.push('fn1');
|
||||
throw make();
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
order.push('fn2');
|
||||
return 'ergebnis';
|
||||
});
|
||||
const refreshUser = vi.fn(async () => {
|
||||
order.push('refresh');
|
||||
return null;
|
||||
});
|
||||
const requireLogin = vi.fn(async () => {
|
||||
order.push('login');
|
||||
return true;
|
||||
});
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ergebnis');
|
||||
expect(order).toEqual(['fn1', 'refresh', 'login', 'fn2']);
|
||||
expect(refreshUser).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ist der Nutzer noch angemeldet, ist es ein echtes „nicht gefunden“ und wird durchgereicht', async () => {
|
||||
const notFound = rowNotFound();
|
||||
const fn = vi.fn().mockRejectedValue(notFound);
|
||||
const requireLogin = vi.fn();
|
||||
const refreshUser = vi.fn().mockResolvedValue(user);
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(notFound);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
expect(requireLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scheitert die Nutzerprüfung (Netz), wird das ursprüngliche 404 durchgereicht', async () => {
|
||||
const notFound = fileNotFound();
|
||||
const fn = vi.fn().mockRejectedValue(notFound);
|
||||
const requireLogin = vi.fn();
|
||||
const refreshUser = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(notFound);
|
||||
expect(requireLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Abbruch im Dialog: LoginCancelled, fn wird nicht erneut aufgerufen', async () => {
|
||||
const fn = vi.fn().mockRejectedValue(rowNotFound());
|
||||
const requireLogin = vi.fn().mockResolvedValue(false);
|
||||
const err = await withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null)).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(LoginCancelled);
|
||||
expect(fn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ein zweites 404 nach der Anmeldung wird durchgereicht (kein dritter Versuch)', async () => {
|
||||
const second = rowNotFound();
|
||||
const fn = vi.fn().mockRejectedValueOnce(rowNotFound()).mockRejectedValueOnce(second);
|
||||
const requireLogin = vi.fn().mockResolvedValue(true);
|
||||
await expect(withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null))).rejects.toBe(second);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(requireLogin).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ein anderes 404 (z. B. falscher Pfad) löst keinen Dialog aus', async () => {
|
||||
const other = Object.assign(new Error('Route not found'), { code: 404, type: 'general_route_not_found' });
|
||||
const fn = vi.fn().mockRejectedValue(other);
|
||||
const refreshUser = vi.fn();
|
||||
await expect(withLogin(fn, vi.fn(), refreshUser)).rejects.toBe(other);
|
||||
expect(refreshUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401) einmal neu an.
|
||||
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401, oder 404 für Gäste) einmal neu an.
|
||||
import { msg } from '../i18n/i18n';
|
||||
import { getCurrentUser } from './auth';
|
||||
import { isUnauthorized } from './errors';
|
||||
import { errorType, isUnauthorized } from './errors';
|
||||
import { requireLogin } from './login-dialog';
|
||||
|
||||
/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert; angezeigt wird er mit `t(LOGIN_NEEDED)`. */
|
||||
export const LOGIN_NEEDED = msg('Bitte melde dich an, um fortzufahren.');
|
||||
|
||||
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
|
||||
export class LoginCancelled extends Error {
|
||||
constructor() {
|
||||
@@ -11,9 +15,20 @@ export class LoginCancelled extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appwrite beantwortet Lesezugriffe von Gästen auf Zeilen und Dateien mit Zeilen-/Dateirechten nicht mit 401,
|
||||
* sondern mit 404 (die Daten sind für sie „nicht vorhanden“).
|
||||
*/
|
||||
function isMissingForGuest(err: unknown): boolean {
|
||||
const type = errorType(err);
|
||||
return type === 'row_not_found' || type === 'storage_file_not_found';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft `fn` auf. Antwortet der Server mit 401, wird der Anmeldestatus neu geholt (damit Konto-Menü und
|
||||
* Nutzer-ID nicht veraltet sind), einmal der Login-Dialog gezeigt und `fn` danach genau einmal wiederholt.
|
||||
* Dasselbe gilt für „Zeile/Datei nicht gefunden“, wenn die Prüfung ergibt, dass gar keine Sitzung mehr besteht;
|
||||
* ist der Nutzer noch angemeldet (oder die Prüfung scheitert), ist es ein echtes „nicht gefunden“ und geht durch.
|
||||
* Schließt der Nutzer den Dialog, wirft das `LoginCancelled`; jeder andere Fehler geht unverändert durch.
|
||||
*/
|
||||
export async function withLogin<T>(
|
||||
@@ -24,12 +39,23 @@ export async function withLogin<T>(
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (!isUnauthorized(err)) throw err;
|
||||
}
|
||||
try {
|
||||
await refreshUser();
|
||||
} catch {
|
||||
// Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu.
|
||||
if (isUnauthorized(err)) {
|
||||
try {
|
||||
await refreshUser();
|
||||
} catch {
|
||||
// Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu.
|
||||
}
|
||||
} else if (isMissingForGuest(err)) {
|
||||
let user: unknown;
|
||||
try {
|
||||
user = await refreshUser();
|
||||
} catch {
|
||||
throw err; // ohne Gewissheit über die Sitzung bleibt es beim ursprünglichen Fehler
|
||||
}
|
||||
if (user) throw err;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
if (!(await requireLoginFn())) throw new LoginCancelled();
|
||||
return fn();
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg viewBox="172 360 680 730" xmlns="http://www.w3.org/2000/svg"><defs><mask id="aircards-symbol-waves" width="680" height="430" x="172" y="660" maskUnits="userSpaceOnUse"><path fill="#fff" d="M172 660h680v430H172z"/><g fill="none" stroke="#000" stroke-linecap="round" stroke-width="32"><path d="M591.1 843.9a44 44 0 0 1 0 62.2m34-96.2a92 92 0 0 1 0 130.2"/><path d="M659 776a140 140 0 0 1 0 198"/></g></mask></defs><g fill="currentColor"><path fill-opacity=".45" d="M252 360h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V440a80 80 0 0 1 80-80"/><path fill-opacity=".7" d="M252 510h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V590a80 80 0 0 1 80-80"/><rect width="680" height="430" x="172" y="660" mask="url(#aircards-symbol-waves)" rx="80"/></g></svg>
|
||||
<svg viewBox="172 360 680 730" xmlns="http://www.w3.org/2000/svg"><defs><mask id="aircards-symbol-waves"><path fill="#fff" d="M100 600h1000v600H100z"/><g fill="none" stroke="#000" stroke-linecap="round" stroke-width="32"><path d="M591.1 843.9a44 44 0 0 1 0 62.2m34-96.2a92 92 0 0 1 0 130.2"/><path d="M659 776a140 140 0 0 1 0 198"/></g></mask></defs><g fill="currentColor"><path fill-opacity=".45" d="M252 360h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V440a80 80 0 0 1 80-80"/><path fill-opacity=".7" d="M252 510h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V590a80 80 0 0 1 80-80"/><rect width="680" height="430" x="172" y="660" mask="url(#aircards-symbol-waves)" rx="80"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 794 B After Width: | Height: | Size: 727 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="#016fd0" d="M0 0h997.517v538.49l-49.375 77.147 49.375 68.661v313.219H0v-507.63l30.859-35.488L0 420.454Z"/><path fill="#fff" d="M193.64 695.099v-156.61h165.82l17.791 23.193 18.38-23.193h601.886V684.3s-15.74 10.643-33.945 10.8H630.295l-20.058-24.687V695.1h-65.73v-42.142s-8.979 5.883-28.39 5.883h-22.373V695.1h-99.52l-17.766-23.69-18.038 23.69zM0 420.454l37.393-87.177h64.668l21.22 48.833v-48.833h80.388l12.633 35.295 12.247-35.295h360.858v17.744s18.97-17.744 50.146-17.744l117.085.41 20.854 48.193v-48.603h67.273l18.515 27.684v-27.684h67.89v156.61h-67.89l-17.744-27.774v27.774h-98.838l-9.94-24.688h-26.57l-9.779 24.688h-67.028c-26.826 0-43.974-17.382-43.974-17.382v17.382H488.343l-20.058-24.688v24.688H92.481l-9.933-24.688H56.06l-9.86 24.688H0z"/><path fill="#016fd0" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="-66 -67.24 1132.01 1132.01" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-width="36" d="M-48-49.24h1096v1096H-48z"/><path fill="currentColor" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="#016fd0" d="M0 0h997.517v538.49l-49.375 77.147 49.375 68.661v313.219H0v-507.63l30.859-35.488L0 420.454Z"/><path fill="#fff" d="M193.64 695.099v-156.61h165.82l17.791 23.193 18.38-23.193h601.886V684.3s-15.74 10.643-33.945 10.8H630.295l-20.058-24.687V695.1h-65.73v-42.142s-8.979 5.883-28.39 5.883h-22.373V695.1h-99.52l-17.766-23.69-18.038 23.69zM0 420.454l37.393-87.177h64.668l21.22 48.833v-48.833h80.388l12.633 35.295 12.247-35.295h360.858v17.744s18.97-17.744 50.146-17.744l117.085.41 20.854 48.193v-48.603h67.273l18.515 27.684v-27.684h67.89v156.61h-67.89l-17.744-27.774v27.774h-98.838l-9.94-24.688h-26.57l-9.779 24.688h-67.028c-26.826 0-43.974-17.382-43.974-17.382v17.382H488.343l-20.058-24.688v24.688H92.481l-9.933-24.688H56.06l-9.86 24.688H0z"/><path fill="#016fd0" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 814.11 999.91" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2m-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3"/></svg>
|
||||
|
Before Width: | Height: | Size: 655 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 -0.49 220.58 37.13" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.125 0 0 -.125 -.2539 59.38386)"><path d="M145.605 261.297c-17.113-15.453-39.343-22.195-74.535-22.195H56.453v184.644H71.07c35.192 0 56.547-6.297 74.535-22.582 18.836-16.773 30.168-42.762 30.168-69.512 0-26.804-11.332-53.589-30.168-70.355M81.981 471.055H2.03V191.832h79.53c42.289 0 72.824 9.977 99.629 32.234 31.851 26.36 50.687 66.09 50.687 107.184 0 82.402-61.566 139.805-149.894 139.805m174.956-279.223h54.465v279.223h-54.465zm187.64 172.074c-32.683 12.094-42.281 20.071-42.281 35.157 0 17.585 17.098 30.949 40.574 30.949 16.32 0 29.731-6.703 43.922-22.614l28.496 37.317c-23.418 20.484-51.437 30.957-82.051 30.957-49.402 0-87.086-34.309-87.086-80.008 0-38.469 17.547-58.164 68.7-76.574 21.32-7.52 32.171-12.531 37.648-15.902 10.883-7.106 16.332-17.165 16.332-28.891 0-22.625-17.988-39.387-42.289-39.387-25.977 0-46.902 12.992-59.441 37.242l-35.188-33.879c25.094-36.835 55.231-53.16 96.672-53.16 56.602 0 96.305 37.633 96.305 91.688 0 44.363-18.356 64.445-80.313 87.105m97.496-32.656c0-82.074 64.446-145.715 147.383-145.715 23.445 0 43.523 4.61 68.285 16.274v64.113c-21.773-21.793-41.058-30.582-65.754-30.582-54.851 0-93.785 39.769-93.785 96.312 0 53.61 40.164 95.899 91.254 95.899 25.977 0 45.641-9.27 68.285-31.418v64.082c-23.906 12.129-43.566 17.152-67 17.152-82.515 0-148.668-64.929-148.668-146.117m647.516-47.75-74.46 187.555h-59.5l118.49-286.387h29.31l120.63 286.387h-59.04zm159.1-91.668h154.46v47.27h-100.04v75.371h96.35v47.297h-96.35v61.976h100.04v47.309h-154.46zm260.84 150.672h-15.91v84.57h16.77c33.92 0 52.36-14.211 52.36-41.386 0-28.071-18.44-43.184-53.22-43.184m109.27 46.117c0 52.274-36.01 82.434-98.82 82.434h-80.76V191.832h54.4V304h7.1l75.39-112.168h66.98l-87.9 117.633c41.02 8.34 63.61 36.402 63.61 79.156m24.44 68.59h-.99v6.414h1.05c2.9 0 4.4-1.043 4.4-3.156 0-2.172-1.52-3.258-4.46-3.258m10.32 3.348c0 4.894-3.37 7.57-9.29 7.57h-7.88v-24.543h5.86v9.519l6.87-9.519h7.15l-8.07 10.121c3.45.93 5.36 3.445 5.36 6.852" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none"/><path d="M1745.37 438.332c-9.38 0-17.05 7.805-17.05 17.543 0 9.773 7.57 17.574 17.05 17.574 9.33 0 16.94-7.98 16.94-17.574 0-9.656-7.61-17.543-16.94-17.543m.08 38.969c-11.92 0-21.38-9.512-21.38-21.395 0-11.875 9.57-21.398 21.38-21.398 11.61 0 21.12 9.625 21.12 21.398 0 11.715-9.51 21.395-21.12 21.395" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none"/><defs><linearGradient id="discover-g" x1=".3" x2=".7" y1="1" y2="0"><stop offset="0" stop-color="#ffb000"/><stop offset="1" stop-color="#e5600f"/></linearGradient></defs><circle cx="924.03" cy="330.5" r="148.5" fill="url(#discover-g)"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="1 1 298.01 229.52" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-17.94377 -13.02567)scale(1.821516)"><path d="M174 108.3c0 14-11.4 25.4-25.4 25.4H10.4V33.1c0-14 11.4-25.4 25.4-25.4H174z" style="fill:#fff"/><linearGradient id="jcb-g1_" x1="117.386" x2="157.33" y1="81.399" y2="81.399" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M129 82.5h10.5c.3 0 1-.1 1.3-.1 2-.4 3.7-2.2 3.7-4.7 0-2.4-1.7-4.2-3.7-4.7-.3-.1-.9-.1-1.3-.1H129z" style="fill:url(#jcb-g1_)"/><linearGradient id="jcb-g2_" x1="117.384" x2="157.332" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M138.3 16.2c-10 0-18.2 8.1-18.2 18.2v18.9h25.7c.6 0 1.3 0 1.8.1 5.8.3 10.1 3.3 10.1 8.5 0 4.1-2.9 7.6-8.3 8.3v.2c5.9.4 10.4 3.7 10.4 8.8 0 5.5-5 9.1-11.6 9.1H120v37h26.7c10 0 18.2-8.1 18.2-18.2V16.2z" style="fill:url(#jcb-g2_)"/><linearGradient id="jcb-g3_" x1="117.385" x2="157.331" y1="68.399" y2="68.399" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M143.2 63.1c0-2.4-1.7-4-3.7-4.3-.2 0-.7-.1-1-.1H129v8.8h9.5c.3 0 .9 0 1-.1 2-.3 3.7-1.9 3.7-4.3" style="fill:url(#jcb-g3_)"/><linearGradient id="jcb-g4_" x1="27.959" x2="68.522" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#1f286f"/><stop offset=".475" style="stop-color:#004e94"/><stop offset=".826" style="stop-color:#0066b1"/><stop offset="1" style="stop-color:#006fbc"/></linearGradient><path d="M37.7 16.2c-10 0-18.2 8.1-18.2 18.2v44.9c5.1 2.5 10.4 4.1 15.7 4.1 6.3 0 9.7-3.8 9.7-9V53.2h15.6v21.1c0 8.2-5.1 14.9-22.4 14.9-10.5 0-18.7-2.3-18.7-2.3v38.3h26.7c10 0 18.2-8.1 18.2-18.2V16.2z" style="fill:url(#jcb-g4_)"/><linearGradient id="jcb-g5_" x1="72.46" x2="111.855" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#6c2c2f"/><stop offset=".173" style="stop-color:#882730"/><stop offset=".573" style="stop-color:#be1833"/><stop offset=".859" style="stop-color:#dc0436"/><stop offset="1" style="stop-color:#e60039"/></linearGradient><path d="M88 16.2c-10 0-18.2 8.1-18.2 18.2v23.8c4.6-3.9 12.6-6.4 25.5-5.8 6.9.3 14.3 2.2 14.3 2.2v7.7c-3.7-1.9-8.1-3.6-13.8-4-9.8-.7-15.7 4.1-15.7 12.5 0 8.5 5.9 13.3 15.7 12.5 5.7-.4 10.1-2.2 13.8-4V87s-7.3 1.9-14.3 2.2c-12.9.6-20.9-1.9-25.5-5.8v42h26.7c10 0 18.2-8.1 18.2-18.2v-91z" style="fill:url(#jcb-g5_)"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="1.97 1.87 296.07 177.27" xmlns="http://www.w3.org/2000/svg"><path d="M298.032 90.5c.014 48.936-39.646 88.614-88.582 88.627-48.937.012-88.614-39.646-88.627-88.582V90.5c-.013-48.935 39.647-88.615 88.581-88.628 48.937-.013 88.615 39.647 88.628 88.583z" style="fill:#f90"/><path d="M90.001 1.895C41.355 2.204 1.967 41.781 1.967 90.5c0 48.909 39.695 88.604 88.605 88.604 22.955 0 43.879-8.748 59.624-23.086l-.007-.004h.019a89.4 89.4 0 0 0 8.995-9.488H141.05a87 87 0 0 1-6.606-9.127h31.308a89 89 0 0 0 5.158-9.488h-41.635a87 87 0 0 1-3.703-9.309h49.045a88.4 88.4 0 0 0 4.56-28.103c0-6.512-.706-12.861-2.042-18.974h-54.164a88 88 0 0 1 2.528-9.308h49.063a89 89 0 0 0-3.82-9.487H129.27a85 85 0 0 1 5.084-9.307h31.285a89 89 0 0 0-6.877-9.488h-17.443a82 82 0 0 1 8.889-8.95c-15.746-14.34-36.676-23.09-59.636-23.09z" style="fill:#c00"/><path d="M289.143 136.82q.723.001 1.409.371.688.37 1.07 1.062c.256.456.384.937.384 1.435q0 .739-.379 1.424-.377.683-1.061 1.063-.678.374-1.424.375c-.746.001-.972-.126-1.426-.375a2.7 2.7 0 0 1-1.063-1.063 2.9 2.9 0 0 1-.377-1.424c0-.498.127-.979.384-1.435a2.66 2.66 0 0 1 1.071-1.062 3 3 0 0 1 1.412-.371m0 .475q-.602 0-1.176.311c-.38.207-.677.5-.891.888a2.4 2.4 0 0 0-.325 1.194q0 .62.315 1.188.321.566.888.885.57.316 1.188.315.62 0 1.189-.315.567-.319.884-.885.313-.568.313-1.188 0-.623-.321-1.194a2.2 2.2 0 0 0-.894-.888 2.4 2.4 0 0 0-1.17-.311m-1.256 3.975v-3.082h1.062q.54-.001.784.088a.76.76 0 0 1 .388.297.81.81 0 0 1-.098 1.039.95.95 0 0 1-.639.281.9.9 0 0 1 .264.164q.186.18.455.61l.375.603h-.606l-.272-.485q-.322-.572-.521-.718-.137-.104-.397-.105h-.293v1.311h-.5m.496-1.738h.604q.431 0 .588-.129a.42.42 0 0 0 .159-.342.4.4 0 0 0-.075-.244.47.47 0 0 0-.213-.162q-.134-.054-.497-.055h-.564v.932" style="fill:#fcb340"/><path d="m133.719 99.926 1.18-8.02c-.645 0-1.593.279-2.431.279-3.284 0-3.694-1.755-3.436-3.037l3.236-16.13h4.992l1.029-9.103h-4.705l.958-5.516H124.7c-.208.208-5.568 31.022-5.568 34.776 0 5.555 3.118 8.027 7.516 7.988 3.442-.029 6.125-.982 7.071-1.237m2.987-15.288c0 13.332 8.799 16.499 16.297 16.499 6.921 0 10.55-1.604 10.55-1.604l1.662-9.1s-5.848 2.378-10.601 2.378c-10.131 0-8.355-7.554-8.355-7.554l19.463.059s1.239-6.111 1.239-8.602c0-6.217-3.387-13.849-13.745-13.849-9.486.002-16.51 10.223-16.51 21.773m16.546-13.325c5.324 0 4.342 5.984 4.342 6.469H147.12c0-.62.989-6.469 6.132-6.469m59.738 28.61 1.689-10.284s-4.632 2.321-7.807 2.321c-6.693 0-9.378-5.11-9.378-10.601 0-11.137 5.758-17.265 12.168-17.265 4.808 0 8.665 2.699 8.665 2.699l1.54-9.993s-4.554-3.289-9.456-3.308c-14.745-.058-23.182 10.208-23.182 27.955 0 11.763 6.248 19.768 17.506 19.768 3.183 0 8.255-1.292 8.255-1.292M81.83 63.012c-6.469 0-11.427 2.079-11.427 2.079l-1.37 8.127s4.093-1.663 10.281-1.663c3.513 0 6.083.395 6.083 3.25 0 1.734-.314 2.374-.314 2.374s-2.772-.231-4.056-.231c-9.21 0-16.729 3.482-16.729 13.98 0 8.273 5.623 10.17 9.108 10.17 6.657 0 9.292-4.203 9.444-4.215l-.077 3.488h8.307l3.706-25.98c0-11.025-9.616-11.379-12.956-11.379m1.438 21.096c.181 1.586-.41 9.086-6.092 9.086-2.93 0-3.691-2.24-3.691-3.562 0-2.584 1.403-5.683 8.315-5.683 1.61 0 1.197.116 1.468.159m20.347 16.798c2.125 0 14.272.541 14.272-11.994 0-11.721-11.244-9.404-11.244-14.114 0-2.342 1.833-3.08 5.184-3.08 1.329 0 6.447.423 6.447.423l1.189-8.33c0 .001-3.312-.741-8.704-.741-6.979 0-14.063 2.786-14.063 12.318 0 10.802 11.812 9.717 11.812 14.267 0 3.037-3.3 3.287-5.844 3.287-4.401 0-8.363-1.511-8.377-1.438l-1.259 8.245c.229.07 2.674 1.157 10.587 1.157m187.192-45.451-1.705 12.709s-3.553-4.905-9.112-4.905c-10.459 0-15.849 10.423-15.849 22.396 0 7.73 3.844 15.307 11.699 15.307 5.651 0 8.784-3.941 8.784-3.941l-.415 3.365h9.178l7.207-44.862zm-4.052 24.701c0 4.983-2.468 11.64-7.581 11.64-3.396 0-4.988-2.851-4.988-7.324 0-7.315 3.285-12.14 7.432-12.14 3.394 0 5.137 2.33 5.137 7.824M30.749 100.423l5.743-33.87.844 33.87h6.499l12.125-33.87-5.371 33.87h9.658l7.437-44.922-15.342-.117-9.126 27.504-.25-27.387h-14.06l-7.544 44.922z" style="fill:#006" transform="translate(-13.74405 15.9939)"/><path d="M176.101 100.487c2.746-15.615 3.724-27.947 11.732-25.393 1.15-6.044 3.891-11.3 5.143-13.858 0 0-.396-.589-2.871-.589-4.225 0-9.866 8.574-9.866 8.574l.843-5.301h-8.786l-5.884 36.566h9.689z" style="fill:#006" transform="translate(-13.74405 15.9939)"/><path d="M-612.55 63.012c-6.472 0-11.43 2.079-11.43 2.079l-1.369 8.127s4.095-1.663 10.28-1.663c3.514 0 6.083.395 6.083 3.25 0 1.734-.313 2.374-.313 2.374s-2.771-.231-4.055-.231c-9.211 0-16.729 3.482-16.729 13.98 0 8.273 5.622 10.17 9.107 10.17 6.655 0 9.292-4.203 9.443-4.215l-.078 3.488h8.309l3.705-25.98c.001-11.025-9.615-11.379-12.953-11.379m1.436 21.096c.18 1.586-.411 9.086-6.092 9.086-2.932 0-3.692-2.24-3.692-3.562 0-2.584 1.402-5.683 8.315-5.683 1.611 0 1.199.116 1.469.159" style="fill:#006" transform="translate(831.55605 15.9939)"/><path d="M255.266 100.487c1.508-11.488 4.299-27.616 11.731-25.393 1.149-6.044.041-6.028-2.433-6.028-4.228 0-5.164.154-5.164.154l.844-5.301h-8.785l-5.884 36.567h9.691z" style="fill:#006" Line truncated
|
||||
|
Before Width: | Height: | Size: 8.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 1000.01 618.01" xmlns="http://www.w3.org/2000/svg"><path fill="#eb001b" d="M308 0a309 309 0 1 0 2 0z"/><path fill="#f79e1b" d="M690 0a309 309 0 1 0 2 0z"/><path fill="#ff5f00" d="M500 66a309 309 0 0 0 0 486 309 309 0 0 0 0-486"/></svg>
|
||||
|
Before Width: | Height: | Size: 253 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 999.21 776.01" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="mc-silver-text-g1" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient><linearGradient id="mc-silver-text-g2" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="#8c8c8c" d="M364 66.1h270.4v485.8H364z"/><path fill="url(#mc-silver-text-g1)" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="url(#mc-silver-text-g2)" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/><path fill="#fff" d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/></svg>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 1000.01 618.01" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="mc-silver-g1" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient><linearGradient id="mc-silver-g2" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="url(#mc-silver-g1)" d="M308 0a309 309 0 1 0 2 0z"/><path fill="url(#mc-silver-g2)" d="M690 0a309 309 0 1 0 2 0z"/><path fill="#8c8c8c" d="M500 66a309 309 0 0 0 0 486 309 309 0 0 0 0-486"/></svg>
|
||||
|
Before Width: | Height: | Size: 686 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 999.21 776.01" xmlns="http://www.w3.org/2000/svg"><path fill="#ff5a00" d="M364 66.1h270.4v485.8H364z"/><path fill="#eb001b" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="#f79e1b" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/><path fill="#fff" d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.Line truncated
|
||||
|
Before Width: | Height: | Size: 7.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 51.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 54.65 66.613 52.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.Line truncated
|
||||
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="up-silver-g1" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#f8f8f8"/><stop offset="1" stop-color="#bdbdbd"/></linearGradient><linearGradient id="up-silver-g2" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#d6d6d6"/><stop offset="1" stop-color="#8a8a8a"/></linearGradient><linearGradient id="up-silver-g3" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#ebebeb"/><stop offset="1" stop-color="#a4a4a4"/></linearGradient><mask id="up-silver-m" width="280" height="180" x="-10" y="-10" maskUnits="userSpaceOnUse"><path fill="#fff" d="M-10-10h280v180H-10z"/><path d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.353c-4.148.802-4.246.93-8.076 1.421l-.299.267c-.042.128Line truncated
|
||||
|
Before Width: | Height: | Size: 8.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="-9.6 -9.6 274.74 179.05" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.Line truncated
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 825 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="#1434cb" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 820 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="#1a1f71" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 820 B |
@@ -1 +0,0 @@
|
||||
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="visa-silver-g" x1=".15" x2=".85" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="url(#visa-silver-g)" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1,7 +1,10 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import indexHtml from '../index.html?raw';
|
||||
import { initialLang } from './i18n/i18n';
|
||||
import { initialTheme } from './shell/theme';
|
||||
|
||||
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../public', name));
|
||||
|
||||
@@ -14,27 +17,30 @@ function pngInfo(buf: Buffer) {
|
||||
|
||||
describe('Name und Kopfbereich', () => {
|
||||
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
|
||||
expect(indexHtml).toContain('<title>AirCard-IOS – Kartenmotiv gestalten</title>');
|
||||
// Statisch englisch, damit Link-Vorschauen englisch sind; der Router setzt danach den Titel in der gewählten Sprache.
|
||||
expect(indexHtml).toContain('<title>AirCard-IOS – Card designs for Apple Wallet</title>');
|
||||
expect(indexHtml).toContain(
|
||||
'<meta name="description" content="Explore community card designs for AirCard-iOS, create your own and download it as PNG." />',
|
||||
);
|
||||
expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />');
|
||||
expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />');
|
||||
});
|
||||
|
||||
it('Icon-Links: SVG, 32-px-PNG, Apple-Touch-Icon', () => {
|
||||
expect(indexHtml).toContain('<link rel="icon" href="/favicon.svg" type="image/svg+xml" />');
|
||||
expect(indexHtml).toContain('<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png" />');
|
||||
expect(indexHtml).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />');
|
||||
expect(indexHtml).toContain('<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />');
|
||||
expect(indexHtml).toContain('<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />');
|
||||
expect(indexHtml).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />');
|
||||
});
|
||||
|
||||
it('Kopfzeile: dekoratives Logo (28 x 28) links vom Namen, beides im Markenlink', () => {
|
||||
expect(indexHtml).toContain(
|
||||
'<a class="brand" href="/" data-link><img src="/favicon.svg" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>',
|
||||
'<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>',
|
||||
);
|
||||
});
|
||||
|
||||
it('Fußzeilen-Hinweis bleibt unverändert', () => {
|
||||
expect(indexHtml).toContain(
|
||||
'Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.',
|
||||
);
|
||||
it('Fußzeile ist leer: ihren Text setzt mountFooter in der gewählten Sprache', () => {
|
||||
expect(indexHtml).toContain('<footer id="app-footer"></footer>');
|
||||
expect(indexHtml).not.toContain('Inoffizielles Hobbyprojekt');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,3 +53,102 @@ describe('Icon-Dateien', () => {
|
||||
expect(pngInfo(publicFile('apple-touch-icon.png'))).toEqual({ width: 180, height: 180, colorType: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inline-Skript für Hell/Dunkel und Sprache in index.html', () => {
|
||||
const script = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)?.[1] ?? '';
|
||||
|
||||
/** Was der Browser zur Sprache hergibt: der gespeicherte Wert von `aircard.lang`, `navigator.languages` und `navigator.language`. */
|
||||
interface BrowserLang {
|
||||
stored?: string | null;
|
||||
languages?: readonly string[];
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt das Skript in einer leeren Umgebung aus (kein Browser) und liefert, was es gesetzt hat.
|
||||
* `stored` ist der Wert von `aircard.theme`; die Sprache steht in `browser`.
|
||||
*/
|
||||
function run(stored: string | null, prefersDark: boolean, storageThrows = false, browser: BrowserLang = {}) {
|
||||
const attributes: Record<string, string> = {};
|
||||
const sandbox: Record<string, unknown> = {
|
||||
localStorage: {
|
||||
getItem: (key: string) => {
|
||||
if (storageThrows) throw new Error('gesperrt');
|
||||
return key === 'aircard.lang' ? (browser.stored ?? null) : stored;
|
||||
},
|
||||
},
|
||||
window: {
|
||||
matchMedia: () => ({ matches: prefersDark }),
|
||||
navigator: { languages: browser.languages, language: browser.language },
|
||||
},
|
||||
document: { documentElement: { setAttribute: (name: string, value: string) => (attributes[name] = value) } },
|
||||
};
|
||||
runInNewContext(script, sandbox);
|
||||
return { attributes, sandbox };
|
||||
}
|
||||
|
||||
it('ist da und läuft ohne Browser-Umgebung durch', () => {
|
||||
expect(script).toContain('data-theme');
|
||||
expect(() => run(null, true)).not.toThrow();
|
||||
});
|
||||
|
||||
it('legt keine globale Variable an (Skript steckt in einer sofort aufgerufenen Funktion)', () => {
|
||||
const { sandbox } = run('dark', false);
|
||||
expect(Object.keys(sandbox).sort()).toEqual(['document', 'localStorage', 'window']);
|
||||
expect('theme' in sandbox).toBe(false);
|
||||
expect('lang' in sandbox).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['dark', false],
|
||||
['light', true],
|
||||
['dark', true],
|
||||
['light', false],
|
||||
['blau', true],
|
||||
['blau', false],
|
||||
['', true],
|
||||
[null, true],
|
||||
[null, false],
|
||||
] as const)('gleiche Regel wie initialTheme(): gespeichert %j, Systemvorgabe dunkel %s', (stored, prefersDark) => {
|
||||
expect(run(stored, prefersDark).attributes['data-theme']).toBe(initialTheme(stored, prefersDark));
|
||||
});
|
||||
|
||||
it('gesperrter localStorage: es gilt die Systemvorgabe', () => {
|
||||
expect(run(null, true, true).attributes['data-theme']).toBe('dark');
|
||||
expect(run(null, false, true).attributes['data-theme']).toBe('light');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, ['de-DE', 'en']],
|
||||
[null, ['en-US', 'de']],
|
||||
['en', ['de-DE']],
|
||||
['de', ['en-US']],
|
||||
['xx', ['de-AT']],
|
||||
['xx', ['fr-FR']],
|
||||
['', ['de']],
|
||||
[null, ['DE']],
|
||||
[null, ['de_CH']],
|
||||
[null, []],
|
||||
] as const)('setzt <html lang> nach derselben Regel wie initialLang(): gespeichert %j, Browsersprachen %j', (stored, languages) => {
|
||||
const { attributes } = run(null, true, false, { stored, languages });
|
||||
expect(attributes.lang).toBe(initialLang(stored, languages));
|
||||
});
|
||||
|
||||
it('ohne navigator.languages gilt navigator.language (wie currentLang), ohne beides Englisch', () => {
|
||||
expect(run(null, true, false, { language: 'de-CH' }).attributes.lang).toBe('de');
|
||||
expect(run(null, true, false, { language: 'en-GB' }).attributes.lang).toBe('en');
|
||||
expect(run(null, true, false, { languages: [], language: 'de' }).attributes.lang).toBe('de');
|
||||
expect(run(null, true, false, {}).attributes.lang).toBe('en');
|
||||
});
|
||||
|
||||
it('gesperrter localStorage: es gilt die Browsersprache', () => {
|
||||
expect(run(null, true, true, { stored: 'en', languages: ['de-DE'] }).attributes.lang).toBe('de');
|
||||
expect(run(null, true, true, { stored: 'de', languages: ['en-US'] }).attributes.lang).toBe('en');
|
||||
});
|
||||
|
||||
it('die gespeicherte Sprache hat mit dem Theme nichts zu tun (und umgekehrt)', () => {
|
||||
const { attributes } = run('light', false, false, { stored: 'en', languages: ['de'] });
|
||||
expect(attributes['data-theme']).toBe('light');
|
||||
expect(attributes.lang).toBe('en');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_CROP,
|
||||
NETWORK_IDS,
|
||||
ID_PATTERN,
|
||||
TONE_COLOR,
|
||||
parseConfig,
|
||||
serializeConfig,
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
const custom: CardConfig = {
|
||||
v: 1,
|
||||
template: 'classic',
|
||||
network: 'jcb',
|
||||
logo: 'abc123',
|
||||
details: 'dark',
|
||||
brand: 'apple',
|
||||
brand: 'aircards-text',
|
||||
crop: { x: -120.5, y: 33, zoom: 2.5 },
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('Standardwerte', () => {
|
||||
expect(DEFAULT_CONFIG).toEqual({
|
||||
v: 1,
|
||||
template: 'signature',
|
||||
network: 'visa-auto',
|
||||
logo: null,
|
||||
details: 'light',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: 0, y: 0, zoom: 1 },
|
||||
@@ -36,16 +36,13 @@ describe('Standardwerte', () => {
|
||||
expect(DEFAULT_CONFIG.crop).not.toBe(DEFAULT_CROP);
|
||||
});
|
||||
|
||||
it('NETWORK_IDS: 18 Logos in der vereinbarten Reihenfolge', () => {
|
||||
expect(NETWORK_IDS.length).toBe(18);
|
||||
expect([...NETWORK_IDS]).toEqual([
|
||||
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
|
||||
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
|
||||
'up-color', 'up-silver', 'up-white-frame', 'up-english',
|
||||
'amex-blue', 'amex-outline', 'amex-select',
|
||||
'jcb', 'discover',
|
||||
]);
|
||||
expect(new Set(NETWORK_IDS).size).toBe(18);
|
||||
it('eine neue Karte hat kein Logo unten rechts', () => {
|
||||
expect(DEFAULT_CONFIG.logo).toBeNull();
|
||||
});
|
||||
|
||||
it('ID_PATTERN: Appwrite-IDs mit höchstens 36 Zeichen und ohne Sonderzeichen am Anfang', () => {
|
||||
for (const ok of ['abc123', 'A', '0', 'a.b_c-d', 'x'.repeat(36)]) expect(ID_PATTERN.test(ok), ok).toBe(true);
|
||||
for (const bad of ['', 'kaputt id', '-abc', '_abc', '.abc', 'ä', 'a/b', 'x'.repeat(37)]) expect(ID_PATTERN.test(bad), bad).toBe(false);
|
||||
});
|
||||
|
||||
it('TONE_COLOR: Hell weiß, Dunkel fast schwarz', () => {
|
||||
@@ -72,9 +69,31 @@ describe('parseConfig / serializeConfig', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('unbekanntes Netzwerk setzt nur dieses Feld auf den Standard', () => {
|
||||
const raw = JSON.stringify({ ...custom, network: 'paypal' });
|
||||
expect(parseConfig(raw)).toEqual({ ...custom, network: DEFAULT_CONFIG.network });
|
||||
it('eine alte Konfiguration mit network und Apple-Logo: network entfällt, Logo oben links fällt auf den Standard', () => {
|
||||
const raw = '{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}';
|
||||
expect(parseConfig(raw)).toEqual({
|
||||
v: 1,
|
||||
template: 'classic',
|
||||
logo: null,
|
||||
details: 'dark',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: 0, y: 0, zoom: 1 },
|
||||
});
|
||||
// andere unbekannte Marken ebenso
|
||||
expect(parseConfig('{"brand":"visa"}').brand).toBe('aircards-symbol');
|
||||
});
|
||||
|
||||
it('logo: eine gültige ID bleibt, alles andere ergibt null', () => {
|
||||
expect(parseConfig('{"logo":"abc123"}').logo).toBe('abc123');
|
||||
expect(parseConfig(JSON.stringify({ logo: 'x'.repeat(36) })).logo).toBe('x'.repeat(36));
|
||||
for (const bad of ['kaputt id', '', '-abc', 'x'.repeat(37), 42, true, null, ['abc'], { id: 'abc' }]) {
|
||||
expect(parseConfig(JSON.stringify({ logo: bad })).logo, JSON.stringify(bad)).toBeNull();
|
||||
}
|
||||
expect(parseConfig('{}').logo).toBeNull();
|
||||
});
|
||||
|
||||
it('das gewählte Logo bleibt nur dem Feld logo zugeordnet: ein altes network wird nie zum Logo', () => {
|
||||
expect(parseConfig('{"network":"abc123"}').logo).toBeNull();
|
||||
});
|
||||
|
||||
it('jedes Feld wird einzeln validiert', () => {
|
||||
@@ -88,11 +107,11 @@ describe('parseConfig / serializeConfig', () => {
|
||||
});
|
||||
|
||||
it('Namen aus Object.prototype gelten nicht als gültige Werte', () => {
|
||||
const raw = JSON.stringify({ ...custom, network: 'constructor', template: '__proto__', brand: 'toString' });
|
||||
const raw = JSON.stringify({ ...custom, template: '__proto__', details: 'constructor', brand: 'toString' });
|
||||
expect(parseConfig(raw)).toEqual({
|
||||
...custom,
|
||||
network: DEFAULT_CONFIG.network,
|
||||
template: DEFAULT_CONFIG.template,
|
||||
details: DEFAULT_CONFIG.details,
|
||||
brand: DEFAULT_CONFIG.brand,
|
||||
});
|
||||
});
|
||||
@@ -137,21 +156,29 @@ describe('parseConfig / serializeConfig', () => {
|
||||
const raw = JSON.stringify({ ...custom, evil: '<script>', crop: { ...custom.crop, extra: 1 } });
|
||||
const c = parseConfig(raw);
|
||||
expect(c).toEqual(custom);
|
||||
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'network', 'template', 'v']);
|
||||
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'logo', 'template', 'v']);
|
||||
expect(Object.keys(c.crop).sort()).toEqual(['x', 'y', 'zoom']);
|
||||
});
|
||||
|
||||
it('serializeConfig schreibt nur bekannte Felder', () => {
|
||||
const s = serializeConfig({ ...custom, evil: 1 } as CardConfig);
|
||||
const s = serializeConfig({ ...custom, evil: 1, network: 'jcb' } as CardConfig);
|
||||
expect(s).not.toContain('evil');
|
||||
expect(s).not.toContain('network');
|
||||
expect(JSON.parse(s)).toEqual(custom);
|
||||
});
|
||||
|
||||
it('serializeConfig schreibt genau {v, template, logo, details, brand, crop} in dieser Reihenfolge', () => {
|
||||
expect(Object.keys(JSON.parse(serializeConfig(custom)))).toEqual(['v', 'template', 'logo', 'details', 'brand', 'crop']);
|
||||
expect(serializeConfig(DEFAULT_CONFIG)).toBe(
|
||||
'{"v":1,"template":"signature","logo":null,"details":"light","brand":"aircards-symbol","crop":{"x":0,"y":0,"zoom":1}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('serializeConfig bleibt unter 2000 Zeichen', () => {
|
||||
expect(serializeConfig(DEFAULT_CONFIG).length).toBeLessThan(2000);
|
||||
const worst: CardConfig = {
|
||||
...custom,
|
||||
network: 'mc-silver-text',
|
||||
logo: 'x'.repeat(36),
|
||||
brand: 'aircards-symbol',
|
||||
crop: { x: -1.2345678901234567e-300, y: -9.87654321098765e300, zoom: 3.3333333333333335 },
|
||||
};
|
||||
|
||||
@@ -3,21 +3,17 @@
|
||||
|
||||
const TEMPLATES = ['image', 'minimal', 'classic', 'signature'] as const;
|
||||
const TONES = ['light', 'dark'] as const;
|
||||
const BRANDS = ['aircards-symbol', 'aircards-text', 'apple', 'none'] as const;
|
||||
const NETWORKS = [
|
||||
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
|
||||
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
|
||||
'up-color', 'up-silver', 'up-white-frame', 'up-english',
|
||||
'amex-blue', 'amex-outline', 'amex-select',
|
||||
'jcb', 'discover',
|
||||
] as const;
|
||||
const BRANDS = ['aircards-symbol', 'aircards-text', 'none'] as const;
|
||||
|
||||
export type TemplateId = (typeof TEMPLATES)[number];
|
||||
export type DetailsTone = (typeof TONES)[number];
|
||||
export type BrandId = (typeof BRANDS)[number];
|
||||
export type NetworkId = (typeof NETWORKS)[number];
|
||||
|
||||
export const NETWORK_IDS: readonly NetworkId[] = NETWORKS;
|
||||
/**
|
||||
* Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. Steht hier, damit die reine
|
||||
* Konfiguration die ID des Community-Logos prüfen kann, ohne Appwrite zu laden (account/cards.ts reicht sie weiter).
|
||||
*/
|
||||
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||
|
||||
/** Zoom-Grenzen relativ zum Cover-Maßstab. */
|
||||
export const MIN_ZOOM = 1;
|
||||
@@ -33,8 +29,10 @@ export interface Crop {
|
||||
export interface CardConfig {
|
||||
v: 1;
|
||||
template: TemplateId;
|
||||
network: NetworkId;
|
||||
/** ID des Community-Logos unten rechts; null = keins. */
|
||||
logo: string | null;
|
||||
details: DetailsTone;
|
||||
/** Das Logo oben links (AirCard-IOS Symbol oder Text, oder keins). */
|
||||
brand: BrandId;
|
||||
crop: Crop;
|
||||
}
|
||||
@@ -44,7 +42,7 @@ export const DEFAULT_CROP: Crop = { x: 0, y: 0, zoom: 1 };
|
||||
export const DEFAULT_CONFIG: CardConfig = {
|
||||
v: 1,
|
||||
template: 'signature',
|
||||
network: 'visa-auto',
|
||||
logo: null,
|
||||
details: 'light',
|
||||
brand: 'aircards-symbol',
|
||||
crop: { ...DEFAULT_CROP },
|
||||
@@ -61,6 +59,11 @@ function pick<T extends string>(value: unknown, allowed: readonly T[], fallback:
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** Die ID eines Community-Logos; alles, was keine Appwrite-ID sein kann, ergibt null (kein Logo). */
|
||||
function parseLogo(value: unknown): string | null {
|
||||
return typeof value === 'string' && ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function finite(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
@@ -82,6 +85,8 @@ function parseCrop(value: unknown): Crop {
|
||||
/**
|
||||
* Liest eine gespeicherte Konfiguration. Jedes Feld wird einzeln geprüft, Unbekanntes oder
|
||||
* Kaputtes fällt auf den Standard dieses Feldes zurück. Liefert immer ein neues Objekt.
|
||||
* Ältere Karten kennen noch das Feld `network` (Kartennetzwerk) und das Apple-Logo: `network` wird ignoriert,
|
||||
* `brand: 'apple'` fällt auf den Standard.
|
||||
*/
|
||||
export function parseConfig(json: string | null | undefined): CardConfig {
|
||||
let raw: unknown = null;
|
||||
@@ -96,7 +101,7 @@ export function parseConfig(json: string | null | undefined): CardConfig {
|
||||
return {
|
||||
v: 1,
|
||||
template: pick(r.template, TEMPLATES, DEFAULT_CONFIG.template),
|
||||
network: pick(r.network, NETWORKS, DEFAULT_CONFIG.network),
|
||||
logo: parseLogo(r.logo),
|
||||
details: pick(r.details, TONES, DEFAULT_CONFIG.details),
|
||||
brand: pick(r.brand, BRANDS, DEFAULT_CONFIG.brand),
|
||||
crop: parseCrop(r.crop),
|
||||
@@ -108,7 +113,7 @@ export function serializeConfig(c: CardConfig): string {
|
||||
return JSON.stringify({
|
||||
v: 1,
|
||||
template: c.template,
|
||||
network: c.network,
|
||||
logo: c.logo,
|
||||
details: c.details,
|
||||
brand: c.brand,
|
||||
crop: { x: c.crop.x, y: c.crop.y, zoom: c.crop.zoom },
|
||||
|
||||
@@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
CARD_H,
|
||||
CARD_W,
|
||||
LOGO_MAX_H,
|
||||
MARGIN,
|
||||
PREVIEW_RADIUS,
|
||||
WALLET_KEEP_CLEAR,
|
||||
fitBrand,
|
||||
fitNetwork,
|
||||
fitLogo,
|
||||
layoutFor,
|
||||
type Box,
|
||||
} from './geometry';
|
||||
@@ -41,36 +42,48 @@ describe('Konstanten', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitNetwork', () => {
|
||||
it('Standardbreite 330 × 109 sitzt unten rechts auf dem Rand', () => {
|
||||
const b = fitNetwork(330 / 109, 109);
|
||||
expect(b.x).toBeCloseTo(1150, 1);
|
||||
expect(b.y).toBeCloseTo(804, 1);
|
||||
expect(b.w).toBeCloseTo(330, 1);
|
||||
expect(b.h).toBeCloseTo(109, 1);
|
||||
describe('fitLogo', () => {
|
||||
it('LOGO_MAX_H ist 125', () => {
|
||||
expect(LOGO_MAX_H).toBe(125);
|
||||
});
|
||||
|
||||
it('Seitenverhältnis 2 bei Höhe 125: 250 × 125, rechte Kante 1480, Unterkante 913', () => {
|
||||
expect(fitLogo(2, 125)).toEqual({ x: 1230, y: 788, w: 250, h: 125 });
|
||||
});
|
||||
|
||||
it('ohne maxH gilt LOGO_MAX_H', () => {
|
||||
expect(fitLogo(2)).toEqual(fitLogo(2, LOGO_MAX_H));
|
||||
expect(fitLogo(1)).toEqual({ x: 1355, y: 788, w: 125, h: 125 });
|
||||
});
|
||||
|
||||
it('sehr breite Logos werden auf 360 begrenzt', () => {
|
||||
const b = fitNetwork(6, 109);
|
||||
const b = fitLogo(10, 125);
|
||||
expect(b.w).toBeCloseTo(360, 1);
|
||||
expect(b.h).toBeCloseTo(60, 1);
|
||||
expect(b.h).toBeCloseTo(36, 1);
|
||||
expect(b.x + b.w).toBeCloseTo(1480, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('reine Symbole dürfen bis 125 px hoch sein', () => {
|
||||
const b = fitNetwork(1, 125);
|
||||
expect(b.w).toBeCloseTo(125, 1);
|
||||
it('hohe Logos werden auf die Höhe begrenzt', () => {
|
||||
const b = fitLogo(0.5, 125);
|
||||
expect(b.h).toBeCloseTo(125, 1);
|
||||
expect(b.w).toBeCloseTo(62.5, 1);
|
||||
expect(b.x + b.w).toBeCloseTo(1480, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('maxH ist einstellbar', () => {
|
||||
const b = fitLogo(1, 100);
|
||||
expect(b.w).toBeCloseTo(100, 1);
|
||||
expect(b.h).toBeCloseTo(100, 1);
|
||||
expect(b.y + b.h).toBeCloseTo(913, 1);
|
||||
});
|
||||
|
||||
it('Seitenverhältnis bleibt erhalten', () => {
|
||||
for (const aspect of [0.8, 1, 1.5, 3, 6, 10]) {
|
||||
const b = fitNetwork(aspect, 109);
|
||||
const b = fitLogo(aspect);
|
||||
expect(b.w / b.h).toBeCloseTo(aspect, 1);
|
||||
expect(b.h).toBeLessThanOrEqual(109 + 1e-9);
|
||||
expect(b.h).toBeLessThanOrEqual(LOGO_MAX_H + 1e-9);
|
||||
expect(b.w).toBeLessThanOrEqual(360 + 1e-9);
|
||||
}
|
||||
});
|
||||
@@ -105,23 +118,23 @@ describe('fitBrand', () => {
|
||||
});
|
||||
|
||||
describe('layoutFor', () => {
|
||||
it('image: kein Netzwerk, keine Marke, keine optionalen Felder', () => {
|
||||
it('image: kein Logo, keine Marke, keine optionalen Felder', () => {
|
||||
const l = layoutFor('image');
|
||||
expect(l.network).toBe(false);
|
||||
expect(l.logo).toBe(false);
|
||||
expect(l.brand).toBe(false);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
|
||||
});
|
||||
|
||||
it('minimal: nur Netzwerk', () => {
|
||||
it('minimal: nur Logo', () => {
|
||||
const l = layoutFor('minimal');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.logo).toBe(true);
|
||||
expect(l.brand).toBe(false);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
|
||||
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
|
||||
});
|
||||
|
||||
it('classic: alles', () => {
|
||||
const l = layoutFor('classic');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.logo).toBe(true);
|
||||
expect(l.brand).toBe(true);
|
||||
expect(l.platinum).toEqual({ x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' });
|
||||
expect(l.chip).toEqual({
|
||||
@@ -141,9 +154,9 @@ describe('layoutFor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('signature: Netzwerk, Marke und PLATINUM links', () => {
|
||||
it('signature: Logo, Marke und PLATINUM links', () => {
|
||||
const l = layoutFor('signature');
|
||||
expect(l.network).toBe(true);
|
||||
expect(l.logo).toBe(true);
|
||||
expect(l.brand).toBe(true);
|
||||
expect(l.platinum).toEqual({ x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' });
|
||||
expect(l.chip).toBeUndefined();
|
||||
@@ -193,9 +206,9 @@ describe.each(TEMPLATES)('Vorlage %s: Wallet-Bereich unten links bleibt frei', (
|
||||
if (l.contactless) boxes.contactless = l.contactless.box;
|
||||
if (l.number) boxes.number = NUMBER_BOX;
|
||||
if (l.platinum) boxes.platinum = PLATINUM_BOX[t]!;
|
||||
if (l.network) {
|
||||
boxes.networkSymbol = fitNetwork(1, 125);
|
||||
boxes.networkWide = fitNetwork(6, 109);
|
||||
if (l.logo) {
|
||||
boxes.logoSquare = fitLogo(1);
|
||||
boxes.logoWide = fitLogo(10);
|
||||
}
|
||||
if (l.brand) boxes.brand = fitBrand(3.6);
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ export const CARD_H = 969;
|
||||
export const MARGIN = 56;
|
||||
/** Nur für die Vorschau (CSS), nie im Export. */
|
||||
export const PREVIEW_RADIUS = 57;
|
||||
/** Vorschaudatei (WebP) einer gespeicherten oder veröffentlichten Karte: Breite in px und Qualität des Exports. */
|
||||
export const PREVIEW_WIDTH = 480;
|
||||
export const PREVIEW_QUALITY = 0.85;
|
||||
|
||||
export interface Box {
|
||||
x: number;
|
||||
@@ -17,19 +20,21 @@ export interface Box {
|
||||
/** Unten links blendet Wallet die letzten vier Ziffern ein, hier darf nichts liegen. */
|
||||
export const WALLET_KEEP_CLEAR: Box = { x: 0, y: 779, w: 620, h: 190 };
|
||||
|
||||
const NETWORK_MAX_W = 360;
|
||||
const NETWORK_RIGHT = CARD_W - MARGIN; // 1480
|
||||
const NETWORK_BOTTOM = CARD_H - MARGIN; // 913
|
||||
/** Höhe, bis zu der ein Community-Logo unten rechts auf der Karte wachsen darf. */
|
||||
export const LOGO_MAX_H = 125;
|
||||
const LOGO_MAX_W = 360;
|
||||
const LOGO_RIGHT = CARD_W - MARGIN; // 1480
|
||||
const LOGO_BOTTOM = CARD_H - MARGIN; // 913
|
||||
const BRAND_MAX_W = 360;
|
||||
|
||||
/** Netzwerk-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
|
||||
export function fitNetwork(aspect: number, maxH: number): Box {
|
||||
const w = Math.min(NETWORK_MAX_W, maxH * aspect);
|
||||
/** Community-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
|
||||
export function fitLogo(aspect: number, maxH = LOGO_MAX_H): Box {
|
||||
const w = Math.min(LOGO_MAX_W, maxH * aspect);
|
||||
const h = w / aspect;
|
||||
return { x: NETWORK_RIGHT - w, y: NETWORK_BOTTOM - h, w, h };
|
||||
return { x: LOGO_RIGHT - w, y: LOGO_BOTTOM - h, w, h };
|
||||
}
|
||||
|
||||
/** AirCard-/Apple-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
|
||||
/** AirCard-IOS-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
|
||||
export function fitBrand(aspect: number, maxH = 100): Box {
|
||||
const w = Math.min(BRAND_MAX_W, maxH * aspect);
|
||||
const h = w / aspect;
|
||||
@@ -37,7 +42,9 @@ export function fitBrand(aspect: number, maxH = 100): Box {
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
network: boolean;
|
||||
/** Community-Logo unten rechts. */
|
||||
logo: boolean;
|
||||
/** AirCard-IOS-Logo oben links. */
|
||||
brand: boolean;
|
||||
platinum?: { x: number; baseline: number; size: number; trackingPx: number; align: 'left' | 'right' };
|
||||
chip?: {
|
||||
@@ -52,10 +59,10 @@ export interface Layout {
|
||||
}
|
||||
|
||||
const LAYOUTS: Record<TemplateId, Layout> = {
|
||||
image: { network: false, brand: false },
|
||||
minimal: { network: true, brand: false },
|
||||
image: { logo: false, brand: false },
|
||||
minimal: { logo: true, brand: false },
|
||||
classic: {
|
||||
network: true,
|
||||
logo: true,
|
||||
brand: true,
|
||||
platinum: { x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' },
|
||||
chip: {
|
||||
@@ -77,7 +84,7 @@ const LAYOUTS: Record<TemplateId, Layout> = {
|
||||
},
|
||||
},
|
||||
signature: {
|
||||
network: true,
|
||||
logo: true,
|
||||
brand: true,
|
||||
platinum: { x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' },
|
||||
},
|
||||
|
||||
@@ -1,62 +1,39 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import { NETWORK_IDS } from './config';
|
||||
import favicon from '../../public/favicon.svg?raw';
|
||||
import {
|
||||
BRAND_MARKS,
|
||||
NETWORK_MARKS,
|
||||
getBrandMark,
|
||||
getNetworkMark,
|
||||
loadMarkImage,
|
||||
svgAspect,
|
||||
tintSvg,
|
||||
} from './marks';
|
||||
import { BRAND_MARKS, getBrandMark, loadMarkImage, svgAspect, tintSvg, viewBoxAspect } from './marks';
|
||||
|
||||
const NETWORK_LABELS = [
|
||||
'Automatisch', 'Blau', 'Marineblau', 'Metallisches Silber',
|
||||
'Farbe', 'Metallisches Silber', 'Wortmarke', 'Silber + Text', 'Klassisch',
|
||||
'Farbe', 'Metallisches Silber', 'Weißer Rahmen', 'Englisch',
|
||||
'Blau', 'Umriss', 'Select',
|
||||
'JCB', 'Discover',
|
||||
];
|
||||
const BRAND_IDS = ['aircards-symbol', 'aircards-text'];
|
||||
|
||||
const ALL = [...NETWORK_MARKS, ...BRAND_MARKS];
|
||||
const TINT_IDS = ['visa-auto', 'amex-outline', 'aircards-symbol', 'aircards-text', 'apple'];
|
||||
|
||||
describe('Registers', () => {
|
||||
it('NETWORK_MARKS folgt NETWORK_IDS', () => {
|
||||
expect(NETWORK_MARKS.map((m) => m.id)).toEqual([...NETWORK_IDS]);
|
||||
});
|
||||
|
||||
it('Labels stehen im Wortlaut der Vorlage in Reihenfolge', () => {
|
||||
expect(NETWORK_MARKS.map((m) => m.label)).toEqual(NETWORK_LABELS);
|
||||
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text', 'Apple']);
|
||||
});
|
||||
|
||||
it('BRAND_MARKS: Logo-Gruppe, feste Reihenfolge', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.id)).toEqual(['aircards-symbol', 'aircards-text', 'apple']);
|
||||
describe('Register', () => {
|
||||
it('BRAND_MARKS: nur die zwei AirCard-IOS-Marken, in fester Reihenfolge, Logo-Gruppe', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.id)).toEqual(BRAND_IDS);
|
||||
expect(BRAND_MARKS.every((m) => m.group === 'Logo')).toBe(true);
|
||||
});
|
||||
|
||||
it('Gruppen der Netzwerke', () => {
|
||||
const groups = NETWORK_MARKS.map((m) => m.group);
|
||||
expect(groups).toEqual([
|
||||
'Visa', 'Visa', 'Visa', 'Visa',
|
||||
'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard',
|
||||
'UnionPay', 'UnionPay', 'UnionPay', 'UnionPay',
|
||||
'American Express', 'American Express', 'American Express',
|
||||
'JCB', 'Discover',
|
||||
]);
|
||||
it('Labels stehen im Wortlaut der Vorlage', () => {
|
||||
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text']);
|
||||
});
|
||||
|
||||
it('getNetworkMark / getBrandMark finden den Eintrag', () => {
|
||||
for (const id of NETWORK_IDS) expect(getNetworkMark(id).id).toBe(id);
|
||||
expect(getBrandMark('apple').id).toBe('apple');
|
||||
it('getBrandMark findet den Eintrag', () => {
|
||||
for (const id of BRAND_IDS) expect(getBrandMark(id as 'aircards-symbol').id).toBe(id);
|
||||
expect(getBrandMark('aircards-text').label).toBe('AirCard-IOS · Text');
|
||||
});
|
||||
|
||||
it('keine Netzwerk- und keine Apple-Marken mehr im Register', () => {
|
||||
const registry = BRAND_MARKS.map((m) => `${m.id} ${m.group} ${m.label}`).join('\n');
|
||||
expect(registry).not.toMatch(/visa|mastercard|unionpay|american express|jcb|discover|apple/i);
|
||||
});
|
||||
|
||||
it('assets/marks enthält nur noch die zwei AirCard-IOS-Dateien (keine fremden Marken-Logos)', () => {
|
||||
const files = readdirSync(join(import.meta.dirname, '../assets/marks')).sort();
|
||||
expect(files).toEqual(['aircards-symbol.svg', 'aircards-text.svg']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG-Inhalt', () => {
|
||||
it.each(ALL.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
|
||||
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
|
||||
expect(m.svg.trimStart().startsWith('<svg')).toBe(true);
|
||||
expect(m.svg).toMatch(/viewBox="[^"]+"/);
|
||||
expect(m.svg).not.toContain('<image');
|
||||
@@ -67,7 +44,7 @@ describe('SVG-Inhalt', () => {
|
||||
});
|
||||
|
||||
it('Wurzelelement: xmlns für Blob-Bilder, keine feste Größe (die viewBox bestimmt die Größe)', () => {
|
||||
for (const m of ALL) {
|
||||
for (const m of BRAND_MARKS) {
|
||||
const root = /^<svg\b[^>]*>/.exec(m.svg.trimStart())![0];
|
||||
expect(root, m.id).toContain('xmlns="http://www.w3.org/2000/svg"');
|
||||
expect(root, m.id).not.toMatch(/\s(width|height)=/);
|
||||
@@ -84,24 +61,24 @@ describe('SVG-Inhalt', () => {
|
||||
expect(svgAspect('<svg viewBox="10 20 300 100"></svg>')).toBeCloseTo(3, 1);
|
||||
});
|
||||
|
||||
it('svgAspect: Visa ist ein breites Wortlogo', () => {
|
||||
expect(svgAspect(getNetworkMark('visa-auto').svg)).toBeCloseTo(1000 / 324.68, 1);
|
||||
it('viewBoxAspect: Breite durch Höhe aus dem Wert einer viewBox (Leerraum oder Kommas), sonst null', () => {
|
||||
expect(viewBoxAspect('0 0 300 100')).toBeCloseTo(3, 5);
|
||||
expect(viewBoxAspect(' 10,20,200,100 ')).toBeCloseTo(2, 5);
|
||||
expect(viewBoxAspect('0 0 100 100')).toBe(1);
|
||||
for (const unusable of ['', 'none', '0 0 0 10', '0 0 10 0', '0 0 -10 5', '0 0 10', '0 0 1 2 3', '0 0 a b', '0 0 Infinity 5']) {
|
||||
expect(viewBoxAspect(unusable), JSON.stringify(unusable)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('Einfärbbar genau bei den fünf Tint-Marken', () => {
|
||||
const tinted = ALL.filter((m) => m.tint).map((m) => m.id).sort();
|
||||
expect(tinted).toEqual([...TINT_IDS].sort());
|
||||
});
|
||||
|
||||
it('Tint-Marken enthalten currentColor, alle anderen nicht', () => {
|
||||
for (const m of ALL) {
|
||||
expect(m.svg.includes('currentColor'), m.id).toBe(m.tint);
|
||||
it('beide Marken sind einfärbbar und enthalten currentColor', () => {
|
||||
for (const m of BRAND_MARKS) {
|
||||
expect(m.tint, m.id).toBe(true);
|
||||
expect(m.svg.includes('currentColor'), m.id).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('tintSvg ersetzt jedes currentColor', () => {
|
||||
for (const id of TINT_IDS) {
|
||||
const m = [...NETWORK_MARKS, ...BRAND_MARKS].find((x) => x.id === id)!;
|
||||
for (const m of BRAND_MARKS) {
|
||||
const dark = tintSvg(m.svg, 'dark');
|
||||
expect(dark).toContain('#111111');
|
||||
expect(dark).not.toContain('currentColor');
|
||||
@@ -111,14 +88,14 @@ describe('SVG-Inhalt', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('tintSvg lässt Marken ohne currentColor unverändert', () => {
|
||||
const m = getNetworkMark('jcb');
|
||||
expect(tintSvg(m.svg, 'dark')).toBe(m.svg);
|
||||
it('tintSvg lässt ein SVG ohne currentColor unverändert', () => {
|
||||
const svg = '<svg viewBox="0 0 10 10"><path fill="#123456" d="M0 0h10v10z"/></svg>';
|
||||
expect(tintSvg(svg, 'dark')).toBe(svg);
|
||||
});
|
||||
|
||||
it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const m of ALL) {
|
||||
for (const m of BRAND_MARKS) {
|
||||
for (const hit of m.svg.matchAll(/\bid="([^"]+)"/g)) {
|
||||
const id = hit[1];
|
||||
expect(seen.has(id), `${id} doppelt in ${m.id} und ${seen.get(id)}`).toBe(false);
|
||||
@@ -129,36 +106,15 @@ describe('SVG-Inhalt', () => {
|
||||
});
|
||||
|
||||
describe('Verweise innerhalb der Dateien', () => {
|
||||
it.each(ALL.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
|
||||
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
|
||||
const defined = new Set([...m.svg.matchAll(/\bid="([^"]+)"/g)].map((h) => h[1]));
|
||||
const refs = [...m.svg.matchAll(/url\(\s*['"]?#([^)'"\s]+)/g)].map((h) => h[1]);
|
||||
for (const ref of refs) expect(defined.has(ref), `${ref} fehlt in ${m.id}`).toBe(true);
|
||||
});
|
||||
|
||||
it('die Silber-/Verlaufs-Marken verwenden überhaupt Verweise', () => {
|
||||
for (const id of ['visa-silver', 'mc-silver', 'mc-silver-text', 'up-silver', 'jcb', 'discover']) {
|
||||
expect(getNetworkMark(id as never).svg, id).toMatch(/url\(#/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxH', () => {
|
||||
const h = (id: string) => NETWORK_MARKS.find((m) => m.id === id)!.maxH;
|
||||
|
||||
it('109 für Visa, Mastercard-Wortmarken, UnionPay English, Discover', () => {
|
||||
for (const id of ['visa-auto', 'visa-blue', 'visa-navy', 'visa-silver', 'mc-wordmark', 'mc-silver-text', 'up-english', 'discover']) {
|
||||
expect(h(id), id).toBe(109);
|
||||
}
|
||||
});
|
||||
|
||||
it('125 für Symbol-Logos', () => {
|
||||
for (const id of ['mc-color', 'mc-silver', 'mc-classic', 'up-color', 'up-silver', 'up-white-frame', 'amex-blue', 'amex-outline', 'jcb']) {
|
||||
expect(h(id), id).toBe(125);
|
||||
}
|
||||
});
|
||||
|
||||
it('90 für Amex Select, 100 für alle Marken', () => {
|
||||
expect(h('amex-select')).toBe(90);
|
||||
it('100 für beide Marken', () => {
|
||||
expect(BRAND_MARKS.every((m) => m.maxH === 100)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -175,6 +131,21 @@ describe('AirCard-IOS-Marken', () => {
|
||||
expect(symbol).not.toMatch(/fill-opacity="1"/);
|
||||
});
|
||||
|
||||
it('Symbol: die Maske hat keine eigene Region und ihr weißes Feld ist größer als die vordere Karte (kein Haarstrich an der Naht)', () => {
|
||||
const tag = /<mask\b[^>]*>/.exec(symbol)?.[0] ?? '';
|
||||
// Ohne x/y/width/height gilt die Standardregion (-10 % / 120 % der Karte); eine Region exakt auf der Kartenkante
|
||||
// würde deren Kantenglättung doppelt anwenden und bei Vergrößerung einen hellen Strich lassen.
|
||||
expect(tag).not.toMatch(/\s(?:x|y|width|height|maskUnits)=/);
|
||||
const white = /<mask\b[^>]*>\s*<path\b[^>]*fill="#fff"[^>]*\bd="M(-?[\d.]+)[ ,](-?[\d.]+)h([\d.]+)v([\d.]+)H/.exec(symbol);
|
||||
expect(white, 'weißes Rechteck am Anfang der Maske').not.toBeNull();
|
||||
const [x, y, w, h] = white!.slice(1).map(Number);
|
||||
// Die vordere Karte: x 172–852, y 660–1090.
|
||||
expect(x).toBeLessThan(172);
|
||||
expect(y).toBeLessThan(660);
|
||||
expect(x + w).toBeGreaterThan(852);
|
||||
expect(y + h).toBeGreaterThan(1090);
|
||||
});
|
||||
|
||||
it('Symbol: enge viewBox um die drei Karten (680 x 730)', () => {
|
||||
expect(svgAspect(symbol)).toBeCloseTo(680 / 730, 3);
|
||||
});
|
||||
@@ -275,11 +246,11 @@ describe('loadMarkImage', () => {
|
||||
|
||||
it('gleiches (id, tone) liefert dieselbe Promise und lädt nur einmal, anderer Ton eine andere', async () => {
|
||||
const m = await fresh();
|
||||
const visa = m.getNetworkMark('visa-auto');
|
||||
const a = m.loadMarkImage(visa, 'dark');
|
||||
const b = m.loadMarkImage(visa, 'dark');
|
||||
const c = m.loadMarkImage(visa, 'light');
|
||||
const d = m.loadMarkImage(m.getNetworkMark('jcb'), 'dark');
|
||||
const symbol = m.getBrandMark('aircards-symbol');
|
||||
const a = m.loadMarkImage(symbol, 'dark');
|
||||
const b = m.loadMarkImage(symbol, 'dark');
|
||||
const c = m.loadMarkImage(symbol, 'light');
|
||||
const d = m.loadMarkImage(m.getBrandMark('aircards-text'), 'dark');
|
||||
expect(b).toBe(a);
|
||||
expect(c).not.toBe(a);
|
||||
expect(d).not.toBe(a);
|
||||
@@ -287,15 +258,15 @@ describe('loadMarkImage', () => {
|
||||
expect(imgs[0]).toBeInstanceOf(FakeImage);
|
||||
expect(blobs).toHaveLength(3);
|
||||
// nach dem Laden bleibt der Eintrag im Cache
|
||||
expect(m.loadMarkImage(visa, 'dark')).toBe(a);
|
||||
expect(m.loadMarkImage(symbol, 'dark')).toBe(a);
|
||||
expect(blobs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('Blob ist ein eingefärbtes SVG mit eigener Größe und dem richtigen MIME-Typ', async () => {
|
||||
const m = await fresh();
|
||||
const visa = m.getNetworkMark('visa-auto');
|
||||
await m.loadMarkImage(visa, 'dark');
|
||||
await m.loadMarkImage(visa, 'light');
|
||||
const symbol = m.getBrandMark('aircards-symbol');
|
||||
await m.loadMarkImage(symbol, 'dark');
|
||||
await m.loadMarkImage(symbol, 'light');
|
||||
const [dark, light] = await Promise.all(blobs.map((b) => b.text()));
|
||||
expect(blobs[0].type).toBe('image/svg+xml');
|
||||
expect(dark).toContain('#111111');
|
||||
@@ -305,33 +276,25 @@ describe('loadMarkImage', () => {
|
||||
const root = /^<svg\b[^>]*>/.exec(dark)![0];
|
||||
const w = Number(/\swidth="([\d.]+)"/.exec(root)![1]);
|
||||
const h = Number(/\sheight="([\d.]+)"/.exec(root)![1]);
|
||||
expect(h).toBe(visa.maxH * 4);
|
||||
expect(w / h).toBeCloseTo(m.svgAspect(visa.svg), 1);
|
||||
expect(h).toBe(symbol.maxH * 4);
|
||||
expect(w / h).toBeCloseTo(m.svgAspect(symbol.svg), 1);
|
||||
// Bild bekommt die Blob-URL
|
||||
expect(env.images[0].src).toBe('blob:fake/1');
|
||||
expect(env.images[1].src).toBe('blob:fake/2');
|
||||
});
|
||||
|
||||
it('Marken ohne currentColor bleiben im Blob unverändert (bis auf die Größe)', async () => {
|
||||
const m = await fresh();
|
||||
const jcb = m.getNetworkMark('jcb');
|
||||
await m.loadMarkImage(jcb, 'dark');
|
||||
const text = await blobs[0].text();
|
||||
expect(text.replace(/^<svg width="[\d.]+" height="[\d.]+"/, '<svg')).toBe(jcb.svg);
|
||||
});
|
||||
|
||||
it('gibt die Blob-URL nach dem Laden frei', async () => {
|
||||
const m = await fresh();
|
||||
await m.loadMarkImage(m.getNetworkMark('visa-blue'), 'light');
|
||||
await m.loadMarkImage(m.getBrandMark('aircards-text'), 'light');
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
});
|
||||
|
||||
it('Fehler: Promise wird abgelehnt, URL freigegeben, Cache-Eintrag verworfen, zweiter Aufruf versucht es neu', async () => {
|
||||
const m = await fresh();
|
||||
const def = m.getNetworkMark('visa-auto');
|
||||
const def = m.getBrandMark('aircards-symbol');
|
||||
env.mode = 'error';
|
||||
const first = m.loadMarkImage(def, 'dark');
|
||||
await expect(first).rejects.toThrow(/visa-auto/);
|
||||
await expect(first).rejects.toThrow(/aircards-symbol/);
|
||||
expect(revoked).toEqual(['blob:fake/1']);
|
||||
|
||||
env.mode = 'load';
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
// Logo-Register: SVG-Dateien der Netzwerke und Marken, Einfärben und Laden als Bild für den Canvas.
|
||||
// Logo-Register: SVG-Dateien der AirCard-IOS-Marken (Logo oben links), Einfärben und Laden als Bild für den Canvas.
|
||||
// Reine Daten plus ein kleiner Browser-Lader; die Größe der Logos bestimmt allein die viewBox.
|
||||
// Die Logos unten rechts kommen von der Community (logos/logo-asset.ts), hier steht keine fremde Marke.
|
||||
|
||||
import { TONE_COLOR, type BrandId, type DetailsTone, type NetworkId, NETWORK_IDS } from './config';
|
||||
import { msg } from '../i18n/i18n';
|
||||
import { TONE_COLOR, type BrandId, type DetailsTone } from './config';
|
||||
|
||||
import visaAuto from '../assets/marks/visa-auto.svg?raw';
|
||||
import visaBlue from '../assets/marks/visa-blue.svg?raw';
|
||||
import visaNavy from '../assets/marks/visa-navy.svg?raw';
|
||||
import visaSilver from '../assets/marks/visa-silver.svg?raw';
|
||||
import mcColor from '../assets/marks/mc-color.svg?raw';
|
||||
import mcSilver from '../assets/marks/mc-silver.svg?raw';
|
||||
import mcWordmark from '../assets/marks/mc-wordmark.svg?raw';
|
||||
import mcSilverText from '../assets/marks/mc-silver-text.svg?raw';
|
||||
import mcClassic from '../assets/marks/mc-classic.svg?raw';
|
||||
import upColor from '../assets/marks/up-color.svg?raw';
|
||||
import upSilver from '../assets/marks/up-silver.svg?raw';
|
||||
import upWhiteFrame from '../assets/marks/up-white-frame.svg?raw';
|
||||
import upEnglish from '../assets/marks/up-english.svg?raw';
|
||||
import amexBlue from '../assets/marks/amex-blue.svg?raw';
|
||||
import amexOutline from '../assets/marks/amex-outline.svg?raw';
|
||||
import amexSelect from '../assets/marks/amex-select.svg?raw';
|
||||
import jcb from '../assets/marks/jcb.svg?raw';
|
||||
import discover from '../assets/marks/discover.svg?raw';
|
||||
import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw';
|
||||
import aircardsText from '../assets/marks/aircards-text.svg?raw';
|
||||
import apple from '../assets/marks/apple.svg?raw';
|
||||
|
||||
export type MarkGroup = 'Visa' | 'Mastercard' | 'UnionPay' | 'American Express' | 'JCB' | 'Discover' | 'Logo';
|
||||
export type MarkGroup = 'Logo';
|
||||
|
||||
export interface MarkDef {
|
||||
id: NetworkId | Exclude<BrandId, 'none'>;
|
||||
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Visa – Blau“). */
|
||||
id: Exclude<BrandId, 'none'>;
|
||||
/**
|
||||
* Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Logo – AirCard-IOS · Symbol“).
|
||||
* Beide sind deutsch markiert (`msg`) und werden erst beim Anzeigen mit `t()` übersetzt.
|
||||
*/
|
||||
group: MarkGroup;
|
||||
label: string;
|
||||
svg: string;
|
||||
@@ -39,56 +25,32 @@ export interface MarkDef {
|
||||
maxH: number;
|
||||
}
|
||||
|
||||
type NetworkEntry = Omit<MarkDef, 'id'>;
|
||||
type MarkEntry = Omit<MarkDef, 'id'>;
|
||||
|
||||
const NETWORK_TABLE: Record<NetworkId, NetworkEntry> = {
|
||||
'visa-auto': { group: 'Visa', label: 'Automatisch', svg: visaAuto, tint: true, maxH: 109 },
|
||||
'visa-blue': { group: 'Visa', label: 'Blau', svg: visaBlue, tint: false, maxH: 109 },
|
||||
'visa-navy': { group: 'Visa', label: 'Marineblau', svg: visaNavy, tint: false, maxH: 109 },
|
||||
'visa-silver': { group: 'Visa', label: 'Metallisches Silber', svg: visaSilver, tint: false, maxH: 109 },
|
||||
'mc-color': { group: 'Mastercard', label: 'Farbe', svg: mcColor, tint: false, maxH: 125 },
|
||||
'mc-silver': { group: 'Mastercard', label: 'Metallisches Silber', svg: mcSilver, tint: false, maxH: 125 },
|
||||
'mc-wordmark': { group: 'Mastercard', label: 'Wortmarke', svg: mcWordmark, tint: false, maxH: 109 },
|
||||
'mc-silver-text': { group: 'Mastercard', label: 'Silber + Text', svg: mcSilverText, tint: false, maxH: 109 },
|
||||
'mc-classic': { group: 'Mastercard', label: 'Klassisch', svg: mcClassic, tint: false, maxH: 125 },
|
||||
'up-color': { group: 'UnionPay', label: 'Farbe', svg: upColor, tint: false, maxH: 125 },
|
||||
'up-silver': { group: 'UnionPay', label: 'Metallisches Silber', svg: upSilver, tint: false, maxH: 125 },
|
||||
'up-white-frame': { group: 'UnionPay', label: 'Weißer Rahmen', svg: upWhiteFrame, tint: false, maxH: 125 },
|
||||
'up-english': { group: 'UnionPay', label: 'Englisch', svg: upEnglish, tint: false, maxH: 109 },
|
||||
'amex-blue': { group: 'American Express', label: 'Blau', svg: amexBlue, tint: false, maxH: 125 },
|
||||
'amex-outline': { group: 'American Express', label: 'Umriss', svg: amexOutline, tint: true, maxH: 125 },
|
||||
'amex-select': { group: 'American Express', label: 'Select', svg: amexSelect, tint: false, maxH: 90 },
|
||||
jcb: { group: 'JCB', label: 'JCB', svg: jcb, tint: false, maxH: 125 },
|
||||
discover: { group: 'Discover', label: 'Discover', svg: discover, tint: false, maxH: 109 },
|
||||
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, MarkEntry> = {
|
||||
'aircards-symbol': { group: msg('Logo'), label: msg('AirCard-IOS · Symbol'), svg: aircardsSymbol, tint: true, maxH: 100 },
|
||||
'aircards-text': { group: msg('Logo'), label: msg('AirCard-IOS · Text'), svg: aircardsText, tint: true, maxH: 100 },
|
||||
};
|
||||
|
||||
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, NetworkEntry> = {
|
||||
'aircards-symbol': { group: 'Logo', label: 'AirCard-IOS · Symbol', svg: aircardsSymbol, tint: true, maxH: 100 },
|
||||
'aircards-text': { group: 'Logo', label: 'AirCard-IOS · Text', svg: aircardsText, tint: true, maxH: 100 },
|
||||
apple: { group: 'Logo', label: 'Apple', svg: apple, tint: true, maxH: 100 },
|
||||
};
|
||||
|
||||
export const NETWORK_MARKS: readonly MarkDef[] = NETWORK_IDS.map((id) => ({ id, ...NETWORK_TABLE[id] }));
|
||||
|
||||
export const BRAND_MARKS: readonly MarkDef[] = (
|
||||
Object.keys(BRAND_TABLE) as Exclude<BrandId, 'none'>[]
|
||||
).map((id) => ({ id, ...BRAND_TABLE[id] }));
|
||||
|
||||
export function getNetworkMark(id: NetworkId): MarkDef {
|
||||
return NETWORK_MARKS.find((m) => m.id === id) ?? NETWORK_MARKS[0];
|
||||
}
|
||||
|
||||
export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
|
||||
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
|
||||
}
|
||||
|
||||
/** Breite durch Höhe aus dem Wert einer viewBox („x y Breite Höhe“, mit Leerraum oder Kommas); null, wenn er nicht brauchbar ist. */
|
||||
export function viewBoxAspect(value: string): number | null {
|
||||
const parts = value.trim().split(/[\s,]+/).map(Number);
|
||||
const [w, h] = [parts[2], parts[3]];
|
||||
return parts.length === 4 && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : null;
|
||||
}
|
||||
|
||||
/** Breite durch Höhe aus der viewBox; 1, wenn keine brauchbare viewBox da ist. */
|
||||
export function svgAspect(svg: string): number {
|
||||
const hit = /viewBox\s*=\s*"([^"]+)"/.exec(svg);
|
||||
if (!hit) return 1;
|
||||
const parts = hit[1].trim().split(/[\s,]+/).map(Number);
|
||||
const [w, h] = [parts[2], parts[3]];
|
||||
return parts.length === 4 && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : 1;
|
||||
return (hit && viewBoxAspect(hit[1])) ?? 1;
|
||||
}
|
||||
|
||||
/** Ersetzt jedes `currentColor` durch die Details-Farbe. */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { loadLogoAsset } from '../logos/logo-asset';
|
||||
import { DEFAULT_CONFIG, type CardConfig } from './config';
|
||||
import { fitLogo } from './geometry';
|
||||
import { loadMarkImage } from './marks';
|
||||
import {
|
||||
drawCard,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
type CardAssets,
|
||||
type CardImage,
|
||||
type Ctx,
|
||||
type LogoAsset,
|
||||
} from './render';
|
||||
|
||||
// marks.ts importiert SVG-Dateien per ?raw; für den Renderer reichen feste viewBoxen.
|
||||
@@ -22,7 +25,6 @@ vi.mock('./marks', () => {
|
||||
maxH,
|
||||
});
|
||||
return {
|
||||
getNetworkMark: (id: string) => mark(id, '0 0 330 109', 109),
|
||||
getBrandMark: (id: string) => mark(id, '0 0 100 100', 100),
|
||||
svgAspect: (svg: string) => {
|
||||
const p = /viewBox="([^"]+)"/.exec(svg)![1].split(' ').map(Number);
|
||||
@@ -32,6 +34,9 @@ vi.mock('./marks', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Der Standard-Lader der Logos (Appwrite, Dekodierung) kommt in diesen Tests nie zum Einsatz.
|
||||
vi.mock('../logos/logo-asset', () => ({ loadLogoAsset: vi.fn() }));
|
||||
|
||||
interface Call {
|
||||
name: string;
|
||||
args: unknown[];
|
||||
@@ -99,13 +104,15 @@ function fakeCtx() {
|
||||
return { ctx: ctx as unknown as Ctx, calls, strokePath };
|
||||
}
|
||||
|
||||
const NETWORK = { tag: 'network' } as unknown as CanvasImageSource;
|
||||
const LOGO_SOURCE = { tag: 'logo' } as unknown as CanvasImageSource;
|
||||
const BRAND = { tag: 'brand' } as unknown as CanvasImageSource;
|
||||
const PHOTO = { tag: 'photo' } as unknown as CanvasImageSource;
|
||||
/** Ein Community-Logo mit Seitenverhältnis 2 : 1 (die Größe in Pixeln spielt für den Platz auf der Karte keine Rolle). */
|
||||
const LOGO: LogoAsset = { source: LOGO_SOURCE, w: 200, h: 100 };
|
||||
|
||||
const assets = (over: Partial<CardAssets> = {}): CardAssets => ({
|
||||
image: null,
|
||||
network: NETWORK,
|
||||
logo: LOGO,
|
||||
brand: BRAND,
|
||||
...over,
|
||||
});
|
||||
@@ -187,18 +194,42 @@ describe('renderCard: Grund und Bild', () => {
|
||||
});
|
||||
|
||||
describe('renderCard: Vorlagen', () => {
|
||||
it('minimal: nur das Netzwerk-Logo unten rechts', () => {
|
||||
it('minimal: nur das Community-Logo unten rechts (Seitenverhältnis 2 : 1 → 250 × 125, rechte Kante 1480, Unterkante 913)', () => {
|
||||
const calls = render(cfg({ template: 'minimal' }), assets());
|
||||
expect(named(calls, 'drawImage')).toHaveLength(1);
|
||||
const [src, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
|
||||
expect(src).toBe(NETWORK);
|
||||
expect(x).toBeCloseTo(1150, 1);
|
||||
expect(y).toBeCloseTo(804, 1);
|
||||
expect(w).toBeCloseTo(330, 1);
|
||||
expect(h).toBeCloseTo(109, 1);
|
||||
const [src, x, y, w, h] = drawsOf(calls, LOGO_SOURCE)[0].args as number[];
|
||||
expect(src).toBe(LOGO_SOURCE);
|
||||
expect([x, y, w, h]).toEqual([1230, 788, 250, 125]);
|
||||
expect(named(calls, 'fillText')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('das Logo sitzt dort, wo fitLogo es hinlegt (Seitenverhältnis aus w und h des Logos)', () => {
|
||||
const wide: LogoAsset = { source: LOGO_SOURCE, w: 720, h: 72 };
|
||||
const [src, x, y, w, h] = drawsOf(render(cfg({ template: 'classic' }), assets({ logo: wide })), LOGO_SOURCE)[0].args as number[];
|
||||
const box = fitLogo(10);
|
||||
expect(src).toBe(LOGO_SOURCE);
|
||||
expect([x, y, w, h]).toEqual([box.x, box.y, box.w, box.h]);
|
||||
expect(w).toBe(360);
|
||||
});
|
||||
|
||||
it('Vorlage image: auch mit Logo wird es nicht gezeichnet', () => {
|
||||
const calls = render(cfg({ template: 'image' }), assets({ image: photo(1536, 969) }));
|
||||
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(0);
|
||||
expect(named(calls, 'drawImage')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ohne Logo (assets.logo null) wird nichts unten rechts gezeichnet, auch wenn die Konfiguration eines wählt', () => {
|
||||
for (const template of ['minimal', 'classic', 'signature'] as const) {
|
||||
const calls = render(cfg({ template, logo: 'abc123' }), assets({ logo: null }));
|
||||
expect(drawsOf(calls, LOGO_SOURCE), template).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('das Logo hängt nur an den Bildern, nicht an config.logo (die Bilder gehören zur Konfiguration, mit der sie geladen wurden)', () => {
|
||||
const calls = render(cfg({ template: 'minimal', logo: null }), assets());
|
||||
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('classic dunkel: Text in #111111, PLATINUM endet bei x 1480, Ziffern und Punkte stimmen', () => {
|
||||
const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
|
||||
|
||||
@@ -238,11 +269,10 @@ describe('renderCard: Vorlagen', () => {
|
||||
expect(named(calls, 'arc').every((c) => c.fillStyle === '#FFFFFF')).toBe(true);
|
||||
});
|
||||
|
||||
it('classic: Brand-Logo links oben, Netzwerk-Logo unten rechts', () => {
|
||||
it('classic: Brand-Logo links oben, Community-Logo unten rechts', () => {
|
||||
const calls = render(cfg({ template: 'classic' }), assets());
|
||||
expect(drawsOf(calls, BRAND)[0].args).toEqual([BRAND, 56, 56, 100, 100]);
|
||||
const [, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
|
||||
expect([x, y, w, h].map((v) => Math.round(v))).toEqual([1150, 804, 330, 109]);
|
||||
expect(drawsOf(calls, LOGO_SOURCE)[0].args).toEqual([LOGO_SOURCE, 1230, 788, 250, 125]);
|
||||
});
|
||||
|
||||
it('classic: Kontaktlos-Bögen in Details-Farbe mit runden Enden', () => {
|
||||
@@ -256,7 +286,7 @@ describe('renderCard: Vorlagen', () => {
|
||||
it('signature, Brand none: kein Brand-drawImage, PLATINUM beginnt bei x 60', () => {
|
||||
const calls = render(cfg({ template: 'signature', brand: 'none' }), assets());
|
||||
expect(drawsOf(calls, BRAND)).toHaveLength(0);
|
||||
expect(drawsOf(calls, NETWORK)).toHaveLength(1);
|
||||
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
|
||||
const plat = letters(calls);
|
||||
expect(plat[0].args).toEqual(['P', 60, 226]);
|
||||
expect(plat[0].font).toBe('700 44px "Nunito Sans Variable"');
|
||||
@@ -273,7 +303,7 @@ describe('renderCard: Vorlagen', () => {
|
||||
});
|
||||
|
||||
it('fehlende Logo-Bilder werden übersprungen', () => {
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ network: null, brand: null }));
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ logo: null, brand: null }));
|
||||
expect(named(calls, 'drawImage')).toHaveLength(0);
|
||||
expect(letters(calls)).toHaveLength(8);
|
||||
});
|
||||
@@ -319,7 +349,7 @@ describe('renderCard: Chip', () => {
|
||||
});
|
||||
|
||||
describe('renderCard: Reihenfolge', () => {
|
||||
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Netzwerk', () => {
|
||||
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Logo', () => {
|
||||
const calls = render(cfg({ template: 'classic' }), assets({ image: photo(1536, 969) }));
|
||||
const at = (pred: (c: Call) => boolean) => calls.findIndex(pred);
|
||||
const order = [
|
||||
@@ -330,7 +360,7 @@ describe('renderCard: Reihenfolge', () => {
|
||||
at((c) => c.name === 'arc'),
|
||||
at((c) => c.name === 'fillText' && c.args[0] === 'P'),
|
||||
at((c) => c.name === 'drawImage' && c.args[0] === BRAND),
|
||||
at((c) => c.name === 'drawImage' && c.args[0] === NETWORK),
|
||||
at((c) => c.name === 'drawImage' && c.args[0] === LOGO_SOURCE),
|
||||
];
|
||||
expect(order.every((i) => i >= 0)).toBe(true);
|
||||
expect([...order].sort((a, b) => a - b)).toEqual(order);
|
||||
@@ -386,14 +416,17 @@ describe('drawCard', () => {
|
||||
});
|
||||
|
||||
describe('loadCardAssets', () => {
|
||||
const NET_IMG = { tag: 'net-img' };
|
||||
const BRAND_IMG = { tag: 'brand-img' };
|
||||
const fontsLoad = vi.fn();
|
||||
const mocked = vi.mocked(loadMarkImage);
|
||||
const defaultLoader = vi.mocked(loadLogoAsset);
|
||||
const loadLogo = vi.fn<(id: string, tone: 'light' | 'dark') => Promise<LogoAsset | null>>();
|
||||
|
||||
beforeEach(() => {
|
||||
fontsLoad.mockReset().mockResolvedValue([]);
|
||||
mocked.mockReset().mockImplementation(async (def) => (def.id === 'apple' || def.id.startsWith('aircards') ? BRAND_IMG : NET_IMG) as unknown as HTMLImageElement);
|
||||
mocked.mockReset().mockImplementation(async () => BRAND_IMG as unknown as HTMLImageElement);
|
||||
defaultLoader.mockReset().mockResolvedValue(LOGO);
|
||||
loadLogo.mockReset().mockResolvedValue(LOGO);
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { load: fontsLoad } });
|
||||
});
|
||||
|
||||
@@ -401,35 +434,68 @@ describe('loadCardAssets', () => {
|
||||
Reflect.deleteProperty(document, 'fonts');
|
||||
});
|
||||
|
||||
it('lädt Netzwerk- und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
|
||||
it('lädt Community-Logo und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
|
||||
const image = photo(100, 100);
|
||||
const result = await loadCardAssets(cfg({ network: 'jcb', brand: 'apple', details: 'dark', template: 'image' }), image);
|
||||
const result = await loadCardAssets(cfg({ logo: 'abc123', brand: 'aircards-text', details: 'dark', template: 'image' }), image, loadLogo);
|
||||
expect(result).toEqual({ image, logo: LOGO, brand: BRAND_IMG, logoMissing: false });
|
||||
expect(result.image).toBe(image);
|
||||
expect(result.network).toBe(NET_IMG);
|
||||
expect(result.brand).toBe(BRAND_IMG);
|
||||
expect(mocked).toHaveBeenCalledTimes(2);
|
||||
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['jcb', 'dark'], ['apple', 'dark']]);
|
||||
expect(loadLogo).toHaveBeenCalledTimes(1);
|
||||
expect(loadLogo).toHaveBeenCalledWith('abc123', 'dark');
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['aircards-text', 'dark']]);
|
||||
expect(fontsLoad).toHaveBeenCalledWith('700 46px "Nunito Sans Variable"');
|
||||
});
|
||||
|
||||
it('ohne Logo (logo null): kein Lader-Aufruf, kein fehlendes Logo', async () => {
|
||||
const result = await loadCardAssets(cfg({ logo: null }), null, loadLogo);
|
||||
expect(loadLogo).not.toHaveBeenCalled();
|
||||
expect(result.logo).toBeNull();
|
||||
expect(result.logoMissing).toBe(false);
|
||||
});
|
||||
|
||||
it('eine leere logo-Angabe zählt wie kein Logo', async () => {
|
||||
const result = await loadCardAssets(cfg({ logo: '' }), null, loadLogo);
|
||||
expect(loadLogo).not.toHaveBeenCalled();
|
||||
expect(result.logoMissing).toBe(false);
|
||||
});
|
||||
|
||||
it('der Lader liefert null (gelöscht, ausgeblendet): logo null und logoMissing true, Brand-Logo wird trotzdem geladen', async () => {
|
||||
loadLogo.mockResolvedValue(null);
|
||||
const result = await loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo);
|
||||
expect(result.logo).toBeNull();
|
||||
expect(result.logoMissing).toBe(true);
|
||||
expect(result.brand).toBe(BRAND_IMG);
|
||||
});
|
||||
|
||||
it('der Lader wirft: loadCardAssets wirft (ein echter Ladefehler ist kein fehlendes Logo)', async () => {
|
||||
loadLogo.mockRejectedValue(new Error('offline'));
|
||||
await expect(loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo)).rejects.toThrow('offline');
|
||||
});
|
||||
|
||||
it('ohne dritten Parameter lädt loadLogoAsset', async () => {
|
||||
const result = await loadCardAssets(cfg({ logo: 'abc123', details: 'light' }), null);
|
||||
expect(defaultLoader).toHaveBeenCalledWith('abc123', 'light');
|
||||
expect(result.logo).toBe(LOGO);
|
||||
});
|
||||
|
||||
it('Brand none: kein Brand-Logo laden', async () => {
|
||||
const result = await loadCardAssets(cfg({ brand: 'none' }), null);
|
||||
const result = await loadCardAssets(cfg({ brand: 'none' }), null, loadLogo);
|
||||
expect(result.image).toBeNull();
|
||||
expect(result.brand).toBeNull();
|
||||
expect(mocked).toHaveBeenCalledTimes(1);
|
||||
expect(mocked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Schrift lädt nicht oder document.fonts fehlt: Karte wird trotzdem fertig', async () => {
|
||||
fontsLoad.mockRejectedValue(new Error('offline'));
|
||||
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
|
||||
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
|
||||
Reflect.deleteProperty(document, 'fonts');
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: undefined });
|
||||
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
|
||||
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
|
||||
});
|
||||
|
||||
it('Logo-Fehler wird weitergereicht', async () => {
|
||||
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: jcb'));
|
||||
await expect(loadCardAssets(cfg({ network: 'jcb' }), null)).rejects.toThrow(/Logo/);
|
||||
it('Marken-Fehler wird weitergereicht', async () => {
|
||||
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: aircards-symbol'));
|
||||
await expect(loadCardAssets(cfg(), null, loadLogo)).rejects.toThrow(/Logo/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
// Kartenrenderer: zeichnet eine Karte in 1536 x 969-Koordinaten auf einen Canvas-Kontext.
|
||||
// Vorschau, Vorlagen-Miniaturen, PNG-Download und Vorschaubild beim Speichern benutzen alle drawCard.
|
||||
import { msg } from '../i18n/i18n';
|
||||
import { loadLogoAsset } from '../logos/logo-asset';
|
||||
import { TONE_COLOR, type CardConfig, type DetailsTone } from './config';
|
||||
import { clampCrop, imageRect } from './crop';
|
||||
import { CARD_H, CARD_W, fitBrand, fitNetwork, layoutFor, type Layout } from './geometry';
|
||||
import { getBrandMark, getNetworkMark, loadMarkImage, svgAspect } from './marks';
|
||||
import { CARD_H, CARD_W, fitBrand, fitLogo, layoutFor, type Layout } from './geometry';
|
||||
import { getBrandMark, loadMarkImage, svgAspect } from './marks';
|
||||
|
||||
export type CardImage = { source: CanvasImageSource; w: number; h: number };
|
||||
|
||||
/** Ein Community-Logo, fertig zum Zeichnen (einfarbige schon in der Details-Farbe); w/h = Pixel der Quelle, daraus folgt das Seitenverhältnis. */
|
||||
export interface LogoAsset {
|
||||
source: CanvasImageSource;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface CardAssets {
|
||||
image: CardImage | null;
|
||||
network: CanvasImageSource | null;
|
||||
/** Das Community-Logo unten rechts; null = keins oder nicht verfügbar. */
|
||||
logo: LogoAsset | null;
|
||||
/** Das AirCard-IOS-Logo oben links. */
|
||||
brand: CanvasImageSource | null;
|
||||
}
|
||||
|
||||
/** Was `loadCardAssets` liefert: dazu die Auskunft, ob die Karte ein Logo wählt, das es nicht (mehr) gibt. */
|
||||
export interface LoadedAssets extends CardAssets {
|
||||
logoMissing: boolean;
|
||||
}
|
||||
|
||||
/** Der kleine Ausschnitt von CanvasRenderingContext2D, den der Renderer braucht (leicht zu faken). */
|
||||
export type Ctx = Pick<
|
||||
CanvasRenderingContext2D,
|
||||
@@ -112,7 +128,7 @@ function drawPlatinum(ctx: Ctx, p: NonNullable<Layout['platinum']>, color: strin
|
||||
});
|
||||
}
|
||||
|
||||
/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Logo, Netzwerk. */
|
||||
/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Marke oben links, Logo unten rechts. */
|
||||
export function renderCard(
|
||||
ctx: Ctx,
|
||||
config: CardConfig,
|
||||
@@ -150,10 +166,10 @@ export function renderCard(
|
||||
ctx.drawImage(assets.brand, b.x, b.y, b.w, b.h);
|
||||
}
|
||||
|
||||
if (layout.network && assets.network) {
|
||||
const mark = getNetworkMark(config.network);
|
||||
const n = fitNetwork(svgAspect(mark.svg), mark.maxH);
|
||||
ctx.drawImage(assets.network, n.x, n.y, n.w, n.h);
|
||||
// Gezeichnet wird, was die Bilder mitbringen (sie gehören zu der Konfiguration, mit der sie geladen wurden).
|
||||
if (layout.logo && assets.logo) {
|
||||
const l = fitLogo(assets.logo.w / assets.logo.h);
|
||||
ctx.drawImage(assets.logo.source, l.x, l.y, l.w, l.h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +181,8 @@ export function drawCard(
|
||||
): void {
|
||||
// Die Überladungen beider Canvas-Typen lassen sich als Union nicht aufrufen; zur Laufzeit gleich.
|
||||
const ctx = (canvas as HTMLCanvasElement).getContext('2d', { alpha: false });
|
||||
if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.');
|
||||
// Die Meldung geht deutsch markiert in den Fehler; der Editor zeigt sie mit `t(error.message)`.
|
||||
if (!ctx) throw new Error(msg('Canvas wird in diesem Browser nicht unterstützt.'));
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.scale(canvas.width / CARD_W, canvas.height / CARD_H);
|
||||
// Fotos werden stark verkleinert; die Standardqualität (low) zeigt dabei Treppenstufen.
|
||||
@@ -173,17 +190,25 @@ export function drawCard(
|
||||
renderCard(ctx, config, assets);
|
||||
}
|
||||
|
||||
/** Lädt Schrift und Logos (in Details-Farbe). Das Bild reicht der Aufrufer durch. */
|
||||
export async function loadCardAssets(config: CardConfig, image: CardImage | null): Promise<CardAssets> {
|
||||
/**
|
||||
* Lädt Schrift und Logos (in Details-Farbe). Das Bild reicht der Aufrufer durch.
|
||||
* Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet), ist `logo` null und `logoMissing` true; die Karte
|
||||
* lässt sich dann ohne Logo zeichnen. Ein echter Ladefehler (Netz, Server) wirft dagegen.
|
||||
*/
|
||||
export async function loadCardAssets(
|
||||
config: CardConfig,
|
||||
image: CardImage | null,
|
||||
loadLogo: (id: string, tone: DetailsTone) => Promise<LogoAsset | null> = loadLogoAsset,
|
||||
): Promise<LoadedAssets> {
|
||||
// Ohne geladene Schrift misst und zeichnet der Canvas mit der Ersatzschrift.
|
||||
const fontReady = Promise.resolve(typeof document !== 'undefined' ? document.fonts?.load(FONT_LOAD) : undefined)
|
||||
.catch(() => undefined);
|
||||
const [network, brand] = await Promise.all([
|
||||
loadMarkImage(getNetworkMark(config.network), config.details),
|
||||
const [logo, brand] = await Promise.all([
|
||||
config.logo ? loadLogo(config.logo, config.details) : null,
|
||||
config.brand === 'none' ? null : loadMarkImage(getBrandMark(config.brand), config.details),
|
||||
fontReady,
|
||||
]);
|
||||
return { image, network, brand };
|
||||
return { image, logo, brand, logoMissing: Boolean(config.logo) && logo === null };
|
||||
}
|
||||
|
||||
/** Rendert die Karte offscreen in der gewünschten Breite (Höhe nach 1536 : 969) und liefert die Bilddatei. */
|
||||
@@ -198,7 +223,7 @@ export async function exportCard(
|
||||
drawCard(canvas, config, assets);
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error('Das Bild konnte nicht erzeugt werden.'))),
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error(msg('Das Bild konnte nicht erzeugt werden.')))),
|
||||
opts.type,
|
||||
opts.quality,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createJWT: vi.fn(),
|
||||
userId: null as string | null,
|
||||
authListeners: [] as Array<(user: unknown) => void>,
|
||||
logoutListeners: [] as Array<() => void>,
|
||||
}));
|
||||
|
||||
vi.mock('../account/appwrite', () => ({ account: { createJWT: mocks.createJWT } }));
|
||||
vi.mock('../account/auth', () => ({
|
||||
currentUserId: () => mocks.userId,
|
||||
onAuthChange: (fn: (user: unknown) => void) => {
|
||||
mocks.authListeners.push(fn);
|
||||
return () => undefined;
|
||||
},
|
||||
onLogout: (fn: () => void) => {
|
||||
mocks.logoutListeners.push(fn);
|
||||
return () => undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
type Api = typeof import('./api');
|
||||
|
||||
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
|
||||
const MINUTE = 60_000;
|
||||
|
||||
// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz. Das gilt auch für i18n: die frische Instanz
|
||||
// beginnt mit der Browsersprache (im Test Englisch) und wird wie im Test-Setup auf Deutsch gestellt.
|
||||
async function freshApi(): Promise<Api> {
|
||||
vi.resetModules();
|
||||
const api = await import('./api');
|
||||
(await import('../i18n/i18n')).setLang('de', { persist: false });
|
||||
return api;
|
||||
}
|
||||
|
||||
/** Stellt die Sprache der frischen Instanz um (dieselbe, die `freshApi` geladen hat). */
|
||||
async function setLanguage(lang: 'de' | 'en'): Promise<void> {
|
||||
(await import('../i18n/i18n')).setLang(lang, { persist: false });
|
||||
}
|
||||
|
||||
const json = (status: number, body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise<Response>>();
|
||||
|
||||
function sentHeaders(call = 0): Record<string, string> {
|
||||
return fetchMock.mock.calls[call][1].headers as Record<string, string>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-10-04T10:00:00Z'));
|
||||
mocks.createJWT.mockReset();
|
||||
mocks.userId = 'u1';
|
||||
mocks.authListeners.length = 0;
|
||||
mocks.logoutListeners.length = 0;
|
||||
let n = 0;
|
||||
mocks.createJWT.mockImplementation(async () => ({ jwt: `jwt${++n}` }));
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockImplementation(async () => json(200, { ok: true }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('apiPost', () => {
|
||||
it('sendet JSON per POST an den Pfad und liefert die JSON-Antwort', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(200, { schonGemeldet: true }));
|
||||
await expect(api.apiPost('/api/melden', { id: 'c1', reason: 'spam' }, { auth: false })).resolves.toEqual({
|
||||
schonGemeldet: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/melden');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(sentHeaders()['Content-Type']).toBe('application/json');
|
||||
expect(init.body).toBe(JSON.stringify({ id: 'c1', reason: 'spam' }));
|
||||
});
|
||||
|
||||
it('ohne auth: kein JWT, kein createJWT', async () => {
|
||||
const api = await freshApi();
|
||||
await api.apiPost('/api/melden', {}, { auth: false });
|
||||
expect(mocks.createJWT).not.toHaveBeenCalled();
|
||||
expect(sentHeaders()['X-Aircard-Jwt']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('mit auth: sendet den Header X-Aircard-Jwt mit dem JWT von createJWT (ohne duration)', async () => {
|
||||
const api = await freshApi();
|
||||
await api.apiPost('/api/zurueckziehen', { id: 'c1' }, { auth: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledOnce();
|
||||
expect(mocks.createJWT.mock.calls[0]).toEqual([]);
|
||||
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
|
||||
});
|
||||
|
||||
describe('JWT-Cache', () => {
|
||||
it('der zweite Aufruf innerhalb von 10 Minuten holt kein neues JWT', async () => {
|
||||
const api = await freshApi();
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
vi.setSystemTime(Date.now() + 9 * MINUTE + 59_000);
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledOnce();
|
||||
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1');
|
||||
});
|
||||
|
||||
it('nach 10 Minuten wird ein neues JWT geholt', async () => {
|
||||
const api = await freshApi();
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
vi.setSystemTime(Date.now() + 10 * MINUTE);
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
|
||||
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
|
||||
});
|
||||
|
||||
it('gilt je Nutzer-ID: ein anderer Nutzer bekommt nie das JWT des vorigen', async () => {
|
||||
const api = await freshApi();
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
mocks.userId = 'u2';
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
|
||||
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
|
||||
});
|
||||
|
||||
it('wird bei einer Änderung des Nutzers (onAuthChange) geleert', async () => {
|
||||
const api = await freshApi();
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.authListeners).toHaveLength(1);
|
||||
mocks.authListeners[0](null);
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('wird beim Abmelden (onLogout) geleert', async () => {
|
||||
const api = await freshApi();
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.logoutListeners).toHaveLength(1);
|
||||
mocks.logoutListeners[0]();
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ein JWT, das erst nach einem Nutzerwechsel eintrifft, wird nicht gemerkt', async () => {
|
||||
const api = await freshApi();
|
||||
let release: (value: { jwt: string }) => void = () => undefined;
|
||||
mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
|
||||
const first = api.apiPost('/api/x', {}, { auth: true });
|
||||
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
|
||||
mocks.authListeners[0](null); // Abmelden, während createJWT noch läuft
|
||||
release({ jwt: 'late' });
|
||||
await first;
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
|
||||
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1');
|
||||
});
|
||||
|
||||
it('gleichzeitige Aufrufe mit leerem Cache teilen sich ein einziges createJWT', async () => {
|
||||
const api = await freshApi();
|
||||
let release: (value: { jwt: string }) => void = () => undefined;
|
||||
mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
|
||||
const first = api.apiPost('/api/x', { n: 1 }, { auth: true });
|
||||
const second = api.apiPost('/api/x', { n: 2 }, { auth: true });
|
||||
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
|
||||
release({ jwt: 'shared' });
|
||||
await Promise.all([first, second]);
|
||||
expect(mocks.createJWT).toHaveBeenCalledOnce();
|
||||
expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('shared');
|
||||
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('shared');
|
||||
});
|
||||
|
||||
it('scheitert das geteilte createJWT, bekommen beide den Fehler; der nächste Aufruf versucht es neu', async () => {
|
||||
const api = await freshApi();
|
||||
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
|
||||
let reject: (reason: unknown) => void = () => undefined;
|
||||
mocks.createJWT.mockImplementationOnce(() => new Promise((_resolve, rej) => (reject = rej)));
|
||||
const first = api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
const second = api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
|
||||
reject(failure);
|
||||
expect(await Promise.all([first, second])).toEqual([failure, failure]);
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ein anderer Nutzer teilt sich kein laufendes createJWT', async () => {
|
||||
const api = await freshApi();
|
||||
let release: (value: { jwt: string }) => void = () => undefined;
|
||||
mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
|
||||
const first = api.apiPost('/api/x', {}, { auth: true });
|
||||
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
|
||||
mocks.userId = 'u2';
|
||||
const second = api.apiPost('/api/x', {}, { auth: true });
|
||||
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledTimes(2));
|
||||
release({ jwt: 'for-u1' });
|
||||
await Promise.all([first, second]);
|
||||
expect(sentHeaders(0)['X-Aircard-Jwt']).not.toBe(sentHeaders(1)['X-Aircard-Jwt']);
|
||||
});
|
||||
|
||||
it('ohne bekannte Nutzer-ID wird nichts gemerkt', async () => {
|
||||
const api = await freshApi();
|
||||
mocks.userId = null;
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('401 von der API', () => {
|
||||
it('holt ein neues JWT und wiederholt genau einmal', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' }));
|
||||
fetchMock.mockResolvedValueOnce(json(200, { ok: true }));
|
||||
await expect(api.apiPost('/api/x', { a: 1 }, { auth: true })).resolves.toEqual({ ok: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('jwt1');
|
||||
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
|
||||
expect(fetchMock.mock.calls[1][1].body).toBe(JSON.stringify({ a: 1 }));
|
||||
});
|
||||
|
||||
it('das neue JWT wird gemerkt, das alte nicht mehr', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' }));
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
await api.apiPost('/api/x', {}, { auth: true });
|
||||
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
|
||||
expect(sentHeaders(2)['X-Aircard-Jwt']).toBe('jwt2');
|
||||
});
|
||||
|
||||
it('bei einem zweiten 401 ist Schluss: ApiError mit code 401, keine dritte Anfrage', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.' }));
|
||||
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ohne auth wird nicht wiederholt', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(401, { error: 'Nein.' }));
|
||||
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 401 });
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('andere Fehler (403) lösen keinen Wiederholungsversuch aus', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
|
||||
await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toMatchObject({ code: 403 });
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(mocks.createJWT).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zeitlimit', () => {
|
||||
it('jede Anfrage trägt ein Zeitlimit von 15 Sekunden (AbortSignal.timeout)', async () => {
|
||||
const api = await freshApi();
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout');
|
||||
await api.apiPost('/api/x', {}, { auth: false });
|
||||
expect(timeout).toHaveBeenCalledWith(15_000);
|
||||
expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('Zeitüberschreitung gilt als verlorene Antwort: code 503, Ausgang offen', async () => {
|
||||
const api = await freshApi();
|
||||
vi.spyOn(AbortSignal, 'timeout').mockReturnValue(
|
||||
AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError')),
|
||||
);
|
||||
fetchMock.mockImplementation(async (_url, init) => {
|
||||
throw init.signal?.reason;
|
||||
});
|
||||
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE });
|
||||
expect(api.isDefinitiveFailure(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fehler', () => {
|
||||
it('ApiError trägt Statuscode (code = status) und den deutschen Servertext aus `message`', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(400, { message: 'Bitte gib einen Titel an.' }));
|
||||
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toMatchObject({ code: 400, status: 400, message: 'Bitte gib einen Titel an.' });
|
||||
});
|
||||
|
||||
it('liest den Text auch aus `error` (so antwortet aircard-api tatsächlich)', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' }));
|
||||
await expect(api.apiPost('/api/melden', {}, { auth: false })).rejects.toMatchObject({
|
||||
code: 429,
|
||||
message: 'Zu viele Meldungen – bitte später nochmal.',
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON ohne Text: allgemeine Meldung mit dem Statuscode', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(500, {}));
|
||||
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({
|
||||
code: 500,
|
||||
message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.',
|
||||
});
|
||||
});
|
||||
|
||||
it('der Statuscode lässt isUnauthorized aus errors.ts greifen', async () => {
|
||||
const api = await freshApi();
|
||||
const { isUnauthorized } = await import('../account/errors');
|
||||
fetchMock.mockResolvedValue(json(401, { error: 'x' }));
|
||||
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
|
||||
expect(isUnauthorized(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('Netzfehler: code 503 und „Der Dienst ist gerade nicht erreichbar.“', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE });
|
||||
});
|
||||
|
||||
it.each([502, 503, 504])('Status %i (auch mit JSON) gilt als nicht erreichbar: code 503', async (status) => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(status, { error: 'irgendwas' }));
|
||||
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
|
||||
});
|
||||
|
||||
it('eine Fehlerseite ohne JSON (z. B. Proxy) gilt als nicht erreichbar', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(new Response('<html>404 page not found</html>', { status: 404 }));
|
||||
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
|
||||
});
|
||||
|
||||
it('eine HTML-Antwort mit Status 200 (Seitenfallback ohne API-Route) gilt als nicht erreichbar', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(new Response('<!doctype html><title>AirCard</title>', { status: 200 }));
|
||||
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
|
||||
});
|
||||
|
||||
it('JSON, das kein Objekt ist, gilt als nicht erreichbar', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(new Response('"ok"', { status: 200 }));
|
||||
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503 });
|
||||
});
|
||||
|
||||
it('createJWT mit user_auth_method_unsupported: Hinweis an den Betreiber, keine Anfrage', async () => {
|
||||
const api = await freshApi();
|
||||
mocks.createJWT.mockRejectedValue(
|
||||
Object.assign(new Error('JWT unsupported'), { code: 501, type: 'user_auth_method_unsupported' }),
|
||||
);
|
||||
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({
|
||||
code: 501,
|
||||
message: 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('createJWT mit 401 (keine Sitzung) geht unverändert durch, damit withLogin greift', async () => {
|
||||
const api = await freshApi();
|
||||
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
|
||||
mocks.createJWT.mockRejectedValue(failure);
|
||||
await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toBe(failure);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isUsernameRequired: verlangt der Dienst einen Benutzernamen?', () => {
|
||||
it('ja bei 403 mit dem Schlüssel usernameRequired (Hochladen und Veröffentlichen ohne Profil)', async () => {
|
||||
const api = await freshApi();
|
||||
const err = await (async () => {
|
||||
fetchMock.mockImplementation(async () => json(403, { error: 'Bitte wähle zuerst einen Benutzernamen.', code: 'usernameRequired' }));
|
||||
return api.apiPost('/api/veroeffentlichen', {}, { auth: true }).catch((e: unknown) => e);
|
||||
})();
|
||||
expect(err).toMatchObject({ code: 403, serverCode: 'usernameRequired' });
|
||||
expect(api.isUsernameRequired(err)).toBe(true);
|
||||
expect(api.isDefinitiveFailure(err)).toBe(true); // es wurde nichts angelegt: die Dateien dieses Versuchs dürfen aufgeräumt werden
|
||||
});
|
||||
|
||||
it('der Schlüssel entscheidet, nicht der Status und nicht der Text', async () => {
|
||||
const api = await freshApi();
|
||||
expect(api.isUsernameRequired(new api.ApiError(403, 'x', undefined, 'usernameRequired'))).toBe(true);
|
||||
expect(api.isUsernameRequired(new api.ApiError(400, 'x', undefined, 'usernameRequired'))).toBe(true);
|
||||
expect(api.isUsernameRequired(new api.ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.'))).toBe(false); // ohne Schlüssel
|
||||
});
|
||||
|
||||
it('andere Fehler nicht: andere 403, Netz, Dienst nicht erreichbar, kein ApiError', async () => {
|
||||
const api = await freshApi();
|
||||
expect(api.isUsernameRequired(new api.ApiError(403, 'x', undefined, 'forbidden'))).toBe(false);
|
||||
expect(api.isUsernameRequired(new api.ApiError(409, 'x', undefined, 'usernameTaken'))).toBe(false);
|
||||
expect(api.isUsernameRequired(api.unavailableError())).toBe(false);
|
||||
expect(api.isUsernameRequired(new TypeError('Failed to fetch'))).toBe(false);
|
||||
expect(api.isUsernameRequired(Object.assign(new Error('x'), { code: 403, serverCode: 'usernameRequired' }))).toBe(false);
|
||||
expect(api.isUsernameRequired(null)).toBe(false);
|
||||
expect(api.isUsernameRequired(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDefinitiveFailure: hat der Server die Anfrage eindeutig abgelehnt?', () => {
|
||||
it.each([400, 401, 403, 404, 409, 422])('ApiError %i: ja', async (code) => {
|
||||
const api = await freshApi();
|
||||
expect(new api.ApiError(code, 'x').definitive).toBe(true);
|
||||
expect(api.isDefinitiveFailure(new api.ApiError(code, 'x'))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([408, 429, 500, 501, 502, 503, 504])('ApiError %i (Zeitüberschreitung, Drosselung, Serverfehler): nein', async (code) => {
|
||||
const api = await freshApi();
|
||||
expect(api.isDefinitiveFailure(new api.ApiError(code, 'x'))).toBe(false);
|
||||
});
|
||||
|
||||
it('das Flag lässt sich setzen (JWT-Fehler: es ging keine Anfrage raus)', async () => {
|
||||
const api = await freshApi();
|
||||
expect(api.isDefinitiveFailure(new api.ApiError(501, 'x', true))).toBe(true);
|
||||
expect(api.isDefinitiveFailure(new api.ApiError(400, 'x', false))).toBe(false);
|
||||
});
|
||||
|
||||
it('ein anderer Fehler (z. B. aus createJWT vor der Anfrage) ist eindeutig', async () => {
|
||||
const api = await freshApi();
|
||||
expect(api.isDefinitiveFailure(Object.assign(new Error('x'), { code: 401 }))).toBe(true);
|
||||
expect(api.isDefinitiveFailure(new TypeError('Failed to fetch'))).toBe(true);
|
||||
});
|
||||
|
||||
const outcome = async (respond: () => Promise<Response>, api: Api) => {
|
||||
fetchMock.mockImplementation(respond);
|
||||
return api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
};
|
||||
|
||||
it.each([400, 401, 403, 409])('Antwort %i vom Dienst: eindeutig', async (status) => {
|
||||
const api = await freshApi();
|
||||
const err = await outcome(async () => json(status, { error: 'Nein.' }), api);
|
||||
expect(err).toMatchObject({ code: status });
|
||||
expect(api.isDefinitiveFailure(err)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([408, 429, 500])('Antwort %i vom Dienst: Ausgang offen', async (status) => {
|
||||
const api = await freshApi();
|
||||
const err = await outcome(async () => json(status, { error: 'Nein.' }), api);
|
||||
expect(err).toMatchObject({ code: status });
|
||||
expect(api.isDefinitiveFailure(err)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([502, 503, 504])('Antwort %i (Proxy): Ausgang offen', async (status) => {
|
||||
const api = await freshApi();
|
||||
const err = await outcome(async () => json(status, { error: 'x' }), api);
|
||||
expect(api.isDefinitiveFailure(err)).toBe(false);
|
||||
});
|
||||
|
||||
it('Netzfehler, Fehlerseite ohne JSON und HTML-200: Ausgang offen', async () => {
|
||||
const api = await freshApi();
|
||||
for (const respond of [
|
||||
async () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
async () => new Response('<html>Bad Gateway</html>', { status: 404 }),
|
||||
async () => new Response('<!doctype html>', { status: 200 }),
|
||||
]) {
|
||||
expect(api.isDefinitiveFailure(await outcome(respond, api))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('der Text einer abgebrochenen Antwort (res.text() scheitert): Ausgang offen', async () => {
|
||||
const api = await freshApi();
|
||||
const broken = { ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response;
|
||||
expect(api.isDefinitiveFailure(await outcome(async () => broken, api))).toBe(false);
|
||||
});
|
||||
|
||||
it('JWT-Methode am Server aus: eindeutig (es wurde nichts gesendet)', async () => {
|
||||
const api = await freshApi();
|
||||
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
|
||||
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(api.isDefinitiveFailure(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('createJWT scheitert mit 401 vor der Anfrage: eindeutig', async () => {
|
||||
const api = await freshApi();
|
||||
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 401, type: 'general_unauthorized_scope' }));
|
||||
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
expect(api.isDefinitiveFailure(err)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishCard', () => {
|
||||
const input = {
|
||||
title: 'Meine Karte',
|
||||
issuer: 'Beispielbank',
|
||||
tags: ['visa', 'gold'],
|
||||
displayName: 'Kenso',
|
||||
imageFileId: 'img1',
|
||||
previewFileId: 'pre1',
|
||||
sourceCardId: 'card1',
|
||||
};
|
||||
|
||||
it('POST /api/veroeffentlichen mit JWT, Tags als Liste; liefert { id }', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(201, { id: 'g1' }));
|
||||
await expect(api.publishCard(input)).resolves.toEqual({ id: 'g1' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/veroeffentlichen');
|
||||
expect(JSON.parse(init.body as string)).toEqual(input);
|
||||
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
|
||||
});
|
||||
|
||||
it('409: „Diese Karte ist schon veröffentlicht.“', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.' }));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' });
|
||||
expect(api.isAlreadyPublished(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('403 „bereits veröffentlicht oder gesperrt“ (Dateien belegt) gilt ebenfalls als schon veröffentlicht', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' });
|
||||
expect(api.isAlreadyPublished(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('ein anderes 403 bleibt ein 403 mit dem Servertext', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.' }));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 403, message: 'Diese Datei gehört dir nicht.' });
|
||||
expect(api.isAlreadyPublished(err)).toBe(false);
|
||||
});
|
||||
|
||||
it('400 (Validierung) behält den Servertext', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(400, { error: 'Bitte gib höchstens 5 Tags an.' }));
|
||||
await expect(api.publishCard(input)).rejects.toMatchObject({ code: 400, message: 'Bitte gib höchstens 5 Tags an.' });
|
||||
});
|
||||
|
||||
it('eine Antwort ohne id ist ein Fehler mit offenem Ausgang (die Zeile kann angelegt sein)', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(201, {}));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE });
|
||||
expect(api.isDefinitiveFailure(err)).toBe(false);
|
||||
});
|
||||
|
||||
it('die umgedeutete 409 (Dateien belegt) ist eindeutig', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
|
||||
expect(api.isDefinitiveFailure(await api.publishCard(input).catch((e: unknown) => e))).toBe(true);
|
||||
});
|
||||
|
||||
it('429 „schon 20 Karten veröffentlicht“ (Limit des Dienstes) behält den Text und ist eindeutig: es wurde nichts angelegt', async () => {
|
||||
const api = await freshApi();
|
||||
const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
|
||||
fetchMock.mockResolvedValue(json(429, { error: text }));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({ code: 429, message: text });
|
||||
expect(api.isAlreadyPublished(err)).toBe(false);
|
||||
// damit räumt der Client die zwei hochgeladenen Dateien auf (ein anderes 429, z. B. vom Proxy, bleibt offen)
|
||||
expect(api.isDefinitiveFailure(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('ein anderes 429 beim Veröffentlichen bleibt ein Fehler mit offenem Ausgang', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Anfragen.' }));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 429, message: 'Zu viele Anfragen.' });
|
||||
expect(api.isDefinitiveFailure(err)).toBe(false);
|
||||
});
|
||||
|
||||
it('isAlreadyPublished: nur ApiError mit code 409', async () => {
|
||||
const api = await freshApi();
|
||||
expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true);
|
||||
expect(api.isAlreadyPublished(new api.ApiError(400, 'x'))).toBe(false);
|
||||
expect(api.isAlreadyPublished(Object.assign(new Error('x'), { code: 409 }))).toBe(false);
|
||||
expect(api.isAlreadyPublished(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withdrawCard, reportCard, setVisibility', () => {
|
||||
it('withdrawCard: POST /api/zurueckziehen { id } mit JWT', async () => {
|
||||
const api = await freshApi();
|
||||
await expect(api.withdrawCard('g1')).resolves.toBeUndefined();
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/zurueckziehen');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ id: 'g1' });
|
||||
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
|
||||
});
|
||||
|
||||
it('reportCard: POST /api/melden { id, reason } ohne JWT; { schonGemeldet: false } bei neuer Meldung', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(200, { ok: true }));
|
||||
await expect(api.reportCard('g1', 'spam')).resolves.toEqual({ schonGemeldet: false });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/melden');
|
||||
expect(JSON.parse(init.body as string)).toEqual({ id: 'g1', reason: 'spam' });
|
||||
expect(mocks.createJWT).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reportCard: { schonGemeldet: true } bei doppelter Meldung', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(200, { schonGemeldet: true }));
|
||||
await expect(api.reportCard('g1', 'urheberrecht')).resolves.toEqual({ schonGemeldet: true });
|
||||
});
|
||||
|
||||
it('reportCard: 429 mit dem Servertext', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' }));
|
||||
await expect(api.reportCard('g1', 'spam')).rejects.toMatchObject({
|
||||
code: 429,
|
||||
message: 'Zu viele Meldungen – bitte später nochmal.',
|
||||
});
|
||||
});
|
||||
|
||||
it('setVisibility: POST /api/admin/sichtbarkeit { id, sichtbar } mit JWT', async () => {
|
||||
const api = await freshApi();
|
||||
await expect(api.setVisibility('g1', false)).resolves.toBeUndefined();
|
||||
await api.setVisibility('g1', true);
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/sichtbarkeit');
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toEqual({ id: 'g1', sichtbar: false });
|
||||
expect(JSON.parse(fetchMock.mock.calls[1][1].body as string)).toEqual({ id: 'g1', sichtbar: true });
|
||||
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
|
||||
});
|
||||
|
||||
it('setVisibility: 403 für Nicht-Admins mit dem Servertext', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
|
||||
await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('serverCode: der Schlüssel der Fehlerantwort', () => {
|
||||
const fail = (api: Api) => api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
|
||||
|
||||
it('ApiError trägt ohne Angabe serverCode null; das Feld lässt sich setzen, ohne code und definitive zu ändern', async () => {
|
||||
const api = await freshApi();
|
||||
expect(new api.ApiError(400, 'x').serverCode).toBeNull();
|
||||
expect(new api.ApiError(400, 'x', undefined, null).serverCode).toBeNull();
|
||||
const err = new api.ApiError(409, 'x', undefined, 'alreadyPublished');
|
||||
expect(err).toMatchObject({ code: 409, status: 409, message: 'x', definitive: true, serverCode: 'alreadyPublished' });
|
||||
expect(new api.ApiError(429, 'x', true, 'tooManyPublications')).toMatchObject({ definitive: true, serverCode: 'tooManyPublications' });
|
||||
});
|
||||
|
||||
it('wird aus {error, code} der Antwort gelesen; code bleibt der Status, message der deutsche Text', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code: 'adminOnly' }));
|
||||
const err = await fail(api);
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({ code: 403, status: 403, message: 'Nur für Admins.', serverCode: 'adminOnly' });
|
||||
});
|
||||
|
||||
it('ohne code in der Antwort (älterer Dienst, Proxy) bleibt serverCode null', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
|
||||
expect(await fail(api)).toMatchObject({ code: 403, message: 'Nur für Admins.', serverCode: null });
|
||||
});
|
||||
|
||||
it.each([[42], [''], [null], [true], [['adminOnly']], [{ a: 1 }]])('ein code, der kein nichtleerer Text ist (%j), zählt nicht', async (code) => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code }));
|
||||
expect(await fail(api)).toMatchObject({ message: 'Nur für Admins.', serverCode: null });
|
||||
});
|
||||
|
||||
it('der Dienst sendet Dienst-Fehler mit ihrem Schlüssel: 429, 404, 409', async () => {
|
||||
const api = await freshApi();
|
||||
for (const [status, error, code] of [
|
||||
[429, 'Zu viele Meldungen – bitte später nochmal.', 'tooManyReports'],
|
||||
[404, 'Dieses Logo ist nicht (mehr) verfügbar.', 'logoGone'],
|
||||
[409, 'Diese Datei wird schon für ein Logo verwendet.', 'logoTaken'],
|
||||
] as const) {
|
||||
fetchMock.mockResolvedValueOnce(json(status, { error, code }));
|
||||
expect(await fail(api)).toMatchObject({ code: status, message: error, serverCode: code });
|
||||
}
|
||||
});
|
||||
|
||||
it('Netzfehler und Zeitlimit: unavailable', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||
vi.spyOn(AbortSignal, 'timeout').mockReturnValue(AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError')));
|
||||
fetchMock.mockImplementation(async (_url, init) => {
|
||||
throw init.signal?.reason;
|
||||
});
|
||||
expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' });
|
||||
});
|
||||
|
||||
it.each([502, 503, 504])('Status %i vom Proxy (ohne code im Body, auch mit fremdem code): unavailable', async (status) => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas' }));
|
||||
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||
fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas', code: 'internal' }));
|
||||
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||
});
|
||||
|
||||
it('Fehlerseite ohne JSON, HTML mit Status 200 und JSON, das kein Objekt ist: unavailable', async () => {
|
||||
const api = await freshApi();
|
||||
for (const response of [
|
||||
new Response('<html>404 page not found</html>', { status: 404 }),
|
||||
new Response('<!doctype html><title>AirCard</title>', { status: 200 }),
|
||||
new Response('"ok"', { status: 200 }),
|
||||
]) {
|
||||
fetchMock.mockResolvedValueOnce(response);
|
||||
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
it('eine abgebrochene Antwort (res.text() scheitert): unavailable', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response);
|
||||
expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' });
|
||||
});
|
||||
|
||||
it('JSON-Fehler ohne Text und ohne code: generic', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(500, {}));
|
||||
expect(await fail(api)).toMatchObject({ code: 500, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'generic' });
|
||||
});
|
||||
|
||||
it('JSON-Fehler ohne Text, aber mit code: der code des Dienstes', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(404, { code: 'logoGone' }));
|
||||
expect(await fail(api)).toMatchObject({ code: 404, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'logoGone' });
|
||||
});
|
||||
|
||||
it('createJWT mit user_auth_method_unsupported: jwtUnsupported (und eindeutig)', async () => {
|
||||
const api = await freshApi();
|
||||
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
|
||||
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 501, serverCode: 'jwtUnsupported', definitive: true });
|
||||
});
|
||||
|
||||
it('der zweite Versuch nach einem 401 liefert den Schlüssel der zweiten Antwort', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.', code: 'unauthorized' }));
|
||||
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.', serverCode: 'unauthorized' });
|
||||
});
|
||||
|
||||
it('unavailableError(): 503, Ausgang offen, Schlüssel unavailable', async () => {
|
||||
const api = await freshApi();
|
||||
const err = api.unavailableError();
|
||||
expect(err).toBeInstanceOf(api.ApiError);
|
||||
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable', definitive: false });
|
||||
expect(api.isDefinitiveFailure(err)).toBe(false);
|
||||
});
|
||||
|
||||
describe('beim Veröffentlichen', () => {
|
||||
const input = {
|
||||
title: 'Meine Karte',
|
||||
issuer: 'Beispielbank',
|
||||
tags: ['visa'],
|
||||
displayName: 'Kenso',
|
||||
imageFileId: 'img1',
|
||||
previewFileId: 'pre1',
|
||||
sourceCardId: 'card1',
|
||||
};
|
||||
|
||||
it('403 „Dateien belegt“: der Dienst sendet filesTaken, publishCard macht daraus 409 alreadyPublished', async () => {
|
||||
const api = await freshApi();
|
||||
const text = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
||||
fetchMock.mockImplementation(async () => json(403, { error: text, code: 'filesTaken' })); // je Aufruf eine neue Response
|
||||
expect(await api.apiPost('/api/veroeffentlichen', input, { auth: true }).catch((e: unknown) => e)).toMatchObject({
|
||||
code: 403,
|
||||
message: text,
|
||||
serverCode: 'filesTaken',
|
||||
});
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.', serverCode: 'alreadyPublished' });
|
||||
expect(api.isAlreadyPublished(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('409 vom Dienst: alreadyPublished', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.', code: 'alreadyPublished' }));
|
||||
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' });
|
||||
});
|
||||
|
||||
it('429 „schon 20 Karten“: tooManyPublications, eindeutig', async () => {
|
||||
const api = await freshApi();
|
||||
const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
|
||||
fetchMock.mockResolvedValue(json(429, { error: text, code: 'tooManyPublications' }));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 429, message: text, serverCode: 'tooManyPublications', definitive: true });
|
||||
});
|
||||
|
||||
it('ohne die Schlüssel (Dienst von vorher) klappt die Umdeutung weiter über den deutschen Text', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValueOnce(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
|
||||
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
json(429, { error: 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.' }),
|
||||
);
|
||||
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 429, serverCode: 'tooManyPublications', definitive: true });
|
||||
});
|
||||
|
||||
it('eine Antwort ohne id: unavailable, Ausgang offen', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(201, {}));
|
||||
const err = await api.publishCard(input).catch((e: unknown) => e);
|
||||
expect(err).toMatchObject({ code: 503, serverCode: 'unavailable', definitive: false });
|
||||
});
|
||||
|
||||
it('ein anderes 403 behält seinen Schlüssel', async () => {
|
||||
const api = await freshApi();
|
||||
fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.', code: 'foreignFile' }));
|
||||
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 403, serverCode: 'foreignFile' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
type ApiErr = InstanceType<Api['ApiError']>;
|
||||
|
||||
/** Der ApiError, mit dem `promise` scheitert. */
|
||||
async function rejection(promise: Promise<unknown>): Promise<ApiErr> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (err) {
|
||||
return err as ApiErr;
|
||||
}
|
||||
throw new Error('Die Anfrage ist nicht gescheitert.');
|
||||
}
|
||||
|
||||
describe('apiErrorText: die Fehlermeldung zum Anzeigen', () => {
|
||||
it('Deutsch: der Text des Dienstes, auch bei Fehlern ohne bekannten Schlüssel', async () => {
|
||||
const api = await freshApi();
|
||||
expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe(
|
||||
'Diese Karte ist schon veröffentlicht.',
|
||||
);
|
||||
expect(api.apiErrorText(new api.ApiError(400, 'Der Titel ist zu lang (höchstens 60 Zeichen).', undefined, 'badRequest'))).toBe(
|
||||
'Der Titel ist zu lang (höchstens 60 Zeichen).',
|
||||
);
|
||||
expect(api.apiErrorText(new api.ApiError(418, 'Ich bin eine Teekanne.'))).toBe('Ich bin eine Teekanne.');
|
||||
});
|
||||
|
||||
it('Englisch: übersetzt über den Schlüssel', async () => {
|
||||
const api = await freshApi();
|
||||
await setLanguage('en');
|
||||
expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe(
|
||||
'This card is already published.',
|
||||
);
|
||||
expect(api.apiErrorText(new api.ApiError(403, 'Nur für Admins.', undefined, 'adminOnly'))).toBe('Admins only.');
|
||||
});
|
||||
|
||||
it('Englisch: Fehler, die der Client selbst erzeugt (Netz, JWT, Veröffentlichen), sind übersetzt', async () => {
|
||||
const api = await freshApi();
|
||||
await setLanguage('en');
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||
const network = await rejection(api.apiPost('/api/x', {}, { auth: false }));
|
||||
expect(api.apiErrorText(network)).toBe('The service is currently unavailable.');
|
||||
|
||||
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
|
||||
const jwt = await rejection(api.apiPost('/api/x', {}, { auth: true }));
|
||||
expect(api.apiErrorText(jwt)).toBe('Token sign-in is turned off on the server – please let the site owner know.');
|
||||
|
||||
fetchMock.mockResolvedValue(json(500, {}));
|
||||
const generic = await rejection(api.apiPost('/api/x', {}, { auth: false }));
|
||||
expect(api.apiErrorText(generic)).toBe('Something went wrong. Please try again.');
|
||||
});
|
||||
|
||||
it('Englisch: unbekannter oder fehlender Schlüssel, allgemeine Meldung je Status (die ApiError.code)', async () => {
|
||||
const api = await freshApi();
|
||||
await setLanguage('en');
|
||||
expect(api.apiErrorText(new api.ApiError(400, 'Das gibt es nur auf Deutsch.'))).toBe('Invalid request.');
|
||||
expect(api.apiErrorText(new api.ApiError(403, 'Das gibt es nur auf Deutsch.', undefined, 'neuerCode'))).toBe(
|
||||
'You are not allowed to do that.',
|
||||
);
|
||||
expect(api.apiErrorText(new api.ApiError(404, 'Das gibt es nur auf Deutsch.'))).toBe('Not found.');
|
||||
expect(api.apiErrorText(new api.ApiError(500, 'Das gibt es nur auf Deutsch.'))).toBe('Something went wrong. Please try again.');
|
||||
});
|
||||
|
||||
it('übergibt Schlüssel, Status und Text an apiMessage', async () => {
|
||||
const api = await freshApi();
|
||||
const { apiMessage } = await import('../i18n/api-messages');
|
||||
await setLanguage('en');
|
||||
for (const err of [
|
||||
new api.ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', undefined, 'logoGone'),
|
||||
new api.ApiError(400, 'Der Name ist ungültig.', undefined, 'badRequest'),
|
||||
new api.ApiError(403, 'Text', undefined, null),
|
||||
]) {
|
||||
expect(api.apiErrorText(err)).toBe(apiMessage(err.serverCode, err.code, err.message));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
// Schreibzugriffe der Community-Galerie: Aufrufe des Dienstes `aircard-api` (deploy/api) mit dem Nutzer-JWT.
|
||||
// Lesen läuft direkt über Appwrite (gallery.ts). Fehler sind `ApiError` mit HTTP-Status als `code`, damit
|
||||
// `isUnauthorized`/`withLogin`/`errorMessage` aus account/ sie wie Appwrite-Fehler behandeln.
|
||||
import { account } from '../account/appwrite';
|
||||
import { currentUserId, onAuthChange, onLogout } from '../account/auth';
|
||||
import { errorType } from '../account/errors';
|
||||
import { API_MESSAGES, apiMessage } from '../i18n/api-messages';
|
||||
|
||||
export type ReportReason = 'unangemessen' | 'kartendaten' | 'urheberrecht' | 'spam' | 'sonstiges';
|
||||
|
||||
export interface PublishInput {
|
||||
title: string;
|
||||
issuer: string;
|
||||
tags: string[];
|
||||
displayName: string;
|
||||
imageFileId: string;
|
||||
previewFileId: string;
|
||||
sourceCardId: string;
|
||||
}
|
||||
|
||||
// Die deutschen Texte stehen einmal in i18n/api-messages.ts (dort mit MSG des Dienstes abgeglichen); `ApiError.message` bleibt
|
||||
// Deutsch, damit der Vergleich mit ihnen weiter geht. Angezeigt wird über `apiErrorText`.
|
||||
const UNAVAILABLE = API_MESSAGES.unavailable;
|
||||
const GENERIC = API_MESSAGES.generic;
|
||||
const JWT_UNSUPPORTED = API_MESSAGES.jwtUnsupported;
|
||||
const ALREADY_PUBLISHED = API_MESSAGES.alreadyPublished;
|
||||
/** Wortlaut der API (deploy/api/common.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
|
||||
const FILES_TAKEN = API_MESSAGES.filesTaken;
|
||||
/** Wortlaut der API (MSG.tooManyPublications): das Limit von 20 Veröffentlichungen je Konto ist erreicht. */
|
||||
const TOO_MANY_PUBLICATIONS = API_MESSAGES.tooManyPublications;
|
||||
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
|
||||
const JWT_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Zeitlimit je Anfrage; danach gilt die Antwort als verloren (503, Ausgang offen). */
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext.
|
||||
* `serverCode` ist der Schlüssel der Fehlerantwort (`code` in `{error, code}`, siehe MSG in deploy/api/common.js; Fehler, die
|
||||
* der Client selbst erzeugt, tragen die passenden Schlüssel `unavailable`, `generic`, `jwtUnsupported`, …), null ohne Schlüssel.
|
||||
* Übersetzt wird der Text nach `serverCode` (`apiErrorText`), nie nach `message`.
|
||||
* `definitive`: der Dienst hat die Anfrage eindeutig abgelehnt oder es ging gar keine raus, also wurde nichts angelegt.
|
||||
* Ohne Angabe gilt das für 4xx außer Zeitüberschreitung (408) und Drosselung (429); 5xx, 408, 429 und alles, was
|
||||
* keine verwertbare Antwort ergab (Netzfehler, Zeitlimit, Fehlerseite, fehlende `id`), lassen offen, ob die
|
||||
* Anfrage trotzdem gewirkt hat.
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
readonly code: number;
|
||||
readonly definitive: boolean;
|
||||
readonly serverCode: string | null;
|
||||
|
||||
constructor(code: number, message: string, definitive?: boolean, serverCode?: string | null) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.code = code;
|
||||
this.definitive = definitive ?? (code >= 400 && code < 500 && code !== 408 && code !== 429);
|
||||
this.serverCode = serverCode ?? null;
|
||||
}
|
||||
|
||||
get status(): number {
|
||||
return this.code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Der Dienst gab keine verwertbare Antwort: 503, Ausgang offen (die Anfrage kann trotzdem gewirkt haben). */
|
||||
export function unavailableError(): ApiError {
|
||||
return new ApiError(503, UNAVAILABLE, false, 'unavailable');
|
||||
}
|
||||
|
||||
/** Der Text eines Fehlers zum Anzeigen, in der Sprache der Seite (bekannter `serverCode` übersetzt, siehe `apiMessage`). */
|
||||
export function apiErrorText(err: ApiError): string {
|
||||
return apiMessage(err.serverCode, err.code, err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wahr, wenn eine fehlgeschlagene `apiPost`-Anfrage sicher nichts bewirkt hat: eindeutige Ablehnung des Dienstes,
|
||||
* oder ein Fehler vor der Anfrage (JWT holen). Falsch bei offenem Ausgang (siehe `ApiError.definitive`).
|
||||
* Andere Fehler als `ApiError` kommen aus `apiPost` nur aus dem JWT-Abruf, also vor der Anfrage.
|
||||
*/
|
||||
export function isDefinitiveFailure(err: unknown): boolean {
|
||||
return err instanceof ApiError ? err.definitive : true;
|
||||
}
|
||||
|
||||
// ---- JWT (je Nutzer-ID gemerkt) ----------------------------------------------------------------------
|
||||
|
||||
let cached: { userId: string; jwt: string; expires: number } | null = null;
|
||||
/** Zählt Leerungen: ein JWT, das erst nach einem Nutzerwechsel eintrifft, gehört dem alten Nutzer und wird nicht gemerkt. */
|
||||
let generation = 0;
|
||||
|
||||
function clearJwt(): void {
|
||||
cached = null;
|
||||
generation++;
|
||||
}
|
||||
|
||||
let subscribed = false;
|
||||
|
||||
/** Erst beim ersten JWT: so lässt sich das Modul importieren, ohne den Anmeldedienst zu berühren. */
|
||||
function subscribe(): void {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
onAuthChange(clearJwt);
|
||||
onLogout(clearJwt);
|
||||
}
|
||||
|
||||
/** Das gerade laufende createJWT, damit gleichzeitige Aufrufe mit leerem Cache nur eines auslösen. */
|
||||
let pending: { userId: string | null; generation: number; promise: Promise<string> } | null = null;
|
||||
|
||||
async function mintJwt(userId: string | null, started: number): Promise<string> {
|
||||
let jwt: string;
|
||||
try {
|
||||
// Ohne `duration`: die Standarddauer (15 Minuten) genügt.
|
||||
jwt = (await account.createJWT()).jwt;
|
||||
} catch (err) {
|
||||
if (errorType(err) === 'user_auth_method_unsupported') {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
// Es ging keine Anfrage an den API-Dienst raus: eindeutig.
|
||||
throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true, 'jwtUnsupported');
|
||||
}
|
||||
throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode
|
||||
}
|
||||
if (userId && started === generation) cached = { userId, jwt, expires: Date.now() + JWT_TTL_MS };
|
||||
return jwt;
|
||||
}
|
||||
|
||||
function getJwt(): Promise<string> {
|
||||
subscribe();
|
||||
const userId = currentUserId();
|
||||
if (userId && cached && cached.userId === userId && Date.now() < cached.expires) return Promise.resolve(cached.jwt);
|
||||
if (pending && pending.userId === userId && pending.generation === generation) return pending.promise;
|
||||
const started = generation;
|
||||
const promise: Promise<string> = mintJwt(userId, started).finally(() => {
|
||||
if (pending?.promise === promise) pending = null;
|
||||
});
|
||||
pending = { userId, generation: started, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
// ---- Anfrage ------------------------------------------------------------------------------------------
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is Json {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Der deutsche Text einer Fehlerantwort: aircard-api sendet `{error}`, `{message}` wird ebenso akzeptiert. */
|
||||
function serverMessage(body: Json): string | null {
|
||||
for (const key of ['message', 'error']) {
|
||||
const value = body[key];
|
||||
if (typeof value === 'string' && value.trim() !== '') return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Der Schlüssel einer Fehlerantwort (`code` neben `error`), sonst null. */
|
||||
function serverCodeOf(body: Json): string | null {
|
||||
const value = body.code;
|
||||
return typeof value === 'string' && value !== '' ? value : null;
|
||||
}
|
||||
|
||||
async function send(path: string, body: unknown, jwt: string | null): Promise<Json> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (jwt) headers['X-Aircard-Jwt'] = jwt;
|
||||
|
||||
const payload = JSON.stringify(body);
|
||||
let res: Response;
|
||||
let text: string;
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payload,
|
||||
credentials: 'omit',
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
text = await res.text();
|
||||
} catch {
|
||||
// Netzfehler, Zeitlimit oder abgebrochene Antwort: die Anfrage kann trotzdem angekommen sein.
|
||||
throw unavailableError();
|
||||
}
|
||||
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200). Sein Body
|
||||
// trägt keinen `code`; darum wird schon der Status (vor dem Lesen des Bodys) zu `unavailable`.
|
||||
if (res.status === 502 || res.status === 503 || res.status === 504) throw unavailableError();
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw unavailableError();
|
||||
}
|
||||
if (!isRecord(data)) throw unavailableError();
|
||||
if (!res.ok) {
|
||||
const message = serverMessage(data);
|
||||
// Ohne Text und ohne Schlüssel: der allgemeine Fehler.
|
||||
throw new ApiError(res.status, message ?? GENERIC, undefined, serverCodeOf(data) ?? (message === null ? 'generic' : null));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST mit JSON-Body an den API-Dienst. Mit `auth` geht das Nutzer-JWT im Header `X-Aircard-Jwt` mit;
|
||||
* antwortet der Dienst mit 401, wird genau einmal mit einem frischen JWT wiederholt.
|
||||
*/
|
||||
export async function apiPost(path: string, body: unknown, opts: { auth: boolean }): Promise<Json> {
|
||||
if (!opts.auth) return send(path, body, null);
|
||||
try {
|
||||
return await send(path, body, await getJwt());
|
||||
} catch (err) {
|
||||
if (!(err instanceof ApiError) || err.code !== 401) throw err;
|
||||
clearJwt();
|
||||
return send(path, body, await getJwt());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Endpunkte ----------------------------------------------------------------------------------------
|
||||
|
||||
/** Wahr, wenn die Karte schon veröffentlicht ist (`publishCard` meldet das immer als 409). */
|
||||
export function isAlreadyPublished(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.code === 409;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wahr, wenn der Dienst einen Benutzernamen verlangt (403 `usernameRequired`): das Konto hat noch keinen, und Hochladen und
|
||||
* Veröffentlichen gehen erst mit Namen (Spec § 4). Der Aufrufer zeigt dann den Namensdialog; angelegt wurde nichts.
|
||||
*/
|
||||
export function isUsernameRequired(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.serverCode === 'usernameRequired';
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlicht eine Karte. Doppelt veröffentlicht (409) und „Dateien schon belegt oder gesperrt“ (403) kommen
|
||||
* beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an. Das Limit des Dienstes (429 mit dem Limit-Text) ist
|
||||
* eine eindeutige Ablehnung (es wurde nichts angelegt, die Dateien dürfen aufgeräumt werden); alle anderen Fehler unverändert.
|
||||
*/
|
||||
export async function publishCard(input: PublishInput): Promise<{ id: string }> {
|
||||
let res: Json;
|
||||
try {
|
||||
res = await apiPost('/api/veroeffentlichen', input, { auth: true });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
|
||||
throw new ApiError(409, ALREADY_PUBLISHED, undefined, 'alreadyPublished');
|
||||
}
|
||||
if (err instanceof ApiError && err.code === 429 && err.message === TOO_MANY_PUBLICATIONS) {
|
||||
throw new ApiError(429, TOO_MANY_PUBLICATIONS, true, 'tooManyPublications');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (typeof res.id !== 'string' || res.id === '') throw unavailableError();
|
||||
return { id: res.id };
|
||||
}
|
||||
|
||||
/** Zieht eine Veröffentlichung zurück (Autor oder Admin): löscht Zeile, Dateien und Meldungen. */
|
||||
export async function withdrawCard(id: string): Promise<void> {
|
||||
await apiPost('/api/zurueckziehen', { id }, { auth: true });
|
||||
}
|
||||
|
||||
/** Meldet eine Karte (ohne Anmeldung). `schonGemeldet`: dieser Melder hatte sie schon gemeldet. */
|
||||
export async function reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }> {
|
||||
const res = await apiPost('/api/melden', { id, reason }, { auth: false });
|
||||
return { schonGemeldet: res.schonGemeldet === true };
|
||||
}
|
||||
|
||||
/** Admin: blendet eine Karte ein oder aus. */
|
||||
export async function setVisibility(id: string, visible: boolean): Promise<void> {
|
||||
await apiPost('/api/admin/sichtbarkeit', { id, sichtbar: visible }, { auth: true });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Direkt veröffentlichen: nach dem Speichern (Editor) und beim Mehrfach-Upload („Meine Karten“) ohne Dialog.
|
||||
// Titel = Name der Karte. Ist die Karte schon veröffentlicht, wird die alte Veröffentlichung zurückgezogen und die neue Fassung
|
||||
// veröffentlicht, damit die Galerie immer den gespeicherten Stand zeigt. Fehlt der Benutzername, fragt der Namensdialog einmal nach.
|
||||
import type { SavedCard } from '../account/cards';
|
||||
import { requireUserId } from '../account/cards';
|
||||
import { usernameDialog } from '../account/username-dialog';
|
||||
import { withLogin } from '../account/with-login';
|
||||
import { isAlreadyPublished, isUsernameRequired, withdrawCard } from './api';
|
||||
import { myPublications } from './gallery';
|
||||
import { publishSavedCard, type PublishForm } from './publish';
|
||||
|
||||
const TITLE_MAX = 60;
|
||||
|
||||
export function autoPublishForm(name: string): PublishForm {
|
||||
const title = name.trim().slice(0, TITLE_MAX).trim();
|
||||
return { title: title === '' ? 'AirCard' : title, issuer: '', tags: '' };
|
||||
}
|
||||
|
||||
export async function autoPublish(card: SavedCard, signal?: AbortSignal): Promise<{ id: string }> {
|
||||
const form = autoPublishForm(card.name);
|
||||
const publish = () => withLogin(() => publishSavedCard(card, form));
|
||||
let askedName = false;
|
||||
let replaced = false;
|
||||
for (;;) {
|
||||
try {
|
||||
return await publish();
|
||||
} catch (err) {
|
||||
if (isUsernameRequired(err) && !askedName) {
|
||||
askedName = true;
|
||||
if ((await usernameDialog({ mode: 'required', signal })) === null) throw err;
|
||||
continue;
|
||||
}
|
||||
if (isAlreadyPublished(err) && !replaced) {
|
||||
replaced = true;
|
||||
const old = (await myPublications(requireUserId())).get(card.id);
|
||||
if (!old) throw err;
|
||||
await withLogin(() => withdrawCard(old.id));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
/* Startseite: kompakter Hero mit schwebender Karte und die Community-Galerie darunter.
|
||||
Wichtig (Abnahmekriterium): bei 1440 x 900 und 390 x 844 stehen die Überschrift „Community-Karten“ und
|
||||
die Oberkante der ersten Kartenreihe schon im ersten Bildschirm. Der Hero bleibt deshalb niedrig. */
|
||||
|
||||
/* Knöpfe der Startseite (unabhängig von editor.css / account.css) */
|
||||
|
||||
.home-btn {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
margin: 0;
|
||||
padding: 0 22px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.home-btn:hover:not(:disabled) {
|
||||
border-color: var(--sel-line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.home-btn-primary {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.home-btn-primary:hover:not(:disabled) {
|
||||
border-color: var(--primary-hover);
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.home-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
|
||||
.home-hero {
|
||||
--hero-card-max: 540px;
|
||||
--card-radius: 3.711% / 5.882%; /* 57 px von 1536 x 969, wie in der Editor-Vorschau */
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||
align-items: center;
|
||||
gap: 24px 56px;
|
||||
min-height: auto;
|
||||
padding: 14px 0 10px;
|
||||
}
|
||||
|
||||
/* Nur auf breiten, hohen Fenstern: der Hero nimmt höchstens 60 % der Höhe. Auf schmaleren oder niedrigeren Fenstern
|
||||
bräche der Text in mehr Zeilen um und würde über die Grenze hinausragen (und die Galerie-Überschrift überdecken). */
|
||||
@media (min-width: 1100px) and (min-height: 700px) {
|
||||
.home-hero {
|
||||
max-height: 60vh;
|
||||
--hero-card-max: min(540px, calc((60vh - 48px) * 1536 / 969));
|
||||
}
|
||||
}
|
||||
|
||||
.home-hero-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-hero-eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
#app-root .home-hero-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: clamp(1.95rem, 4.4vw, 3.15rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.home-hero-lead {
|
||||
max-width: 34em;
|
||||
margin: 0 0 22px;
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.home-hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.home-hero-visual {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
/* weicher Lichtschein hinter der Karte */
|
||||
.home-hero-visual::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -10% 0; /* seitlich nicht über den Hero hinaus: sonst entsteht waagerechtes Scrollen */
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(closest-side, rgb(112 92 255 / 0.32), rgb(60 140 255 / 0.12) 60%, transparent);
|
||||
filter: blur(14px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .home-hero-visual::before {
|
||||
background: radial-gradient(closest-side, rgb(112 92 255 / 0.22), rgb(60 140 255 / 0.1) 60%, transparent);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
position: relative;
|
||||
width: min(100%, var(--hero-card-max));
|
||||
aspect-ratio: 1536 / 969;
|
||||
overflow: hidden;
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--panel-2);
|
||||
box-shadow: var(--shadow-card), 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
transform: rotate(-1.5deg);
|
||||
}
|
||||
|
||||
.hero-card img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.hero-card-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Schwebe-Animation: nur ohne „Bewegung reduzieren“ */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.hero-card {
|
||||
animation: hero-float 7s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hero-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(-1.5deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px) rotate(-0.7deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Galerie-Abschnitt */
|
||||
|
||||
.home-community {
|
||||
margin-top: 36px;
|
||||
scroll-margin-top: 16px;
|
||||
}
|
||||
|
||||
.community-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px 24px;
|
||||
}
|
||||
|
||||
.community-head-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.community-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 3vw, 1.95rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.community-sub {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.community-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.community-toolbar > :empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.community-active,
|
||||
.community-filter {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.community-active {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.community-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 3px 4px 3px 14px;
|
||||
border: 1px solid var(--sel-line);
|
||||
border-radius: 999px;
|
||||
background: var(--sel-bg);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.community-chip-label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.community-chip-value {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.community-chip-clear {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.community-chip-clear:hover {
|
||||
background: rgb(127 127 127 / 0.22);
|
||||
}
|
||||
|
||||
.community-filter {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.community-filter-btn {
|
||||
appearance: none;
|
||||
min-height: 36px;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.community-filter-btn:hover {
|
||||
border-color: var(--sel-line);
|
||||
}
|
||||
|
||||
.community-filter-btn[aria-pressed='true'] {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.community-status {
|
||||
margin: 24px 0 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.community-status.is-error,
|
||||
.community-more-msg.is-error {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.community-empty {
|
||||
margin-top: 20px;
|
||||
padding: 36px 20px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 18px;
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.community-empty p {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.community-empty p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.community-more {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.community-more:empty {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.community-more-msg {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Kachelraster */
|
||||
|
||||
.gallery-grid {
|
||||
--card-radius: 3.711% / 5.882%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
|
||||
gap: 20px;
|
||||
margin: 20px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.gallery-tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 10px 10px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.gallery-tile:hover {
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.gallery-tile-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border-radius: 12px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.gallery-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
aspect-ratio: 1536 / 969;
|
||||
overflow: hidden;
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--panel-2);
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gallery-thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.gallery-title {
|
||||
display: -webkit-box;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
}
|
||||
|
||||
.gallery-issuer {
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gallery-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
padding: 6px 4px 0;
|
||||
}
|
||||
|
||||
.gallery-tag {
|
||||
appearance: none;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
padding: 2px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gallery-tag:hover {
|
||||
border-color: var(--sel-line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gallery-badges {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-badge {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgb(15 24 40 / 0.85);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gallery-badge-hidden {
|
||||
background: #b42318;
|
||||
}
|
||||
|
||||
/* Detailseite /community/<id> */
|
||||
|
||||
.detail-page {
|
||||
--card-radius: 3.711% / 5.882%;
|
||||
}
|
||||
|
||||
.detail-back {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.detail-back a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.detail-back a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr);
|
||||
align-items: start;
|
||||
gap: 24px 36px;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
position: relative;
|
||||
aspect-ratio: 1536 / 969;
|
||||
overflow: hidden;
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--panel-2);
|
||||
box-shadow: var(--shadow-card), 0 0 0 1px rgb(255 255 255 / 0.06);
|
||||
}
|
||||
|
||||
.detail-card img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.detail-card-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-card-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#app-root .detail-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-issuer {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Solange der Name des Autors noch gesucht wird, ist die Zeile leer (kein Knoten darin). Ein Zeichen ohne Breite (U+200B) hält die Zeile
|
||||
in der Höhe des Textes, damit die Seite nicht springt, wenn „von @name · Datum“ dazukommt. */
|
||||
.detail-meta:empty::before {
|
||||
content: '\200b';
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.detail-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 2px 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-2);
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-tag:hover {
|
||||
border-color: var(--sel-line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.detail-notice-slot:empty,
|
||||
.detail-admin-slot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-hidden {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 12px;
|
||||
background: rgb(229 72 77 / 0.12);
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.detail-btn-danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
.detail-btn-danger:hover:not(:disabled) {
|
||||
border-color: var(--danger);
|
||||
background: rgb(229 72 77 / 0.16);
|
||||
}
|
||||
|
||||
.detail-msg {
|
||||
margin: 0;
|
||||
color: var(--ok-text);
|
||||
}
|
||||
|
||||
.detail-msg.is-error {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
/* Leer bleibt die Meldungszeile gerendert (Live-Region), nimmt aber keinen Platz ein: mit display: none stünde sie nicht im
|
||||
Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
|
||||
.detail-msg:empty {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-admin {
|
||||
margin-top: 8px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.detail-admin-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.detail-reports-count {
|
||||
margin: 0 0 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-reasons {
|
||||
margin: 0 0 12px;
|
||||
padding-left: 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-admin-note {
|
||||
margin: 0 0 12px;
|
||||
color: var(--warn-text);
|
||||
}
|
||||
|
||||
.detail-admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-unavailable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
max-width: 560px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.detail-load-error,
|
||||
.detail-status {
|
||||
margin: 24px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.detail-load-error {
|
||||
margin: 0;
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
/* Melden-Dialog (Knöpfe und Meldungen kommen aus account.css) */
|
||||
|
||||
.report-dialog {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 20px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.report-dialog::backdrop {
|
||||
background: var(--backdrop);
|
||||
}
|
||||
|
||||
.report-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.report-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.report-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.report-text {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.report-reasons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.report-legend {
|
||||
margin-bottom: 4px;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.report-reason {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.report-reason:hover {
|
||||
border-color: var(--sel-line);
|
||||
}
|
||||
|
||||
.report-reason:has(input:checked) {
|
||||
border-color: var(--sel-line);
|
||||
background: var(--sel-bg);
|
||||
}
|
||||
|
||||
.report-reason input {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
accent-color: var(--sel-line);
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Veröffentlichen-Dialog: Hülle, Meldungen und Knöpfe wie beim Melden-Dialog, dazu Felder und die Pflicht-Checkbox */
|
||||
|
||||
.publish-dialog {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.publish-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.publish-consent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.publish-consent:has(input:checked) {
|
||||
border-color: var(--sel-line);
|
||||
background: var(--sel-bg);
|
||||
}
|
||||
|
||||
.publish-consent input {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 2px 0 0;
|
||||
accent-color: var(--sel-line);
|
||||
}
|
||||
|
||||
.publish-consent span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Tablet und Handy: eine Spalte, der Hero bleibt niedrig */
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.detail-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.home-hero {
|
||||
--hero-card-max: 400px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 4px 0 6px;
|
||||
}
|
||||
|
||||
.home-hero-visual {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.detail-actions .home-btn {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.home-hero {
|
||||
--hero-card-max: 250px;
|
||||
gap: 8px;
|
||||
margin-top: -4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.home-hero-eyebrow {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#app-root .home-hero-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.85rem;
|
||||
}
|
||||
|
||||
.home-hero-lead {
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.97rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.home-btn {
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.home-hero-visual {
|
||||
padding: 10px 0 4px; /* Luft nach oben für die Schwebe-Animation (10 px nach oben) */
|
||||
}
|
||||
|
||||
.home-community {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.community-head {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.community-publish {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-btn,
|
||||
.gallery-tile {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { germanLeft } from '../i18n/english-check';
|
||||
import { setLang } from '../i18n/i18n';
|
||||
import type { CheckedImage, GalleryCard } from './gallery';
|
||||
import { mountGalleryGrid, type GalleryGrid } from './gallery-grid';
|
||||
|
||||
const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
|
||||
id: `g${n}`,
|
||||
title: `Karte ${n}`,
|
||||
issuer: 'Beispielbank',
|
||||
tags: ['visa', 'gold'],
|
||||
displayName: 'Kenso',
|
||||
imageFileId: `img${n}`,
|
||||
previewFileId: `pre${n}`,
|
||||
sourceCardId: `src${n}`,
|
||||
ownerId: 'u1',
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
createdAt: '2026-10-04T10:00:00.000Z',
|
||||
...patch,
|
||||
});
|
||||
|
||||
let counter: number;
|
||||
/** Ein geprüftes Bild, wie es `loadGalleryImageChecked` liefert: Blob plus fertige Object-URL. */
|
||||
const image = (): CheckedImage => ({ blob: new Blob(['x'], { type: 'image/webp' }), url: `blob:t-${++counter}` });
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
let host: HTMLElement;
|
||||
let grid: GalleryGrid | null;
|
||||
const revoked: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
grid = null;
|
||||
counter = 0;
|
||||
revoked.length = 0;
|
||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
grid?.dispose();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const tiles = () => [...host.querySelectorAll<HTMLElement>('.gallery-tile')];
|
||||
|
||||
describe('mountGalleryGrid: Kacheln', () => {
|
||||
it('zeigt Titel, Herausgeber und Kachel-Link auf die Detailseite', () => {
|
||||
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
|
||||
grid.append([card(1), card(2)]);
|
||||
expect(tiles()).toHaveLength(2);
|
||||
const first = tiles()[0];
|
||||
const link = first.querySelector<HTMLAnchorElement>('a[data-link]');
|
||||
expect(link?.getAttribute('href')).toBe('/community/g1');
|
||||
expect(first.querySelector('.gallery-title')?.textContent).toBe('Karte 1');
|
||||
expect(first.querySelector('.gallery-issuer')?.textContent).toBe('Beispielbank');
|
||||
});
|
||||
|
||||
it('kodiert die ID im Link und lässt den Herausgeber weg, wenn er fehlt', () => {
|
||||
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
|
||||
grid.append([card(1, { id: 'a/b c', issuer: '' })]);
|
||||
expect(host.querySelector('a[data-link]')?.getAttribute('href')).toBe('/community/a%2Fb%20c');
|
||||
expect(host.querySelector('.gallery-issuer')).toBeNull();
|
||||
});
|
||||
|
||||
it('zeigt höchstens drei Tags als Knöpfe; Klick meldet den Tag', () => {
|
||||
const onTag = vi.fn();
|
||||
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag });
|
||||
grid.append([card(1, { tags: ['visa', 'gold', 'dunkel', 'extra'] })]);
|
||||
const buttons = [...host.querySelectorAll<HTMLButtonElement>('button.gallery-tag')];
|
||||
expect(buttons.map((b) => b.textContent)).toEqual(['visa', 'gold', 'dunkel']);
|
||||
expect(buttons.every((b) => b.type === 'button')).toBe(true);
|
||||
buttons[1].click();
|
||||
expect(onTag).toHaveBeenCalledWith('gold');
|
||||
});
|
||||
|
||||
it('die Tags stehen außerhalb des Kachel-Links (kein Knopf im Link)', () => {
|
||||
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
|
||||
grid.append([card(1)]);
|
||||
expect(host.querySelector('a button')).toBeNull();
|
||||
});
|
||||
|
||||
it('setzt Titel und Herausgeber als Text, nie als HTML', () => {
|
||||
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
|
||||
grid.append([card(1, { title: '<img src=x onerror=alert(1)>', issuer: '<b>fett</b>', tags: ['<i>x</i>'] })]);
|
||||
expect(host.querySelector('.gallery-title')?.textContent).toBe('<img src=x onerror=alert(1)>');
|
||||
expect(host.querySelector('.gallery-tile img[src="x"]')).toBeNull();
|
||||
expect(host.querySelector('.gallery-issuer b')).toBeNull();
|
||||
expect(host.querySelector('.gallery-tag i')).toBeNull();
|
||||
});
|
||||
|
||||
it('append hängt an, ohne die vorhandenen Kacheln zu ersetzen', () => {
|
||||
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
|
||||
grid.append([card(1)]);
|
||||
const firstTile = tiles()[0];
|
||||
grid.append([card(2), card(3)]);
|
||||
expect(tiles()).toHaveLength(3);
|
||||
expect(tiles()[0]).toBe(firstTile);
|
||||
});
|
||||
|
||||
it('Status-Marken (Ausgeblendet, Meldungen) nur mit showStatus', () => {
|
||||
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {}, showStatus: true });
|
||||
grid.append([card(1, { hidden: true, reportCount: 3 }), card(2, { reportCount: 1 }), card(3)]);
|
||||
const badges = tiles().map((t) => [...t.querySelectorAll('.gallery-badge')].map((b) => b.textContent));
|
||||
expect(badges).toEqual([['Ausgeblendet', '3 Meldungen'], ['1 Meldung'], []]);
|
||||
|
||||
grid.dispose();
|
||||
host.replaceChildren();
|
||||
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
|
||||
grid.append([card(1, { hidden: true, reportCount: 3 })]);
|
||||
expect(host.querySelector('.gallery-badge')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mountGalleryGrid: Vorschaubilder', () => {
|
||||
it('lädt die Vorschau jeder Karte und zeigt sie per Object-URL', async () => {
|
||||
const load = vi.fn(async () => image());
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||
const cards = [card(1), card(2)];
|
||||
grid.append(cards);
|
||||
await flush();
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(load.mock.calls.map((c) => (c as unknown as [GalleryCard])[0].id)).toEqual(['g1', 'g2']);
|
||||
const srcs = [...host.querySelectorAll<HTMLImageElement>('.gallery-thumb img')].map((i) => i.getAttribute('src'));
|
||||
expect(srcs).toEqual(['blob:t-1', 'blob:t-2']);
|
||||
// Vorschau 480 breit, Höhe nach 1536 : 969 (wie exportCard sie rendert)
|
||||
const first = host.querySelector('.gallery-thumb img');
|
||||
expect([first?.getAttribute('width'), first?.getAttribute('height')]).toEqual(['480', '303']);
|
||||
});
|
||||
|
||||
it('gibt alle Object-URLs beim Aufräumen frei', async () => {
|
||||
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
|
||||
grid.append([card(1), card(2), card(3)]);
|
||||
await flush();
|
||||
expect(counter).toBe(3);
|
||||
expect(revoked).toEqual([]);
|
||||
grid.dispose();
|
||||
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2', 'blob:t-3']);
|
||||
});
|
||||
|
||||
it('eine nach dem Aufräumen eintreffende URL wird sofort freigegeben (späte Antwort), neue Ladungen starten nicht mehr', async () => {
|
||||
const resolvers: Array<(i: CheckedImage) => void> = [];
|
||||
const load = vi.fn(() => new Promise<CheckedImage>((r) => resolvers.push(r)));
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||
grid.append([card(1), card(2)]);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
grid.dispose();
|
||||
for (const resolve of resolvers) resolve(image());
|
||||
await flush();
|
||||
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2']);
|
||||
expect(host.querySelector('img')).toBeNull();
|
||||
grid.append([card(3)]);
|
||||
expect(load).toHaveBeenCalledTimes(2);
|
||||
expect(tiles()).toHaveLength(2); // die Kachel nach dem Aufräumen kommt nicht mehr dazu
|
||||
});
|
||||
|
||||
it('lädt höchstens sechs Vorschauen gleichzeitig', async () => {
|
||||
const pending: Array<(i: CheckedImage) => void> = [];
|
||||
const load = vi.fn(() => new Promise<CheckedImage>((r) => pending.push(r)));
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||
grid.append(Array.from({ length: 10 }, (_, i) => card(i + 1)));
|
||||
expect(load).toHaveBeenCalledTimes(6);
|
||||
pending[0](image());
|
||||
await flush();
|
||||
expect(load).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('eine fehlgeschlagene Vorschau zeigt „Keine Vorschau“ und hält die anderen nicht auf', async () => {
|
||||
const load = vi.fn(async (c: GalleryCard) => {
|
||||
if (c.id === 'g1') throw new Error('404');
|
||||
return image();
|
||||
});
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||
grid.append([card(1), card(2)]);
|
||||
await flush();
|
||||
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
|
||||
expect(tiles()[1].querySelector('.gallery-thumb img')).not.toBeNull();
|
||||
expect(tiles()[0].querySelector('.gallery-thumb')?.hasAttribute('aria-busy')).toBe(false);
|
||||
});
|
||||
|
||||
it('eine abgelehnte Vorschau (zu viele Pixel, nicht dekodierbar) zeigt „Keine Vorschau“, nie ein defektes Bild', async () => {
|
||||
const load = vi.fn(async () => {
|
||||
throw new Error('Das Bild konnte nicht geladen werden.'); // so lehnt checkGalleryImage ab
|
||||
});
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||
grid.append([card(1)]);
|
||||
await flush();
|
||||
expect(host.querySelector('.gallery-thumb img')).toBeNull();
|
||||
expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
|
||||
});
|
||||
|
||||
it('zeigt das Bild nur, wenn es im Browser wirklich lädt: Ladefehler des <img> ersetzt es durch „Keine Vorschau“ und gibt die URL frei', async () => {
|
||||
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
|
||||
grid.append([card(1)]);
|
||||
await flush();
|
||||
host.querySelector('.gallery-thumb img')?.dispatchEvent(new Event('error'));
|
||||
expect(host.querySelector('.gallery-thumb img')).toBeNull();
|
||||
expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
|
||||
expect(revoked).toEqual(['blob:t-1']);
|
||||
grid.dispose();
|
||||
expect(revoked).toEqual(['blob:t-1']); // nicht doppelt freigeben
|
||||
});
|
||||
});
|
||||
|
||||
describe('mountGalleryGrid auf Englisch', () => {
|
||||
it('Tag-Titel, „No preview“ und die Status-Marken der Admin-Ansicht; Nutzerinhalte bleiben unverändert', async () => {
|
||||
setLang('en', { persist: false });
|
||||
const load = vi.fn(async (c: GalleryCard) => {
|
||||
if (c.id === 'g1') throw new Error('404');
|
||||
return image();
|
||||
});
|
||||
grid = mountGalleryGrid(host, { load, onTag: () => {}, showStatus: true });
|
||||
grid.append([card(1, { hidden: true, reportCount: 3 }), card(2, { reportCount: 1 })]);
|
||||
await flush();
|
||||
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('No preview');
|
||||
expect(tiles()[0].querySelector('.gallery-tag')?.getAttribute('title')).toBe('Show cards with this tag');
|
||||
const badges = tiles().map((t) => [...t.querySelectorAll('.gallery-badge')].map((b) => b.textContent));
|
||||
expect(badges).toEqual([['Hidden', '3 reports'], ['1 report']]);
|
||||
expect(tiles()[0].querySelector('.gallery-title')?.textContent).toBe('Karte 1');
|
||||
expect(tiles()[0].querySelector('.gallery-issuer')?.textContent).toBe('Beispielbank');
|
||||
expect(germanLeft(host)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags.
|
||||
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket, mit Pixelgrenze geprüft: loadGalleryImageChecked)
|
||||
// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML; übersetzt wird beim Aufbau.
|
||||
import { CARD_H, CARD_W, PREVIEW_WIDTH } from '../card/geometry';
|
||||
import { el } from '../editor/dom';
|
||||
import { t } from '../i18n/i18n';
|
||||
import { reportsLabel } from '../pages/format';
|
||||
import type { CheckedImage, GalleryCard } from './gallery';
|
||||
|
||||
export interface GalleryGridOptions {
|
||||
/** Lädt und prüft die Vorschau einer Karte; wirft, wenn sie sich nicht anzeigen lässt („Keine Vorschau“). */
|
||||
load: (card: GalleryCard) => Promise<CheckedImage>;
|
||||
/** Ein Tag-Knopf wurde angeklickt. */
|
||||
onTag: (tag: string) => void;
|
||||
/** Admin-Ansicht: „Ausgeblendet“ und die Zahl der Meldungen an der Kachel zeigen (für alle anderen bleibt das verborgen). */
|
||||
showStatus?: boolean;
|
||||
}
|
||||
|
||||
export interface GalleryGrid {
|
||||
/** Hängt Kacheln hinten an. */
|
||||
append(cards: readonly GalleryCard[]): void;
|
||||
/** Gibt alle Object-URLs frei und beendet laufende Ladungen (späte Antworten werden verworfen). */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const PARALLEL = 6;
|
||||
const MAX_TAGS = 3;
|
||||
const THUMB_W = PREVIEW_WIDTH;
|
||||
const THUMB_H = Math.round((THUMB_W * CARD_H) / CARD_W); // wie exportCard: 480 x 1536:969 = 303
|
||||
|
||||
export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOptions): GalleryGrid {
|
||||
const list = el('ul', { class: 'gallery-grid' });
|
||||
container.replaceChildren(list);
|
||||
|
||||
let disposed = false;
|
||||
const urls = new Set<string>();
|
||||
const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = [];
|
||||
let active = 0;
|
||||
|
||||
const showEmpty = (thumb: HTMLElement) =>
|
||||
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, t('Keine Vorschau')));
|
||||
|
||||
async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise<void> {
|
||||
try {
|
||||
const { url } = await opts.load(card);
|
||||
if (disposed) {
|
||||
URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei
|
||||
return;
|
||||
}
|
||||
urls.add(url);
|
||||
const img = el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' });
|
||||
// Die Datei kam an und wurde geprüft, lässt sich aber nicht darstellen: lieber der Hinweis als ein defektes Bild.
|
||||
img.addEventListener('error', () => {
|
||||
if (disposed) return;
|
||||
urls.delete(url);
|
||||
URL.revokeObjectURL(url);
|
||||
showEmpty(thumb);
|
||||
});
|
||||
thumb.replaceChildren(img);
|
||||
} catch {
|
||||
if (!disposed) showEmpty(thumb);
|
||||
} finally {
|
||||
thumb.removeAttribute('aria-busy');
|
||||
}
|
||||
}
|
||||
|
||||
function pump(): void {
|
||||
while (!disposed && active < PARALLEL && queue.length > 0) {
|
||||
const job = queue.shift() as { card: GalleryCard; thumb: HTMLElement };
|
||||
active += 1;
|
||||
void loadThumb(job.card, job.thumb).finally(() => {
|
||||
active -= 1;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildTile(card: GalleryCard): HTMLElement {
|
||||
const thumb = el('span', { class: 'gallery-thumb', 'aria-busy': 'true' });
|
||||
const link = el(
|
||||
'a',
|
||||
{ class: 'gallery-tile-link', href: `/community/${encodeURIComponent(card.id)}`, 'data-link': true },
|
||||
thumb,
|
||||
el('h3', { class: 'gallery-title' }, card.title),
|
||||
);
|
||||
const tile = el('li', { class: 'gallery-tile' }, link);
|
||||
if (card.issuer) tile.append(el('p', { class: 'gallery-issuer' }, card.issuer));
|
||||
|
||||
const tags = card.tags.slice(0, MAX_TAGS);
|
||||
if (tags.length > 0) {
|
||||
const row = el('div', { class: 'gallery-tags' });
|
||||
for (const tag of tags) {
|
||||
const button = el('button', { type: 'button', class: 'gallery-tag', title: t('Karten mit diesem Tag zeigen') }, tag);
|
||||
button.addEventListener('click', () => opts.onTag(tag));
|
||||
row.append(button);
|
||||
}
|
||||
tile.append(row);
|
||||
}
|
||||
|
||||
if (opts.showStatus && (card.hidden || card.reportCount > 0)) {
|
||||
const badges = el('div', { class: 'gallery-badges' });
|
||||
if (card.hidden) badges.append(el('span', { class: 'gallery-badge gallery-badge-hidden' }, t('Ausgeblendet')));
|
||||
if (card.reportCount > 0) badges.append(el('span', { class: 'gallery-badge' }, reportsLabel(card.reportCount)));
|
||||
tile.append(badges);
|
||||
}
|
||||
|
||||
queue.push({ card, thumb });
|
||||
return tile;
|
||||
}
|
||||
|
||||
return {
|
||||
append(cards) {
|
||||
if (disposed) return;
|
||||
list.append(...cards.map((card) => buildTile(card)));
|
||||
pump();
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
queue.length = 0;
|
||||
for (const url of urls) URL.revokeObjectURL(url);
|
||||
urls.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import { AppwriteException, Query } from 'appwrite';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listRows: vi.fn(),
|
||||
getRow: vi.fn(),
|
||||
fetchFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../account/appwrite', () => ({
|
||||
IDS: {
|
||||
database: 'db',
|
||||
table: 'cards-table',
|
||||
bucket: 'cards-bucket',
|
||||
gallery: 'gallery-table',
|
||||
reports: 'reports-table',
|
||||
galleryBucket: 'gallery-bucket',
|
||||
},
|
||||
account: {},
|
||||
client: {},
|
||||
storage: {},
|
||||
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
|
||||
tables: { listRows: mocks.listRows, getRow: mocks.getRow },
|
||||
}));
|
||||
|
||||
// ID_PATTERN & Co. bleiben echt; nur das Laden der Datei geht nicht ins Netz.
|
||||
vi.mock('../account/cards', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../account/cards')>()),
|
||||
fetchFile: mocks.fetchFile,
|
||||
}));
|
||||
|
||||
import {
|
||||
MAX_GALLERY_PIXELS,
|
||||
PAGE_SIZE,
|
||||
checkGalleryImage,
|
||||
galleryQueries,
|
||||
getGalleryCard,
|
||||
listGallery,
|
||||
listReports,
|
||||
loadGalleryImage,
|
||||
loadGalleryImageChecked,
|
||||
myPublications,
|
||||
toGalleryCard,
|
||||
type GalleryCard,
|
||||
} from './gallery';
|
||||
|
||||
const row = (n: number, patch: Record<string, unknown> = {}) => ({
|
||||
$id: `g${n}`,
|
||||
$createdAt: `2026-10-0${(n % 9) + 1}T10:00:00.000Z`,
|
||||
title: `Karte ${n}`,
|
||||
issuer: 'Beispielbank',
|
||||
tags: 'visa,gold',
|
||||
displayName: 'Kenso',
|
||||
imageFileId: `img${n}`,
|
||||
previewFileId: `pre${n}`,
|
||||
sourceCardId: `src${n}`,
|
||||
ownerId: 'u1',
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
searchText: 'karte beispielbank visa gold',
|
||||
...patch,
|
||||
});
|
||||
|
||||
const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type });
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.listRows.mockReset();
|
||||
mocks.getRow.mockReset();
|
||||
mocks.fetchFile.mockReset();
|
||||
});
|
||||
|
||||
describe('galleryQueries', () => {
|
||||
it('öffentlich: nur sichtbare Karten, neueste zuerst, 24 pro Seite', () => {
|
||||
expect(galleryQueries({})).toEqual([
|
||||
Query.equal('hidden', false),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
]);
|
||||
});
|
||||
|
||||
it('die Abfragen sind genau die Appwrite-JSON-Strings', () => {
|
||||
expect(galleryQueries({})).toEqual([
|
||||
JSON.stringify({ method: 'equal', attribute: 'hidden', values: [false] }),
|
||||
JSON.stringify({ method: 'orderDesc', attribute: '$createdAt' }),
|
||||
JSON.stringify({ method: 'limit', values: [24] }),
|
||||
]);
|
||||
expect(PAGE_SIZE).toBe(24);
|
||||
});
|
||||
|
||||
it('mit Cursor: cursorAfter(letzte ID)', () => {
|
||||
expect(galleryQueries({ cursor: 'g24' })).toEqual([
|
||||
Query.equal('hidden', false),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.cursorAfter('g24'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('mit Suche: Volltext auf searchText, Leerraum zusammengefasst', () => {
|
||||
expect(galleryQueries({ q: ' Visa Gold ' })).toEqual([
|
||||
Query.equal('hidden', false),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.search('searchText', 'Visa Gold'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('Suche mit höchstens 2 Zeichen: Teilstring (contains, klein) statt Volltext, der so kurze Wörter nicht findet', () => {
|
||||
expect(galleryQueries({ q: 'Vi' })).toEqual([
|
||||
Query.equal('hidden', false),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.contains('searchText', 'vi'),
|
||||
]);
|
||||
expect(galleryQueries({ q: ' A ' })).toContain(Query.contains('searchText', 'a'));
|
||||
});
|
||||
|
||||
it('ab 3 Zeichen Volltext (die Grenze liegt bei 3)', () => {
|
||||
expect(galleryQueries({ q: 'ab' })).toContain(Query.contains('searchText', 'ab'));
|
||||
expect(galleryQueries({ q: 'abc' })).toContain(Query.search('searchText', 'abc'));
|
||||
expect(galleryQueries({ q: 'abc' }).join('')).not.toContain('contains');
|
||||
expect(galleryQueries({ q: 'ä ö' })).toContain(Query.search('searchText', 'ä ö'));
|
||||
});
|
||||
|
||||
it('Tag: exakter Treffer auf ",tag," in tags (der Server speichert ",a,b,"), getrimmt und klein; nur sichtbare Karten', () => {
|
||||
expect(galleryQueries({ tag: ' Visa ' })).toEqual([
|
||||
Query.equal('hidden', false),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.contains('tags', ',visa,'),
|
||||
]);
|
||||
expect(JSON.stringify(Query.contains('tags', ',visa,'))).toContain('contains');
|
||||
});
|
||||
|
||||
it('Tag: "gold" trifft nicht "marigold" (Trennzeichen an beiden Seiten)', () => {
|
||||
const [, , , tagQuery] = galleryQueries({ tag: 'gold' });
|
||||
expect(JSON.parse(tagQuery)).toEqual({ method: 'contains', attribute: 'tags', values: [',gold,'] });
|
||||
const stored = (tags: string) => tags.includes(JSON.parse(tagQuery).values[0] as string);
|
||||
expect(stored(',visa,gold,')).toBe(true);
|
||||
expect(stored(',gold,')).toBe(true);
|
||||
expect(stored(',marigold,')).toBe(false);
|
||||
expect(stored(',goldig,')).toBe(false);
|
||||
});
|
||||
|
||||
it('ein leerer oder nur aus Leerraum bestehender Tag wird ignoriert', () => {
|
||||
expect(galleryQueries({ tag: '' })).toEqual(galleryQueries({}));
|
||||
expect(galleryQueries({ tag: ' ' })).toEqual(galleryQueries({}));
|
||||
});
|
||||
|
||||
it('Tag, Suche und Cursor zusammen: immer mit hidden = false', () => {
|
||||
expect(galleryQueries({ tag: 'Gold', q: 'visa', cursor: 'g24' })).toEqual([
|
||||
Query.equal('hidden', false),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.cursorAfter('g24'),
|
||||
Query.contains('tags', ',gold,'),
|
||||
Query.search('searchText', 'visa'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('Suche und Cursor zusammen', () => {
|
||||
expect(galleryQueries({ q: 'visa', cursor: 'g24' })).toEqual([
|
||||
Query.equal('hidden', false),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.cursorAfter('g24'),
|
||||
Query.search('searchText', 'visa'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('eine leere oder nur aus Leerraum bestehende Suche wird ignoriert', () => {
|
||||
expect(galleryQueries({ q: '' })).toEqual(galleryQueries({}));
|
||||
expect(galleryQueries({ q: ' \t ' })).toEqual(galleryQueries({}));
|
||||
});
|
||||
|
||||
it("Admin-Filter 'all' ist die öffentliche Ansicht", () => {
|
||||
expect(galleryQueries({ filter: 'all' })).toEqual(galleryQueries({}));
|
||||
});
|
||||
|
||||
it("Admin-Filter 'reported': reportCount > 0, ohne hidden-Bedingung (gemeldet und schon ausgeblendet)", () => {
|
||||
const queries = galleryQueries({ filter: 'reported' });
|
||||
expect(queries).toEqual([Query.greaterThan('reportCount', 0), Query.orderDesc('$createdAt'), Query.limit(24)]);
|
||||
expect(queries.join('')).not.toContain('hidden');
|
||||
});
|
||||
|
||||
it("Admin-Filter 'hidden': nur ausgeblendete Karten", () => {
|
||||
expect(galleryQueries({ filter: 'hidden' })).toEqual([
|
||||
Query.equal('hidden', true),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
]);
|
||||
});
|
||||
|
||||
it('Tag und kurze Suche mit Admin-Filter', () => {
|
||||
expect(galleryQueries({ filter: 'hidden', tag: 'visa', q: 'x' })).toEqual([
|
||||
Query.equal('hidden', true),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.contains('tags', ',visa,'),
|
||||
Query.contains('searchText', 'x'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('Admin-Filter lassen sich mit Suche und Cursor kombinieren', () => {
|
||||
expect(galleryQueries({ filter: 'reported', q: 'visa', cursor: 'g5' })).toEqual([
|
||||
Query.greaterThan('reportCount', 0),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.cursorAfter('g5'),
|
||||
Query.search('searchText', 'visa'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toGalleryCard', () => {
|
||||
it('übernimmt alle Felder; Tags werden zur Liste', () => {
|
||||
expect(toGalleryCard(row(1, { hidden: true, reportCount: 3 }))).toEqual({
|
||||
id: 'g1',
|
||||
title: 'Karte 1',
|
||||
issuer: 'Beispielbank',
|
||||
tags: ['visa', 'gold'],
|
||||
displayName: 'Kenso',
|
||||
imageFileId: 'img1',
|
||||
previewFileId: 'pre1',
|
||||
sourceCardId: 'src1',
|
||||
ownerId: 'u1',
|
||||
hidden: true,
|
||||
reportCount: 3,
|
||||
createdAt: row(1).$createdAt,
|
||||
} satisfies GalleryCard);
|
||||
});
|
||||
|
||||
it('fehlende Felder bekommen leere Standardwerte', () => {
|
||||
expect(toGalleryCard({ $id: 'g9' })).toEqual({
|
||||
id: 'g9',
|
||||
title: '',
|
||||
issuer: '',
|
||||
tags: [],
|
||||
displayName: '',
|
||||
imageFileId: '',
|
||||
previewFileId: '',
|
||||
sourceCardId: '',
|
||||
ownerId: '',
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
createdAt: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('Felder mit falschem Typ gelten als fehlend', () => {
|
||||
const card = toGalleryCard({
|
||||
$id: 'g9',
|
||||
title: 42,
|
||||
tags: ['x'],
|
||||
hidden: 'ja',
|
||||
reportCount: 'viele',
|
||||
displayName: null,
|
||||
$createdAt: 5,
|
||||
});
|
||||
expect(card).toMatchObject({ title: '', tags: [], hidden: false, reportCount: 0, displayName: '', createdAt: '' });
|
||||
});
|
||||
|
||||
it('Tags: getrimmt, Leere raus', () => {
|
||||
expect(toGalleryCard(row(1, { tags: ' visa , ,gold,, ' })).tags).toEqual(['visa', 'gold']);
|
||||
expect(toGalleryCard(row(1, { tags: '' })).tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('Tags im Speicherformat ",a,b," (Komma an beiden Enden) werden zur sauberen Liste', () => {
|
||||
expect(toGalleryCard(row(1, { tags: ',visa,gold,' })).tags).toEqual(['visa', 'gold']);
|
||||
expect(toGalleryCard(row(1, { tags: ',blau,' })).tags).toEqual(['blau']);
|
||||
expect(toGalleryCard(row(1, { tags: ',' })).tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('reportCount: nur endliche, nicht negative Zahlen', () => {
|
||||
expect(toGalleryCard(row(1, { reportCount: Number.NaN })).reportCount).toBe(0);
|
||||
expect(toGalleryCard(row(1, { reportCount: -2 })).reportCount).toBe(0);
|
||||
expect(toGalleryCard(row(1, { reportCount: 2 })).reportCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listGallery', () => {
|
||||
it('fragt die Galerie-Tabelle mit den Abfragen ab und liefert Karten', async () => {
|
||||
mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1), row(2)] });
|
||||
const page = await listGallery({ q: 'visa' });
|
||||
expect(mocks.listRows).toHaveBeenCalledOnce();
|
||||
expect(mocks.listRows.mock.calls[0][0]).toEqual({
|
||||
databaseId: 'db',
|
||||
tableId: 'gallery-table',
|
||||
queries: galleryQueries({ q: 'visa' }),
|
||||
});
|
||||
expect(page.cards.map((c) => c.id)).toEqual(['g1', 'g2']);
|
||||
expect(page.cards[0].tags).toEqual(['visa', 'gold']);
|
||||
});
|
||||
|
||||
it('weniger als 24 Zeilen: keine weitere Seite (nextCursor null)', async () => {
|
||||
mocks.listRows.mockResolvedValue({ total: 23, rows: Array.from({ length: 23 }, (_, i) => row(i + 1)) });
|
||||
expect((await listGallery({})).nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it('keine Zeilen: leere Liste, nextCursor null', async () => {
|
||||
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
|
||||
await expect(listGallery({})).resolves.toEqual({ cards: [], nextCursor: null });
|
||||
});
|
||||
|
||||
it('genau 24 Zeilen: nextCursor ist die ID der letzten Karte', async () => {
|
||||
mocks.listRows.mockResolvedValue({ total: 60, rows: Array.from({ length: 24 }, (_, i) => row(i + 1)) });
|
||||
const page = await listGallery({});
|
||||
expect(page.cards).toHaveLength(24);
|
||||
expect(page.nextCursor).toBe('g24');
|
||||
});
|
||||
|
||||
it('Tag und kurze Suche gehen an die Abfrage', async () => {
|
||||
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
|
||||
await listGallery({ tag: 'Visa', q: 'ab' });
|
||||
expect(mocks.listRows.mock.calls[0][0].queries).toEqual([
|
||||
Query.equal('hidden', false),
|
||||
Query.orderDesc('$createdAt'),
|
||||
Query.limit(24),
|
||||
Query.contains('tags', ',visa,'),
|
||||
Query.contains('searchText', 'ab'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('die nächste Seite schickt den Cursor mit', async () => {
|
||||
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
|
||||
await listGallery({ cursor: 'g24', filter: 'hidden' });
|
||||
expect(mocks.listRows.mock.calls[0][0].queries).toEqual(galleryQueries({ cursor: 'g24', filter: 'hidden' }));
|
||||
});
|
||||
|
||||
it('Fehler gehen durch', async () => {
|
||||
const failure = Object.assign(new Error('down'), { code: 503 });
|
||||
mocks.listRows.mockRejectedValue(failure);
|
||||
await expect(listGallery({})).rejects.toBe(failure);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGalleryCard', () => {
|
||||
it('liefert die Karte', async () => {
|
||||
mocks.getRow.mockResolvedValue(row(7));
|
||||
const card = await getGalleryCard('g7');
|
||||
expect(card?.id).toBe('g7');
|
||||
expect(mocks.getRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'gallery-table', rowId: 'g7' });
|
||||
});
|
||||
|
||||
it('404 (nicht vorhanden oder ausgeblendet für mich) ergibt null', async () => {
|
||||
mocks.getRow.mockRejectedValue(notFound());
|
||||
await expect(getGalleryCard('g7')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('404 ohne Typ (z. B. Proxy) ergibt ebenfalls null', async () => {
|
||||
mocks.getRow.mockRejectedValue(Object.assign(new Error('not found'), { code: 404 }));
|
||||
await expect(getGalleryCard('g7')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('eine fehlende Tabelle (404 table_not_found) ist ein Konfigurationsfehler, kein „Karte weg“', async () => {
|
||||
const failure = notFound('table_not_found');
|
||||
mocks.getRow.mockRejectedValue(failure);
|
||||
await expect(getGalleryCard('g7')).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it.each([500, 503, 401, 429])('Fehler %i geht durch', async (code) => {
|
||||
const failure = Object.assign(new Error('x'), { code, type: 'general_server_error' });
|
||||
mocks.getRow.mockRejectedValue(failure);
|
||||
await expect(getGalleryCard('g7')).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it('Netzfehler gehen durch', async () => {
|
||||
const failure = new TypeError('Failed to fetch');
|
||||
mocks.getRow.mockRejectedValue(failure);
|
||||
await expect(getGalleryCard('g7')).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it.each(['', '../x', 'a/b', 'a?b', '.hidden', '-x', 'x'.repeat(37), 'ä'])(
|
||||
'ungültige ID %j: null, ohne Anfrage',
|
||||
async (id) => {
|
||||
await expect(getGalleryCard(id)).resolves.toBeNull();
|
||||
expect(mocks.getRow).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('myPublications', () => {
|
||||
it('liest die Zeilen des Nutzers (höchstens 100) und ordnet sie nach sourceCardId', async () => {
|
||||
mocks.listRows.mockResolvedValue({
|
||||
total: 2,
|
||||
rows: [row(1, { sourceCardId: 'cardA' }), row(2, { sourceCardId: 'cardB', hidden: true, reportCount: 3 })],
|
||||
});
|
||||
const map = await myPublications('u1');
|
||||
expect(mocks.listRows).toHaveBeenCalledOnce();
|
||||
expect(mocks.listRows.mock.calls[0][0]).toEqual({
|
||||
databaseId: 'db',
|
||||
tableId: 'gallery-table',
|
||||
queries: [Query.equal('ownerId', 'u1'), Query.limit(100)],
|
||||
});
|
||||
expect(map).toBeInstanceOf(Map);
|
||||
expect([...map.keys()]).toEqual(['cardA', 'cardB']);
|
||||
expect(map.get('cardA')?.id).toBe('g1');
|
||||
expect(map.get('cardB')).toMatchObject({ id: 'g2', hidden: true, reportCount: 3 });
|
||||
});
|
||||
|
||||
it('ohne Veröffentlichungen: leere Map', async () => {
|
||||
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
|
||||
expect((await myPublications('u1')).size).toBe(0);
|
||||
});
|
||||
|
||||
it('Zeilen ohne sourceCardId (Altdaten) werden übersprungen', async () => {
|
||||
mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1, { sourceCardId: '' }), row(2, { sourceCardId: 'cardB' })] });
|
||||
expect([...(await myPublications('u1')).keys()]).toEqual(['cardB']);
|
||||
});
|
||||
|
||||
it('ohne Nutzer-ID: leere Map, ohne Anfrage', async () => {
|
||||
expect((await myPublications('')).size).toBe(0);
|
||||
expect(mocks.listRows).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listReports', () => {
|
||||
it('liest die Meldungen einer Karte (höchstens 100), neueste zuerst', async () => {
|
||||
mocks.listRows.mockResolvedValue({
|
||||
total: 2,
|
||||
rows: [
|
||||
{ $id: 'r2', $createdAt: '2026-10-04T11:00:00.000Z', galleryId: 'g1', reason: 'spam', reporterHash: 'abc' },
|
||||
{ $id: 'r1', $createdAt: '2026-10-04T10:00:00.000Z', galleryId: 'g1', reason: 'urheberrecht', reporterHash: 'def' },
|
||||
],
|
||||
});
|
||||
const reports = await listReports('g1');
|
||||
expect(mocks.listRows.mock.calls[0][0]).toEqual({
|
||||
databaseId: 'db',
|
||||
tableId: 'reports-table',
|
||||
queries: [Query.equal('galleryId', 'g1'), Query.orderDesc('$createdAt'), Query.limit(100)],
|
||||
});
|
||||
expect(reports).toEqual([
|
||||
{ id: 'r2', galleryId: 'g1', reason: 'spam', createdAt: '2026-10-04T11:00:00.000Z' },
|
||||
{ id: 'r1', galleryId: 'g1', reason: 'urheberrecht', createdAt: '2026-10-04T10:00:00.000Z' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('der Melder-Hash wird nicht weitergereicht', async () => {
|
||||
mocks.listRows.mockResolvedValue({ total: 1, rows: [{ $id: 'r1', galleryId: 'g1', reason: 'spam', reporterHash: 'abc' }] });
|
||||
expect(JSON.stringify(await listReports('g1'))).not.toContain('abc');
|
||||
});
|
||||
|
||||
it('ungültige ID: leere Liste, ohne Anfrage', async () => {
|
||||
await expect(listReports('../x')).resolves.toEqual([]);
|
||||
expect(mocks.listRows).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadGalleryImage', () => {
|
||||
const card = toGalleryCard(row(3));
|
||||
const blob = new Blob(['x'], { type: 'image/png' });
|
||||
|
||||
it("'full' lädt das Bild aus dem Galerie-Bucket", async () => {
|
||||
mocks.fetchFile.mockResolvedValue(blob);
|
||||
await expect(loadGalleryImage(card, 'full')).resolves.toBe(blob);
|
||||
expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'img3');
|
||||
});
|
||||
|
||||
it("'preview' lädt die Vorschau aus dem Galerie-Bucket", async () => {
|
||||
mocks.fetchFile.mockResolvedValue(blob);
|
||||
await loadGalleryImage(card, 'preview');
|
||||
expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'pre3');
|
||||
});
|
||||
|
||||
it.each(['', '../x', 'a/b', 'a?b', '.hidden', 'x'.repeat(37)])(
|
||||
'eine Datei-ID, die keine Appwrite-ID sein kann (%j): 404 „Datei fehlt“, ohne Anfrage',
|
||||
async (fileId) => {
|
||||
for (const size of ['full', 'preview'] as const) {
|
||||
const err = await loadGalleryImage({ ...card, imageFileId: fileId, previewFileId: fileId }, size).catch(
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(err).toBeInstanceOf(AppwriteException);
|
||||
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' });
|
||||
}
|
||||
expect(mocks.fetchFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('checkGalleryImage (Pixelgrenze beim Anzeigen)', () => {
|
||||
interface Behaviour {
|
||||
load: 'ok' | 'fail';
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
let behaviour: Behaviour;
|
||||
let created: FakeImage[];
|
||||
let urlCounter: number;
|
||||
let revoked: string[];
|
||||
|
||||
class FakeImage {
|
||||
naturalWidth = 0;
|
||||
naturalHeight = 0;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
#src = '';
|
||||
|
||||
constructor() {
|
||||
created.push(this);
|
||||
}
|
||||
|
||||
set src(value: string) {
|
||||
this.#src = value;
|
||||
queueMicrotask(() => {
|
||||
if (behaviour.load === 'ok') {
|
||||
this.naturalWidth = behaviour.width;
|
||||
this.naturalHeight = behaviour.height;
|
||||
this.onload?.();
|
||||
} else {
|
||||
this.onerror?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get src(): string {
|
||||
return this.#src;
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob(['x'], { type: 'image/png' });
|
||||
|
||||
beforeEach(() => {
|
||||
behaviour = { load: 'ok', width: 1536, height: 969 };
|
||||
created = [];
|
||||
urlCounter = 0;
|
||||
revoked = [];
|
||||
vi.stubGlobal('Image', FakeImage);
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
Object.assign(URL, {
|
||||
createObjectURL: vi.fn(() => `blob:check-${++urlCounter}`),
|
||||
revokeObjectURL: vi.fn((url: string) => {
|
||||
revoked.push(url);
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('Grenze: 4 Megapixel (die Karte hat 1536 x 969, rund 1,5)', () => {
|
||||
expect(MAX_GALLERY_PIXELS).toBe(4_000_000);
|
||||
});
|
||||
|
||||
it('eine Karte in Normalgröße: liefert Blob und fertige Object-URL, die nicht freigegeben wird (der Aufrufer macht das)', async () => {
|
||||
const image = await checkGalleryImage(blob);
|
||||
expect(image).toEqual({ blob, url: 'blob:check-1' });
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0].src).toBe('blob:check-1');
|
||||
expect(revoked).toEqual([]);
|
||||
});
|
||||
|
||||
it('genau 4 000 000 Pixel gehen noch durch', async () => {
|
||||
behaviour = { ...behaviour, width: 2000, height: 2000 };
|
||||
await expect(checkGalleryImage(blob)).resolves.toMatchObject({ url: 'blob:check-1' });
|
||||
});
|
||||
|
||||
it('mehr als 4 000 000 Pixel: abgelehnt, die URL wird freigegeben (Dekompressionsbombe)', async () => {
|
||||
behaviour = { ...behaviour, width: 2000, height: 2001 };
|
||||
await expect(checkGalleryImage(blob)).rejects.toThrow();
|
||||
expect(revoked).toEqual(['blob:check-1']);
|
||||
});
|
||||
|
||||
it('riesige Maße (60 000 x 60 000): abgelehnt, ohne dass das Bild je angezeigt würde', async () => {
|
||||
behaviour = { ...behaviour, width: 60_000, height: 60_000 };
|
||||
await expect(checkGalleryImage(blob)).rejects.toThrow();
|
||||
expect(revoked).toEqual(['blob:check-1']);
|
||||
});
|
||||
|
||||
it('nicht dekodierbar (Ladefehler): abgelehnt, URL freigegeben', async () => {
|
||||
behaviour = { ...behaviour, load: 'fail' };
|
||||
await expect(checkGalleryImage(blob)).rejects.toThrow();
|
||||
expect(revoked).toEqual(['blob:check-1']);
|
||||
});
|
||||
|
||||
it('Maße 0 (kein echtes Bild): abgelehnt, URL freigegeben', async () => {
|
||||
behaviour = { ...behaviour, width: 0, height: 0 };
|
||||
await expect(checkGalleryImage(blob)).rejects.toThrow();
|
||||
expect(revoked).toEqual(['blob:check-1']);
|
||||
});
|
||||
|
||||
it('loadGalleryImageChecked lädt die Datei und prüft sie; Vorschau und Vollbild nutzen dieselbe Prüfung', async () => {
|
||||
const card = toGalleryCard(row(3));
|
||||
mocks.fetchFile.mockResolvedValue(blob);
|
||||
await expect(loadGalleryImageChecked(card, 'preview')).resolves.toEqual({ blob, url: 'blob:check-1' });
|
||||
expect(mocks.fetchFile).toHaveBeenLastCalledWith('gallery-bucket', 'pre3');
|
||||
await expect(loadGalleryImageChecked(card, 'full')).resolves.toEqual({ blob, url: 'blob:check-2' });
|
||||
expect(mocks.fetchFile).toHaveBeenLastCalledWith('gallery-bucket', 'img3');
|
||||
|
||||
behaviour = { ...behaviour, width: 8000, height: 8000 };
|
||||
await expect(loadGalleryImageChecked(card, 'full')).rejects.toThrow();
|
||||
expect(revoked).toEqual(['blob:check-3']);
|
||||
});
|
||||
|
||||
it('ein Fehler beim Laden der Datei geht durch, ohne dass eine URL angelegt wird', async () => {
|
||||
const card = toGalleryCard(row(3));
|
||||
mocks.fetchFile.mockRejectedValue(notFound('storage_file_not_found'));
|
||||
await expect(loadGalleryImageChecked(card, 'full')).rejects.toMatchObject({ code: 404 });
|
||||
expect(urlCounter).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
// Die öffentliche Galerie lesen: Tabelle `gallery` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen).
|
||||
// Schreiben (Veröffentlichen, Zurückziehen, Melden, Ausblenden) läuft über den API-Dienst, siehe api.ts.
|
||||
import { AppwriteException, Query, type Models } from 'appwrite';
|
||||
import { ID_PATTERN, fetchFile } from '../account/cards';
|
||||
import { IDS, tables } from '../account/appwrite';
|
||||
import { isRowMissing } from '../account/errors';
|
||||
import { msg } from '../i18n/i18n';
|
||||
|
||||
export interface GalleryCard {
|
||||
id: string;
|
||||
title: string;
|
||||
issuer: string;
|
||||
tags: string[];
|
||||
displayName: string;
|
||||
imageFileId: string;
|
||||
previewFileId: string;
|
||||
sourceCardId: string;
|
||||
ownerId: string;
|
||||
hidden: boolean;
|
||||
reportCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface GalleryReport {
|
||||
id: string;
|
||||
galleryId: string;
|
||||
reason: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Ansicht der Galerie: öffentlich `all`; `reported`/`hidden` zeigen nur Admins etwas (die Rechte prüft der Server). */
|
||||
export type GalleryFilter = 'all' | 'reported' | 'hidden';
|
||||
|
||||
export interface GalleryOptions {
|
||||
/** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */
|
||||
q?: string;
|
||||
/** Nur Karten mit genau diesem Tag (klein; der Server speichert `tags` als `,a,b,`, gesucht wird `,<tag>,`). */
|
||||
tag?: string;
|
||||
cursor?: string;
|
||||
filter?: GalleryFilter;
|
||||
}
|
||||
|
||||
export const PAGE_SIZE = 24;
|
||||
/** Obergrenze für eigene Veröffentlichungen und Meldungen einer Karte (eine Seite genügt). */
|
||||
const LIST_MAX = 100;
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
/** Eine Zeile, wie Appwrite sie liefert: Systemfelder (`$id`, …) plus die Spalten der Tabelle. */
|
||||
type TableRow = Models.Row & Row;
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/** Eine Zeile der Tabelle `gallery` als Karte; fehlende oder falsch typisierte Felder werden leer. */
|
||||
export function toGalleryCard(row: Row): GalleryCard {
|
||||
const count = row.reportCount;
|
||||
return {
|
||||
id: text(row.$id),
|
||||
title: text(row.title),
|
||||
issuer: text(row.issuer),
|
||||
tags: text(row.tags)
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag !== ''),
|
||||
displayName: text(row.displayName),
|
||||
imageFileId: text(row.imageFileId),
|
||||
previewFileId: text(row.previewFileId),
|
||||
sourceCardId: text(row.sourceCardId),
|
||||
ownerId: text(row.ownerId),
|
||||
hidden: row.hidden === true,
|
||||
reportCount: typeof count === 'number' && Number.isFinite(count) && count > 0 ? count : 0,
|
||||
createdAt: text(row.$createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ab dieser Länge sucht `q` im Volltext-Index; kürzere Texte finden dort nichts. */
|
||||
const FULLTEXT_MIN = 3;
|
||||
|
||||
/**
|
||||
* Abfragen für eine Seite der Galerie, neueste zuerst. Öffentlich immer nur sichtbare Karten;
|
||||
* Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon ausgeblendete), „Ausgeblendet“ = hidden.
|
||||
* Optional: Cursor, Tag (`contains` auf `tags`) und Suchtext (Volltext ab 3 Zeichen, sonst `contains` auf `searchText`).
|
||||
*/
|
||||
export function galleryQueries(opts: GalleryOptions): string[] {
|
||||
const base =
|
||||
opts.filter === 'reported'
|
||||
? Query.greaterThan('reportCount', 0)
|
||||
: opts.filter === 'hidden'
|
||||
? Query.equal('hidden', true)
|
||||
: Query.equal('hidden', false);
|
||||
const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)];
|
||||
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
|
||||
const tag = (opts.tag ?? '').trim().toLowerCase();
|
||||
if (tag) queries.push(Query.contains('tags', `,${tag},`)); // mit Trennzeichen: „gold“ trifft nicht „marigold“
|
||||
const q = (opts.q ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (q) {
|
||||
queries.push(
|
||||
[...q].length >= FULLTEXT_MIN ? Query.search('searchText', q) : Query.contains('searchText', q.toLowerCase()),
|
||||
);
|
||||
}
|
||||
return queries;
|
||||
}
|
||||
|
||||
/** Eine Seite der Galerie. `nextCursor` ist die ID der letzten Karte, solange die Seite voll war, sonst null. */
|
||||
export async function listGallery(opts: GalleryOptions): Promise<{ cards: GalleryCard[]; nextCursor: string | null }> {
|
||||
const list = await tables.listRows<TableRow>({
|
||||
databaseId: IDS.database,
|
||||
tableId: IDS.gallery,
|
||||
queries: galleryQueries(opts),
|
||||
});
|
||||
const cards = list.rows.map((row) => toGalleryCard(row));
|
||||
return { cards, nextCursor: cards.length === PAGE_SIZE ? cards[cards.length - 1].id : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Karte per ID. Gibt es sie nicht oder ist sie für mich ausgeblendet (Appwrite antwortet beides mit 404), ist das
|
||||
* Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage.
|
||||
*/
|
||||
export async function getGalleryCard(id: string): Promise<GalleryCard | null> {
|
||||
if (!ID_PATTERN.test(id)) return null;
|
||||
try {
|
||||
return toGalleryCard(await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.gallery, rowId: id }));
|
||||
} catch (err) {
|
||||
if (isRowMissing(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Die eigenen Veröffentlichungen (auch ausgeblendete), nach privater Karte (`sourceCardId`) geordnet. */
|
||||
export async function myPublications(userId: string): Promise<Map<string, GalleryCard>> {
|
||||
const map = new Map<string, GalleryCard>();
|
||||
if (!userId) return map;
|
||||
const list = await tables.listRows<TableRow>({
|
||||
databaseId: IDS.database,
|
||||
tableId: IDS.gallery,
|
||||
queries: [Query.equal('ownerId', userId), Query.limit(LIST_MAX)],
|
||||
});
|
||||
for (const row of list.rows) {
|
||||
const card = toGalleryCard(row);
|
||||
if (card.sourceCardId) map.set(card.sourceCardId, card);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Admin: die Meldungen einer Karte, neueste zuerst. Der Melder-Hash bleibt außen vor. */
|
||||
export async function listReports(galleryId: string): Promise<GalleryReport[]> {
|
||||
if (!ID_PATTERN.test(galleryId)) return [];
|
||||
const list = await tables.listRows<TableRow>({
|
||||
databaseId: IDS.database,
|
||||
tableId: IDS.reports,
|
||||
queries: [Query.equal('galleryId', galleryId), Query.orderDesc('$createdAt'), Query.limit(LIST_MAX)],
|
||||
});
|
||||
return list.rows.map((row) => ({
|
||||
id: text(row.$id),
|
||||
galleryId: text(row.galleryId),
|
||||
reason: text(row.reason),
|
||||
createdAt: text(row.$createdAt),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Lädt das Kartenbild (PNG 1536 px) oder die Vorschau (WebP 480 px) aus dem Galerie-Bucket. */
|
||||
export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob> {
|
||||
const fileId = size === 'full' ? card.imageFileId : card.previewFileId;
|
||||
// Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht.
|
||||
if (!ID_PATTERN.test(fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', '');
|
||||
return fetchFile(IDS.galleryBucket, fileId);
|
||||
}
|
||||
|
||||
/** Obergrenze für Breite × Höhe eines Galerie-Bildes: die Karte hat 1536 × 969 (rund 1,5 Megapixel). */
|
||||
export const MAX_GALLERY_PIXELS = 4_000_000;
|
||||
const IMAGE_UNUSABLE = msg('Das Bild konnte nicht geladen werden.');
|
||||
|
||||
/** Ein geprüftes Galerie-Bild; `url` ist eine Object-URL auf `blob`, die der Aufrufer mit `URL.revokeObjectURL` freigibt. */
|
||||
export interface CheckedImage {
|
||||
blob: Blob;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein Galerie-Bild, bevor es angezeigt wird: Das Bild wird über eine Object-URL in ein `Image` geladen (dann sind
|
||||
* die Maße bekannt, es ist aber noch nicht dekodiert) und abgelehnt, wenn das nicht klappt oder Breite × Höhe über
|
||||
* `MAX_GALLERY_PIXELS` liegt. Ein kleines, stark komprimiertes PNG/WebP kann sonst Gigabytes an Pixeln enthalten und den
|
||||
* Tab einfrieren (Startseite und Galerie zeigen fremde Bilder). Die Server-Prüfung beim Veröffentlichen ersetzt das nicht.
|
||||
* Bei Erfolg liefert es dieselbe Object-URL, die dann angezeigt wird; bei Ablehnung ist sie schon freigegeben.
|
||||
*/
|
||||
export async function checkGalleryImage(blob: Blob): Promise<CheckedImage> {
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const img = new Image();
|
||||
const loaded = new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error(IMAGE_UNUSABLE));
|
||||
});
|
||||
img.src = url;
|
||||
await loaded;
|
||||
const { naturalWidth: w, naturalHeight: h } = img;
|
||||
if (!w || !h || w * h > MAX_GALLERY_PIXELS) throw new Error(IMAGE_UNUSABLE);
|
||||
return { blob, url };
|
||||
} catch (err) {
|
||||
URL.revokeObjectURL(url);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie `loadGalleryImage`, aber geprüft (`checkGalleryImage`) und mit fertiger Object-URL: der gemeinsame Weg für Raster,
|
||||
* Hero und Detailseite. Wirft bei Ladefehler, zu großen Maßen und nicht dekodierbaren Dateien.
|
||||
*/
|
||||
export async function loadGalleryImageChecked(card: GalleryCard, size: 'full' | 'preview'): Promise<CheckedImage> {
|
||||
return checkGalleryImage(await loadGalleryImage(card, size));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Als Text lesen: vitest verarbeitet CSS-Importe (auch ?raw) nicht zu Text.
|
||||
const accountCss = readFileSync(resolve(import.meta.dirname, '../account/account.css'), 'utf8');
|
||||
const communityCss = readFileSync(resolve(import.meta.dirname, 'community.css'), 'utf8');
|
||||
|
||||
/** Die Deklarationen einer Regel, deren Auswahlkriterium genau `selector` ist (mehrere Regeln: alle zusammen). */
|
||||
function declarations(css: string, selector: string): string {
|
||||
const found: string[] = [];
|
||||
for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||
const selectors = rule[1].replace(/\/\*[\s\S]*?\*\//g, '').split(',').map((part) => part.trim());
|
||||
if (selectors.includes(selector)) found.push(rule[2]);
|
||||
}
|
||||
return found.join('\n');
|
||||
}
|
||||
|
||||
describe('Live-Regionen (role alert/status) bleiben gerendert, auch leer', () => {
|
||||
it.each([
|
||||
['community.css', '.detail-msg:empty', communityCss],
|
||||
['account.css', '.auth-msg:empty', accountCss],
|
||||
])('%s: %s ist nicht display: none, sondern aus dem Layout genommen und optisch leer', (_file, selector, css) => {
|
||||
const rule = declarations(css, selector);
|
||||
expect(rule).not.toBe('');
|
||||
expect(rule).not.toMatch(/display\s*:\s*none/);
|
||||
expect(rule).not.toMatch(/visibility\s*:\s*hidden/);
|
||||
expect(rule).toMatch(/position\s*:\s*absolute/); // kein Platz im Layout (kein Gap im Flex-Container)
|
||||
expect(rule).toMatch(/width\s*:\s*1px/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,744 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ usernameDialog: vi.fn() }));
|
||||
|
||||
// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen. Der Namensdialog ist eine Attrappe.
|
||||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||||
vi.mock('../account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
|
||||
|
||||
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||
import { germanLeft } from '../i18n/english-check';
|
||||
import { setLang } from '../i18n/i18n';
|
||||
import { ApiError } from './api';
|
||||
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
|
||||
import {
|
||||
CONSENT_TEXT,
|
||||
publishDialog,
|
||||
publishFailureText,
|
||||
validatePublishForm,
|
||||
type PublishFormValues,
|
||||
type PublishOutcome,
|
||||
} from './publish-dialog';
|
||||
|
||||
type Submit = (form: PublishForm) => Promise<{ id: string }>;
|
||||
const submit = vi.fn<Submit>();
|
||||
|
||||
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.publish-dialog');
|
||||
const input = (name: string) => dialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
|
||||
const consent = () => input('consent');
|
||||
const buttonByText = (text: string) =>
|
||||
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
|
||||
const form = () => dialogEl()!.querySelector('form')!;
|
||||
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
|
||||
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
|
||||
const errorLine = () => dialogEl()!.querySelector<HTMLElement>('[role="alert"]')!;
|
||||
/** Die IDs in `aria-describedby` eines Feldes (leer, wenn es das Attribut nicht hat). */
|
||||
const describedBy = (name: string) => (input(name).getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function fill(values: Partial<PublishFormValues> = {}): void {
|
||||
const v = { title: 'Meine Karte', issuer: '', tags: '', consent: true, ...values };
|
||||
input('title').value = v.title;
|
||||
input('issuer').value = v.issuer;
|
||||
input('tags').value = v.tags;
|
||||
consent().checked = v.consent;
|
||||
}
|
||||
|
||||
function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise<PublishOutcome> {
|
||||
return publishDialog({ submit, ...opts });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
submit.mockReset();
|
||||
mocks.usernameDialog.mockReset();
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: Felder', () => {
|
||||
it('zeigt die Felder der Spec in Reihenfolge, alle leer, die Checkbox ungesetzt', () => {
|
||||
void open({ cardName: 'Urlaub' });
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte veröffentlichen');
|
||||
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'consent']);
|
||||
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
|
||||
'Titel (Pflicht)',
|
||||
'Herausgeber / Bank (optional)',
|
||||
'Tags (optional)',
|
||||
]);
|
||||
for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe('');
|
||||
expect(consent().type).toBe('checkbox');
|
||||
expect(consent().checked).toBe(false);
|
||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||
expect(buttonByText('Veröffentlichen')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('es gibt kein Feld „Anzeigename“ mehr (die Karte zeigt den Benutzernamen, Spec § 5), und nie eine E-Mail im Dialog', () => {
|
||||
void open();
|
||||
expect(dialogEl()!.querySelector('input[name="displayName"]')).toBeNull();
|
||||
expect(dialogEl()!.textContent).not.toMatch(/Anzeigename|Anonym/);
|
||||
expect(dialogEl()!.textContent).not.toMatch(/@/);
|
||||
});
|
||||
|
||||
it('die Pflicht-Checkbox hat genau den Wortlaut der Spec', () => {
|
||||
void open();
|
||||
expect(CONSENT_TEXT).toBe('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
|
||||
expect(consent().closest('label')!.textContent).toBe(CONSENT_TEXT);
|
||||
});
|
||||
|
||||
it('jedes Feld hat sein Label (label.control === Eingabe), alle IDs sind eindeutig, der Titel des Dialogs ist die Überschrift', () => {
|
||||
void open();
|
||||
const labels = [...form().querySelectorAll('label')];
|
||||
const inputs = [...form().querySelectorAll('input')];
|
||||
expect(labels).toHaveLength(4);
|
||||
expect(labels.map((l) => l.control)).toEqual(inputs);
|
||||
for (const control of inputs) expect(control.labels).toHaveLength(1);
|
||||
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
const heading = dialogEl()!.querySelector('h2')!;
|
||||
expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(heading.id);
|
||||
expect(inputs.map((i) => i.id)).not.toContain(heading.id);
|
||||
});
|
||||
|
||||
it('nennt den Kartennamen als Text, nie als HTML', () => {
|
||||
void open({ cardName: '<img src=x onerror=alert(1)>' });
|
||||
expect(dialogEl()!.querySelector('img')).toBeNull();
|
||||
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
|
||||
const invalid = async (values: Partial<PublishFormValues>, message: string, field: string) => {
|
||||
void open();
|
||||
fill(values);
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(message);
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(input(field).getAttribute('aria-invalid')).toBe('true');
|
||||
expect(describedBy(field)).toContain(errorLine().id); // das ungültige Feld verweist auf die Fehlerzeile
|
||||
expect(document.activeElement).toBe(input(field));
|
||||
};
|
||||
|
||||
it('Titel leer (auch nur Leerzeichen)', async () => {
|
||||
await invalid({ title: ' ' }, 'Bitte gib einen Titel ein.', 'title');
|
||||
});
|
||||
|
||||
it('Titel länger als 60 Zeichen', async () => {
|
||||
await invalid({ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).', 'title');
|
||||
});
|
||||
|
||||
it('Herausgeber länger als 40 Zeichen', async () => {
|
||||
await invalid({ issuer: 'x'.repeat(41) }, 'Herausgeber / Bank ist zu lang (höchstens 40 Zeichen).', 'issuer');
|
||||
});
|
||||
|
||||
it('sechs Tags', async () => {
|
||||
await invalid({ tags: 'a, b, c, d, e, f' }, 'Bitte gib höchstens 5 Tags an.', 'tags');
|
||||
});
|
||||
|
||||
it('ein Tag länger als 20 Zeichen', async () => {
|
||||
await invalid({ tags: `visa, ${'x'.repeat(21)}` }, 'Ein Tag darf höchstens 20 Zeichen lang sein.', 'tags');
|
||||
});
|
||||
|
||||
it('Checkbox fehlt', async () => {
|
||||
await invalid({ consent: false }, 'Bitte bestätige die Erklärung zum Bild.', 'consent');
|
||||
});
|
||||
|
||||
it('Steuerzeichen werden abgelehnt (der Server täte es auch)', async () => {
|
||||
await invalid({ title: 'a\tb' }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
||||
});
|
||||
|
||||
// Dasselbe Muster wie deploy/api/app.js: C1-Steuerzeichen, Nullbreiten-, Bidi-Zeichen und BOM sind unsichtbar oder ordnen Text um.
|
||||
it.each([
|
||||
['Nullbreiten-Leerzeichen', 'a\u200bb'],
|
||||
['Rechts-nach-links-Überschreibung', 'foto\u202egnp.exe'],
|
||||
['Isolate', 'a\u2066b'],
|
||||
['Wortverbinder', 'a\u2060b'],
|
||||
['C1-Steuerzeichen', 'a\u0085b'],
|
||||
['BOM mitten im Titel', 'a\ufeffb'],
|
||||
])('unsichtbare Zeichen werden abgelehnt: %s (Titel)', async (_name, title) => {
|
||||
await invalid({ title }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
||||
});
|
||||
|
||||
it('ein Titel nur aus unsichtbaren Zeichen gilt als ungültig', async () => {
|
||||
await invalid({ title: '\u200b\u200d' }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
||||
});
|
||||
|
||||
it('unsichtbare Zeichen im Herausgeber', async () => {
|
||||
await invalid({ issuer: 'Bank\u200f' }, 'Herausgeber / Bank enthält ungültige Zeichen.', 'issuer');
|
||||
});
|
||||
|
||||
it('unsichtbares Zeichen in einem Tag', async () => {
|
||||
await invalid({ tags: 'visa, gol\u200bd' }, 'Tags enthalten ungültige Zeichen.', 'tags');
|
||||
});
|
||||
|
||||
it('normale Sonderzeichen (Umlaute, Emoji, Bindestrich, Zeilenabstand durch Leerzeichen) bleiben erlaubt', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', tags: 'grün, ✨' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
});
|
||||
|
||||
it('Grenzen sind inklusive: 60/40 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), tags: 'A, a, , b, c, d, e,' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
void open();
|
||||
fill({ title: '' });
|
||||
send();
|
||||
expect(input('title').getAttribute('aria-invalid')).toBe('true');
|
||||
fill({ title: 'Ok' });
|
||||
send();
|
||||
expect(input('title').getAttribute('aria-invalid')).toBeNull();
|
||||
expect(errorText()).toBe('');
|
||||
});
|
||||
|
||||
describe('Fehlerzeile und Felder (Barrierefreiheit)', () => {
|
||||
it('die Fehlerzeile hat eine ID, die im Dialog einmalig ist', () => {
|
||||
void open();
|
||||
expect(errorLine().id).toBe('publish-error');
|
||||
expect(dialogEl()!.querySelectorAll(`#${errorLine().id}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
|
||||
void open();
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
|
||||
expect(describedBy('title')).toEqual([]);
|
||||
expect(describedBy('consent')).toEqual([]);
|
||||
});
|
||||
|
||||
it('ein ungültiges Feld verweist mit aria-describedby auf die Fehlerzeile, hinter seinem Hinweis, wenn es einen hat', () => {
|
||||
void open();
|
||||
fill({ title: '' });
|
||||
send();
|
||||
expect(describedBy('title')).toEqual(['publish-error']);
|
||||
|
||||
fill({ tags: 'a,b,c,d,e,f' });
|
||||
send();
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
|
||||
expect(describedBy('title')).toEqual([]); // nur das Feld mit dem Problem
|
||||
|
||||
fill({ consent: false });
|
||||
send();
|
||||
expect(describedBy('consent')).toEqual(['publish-error']);
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint']); // nur das Feld mit dem aktuellen Problem
|
||||
});
|
||||
|
||||
it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
void open();
|
||||
fill({ tags: 'a,b,c,d,e,f' });
|
||||
send();
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
|
||||
fill({ tags: 'a,b' });
|
||||
send();
|
||||
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
|
||||
expect(input('tags').hasAttribute('aria-invalid')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => {
|
||||
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', consent: true };
|
||||
expect(validatePublishForm(ok)).toBeNull();
|
||||
expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title');
|
||||
expect(validatePublishForm({ ...ok, tags: 'a,b,c,d,e,f', consent: false })?.field).toBe('tags');
|
||||
expect(validatePublishForm({ ...ok, consent: false })?.field).toBe('consent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: Senden', () => {
|
||||
it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg; einen Anzeigenamen gibt es nicht mehr', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold' });
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('leere optionale Felder werden leer gesendet', async () => {
|
||||
submit.mockResolvedValue({ id: 'g1' });
|
||||
const outcome = open();
|
||||
fill({ title: 'Nur Titel' });
|
||||
send();
|
||||
await outcome;
|
||||
expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Nur Titel', issuer: '', tags: '' });
|
||||
});
|
||||
|
||||
it('während des Sendens: „Veröffentlichen“ und „Abbrechen“ gesperrt, Felder gesperrt, zweites Senden wird ignoriert', async () => {
|
||||
let finish!: (r: { id: string }) => void;
|
||||
submit.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
const outcome = open();
|
||||
fill();
|
||||
send();
|
||||
expect(buttonByText('Veröffentlichen')!.disabled).toBe(true);
|
||||
expect(buttonByText('Abbrechen')!.disabled).toBe(true);
|
||||
expect(input('title').disabled).toBe(true);
|
||||
expect(consent().disabled).toBe(true);
|
||||
expect(form().getAttribute('aria-busy')).toBe('true');
|
||||
expect(statusText()).toBe('Wird veröffentlicht …');
|
||||
send();
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
finish({ id: 'g1' });
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
});
|
||||
|
||||
it('während des Sendens schließen weder Esc noch ein Klick auf den Hintergrund den Dialog', async () => {
|
||||
let finish!: (r: { id: string }) => void;
|
||||
submit.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
const outcome = open();
|
||||
fill();
|
||||
send();
|
||||
const esc = new Event('cancel', { cancelable: true });
|
||||
dialogEl()!.dispatchEvent(esc);
|
||||
expect(esc.defaultPrevented).toBe(true);
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
finish({ id: 'g1' });
|
||||
await outcome;
|
||||
});
|
||||
|
||||
it('schließt der Browser den Dialog mitten im Senden (close-Ereignis), geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
|
||||
let finish!: (r: { id: string }) => void;
|
||||
submit.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
let resolved: PublishOutcome | null = null;
|
||||
const outcome = open().then((o) => (resolved = o));
|
||||
fill();
|
||||
send();
|
||||
dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet
|
||||
await settle();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
dialogEl()!.dispatchEvent(new Event('close'));
|
||||
await settle();
|
||||
expect(resolved).toBeNull();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
finish({ id: 'g1' });
|
||||
await outcome;
|
||||
expect(resolved).toBe('published');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('wurde der Dialog mitten im Senden geschlossen und das Senden scheitert, ist der Fehler sichtbar (Dialog wieder offen)', async () => {
|
||||
let fail!: (e: Error) => void;
|
||||
submit.mockReturnValue(new Promise((_, r) => (fail = r)));
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
dialogEl()!.close();
|
||||
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
|
||||
await settle();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
|
||||
});
|
||||
|
||||
it('Fehler des Dienstes: Servertext im Dialog, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => {
|
||||
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
|
||||
const outcome = open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
|
||||
expect(buttonByText('Abbrechen')!.disabled).toBe(false);
|
||||
expect(input('title').disabled).toBe(false);
|
||||
expect(input('title').value).toBe('Meine Karte');
|
||||
expect(consent().checked).toBe(true);
|
||||
expect(statusText()).toBe('');
|
||||
|
||||
submit.mockResolvedValueOnce({ id: 'g1' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Validierungsfehler des Dienstes (400) erscheinen mit ihrem Text', async () => {
|
||||
submit.mockRejectedValue(new ApiError(400, 'Der Titel enthält ungültige Zeichen.'));
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.');
|
||||
});
|
||||
|
||||
it('409: „Diese Karte ist schon veröffentlicht.“, kein neuer Versuch, Ergebnis „already“ nach „Schließen“', async () => {
|
||||
submit.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
|
||||
const outcome = open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(statusText()).toBe('Diese Karte ist schon veröffentlicht.');
|
||||
expect(errorText()).toBe('');
|
||||
expect(form().querySelector('input')).toBeNull();
|
||||
expect(buttonByText('Veröffentlichen')).toBeUndefined();
|
||||
send();
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
buttonByText('Schließen')!.click();
|
||||
await expect(outcome).resolves.toBe('already');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('abgebrochene Anmeldung: Hinweis „Bitte melde dich an“', async () => {
|
||||
submit.mockRejectedValue(new LoginCancelled());
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(LOGIN_NEEDED);
|
||||
});
|
||||
|
||||
it('deutsche Meldungen aus dem Rendern und andere Fehler werden übersetzt', async () => {
|
||||
submit.mockRejectedValueOnce(new Error(TOO_LARGE));
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${TOO_LARGE}`);
|
||||
submit.mockRejectedValueOnce(new Error(NOT_RENDERED));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${NOT_RENDERED}`);
|
||||
submit.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Keine Verbindung zum Server.');
|
||||
submit.mockRejectedValueOnce(new Error('boom: interne Details'));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: Benutzername nötig (403 usernameRequired)', () => {
|
||||
const required = () => new ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.', true, 'usernameRequired');
|
||||
const REASON = 'Veröffentlichen fehlgeschlagen – Bitte wähle zuerst einen Benutzernamen.';
|
||||
|
||||
it('der Namensdialog „required“ öffnet sich (mit dem Signal der Seite); der Dialog bleibt offen und nennt den Grund', async () => {
|
||||
mocks.usernameDialog.mockReturnValue(new Promise(() => {})); // der Nutzer überlegt noch
|
||||
submit.mockRejectedValueOnce(required());
|
||||
const controller = new AbortController();
|
||||
void open({ signal: controller.signal });
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'required', signal: controller.signal });
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(errorText()).toBe(REASON);
|
||||
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('nach dem gespeicherten Namen ist der Grund weg, die Eingaben bleiben, der Fokus liegt auf „Veröffentlichen“ und ein neuer Versuch geht durch', async () => {
|
||||
mocks.usernameDialog.mockResolvedValue('Kenso');
|
||||
submit.mockRejectedValueOnce(required());
|
||||
const outcome = open();
|
||||
fill({ title: 'Mein Motiv', issuer: 'Bank', tags: 'visa' });
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('');
|
||||
expect(input('title').value).toBe('Mein Motiv');
|
||||
expect(input('issuer').value).toBe('Bank');
|
||||
expect(consent().checked).toBe(true);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(document.activeElement).toBe(buttonByText('Veröffentlichen'));
|
||||
|
||||
submit.mockResolvedValueOnce({ id: 'g1' });
|
||||
send();
|
||||
await expect(outcome).resolves.toBe('published');
|
||||
expect(submit).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('„Abbrechen“ im Namensdialog: der Grund bleibt stehen, nichts ist veröffentlicht, der Dialog bleibt zum Abbrechen oder Wiederholen', async () => {
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
submit.mockRejectedValueOnce(required());
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(REASON);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
// ein neuer Versuch fragt der Dienst wieder, und der Namensdialog erscheint wieder
|
||||
submit.mockRejectedValueOnce(required());
|
||||
send();
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('lässt sich der Namensdialog nicht öffnen, bleibt der Grund stehen und nichts wirft', async () => {
|
||||
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
|
||||
submit.mockRejectedValueOnce(required());
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(REASON);
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('wird der Dialog währenddessen geschlossen (Seite verlassen), bleibt die Antwort des Namensdialogs folgenlos', async () => {
|
||||
let answer!: (name: string | null) => void;
|
||||
mocks.usernameDialog.mockReturnValue(new Promise<string | null>((resolve) => (answer = resolve)));
|
||||
submit.mockRejectedValueOnce(required());
|
||||
const controller = new AbortController();
|
||||
const outcome = open({ signal: controller.signal });
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
controller.abort();
|
||||
await expect(outcome).resolves.toBe('cancelled');
|
||||
answer('Kenso');
|
||||
await settle();
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(submit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('andere Fehler (auch andere 403) öffnen den Namensdialog nicht', async () => {
|
||||
submit.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
|
||||
send();
|
||||
await settle();
|
||||
submit.mockRejectedValueOnce(new Error('boom'));
|
||||
send();
|
||||
await settle();
|
||||
expect(mocks.usernameDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Englisch: der Grund erscheint auf Englisch', async () => {
|
||||
setLang('en', { persist: false });
|
||||
mocks.usernameDialog.mockResolvedValue(null);
|
||||
submit.mockRejectedValueOnce(required());
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Publishing failed – Please choose a username first.');
|
||||
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
|
||||
it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => {
|
||||
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe(
|
||||
'Veröffentlichen fehlgeschlagen – Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
|
||||
);
|
||||
expect(errorText()).toContain(LOGO_MISSING_PUBLISH);
|
||||
expect(errorText()).not.toContain('schiefgelaufen');
|
||||
});
|
||||
|
||||
it('publishFailureText reicht ihn ebenso durch', () => {
|
||||
expect(publishFailureText(new Error(LOGO_MISSING_PUBLISH))).toBe(`Veröffentlichen fehlgeschlagen – ${LOGO_MISSING_PUBLISH}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog: Schließen', () => {
|
||||
it('„Abbrechen“ schließt ohne zu senden', async () => {
|
||||
const outcome = open();
|
||||
fill();
|
||||
buttonByText('Abbrechen')!.click();
|
||||
await expect(outcome).resolves.toBe('cancelled');
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen', async () => {
|
||||
const first = open();
|
||||
dialogEl()!.dispatchEvent(new Event('close'));
|
||||
await expect(first).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
|
||||
const second = open();
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await expect(second).resolves.toBe('cancelled');
|
||||
});
|
||||
|
||||
it('ein Markieren, das außerhalb endet, schließt nicht (Drücken im Formular, Loslassen auf dem Hintergrund)', () => {
|
||||
void open();
|
||||
input('title').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
});
|
||||
|
||||
it('das Abbrechen-Signal (Seite verlassen) schließt den Dialog, auch während des Sendens', async () => {
|
||||
const controller = new AbortController();
|
||||
let finish!: (r: { id: string }) => void;
|
||||
submit.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
const outcome = open({ signal: controller.signal });
|
||||
fill();
|
||||
send();
|
||||
controller.abort();
|
||||
await expect(outcome).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
// Eine danach eintreffende Antwort bleibt folgenlos.
|
||||
finish({ id: 'g1' });
|
||||
await settle();
|
||||
});
|
||||
|
||||
it('ein schon abgebrochenes Signal öffnet gar nichts', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(open({ signal: controller.signal })).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
|
||||
const controller = new AbortController();
|
||||
let fail!: (e: Error) => void;
|
||||
submit.mockReturnValue(new Promise((_, r) => (fail = r)));
|
||||
const outcome = open({ signal: controller.signal });
|
||||
fill();
|
||||
send();
|
||||
controller.abort();
|
||||
await outcome;
|
||||
fail(new ApiError(503, 'weg'));
|
||||
await settle();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('der Fokus kehrt zum auslösenden Knopf zurück', async () => {
|
||||
const opener = document.createElement('button');
|
||||
document.body.append(opener);
|
||||
opener.focus();
|
||||
const outcome = open();
|
||||
buttonByText('Abbrechen')!.click();
|
||||
await outcome;
|
||||
expect(document.activeElement).toBe(opener);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Veröffentlichen-Dialog auf Englisch', () => {
|
||||
beforeEach(() => setLang('en', { persist: false }));
|
||||
|
||||
it('Überschrift, Einleitung, Felder, Hinweise, Einwilligung und Knöpfe', () => {
|
||||
void open({ cardName: 'Urlaub' });
|
||||
const dialog = dialogEl()!;
|
||||
expect(dialog.querySelector('h2')!.textContent).toBe('Publish card');
|
||||
expect(dialog.querySelector('.report-text')!.textContent).toBe('“Urlaub” will be shown publicly in the community gallery, including its image.');
|
||||
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
|
||||
'Title (required)',
|
||||
'Issuer / bank (optional)',
|
||||
'Tags (optional)',
|
||||
]);
|
||||
expect(dialog.querySelector('#publish-tags-hint')!.textContent).toBe('Up to 5, separated by commas, e.g. “visa, gold”.');
|
||||
expect(consent().closest('label')!.textContent).toBe('The image is mine or I’m allowed to use it, and it contains no real card data.');
|
||||
expect(buttonByText('Cancel')).toBeTruthy();
|
||||
expect(buttonByText('Publish')).toBeTruthy();
|
||||
expect(germanLeft(dialog)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ohne Kartennamen eine allgemeine Einleitung; die Konstante bleibt der deutsche Schlüssel', () => {
|
||||
void open();
|
||||
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('The card will be shown publicly in the community gallery, including its image.');
|
||||
expect(CONSENT_TEXT).toBe('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
|
||||
});
|
||||
|
||||
it('die Prüfung vor dem Senden meldet in der Sprache der Seite', () => {
|
||||
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', consent: true };
|
||||
const problem = (values: Partial<PublishFormValues>) => validatePublishForm({ ...ok, ...values })?.message;
|
||||
expect(problem({ title: ' ' })).toBe('Please enter a title.');
|
||||
expect(problem({ title: 'a\tb' })).toBe('The title contains invalid characters.');
|
||||
expect(problem({ title: 'x'.repeat(61) })).toBe('The title is too long (at most 60 characters).');
|
||||
expect(problem({ issuer: 'Bank\u200f' })).toBe('Issuer / bank contains invalid characters.');
|
||||
expect(problem({ issuer: 'x'.repeat(41) })).toBe('Issuer / bank is too long (at most 40 characters).');
|
||||
expect(problem({ tags: 'visa, gol\u200bd' })).toBe('Tags contain invalid characters.');
|
||||
expect(problem({ tags: `visa, ${'x'.repeat(21)}` })).toBe('A tag can be at most 20 characters long.');
|
||||
expect(problem({ tags: 'a, b, c, d, e, f' })).toBe('Please enter at most 5 tags.');
|
||||
expect(problem({ consent: false })).toBe('Please confirm the statement about the image.');
|
||||
});
|
||||
|
||||
it('Laufzeit, Fehler des Dienstes (über ihren Code), Rendern, Netz und abgebrochene Anmeldung erscheinen auf Englisch', async () => {
|
||||
let fail!: (e: Error) => void;
|
||||
submit.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)));
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
expect(statusText()).toBe('Publishing …');
|
||||
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable'));
|
||||
await settle();
|
||||
expect(errorText()).toBe('Publishing failed – The service is currently unavailable.');
|
||||
expect(statusText()).toBe('');
|
||||
|
||||
// ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status, nicht den deutschen Servertext
|
||||
submit.mockRejectedValueOnce(new ApiError(403, 'Irgendein Servertext.'));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Publishing failed – You are not allowed to do that.');
|
||||
|
||||
submit.mockRejectedValueOnce(new Error(TOO_LARGE));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Publishing failed – The card image is too large for the gallery (max. 5 MB).');
|
||||
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Publishing failed – This card’s logo is no longer available. Open the card in the editor and choose another one.');
|
||||
submit.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Publishing failed – Can’t reach the server.');
|
||||
submit.mockRejectedValueOnce(new LoginCancelled());
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Please sign in to continue.');
|
||||
expect(germanLeft(dialogEl()!)).toEqual([]);
|
||||
});
|
||||
|
||||
it('„Publishing failed – This card is already published.“: ein ApiError mit dem Code alreadyPublished', () => {
|
||||
const err = new ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished');
|
||||
expect(publishFailureText(err)).toBe('Publishing failed – This card is already published.');
|
||||
// in der anderen Sprache derselbe Fehler mit dem deutschen Text
|
||||
setLang('de', { persist: false });
|
||||
expect(publishFailureText(err)).toBe('Veröffentlichen fehlgeschlagen – Diese Karte ist schon veröffentlicht.');
|
||||
});
|
||||
|
||||
it('ein Fehler mit dem Code alreadyPublished, aber anderem Status, zeigt ihn im Dialog mit dem Vorsatz', async () => {
|
||||
submit.mockRejectedValueOnce(new ApiError(400, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'));
|
||||
void open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Publishing failed – This card is already published.');
|
||||
});
|
||||
|
||||
it('409: „This card is already published.“ und „Close“', async () => {
|
||||
submit.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'));
|
||||
const outcome = open();
|
||||
fill();
|
||||
send();
|
||||
await settle();
|
||||
expect(statusText()).toBe('This card is already published.');
|
||||
buttonByText('Close')!.click();
|
||||
await expect(outcome).resolves.toBe('already');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags und die Pflicht-Checkbox. Einen Anzeigenamen gibt
|
||||
// es nicht mehr: die Detailseite zeigt den Benutzernamen des Kontos (Spec § 5). Hat das Konto keinen, antwortet der Dienst mit 403
|
||||
// `usernameRequired`: dann öffnet sich der Namensdialog, und mit dem gespeicherten Namen lässt es sich noch einmal versuchen.
|
||||
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
|
||||
import '../account/account.css';
|
||||
import './community.css';
|
||||
import { errorMessage } from '../account/errors';
|
||||
import { usernameDialog } from '../account/username-dialog';
|
||||
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
|
||||
import { el, setFieldInvalid } from '../editor/dom';
|
||||
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { ApiError, apiErrorText, isAlreadyPublished, isUsernameRequired } from './api';
|
||||
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
|
||||
|
||||
/** Wortlaut aus Spec § 6. */
|
||||
export const CONSENT_TEXT = msg('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
|
||||
/** Wortlaut aus Spec § 11. */
|
||||
export const ALREADY_PUBLISHED = msg('Diese Karte ist schon veröffentlicht.');
|
||||
const RUNNING = msg('Wird veröffentlicht …');
|
||||
|
||||
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
|
||||
const TITLE_MAX = 60;
|
||||
const ISSUER_MAX = 40;
|
||||
const TAGS_MAX = 5;
|
||||
const TAG_MAX = 20;
|
||||
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
|
||||
const ERROR_ID = 'publish-error';
|
||||
/**
|
||||
* Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
|
||||
* unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS).
|
||||
* Auch für den Namen im Dialog „Logo hochladen“ (logos/logo-dialog.ts).
|
||||
*/
|
||||
export const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
|
||||
|
||||
export interface PublishFormValues extends PublishForm {
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
export type PublishField = 'title' | 'issuer' | 'tags' | 'consent';
|
||||
|
||||
/** Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
|
||||
export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null {
|
||||
const title = values.title.trim();
|
||||
if (title === '') return { field: 'title', message: t('Bitte gib einen Titel ein.') };
|
||||
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: t('Der Titel enthält ungültige Zeichen.') };
|
||||
if (title.length > TITLE_MAX) {
|
||||
return { field: 'title', message: t('Der Titel ist zu lang (höchstens {max} Zeichen).', { max: TITLE_MAX }) };
|
||||
}
|
||||
|
||||
const issuer = values.issuer.trim();
|
||||
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: t('Herausgeber / Bank enthält ungültige Zeichen.') };
|
||||
if (issuer.length > ISSUER_MAX) {
|
||||
return { field: 'issuer', message: t('Herausgeber / Bank ist zu lang (höchstens {max} Zeichen).', { max: ISSUER_MAX }) };
|
||||
}
|
||||
|
||||
const tags = normalizeTags(values.tags);
|
||||
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: t('Tags enthalten ungültige Zeichen.') };
|
||||
if (tags.some((tag) => tag.length > TAG_MAX)) {
|
||||
return { field: 'tags', message: t('Ein Tag darf höchstens {max} Zeichen lang sein.', { max: TAG_MAX }) };
|
||||
}
|
||||
if (tags.length > TAGS_MAX) return { field: 'tags', message: t('Bitte gib höchstens {max} Tags an.', { max: TAGS_MAX }) };
|
||||
|
||||
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige die Erklärung zum Bild.') };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Meldungen aus dem Rendern und dem Bildlesen tragen den deutschen Text (msg); angezeigt werden sie mit t(). Alles andere übersetzt `errorMessage`. */
|
||||
const GERMAN_MESSAGES = new Set<string>([TOO_LARGE, NOT_RENDERED, LOGO_MISSING_PUBLISH, READ_MESSAGE, PIXELS_MESSAGE]);
|
||||
|
||||
/**
|
||||
* Der Text zu einem fehlgeschlagenen Veröffentlichen in der Sprache der Seite (Spec § 11: „Veröffentlichen fehlgeschlagen – …“).
|
||||
* Fehler des Dienstes kommen über ihren Code (`apiErrorText`).
|
||||
*/
|
||||
export function publishFailureText(err: unknown): string {
|
||||
if (err instanceof LoginCancelled) return t(LOGIN_NEEDED);
|
||||
let reason: string;
|
||||
if (err instanceof ApiError) reason = apiErrorText(err);
|
||||
else if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) reason = t(err.message);
|
||||
else reason = errorMessage(err, 'save');
|
||||
return t('Veröffentlichen fehlgeschlagen – {reason}', { reason });
|
||||
}
|
||||
|
||||
/** `published`: veröffentlicht; `already`: die Karte war schon veröffentlicht (409); `cancelled`: nichts veröffentlicht oder Dialog verlassen. */
|
||||
export type PublishOutcome = 'published' | 'already' | 'cancelled';
|
||||
|
||||
export interface PublishDialogOptions {
|
||||
/** Veröffentlicht die Karte (rendern, hochladen, API); wirft bei Fehler. */
|
||||
submit: (form: PublishForm) => Promise<{ id: string }>;
|
||||
/** Name der privaten Karte für den Einleitungstext (als Text). */
|
||||
cardName?: string;
|
||||
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist. Fehler erscheinen im Dialog; er bleibt dann für einen neuen
|
||||
* Versuch offen. Während des Sendens lässt er sich nicht schließen (Abbrechen gesperrt, Esc und Hintergrund wirkungslos),
|
||||
* damit niemand glaubt, etwas abgebrochen zu haben, das dann doch veröffentlicht wird; nur `signal` schließt immer.
|
||||
*/
|
||||
export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcome> {
|
||||
return new Promise<PublishOutcome>((resolve, reject) => {
|
||||
if (opts.signal?.aborted) {
|
||||
resolve('cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
let outcome: PublishOutcome = 'cancelled';
|
||||
let closed = false;
|
||||
let busy = false;
|
||||
|
||||
const text = (name: string, extra: Record<string, string> = {}) =>
|
||||
el('input', { class: 'auth-input', type: 'text', name, id: `publish-${name}`, autocomplete: 'off', ...extra });
|
||||
const titleInput = text('title', { 'aria-required': 'true' });
|
||||
const issuerInput = text('issuer');
|
||||
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
|
||||
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
|
||||
const inputs: Record<PublishField, HTMLInputElement> = {
|
||||
title: titleInput,
|
||||
issuer: issuerInput,
|
||||
tags: tagsInput,
|
||||
consent: consentInput,
|
||||
};
|
||||
|
||||
const field = (label: string, control: HTMLInputElement, hint?: string) =>
|
||||
el(
|
||||
'div',
|
||||
{ class: 'auth-field' },
|
||||
el('label', { class: 'auth-label', for: control.id }, label),
|
||||
control,
|
||||
hint ? el('p', { class: 'auth-hint', id: `${control.id}-hint` }, hint) : '',
|
||||
);
|
||||
|
||||
const intro = el(
|
||||
'p',
|
||||
{ class: 'report-text' },
|
||||
opts.cardName
|
||||
? t('„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.', { name: opts.cardName })
|
||||
: t('Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.'),
|
||||
);
|
||||
const fields = el(
|
||||
'div',
|
||||
{ class: 'publish-fields' },
|
||||
field(t('Titel (Pflicht)'), titleInput),
|
||||
field(t('Herausgeber / Bank (optional)'), issuerInput),
|
||||
field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')),
|
||||
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
|
||||
);
|
||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
|
||||
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
|
||||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
|
||||
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Veröffentlichen'));
|
||||
const actions = el('div', { class: 'report-actions' }, cancel, send);
|
||||
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
|
||||
const form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
|
||||
const dialog = el(
|
||||
'dialog',
|
||||
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-heading' },
|
||||
// Eigene ID für die Überschrift: `publish-title` gehört dem Titelfeld (`publish-<name>`), sonst zeigt sein Label auf die Überschrift.
|
||||
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, t('Karte veröffentlichen')), form),
|
||||
);
|
||||
|
||||
const finish = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
opts.signal?.removeEventListener('abort', finish);
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
if (opener?.isConnected) opener.focus();
|
||||
resolve(outcome);
|
||||
};
|
||||
|
||||
/** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */
|
||||
function reopen(): boolean {
|
||||
if (dialog.open) return true;
|
||||
try {
|
||||
dialog.showModal();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function setBusy(value: boolean): void {
|
||||
busy = value;
|
||||
form.setAttribute('aria-busy', String(value));
|
||||
for (const control of Object.values(inputs)) control.disabled = value;
|
||||
send.disabled = value;
|
||||
cancel.disabled = value;
|
||||
statusEl.textContent = value ? t(RUNNING) : '';
|
||||
}
|
||||
|
||||
function clearInvalid(): void {
|
||||
for (const control of Object.values(inputs)) setFieldInvalid(control, ERROR_ID, false);
|
||||
}
|
||||
|
||||
function showAlready(): void {
|
||||
outcome = 'already';
|
||||
errorEl.textContent = '';
|
||||
statusEl.textContent = t(ALREADY_PUBLISHED);
|
||||
intro.remove();
|
||||
fields.remove();
|
||||
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
|
||||
close.addEventListener('click', finish);
|
||||
actions.replaceChildren(close);
|
||||
close.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Dienst verlangt einen Benutzernamen: der Namensdialog öffnet sich über dem Dialog. Mit gespeichertem Namen ist der Hinweis erledigt
|
||||
* und der Fokus liegt auf „Veröffentlichen“ (erneut versuchen); bei „Abbrechen“ oder wenn der Namensdialog nicht aufgeht, bleibt der Grund stehen.
|
||||
*/
|
||||
async function askForUsername(): Promise<void> {
|
||||
let saved: string | null = null;
|
||||
try {
|
||||
saved = await usernameDialog({ mode: 'required', signal: opts.signal });
|
||||
} catch {
|
||||
// der Namensdialog lässt sich nicht öffnen: der Grund oben steht, der Nutzer kann den Namen im Konto-Menü wählen
|
||||
}
|
||||
if (closed || saved === null) return;
|
||||
errorEl.textContent = '';
|
||||
send.focus();
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const values: PublishFormValues = {
|
||||
title: titleInput.value.trim(),
|
||||
issuer: issuerInput.value.trim(),
|
||||
tags: tagsInput.value,
|
||||
consent: consentInput.checked,
|
||||
};
|
||||
clearInvalid();
|
||||
errorEl.textContent = '';
|
||||
const problem = validatePublishForm(values);
|
||||
if (problem) {
|
||||
errorEl.textContent = problem.message;
|
||||
setFieldInvalid(inputs[problem.field], ERROR_ID, true);
|
||||
inputs[problem.field].focus();
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags });
|
||||
} catch (err) {
|
||||
if (closed) return;
|
||||
if (isAlreadyPublished(err)) {
|
||||
setBusy(false);
|
||||
reopen();
|
||||
showAlready();
|
||||
return;
|
||||
}
|
||||
setBusy(false);
|
||||
errorEl.textContent = publishFailureText(err);
|
||||
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
|
||||
if (isUsernameRequired(err)) await askForUsername();
|
||||
return;
|
||||
}
|
||||
if (closed) return;
|
||||
outcome = 'published';
|
||||
finish();
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (busy || outcome !== 'cancelled') return;
|
||||
void submit();
|
||||
});
|
||||
cancel.addEventListener('click', finish);
|
||||
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Senden darf der Dialog nicht zugehen.
|
||||
dialog.addEventListener('cancel', (event) => {
|
||||
if (busy) event.preventDefault();
|
||||
});
|
||||
// Schließt der Browser den Dialog trotzdem mitten im Senden, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis.
|
||||
dialog.addEventListener('close', () => {
|
||||
if (busy && !closed) {
|
||||
reopen();
|
||||
return;
|
||||
}
|
||||
finish();
|
||||
});
|
||||
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
|
||||
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
|
||||
let pressedOnBackdrop = false;
|
||||
dialog.addEventListener('mousedown', (event) => {
|
||||
pressedOnBackdrop = event.target === dialog;
|
||||
});
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target === dialog && pressedOnBackdrop && !busy) finish();
|
||||
pressedOnBackdrop = false;
|
||||
});
|
||||
opts.signal?.addEventListener('abort', finish);
|
||||
|
||||
document.body.append(dialog);
|
||||
try {
|
||||
dialog.showModal();
|
||||
} catch (err) {
|
||||
closed = true;
|
||||
opts.signal?.removeEventListener('abort', finish);
|
||||
dialog.remove();
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
titleInput.focus();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DEFAULT_CONFIG, type TemplateId } from '../card/config';
|
||||
import type { SavedCard } from '../account/cards';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createJWT: vi.fn(),
|
||||
decodeImage: vi.fn(),
|
||||
loadCardAssets: vi.fn(),
|
||||
exportCard: vi.fn(),
|
||||
getLogo: vi.fn(),
|
||||
loadLogoBlob: vi.fn(),
|
||||
}));
|
||||
|
||||
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz;
|
||||
// nur createJWT wird für den Test mit der echten API-Schicht gebraucht.
|
||||
vi.mock('../account/appwrite', () => ({
|
||||
IDS: { galleryBucket: 'gallery-bucket' },
|
||||
account: { createJWT: mocks.createJWT },
|
||||
client: {},
|
||||
tables: {},
|
||||
storage: {},
|
||||
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
|
||||
}));
|
||||
// Rendern (Bild dekodieren, Logos laden, Canvas) ist für renderForGallery ersetzt.
|
||||
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
|
||||
// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt.
|
||||
vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
|
||||
vi.mock('../editor/upload', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../editor/upload')>()),
|
||||
decodeImage: mocks.decodeImage,
|
||||
}));
|
||||
|
||||
import { forgetLogo, loadLogoAsset } from '../logos/logo-asset';
|
||||
import type { CommunityLogo } from '../logos/logos';
|
||||
import { ApiError, publishCard } from './api';
|
||||
import {
|
||||
LOGO_MISSING_PUBLISH,
|
||||
PNG_MAX_BYTES,
|
||||
assertLogoAvailable,
|
||||
normalizeTags,
|
||||
publishSavedCard,
|
||||
renderForGallery,
|
||||
type PublishDeps,
|
||||
} from './publish';
|
||||
|
||||
const card: SavedCard = {
|
||||
id: 'card1',
|
||||
name: 'Meine Karte',
|
||||
imageFileId: 'privImage',
|
||||
previewFileId: 'privPreview',
|
||||
updatedAt: '2026-10-04T10:00:00.000Z',
|
||||
config: DEFAULT_CONFIG,
|
||||
};
|
||||
|
||||
const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold' };
|
||||
|
||||
const png = () => new Blob(['png'], { type: 'image/png' });
|
||||
const webp = () => new Blob(['webp'], { type: 'image/webp' });
|
||||
|
||||
type Call = { name: string; args: unknown[] };
|
||||
|
||||
/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; Dateien bekommen die IDs f1, f2, … */
|
||||
function fakeDeps(overrides: Partial<PublishDeps> = {}) {
|
||||
const calls: Call[] = [];
|
||||
let files = 0;
|
||||
const log = <T>(name: string, args: unknown[], result: T): T => {
|
||||
calls.push({ name, args });
|
||||
return result;
|
||||
};
|
||||
const deps: PublishDeps = {
|
||||
userId: () => log('userId', [], 'u1'),
|
||||
loadImage: async (c) => log('loadImage', [c], new Blob(['src'], { type: 'image/jpeg' })),
|
||||
render: async (c, image) => log('render', [c, image], { png: png(), preview: webp() }),
|
||||
createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: `f${++files}` }),
|
||||
deleteFile: async (id) => log('deleteFile', [id], undefined),
|
||||
publishCard: async (input) => log('publishCard', [input], { id: 'g1' }),
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
deps,
|
||||
calls,
|
||||
names: () => calls.map((c) => c.name),
|
||||
of: (name: string) => calls.filter((c) => c.name === name),
|
||||
};
|
||||
}
|
||||
|
||||
describe('publishSavedCard', () => {
|
||||
it('Reihenfolge: Nutzer, Bild laden, rendern, Upload, Upload, API; liefert { id }', async () => {
|
||||
const fake = fakeDeps();
|
||||
await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' });
|
||||
expect(fake.names()).toEqual(['userId', 'loadImage', 'render', 'createFile', 'createFile', 'publishCard']);
|
||||
expect(fake.of('loadImage')[0].args[0]).toBe(card);
|
||||
expect(fake.of('render')[0].args[0]).toBe(card);
|
||||
});
|
||||
|
||||
it('lädt zuerst das PNG, dann die Vorschau hoch; beide mit read(any) und delete(user:<ich>)', async () => {
|
||||
const fake = fakeDeps();
|
||||
await publishSavedCard(card, form, fake.deps);
|
||||
const [first, second] = fake.of('createFile');
|
||||
expect((first.args[0] as File).name).toBe('image.png');
|
||||
expect((first.args[0] as File).type).toBe('image/png');
|
||||
expect((second.args[0] as File).name).toBe('preview.webp');
|
||||
expect((second.args[0] as File).type).toBe('image/webp');
|
||||
for (const call of [first, second]) {
|
||||
expect(call.args[1]).toEqual(['read("any")', 'delete("user:u1")']);
|
||||
}
|
||||
});
|
||||
|
||||
it('benennt die Vorschau nach dem echten Blob-Typ (Browser ohne WebP-Export liefern PNG)', async () => {
|
||||
const fake = fakeDeps({ render: async () => ({ png: png(), preview: png() }) });
|
||||
await publishSavedCard(card, form, fake.deps);
|
||||
const preview = fake.of('createFile')[1].args[0] as File;
|
||||
expect(preview.name).toBe('preview.png');
|
||||
expect(preview.type).toBe('image/png');
|
||||
});
|
||||
|
||||
it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste); der Anzeigename ist immer leer (die Seite zeigt den Benutzernamen)', async () => {
|
||||
const fake = fakeDeps();
|
||||
await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,' }, fake.deps);
|
||||
expect(fake.of('publishCard')[0].args[0]).toEqual({
|
||||
title: 'Meine Karte',
|
||||
issuer: 'Bank',
|
||||
tags: ['visa', 'gold'],
|
||||
displayName: '',
|
||||
imageFileId: 'f1',
|
||||
previewFileId: 'f2',
|
||||
sourceCardId: 'card1',
|
||||
});
|
||||
});
|
||||
|
||||
it('ein von einem alten Aufrufer mitgegebener Anzeigename geht nicht mehr mit', async () => {
|
||||
const fake = fakeDeps();
|
||||
await publishSavedCard(card, { ...form, displayName: 'Kenso' } as typeof form, fake.deps);
|
||||
expect(fake.of('publishCard')[0].args[0]).toMatchObject({ displayName: '' });
|
||||
});
|
||||
|
||||
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
|
||||
it.each([400, 401, 403, 404, 409])(
|
||||
'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an',
|
||||
async (code) => {
|
||||
const failure = new ApiError(code, 'Abgelehnt.');
|
||||
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||||
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([408, 429, 500, 502, 503, 504])(
|
||||
'unklarer Ausgang (%i): die Zeile kann angelegt sein, die Dateien bleiben liegen, der Fehler kommt an',
|
||||
async (code) => {
|
||||
const failure = new ApiError(code, 'Unklar.');
|
||||
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => {
|
||||
const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true);
|
||||
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||||
expect(fake.of('deleteFile')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ein anderer Fehler vor der Anfrage (z. B. createJWT mit 401): gelöscht', async () => {
|
||||
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
|
||||
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||||
expect(fake.of('deleteFile')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('zweiter Upload scheitert: nur der erste wird gelöscht, die API wird nicht gerufen', async () => {
|
||||
const failure = new Error('upload kaputt');
|
||||
let uploads = 0;
|
||||
const fake = fakeDeps({
|
||||
createFile: async (file, permissions) => {
|
||||
fake.calls.push({ name: 'createFile', args: [file, permissions] });
|
||||
if (++uploads === 2) throw failure;
|
||||
return { $id: 'f1' };
|
||||
},
|
||||
});
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||||
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']);
|
||||
expect(fake.of('publishCard')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('erster Upload scheitert: nichts zu löschen, kein zweiter Upload', async () => {
|
||||
const failure = new Error('upload kaputt');
|
||||
const fake = fakeDeps({ createFile: async () => Promise.reject(failure) });
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||
expect(fake.of('publishCard')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('löscht nie etwas anderes als die zwei Dateien dieses Versuchs (nicht die privaten Dateien der Karte)', async () => {
|
||||
const fake = fakeDeps({ publishCard: async () => Promise.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.')) });
|
||||
await publishSavedCard(card, form, fake.deps).catch(() => undefined);
|
||||
const deleted = fake.of('deleteFile').map((c) => c.args[0]);
|
||||
expect(deleted).not.toContain('privImage');
|
||||
expect(deleted).not.toContain('privPreview');
|
||||
expect(deleted.every((id) => id === 'f1' || id === 'f2')).toBe(true);
|
||||
});
|
||||
|
||||
it('ein Fehler beim Aufräumen überdeckt den ursprünglichen Fehler nicht und stoppt das Aufräumen nicht', async () => {
|
||||
const failure = new ApiError(400, 'Bitte gib einen Titel an.');
|
||||
const attempted: string[] = [];
|
||||
const fake = fakeDeps({
|
||||
publishCard: async () => Promise.reject(failure),
|
||||
deleteFile: async (id) => {
|
||||
attempted.push(id);
|
||||
throw new Error('delete kaputt');
|
||||
},
|
||||
});
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||||
expect(attempted.sort()).toEqual(['f1', 'f2']);
|
||||
});
|
||||
|
||||
it('bei Erfolg wird nichts gelöscht', async () => {
|
||||
const fake = fakeDeps();
|
||||
await publishSavedCard(card, form, fake.deps);
|
||||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('vor dem Hochladen', () => {
|
||||
it('ein PNG über 5 MiB wird nicht hochgeladen: „Das Kartenbild ist zu groß für die Galerie (max. 5 MB).“', async () => {
|
||||
const big = new Blob([new Uint8Array(PNG_MAX_BYTES + 1)], { type: 'image/png' });
|
||||
const fake = fakeDeps({ render: async () => ({ png: big, preview: webp() }) });
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow(
|
||||
'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).',
|
||||
);
|
||||
expect(fake.of('createFile')).toHaveLength(0);
|
||||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||
expect(fake.of('publishCard')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('genau 5 MiB sind erlaubt', async () => {
|
||||
const exact = new Blob([new Uint8Array(PNG_MAX_BYTES)], { type: 'image/png' });
|
||||
const fake = fakeDeps({ render: async () => ({ png: exact, preview: webp() }) });
|
||||
await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' });
|
||||
});
|
||||
|
||||
it('ein Bild mit unzulässigem Typ wird nicht hochgeladen', async () => {
|
||||
const fake = fakeDeps({ render: async () => ({ png: new Blob(['x'], { type: 'image/jpeg' }), preview: webp() }) });
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow('Das Kartenbild konnte nicht erzeugt werden.');
|
||||
expect(fake.of('createFile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('scheitert das Rendern, wird nichts hochgeladen oder gelöscht', async () => {
|
||||
const failure = new Error('Das Bild konnte nicht gelesen werden.');
|
||||
const fake = fakeDeps({ render: async () => Promise.reject(failure) });
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||||
expect(fake.names()).toEqual(['userId', 'loadImage']);
|
||||
});
|
||||
|
||||
it('scheitert das Laden des Bildes, passiert nichts weiter', async () => {
|
||||
const failure = Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' });
|
||||
const fake = fakeDeps({ loadImage: async () => Promise.reject(failure) });
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||||
expect(fake.names()).toEqual(['userId']);
|
||||
});
|
||||
|
||||
it('ohne angemeldeten Nutzer wird weder geladen noch gerendert', async () => {
|
||||
const failure = Object.assign(new Error('unauthorized'), { code: 401 });
|
||||
const fake = fakeDeps({
|
||||
userId: () => {
|
||||
throw failure;
|
||||
},
|
||||
});
|
||||
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
|
||||
expect(fake.names()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logo der Karte beim Veröffentlichen', () => {
|
||||
const photo = { source: {}, w: 3000, h: 2000 };
|
||||
const source = new Blob(['src'], { type: 'image/jpeg' });
|
||||
const withLogo = (logo: string | null, template: TemplateId = DEFAULT_CONFIG.template): SavedCard => ({
|
||||
...card,
|
||||
config: { ...DEFAULT_CONFIG, template, logo },
|
||||
});
|
||||
/** Die Vorlagen, die das Community-Logo zeichnen (alle außer „Nur Bild“). */
|
||||
const DRAWING = ['minimal', 'classic', 'signature'] as const;
|
||||
const assetsFor = (patch: Record<string, unknown> = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch });
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.decodeImage.mockReset().mockResolvedValue(photo);
|
||||
mocks.loadCardAssets.mockReset().mockResolvedValue(assetsFor());
|
||||
mocks.exportCard
|
||||
.mockReset()
|
||||
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
|
||||
});
|
||||
|
||||
it('LOGO_MISSING_PUBLISH: der Wortlaut aus der Spec, auf Englisch übersetzt', async () => {
|
||||
const { t, setLang } = await import('../i18n/i18n');
|
||||
expect(LOGO_MISSING_PUBLISH).toBe(
|
||||
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
|
||||
);
|
||||
try {
|
||||
setLang('en', { persist: false });
|
||||
expect(t(LOGO_MISSING_PUBLISH)).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.');
|
||||
} finally {
|
||||
setLang('de', { persist: false });
|
||||
}
|
||||
});
|
||||
|
||||
it('renderForGallery: rendert mit dem Logo der Karte als PNG (1536 px) und WebP-Vorschau (480 px)', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logo: { source: {}, w: 200, h: 100 } }));
|
||||
const out = await renderForGallery(withLogo('abc123'), source);
|
||||
expect(mocks.decodeImage).toHaveBeenCalledWith(source);
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), photo);
|
||||
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
|
||||
expect(mocks.exportCard.mock.calls[1][2]).toEqual({ width: 480, type: 'image/webp', quality: 0.85 });
|
||||
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
|
||||
});
|
||||
|
||||
it('eine Karte ohne Logo wird ganz normal gerendert', async () => {
|
||||
const out = await renderForGallery(withLogo(null), source);
|
||||
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: null }), photo);
|
||||
expect(out.png.type).toBe('image/png');
|
||||
});
|
||||
|
||||
it.each(DRAWING)(
|
||||
'fehlendes Logo (gelöscht oder ausgeblendet), Vorlage %s zeichnet es: Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert',
|
||||
async (template) => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||||
await expect(renderForGallery(withLogo('gone', template), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||||
expect(mocks.exportCard).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('fehlendes Logo bei „Nur Bild“ (image): die Vorlage zeichnet es nie, es wird ganz normal gerendert', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||||
const out = await renderForGallery(withLogo('gone', 'image'), source);
|
||||
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
|
||||
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
|
||||
});
|
||||
|
||||
it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => {
|
||||
const failure = new TypeError('Failed to fetch');
|
||||
mocks.loadCardAssets.mockRejectedValue(failure);
|
||||
await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it('publishSavedCard: classic mit fehlendem Logo – nichts wird hochgeladen, nichts gelöscht, die API nicht gerufen', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||||
const fake = fakeDeps({ render: renderForGallery });
|
||||
await expect(publishSavedCard(withLogo('gone', 'classic'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||||
expect(fake.names()).toEqual(['userId', 'loadImage']);
|
||||
expect(fake.of('createFile')).toHaveLength(0);
|
||||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||
expect(fake.of('publishCard')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('publishSavedCard: „Nur Bild“ (image) mit fehlendem Logo – wird veröffentlicht, als wäre nichts', async () => {
|
||||
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
|
||||
const fake = fakeDeps({ render: renderForGallery });
|
||||
await expect(publishSavedCard(withLogo('gone', 'image'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
|
||||
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
|
||||
expect(fake.of('deleteFile')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => {
|
||||
const fake = fakeDeps({ render: renderForGallery });
|
||||
await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
|
||||
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zwischenspeicher der Logos beim Veröffentlichen (ein ausgeblendetes Logo darf nicht in die Galerie)', () => {
|
||||
const photo = { source: {}, w: 3000, h: 2000 };
|
||||
const source = new Blob(['src'], { type: 'image/jpeg' });
|
||||
const logoRow = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
|
||||
id: 'zw1',
|
||||
name: 'Mein Logo',
|
||||
displayName: 'Kenso',
|
||||
fileId: 'zw1',
|
||||
ownerId: 'u1',
|
||||
mono: false,
|
||||
likeCount: 0,
|
||||
hidden: false,
|
||||
reportCount: 0,
|
||||
createdAt: '2026-10-04T10:00:00.000Z',
|
||||
...patch,
|
||||
});
|
||||
const withLogo = (id: string, template: TemplateId = 'classic'): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, template, logo: id } });
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.decodeImage.mockReset().mockResolvedValue(photo);
|
||||
mocks.exportCard
|
||||
.mockReset()
|
||||
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
|
||||
mocks.getLogo.mockReset().mockResolvedValue(logoRow());
|
||||
mocks.loadLogoBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' }));
|
||||
// Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter.
|
||||
mocks.loadCardAssets.mockReset().mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, image: unknown) => {
|
||||
const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null;
|
||||
return { image, logo, brand: null, logoMissing: Boolean(config.logo) && logo === null };
|
||||
});
|
||||
vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
forgetLogo('zw1');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('ein Logo, das nach dem ersten Laden (Editor, Zwischenspeicher) ausgeblendet wurde, wird neu geprüft: Abbruch, nichts wird hochgeladen', async () => {
|
||||
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull(); // der Editor hat es geladen: Zeile und Bild stecken im Zwischenspeicher
|
||||
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); // dann hat ein Admin oder die Melder es ausgeblendet
|
||||
const fake = fakeDeps({ render: renderForGallery });
|
||||
await expect(publishSavedCard(withLogo('zw1'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.exportCard).not.toHaveBeenCalled();
|
||||
expect(fake.of('createFile')).toHaveLength(0);
|
||||
expect(fake.of('publishCard')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ein Logo, das nach dem ersten Laden gelöscht wurde, ebenso', async () => {
|
||||
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull();
|
||||
mocks.getLogo.mockResolvedValue(null);
|
||||
await expect(renderForGallery(withLogo('zw1'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
|
||||
expect(mocks.exportCard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei gerendert (kein Zwischenspeicher-Treffer)', async () => {
|
||||
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull();
|
||||
const out = await renderForGallery(withLogo('zw1'), source);
|
||||
expect(out.png.type).toBe('image/png');
|
||||
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.exportCard.mock.calls[0][1]).toMatchObject({ logo: { w: 200, h: 100 } });
|
||||
});
|
||||
|
||||
it('beide Tonarten des Logos werden vergessen, nicht nur die der Karte', async () => {
|
||||
await loadLogoAsset('zw1', 'light');
|
||||
await loadLogoAsset('zw1', 'dark');
|
||||
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true }));
|
||||
await expect(renderForGallery({ ...withLogo('zw1'), config: { ...withLogo('zw1').config, details: 'dark' } }, source)).rejects.toThrow(
|
||||
LOGO_MISSING_PUBLISH,
|
||||
);
|
||||
await expect(loadLogoAsset('zw1', 'light')).resolves.toBeNull(); // auch das helle Bild ist weg: es wird neu geprüft
|
||||
});
|
||||
|
||||
it('eine Karte ohne Logo vergisst und holt nichts', async () => {
|
||||
const out = await renderForGallery({ ...card, config: { ...DEFAULT_CONFIG, logo: null } }, source);
|
||||
expect(out.png.type).toBe('image/png');
|
||||
expect(mocks.getLogo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('„Nur Bild“ (image) zeichnet nie ein Logo: auch ein inzwischen ausgeblendetes Logo hindert das Veröffentlichen nicht', async () => {
|
||||
await loadLogoAsset('zw1', 'light');
|
||||
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true }));
|
||||
const out = await renderForGallery(withLogo('zw1', 'image'), source);
|
||||
expect(out.png.type).toBe('image/png');
|
||||
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertLogoAvailable', () => {
|
||||
const config = (template: TemplateId) => ({ ...DEFAULT_CONFIG, template, logo: 'abc123' });
|
||||
const assets = (logoMissing: boolean) => ({ image: null, logo: null, brand: null, logoMissing });
|
||||
|
||||
it.each(['minimal', 'classic', 'signature'] as const)('Logo fehlt und Vorlage %s zeichnet es: wirft LOGO_MISSING_PUBLISH', (template) => {
|
||||
expect(() => assertLogoAvailable(config(template), assets(true))).toThrow(LOGO_MISSING_PUBLISH);
|
||||
});
|
||||
|
||||
it('Logo fehlt, aber „Nur Bild“ zeichnet nie ein Logo: wirft nicht', () => {
|
||||
expect(() => assertLogoAvailable(config('image'), assets(true))).not.toThrow();
|
||||
});
|
||||
|
||||
it.each(['image', 'minimal', 'classic', 'signature'] as const)('Logo ist da (oder keins gewählt): Vorlage %s wirft nicht', (template) => {
|
||||
expect(() => assertLogoAvailable(config(template), assets(false))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
|
||||
const json = (status: number, body: unknown) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise<Response>>();
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.createJWT.mockReset();
|
||||
mocks.createJWT.mockResolvedValue({ jwt: 'jwt' });
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Läuft den ganzen Ablauf mit der echten publishCard; `respond` erzeugt bei jedem Aufruf eine neue Antwort. */
|
||||
async function run(respond: () => Promise<Response>) {
|
||||
fetchMock.mockImplementation(respond);
|
||||
const fake = fakeDeps({ publishCard });
|
||||
const result = await publishSavedCard(card, form, fake.deps).catch((e: unknown) => e);
|
||||
return { fake, result, deleted: fake.of('deleteFile').map((c) => c.args[0]).sort() };
|
||||
}
|
||||
|
||||
it('201 mit id: Erfolg, nichts gelöscht', async () => {
|
||||
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
|
||||
expect(result).toEqual({ id: 'g1' });
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['400 Validierung', async () => json(400, { error: 'Bitte gib einen Titel an.' })],
|
||||
['401 (auch nach der Wiederholung)', async () => json(401, { error: 'Bitte melde dich an.' })],
|
||||
['403 fremde Datei', async () => json(403, { error: 'Diese Datei gehört dir nicht.' })],
|
||||
['403 Dateien belegt oder gesperrt', async () => json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' })],
|
||||
['409 schon veröffentlicht', async () => json(409, { error: 'Diese Karte ist schon veröffentlicht.' })],
|
||||
])('%s: beide Dateien werden gelöscht', async (_name, respond) => {
|
||||
const { result, deleted } = await run(respond);
|
||||
expect(result).toBeInstanceOf(ApiError);
|
||||
expect(deleted).toEqual(['f1', 'f2']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['429 Drosselung', async () => json(429, { error: 'Zu viele Anfragen.' })],
|
||||
['408 Zeitüberschreitung', async () => json(408, { error: 'Zeit abgelaufen.' })],
|
||||
['500 Serverfehler', async () => json(500, { error: 'Interner Fehler.' })],
|
||||
['502 Dienst nicht erreichbar', async () => json(502, { error: 'Der Dienst ist gerade nicht erreichbar.' })],
|
||||
['503 vom Proxy', async () => new Response('<html>503</html>', { status: 503 })],
|
||||
['504 vom Proxy', async () => new Response('<html>504</html>', { status: 504 })],
|
||||
['Netzfehler (Antwort verloren)', async () => Promise.reject(new TypeError('Failed to fetch'))],
|
||||
['Zeitüberschreitung des Clients', async () => Promise.reject(new DOMException('timed out', 'TimeoutError'))],
|
||||
['HTML mit Status 200', async () => new Response('<!doctype html>', { status: 200 })],
|
||||
['201 ohne id', async () => json(201, {})],
|
||||
])('%s: Ausgang offen, die Dateien bleiben liegen', async (_name, respond) => {
|
||||
const { result, deleted } = await run(respond);
|
||||
expect(result).toBeInstanceOf(ApiError);
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
|
||||
it('createJWT scheitert mit 401 (keine Sitzung): es ging nichts raus, die Dateien werden gelöscht', async () => {
|
||||
mocks.createJWT.mockRejectedValue(Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }));
|
||||
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
|
||||
expect(result).toMatchObject({ code: 401 });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(deleted).toEqual(['f1', 'f2']);
|
||||
});
|
||||
|
||||
it('JWT-Methode am Server aus: es ging nichts raus, die Dateien werden gelöscht', async () => {
|
||||
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
|
||||
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
|
||||
expect(result).toBeInstanceOf(ApiError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(deleted).toEqual(['f1', 'f2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeTags', () => {
|
||||
it('trennt an Kommas, trimmt, schreibt klein und entfernt Leere und Doppelte', () => {
|
||||
expect(normalizeTags(' Visa, Gold ,, visa ,GOLD, Karte ')).toEqual(['visa', 'gold', 'karte']);
|
||||
});
|
||||
|
||||
it('leere Eingabe ergibt eine leere Liste', () => {
|
||||
expect(normalizeTags('')).toEqual([]);
|
||||
expect(normalizeTags(' , , ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('behält die Reihenfolge der ersten Nennung', () => {
|
||||
expect(normalizeTags('b, a, b, c, a')).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('kürzt nichts: Grenzen (5 Tags, 20 Zeichen) prüft der Server mit deutscher Meldung', () => {
|
||||
const long = 'x'.repeat(30);
|
||||
expect(normalizeTags(`a,b,c,d,e,f,${long}`)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', long]);
|
||||
});
|
||||
|
||||
it('Umlaute werden mit klein geschrieben', () => {
|
||||
expect(normalizeTags('ÄÖÜ, Straße')).toEqual(['äöü', 'straße']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen.
|
||||
// Aufgeräumt wird nur bei eindeutigem Scheitern (Upload-Fehler, eindeutige Ablehnung der API): dann löscht der Client genau
|
||||
// die zwei Dateien dieses Versuchs wieder. Bleibt der Ausgang offen, bleiben sie liegen (siehe publishSavedCard).
|
||||
import { ID, Permission, Role } from 'appwrite';
|
||||
import { IDS, storage } from '../account/appwrite';
|
||||
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
|
||||
import type { CardConfig } from '../card/config';
|
||||
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH, layoutFor } from '../card/geometry';
|
||||
import { clampCrop } from '../card/crop';
|
||||
import { exportCard, loadCardAssets, type LoadedAssets } from '../card/render';
|
||||
import { decodeImage } from '../editor/upload';
|
||||
import { msg } from '../i18n/i18n';
|
||||
import { forgetLogo } from '../logos/logo-asset';
|
||||
import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
|
||||
|
||||
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
|
||||
export const PNG_MAX_BYTES = 5_242_880;
|
||||
// Die Meldungen dieser Datei gehen deutsch markiert in den Fehler; der Veröffentlichen-Dialog zeigt sie mit t().
|
||||
export const TOO_LARGE = msg('Das Kartenbild ist zu groß für die Galerie (max. 5 MB).');
|
||||
export const NOT_RENDERED = msg('Das Kartenbild konnte nicht erzeugt werden.');
|
||||
/** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: ohne dieses Logo wird sie weder veröffentlicht noch heruntergeladen. */
|
||||
export const LOGO_MISSING_PUBLISH = msg(
|
||||
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
|
||||
);
|
||||
const GALLERY_TYPES = ['image/png', 'image/webp'];
|
||||
|
||||
export interface PublishForm {
|
||||
title: string;
|
||||
issuer: string;
|
||||
/** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */
|
||||
tags: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Anzeigename, den der Client mitschickt: keiner. Seit den Benutzernamen (Spec § 5) zeigt die Detailseite den Namen des Kontos;
|
||||
* der Dienst nimmt das Feld weiter an (der Altbestand trägt noch Anzeigenamen).
|
||||
*/
|
||||
const NO_DISPLAY_NAME = '';
|
||||
|
||||
/** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */
|
||||
export interface PublishDeps {
|
||||
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
|
||||
userId(): string;
|
||||
/** Das Originalbild der privaten Karte. */
|
||||
loadImage(card: SavedCard): Promise<Blob>;
|
||||
/** Rendert Kartenbild (PNG, 1536 px) und Vorschau (WebP, 480 px). */
|
||||
render(card: SavedCard, image: Blob): Promise<{ png: Blob; preview: Blob }>;
|
||||
createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
|
||||
deleteFile(fileId: string): Promise<void>;
|
||||
publishCard(input: PublishInput): Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
/** Wie der Server: an Kommas trennen, trimmen, klein schreiben, Leere und Doppelte entfernen. Grenzen prüft der Server. */
|
||||
export function normalizeTags(input: string): string[] {
|
||||
const tags: string[] = [];
|
||||
for (const part of input.split(',')) {
|
||||
const tag = part.trim().toLowerCase();
|
||||
if (tag !== '' && !tags.includes(tag)) tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft nach dem Laden der Bilder einer gespeicherten Karte, ob ihr beim Rendern etwas fehlt (Veröffentlichen und Herunterladen
|
||||
* aus „Meine Karten“): Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet) und zeichnet die Vorlage es, wirft
|
||||
* das LOGO_MISSING_PUBLISH. So wird nie eine andere Karte veröffentlicht oder heruntergeladen, als der Nutzer sie gestaltet hat.
|
||||
* Bei „Nur Bild“ wird ein Logo nie gezeichnet: dort fehlt nichts, und es wird ganz normal gerendert.
|
||||
*/
|
||||
export function assertLogoAvailable(config: CardConfig, assets: LoadedAssets): void {
|
||||
if (assets.logoMissing && layoutFor(config.template).logo) throw new Error(LOGO_MISSING_PUBLISH);
|
||||
}
|
||||
|
||||
export async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
|
||||
const image = await decodeImage(imageBlob);
|
||||
const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
|
||||
// Zeile und Bild des Logos neu vom Server prüfen: der Zwischenspeicher lebt so lange wie die Seite, ein inzwischen ausgeblendetes oder
|
||||
// gelöschtes Logo käme sonst aus ihm in ein öffentliches Kartenbild.
|
||||
if (config.logo) forgetLogo(config.logo);
|
||||
const assets = await loadCardAssets(config, image);
|
||||
// Lieber abbrechen, als eine Karte ohne ihr Logo zu veröffentlichen.
|
||||
assertLogoAvailable(config, assets);
|
||||
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
|
||||
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
|
||||
return { png, preview };
|
||||
}
|
||||
|
||||
function defaultDeps(): PublishDeps {
|
||||
const service = cardsService();
|
||||
return {
|
||||
userId: requireUserId,
|
||||
loadImage: (card) => service.loadImage(card),
|
||||
render: renderForGallery,
|
||||
// Immer eine frische ID: aufgeräumt wird nur, was dieser Versuch selbst angelegt hat.
|
||||
createFile: (file, permissions) =>
|
||||
storage.createFile({ bucketId: IDS.galleryBucket, fileId: ID.unique(), file, permissions }),
|
||||
deleteFile: async (fileId) => {
|
||||
await storage.deleteFile({ bucketId: IDS.galleryBucket, fileId });
|
||||
},
|
||||
publishCard,
|
||||
};
|
||||
}
|
||||
|
||||
/** Räumt auf, soweit es geht; ein Fehler (auch 404) darf den eigentlichen Fehler nie überdecken. */
|
||||
async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise<void> {
|
||||
await Promise.allSettled(fileIds.map((id) => deps.deleteFile(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlicht die Karte: Bild laden, rendern, PNG und Vorschau in den Galerie-Bucket laden, API rufen.
|
||||
*
|
||||
* Aufräumen (nur die zwei Dateien dieses Versuchs, nie etwas anderes):
|
||||
* - Scheitert ein Upload, ist noch nichts veröffentlicht: der erste Upload wird gelöscht.
|
||||
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409 und 403 `usernameRequired`, oder schon der JWT-Abruf), wurde nichts
|
||||
* angelegt: beide Dateien werden gelöscht.
|
||||
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API die Zeile schon
|
||||
* angelegt haben. Dann bleiben die Dateien liegen: sonst zeigte eine öffentliche Karte auf gelöschte Bilder.
|
||||
* (Ein Waisen-Upload ist harmlos: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.)
|
||||
* Der Fehler wird in jedem Fall weitergeworfen.
|
||||
*/
|
||||
export async function publishSavedCard(
|
||||
card: SavedCard,
|
||||
form: PublishForm,
|
||||
deps: PublishDeps = defaultDeps(),
|
||||
): Promise<{ id: string }> {
|
||||
// Ohne Nutzer wirft das hier, bevor etwas geladen oder gerendert wird.
|
||||
const userId = deps.userId();
|
||||
const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))];
|
||||
|
||||
const source = await deps.loadImage(card);
|
||||
const { png, preview } = await deps.render(card, source);
|
||||
// Alles vorab prüfen: ein unzulässiges oder zu großes Bild darf keine halben Uploads hinterlassen.
|
||||
if (png.type !== 'image/png' || !GALLERY_TYPES.includes(preview.type)) throw new Error(NOT_RENDERED);
|
||||
if (png.size > PNG_MAX_BYTES || preview.size > PNG_MAX_BYTES) throw new Error(TOO_LARGE);
|
||||
const imageFile = toUploadFile(png, 'image');
|
||||
// Der Name folgt dem echten Typ: Browser ohne WebP-Export liefern für „webp“ ein PNG.
|
||||
const previewFile = toUploadFile(preview, 'preview');
|
||||
|
||||
const uploaded: string[] = [];
|
||||
try {
|
||||
uploaded.push((await deps.createFile(imageFile, permissions)).$id);
|
||||
uploaded.push((await deps.createFile(previewFile, permissions)).$id);
|
||||
} catch (err) {
|
||||
// Die API wurde noch nicht gerufen: es gibt keine Zeile, die auf die Dateien zeigen könnte.
|
||||
await deleteQuietly(deps, uploaded);
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
return await deps.publishCard({
|
||||
title: form.title.trim(),
|
||||
issuer: form.issuer.trim(),
|
||||
tags: normalizeTags(form.tags),
|
||||
displayName: NO_DISPLAY_NAME,
|
||||
imageFileId: uploaded[0],
|
||||
previewFileId: uploaded[1],
|
||||
sourceCardId: card.id,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isDefinitiveFailure(err)) await deleteQuietly(deps, uploaded);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// api.ts hängt am Appwrite-Client; der Dialog bekommt `send` von außen, ApiError wird nur als Klasse gebraucht.
|
||||
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
|
||||
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
|
||||
|
||||
import { setLang } from '../i18n/i18n';
|
||||
import { ApiError, type ReportReason } from './api';
|
||||
import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog';
|
||||
|
||||
type Send = (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
|
||||
|
||||
const send = vi.fn<Send>();
|
||||
|
||||
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.report-dialog');
|
||||
const buttonByText = (text: string) =>
|
||||
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
|
||||
const radios = () => [...(dialogEl()?.querySelectorAll<HTMLInputElement>('input[type="radio"]') ?? [])];
|
||||
const choose = (value: string) => {
|
||||
const radio = radios().find((r) => r.value === value) as HTMLInputElement;
|
||||
radio.checked = true;
|
||||
radio.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
const submit = () => dialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
|
||||
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function open(opts: { cardTitle?: string; signal?: AbortSignal; subject?: 'card' | 'logo' } = {}): Promise<ReportOutcome> {
|
||||
return reportDialog({ send, ...opts });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
send.mockReset();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('Melden-Dialog', () => {
|
||||
it('zeigt die fünf Gründe in der Reihenfolge der Spec, keiner ist vorgewählt', () => {
|
||||
void open();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
|
||||
expect(REPORT_REASONS.map((r) => r.label)).toEqual([
|
||||
'Unangemessen',
|
||||
'Echte Kartendaten',
|
||||
'Urheberrecht/Marke',
|
||||
'Spam',
|
||||
'Sonstiges',
|
||||
]);
|
||||
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
|
||||
'Unangemessen',
|
||||
'Echte Kartendaten',
|
||||
'Urheberrecht/Marke',
|
||||
'Spam',
|
||||
'Sonstiges',
|
||||
]);
|
||||
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
|
||||
expect(radios().some((r) => r.checked)).toBe(false);
|
||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||
expect(buttonByText('Melden')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('nennt den Kartentitel als Text, nie als HTML', () => {
|
||||
void open({ cardTitle: '<img src=x onerror=alert(1)>' });
|
||||
expect(dialogEl()!.querySelector('img')).toBeNull();
|
||||
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
|
||||
it('ohne Grund: Hinweis, nichts wird gesendet', async () => {
|
||||
void open();
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Bitte wähle einen Grund.');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unangemessen', 'unangemessen'],
|
||||
['kartendaten', 'kartendaten'],
|
||||
['urheberrecht', 'urheberrecht'],
|
||||
['spam', 'spam'],
|
||||
['sonstiges', 'sonstiges'],
|
||||
])('Grund %s geht als %s an die API', async (value, expected) => {
|
||||
send.mockResolvedValue({ schonGemeldet: false });
|
||||
void open();
|
||||
choose(value);
|
||||
submit();
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it('neue Meldung: Dank im Dialog, „Schließen“ beendet mit „reported“', async () => {
|
||||
send.mockResolvedValue({ schonGemeldet: false });
|
||||
const result = open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(statusText()).toBe('Danke für deine Meldung.');
|
||||
expect(errorText()).toBe('');
|
||||
expect(radios()).toHaveLength(0);
|
||||
expect(buttonByText('Melden')).toBeUndefined();
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
buttonByText('Schließen')!.click();
|
||||
await expect(result).resolves.toBe('reported');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('doppelte Meldung: „Danke, du hast diese Karte schon gemeldet.“ und „duplicate“', async () => {
|
||||
send.mockResolvedValue({ schonGemeldet: true });
|
||||
const result = open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(statusText()).toBe('Danke, du hast diese Karte schon gemeldet.');
|
||||
buttonByText('Schließen')!.click();
|
||||
await expect(result).resolves.toBe('duplicate');
|
||||
});
|
||||
|
||||
it('Drosselung (429): der Servertext erscheint, der Dialog bleibt und lässt einen neuen Versuch zu', async () => {
|
||||
send
|
||||
.mockRejectedValueOnce(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.'))
|
||||
.mockResolvedValueOnce({ schonGemeldet: false });
|
||||
const result = open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Zu viele Meldungen – bitte später nochmal.');
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(buttonByText('Melden')!.disabled).toBe(false);
|
||||
expect(radios().every((r) => !r.disabled)).toBe(true);
|
||||
// Die Auswahl bleibt stehen.
|
||||
expect(radios().find((r) => r.checked)?.value).toBe('spam');
|
||||
|
||||
submit();
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(errorText()).toBe('');
|
||||
expect(statusText()).toBe('Danke für deine Meldung.');
|
||||
buttonByText('Schließen')!.click();
|
||||
await expect(result).resolves.toBe('reported');
|
||||
});
|
||||
|
||||
it('andere Fehler des Dienstes zeigen ihren Servertext (z. B. 404, 503)', async () => {
|
||||
send.mockRejectedValueOnce(new ApiError(404, 'Diese Karte gibt es nicht (mehr).'));
|
||||
void open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Diese Karte gibt es nicht (mehr).');
|
||||
|
||||
send.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
|
||||
});
|
||||
|
||||
it('ein unerwarteter Fehler (kein ApiError) bekommt einen allgemeinen deutschen Text', async () => {
|
||||
send.mockRejectedValueOnce(new TypeError('offline'));
|
||||
void open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Keine Verbindung zum Server.');
|
||||
});
|
||||
|
||||
it('während des Sendens ist alles gesperrt, ein zweites Absenden sendet nichts', async () => {
|
||||
let finish!: (v: { schonGemeldet: boolean }) => void;
|
||||
send.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
void open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(buttonByText('Melden')!.disabled).toBe(true);
|
||||
expect(radios().every((r) => r.disabled)).toBe(true);
|
||||
expect(dialogEl()!.querySelector('form')!.getAttribute('aria-busy')).toBe('true');
|
||||
submit();
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
finish({ schonGemeldet: false });
|
||||
await settle();
|
||||
expect(statusText()).toBe('Danke für deine Meldung.');
|
||||
});
|
||||
|
||||
it('Abbrechen schließt ohne zu senden: „cancelled“', async () => {
|
||||
const result = open();
|
||||
buttonByText('Abbrechen')!.click();
|
||||
await expect(result).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen ebenfalls', async () => {
|
||||
const first = open();
|
||||
dialogEl()!.dispatchEvent(new Event('close'));
|
||||
await expect(first).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
|
||||
const second = open();
|
||||
const dialog = dialogEl()!;
|
||||
dialog.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
dialog.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await expect(second).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ein Klick im Inneren schließt nicht', () => {
|
||||
void open();
|
||||
dialogEl()!.querySelector('.report-card')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
});
|
||||
|
||||
it('das Signal (Seite verlassen) schließt den Dialog; eine spät eintreffende Antwort ändert nichts', async () => {
|
||||
let finish!: (v: { schonGemeldet: boolean }) => void;
|
||||
send.mockReturnValue(new Promise((r) => (finish = r)));
|
||||
const controller = new AbortController();
|
||||
const result = open({ signal: controller.signal });
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
controller.abort();
|
||||
await expect(result).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
finish({ schonGemeldet: false });
|
||||
await settle();
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
|
||||
it('ein schon abgebrochenes Signal öffnet gar nicht erst', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await expect(open({ signal: controller.signal })).resolves.toBe('cancelled');
|
||||
expect(dialogEl()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Melden-Dialog für Logos (subject: „logo“)', () => {
|
||||
const reasonLabels = () => [...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent);
|
||||
|
||||
it('Überschrift „Logo melden“, Einleitung nennt „dieses Logo“, vier Gründe ohne „Echte Kartendaten“', () => {
|
||||
void open({ subject: 'logo' });
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo melden');
|
||||
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
|
||||
expect(reasonLabels()).toEqual(['Unangemessen', 'Urheberrecht/Marke', 'Spam', 'Sonstiges']);
|
||||
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'urheberrecht', 'spam', 'sonstiges']);
|
||||
expect(radios().some((r) => r.checked)).toBe(false);
|
||||
expect(buttonByText('Abbrechen')).toBeTruthy();
|
||||
expect(buttonByText('Melden')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('die Gründe der Karten bleiben unverändert (REPORT_REASONS enthält weiter „Echte Kartendaten“)', () => {
|
||||
expect(REPORT_REASONS.map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
|
||||
});
|
||||
|
||||
it('der Titel der Karte spielt für Logos keine Rolle: die Einleitung nennt immer „dieses Logo“', () => {
|
||||
void open({ subject: 'logo', cardTitle: 'Urlaub' });
|
||||
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
|
||||
});
|
||||
|
||||
it.each(['unangemessen', 'urheberrecht', 'spam', 'sonstiges'] as const)('Grund %s geht unverändert an send', async (value) => {
|
||||
send.mockResolvedValue({ schonGemeldet: false });
|
||||
void open({ subject: 'logo' });
|
||||
choose(value);
|
||||
submit();
|
||||
await settle();
|
||||
expect(send).toHaveBeenCalledExactlyOnceWith(value);
|
||||
});
|
||||
|
||||
it('ohne Grund: derselbe Hinweis, nichts wird gesendet', async () => {
|
||||
void open({ subject: 'logo' });
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Bitte wähle einen Grund.');
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('neue Meldung: derselbe Dank, „reported“', async () => {
|
||||
send.mockResolvedValue({ schonGemeldet: false });
|
||||
const result = open({ subject: 'logo' });
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(statusText()).toBe('Danke für deine Meldung.');
|
||||
buttonByText('Schließen')!.click();
|
||||
await expect(result).resolves.toBe('reported');
|
||||
});
|
||||
|
||||
it('doppelte Meldung: „Danke, du hast dieses Logo schon gemeldet.“ und „duplicate“', async () => {
|
||||
send.mockResolvedValue({ schonGemeldet: true });
|
||||
const result = open({ subject: 'logo' });
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(statusText()).toBe('Danke, du hast dieses Logo schon gemeldet.');
|
||||
buttonByText('Schließen')!.click();
|
||||
await expect(result).resolves.toBe('duplicate');
|
||||
});
|
||||
|
||||
it('Fehler des Dienstes erscheinen wie bei Karten im Dialog, der offen bleibt', async () => {
|
||||
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
|
||||
void open({ subject: 'logo' });
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
|
||||
expect(dialogEl()?.open).toBe(true);
|
||||
expect(buttonByText('Melden')!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('ein Dialog mit subject „card“ ist derselbe wie ohne Angabe', () => {
|
||||
void open({ subject: 'card', cardTitle: 'Urlaub' });
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
|
||||
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du „Urlaub“ melden?');
|
||||
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Melden-Dialog auf Englisch', () => {
|
||||
beforeEach(() => setLang('en', { persist: false }));
|
||||
|
||||
it('Karte: Überschrift, Einleitung, Gründe, Knöpfe', () => {
|
||||
void open({ cardTitle: 'Holiday' });
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report card');
|
||||
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report “Holiday”?');
|
||||
expect(dialogEl()!.querySelector('legend')!.textContent).toBe('Reason for the report');
|
||||
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
|
||||
'Inappropriate',
|
||||
'Real card data',
|
||||
'Copyright/trademark',
|
||||
'Spam',
|
||||
'Other',
|
||||
]);
|
||||
expect(buttonByText('Cancel')).toBeTruthy();
|
||||
expect(buttonByText('Report')).toBeTruthy();
|
||||
// ohne Titel
|
||||
dialogEl()!.remove();
|
||||
void open();
|
||||
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this card?');
|
||||
});
|
||||
|
||||
it('Logo: Überschrift, Einleitung, vier Gründe, Dank und Duplikat', async () => {
|
||||
send.mockResolvedValueOnce({ schonGemeldet: true });
|
||||
const result = open({ subject: 'logo' });
|
||||
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report logo');
|
||||
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this logo?');
|
||||
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
|
||||
'Inappropriate',
|
||||
'Copyright/trademark',
|
||||
'Spam',
|
||||
'Other',
|
||||
]);
|
||||
submit(); // ohne Grund
|
||||
await settle();
|
||||
expect(errorText()).toBe('Please choose a reason.');
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(statusText()).toBe('Thanks, you’ve already reported this logo.');
|
||||
buttonByText('Close')!.click();
|
||||
await expect(result).resolves.toBe('duplicate');
|
||||
});
|
||||
|
||||
it('Dank und Duplikat bei Karten', async () => {
|
||||
send.mockResolvedValueOnce({ schonGemeldet: false }).mockResolvedValueOnce({ schonGemeldet: true });
|
||||
const first = open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(statusText()).toBe('Thanks for your report.');
|
||||
buttonByText('Close')!.click();
|
||||
await first;
|
||||
const second = open();
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(statusText()).toBe('Thanks, you’ve already reported this card.');
|
||||
buttonByText('Close')!.click();
|
||||
await second;
|
||||
});
|
||||
|
||||
it('Fehler des Dienstes erscheinen übersetzt, wenn der Schlüssel bekannt ist', async () => {
|
||||
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
|
||||
void open({ subject: 'logo' });
|
||||
choose('spam');
|
||||
submit();
|
||||
await settle();
|
||||
expect(errorText()).toBe('This logo is no longer available.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// Melden-Dialog für Karten (Detailseite) und Logos (Editor): Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen.
|
||||
// Eigenes <dialog> wie confirm-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
|
||||
import '../account/account.css';
|
||||
import './community.css';
|
||||
import { errorMessage } from '../account/errors';
|
||||
import { el } from '../editor/dom';
|
||||
import { msg, t } from '../i18n/i18n';
|
||||
import { ApiError, apiErrorText, type ReportReason } from './api';
|
||||
|
||||
/** Die Gründe in der Reihenfolge und mit dem Wortlaut der Spec (§ 5), dazu der Wert für die API. */
|
||||
export const REPORT_REASONS: ReadonlyArray<{ value: ReportReason; label: string }> = [
|
||||
{ value: 'unangemessen', label: msg('Unangemessen') },
|
||||
{ value: 'kartendaten', label: msg('Echte Kartendaten') },
|
||||
{ value: 'urheberrecht', label: msg('Urheberrecht/Marke') },
|
||||
{ value: 'spam', label: msg('Spam') },
|
||||
{ value: 'sonstiges', label: msg('Sonstiges') },
|
||||
];
|
||||
|
||||
/** Ein Logo hat keine Kartendaten: für Logos entfällt „Echte Kartendaten“ (Spec § 6). */
|
||||
const LOGO_REASONS = REPORT_REASONS.filter((reason) => reason.value !== 'kartendaten');
|
||||
|
||||
export const REPORT_THANKS = msg('Danke für deine Meldung.');
|
||||
/** Wortlaut aus Spec § 11. */
|
||||
export const REPORT_DUPLICATE = msg('Danke, du hast diese Karte schon gemeldet.');
|
||||
export const LOGO_REPORT_DUPLICATE = msg('Danke, du hast dieses Logo schon gemeldet.');
|
||||
const NO_REASON = msg('Bitte wähle einen Grund.');
|
||||
|
||||
/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte (das Logo) schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */
|
||||
export type ReportOutcome = 'reported' | 'duplicate' | 'cancelled';
|
||||
|
||||
/** Was gemeldet wird: eine Karte (Standard) oder ein Logo. */
|
||||
export type ReportSubject = 'card' | 'logo';
|
||||
|
||||
export interface ReportDialogOptions {
|
||||
/** Sendet die Meldung (`reportCard` bzw. `reportLogo` der API). */
|
||||
send: (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
|
||||
/** Titel der Karte für die Überschrift des Textes (als Text); für Logos ohne Wirkung (dort heißt es „dieses Logo“). */
|
||||
cardTitle?: string;
|
||||
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
|
||||
signal?: AbortSignal;
|
||||
/** Karte (Standard) oder Logo: Überschrift, Einleitung, Dank bei Duplikat und die Gründe (ohne „Echte Kartendaten“) folgen daraus. */
|
||||
subject?: ReportSubject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem Ergebnis der letzten Meldung, sonst mit `cancelled`.
|
||||
* Fehler des Dienstes erscheinen im Dialog in der Sprache der Seite (`apiErrorText`: der Text zum Schlüssel des Fehlers, nicht der
|
||||
* Servertext); der Dialog bleibt dann offen für einen neuen Versuch.
|
||||
*/
|
||||
export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome> {
|
||||
return new Promise<ReportOutcome>((resolve, reject) => {
|
||||
if (opts.signal?.aborted) {
|
||||
resolve('cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const logo = opts.subject === 'logo';
|
||||
const choices = logo ? LOGO_REASONS : REPORT_REASONS;
|
||||
let outcome: ReportOutcome = 'cancelled';
|
||||
let closed = false;
|
||||
let busy = false;
|
||||
|
||||
const radios = choices.map(({ value }) => el('input', { type: 'radio', name: 'reason', value }));
|
||||
const reasons = el(
|
||||
'fieldset',
|
||||
{ class: 'report-reasons' },
|
||||
el('legend', { class: 'report-legend' }, t('Grund der Meldung')),
|
||||
...choices.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], t(label))),
|
||||
);
|
||||
const intro = el(
|
||||
'p',
|
||||
{ class: 'report-text' },
|
||||
logo
|
||||
? t('Warum möchtest du dieses Logo melden?')
|
||||
: opts.cardTitle
|
||||
? t('Warum möchtest du „{title}“ melden?', { title: opts.cardTitle })
|
||||
: t('Warum möchtest du diese Karte melden?'),
|
||||
);
|
||||
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
|
||||
const statusEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
|
||||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
|
||||
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Melden'));
|
||||
const actions = el('div', { class: 'report-actions' }, cancel, send);
|
||||
const form = el('form', { class: 'report-form' }, intro, reasons, errorEl, statusEl, actions);
|
||||
const dialog = el(
|
||||
'dialog',
|
||||
{ class: 'report-dialog', 'aria-labelledby': 'report-title' },
|
||||
el(
|
||||
'div',
|
||||
{ class: 'report-card' },
|
||||
el('h2', { id: 'report-title', class: 'report-title' }, logo ? t('Logo melden') : t('Karte melden')),
|
||||
form,
|
||||
),
|
||||
);
|
||||
|
||||
const finish = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
opts.signal?.removeEventListener('abort', finish);
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
if (opener?.isConnected) opener.focus();
|
||||
resolve(outcome);
|
||||
};
|
||||
|
||||
function setBusy(value: boolean): void {
|
||||
busy = value;
|
||||
form.setAttribute('aria-busy', String(value));
|
||||
for (const radio of radios) radio.disabled = value;
|
||||
send.disabled = value;
|
||||
}
|
||||
|
||||
function showResult(duplicate: boolean): void {
|
||||
outcome = duplicate ? 'duplicate' : 'reported';
|
||||
errorEl.textContent = '';
|
||||
statusEl.textContent = t(duplicate ? (logo ? LOGO_REPORT_DUPLICATE : REPORT_DUPLICATE) : REPORT_THANKS);
|
||||
intro.remove();
|
||||
reasons.remove();
|
||||
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
|
||||
close.addEventListener('click', finish);
|
||||
actions.replaceChildren(close);
|
||||
close.focus();
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const chosen = choices.find((_, i) => radios[i].checked);
|
||||
if (!chosen) {
|
||||
statusEl.textContent = '';
|
||||
errorEl.textContent = t(NO_REASON);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
errorEl.textContent = '';
|
||||
statusEl.textContent = '';
|
||||
try {
|
||||
const res = await opts.send(chosen.value);
|
||||
if (closed) return;
|
||||
showResult(res.schonGemeldet);
|
||||
} catch (err) {
|
||||
if (closed) return;
|
||||
// Texte des Dienstes (auch für 429) kommen über ihren Schlüssel in der Sprache der Seite; alles andere (Netz, JWT) über errorMessage.
|
||||
errorEl.textContent = err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load');
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (busy || outcome !== 'cancelled') return;
|
||||
void submit();
|
||||
});
|
||||
cancel.addEventListener('click', finish);
|
||||
// Esc schließt nativ und löst `close` aus.
|
||||
dialog.addEventListener('close', finish);
|
||||
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
|
||||
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
|
||||
let pressedOnBackdrop = false;
|
||||
dialog.addEventListener('mousedown', (event) => {
|
||||
pressedOnBackdrop = event.target === dialog;
|
||||
});
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target === dialog && pressedOnBackdrop) finish();
|
||||
pressedOnBackdrop = false;
|
||||
});
|
||||
opts.signal?.addEventListener('abort', finish);
|
||||
|
||||
document.body.append(dialog);
|
||||
try {
|
||||
dialog.showModal();
|
||||
} catch (err) {
|
||||
closed = true;
|
||||
opts.signal?.removeEventListener('abort', finish);
|
||||
dialog.remove();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Konfiguration der Website.
|
||||
|
||||
/**
|
||||
* Öffentlicher Spenden-Link (Buy Me a Coffee, `https://buymeacoffee.com/<name>`).
|
||||
* Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt.
|
||||
*/
|
||||
export const DONATE_URL: string = 'https://buymeacoffee.com/aircard';
|
||||
|
||||
/** Original-Repo der App AirCard-iOS auf GitHub; Ziel des GitHub-Links in der Kopfzeile. */
|
||||
export const GITHUB_URL = 'https://github.com/Mak5er/AirCard-iOS';
|
||||
@@ -67,7 +67,7 @@ beforeEach(() => {
|
||||
|
||||
describe('bindEditorToAuth', () => {
|
||||
it('Abmelden: der Editor wird ganz geleert und ?karte= fällt aus der Adresse', () => {
|
||||
history.replaceState(null, '', '/?karte=k1');
|
||||
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||
const fake = fakeAuth('a');
|
||||
bindEditorToAuth(store, fake.auth);
|
||||
design();
|
||||
@@ -76,7 +76,7 @@ describe('bindEditorToAuth', () => {
|
||||
expect(store.get().image).toBeNull();
|
||||
expect(store.get().saved).toBeNull();
|
||||
expect(location.search).toBe('');
|
||||
expect(location.pathname).toBe('/');
|
||||
expect(location.pathname).toBe('/gestalten');
|
||||
});
|
||||
|
||||
it('Abmelden: der Anfangszustand besteht aus frischen Objekten (nichts geteilt mit dem alten)', () => {
|
||||
@@ -90,7 +90,7 @@ describe('bindEditorToAuth', () => {
|
||||
});
|
||||
|
||||
it('Kontowechsel A → B: Entwurf bleibt, aber ohne Karte und als „Bild noch nicht gespeichert“', () => {
|
||||
history.replaceState(null, '', '/?karte=k1');
|
||||
history.replaceState(null, '', '/gestalten?karte=k1');
|
||||
const fake = fakeAuth('a');
|
||||
bindEditorToAuth(store, fake.auth);
|
||||
const { blob, config } = design();
|
||||
@@ -172,10 +172,11 @@ describe('bindEditorToAuth', () => {
|
||||
});
|
||||
|
||||
it('lässt andere Teile der Adresse stehen', () => {
|
||||
history.replaceState(null, '', '/?karte=k1&x=1#h');
|
||||
history.replaceState(null, '', '/gestalten?karte=k1&x=1#h');
|
||||
const fake = fakeAuth('a');
|
||||
bindEditorToAuth(store, fake.auth);
|
||||
fake.logout();
|
||||
expect(location.pathname).toBe('/gestalten');
|
||||
expect(location.search).toBe('?x=1');
|
||||
expect(location.hash).toBe('#h');
|
||||
});
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
// Die Adresse des Editors kann eine Karte nennen (`/?karte=<id>`).
|
||||
// Die Adresse des Editors kann eine Karte (`/gestalten?karte=<id>`; alte Links `/?karte=<id>` leitet main.ts um)
|
||||
// oder eine Community-Vorlage (`/gestalten?vorlage=<galleryId>`) nennen.
|
||||
|
||||
/** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */
|
||||
export function dropCardParam(): void {
|
||||
/** Entfernt den Parameter aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen oder „Zurück“ nicht erneut lädt. */
|
||||
function dropParam(name: string): void {
|
||||
const url = new URL(location.href);
|
||||
if (!url.searchParams.has('karte')) return;
|
||||
url.searchParams.delete('karte');
|
||||
if (!url.searchParams.has(name)) return;
|
||||
url.searchParams.delete(name);
|
||||
history.replaceState(null, '', url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
/** Entfernt `karte` aus der Adresse. */
|
||||
export function dropCardParam(): void {
|
||||
dropParam('karte');
|
||||
}
|
||||
|
||||
/** Entfernt `vorlage` aus der Adresse. */
|
||||
export function dropTemplateParam(): void {
|
||||
dropParam('vorlage');
|
||||
}
|
||||
@@ -7,11 +7,11 @@
|
||||
border-radius: 20px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.confirm-dialog::backdrop {
|
||||
background: rgb(6 11 20 / 0.72);
|
||||
background: var(--backdrop);
|
||||
}
|
||||
|
||||
.confirm-card {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Bestätigungsdialog als eigenes <dialog> (kein window.confirm): Löschen einer Karte, Verwerfen von Änderungen.
|
||||
import '../account/account.css';
|
||||
import './confirm-dialog.css';
|
||||
import { t } from '../i18n/i18n';
|
||||
import { el } from './dom';
|
||||
|
||||
export interface ConfirmOptions {
|
||||
@@ -20,7 +21,7 @@ export function confirmDialog(options: ConfirmOptions, signal?: AbortSignal): Pr
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, options.cancelLabel ?? 'Abbrechen');
|
||||
const cancel = el('button', { type: 'button', class: 'auth-btn' }, options.cancelLabel ?? t('Abbrechen'));
|
||||
const confirm = el('button', { type: 'button', class: 'auth-btn auth-btn-danger' }, options.confirmLabel);
|
||||
const dialog = el(
|
||||
'dialog',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { el, setFieldInvalid } from './dom';
|
||||
|
||||
describe('setFieldInvalid', () => {
|
||||
const field = (describedBy?: string) => el('input', { 'aria-describedby': describedBy });
|
||||
|
||||
it('markiert das Feld und verweist auf die Fehlerzeile', () => {
|
||||
const input = field();
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
expect(input.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(input.getAttribute('aria-describedby')).toBe('fehler');
|
||||
});
|
||||
|
||||
it('der Hinweis des Feldes bleibt davor stehen', () => {
|
||||
const input = field('hinweis');
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
expect(input.getAttribute('aria-describedby')).toBe('hinweis fehler');
|
||||
});
|
||||
|
||||
it('mehrfach markiert verweist es nur einmal auf die Fehlerzeile', () => {
|
||||
const input = field('a b');
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
expect(input.getAttribute('aria-describedby')).toBe('a b fehler');
|
||||
});
|
||||
|
||||
it('zurücknehmen entfernt Markierung und Verweis, die Hinweise bleiben', () => {
|
||||
const input = field('hinweis');
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
setFieldInvalid(input, 'fehler', false);
|
||||
expect(input.hasAttribute('aria-invalid')).toBe(false);
|
||||
expect(input.getAttribute('aria-describedby')).toBe('hinweis');
|
||||
});
|
||||
|
||||
it('ohne Hinweis verschwindet aria-describedby ganz', () => {
|
||||
const input = field();
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
setFieldInvalid(input, 'fehler', false);
|
||||
expect(input.hasAttribute('aria-describedby')).toBe(false);
|
||||
expect(input.hasAttribute('aria-invalid')).toBe(false);
|
||||
});
|
||||
|
||||
it('ein Feld, das nie markiert war, bleibt beim Zurücknehmen unberührt', () => {
|
||||
const plain = field();
|
||||
setFieldInvalid(plain, 'fehler', false);
|
||||
expect(plain.getAttributeNames()).toEqual([]);
|
||||
const withHint = field('hinweis');
|
||||
setFieldInvalid(withHint, 'fehler', false);
|
||||
expect(withHint.getAttribute('aria-describedby')).toBe('hinweis');
|
||||
});
|
||||
|
||||
it('eine andere Fehlerzeile bleibt, wo sie ist (nur die genannte ID wird verwaltet)', () => {
|
||||
const input = field('hinweis anderer');
|
||||
setFieldInvalid(input, 'fehler', true);
|
||||
setFieldInvalid(input, 'fehler', false);
|
||||
expect(input.getAttribute('aria-describedby')).toBe('hinweis anderer');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,23 @@ export function el<K extends keyof HTMLElementTagNameMap>(
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert ein Formularfeld als ungültig (`aria-invalid`) und verknüpft es mit der Fehlerzeile `errorId` (`aria-describedby`, hinter einem
|
||||
* Hinweis, den das Feld schon hat): ein Screenreader liest den Fehler dann mit dem Feld vor. Mit `invalid = false` fallen beide wieder
|
||||
* weg, der Hinweis bleibt.
|
||||
*/
|
||||
export function setFieldInvalid(control: HTMLElement, errorId: string, invalid: boolean): void {
|
||||
const described = (control.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id !== '' && id !== errorId);
|
||||
if (invalid) {
|
||||
control.setAttribute('aria-invalid', 'true');
|
||||
described.push(errorId);
|
||||
} else {
|
||||
control.removeAttribute('aria-invalid');
|
||||
}
|
||||
if (described.length > 0) control.setAttribute('aria-describedby', described.join(' '));
|
||||
else control.removeAttribute('aria-describedby');
|
||||
}
|
||||
|
||||
// Feste Symbole (Strichstärke aus dem Stylesheet); die Zeichenketten sind Konstanten, keine Nutzereingaben.
|
||||
const ICONS = {
|
||||
imagePlus:
|
||||
@@ -25,6 +42,19 @@ const ICONS = {
|
||||
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||
ban: '<circle cx="12" cy="12" r="9"/><path d="m5.6 5.6 12.8 12.8"/>',
|
||||
sun:
|
||||
'<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/>' +
|
||||
'<path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/>' +
|
||||
'<path d="m19.07 4.93-1.41 1.41"/>',
|
||||
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
|
||||
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
|
||||
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
||||
github:
|
||||
'<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>' +
|
||||
'<path d="M9 18c-4.51 2-5-2-7-2"/>',
|
||||
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof ICONS;
|
||||
|
||||