Compare commits

...
6 Commits
Author SHA1 Message Date
ClaudeandClaude Sonnet 5.5 a3523d3316 chore(web): veraltete Kommentare, Theme-Skript in IIFE, CARD_W/CARD_H und reportsLabel nur aus einer Quelle
- publish.ts und editor-page.ts: Kommentare zum Aufräumen bzw. zur Adresse /gestalten?karte= stimmen wieder.
- index.html: Inline-Skript für Hell/Dunkel in einer sofort aufgerufenen Funktion (keine globale Variable theme);
  Test führt es aus und vergleicht mit initialTheme().
- CARD_W/CARD_H nur noch aus card/geometry.ts (Startseite, Detailseite, Raster), Vorschauhöhe des Rasters daraus abgeleitet;
  reportsLabel nur aus pages/format.ts. Ein Test sorgt dafür, dass es dabei bleibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:40:17 +00:00
ClaudeandClaude Sonnet 5.5 f6b9917b07 docs: Scope-Namen der Console (documents.*), sicherer .env-Befehl, X-Real-Ip-Risiko
- Der Key aircard-api braucht in der Console documents.read/documents.write (entspricht rows.* der API) sowie files.read/files.write.
- Der Befehl zum Eintragen des Keys nutzt read -rs, schreibt bei leerer Eingabe nichts und beginnt mit einem Zeilenumbruch
  (klebt nie an einer letzten .env-Zeile ohne Umbruch); Hinweis zum Zeilenumbruch auch bei der Eingabe mit nano.
- deploy/README.md: nginx-503 bei fehlendem Dienst, up -d nach .env-Änderung, X-Real-Ip im Docker-Netz als akzeptiertes Risiko.
- Kommentare in server.js und compose-snippet.yml angepasst.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:40:17 +00:00
ClaudeandClaude Sonnet 5.5 aa9f9548ad fix(shell): aria-current in der Navigation, Suche wird bei Seitenwechsel geleert, Live-Regionen bleiben gerendert
- Router: onRouteChange meldet jede neue Seite (vor dem Aufbau). Die Kopfzeile markiert damit den aktuellen Navigationslink
  mit aria-current="page" („Community“ auch auf der Detailseite) und leert das Suchfeld, wenn die neue Seite nicht die
  Startseite mit ?q= ist (bisher blieb „testbank“ auf /gestalten stehen).
- Spenden-Knopf in der Kopfzeile trägt title „Unterstützen“ (zwischen 600 und 1179 px nur Symbol).
- .detail-msg:empty und .auth-msg:empty sind nicht mehr display: none (Live-Regionen standen nicht im Zugänglichkeitsbaum),
  sondern ohne Platz im Layout, aber gerendert; Test liest die CSS-Dateien.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:37:46 +00:00
ClaudeandClaude Sonnet 5.5 c62075f2d4 fix(community): Pixelgrenze beim Anzeigen, exakter Tag-Filter, unsichtbare Zeichen
- Gemeinsamer Helfer loadGalleryImageChecked/checkGalleryImage (gallery.ts): lädt das Bild über new Image(), lehnt über
  4 Megapixel oder nicht dekodierbare Dateien ab und liefert eine fertige Object-URL (der Aufrufer gibt sie frei).
  Raster -> „Keine Vorschau“ (auch bei <img>-Fehler), Hero -> Beispielmotiv, Detailseite -> „Das Bild konnte nicht geladen werden.“
  Verspätet eintreffende URLs werden sofort freigegeben.
- Tag-Filter sucht ",<tag>," (der Server speichert ",a,b,"): „gold“ trifft nicht mehr „marigold“.
- Veröffentlichen-Dialog lehnt dieselben unsichtbaren Zeichen ab wie die API (C1, Nullbreite, Bidi, BOM).
- Das Limit der API (429 „schon 20 Karten veröffentlicht“) gilt als eindeutige Ablehnung: die hochgeladenen Dateien werden aufgeräumt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:35:10 +00:00
ClaudeandClaude Sonnet 5.5 5500495443 fix(nginx): /api liefert ohne Dienst 503 mit JSON statt der App
Fällt aircard-api aus, verschwindet sein Traefik-Router und /api/* fiel auf index.html (200 text/html) zurück.
Jetzt 503 mit {"error":"Der Dienst ist gerade nicht erreichbar."} und Content-Type application/json.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:30:28 +00:00
ClaudeandClaude Sonnet 5.5 4c90ee49e8 fix(api): Bildmaße prüfen, Dateien ohne delete(owner), Limits, Melder-Netz
- Beim Veröffentlichen liest die API die ersten 64 Bytes beider Dateien (PNG-IHDR, WebP VP8/VP8L/VP8X) und verlangt Bild 1536x969,
  Vorschau 480x302-304, sonst 400 "Das Bild hat nicht das Kartenformat."
- Veröffentlichte und ausgeblendete Dateien tragen kein delete(owner) mehr (Abweichung von Spec §7): kein Löschen und Neuanlegen
  derselben Datei-ID nach dem Veröffentlichen; scheitert das Festlegen, bekommen die Dateien delete(owner) für den Client-Aufräumer zurück
- Melder-Identität aus dem IPv4-/24 bzw. IPv6-/48; das Rate-Limit zählt weiter je Anschluss
- Höchstens 20 Veröffentlichungen je Konto (429); sourceCardId muss eine eigene Zeile in `cards` sein (403)
- Steuer-, Nullbreiten- und Bidi-Zeichen in Titel, Herausgeber, Anzeigename und Tags abgelehnt
- Tags als ",a,b," gespeichert (exakter Tag-Filter)
- fehlgeschlagenes Zurücknehmen wird mit `rollback failed for card <id>` protokolliert
- README: Endpunkt-Tabelle, Regeln, Scopes (documents.*), akzeptiertes Risiko X-Real-Ip im Docker-Netz

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:30:28 +00:00
37 changed files with 1559 additions and 281 deletions

No files matched your search

+5 -5
View File
@@ -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
```
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
@@ -55,13 +55,13 @@ Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.e
## API-Dienst `aircard-api`
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie 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`.
- **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`.
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
- **Prüfen** nach dem Start: `curl -s https://aircard-ios.org/api/gesundheit` liefert `{"ok":true}`; `docker logs webklar-aircard-api` zeigt `listening on :8088`. Läuft der Dienst nicht, fehlt auch sein Traefik-Router: nginx antwortet dann auf `/api/*` mit `503` und `{"error":"Der Dienst ist gerade nicht erreichbar."}` (Content-Type `application/json`), nie mit der App.
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`.
- **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
+10 -8
View File
@@ -19,7 +19,7 @@ Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur `server.js
| 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_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) |
| `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 |
|---|---|---|
| `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/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` |
@@ -44,14 +44,16 @@ Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Server
### Regeln im Einzelnen
- **IDs**: Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen (nur Zeichenketten), sonst `400`. `appwrite.js` kodiert zusätzlich jedes Pfadsegment und lehnt `.` und `..` ab.
- **Felder**: Titel 1–60, Herausgeber ≤ 40, 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.
- **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.“
- **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.
- **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.
- **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.
- **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).
- **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")`).
- **Melden**: Rate-Limit 10 pro Stunde und Anschluss (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge; Schlüssel: gehashte IPv4, IPv6 auf /64 gekürzt, IPv4-gemappte IPv6 als IPv4). Der Melder-Hash ist `HMAC-SHA256(salt, netz)` mit dem **Netz** der Adresse (IPv4 → `/24`, IPv6 → `/48`, IPv4-gemappte IPv6 wie IPv4): drei Adressen aus einem Netz sind ein Melder und blenden eine Karte nicht aus (sonst genügten drei VPN-/Mobilfunk-Adressen). Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig.
- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst veröffentlicht nie `ports:`, ist aber im Docker-Netz `webklar-network` für andere Container erreichbar, und dort gilt `X-Real-Ip` als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den `clientIp` prüft, oder ein eigenes Netz nur für Traefik und `aircard-api`. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`).
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht, gemeldet, ausgeblendet, zurückgezogen, Admin-Aktion) 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
+181 -56
View File
@@ -5,12 +5,12 @@
* Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
*
* 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 /
* Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet
* 409 schon veröffentlicht · 201 {id}
* 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
* falscher Bucket / Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet / Karte nicht
* meine (`cards`) · 409 schon veröffentlicht · 429 schon 20 Veröffentlichungen · 201 {id}
* POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id}
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
* POST /api/melden ohne Anmeldung {id, reason}
* POST /api/melden ohne Anmeldung {id, reason} (Melder = IPv4-/24 bzw. IPv6-/48, gehasht)
* 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
* POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
@@ -18,6 +18,7 @@
* alles andere 404; Body höchstens 8 KB (413), nur application/json (415)
*
* Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`.
* Der Appwrite-Key braucht die Console-Scopes documents.read/documents.write (= rows.* der API) und files.read/files.write.
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
* Fehlerantworten sind immer {error: <deutscher Text>}.
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle).
@@ -27,10 +28,11 @@
const crypto = require('node:crypto');
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_REPORTS = 'reports';
const TABLE_CARDS = 'cards';
const BUCKET = 'gallery-images';
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 HIDE_AT_REPORTS = 3;
const REPORT_LIST_LIMIT = 100;
const OWNER_ROWS_PAGE = 100;
const OWNER_ROWS_MAX_PAGES = 100;
/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
const MAX_PUBLICATIONS = 20;
const REPORT_DELETE_ROUNDS = 50;
/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */
const IMAGE_HEAD_BYTES = 64;
/** Kartenmaße wie web/src/card/geometry.ts (CARD_W, CARD_H, PREVIEW_WIDTH; Vorschauhöhe 303 plus/minus 1). */
const IMAGE_WIDTH = 1536;
const IMAGE_HEIGHT = 969;
const PREVIEW_WIDTH = 480;
const PREVIEW_HEIGHT_MIN = 302;
const PREVIEW_HEIGHT_MAX = 304;
const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 60 * 60 * 1000;
const RATE_SWEEP_MS = 60 * 1000;
@@ -50,7 +60,9 @@ const LOG_WINDOW_MS = 60 * 1000;
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
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 MSG = {
@@ -65,6 +77,9 @@ const MSG = {
fileSize: 'Das Bild ist größer als 5 MB.',
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
imageFormat: 'Das Bild hat nicht das Kartenformat.',
notYourCard: 'Diese Karte gehört nicht zu deinem Konto.',
tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`,
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
badRequest: 'Ungültige Anfrage.',
badCardId: 'Ungültige Karten-ID.',
@@ -98,14 +113,28 @@ function hiddenRowPermissions(owner) {
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);
return ['read("any")', `delete("user:${owner}")`];
}
/**
* Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die
* Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen
* löscht die Dateien serverseitig mit dem Key.
*/
function visibleFilePermissions() {
return ['read("any")'];
}
/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */
function hiddenFilePermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")', `delete("user:${owner}")`];
return [`read("user:${owner}")`, 'read("label:admin")'];
}
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,
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
* Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
* { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
*/
function normalizeIp(ip) {
if (typeof ip !== 'string') return '';
function parseIp(ip) {
if (typeof ip !== 'string') return { kind: 'none' };
let text = ip.trim();
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
const zone = text.indexOf('%');
@@ -150,15 +179,40 @@ function normalizeIp(ip) {
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
if (withPort) text = withPort[1];
if (net.isIPv4(text)) return text;
if (net.isIPv4(text)) return { kind: 'v4', text };
if (net.isIPv6(text)) {
const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
return `${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) {
@@ -173,9 +227,13 @@ function clientIp(req) {
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) {
return crypto.createHmac('sha256', salt).update(normalizeIp(ip)).digest('hex');
return hmacHex(salt, reporterNetwork(ip));
}
function isPlainObject(value) {
@@ -188,6 +246,37 @@ function safeToken(value, fallback = 'unknown') {
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 = [
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
{ 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
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.bucketId !== BUCKET) throw new HttpError(403, MSG.foreignFile);
}
/**
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") und read("any"). Eine Datei ohne delete(ich) gehört
* jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Zeilen-Dateien
* hat der Aufrufer vorher mit `used` als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet.
*/
function checkFileOwnership(file, userId) {
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) {
throw new HttpError(403, MSG.foreignFile);
}
if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
}
function checkFileContent(file) {
if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType);
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize);
}
/** 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);
/**
* Datei-IDs, die schon eine Galerie-Zeile dieses Autors verwendet (Bild oder Vorschau, auch ausgeblendete Karten).
* Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
* Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId` nicht) und blättert mit
* Cursor. Mehr als OWNER_ROWS_MAX_PAGES Seiten: null (der Aufrufer lehnt dann ab).
* Die Galerie-Zeilen dieses Autors (auch ausgeblendete), höchstens MAX_PUBLICATIONS (mehr braucht der Aufrufer nicht:
* ab dort ist das Limit erreicht). Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId`
* nicht). Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
*/
async function fileIdsInUse(ownerId) {
const used = new Set();
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;
}
return null;
function ownerRows(ownerId) {
return appwrite.listRows(TABLE_GALLERY, [queryEqual('ownerId', [ownerId]), queryLimit(MAX_PUBLICATIONS)]);
}
/** Die private Karte muss es geben und dem Konto gehören (Appwrite gibt dem Besitzer read("user:<id>") auf seine Zeile). */
async function ownsSourceCard(userId, cardId) {
const card = await appwrite.getRow(TABLE_CARDS, cardId);
return card !== null && Array.isArray(card.$permissions) && card.$permissions.includes(`read("user:${userId}")`);
}
/**
@@ -530,15 +637,20 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
appwrite.getFile(BUCKET, data.imageFileId),
appwrite.getFile(BUCKET, data.previewFileId),
]);
checkFile(image, user.id);
checkFile(preview, user.id);
checkFileExists(image);
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 () => {
const used = await fileIdsInUse(user.id);
if (used === null || used.has(data.imageFileId) || used.has(data.previewFileId)) {
throw new HttpError(403, MSG.filesTaken);
}
const rows = await ownerRows(user.id);
if (rows.length >= MAX_PUBLICATIONS) throw new HttpError(429, MSG.tooManyPublications);
if (!(await ownsSourceCard(user.id, data.sourceCardId))) throw new HttpError(403, MSG.notYourCard);
const used = new Set(rows.flatMap(fileIdsOf));
if (used.has(data.imageFileId) || used.has(data.previewFileId)) throw new HttpError(403, MSG.filesTaken);
for (const file of [image, preview]) checkFileOwnership(file, user.id);
for (const file of [image, preview]) checkFileContent(file);
await checkImageSizes(data);
const id = galleryRowId(user.id, data.sourceCardId);
try {
@@ -548,7 +660,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
{
title: data.title,
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,
imageFileId: data.imageFileId,
previewFileId: data.previewFileId,
@@ -565,14 +678,21 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
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.
// Scheitert das Festlegen, wird die Zeile zurückgenommen.
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);
} 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;
}
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);
const id = body.id;
const reporter = reporterHash(salt, clientIp(req));
const limit = rateLimit(reporter);
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt weiter je Anschluss (IPv4 voll, IPv6 /64).
const ip = clientIp(req);
const reporter = reporterHash(salt, ip);
const limit = rateLimit(hmacHex(salt, normalizeIp(ip)));
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
return withLock(id, async () => {
@@ -724,11 +846,14 @@ module.exports = {
createApp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
reporterHash,
reporterNetwork,
clientIp,
normalizeIp,
readImageSize,
validatePublish,
buildSearchText,
galleryRowId,
+415 -94
View File
@@ -9,16 +9,19 @@ const crypto = require('node:crypto');
const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
const {
createApp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
reporterHash,
reporterNetwork,
clientIp,
normalizeIp,
readImageSize,
validatePublish,
buildSearchText,
galleryRowId,
@@ -33,6 +36,10 @@ const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
const JWT_ADMIN = 'eyJhbGciOiJI.payload-admin.signature-admin';
const 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 ---------------------------------------------------------------------------------
@@ -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: 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). */
@@ -121,10 +130,11 @@ function seedCard(fake, { id = 'card-1', owner = ME, hidden = false, reportCount
const previewFileId = `${id}-prev`;
fake.addFile({ id: imageFileId, owner });
fake.addFile({ id: previewFileId, owner, mimeType: 'image/webp' });
if (hidden) {
fake.file(imageFileId).$permissions = hiddenFilePermissions(owner);
fake.file(previewFileId).$permissions = hiddenFilePermissions(owner);
}
// Veröffentlichte Dateien tragen nie delete(owner): sichtbar nur read("any"), ausgeblendet nur Autor und Admin lesen.
const filePermissions = hidden ? hiddenFilePermissions(owner) : visibleFilePermissions();
fake.file(imageFileId).$permissions = filePermissions;
fake.file(previewFileId).$permissions = filePermissions;
fake.addCard(`src-${id}`, owner);
fake.addRow(
'gallery',
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', () => {
assert.deepEqual(visibleRowPermissions(), ['read("any")']);
assert.deepEqual(hiddenRowPermissions('abc'), ['read("user:abc")', 'read("label:admin")']);
assert.deepEqual(visibleFilePermissions('abc'), ['read("any")', 'delete("user:abc")']);
assert.deepEqual(hiddenFilePermissions('abc'), ['read("user:abc")', 'read("label:admin")', 'delete("user:abc")']);
// Abweichung von Spec §7 (Ruling): veröffentlichte Dateien tragen kein delete(owner), sonst ließe sich der Inhalt
// 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)', () => {
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) {
assert.throws(() => hiddenRowPermissions(bad));
assert.throws(() => visibleFilePermissions(bad));
assert.throws(() => uploadFilePermissions(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'));
});
test('reporterHash ist HMAC-SHA256 (Hex) mit dem Salz; IPv6 wird auf /64 gekürzt', () => {
assert.equal(reporterHash(SALT, '203.0.113.7'), crypto.createHmac('sha256', SALT).update('203.0.113.7').digest('hex'));
assert.notEqual(reporterHash('anderes-salz', '203.0.113.7'), reporterHash(SALT, '203.0.113.7'));
assert.equal(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:12:ffff:eeee:dddd:2'));
assert.notEqual(reporterHash(SALT, '2001:db8:abcd:12::1'), reporterHash(SALT, '2001:db8:abcd:13::1'));
test('reporterNetwork: IPv4 -> /24, IPv6 -> /48, IPv4-gemappte IPv6 wie IPv4', () => {
assert.equal(reporterNetwork('203.0.7.9'), '203.0.7.0/24');
assert.equal(reporterNetwork(' 203.0.7.200:5555 '), '203.0.7.0/24');
assert.equal(reporterNetwork('::ffff:203.0.7.9'), '203.0.7.0/24');
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', () => {
assert.equal(normalizeIp('203.0.113.7'), '203.0.113.7');
assert.equal(normalizeIp(' 203.0.113.7:5555 '), '203.0.113.7');
assert.equal(normalizeIp('::ffff:203.0.113.7'), '203.0.113.7');
assert.equal(normalizeIp('203.0.7.9'), '203.0.7.9');
assert.equal(normalizeIp(' 203.0.7.9:5555 '), '203.0.7.9');
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('2001:0DB8:abcd:0012:0000:0000:0000:0001'), '2001:db8:abcd:12::/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', {
raw: '{"id":"card-1","reason":"spam"}',
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);
});
@@ -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);
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(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.equal(row.title, 'Mein Motiv');
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.imageFileId, 'img1');
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 () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
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);
assert.equal(res.status, 201);
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 () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
const attempt = (image, preview) => {
fake.addFile({ id: 'img1', owner: ME, ...image });
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(fake.rows('gallery').length, 1);
// eine andere Person darf dieselbe sourceCardId-Zeichenfolge haben (Zeilen-ID enthält den Besitzer)
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9' });
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9' }), JWT_OTHER);
// eine andere Person veröffentlicht ihre eigene Karte (Zeilen-ID enthält den Besitzer)
seedUploads(fake, OTHER, { image: 'img9', preview: 'prev9', card: 'card9' });
const other = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'img9', previewFileId: 'prev9', sourceCardId: 'card9' }), JWT_OTHER);
assert.equal(other.status, 201);
assert.notEqual(other.json.id, galleryRowId(ME, 'card1'));
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/],
['Tag keine Zeichenkette', { tags: ['a', 5] }, /Tags/],
['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 ..', { imageFileId: '..' }, /ID/],
['Bild-ID Zahl', { imageFileId: 123 }, /ID/],
@@ -514,7 +554,7 @@ test('Veröffentlichen: Tags werden serverseitig erneut normalisiert', async ()
seedUploads(fake);
const res = await post('/api/veroeffentlichen', publishBody({ tags: [' BLAU ', 'Neon', '', ' ', 'blau'] }), JWT_ME);
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 () => {
@@ -537,16 +577,16 @@ test('Melden: drei verschiedene IPs blenden die Karte aus (Zeilen- und Dateirech
const { report, fake } = await setup();
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.equal(fake.row('gallery', 'card-1').reportCount, 1);
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.equal(fake.row('gallery', 'card-1').reportCount, 2);
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.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.reportCount, 3);
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(previewFileId).$permissions, hiddenFile);
assert.equal(fake.rows('reports').length, 3);
// 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.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
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 () => {
const { report, fake } = await setup();
seedCard(fake);
await report('card-1', '203.0.113.1', 'spam');
await report('card-1', '203.0.113.2', 'sonstiges');
await report('card-1', '203.0.1.9', 'spam');
await report('card-1', '203.0.2.9', 'sonstiges');
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));
assert.ok(stored, 'Zeilen-ID = sha256(galleryId|reporterHash)');
assert.equal(stored.galleryId, 'card-1');
assert.equal(stored.reason, 'spam');
assert.equal(stored.reporterHash, hash);
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 () => {
const { report, fake } = await setup();
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 });
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.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);
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}`);
}
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.json.error, 'Zu viele Meldungen – bitte später nochmal.');
assert.equal(res.headers['retry-after'], '3600');
// 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
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;
res = await report('card-1', '203.0.113.50');
res = await report('card-1', '203.0.50.9');
assert.equal(res.status, 200);
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 () => {
const { report, fake, clock, app } = await setup();
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);
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');
});
@@ -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();
seedCard(fake);
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(typeof res.json.error, 'string');
}
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(fake.calls.length, 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 () => {
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.json.error, 'Diese Karte ist nicht (mehr) verfügbar.');
assert.equal(fake.rows('reports').length, 0);
@@ -665,7 +705,7 @@ test('Melden: gültige Gründe sind unangemessen, kartendaten, urheberrecht, spa
seedCard(fake);
const reasons = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
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]);
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']);
});
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();
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: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);
});
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 () => {
const { post, fake } = await setup();
seedCard(fake);
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.113.77')).json, { schonGemeldet: true });
assert.deepEqual((await viaForwarded('203.0.113.78')).json, { ok: true });
assert.deepEqual((await viaForwarded('203.0.77.9')).json, { ok: true });
assert.deepEqual((await viaForwarded('203.0.77.9')).json, { schonGemeldet: 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
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');
@@ -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 () => {
const { report, fake } = await setup();
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]);
const row = fake.row('gallery', 'card-1');
assert.equal(row.reportCount, 3);
@@ -710,9 +774,9 @@ test('Melden: eine verspätete Zählerstand-Änderung überschreibt neuere Stän
seedCard(fake);
// 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);
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));
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;
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();
seedCard(fake);
// 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);
fake.addRow('reports', reportRowId('card-1', hash), { galleryId: 'card-1', reason: 'spam', reporterHash: hash }, []);
}
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.equal(fake.row('gallery', 'card-1').hidden, true);
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();
seedCard(fake);
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(fake.row('gallery', 'card-1').reportCount, 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 () => {
const { report, fake } = await setup();
seedCard(fake, { reportCount: 0 });
await report('card-1', '203.0.113.1');
await report('card-1', '203.0.113.2');
await report('card-1', '203.0.1.9');
await report('card-1', '203.0.2.9');
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(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(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.reportCount, 1);
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-prev').$permissions, hiddenFile);
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.reportCount, 0);
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-prev').$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")']);
assert.deepEqual(fake.rows('reports').map((r) => r.galleryId), ['card-2']);
// 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 () => {
@@ -901,9 +965,9 @@ test('Logs enthalten nie Key, Salz, JWT oder IPs im Klartext, aber das Präfix [
seedCard(fake, { id: 'card-9' });
await post('/api/veroeffentlichen', publishBody(), JWT_ME);
await post('/api/veroeffentlichen', publishBody(), 'xxx.yyy.zzz');
await report('card-9', '203.0.113.7');
await report('card-9', '203.0.113.8');
await report('card-9', '203.0.113.9');
await report('card-9', '203.0.7.9');
await report('card-9', '203.0.8.9');
await report('card-9', '203.0.9.9');
await post('/api/admin/sichtbarkeit', { id: 'card-9', sichtbar: true }, JWT_ADMIN);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/rows/'), { status: 500 });
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);
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)}…`);
}
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 big = `{"id":"x","reason":"spam"}${' '.repeat(9000)}`;
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: big }); // 413
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 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.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) {
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.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.');
});
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();
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: '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);
assert.equal(res.status, 201);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('prev1').$permissions, ['read("any")', 'delete("user:user-me")']);
assert.deepEqual(fake.file('img1').$permissions, ['read("any")']);
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 () => {
@@ -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');
});
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 () => {
const { post, fake } = await setup();
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);
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 () => {
const { post, fake } = await setup();
const { imageFileId, previewFileId } = seedCard(fake, { id: 'vis' });
fake.addCard('andere-karte', ME);
fake.addFile({ id: 'fresh-img', owner: ME });
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 () => {
const { post, fake } = await setup();
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-prev', owner: ME, mimeType: 'image/webp' });
// 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 () => {
const { post, fake } = await setup();
fake.addCard('card1', ME);
fake.addFile({ id: 'img1', owner: ME, permissions: ['delete("user:user-me")'] });
fake.addFile({ id: 'prev1', owner: ME, mimeType: 'image/webp' });
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);
});
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();
for (let i = 0; i < 250; i += 1) {
const owner = i % 50 === 0 ? OTHER : ME;
for (let i = 0; i < 40; i += 1) {
const owner = i % 2 === 0 ? OTHER : ME; // 20 Karten je Person
fake.addRow(
'gallery',
`row${i}`,
@@ -1112,26 +1194,31 @@ test('Prüfung der Dateinutzung blättert durch alle Karten des Autors (250 Kart
['read("any")'],
);
}
fake.addFile({ id: 'f230a', owner: ME });
fake.addFile({ id: 'f230b', owner: ME, mimeType: 'image/webp' });
const taken = await post('/api/veroeffentlichen', publishBody({ imageFileId: 'f230a', previewFileId: 'f230b', sourceCardId: 'neu1' }), JWT_ME);
assert.equal(taken.status, 403, 'Karte 230 liegt auf der dritten Seite');
// Zeile 39 gehört OTHER: ME hat 19 Zeilen (eine weniger als das Limit), OTHER 21
fake.rows('gallery').find((row) => row.$id === 'row39').ownerId = OTHER;
fake.addFile({ id: 'f37a', owner: ME, permissions: ['read("any")', 'delete("user:user-me")'] });
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);
const lists = fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith('/tables/gallery/rows'));
assert.equal(lists.length, 3, 'drei Seiten à 100');
for (const call of lists) assert.ok(decodeURIComponent(call.search).includes('"attribute":"ownerId","values":["user-me"]'));
assert.equal(lists.length, 1);
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);
fake.addCard('neu2', ME);
const ok = await post('/api/veroeffentlichen', publishBody({ sourceCardId: 'neu2' }), JWT_ME);
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 () => {
const { post, fake } = await setup();
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.
fake.delayNext((c) => c.method === 'POST' && c.path.endsWith('/tables/gallery/rows'), 80);
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);
assert.equal(res.status, 502);
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
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);
assert.equal(res.status, 502);
// 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-prev').$permissions, ['read("any")', '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")']);
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');
});
// ---- 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', () => {
assert.throws(() => createApp({ salt: SALT }));
assert.throws(() => createApp({ appwrite: {}, salt: '' }));
+35 -2
View File
@@ -11,6 +11,8 @@
* updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben
* deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar)
* getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null
* readFileHead(bucket, id, maxBytes) die ersten Bytes des Dateiinhalts (Buffer) oder null; liest nie mehr als
* nötig und bricht den Rest der Übertragung ab
* updateFilePermissions(bucket, id, permissions) Datei oder null (404 storage_file_not_found)
* deleteFile(bucket, id) true, false bei 404 storage_file_not_found
* accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
@@ -28,6 +30,7 @@
*/
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_HEAD_BYTES = 64;
const ROW_NOT_FOUND = 'row_not_found';
const FILE_NOT_FOUND = 'storage_file_not_found';
@@ -72,8 +75,30 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`;
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. */
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [] } = {}) {
/** Liest höchstens `limit` Bytes vom Anfang der Antwort und bricht den Rest ab (Dateien können 5 MiB groß sein). */
async function readHead(response, limit) {
if (!response.body) return Buffer.from(await response.arrayBuffer()).subarray(0, limit);
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (size < limit) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(Buffer.from(value));
size += value.length;
}
} finally {
await reader.cancel().catch(() => {});
}
return Buffer.concat(chunks).subarray(0, limit);
}
/**
* Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er.
* Mit `headBytes` ist `body` bei 2xx ein Buffer mit den ersten Bytes der Antwort statt geparstem JSON.
*/
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [], headBytes = 0 } = {}) {
const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) };
@@ -86,6 +111,9 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
let text;
try {
response = await fetchImpl(`${base}${path}`, init);
if (headBytes > 0 && response.status >= 200 && response.status < 300) {
return { status: response.status, body: await readHead(response, headBytes) };
}
text = await response.text();
} catch (err) {
// Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten).
@@ -147,6 +175,11 @@ function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch,
return result.status === 404 ? null : result.body;
},
async readFileHead(bucket, id, maxBytes = DEFAULT_HEAD_BYTES) {
const result = await send('GET', `${filePath(bucket, id)}/view`, { missingTypes: [FILE_NOT_FOUND], headBytes: maxBytes });
return result.status === 404 ? null : result.body;
},
async updateFilePermissions(bucket, id, permissions) {
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body;
+30
View File
@@ -149,6 +149,36 @@ test('Dateien: lesen, Rechte setzen, löschen', async () => {
assert.equal(fake.file('f1'), null);
});
test('readFileHead: liefert nur die ersten Bytes, mit Server-Key, auch bei einer großen Datei', async () => {
const { fake, appwrite } = await setup();
const content = Buffer.concat([Buffer.from('HEADBYTES'), Buffer.alloc(2 * 1024 * 1024, 7)]);
fake.addFile({ id: 'big', owner: 'u1', content, size: content.length });
const head = await appwrite.readFileHead('gallery-images', 'big', 9);
assert.ok(Buffer.isBuffer(head));
assert.equal(head.toString('latin1'), 'HEADBYTES');
assert.equal(lastCall(fake).method, 'GET');
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/big/view');
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
const sixtyFour = await appwrite.readFileHead('gallery-images', 'big');
assert.equal(sixtyFour.length, 64, 'Standard: 64 Bytes');
fake.addFile({ id: 'small', owner: 'u1', content: Buffer.from('abc'), size: 3 });
assert.equal((await appwrite.readFileHead('gallery-images', 'small', 64)).toString(), 'abc', 'kürzere Datei: alles');
});
test('readFileHead: fehlende Datei ist null, andere Fehler werfen AppwriteError', async () => {
const { fake, appwrite } = await setup();
assert.equal(await appwrite.readFileHead('gallery-images', 'nope'), null);
fake.addFile({ id: 'f1', owner: 'u1' });
fake.failNext((c) => c.path.endsWith('/files/f1/view'), { status: 500 });
await assert.rejects(() => appwrite.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 500);
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
await assert.rejects(() => wrongKey.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 401);
await assert.rejects(() => appwrite.readFileHead('gallery-images', '..'), TypeError);
});
test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => {
const { fake, appwrite } = await setup();
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
+72 -3
View File
@@ -7,6 +7,9 @@
* GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409
* GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?}
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions}
* GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`)
*
* Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an).
*
* 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.
@@ -18,6 +21,46 @@ const http = require('node:http');
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 = {
gallery: {
title: { size: 60, required: true },
@@ -40,8 +83,9 @@ const COLUMNS = {
};
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 contents = new Map();
const users = new Map();
const calls = [];
const injections = [];
@@ -56,7 +100,13 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
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, {
$id: id,
bucketId: bucket,
@@ -65,6 +115,12 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
mimeType,
sizeOriginal: size,
});
contents.set(id, content || (mimeType === 'image/webp' ? webpHeader('VP8 ', width, height) : pngHeader(width, height)));
}
/** Private Karte (Tabelle `cards`) mit den Rechten, die Appwrite dem Besitzer beim Anlegen gibt. */
function addCard(id, owner, permissions) {
return addRow('cards', id, { name: 'Karte' }, permissions || [`read("user:${owner}")`, `update("user:${owner}")`, `delete("user:${owner}")`]);
}
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
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');
@@ -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 === 'DELETE') {
files.delete(segments[5]);
contents.delete(segments[5]);
return reply(204);
}
if (req.method === 'PUT') {
@@ -240,6 +308,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
addUser,
addRawUser,
addFile,
addCard,
addRow,
failNext,
hangNext,
@@ -303,4 +372,4 @@ function runQueries(rows, search) {
return result.slice(0, limit);
}
module.exports = { startFakeAppwrite };
module.exports = { startFakeAppwrite, pngHeader, webpHeader };
+1 -1
View File
@@ -5,7 +5,7 @@
* lauscht auf einem Port. Beschreibung der Endpunkte: app.js und README.md.
*
* 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_APPWRITE_PROJECT_ID Pflicht Projekt-ID
* AIRCARD_APPWRITE_ENDPOINT http://webklar-appwrite/v1
+2 -1
View File
@@ -85,7 +85,8 @@
# --- API-Dienst der Community-Galerie (deploy/api/, siehe deploy/api/README.md) ---
# Ohne AIRCARD_API_KEY oder AIRCARD_API_SALT in der .env beendet sich der Dienst mit Exit-Code 1.
# Kein `ports:`: 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:
image: node:22-alpine
container_name: webklar-aircard-api
+9
View File
@@ -49,6 +49,15 @@ server {
return 404;
}
# Die Community-API (aircard-api) liegt ebenfalls nur über Traefik auf /api. Fehlt der Dienst (Container weg, damit
# auch sein Traefik-Router), darf /api/* nie die App ausliefern: der Client bekäme HTML mit 200 statt eines Fehlers.
# Stattdessen 503 mit JSON-Text, den die Seite wie eine API-Antwort anzeigt. Gleiche Reihenfolge-Regel wie bei /v1.
# (Kein add_header: sonst entfallen die Sicherheits-Header des server-Blocks; default_type setzt den Content-Type.)
location ~ ^/api(/|$) {
default_type application/json;
return 503 '{"error":"Der Dienst ist gerade nicht erreichbar."}';
}
location = /index.html {
expires -1;
}
+7 -5
View File
@@ -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.
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>
@@ -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**.
2. Name: `aircard-api`. Ablauf (Expiration): **Never**.
3. Unter **Scopes** genau diese vier ankreuzen:
- `rows.read` und `rows.write`
- `documents.read` und `documents.write`
- `files.read` und `files.write`
Die Console beschriftet die Zeilen-Scopes mit `documents.*`; das sind dieselben wie die `rows.read`/`rows.write` der Appwrite-API (Tabellen heißen dort Zeilen). Einen Eintrag `rows.*` gibt es in der Console nicht.
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
+11 -8
View File
@@ -11,16 +11,19 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
<script>
// Hell/Dunkel vor dem ersten Paint setzen (kein Aufblitzen); dieselbe Regel wie initialTheme() in src/shell/theme.ts.
try {
var theme = null;
// In einer sofort aufgerufenen Funktion, damit keine globale Variable `theme` entsteht.
(function () {
try {
theme = localStorage.getItem('aircard.theme');
var theme = null;
try {
theme = localStorage.getItem('aircard.theme');
} catch (e) {}
if (theme !== 'light' && theme !== 'dark') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
if (theme !== 'light' && theme !== 'dark') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
})();
</script>
</head>
<body>
+10 -1
View File
@@ -318,8 +318,17 @@
font-size: 0.95rem;
}
/* Leer bleibt die Zeile gerendert (Live-Region, role alert/status), nimmt aber keinen Platz ein: display: none nähme sie aus
dem Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
.auth-msg:empty {
display: none;
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.auth-msg-error {
+53
View File
@@ -1,7 +1,9 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { runInNewContext } from 'node:vm';
import { describe, expect, it } from 'vitest';
import indexHtml from '../index.html?raw';
import { initialTheme } from './shell/theme';
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 });
});
});
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');
});
});
+20
View File
@@ -513,6 +513,26 @@ describe('publishCard', () => {
expect(api.isDefinitiveFailure(await api.publishCard(input).catch((e: unknown) => e))).toBe(true);
});
it('429 „schon 20 Karten veröffentlicht“ (Limit des Dienstes) behält den Text und ist eindeutig: es wurde nichts angelegt', async () => {
const api = await freshApi();
const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
fetchMock.mockResolvedValue(json(429, { error: text }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 429, message: text });
expect(api.isAlreadyPublished(err)).toBe(false);
// damit räumt der Client die zwei hochgeladenen Dateien auf (ein anderes 429, z. B. vom Proxy, bleibt offen)
expect(api.isDefinitiveFailure(err)).toBe(true);
});
it('ein anderes 429 beim Veröffentlichen bleibt ein Fehler mit offenem Ausgang', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Anfragen.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 429, message: 'Zu viele Anfragen.' });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
it('isAlreadyPublished: nur ApiError mit code 409', async () => {
const api = await freshApi();
expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true);
+7 -1
View File
@@ -23,6 +23,8 @@ const JWT_UNSUPPORTED = 'Anmeldung per Token ist auf dem Server ausgeschaltet
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. */
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. */
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
* 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 }> {
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) {
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;
}
if (typeof res.id !== 'string' || res.id === '') throw new ApiError(503, UNAVAILABLE);
+10 -1
View File
@@ -629,8 +629,17 @@
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 {
display: none;
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.detail-admin {
+43 -15
View File
@@ -1,5 +1,5 @@
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';
const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
@@ -18,14 +18,15 @@ const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
...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 () => {
for (let i = 0; i < 5; i++) await Promise.resolve();
};
let host: HTMLElement;
let grid: GalleryGrid | null;
let counter: number;
const revoked: string[] = [];
beforeEach(() => {
@@ -34,7 +35,6 @@ beforeEach(() => {
grid = null;
counter = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => `blob:t-${++counter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
@@ -117,7 +117,7 @@ describe('mountGalleryGrid: Kacheln', () => {
describe('mountGalleryGrid: Vorschaubilder', () => {
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: () => {} });
const cards = [card(1), card(2)];
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']);
const srcs = [...host.querySelectorAll<HTMLImageElement>('.gallery-thumb img')].map((i) => i.getAttribute('src'));
expect(srcs).toEqual(['blob:t-1', 'blob:t-2']);
// Vorschau 480 breit, Höhe nach 1536 : 969 (wie exportCard sie rendert)
const first = host.querySelector('.gallery-thumb img');
expect([first?.getAttribute('width'), first?.getAttribute('height')]).toEqual(['480', '303']);
});
it('gibt alle Object-URLs beim Aufräumen frei', async () => {
grid = mountGalleryGrid(host, { load: async () => blob(), onTag: () => {} });
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
grid.append([card(1), card(2), card(3)]);
await flush();
expect(counter).toBe(3);
@@ -138,28 +141,29 @@ describe('mountGalleryGrid: Vorschaubilder', () => {
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 () => {
const resolvers: Array<(b: Blob) => void> = [];
const load = vi.fn(() => new Promise<Blob>((r) => resolvers.push(r)));
it('eine nach dem Aufräumen eintreffende URL wird sofort freigegeben (späte Antwort), neue Ladungen starten nicht mehr', async () => {
const resolvers: Array<(i: CheckedImage) => void> = [];
const load = vi.fn(() => new Promise<CheckedImage>((r) => resolvers.push(r)));
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1), card(2)]);
expect(load).toHaveBeenCalledTimes(2);
grid.dispose();
for (const resolve of resolvers) resolve(blob());
for (const resolve of resolvers) resolve(image());
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)]);
expect(load).toHaveBeenCalledTimes(2);
expect(tiles()).toHaveLength(2); // die Kachel nach dem Aufräumen kommt nicht mehr dazu
});
it('lädt höchstens sechs Vorschauen gleichzeitig', async () => {
const pending: Array<(b: Blob) => void> = [];
const load = vi.fn(() => new Promise<Blob>((r) => pending.push(r)));
const pending: Array<(i: CheckedImage) => void> = [];
const load = vi.fn(() => new Promise<CheckedImage>((r) => pending.push(r)));
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append(Array.from({ length: 10 }, (_, i) => card(i + 1)));
expect(load).toHaveBeenCalledTimes(6);
pending[0](blob());
pending[0](image());
await flush();
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 () => {
const load = vi.fn(async (c: GalleryCard) => {
if (c.id === 'g1') throw new Error('404');
return blob();
return image();
});
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1), card(2)]);
await flush();
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
expect(tiles()[1].querySelector('.gallery-thumb img')).not.toBeNull();
expect(tiles()[0].querySelector('.gallery-thumb')?.hasAttribute('aria-busy')).toBe(false);
});
it('eine abgelehnte Vorschau (zu viele Pixel, nicht dekodierbar) zeigt „Keine Vorschau“, nie ein defektes Bild', async () => {
const load = vi.fn(async () => {
throw new Error('Das Bild konnte nicht geladen werden.'); // so lehnt checkGalleryImage ab
});
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1)]);
await flush();
expect(host.querySelector('.gallery-thumb img')).toBeNull();
expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
});
it('zeigt das Bild nur, wenn es im Browser wirklich lädt: Ladefehler des <img> ersetzt es durch „Keine Vorschau“ und gibt die URL frei', async () => {
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
grid.append([card(1)]);
await flush();
host.querySelector('.gallery-thumb img')?.dispatchEvent(new Event('error'));
expect(host.querySelector('.gallery-thumb img')).toBeNull();
expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
expect(revoked).toEqual(['blob:t-1']);
grid.dispose();
expect(revoked).toEqual(['blob:t-1']); // nicht doppelt freigeben
});
});
+27 -18
View File
@@ -1,12 +1,14 @@
// 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;
// `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML.
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket, mit Pixelgrenze geprüft: loadGalleryImageChecked)
// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML.
import { CARD_H, CARD_W, PREVIEW_WIDTH } from '../card/geometry';
import { el } from '../editor/dom';
import type { GalleryCard } from './gallery';
import { reportsLabel } from '../pages/format';
import type { CheckedImage, GalleryCard } from './gallery';
export interface GalleryGridOptions {
/** Lädt die Vorschau einer Karte. */
load: (card: GalleryCard) => Promise<Blob>;
/** Lädt und prüft die Vorschau einer Karte; wirft, wenn sie sich nicht anzeigen lässt („Keine Vorschau“). */
load: (card: GalleryCard) => Promise<CheckedImage>;
/** Ein Tag-Knopf wurde angeklickt. */
onTag: (tag: string) => void;
/** Admin-Ansicht: „Ausgeblendet“ und die Zahl der Meldungen an der Kachel zeigen (für alle anderen bleibt das verborgen). */
@@ -22,13 +24,8 @@ export interface GalleryGrid {
const PARALLEL = 6;
const MAX_TAGS = 3;
const THUMB_W = 480;
const THUMB_H = 303; // 480 x 1536:969
/** „1 Meldung“, „3 Meldungen“. */
function reportsLabel(count: number): string {
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
}
const THUMB_W = PREVIEW_WIDTH;
const THUMB_H = Math.round((THUMB_W * CARD_H) / CARD_W); // wie exportCard: 480 x 1536:969 = 303
export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOptions): GalleryGrid {
const list = el('ul', { class: 'gallery-grid' });
@@ -39,16 +36,28 @@ export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOption
const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = [];
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> {
try {
const blob = await opts.load(card);
if (disposed) return; // zu spät: keine URL anlegen, die niemand mehr freigibt
const url = URL.createObjectURL(blob);
const { url } = await opts.load(card);
if (disposed) {
URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei
return;
}
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' });
// Die Datei kam an und wurde geprüft, lässt sich aber nicht darstellen: lieber der Hinweis als ein defektes Bild.
img.addEventListener('error', () => {
if (disposed) return;
urls.delete(url);
URL.revokeObjectURL(url);
showEmpty(thumb);
});
thumb.replaceChildren(img);
} catch {
if (disposed) return;
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, 'Keine Vorschau'));
if (!disposed) showEmpty(thumb);
} finally {
thumb.removeAttribute('aria-busy');
}
+151 -7
View File
@@ -1,5 +1,5 @@
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(() => ({
listRows: vi.fn(),
@@ -30,12 +30,15 @@ vi.mock('../account/cards', async (importOriginal) => ({
}));
import {
MAX_GALLERY_PIXELS,
PAGE_SIZE,
checkGalleryImage,
galleryQueries,
getGalleryCard,
listGallery,
listReports,
loadGalleryImage,
loadGalleryImageChecked,
myPublications,
toGalleryCard,
type GalleryCard,
@@ -119,14 +122,24 @@ describe('galleryQueries', () => {
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([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
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', () => {
@@ -140,7 +153,7 @@ describe('galleryQueries', () => {
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g24'),
Query.contains('tags', 'gold'),
Query.contains('tags', ',gold,'),
Query.search('searchText', 'visa'),
]);
});
@@ -183,7 +196,7 @@ describe('galleryQueries', () => {
Query.equal('hidden', true),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', 'visa'),
Query.contains('tags', ',visa,'),
Query.contains('searchText', 'x'),
]);
});
@@ -252,6 +265,12 @@ describe('toGalleryCard', () => {
expect(toGalleryCard(row(1, { tags: '' })).tags).toEqual([]);
});
it('Tags im Speicherformat ",a,b," (Komma an beiden Enden) werden zur sauberen Liste', () => {
expect(toGalleryCard(row(1, { tags: ',visa,gold,' })).tags).toEqual(['visa', 'gold']);
expect(toGalleryCard(row(1, { tags: ',blau,' })).tags).toEqual(['blau']);
expect(toGalleryCard(row(1, { tags: ',' })).tags).toEqual([]);
});
it('reportCount: nur endliche, nicht negative Zahlen', () => {
expect(toGalleryCard(row(1, { reportCount: Number.NaN })).reportCount).toBe(0);
expect(toGalleryCard(row(1, { reportCount: -2 })).reportCount).toBe(0);
@@ -297,7 +316,7 @@ describe('listGallery', () => {
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', 'visa'),
Query.contains('tags', ',visa,'),
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);
});
});
+46 -2
View File
@@ -32,7 +32,7 @@ export type GalleryFilter = 'all' | 'reported' | 'hidden';
export interface GalleryOptions {
/** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */
q?: string;
/** Nur Karten mit 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;
cursor?: string;
filter?: GalleryFilter;
@@ -90,7 +90,7 @@ export function galleryQueries(opts: GalleryOptions): string[] {
const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)];
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
const tag = (opts.tag ?? '').trim().toLowerCase();
if (tag) queries.push(Query.contains('tags', tag));
if (tag) queries.push(Query.contains('tags', `,${tag},`)); // mit Trennzeichen: „gold“ trifft nicht „marigold“
const q = (opts.q ?? '').replace(/\s+/g, ' ').trim();
if (q) {
queries.push(
@@ -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', '');
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));
}
+31
View File
@@ -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/);
});
});
+36
View File
@@ -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');
});
// 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 () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
+5 -1
View File
@@ -22,7 +22,11 @@ const ISSUER_MAX = 40;
const DISPLAY_NAME_MAX = 30;
const TAGS_MAX = 5;
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 {
consent: boolean;
+2 -1
View File
@@ -1,5 +1,6 @@
// 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 { IDS, storage } from '../account/appwrite';
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
+1 -1
View File
@@ -205,7 +205,7 @@ export function mountEditor(root: HTMLElement, store: EditorStore): () => void {
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;
function cancelCardLoad(): void {
loadToken += 1;
+36 -9
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { User } from '../account/auth';
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.
vi.mock('../account/appwrite', () => ({
@@ -43,6 +43,8 @@ const settle = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
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 owner: User = { $id: 'u1', email: 'u@example.com', labels: [] };
@@ -58,7 +60,7 @@ let urlCounter: number;
const revoked: string[] = [];
const getCard = vi.fn<(id: string) => Promise<GalleryCard | null>>();
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 withdrawCard = vi.fn<(id: string) => Promise<void>>();
const setVisibility = vi.fn<(id: string, visible: boolean) => Promise<void>>();
@@ -115,7 +117,7 @@ beforeEach(() => {
}
getCard.mockResolvedValue(card());
listReports.mockResolvedValue([]);
loadImage.mockImplementation(async () => blob());
loadImage.mockImplementation(async () => image());
withdrawCard.mockResolvedValue(undefined);
setVisibility.mockResolvedValue(undefined);
getUser.mockResolvedValue(null);
@@ -123,7 +125,6 @@ beforeEach(() => {
authListeners = new Set();
urlCounter = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => `blob:detail-${++urlCounter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
@@ -276,6 +277,31 @@ describe('Detailseite: Karte', () => {
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 () => {
loadImage.mockRejectedValueOnce(new TypeError('offline'));
await mount();
@@ -311,17 +337,18 @@ describe('Detailseite: Karte', () => {
expect(q('h1')).toBeNull(); // die Karte wird nicht mehr aufgebaut
// Bild kommt erst nach dem Verlassen
let finishImage!: (b: Blob) => void;
let finishImage!: (i: CheckedImage) => void;
getCard.mockResolvedValue(card());
loadImage.mockReturnValue(new Promise((r) => (finishImage = r)));
unmount = mountCommunityPage(root, 'g1', deps());
await settle();
unmount();
unmount = null;
const created = urlCounter;
finishImage(blob());
revoked.length = 0;
finishImage({ blob: blob(), url: 'blob:detail-late' });
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', () => {
it('Herunterladen speichert das volle PNG als aircard-<titel-slug>.png', async () => {
const full = blob();
loadImage.mockResolvedValue(full);
loadImage.mockImplementation(async () => image(full));
getCard.mockResolvedValue(card({ title: 'Grüner Wald!' }));
await mount();
buttonByText('Herunterladen')!.click();
+20 -17
View File
@@ -10,22 +10,25 @@ import { ApiError, reportCard, setVisibility, withdrawCard, type ReportReason }
import {
getGalleryCard,
listReports,
loadGalleryImage,
loadGalleryImageChecked,
type CheckedImage,
type GalleryCard,
type GalleryReport,
} from '../community/gallery';
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 { el } from '../editor/dom';
import { downloadFileName, saveBlobAs } from '../editor/download';
import { formatDate } from './format';
import { formatDate, reportsLabel } from './format';
import { navigate, titleFor } from './router';
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
export interface CommunityDeps {
getCard(id: string): Promise<GalleryCard | null>;
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 }>;
withdrawCard(id: string): Promise<void>;
setVisibility(id: string, visible: boolean): Promise<void>;
@@ -42,7 +45,7 @@ export interface CommunityDeps {
export const defaultDeps: CommunityDeps = {
getCard: getGalleryCard,
listReports,
loadImage: loadGalleryImage,
loadImage: loadGalleryImageChecked,
reportCard,
withdrawCard,
setVisibility,
@@ -54,9 +57,6 @@ export const defaultDeps: CommunityDeps = {
withLogin: (fn) => withLogin(fn),
};
const CARD_W = 1536;
const CARD_H = 969;
const UNAVAILABLE = 'Diese Karte ist nicht (mehr) verfügbar.';
const LOADING = 'Karte wird geladen …';
const LOAD_ERROR = 'Die Karte konnte gerade nicht geladen werden.';
@@ -74,11 +74,6 @@ interface ReportsState {
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. */
function failureText(err: unknown): string {
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.replaceChildren(el('div', { class: 'detail-card-empty' }, el('p', {}, IMAGE_LOADING)));
deps.loadImage(forCard, 'full').then(
(blob) => {
if (disposed || token !== imageToken) return; // zu spät: keine URL anlegen, die niemand mehr freigibt
imageBlob = blob;
imageUrl = URL.createObjectURL(blob);
(loaded) => {
if (disposed || token !== imageToken) {
URL.revokeObjectURL(loaded.url); // zu spät: niemand gibt diese URL mehr frei
return;
}
imageBlob = loaded.blob;
imageUrl = loaded.url;
const img = el('img', {
src: imageUrl,
alt: `Community-Karte „${forCard.title}“`,
@@ -355,7 +353,12 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
setBusy('download');
try {
// 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;
const created = new Date(c.createdAt);
deps.saveBlobAs(blob, downloadFileName(c.title, Number.isNaN(created.getTime()) ? new Date() : created));
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatDate } from './format';
import { formatDate, reportsLabel } from './format';
describe('formatDate', () => {
it('schreibt dd.mm.yyyy', () => {
@@ -11,3 +11,12 @@ describe('formatDate', () => {
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');
});
});
+6 -1
View File
@@ -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. */
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');
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`;
}
+35 -5
View File
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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.
vi.mock('../account/appwrite', () => ({
@@ -37,6 +37,8 @@ const settle = async () => {
for (let i = 0; i < 40; i++) await Promise.resolve();
};
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 member: User = { $id: 'u1', email: 'u@example.com', labels: [] };
@@ -48,7 +50,7 @@ let authListeners: Set<(u: User | null) => void>;
let urlCounter: number;
const revoked: string[] = [];
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 scrollToHash = vi.fn<(hash: string) => void>();
@@ -81,13 +83,12 @@ beforeEach(() => {
loadImage.mockReset();
navigate.mockReset();
scrollToHash.mockReset();
loadImage.mockImplementation(async () => blob());
loadImage.mockImplementation(async () => image());
listGallery.mockResolvedValue(page([]));
user = null;
authListeners = new Set();
urlCounter = 0;
revoked.length = 0;
URL.createObjectURL = vi.fn(() => `blob:home-${++urlCounter}`);
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
@@ -143,12 +144,41 @@ describe('Startseite: Hero', () => {
listGallery.mockResolvedValue(page([card(1)]));
loadImage.mockImplementation(async (_c, size) => {
if (size === 'full') throw new Error('404');
return blob();
return image();
});
await mount();
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 () => {
listGallery.mockRejectedValue(new Error('offline'));
await mount();
+19 -9
View File
@@ -3,7 +3,15 @@
import '../community/community.css';
import { currentUser, isAdmin, onAuthChange, type User } from '../account/auth';
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 { 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). */
export interface HomeDeps {
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;
onAuthChange(fn: (user: User | null) => void): () => void;
navigate(path: string): void;
@@ -21,7 +30,7 @@ export interface HomeDeps {
export const defaultDeps: HomeDeps = {
listGallery,
loadImage: loadGalleryImage,
loadImage: loadGalleryImageChecked,
currentUser,
onAuthChange,
navigate,
@@ -30,8 +39,6 @@ export const defaultDeps: HomeDeps = {
const EXAMPLE_IMAGE = '/hero-beispiel.png';
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 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 (!card) return showExample();
try {
const blob = await deps.loadImage(card, 'full');
if (disposed) return;
heroUrl = URL.createObjectURL(blob);
setHeroImage(heroUrl, `Community-Karte „${card.title}“`, card);
const { url } = await deps.loadImage(card, 'full');
if (disposed) {
URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei
return;
}
heroUrl = url;
setHeroImage(url, `Community-Karte „${card.title}“`, card);
} catch {
if (!disposed) showExample();
}
+31 -1
View File
@@ -1,5 +1,5 @@
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', () => {
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', () => {
const cleanup = vi.fn();
const first = vi.fn(() => cleanup);
+14
View File
@@ -45,6 +45,19 @@ export function titleFor(route: Route, detailTitle?: string): string {
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.
let stopCurrent: (() => void) | null = null;
let renderCurrent: (() => void) | null = null;
@@ -91,6 +104,7 @@ export function startRouter(render: Render): void {
cleanup = undefined;
const route = matchRoute(location.pathname);
document.title = titleFor(route);
for (const listener of [...routeListeners]) listener(route);
cleanup = render(route, root);
};
+112 -2
View File
@@ -1,7 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ navigate: vi.fn() }));
vi.mock('../pages/router', () => ({ navigate: mocks.navigate }));
const mocks = vi.hoisted(() => ({
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';
@@ -21,8 +30,15 @@ const submit = () => search().dispatchEvent(new Event('submit', { bubbles: true,
const menuButton = () => header.querySelector<HTMLButtonElement>('.menu-btn')!;
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(() => {
mocks.navigate.mockReset();
mocks.routeListeners.clear();
localStorage.clear();
html.setAttribute('data-theme', 'dark');
document.body.innerHTML =
@@ -36,6 +52,7 @@ beforeEach(() => {
afterEach(() => {
dispose?.();
dispose = null;
history.replaceState(null, '', '/');
});
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', () => {
it('ohne donateUrl gibt es keinen ☕-Knopf', () => {
mount('');
@@ -123,6 +228,11 @@ describe('mountHeader: Spenden-Knopf', () => {
expect(link.textContent).toContain('Unterstützen');
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', () => {
+25 -5
View File
@@ -3,7 +3,7 @@
// und das Suchfeld bekommt eine eigene Zeile (Stile in shell.css).
import './shell.css';
import { el, icon } from '../editor/dom';
import { navigate } from '../pages/router';
import { navigate, onRouteChange, type RouteId } from '../pages/router';
import { toggleTheme } from './theme';
export interface HeaderOptions {
@@ -17,14 +17,21 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
const hasDonate = opts.donateUrl.trim() !== '';
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 nav = el(
'nav',
{ class: 'app-nav', id: 'app-nav', 'aria-label': 'Hauptnavigation' },
link('Community', '/#community'),
link('Gestalten', '/gestalten'),
link('Meine Karten', '/karten'),
navLink('Community', '/#community', ['home', 'community']),
navLink('Gestalten', '/gestalten', ['gestalten']),
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ü.
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(
el(
'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-text' }, 'Unterstützen'),
),
@@ -101,9 +109,21 @@ export function mountHeader(header: HTMLElement, opts: HeaderOptions): () => voi
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));
return () => {
stopRoute();
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onKeydown);
nav.remove();
+31
View File
@@ -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']);
});
});