Compare commits
6
Commits
87b71ec71f
...
a3523d3316
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3523d3316 | ||
|
|
f6b9917b07 | ||
|
|
aa9f9548ad | ||
|
|
c62075f2d4 | ||
|
|
5500495443 | ||
|
|
4c90ee49e8 |
No files matched your search
+5
-5
@@ -41,7 +41,7 @@ Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff
|
|||||||
printf 'AIRCARD_API_SALT=%s\n' "$(openssl rand -hex 32)" >> /home/webklar/apps/.env
|
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 und trägt ihn mit dem verdeckten `read -s`-Befehl ein.
|
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
|
## Starten
|
||||||
|
|
||||||
@@ -55,13 +55,13 @@ Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.e
|
|||||||
|
|
||||||
## API-Dienst `aircard-api`
|
## API-Dienst `aircard-api`
|
||||||
|
|
||||||
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie mit dem Appwrite-Server-Key aus (Veröffentlichen, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern, Admin-Sichtbarkeit). Endpunkte, Statuscodes und Betrieb stehen in [api/README.md](api/README.md).
|
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie mit dem Appwrite-Server-Key aus (Veröffentlichen mit Bildmaß-Prüfung und höchstens 20 Karten je Konto, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern aus verschiedenen Netzen, Admin-Sichtbarkeit). Endpunkte, Statuscodes und Betrieb stehen in [api/README.md](api/README.md).
|
||||||
|
|
||||||
- **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`.
|
- **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`.
|
- **Prüfen** nach dem Start: `curl -s https://aircard-ios.org/api/gesundheit` liefert `{"ok":true}`; `docker logs webklar-aircard-api` zeigt `listening on :8088`. Läuft der Dienst nicht, fehlt auch sein Traefik-Router: nginx antwortet dann auf `/api/*` mit `503` und `{"error":"Der Dienst ist gerade nicht erreichbar."}` (Content-Type `application/json`), nie mit der App.
|
||||||
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
|
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`.
|
||||||
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
## Manuell deployen
|
## Manuell deployen
|
||||||
|
|
||||||
|
|||||||
+10
-8
@@ -19,7 +19,7 @@ Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur `server.js
|
|||||||
|
|
||||||
| Variable | Standard | Bedeutung |
|
| Variable | Standard | Bedeutung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `AIRCARD_API_KEY` | – (Pflicht) | Appwrite-Server-Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) |
|
| `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_API_SALT` | – (Pflicht) | Salz für die Melder-Hashes, 32 Byte hex |
|
||||||
| `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) |
|
| `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) |
|
||||||
| `AIRCARD_APPWRITE_ENDPOINT` | `http://webklar-appwrite/v1` | interne Appwrite-Adresse im Docker-Netz |
|
| `AIRCARD_APPWRITE_ENDPOINT` | `http://webklar-appwrite/v1` | interne Appwrite-Adresse im Docker-Netz |
|
||||||
@@ -33,7 +33,7 @@ Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>"}`.
|
|||||||
|
|
||||||
| Endpunkt | Auth | Antworten |
|
| Endpunkt | Auth | Antworten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falsches Format oder > 5 MiB · `403` Datei nicht meine / falscher Bucket · `409` „Diese Karte ist schon veröffentlicht.“ |
|
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), falscher Typ oder > 5 MiB, falsche Bildmaße („Das Bild hat nicht das Kartenformat.“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei schon veröffentlicht oder gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“), Karte nicht meine („Diese Karte gehört nicht zu deinem Konto.“) · `409` „Diese Karte ist schon veröffentlicht.“ · `429` „Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.“ |
|
||||||
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
|
| `POST /api/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/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/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
|
||||||
@@ -44,14 +44,16 @@ Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Server
|
|||||||
### Regeln im Einzelnen
|
### 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.
|
- **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, Anzeigename ≤ 30 Zeichen (getrimmt, keine Steuerzeichen). `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Bild und Vorschau müssen verschiedene Dateien sein.
|
- **Felder**: Titel 1–60, Herausgeber ≤ 40, Anzeigename ≤ 30 Zeichen (getrimmt). Nicht erlaubt in Titel, Herausgeber, Anzeigename und Tags: Steuerzeichen (U+0000–001F, U+007F–009F), Nullbreiten- und Bidi-Zeichen (U+200B–200F, U+202A–202E, U+2060–206F) und das BOM (U+FEFF); sie machen Titel unsichtbar oder ordnen Text um. Dasselbe Muster prüft der Veröffentlichen-Dialog. `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.
|
||||||
- **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`. **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 liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index, auf die Datei-ID-Spalten nicht) seitenweise à 100 mit Cursor und läuft zusammen mit dem Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")`, `delete("user:<ich>")` (kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen. 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.“
|
- **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.“
|
||||||
- **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.
|
- **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 Melder (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge). Der Melder-Hash ist `HMAC-SHA256(salt, ip)`; IPv6-Adressen werden vorher auf ihr /64-Netz gekürzt, IPv4-gemappte IPv6 auf IPv4. 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.
|
- **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 darf deshalb nur über Traefik erreichbar sein (kein `ports:` in Compose).
|
- **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): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei sichtbar `read("any")`, `delete("user:<owner>")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`, `delete("user:<owner>")`. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`).
|
- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`).
|
||||||
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
|
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
|
||||||
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
|
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion, fehlgeschlagenes Zurücknehmen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten-ID und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
+180
-55
@@ -5,12 +5,12 @@
|
|||||||
* Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
|
* Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
|
||||||
*
|
*
|
||||||
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
|
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
|
||||||
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format · 403 Datei nicht meine / falscher Bucket /
|
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
|
||||||
* Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet
|
* falscher Bucket / Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet / Karte nicht
|
||||||
* 409 schon veröffentlicht · 201 {id}
|
* meine (`cards`) · 409 schon veröffentlicht · 429 schon 20 Veröffentlichungen · 201 {id}
|
||||||
* POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id}
|
* POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id}
|
||||||
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
|
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
|
||||||
* POST /api/melden ohne Anmeldung {id, reason}
|
* 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}
|
* 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
|
||||||
* POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar}
|
* POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar}
|
||||||
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
|
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
* alles andere 404; Body höchstens 8 KB (413), nur application/json (415)
|
* 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`.
|
* Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`.
|
||||||
|
* Der Appwrite-Key braucht die Console-Scopes documents.read/documents.write (= rows.* der API) und files.read/files.write.
|
||||||
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
|
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
|
||||||
* Fehlerantworten sind immer {error: <deutscher Text>}.
|
* Fehlerantworten sind immer {error: <deutscher Text>}.
|
||||||
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle).
|
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle).
|
||||||
@@ -27,10 +28,11 @@
|
|||||||
|
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
const net = require('node:net');
|
const net = require('node:net');
|
||||||
const { AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js');
|
const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
|
||||||
|
|
||||||
const TABLE_GALLERY = 'gallery';
|
const TABLE_GALLERY = 'gallery';
|
||||||
const TABLE_REPORTS = 'reports';
|
const TABLE_REPORTS = 'reports';
|
||||||
|
const TABLE_CARDS = 'cards';
|
||||||
const BUCKET = 'gallery-images';
|
const BUCKET = 'gallery-images';
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 8 * 1024;
|
const MAX_BODY_BYTES = 8 * 1024;
|
||||||
@@ -38,9 +40,17 @@ const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|||||||
const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']);
|
const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']);
|
||||||
const HIDE_AT_REPORTS = 3;
|
const HIDE_AT_REPORTS = 3;
|
||||||
const REPORT_LIST_LIMIT = 100;
|
const REPORT_LIST_LIMIT = 100;
|
||||||
const OWNER_ROWS_PAGE = 100;
|
/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
|
||||||
const OWNER_ROWS_MAX_PAGES = 100;
|
const MAX_PUBLICATIONS = 20;
|
||||||
const REPORT_DELETE_ROUNDS = 50;
|
const REPORT_DELETE_ROUNDS = 50;
|
||||||
|
/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */
|
||||||
|
const IMAGE_HEAD_BYTES = 64;
|
||||||
|
/** Kartenmaße wie web/src/card/geometry.ts (CARD_W, CARD_H, PREVIEW_WIDTH; Vorschauhöhe 303 plus/minus 1). */
|
||||||
|
const IMAGE_WIDTH = 1536;
|
||||||
|
const IMAGE_HEIGHT = 969;
|
||||||
|
const PREVIEW_WIDTH = 480;
|
||||||
|
const PREVIEW_HEIGHT_MIN = 302;
|
||||||
|
const PREVIEW_HEIGHT_MAX = 304;
|
||||||
const RATE_LIMIT = 10;
|
const RATE_LIMIT = 10;
|
||||||
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
const RATE_WINDOW_MS = 60 * 60 * 1000;
|
||||||
const RATE_SWEEP_MS = 60 * 1000;
|
const RATE_SWEEP_MS = 60 * 1000;
|
||||||
@@ -50,7 +60,9 @@ const LOG_WINDOW_MS = 60 * 1000;
|
|||||||
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
|
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
|
||||||
|
|
||||||
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
|
||||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
|
||||||
|
// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts.
|
||||||
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
|
||||||
const JWT_CHARACTERS = /^[\x21-\x7e]+$/;
|
const JWT_CHARACTERS = /^[\x21-\x7e]+$/;
|
||||||
|
|
||||||
const MSG = {
|
const MSG = {
|
||||||
@@ -65,6 +77,9 @@ const MSG = {
|
|||||||
fileSize: 'Das Bild ist größer als 5 MB.',
|
fileSize: 'Das Bild ist größer als 5 MB.',
|
||||||
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
|
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
|
||||||
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
|
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.',
|
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
|
||||||
badRequest: 'Ungültige Anfrage.',
|
badRequest: 'Ungültige Anfrage.',
|
||||||
badCardId: 'Ungültige Karten-ID.',
|
badCardId: 'Ungültige Karten-ID.',
|
||||||
@@ -98,14 +113,28 @@ function hiddenRowPermissions(owner) {
|
|||||||
return [`read("user:${owner}")`, 'read("label:admin")'];
|
return [`read("user:${owner}")`, 'read("label:admin")'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleFilePermissions(owner) {
|
/**
|
||||||
|
* Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit
|
||||||
|
* Fehlversuche auf). Nur so kommen Dateien in `publish`; die API setzt sie beim Veröffentlichen auf `visibleFilePermissions`.
|
||||||
|
*/
|
||||||
|
function uploadFilePermissions(owner) {
|
||||||
assertOwner(owner);
|
assertOwner(owner);
|
||||||
return ['read("any")', `delete("user:${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) {
|
function hiddenFilePermissions(owner) {
|
||||||
assertOwner(owner);
|
assertOwner(owner);
|
||||||
return [`read("user:${owner}")`, 'read("label:admin")', `delete("user:${owner}")`];
|
return [`read("user:${owner}")`, 'read("label:admin")'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function hash32(text) {
|
function hash32(text) {
|
||||||
@@ -138,11 +167,11 @@ function expandIpv6(address) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vereinheitlicht eine Client-Adresse für Hash und Rate-Limit: IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4,
|
* Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
|
||||||
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
|
* { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
|
||||||
*/
|
*/
|
||||||
function normalizeIp(ip) {
|
function parseIp(ip) {
|
||||||
if (typeof ip !== 'string') return '';
|
if (typeof ip !== 'string') return { kind: 'none' };
|
||||||
let text = ip.trim();
|
let text = ip.trim();
|
||||||
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
|
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
|
||||||
const zone = text.indexOf('%');
|
const zone = text.indexOf('%');
|
||||||
@@ -150,15 +179,40 @@ function normalizeIp(ip) {
|
|||||||
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
|
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
|
||||||
if (withPort) text = withPort[1];
|
if (withPort) text = withPort[1];
|
||||||
|
|
||||||
if (net.isIPv4(text)) return text;
|
if (net.isIPv4(text)) return { kind: 'v4', text };
|
||||||
if (net.isIPv6(text)) {
|
if (net.isIPv6(text)) {
|
||||||
const groups = expandIpv6(text);
|
const groups = expandIpv6(text);
|
||||||
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
|
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
|
||||||
return `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`;
|
return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` };
|
||||||
}
|
}
|
||||||
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(':')}::/64`;
|
return { kind: 'v6', groups };
|
||||||
}
|
}
|
||||||
return text.toLowerCase().slice(0, 64);
|
return { kind: 'other', text: text.toLowerCase().slice(0, 64) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4,
|
||||||
|
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
|
||||||
|
*/
|
||||||
|
function normalizeIp(ip) {
|
||||||
|
const parsed = parseIp(ip);
|
||||||
|
if (parsed.kind === 'none') return '';
|
||||||
|
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`;
|
||||||
|
return parsed.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4).
|
||||||
|
* So zählen drei Adressen aus demselben Netz als ein Melder und blenden eine Karte nicht aus.
|
||||||
|
*/
|
||||||
|
function reporterNetwork(ip) {
|
||||||
|
const parsed = parseIp(ip);
|
||||||
|
if (parsed.kind === 'none') return '';
|
||||||
|
if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`;
|
||||||
|
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`;
|
||||||
|
return parsed.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstHeaderValue(req, name) {
|
function firstHeaderValue(req, name) {
|
||||||
@@ -173,9 +227,13 @@ function clientIp(req) {
|
|||||||
return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || '';
|
return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HMAC-SHA256 (Hex) der normalisierten IP mit dem Server-Salz. */
|
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) {
|
function reporterHash(salt, ip) {
|
||||||
return crypto.createHmac('sha256', salt).update(normalizeIp(ip)).digest('hex');
|
return hmacHex(salt, reporterNetwork(ip));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPlainObject(value) {
|
function isPlainObject(value) {
|
||||||
@@ -188,6 +246,37 @@ function safeToken(value, fallback = 'unknown') {
|
|||||||
return text === '' ? fallback : text;
|
return text === '' ? fallback : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang).
|
||||||
|
* @returns {{width: number, height: number} | null}
|
||||||
|
*/
|
||||||
|
function readImageSize(bytes) {
|
||||||
|
if (!Buffer.isBuffer(bytes)) return null;
|
||||||
|
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') {
|
||||||
|
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
||||||
|
}
|
||||||
|
if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') {
|
||||||
|
const kind = bytes.toString('latin1', 12, 16);
|
||||||
|
if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
|
||||||
|
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
|
||||||
|
}
|
||||||
|
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
|
||||||
|
const bits = bytes.readUInt32LE(21);
|
||||||
|
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
|
||||||
|
}
|
||||||
|
if (kind === 'VP8X' && bytes.length >= 30) {
|
||||||
|
return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCardImageSize = (size) => size !== null && size.width === IMAGE_WIDTH && size.height === IMAGE_HEIGHT;
|
||||||
|
const isCardPreviewSize = (size) =>
|
||||||
|
size !== null && size.width === PREVIEW_WIDTH && size.height >= PREVIEW_HEIGHT_MIN && size.height <= PREVIEW_HEIGHT_MAX;
|
||||||
|
|
||||||
const TEXT_FIELDS = [
|
const TEXT_FIELDS = [
|
||||||
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
|
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
|
||||||
{ key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false },
|
{ key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false },
|
||||||
@@ -444,41 +533,59 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Dateien und Zeilen
|
// ---- Dateien und Zeilen
|
||||||
function checkFile(file, userId) {
|
/**
|
||||||
|
* Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im Galerie-Bucket. Metadaten wie Typ und
|
||||||
|
* Größe prüft erst `checkFileContent`, damit fremde Dateien nichts darüber verraten.
|
||||||
|
*/
|
||||||
|
function checkFileExists(file) {
|
||||||
if (!file) throw new HttpError(400, MSG.fileMissing);
|
if (!file) throw new HttpError(400, MSG.fileMissing);
|
||||||
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
|
if (file.bucketId !== BUCKET) throw new HttpError(403, MSG.foreignFile);
|
||||||
if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) {
|
|
||||||
throw new HttpError(403, MSG.foreignFile);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") und read("any"). Eine Datei ohne delete(ich) gehört
|
||||||
|
* jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Zeilen-Dateien
|
||||||
|
* hat der Aufrufer vorher mit `used` als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet.
|
||||||
|
*/
|
||||||
|
function checkFileOwnership(file, userId) {
|
||||||
|
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
|
||||||
|
if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
|
||||||
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
|
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
|
||||||
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
|
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkFileContent(file) {
|
||||||
if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType);
|
if (!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);
|
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);
|
const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Datei-IDs, die schon eine Galerie-Zeile dieses Autors verwendet (Bild oder Vorschau, auch ausgeblendete Karten).
|
* Die Galerie-Zeilen dieses Autors (auch ausgeblendete), höchstens MAX_PUBLICATIONS (mehr braucht der Aufrufer nicht:
|
||||||
* Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
|
* ab dort ist das Limit erreicht). Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId`
|
||||||
* Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId` nicht) und blättert mit
|
* nicht). Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
|
||||||
* Cursor. Mehr als OWNER_ROWS_MAX_PAGES Seiten: null (der Aufrufer lehnt dann ab).
|
|
||||||
*/
|
*/
|
||||||
async function fileIdsInUse(ownerId) {
|
function ownerRows(ownerId) {
|
||||||
const used = new Set();
|
return appwrite.listRows(TABLE_GALLERY, [queryEqual('ownerId', [ownerId]), queryLimit(MAX_PUBLICATIONS)]);
|
||||||
let cursor = null;
|
|
||||||
for (let page = 0; page < OWNER_ROWS_MAX_PAGES; page += 1) {
|
|
||||||
const queries = [queryEqual('ownerId', [ownerId]), queryLimit(OWNER_ROWS_PAGE)];
|
|
||||||
if (cursor !== null) queries.push(queryCursorAfter(cursor));
|
|
||||||
const rows = await appwrite.listRows(TABLE_GALLERY, queries);
|
|
||||||
for (const row of rows) {
|
|
||||||
used.add(row.imageFileId);
|
|
||||||
used.add(row.previewFileId);
|
|
||||||
}
|
}
|
||||||
if (rows.length < OWNER_ROWS_PAGE) return used;
|
|
||||||
cursor = rows[rows.length - 1].$id;
|
/** 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) {
|
||||||
return null;
|
const card = await appwrite.getRow(TABLE_CARDS, cardId);
|
||||||
|
return card !== null && Array.isArray(card.$permissions) && card.$permissions.includes(`read("user:${userId}")`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -530,15 +637,20 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
|||||||
appwrite.getFile(BUCKET, data.imageFileId),
|
appwrite.getFile(BUCKET, data.imageFileId),
|
||||||
appwrite.getFile(BUCKET, data.previewFileId),
|
appwrite.getFile(BUCKET, data.previewFileId),
|
||||||
]);
|
]);
|
||||||
checkFile(image, user.id);
|
checkFileExists(image);
|
||||||
checkFile(preview, user.id);
|
checkFileExists(preview);
|
||||||
|
|
||||||
// Je Autor nacheinander: Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
|
// Je Autor nacheinander: Limit, Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
|
||||||
return withLock(`owner:${user.id}`, async () => {
|
return withLock(`owner:${user.id}`, async () => {
|
||||||
const used = await fileIdsInUse(user.id);
|
const rows = await ownerRows(user.id);
|
||||||
if (used === null || used.has(data.imageFileId) || used.has(data.previewFileId)) {
|
if (rows.length >= MAX_PUBLICATIONS) throw new HttpError(429, MSG.tooManyPublications);
|
||||||
throw new HttpError(403, MSG.filesTaken);
|
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);
|
const id = galleryRowId(user.id, data.sourceCardId);
|
||||||
try {
|
try {
|
||||||
@@ -548,7 +660,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
|||||||
{
|
{
|
||||||
title: data.title,
|
title: data.title,
|
||||||
issuer: data.issuer,
|
issuer: data.issuer,
|
||||||
tags: data.tags.join(','),
|
// ",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,
|
displayName: data.displayName,
|
||||||
imageFileId: data.imageFileId,
|
imageFileId: data.imageFileId,
|
||||||
previewFileId: data.previewFileId,
|
previewFileId: data.previewFileId,
|
||||||
@@ -565,14 +678,21 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kanonischer Zustand: Beide Dateien tragen genau die sichtbaren Rechte (kein update, keine fremden Rollen).
|
// 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.
|
// 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.
|
// Scheitert das Festlegen, wird die Zeile zurückgenommen.
|
||||||
try {
|
try {
|
||||||
const pinned = await setFilePermissions(data, visibleFilePermissions(user.id));
|
const pinned = await setFilePermissions(data, visibleFilePermissions());
|
||||||
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
|
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {});
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
say(`card ${id} published`);
|
say(`card ${id} published`);
|
||||||
@@ -606,8 +726,10 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
|||||||
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
|
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
|
||||||
const id = body.id;
|
const id = body.id;
|
||||||
|
|
||||||
const reporter = reporterHash(salt, clientIp(req));
|
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt weiter je Anschluss (IPv4 voll, IPv6 /64).
|
||||||
const limit = rateLimit(reporter);
|
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) });
|
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
|
||||||
|
|
||||||
return withLock(id, async () => {
|
return withLock(id, async () => {
|
||||||
@@ -724,11 +846,14 @@ module.exports = {
|
|||||||
createApp,
|
createApp,
|
||||||
visibleRowPermissions,
|
visibleRowPermissions,
|
||||||
hiddenRowPermissions,
|
hiddenRowPermissions,
|
||||||
|
uploadFilePermissions,
|
||||||
visibleFilePermissions,
|
visibleFilePermissions,
|
||||||
hiddenFilePermissions,
|
hiddenFilePermissions,
|
||||||
reporterHash,
|
reporterHash,
|
||||||
|
reporterNetwork,
|
||||||
clientIp,
|
clientIp,
|
||||||
normalizeIp,
|
normalizeIp,
|
||||||
|
readImageSize,
|
||||||
validatePublish,
|
validatePublish,
|
||||||
buildSearchText,
|
buildSearchText,
|
||||||
galleryRowId,
|
galleryRowId,
|
||||||
|
|||||||
+415
-94
@@ -9,16 +9,19 @@ const crypto = require('node:crypto');
|
|||||||
const http = require('node:http');
|
const http = require('node:http');
|
||||||
|
|
||||||
const { createAppwrite, AppwriteError } = require('./appwrite.js');
|
const { createAppwrite, AppwriteError } = require('./appwrite.js');
|
||||||
const { startFakeAppwrite } = require('./fake-appwrite.js');
|
const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
|
||||||
const {
|
const {
|
||||||
createApp,
|
createApp,
|
||||||
visibleRowPermissions,
|
visibleRowPermissions,
|
||||||
hiddenRowPermissions,
|
hiddenRowPermissions,
|
||||||
|
uploadFilePermissions,
|
||||||
visibleFilePermissions,
|
visibleFilePermissions,
|
||||||
hiddenFilePermissions,
|
hiddenFilePermissions,
|
||||||
reporterHash,
|
reporterHash,
|
||||||
|
reporterNetwork,
|
||||||
clientIp,
|
clientIp,
|
||||||
normalizeIp,
|
normalizeIp,
|
||||||
|
readImageSize,
|
||||||
validatePublish,
|
validatePublish,
|
||||||
buildSearchText,
|
buildSearchText,
|
||||||
galleryRowId,
|
galleryRowId,
|
||||||
@@ -33,6 +36,10 @@ const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
|
|||||||
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
|
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
|
||||||
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
|
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
|
||||||
const MIB5 = 5 * 1024 * 1024;
|
const MIB5 = 5 * 1024 * 1024;
|
||||||
|
const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
||||||
|
const NOT_CARD_FORMAT = 'Das Bild hat nicht das Kartenformat.';
|
||||||
|
const NOT_YOUR_CARD = 'Diese Karte gehört nicht zu deinem Konto.';
|
||||||
|
const TOO_MANY_CARDS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
|
||||||
|
|
||||||
// ---- Aufbau ---------------------------------------------------------------------------------
|
// ---- Aufbau ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -110,9 +117,11 @@ function publishBody(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedUploads(fake, owner = ME, { image = 'img1', preview = 'prev1' } = {}) {
|
/** Zwei frisch hochgeladene Dateien und die private Karte, aus der veröffentlicht wird (Standard-sourceCardId `card1`). */
|
||||||
|
function seedUploads(fake, owner = ME, { image = 'img1', preview = 'prev1', card = 'card1' } = {}) {
|
||||||
fake.addFile({ id: image, owner });
|
fake.addFile({ id: image, owner });
|
||||||
fake.addFile({ id: preview, owner, mimeType: 'image/webp' });
|
fake.addFile({ id: preview, owner, mimeType: 'image/webp' });
|
||||||
|
if (card) fake.addCard(card, owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legt eine veröffentlichte Karte direkt im Fake an (Zeile + zwei Dateien mit passenden Rechten). */
|
/** Legt eine veröffentlichte Karte direkt im Fake an (Zeile + zwei Dateien mit passenden Rechten). */
|
||||||
@@ -121,10 +130,11 @@ function seedCard(fake, { id = 'card-1', owner = ME, hidden = false, reportCount
|
|||||||
const previewFileId = `${id}-prev`;
|
const previewFileId = `${id}-prev`;
|
||||||
fake.addFile({ id: imageFileId, owner });
|
fake.addFile({ id: imageFileId, owner });
|
||||||
fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' });
|
fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' });
|
||||||
if (hidden) {
|
// Veröffentlichte Dateien tragen nie delete(owner): sichtbar nur read("any"), ausgeblendet nur Autor und Admin lesen.
|
||||||
fake.file(imageFileId).$permissions = hiddenFilePermissions(owner);
|
const filePermissions = hidden ? hiddenFilePermissions(owner) : visibleFilePermissions();
|
||||||
fake.file(previewFileId).$permissions = hiddenFilePermissions(owner);
|
fake.file(imageFileId).$permissions = filePermissions;
|
||||||
}
|
fake.file(previewFileId).$permissions = filePermissions;
|
||||||
|
fake.addCard(`src-${id}`, owner);
|
||||||
fake.addRow(
|
fake.addRow(
|
||||||
'gallery',
|
'gallery',
|
||||||
id,
|
id,
|
||||||
@@ -154,14 +164,18 @@ const sha32 = (text) => crypto.createHash('sha256').update(text).digest('hex').s
|
|||||||
test('Rechte-Strings sind genau die aus der Spec', () => {
|
test('Rechte-Strings sind genau die aus der Spec', () => {
|
||||||
assert.deepEqual(visibleRowPermissions(), ['read("any")']);
|
assert.deepEqual(visibleRowPermissions(), ['read("any")']);
|
||||||
assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
|
assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
|
||||||
assert.deepEqual(visibleFilePermissions('abc'), ['read("any")', 'delete("user:abc")']);
|
// Abweichung von Spec §7 (Ruling): veröffentlichte Dateien tragen kein delete(owner), sonst ließe sich der Inhalt
|
||||||
assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")', 'delete("user:abc")']);
|
// nach dem Veröffentlichen löschen und unter derselben ID neu anlegen.
|
||||||
|
assert.deepEqual(visibleFilePermissions(), ['read("any")']);
|
||||||
|
assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
|
||||||
|
// frisch hochgeladene (noch nicht veröffentlichte) Dateien: mit delete(owner), damit der Client Fehlversuche aufräumt
|
||||||
|
assert.deepEqual(uploadFilePermissions('abc'), ['read("any")', 'delete("user:abc")']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Rechte-Helfer lehnen unzulässige Nutzer-IDs ab (kein Einschleusen in Rechte-Strings)', () => {
|
test('Rechte-Helfer lehnen unzulässige Nutzer-IDs ab (kein Einschleusen in Rechte-Strings)', () => {
|
||||||
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) {
|
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) {
|
||||||
assert.throws(() => hiddenRowPermissions(bad));
|
assert.throws(() => hiddenRowPermissions(bad));
|
||||||
assert.throws(() => visibleFilePermissions(bad));
|
assert.throws(() => uploadFilePermissions(bad));
|
||||||
assert.throws(() => hiddenFilePermissions(bad));
|
assert.throws(() => hiddenFilePermissions(bad));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -173,17 +187,33 @@ test('Zeilen-IDs sind deterministisch: erste 32 Hex von sha256(a|b)', () => {
|
|||||||
assert.notEqual(galleryRowId('u1', 'c1'), galleryRowId('u2', 'c1'));
|
assert.notEqual(galleryRowId('u1', 'c1'), galleryRowId('u2', 'c1'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reporterHash ist HMAC-SHA256 (Hex) mit dem Salz; IPv6 wird auf /64 gekürzt', () => {
|
test('reporterNetwork: IPv4 -> /24, IPv6 -> /48, IPv4-gemappte IPv6 wie IPv4', () => {
|
||||||
assert.equal(reporterHash(SALT, '203.0.113.7'), crypto.createHmac('sha256', SALT).update('203.0.113.7').digest('hex'));
|
assert.equal(reporterNetwork('203.0.7.9'), '203.0.7.0/24');
|
||||||
assert.notEqual(reporterHash('anderes-salz', '203.0.113.7'), reporterHash(SALT, '203.0.113.7'));
|
assert.equal(reporterNetwork(' 203.0.7.200:5555 '), '203.0.7.0/24');
|
||||||
assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:12:ffff:eeee:dddd:2'));
|
assert.equal(reporterNetwork('::ffff:203.0.7.9'), '203.0.7.0/24');
|
||||||
assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:13::1'));
|
assert.equal(reporterNetwork('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd::/48');
|
||||||
|
assert.equal(reporterNetwork('[2001:db8:abcd:ffff::9]'), '2001:db8:abcd::/48');
|
||||||
|
assert.equal(reporterNetwork('2001:db8::1'), '2001:db8:0::/48');
|
||||||
|
assert.equal(reporterNetwork('fe80::1%eth0'), 'fe80:0:0::/48');
|
||||||
|
assert.equal(reporterNetwork('kein-ip'), 'kein-ip');
|
||||||
|
assert.equal(reporterNetwork(undefined), '');
|
||||||
|
// normalizeIp (Rate-Limit je Anschluss) bleibt feiner: volle IPv4, IPv6 /64
|
||||||
|
assert.equal(normalizeIp('203.0.7.9'), '203.0.7.9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reporterHash ist HMAC-SHA256 (Hex) des Netzes: drei Adressen aus einem /24 (IPv6: /48) sind ein Melder', () => {
|
||||||
|
assert.equal(reporterHash(SALT, '203.0.7.9'), crypto.createHmac('sha256', SALT).update('203.0.7.0/24').digest('hex'));
|
||||||
|
assert.notEqual(reporterHash('anderes-salz', '203.0.7.9'), reporterHash(SALT, '203.0.7.9'));
|
||||||
|
assert.equal(reporterHash(SALT, '203.0.7.9'), reporterHash(SALT, '203.0.7.200'));
|
||||||
|
assert.notEqual(reporterHash(SALT, '203.0.7.9'), reporterHash(SALT, '203.0.8.9'));
|
||||||
|
assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:ffff:ffff:eeee:dddd:2'));
|
||||||
|
assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abce:12::1'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizeIp: IPv4 bleibt, IPv4-gemappte IPv6 wird IPv4, sonst /64', () => {
|
test('normalizeIp: IPv4 bleibt, IPv4-gemappte IPv6 wird IPv4, sonst /64', () => {
|
||||||
assert.equal(normalizeIp('203.0.113.7'), '203.0.113.7');
|
assert.equal(normalizeIp('203.0.7.9'), '203.0.7.9');
|
||||||
assert.equal(normalizeIp(' 203.0.113.7:5555 '), '203.0.113.7');
|
assert.equal(normalizeIp(' 203.0.7.9:5555 '), '203.0.7.9');
|
||||||
assert.equal(normalizeIp('::ffff:203.0.113.7'), '203.0.113.7');
|
assert.equal(normalizeIp('::ffff:203.0.7.9'), '203.0.7.9');
|
||||||
assert.equal(normalizeIp('::ffff:cb00:7107'), '203.0.113.7');
|
assert.equal(normalizeIp('::ffff:cb00:7107'), '203.0.113.7');
|
||||||
assert.equal(normalizeIp('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd:12::/64');
|
assert.equal(normalizeIp('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd:12::/64');
|
||||||
assert.equal(normalizeIp('2001:db8::1'), '2001:db8:0:0::/64');
|
assert.equal(normalizeIp('2001:db8::1'), '2001:db8:0:0::/64');
|
||||||
@@ -279,7 +309,7 @@ test('Nur application/json: sonst 415 (auch mit Charset-Zusatz erlaubt)', async
|
|||||||
const ok = await call('POST', '/api/melden', {
|
const ok = await call('POST', '/api/melden', {
|
||||||
raw: '{"id":"card-1","reason":"spam"}',
|
raw: '{"id":"card-1","reason":"spam"}',
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
headers: { 'X-Real-Ip': '203.0.113.1' },
|
headers: { 'X-Real-Ip': '203.0.1.9' },
|
||||||
});
|
});
|
||||||
assert.equal(ok.status, 200);
|
assert.equal(ok.status, 200);
|
||||||
});
|
});
|
||||||
@@ -302,7 +332,7 @@ test('Body über 8 KB: 413 (mit Content-Length und chunked), genau 8 KB geht noc
|
|||||||
});
|
});
|
||||||
assert.equal(chunked.status, 413);
|
assert.equal(chunked.status, 413);
|
||||||
|
|
||||||
const exact = await call('POST', '/api/melden', { raw: padded(8192), headers: { 'X-Real-Ip': '203.0.113.2' } });
|
const exact = await call('POST', '/api/melden', { raw: padded(8192), headers: { 'X-Real-Ip': '203.0.2.9' } });
|
||||||
assert.equal(exact.status, 404, 'genau 8192 Byte werden verarbeitet (Karte existiert nicht)');
|
assert.equal(exact.status, 404, 'genau 8192 Byte werden verarbeitet (Karte existiert nicht)');
|
||||||
assert.equal(fake.calls.filter((c) => c.path.includes('/account')).length, 0, 'bei 413 wurde nichts bei Appwrite abgefragt');
|
assert.equal(fake.calls.filter((c) => c.path.includes('/account')).length, 0, 'bei 413 wurde nichts bei Appwrite abgefragt');
|
||||||
});
|
});
|
||||||
@@ -330,7 +360,7 @@ test('Veröffentlichen: legt eine sichtbare Zeile an (Rechte, searchText, ownerI
|
|||||||
assert.deepEqual(row.$permissions, ['read("any")']);
|
assert.deepEqual(row.$permissions, ['read("any")']);
|
||||||
assert.equal(row.title, 'Mein Motiv');
|
assert.equal(row.title, 'Mein Motiv');
|
||||||
assert.equal(row.issuer, 'Beispielbank');
|
assert.equal(row.issuer, 'Beispielbank');
|
||||||
assert.equal(row.tags, 'blau,neon');
|
assert.equal(row.tags, ',blau,neon,', 'Tags mit Trennzeichen an beiden Enden (exakter Tag-Filter)');
|
||||||
assert.equal(row.displayName, 'Kenso');
|
assert.equal(row.displayName, 'Kenso');
|
||||||
assert.equal(row.imageFileId, 'img1');
|
assert.equal(row.imageFileId, 'img1');
|
||||||
assert.equal(row.previewFileId, 'prev1');
|
assert.equal(row.previewFileId, 'prev1');
|
||||||
@@ -351,8 +381,9 @@ test('Veröffentlichen: legt eine sichtbare Zeile an (Rechte, searchText, ownerI
|
|||||||
|
|
||||||
test('Veröffentlichen: Bild und Vorschau dürfen PNG oder WebP sein, Anzeigename und Herausgeber sind optional', async () => {
|
test('Veröffentlichen: Bild und Vorschau dürfen PNG oder WebP sein, Anzeigename und Herausgeber sind optional', async () => {
|
||||||
const { post, fake } = await setup();
|
const { post, fake } = await setup();
|
||||||
|
fake.addCard('card1', ME);
|
||||||
fake.addFile({ id: 'img1', owner: ME, mimeType: 'image/png', size: MIB5 });
|
fake.addFile({ id: 'img1', owner: ME, mimeType: 'image/png', size: MIB5 });
|
||||||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/png' });
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/png', role: 'preview' });
|
||||||
const res = await post('/api/veroeffentlichen', { title: 'Nur Titel', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1' }, JWT_ME);
|
const res = await post('/api/veroeffentlichen', { title: 'Nur Titel', imageFileId: 'img1', previewFileId: 'prev1', sourceCardId: 'card1' }, JWT_ME);
|
||||||
assert.equal(res.status, 201);
|
assert.equal(res.status, 201);
|
||||||
const row = fake.row('gallery', res.json.id);
|
const row = fake.row('gallery', res.json.id);
|
||||||
@@ -421,6 +452,7 @@ test('Veröffentlichen mit fehlender Datei: 400 "Bild nicht gefunden."', async (
|
|||||||
|
|
||||||
test('Veröffentlichen: nur PNG/WebP bis 5 MiB, sonst 400', async () => {
|
test('Veröffentlichen: nur PNG/WebP bis 5 MiB, sonst 400', async () => {
|
||||||
const { post, fake } = await setup();
|
const { post, fake } = await setup();
|
||||||
|
fake.addCard('card1', ME);
|
||||||
const attempt = (image, preview) => {
|
const attempt = (image, preview) => {
|
||||||
fake.addFile({ id: 'img1', owner: ME, ...image });
|
fake.addFile({ id: 'img1', owner: ME, ...image });
|
||||||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', ...preview });
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', ...preview });
|
||||||
@@ -449,9 +481,9 @@ test('Veröffentlichen doppelt: 409 "Diese Karte ist schon veröffentlicht."', a
|
|||||||
assert.equal(again.json.error, 'Diese Karte ist schon veröffentlicht.');
|
assert.equal(again.json.error, 'Diese Karte ist schon veröffentlicht.');
|
||||||
assert.equal(fake.rows('gallery').length, 1);
|
assert.equal(fake.rows('gallery').length, 1);
|
||||||
|
|
||||||
// eine andere Person darf dieselbe sourceCardId-Zeichenfolge haben (Zeilen-ID enthält den Besitzer)
|
// eine andere Person veröffentlicht ihre eigene Karte (Zeilen-ID enthält den Besitzer)
|
||||||
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9' });
|
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9', card: 'card9' });
|
||||||
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9' }), JWT_OTHER);
|
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9', sourceCardId: 'card9' }), JWT_OTHER);
|
||||||
assert.equal(other.status, 201);
|
assert.equal(other.status, 201);
|
||||||
assert.notEqual(other.json.id, galleryRowId(ME, 'card1'));
|
assert.notEqual(other.json.id, galleryRowId(ME, 'card1'));
|
||||||
assert.equal(fake.rows('gallery').length, 2);
|
assert.equal(fake.rows('gallery').length, 2);
|
||||||
@@ -485,6 +517,14 @@ test('Veröffentlichen: Validierung liefert 400 und fasst Appwrite (Dateien, Zei
|
|||||||
['Tags als Zeichenkette', { tags: 'a,b' }, /Tags/],
|
['Tags als Zeichenkette', { tags: 'a,b' }, /Tags/],
|
||||||
['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/],
|
['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/],
|
||||||
['Tag mit Komma', { tags: ['a,b'] }, /Komma/],
|
['Tag mit Komma', { tags: ['a,b'] }, /Komma/],
|
||||||
|
['Titel mit Nullbreiten-Leerzeichen', { title: 'a\u200bb' }, /Titel/],
|
||||||
|
['Titel nur aus Nullbreitenzeichen', { title: '\u200b\u200d\ufeff' }, /Titel/],
|
||||||
|
['Titel mit Rechts-nach-links-Überschreibung', { title: 'foto\u202egnp.exe' }, /Titel/],
|
||||||
|
['Titel mit Isolate', { title: 'a\u2066b' }, /Titel/],
|
||||||
|
['Titel mit C1-Steuerzeichen', { title: 'a\u0085b' }, /Titel/],
|
||||||
|
['Herausgeber mit Bidi-Zeichen', { issuer: 'Bank\u200f' }, /Herausgeber/],
|
||||||
|
['Anzeigename mit Wortverbinder', { displayName: 'Ken\u2060so' }, /Anzeigename/],
|
||||||
|
['Tag mit Nullbreiten-Zeichen', { tags: ['gol\u200bd'] }, /Tags/],
|
||||||
['Bild-ID mit ../', { imageFileId: '../x' }, /ID/],
|
['Bild-ID mit ../', { imageFileId: '../x' }, /ID/],
|
||||||
['Bild-ID ..', { imageFileId: '..' }, /ID/],
|
['Bild-ID ..', { imageFileId: '..' }, /ID/],
|
||||||
['Bild-ID Zahl', { imageFileId: 123 }, /ID/],
|
['Bild-ID Zahl', { imageFileId: 123 }, /ID/],
|
||||||
@@ -514,7 +554,7 @@ test('Veröffentlichen: Tags werden serverseitig erneut normalisiert', async ()
|
|||||||
seedUploads(fake);
|
seedUploads(fake);
|
||||||
const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME);
|
const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME);
|
||||||
assert.equal(res.status, 201);
|
assert.equal(res.status, 201);
|
||||||
assert.equal(fake.row('gallery', res.json.id).tags, 'blau,neon');
|
assert.equal(fake.row('gallery', res.json.id).tags, ',blau,neon,');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Veröffentlichen: Appwrite-Ausfall ergibt 502 mit deutschem Text, nichts angelegt', async () => {
|
test('Veröffentlichen: Appwrite-Ausfall ergibt 502 mit deutschem Text, nichts angelegt', async () => {
|
||||||
@@ -537,16 +577,16 @@ test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirech
|
|||||||
const { report, fake } = await setup();
|
const { report, fake } = await setup();
|
||||||
const { imageFileId, previewFileId } = seedCard(fake);
|
const { imageFileId, previewFileId } = seedCard(fake);
|
||||||
|
|
||||||
let res = await report('card-1', '203.0.113.1');
|
let res = await report('card-1', '203.0.1.9');
|
||||||
assert.deepEqual(res.json, { ok: true });
|
assert.deepEqual(res.json, { ok: true });
|
||||||
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
|
||||||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
res = await report('card-1', '203.0.113.2', 'urheberrecht');
|
res = await report('card-1', '203.0.2.9', 'urheberrecht');
|
||||||
assert.deepEqual(res.json, { ok: true });
|
assert.deepEqual(res.json, { ok: true });
|
||||||
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
|
||||||
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
|
assert.deepEqual(fake.row('gallery', 'card-1').$permissions, ['read("any")']);
|
||||||
|
|
||||||
res = await report('card-1', '203.0.113.3', 'kartendaten');
|
res = await report('card-1', '203.0.3.9', 'kartendaten');
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.deepEqual(res.json, { ok: true });
|
assert.deepEqual(res.json, { ok: true });
|
||||||
|
|
||||||
@@ -554,13 +594,13 @@ test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirech
|
|||||||
assert.equal(row.hidden, true);
|
assert.equal(row.hidden, true);
|
||||||
assert.equal(row.reportCount, 3);
|
assert.equal(row.reportCount, 3);
|
||||||
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
||||||
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
|
const hiddenFile = ['read("user:user-me")', 'read("label:admin")'];
|
||||||
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
|
assert.deepEqual(fake.file(imageFileId).$permissions, hiddenFile);
|
||||||
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
|
assert.deepEqual(fake.file(previewFileId).$permissions, hiddenFile);
|
||||||
assert.equal(fake.rows('reports').length, 3);
|
assert.equal(fake.rows('reports').length, 3);
|
||||||
|
|
||||||
// ausgeblendete Karte melden: 404
|
// ausgeblendete Karte melden: 404
|
||||||
res = await report('card-1', '203.0.113.4');
|
res = await report('card-1', '203.0.4.9');
|
||||||
assert.equal(res.status, 404);
|
assert.equal(res.status, 404);
|
||||||
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
|
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
|
||||||
assert.equal(fake.rows('reports').length, 3);
|
assert.equal(fake.rows('reports').length, 3);
|
||||||
@@ -569,28 +609,28 @@ test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirech
|
|||||||
test('Melden: zwei Melder reichen nicht, die Meldungen speichern nur Hash, Grund und Karte', async () => {
|
test('Melden: zwei Melder reichen nicht, die Meldungen speichern nur Hash, Grund und Karte', async () => {
|
||||||
const { report, fake } = await setup();
|
const { report, fake } = await setup();
|
||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
await report('card-1', '203.0.113.1', 'spam');
|
await report('card-1', '203.0.1.9', 'spam');
|
||||||
await report('card-1', '203.0.113.2', 'sonstiges');
|
await report('card-1', '203.0.2.9', 'sonstiges');
|
||||||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
|
|
||||||
const hash = reporterHash(SALT, '203.0.113.1');
|
const hash = reporterHash(SALT, '203.0.1.9');
|
||||||
const stored = fake.row('reports', reportRowId('card-1', hash));
|
const stored = fake.row('reports', reportRowId('card-1', hash));
|
||||||
assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)');
|
assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)');
|
||||||
assert.equal(stored.galleryId, 'card-1');
|
assert.equal(stored.galleryId, 'card-1');
|
||||||
assert.equal(stored.reason, 'spam');
|
assert.equal(stored.reason, 'spam');
|
||||||
assert.equal(stored.reporterHash, hash);
|
assert.equal(stored.reporterHash, hash);
|
||||||
assert.deepEqual(stored.$permissions, []);
|
assert.deepEqual(stored.$permissions, []);
|
||||||
assert.ok(!JSON.stringify(fake.rows('reports')).includes('203.0.113'), 'keine IP im Klartext gespeichert');
|
assert.ok(!JSON.stringify(fake.rows('reports')).includes('203.0.'), 'keine IP im Klartext gespeichert');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Melden: dieselbe IP fünfmal zählt einmal, Antworten sind schonGemeldet', async () => {
|
test('Melden: dieselbe IP fünfmal zählt einmal, Antworten sind schonGemeldet', async () => {
|
||||||
const { report, fake } = await setup();
|
const { report, fake } = await setup();
|
||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
|
|
||||||
const first = await report('card-1', '203.0.113.9');
|
const first = await report('card-1', '203.0.9.9');
|
||||||
assert.deepEqual(first.json, { ok: true });
|
assert.deepEqual(first.json, { ok: true });
|
||||||
for (let i = 0; i < 4; i += 1) {
|
for (let i = 0; i < 4; i += 1) {
|
||||||
const again = await report('card-1', '203.0.113.9', i % 2 ? 'spam' : 'unangemessen');
|
const again = await report('card-1', '203.0.9.9', i % 2 ? 'spam' : 'unangemessen');
|
||||||
assert.equal(again.status, 200);
|
assert.equal(again.status, 200);
|
||||||
assert.deepEqual(again.json, { schonGemeldet: true });
|
assert.deepEqual(again.json, { schonGemeldet: true });
|
||||||
}
|
}
|
||||||
@@ -604,22 +644,22 @@ test('Melden: Rate-Limit 10 pro Stunde und IP, danach 429, nach einer Stunde wie
|
|||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
|
|
||||||
for (let i = 0; i < 10; i += 1) {
|
for (let i = 0; i < 10; i += 1) {
|
||||||
const res = await report('card-1', '203.0.113.50');
|
const res = await report('card-1', '203.0.50.9');
|
||||||
assert.equal(res.status, 200, `Meldung ${i + 1}`);
|
assert.equal(res.status, 200, `Meldung ${i + 1}`);
|
||||||
}
|
}
|
||||||
let res = await report('card-1', '203.0.113.50');
|
let res = await report('card-1', '203.0.50.9');
|
||||||
assert.equal(res.status, 429);
|
assert.equal(res.status, 429);
|
||||||
assert.equal(res.json.error, 'Zu viele Meldungen – bitte später nochmal.');
|
assert.equal(res.json.error, 'Zu viele Meldungen – bitte später nochmal.');
|
||||||
assert.equal(res.headers['retry-after'], '3600');
|
assert.equal(res.headers['retry-after'], '3600');
|
||||||
|
|
||||||
// eine andere IP ist nicht betroffen
|
// eine andere IP ist nicht betroffen
|
||||||
assert.equal((await report('card-1', '203.0.113.51')).status, 200);
|
assert.equal((await report('card-1', '203.0.51.9')).status, 200);
|
||||||
|
|
||||||
// knapp vor Ablauf noch gesperrt, nach einer Stunde wieder frei
|
// knapp vor Ablauf noch gesperrt, nach einer Stunde wieder frei
|
||||||
clock.value += 3600_000 - 1;
|
clock.value += 3600_000 - 1;
|
||||||
assert.equal((await report('card-1', '203.0.113.50')).status, 429);
|
assert.equal((await report('card-1', '203.0.50.9')).status, 429);
|
||||||
clock.value += 1;
|
clock.value += 1;
|
||||||
res = await report('card-1', '203.0.113.50');
|
res = await report('card-1', '203.0.50.9');
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.deepEqual(res.json, { schonGemeldet: true });
|
assert.deepEqual(res.json, { schonGemeldet: true });
|
||||||
});
|
});
|
||||||
@@ -627,11 +667,11 @@ test('Melden: Rate-Limit 10 pro Stunde und IP, danach 429, nach einer Stunde wie
|
|||||||
test('Melden: abgelaufene Rate-Limit-Einträge werden entfernt', async () => {
|
test('Melden: abgelaufene Rate-Limit-Einträge werden entfernt', async () => {
|
||||||
const { report, fake, clock, app } = await setup();
|
const { report, fake, clock, app } = await setup();
|
||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) await report('card-1', ip);
|
for (const ip of ['203.0.1.9', '203.0.2.9', '203.0.3.9']) await report('card-1', ip);
|
||||||
assert.equal(app.rateLimitEntries(), 3);
|
assert.equal(app.rateLimitEntries(), 3);
|
||||||
|
|
||||||
clock.value += 3600_000 + 60_000;
|
clock.value += 3600_000 + 60_000;
|
||||||
await report('card-1', '203.0.113.4');
|
await report('card-1', '203.0.4.9');
|
||||||
assert.equal(app.rateLimitEntries(), 1, 'die drei alten Einträge sind weg, nur der neue bleibt');
|
assert.equal(app.rateLimitEntries(), 1, 'die drei alten Einträge sind weg, nur der neue bleibt');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -639,22 +679,22 @@ test('Melden: Ungültige Anfragen (Grund, ID) zählen nicht zum Limit und erreic
|
|||||||
const { report, post, fake, app } = await setup();
|
const { report, post, fake, app } = await setup();
|
||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) {
|
for (const reason of ['gibt-es-nicht', '', null, 7, undefined, 'SPAM']) {
|
||||||
const res = await post('/api/melden', { id: 'card-1', reason }, null, { 'X-Real-Ip': '203.0.113.1' });
|
const res = await post('/api/melden', { id: 'card-1', reason }, null, { 'X-Real-Ip': '203.0.1.9' });
|
||||||
assert.equal(res.status, 400, String(reason));
|
assert.equal(res.status, 400, String(reason));
|
||||||
assert.equal(typeof res.json.error, 'string');
|
assert.equal(typeof res.json.error, 'string');
|
||||||
}
|
}
|
||||||
for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) {
|
for (const id of ['../x', '..', 42, null, undefined, 'a/b', 'a'.repeat(37), '']) {
|
||||||
const res = await post('/api/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.113.1' });
|
const res = await post('/api/melden', { id, reason: 'spam' }, null, { 'X-Real-Ip': '203.0.1.9' });
|
||||||
assert.equal(res.status, 400, String(id));
|
assert.equal(res.status, 400, String(id));
|
||||||
}
|
}
|
||||||
assert.equal(fake.calls.length, 0);
|
assert.equal(fake.calls.length, 0);
|
||||||
assert.equal(app.rateLimitEntries(), 0);
|
assert.equal(app.rateLimitEntries(), 0);
|
||||||
assert.equal((await report('card-1', '203.0.113.1')).status, 200);
|
assert.equal((await report('card-1', '203.0.1.9')).status, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Melden: nicht vorhandene Karte ist 404', async () => {
|
test('Melden: nicht vorhandene Karte ist 404', async () => {
|
||||||
const { report, fake } = await setup();
|
const { report, fake } = await setup();
|
||||||
const res = await report('gibts-nicht', '203.0.113.1');
|
const res = await report('gibts-nicht', '203.0.1.9');
|
||||||
assert.equal(res.status, 404);
|
assert.equal(res.status, 404);
|
||||||
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
|
assert.equal(res.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
|
||||||
assert.equal(fake.rows('reports').length, 0);
|
assert.equal(fake.rows('reports').length, 0);
|
||||||
@@ -665,7 +705,7 @@ test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spa
|
|||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
|
const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
|
||||||
for (let i = 0; i < reasons.length; i += 1) {
|
for (let i = 0; i < reasons.length; i += 1) {
|
||||||
const res = await report('card-1', `198.51.100.${i + 1}`, reasons[i]);
|
const res = await report('card-1', `198.51.${100 + i}.1`, reasons[i]);
|
||||||
assert.equal(res.status, 200, reasons[i]);
|
assert.equal(res.status, 200, reasons[i]);
|
||||||
if (i === 2) break; // ab 3 Meldern ist die Karte weg
|
if (i === 2) break; // ab 3 Meldern ist die Karte weg
|
||||||
}
|
}
|
||||||
@@ -673,22 +713,46 @@ test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spa
|
|||||||
assert.deepEqual(stored, ['kartendaten', 'unangemessen', 'urheberrecht']);
|
assert.deepEqual(stored, ['kartendaten', 'unangemessen', 'urheberrecht']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Melden: IPv6-Adressen aus demselben /64 zählen als ein Melder', async () => {
|
test('Melden: IPv6-Adressen aus demselben /48 zählen als ein Melder', async () => {
|
||||||
const { report, fake } = await setup();
|
const { report, fake } = await setup();
|
||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
assert.deepEqual((await report('card-1', '2001:db8:abcd:12::1')).json, { ok: true });
|
assert.deepEqual((await report('card-1', '2001:db8:abcd:12::1')).json, { ok: true });
|
||||||
assert.deepEqual((await report('card-1', '2001:db8:abcd:12:aaaa:bbbb:cccc:dddd')).json, { schonGemeldet: true });
|
assert.deepEqual((await report('card-1', '2001:db8:abcd:12:aaaa:bbbb:cccc:dddd')).json, { schonGemeldet: true });
|
||||||
assert.deepEqual((await report('card-1', '2001:db8:abcd:13::1')).json, { ok: true });
|
assert.deepEqual((await report('card-1', '2001:db8:abcd:13::1')).json, { schonGemeldet: true }, 'anderes /64, selbes /48');
|
||||||
|
assert.deepEqual((await report('card-1', '2001:db8:abce:12::1')).json, { ok: true });
|
||||||
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Melden: drei Adressen aus einem IPv4-/24 zählen als ein Melder und blenden nicht aus', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
assert.deepEqual((await report('card-1', '198.51.100.1')).json, { ok: true });
|
||||||
|
assert.deepEqual((await report('card-1', '198.51.100.77')).json, { schonGemeldet: true });
|
||||||
|
assert.deepEqual((await report('card-1', '198.51.100.254')).json, { schonGemeldet: true });
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
|
// drei verschiedene /24 blenden aus
|
||||||
|
await report('card-1', '198.51.101.1');
|
||||||
|
await report('card-1', '198.51.102.1');
|
||||||
|
assert.equal(fake.row('gallery', 'card-1').hidden, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Melden: das Rate-Limit zählt weiter je Anschluss (volle IPv4), nicht je Netz', async () => {
|
||||||
|
const { report, fake } = await setup();
|
||||||
|
seedCard(fake);
|
||||||
|
// 10 Meldungen von einer Adresse sind erlaubt, die 11. nicht; eine Nachbaradresse im selben /24 ist noch frei
|
||||||
|
for (let i = 0; i < 10; i += 1) assert.equal((await report('card-1', '198.51.100.1')).status, 200);
|
||||||
|
assert.equal((await report('card-1', '198.51.100.1')).status, 429);
|
||||||
|
assert.equal((await report('card-1', '198.51.100.2')).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Adresse', async () => {
|
test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Adresse', async () => {
|
||||||
const { post, fake } = await setup();
|
const { post, fake } = await setup();
|
||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
const viaForwarded = (ip) => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, { 'X-Forwarded-For': `${ip}, 10.0.0.1` });
|
const viaForwarded = (ip) => post('/api/melden', { id: 'card-1', reason: 'spam' }, null, { 'X-Forwarded-For': `${ip}, 10.0.0.1` });
|
||||||
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { ok: true });
|
assert.deepEqual((await viaForwarded('203.0.77.9')).json, { ok: true });
|
||||||
assert.deepEqual((await viaForwarded('203.0.113.77')).json, { schonGemeldet: true });
|
assert.deepEqual((await viaForwarded('203.0.77.9')).json, { schonGemeldet: true });
|
||||||
assert.deepEqual((await viaForwarded('203.0.113.78')).json, { ok: true });
|
assert.deepEqual((await viaForwarded('203.0.78.9')).json, { ok: true });
|
||||||
// ohne beide Header zählt die Socket-Adresse (127.0.0.1) als dritter Melder
|
// ohne beide Header zählt die Socket-Adresse (127.0.0.1) als dritter Melder
|
||||||
assert.deepEqual((await post('/api/melden', { id: 'card-1', reason: 'spam' })).json, { ok: true });
|
assert.deepEqual((await post('/api/melden', { id: 'card-1', reason: 'spam' })).json, { ok: true });
|
||||||
assert.equal(fake.row('gallery', 'card-1').hidden, true, 'drei verschiedene Quellen');
|
assert.equal(fake.row('gallery', 'card-1').hidden, true, 'drei verschiedene Quellen');
|
||||||
@@ -697,7 +761,7 @@ test('Melden: ohne X-Real-Ip gilt das erste X-Forwarded-For, sonst die Socket-Ad
|
|||||||
test('Melden: gleichzeitige Meldungen von drei IPs ergeben genau drei und blenden aus', async () => {
|
test('Melden: gleichzeitige Meldungen von drei IPs ergeben genau drei und blenden aus', async () => {
|
||||||
const { report, fake } = await setup();
|
const { report, fake } = await setup();
|
||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
const results = await Promise.all([report('card-1', '203.0.113.1'), report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]);
|
const results = await Promise.all([report('card-1', '203.0.1.9'), report('card-1', '203.0.2.9'), report('card-1', '203.0.3.9')]);
|
||||||
assert.deepEqual(results.map((r) => r.status), [200, 200, 200]);
|
assert.deepEqual(results.map((r) => r.status), [200, 200, 200]);
|
||||||
const row = fake.row('gallery', 'card-1');
|
const row = fake.row('gallery', 'card-1');
|
||||||
assert.equal(row.reportCount, 3);
|
assert.equal(row.reportCount, 3);
|
||||||
@@ -710,9 +774,9 @@ test('Melden: eine verspätete Zählerstand-Änderung überschreibt neuere Stän
|
|||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
// Die erste Meldung schreibt ihren Zähler (1) erst nach 150 ms; währenddessen kommen zwei weitere.
|
// Die erste Meldung schreibt ihren Zähler (1) erst nach 150 ms; währenddessen kommen zwei weitere.
|
||||||
fake.delayNext((c) => c.method === 'PATCH' && c.body && c.body.data && c.body.data.reportCount === 1, 150);
|
fake.delayNext((c) => c.method === 'PATCH' && c.body && c.body.data && c.body.data.reportCount === 1, 150);
|
||||||
const first = report('card-1', '203.0.113.1');
|
const first = report('card-1', '203.0.1.9');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
const others = await Promise.all([report('card-1', '203.0.113.2'), report('card-1', '203.0.113.3')]);
|
const others = await Promise.all([report('card-1', '203.0.2.9'), report('card-1', '203.0.3.9')]);
|
||||||
const firstResult = await first;
|
const firstResult = await first;
|
||||||
assert.deepEqual([firstResult.status, ...others.map((r) => r.status)], [200, 200, 200]);
|
assert.deepEqual([firstResult.status, ...others.map((r) => r.status)], [200, 200, 200]);
|
||||||
|
|
||||||
@@ -726,12 +790,12 @@ test('Melden: eine doppelte Meldung heilt einen früher steckengebliebenen Zähl
|
|||||||
const { report, fake } = await setup();
|
const { report, fake } = await setup();
|
||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
// drei Meldungen liegen schon vor, der Zähler an der Karte kam aber nie an (z. B. Ausfall mitten im Ablauf)
|
// drei Meldungen liegen schon vor, der Zähler an der Karte kam aber nie an (z. B. Ausfall mitten im Ablauf)
|
||||||
for (const ip of ['203.0.113.1', '203.0.113.2', '203.0.113.3']) {
|
for (const ip of ['203.0.1.9', '203.0.2.9', '203.0.3.9']) {
|
||||||
const hash = reporterHash(SALT, ip);
|
const hash = reporterHash(SALT, ip);
|
||||||
fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []);
|
fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []);
|
||||||
}
|
}
|
||||||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
const res = await report('card-1', '203.0.113.1');
|
const res = await report('card-1', '203.0.1.9');
|
||||||
assert.deepEqual(res.json, { schonGemeldet: true });
|
assert.deepEqual(res.json, { schonGemeldet: true });
|
||||||
assert.equal(fake.row('gallery', 'card-1').hidden, true);
|
assert.equal(fake.row('gallery', 'card-1').hidden, true);
|
||||||
assert.equal(fake.row('gallery', 'card-1').reportCount, 3);
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 3);
|
||||||
@@ -741,7 +805,7 @@ test('Melden: Ausfall beim Anlegen der Meldung ergibt 502 und zählt nicht', asy
|
|||||||
const { report, fake } = await setup();
|
const { report, fake } = await setup();
|
||||||
seedCard(fake);
|
seedCard(fake);
|
||||||
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/reports/rows'), { status: 500 });
|
fake.failNext((c) => c.method === 'POST' && c.path.endsWith('/tables/reports/rows'), { status: 500 });
|
||||||
const res = await report('card-1', '203.0.113.1');
|
const res = await report('card-1', '203.0.1.9');
|
||||||
assert.equal(res.status, 502);
|
assert.equal(res.status, 502);
|
||||||
assert.equal(fake.row('gallery', 'card-1').reportCount, 0);
|
assert.equal(fake.row('gallery', 'card-1').reportCount, 0);
|
||||||
assert.equal(fake.rows('reports').length, 0);
|
assert.equal(fake.rows('reports').length, 0);
|
||||||
@@ -750,13 +814,13 @@ test('Melden: Ausfall beim Anlegen der Meldung ergibt 502 und zählt nicht', asy
|
|||||||
test('Melden: Ausfall beim Ausblenden endet mit 502, ein erneuter Versuch blendet aus', async () => {
|
test('Melden: Ausfall beim Ausblenden endet mit 502, ein erneuter Versuch blendet aus', async () => {
|
||||||
const { report, fake } = await setup();
|
const { report, fake } = await setup();
|
||||||
seedCard(fake, { reportCount: 0 });
|
seedCard(fake, { reportCount: 0 });
|
||||||
await report('card-1', '203.0.113.1');
|
await report('card-1', '203.0.1.9');
|
||||||
await report('card-1', '203.0.113.2');
|
await report('card-1', '203.0.2.9');
|
||||||
fake.failNext((c) => c.method === 'PATCH' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
|
fake.failNext((c) => c.method === 'PATCH' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
|
||||||
const res = await report('card-1', '203.0.113.3');
|
const res = await report('card-1', '203.0.3.9');
|
||||||
assert.equal(res.status, 502);
|
assert.equal(res.status, 502);
|
||||||
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
assert.equal(fake.row('gallery', 'card-1').hidden, false);
|
||||||
const retry = await report('card-1', '203.0.113.3');
|
const retry = await report('card-1', '203.0.3.9');
|
||||||
assert.equal(retry.status, 200);
|
assert.equal(retry.status, 200);
|
||||||
assert.equal(fake.row('gallery', 'card-1').hidden, true);
|
assert.equal(fake.row('gallery', 'card-1').hidden, true);
|
||||||
});
|
});
|
||||||
@@ -849,7 +913,7 @@ test('Admin blendet aus: Zeilen- und Dateirechte, hidden=true, Meldungen bleiben
|
|||||||
assert.equal(row.hidden, true);
|
assert.equal(row.hidden, true);
|
||||||
assert.equal(row.reportCount, 1);
|
assert.equal(row.reportCount, 1);
|
||||||
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
assert.deepEqual(row.$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
||||||
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
|
const hiddenFile = ['read("user:user-me")', 'read("label:admin")'];
|
||||||
assert.deepEqual(fake.file('card-1-img').$permissions, hiddenFile);
|
assert.deepEqual(fake.file('card-1-img').$permissions, hiddenFile);
|
||||||
assert.deepEqual(fake.file('card-1-prev').$permissions, hiddenFile);
|
assert.deepEqual(fake.file('card-1-prev').$permissions, hiddenFile);
|
||||||
assert.equal(fake.rows('reports').length, 1);
|
assert.equal(fake.rows('reports').length, 1);
|
||||||
@@ -869,12 +933,12 @@ test('Admin blendet ein: sichtbare Rechte, hidden=false, reportCount=0, Meldunge
|
|||||||
assert.equal(row.hidden, false);
|
assert.equal(row.hidden, false);
|
||||||
assert.equal(row.reportCount, 0);
|
assert.equal(row.reportCount, 0);
|
||||||
assert.deepEqual(row.$permissions, ['read("any")']);
|
assert.deepEqual(row.$permissions, ['read("any")']);
|
||||||
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("any")', 'delete("user:user-me")']);
|
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("any")']);
|
||||||
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
|
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")']);
|
||||||
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
|
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
|
||||||
|
|
||||||
// die Karte ist wieder meldbar
|
// die Karte ist wieder meldbar
|
||||||
assert.equal((await report('card-1', '203.0.113.1')).status, 200);
|
assert.equal((await report('card-1', '203.0.1.9')).status, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Admin-Sichtbarkeit: Body-Prüfung und fehlende Karte', async () => {
|
test('Admin-Sichtbarkeit: Body-Prüfung und fehlende Karte', async () => {
|
||||||
@@ -901,9 +965,9 @@ test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [
|
|||||||
seedCard(fake, { id: 'card-9' });
|
seedCard(fake, { id: 'card-9' });
|
||||||
await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz');
|
await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz');
|
||||||
await report('card-9', '203.0.113.7');
|
await report('card-9', '203.0.7.9');
|
||||||
await report('card-9', '203.0.113.8');
|
await report('card-9', '203.0.8.9');
|
||||||
await report('card-9', '203.0.113.9');
|
await report('card-9', '203.0.9.9');
|
||||||
await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN);
|
await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN);
|
||||||
fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 });
|
fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 });
|
||||||
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
|
await post('/api/zurueckziehen', { id: 'card-9' }, JWT_ME);
|
||||||
@@ -911,7 +975,7 @@ test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [
|
|||||||
|
|
||||||
assert.ok(logs.length > 0);
|
assert.ok(logs.length > 0);
|
||||||
const all = logs.join('\n');
|
const all = logs.join('\n');
|
||||||
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.113', '127.0.0.1', reporterHash(SALT, '203.0.113.7')]) {
|
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.', '127.0.0.1', reporterHash(SALT, '203.0.7.9')]) {
|
||||||
assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);
|
assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);
|
||||||
}
|
}
|
||||||
for (const line of logs) assert.ok(line.startsWith('[aircard-api] '), line);
|
for (const line of logs) assert.ok(line.startsWith('[aircard-api] '), line);
|
||||||
@@ -921,7 +985,7 @@ test('Log-Flut: abgewiesene Anfragen (400/401/404/413/415/429) erzeugen keine Lo
|
|||||||
const { call, post, report, logs } = await setup();
|
const { call, post, report, logs } = await setup();
|
||||||
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
|
const big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
|
||||||
for (let i = 0; i < 12; i += 1) {
|
for (let i = 0; i < 12; i += 1) {
|
||||||
await report('gibts-nicht', '203.0.113.5'); // 404, ab der 11. Meldung 429
|
await report('gibts-nicht', '203.0.5.9'); // 404, ab der 11. Meldung 429
|
||||||
await call('POST', '/api/melden', { raw: '{"id":"x","reason":"spam"}', contentType: 'text/plain' }); // 415
|
await call('POST', '/api/melden', { raw: '{"id":"x","reason":"spam"}', contentType: 'text/plain' }); // 415
|
||||||
await call('POST', '/api/melden', { raw: big }); // 413
|
await call('POST', '/api/melden', { raw: big }); // 413
|
||||||
await call('POST', '/api/melden', { raw: '{kaputt' }); // 400
|
await call('POST', '/api/melden', { raw: '{kaputt' }); // 400
|
||||||
@@ -930,7 +994,7 @@ test('Log-Flut: abgewiesene Anfragen (400/401/404/413/415/429) erzeugen keine Lo
|
|||||||
await post('/api/zurueckziehen', { id: 'x' }, 'zwei teile'); // 401
|
await post('/api/zurueckziehen', { id: 'x' }, 'zwei teile'); // 401
|
||||||
await call('GET', '/api/gibts-nicht'); // 404
|
await call('GET', '/api/gibts-nicht'); // 404
|
||||||
}
|
}
|
||||||
const lastLimited = await report('gibts-nicht', '203.0.113.5');
|
const lastLimited = await report('gibts-nicht', '203.0.5.9');
|
||||||
assert.equal(lastLimited.status, 429, 'das Rate-Limit hat wirklich gegriffen');
|
assert.equal(lastLimited.status, 429, 'das Rate-Limit hat wirklich gegriffen');
|
||||||
assert.deepEqual(logs, []);
|
assert.deepEqual(logs, []);
|
||||||
});
|
});
|
||||||
@@ -977,7 +1041,7 @@ test('Appwrite-Fehler: 502 bei 5xx und 429, 500 bei allem anderen (falscher Key,
|
|||||||
];
|
];
|
||||||
for (const [failure, status, message] of expectations) {
|
for (const [failure, status, message] of expectations) {
|
||||||
fake.failNext(isRowRead, failure);
|
fake.failNext(isRowRead, failure);
|
||||||
const res = await report('card-1', '203.0.113.1');
|
const res = await report('card-1', '203.0.1.9');
|
||||||
assert.equal(res.status, status, `${failure.status} ${failure.type}`);
|
assert.equal(res.status, status, `${failure.status} ${failure.type}`);
|
||||||
assert.equal(res.json.error, message);
|
assert.equal(res.json.error, message);
|
||||||
}
|
}
|
||||||
@@ -1002,14 +1066,29 @@ test('Fehlende Tabelle oder Bucket ist kein „Karte nicht verfügbar“, sonder
|
|||||||
assert.equal(res.json.error, 'Interner Fehler.');
|
assert.equal(res.json.error, 'Interner Fehler.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Veröffentlichen legt beide Dateien auf genau die sichtbaren Rechte fest (kanonischer Zustand)', async () => {
|
test('Veröffentlichen legt beide Dateien auf genau read("any") fest: kein delete(owner) mehr (kanonischer Zustand)', async () => {
|
||||||
const { post, fake } = await setup();
|
const { post, fake } = await setup();
|
||||||
|
fake.addCard('card1', ME);
|
||||||
fake.addFile({ id: 'img1', owner: ME, permissions: ['read("any")', 'delete("user:user-me")', 'update("user:user-me")', 'read("users")'] });
|
fake.addFile({ id: 'img1', owner: ME, permissions: ['read("any")', 'delete("user:user-me")', 'update("user:user-me")', 'read("users")'] });
|
||||||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("any")', 'delete("user:user-me")', 'write("user:user-me")'] });
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', permissions: ['read("any")', 'delete("user:user-me")', 'write("user:user-me")'] });
|
||||||
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
assert.equal(res.status, 201);
|
assert.equal(res.status, 201);
|
||||||
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']);
|
assert.deepEqual(fake.file('img1').$permissions, ['read("any")']);
|
||||||
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")', 'delete("user:user-me")']);
|
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Löschen und Neuanlegen derselben Datei nach dem Veröffentlichen ist ausgeschlossen: Autor hat kein delete mehr', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
|
||||||
|
for (const id of ['img1', 'prev1']) {
|
||||||
|
assert.ok(!fake.file(id).$permissions.some((p) => p.startsWith('delete(') || p.startsWith('update(') || p.startsWith('write(')), id);
|
||||||
|
}
|
||||||
|
// dieselben (jetzt gepinnten) Dateien lassen sich nicht noch einmal einreichen
|
||||||
|
fake.addCard('card2', ME);
|
||||||
|
const again = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'card2' }), JWT_ME);
|
||||||
|
assert.equal(again.status, 403);
|
||||||
|
assert.equal(again.json.error, TAKEN);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ein 409 beim Veröffentlichen fasst die Dateirechte der neu hochgeladenen Dateien nicht an', async () => {
|
test('Ein 409 beim Veröffentlichen fasst die Dateirechte der neu hochgeladenen Dateien nicht an', async () => {
|
||||||
@@ -1028,12 +1107,11 @@ test('Ein 409 beim Veröffentlichen fasst die Dateirechte der neu hochgeladenen
|
|||||||
assert.equal(fake.calls.filter((c) => c.method === 'PUT' && /\/files\/(img2|prev2)$/.test(c.path)).length, 0, 'kein PUT auf die neuen Dateien');
|
assert.equal(fake.calls.filter((c) => c.method === 'PUT' && /\/files\/(img2|prev2)$/.test(c.path)).length, 0, 'kein PUT auf die neuen Dateien');
|
||||||
});
|
});
|
||||||
|
|
||||||
const TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
|
||||||
|
|
||||||
test('Dateien einer ausgeblendeten Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
|
test('Dateien einer ausgeblendeten Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
|
||||||
const { post, fake } = await setup();
|
const { post, fake } = await setup();
|
||||||
const { imageFileId, previewFileId } = seedCard(fake, { id: 'hid', hidden: true, reportCount: 3 });
|
const { imageFileId, previewFileId } = seedCard(fake, { id: 'hid', hidden: true, reportCount: 3 });
|
||||||
const hiddenFile = ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")'];
|
fake.addCard('andere-karte', ME);
|
||||||
|
const hiddenFile = ['read("user:user-me")', 'read("label:admin")'];
|
||||||
|
|
||||||
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'andere-karte' }), JWT_ME);
|
const res = await post('/api/veroeffentlichen', publishBody({ imageFileId, previewFileId, sourceCardId: 'andere-karte' }), JWT_ME);
|
||||||
assert.equal(res.status, 403);
|
assert.equal(res.status, 403);
|
||||||
@@ -1052,6 +1130,7 @@ test('Dateien einer ausgeblendeten Karte lassen sich nicht unter anderer sourceC
|
|||||||
test('Dateien einer sichtbaren Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
|
test('Dateien einer sichtbaren Karte lassen sich nicht unter anderer sourceCardId neu veröffentlichen (403)', async () => {
|
||||||
const { post, fake } = await setup();
|
const { post, fake } = await setup();
|
||||||
const { imageFileId, previewFileId } = seedCard(fake, { id: 'vis' });
|
const { imageFileId, previewFileId } = seedCard(fake, { id: 'vis' });
|
||||||
|
fake.addCard('andere-karte', ME);
|
||||||
fake.addFile({ id: 'fresh-img', owner: ME });
|
fake.addFile({ id: 'fresh-img', owner: ME });
|
||||||
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
|
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
|
||||||
|
|
||||||
@@ -1074,6 +1153,8 @@ test('Dateien einer sichtbaren Karte lassen sich nicht unter anderer sourceCardI
|
|||||||
test('Überkreuz-Verwendung: Bild einer Karte als Vorschau der neuen (und umgekehrt) wird abgelehnt', async () => {
|
test('Überkreuz-Verwendung: Bild einer Karte als Vorschau der neuen (und umgekehrt) wird abgelehnt', async () => {
|
||||||
const { post, fake } = await setup();
|
const { post, fake } = await setup();
|
||||||
seedCard(fake, { id: 'vis' }); // Bild vis-img, Vorschau vis-prev
|
seedCard(fake, { id: 'vis' }); // Bild vis-img, Vorschau vis-prev
|
||||||
|
fake.addCard('x1', ME);
|
||||||
|
fake.addCard('x2', ME);
|
||||||
fake.addFile({ id: 'fresh-img', owner: ME });
|
fake.addFile({ id: 'fresh-img', owner: ME });
|
||||||
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
|
fake.addFile({ id: 'fresh-prev', owner: ME, mimeType: 'image/webp' });
|
||||||
// vis-img (PNG) darf Vorschau sein, vis-prev (WebP) darf Bild sein: Format allein verhindert die Kreuzung nicht
|
// vis-img (PNG) darf Vorschau sein, vis-prev (WebP) darf Bild sein: Format allein verhindert die Kreuzung nicht
|
||||||
@@ -1088,6 +1169,7 @@ test('Überkreuz-Verwendung: Bild einer Karte als Vorschau der neuen (und umgeke
|
|||||||
|
|
||||||
test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)', async () => {
|
test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)', async () => {
|
||||||
const { post, fake } = await setup();
|
const { post, fake } = await setup();
|
||||||
|
fake.addCard('card1', ME);
|
||||||
fake.addFile({ id: 'img1', owner: ME, permissions: ['delete("user:user-me")'] });
|
fake.addFile({ id: 'img1', owner: ME, permissions: ['delete("user:user-me")'] });
|
||||||
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
|
||||||
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
@@ -1101,10 +1183,10 @@ test('Dateien ohne read("any") (nicht frisch hochgeladen) werden abgelehnt (403)
|
|||||||
assert.equal(fake.rows('gallery').length, 0);
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Prüfung der Dateinutzung blättert durch alle Karten des Autors (250 Karten) und fragt nur dessen Zeilen ab', async () => {
|
test('Prüfung der Dateinutzung und des Limits fragt nur die Zeilen des Autors ab (ein Aufruf, höchstens 20 Zeilen)', async () => {
|
||||||
const { post, fake } = await setup();
|
const { post, fake } = await setup();
|
||||||
for (let i = 0; i < 250; i += 1) {
|
for (let i = 0; i < 40; i += 1) {
|
||||||
const owner = i % 50 === 0 ? OTHER : ME;
|
const owner = i % 2 === 0 ? OTHER : ME; // 20 Karten je Person
|
||||||
fake.addRow(
|
fake.addRow(
|
||||||
'gallery',
|
'gallery',
|
||||||
`row${i}`,
|
`row${i}`,
|
||||||
@@ -1112,26 +1194,31 @@ test('Prüfung der Dateinutzung blättert durch alle Karten des Autors (250 Kart
|
|||||||
['read("any")'],
|
['read("any")'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fake.addFile({ id: 'f230a', owner: ME });
|
// Zeile 39 gehört OTHER: ME hat 19 Zeilen (eine weniger als das Limit), OTHER 21
|
||||||
fake.addFile({ id: 'f230b', owner: ME, mimeType: 'image/webp' });
|
fake.rows('gallery').find((row) => row.$id === 'row39').ownerId = OTHER;
|
||||||
const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f230a', previewFileId: 'f230b', sourceCardId: 'neu1' }), JWT_ME);
|
fake.addFile({ id: 'f37a', owner: ME, permissions: ['read("any")', 'delete("user:user-me")'] });
|
||||||
assert.equal(taken.status, 403, 'Karte 230 liegt auf der dritten Seite');
|
fake.addFile({ id: 'f37b', owner: ME, mimeType: 'image/webp' });
|
||||||
|
fake.addCard('neu1', ME);
|
||||||
|
const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f37a', previewFileId: 'f37b', sourceCardId: 'neu1' }), JWT_ME);
|
||||||
|
assert.equal(taken.status, 403, 'Karte 37 des Autors gehört zu seinen 19 Zeilen');
|
||||||
assert.equal(taken.json.error, TAKEN);
|
assert.equal(taken.json.error, TAKEN);
|
||||||
const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/gallery/rows'));
|
const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/gallery/rows'));
|
||||||
assert.equal(lists.length, 3, 'drei Seiten à 100');
|
assert.equal(lists.length, 1);
|
||||||
for (const call of lists) assert.ok(decodeURIComponent(call.search).includes('"attribute":"ownerId","values":["user-me"]'));
|
const search = decodeURIComponent(lists[0].search);
|
||||||
|
assert.ok(search.includes('"attribute":"ownerId","values":["user-me"]'));
|
||||||
|
assert.ok(search.includes('"method":"limit","values":[20]'));
|
||||||
|
|
||||||
seedUploads(fake);
|
seedUploads(fake);
|
||||||
|
fake.addCard('neu2', ME);
|
||||||
const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME);
|
const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME);
|
||||||
assert.equal(ok.status, 201);
|
assert.equal(ok.status, 201);
|
||||||
|
|
||||||
// Karten anderer Autoren zählen nicht (deren Dateien kann dieser Autor ohnehin nicht verwenden)
|
|
||||||
assert.ok(fake.row('gallery', 'row0'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Gleichzeitiges Veröffentlichen derselben Dateien unter zwei sourceCardIds: genau eines gelingt', async () => {
|
test('Gleichzeitiges Veröffentlichen derselben Dateien unter zwei sourceCardIds: genau eines gelingt', async () => {
|
||||||
const { post, fake } = await setup();
|
const { post, fake } = await setup();
|
||||||
seedUploads(fake);
|
seedUploads(fake);
|
||||||
|
fake.addCard('c1', ME);
|
||||||
|
fake.addCard('c2', ME);
|
||||||
// Das Anlegen der ersten Zeile verzögern, damit beide Anfragen die Prüfung vor dem Anlegen erreichen würden.
|
// Das Anlegen der ersten Zeile verzögern, damit beide Anfragen die Prüfung vor dem Anlegen erreichen würden.
|
||||||
fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows'), 80);
|
fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows'), 80);
|
||||||
const results = await Promise.all([
|
const results = await Promise.all([
|
||||||
@@ -1149,6 +1236,8 @@ test('Veröffentlichen: scheitert das Festlegen der Dateirechte, wird die Zeile
|
|||||||
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
assert.equal(res.status, 502);
|
assert.equal(res.status, 502);
|
||||||
assert.equal(fake.rows('gallery').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert');
|
assert.equal(fake.rows('gallery').length, 0, 'keine halbfertige Zeile, die eine Wiederholung mit 409 blockiert');
|
||||||
|
// Das schon festgelegte Bild bekommt delete(owner) zurück, damit der Client den Fehlversuch aufräumen kann
|
||||||
|
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']);
|
||||||
|
|
||||||
// Datei verschwindet zwischen Prüfung und Festlegen: 400 und ebenfalls zurückgenommen
|
// Datei verschwindet zwischen Prüfung und Festlegen: 400 und ebenfalls zurückgenommen
|
||||||
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/img1'), { status: 404, type: 'storage_file_not_found' });
|
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/img1'), { status: 404, type: 'storage_file_not_found' });
|
||||||
@@ -1170,8 +1259,8 @@ test('Dateirechte setzen wartet auf beide Aufrufe, auch wenn einer scheitert (Sp
|
|||||||
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
|
const res = await post('/api/admin/sichtbarkeit', { id: 'card-1', sichtbar: false }, JWT_ADMIN);
|
||||||
assert.equal(res.status, 502);
|
assert.equal(res.status, 502);
|
||||||
// Die Antwort kam erst, nachdem auch der langsame PUT durch war.
|
// Die Antwort kam erst, nachdem auch der langsame PUT durch war.
|
||||||
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("user:user-me")', 'read("label:admin")', 'delete("user:user-me")']);
|
assert.deepEqual(fake.file('card-1-img').$permissions, ['read("user:user-me")', 'read("label:admin")']);
|
||||||
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")', 'delete("user:user-me")']);
|
assert.deepEqual(fake.file('card-1-prev').$permissions, ['read("any")']);
|
||||||
assert.equal(fake.row('gallery', 'card-1').hidden, false, 'die Zeile wird erst nach den Dateien geändert');
|
assert.equal(fake.row('gallery', 'card-1').hidden, false, 'die Zeile wird erst nach den Dateien geändert');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1243,6 +1332,238 @@ test('Unerwartete Fehler im Appwrite-Objekt ergeben 500, ohne die Anfrage hänge
|
|||||||
assert.ok(!logs.join('\n').includes('GEHEIM'), 'Fehlertexte werden nicht ins Log übernommen');
|
assert.ok(!logs.join('\n').includes('GEHEIM'), 'Fehlertexte werden nicht ins Log übernommen');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Bildmaße (Defense in Depth beim Veröffentlichen) ---------------------------------------------
|
||||||
|
|
||||||
|
test('readImageSize liest PNG-IHDR sowie WebP VP8, VP8L und VP8X', () => {
|
||||||
|
assert.deepEqual(readImageSize(pngHeader(1536, 969)), { width: 1536, height: 969 });
|
||||||
|
assert.deepEqual(readImageSize(webpHeader('VP8 ', 480, 303)), { width: 480, height: 303 });
|
||||||
|
assert.deepEqual(readImageSize(webpHeader('VP8L', 1536, 969)), { width: 1536, height: 969 });
|
||||||
|
assert.deepEqual(readImageSize(webpHeader('VP8X', 16383, 16383)), { width: 16383, height: 16383 });
|
||||||
|
assert.deepEqual(readImageSize(pngHeader(65535, 70000)), { width: 65535, height: 70000 });
|
||||||
|
// gekürzt auf genau die nötigen Bytes (PNG 24, VP8 30, VP8L 25, VP8X 30) geht es noch, eines weniger nicht
|
||||||
|
assert.deepEqual(readImageSize(pngHeader(7, 8).subarray(0, 24)), { width: 7, height: 8 });
|
||||||
|
assert.equal(readImageSize(pngHeader(7, 8).subarray(0, 23)), null);
|
||||||
|
assert.deepEqual(readImageSize(webpHeader('VP8 ', 7, 8).subarray(0, 30)), { width: 7, height: 8 });
|
||||||
|
assert.equal(readImageSize(webpHeader('VP8 ', 7, 8).subarray(0, 29)), null);
|
||||||
|
assert.deepEqual(readImageSize(webpHeader('VP8L', 7, 8).subarray(0, 25)), { width: 7, height: 8 });
|
||||||
|
assert.equal(readImageSize(webpHeader('VP8L', 7, 8).subarray(0, 24)), null);
|
||||||
|
assert.deepEqual(readImageSize(webpHeader('VP8X', 7, 8).subarray(0, 30)), { width: 7, height: 8 });
|
||||||
|
assert.equal(readImageSize(webpHeader('VP8X', 7, 8).subarray(0, 29)), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readImageSize: Unbekanntes, Leeres und Kaputtes ergibt null', () => {
|
||||||
|
assert.equal(readImageSize(Buffer.alloc(0)), null);
|
||||||
|
assert.equal(readImageSize(Buffer.from('GIF89a')), null);
|
||||||
|
assert.equal(readImageSize(Buffer.alloc(64)), null);
|
||||||
|
assert.equal(readImageSize(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="1536" height="969"></svg>')), null);
|
||||||
|
const brokenPng = pngHeader(1536, 969);
|
||||||
|
brokenPng.write('IDAT', 12, 'latin1'); // erster Chunk ist nicht IHDR
|
||||||
|
assert.equal(readImageSize(brokenPng), null);
|
||||||
|
const lossyBadStart = webpHeader('VP8 ', 480, 303);
|
||||||
|
lossyBadStart[24] = 0; // Startcode kaputt
|
||||||
|
assert.equal(readImageSize(lossyBadStart), null);
|
||||||
|
const losslessBadSignature = webpHeader('VP8L', 480, 303);
|
||||||
|
losslessBadSignature[20] = 0;
|
||||||
|
assert.equal(readImageSize(losslessBadSignature), null);
|
||||||
|
const unknownChunk = webpHeader('VP8 ', 480, 303);
|
||||||
|
unknownChunk.write('ANIM', 12, 'latin1');
|
||||||
|
assert.equal(readImageSize(unknownChunk), null);
|
||||||
|
assert.equal(readImageSize(null), null);
|
||||||
|
assert.equal(readImageSize('PNG'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: Bild muss 1536 x 969 sein, Vorschau 480 breit und 302 bis 304 hoch (sonst 400)', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
fake.addCard('card1', ME);
|
||||||
|
const attempt = ({ image, preview }) => {
|
||||||
|
fake.addFile({ id: 'img1', owner: ME, content: image });
|
||||||
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp', content: preview });
|
||||||
|
return post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
};
|
||||||
|
const okImage = pngHeader(1536, 969);
|
||||||
|
const okPreview = webpHeader('VP8 ', 480, 303);
|
||||||
|
|
||||||
|
for (const [label, files] of [
|
||||||
|
['Bild zu schmal', { image: pngHeader(1535, 969), preview: okPreview }],
|
||||||
|
['Bild zu hoch', { image: pngHeader(1536, 970), preview: okPreview }],
|
||||||
|
['Bild 4 Megapixel', { image: pngHeader(2000, 2000), preview: okPreview }],
|
||||||
|
['Bild riesig (Dekompressionsbombe)', { image: pngHeader(60000, 60000), preview: okPreview }],
|
||||||
|
['Bild WebP riesig', { image: webpHeader('VP8L', 16383, 16383), preview: okPreview }],
|
||||||
|
['Vorschau zu breit', { image: okImage, preview: webpHeader('VP8 ', 481, 303) }],
|
||||||
|
['Vorschau zu niedrig', { image: okImage, preview: webpHeader('VP8 ', 480, 301) }],
|
||||||
|
['Vorschau zu hoch', { image: okImage, preview: webpHeader('VP8 ', 480, 305) }],
|
||||||
|
['Vorschau in Bildgröße', { image: okImage, preview: webpHeader('VP8L', 1536, 969) }],
|
||||||
|
['Bild ist kein Bild', { image: Buffer.from('MZ\x90\x00 kein Bild, nur Text, lang genug für die Prüfung 123456789'), preview: okPreview }],
|
||||||
|
['Vorschau ist kein Bild', { image: okImage, preview: Buffer.alloc(100) }],
|
||||||
|
['Bild leer', { image: Buffer.alloc(0), preview: okPreview }],
|
||||||
|
['Bild abgeschnitten', { image: pngHeader(1536, 969).subarray(0, 20), preview: okPreview }],
|
||||||
|
]) {
|
||||||
|
const res = await attempt(files);
|
||||||
|
assert.equal(res.status, 400, label);
|
||||||
|
assert.equal(res.json.error, NOT_CARD_FORMAT, label);
|
||||||
|
assert.equal(fake.rows('gallery').length, 0, label);
|
||||||
|
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], `${label}: Dateien bleiben unangetastet`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// genau die erwarteten Maße gehen; die Vorschau darf 302, 303 oder 304 hoch sein, auch als PNG (Browser ohne WebP-Export)
|
||||||
|
for (const preview of [webpHeader('VP8 ', 480, 302), webpHeader('VP8L', 480, 304), webpHeader('VP8X', 480, 303), pngHeader(480, 303)]) {
|
||||||
|
const res = await attempt({ image: okImage, preview });
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
assert.equal((await post('/api/zurueckziehen', { id: res.json.id }, JWT_ME)).status, 200);
|
||||||
|
}
|
||||||
|
// auch ein WebP-Bild in voller Größe ist ein gültiges Kartenbild
|
||||||
|
assert.equal((await attempt({ image: webpHeader('VP8L', 1536, 969), preview: okPreview })).status, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen liest nur den Anfang der Dateien und schickt dafür den Server-Key an /view', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
const big = Buffer.concat([pngHeader(1536, 969), Buffer.alloc(3 * 1024 * 1024)]);
|
||||||
|
fake.addFile({ id: 'img1', owner: ME, content: big, size: big.length });
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
|
||||||
|
const views = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/view'));
|
||||||
|
assert.deepEqual(views.map((c) => c.path).sort(), [
|
||||||
|
'/v1/storage/buckets/gallery-images/files/img1/view',
|
||||||
|
'/v1/storage/buckets/gallery-images/files/prev1/view',
|
||||||
|
]);
|
||||||
|
for (const c of views) assert.equal(c.headers['x-appwrite-key'], fake.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: fremde oder gesperrte Dateien werden nicht gelesen (kein Maß-Orakel)', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
fake.addCard('card1', ME);
|
||||||
|
fake.addFile({ id: 'img1', owner: OTHER });
|
||||||
|
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 403);
|
||||||
|
assert.equal(fake.calls.filter((c) => c.path.endsWith('/view')).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: Datei verschwindet vor dem Lesen der Maße ergibt 400 "Bild nicht gefunden."', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/files/prev1/view'), { status: 404, type: 'storage_file_not_found' });
|
||||||
|
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(res.json.error, 'Bild nicht gefunden.');
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: Appwrite-Fehler beim Lesen der Maße ergibt 502, nichts angelegt', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
fake.failNext((c) => c.path.endsWith('/files/img1/view'), { status: 500 });
|
||||||
|
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Limits und Besitz der Quellkarte ---------------------------------------------------------
|
||||||
|
|
||||||
|
function seedPublications(fake, owner, count) {
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
fake.addRow(
|
||||||
|
'gallery',
|
||||||
|
`${owner}-pub${i}`,
|
||||||
|
{ title: `P${i}`, imageFileId: `${owner}-i${i}`, previewFileId: `${owner}-p${i}`, sourceCardId: `${owner}-s${i}`, ownerId: owner, hidden: i % 2 === 1, reportCount: 0 },
|
||||||
|
['read("any")'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Veröffentlichen: höchstens 20 Veröffentlichungen je Konto (ausgeblendete zählen mit), die 21. ergibt 429', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedPublications(fake, ME, 20);
|
||||||
|
seedUploads(fake);
|
||||||
|
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 429);
|
||||||
|
assert.equal(res.json.error, TOO_MANY_CARDS);
|
||||||
|
assert.equal(fake.rows('gallery').length, 20);
|
||||||
|
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], 'Dateien unangetastet');
|
||||||
|
|
||||||
|
// Karten anderer Konten zählen nicht
|
||||||
|
seedPublications(fake, OTHER, 25);
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 429);
|
||||||
|
seedUploads(fake, OTHER, { image: 'oimg', preview: 'oprev', card: 'ocard' });
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'oimg', previewFileId: 'oprev', sourceCardId: 'ocard' }), JWT_OTHER)).status, 429);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: mit 19 Veröffentlichungen geht die 20., nach dem Zurückziehen wieder eine', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedPublications(fake, ME, 19);
|
||||||
|
seedUploads(fake);
|
||||||
|
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
fake.addCard('card2', ME);
|
||||||
|
seedUploads(fake, ME, { image: 'img2', preview: 'prev2', card: 'card2' });
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2', sourceCardId: 'card2' }), JWT_ME)).status, 429);
|
||||||
|
assert.equal((await post('/api/zurueckziehen', { id: res.json.id }, JWT_ME)).status, 200);
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img2', previewFileId: 'prev2', sourceCardId: 'card2' }), JWT_ME)).status, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: sourceCardId muss eine Zeile in `cards` mit read("user:<ich>") sein, sonst 403', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake, ME, { card: null });
|
||||||
|
|
||||||
|
// gibt es nicht
|
||||||
|
let res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.equal(res.json.error, NOT_YOUR_CARD);
|
||||||
|
|
||||||
|
// gehört jemand anderem
|
||||||
|
fake.addCard('card1', OTHER);
|
||||||
|
res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.equal(res.json.error, NOT_YOUR_CARD);
|
||||||
|
|
||||||
|
// nur lesbar für alle, aber nicht meine
|
||||||
|
fake.addCard('card1', OTHER, ['read("any")']);
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 403);
|
||||||
|
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")'], 'Dateien unangetastet');
|
||||||
|
assert.equal(fake.calls.filter((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows')).length, 0);
|
||||||
|
|
||||||
|
// meine Karte geht
|
||||||
|
fake.addCard('card1', ME);
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: Fehler beim Lesen von `cards` ergibt 502, ein fehlender Tabellenname 500, nichts angelegt', async () => {
|
||||||
|
const { post, fake } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/cards/rows/'), { status: 503 });
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 502);
|
||||||
|
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/cards/rows/'), { status: 404, type: 'table_not_found' });
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 500);
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Rollback-Protokoll ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('Veröffentlichen: scheitern Festlegen und Zurücknehmen, steht `rollback failed for card <id>` im Log (nur die ID)', async () => {
|
||||||
|
const { post, fake, logs } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
|
||||||
|
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/gallery/rows/'), { status: 500 });
|
||||||
|
const res = await post('/api/veroeffentlichen', publishBody(), JWT_ME);
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
const id = galleryRowId(ME, 'card1');
|
||||||
|
assert.equal(fake.rows('gallery').length, 1, 'die Zeile blieb stehen (Zurücknehmen scheiterte)');
|
||||||
|
const lines = logs.filter((line) => line.includes('rollback failed'));
|
||||||
|
assert.equal(lines.length, 1);
|
||||||
|
assert.match(lines[0], new RegExp(`^\\[aircard-api\\] \\S+ rollback failed for card ${id}$`));
|
||||||
|
assert.ok(!logs.join('\n').includes(JWT_ME));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Veröffentlichen: gelingt das Zurücknehmen, steht keine Rollback-Fehlerzeile im Log', async () => {
|
||||||
|
const { post, fake, logs } = await setup();
|
||||||
|
seedUploads(fake);
|
||||||
|
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/prev1'), { status: 500 });
|
||||||
|
assert.equal((await post('/api/veroeffentlichen', publishBody(), JWT_ME)).status, 502);
|
||||||
|
assert.equal(fake.rows('gallery').length, 0);
|
||||||
|
assert.equal(logs.filter((line) => line.includes('rollback failed')).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('createApp verlangt Appwrite-Objekt und Salz', () => {
|
test('createApp verlangt Appwrite-Objekt und Salz', () => {
|
||||||
assert.throws(() => createApp({ salt: SALT }));
|
assert.throws(() => createApp({ salt: SALT }));
|
||||||
assert.throws(() => createApp({ appwrite: {}, salt: '' }));
|
assert.throws(() => createApp({ appwrite: {}, salt: '' }));
|
||||||
|
|||||||
+35
-2
@@ -11,6 +11,8 @@
|
|||||||
* updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben
|
* updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben
|
||||||
* deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar)
|
* deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar)
|
||||||
* getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null
|
* 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)
|
* updateFilePermissions(bucket, id, permissions) Datei oder null (404 storage_file_not_found)
|
||||||
* deleteFile(bucket, id) true, false bei 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
|
* accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||||
|
const DEFAULT_HEAD_BYTES = 64;
|
||||||
const ROW_NOT_FOUND = 'row_not_found';
|
const ROW_NOT_FOUND = 'row_not_found';
|
||||||
const FILE_NOT_FOUND = 'storage_file_not_found';
|
const FILE_NOT_FOUND = 'storage_file_not_found';
|
||||||
|
|
||||||
@@ -72,8 +75,30 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
|
|||||||
const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`;
|
const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`;
|
||||||
const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`;
|
const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`;
|
||||||
|
|
||||||
/** Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er. */
|
/** Liest höchstens `limit` Bytes vom Anfang der Antwort und bricht den Rest ab (Dateien können 5 MiB groß sein). */
|
||||||
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [] } = {}) {
|
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 };
|
const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
|
||||||
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
|
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
|
||||||
const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) };
|
const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) };
|
||||||
@@ -86,6 +111,9 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
|
|||||||
let text;
|
let text;
|
||||||
try {
|
try {
|
||||||
response = await fetchImpl(`${base}${path}`, init);
|
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();
|
text = await response.text();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten).
|
// Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten).
|
||||||
@@ -147,6 +175,11 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
|
|||||||
return result.status === 404 ? null : result.body;
|
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) {
|
async updateFilePermissions(bucket, id, permissions) {
|
||||||
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] });
|
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] });
|
||||||
return result.status === 404 ? null : result.body;
|
return result.status === 404 ? null : result.body;
|
||||||
|
|||||||
@@ -149,6 +149,36 @@ test('Dateien: lesen, Rechte setzen, löschen', async () => {
|
|||||||
assert.equal(fake.file('f1'), null);
|
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 () => {
|
test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => {
|
||||||
const { fake, appwrite } = await setup();
|
const { fake, appwrite } = await setup();
|
||||||
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
|
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
* GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409
|
* GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409
|
||||||
* GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?}
|
* GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?}
|
||||||
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {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).
|
||||||
*
|
*
|
||||||
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Zeilen werden gegen die Spaltenlängen der
|
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Zeilen werden gegen die Spaltenlängen der
|
||||||
* echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im Test auffallen.
|
* echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im Test auffallen.
|
||||||
@@ -18,6 +21,46 @@ const http = require('node:http');
|
|||||||
|
|
||||||
const BUCKETS = ['gallery-images', 'card-images'];
|
const BUCKETS = ['gallery-images', 'card-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 = {
|
const COLUMNS = {
|
||||||
gallery: {
|
gallery: {
|
||||||
title: { size: 60, required: true },
|
title: { size: 60, required: true },
|
||||||
@@ -40,8 +83,9 @@ const COLUMNS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) {
|
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) {
|
||||||
const tables = { gallery: new Map(), reports: new Map() };
|
const tables = { gallery: new Map(), reports: new Map(), cards: new Map() };
|
||||||
const files = new Map();
|
const files = new Map();
|
||||||
|
const contents = new Map();
|
||||||
const users = new Map();
|
const users = new Map();
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const injections = [];
|
const injections = [];
|
||||||
@@ -56,7 +100,13 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
|||||||
users.set(jwt, account);
|
users.set(jwt, account);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000 }) {
|
/**
|
||||||
|
* 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, {
|
files.set(id, {
|
||||||
$id: id,
|
$id: id,
|
||||||
bucketId: bucket,
|
bucketId: bucket,
|
||||||
@@ -65,6 +115,12 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
|||||||
mimeType,
|
mimeType,
|
||||||
sizeOriginal: size,
|
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}")`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRow(table, id, data, permissions) {
|
function addRow(table, id, data, permissions) {
|
||||||
@@ -206,6 +262,17 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Dateien
|
||||||
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) {
|
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');
|
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
|
||||||
@@ -214,6 +281,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
|||||||
if (req.method === 'GET') return reply(200, file);
|
if (req.method === 'GET') return reply(200, file);
|
||||||
if (req.method === 'DELETE') {
|
if (req.method === 'DELETE') {
|
||||||
files.delete(segments[5]);
|
files.delete(segments[5]);
|
||||||
|
contents.delete(segments[5]);
|
||||||
return reply(204);
|
return reply(204);
|
||||||
}
|
}
|
||||||
if (req.method === 'PUT') {
|
if (req.method === 'PUT') {
|
||||||
@@ -240,6 +308,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
|
|||||||
addUser,
|
addUser,
|
||||||
addRawUser,
|
addRawUser,
|
||||||
addFile,
|
addFile,
|
||||||
|
addCard,
|
||||||
addRow,
|
addRow,
|
||||||
failNext,
|
failNext,
|
||||||
hangNext,
|
hangNext,
|
||||||
@@ -303,4 +372,4 @@ function runQueries(rows, search) {
|
|||||||
return result.slice(0, limit);
|
return result.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startFakeAppwrite };
|
module.exports = { startFakeAppwrite, pngHeader, webpHeader };
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* lauscht auf einem Port. Beschreibung der Endpunkte: app.js und README.md.
|
* lauscht auf einem Port. Beschreibung der Endpunkte: app.js und README.md.
|
||||||
*
|
*
|
||||||
* Umgebung:
|
* Umgebung:
|
||||||
* AIRCARD_API_KEY Pflicht Appwrite-Server-Key `aircard-api` (rows.read/write, files.read/write)
|
* 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_API_SALT Pflicht Salz für die Melder-Hashes (32 Byte hex)
|
||||||
* AIRCARD_APPWRITE_PROJECT_ID Pflicht Projekt-ID
|
* AIRCARD_APPWRITE_PROJECT_ID Pflicht Projekt-ID
|
||||||
* AIRCARD_APPWRITE_ENDPOINT http://webklar-appwrite/v1
|
* AIRCARD_APPWRITE_ENDPOINT http://webklar-appwrite/v1
|
||||||
|
|||||||
@@ -85,7 +85,8 @@
|
|||||||
|
|
||||||
# --- API-Dienst der Community-Galerie (deploy/api/, siehe deploy/api/README.md) ---
|
# --- 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.
|
# Ohne AIRCARD_API_KEY oder AIRCARD_API_SALT in der .env beendet sich der Dienst mit Exit-Code 1.
|
||||||
# Kein `ports:`: erreichbar nur über Traefik (der Dienst vertraut dem Header X-Real-Ip).
|
# 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:
|
aircard-api:
|
||||||
image: node:22-alpine
|
image: node:22-alpine
|
||||||
container_name: webklar-aircard-api
|
container_name: webklar-aircard-api
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ server {
|
|||||||
return 404;
|
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 {
|
location = /index.html {
|
||||||
expires -1;
|
expires -1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ Die Indizes der Tabellen `gallery` und `reports` legt das Skript mit denselben S
|
|||||||
Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
|
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`.
|
1. Auf dem Server die Datei öffnen, z. B. `nano /home/webklar/apps/.env`.
|
||||||
2. Ans Ende springen und neue Zeilen anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=` (die Projekt-ID ist die in Schritt 1 notierte, siehe Hinweis oben):
|
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_PROJECT_ID=<Projekt-ID>
|
||||||
@@ -97,16 +97,18 @@ Dieser Key gehört nur dem API-Dienst `aircard-api`, nicht dem Setup-Skript und
|
|||||||
1. **Overview** → **Integrations** → **API keys** → **Create API key**.
|
1. **Overview** → **Integrations** → **API keys** → **Create API key**.
|
||||||
2. Name: `aircard-api`. Ablauf (Expiration): **Never**.
|
2. Name: `aircard-api`. Ablauf (Expiration): **Never**.
|
||||||
3. Unter **Scopes** genau diese vier ankreuzen:
|
3. Unter **Scopes** genau diese vier ankreuzen:
|
||||||
- `rows.read` und `rows.write`
|
- `documents.read` und `documents.write`
|
||||||
- `files.read` und `files.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.
|
||||||
4. **Create** klicken und auf der Seite des Keys den **Secret**-Wert kopieren.
|
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 -s`), hängt nur eine Zeile an und zeigt den Key nirgends an:
|
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 -s -p "AIRCARD_API_KEY: " K; echo; printf 'AIRCARD_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; unset K
|
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. Stand der Provisioning-Key aus Schritt 5 einmal im Chat, in der Console unter **API keys** löschen und bei Bedarf neu anlegen.
|
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
|
## 8. Bescheid geben
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
|
||||||
<script>
|
<script>
|
||||||
// Hell/Dunkel vor dem ersten Paint setzen (kein Aufblitzen); dieselbe Regel wie initialTheme() in src/shell/theme.ts.
|
// Hell/Dunkel vor dem ersten Paint setzen (kein Aufblitzen); dieselbe Regel wie initialTheme() in src/shell/theme.ts.
|
||||||
|
// In einer sofort aufgerufenen Funktion, damit keine globale Variable `theme` entsteht.
|
||||||
|
(function () {
|
||||||
try {
|
try {
|
||||||
var theme = null;
|
var theme = null;
|
||||||
try {
|
try {
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
}
|
}
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -318,8 +318,17 @@
|
|||||||
font-size: 0.95rem;
|
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 {
|
.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 {
|
.auth-msg-error {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
import { runInNewContext } from 'node:vm';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import indexHtml from '../index.html?raw';
|
import indexHtml from '../index.html?raw';
|
||||||
|
import { initialTheme } from './shell/theme';
|
||||||
|
|
||||||
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../public', name));
|
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../public', name));
|
||||||
|
|
||||||
@@ -47,3 +49,54 @@ describe('Icon-Dateien', () => {
|
|||||||
expect(pngInfo(publicFile('apple-touch-icon.png'))).toEqual({ width: 180, height: 180, colorType: 2 });
|
expect(pngInfo(publicFile('apple-touch-icon.png'))).toEqual({ width: 180, height: 180, colorType: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Inline-Skript für Hell/Dunkel in index.html', () => {
|
||||||
|
const script = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)?.[1] ?? '';
|
||||||
|
|
||||||
|
/** Führt das Skript in einer leeren Umgebung aus (kein Browser) und liefert, was es gesetzt hat. */
|
||||||
|
function run(stored: string | null, prefersDark: boolean, storageThrows = false) {
|
||||||
|
const attributes: Record<string, string> = {};
|
||||||
|
const sandbox: Record<string, unknown> = {
|
||||||
|
localStorage: {
|
||||||
|
getItem: () => {
|
||||||
|
if (storageThrows) throw new Error('gesperrt');
|
||||||
|
return stored;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
window: { matchMedia: () => ({ matches: prefersDark }) },
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -513,6 +513,26 @@ describe('publishCard', () => {
|
|||||||
expect(api.isDefinitiveFailure(await api.publishCard(input).catch((e: unknown) => e))).toBe(true);
|
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 () => {
|
it('isAlreadyPublished: nur ApiError mit code 409', async () => {
|
||||||
const api = await freshApi();
|
const api = await freshApi();
|
||||||
expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true);
|
expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet
|
|||||||
const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
|
const ALREADY_PUBLISHED = 'Diese Karte ist schon veröffentlicht.';
|
||||||
/** Wortlaut der API (deploy/api/app.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
|
/** Wortlaut der API (deploy/api/app.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
|
||||||
const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
const FILES_TAKEN = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
|
||||||
|
/** Wortlaut der API (MSG.tooManyPublications): das Limit von 20 Veröffentlichungen je Konto ist erreicht. */
|
||||||
|
const TOO_MANY_PUBLICATIONS = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
|
||||||
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
|
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
|
||||||
const JWT_TTL_MS = 10 * 60 * 1000;
|
const JWT_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
@@ -189,7 +191,8 @@ export function isAlreadyPublished(err: unknown): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Veröffentlicht eine Karte. Doppelt veröffentlicht (409) und „Dateien schon belegt oder gesperrt“ (403) kommen
|
* 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; alle anderen Fehler unverändert.
|
* 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 }> {
|
export async function publishCard(input: PublishInput): Promise<{ id: string }> {
|
||||||
let res: Json;
|
let res: Json;
|
||||||
@@ -199,6 +202,9 @@ export async function publishCard(input: PublishInput): Promise<{ id: string }>
|
|||||||
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
|
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
|
||||||
throw new ApiError(409, ALREADY_PUBLISHED);
|
throw new ApiError(409, ALREADY_PUBLISHED);
|
||||||
}
|
}
|
||||||
|
if (err instanceof ApiError && err.code === 429 && err.message === TOO_MANY_PUBLICATIONS) {
|
||||||
|
throw new ApiError(429, TOO_MANY_PUBLICATIONS, true);
|
||||||
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE);
|
if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE);
|
||||||
|
|||||||
@@ -629,8 +629,17 @@
|
|||||||
color: var(--danger-text);
|
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 {
|
.detail-msg:empty {
|
||||||
display: none;
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-admin {
|
.detail-admin {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { GalleryCard } from './gallery';
|
import type { CheckedImage, GalleryCard } from './gallery';
|
||||||
import { mountGalleryGrid, type GalleryGrid } from './gallery-grid';
|
import { mountGalleryGrid, type GalleryGrid } from './gallery-grid';
|
||||||
|
|
||||||
const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
|
const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
|
||||||
@@ -18,14 +18,15 @@ const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
|
|||||||
...patch,
|
...patch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = () => new Blob(['x'], { type: 'image/webp' });
|
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 () => {
|
const flush = async () => {
|
||||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
let host: HTMLElement;
|
let host: HTMLElement;
|
||||||
let grid: GalleryGrid | null;
|
let grid: GalleryGrid | null;
|
||||||
let counter: number;
|
|
||||||
const revoked: string[] = [];
|
const revoked: string[] = [];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -34,7 +35,6 @@ beforeEach(() => {
|
|||||||
grid = null;
|
grid = null;
|
||||||
counter = 0;
|
counter = 0;
|
||||||
revoked.length = 0;
|
revoked.length = 0;
|
||||||
URL.createObjectURL = vi.fn(() => `blob:t-${++counter}`);
|
|
||||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||||
revoked.push(url);
|
revoked.push(url);
|
||||||
});
|
});
|
||||||
@@ -117,7 +117,7 @@ describe('mountGalleryGrid: Kacheln', () => {
|
|||||||
|
|
||||||
describe('mountGalleryGrid: Vorschaubilder', () => {
|
describe('mountGalleryGrid: Vorschaubilder', () => {
|
||||||
it('lädt die Vorschau jeder Karte und zeigt sie per Object-URL', async () => {
|
it('lädt die Vorschau jeder Karte und zeigt sie per Object-URL', async () => {
|
||||||
const load = vi.fn(async () => blob());
|
const load = vi.fn(async () => image());
|
||||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||||
const cards = [card(1), card(2)];
|
const cards = [card(1), card(2)];
|
||||||
grid.append(cards);
|
grid.append(cards);
|
||||||
@@ -126,10 +126,13 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
|
|||||||
expect(load.mock.calls.map((c) => (c as unknown as [GalleryCard])[0].id)).toEqual(['g1', 'g2']);
|
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'));
|
const srcs = [...host.querySelectorAll<HTMLImageElement>('.gallery-thumb img')].map((i) => i.getAttribute('src'));
|
||||||
expect(srcs).toEqual(['blob:t-1', 'blob:t-2']);
|
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 () => {
|
it('gibt alle Object-URLs beim Aufräumen frei', async () => {
|
||||||
grid = mountGalleryGrid(host, { load: async () => blob(), onTag: () => {} });
|
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
|
||||||
grid.append([card(1), card(2), card(3)]);
|
grid.append([card(1), card(2), card(3)]);
|
||||||
await flush();
|
await flush();
|
||||||
expect(counter).toBe(3);
|
expect(counter).toBe(3);
|
||||||
@@ -138,28 +141,29 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
|
|||||||
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2', 'blob:t-3']);
|
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2', 'blob:t-3']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('legt nach dem Aufräumen keine URL mehr an (späte Antwort) und stößt keine neuen Ladungen an', async () => {
|
it('eine nach dem Aufräumen eintreffende URL wird sofort freigegeben (späte Antwort), neue Ladungen starten nicht mehr', async () => {
|
||||||
const resolvers: Array<(b: Blob) => void> = [];
|
const resolvers: Array<(i: CheckedImage) => void> = [];
|
||||||
const load = vi.fn(() => new Promise<Blob>((r) => resolvers.push(r)));
|
const load = vi.fn(() => new Promise<CheckedImage>((r) => resolvers.push(r)));
|
||||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||||
grid.append([card(1), card(2)]);
|
grid.append([card(1), card(2)]);
|
||||||
expect(load).toHaveBeenCalledTimes(2);
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
grid.dispose();
|
grid.dispose();
|
||||||
for (const resolve of resolvers) resolve(blob());
|
for (const resolve of resolvers) resolve(image());
|
||||||
await flush();
|
await flush();
|
||||||
expect(counter).toBe(0);
|
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2']);
|
||||||
|
expect(host.querySelector('img')).toBeNull();
|
||||||
grid.append([card(3)]);
|
grid.append([card(3)]);
|
||||||
expect(load).toHaveBeenCalledTimes(2);
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
expect(tiles()).toHaveLength(2); // die Kachel nach dem Aufräumen kommt nicht mehr dazu
|
expect(tiles()).toHaveLength(2); // die Kachel nach dem Aufräumen kommt nicht mehr dazu
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lädt höchstens sechs Vorschauen gleichzeitig', async () => {
|
it('lädt höchstens sechs Vorschauen gleichzeitig', async () => {
|
||||||
const pending: Array<(b: Blob) => void> = [];
|
const pending: Array<(i: CheckedImage) => void> = [];
|
||||||
const load = vi.fn(() => new Promise<Blob>((r) => pending.push(r)));
|
const load = vi.fn(() => new Promise<CheckedImage>((r) => pending.push(r)));
|
||||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||||
grid.append(Array.from({ length: 10 }, (_, i) => card(i + 1)));
|
grid.append(Array.from({ length: 10 }, (_, i) => card(i + 1)));
|
||||||
expect(load).toHaveBeenCalledTimes(6);
|
expect(load).toHaveBeenCalledTimes(6);
|
||||||
pending[0](blob());
|
pending[0](image());
|
||||||
await flush();
|
await flush();
|
||||||
expect(load).toHaveBeenCalledTimes(7);
|
expect(load).toHaveBeenCalledTimes(7);
|
||||||
});
|
});
|
||||||
@@ -167,12 +171,36 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
|
|||||||
it('eine fehlgeschlagene Vorschau zeigt „Keine Vorschau“ und hält die anderen nicht auf', async () => {
|
it('eine fehlgeschlagene Vorschau zeigt „Keine Vorschau“ und hält die anderen nicht auf', async () => {
|
||||||
const load = vi.fn(async (c: GalleryCard) => {
|
const load = vi.fn(async (c: GalleryCard) => {
|
||||||
if (c.id === 'g1') throw new Error('404');
|
if (c.id === 'g1') throw new Error('404');
|
||||||
return blob();
|
return image();
|
||||||
});
|
});
|
||||||
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
grid = mountGalleryGrid(host, { load, onTag: () => {} });
|
||||||
grid.append([card(1), card(2)]);
|
grid.append([card(1), card(2)]);
|
||||||
await flush();
|
await flush();
|
||||||
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
|
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
|
||||||
expect(tiles()[1].querySelector('.gallery-thumb img')).not.toBeNull();
|
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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
// Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags.
|
// Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags.
|
||||||
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket) und werden als Object-URL angezeigt;
|
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket, mit Pixelgrenze geprüft: loadGalleryImageChecked)
|
||||||
// `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML.
|
// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML.
|
||||||
|
import { CARD_H, CARD_W, PREVIEW_WIDTH } from '../card/geometry';
|
||||||
import { el } from '../editor/dom';
|
import { el } from '../editor/dom';
|
||||||
import type { GalleryCard } from './gallery';
|
import { reportsLabel } from '../pages/format';
|
||||||
|
import type { CheckedImage, GalleryCard } from './gallery';
|
||||||
|
|
||||||
export interface GalleryGridOptions {
|
export interface GalleryGridOptions {
|
||||||
/** Lädt die Vorschau einer Karte. */
|
/** Lädt und prüft die Vorschau einer Karte; wirft, wenn sie sich nicht anzeigen lässt („Keine Vorschau“). */
|
||||||
load: (card: GalleryCard) => Promise<Blob>;
|
load: (card: GalleryCard) => Promise<CheckedImage>;
|
||||||
/** Ein Tag-Knopf wurde angeklickt. */
|
/** Ein Tag-Knopf wurde angeklickt. */
|
||||||
onTag: (tag: string) => void;
|
onTag: (tag: string) => void;
|
||||||
/** Admin-Ansicht: „Ausgeblendet“ und die Zahl der Meldungen an der Kachel zeigen (für alle anderen bleibt das verborgen). */
|
/** Admin-Ansicht: „Ausgeblendet“ und die Zahl der Meldungen an der Kachel zeigen (für alle anderen bleibt das verborgen). */
|
||||||
@@ -22,13 +24,8 @@ export interface GalleryGrid {
|
|||||||
|
|
||||||
const PARALLEL = 6;
|
const PARALLEL = 6;
|
||||||
const MAX_TAGS = 3;
|
const MAX_TAGS = 3;
|
||||||
const THUMB_W = 480;
|
const THUMB_W = PREVIEW_WIDTH;
|
||||||
const THUMB_H = 303; // 480 x 1536:969
|
const THUMB_H = Math.round((THUMB_W * CARD_H) / CARD_W); // wie exportCard: 480 x 1536:969 = 303
|
||||||
|
|
||||||
/** „1 Meldung“, „3 Meldungen“. */
|
|
||||||
function reportsLabel(count: number): string {
|
|
||||||
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOptions): GalleryGrid {
|
export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOptions): GalleryGrid {
|
||||||
const list = el('ul', { class: 'gallery-grid' });
|
const list = el('ul', { class: 'gallery-grid' });
|
||||||
@@ -39,16 +36,28 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption
|
|||||||
const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = [];
|
const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = [];
|
||||||
let active = 0;
|
let active = 0;
|
||||||
|
|
||||||
|
const showEmpty = (thumb: HTMLElement) =>
|
||||||
|
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau'));
|
||||||
|
|
||||||
async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise<void> {
|
async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const blob = await opts.load(card);
|
const { url } = await opts.load(card);
|
||||||
if (disposed) return; // zu spät: keine URL anlegen, die niemand mehr freigibt
|
if (disposed) {
|
||||||
const url = URL.createObjectURL(blob);
|
URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei
|
||||||
|
return;
|
||||||
|
}
|
||||||
urls.add(url);
|
urls.add(url);
|
||||||
thumb.replaceChildren(el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' }));
|
const img = el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' });
|
||||||
} catch {
|
// 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;
|
if (disposed) return;
|
||||||
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau'));
|
urls.delete(url);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showEmpty(thumb);
|
||||||
|
});
|
||||||
|
thumb.replaceChildren(img);
|
||||||
|
} catch {
|
||||||
|
if (!disposed) showEmpty(thumb);
|
||||||
} finally {
|
} finally {
|
||||||
thumb.removeAttribute('aria-busy');
|
thumb.removeAttribute('aria-busy');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AppwriteException, Query } from 'appwrite';
|
import { AppwriteException, Query } from 'appwrite';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
listRows: vi.fn(),
|
listRows: vi.fn(),
|
||||||
@@ -30,12 +30,15 @@ vi.mock('../account/cards', async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
MAX_GALLERY_PIXELS,
|
||||||
PAGE_SIZE,
|
PAGE_SIZE,
|
||||||
|
checkGalleryImage,
|
||||||
galleryQueries,
|
galleryQueries,
|
||||||
getGalleryCard,
|
getGalleryCard,
|
||||||
listGallery,
|
listGallery,
|
||||||
listReports,
|
listReports,
|
||||||
loadGalleryImage,
|
loadGalleryImage,
|
||||||
|
loadGalleryImageChecked,
|
||||||
myPublications,
|
myPublications,
|
||||||
toGalleryCard,
|
toGalleryCard,
|
||||||
type GalleryCard,
|
type GalleryCard,
|
||||||
@@ -119,14 +122,24 @@ describe('galleryQueries', () => {
|
|||||||
expect(galleryQueries({ q: 'ä ö' })).toContain(Query.search('searchText', 'ä ö'));
|
expect(galleryQueries({ q: 'ä ö' })).toContain(Query.search('searchText', 'ä ö'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Tag: contains auf tags, getrimmt und klein; die Galerie bleibt auf sichtbare Karten beschränkt', () => {
|
it('Tag: exakter Treffer auf ",tag," in tags (der Server speichert ",a,b,"), getrimmt und klein; nur sichtbare Karten', () => {
|
||||||
expect(galleryQueries({ tag: ' Visa ' })).toEqual([
|
expect(galleryQueries({ tag: ' Visa ' })).toEqual([
|
||||||
Query.equal('hidden', false),
|
Query.equal('hidden', false),
|
||||||
Query.orderDesc('$createdAt'),
|
Query.orderDesc('$createdAt'),
|
||||||
Query.limit(24),
|
Query.limit(24),
|
||||||
Query.contains('tags', 'visa'),
|
Query.contains('tags', ',visa,'),
|
||||||
]);
|
]);
|
||||||
expect(JSON.stringify(Query.contains('tags', 'visa'))).toContain('contains');
|
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', () => {
|
it('ein leerer oder nur aus Leerraum bestehender Tag wird ignoriert', () => {
|
||||||
@@ -140,7 +153,7 @@ describe('galleryQueries', () => {
|
|||||||
Query.orderDesc('$createdAt'),
|
Query.orderDesc('$createdAt'),
|
||||||
Query.limit(24),
|
Query.limit(24),
|
||||||
Query.cursorAfter('g24'),
|
Query.cursorAfter('g24'),
|
||||||
Query.contains('tags', 'gold'),
|
Query.contains('tags', ',gold,'),
|
||||||
Query.search('searchText', 'visa'),
|
Query.search('searchText', 'visa'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -183,7 +196,7 @@ describe('galleryQueries', () => {
|
|||||||
Query.equal('hidden', true),
|
Query.equal('hidden', true),
|
||||||
Query.orderDesc('$createdAt'),
|
Query.orderDesc('$createdAt'),
|
||||||
Query.limit(24),
|
Query.limit(24),
|
||||||
Query.contains('tags', 'visa'),
|
Query.contains('tags', ',visa,'),
|
||||||
Query.contains('searchText', 'x'),
|
Query.contains('searchText', 'x'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -252,6 +265,12 @@ describe('toGalleryCard', () => {
|
|||||||
expect(toGalleryCard(row(1, { tags: '' })).tags).toEqual([]);
|
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', () => {
|
it('reportCount: nur endliche, nicht negative Zahlen', () => {
|
||||||
expect(toGalleryCard(row(1, { reportCount: Number.NaN })).reportCount).toBe(0);
|
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(0);
|
||||||
@@ -297,7 +316,7 @@ describe('listGallery', () => {
|
|||||||
Query.equal('hidden', false),
|
Query.equal('hidden', false),
|
||||||
Query.orderDesc('$createdAt'),
|
Query.orderDesc('$createdAt'),
|
||||||
Query.limit(24),
|
Query.limit(24),
|
||||||
Query.contains('tags', 'visa'),
|
Query.contains('tags', ',visa,'),
|
||||||
Query.contains('searchText', 'ab'),
|
Query.contains('searchText', 'ab'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -457,3 +476,128 @@ describe('loadGalleryImage', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,7 @@ export type GalleryFilter = 'all' | 'reported' | 'hidden';
|
|||||||
export interface GalleryOptions {
|
export interface GalleryOptions {
|
||||||
/** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */
|
/** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */
|
||||||
q?: string;
|
q?: string;
|
||||||
/** Nur Karten mit diesem Tag (Teilstring in `tags`, klein). */
|
/** Nur Karten mit genau diesem Tag (klein; der Server speichert `tags` als `,a,b,`, gesucht wird `,<tag>,`). */
|
||||||
tag?: string;
|
tag?: string;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
filter?: GalleryFilter;
|
filter?: GalleryFilter;
|
||||||
@@ -90,7 +90,7 @@ export function galleryQueries(opts: GalleryOptions): string[] {
|
|||||||
const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)];
|
const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)];
|
||||||
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
|
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
|
||||||
const tag = (opts.tag ?? '').trim().toLowerCase();
|
const tag = (opts.tag ?? '').trim().toLowerCase();
|
||||||
if (tag) queries.push(Query.contains('tags', tag));
|
if (tag) queries.push(Query.contains('tags', `,${tag},`)); // mit Trennzeichen: „gold“ trifft nicht „marigold“
|
||||||
const q = (opts.q ?? '').replace(/\s+/g, ' ').trim();
|
const q = (opts.q ?? '').replace(/\s+/g, ' ').trim();
|
||||||
if (q) {
|
if (q) {
|
||||||
queries.push(
|
queries.push(
|
||||||
@@ -171,3 +171,47 @@ export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'previe
|
|||||||
if (!ID_PATTERN.test(fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', '');
|
if (!ID_PATTERN.test(fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', '');
|
||||||
return fetchFile(IDS.galleryBucket, fileId);
|
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 = '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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -151,6 +151,42 @@ describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
|
|||||||
await invalid({ title: 'a\tb' }, 'Der Titel enthält ungültige Zeichen.', 'title');
|
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 in Herausgeber, Tags und Anzeigename', 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('unsichtbares Zeichen im Anzeigenamen', async () => {
|
||||||
|
await invalid({ displayName: 'Ken\u2060so' }, 'Der Anzeigename enthält ungültige Zeichen.', 'displayName');
|
||||||
|
});
|
||||||
|
|
||||||
|
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', displayName: 'Zoë', tags: 'grün, ✨' });
|
||||||
|
send();
|
||||||
|
await expect(outcome).resolves.toBe('published');
|
||||||
|
});
|
||||||
|
|
||||||
it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
|
it('Grenzen sind inklusive: 60/40/30 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
|
||||||
submit.mockResolvedValue({ id: 'g1' });
|
submit.mockResolvedValue({ id: 'g1' });
|
||||||
const outcome = open();
|
const outcome = open();
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ const ISSUER_MAX = 40;
|
|||||||
const DISPLAY_NAME_MAX = 30;
|
const DISPLAY_NAME_MAX = 30;
|
||||||
const TAGS_MAX = 5;
|
const TAGS_MAX = 5;
|
||||||
const TAG_MAX = 20;
|
const TAG_MAX = 20;
|
||||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
/**
|
||||||
|
* 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).
|
||||||
|
*/
|
||||||
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
|
||||||
|
|
||||||
export interface PublishFormValues extends PublishForm {
|
export interface PublishFormValues extends PublishForm {
|
||||||
consent: boolean;
|
consent: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen.
|
// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen.
|
||||||
// Scheitert danach irgendetwas, löscht der Client genau die zwei Dateien dieses Versuchs wieder.
|
// 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 { ID, Permission, Role } from 'appwrite';
|
||||||
import { IDS, storage } from '../account/appwrite';
|
import { IDS, storage } from '../account/appwrite';
|
||||||
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
|
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
|
|||||||
onStatus: onSaveStatus,
|
onStatus: onSaveStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Karte öffnen (/?karte=<id>): nur die zuletzt angeforderte zählt.
|
// Karte öffnen (/gestalten?karte=<id>): nur die zuletzt angeforderte zählt.
|
||||||
let loadToken = 0;
|
let loadToken = 0;
|
||||||
function cancelCardLoad(): void {
|
function cancelCardLoad(): void {
|
||||||
loadToken += 1;
|
loadToken += 1;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { User } from '../account/auth';
|
import type { User } from '../account/auth';
|
||||||
import { ApiError, type ReportReason } from '../community/api';
|
import { ApiError, type ReportReason } from '../community/api';
|
||||||
import type { GalleryCard, GalleryReport } from '../community/gallery';
|
import type { CheckedImage, GalleryCard, GalleryReport } from '../community/gallery';
|
||||||
|
|
||||||
// gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten.
|
// gallery.ts, api.ts und auth.ts hängen am Appwrite-Client; die Seite bekommt alles über ihre Abhängigkeiten.
|
||||||
vi.mock('../account/appwrite', () => ({
|
vi.mock('../account/appwrite', () => ({
|
||||||
@@ -43,6 +43,8 @@ const settle = async () => {
|
|||||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
};
|
};
|
||||||
const blob = () => new Blob(['png'], { type: 'image/png' });
|
const blob = () => new Blob(['png'], { type: 'image/png' });
|
||||||
|
/** Ein geprüftes Bild, wie es loadGalleryImageChecked liefert: Blob plus fertige Object-URL. */
|
||||||
|
const image = (b: Blob = blob()): CheckedImage => ({ blob: b, url: `blob:detail-${++urlCounter}` });
|
||||||
|
|
||||||
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
|
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
|
||||||
const owner: User = { $id: 'u1', email: 'u@example.com', labels: [] };
|
const owner: User = { $id: 'u1', email: 'u@example.com', labels: [] };
|
||||||
@@ -58,7 +60,7 @@ let urlCounter: number;
|
|||||||
const revoked: string[] = [];
|
const revoked: string[] = [];
|
||||||
const getCard = vi.fn<(id: string) => Promise<GalleryCard | null>>();
|
const getCard = vi.fn<(id: string) => Promise<GalleryCard | null>>();
|
||||||
const listReports = vi.fn<(id: string) => Promise<GalleryReport[]>>();
|
const listReports = vi.fn<(id: string) => Promise<GalleryReport[]>>();
|
||||||
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
|
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<CheckedImage>>();
|
||||||
const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>();
|
const reportCard = vi.fn<(id: string, reason: ReportReason) => Promise<{ schonGemeldet: boolean }>>();
|
||||||
const withdrawCard = vi.fn<(id: string) => Promise<void>>();
|
const withdrawCard = vi.fn<(id: string) => Promise<void>>();
|
||||||
const setVisibility = vi.fn<(id: string, visible: boolean) => Promise<void>>();
|
const setVisibility = vi.fn<(id: string, visible: boolean) => Promise<void>>();
|
||||||
@@ -115,7 +117,7 @@ beforeEach(() => {
|
|||||||
}
|
}
|
||||||
getCard.mockResolvedValue(card());
|
getCard.mockResolvedValue(card());
|
||||||
listReports.mockResolvedValue([]);
|
listReports.mockResolvedValue([]);
|
||||||
loadImage.mockImplementation(async () => blob());
|
loadImage.mockImplementation(async () => image());
|
||||||
withdrawCard.mockResolvedValue(undefined);
|
withdrawCard.mockResolvedValue(undefined);
|
||||||
setVisibility.mockResolvedValue(undefined);
|
setVisibility.mockResolvedValue(undefined);
|
||||||
getUser.mockResolvedValue(null);
|
getUser.mockResolvedValue(null);
|
||||||
@@ -123,7 +125,6 @@ beforeEach(() => {
|
|||||||
authListeners = new Set();
|
authListeners = new Set();
|
||||||
urlCounter = 0;
|
urlCounter = 0;
|
||||||
revoked.length = 0;
|
revoked.length = 0;
|
||||||
URL.createObjectURL = vi.fn(() => `blob:detail-${++urlCounter}`);
|
|
||||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||||
revoked.push(url);
|
revoked.push(url);
|
||||||
});
|
});
|
||||||
@@ -276,6 +277,31 @@ describe('Detailseite: Karte', () => {
|
|||||||
expect(saveBlobAs).toHaveBeenCalledTimes(1);
|
expect(saveBlobAs).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ein von der Pixelprüfung abgelehntes Bild (zu groß, nicht dekodierbar) zeigt den Hinweis statt eines Bildes', async () => {
|
||||||
|
// so lehnt loadGalleryImageChecked ab: es wirft, noch bevor ein <img> entsteht
|
||||||
|
loadImage.mockRejectedValue(new Error('Das Bild konnte nicht geladen werden.'));
|
||||||
|
await mount();
|
||||||
|
expect(q('.detail-card')?.textContent).toContain('Das Bild konnte nicht geladen werden.');
|
||||||
|
expect(q('.detail-card img')).toBeNull();
|
||||||
|
expect(q('h1')?.textContent).toBe('Mitternachtsblau'); // die Angaben und Knöpfe (Melden!) bleiben bedienbar
|
||||||
|
expect(buttonByText('Melden')).toBeDefined();
|
||||||
|
// auch Herunterladen bekommt das Bild nicht
|
||||||
|
buttonByText('Herunterladen')!.click();
|
||||||
|
await settle();
|
||||||
|
expect(saveBlobAs).not.toHaveBeenCalled();
|
||||||
|
expect(errorText()).toBe('Das Bild konnte nicht geladen werden.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Bild zum Herunterladen kommt aus der Prüfung; eine dabei angelegte URL wird sofort freigegeben', async () => {
|
||||||
|
await mount();
|
||||||
|
q('.detail-card img')!.dispatchEvent(new Event('error')); // kein Bild im Speicher
|
||||||
|
revoked.length = 0;
|
||||||
|
buttonByText('Herunterladen')!.click();
|
||||||
|
await settle();
|
||||||
|
expect(saveBlobAs).toHaveBeenCalledTimes(1);
|
||||||
|
expect(revoked).toEqual(['blob:detail-2']);
|
||||||
|
});
|
||||||
|
|
||||||
it('„Nochmal versuchen“ beim Bild lädt es erneut', async () => {
|
it('„Nochmal versuchen“ beim Bild lädt es erneut', async () => {
|
||||||
loadImage.mockRejectedValueOnce(new TypeError('offline'));
|
loadImage.mockRejectedValueOnce(new TypeError('offline'));
|
||||||
await mount();
|
await mount();
|
||||||
@@ -311,17 +337,18 @@ describe('Detailseite: Karte', () => {
|
|||||||
expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut
|
expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut
|
||||||
|
|
||||||
// Bild kommt erst nach dem Verlassen
|
// Bild kommt erst nach dem Verlassen
|
||||||
let finishImage!: (b: Blob) => void;
|
let finishImage!: (i: CheckedImage) => void;
|
||||||
getCard.mockResolvedValue(card());
|
getCard.mockResolvedValue(card());
|
||||||
loadImage.mockReturnValue(new Promise((r) => (finishImage = r)));
|
loadImage.mockReturnValue(new Promise((r) => (finishImage = r)));
|
||||||
unmount = mountCommunityPage(root, 'g1', deps());
|
unmount = mountCommunityPage(root, 'g1', deps());
|
||||||
await settle();
|
await settle();
|
||||||
unmount();
|
unmount();
|
||||||
unmount = null;
|
unmount = null;
|
||||||
const created = urlCounter;
|
revoked.length = 0;
|
||||||
finishImage(blob());
|
finishImage({ blob: blob(), url: 'blob:detail-late' });
|
||||||
await settle();
|
await settle();
|
||||||
expect(urlCounter).toBe(created);
|
expect(revoked).toEqual(['blob:detail-late']); // die verspätete URL wird sofort freigegeben
|
||||||
|
expect(q('img')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -419,7 +446,7 @@ describe('Detailseite: Aufräumen', () => {
|
|||||||
describe('Detailseite: Herunterladen und Vorlage', () => {
|
describe('Detailseite: Herunterladen und Vorlage', () => {
|
||||||
it('Herunterladen speichert das volle PNG als aircard-<titel-slug>.png', async () => {
|
it('Herunterladen speichert das volle PNG als aircard-<titel-slug>.png', async () => {
|
||||||
const full = blob();
|
const full = blob();
|
||||||
loadImage.mockResolvedValue(full);
|
loadImage.mockImplementation(async () => image(full));
|
||||||
getCard.mockResolvedValue(card({ title: 'Grüner Wald!' }));
|
getCard.mockResolvedValue(card({ title: 'Grüner Wald!' }));
|
||||||
await mount();
|
await mount();
|
||||||
buttonByText('Herunterladen')!.click();
|
buttonByText('Herunterladen')!.click();
|
||||||
|
|||||||
@@ -10,22 +10,25 @@ import { ApiError, reportCard, setVisibility, withdrawCard, type ReportReason }
|
|||||||
import {
|
import {
|
||||||
getGalleryCard,
|
getGalleryCard,
|
||||||
listReports,
|
listReports,
|
||||||
loadGalleryImage,
|
loadGalleryImageChecked,
|
||||||
|
type CheckedImage,
|
||||||
type GalleryCard,
|
type GalleryCard,
|
||||||
type GalleryReport,
|
type GalleryReport,
|
||||||
} from '../community/gallery';
|
} from '../community/gallery';
|
||||||
import { REPORT_REASONS, reportDialog } from '../community/report-dialog';
|
import { REPORT_REASONS, reportDialog } from '../community/report-dialog';
|
||||||
|
import { CARD_H, CARD_W } from '../card/geometry';
|
||||||
import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog';
|
import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog';
|
||||||
import { el } from '../editor/dom';
|
import { el } from '../editor/dom';
|
||||||
import { downloadFileName, saveBlobAs } from '../editor/download';
|
import { downloadFileName, saveBlobAs } from '../editor/download';
|
||||||
import { formatDate } from './format';
|
import { formatDate, reportsLabel } from './format';
|
||||||
import { navigate, titleFor } from './router';
|
import { navigate, titleFor } from './router';
|
||||||
|
|
||||||
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
|
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
|
||||||
export interface CommunityDeps {
|
export interface CommunityDeps {
|
||||||
getCard(id: string): Promise<GalleryCard | null>;
|
getCard(id: string): Promise<GalleryCard | null>;
|
||||||
listReports(galleryId: string): Promise<GalleryReport[]>;
|
listReports(galleryId: string): Promise<GalleryReport[]>;
|
||||||
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob>;
|
/** Lädt und prüft ein Galerie-Bild (Pixelgrenze); die fertige Object-URL gibt der Aufrufer frei. */
|
||||||
|
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<CheckedImage>;
|
||||||
reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>;
|
reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>;
|
||||||
withdrawCard(id: string): Promise<void>;
|
withdrawCard(id: string): Promise<void>;
|
||||||
setVisibility(id: string, visible: boolean): Promise<void>;
|
setVisibility(id: string, visible: boolean): Promise<void>;
|
||||||
@@ -42,7 +45,7 @@ export interface CommunityDeps {
|
|||||||
export const defaultDeps: CommunityDeps = {
|
export const defaultDeps: CommunityDeps = {
|
||||||
getCard: getGalleryCard,
|
getCard: getGalleryCard,
|
||||||
listReports,
|
listReports,
|
||||||
loadImage: loadGalleryImage,
|
loadImage: loadGalleryImageChecked,
|
||||||
reportCard,
|
reportCard,
|
||||||
withdrawCard,
|
withdrawCard,
|
||||||
setVisibility,
|
setVisibility,
|
||||||
@@ -54,9 +57,6 @@ export const defaultDeps: CommunityDeps = {
|
|||||||
withLogin: (fn) => withLogin(fn),
|
withLogin: (fn) => withLogin(fn),
|
||||||
};
|
};
|
||||||
|
|
||||||
const CARD_W = 1536;
|
|
||||||
const CARD_H = 969;
|
|
||||||
|
|
||||||
const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.';
|
const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.';
|
||||||
const LOADING = 'Karte wird geladen …';
|
const LOADING = 'Karte wird geladen …';
|
||||||
const LOAD_ERROR = 'Die Karte konnte gerade nicht geladen werden.';
|
const LOAD_ERROR = 'Die Karte konnte gerade nicht geladen werden.';
|
||||||
@@ -74,11 +74,6 @@ interface ReportsState {
|
|||||||
list: GalleryReport[];
|
list: GalleryReport[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** „1 Meldung“, „3 Meldungen“. */
|
|
||||||
function reportsLabel(count: number): string {
|
|
||||||
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Der Text für den Nutzer: Servertexte (deutsch) unverändert, alles andere über errorMessage. */
|
/** Der Text für den Nutzer: Servertexte (deutsch) unverändert, alles andere über errorMessage. */
|
||||||
function failureText(err: unknown): string {
|
function failureText(err: unknown): string {
|
||||||
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
|
if (err instanceof LoginCancelled) return LOGIN_NEEDED;
|
||||||
@@ -132,10 +127,13 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
|||||||
imageSlot.setAttribute('aria-busy', 'true');
|
imageSlot.setAttribute('aria-busy', 'true');
|
||||||
imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_LOADING)));
|
imageSlot.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_LOADING)));
|
||||||
deps.loadImage(forCard, 'full').then(
|
deps.loadImage(forCard, 'full').then(
|
||||||
(blob) => {
|
(loaded) => {
|
||||||
if (disposed || token !== imageToken) return; // zu spät: keine URL anlegen, die niemand mehr freigibt
|
if (disposed || token !== imageToken) {
|
||||||
imageBlob = blob;
|
URL.revokeObjectURL(loaded.url); // zu spät: niemand gibt diese URL mehr frei
|
||||||
imageUrl = URL.createObjectURL(blob);
|
return;
|
||||||
|
}
|
||||||
|
imageBlob = loaded.blob;
|
||||||
|
imageUrl = loaded.url;
|
||||||
const img = el('img', {
|
const img = el('img', {
|
||||||
src: imageUrl,
|
src: imageUrl,
|
||||||
alt: `Community-Karte „${forCard.title}“`,
|
alt: `Community-Karte „${forCard.title}“`,
|
||||||
@@ -355,7 +353,12 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
|||||||
setBusy('download');
|
setBusy('download');
|
||||||
try {
|
try {
|
||||||
// Normalerweise liegt das Bild schon vor (es ist die große Karte); sonst wird es jetzt geholt.
|
// Normalerweise liegt das Bild schon vor (es ist die große Karte); sonst wird es jetzt geholt.
|
||||||
const blob = imageBlob ?? (await deps.loadImage(c, 'full'));
|
let blob = imageBlob;
|
||||||
|
if (!blob) {
|
||||||
|
const loaded = await deps.loadImage(c, 'full');
|
||||||
|
URL.revokeObjectURL(loaded.url); // hier zählt nur die Datei, nicht die Anzeige-URL
|
||||||
|
blob = loaded.blob;
|
||||||
|
}
|
||||||
if (disposed || mine !== generation) return;
|
if (disposed || mine !== generation) return;
|
||||||
const created = new Date(c.createdAt);
|
const created = new Date(c.createdAt);
|
||||||
deps.saveBlobAs(blob, downloadFileName(c.title, Number.isNaN(created.getTime()) ? new Date() : created));
|
deps.saveBlobAs(blob, downloadFileName(c.title, Number.isNaN(created.getTime()) ? new Date() : created));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { formatDate } from './format';
|
import { formatDate, reportsLabel } from './format';
|
||||||
|
|
||||||
describe('formatDate', () => {
|
describe('formatDate', () => {
|
||||||
it('schreibt dd.mm.yyyy', () => {
|
it('schreibt dd.mm.yyyy', () => {
|
||||||
@@ -11,3 +11,12 @@ describe('formatDate', () => {
|
|||||||
expect(formatDate('kaputt')).toBe('');
|
expect(formatDate('kaputt')).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reportsLabel', () => {
|
||||||
|
it('„1 Meldung“, sonst „n Meldungen“', () => {
|
||||||
|
expect(reportsLabel(1)).toBe('1 Meldung');
|
||||||
|
expect(reportsLabel(0)).toBe('0 Meldungen');
|
||||||
|
expect(reportsLabel(2)).toBe('2 Meldungen');
|
||||||
|
expect(reportsLabel(12)).toBe('12 Meldungen');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Gemeinsame Formatierung der Seiten (Meine Karten, Community-Detailseite).
|
// Gemeinsame Formatierung der Seiten (Meine Karten, Community-Detailseite, Galerie-Raster).
|
||||||
|
|
||||||
/** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */
|
/** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */
|
||||||
export function formatDate(iso: string): string {
|
export function formatDate(iso: string): string {
|
||||||
@@ -7,3 +7,8 @@ export function formatDate(iso: string): string {
|
|||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
|
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** „1 Meldung“, „3 Meldungen“. */
|
||||||
|
export function reportsLabel(count: number): string {
|
||||||
|
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { User } from '../account/auth';
|
import type { User } from '../account/auth';
|
||||||
import type { GalleryCard, GalleryOptions } from '../community/gallery';
|
import type { CheckedImage, GalleryCard, GalleryOptions } from '../community/gallery';
|
||||||
|
|
||||||
// gallery.ts und auth.ts hängen am Appwrite-Client; hier läuft alles über die Abhängigkeiten von mountHomePage.
|
// gallery.ts und auth.ts hängen am Appwrite-Client; hier läuft alles über die Abhängigkeiten von mountHomePage.
|
||||||
vi.mock('../account/appwrite', () => ({
|
vi.mock('../account/appwrite', () => ({
|
||||||
@@ -37,6 +37,8 @@ const settle = async () => {
|
|||||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||||
};
|
};
|
||||||
const blob = () => new Blob(['x'], { type: 'image/png' });
|
const blob = () => new Blob(['x'], { type: 'image/png' });
|
||||||
|
/** Ein geprüftes Bild, wie es loadGalleryImageChecked liefert: Blob plus fertige Object-URL. */
|
||||||
|
const image = (): CheckedImage => ({ blob: blob(), url: `blob:home-${++urlCounter}` });
|
||||||
|
|
||||||
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
|
const admin: User = { $id: 'a1', email: 'kenso@example.com', labels: ['admin'] };
|
||||||
const member: User = { $id: 'u1', email: 'u@example.com', labels: [] };
|
const member: User = { $id: 'u1', email: 'u@example.com', labels: [] };
|
||||||
@@ -48,7 +50,7 @@ let authListeners: Set<(u: User | null) => void>;
|
|||||||
let urlCounter: number;
|
let urlCounter: number;
|
||||||
const revoked: string[] = [];
|
const revoked: string[] = [];
|
||||||
const listGallery = vi.fn<(opts: GalleryOptions) => Promise<Page>>();
|
const listGallery = vi.fn<(opts: GalleryOptions) => Promise<Page>>();
|
||||||
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<Blob>>();
|
const loadImage = vi.fn<(c: GalleryCard, size: 'full' | 'preview') => Promise<CheckedImage>>();
|
||||||
const navigate = vi.fn<(path: string) => void>();
|
const navigate = vi.fn<(path: string) => void>();
|
||||||
const scrollToHash = vi.fn<(hash: string) => void>();
|
const scrollToHash = vi.fn<(hash: string) => void>();
|
||||||
|
|
||||||
@@ -81,13 +83,12 @@ beforeEach(() => {
|
|||||||
loadImage.mockReset();
|
loadImage.mockReset();
|
||||||
navigate.mockReset();
|
navigate.mockReset();
|
||||||
scrollToHash.mockReset();
|
scrollToHash.mockReset();
|
||||||
loadImage.mockImplementation(async () => blob());
|
loadImage.mockImplementation(async () => image());
|
||||||
listGallery.mockResolvedValue(page([]));
|
listGallery.mockResolvedValue(page([]));
|
||||||
user = null;
|
user = null;
|
||||||
authListeners = new Set();
|
authListeners = new Set();
|
||||||
urlCounter = 0;
|
urlCounter = 0;
|
||||||
revoked.length = 0;
|
revoked.length = 0;
|
||||||
URL.createObjectURL = vi.fn(() => `blob:home-${++urlCounter}`);
|
|
||||||
URL.revokeObjectURL = vi.fn((url: string) => {
|
URL.revokeObjectURL = vi.fn((url: string) => {
|
||||||
revoked.push(url);
|
revoked.push(url);
|
||||||
});
|
});
|
||||||
@@ -143,12 +144,41 @@ describe('Startseite: Hero', () => {
|
|||||||
listGallery.mockResolvedValue(page([card(1)]));
|
listGallery.mockResolvedValue(page([card(1)]));
|
||||||
loadImage.mockImplementation(async (_c, size) => {
|
loadImage.mockImplementation(async (_c, size) => {
|
||||||
if (size === 'full') throw new Error('404');
|
if (size === 'full') throw new Error('404');
|
||||||
return blob();
|
return image();
|
||||||
});
|
});
|
||||||
await mount();
|
await mount();
|
||||||
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
|
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fällt auf das Beispielmotiv zurück, wenn die Pixelprüfung das Kartenbild ablehnt (Dekompressionsbombe)', async () => {
|
||||||
|
listGallery.mockResolvedValue(page([card(1), card(2)]));
|
||||||
|
loadImage.mockImplementation(async (c, size) => {
|
||||||
|
if (size === 'full') throw new Error('Das Bild konnte nicht geladen werden.'); // so lehnt checkGalleryImage ab
|
||||||
|
return image();
|
||||||
|
});
|
||||||
|
await mount();
|
||||||
|
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).toBe('/hero-beispiel.png');
|
||||||
|
expect(q('.hero-card a')).toBeNull();
|
||||||
|
expect(q<HTMLImageElement>('.hero-card img')?.getAttribute('src')).not.toMatch(/^blob:/);
|
||||||
|
// die Kacheln darunter laden weiter, jede Vorschau mit eigener Prüfung
|
||||||
|
expect(qa('.gallery-tile')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine nach dem Verlassen eintreffende Hero-URL wird sofort freigegeben', async () => {
|
||||||
|
listGallery.mockResolvedValue(page([card(1)]));
|
||||||
|
let finish!: (i: CheckedImage) => void;
|
||||||
|
loadImage.mockImplementation((_c, size) =>
|
||||||
|
size === 'full' ? new Promise<CheckedImage>((r) => (finish = r)) : Promise.resolve(image()),
|
||||||
|
);
|
||||||
|
await mount();
|
||||||
|
unmount!();
|
||||||
|
unmount = null;
|
||||||
|
revoked.length = 0;
|
||||||
|
finish({ blob: blob(), url: 'blob:home-late' });
|
||||||
|
await settle();
|
||||||
|
expect(revoked).toEqual(['blob:home-late']);
|
||||||
|
});
|
||||||
|
|
||||||
it('Ladefehler der Galerie: Hero zeigt das Beispielmotiv', async () => {
|
it('Ladefehler der Galerie: Hero zeigt das Beispielmotiv', async () => {
|
||||||
listGallery.mockRejectedValue(new Error('offline'));
|
listGallery.mockRejectedValue(new Error('offline'));
|
||||||
await mount();
|
await mount();
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
import '../community/community.css';
|
import '../community/community.css';
|
||||||
import { currentUser, isAdmin, onAuthChange, type User } from '../account/auth';
|
import { currentUser, isAdmin, onAuthChange, type User } from '../account/auth';
|
||||||
import { mountGalleryGrid, type GalleryGrid } from '../community/gallery-grid';
|
import { mountGalleryGrid, type GalleryGrid } from '../community/gallery-grid';
|
||||||
import { listGallery, loadGalleryImage, type GalleryCard, type GalleryFilter, type GalleryOptions } from '../community/gallery';
|
import {
|
||||||
|
listGallery,
|
||||||
|
loadGalleryImageChecked,
|
||||||
|
type CheckedImage,
|
||||||
|
type GalleryCard,
|
||||||
|
type GalleryFilter,
|
||||||
|
type GalleryOptions,
|
||||||
|
} from '../community/gallery';
|
||||||
|
import { CARD_H, CARD_W } from '../card/geometry';
|
||||||
import { el } from '../editor/dom';
|
import { el } from '../editor/dom';
|
||||||
import { navigate, scrollToHash } from './router';
|
import { navigate, scrollToHash } from './router';
|
||||||
|
|
||||||
@@ -12,7 +20,8 @@ type Page = { cards: GalleryCard[]; nextCursor: string | null };
|
|||||||
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
|
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
|
||||||
export interface HomeDeps {
|
export interface HomeDeps {
|
||||||
listGallery(opts: GalleryOptions): Promise<Page>;
|
listGallery(opts: GalleryOptions): Promise<Page>;
|
||||||
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob>;
|
/** Lädt und prüft ein Galerie-Bild (Pixelgrenze); die fertige Object-URL gibt der Aufrufer frei. */
|
||||||
|
loadImage(card: GalleryCard, size: 'full' | 'preview'): Promise<CheckedImage>;
|
||||||
currentUser(): User | null;
|
currentUser(): User | null;
|
||||||
onAuthChange(fn: (user: User | null) => void): () => void;
|
onAuthChange(fn: (user: User | null) => void): () => void;
|
||||||
navigate(path: string): void;
|
navigate(path: string): void;
|
||||||
@@ -21,7 +30,7 @@ export interface HomeDeps {
|
|||||||
|
|
||||||
export const defaultDeps: HomeDeps = {
|
export const defaultDeps: HomeDeps = {
|
||||||
listGallery,
|
listGallery,
|
||||||
loadImage: loadGalleryImage,
|
loadImage: loadGalleryImageChecked,
|
||||||
currentUser,
|
currentUser,
|
||||||
onAuthChange,
|
onAuthChange,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -30,8 +39,6 @@ export const defaultDeps: HomeDeps = {
|
|||||||
|
|
||||||
const EXAMPLE_IMAGE = '/hero-beispiel.png';
|
const EXAMPLE_IMAGE = '/hero-beispiel.png';
|
||||||
const EXAMPLE_ALT = 'Beispielmotiv: violetter Verlauf mit Wellen und dem AirCard-IOS-Symbol';
|
const EXAMPLE_ALT = 'Beispielmotiv: violetter Verlauf mit Wellen und dem AirCard-IOS-Symbol';
|
||||||
const CARD_W = 1536;
|
|
||||||
const CARD_H = 969;
|
|
||||||
|
|
||||||
const LOADING = 'Karten werden geladen …';
|
const LOADING = 'Karten werden geladen …';
|
||||||
const LOAD_ERROR = 'Die Community-Karten konnten gerade nicht geladen werden.';
|
const LOAD_ERROR = 'Die Community-Karten konnten gerade nicht geladen werden.';
|
||||||
@@ -112,10 +119,13 @@ export function mountHomePage(root: HTMLElement, deps: HomeDeps = defaultDeps):
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (!card) return showExample();
|
if (!card) return showExample();
|
||||||
try {
|
try {
|
||||||
const blob = await deps.loadImage(card, 'full');
|
const { url } = await deps.loadImage(card, 'full');
|
||||||
if (disposed) return;
|
if (disposed) {
|
||||||
heroUrl = URL.createObjectURL(blob);
|
URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei
|
||||||
setHeroImage(heroUrl, `Community-Karte „${card.title}“`, card);
|
return;
|
||||||
|
}
|
||||||
|
heroUrl = url;
|
||||||
|
setHeroImage(url, `Community-Karte „${card.title}“`, card);
|
||||||
} catch {
|
} catch {
|
||||||
if (!disposed) showExample();
|
if (!disposed) showExample();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { legacyRedirect, matchRoute, navigate, ROUTE_TITLES, startRouter, titleFor } from './router';
|
import { legacyRedirect, matchRoute, navigate, onRouteChange, ROUTE_TITLES, startRouter, titleFor } from './router';
|
||||||
|
|
||||||
describe('matchRoute', () => {
|
describe('matchRoute', () => {
|
||||||
it('ordnet / der Startseite zu', () => {
|
it('ordnet / der Startseite zu', () => {
|
||||||
@@ -131,6 +131,36 @@ describe('startRouter', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('onRouteChange: meldet jede neue Route vor dem Rendern der Seite (Start, navigate, popstate), nicht bei nur einem Hash', () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
const stop = onRouteChange((route) => order.push(`route:${route.id}`));
|
||||||
|
startRouter((route) => {
|
||||||
|
order.push(`render:${route.id}`);
|
||||||
|
});
|
||||||
|
navigate('/karten');
|
||||||
|
navigate('/karten#oben'); // nur der Hash ändert sich: keine neue Seite
|
||||||
|
history.pushState(null, '', '/gestalten');
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||||
|
expect(order).toEqual(['route:home', 'render:home', 'route:cards', 'render:cards', 'route:gestalten', 'render:gestalten']);
|
||||||
|
|
||||||
|
stop();
|
||||||
|
navigate('/');
|
||||||
|
expect(order).toHaveLength(6 + 1); // nur noch das Rendern der Startseite, kein route-Eintrag mehr
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onRouteChange: mehrere Hörer, ein Hörer kann sich in der Meldung abmelden', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const stopA = onRouteChange(() => {
|
||||||
|
calls.push('a');
|
||||||
|
stopA();
|
||||||
|
});
|
||||||
|
const stopB = onRouteChange(() => calls.push('b'));
|
||||||
|
startRouter(() => undefined);
|
||||||
|
navigate('/karten');
|
||||||
|
expect(calls).toEqual(['a', 'b', 'b']);
|
||||||
|
stopB();
|
||||||
|
});
|
||||||
|
|
||||||
it('baut beim erneuten Start den alten Router ab', () => {
|
it('baut beim erneuten Start den alten Router ab', () => {
|
||||||
const cleanup = vi.fn();
|
const cleanup = vi.fn();
|
||||||
const first = vi.fn(() => cleanup);
|
const first = vi.fn(() => cleanup);
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ export function titleFor(route: Route, detailTitle?: string): string {
|
|||||||
return ROUTE_TITLES[route.id];
|
return ROUTE_TITLES[route.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const routeListeners = new Set<(route: Route) => void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet jede neue Seite (Start, navigate, popstate; nicht bei einem reinen Hash-Wechsel), nachdem die vorige aufgeräumt
|
||||||
|
* wurde und bevor die neue aufgebaut wird. Für Dinge außerhalb der Seiten, etwa die Kopfzeile. Gibt die Abmeldung zurück.
|
||||||
|
*/
|
||||||
|
export function onRouteChange(listener: (route: Route) => void): () => void {
|
||||||
|
routeListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
routeListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
|
// Beendet den laufenden Router (Seiten-Cleanup, Listener); null, solange keiner läuft.
|
||||||
let stopCurrent: (() => void) | null = null;
|
let stopCurrent: (() => void) | null = null;
|
||||||
let renderCurrent: (() => void) | null = null;
|
let renderCurrent: (() => void) | null = null;
|
||||||
@@ -91,6 +104,7 @@ export function startRouter(render: Render): void {
|
|||||||
cleanup = undefined;
|
cleanup = undefined;
|
||||||
const route = matchRoute(location.pathname);
|
const route = matchRoute(location.pathname);
|
||||||
document.title = titleFor(route);
|
document.title = titleFor(route);
|
||||||
|
for (const listener of [...routeListeners]) listener(route);
|
||||||
cleanup = render(route, root);
|
cleanup = render(route, root);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({ navigate: vi.fn() }));
|
const mocks = vi.hoisted(() => ({
|
||||||
vi.mock('../pages/router', () => ({ navigate: mocks.navigate }));
|
navigate: vi.fn(),
|
||||||
|
routeListeners: new Set<(route: { id: string; params: { id?: string } }) => void>(),
|
||||||
|
}));
|
||||||
|
vi.mock('../pages/router', () => ({
|
||||||
|
navigate: mocks.navigate,
|
||||||
|
onRouteChange: (fn: (route: { id: string; params: { id?: string } }) => void) => {
|
||||||
|
mocks.routeListeners.add(fn);
|
||||||
|
return () => mocks.routeListeners.delete(fn);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { mountHeader } from './header';
|
import { mountHeader } from './header';
|
||||||
|
|
||||||
@@ -21,8 +30,15 @@ const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true,
|
|||||||
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
|
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
|
||||||
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
|
const nav = () => header.querySelector<HTMLElement>('nav.app-nav')!;
|
||||||
|
|
||||||
|
/** Lässt den Router eine Seite melden (wie startRouter/navigate es tun). */
|
||||||
|
function route(id: string, search = ''): void {
|
||||||
|
history.replaceState(null, '', `${id === 'home' ? '/' : `/${id}`}${search}`);
|
||||||
|
for (const fn of [...mocks.routeListeners]) fn({ id, params: {} });
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.navigate.mockReset();
|
mocks.navigate.mockReset();
|
||||||
|
mocks.routeListeners.clear();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
html.setAttribute('data-theme', 'dark');
|
html.setAttribute('data-theme', 'dark');
|
||||||
document.body.innerHTML =
|
document.body.innerHTML =
|
||||||
@@ -36,6 +52,7 @@ beforeEach(() => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
dispose?.();
|
dispose?.();
|
||||||
dispose = null;
|
dispose = null;
|
||||||
|
history.replaceState(null, '', '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mountHeader: Aufbau', () => {
|
describe('mountHeader: Aufbau', () => {
|
||||||
@@ -105,6 +122,94 @@ describe('mountHeader: Suche', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: aktive Seite (aria-current)', () => {
|
||||||
|
const current = () =>
|
||||||
|
[...nav().querySelectorAll<HTMLAnchorElement>('a[aria-current]')].map((a) => [a.textContent, a.getAttribute('aria-current')]);
|
||||||
|
|
||||||
|
it('ohne gemeldete Seite ist nichts als aktuell markiert', () => {
|
||||||
|
mount();
|
||||||
|
expect(current()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Startseite und Karten-Detailseite: „Community“', () => {
|
||||||
|
mount();
|
||||||
|
route('home');
|
||||||
|
expect(current()).toEqual([['Community', 'page']]);
|
||||||
|
route('community');
|
||||||
|
expect(current()).toEqual([['Community', 'page']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Gestalten und Meine Karten', () => {
|
||||||
|
mount();
|
||||||
|
route('gestalten');
|
||||||
|
expect(current()).toEqual([['Gestalten', 'page']]);
|
||||||
|
route('cards');
|
||||||
|
expect(current()).toEqual([['Meine Karten', 'page']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Passwort-Seite: kein Link ist aktuell; die Marke bleibt unberührt', () => {
|
||||||
|
mount();
|
||||||
|
route('home');
|
||||||
|
route('reset');
|
||||||
|
expect(current()).toEqual([]);
|
||||||
|
expect(header.querySelector('.brand')?.hasAttribute('aria-current')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Spenden-Link (externer Link) bekommt nie aria-current', () => {
|
||||||
|
mount(DONATE);
|
||||||
|
for (const id of ['home', 'gestalten', 'cards', 'community', 'reset']) route(id);
|
||||||
|
expect(header.querySelector('.nav-donate')?.hasAttribute('aria-current')).toBe(false);
|
||||||
|
expect(header.querySelector('.header-donate')?.hasAttribute('aria-current')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mountHeader: Suchfeld und Seitenwechsel', () => {
|
||||||
|
it('die Suche bleibt nicht stehen, wenn man auf eine andere Seite geht', () => {
|
||||||
|
mount();
|
||||||
|
input().value = 'testbank';
|
||||||
|
route('gestalten');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
input().value = 'noch was';
|
||||||
|
route('cards');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
input().value = 'und noch was';
|
||||||
|
route('community');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Startseite ohne ?q= leert das Feld ebenfalls', () => {
|
||||||
|
mount();
|
||||||
|
input().value = 'alt';
|
||||||
|
route('home');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Startseite mit ?q= lässt den Suchbegriff stehen (die Seite selbst setzt ihn)', () => {
|
||||||
|
mount();
|
||||||
|
input().value = 'visa';
|
||||||
|
route('home', '?q=visa');
|
||||||
|
expect(input().value).toBe('visa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Startseite mit leerem ?q= oder nur ?tag= gilt als ohne Suche', () => {
|
||||||
|
mount();
|
||||||
|
input().value = 'alt';
|
||||||
|
route('home', '?q=');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
input().value = 'alt';
|
||||||
|
route('home', '?tag=gold');
|
||||||
|
expect(input().value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach dem Aufräumen meldet sich der Header beim Router ab', () => {
|
||||||
|
mount();
|
||||||
|
expect(mocks.routeListeners.size).toBe(1);
|
||||||
|
dispose?.();
|
||||||
|
dispose = null;
|
||||||
|
expect(mocks.routeListeners.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('mountHeader: Spenden-Knopf', () => {
|
describe('mountHeader: Spenden-Knopf', () => {
|
||||||
it('ohne donateUrl gibt es keinen ☕-Knopf', () => {
|
it('ohne donateUrl gibt es keinen ☕-Knopf', () => {
|
||||||
mount('');
|
mount('');
|
||||||
@@ -123,6 +228,11 @@ describe('mountHeader: Spenden-Knopf', () => {
|
|||||||
expect(link.textContent).toContain('Unterstützen');
|
expect(link.textContent).toContain('Unterstützen');
|
||||||
expect(link.hasAttribute('data-link')).toBe(false);
|
expect(link.hasAttribute('data-link')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('der Knopf trägt title „Unterstützen“ (zwischen 600 und 1179 px zeigt er nur das Symbol)', () => {
|
||||||
|
mount(DONATE);
|
||||||
|
expect(header.querySelector('a.header-donate')?.getAttribute('title')).toBe('Unterstützen');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mountHeader: Hell/Dunkel', () => {
|
describe('mountHeader: Hell/Dunkel', () => {
|
||||||
|
|||||||
+25
-5
@@ -3,7 +3,7 @@
|
|||||||
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
|
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
|
||||||
import './shell.css';
|
import './shell.css';
|
||||||
import { el, icon } from '../editor/dom';
|
import { el, icon } from '../editor/dom';
|
||||||
import { navigate } from '../pages/router';
|
import { navigate, onRouteChange, type RouteId } from '../pages/router';
|
||||||
import { toggleTheme } from './theme';
|
import { toggleTheme } from './theme';
|
||||||
|
|
||||||
export interface HeaderOptions {
|
export interface HeaderOptions {
|
||||||
@@ -17,14 +17,21 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
|
|||||||
const hasDonate = opts.donateUrl.trim() !== '';
|
const hasDonate = opts.donateUrl.trim() !== '';
|
||||||
|
|
||||||
const link = (label: string, href: string) => el('a', { href, 'data-link': true }, label);
|
const link = (label: string, href: string) => el('a', { href, 'data-link': true }, label);
|
||||||
|
// Welche Seiten einen Navigationslink als aktuell zeigen („Community“ steht auch für die Detailseite einer Karte).
|
||||||
|
const navLinks: Array<{ anchor: HTMLAnchorElement; routes: readonly RouteId[] }> = [];
|
||||||
|
const navLink = (label: string, href: string, routes: readonly RouteId[]) => {
|
||||||
|
const anchor = link(label, href);
|
||||||
|
navLinks.push({ anchor, routes });
|
||||||
|
return anchor;
|
||||||
|
};
|
||||||
const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' };
|
const donateAttrs = { href: opts.donateUrl, target: '_blank', rel: 'noopener noreferrer' };
|
||||||
|
|
||||||
const nav = el(
|
const nav = el(
|
||||||
'nav',
|
'nav',
|
||||||
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
|
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
|
||||||
link('Community', '/#community'),
|
navLink('Community', '/#community', ['home', 'community']),
|
||||||
link('Gestalten', '/gestalten'),
|
navLink('Gestalten', '/gestalten', ['gestalten']),
|
||||||
link('Meine Karten', '/karten'),
|
navLink('Meine Karten', '/karten', ['cards']),
|
||||||
);
|
);
|
||||||
// Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü.
|
// Unter 600 px ist in der Kopfzeile kein Platz mehr für den Knopf: er steht dann im aufgeklappten Menü.
|
||||||
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen'));
|
if (hasDonate) nav.append(el('a', { class: 'nav-donate', ...donateAttrs }, '☕ Unterstützen'));
|
||||||
@@ -60,7 +67,8 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
|
|||||||
tools.append(
|
tools.append(
|
||||||
el(
|
el(
|
||||||
'a',
|
'a',
|
||||||
{ class: 'header-donate', ...donateAttrs },
|
// title: zwischen 600 und 1179 px zeigt der Knopf nur das Symbol (Stile in shell.css).
|
||||||
|
{ class: 'header-donate', title: 'Unterstützen', ...donateAttrs },
|
||||||
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
|
el('span', { class: 'header-donate-icon', 'aria-hidden': 'true' }, '☕'),
|
||||||
el('span', { class: 'header-donate-text' }, 'Unterstützen'),
|
el('span', { class: 'header-donate-text' }, 'Unterstützen'),
|
||||||
),
|
),
|
||||||
@@ -101,9 +109,21 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
|
|||||||
navigate(`/?q=${encodeURIComponent(q)}#community`);
|
navigate(`/?q=${encodeURIComponent(q)}#community`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Seitenwechsel: aktuellen Navigationslink markieren und eine Suche löschen, die nicht zur neuen Seite gehört
|
||||||
|
// (nur die Startseite mit ?q= zeigt ihren Suchbegriff im Feld; die Startseite setzt ihn selbst).
|
||||||
|
const stopRoute = onRouteChange((route) => {
|
||||||
|
for (const { anchor, routes } of navLinks) {
|
||||||
|
if (routes.includes(route.id)) anchor.setAttribute('aria-current', 'page');
|
||||||
|
else anchor.removeAttribute('aria-current');
|
||||||
|
}
|
||||||
|
const searching = route.id === 'home' && (new URLSearchParams(location.search).get('q') ?? '').trim() !== '';
|
||||||
|
if (!searching) input.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
header.replaceChildren(...[brand, nav, search, tools, accountSlot].filter((node): node is Element => node !== null));
|
header.replaceChildren(...[brand, nav, search, tools, accountSlot].filter((node): node is Element => node !== null));
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
stopRoute();
|
||||||
document.removeEventListener('click', onDocumentClick);
|
document.removeEventListener('click', onDocumentClick);
|
||||||
document.removeEventListener('keydown', onKeydown);
|
document.removeEventListener('keydown', onKeydown);
|
||||||
nav.remove();
|
nav.remove();
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { join, relative } from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
/** Alle Quelldateien (ohne Tests) unter src/ als [Pfad relativ zu src, Text]. */
|
||||||
|
function sources(dir = import.meta.dirname): Array<[string, string]> {
|
||||||
|
const out: Array<[string, string]> = [];
|
||||||
|
for (const name of readdirSync(dir)) {
|
||||||
|
const path = join(dir, name);
|
||||||
|
if (statSync(path).isDirectory()) out.push(...sources(path));
|
||||||
|
else if (/\.ts$/.test(name) && !/\.test\.ts$/.test(name)) out.push([relative(import.meta.dirname, path), readFileSync(path, 'utf8')]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const definedIn = (pattern: RegExp) =>
|
||||||
|
sources()
|
||||||
|
.filter(([, text]) => pattern.test(text))
|
||||||
|
.map(([path]) => path)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
describe('Einzelne Quellen', () => {
|
||||||
|
it('CARD_W und CARD_H werden nur in card/geometry.ts definiert', () => {
|
||||||
|
expect(definedIn(/\b(?:const|let|var)\s+CARD_W\b/)).toEqual(['card/geometry.ts']);
|
||||||
|
expect(definedIn(/\b(?:const|let|var)\s+CARD_H\b/)).toEqual(['card/geometry.ts']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reportsLabel wird nur in pages/format.ts definiert', () => {
|
||||||
|
expect(definedIn(/\bfunction\s+reportsLabel\b/)).toEqual(['pages/format.ts']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user