Compare commits

..
87 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 1cdebb7102 feat: Speichern veröffentlicht direkt, mehrere Karten auf einmal hochladen und veröffentlichen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:59:07 +00:00
ClaudeandClaude Opus 5.5 4e9659ca85 docs: Benutzernamen in Console-Klickliste, API-README und README
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:34:15 +00:00
ClaudeandClaude Opus 5.5 dc6bd3362b fix(community): Zeile „von …“ bleibt leer, solange der Name des Hochladers noch gesucht wird
Logo-Kacheln, Logo-Infozeile und Detailseite zeigen keinen Ersatz („von Anonym“, alter Anzeigename) mehr, der gleich auf „von @name“
wechselt: Solange die Abfrage für den Hochlader läuft, ist die Zeile leer und behält ihre Höhe (U+200B vor dem leeren Inhalt in
logos.css und community.css); danach steht der Name, ohne Profil oder bei einem Fehler der Ersatz. Schon bekannte Hochlader zeigen
ihre Zeile sofort, ein gemeldeter Name beendet das Warten. Die Infozeile lässt dabei Zeile und Trenner weg („<Name> · ♥ <Zahl>“),
LogoInfoHost bekommt byline(logo) statt names().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:20:24 +00:00
ClaudeandClaude Opus 5.5 56cb67be4b feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen
Logo-Kacheln, Logo-Infozeile und die Detailseite einer Community-Karte zeigen den Benutzernamen des Kontos (Hilfsfunktion byline: Profil,
sonst alter Anzeigename, sonst „Anonym“). Die Namen kommen je geladener Seite in einer Abfrage nach, ein Fehler dabei bleibt ohne Fehlertext,
ein eigenes Umbenennen aktualisiert die eigenen Einträge sofort.

Die Felder „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ fallen weg, ebenso account/display-name.ts und der
localStorage-Schlüssel; der Client schickt displayName: ''. Vor dem Hochladen und Veröffentlichen verlangt requireUsername() einen Namen
(„Abbrechen“: es passiert nichts); antwortet der Dienst mit 403 usernameRequired, öffnet sich der Namensdialog, danach ist ein neuer Versuch möglich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:06:44 +00:00
ClaudeandClaude Opus 5.5 3440eb46c3 fix(account): Registrieren zeigt die Fehlerzeile am Feld im Sichtbaren, der Rückfall-Dialog nennt Namen und Grund
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:49:45 +00:00
ClaudeandClaude Opus 5.5 53bcecf3cb fix(account): Abmelden nimmt den Merker „Benutzername schon gefragt“ weg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:47:45 +00:00
ClaudeandClaude Opus 5.5 7c55912ebe fix(account): ein vor dem Umbenennen gestartetes Lesen meldet den alten Namen nicht mehr
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:46:47 +00:00
ClaudeandClaude Opus 5.5 f46a69df68 fix(account): Konto-Menü zeichnet bei unverändertem Namen nicht neu (Dropdown und Fokus bleiben)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:46:02 +00:00
ClaudeandClaude Opus 5.5 04404ffdae refactor(account): isRowMissing als gemeinsame Hilfsfunktion statt dreier Kopien
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:45:05 +00:00
ClaudeandClaude Opus 5.5 0ad51bf7c6 docs: Spec Benutzernamen – autocomplete="nickname"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:26:34 +00:00
ClaudeandClaude Opus 5.5 151e340fdd fix(account): Benutzernamenfeld mit autocomplete="nickname" statt "username"
Angemeldet wird per E-Mail: Das Namensfeld der Registrierung und das Feld im
Namensdialog sind ein Spitzname, kein Anmeldename. Mit `username` würden
Passwortmanager den Namen statt der E-Mail als Anmeldenamen speichern und später
in die Anmeldung füllen. Tests angepasst, dazu einer, der prüft, dass in keiner
Ansicht des Login-Dialogs ein Feld `autocomplete="username"` trägt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:26:18 +00:00
ClaudeandClaude Opus 5.5 3e4bc21f00 feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage
- Neu `account/profile.ts`: Muster, Sperrliste und Schlüssel (ein Test vergleicht
  sie mit `deploy/api/common.js`), Profile lesen (`getUsername`, `usernamesFor`
  gesammelt in einer Abfrage und zwischengespeichert, bei einem Fehler nur die
  bekannten Namen), `isUsernameTaken`, `saveUsername` (POST /api/profil,
  Zwischenspeicher + `onUsernameChange`), `myUsername`/`loadMyUsername` und der
  Merker `aircard.usernamePrompted`. `IDS.profiles`.
- Neu `account/username-dialog.ts`: Dialog „Benutzername wählen“ in den Modi
  prompt („Später“), required („Abbrechen“) und change (vorbelegt), Fehler am
  Feld, Busy-Sperre, höchstens ein Dialog zur Zeit; dazu `requireUsername()`.
- Registrieren: Pflichtfeld „Benutzername“ vor der E-Mail. Muster, Sperrliste und
  Verfügbarkeit werden vor dem Anlegen des Kontos geprüft (kein halbes Konto);
  danach wird der Name gespeichert, scheitert das, schließt der Login-Dialog und
  der Namensdialog fragt.
- Konto-Menü: „@name“ oben, die E-Mail klein darunter, Eintrag „Benutzername
  ändern“ bzw. „Benutzername wählen“.
- main.ts: Name nach Start und Anmeldung lesen; ohne Namen einmal pro Sitzung der
  Dialog „prompt“ (nicht, wenn der Name nicht lesbar ist).
- Texte in `en.ts`, Stile in `account.css`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:22:15 +00:00
ClaudeandClaude Opus 5.5 19c8151c7e feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)
- Schema: Tabelle `profiles` (Zeilen-ID = Konto-ID, `username` und `usernameKey`
  je 20 Zeichen, Unique-Index `username_key` auf `usernameKey`, rowSecurity an,
  keine Tabellenrechte) im Setup-Skript.
- Neues Modul `deploy/api/profiles.js`: `POST /api/profil` legt das Profil an
  oder ändert es (Zeilenrecht genau read("any"), Sperre je Konto). Muster,
  Sperrliste und Schlüssel (`usernameKey`) stehen in `common.js`; vergeben ->
  409 `usernameTaken`, auch wenn erst der Unique-Index bei einem Rennen
  entscheidet. Dazu die MSG-Texte `usernameInvalid`, `usernameReserved`,
  `usernameTaken`, `usernameRequired`.
- `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten 403
  `usernameRequired`, wenn das Konto kein Profil hat (`hasProfile`, nach der
  Anmeldung und vor allen anderen Prüfungen).
- Fake-Appwrite: Tabelle `profiles` mit Unique-Index (409), `addProfile`,
  `holdNext`. API-Dienst besteht jetzt aus sechs Dateien (READMEs).
- Client: die vier API-Texte in `API_MESSAGES` und `en.ts` (Gleichlauf mit MSG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:40:44 +00:00
ClaudeandClaude Opus 5.5 a65c075004 docs: Spec und Plan Benutzernamen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:23:08 +00:00
ClaudeandClaude Haiku 4.5 13b46da2e0 fix(logos): Fokus auf die Überschrift ohne Scrollen
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-10-05 06:06:04 +00:00
ClaudeandClaude Opus 5.5 6e5fe3e199 fix(pages): englischer Datumsformatierer nur einmal angelegt, Kommentar im Melden-Dialog berichtigt
- format.ts: `Intl.DateTimeFormat('en-US', …)` wurde bei jedem Aufruf von formatDate neu angelegt (Meine Karten, Galerie, Detailseite, Logo-Infozeile rufen es
  je Karte). Er hängt nicht von der Sprache ab und steht jetzt einmal auf Modulebene (EN_DATE); die Ausgabe ist gleich, die vorhandenen Tests bleiben unverändert.
- report-dialog.ts: Der Kommentar zu reportDialog sprach noch vom „Servertext“; angezeigt wird `apiErrorText`, der Text zum Schlüssel des Fehlers in der
  Sprache der Seite.
- Test: fünf Aufrufe auf Englisch legen höchstens einen Formatierer an (frische Modulkopie, Konstruktor gezählt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 691b28df19 fix(editor): der Standardname einer neuen Karte folgt der Sprache
Der Editor-Zustand überlebt den Sprachwechsel (nur die Seite wird neu aufgebaut). Sein Standardname „Meine Karte“ blieb dabei deutsch, auch wenn die Seite
danach Englisch sprach: Herunterladen („aircard-meine-karte.png“) und Speichern nahmen ihn mit.

- state.ts: `defaultCardName()` (der Standardname in der Sprache der Seite, trägt auch initialEditorState) und `followLanguage(store)`: ein Hörer von
  onLangChange, der den Namen einer noch nicht gespeicherten Karte ersetzt, wenn er dem unberührten Standardnamen der bisherigen Sprache gleicht. Ein eigener
  Name bleibt, ebenso der Name einer gespeicherten Karte (auch wenn er dem Standard gleicht). Er merkt sich den Standard der Sprache bis zum nächsten Wechsel,
  so braucht es kein Wörterbuch der Gegensprache; gibt die Abmeldung zurück.
- main.ts: `followLanguage(editorStore)` direkt nach bindEditorToAuth, also vor dem Hörer, der Rahmen und Seite neu aufbaut: die neue Seite sieht den neuen Namen.
- Tests: Wechsel hin und zurück, Start auf Englisch, eigener Name, zurückgesetzter Name, gespeicherte Karte, leerer Name, Rest des Zustands und genau
  eine Benachrichtigung, Abmeldung; in main.test.ts mit dem echten Sprachknopf samt Name, den die neu gebaute Seite beim Aufbau sieht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 e0f11f905e fix(editor): Vorschau und Miniaturen laden ein ausgeblendetes, eingeblendetes oder gelöschtes Logo neu
Blendete ein Admin das gewählte Logo im Picker aus (oder ein), forgetLogo leerte zwar den Zwischenspeicher, aber der Editor behielt seine schon
geladenen Logo-Bilder: Vorschau und Miniaturen zeigten weiter den alten Stand, bis sich Logo, Details oder Marke änderten.

- logo-picker.ts: neuer Rückruf `onLogoChanged(id)` (Typ LogoPickerHandlers). Der Picker ruft ihn, wenn die Infozeile ein Logo aus- oder eingeblendet hat
  (nach patchRow, die Zeile ist dann schon aktuell) und nach dem Löschen (nach onSelect(null)).
- editor-page.ts: Ist das gemeldete Logo das gewählte, verwirft der Editor seinen Logo-Zustand samt Sperren (logos, pendingKey, failedKey), blendet den Hinweis
  aus und ruft ensureLogos neu: Vorschau und alle vier Miniaturen werden mit dem neuen Stand gezeichnet, der Hinweis „nicht mehr verfügbar“ folgt. Ein
  anderes oder kein gewähltes Logo löst nichts aus; ein noch laufender Ladevorgang wird überholt (Token).
- editor-page.ts, followDownload: Stand die Karte nach einem Ladefehler ohne Logo da (`failed`), kehrte der Download früh zurück, obwohl er die Logos
  geladen hatte: das PNG hatte das Logo, die Vorschau nicht. Jetzt übernimmt die Vorschau es (Hinweis passend, failedKey leer).
- editor-page.ts, doSave: forgetLogo(config.logo) vor dem Laden der Bilder, wie Download und Veröffentlichen; ein inzwischen ausgeblendetes Logo kommt nicht
  mehr ins Vorschaubild der gespeicherten Karte.
- editor-page.ts: Beim Ladefehler für eine neue Auswahl blieb der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ der früheren Auswahl neben dem neuen
  Fehler stehen; die Logos der früheren Auswahl und ihr Hinweis fallen jetzt sofort weg (andere Hinweise bleiben).
- Tests: Picker (Rückruf nach Aus-/Einblenden und Löschen mit Reihenfolge, nicht bei Fehlern, Herz, Melden, nach dem Aufräumen) und Editor (Aus- und Einblenden,
  anderes oder kein Logo, überholter Ladevorgang, nach Ladefehler, mit dem echten Zwischenspeicher; Download-Übernahme, Speichern, alter Hinweis neben Fehler).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 0e4759a05a fix(logos): Infozeile zeigt laufende Aktionen an, Fehler gehören zur Auswahl, Gründe nach „Einblenden“ verworfen
- logo-info.ts, run: Die Aktion merkt sich die Auswahl beim Start. Ein Fehler, der erst eintrifft, nachdem der Nutzer ein anderes Logo gewählt hat,
  erschien sonst in dessen Meldezeile (render() leert sie nur beim Wechsel, nicht danach). Jetzt steht er nur da, wenn noch dasselbe Logo gewählt ist.
  Ergebnisse, die an einer ID hängen (patchRow, removeLogo, forgetLogo), gelten wie bisher dem Logo, für das die Aktion lief.
- logo-info.ts: Solange eine Aktion läuft (Rückfrage, Dialog oder Anfrage), trägt die Infozeile aria-busy="true" und die anderen Aktionsknöpfe sind
  gesperrt. Der gedrückte Knopf bleibt bedienbar und behält den Fokus (ein gesperrter verlöre ihn, und der Picker holt den Fokus von „Löschen“ nur,
  wenn er in der Infozeile liegt). Danach ist alles wieder frei, auch nach einem Fehler und nach dem Verlassen der Seite.
- logo-info.ts, renderReports: Bei 0 Meldungen (etwa nach „Einblenden“, das sie löscht) wird der Gründe-Zwischenspeicher geleert. Kamen später genau so
  viele Meldungen hinzu wie vorher (gleicher Schlüssel <id>:<n>), wurden die alten, längst gelöschten Gründe gezeigt.
- Tests: Zustand während jeder Aktion (Löschen mit Rückfrage und Anfrage, Melden, Ausblenden, Fehler, abort), Fokus, wirkungslose Klicks; Fehler nach
  Wechsel der Auswahl (anderes Logo, „Kein Logo“, Aus-/Einblenden) und bei gleicher Auswahl; Gründe nach „Einblenden“ samt später Antwort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 02b01c9deb fix(logos): Picker hält den Fokus bei „Mehr laden“/„Nochmal versuchen“, Raster als Liste, Cursor ohne Vorgänger
- logo-picker.ts: Verschwindet „Mehr laden“ oder „Nochmal versuchen“, während es den Fokus hat, geht der Fokus weiter statt auf der Seite verloren zu
  gehen: an den Knopf, der seinen Platz einnimmt („Nochmal versuchen“ nach einem Fehler beim Nachladen), sonst an die erste Kachel, die der Ladevorgang
  angehängt hat, sonst an die Überschrift „Logo unten rechts“ (jetzt tabindex −1). Ein gesperrter Knopf verliert den Fokus, darum bleibt „Mehr laden“
  während des Ladens bedienbar, solange es ihn hat (ein zweiter Druck ist wirkungslos, load prüft es); ohne Fokus bleibt es gesperrt wie bisher.
  Wer den Fokus woanders hat, bekommt ihn nicht weggenommen.
- logo-picker.ts: das Raster hat role="list" (list-style: none nimmt dem <ul> sonst die Listensemantik) und kein eigenes aria-labelledby mehr; der
  Name steht an der Gruppe. Das lokale `const select` in createTile verdeckte die Funktion select() und heißt jetzt selectButton.
- logo-picker.ts, removeLogo: War das gelöschte Logo der Cursor und steht kein früheres mehr in der Liste, wurde der Cursor verworfen und „Mehr laden“
  verschwand samt aller weiteren Seiten. Jetzt beginnt die Ansicht wieder bei Seite 1 (load('reset')).
- Tests: Fokus in allen Fällen (letzte Seite, weitere Seite, Fehler, Retry bei leerer und gefüllter Liste, Fokus woanders, nur schon bekannte Logos,
  nach dem Aufräumen), role/aria-labelledby/tabindex, Cursor ohne Vorgänger (auch in der Admin-Ansicht „Gemeldet“) und die Fälle ohne Neuladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 8167a18857 fix(a11y): Datei-Feld nativ „required“, ungültige Felder verweisen auf die Fehlerzeile
- logo-dialog.ts: das Datei-Feld trägt `required` statt `aria-required` (für ein Datei-Feld gilt aria-required nicht; das Formular hat novalidate und
  prüft selbst, es ändert sich nichts am Ablauf).
- logo-dialog.ts und publish-dialog.ts: die Fehlerzeile hat eine ID (logo-upload-error, publish-error); solange ein Feld ungültig ist, steht sie in
  seinem aria-describedby, hinter dem Hinweis des Feldes (Datei, Anzeigename, Tags). Ein Screenreader liest den Fehler so mit dem Feld vor.
  Beim nächsten Senden fällt der Verweis mit aria-invalid weg, der Hinweis bleibt. Im Dialog „Logo hochladen“ löscht auch eine neue Dateiwahl die
  Markierung aller Felder (sie löscht ja den Fehlertext, auf den sie verwiesen).
- editor/dom.ts: setFieldInvalid(control, errorId, invalid) für beide Dialoge; eigene Tests (dom.test.ts).
- Tests: required statt aria-required; ID einmalig; je Feld Verweis samt Hinweis in Reihenfolge, Rücknahme, neue Dateiwahl; der vorhandene Hilfsfall
  `invalid` beider Dialoge prüft den Verweis für jedes Feld mit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 29c9b2efaf fix(logos): SVG-Wurzelelement richtig finden, Quell-Canvas und Pixelpuffer früh freigeben, Konstanten umbenannt
- prepare-logo.ts, sizeSvg: Das Wurzelelement wird nicht mehr mit der ersten Fundstelle von „<svg“ gesucht. rootStart überspringt Byte-Order-Marke,
  Leerraum, XML-Deklaration und Anweisungen (<?…?>), Kommentare und den DOCTYPE (auch mit eigener DTD in […] samt Zeichenketten, Kommentaren und
  Anweisungen darin) und verlangt dahinter `<svg` mit Leerraum, „/“ oder „>“. Ein auskommentiertes <svg …> oder eines im DOCTYPE wurde sonst
  umgeschrieben, ein `<svg:svg …>` (Präfix) oder `<svg-x` zerstörte das Tag. Beides bleibt jetzt unverändert (quadratisch), wie ein Text ohne
  Wurzelelement. Ein Durchlauf ohne Zurücksetzen, auch ein feindlicher Prolog braucht nur linear viel Zeit.
- prepare-logo.ts, prepareLogo: renderLogo zeichnet das Quellbild auf den Quell-Canvas, liest den Pixelpuffer einmal als Argument von planLogo
  (keine Referenz danach) und setzt den Quell-Canvas im finally auf 0 × 0, noch vor dem PNG und auch bei Fehlern. Das Ergebnis ist gleich.
- prepare-logo.ts: LOGO_MAX_W/LOGO_MAX_H (720/250, die Dateigrenze) heißen LOGO_FILE_MAX_W/LOGO_FILE_MAX_H und kollidieren nicht mehr im Namen mit
  LOGO_MAX_H aus card/geometry.ts (125, Höhe auf der Karte). Einziger Verwender ist der Test.
- Tests: Prolog-Fälle (Kommentar, Anweisung, DOCTYPE mit DTD, BOM, mehrere, Präfix, Bindestrich, nicht endender Prolog, feindliche Eingaben mit
  Zeitgrenze); Quell-Canvas ist frei (auch vor toBlob, bei leer, zu klein, Zeichenfehler, fehlendem Ergebnis-Canvas, fehlendem PNG), der Pixelpuffer
  wird einmal gelesen. Die vorhandenen Größenprüfungen lesen die Größe beim Zeichnen (`used`), weil der Quell-Canvas am Ende 0 × 0 ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:49:04 +00:00
ClaudeandClaude Opus 5.5 76c3c3aea2 fix(logos): nur „Datei nicht gefunden“ gilt als fehlendes Logo, nicht genutzte Bilder werden freigegeben
- logo-asset.ts: isFileMissing galt für jedes 404 und für jeden Fehler mit dem Typ storage_file_not_found. Jetzt nur noch ein 404 mit diesem Typ
  (oder ohne bzw. mit leerem Typ), wie isRowMissing in logos.ts. Ein fehlender Bucket (404 storage_bucket_not_found) und andere Fehler sind echte
  Fehler: sie werden weitergereicht und nicht zwischengespeichert, statt stillschweigend „Logo nicht verfügbar“ zu melden.
- logo-asset.ts: dekodierte ImageBitmaps, die nicht bleiben, werden mit close() freigegeben: das Zwischenbild eines einfarbigen Logos nach dem
  Einfärben (auch wenn das Füllen scheitert), Bilder über der Pixelgrenze und Bilder ohne Fläche. Das Bild eines mehrfarbigen Logos bleibt offen,
  es ist das Ergebnis; ein `Image` (Ersatzweg ohne createImageBitmap) hat kein close und wird übersprungen.
- Tests: die Fehlerfälle (Bucket, Projekt, andere 404, Typ ohne 404) mit dem Nachweis, dass nichts zwischengespeichert wird; close() in allen
  Fällen samt Reihenfolge (erst nach dem Einfärben).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:32:51 +00:00
ClaudeandClaude Opus 5.5 1fd08d6373 fix(i18n): ein werfender Hörer hält die übrigen beim Sprachwechsel nicht auf
setLang rief die Hörer in einer Schleife ohne Schutz: warf einer (etwa beim Neuaufbau der Kopfzeile), blieben die folgenden ungerufen und der
Aufrufer bekam den Fehler zu sehen, obwohl die Sprache schon umgestellt war.

- i18n.ts: jeder Hörer läuft in try/catch; der Fehler geht mit console.error in die Konsole, der nächste Hörer folgt.
- i18n.ts: der Parameter `browserLanguages` von initialLang hieß wie die Funktion darunter und verdeckte sie; jetzt `languages`.
- Tests: ein werfender Hörer vor, zwischen und nach anderen: alle übrigen laufen, setLang wirft nicht, Sprache und <html lang> stehen, der Fehler
  steht einmal je Aufruf in der Konsole, der werfende Hörer bleibt angemeldet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:32:01 +00:00
ClaudeandClaude Opus 5.5 0bcdfe258d fix(shell): geschlossenes Spenden-Banner kommt beim Sprachwechsel auch ohne localStorage nicht zurück
Das Banner merkte sich „geschlossen“ nur in localStorage. Bei gesperrtem Speicher (privates Fenster, gesperrte Website-Daten) baute der Neuaufbau
des Rahmens beim Sprachwechsel (main.ts) es wieder auf.

- donate-banner.ts: zusätzlich ein Merker im Speicher des Moduls (closedThisSession); er gilt neben localStorage.
- Tests: donate-banner.test.ts lädt je Test eine frische Modulkopie (der Merker lebt im Modul); neue Fälle: gesperrter Speicher, erneutes Einhängen
  nach dem Schließen (bleibt weg) und ohne Schließen (kommt wieder); main.test.ts: Sprachwechsel nach dem Schließen bei gesperrtem Speicher.
  Die beiden schon vorhandenen Fälle zum gesperrten Speicher sperren jetzt wirklich (vi.stubGlobal): vi.spyOn(Storage.prototype, …) greift in
  happy-dom nicht, die Fälle waren leer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:31:38 +00:00
ClaudeandClaude Opus 5.5 af05e5f1f1 fix(api): Logo löschen blendet das Logo zuerst aus, dann folgen Datei, Herzen, Meldungen und die Zeile
deleteLogo löschte zuerst die Datei und zuletzt die Zeile. Brach es dazwischen ab, blieb eine öffentliche Zeile mit gelöschter Datei stehen: unter
dieser Datei-ID kann jedes Konto im Bucket eine neue Datei anlegen, und alle gespeicherten Karten mit diesem Logo zeigten bis zur Wiederholung deren Bild.

- logos.js: Vor dem Löschen der Datei wird das Logo ausgeblendet wie bei der Admin-Sichtbarkeit (hideLogo: Datei, dann Zeile auf genau die ausgeblendeten
  Rechte mit hidden: true; die Rechte der Zeile bleiben die des Hochladers, auch wenn ein Admin löscht). Ein schon ausgeblendetes Logo überspringt das.
  Danach wie bisher Datei, Herzen, Meldungen, die Zeile zuletzt. Wer nicht löschen darf (403), ändert nichts. Das Zurückziehen von Karten bleibt unverändert.
- Tests: Reihenfolge (Datei und Zeile ausblenden, Datei, Herzen, Meldungen, Zeile); Abbruch nach dem Löschen der Datei bei „Herzen lesen“, „Meldungen lesen“
  und „Zeile löschen“ (Hochlader und Admin): die Zeile bleibt mit genau den ausgeblendeten Rechten, kein Gast liest sie, die Wiederholung vollendet das
  Löschen ohne erneutes Ausblenden; scheitert schon das Ausblenden, ist nichts gelöscht; Fremde und schon ausgeblendete Logos lösen kein Ausblenden aus.
- README und Spec: Reihenfolge beim Löschen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:19:57 +00:00
ClaudeandClaude Opus 5.5 7cb1381566 fix(api): Logzeile, wenn die Rückgabe von delete(owner) nach einem Fehler beim Hochladen scheitert
Scheitert das Festlegen oder die zweite Prüfung beim Hochladen eines Logos, nimmt die API die Zeile zurück und gibt der Datei delete("user:<ich>")
zurück, damit der Client sie aufräumen kann. Scheiterte diese Rückgabe, blieb die Datei ohne Zeile und ohne dieses Recht liegen, und das Log
schwieg: die Antwort bleibt der ursprüngliche Fehler, ein Rest ohne Zeile ist sonst nicht zu finden.

- logos.js: eine Logzeile `restore failed for logo <Zeilen-ID> (file <Datei-ID>)` (nur IDs, wie `rollback failed for logo …`).
- Test: zweite Prüfung scheitert, Zeile wird zurückgenommen, die Rückgabe scheitert: genau diese Zeile, Antwort 502, Datei bleibt festgelegt;
  ohne Fehler und bei gescheitertem Zurücknehmen steht keine solche Zeile im Log.
- README: neue Logzeile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:11:50 +00:00
ClaudeandClaude Opus 5.5 6134fd26d7 fix(api): Hochladen hält die Sperre des Logos bis zur zweiten Prüfung
Ab dem Anlegen steht die Zeile öffentlich in der Liste. Ein Herz, eine Meldung oder ein Löschen, das zwischen Anlegen, Festlegen der Dateirechte
und zweiter Prüfung eintraf, lief an der Sperre `logo:<id>` vorbei: entfernte die Prüfung das Logo danach, blieb eine Herz- oder Meldungszeile
ohne Logo zurück.

- logos.js: Der Upload nimmt die Sperre `logo:<Zeilen-ID>` innerhalb der Kontosperre (Reihenfolge Konto, dann Logo; nur das Hochladen nimmt beide,
  nichts kann sich verklemmen) für Anlegen, Festlegen, zweite Prüfung und Rücknahme.
- Tests mit Verzögerung (delayNext): Herz und Meldung, die in dieser Zeit eintreffen, warten und bekommen 404 ohne Reste (Beanstandung und
  Rücknahme nach einem Fehler); ein Löschen wartet, der Upload gelingt und danach ist das fertige Logo weg.
- README: Sperre beim Hochladen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:11:06 +00:00
ClaudeandClaude Opus 5.5 738358fcac fix(logos): hochgeladenes Logo trägt die Datei-ID der Datei und die ID des Dienstes
Die API vergibt die Zeilen-ID des Logos jetzt selbst (Hash aus Konto und Datei, nicht mehr die Datei-ID). uploadPreparedLogo baute das neue Logo
aber noch mit `fileId: id`: das Bild des frisch hochgeladenen Logos wäre aus einer Datei geladen worden, die es nicht gibt.

- upload-logo.ts: `id` aus der Antwort der API, `fileId` aus der angelegten Datei (`$id`). Kommentare ohne „Zeilen-ID = Datei-ID“ (auch logo-api.ts).
- Test: Dienst-ID und Datei-ID sind verschieden und werden nicht vertauscht (Fakes und Standard-Abhängigkeiten).
- Sonst nirgends in web/src: Auswahl und Karten-Konfiguration nehmen `logo.id`, das Bild kommt über `logo.fileId` (loadLogoBlob), beides lief schon getrennt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:08:39 +00:00
ClaudeandClaude Opus 5.5 6d152c11b3 fix(api): Logo-Zeilen-ID ist ein Hash aus Konto und Datei, nicht mehr die Datei-ID
Die Zeilen-ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten. War sie die Datei-ID, konnte jeder eine Datei
mit genau dieser ID anlegen, als Logo hochladen und so die alte ID neu belegen: gespeicherte Karten anderer zeigten dann sein Bild.

- logos.js: Zeilen-ID = hash32("<ownerId>|<fileId>") (logoRowId); die Antwort 201 {id} trägt sie. Dieselbe Datei desselben Kontos ergibt dieselbe ID
  (409 logoTaken, bei einem Altlogo über die fileId der Zeile); alle anderen Regeln bleiben. Die Logzeilen nennen die Zeilen-ID, die Fehlerzeilen
  zusätzlich die Datei-ID (sie folgt nicht mehr aus der Zeilen-ID, und die Zeile ist beim Aufräumen vielleicht schon weg).
- Altlogos (Zeilen-ID = Datei-ID, eines ist live) gehen weiter: Herz, Melden, Löschen und Sichtbarkeit adressieren nur die Zeile und lesen die Datei aus fileId.
- Tests: seedLogo legt Zeile und Datei mit verschiedenen IDs an; neu: logoRowId, die ID eines gelöschten Logos lässt sich nicht neu belegen, anderes Konto
  bekommt eine verwendete Datei nicht (403), Altlogo (Herz, Meldung, Ausblenden/Einblenden, Löschen, erneutes Hochladen 409).
- README und Spec: ID-Regel. Der Client (upload-logo.ts) folgt im nächsten Commit; beide gehen zusammen live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 05:07:28 +00:00
ClaudeandClaude Opus 5.5 ec0b51a949 fix(i18n): „Explore" statt „Discover" im englischen Text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:44:15 +00:00
ClaudeandClaude Opus 5.5 d85cce0bf3 docs: Community-Logos, Englisch und Reihenfolge Schema → API → Seite nachgetragen
Schlussreview F8. Die Dokumentation kannte die Community-Logos, die Herzen, die zweite Sprache und den GitHub-Link noch nicht.

- README.md: Einleitung ohne „Netzwerk-Logo“, neuer Abschnitt „Funktionen“ (Editor, Community-Logos mit Herzen und Moderation,
  Galerie, Deutsch/Englisch mit GitHub-Link), Modulübersicht um logos/, community/, i18n/ und shell/ ergänzt und die Routen vervollständigt,
  Tabellen und Buckets (cards, gallery, reports, logos, logo_likes, logo_reports; card-images, gallery-images, logo-images), Testbefehle
  (API-Tests, Build mit tsconfig.build.json), Hinweis auf die Reihenfolge beim Einführen.
- docs/appwrite-console.md: Schema-Liste (oben, Schritt 5, Abschnitt 7 und 8) mit den Tabellen und dem Bucket der Logos, das Label
  `admin` gilt auch für die Logos, die Scopes des Keys `aircard-api` genügen für die Logo-Endpunkte.
- deploy/README.md: neuer Abschnitt „Reihenfolge bei Änderungen an Schema, API und Seite“: 1. Schema (--check, anlegen, noch einmal
  prüfen), 2. API (fünf Dateien, Neustart, Prüfung: POST /api/logos/herz ohne JWT ergibt 401 mit code unauthorized, 404 hieße alte
  API), 3. erst dann main pushen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:23:23 +00:00
ClaudeandClaude Opus 5.5 7fec92c20a build(web): Produktions-Build prüft keine Testdateien mehr
Schlussreview F7. `npm run build` rief `tsc --noEmit` mit der vollen tsconfig.json auf und prüfte dabei auch alle 53 Testdateien samt
Test-Setup und Englisch-Hilfen: für den Build im Container (node:22-alpine, 15 Minuten Zeitlimit, begrenzter Speicher) unnötige Last.

- Neu: web/tsconfig.build.json (erweitert tsconfig.json, schließt **/*.test.ts, src/test-setup.ts und src/i18n/english-check.ts aus).
- Das Build-Skript lautet jetzt `tsc --noEmit -p tsconfig.build.json && vite build` (deploy.sh ruft `npm run build`).
- `npx tsc --noEmit` prüft weiter alles einschließlich der Tests (für die Entwicklung).
- Gemessen: Prüfung 112 → 57 Dateien (0 Testdateien), 560 MB → 411 MB Speicher (Spitze), 6,3 s → 2,8 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:21:12 +00:00
ClaudeandClaude Opus 5.5 4232afdf35 fix(logos): unerwartete Fehler beim Vorbereiten eines Logos in die Konsole
Schlussreview F6. prepareLogo machte aus jedem Fehler außer seinen eigenen („leer“, „zu klein“ …) die Meldung „Das Logo konnte nicht
gelesen werden.“ und verlor dabei den Anlass (Fehler des Browsers beim Zeichnen, Dekodierfehler): bei einer Rückmeldung eines Nutzers
war nichts mehr nachzuvollziehen.

- Vor dem Umwandeln steht jetzt genau eine console.warn mit dem Originalfehler. Der Text für den Nutzer bleibt unverändert (in beiden
  Sprachen). Fehler mit eigener Meldung (LogoError: Typ, Größe, zu viele Pixel, leer, zu klein, kein Canvas, kein PNG) sind erwartet und
  warnen nicht.
- Tests: Browserfehler beim Zeichnen (Raster und SVG), Dekodierfehler, Englisch, keine Warnung bei den erwarteten Fehlern und bei
  Erfolg. Die Tests der Gruppe „Bild und Canvas gestellt“ ersetzen console.warn durch eine Attrappe, damit die Ausgabe sauber bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:20:28 +00:00
ClaudeandClaude Opus 5.5 df1cf29d42 fix(logos): 404 „logoGone“ beim Löschen gilt als Erfolg
Schlussreview F5. Geht die Antwort einer Löschanfrage verloren und der Nutzer klickt noch einmal auf „Löschen“, antwortet der Dienst mit
404 `logoGone` (das Logo ist ja schon weg). Die Infozeile zeigte dann „Dieses Logo ist nicht (mehr) verfügbar.“ als Fehler, die Kachel
blieb stehen.

- Dieser Fehler (ApiError mit Status 404 und Schlüssel `logoGone`) gilt wie ein gelungenes Löschen: forgetLogo, die Kachel verschwindet,
  eine Auswahl wird aufgehoben (onSelect(null)), kein Fehlertext – wie bei „Zurückziehen“ einer schon zurückgezogenen Karte.
- Jeder andere Fehler bleibt einer, auch ein 404 mit anderem oder ohne Schlüssel (etwa ein Dienst, der die Anfrage nicht kennt).
- Tests in logo-info.test.ts (Hochlader, Admin, Seite verlassen, Fehler, die Fehler bleiben) und im Picker mit Raster und Auswahl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:19:17 +00:00
ClaudeandClaude Opus 5.5 4aedb1d322 fix(logos): Logo vor Veröffentlichen und Herunterladen frisch prüfen
Schlussreview F4. Zeilen und Bilder der Community-Logos (logo-asset.ts) bleiben bis zum Neuladen der Seite im Speicher. Wurde ein Logo
danach ausgeblendet oder gelöscht, konnte es aus diesem Zwischenspeicher in ein veröffentlichtes Kartenbild oder in ein heruntergeladenes
PNG gelangen.

- renderForGallery (Veröffentlichen), der Download im Editor und der Download in „Meine Karten“ rufen vor dem Laden der Bilder
  forgetLogo(config.logo) (falls eines gewählt ist): Zeile und Bild werden neu vom Server geholt, hidden/fehlt wird also neu geprüft.
  Veröffentlichen bricht bei einem fehlenden Logo wie bisher mit LOGO_MISSING_PUBLISH ab, „Meine Karten“ meldet es an der Kachel.
- Der Download im Editor berücksichtigt assets.logoMissing wie der Hinweis im Editor: das PNG entsteht ohne das fehlende Logo (die Vorschau
  zeigte es bis dahin noch), der Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“ erscheint und die Vorschau zeichnet ohne Logo, damit
  PNG und Vorschau dasselbe zeigen. Ist ein Logo wieder da, folgt die Vorschau ebenso. Das gilt nur, solange die Auswahl noch dieselbe ist.
- Tests mit dem echten Zwischenspeicher (nur Zeile und Datei aus logos.ts ersetzt): erst geladen, dann ausgeblendet bzw. gelöscht → neu
  geprüft, Veröffentlichen bricht ab / „Meine Karten“ meldet / der Editor lädt ohne Logo herunter, zeigt den Hinweis (auch zusammen mit dem
  Größenhinweis) und zeichnet neu; sichtbares Logo wird frisch geholt; Karte ohne Logo holt nichts; „Nur Bild“ bleibt unberührt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:18:32 +00:00
ClaudeandClaude Opus 5.5 b3c6e65801 fix(passwort): Erfolgsansicht überlebt den Neuaufbau der Seite beim Sprachwechsel
Schlussreview F3. showDone() entfernt userId und secret aus der Adresse. Wechselte der Nutzer danach die Sprache, baute main.ts die Seite
neu auf und sie las eine Adresse ohne Link: „Der Link ist ungültig oder abgelaufen.“ statt des Erfolgs.

- Ein Stand außerhalb der Seite (`passwordChanged`, Modulvariable) merkt, dass das Passwort gesetzt wurde; ein Neuaufbau zeigt dann wieder
  die Erfolgsansicht (in der neuen Sprache) mit „Anmelden“.
- Ein frischer Link (userId und secret in der Adresse) setzt den Stand zurück und zeigt das Formular. Gemerkt wird nur der Erfolg, ein
  Fehlschlag nicht.
- Tests: Neuaufbau nach dem Erfolg (deutsch, mehrfach, englisch), frischer Link danach, Seite ohne Link bleibt ungültig, Fehlschlag wird
  nicht als Erfolg gemerkt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:13:59 +00:00
ClaudeandClaude Opus 5.5 ec4e80c62c fix(logos): „Mehr laden“ beginnt von vorn, wenn der Cursor nicht mehr existiert
Schlussreview F2. Hat jemand das Logo gelöscht oder ausgeblendet, hinter dem „Mehr laden“ ansetzt (der Cursor), antwortet Appwrite mit
400 `general_cursor_not_found`. „Nochmal versuchen“ wiederholte dann denselben Cursor und scheiterte immer wieder.

- Dieser Fehlertyp beim Nachladen löst jetzt `load('reset')` aus: die erste Seite der aktuellen Ansicht (gleiche Sortierung, gleicher Filter)
  wird neu geladen, die Kachel des gewählten Logos bleibt stehen. Kein Fehlertext, kein „Nochmal versuchen“ für den toten Cursor.
- Jeder andere Fehler beim Nachladen und jeder Fehler beim Laden der ersten Seite bleibt wie bisher (Meldung und „Nochmal versuchen“);
  die erste Seite hat keinen Cursor, ein Neuladen im Kreis ist ausgeschlossen.
- Tests mit einem Fehler {code: 400, type: 'general_cursor_not_found'}: Neuladen von Seite 1 mit Anfragefolge, Ansicht „Gemeldet“ bleibt,
  gewählte Kachel bleibt, Neuladen scheitert selbst, anderer Fehlertyp, Fehlertyp beim ersten Laden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:12:59 +00:00
ClaudeandClaude Opus 5.5 202254c09e fix(editor): Karte ohne Logo zeichnen, wenn ein Logo nicht lädt; erneute Wahl versucht es noch einmal
Schlussreview F1. Scheiterte das Laden der Bilder (Netz, Server), bricht draw() ab, solange logos.key nicht zur Auswahl passt: Vorschau
und Miniaturen blieben leer, und ein erneuter Klick auf dieselbe Kachel änderte nichts, weil failedKey dem Schlüssel entsprach.

- Nach einem echten Ladefehler lädt der Editor einmal die Marke allein (loadCardAssets mit logo: null) und zeichnet die Karte damit ohne
  Logo (`failed`); scheitert auch das, fehlt die Marke ebenfalls. LOGO_ERROR bleibt stehen, die Auswahl auch. Ein anderes Logo wird nie mit
  den Bildern der gescheiterten Auswahl gezeichnet (der Schlüssel entscheidet wie bisher).
- Der Picker meldet jeden Klick, auch auf das schon gewählte Logo: ist es das Logo, dessen Laden scheiterte, hebt der Editor die Sperre
  (failedKey) auf und versucht es noch einmal. Ein geladenes Logo wird dabei nicht neu geladen, ein Fehlschlag wiederholt sich nicht im Kreis.
- Tests: Karte ohne Logo nach dem Fehler (Vorschau und vier Miniaturen), Wiederholung per Klick (Erfolg und erneuter Fehlschlag, kein Loop),
  Marke lädt auch nicht, anderes Logo nach dem Fehler, Klick auf geladenes Logo lädt nichts neu; Picker meldet den Klick auf die gewählte
  Kachel. Zwei bestehende Tests angepasst: „nicht gezeichnet nach Fehler“ gilt nicht mehr, und die Zahl der loadCardAssets-Aufrufe zählt
  jetzt den zusätzlichen Lauf für die Marke nicht mehr mit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:12:11 +00:00
ClaudeandClaude Opus 5.5 7e66cd0f0f feat(i18n): Community, Startseite und Meine Karten auf Englisch
Task 11: alle sichtbaren Texte von community/, pages/home-page.ts, pages/community-page.ts, pages/cards-page.ts und pages/format.ts
(Texte, aria-label, title, Platzhalter, Alt-Texte, Rückfragen, Meldungen) gehen durch t()/msg(); en.ts bekommt 82 Einträge (214 → 296).

- Konstanten bleiben deutsche Strings per msg() und gehen beim Anzeigen durch t() (publish.ts, publish-dialog.ts, gallery.ts und die Seiten);
  geworfene Fehler tragen weiter den deutschen Text, damit die Vergleiche gehen. Funktionen, die Text für Nutzer liefern, übersetzen beim
  Zurückgeben (validatePublishForm, publishFailureText, formatDate, reportsLabel). Template-Literale sind Platzhalter geworden ({max},
  {reason}, {name}, {title}, {query}, {count}); der deutsche Text bleibt Zeichen für Zeichen derselbe.
- API-Fehler: jeder ApiError wird mit apiErrorText() angezeigt (Veröffentlichen-Dialog, Detailseite, Meine Karten), nach dem Code des Dienstes
  statt nach dem deutschen Servertext. Logik, die err.message vergleicht (publishCard, 404 beim Zurückziehen), bleibt unverändert.
- Bekannte Reste aus Task 10 behoben: LOGIN_NEEDED ging in publish-dialog.ts, community-page.ts und cards-page.ts ohne t() an die Oberfläche;
  die Meldungen zu Bild lesen/Pixelgrenze/fehlendem Logo (cards-page.ts, GERMAN_MESSAGES in publish-dialog.ts) erscheinen jetzt übersetzt;
  die Zahl der Meldungen in der Logo-Infozeile („3 reports“).
- pages/format.ts: formatDate liefert Deutsch dd.mm.yyyy und Englisch „Oct 4, 2026“ (Intl.DateTimeFormat en-US), reportsLabel „1 report“ /
  „3 reports“. „Herausgeber“ heißt als Bank „Issuer / bank“.
- logos/prepare-logo.ts: der Helfer problem() ist weg, die sechs Stellen schreiben new LogoError(t(MELDUNG)) selbst (gleiches Verhalten);
  so sieht der neue Prüfer unten jede Verwendung einer Konstante.
- i18n/coverage.test.ts: Die Heuristik für deutsche Literale deckt jetzt die ganze Oberfläche ab (account/, card/, community/, editor/, logos/,
  pages/, shell/, main.ts, i18n/api-messages.ts). Sie erkennt zusätzlich Wörter mit Klammer („Ausgeblendet (gemeldet)“) und nimmt rel und
  Namen in Klammerschreibweise (headers['X-Aircard-Jwt']) aus. Acht begründete Ausnahmen kommen dazu (zwei AppwriteException-Meldungen,
  vier interne Logo-Fehler, der Sprachknopf in der Zielsprache). Neu: ein Prüfer über den Syntaxbaum, der verlangt, dass jede Konstante
  mit msg() nur in t(), new Error(), Vergleichen, case und Listen/Mengen vorkommt (auch über Importe und Umbenennungen); ohne Ausnahmen.
- Tests: format.test.ts (beide Sprachen), Englisch-Blöcke in publish-dialog, gallery-grid, home-page, community-page, cards-page und
  logo-info (Texte, Fehlerwege, „Publishing failed – This card is already published.“, kein deutscher Wörterbuchtext auf der Seite). Die
  Hilfen texts()/germanLeft() liegen jetzt in i18n/english-check.ts (vorher in english-ui.test.ts). Der bestehende Englisch-Test in
  cards-page.test.ts sucht den Knopf „Download“ statt „Herunterladen“.
- Vitest 53 Dateien, 1812 Tests grün (vorher 1779), tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:34:10 +00:00
ClaudeandClaude Opus 5.5 1ff1873b27 feat(i18n): Konto, Editor und Karte auf Englisch
Task 10: alle sichtbaren Texte von account/, editor/, card/ und der Seite „Passwort neu setzen“ (Texte, aria-label, title, Meldungen,
Rückfragen) gehen durch t()/msg(); en.ts bekommt 87 Einträge.

- account/: Konto-Menü, Login-Dialog (Titel, Reiter, Felder, Meldungen) und errors.ts (errorMessage übersetzt beim Zurückgeben).
  validatePassword nutzt {min}/{max}. Der Login-Dialog wird nur einmal gebaut: die Beschriftung des Schließen-Knopfs wird bei jedem
  Öffnen in der aktuellen Sprache gesetzt. LOGIN_NEEDED und DEFAULT_CARD_NAME bleiben deutsche Konstanten (msg), angezeigt werden sie
  mit t(); der Standardname einer neuen Karte folgt damit der Sprache („My card“).
- editor/: Vorlagen-, Options- und Vorschauspalte, Bild-Upload, Speichern, Download, Rückfragen. Konstanten (upload.ts, download.ts,
  editor-page.ts) bleiben deutsche Strings per msg(); validateImageFile, pngSizeWarning und das Speichern („Speichern fehlgeschlagen –
  {reason}“) liefern den Text schon übersetzt, Fehler tragen weiter die deutsche Konstante (Vergleiche mit READ_MESSAGE/PIXELS_MESSAGE
  gehen weiter) und werden beim Anzeigen mit t(error.message) übersetzt. Zoom „100 %“ / „100%“ und „Bearbeitet: {name}“ mit Platzhaltern.
- card/: Bezeichnungen der Marken (msg) und die Meldungen des Renderers (msg, Anzeige im Editor über t()).
- pages/reset-page.ts, account/login-dialog.ts: Die Meldung „Die Passwörter stimmen nicht überein.“ ist nicht mehr der Schlüssel zum
  Fokus: es zählt die Bedingung selbst, nicht der Vergleich mit dem (nun übersetzten) Text.
- i18n/coverage.test.ts: Heuristik für deutsche Text-Literale außerhalb von t()/msg() (Umlaut/ß, mindestens zwei Wörter, einzelnes
  großgeschriebenes Wort; gelesen wird der Syntaxbaum) für account/, editor/, card/ und pages/reset-page.ts; Ausnahmen mit Grund, jede
  muss noch zutreffen; Scanner mit eigenen Tests. Die Liste der Bereiche ist eine Konstante für Task 11.
- i18n/english-ui.test.ts (neu): Stichproben auf Englisch (Konto-Menü, Login-Dialog, Passwort-Seite, Editor inkl. Fehlerwege) und
  „kein deutscher Wörterbuchtext auf der englischen Seite“.
- test-setup.ts: Wer Module mit vi.resetModules() frisch lädt, bekam eine neue Kopie von i18n.ts mit der Browsersprache (happy-dom: en-US);
  der Browser meldet im Test jetzt Deutsch, damit auch diese Tests deutsch beginnen. main.test.ts: nach dem Wechsel auf Englisch heißt
  der Knopf des Konto-Menüs „Sign in“.
- Tests: Vitest 53 Dateien, 1779 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:58:49 +00:00
ClaudeandClaude Opus 5.5 a75be2d44c feat(logos): Infozeile mit Melden, Löschen und Admin-Aktionen
Task 9: die Infozeile zum gewählten Logo unter dem Raster (Spec § 3 und § 6).

- logos/logo-info.ts (neu): „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ (auch für Gäste; ein ausgeblendetes Logo lässt
  sich nicht melden). Der Hochlader sieht „Löschen“ mit Rückfrage (Titel mit Namen, Folgen im Text), Admins „<n> Meldungen“ mit den
  Gründen als Text (listLogoReports, einmal je Logo und Stand), „Ausblenden“/„Einblenden“ und „Löschen“. Gelöscht: deleteLogo,
  forgetLogo, Kachel weg, onSelect(null); ein-/ausgeblendet: setLogoVisibility, forgetLogo (ein Null-Bild bleibt nicht im Zwischen-
  speicher), die Zeile des Pickers wird fortgeschrieben (Einblenden setzt reportCount auf 0). Gewähltes Logo nicht auffindbar: „Das
  gewählte Logo ist nicht mehr verfügbar.“ (ein Netzfehler von getLogo sagt nichts). Zugleich die Meldezeile (role="alert") für Fehler
  von Herz, Hochladen und diesen Aktionen; ein Wechsel der Auswahl räumt sie ab. Immer nur eine Aktion zugleich; die Rechte werden
  nach der Rückfrage noch einmal geprüft; der Aufbau steht einmal da, update() ändert nur Texte und Sichtbarkeit (Fokus bleibt).
- logos/logo-picker.ts: hängt die Infozeile als Letztes unter das Raster (info.el). Herz- und Upload-Fehler gehen in die Infozeile,
  der Statusbereich des Pickers bleibt den Ladefehlern der Liste vorbehalten (ein Klick auf ein Herz löscht ihren Text nicht mehr).
  Aus dem Review von Task 8: requireLogin, das wirft, zeigt den Grund statt einer unbehandelten Ablehnung; select() nach dispose()
  ist wirkungslos. Löschen entfernt Kachel, Zeile, Herz und Eintrag der Liste, setzt den Cursor von „Mehr laden“ auf das Logo davor
  (Appwrite lehnt einen Cursor auf eine gelöschte Zeile ab), hebt die Auswahl über onSelect(null) auf und holt den Fokus nach
  „Kein Logo“, wenn er auf „Löschen“ lag. LogoPickerDeps erweitert um reportLogo, deleteLogo, setLogoVisibility, listLogoReports,
  confirm und reportDialog.
- community/report-dialog.ts: subject 'card' (Standard, unverändert) oder 'logo': Überschrift „Logo melden“, Einleitung „dieses
  Logo“, vier Gründe ohne „Echte Kartendaten“, Duplikat-Dank „Danke, du hast dieses Logo schon gemeldet.“. Der ganze Dialog geht
  durch t()/msg(); Fehler des Dienstes über apiErrorText.
- logos/logos.css: das Herz sitzt in der Textzeile unter der Bildfläche (nicht mehr auf dem Logo, folgt dem Theme), die Kachel füllt
  die Zelle (height: 100%), damit „Kein Logo“ so hoch ist wie sein Nachbar; Stile der Infozeile.
- editor/editor-page.ts: Aus dem Review von Task 8: der Ladefehler der Logos verschwindet, sobald ein Logo danach lädt, aber nur
  der, den der Logo-Zustand selbst ins Fehlerfeld geschrieben hat; jeder andere Text im Feld bleibt.
- i18n/en.ts: englische Texte zu allen neuen Beschriftungen und zum Melden-Dialog.
- Tests: logo-info.test.ts (neu), logo-picker.test.ts (Infozeile, Löschen, Ein-/Ausblenden, Fehler, Cursor, Fokus), report-dialog.test.ts
  (Logo und Englisch), editor-page.test.ts (Fehlerfeld). Vitest 52 Dateien, 1764 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:21:33 +00:00
ClaudeandClaude Opus 5.5 4cf9fe192d feat(editor): Community-Logos unten rechts mit Herzen und Sortierung
Task 8: der Bereich „Logo unten rechts“ in der Optionsspalte des Editors (Spec § 3 und § 5).

- logos/logo-picker.ts (neu): createLogoPicker baut Kopf (Überschrift, Plakette „Community“, „+ Hochladen“, Satz zur Community), den
  Umschalter „Beliebt“/„Neu“ (Admins zusätzlich „Gemeldet“/„Ausgeblendet“, je eine Ansicht mit sort und filter) und das Raster:
  erste Kachel „Kein Logo“, je Logo eine Kachel mit Bild, Name, „von <Anzeigename>“ bzw. „von Anonym“ und einem eigenen Herz-Knopf
  neben dem Auswahlknopf. „Mehr laden“ hängt die nächste Seite an (Duplikate nur einmal), Leer-Text und Ladefehler mit „Nochmal
  versuchen“ (wiederholt genau den gescheiterten Schritt); nur die jüngste Antwort einer Ansicht zählt. Die Auswahl gehört dem
  Editor: ein Klick meldet onSelect, markiert wird über select(); ein Logo außerhalb der Liste wird nach dem Laden der ersten Seite
  per getLogo geholt und als zweite Kachel angeheftet (steht es in der Liste, gibt es keine Anfrage). selected() liefert die Zeile
  für die Infozeile in Task 9. Herzen: optimistisch mit Rücksprung und Fehlertext (role="alert"); solange eine Anfrage für ein
  Logo läuft, bleiben weitere Klicks auf sein Herz wirkungslos; Gäste öffnen erst den Login (abgebrochen: nichts passiert, kein
  Fehlertext); die Herzen einer Seite kommen mit genau deren IDs, beim Anmelden für alle gezeigten Logos, beim Abmelden aus; eine
  späte Antwort überschreibt kein inzwischen selbst geschaltetes Herz. „Hochladen“ meldet Gäste zuerst an, öffnet den Dialog (mit
  Signal, das beim Verlassen der Seite schließt), setzt das neue Logo als erste Kachel, ruft rememberLogo und onSelect(id).
- logos/logo-image.ts (neu): das PNG einer Kachel laden, auf die Pixelgrenze prüfen (MAX_LOGO_PIXELS, so wie checkGalleryImage die
  Galerie-Bilder prüft; darüber, nicht lesbar oder Ladefehler: neutraler Platzhalter statt Absturz) und zeigen: mehrfarbig als <img>,
  einfarbig als weiße Maske, immer auf dem dunklen Kartengrund. Die Object-URL gibt die zurückgegebene Funktion frei; eine Datei, die
  erst danach eintrifft, wird nicht mehr angezeigt.
- logos/logo-asset.ts: MAX_LOGO_PIXELS wird exportiert (eine Quelle für Renderer und Kacheln).
- logos/logos.css: Kopf, Umschalter, Raster (ab 1024 px zwei Spalten, sonst auto-fill ab 140 px), Kacheln mit Bildfläche auf #1B2738,
  Herz (gefüllt, wenn gedrückt), Leer- und Fehlerzeilen; Bild und Maske teilen sich die Regeln mit der Dialog-Vorschau.
- editor/editor-page.ts: der Picker sitzt in options.logoSlot, die Wahl setzt config.logo, sync ruft picker.select(config.logo), das
  Verlassen der Seite ruft dispose. Übernommen aus dem Review von Task 5: failedKey blockiert ein Logo nicht mehr für immer, eine
  andere Wahl (Logo, Details, Marke) hebt die Sperre auf; derselbe Schlüssel wird weiter nicht im Kreis neu versucht.
- editor/dom.ts: Symbole heart und plus. i18n/en.ts: englische Texte zu allen neuen Beschriftungen.
- Tests: logo-picker.test.ts (Aufbau, Sortierung, Mehr laden, Auswahl und angeheftetes Logo, Herzen mit Rennen und Login, Leer und
  Ladefehler, Hochladen, Kachelbilder, dispose, Englisch), logo-image.test.ts, editor-page.test.ts (Verdrahtung, Regressionstest zu
  failedKey). Vitest 51 Dateien, 1666 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:40:37 +00:00
ClaudeandClaude Opus 5.5 e22919f4dc feat(logos): Dialog „Logo hochladen“ und gemerkter Anzeigename
Task 7: der Dialog zum Hochladen eines Community-Logos (Spec § 4) und der zuletzt benutzte Anzeigename, den er mit dem
Veröffentlichen-Dialog teilt.

- logos/logo-dialog.ts (neu): logoUploadDialog öffnet ein eigenes <dialog> nach dem Muster von publish-dialog.ts (showModal, Fokus
  zurück an den Auslöser, Texte nur als Text; beim Hochladen sind Abbrechen, Esc und Hintergrund wirkungslos, nur das Signal schließt
  immer; ein zweites Absenden wird ignoriert). Die gewählte Datei geht erst durch validateLogoFile, dann durch prepare (Standard
  prepareLogo); währenddessen ist „Hochladen“ gesperrt, eine neue Wahl macht die laufende Verarbeitung hinfällig. Das fertige PNG
  erscheint als Object-URL zweimal, auf dunklem und hellem Grund; „Einfarbig“ (nur wählbar mit transparenten Bereichen, bei jeder
  neuen Datei zurückgesetzt) zeigt stattdessen eine Maske auf dieselbe URL, gefüllt mit TONE_COLOR (Weiß bzw. #111111). Die URL wird
  beim Schließen und beim Ersetzen freigegeben. Der Name wird aus dem Dateinamen ohne Endung vorbelegt (höchstens 40 Zeichen, nie
  mitten in einem Emoji), ein selbst getippter Name bleibt. Hochladen über uploadPreparedLogo mit withLogin (abgelaufene Sitzung →
  Anmeldung); Fehler erscheinen als „Hochladen fehlgeschlagen – <Grund>“ (ApiError über apiErrorText, Texte von prepareLogo im
  Wortlaut, sonst errorMessage), eine abgebrochene Anmeldung als „Bitte melde dich an“. validateLogoForm prüft Datei, Name (leer,
  Steuerzeichen, 40), Anzeigename (Steuerzeichen, 30) und Pflicht-Häkchen mit den Texten aus Spec § 4 und § 11.
- logos/logos.css (neu): Breite des Dialogs, Datei-Feld, die zwei Vorschauflächen und die Maske; Hülle, Felder und Knöpfe kommen aus
  community.css und account.css.
- account/display-name.ts (neu): DISPLAY_NAME_KEY (aircard.displayName), rememberedDisplayName und rememberDisplayName; jeder Zugriff
  in try/catch, ein leerer Name entfernt den Schlüssel.
- community/publish-dialog.ts: der Anzeigename ist aus dem gemerkten Namen vorbelegt und wird nach erfolgreichem Veröffentlichen
  gemerkt; CONTROL_CHARACTERS und DISPLAY_NAME_MAX werden exportiert (der Logo-Dialog prüft mit denselben Regeln).
- i18n/en.ts: englische Texte zu allen neuen Beschriftungen, Hinweisen und Meldungen („Einfarbig“ heißt „Single color“).
- Tests: logo-dialog.test.ts (validateLogoForm, Aufbau, Dateiwahl mit Vorschau, Namensvorschlag, Maske, Object-URLs, veraltete
  Verarbeitung, Prüfung, Senden mit Doppelklick und Sperren, Fehlertexte in beiden Sprachen, Standardwerte, Schließen),
  display-name.test.ts, publish-dialog.test.ts (Vorbelegung und Speichern). Vitest 49 Dateien, 1566 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:00:12 +00:00
ClaudeandClaude Opus 5.5 56d841b8e2 fix(logos): SVG-Größe aus Breite und Höhe mit ergänzter viewBox, Pixelgrenze vor dem Dekodieren
Entscheidungen des Controllers zu Task 6 (Bedenken 2 bis 4).

- logos/prepare-logo.ts: sizeSvg(svg) ersetzt sizeSvgRoot und liefert { svg, w, h }. Das Seitenverhältnis kommt aus der brauchbaren
  viewBox des Wurzelelements, sonst aus dessen Breite und Höhe (Pixel oder ohne Einheit), sonst ist es quadratisch; Prozent und
  andere Einheiten, 0 und negative Werte zählen als unbrauchbar. Ohne brauchbare viewBox, aber mit Größe, wird viewBox="0 0 <Breite>
  <Höhe>" ergänzt (eine unbrauchbare viewBox wird dabei ersetzt, nie doppelt geschrieben), damit der Inhalt auf das Raster skaliert
  statt in Originalgröße oben links zu stehen. Eine brauchbare viewBox bleibt immer unberührt. Gelesen werden nur die Attribute des
  Wurzelelements (vorher die erste viewBox irgendwo im Text, etwa die eines inneren <symbol>); Anführungszeichen " und ' gehen
  beide. Tokenizer und Längenparser laufen linear (zwei quadratische Entwürfe fielen im Zeittest durch).
- logos/prepare-logo.ts: Raster und SVG werden über decodeImage aus editor/upload.ts geladen: erst die Maße lesen und gegen
  40 Megapixel prüfen, erst dann dekodieren (vorher dekodierte createImageBitmap zuerst). decodeImages Text für „zu groß“ wird zum
  Text des Logos, jeder andere Fehler bleibt „Das Logo konnte nicht gelesen werden.“ Die eigene Ladefunktion für SVG und
  createImageBitmap entfallen. LOGO_SOURCE_MAX_PIXELS ist jetzt MAX_IMAGE_PIXELS (eine Quelle, Wert 40 000 000 per Test festgehalten).
- logos/prepare-logo.ts: SVG_FALLBACK_SIZE entfernt (nichts benutzte es).
- card/marks.ts: neu viewBoxAspect(value) mit den Regeln aus svgAspect; svgAspect ruft es auf (Verhalten unverändert), so stehen die
  viewBox-Regeln nur einmal da.
- Tests: sizeSvg als Tabelle (Größe ohne Einheit, px, hochkant, Prozent, mm/em/pt/auto, nur Breite oder Höhe, 0 und negativ,
  vorhandene viewBox bleibt, unbrauchbare viewBox wird ersetzt, innere Elemente zählen nicht, XML-Kopf, „>“ im Wert, Zeitgrenze),
  prepareLogo mit gestelltem Image: Zahl der Pixel wird vor decode() geprüft (8001 x 5000 und 20 000 x 20 000 ohne decode, genau
  8000 x 5000 mit), SVG nur mit Breite und Höhe wird 2048 x 614 gerastert und bekommt viewBox="0 0 200 60"; marks.test.ts für
  viewBoxAspect. Vitest 47 Dateien, 1478 Tests grün, tsc sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:26:30 +00:00
ClaudeandClaude Opus 5.5 0f66136d82 feat(logos): Logo zuschneiden, verkleinern und hochladen
Task 6: Verarbeitung und Hochladen eines Logos als Ablauf ohne Oberfläche (der Dialog folgt in Task 7).

- logos/prepare-logo.ts (neu): validateLogoFile (PNG, JPG, WebP, SVG; ohne Typ zählt die Endung .svg; höchstens 5 MB), die reinen
  Helfer trimBox (Alpha ≤ 8 gilt als leer), fitWithin (nie größer, gerundet, mindestens 1), svgRasterSize (längste Seite genau 2048,
  ungültiges Seitenverhältnis quadratisch), hasTransparency und planLogo (Ausschnitt und Zielgröße, „leer“ und „zu klein“ nach der
  fertigen Größe) sowie sizeSvgRoot. prepareLogo fügt sie zusammen: Raster über createImageBitmap (Breite × Höhe wird vor dem
  Zeichnen gegen 40 Megapixel geprüft, das Bild danach freigegeben), SVG nur als Bild aus einer Object-URL (Seitenverhältnis aus
  der viewBox, Größe ins Wurzelelement geschrieben), Zuschnitt, Verkleinern auf höchstens 720 × 250, PNG, hasTransparency auf den
  Endpixeln. Jeder Fehler beim Dekodieren oder Zeichnen heißt „Das Logo konnte nicht gelesen werden.“; alle Meldungen sind schon
  übersetzt (t() beim Auslösen), die deutschen Konstanten LOGO_*_MESSAGE stehen über msg().
- sizeSvgRoot ersetzt eine eigene width/height des Wurzelelements, statt ein zweites Attribut anzuhängen (doppelte Attribute machen
  ein SVG ungültig, so scheiterten viele echte SVGs). Es macht genau einen Versuch ab dem ersten „<svg“: ein Suchmuster, das an
  jedem „<svg“ neu ansetzt, brauchte bei einer Datei voller offener „<svg x="“ quadratisch viel Zeit (160 kB: 6 s).
- logos/upload-logo.ts (neu): uploadPreparedLogo legt logo.png mit read("any") und delete("user:<ich>") in den Bucket logo-images,
  ruft uploadLogo und liefert das neue CommunityLogo (ID = Datei-ID, likeCount 0). mono gilt nur bei durchsichtigen Stellen. Wie
  beim Veröffentlichen wird nur bei eindeutigem Scheitern aufgeräumt (genau die Datei dieses Versuchs, ein Fehler dabei überdeckt
  nie den eigentlichen), bei offenem Ausgang bleibt die Datei. Ausnahme: 409 mit serverCode logoTaken löscht nichts, ein anderes
  Logo trägt diese Datei-ID.
- i18n/en.ts: englische Texte zu den sechs neuen Meldungen.
- Tests: prepare-logo.test.ts (Helfer einzeln, dazu prepareLogo mit gestelltem Dekoder und Canvas: Zuschnitt, Skalierung, Rasterung,
  Fehlertexte, 40-Megapixel-Grenze, SVG nie in der Seite) und upload-logo.test.ts (Reihenfolge, Rechte, Felder, mono, Aufräumen je
  Status, logoTaken, Standard-Abhängigkeiten gegen den Bucket logo-images). Vitest 47 Dateien, 1452 Tests grün, tsc sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 21:05:38 +00:00
ClaudeandClaude Opus 5.5 8205f7c37f fix(card): fehlendes Logo bricht nur ab, wenn die Vorlage es zeichnet; Download aus „Meine Karten“ meldet es
Entscheidung des Controllers zu Task 5: Ein fehlendes Community-Logo ist beim Rendern einer gespeicherten Karte nur dann ein
Problem, wenn die Vorlage es zeichnet (layoutFor(template).logo). Bei „Nur Bild“ wird ein Logo nie gezeichnet, dort fehlt
nichts. Der Editor-Hinweis (Auswahlzustand) und der Download im Editor bleiben unverändert.

- community/publish.ts: neue gemeinsame Prüfung assertLogoAvailable(config, assets); sie wirft LOGO_MISSING_PUBLISH, wenn
  assets.logoMissing gilt und die Vorlage das Logo zeichnet (minimal, classic, signature). renderForGallery nutzt sie: bei
  einer zeichnenden Vorlage Abbruch vor dem Rendern (nichts hochgeladen), bei „Nur Bild“ wird normal gerendert und
  veröffentlicht.
- pages/cards-page.ts: der Download einer gespeicherten Karte ruft dieselbe Prüfung. Statt stillschweigend ohne Logo
  herunterzuladen, zeigt die Kachel den Text LOGO_MISSING_PUBLISH über den vorhandenen Fehlermechanismus (run() und
  failureText() schreiben in .card-msg), übersetzt mit t(); es wird nichts exportiert oder gespeichert. Bei „Nur Bild“ läuft
  der Download normal.
- Tests: publish.test.ts (classic/minimal/signature mit fehlendem Logo brechen ab, „Nur Bild“ veröffentlicht normal,
  Wahrheitstabelle für assertLogoAvailable) und cards-page.test.ts (Download mit fehlendem Logo: Meldung, kein Download;
  „Nur Bild“ und vorhandenes Logo: Download wie sonst; zweiter Versuch; Englisch). Vitest 45 Dateien, 1338 Tests grün, tsc
  und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:38:23 +00:00
ClaudeandClaude Opus 5.5 0be5abd954 feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer
- card/config.ts: network und das Apple-Logo entfallen. CardConfig.logo ist die ID eines Community-Logos (Muster ID_PATTERN,
  sonst null), DEFAULT_CONFIG.logo = null, serializeConfig schreibt {v, template, logo, details, brand, crop}. Ein altes network
  wird ignoriert, brand 'apple' fällt auf den Standard. ID_PATTERN steht jetzt hier (die reine Konfiguration prüft die Logo-ID,
  ohne Appwrite zu laden), account/cards.ts reicht sie weiter; single-sources.test.ts sorgt für genau eine Definition.
- card/geometry.ts: fitLogo und LOGO_MAX_H (125) ersetzen fitNetwork, Layout.network heißt Layout.logo.
- card/marks.ts: nur noch die AirCard-IOS-Marken (BRAND_MARKS, getBrandMark, svgAspect, tintSvg, loadMarkImage). Die 19 SVGs
  der Netzwerke und von Apple sind per git rm gelöscht, ein Test hält assets/marks auf die zwei AirCard-Dateien.
- card/render.ts: LogoAsset, CardAssets.logo statt network, LoadedAssets.logoMissing. loadCardAssets(config, image, loadLogo =
  loadLogoAsset): ohne Logo kein Aufruf, null vom Lader ergibt logoMissing, ein Ladefehler wirft. Der Renderer zeichnet das
  Logo mit fitLogo(w / h) unten rechts, wenn Vorlage und Bilder eines haben.
- logos/logo-asset.ts (neu): loadLogoAsset(id, tone) holt Zeile (getLogo) und PNG (loadLogoBlob) und dekodiert mit
  createImageBitmap, sonst über ein Image. Einfarbige Logos werden in einem eigenen Canvas mit source-in in der Details-Farbe
  gefüllt, mehrfarbige bleiben. Fehlende Zeile, ausgeblendetes Logo (auch für Hochlader und Admins), Datei 404, unlesbare
  Datei und mehr als 1 000 000 Pixel ergeben null. Je (id, tone) ein Ladevorgang, ein Fehler bleibt nicht im Cache.
  rememberLogo, forgetLogo und LOGO_MISSING nach der Schnittstelle des Plans.
- editor/options-panel.ts: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit Symbol, Text und Kein Logo (ohne
  Apple), dazu logoSlot als leerer Platz oben für Task 8 (editor.css: Details rückt bei leerem Platz wie bisher nach oben).
- editor/editor-page.ts: logoKey aus Logo, Details und Marke, der Logo-Zustand hält missing. Fehlt das Logo, wird die Karte
  ohne es gezeichnet und der Hinweis LOGO_MISSING gezeigt, solange das so bleibt; ein Wechsel auf ein vorhandenes Logo oder
  auf kein Logo nimmt nur diesen Hinweis weg (PNG-Größe und „Bild fehlt“ stehen getrennt und bleiben). Kein LOGO_ERROR bei
  fehlendem Logo, nur bei echtem Ladefehler, auch beim Download.
- community/publish.ts: LOGO_MISSING_PUBLISH, renderForGallery (jetzt exportiert) bricht bei logoMissing vor dem Rendern ab,
  es wird nichts hochgeladen. publish-dialog.ts lässt den Text im Wortlaut durch.
- i18n/en.ts: englische Texte zu LOGO_MISSING, LOGO_MISSING_PUBLISH und „Logo oben links“.
- Tests: config, geometry, marks, render und die Fixtures von cards, theme, cards-page auf das neue Modell; neu
  logo-asset.test.ts (28) und options-panel.test.ts (10); editor-page (14 neue), publish und publish-dialog erweitert.
  Vitest 45 Dateien, 1319 Tests grün, tsc und Build sauber.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:32:45 +00:00
ClaudeandClaude Opus 5.5 3051b3b3f9 feat(logos): Datenschicht, API-Aufrufe und übersetzbare API-Fehler
- logos/logos.ts (Muster community/gallery.ts): CommunityLogo, toLogo, logoQueries (Beliebt/Neu, Admin-Filter Gemeldet und
  Ausgeblendet, Cursor), listLogos (nextCursor nur bei voller Seite von 24), getLogo (ungültige ID oder 404 -> null),
  myHearts (eigene Herzen einer Seite, ohne IDs keine Anfrage), listLogoReports (ohne Melder-Hash), loadLogoBlob.
- logos/logo-api.ts: uploadLogo, setHeart, reportLogo (ohne Anmeldung), deleteLogo, setLogoVisibility über apiPost.
  uploadLogo behandelt das Limit (429 tooManyLogos) wie publishCard als eindeutige Ablehnung (die hochgeladene Datei darf
  aufgeräumt werden); eine Antwort ohne id (und bei setHeart ohne Zustand und Zähler) ist 503 mit offenem Ausgang.
- account/appwrite.ts: IDS um logos, logoLikes, logoReports und logoBucket.
- community/api.ts: ApiError.serverCode (der Schlüssel aus {error, code}; code bleibt der HTTP-Status, message der deutsche
  Text). Client-Fehler tragen unavailable (auch 502/503/504 vom Proxy, vor dem Lesen des Bodys), generic, jwtUnsupported,
  alreadyPublished, tooManyPublications. Neu: unavailableError() und apiErrorText(err). Die deutschen Texte stehen einmal
  in api-messages.ts, die Vergleiche in publishCard laufen unverändert über err.message.
- i18n/api-messages.ts: API_MESSAGES (alle 33 Texte von MSG plus die Client-Codes generic und jwtUnsupported) und
  apiMessage: bekannter Code -> t(Text); unbekannt: Deutsch der Servertext, Englisch eine Meldung je Status (400, 403, 404,
  sonst allgemein). Der Code badRequest trägt auch die dynamischen Prüftexte des Dienstes, Deutsch zeigt dann den
  genaueren Servertext.
- i18n/en.ts: englische Texte zu allen API_MESSAGES.
- Tests: api-messages.test.ts lädt MSG aus deploy/api/common.js (createRequire) und prüft den Gleichlauf in beide
  Richtungen; logos.test.ts, logo-api.test.ts und neue Fälle in api.test.ts (der Helfer freshApi stellt die frische
  i18n-Instanz auf Deutsch). 156 neue Tests, die bisherigen unverändert grün (1285 gesamt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:59:08 +00:00
ClaudeandClaude Opus 5.5 fc9e5f5577 fix(api): Logo-Datei nach dem Festlegen der Rechte noch einmal prüfen
Der Hochlader behält delete auf seiner Datei, bis die API sie auf read("any") festlegt. Zwischen der ersten Prüfung
(Typ, Größe, PNG-Kopf, Maße) und dem Festlegen ließ sich die Datei unter derselben ID löschen und mit anderem Inhalt
neu anlegen (z. B. 4000 × 4000). Jetzt liest die API den Dateianfang nach dem Festlegen noch einmal, wenn der
Hochlader die Datei nicht mehr ändern kann:

- PNG-Kopf und Maße 8 × 8 bis 720 × 250 werden erneut geprüft. Fällt die Datei durch oder fehlt sie, löscht die API die
  Datei (Server-Key) und die Zeile (beides wird versucht) und antwortet 400 logoFormat bzw. fileMissing. Log:
  "logo <id> removed after upload check (<code>)", bleibt etwas stehen: "rollback failed for logo <id>".
- Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (502, Zeile zurückgenommen, die
  Datei bekommt delete(owner) zurück).
- fake-appwrite.js: Testhilfe swapContentAfterPermissions (der Inhalt ändert sich nach dem nächsten Rechte-Update).
- Tests: 7 neue in logos.test.js (Austausch auf 4000 × 4000, weitere ungültige Inhalte, Datei weg, Appwrite-Fehler,
  Aufräumen scheitert, Ablauffolge, weiterhin gültiger Inhalt), der Logtest prüft das neue Ereignis mit. Der
  Karten-Ablauf ist unverändert (app.js und common.js nicht angefasst).
- Doku: api/README.md.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:20:25 +00:00
ClaudeandClaude Opus 5.5 88abc2061d feat(appwrite): Schema für Community-Logos, Herzen und Logo-Meldungen
- Setup-Skript: Tabellen `logos`, `logo_likes` (Mehrspalten-Index `user_logo` über userId, logoId)
  und `logo_reports` sowie der Bucket `logo-images` (nur PNG, höchstens 1 MiB); der Kopfkommentar
  nennt die neuen Objekte. Der Index-Request schickt `columns` schon als Liste, dort war nichts zu ändern.
- Tests: DESIRED mit genau diesen Werten, Anlegen samt Indizes gegen den Fake-Server, --check meldet jedes
  fehlende Objekt (51 statt 28) mit Exit 1, bestehende Tabellen und Buckets bleiben unberührt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:51:36 +00:00
ClaudeandClaude Opus 5.5 e1fb632cdd feat(i18n): Deutsch/Englisch, Sprachknopf und GitHub-Link in der Kopfzeile
- i18n-Kern im gettext-Stil (t/msg, Wörterbuch en.ts); Startsprache aus localStorage, sonst Browsersprache;
  <html lang> setzt auch das Inline-Skript in index.html. Ein Test prüft t()/msg() und en.ts in beide Richtungen.
- Kopfzeile: GitHub-Link und Sprachknopf (Zielsprache, Beschriftung und lang in der Zielsprache), Fußzeile aus mountFooter,
  Banner und Seitentitel übersetzt, statisches index.html englisch.
- Sprachwechsel ohne Neuladen (main.ts): Kopfzeile, Banner, Konto-Menü und Fußzeile neu aufgebaut, rerender() des Routers baut
  die Seite neu auf, der Editor-Store bleibt; der Appwrite-Client bekommt die Sprache.
- Kopfzeile bleibt in einer Zeile: einzeilig ab 1000 px (vorher 900), GitHub-Text ab 1230 px, auf schmalen Telefonen steht
  „Anmelden“ neben der Suche.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:33:15 +00:00
ClaudeandClaude Opus 5.5 9455013833 docs: Plan – SVG-Rastergröße aus der viewBox
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:04:53 +00:00
ClaudeandClaude Opus 5.5 562e760191 docs: Plan Community-Logos, Englisch und GitHub-Link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:03:45 +00:00
ClaudeandClaude Opus 5.5 067e428340 docs: Spec Community-Logos, Englisch und GitHub-Link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:54:44 +00:00
ClaudeandClaude Opus 5.5 8fb28eb1d7 feat(shell): Spenden-Link Buy Me a Coffee
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:30:30 +00:00
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
ClaudeandClaude Sonnet 5.5 87b71ec71f fix(community): Label-ID im Veröffentlichen-Dialog, Status nie in „lädt“, Nachprüfung robust
Die Überschrift des Dialogs hatte dieselbe ID wie das Titelfeld (publish-title), das Label des
Pflichtfelds zeigte damit auf die Überschrift; jetzt publish-heading. Schließt der Browser den Dialog
mitten im Senden, wird er wieder geöffnet statt als abgebrochen aufgelöst. Die Status-Abfrage der Seite
wird bei einer lokalen Änderung während der Anfrage wiederholt (nie in „lädt“ hängen), die Nachprüfung nach
offenem Ausgang ist eine eigene Abfrage. Der Löschen-Dialog nennt bei unbekanntem Status einen allgemeinen
Hinweis und sagt, dass sich eine Veröffentlichung nur auf der Community-Seite zurückziehen lässt.
publishCard heißt lokal publishWithCheck.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:56:21 +00:00
ClaudeandClaude Sonnet 5.5 f6b9ee61f4 feat(community): Veröffentlichen aus Meine Karten
Neuer Dialog (publish-dialog.ts) laut Spec § 6: Titel (Pflicht, höchstens 60), Herausgeber/Bank (40),
Tags (bis 5, je 20, Komma-getrennt), Anzeigename (30, leer = Anonym, nie vorbelegt) und die
Pflicht-Checkbox im Wortlaut der Spec. Prüfung vor dem Senden mit deutschen Texten, während des Sendens
lässt sich der Dialog nicht schließen; Fehler stehen im Dialog („Veröffentlichen fehlgeschlagen – …“).

Meine Karten zeigt je Kachel den Status aus myPublications (einmal je Seitenaufbau, danach lokal
nachgeführt): Veröffentlicht + Ansehen + Zurückziehen, Ausgeblendet (gemeldet) + Zurückziehen, sonst
Veröffentlichen. Bei 409 wird der Status neu geholt; bleibt der Ausgang offen (503/5xx/429/408), wird vor
der Fehlermeldung nachgesehen und bei Erfolg der Erfolgszustand gezeigt. Zurückziehen fragt nach, 404 gilt
als erledigt. Nicht angemeldet läuft über withLogin. Das Löschen einer veröffentlichten Karte weist darauf
hin, dass die Veröffentlichung bleibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:47:56 +00:00
ClaudeandClaude Sonnet 5.5 be440bc8e9 fix(community): gemeinsames formatDate, Rechte beim Ausführen, Melden-Dialog abgesichert
formatDate liegt jetzt in pages/format.ts und wird von Meine Karten und der
Detailseite genutzt; LOGIN_NEEDED kommt aus account/with-login.ts. Ausblenden,
Löschen und Zurückziehen prüfen Admin- bzw. Autor-Recht beim Ausführen (auch nach
der Rückfrage). Ein werfendes showModal im Melden-Dialog zeigt einen Hinweis statt
eines unbehandelten Fehlers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:35:44 +00:00
ClaudeandClaude Sonnet 5.5 3cec7151a6 feat(community): Detailseite, Melden, Admin-Aktionen, Vorlage
Detailseite /community/<id>: große Karte, Titel, Herausgeber, „von …“, Datum,
Tags, Herunterladen (aircard-<slug>.png), „Als Vorlage gestalten“ und Melden-Dialog
(fünf Gründe, Dank-, Doppelt- und Drosselungs-Text). Autor: Zurückziehen; Admin:
Ausblenden/Einblenden, Löschen, Meldungszahl und Gründe. Fehlende oder
ausgeblendete Karten zeigen Besuchern „nicht (mehr) verfügbar“, Autor und Admin
sehen sie mit dem Hinweis „Ausgeblendet“.

Editor: /gestalten?vorlage=<id> übernimmt das Galerie-Vollbild als „Nur Bild“,
setzt saved, savedConfig und cardName zurück, ersetzt die Adresse durch /gestalten
und fragt vorher bei ungespeicherter Arbeit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:28:00 +00:00
ClaudeandClaude Sonnet 5.5 c4a66f674f feat(home): kompakter Hero und Community-Galerie
Startseite "/" mit kompaktem Hero (grosse schwebende Karte rechts: neueste
sichtbare Community-Karte, sonst das eigene Beispielmotiv hero-beispiel.png)
und direkt darunter der Galerie #community, damit Ueberschrift und Oberkante der
ersten Kartenreihe bei 1440x900 und 390x844 im ersten Bildschirm stehen.

- mountHomePage(root, deps): Suche ?q= und Tag ?tag= aus der Adresse (aktiver
  Begriff mit "x"), Suchfeld der Kopfzeile per .value gefuellt, Mehr laden per
  Cursor, Leer-/Kein-Treffer-/Fehlertexte, Admin-Filter Alle/Gemeldet/
  Ausgeblendet (reagiert auf Anmelden/Abmelden), Anker nach dem Laden erneut
  angesprungen (router.scrollToHash exportiert), Object-URLs beim Verlassen frei.
- mountGalleryGrid: Kacheln mit Vorschau (Object-URL, max. 6 parallel), Titel,
  Herausgeber, bis 3 Tag-Knoepfe (Tag-Klick -> /?tag=<tag>#community), Link auf
  /community/<id>; Moderations-Marken nur in den Admin-Ansichten.
- Hero hoechstens 60vh auf breiten, hohen Fenstern; Schwebe-Animation nur ohne
  prefers-reduced-motion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 15:07:40 +00:00
ClaudeandClaude Sonnet 5.5 ae73dc4a0b fix(community): nur bei eindeutigem Ausgang aufräumen, Tag- und Kurzsuche, kleine Härtungen
- publish.ts: bei Fehlern der API werden die hochgeladenen Dateien nur bei eindeutiger Ablehnung
  (4xx außer 408/429, JWT-Fehler vor der Anfrage) gelöscht; bei offenem Ausgang (Netzfehler,
  Zeitlimit, 5xx, 429, Antwort ohne id) bleiben sie liegen, damit keine öffentliche Zeile auf
  gelöschte Bilder zeigt
- api.ts: ApiError.definitive und isDefinitiveFailure, Zeitlimit 15 s, gleichzeitige createJWT
  werden geteilt
- gallery.ts: Tag-Filter (contains auf tags), Kurzsuche unter 3 Zeichen per contains,
  loadGalleryImage prüft die Datei-ID
- geometry.ts: PREVIEW_WIDTH/PREVIEW_QUALITY an einer Stelle (Editor und Veröffentlichen)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:48:06 +00:00
ClaudeandClaude Sonnet 5.5 89c86fd578 feat(community): Datenschicht für Galerie, API und Veröffentlichen
- gallery.ts: Galerie aus Appwrite lesen (Abfragen für Liste, Suche, Cursor, Admin-Filter),
  getGalleryCard (404 und ungültige ID ergeben null), eigene Veröffentlichungen, Meldungen, Bilder
- api.ts: Aufrufe von aircard-api mit JWT (Cache je Nutzer, 10 Minuten, geleert bei Auth-Änderung,
  genau eine Wiederholung bei 401), ApiError mit code, Netz-/Proxy-Fehler als 503
- publish.ts: rendern, zwei Uploads in gallery-images, API; bei Fehler genau die zwei Dateien
  dieses Versuchs löschen; normalizeTags
- cards.ts: eigenständiges fetchFile(bucketId, fileId); CardsApi.fetchFile bleibt Hülle
- appwrite.ts: IDS für gallery, reports, gallery-images

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:39:30 +00:00
ClaudeandClaude Sonnet 5.5 7bbe0a9fe6 fix(api): Dateien bereits veröffentlichter oder gesperrter Karten nicht wiederverwenden
Veröffentlichen lehnt mit 403 "Diese Bilder sind bereits veröffentlicht oder
gesperrt." ab, wenn eine Datei kein read("any") trägt (ausgeblendet) oder von
einer Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet wird
(auch überkreuz, auch ausgeblendete Karten). Die Nutzungsprüfung liest die
Zeilen des Autors über den vorhandenen ownerId-Index seitenweise mit Cursor
(auf die Datei-ID-Spalten gibt es keinen Index) und läuft zusammen mit dem
Anlegen je Autor nacheinander. 92 Tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:27:29 +00:00
ClaudeandClaude Sonnet 5.5 cd55d4c891 fix(api): Log-Flut begrenzen und Appwrite-Fehler strenger behandeln
- Kein Zugriffsprotokoll je Anfrage mehr (abgewiesene Anfragen an das
  unauthentifizierte /api/melden loggten sonst unbegrenzt); nur noch
  Ereignis- und 5xx-Zeilen, höchstens 120 pro Minute mit Sammelzeile
- Compose: logging json-file, max-size 5m, max-file 2
- appwrite.js: 404 nur mit Typ row_not_found/storage_file_not_found als
  "fehlt", listRows ohne rows-Array wirft, redirect: error
- Appwrite-Fehler: 502 bei 0/429/5xx, sonst 500 "Interner Fehler."
- Veröffentlichen legt beide Dateien auf die sichtbaren Rechte fest
  (erst nach dem Anlegen der Zeile, Rollback bei Fehler)
- setFilePermissions mit Promise.allSettled, gemeinsamer safeToken-Helfer
- Tests: 85 (Log-Flut, Log-Budget, 404-Typen, Auth-Header, Konto-Antworten)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:24:05 +00:00
ClaudeandClaude Sonnet 5.5 dd1597862a feat(api): aircard-api für Veröffentlichen, Melden und Admin
Neuer abhängigkeitsfreier Node-22-Dienst in deploy/api/ (appwrite.js, app.js,
server.js), der alle Galerie-Schreibzugriffe mit dem Appwrite-Server-Key
ausführt: POST /api/veroeffentlichen, /zurueckziehen, /melden (Rate-Limit
10/h je Melder, IPv6 auf /64, Auto-Ausblenden ab 3), /admin/sichtbarkeit und
GET /api/gesundheit.

- JWT-Prüfung über GET /account (Header X-Aircard-Jwt), Admin = Label admin
- IDs per Muster geprüft, Pfadsegmente kodiert, "." und ".." abgelehnt
- deterministische Zeilen-IDs (Galerie, Meldung): Appwrite-409 ersetzt jede
  Doppelt-Prüfung; Sperre je Karte gegen verlorene Zählerstände
- Dateiprüfung: Bucket, delete("user:<ich>"), PNG/WebP, höchstens 5 MiB
- Appwrite-Aufrufe mit 10-s-Zeitlimit, Logs ohne Key, Salz, JWT und IPs
- Tests gegen einen Fake-Appwrite im Speicher (70 Tests), Compose-Service
  aircard-api (Router aircard-gallery-api) und README ergänzt

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:09:30 +00:00
ClaudeandClaude Sonnet 5.5 9e4f4ffacc feat(appwrite): Schema für Galerie, Meldungen und Galerie-Bilder
Setup-Skript um Tabellen gallery und reports (Spalten boolean/integer/enum,
Volltext- und Key-Indizes) sowie den Bucket gallery-images erweitert.
DESIRED auf tables/buckets umgebaut, Vergleich je Spaltentyp und Index,
Indizes erst nach available-Spalten. Console-Anleitung um Abschnitt
Community (JWT an, Label admin, Key aircard-api) ergänzt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:50:54 +00:00
ClaudeandClaude Sonnet 5.5 25ff496548 feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner
- Router: Routen home, gestalten, cards, community/<id>, reset; Umleitung
  /?karte=<id> nach /gestalten?karte=<id>; Titel je Route (titleFor);
  Hash-Ziele (/#community) scrollen den Anker ins Bild, nur-Hash-Wechsel
  bauen die Seite nicht neu auf.
- Kopfzeile: Navigation, Suche (Enter -> /?q=...#community), Spenden-Knopf,
  Hell/Dunkel-Schalter, Menue unter 900 px, Konto als Avatar-Kreis mit Menue.
- Theme: data-theme auf <html> (Skript im <head> gegen Aufblitzen), helle
  Tokens, danger-/warn-/ok-text-Tokens statt fester Farben.
- Spenden-Banner (schliessbar); ohne DONATE_URL bleiben Banner und Knopf weg.
- User.labels und isAdmin; Platzhalter fuer Startseite und Detailseite.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:39:09 +00:00
ClaudeandClaude Opus 5.5 1a64e12bc5 docs: Plan Community – Ergänzungen aus Plan-Review
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:23:02 +00:00
ClaudeandClaude Opus 5.5 21e1db1858 docs: Plan Community – Präzisierungen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:08:34 +00:00
ClaudeandClaude Opus 5.5 23463f7d29 docs: Plan Startseite & Community-Galerie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:08:21 +00:00
ClaudeandClaude Opus 5.5 970e68e7fc docs: Spec Startseite & Community-Galerie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:05:59 +00:00
ClaudeandClaude Opus 5.5 562fac05a5 fix(brand): Icons mit Versions-Parameter gegen alten Browser-Cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:24:04 +00:00
ClaudeandClaude Opus 5.5 e7acd79225 fix(brand): Haarstrich an der Naht des AirCard-IOS-Symbols
Die Maske hatte exakt die Kartenkante als Region und als weißes Feld; die
Kantenglättung wurde doppelt angewendet und ließ bei Vergrößerung einen
hellen Strich. Jetzt Standardregion der Maske und ein überstehendes weißes Feld.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:12:46 +00:00
ClaudeandClaude Opus 5.5 9c4e80ec9a fix(editor): Strg/Cmd/Alt-Tasten frei, 40-Megapixel-Grenze, Umbenennen-Test
- Vorschau: Tastenkürzel mit Strg/Cmd/Alt gehören dem Browser (Seitenzoom).
- decodeImage lehnt Bilder über 40 Megapixel nach dem Laden, vor dem
  Dekodieren ab (Meldung "Das Bild ist zu groß (max. 40 Megapixel).").
- Test, dass das Umbenennen-Feld den Namen markiert (tat der Code schon).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:11:06 +00:00
ClaudeandClaude Opus 5.5 144181f2c9 chore(deploy): nginx härten, deploy.sh mit Sperre, Zeitlimit und ohne Webhook-Geheimnis
nginx: server_tokens off, HSTS, exaktes /v1 liefert 404 (Regex-Block statt
^~ /v1/). deploy.sh: AIRCARD_WEBHOOK_TOKEN nicht an npm, Sperrverzeichnis
gegen parallele Läufe (verwaiste Sperren werden übernommen), timeout 900
für npm ci und Build. Neuer Test deploy/deploy.test.js gegen das echte Skript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:08:09 +00:00
ClaudeandClaude Opus 5.5 436ea4f4a5 docs: README und Appwrite-Console-Anleitung an den Ist-Stand angleichen
Endpoint-Standard ${origin}/v1, echte Projekt-ID im Traefik-Header, Console-
Einstellungen als Soll-Stand statt Tatsache, Test-Befehle je mit Verzeichnis;
Projekt-ID nicht mehr "aircard", Auth-/Dienste-Schritte als optionale Härtung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:02:38 +00:00
ClaudeandClaude Opus 5.5 0c51689dfd fix(cards): Sitzung, Entwurf und Datei-Aufräumen beim Öffnen und Speichern
- Gäste/abgelaufene Sitzung: Appwrite antwortet mit 404; withLogin und
  openSavedCard melden dann an (Dialog, einmal wiederholen) statt
  „nicht gefunden“ zu zeigen.
- Öffnen einer Karte fragt vor dem Verwerfen eines ungespeicherten Entwurfs
  nach; ein fehlgeschlagenes Öffnen setzt einen gefüllten Editor nicht zurück.
- Update ohne Bildwechsel schickt kein imageFileId mehr (veralteter Stand
  zeigte sonst auf eine gelöschte Datei).
- Uploads werden nur nach eindeutiger Ablehnung (4xx ohne 408/429) gelöscht,
  nicht bei Netzfehler, 5xx oder 429.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:01:28 +00:00
ClaudeandClaude Opus 5.5 09de06e797 fix(web): Editor-Layout ab 1024 px und Scroll-Reset beim Seitenwechsel
Editor: Seitenspalten geben nach (minmax), die Vorschau bleibt ab 1024 px
mindestens 360 px breit, einspaltig erst unter 1024 px. Router: navigate()
springt nach dem Rendern nach oben, Dokumenttitel je Route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:55:56 +00:00
ClaudeandClaude Opus 5.5 09832c2cf4 docs: README für AirCard-IOS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:04:05 +00:00
173 changed files with 41162 additions and 1055 deletions

No files matched your search

+99 -7
View File
@@ -1,15 +1,107 @@
# AirCard-IOS
Inoffizielles Hobbyprojekt: AirCard-IOS ist ein Editor, mit dem man eigene Kartenmotive für https://aircard-ios.org gestalten kann. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover.
Inoffizielles Hobbyprojekt, mit dem man eigene Kartenmotive für Apple Wallet unter https://aircard-ios.org gestalten kann. Bild hochladen, Vorlage und Community-Logo wählen, als PNG herunterladen – oder mit Konto speichern und in der Community-Galerie veröffentlichen. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover; Logos und Motive stammen von Nutzern, alle Marken gehören ihren Inhabern.
Die Website liegt in `web/` (statisches Vite + TypeScript, ohne UI-Framework). Die Design-Spec steht in `docs/superpowers/specs/2026-10-04-aircard-website-design.md`.
## Funktionen
## Entwicklung
- **Editor** (`/gestalten`): Bild hochladen, Vorlage, Ausschnitt, Details (Hell/Dunkel) und das Logo oben links (AirCard-IOS) wählen, als PNG herunterladen oder mit Konto speichern („Meine Karten“, `/karten`).
- **Community-Logos** unten rechts auf der Karte: Angemeldete laden Logos hoch (PNG, JPG, WebP oder SVG; der Browser schneidet sie zu, verkleinert sie auf höchstens 720 × 250 px und lädt ein PNG hoch), alle können sie wählen. Eingebaute Marken-Logos gibt es nicht. Je Konto und Logo gibt es ein Herz (nur angemeldet); die Herzen bestimmen die Sortierung „Beliebt“. Logos lassen sich melden (ab 3 Meldern aus verschiedenen Netzen werden sie ausgeblendet) und vom Hochlader löschen; Admins (Label `admin`) sehen zusätzlich „Gemeldet“ und „Ausgeblendet“.
- **Community-Galerie** (Startseite `/` und `/community/<id>`): Karten veröffentlichen, ansehen, melden und zurückziehen.
- **Deutsch und Englisch**: Die Sprache folgt der Browsersprache; der Sprachknopf in der Kopfzeile schaltet ohne Neuladen um und merkt sich die Wahl. Daneben steht ein GitHub-Link zum Original-Projekt [AirCard-iOS](https://github.com/Mak5er/AirCard-iOS).
## Aufbau
**`web/`** – Vite + TypeScript ohne Framework. Frontend-Module in `src/`:
- `card/` – Geometrie, Zuschnitt, Canvas-Rendering (keine DOM-Abhängigkeiten, einzeln testbar)
- `editor/` – UI (Vorlagen, Vorschau, Download, Speichern)
- `logos/` – Community-Logos: Lesen aus Appwrite, Auswahl mit Herzen und Infozeile (Melden, Löschen, Admin), Hochladen mit Aufbereitung im Browser (Zuschnitt, Verkleinern, PNG)
- `community/` – Galerie: Lesen, Veröffentlichen, Melden, Aufrufe des API-Dienstes
- `account/` – Appwrite-Client, Auth, Kartenverwaltung
- `i18n/` – Deutsch und Englisch: `t('Deutscher Text')` und `msg('…')`, Wörterbuch Deutsch → Englisch in `en.ts` (ein Test verlangt für jeden Text einen Eintrag)
- `pages/` – Routen (`/`, `/gestalten`, `/karten`, `/community/<id>`, `/passwort-neu` via History-API)
- `shell/` – Kopfzeile (GitHub-Link, Sprachknopf, Hell/Dunkel), Fußzeile, Spenden-Banner
**`deploy/`** – Webhook-Router (Port 8087), Build-Skript (`deploy.sh`), nginx-Config, Traefik-Route für Appwrite.
**`docs/`** – Spec, Plan, Appwrite-Konfiguration.
## Lokal entwickeln
```sh
cd web
npm install
npm run dev # Entwicklungsserver
npm test # Tests (Vitest)
npm run build # Typprüfung + Produktions-Build nach web/dist
npm ci
npm run dev # Entwicklungsserver http://localhost:5173
```
Login und Speichern brauchen die Appwrite-API. Ohne Einstellung gilt als Endpoint `${location.origin}/v1` (der Entwicklungsserver hat keinen Proxy dafür, es würde also der Vite-Server selbst gefragt). Für die lokale Entwicklung deshalb in `web/.env.local` (steht über `.env*` in `.gitignore`) den Endpoint setzen:
```sh
VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
```
Die Projekt-ID ist als Standard im Code hinterlegt (`6ac2130a00289343d6d5`, `VITE_APPWRITE_PROJECT_ID` überschreibt sie). Damit Appwrite den lokalen Host akzeptiert, muss die Web-Plattform `localhost` in der Appwrite-Console eingetragen sein (Projekt → Overview → Integrations → Platforms). Der Produktions-Build bekommt `VITE_APPWRITE_ENDPOINT=https://aircard-ios.org/v1` aus der Container-Umgebung (`deploy/compose-snippet.yml`).
## Tests
Im Frontend (`web/`):
```sh
cd web
npx vitest run # Unit-Tests (alle Module unter src/)
npx tsc --noEmit # TypeScript-Prüfung, auch der Tests
npm run build # Produktions-Build; die Prüfung davor nutzt tsconfig.build.json (ohne Tests)
```
Im Repo-Wurzelverzeichnis:
```sh
node --test deploy/webhook-server.test.js # Webhook-Server (deploy.sh wird dort durch einen Stub ersetzt)
node --test deploy/deploy.test.js # deploy.sh (echtes Skript, lokaler git-Remote, npm-Stub)
node --test "deploy/api/*.test.js" # API-Dienst (Glob in Anführungszeichen)
node --test web/scripts/appwrite-setup.test.mjs # Appwrite-Schema-Skript
```
## Deploy
**Automatisch:** Push auf `main` → Gitea-Webhook → Container `webklar-aircard-build` führt `npm ci` und `npm run build` (Typprüfung ohne Tests, dann `vite build`) aus → Ergebnis nach `out/` → nginx `webklar-aircard` liefert.
**Reihenfolge, wenn eine Änderung auch Schema und API braucht** (wie die Community-Logos): erst das Schema anlegen, dann die API-Dateien ersetzen und neu starten, zuletzt `main` pushen. Die Schritte stehen in [deploy/README.md](deploy/README.md), Abschnitt „Reihenfolge bei Änderungen an Schema, API und Seite“.
**Manuell:**
```sh
docker exec webklar-aircard-build sh /deploy.sh
```
Starten nur mit:
```sh
docker compose up -d aircard-build aircard aircard-api
```
`aircard-api` ist der kleine API-Dienst der Community-Galerie, der Community-Logos und der Benutzernamen (`deploy/api/`, Route `aircard-ios.org/api`, siehe `deploy/api/README.md`).
Die Appwrite-API wird über `/home/webklar/apps/traefik/dynamic/aircard.yml` geroutet (Vorlage: `deploy/traefik/aircard.yml`): Requests auf `aircard-ios.org/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` gehen an den Appwrite-Container. Ohne diesen Header antwortet der nginx der Seite mit 404.
## Appwrite
Projekt „AirCard“ auf `appwrite.webklar.com`:
- Projekt-ID (öffentlich, von Appwrite generiert): `6ac2130a00289343d6d5`. Sie steht in `deploy/traefik/aircard.yml`, in `/home/webklar/apps/.env` (`AIRCARD_APPWRITE_PROJECT_ID`) und als Standard in `web/src/account/appwrite.ts`.
- Datenbank: `aircard`; Tabellen: `cards` (private Karten), `gallery` und `reports` (Community-Galerie, Meldungen), `logos`, `logo_likes` und `logo_reports` (Community-Logos, Herzen, Meldungen) und `profiles` (Benutzernamen, eine Zeile je Konto, Unique-Index auf dem klein geschriebenen Namen); Buckets: `card-images`, `gallery-images` und `logo-images` (das Skript unten legt sie an und prüft sie)
- Console-Einstellungen laut `docs/appwrite-console.md` (Soll-Stand, nicht maschinell geprüft; `--check` prüft nur das Schema): Auth mit E-Mail + Passwort, Sitzungsdauer 30 Tage, max. 5 Sitzungen pro Nutzer, Nutzerlimit 2000, Web-Plattformen `aircard-ios.org` und `localhost`. Schritte 3 und 4 dort (Auth-Härtung, Dienste abschalten) sind optional und am Live-Projekt unbestätigt.
**Schema prüfen:**
```sh
node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check
```
**Schema erstmalig anlegen:**
```sh
node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs
```
Umgebungsvariablen (in `/home/webklar/apps/.env`):
- `AIRCARD_APPWRITE_PROJECT_ID` – Projekt-ID (idempotent im Skript lesbar)
- `AIRCARD_APPWRITE_API_KEY` – API-Key `aircard-provision` (nur für Setup)
## Hinweis
Inoffizielles Hobbyprojekt. Alle Marken gehören ihren Inhabern.
+47 -9
View File
@@ -10,7 +10,8 @@ Die Seite wird auf dem Webklar-Server aus dem Gitea-Repo `knso/AirCardIOS` gebau
| `deploy/webhook-server.js` | `aircard-ios.org/webhook-server.js` | nimmt den Gitea-Webhook an, startet `deploy.sh` nacheinander, pollt alle 15 Minuten |
| `deploy/nginx/aircard-ios.org.conf` | `aircard-ios.org/nginx/aircard-ios.org.conf` | nginx-Konfiguration der Seite |
| `deploy/traefik/aircard.yml` | `traefik/dynamic/aircard.yml` | Route `/v1` mit Header `X-Appwrite-Project: 6ac2130a00289343d6d5` an Appwrite |
| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build` und `aircard` |
| `deploy/api/appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js` | `aircard-ios.org/api/` (nur diese sechs Dateien) | API-Dienst `aircard-api` der Community-Galerie, der Community-Logos und der Benutzernamen, siehe [api/README.md](api/README.md) |
| `deploy/compose-snippet.yml` | `docker-compose.yml` (nach dem eship-Block) | Dienste `aircard-build`, `aircard` und `aircard-api` |
Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repos, Arbeitsordner des Build-Containers), `out/` (gebaute Seite, von nginx ausgeliefert), `dev/` (Arbeitskopie für die Entwicklung, nicht gemountet).
@@ -20,10 +21,11 @@ Weitere Ordner unter `aircard-ios.org/` (nicht im Repo): `build/` (Klon des Repo
APP=/home/webklar/apps
SRC=$APP/aircard-ios.org/dev/deploy
mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx
mkdir -p $APP/aircard-ios.org/build $APP/aircard-ios.org/out $APP/aircard-ios.org/nginx $APP/aircard-ios.org/api
cp $SRC/deploy.sh $APP/aircard-ios.org/deploy.sh && chmod +x $APP/aircard-ios.org/deploy.sh
cp $SRC/webhook-server.js $APP/aircard-ios.org/webhook-server.js
cp $SRC/nginx/aircard-ios.org.conf $APP/aircard-ios.org/nginx/aircard-ios.org.conf
cp $SRC/api/appwrite.js $SRC/api/common.js $SRC/api/app.js $SRC/api/logos.js $SRC/api/profiles.js $SRC/api/server.js $APP/aircard-ios.org/api/
# Compose: Backup, dann den Inhalt von compose-snippet.yml unter `services:` direkt nach dem eship-Block einfügen
cp $APP/docker-compose.yml $APP/docker-compose.yml.bak-2026-10-04-aircard
@@ -33,17 +35,49 @@ cd $APP && docker compose config -q; echo $? # 0; einzige erlaubte Ausgabe: W
cp $SRC/traefik/aircard.yml $APP/traefik/dynamic/aircard.yml.tmp && mv $APP/traefik/dynamic/aircard.yml.tmp $APP/traefik/dynamic/aircard.yml
```
Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten. Beide Werte gehören nie in Logs, Chats oder Commits.
Die `.env` neben der `docker-compose.yml` muss `GITEA_DEPLOY_TOKEN` (Lesezugriff auf `knso/AirCardIOS`) und `GITEA_DEPLOY_WEBHOOK_SECRET` enthalten, für den API-Dienst außerdem `AIRCARD_APPWRITE_PROJECT_ID`, `AIRCARD_API_KEY` und `AIRCARD_API_SALT`. Diese Werte gehören nie in Logs, Chats oder Commits. Das Salz entsteht, ohne dass der Wert auf dem Bildschirm erscheint:
```sh
printf 'AIRCARD_API_SALT=%s\n' "$(openssl rand -hex 32)" >> /home/webklar/apps/.env
```
Den Key `AIRCARD_API_KEY` legt Kenso nach [docs/appwrite-console.md](../docs/appwrite-console.md) (Abschnitt 7.3) an (Scopes `documents.read`, `documents.write`, `files.read`, `files.write`, so beschriftet die Console die Zeilen-Scopes) und trägt ihn mit dem verdeckten `read -rs`-Befehl ein (leere Eingabe schreibt nichts).
## Starten
```sh
cd /home/webklar/apps && docker compose up -d aircard-build aircard
cd /home/webklar/apps && docker compose up -d aircard-build aircard aircard-api
```
Nur diese beiden Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`).
Nur diese drei Dienste starten, nie ein nacktes `docker compose up -d` (das würde nicht laufende Dienste wie eship-build wieder anlegen). Der Build-Container installiert beim Start `git`, startet den Webhook-Server und baut sofort einmal (`initial`). Den Fortschritt zeigt `docker logs -f webklar-aircard-build`; am Ende steht `Deployment abgeschlossen` (oder eine Zeile mit `ERROR:`).
Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen.
Ohne `AIRCARD_WEBHOOK_TOKEN` (also ohne `GITEA_DEPLOY_WEBHOOK_SECRET` in der `.env`) beendet sich der Webhook-Server mit Exit-Code 1, statt Anfragen ungeprüft durchzulassen. Ebenso beendet sich `aircard-api` mit Exit-Code 1, wenn `AIRCARD_API_KEY`, `AIRCARD_API_SALT` oder `AIRCARD_APPWRITE_PROJECT_ID` fehlen (die Meldung nennt nur den Namen der Variable).
## API-Dienst `aircard-api`
Kleiner Node-Dienst ohne Abhängigkeiten (`deploy/api/`), Container `webklar-aircard-api`. Er führt alle Schreibzugriffe der Community-Galerie, der Community-Logos und der Benutzernamen mit dem Appwrite-Server-Key aus (Veröffentlichen mit Bildmaß-Prüfung und höchstens 20 Karten je Konto, Zurückziehen, Melden mit Auto-Ausblenden ab 3 Meldern aus verschiedenen Netzen, Admin-Sichtbarkeit; bei den Logos Hochladen mit höchstens 30 Logos je Konto, Herzen, Melden, Löschen, Admin-Sichtbarkeit; Benutzernamen anlegen und ändern, Veröffentlichen und Hochladen verlangen einen). Endpunkte, Statuscodes, Fehlercodes und Betrieb stehen in [api/README.md](api/README.md).
- **Route**: Traefik-Label am Dienst, Router `aircard-gallery-api` (``Host(`aircard-ios.org`) && PathPrefix(`/api`)``, Priorität 200, Port 8088). Der Name ist absichtlich nicht `aircard-api`: so heißt schon die Appwrite-Route (`/v1`) in `traefik/dynamic/aircard.yml`.
- **Prüfen** nach dem Start: `curl -s https://aircard-ios.org/api/gesundheit` liefert `{"ok":true}`; `docker logs webklar-aircard-api` zeigt `listening on :8088`. Läuft der Dienst nicht, fehlt auch sein Traefik-Router: nginx antwortet dann auf `/api/*` mit `503` und `{"error":"Der Dienst ist gerade nicht erreichbar."}` (Content-Type `application/json`), nie mit der App.
- **Ändern**: Dateien mit `cp` nach `aircard-ios.org/api/` ersetzen (alle sechs: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`; `app.js` lädt `common.js`, `logos.js` und `profiles.js`, `logos.js` lädt `profiles.js`, ohne sie startet der Dienst nicht), dann `cd /home/webklar/apps && docker compose restart aircard-api`. Nach einer Änderung der `.env` (Key, Salz) reicht `restart` nicht: `docker compose up -d aircard-api`.
- **Logos brauchen das Schema**: Die Logo-Endpunkte schreiben in die Tabellen `logos`, `logo_likes`, `logo_reports` und den Bucket `logo-images`. Das Schema-Skript `web/scripts/appwrite-setup.mjs` legt sie an (zuerst mit `--check` ansehen, was fehlt, siehe [../README.md](../README.md)); das gehört vor den Neustart des API-Dienstes mit den neuen Dateien. Ohne die Tabellen antworten die Logo-Endpunkte mit `500` „Interner Fehler.“, die Karten-Endpunkte laufen weiter. Die ganze Reihenfolge steht im nächsten Abschnitt.
- **Benutzernamen brauchen das Schema**: `POST /api/profil` schreibt in die Tabelle `profiles` (Zeilen-ID = Konto-ID, Unique-Index `username_key` auf `usernameKey`); dieselbe Tabelle fragen `/api/veroeffentlichen` und `/api/logos/hochladen` als Erstes ab (ohne Zeile für das Konto: `403` `usernameRequired`). Das Schema-Skript legt sie an, dieselben Scopes des Keys `aircard-api` genügen, in der Console ist nichts zu tun. Ohne die Tabelle antworten Hochladen, Veröffentlichen und `/api/profil` mit `500` „Interner Fehler.“, nicht mit `403`. Zwischen dem Neustart des Dienstes und dem Livegang der neuen Seite kann die alte Seite Konten ohne Benutzernamen nicht mehr hochladen oder veröffentlichen lassen (sie zeigt den Text des Dienstes, „Bitte wähle zuerst einen Benutzernamen.“): die neue Seite direkt danach live bringen.
- **Log-Grenze**: Der Docker-Daemon des Servers hat keine eigenen Log-Optionen, deshalb steht im Service `logging: json-file` mit `max-size: 5m` und `max-file: 2` (höchstens 10 MB). Der Dienst selbst schreibt kein Zugriffsprotokoll und höchstens 120 Zeilen pro Minute.
- **Kein Port nach außen**: der Dienst vertraut dem Header `X-Real-Ip` (setzt Traefik) für Rate-Limit und Melder-Hash, deshalb nie `ports:` ergänzen. Im Docker-Netz `webklar-network` erreichen ihn andere Container trotzdem und könnten `X-Real-Ip` setzen: akzeptiertes Risiko, siehe [api/README.md](api/README.md) (Client-IP). Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
## Reihenfolge bei Änderungen an Schema, API und Seite
Bringt eine Änderung neue Tabellen, neue Endpunkte und eine Seite mit, die beides braucht (wie die Community-Logos), gilt immer diese Reihenfolge. Die Seite geht zuletzt live: sonst ruft sie Endpunkte auf, die es noch nicht gibt, und ein Push von `main` baut sie sofort.
1. **Schema**: `node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check` zeigt, was fehlt (`FEHLT: …`). Ohne `--check` legt das Skript es an. Danach noch einmal mit `--check`: die Ausgabe muss mit `OK: Schema vollständig` enden (Exit-Code 0). Für die Logos sind das die Tabellen `logos`, `logo_likes`, `logo_reports` und der Bucket `logo-images`, für die Benutzernamen die Tabelle `profiles` mit dem Unique-Index `username_key`.
2. **API**: alle sechs Dateien (`appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`) mit `cp` nach `aircard-ios.org/api/` kopieren, dann `cd /home/webklar/apps && docker compose restart aircard-api`. `docker logs webklar-aircard-api` zeigt `listening on :8088`. Prüfen, dass die Logo-Endpunkte da sind: ein `POST` auf `/api/logos/herz` ohne JWT muss mit `401` und `"code":"unauthorized"` antworten (der Header `Content-Type: application/json` gehört dazu, sonst kommt `415`):
```sh
curl -s -i -X POST -H 'Content-Type: application/json' -d '{}' https://aircard-ios.org/api/logos/herz
```
Antwortet er mit `404`, läuft noch die alte API (Dateien oder Neustart prüfen); dasselbe gilt für `POST /api/profil` (ohne JWT ebenfalls `401`). Das Schema prüft dieser Aufruf nicht, denn die Anmeldung kommt vor jedem Zugriff auf Appwrite: dafür ist Schritt 1 da.
3. **Seite**: erst jetzt `main` pushen (Gitea-Webhook, siehe unten). Der Build führt `npm ci` und `npm run build` aus: Typprüfung ohne Testdateien (`web/tsconfig.build.json`), dann `vite build`.
## Manuell deployen
@@ -53,6 +87,8 @@ docker exec webklar-aircard-build sh /deploy.sh
Ohne `DEPLOY_TRIGGER` baut das Skript immer neu, auch wenn sich der Commit nicht geändert hat.
Es läuft immer nur ein Deployment: Ist schon eines aktiv (Sperrverzeichnis `build/.git/aircard-deploy.lock`), schreibt das Skript „Deployment läuft bereits“ und endet mit Exit-Code 0. Eine verwaiste Sperre (Prozess weg oder älter als 60 Minuten) übernimmt der nächste Lauf selbst. `npm ci` und der Build brechen nach 15 Minuten ab (`timeout 900`).
## Gitea-Webhook
Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
@@ -77,12 +113,13 @@ Repo `knso/AirCardIOS` → Einstellungen → Webhooks → Gitea:
- **Gemerkter Commit**: `build/.git/aircard-last-deployed-commit` enthält den zuletzt **versuchten** Commit, geschrieben vor dem Build. Ein kaputter Build wird deshalb nicht alle 15 Minuten wiederholt; die alte Seite bleibt online, bis ein neuer Commit oder ein Webhook-/manueller Lauf kommt.
- **Fehler**: Ein fehlgeschlagener Lauf wird mit `[webhook] … deployment failed` protokolliert und beendet den Server nicht. Fehlerzeilen des Skripts beginnen mit `ERROR:`.
- **Build-Variablen**: `VITE_APPWRITE_ENDPOINT` und `VITE_APPWRITE_PROJECT_ID` kommen aus der Container-Umgebung (compose); `AIRCARD_APPWRITE_PROJECT_ID` wird aus `/home/webklar/apps/.env` gelesen. Vite liest sie direkt.
- **.env-Variablen**: `AIRCARD_APPWRITE_PROJECT_ID` (Projekt-ID `6ac2130a00289343d6d5`) und `AIRCARD_APPWRITE_API_KEY` (für das Setup-Skript) müssen in `/home/webklar/apps/.env` eingetragen sein.
- **.env-Variablen**: `AIRCARD_APPWRITE_PROJECT_ID` (Projekt-ID `6ac2130a00289343d6d5`) und `AIRCARD_APPWRITE_API_KEY` (für das Setup-Skript) müssen in `/home/webklar/apps/.env` eingetragen sein; für `aircard-api` zusätzlich `AIRCARD_API_KEY` (Key `aircard-api`) und `AIRCARD_API_SALT`.
## Änderungen später
- `deploy.sh` ändern: Datei auf dem Server mit `cp` überschreiben (nicht `mv`, sonst zeigt der Datei-Mount auf die alte Datei). Gilt beim nächsten Lauf.
- `webhook-server.js` ändern: ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-build`.
- `deploy/api/*.js` ändern (die sechs Dateien des Dienstes, nicht die Tests und nicht `fake-appwrite.js`): ebenso mit `cp` ersetzen, dann `cd /home/webklar/apps && docker compose restart aircard-api`.
- nginx-Konfiguration ändern: mit `cp` ersetzen, dann `docker exec webklar-aircard nginx -t && docker exec webklar-aircard nginx -s reload`.
- Traefik-Route ändern: Datei atomar ersetzen wie oben; Löschen der Datei entfernt die Route wieder.
@@ -93,7 +130,8 @@ Solange `dig +short www.aircard-ios.org @8.8.8.8` nichts liefert, gibt es nur di
## Tests
```sh
node --test deploy/webhook-server.test.js
node --test deploy/webhook-server.test.js deploy/deploy.test.js
node --test "deploy/api/*.test.js"
```
Die Tests brauchen nur Node und starten die Server auf Port 0.
Die Tests brauchen nur Node und starten die Server auf Port 0. Die API-Tests laufen gegen einen Fake-Appwrite im Speicher (`deploy/api/fake-appwrite.js`); das echte Appwrite wird nie angesprochen. Das Glob muss in Anführungszeichen stehen.
+94
View File
@@ -0,0 +1,94 @@
# aircard-api
Kleiner HTTP-Dienst (Node 22, **keine Abhängigkeiten**) für die Community-Galerie, die Community-Logos und die Benutzernamen von aircard-ios.org. Er führt alle Schreibzugriffe mit dem Appwrite-Server-Key aus: bei den Karten Veröffentlichen, Zurückziehen, Melden (Auto-Ausblenden ab 3 verschiedenen Meldern) und die Admin-Sichtbarkeit, bei den Logos Hochladen, Herz setzen, Melden, Löschen und die Admin-Sichtbarkeit, bei den Benutzernamen Anlegen und Ändern. Hochladen und Veröffentlichen gehen nur mit Benutzernamen. Die Website liest Galerie, Logos und Benutzernamen direkt aus Appwrite; nur das Schreiben läuft hierüber.
Spec: `docs/superpowers/specs/2026-10-04-aircard-community-design.md` §7, §8, §11 (Karten) und `docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md` §6, §8, §9 (Logos) und `docs/superpowers/specs/2026-10-05-aircard-benutzernamen-design.md` §2–4 (Benutzernamen). Deployment: [../README.md](../README.md).
## Dateien
| Datei | Aufgabe |
|---|---|
| `server.js` | Start: liest die Umgebung, `http.createServer`, Zeitlimits, sauberes Beenden |
| `app.js` | HTTP-Anbindung (Routing, Body, Anmeldung, Rate-Limit, Log, Fehlerantworten) und die Karten-Endpunkte (`createApp`) |
| `logos.js` | die fünf Logo-Endpunkte (`createLogoRoutes`); bekommt Anmeldung, Sperre je Schlüssel, Rate-Limit und Log von `app.js` |
| `profiles.js` | der Benutzernamen-Endpunkt (`createProfileRoutes`) und `hasProfile`, das `app.js` und `logos.js` vor dem Veröffentlichen und Hochladen fragen; bekommt Anmeldung, Sperre je Schlüssel und Log von `app.js` |
| `common.js` | gemeinsame Helfer: `MSG` (alle Fehlertexte, Schlüssel = `code`), `HttpError`, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Namensmuster und Sperrliste der Benutzernamen |
| `appwrite.js` | REST-Client mit Server-Key (`createAppwrite`, auch `countRows`) |
| `*.test.js`, `fake-appwrite.js` | Tests und Fake-Appwrite im Speicher (gehören nicht auf den Server) |
Auf den Server (`/home/webklar/apps/aircard-ios.org/api/`) kommen nur die sechs Dateien `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js` und `server.js`.
## Umgebung
| Variable | Standard | Bedeutung |
|---|---|---|
| `AIRCARD_API_KEY` | – (Pflicht) | Appwrite-Server-Key `aircard-api`. Scopes (so beschriftet die Console): `documents.read`, `documents.write`, `files.read`, `files.write`; `documents.*` entspricht den `rows.*` der API (TablesDB) |
| `AIRCARD_API_SALT` | – (Pflicht) | Salz für die Melder-Hashes, 32 Byte hex |
| `AIRCARD_APPWRITE_PROJECT_ID` | – (Pflicht) | Projekt-ID (`6ac2130a00289343d6d5`) |
| `AIRCARD_APPWRITE_ENDPOINT` | `http://webklar-appwrite/v1` | interne Appwrite-Adresse im Docker-Netz |
| `AIRCARD_API_PORT` | `8088` | Port des Dienstes |
Fehlt eine Pflichtvariable, nennt die Meldung nur ihren Namen und der Prozess endet mit Exit-Code 1. Wer das Salz ändert, macht alle bisherigen Melder-Hashes ungültig: dieselben Personen können dieselbe Karte dann nochmal melden (die Karten selbst bleiben unberührt).
## Endpunkte
Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>", "code": "<Schlüssel>"}` (Codes siehe unten). Das Nutzer-JWT kommt im Header `X-Aircard-Jwt` und wird bei Appwrite (`GET /account`) geprüft; Admin = Label `admin`. Alles andere (auch falsche Methoden) ist `404`; `POST` nur mit `Content-Type: application/json` (sonst `415`), Body höchstens 8 KB (sonst `413`).
| Endpunkt | Auth | Antworten |
|---|---|---|
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | `201 {id}` · `401` · `403` Konto ohne Benutzernamen („Bitte wähle zuerst einen Benutzernamen.”, vor allen anderen Prüfungen) · `400` Validierung, Datei fehlt („Bild nicht gefunden.”), falscher Typ oder > 5 MiB, falsche Bildmaße („Das Bild hat nicht das Kartenformat.”) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.”), Datei schon veröffentlicht oder gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.”), Karte nicht meine („Diese Karte gehört nicht zu deinem Konto.”) · `409` „Diese Karte ist schon veröffentlicht.” · `429` „Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.” |
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
| `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) |
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
| `POST /api/logos/hochladen` `{name, mono?, fileId}` | Nutzer-JWT | `201 {id}` (Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID) · `401` · `403` Konto ohne Benutzernamen (wie beim Veröffentlichen) · `400` Validierung, Datei fehlt („Bild nicht gefunden.”), kein PNG („Das Logo muss eine PNG-Datei sein.”), größer als 1 MiB („Das Logo ist größer als 1 MB.”), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).”) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.”), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.”) · `409` „Diese Datei wird schon für ein Logo verwendet.” · `429` „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.” |
| `POST /api/logos/herz` `{id, herz}` | Nutzer-JWT | `200 {herz, likeCount}` · `401` · `400` ID ungültig oder `herz` kein Boolean · `404` fehlt oder ausgeblendet („Dieses Logo ist nicht (mehr) verfügbar.“) |
| `POST /api/logos/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`; dasselbe Limit wie `/api/melden`) |
| `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
| `POST /api/admin/logo-sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
| `POST /api/profil` `{username}` | Nutzer-JWT | `200 {username}` (Name angelegt oder geändert) · `401` · `400` Muster („Der Benutzername muss 3–20 Zeichen lang sein, …“) oder gesperrter Name („Dieser Benutzername ist nicht erlaubt.“) · `409` „Dieser Benutzername ist schon vergeben.“ |
| `GET /api/gesundheit` | keine | `200 {ok:true}` |
Ist Appwrite nicht erreichbar, überlastet (429) oder antwortet mit einem Serverfehler (5xx), antwortet der Dienst `502` „Der Dienst ist gerade nicht erreichbar.“ (`code` `unavailable`). Jede andere unerwartete Appwrite-Antwort (falscher Key, fehlende Tabelle oder Datenbank, fehlender Bucket, unbekannte Route, ungültige Antwort) und jeder unerwartete Fehler ist `500` „Interner Fehler.“ (`code` `internal`). Details stehen nur im Log, ohne Secrets. Ein 404 von Appwrite gilt nur mit dem Typ `row_not_found` bzw. `storage_file_not_found` als „fehlt“; ein fehlendes Konfigurationsobjekt wird so nie zu „Karte nicht verfügbar“ oder „Logo nicht verfügbar“.
### Fehlercodes
Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel in `MSG` (`common.js`), dessen Text genau dem `error` entspricht. Die Texte in `MSG` sind paarweise verschieden (ein Test prüft das), der Code ist also eindeutig. Dynamische Prüftexte ohne festen Schlüssel („Der Titel ist zu lang (höchstens 60 Zeichen).“, „Ungültige Bild-ID.“, „Bitte gib höchstens 5 Tags an.“, „Der Name ist zu lang (höchstens 40 Zeichen).“, …) haben bei Status `400` den `code` `badRequest`, sonst `error` (kommt derzeit nicht vor). Der `code` ist die stabile Schnittstelle für die Clients (z. B. um Meldungen zu übersetzen); `MSG` ist dafür ein schlichtes Objekt aus Texten, das `common.js` unverändert exportiert.
| Status | `code` · Text |
|---|---|
| `400` | `badRequest` „Ungültige Anfrage.“ (auch jeder dynamische Prüftext) · `badCardId` „Ungültige Karten-ID.“ · `badLogoId` „Ungültige Logo-ID.“ · `badReason` „Bitte wähle einen gültigen Grund.“ · `badVisibility` „Ungültige Angabe zur Sichtbarkeit.“ · `badHeart` „Ungültige Angabe zum Herz.“ · `badMono` „Ungültige Angabe zu „einfarbig“.“ · `logoNameMissing` „Bitte gib einen Namen an.“ · `fileMissing` „Bild nicht gefunden.“ · `fileType` „Das Bild muss eine PNG- oder WebP-Datei sein.“ · `fileSize` „Das Bild ist größer als 5 MB.“ · `imageFormat` „Das Bild hat nicht das Kartenformat.“ · `logoFileType` „Das Logo muss eine PNG-Datei sein.“ · `logoFileSize` „Das Logo ist größer als 1 MB.“ · `logoFormat` „Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“ · `usernameInvalid` „Der Benutzername muss 3–20 Zeichen lang sein, …“ · `usernameReserved` „Dieser Benutzername ist nicht erlaubt.“ |
| `401` | `unauthorized` „Bitte melde dich an.“ |
| `403` | `forbidden` „Dazu hast du keine Berechtigung.“ · `adminOnly` „Nur für Admins.“ · `foreignFile` „Diese Datei gehört dir nicht.“ · `filesTaken` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ · `notYourCard` „Diese Karte gehört nicht zu deinem Konto.“ · `usernameRequired` „Bitte wähle zuerst einen Benutzernamen.“ |
| `404` | `notFound` „Nicht gefunden.“ · `cardGone` „Diese Karte ist nicht (mehr) verfügbar.“ · `logoGone` „Dieses Logo ist nicht (mehr) verfügbar.“ |
| `409` | `alreadyPublished` „Diese Karte ist schon veröffentlicht.“ · `logoTaken` „Diese Datei wird schon für ein Logo verwendet.“ · `usernameTaken` „Dieser Benutzername ist schon vergeben.“ |
| `413` `415` | `tooLarge` „Die Anfrage ist zu groß.“ · `jsonOnly` „Nur application/json wird unterstützt.“ |
| `429` | `tooManyPublications` „Du hast schon 20 Karten veröffentlicht. …“ · `tooManyLogos` „Du hast schon 30 Logos hochgeladen. …“ · `tooManyReports` „Zu viele Meldungen – bitte später nochmal.“ |
| `500` `502` | `internal` „Interner Fehler.“ · `unavailable` „Der Dienst ist gerade nicht erreichbar.“ |
### Regeln im Einzelnen
- **IDs**: Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen (nur Zeichenketten), sonst `400`. `appwrite.js` kodiert zusätzlich jedes Pfadsegment und lehnt `.` und `..` ab.
- **Felder**: Titel 1–60, Herausgeber ≤ 40 Zeichen (getrimmt). Nicht erlaubt in Titel, Herausgeber und Tags: Steuerzeichen (U+0000–001F, U+007F–009F), Nullbreiten- und Bidi-Zeichen (U+200B–200F, U+202A–202E, U+2060–206F) und das BOM (U+FEFF); sie machen Titel unsichtbar oder ordnen Text um. Dasselbe Muster prüft der Veröffentlichen-Dialog. `tags` ist ein `string[]`: getrimmt, klein, Leere und Doppelte entfernt, höchstens 5 mit je höchstens 20 Zeichen, kein Komma. Gespeichert wird die Spalte `tags` als `,tag1,tag2,` (Komma an beiden Enden, leer bei keinen Tags): der Tag-Filter der Website sucht `,<tag>,` und trifft so nur ganze Tags. Bild und Vorschau müssen verschiedene Dateien sein.
- **Quellkarte**: `sourceCardId` muss eine Zeile der Tabelle `cards` sein, deren `$permissions` `read("user:<ich>")` enthalten, sonst `403` „Diese Karte gehört nicht zu deinem Konto.“ (auch wenn es die Zeile nicht gibt).
- **Limit**: höchstens 20 Veröffentlichungen je Konto (Zeilen mit `ownerId` = ich, auch ausgeblendete); ab da `429` mit dem Text aus der Tabelle. Zurückziehen gibt einen Platz frei. Das Limit liest die Zeilen des Autors über `ownerId` (dort gibt es einen Index) mit `limit(20)`.
- **Dateien** (beide): liegen im Bucket `gallery-images`, tragen `delete("user:<ich>")`, sind `image/png` oder `image/webp` und höchstens 5 MiB. Eine Datei, die es nicht gibt, ergibt `400`; eine fremde `403` (Typ und Größe fremder Dateien nennt die API nicht). **Bildmaße**: Die API liest die ersten 64 Bytes beider Dateien (`GET …/files/<id>/view` mit Server-Key, der Rest der Übertragung wird abgebrochen) und liest PNG-IHDR bzw. WebP (`VP8 `, `VP8L`, `VP8X`) aus. Das Bild muss genau 1536 × 969 sein, die Vorschau 480 breit und 302–304 hoch (die Karte ist 1536 × 969, die Vorschau 480 × 303), sonst `400` „Das Bild hat nicht das Kartenformat.“ Das ist Defense in Depth gegen Dekompressionsbomben; die maßgebliche Prüfung beim Anzeigen macht der Client (Pixelgrenze 4 Megapixel). **Keine Wiederverwendung**: Beide Dateien müssen `read("any")` tragen (frisch hochgeladen; ausgeblendete Dateien nie) und dürfen von keiner Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet werden (auch überkreuz, auch von ausgeblendeten Karten), sonst `403` „Diese Bilder sind bereits veröffentlicht oder gesperrt.“ So lassen sich die Dateien einer gemeldeten oder bereits veröffentlichten Karte nicht unter einer anderen `sourceCardId` neu einstellen. Die Prüfung läuft zusammen mit Limit, Quellkarte und Anlegen je Autor nacheinander. Nach dem Anlegen der Zeile setzt die API beide Dateien auf genau `read("any")` (**kein `delete("user:<ich>")` mehr**, kanonischer Zustand, auch wenn der Client mehr Rechte vergeben hat); scheitert das, wird die Zeile zurückgenommen und schon festgelegte Dateien bekommen `delete("user:<ich>")` zurück, damit der Client den Fehlversuch aufräumen kann (scheitert schon das Zurücknehmen, steht `rollback failed for card <id>` im Log). Ein 409 fasst die Dateirechte nie an. Bei Appwrite 1.8 liefert eine Datei aus einem anderen Bucket schon beim Abruf 404, das erscheint dann ebenfalls als `400` „Bild nicht gefunden.“
- **Logo-Felder** (`/api/logos/hochladen`): `name` 1–40 Zeichen (getrimmt; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. **Zeilen-ID des Logos** = `sha256(<ownerId>|<fileId>)` (erste 32 Hex), **nicht** die Datei-ID; die Antwort `201 {id}` trägt diese ID. Dieselbe Datei desselben Kontos ergibt dieselbe ID (`409`). Die ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten; wäre sie die Datei-ID, könnte jeder eine Datei mit genau dieser ID anlegen, als Logo hochladen und so die alte ID neu belegen (gespeicherte Karten anderer zeigten dann sein Bild). Mit dem Konto im Hash geht das nicht. Dass eine Datei nie zwei Logos trägt, sichert der Besitz: nach dem Anlegen hat die Datei kein `delete("user:<ich>")` mehr, ein anderes Konto bekommt sie nicht (`403`). **Altlogos** (vor dieser Regel angelegt) haben noch Zeilen-ID = Datei-ID: Herz, Melden, Löschen und Sichtbarkeit adressieren die Zeile nur über ihre ID und die Datei über das Feld `fileId`, darum gehen beide Arten; dieselbe Datei eines Altlogos noch einmal hochzuladen ist ebenfalls `409`.
- **Logo-Datei und Reihenfolge**: Die Datei liegt im Bucket `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist `image/png`, höchstens 1 MiB (1048576 Byte) und hat einen PNG-Kopf mit 8 × 8 bis 720 × 250 Pixeln (die API liest nur die ersten 64 Bytes). Die Prüfungen laufen in dieser Reihenfolge, damit fremde Dateien nichts verraten: JWT (`401`) → Benutzername (`403` `usernameRequired`) → Body (`400`) → Datei da und im richtigen Bucket (`400`/`403`) → je Konto nacheinander (Sperre `logo-owner:<id>`): schon 30 Logos (`429`, ausgeblendete zählen mit) → diese Datei ist schon ein Logo des Kontos (`409`: Zeile mit dieser Zeilen-ID oder mit dieser `fileId`, also auch ein Altlogo) → Besitz (`403`: kein `delete("user:<ich>")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → ab hier zusätzlich die Sperre des Logos `logo:<Zeilen-ID>` (immer erst Konto, dann Logo) bis zur zweiten Prüfung unten: Zeile anlegen (`409`, wenn die ID schon vergeben ist) → Datei auf genau `read("any")` setzen (**kein `delete("user:<ich>")` mehr**, sonst ließe sich das Bild unter derselben ID tauschen). Scheitert das Festlegen, wird die Zeile zurückgenommen und nur dann bekommt die Datei `delete("user:<ich>")` zurück (scheitert schon das Zurücknehmen, steht `rollback failed for logo <id> (file <Datei-ID>)` im Log; scheitert danach die Rückgabe von `delete("user:<ich>")`, bleibt die Datei ohne Zeile und ohne dieses Recht liegen und `restore failed for logo <id> (file <Datei-ID>)` steht im Log, die Antwort bleibt in beiden Fällen der ursprüngliche Fehler). **Zweite Prüfung**: Danach liest die API den Dateianfang noch einmal und prüft PNG-Kopf und Maße erneut. Jetzt kann der Hochlader die Datei nicht mehr ändern; so lässt sich der Inhalt nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen (Löschen und Neuanlegen der Datei). Fällt die Datei jetzt durch oder fehlt sie, löscht die API die Datei mit dem Server-Key und die Zeile (beides wird versucht, auch wenn das erste scheitert) und antwortet `400` `logoFormat` bzw. `fileMissing`; Log: `logo <id> removed after upload check (<code>)`, bleibt etwas stehen `rollback failed for logo <id> (file <Datei-ID>)`. Ein Appwrite-Fehler beim zweiten Lesen wird wie ein Fehler beim Festlegen behandelt (`502`, Zeile zurückgenommen, die Datei bekommt `delete("user:<ich>")` zurück). Fremde, schon veröffentlichte oder gesperrte Dateien ergeben `403`, ohne dass die API ihren Inhalt liest. **Sperre des Logos beim Hochladen**: Die Zeile steht ab dem Anlegen öffentlich in der Liste; ein Herz, eine Meldung oder ein Löschen, das bis zum Ende der zweiten Prüfung eintrifft, wartet auf die Sperre und sieht danach den Endzustand (das fertige Logo oder `404`, wenn die Prüfung es entfernt hat). So bleibt keine Herz- oder Meldungszeile ohne Logo zurück.
- **Herzen** (`/api/logos/herz`): ein Herz je Konto und Logo, Zeilen-ID `sha256(<logoId>|<userId>)` (erste 32 Hex), Recht `read("user:<id>")`. `herz: true` legt die Zeile an (`409` = schon da, kein Fehler), `false` löscht sie (schon weg = kein Fehler). Danach ist `likeCount` immer `countRows` aller Herzen des Logos (neu gezählt, nie hoch- oder runtergezählt) und wird nur geschrieben, wenn er abweicht. Fehlendes oder ausgeblendetes Logo: `404`, auch beim Entfernen. Pro Logo läuft immer nur eine Änderung gleichzeitig (Sperre `logo:<id>`, auch gegen ein gerade laufendes Hochladen dieses Logos). Herzen werden nicht geloggt.
- **Logo melden** (`/api/logos/melden`): wie `/api/melden`, mit denselben Gründen, demselben Melder (Netz, gehasht) und **demselben Rate-Limit** (ein gemeinsamer Zähler je Anschluss für Karten und Logos: 6 Karten- und 5 Logo-Meldungen vom selben Anschluss ergeben bei der 11. ein `429`). Die Zeilen-ID der Meldung ist `sha256(logo|<logoId>|<reporterHash>)`, `reportCount` ist `countRows` der Meldungen des Logos, ab 3 wird ausgeblendet (Datei und Zeile auf `read("user:<owner>")` und `read("label:admin")`).
- **Logo löschen und Sichtbarkeit**: Löschen (Hochlader oder Admin) blendet das Logo zuerst aus (Datei, dann Zeile auf die ausgeblendeten Rechte mit `hidden: true`; ein schon ausgeblendetes Logo überspringt das) und entfernt dann Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). Das Ausblenden zuerst verhindert, dass nach einem Abbruch (502) eine öffentliche Zeile mit gelöschter Datei stehen bleibt: unter deren Datei-ID könnte jedes Konto eine neue Datei anlegen, und alle gespeicherten Karten zeigten sie. Es bleibt höchstens eine unsichtbare Zeile (nur Hochlader und Admins lesen sie); die Wiederholung vollendet das Löschen. Die Admin-Sichtbarkeit verhält sich wie bei den Karten: Ausblenden setzt Datei und Zeile auf die ausgeblendeten Rechte (`hidden: true`), Einblenden setzt sie auf `read("any")`, löscht die Meldungen und setzt `reportCount` auf 0 (die Herzen bleiben).
- **Doppelt veröffentlichen**: Die Zeilen-ID der Galerie ist `sha256(ownerId|sourceCardId)` (erste 32 Hex). Eine zweite Veröffentlichung derselben Karte scheitert deshalb atomar an Appwrite (409), auch bei gleichzeitigen Anfragen. Nach dem Zurückziehen ist die Karte wieder frei.
- **Melden**: Rate-Limit 10 pro Stunde und Anschluss (Speicher, geht bei Neustart verloren; abgelaufene Einträge werden entfernt, höchstens 50 000 Einträge; Schlüssel: gehashte IPv4, IPv6 auf /64 gekürzt, IPv4-gemappte IPv6 als IPv4). Der Melder-Hash ist `HMAC-SHA256(salt, netz)` mit dem **Netz** der Adresse (IPv4 → `/24`, IPv6 → `/48`, IPv4-gemappte IPv6 wie IPv4): drei Adressen aus einem Netz sind ein Melder und blenden eine Karte nicht aus (sonst genügten drei VPN-/Mobilfunk-Adressen). Die Zeilen-ID der Meldung ist `sha256(galleryId|reporterHash)`, eine zweite Meldung desselben Melders ergibt `{schonGemeldet:true}`. `reportCount` = Anzahl der Meldungszeilen der Karte; ab 3 wird ausgeblendet. Auch eine doppelte Meldung zählt neu und heilt so einen früher steckengebliebenen Zählerstand. Pro Karte läuft immer nur eine Änderung gleichzeitig.
- **Client-IP**: `X-Real-Ip` (setzt Traefik), sonst erster Eintrag von `X-Forwarded-For`, sonst die Socket-Adresse. Der Dienst veröffentlicht nie `ports:`, ist aber im Docker-Netz `webklar-network` für andere Container erreichbar, und dort gilt `X-Real-Ip` als vertrauenswürdig: ein Container im selben Netz könnte beliebige Melder-Adressen vortäuschen. Akzeptiertes Risiko (kein Code dagegen); eine Abhilfe wäre eine Traefik-Middleware mit geheimem Header, den `clientIp` prüft, oder ein eigenes Netz nur für Traefik und `aircard-api`. Vor einem AAAA-Eintrag für aircard-ios.org die Client-IP-Ermittlung prüfen.
- **Rechte** (genau diese Strings; Abweichung von Spec §7, siehe unten): sichtbar Zeile `read("any")`; ausgeblendet Zeile `read("user:<owner>")`, `read("label:admin")`; Datei hochgeladen (noch nicht veröffentlicht, vom Client gesetzt) `read("any")`, `delete("user:<owner>")`; Datei veröffentlicht `read("any")`; Datei ausgeblendet `read("user:<owner>")`, `read("label:admin")`. Veröffentlichte und ausgeblendete Dateien tragen **kein** `delete(owner)`: sonst könnte der Autor eine Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen und Admin-Aktionen löschen serverseitig mit dem Key. Meldungszeilen haben keine eigenen Rechte (Tabelle: nur `read("label:admin")`). Für Logos gilt dasselbe (Zeile und Datei sichtbar `read("any")`, ausgeblendet `read("user:<owner>")` und `read("label:admin")`, hochgeladen `read("any")` und `delete("user:<owner>")`); Herzen tragen `read("user:<id>")` des Kontos; Profile (Benutzernamen) tragen genau `read("any")`.
- **Benutzernamen** (`/api/profil`, `profiles.js`): `username` muss `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$` erfüllen (3–20 Zeichen, nur ASCII, kein Trimmen: Leerzeichen sind ungültig), sonst `400` `usernameInvalid` (auch wenn es keine Zeichenkette ist). Eindeutig ist der **Schlüssel** `usernameKey` = der Name klein geschrieben („Kenso“ und „kenso“ sind derselbe Name); gespeichert und angezeigt wird der Name, wie er eingegeben wurde. **Gesperrte Schlüssel** (`400` `usernameReserved`, in jeder Schreibweise): `admin`, `administrator`, `root`, `system`, `support`, `moderator`, `mod`, `aircard`, `aircardios`, `aircard-ios`, `aircard.ios`, `aircard_ios`, `webklar`, `anonym`, `anonymous`, `null`, `undefined`, `team`, `staff`, `official`, `offiziell`, `hilfe`, `help`, `kontakt`, `contact`, `info`. Die Zeile steht in der Tabelle `profiles` (Spalten `username`, `usernameKey`, je höchstens 20 Zeichen): Zeilen-ID = Konto-ID (ein Name je Konto, das Konto kommt aus dem JWT, nie aus dem Body), Recht genau `read("any")` (öffentlich lesbar; der Client liest die Namen direkt), Tabellenrechte keine. Ablauf je Konto nacheinander (Sperre `profile:<Konto-ID>`): Gehört der Schlüssel einem **anderen** Konto (Abfrage `equal usernameKey`), kommt `409` `usernameTaken`; die eigene Zeile zählt nicht, die Schreibweise des eigenen Namens zu ändern („kenso“ → „Kenso“) oder denselben Namen nochmal zu senden geht also. Sonst wird die Zeile des Kontos geändert (der alte Name ist damit sofort frei) oder, wenn es noch keine gibt, angelegt. **Gleichzeitigkeit**: Die Vorabprüfung ist nur der Normalfall mit klarer Antwort; entscheiden tut der Unique-Index `username_key` auf `usernameKey` (Schema-Skript). Wollen zwei Konten gleichzeitig denselben Namen, meldet Appwrite dem zweiten beim Anlegen bzw. Ändern `409`, und das wird ebenfalls `usernameTaken`: genau ein Konto bekommt den Namen. **Pflicht**: `/api/veroeffentlichen` und `/api/logos/hochladen` antworten `403` `usernameRequired`, wenn das Konto keine Zeile in `profiles` hat (`hasProfile`, ein `GET` der Zeile mit der Konto-ID). Die Prüfung läuft nach der Anmeldung und vor allen anderen Prüfungen (Body, Dateien, Karte), es wird nichts angelegt oder angefasst; bestehende Logos und Karten bleiben unberührt. Fehlt die Tabelle `profiles` (Schema nicht eingespielt), antworten beide Endpunkte `500` „Interner Fehler.“.
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; Logo löschen blendet zuerst aus und löscht dann Datei, Herzen, Meldungen, Zeile. Bleibt etwas hängen (502), heilt der nächste Versuch.
- **Logs**: Zeilen beginnen mit `[aircard-api]`. Es gibt kein Zugriffsprotokoll je Anfrage (`/api/melden` ist ohne Anmeldung erreichbar, ein Angreifer würde sonst das Log-Volumen bestimmen): geloggt werden nur Ereignisse (veröffentlicht bzw. Logo hochgeladen oder nach der zweiten Prüfung entfernt, gemeldet, ausgeblendet, zurückgezogen bzw. Logo gelöscht, Admin-Aktion, fehlgeschlagenes Zurücknehmen oder Wiederherstellen von `delete(owner)`, `profile created` bzw. `profile updated` (Benutzername angelegt bzw. geändert, ohne Namen und Konto-ID); keine Herzen) und Serverfehler (5xx), zusammen höchstens 120 Zeilen pro Minute (der Rest wird gezählt und als Sammelzeile „N log lines suppressed“ nachgetragen). Die Zeilen nennen Route, Status, Karten- bzw. Logo-ID (bei einem Fehler beim Aufräumen eines Logos auch die Datei-ID) und Fehlertypen, nie Key, Salz, JWT, IPs oder Fehlertexte von Appwrite. Zusätzlich deckelt Compose die Logdatei (`logging: json-file`, 5 MB, 2 Dateien).
## Tests
```sh
node --test "deploy/api/*.test.js"
```
Die Tests starten den Dienst auf Port 0 gegen einen Fake-Appwrite im Speicher (`fake-appwrite.js`, mit den Spaltenlängen der echten Tabellen und dem Unique-Index von `profiles`, der eine zweite Zeile mit demselben `usernameKey` mit `409` abweist) und greifen nie auf das echte Appwrite zu.
+662
View File
@@ -0,0 +1,662 @@
'use strict';
/**
* Routen und Logik des API-Dienstes `aircard-api` (Node 22, nur Bordmittel). Alle Schreibzugriffe auf die
* Community-Galerie, die Community-Logos und die Benutzernamen laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
* Gemeinsame Helfer, Fehlertexte und -codes: common.js. Die Logo-Endpunkte stehen in logos.js, der Endpunkt der Benutzernamen in
* profiles.js; dieses Modul gibt ihnen Anmeldung, Sperre je Schlüssel, das (gemeinsame) Rate-Limit und das Log.
*
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
* 401 kein/ungültiges JWT · 403 `usernameRequired` (das Konto hat noch keinen Benutzernamen; vor allen anderen Prüfungen) ·
* 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
* falscher Bucket / Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet / Karte nicht
* meine (`cards`) · 409 schon veröffentlicht · 429 schon 20 Veröffentlichungen · 201 {id}
* POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id}
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
* POST /api/melden ohne Anmeldung {id, reason} (Melder = IPv4-/24 bzw. IPv6-/48, gehasht)
* 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
* POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
* POST /api/logos/hochladen · /api/logos/herz · /api/logos/melden · /api/logos/loeschen · /api/admin/logo-sichtbarkeit
* siehe logos.js
* POST /api/profil siehe profiles.js
* GET /api/gesundheit {ok:true}
* alles andere 404; Body höchstens 8 KB (413), nur application/json (415)
*
* Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`.
* Der Appwrite-Key braucht die Console-Scopes documents.read/documents.write (= rows.* der API) und files.read/files.write.
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> Benutzername (403, nur Veröffentlichen und
* Logo hochladen) -> JSON und Felder (400) -> Logik.
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>} (siehe common.js).
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx; code `unavailable`) bzw. 500 (alle anderen, z. B. falscher Key oder
* fehlende Tabelle; code `internal`).
* Logs: kein Zugriffsprotokoll je Anfrage (sonst steuert ein Angreifer das Log-Volumen), nur Ereigniszeilen
* (veröffentlicht, gemeldet, ausgeblendet, …) und Serverfehler, zusammen höchstens 120 Zeilen pro Minute.
*/
const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
const {
MAX_PUBLICATIONS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
CONTROL_CHARACTERS,
MSG,
HttpError,
isId,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
reporterNetwork,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
} = require('./common.js');
const { createLogoRoutes } = require('./logos.js');
const { createProfileRoutes, hasProfile } = require('./profiles.js');
const TABLE_GALLERY = 'gallery';
const TABLE_REPORTS = 'reports';
const TABLE_CARDS = 'cards';
const BUCKET = 'gallery-images';
const MAX_BODY_BYTES = 8 * 1024;
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']);
const REPORT_LIST_LIMIT = 100;
const REPORT_DELETE_ROUNDS = 50;
/** Kartenmaße wie web/src/card/geometry.ts (CARD_W, CARD_H, PREVIEW_WIDTH; Vorschauhöhe 303 plus/minus 1). */
const IMAGE_WIDTH = 1536;
const IMAGE_HEIGHT = 969;
const PREVIEW_WIDTH = 480;
const PREVIEW_HEIGHT_MIN = 302;
const PREVIEW_HEIGHT_MAX = 304;
const RATE_LIMIT = 10;
const RATE_WINDOW_MS = 60 * 60 * 1000;
const RATE_SWEEP_MS = 60 * 1000;
const RATE_MAX_KEYS = 50_000;
const LOG_BUDGET_PER_MINUTE = 120;
const LOG_WINDOW_MS = 60 * 1000;
const JWT_CHARACTERS = /^[\x21-\x7e]+$/;
// ---- Reine Helfer ----------------------------------------------------------------------------------
/** Feste Zeilen-ID je Autor und private Karte: Appwrite-409 ersetzt jede Doppelt-Prüfung (auch bei Gleichzeitigkeit). */
function galleryRowId(ownerId, sourceCardId) {
return hash32(`${ownerId}|${sourceCardId}`);
}
/** Feste Zeilen-ID je Karte und Melder: eine Meldung pro Melder und Karte. */
function reportRowId(galleryId, hash) {
return hash32(`${galleryId}|${hash}`);
}
/** Fehlername oder -typ fürs Log: nur Wortzeichen, Punkt und Bindestrich, höchstens 60 Zeichen. */
function safeToken(value, fallback = 'unknown') {
const text = String(value === undefined || value === null ? '' : value).replace(/[^\w.-]/g, '').slice(0, 60);
return text === '' ? fallback : text;
}
const isCardImageSize = (size) => size !== null && size.width === IMAGE_WIDTH && size.height === IMAGE_HEIGHT;
const isCardPreviewSize = (size) =>
size !== null && size.width === PREVIEW_WIDTH && size.height >= PREVIEW_HEIGHT_MIN && size.height <= PREVIEW_HEIGHT_MAX;
const TEXT_FIELDS = [
{ key: 'title', article: 'Der', name: 'Titel', max: 60, required: true },
{ key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false },
{ key: 'displayName', article: 'Der', name: 'Anzeigename', max: 30, required: false },
];
function readText(raw, field) {
if (raw === undefined || raw === null) {
return field.required ? { message: 'Bitte gib einen Titel an.' } : { value: '' };
}
if (typeof raw !== 'string') return { message: `${field.article} ${field.name} ist ungültig.` };
const value = raw.trim();
if (value === '' && field.required) return { message: 'Bitte gib einen Titel an.' };
if (CONTROL_CHARACTERS.test(value)) return { message: `${field.article} ${field.name} enthält ungültige Zeichen.` };
if (value.length > field.max) return { message: `${field.article} ${field.name} ist zu lang (höchstens ${field.max} Zeichen).` };
return { value };
}
/** Tags: string[], je getrimmt und klein, Leere raus, Doppelte raus, höchstens 5 mit je höchstens 20 Zeichen. */
function readTags(raw) {
if (raw === undefined || raw === null) return { tags: [] };
if (!Array.isArray(raw)) return { message: 'Tags müssen als Liste gesendet werden.' };
const tags = [];
for (const entry of raw) {
if (typeof entry !== 'string') return { message: 'Ungültige Tags.' };
const tag = entry.trim().toLowerCase();
if (tag === '') continue;
if (tag.includes(',')) return { message: 'Tags dürfen kein Komma enthalten.' };
if (CONTROL_CHARACTERS.test(tag)) return { message: 'Tags enthalten ungültige Zeichen.' };
if (tag.length > 20) return { message: 'Ein Tag darf höchstens 20 Zeichen lang sein.' };
if (!tags.includes(tag)) tags.push(tag);
}
if (tags.length > 5) return { message: 'Bitte gib höchstens 5 Tags an.' };
return { tags };
}
/**
* Prüft den Veröffentlichen-Body (Längen aus Spec §6, IDs nach ID_PATTERN).
* @returns {{ok: true, data: object} | {ok: false, message: string}}
*/
function validatePublish(body) {
if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest };
const data = {};
for (const field of TEXT_FIELDS) {
const result = readText(body[field.key], field);
if (result.message) return { ok: false, message: result.message };
data[field.key] = result.value;
}
const tags = readTags(body.tags);
if (tags.message) return { ok: false, message: tags.message };
data.tags = tags.tags;
for (const [key, message] of [
['imageFileId', 'Ungültige Bild-ID.'],
['previewFileId', 'Ungültige Vorschau-ID.'],
['sourceCardId', MSG.badCardId],
]) {
if (!isId(body[key])) return { ok: false, message };
data[key] = body[key];
}
if (data.imageFileId === data.previewFileId) {
return { ok: false, message: 'Bild und Vorschau müssen verschiedene Dateien sein.' };
}
return { ok: true, data };
}
/** `title issuer tags` klein mit Leerzeichen, höchstens 300 Zeichen (Volltext-Index der Galerie). */
function buildSearchText({ title = '', issuer = '', tags = [] }) {
const tagList = Array.isArray(tags) ? tags : String(tags).split(',');
return [title, issuer, ...tagList]
.map((part) => String(part).trim())
.filter((part) => part !== '')
.join(' ')
.toLowerCase()
.slice(0, 300);
}
// ---- HTTP-Hilfen ------------------------------------------------------------------------------------
function sendJson(res, status, payload, headers = {}) {
const body = JSON.stringify(payload);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
...headers,
});
res.end(body);
}
const JSON_CONTENT_TYPE = /^application\/json\s*(;|$)/i;
/** Liest den Rohbody mit Obergrenze; bei Überschreitung wird der Rest verworfen und die Verbindung geschlossen. */
function readRawBody(req) {
return new Promise((resolve, reject) => {
const tooLarge = () => new HttpError(413, MSG.tooLarge, { Connection: 'close' });
const declared = Number(req.headers['content-length']);
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
req.resume();
reject(tooLarge());
return;
}
const chunks = [];
let size = 0;
let settled = false;
const settle = (fn, value) => {
if (settled) return;
settled = true;
chunks.length = 0;
fn(value);
};
req.on('data', (chunk) => {
if (settled) return; // Rest verwerfen, Verbindung bleibt sauber
size += chunk.length;
if (size > MAX_BODY_BYTES) settle(reject, tooLarge());
else chunks.push(chunk);
});
req.on('end', () => {
if (settled) return;
const raw = Buffer.concat(chunks).toString('utf8');
settle(resolve, raw);
});
req.on('error', () => settle(reject, new HttpError(400, MSG.badRequest)));
req.on('close', () => settle(reject, new HttpError(400, MSG.badRequest)));
});
}
/** Je Schlüssel nacheinander abarbeiten (z. B. je Karten-ID), damit Zähler und Rechte nie durcheinanderlaufen. */
function createKeyedLock() {
const tails = new Map();
return async function withLock(key, fn) {
const previous = tails.get(key) || Promise.resolve();
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
const tail = previous.then(() => gate);
tails.set(key, tail);
await previous;
try {
return await fn();
} finally {
release();
if (tails.get(key) === tail) tails.delete(key);
}
};
}
// ---- Die App ---------------------------------------------------------------------------------------
/**
* @param {object} options
* @param {object} options.appwrite Objekt aus createAppwrite
* @param {string} options.salt Salz für die Melder-Hashes
* @param {() => number} [options.now] Uhr in ms (für das Rate-Limit)
* @param {(line: string) => void} [options.log] bekommt fertige Zeilen mit Präfix [aircard-api]
* @returns {(req, res) => Promise<void>} Handler für http.createServer, mit `rateLimitEntries()` für Tests
*/
function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
if (!appwrite || typeof appwrite !== 'object') throw new TypeError('appwrite fehlt');
if (typeof salt !== 'string' || salt === '') throw new TypeError('salt fehlt');
// Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten- und Logo-IDs, Zahlen und Fehlertypen. Zusätzlich gilt ein
// Zeilenbudget je Minute; was darüber liegt, wird gezählt und als eine Sammelzeile nachgetragen.
const emit = (message) => log(`[aircard-api] ${new Date().toISOString()} ${message}`);
let logWindowStart = 0;
let logCount = 0;
let logSuppressed = 0;
function say(message) {
const t = now();
if (t - logWindowStart >= LOG_WINDOW_MS) {
if (logSuppressed > 0) emit(`${logSuppressed} log lines suppressed (limit ${LOG_BUDGET_PER_MINUTE} per minute)`);
logWindowStart = t;
logCount = 0;
logSuppressed = 0;
}
if (logCount >= LOG_BUDGET_PER_MINUTE) {
logSuppressed += 1;
return;
}
logCount += 1;
emit(message);
}
const withLock = createKeyedLock();
// ---- Rate-Limit je Anschluss (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports` und `logo_reports`).
// Ein Zähler für Karten- und Logo-Meldungen: logos.js bekommt `rateLimit` von hier.
const hits = new Map();
let lastSweep = 0;
function sweepRateLimit(t) {
if (t - lastSweep < RATE_SWEEP_MS) return;
lastSweep = t;
for (const [key, list] of hits) {
if (t - list[list.length - 1] >= RATE_WINDOW_MS) hits.delete(key);
}
}
/** @returns {{allowed: boolean, retryAfter?: number}} zählt die Meldung mit, wenn erlaubt */
function rateLimit(key) {
const t = now();
sweepRateLimit(t);
const list = (hits.get(key) || []).filter((stamp) => t - stamp < RATE_WINDOW_MS);
if (list.length >= RATE_LIMIT) {
hits.set(key, list);
return { allowed: false, retryAfter: Math.max(1, Math.ceil((list[0] + RATE_WINDOW_MS - t) / 1000)) };
}
list.push(t);
hits.delete(key); // neu einfügen: Reihenfolge = zuletzt benutzt
hits.set(key, list);
while (hits.size > RATE_MAX_KEYS) hits.delete(hits.keys().next().value);
return { allowed: true };
}
// ---- Anmeldung
async function authenticate(req) {
const jwt = req.headers['x-aircard-jwt'];
if (typeof jwt !== 'string' || jwt.length > 4096 || !JWT_CHARACTERS.test(jwt)) return null;
const account = await appwrite.accountFromJwt(jwt);
if (!account || !isId(account.$id)) return null;
return { id: account.$id, isAdmin: Array.isArray(account.labels) && account.labels.includes('admin') };
}
async function requireUser(req) {
const user = await authenticate(req);
if (!user) throw new HttpError(401, MSG.unauthorized);
return user;
}
// ---- Dateien und Zeilen
function checkFileContent(file) {
if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType);
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize);
}
/** Liest den Anfang beider Dateien und verlangt Kartenformat: Bild 1536 x 969, Vorschau 480 x 302-304. */
async function checkImageSizes(data) {
const [image, preview] = await Promise.all([
appwrite.readFileHead(BUCKET, data.imageFileId, IMAGE_HEAD_BYTES),
appwrite.readFileHead(BUCKET, data.previewFileId, IMAGE_HEAD_BYTES),
]);
if (image === null || preview === null) throw new HttpError(400, MSG.fileMissing);
if (!isCardImageSize(readImageSize(image)) || !isCardPreviewSize(readImageSize(preview))) {
throw new HttpError(400, MSG.imageFormat);
}
}
const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId);
/**
* Die Galerie-Zeilen dieses Autors (auch ausgeblendete), höchstens MAX_PUBLICATIONS (mehr braucht der Aufrufer nicht:
* ab dort ist das Limit erreicht). Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId`
* nicht). Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht).
*/
function ownerRows(ownerId) {
return appwrite.listRows(TABLE_GALLERY, [queryEqual('ownerId', [ownerId]), queryLimit(MAX_PUBLICATIONS)]);
}
/** Die private Karte muss es geben und dem Konto gehören (Appwrite gibt dem Besitzer read("user:<id>") auf seine Zeile). */
async function ownsSourceCard(userId, cardId) {
const card = await appwrite.getRow(TABLE_CARDS, cardId);
return card !== null && Array.isArray(card.$permissions) && card.$permissions.includes(`read("user:${userId}")`);
}
/**
* Setzt die Rechte beider Dateien. Wartet auf beide Aufrufe, auch wenn einer scheitert (die Sperre der Karte darf
* nicht freigegeben werden, solange noch ein PUT unterwegs ist), und wirft danach den ersten Fehler.
* @returns {Promise<Array>} je Datei das Ergebnis; null heißt: Datei gibt es nicht (mehr)
*/
async function setFilePermissions(row, permissions) {
const results = await Promise.allSettled(fileIdsOf(row).map((fileId) => appwrite.updateFilePermissions(BUCKET, fileId, permissions)));
const failed = results.find((result) => result.status === 'rejected');
if (failed) throw failed.reason;
return results.map((result) => result.value);
}
/** Dateien zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
async function hideCard(id, row, extra = {}) {
await setFilePermissions(row, hiddenFilePermissions(row.ownerId));
await appwrite.updateRow(TABLE_GALLERY, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId));
}
async function deleteReports(galleryId) {
for (let round = 0; round < REPORT_DELETE_ROUNDS; round += 1) {
const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [galleryId]), queryLimit(REPORT_LIST_LIMIT)]);
if (reports.length === 0) return;
for (const report of reports) await appwrite.deleteRow(TABLE_REPORTS, report.$id);
}
}
/** reportCount = Anzahl Meldungszeilen der Karte; ab 3 wird ausgeblendet. */
async function syncReports(id, row) {
const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [id]), queryLimit(REPORT_LIST_LIMIT)]);
const count = reports.length;
if (count >= HIDE_AT_REPORTS) {
await hideCard(id, row, { reportCount: count });
say(`card ${id} hidden automatically (${count} reports)`);
} else if (row.reportCount !== count) {
await appwrite.updateRow(TABLE_GALLERY, id, { reportCount: count });
}
}
// ---- Endpunkte
async function publish({ req, raw }) {
const user = await requireUser(req);
// Ohne Benutzernamen geht nichts weiter: vor allen anderen Prüfungen, es wird nichts angelegt oder angefasst.
if (!(await hasProfile(appwrite, user.id))) throw new HttpError(403, MSG.usernameRequired);
const validation = validatePublish(parseJsonObject(raw));
if (!validation.ok) throw new HttpError(400, validation.message);
const data = validation.data;
const [image, preview] = await Promise.all([
appwrite.getFile(BUCKET, data.imageFileId),
appwrite.getFile(BUCKET, data.previewFileId),
]);
checkFileExists(image, BUCKET);
checkFileExists(preview, BUCKET);
// Je Autor nacheinander: Limit, Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
return withLock(`owner:${user.id}`, async () => {
const rows = await ownerRows(user.id);
if (rows.length >= MAX_PUBLICATIONS) throw new HttpError(429, MSG.tooManyPublications);
if (!(await ownsSourceCard(user.id, data.sourceCardId))) throw new HttpError(403, MSG.notYourCard);
const used = new Set(rows.flatMap(fileIdsOf));
if (used.has(data.imageFileId) || used.has(data.previewFileId)) throw new HttpError(403, MSG.filesTaken);
for (const file of [image, preview]) checkFileOwnership(file, user.id);
for (const file of [image, preview]) checkFileContent(file);
await checkImageSizes(data);
const id = galleryRowId(user.id, data.sourceCardId);
try {
await appwrite.createRow(
TABLE_GALLERY,
id,
{
title: data.title,
issuer: data.issuer,
// ",a,b," statt "a,b": der Tag-Filter der Galerie sucht ",<tag>," und trifft so nur ganze Tags.
tags: data.tags.length > 0 ? `,${data.tags.join(',')},` : '',
displayName: data.displayName,
imageFileId: data.imageFileId,
previewFileId: data.previewFileId,
sourceCardId: data.sourceCardId,
ownerId: user.id,
hidden: false,
reportCount: 0,
searchText: buildSearchText(data),
},
visibleRowPermissions(),
);
} catch (err) {
if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished);
throw err;
}
// Kanonischer Zustand: Beide Dateien tragen genau read("any") (kein update/delete, keine fremden Rollen).
// Erst nach dem Anlegen der Zeile: ein 409 (dieselbe private Karte nochmal) fasst die Dateien nie an.
// Scheitert das Festlegen, wird die Zeile zurückgenommen.
try {
const pinned = await setFilePermissions(data, visibleFilePermissions());
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
} catch (err) {
let rolledBack = true;
await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => {
rolledBack = false;
say(`rollback failed for card ${id}`);
});
// Nur wenn keine Zeile mehr auf die Dateien zeigt: schon festgelegte Dateien bekommen delete(owner) zurück,
// damit der Client den Fehlversuch aufräumen kann (sonst blieben sie öffentlich und unlöschbar liegen).
if (rolledBack) await setFilePermissions(data, uploadFilePermissions(user.id)).catch(() => {});
throw err;
}
say(`card ${id} published`);
return { status: 201, body: { id } };
});
}
async function withdraw({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
const id = body.id;
return withLock(id, async () => {
const row = await appwrite.getRow(TABLE_GALLERY, id);
if (!row) throw new HttpError(404, MSG.cardGone);
if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden);
// Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen.
for (const fileId of fileIdsOf(row)) await appwrite.deleteFile(BUCKET, fileId);
await deleteReports(id);
await appwrite.deleteRow(TABLE_GALLERY, id);
say(`card ${id} withdrawn${row.ownerId === user.id ? '' : ' by admin'}`);
return { status: 200, body: { ok: true } };
});
}
async function reportCard({ req, raw }) {
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
const id = body.id;
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt weiter je Anschluss (IPv4 voll, IPv6 /64).
const ip = clientIp(req);
const reporter = reporterHash(salt, ip);
const limit = rateLimit(hmacHex(salt, normalizeIp(ip)));
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
return withLock(id, async () => {
const row = await appwrite.getRow(TABLE_GALLERY, id);
if (!row || row.hidden === true) throw new HttpError(404, MSG.cardGone);
let isNew = true;
try {
await appwrite.createRow(TABLE_REPORTS, reportRowId(id, reporter), { galleryId: id, reason: body.reason, reporterHash: reporter }, []);
} catch (err) {
if (!isDuplicate(err)) throw err;
isNew = false;
}
// Auch bei einer doppelten Meldung neu zählen: heilt einen früher steckengebliebenen Zählerstand.
await syncReports(id, row);
if (isNew) say(`card ${id} reported (${body.reason})`);
return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } };
});
}
async function setVisibility({ req, raw }) {
const user = await requireUser(req);
if (!user.isAdmin) throw new HttpError(403, MSG.adminOnly);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badCardId);
if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility);
const id = body.id;
return withLock(id, async () => {
const row = await appwrite.getRow(TABLE_GALLERY, id);
if (!row) throw new HttpError(404, MSG.cardGone);
if (body.sichtbar) {
// Dateien, dann Meldungen, Zeile zuletzt: sichtbar wird die Karte erst, wenn alles andere steht.
await setFilePermissions(row, visibleFilePermissions(row.ownerId));
await deleteReports(id);
await appwrite.updateRow(TABLE_GALLERY, id, { hidden: false, reportCount: 0 }, visibleRowPermissions());
say(`card ${id} made visible by admin`);
} else {
await hideCard(id, row);
say(`card ${id} hidden by admin`);
}
return { status: 200, body: { ok: true } };
});
}
const ROUTES = new Map([
['GET /api/gesundheit', { name: 'gesundheit', run: async () => ({ status: 200, body: { ok: true } }) }],
['POST /api/veroeffentlichen', { name: 'veroeffentlichen', json: true, run: publish }],
['POST /api/zurueckziehen', { name: 'zurueckziehen', json: true, run: withdraw }],
['POST /api/melden', { name: 'melden', json: true, run: reportCard }],
['POST /api/admin/sichtbarkeit', { name: 'admin/sichtbarkeit', json: true, run: setVisibility }],
// Logos (logos.js): bekommen Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log von hier.
...createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }),
// Benutzernamen (profiles.js): bekommen Anmeldung, Sperre je Schlüssel und das Log von hier.
...createProfileRoutes({ appwrite, say, withLock, requireUser }),
]);
async function handle(req, res) {
const pathname = (req.url || '').split('?')[0];
const route = ROUTES.get(`${req.method} ${pathname}`);
let status = 500;
try {
if (!route) throw new HttpError(404, MSG.notFound);
let raw = '';
if (route.json) {
if (!JSON_CONTENT_TYPE.test(req.headers['content-type'] || '')) {
req.resume();
throw new HttpError(415, MSG.jsonOnly);
}
raw = await readRawBody(req);
}
const result = await route.run({ req, raw });
status = result.status;
sendJson(res, result.status, result.body);
} catch (err) {
let headers = {};
let message = MSG.internal;
let code = 'internal';
if (err instanceof HttpError) {
status = err.status;
message = err.message;
code = err.code;
headers = err.headers;
} else if (err instanceof AppwriteError) {
// Keine Antwort, Appwrite überlastet (429) oder Serverfehler: nicht erreichbar (502). Alles andere
// (falscher Key, fehlende Tabelle, ungültige Struktur) ist ein Fehler hier (500).
const unavailable = err.status === 0 || err.status === 429 || err.status >= 500;
status = unavailable ? 502 : 500;
message = unavailable ? MSG.unavailable : MSG.internal;
code = unavailable ? 'unavailable' : 'internal';
say(`${req.method} /api/${route.name} -> ${status}: appwrite error status=${err.status} type=${safeToken(err.type)}`);
} else {
status = 500;
say(`${req.method} /api/${route ? route.name : '-'} -> 500: unexpected error ${safeToken(err && err.name)}`);
}
if (!res.headersSent) sendJson(res, status, { error: message, code }, headers);
}
}
function handler(req, res) {
return handle(req, res).catch((err) => {
// handle() fängt alles selbst ab; das hier sichert nur gegen Fehler beim Senden.
say(`unexpected error ${safeToken(err && err.name)}`);
try {
res.destroy();
} catch {
// schon geschlossen
}
});
}
handler.rateLimitEntries = () => hits.size;
return handler;
}
module.exports = {
createApp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
reporterHash,
reporterNetwork,
clientIp,
normalizeIp,
readImageSize,
validatePublish,
buildSearchText,
galleryRowId,
reportRowId,
};
File diff suppressed because it is too large. Load diff
+221
View File
@@ -0,0 +1,221 @@
'use strict';
/**
* Schmaler REST-Client für Appwrite 1.8 (Node 22, nur Bordmittel) mit dem Server-Key des API-Dienstes.
*
* createAppwrite({ endpoint, project, key, fetchImpl?, database?, timeoutMs? })
* getRow(table, id) Zeile oder null (404 row_not_found)
* listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit, queryCursorAfter);
* eine Antwort ohne `rows`-Array ist ein Fehler (kein stilles [])
* countRows(table, queries) Anzahl aller Treffer (`total` einer Abfrage mit limit(1), ohne eigenes limit
* in `queries`); eine Antwort ohne gültiges `total` ist ein Fehler
* createRow(table, rowId, data, permissions) doppelte ID -> AppwriteError mit status 409
* updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben
* deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar)
* getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null
* readFileHead(bucket, id, maxBytes) die ersten Bytes des Dateiinhalts (Buffer) oder null; liest nie mehr als
* nötig und bricht den Rest der Übertragung ab
* updateFilePermissions(bucket, id, permissions) Datei oder null (404 storage_file_not_found)
* deleteFile(bucket, id) true, false bei 404 storage_file_not_found
* accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
*
* Als „fehlt“ gilt nur ein 404 mit dem erwarteten Typ (row_not_found, storage_file_not_found). Jedes andere 404
* (table_not_found, database_not_found, storage_bucket_not_found, unbekannte Route, 404 ohne JSON) ist ein Fehler:
* so verschwindet ein Konfigurationsfehler nicht als „Karte nicht gefunden“.
* Redirects werden nie verfolgt (`redirect: 'error'`), damit der Server-Key nicht an eine andere Adresse geht.
* Jeder Aufruf bricht nach `timeoutMs` (10 s) ab. Jedes Pfadsegment wird mit encodeURIComponent kodiert;
* "." und ".." lehnt der Client ab, weil encodeURIComponent sie nicht verändert und die URL-Normalisierung
* sie sonst als Verzeichniswechsel auslegen würde.
*
* Fehler: AppwriteError mit `status` (0 = keine Antwort), `type` (Appwrite-Typ, "timeout" oder "unreachable").
* Der Text enthält nie Key oder JWT.
*/
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_HEAD_BYTES = 64;
const ROW_NOT_FOUND = 'row_not_found';
const FILE_NOT_FOUND = 'storage_file_not_found';
class AppwriteError extends Error {
constructor(status, type, message) {
super(message);
this.name = 'AppwriteError';
this.status = status;
this.type = type;
}
}
/** Appwrite-Abfrage als JSON-String. */
function queryEqual(attribute, values) {
return JSON.stringify({ method: 'equal', attribute, values });
}
function queryLimit(count) {
return JSON.stringify({ method: 'limit', values: [count] });
}
/** Nächste Seite nach der Zeile mit dieser ID (Reihenfolge: Anlegereihenfolge). */
function queryCursorAfter(id) {
return JSON.stringify({ method: 'cursorAfter', values: [id] });
}
/** Ein Pfadsegment: nur nichtleere Zeichenketten, nie "." oder "..", immer kodiert. */
function segment(value) {
if (typeof value !== 'string' || value === '' || value === '.' || value === '..') {
throw new TypeError('Ungültiges Pfadsegment');
}
return encodeURIComponent(value);
}
function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, database = 'aircard', timeoutMs = DEFAULT_TIMEOUT_MS }) {
if (typeof endpoint !== 'string' || endpoint === '') throw new TypeError('endpoint fehlt');
if (typeof project !== 'string' || project === '') throw new TypeError('project fehlt');
if (typeof key !== 'string' || key === '') throw new TypeError('key fehlt');
if (typeof fetchImpl !== 'function') throw new TypeError('fetch fehlt');
const base = endpoint.replace(/\/+$/, '');
const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`;
const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`;
const rowsUrl = (table, queries) => {
const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&');
return `${rowsPath(table)}${search === '' ? '' : `?${search}`}`;
};
/** Liest höchstens `limit` Bytes vom Anfang der Antwort und bricht den Rest ab (Dateien können 5 MiB groß sein). */
async function readHead(response, limit) {
if (!response.body) return Buffer.from(await response.arrayBuffer()).subarray(0, limit);
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (size < limit) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(Buffer.from(value));
size += value.length;
}
} finally {
await reader.cancel().catch(() => {});
}
return Buffer.concat(chunks).subarray(0, limit);
}
/**
* Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er.
* Mit `headBytes` ist `body` bei 2xx ein Buffer mit den ersten Bytes der Antwort statt geparstem JSON.
*/
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [], headBytes = 0 } = {}) {
const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) };
if (body !== undefined) {
requestHeaders['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
let response;
let text;
try {
response = await fetchImpl(`${base}${path}`, init);
if (headBytes > 0 && response.status >= 200 && response.status < 300) {
return { status: response.status, body: await readHead(response, headBytes) };
}
text = await response.text();
} catch (err) {
// Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten).
const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
throw timedOut
? new AppwriteError(0, 'timeout', 'Appwrite antwortet nicht rechtzeitig')
: new AppwriteError(0, 'unreachable', 'Appwrite nicht erreichbar');
}
let parsed = null;
if (text !== '') {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
}
if (response.status >= 200 && response.status < 300) return { status: response.status, body: parsed };
const type = parsed && typeof parsed.type === 'string' ? parsed.type : 'unknown';
if (response.status === 404 && missingTypes.includes(type)) return { status: 404, body: null };
const message = parsed && typeof parsed.message === 'string' ? parsed.message : `HTTP ${response.status}`;
throw new AppwriteError(response.status, type, message);
}
return {
async getRow(table, id) {
const result = await send('GET', `${rowsPath(table)}/${segment(id)}`, { missingTypes: [ROW_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async listRows(table, queries = []) {
const result = await send('GET', rowsUrl(table, queries));
if (!result.body || !Array.isArray(result.body.rows)) {
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
}
return result.body.rows;
},
/** Zählt die Treffer einer Abfrage über `total` (Appwrite zählt bis 5000): es wird höchstens eine Zeile übertragen. */
async countRows(table, queries = []) {
const result = await send('GET', rowsUrl(table, [...queries, queryLimit(1)]));
const total = result.body && result.body.total;
if (!Number.isInteger(total) || total < 0) {
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
}
return total;
},
async createRow(table, rowId, data, permissions) {
const result = await send('POST', rowsPath(table), { body: { rowId, data, permissions } });
return result.body;
},
async updateRow(table, id, data, permissions) {
const body = { data };
if (Array.isArray(permissions)) body.permissions = permissions;
const result = await send('PATCH', `${rowsPath(table)}/${segment(id)}`, { body });
return result.body;
},
async deleteRow(table, id) {
const result = await send('DELETE', `${rowsPath(table)}/${segment(id)}`, { missingTypes: [ROW_NOT_FOUND] });
return result.status !== 404;
},
async getFile(bucket, id) {
const result = await send('GET', filePath(bucket, id), { missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async readFileHead(bucket, id, maxBytes = DEFAULT_HEAD_BYTES) {
const result = await send('GET', `${filePath(bucket, id)}/view`, { missingTypes: [FILE_NOT_FOUND], headBytes: maxBytes });
return result.status === 404 ? null : result.body;
},
async updateFilePermissions(bucket, id, permissions) {
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async deleteFile(bucket, id) {
const result = await send('DELETE', filePath(bucket, id), { missingTypes: [FILE_NOT_FOUND] });
return result.status !== 404;
},
/** Prüft ein Nutzer-JWT: Konto aus GET /account (ohne Server-Key), null bei 401. */
async accountFromJwt(jwt) {
try {
const result = await send('GET', '/account', { headers: { 'X-Appwrite-JWT': jwt }, withKey: false });
return result.body;
} catch (err) {
if (err instanceof AppwriteError && err.status === 401) return null;
throw err;
}
},
};
}
module.exports = { createAppwrite, AppwriteError, queryEqual, queryLimit, queryCursorAfter };
+418
View File
@@ -0,0 +1,418 @@
'use strict';
// Tests für deploy/api/appwrite.js gegen einen Fake-Appwrite im Speicher (fake-appwrite.js).
// Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createAppwrite, AppwriteError, queryEqual, queryLimit, queryCursorAfter } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const fakes = [];
afterEach(async () => {
for (const fake of fakes.splice(0)) await fake.close();
});
async function setup(options = {}) {
const fake = await startFakeAppwrite();
fakes.push(fake);
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, ...options });
return { fake, appwrite };
}
const lastCall = (fake) => fake.calls[fake.calls.length - 1];
test('Zeilen: anlegen, lesen, ändern, löschen mit Projekt- und Key-Header', async () => {
const { fake, appwrite } = await setup();
const created = await appwrite.createRow('gallery', 'row1', { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, ['read("any")']);
assert.equal(created.$id, 'row1');
assert.equal(lastCall(fake).method, 'POST');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows');
assert.deepEqual(lastCall(fake).body, {
rowId: 'row1',
data: { title: 'T', imageFileId: 'i', previewFileId: 'p', ownerId: 'o' },
permissions: ['read("any")'],
});
assert.equal(lastCall(fake).headers['x-appwrite-project'], fake.project);
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
assert.match(lastCall(fake).headers['content-type'], /^application\/json/);
assert.equal((await appwrite.getRow('gallery', 'row1')).title, 'T');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/row1');
await appwrite.updateRow('gallery', 'row1', { hidden: true });
assert.equal(lastCall(fake).method, 'PATCH');
assert.deepEqual(lastCall(fake).body, { data: { hidden: true } }, 'ohne permissions-Feld, wenn keine übergeben werden');
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("any")']);
await appwrite.updateRow('gallery', 'row1', { reportCount: 3 }, ['read("user:o")']);
assert.deepEqual(lastCall(fake).body, { data: { reportCount: 3 }, permissions: ['read("user:o")'] });
assert.deepEqual(fake.row('gallery', 'row1').$permissions, ['read("user:o")']);
assert.equal(await appwrite.deleteRow('gallery', 'row1'), true);
assert.equal(fake.row('gallery', 'row1'), null);
});
test('Fehlende Zeile/Datei: getRow und getFile liefern null, deleteRow/deleteFile/updateFilePermissions sind tolerant', async () => {
const { appwrite } = await setup();
assert.equal(await appwrite.getRow('gallery', 'nope'), null);
assert.equal(await appwrite.getFile('gallery-images', 'nope'), null);
assert.equal(await appwrite.deleteRow('gallery', 'nope'), false);
assert.equal(await appwrite.deleteFile('gallery-images', 'nope'), false);
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'nope', ['read("any")']), null);
});
test('Doppelte Zeilen-ID wirft AppwriteError mit Status 409 und Typ row_already_exists', async () => {
const { appwrite } = await setup();
const data = { galleryId: 'g', reason: 'spam', reporterHash: 'h' };
await appwrite.createRow('reports', 'r1', data, []);
await assert.rejects(
() => appwrite.createRow('reports', 'r1', data, []),
(err) => err instanceof AppwriteError && err.status === 409 && err.type === 'row_already_exists',
);
});
test('Andere Fehler (400, 401, 500) werden als AppwriteError geworfen', async () => {
const { fake, appwrite } = await setup();
await assert.rejects(
() => appwrite.createRow('gallery', 'x', { title: 'x'.repeat(61), imageFileId: 'i', previewFileId: 'p', ownerId: 'o' }, []),
(err) => err instanceof AppwriteError && err.status === 400 && err.type === 'document_invalid_structure',
);
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
await assert.rejects(
() => appwrite.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.status === 500,
);
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
await assert.rejects(
() => wrongKey.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.status === 401,
);
});
test('listRows schickt die Queries als queries[]-Parameter und liefert die Zeilen', async () => {
const { fake, appwrite } = await setup();
for (const id of ['a', 'b', 'c']) {
await appwrite.createRow('reports', id, { galleryId: id === 'c' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
}
const rows = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(100)]);
assert.deepEqual(rows.map((row) => row.$id).sort(), ['a', 'b']);
const { search } = lastCall(fake);
const params = search.split('&').map((part) => decodeURIComponent(part));
assert.deepEqual(params, [
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
'queries[]={"method":"limit","values":[100]}',
]);
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
});
test('queryEqual, queryLimit und queryCursorAfter liefern Appwrite-JSON-Strings', () => {
assert.equal(queryEqual('galleryId', ['x']), '{"method":"equal","attribute":"galleryId","values":["x"]}');
assert.equal(queryLimit(100), '{"method":"limit","values":[100]}');
assert.equal(queryCursorAfter('abc'), '{"method":"cursorAfter","values":["abc"]}');
});
test('listRows blättert mit cursorAfter', async () => {
const { appwrite } = await setup();
for (const id of ['a', 'b', 'c', 'd', 'e']) {
await appwrite.createRow('reports', id, { galleryId: 'g1', reason: 'spam', reporterHash: id }, []);
}
const first = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2)]);
assert.deepEqual(first.map((row) => row.$id), ['a', 'b']);
const second = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('b')]);
assert.deepEqual(second.map((row) => row.$id), ['c', 'd']);
const last = await appwrite.listRows('reports', [queryEqual('galleryId', ['g1']), queryLimit(2), queryCursorAfter('d')]);
assert.deepEqual(last.map((row) => row.$id), ['e']);
});
test('countRows: liefert das total der Abfrage (nicht die Länge der Seite) und schickt limit(1) mit', async () => {
const { fake, appwrite } = await setup();
for (const id of ['a', 'b', 'c', 'd']) {
await appwrite.createRow('reports', id, { galleryId: id === 'd' ? 'other' : 'g1', reason: 'spam', reporterHash: id }, []);
}
assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['g1'])]), 3);
const call = lastCall(fake);
assert.equal(call.method, 'GET');
assert.equal(call.path, '/v1/tablesdb/aircard/tables/reports/rows');
assert.deepEqual(call.search.split('&').map((part) => decodeURIComponent(part)), [
'queries[]={"method":"equal","attribute":"galleryId","values":["g1"]}',
'queries[]={"method":"limit","values":[1]}',
]);
assert.equal(call.headers['x-appwrite-key'], fake.key);
assert.equal(await appwrite.countRows('reports', [queryEqual('galleryId', ['nix'])]), 0);
assert.equal(await appwrite.countRows('reports'), 4, 'ohne Abfragen zählt es die ganze Tabelle');
});
test('countRows: fehlendes oder ungültiges total ist ein AppwriteError (502, invalid_response), Appwrite-Fehler werden weitergereicht', async () => {
for (const payload of [{ rows: [] }, { total: '3', rows: [] }, { total: null, rows: [] }, { total: -1, rows: [] }, { total: 1.5, rows: [] }, [], 'text', null]) {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
await assert.rejects(
() => appwrite.countRows('reports', []),
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
JSON.stringify(payload),
);
}
const zero = createAppwrite({ endpoint: 'http://appwrite.invalid/v1', project: 'p', key: 'k', fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }) });
assert.equal(await zero.countRows('reports', []), 0, 'total 0 ist eine gültige Zahl');
const { fake, appwrite } = await setup();
fake.failNext((call) => call.method === 'GET', { status: 500, type: 'general_server_error' });
await assert.rejects(() => appwrite.countRows('reports', []), (err) => err instanceof AppwriteError && err.status === 500);
await assert.rejects(() => appwrite.countRows('keine-tabelle', []), (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'table_not_found');
});
test('Dateien: lesen, Rechte setzen, löschen', async () => {
const { fake, appwrite } = await setup();
fake.addFile({ id: 'f1', owner: 'u1', mimeType: 'image/webp', size: 1234 });
const file = await appwrite.getFile('gallery-images', 'f1');
assert.equal(file.mimeType, 'image/webp');
assert.equal(file.sizeOriginal, 1234);
assert.equal(file.bucketId, 'gallery-images');
assert.deepEqual(file.$permissions, ['read("any")', 'delete("user:u1")']);
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/f1');
await appwrite.updateFilePermissions('gallery-images', 'f1', ['read("user:u1")']);
assert.equal(lastCall(fake).method, 'PUT');
assert.deepEqual(lastCall(fake).body, { permissions: ['read("user:u1")'] });
assert.deepEqual(fake.file('f1').$permissions, ['read("user:u1")']);
assert.equal(await appwrite.deleteFile('gallery-images', 'f1'), true);
assert.equal(fake.file('f1'), null);
});
test('readFileHead: liefert nur die ersten Bytes, mit Server-Key, auch bei einer großen Datei', async () => {
const { fake, appwrite } = await setup();
const content = Buffer.concat([Buffer.from('HEADBYTES'), Buffer.alloc(2 * 1024 * 1024, 7)]);
fake.addFile({ id: 'big', owner: 'u1', content, size: content.length });
const head = await appwrite.readFileHead('gallery-images', 'big', 9);
assert.ok(Buffer.isBuffer(head));
assert.equal(head.toString('latin1'), 'HEADBYTES');
assert.equal(lastCall(fake).method, 'GET');
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/big/view');
assert.equal(lastCall(fake).headers['x-appwrite-key'], fake.key);
const sixtyFour = await appwrite.readFileHead('gallery-images', 'big');
assert.equal(sixtyFour.length, 64, 'Standard: 64 Bytes');
fake.addFile({ id: 'small', owner: 'u1', content: Buffer.from('abc'), size: 3 });
assert.equal((await appwrite.readFileHead('gallery-images', 'small', 64)).toString(), 'abc', 'kürzere Datei: alles');
});
test('readFileHead: fehlende Datei ist null, andere Fehler werfen AppwriteError', async () => {
const { fake, appwrite } = await setup();
assert.equal(await appwrite.readFileHead('gallery-images', 'nope'), null);
fake.addFile({ id: 'f1', owner: 'u1' });
fake.failNext((c) => c.path.endsWith('/files/f1/view'), { status: 500 });
await assert.rejects(() => appwrite.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 500);
const wrongKey = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: 'falscher-key' });
await assert.rejects(() => wrongKey.readFileHead('gallery-images', 'f1'), (err) => err instanceof AppwriteError && err.status === 401);
await assert.rejects(() => appwrite.readFileHead('gallery-images', '..'), TypeError);
});
test('accountFromJwt: JWT-Header ohne Key; 401 gibt null, andere Fehler werfen', async () => {
const { fake, appwrite } = await setup();
fake.addUser('aaa.bbb.ccc', { id: 'u1', labels: ['admin'] });
const user = await appwrite.accountFromJwt('aaa.bbb.ccc');
assert.equal(user.$id, 'u1');
assert.deepEqual(user.labels, ['admin']);
const call = lastCall(fake);
assert.equal(call.path, '/v1/account');
assert.equal(call.headers['x-appwrite-jwt'], 'aaa.bbb.ccc');
assert.equal(call.headers['x-appwrite-project'], fake.project);
assert.equal(call.headers['x-appwrite-key'], undefined, 'das Konto wird nie mit dem Server-Key abgefragt');
assert.equal(await appwrite.accountFromJwt('xxx.yyy.zzz'), null);
fake.failNext((c) => c.path === '/v1/account', { status: 500 });
await assert.rejects(() => appwrite.accountFromJwt('aaa.bbb.ccc'), (err) => err instanceof AppwriteError && err.status === 500);
});
test('Pfadsegmente: jede ID wird kodiert, "." und ".." werden abgelehnt, bevor eine Anfrage rausgeht', async () => {
const { fake, appwrite } = await setup();
await appwrite.getRow('gallery', '../x');
assert.equal(lastCall(fake).path, '/v1/tablesdb/aircard/tables/gallery/rows/..%2Fx');
await appwrite.getFile('gallery-images', 'a b/c');
assert.equal(lastCall(fake).path, '/v1/storage/buckets/gallery-images/files/a%20b%2Fc');
const before = fake.calls.length;
for (const bad of ['..', '.', '', 42, null, undefined]) {
await assert.rejects(() => appwrite.getRow('gallery', bad), /Pfadsegment/);
await assert.rejects(() => appwrite.deleteFile('gallery-images', bad), /Pfadsegment/);
}
await assert.rejects(() => appwrite.getRow('..', 'x'), /Pfadsegment/);
assert.equal(fake.calls.length, before, 'es ging keine Anfrage raus');
});
test('Zeitlimit: Appwrite-Aufrufe tragen standardmäßig AbortSignal.timeout(10000)', async () => {
let seenSignal = null;
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async (url, init) => {
seenSignal = init.signal;
return new Response(JSON.stringify({ $id: 'x' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
},
});
await appwrite.getRow('gallery', 'x');
assert.ok(seenSignal instanceof AbortSignal);
assert.equal(seenSignal.aborted, false);
});
test('Zeitlimit: hängender Appwrite-Aufruf endet mit AppwriteError (timeout)', async () => {
const { fake, appwrite } = await setup({ timeoutMs: 80 });
fake.hangNext((call) => call.path.endsWith('/rows/slow'));
await assert.rejects(
() => appwrite.getRow('gallery', 'slow'),
(err) => err instanceof AppwriteError && err.type === 'timeout' && err.status === 0,
);
});
test('Netzfehler: AppwriteError (unreachable) ohne Key im Text', async () => {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'GEHEIMER-KEY-123',
fetchImpl: async () => {
throw new TypeError('fetch failed GEHEIMER-KEY-123');
},
});
await assert.rejects(
() => appwrite.getRow('gallery', 'x'),
(err) => err instanceof AppwriteError && err.type === 'unreachable' && !String(err.message).includes('GEHEIMER-KEY-123'),
);
});
test('404 gilt nur mit dem erwarteten Typ als „fehlt“, jedes andere 404 ist ein Fehler', async () => {
const { fake, appwrite } = await setup();
const rejectsWith404 = (promise, type) =>
assert.rejects(promise, (err) => err instanceof AppwriteError && err.status === 404 && err.type === type);
// erwartete Typen: fehlt
assert.equal(await appwrite.getRow('gallery', 'x'), null);
assert.equal(await appwrite.deleteRow('gallery', 'x'), false);
assert.equal(await appwrite.getFile('gallery-images', 'x'), null);
assert.equal(await appwrite.deleteFile('gallery-images', 'x'), false);
assert.equal(await appwrite.updateFilePermissions('gallery-images', 'x', []), null);
// unbekannte Tabelle / Datenbank / Bucket: Fehler
await rejectsWith404(appwrite.getRow('keine-tabelle', 'x'), 'table_not_found');
await rejectsWith404(appwrite.deleteRow('keine-tabelle', 'x'), 'table_not_found');
await rejectsWith404(appwrite.listRows('keine-tabelle', []), 'table_not_found');
await rejectsWith404(appwrite.getFile('kein-bucket', 'x'), 'storage_bucket_not_found');
await rejectsWith404(appwrite.updateFilePermissions('kein-bucket', 'x', []), 'storage_bucket_not_found');
await rejectsWith404(appwrite.deleteFile('kein-bucket', 'x'), 'storage_bucket_not_found');
const otherDatabase = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key, database: 'andere-db' });
await rejectsWith404(otherDatabase.getRow('gallery', 'x'), 'database_not_found');
// Typ von der falschen Art (Zeile statt Datei und umgekehrt) und unbekannte Route
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'storage_file_not_found' });
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'storage_file_not_found');
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'row_not_found' });
await rejectsWith404(appwrite.getFile('gallery-images', 'x'), 'row_not_found');
fake.failNext((c) => c.method === 'GET', { status: 404, type: 'general_route_not_found' });
await rejectsWith404(appwrite.getRow('gallery', 'x'), 'general_route_not_found');
});
test('404 ohne JSON-Antwort (z. B. von einem Proxy) ist ein Fehler, kein „fehlt“', async () => {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response('404 page not found', { status: 404 }),
});
for (const call of [
() => appwrite.getRow('gallery', 'x'),
() => appwrite.getFile('gallery-images', 'x'),
() => appwrite.deleteRow('gallery', 'x'),
() => appwrite.deleteFile('gallery-images', 'x'),
() => appwrite.updateFilePermissions('gallery-images', 'x', []),
]) {
await assert.rejects(call, (err) => err instanceof AppwriteError && err.status === 404 && err.type === 'unknown');
}
});
test('listRows: eine Antwort ohne rows-Array wirft einen AppwriteError statt still [] zu liefern', async () => {
for (const payload of [{ total: 0 }, { rows: 'x' }, { rows: null }, [], 'text', null]) {
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } }),
});
await assert.rejects(
() => appwrite.listRows('reports', []),
(err) => err instanceof AppwriteError && err.type === 'invalid_response' && err.status === 502,
JSON.stringify(payload),
);
}
const empty = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async () => new Response(JSON.stringify({ total: 0, rows: [] }), { status: 200 }),
});
assert.deepEqual(await empty.listRows('reports', []), [], 'eine echte leere Liste bleibt eine leere Liste');
});
test('Alle Appwrite-Aufrufe verwenden redirect: "error" (der Key folgt keiner Weiterleitung)', async () => {
const seen = [];
const appwrite = createAppwrite({
endpoint: 'http://appwrite.invalid/v1',
project: 'p',
key: 'k',
fetchImpl: async (url, init) => {
seen.push(init.redirect);
return new Response(JSON.stringify({ total: 0, rows: [], $id: 'x' }), { status: 200 });
},
});
await appwrite.getRow('gallery', 'x');
await appwrite.listRows('gallery', []);
await appwrite.countRows('gallery', []);
await appwrite.createRow('gallery', 'x', {}, []);
await appwrite.updateRow('gallery', 'x', {});
await appwrite.deleteRow('gallery', 'x');
await appwrite.getFile('gallery-images', 'x');
await appwrite.updateFilePermissions('gallery-images', 'x', []);
await appwrite.deleteFile('gallery-images', 'x');
await appwrite.accountFromJwt('a.b.c');
assert.equal(seen.length, 10);
assert.ok(seen.every((value) => value === 'error'));
});
test('Eine Weiterleitung von Appwrite wird nicht verfolgt (echter Server antwortet 302)', async () => {
const target = http.createServer((req, res) => {
target.hits = (target.hits || 0) + 1;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('{}');
});
await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve));
const redirector = http.createServer((req, res) => {
res.writeHead(302, { Location: `http://127.0.0.1:${target.address().port}/gesammelt` });
res.end();
});
await new Promise((resolve) => redirector.listen(0, '127.0.0.1', resolve));
try {
const appwrite = createAppwrite({ endpoint: `http://127.0.0.1:${redirector.address().port}/v1`, project: 'p', key: 'GEHEIMER-KEY' });
await assert.rejects(() => appwrite.getRow('gallery', 'x'), (err) => err instanceof AppwriteError && err.type === 'unreachable');
assert.equal(target.hits || 0, 0, 'das Ziel der Weiterleitung wurde nie angefragt');
} finally {
redirector.closeAllConnections();
target.closeAllConnections();
await new Promise((resolve) => redirector.close(resolve));
await new Promise((resolve) => target.close(resolve));
}
});
+394
View File
@@ -0,0 +1,394 @@
'use strict';
/**
* Gemeinsame Helfer des API-Dienstes `aircard-api` für die Karten (app.js), die Logos (logos.js) und die Benutzernamen
* (profiles.js): Fehlertexte und -codes, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Muster und Sperrliste der Benutzernamen.
* Nur reine Funktionen und Konstanten, kein Zustand.
*
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>}. Texte ohne festen Schlüssel (dynamische
* Prüftexte wie „Der Titel ist zu lang (höchstens 60 Zeichen).“) haben bei Status 400 den code `badRequest`, sonst `error`.
*/
const crypto = require('node:crypto');
const net = require('node:net');
const { AppwriteError } = require('./appwrite.js');
/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
const MAX_PUBLICATIONS = 20;
/** Höchstens so viele Logos je Konto (ausgeblendete zählen mit); Löschen gibt einen Platz frei. */
const MAX_LOGOS = 30;
/** Ab so vielen verschiedenen Meldern wird eine Karte bzw. ein Logo ausgeblendet. */
const HIDE_AT_REPORTS = 3;
/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */
const IMAGE_HEAD_BYTES = 64;
const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'];
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
/** Benutzername: 3-20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich (nur ASCII). */
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
/**
* Gesperrte Benutzernamen als Schlüssel (klein geschrieben, siehe `usernameKey`): Rollen, der Dienst selbst und Platzhalter, die nach
* einem Konto aussehen. Jeder Eintrag passt zu USERNAME_PATTERN (sonst wäre er wirkungslos: der Name scheiterte schon als ungültig).
*/
const RESERVED_USERNAMES = Object.freeze([
'admin',
'administrator',
'root',
'system',
'support',
'moderator',
'mod',
'aircard',
'aircardios',
'aircard-ios',
'aircard.ios',
'aircard_ios',
'webklar',
'anonym',
'anonymous',
'null',
'undefined',
'team',
'staff',
'official',
'offiziell',
'hilfe',
'help',
'kontakt',
'contact',
'info',
]);
const RESERVED_USERNAME_SET = new Set(RESERVED_USERNAMES);
// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts.
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
/**
* Alle Fehlertexte der API, Schlüssel = `code` der Fehlerantwort. Die Texte sind paarweise verschieden (ein Test prüft das):
* `codeFor` findet den Schlüssel über den Text. Die Clients übersetzen Meldungen über den Schlüssel; darum ist MSG ein
* schlichtes Objekt aus Texten, das dieses Modul unverändert exportiert.
*/
const MSG = {
unauthorized: 'Bitte melde dich an.',
notFound: 'Nicht gefunden.',
cardGone: 'Diese Karte ist nicht (mehr) verfügbar.',
forbidden: 'Dazu hast du keine Berechtigung.',
adminOnly: 'Nur für Admins.',
foreignFile: 'Diese Datei gehört dir nicht.',
fileMissing: 'Bild nicht gefunden.',
fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.',
fileSize: 'Das Bild ist größer als 5 MB.',
alreadyPublished: 'Diese Karte ist schon veröffentlicht.',
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
imageFormat: 'Das Bild hat nicht das Kartenformat.',
notYourCard: 'Diese Karte gehört nicht zu deinem Konto.',
tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`,
tooManyReports: 'Zu viele Meldungen – bitte später nochmal.',
badRequest: 'Ungültige Anfrage.',
badCardId: 'Ungültige Karten-ID.',
badReason: 'Bitte wähle einen gültigen Grund.',
badVisibility: 'Ungültige Angabe zur Sichtbarkeit.',
// Logos
logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.',
badLogoId: 'Ungültige Logo-ID.',
logoNameMissing: 'Bitte gib einen Namen an.',
logoFileType: 'Das Logo muss eine PNG-Datei sein.',
logoFileSize: 'Das Logo ist größer als 1 MB.',
logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).',
logoTaken: 'Diese Datei wird schon für ein Logo verwendet.',
tooManyLogos: `Du hast schon ${MAX_LOGOS} Logos hochgeladen. Lösche eins, um ein neues hochzuladen.`,
badHeart: 'Ungültige Angabe zum Herz.',
badMono: 'Ungültige Angabe zu „einfarbig“.',
// Benutzernamen
usernameInvalid:
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.',
usernameReserved: 'Dieser Benutzername ist nicht erlaubt.',
usernameTaken: 'Dieser Benutzername ist schon vergeben.',
usernameRequired: 'Bitte wähle zuerst einen Benutzernamen.',
tooLarge: 'Die Anfrage ist zu groß.',
jsonOnly: 'Nur application/json wird unterstützt.',
unavailable: 'Der Dienst ist gerade nicht erreichbar.',
internal: 'Interner Fehler.',
};
// ---- Fehler ----------------------------------------------------------------------------------------
/** Text -> Schlüssel (die Texte in MSG sind paarweise verschieden, sonst wäre der Code nicht eindeutig). */
const CODE_BY_TEXT = new Map(Object.entries(MSG).map(([key, text]) => [text, key]));
/**
* Der `code` einer Fehlerantwort: der MSG-Schlüssel mit genau diesem Text, sonst `badRequest` bei Status 400 und
* `error` bei jedem anderen Status.
*/
function codeFor(status, message) {
return CODE_BY_TEXT.get(message) || (status === 400 ? 'badRequest' : 'error');
}
class HttpError extends Error {
constructor(status, message, headers = {}, code = codeFor(status, message)) {
super(message);
this.status = status;
this.headers = headers;
this.code = code;
}
}
// ---- Reine Helfer ----------------------------------------------------------------------------------
function isId(value) {
return typeof value === 'string' && ID_PATTERN.test(value);
}
/**
* Der Schlüssel eines Benutzernamens (Spalte `usernameKey`): der Name klein geschrieben. Über ihn ist ein Name nur einmal
* vergeben, egal wie er geschrieben ist („Kenso“ und „kenso“ sind derselbe). Für Zeichenketten, die schon USERNAME_PATTERN erfüllen.
*/
function usernameKey(name) {
return name.toLowerCase();
}
/** Steht der Schlüssel dieses Namens auf der Sperrliste? (In jeder Schreibweise: „Admin“, „AirCard-IOS“.) */
function isReservedUsername(name) {
return RESERVED_USERNAME_SET.has(usernameKey(name));
}
function assertOwner(owner) {
if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID');
return owner;
}
/** Sichtbare Zeile (Karte oder Logo): lesen darf jeder. */
function visibleRowPermissions() {
return ['read("any")'];
}
/** Ausgeblendete Zeile: nur Autor und Admins. */
function hiddenRowPermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")'];
}
/** Herz (Zeile in `logo_likes`): lesen darf nur das Konto selbst. */
function likeRowPermissions(userId) {
assertOwner(userId);
return [`read("user:${userId}")`];
}
/**
* Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit
* Fehlversuche auf). Nur so kommen Dateien in `publish` (Karten) und `uploadLogo` (Logos); die API setzt sie danach auf
* `visibleFilePermissions`.
*/
function uploadFilePermissions(owner) {
assertOwner(owner);
return ['read("any")', `delete("user:${owner}")`];
}
/**
* Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die
* Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen
* löscht die Dateien serverseitig mit dem Key.
*/
function visibleFilePermissions() {
return ['read("any")'];
}
/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */
function hiddenFilePermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")'];
}
function hash32(text) {
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
}
function expandIpv6(address) {
let text = address;
const lastColon = text.lastIndexOf(':');
const tail = text.slice(lastColon + 1);
if (tail.includes('.')) {
const o = tail.split('.').map(Number);
text = `${text.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`;
}
const [head, rest] = text.split('::');
const headGroups = head === '' ? [] : head.split(':');
const restGroups = rest === undefined || rest === '' ? [] : rest.split(':');
const groups = rest === undefined ? headGroups : [...headGroups, ...Array(8 - headGroups.length - restGroups.length).fill('0'), ...restGroups];
return groups.map((group) => Number.parseInt(group, 16));
}
/**
* Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
* { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
*/
function parseIp(ip) {
if (typeof ip !== 'string') return { kind: 'none' };
let text = ip.trim();
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
const zone = text.indexOf('%');
if (zone !== -1) text = text.slice(0, zone);
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text);
if (withPort) text = withPort[1];
if (net.isIPv4(text)) return { kind: 'v4', text };
if (net.isIPv6(text)) {
const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` };
}
return { kind: 'v6', groups };
}
return { kind: 'other', text: text.toLowerCase().slice(0, 64) };
}
const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':');
/**
* Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4,
* jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen).
*/
function normalizeIp(ip) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`;
return parsed.text;
}
/**
* Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4).
* So zählen drei Adressen aus demselben Netz als ein Melder und blenden keine Karte und kein Logo aus.
*/
function reporterNetwork(ip) {
const parsed = parseIp(ip);
if (parsed.kind === 'none') return '';
if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`;
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`;
return parsed.text;
}
function firstHeaderValue(req, name) {
const value = req.headers && req.headers[name];
const text = Array.isArray(value) ? value[0] : value;
if (typeof text !== 'string') return '';
return text.split(',')[0].trim();
}
/** Echte Client-IP: X-Real-Ip (setzt Traefik), sonst erster X-Forwarded-For-Eintrag, sonst Socket. */
function clientIp(req) {
return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || '';
}
function hmacHex(salt, text) {
return crypto.createHmac('sha256', salt).update(text).digest('hex');
}
/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */
function reporterHash(salt, ip) {
return hmacHex(salt, reporterNetwork(ip));
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang).
* @returns {{width: number, height: number} | null}
*/
function readImageSize(bytes) {
if (!Buffer.isBuffer(bytes)) return null;
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') {
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') {
const kind = bytes.toString('latin1', 12, 16);
if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
}
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
const bits = bytes.readUInt32LE(21);
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
}
if (kind === 'VP8X' && bytes.length >= 30) {
return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
}
}
return null;
}
function parseJsonObject(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw new HttpError(400, MSG.badRequest);
}
if (!isPlainObject(parsed)) throw new HttpError(400, MSG.badRequest);
return parsed;
}
function isDuplicate(err) {
return err instanceof AppwriteError && err.status === 409;
}
// ---- Dateiprüfungen (Karten und Logos) -----------------------------------------------------------------
/**
* Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im erwarteten Bucket. Metadaten wie Typ und
* Größe prüft der Aufrufer erst nach `checkFileOwnership`, damit fremde Dateien nichts darüber verraten.
*/
function checkFileExists(file, bucket) {
if (!file) throw new HttpError(400, MSG.fileMissing);
if (file.bucketId !== bucket) throw new HttpError(403, MSG.foreignFile);
}
/**
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") und read("any"). Eine Datei ohne delete(ich) gehört
* jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Dateien schon
* verwendeter Zeilen hat der Aufrufer vorher als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet.
*/
function checkFileOwnership(file, userId) {
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
// Frisch hochgeladene Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
}
module.exports = {
MAX_PUBLICATIONS,
MAX_LOGOS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
ID_PATTERN,
USERNAME_PATTERN,
RESERVED_USERNAMES,
CONTROL_CHARACTERS,
MSG,
codeFor,
HttpError,
isId,
usernameKey,
isReservedUsername,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
reporterNetwork,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
likeRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
};
+478
View File
@@ -0,0 +1,478 @@
'use strict';
/**
* Nur für Tests: Appwrite im Speicher (Teilmenge der REST-API von 1.8.1) als echter HTTP-Server auf Port 0.
*
* GET /v1/account Header X-Appwrite-JWT (+ Projekt, ohne Key)
* GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409
* GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?}
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions}
* GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`)
*
* Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an). Es gibt außerdem die Tabellen
* `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` und die Buckets `gallery-images`, `card-images`,
* `logo-images`. `profiles` hat wie in Appwrite einen Unique-Index auf `usernameKey` (UNIQUE_INDEXES): eine zweite Zeile mit
* demselben Schlüssel, angelegt oder per PATCH geändert, antwortet 409 `row_already_exists` (so meldet Appwrite auch eine
* Verletzung des Unique-Index); Testhilfe: `addProfile`.
*
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Die Antwort enthält wie bei Appwrite `total`:
* die Zahl aller Treffer der equal-Abfragen, unabhängig von limit und cursorAfter (darauf baut `countRows`).
* Zeilen werden gegen die Spaltenlängen der echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im
* Test auffallen.
* Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht),
* so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen.
* Testhilfen: failNext/hangNext/delayNext/holdNext (Aufruf scheitert, hängt, wird verzögert, wartet auf die Freigabe des Tests)
* und swapContentAfterPermissions (Dateiinhalt ändert sich nach dem nächsten Rechte-Update).
*/
const http = require('node:http');
const BUCKETS = ['gallery-images', 'card-images', 'logo-images'];
/** PNG-Anfang (Signatur + IHDR) mit den Maßen; reicht für die Größenprüfung der API. */
function pngHeader(width, height) {
const head = Buffer.alloc(33);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(head, 0);
head.writeUInt32BE(13, 8);
head.write('IHDR', 12, 'latin1');
head.writeUInt32BE(width, 16);
head.writeUInt32BE(height, 20);
head[24] = 8; // Bit-Tiefe
head[25] = 6; // RGBA
return head;
}
/** WebP-Anfang mit den Maßen; kind: 'VP8 ' (verlustbehaftet), 'VP8L' (verlustfrei) oder 'VP8X' (erweitert). */
function webpHeader(kind, width, height) {
const head = Buffer.alloc(40);
head.write('RIFF', 0, 'latin1');
head.writeUInt32LE(32, 4);
head.write('WEBP', 8, 'latin1');
head.write(kind, 12, 'latin1');
if (kind === 'VP8 ') {
head.writeUInt32LE(10, 16);
Buffer.from([0x30, 0x01, 0x00, 0x9d, 0x01, 0x2a]).copy(head, 20);
head.writeUInt16LE(width & 0x3fff, 26);
head.writeUInt16LE(height & 0x3fff, 28);
} else if (kind === 'VP8L') {
head.writeUInt32LE(5, 16);
head[20] = 0x2f;
head.writeUInt32LE(((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14), 21);
} else if (kind === 'VP8X') {
head.writeUInt32LE(10, 16);
head[20] = 0x10; // Alpha-Flag
head.writeUIntLE(width - 1, 24, 3);
head.writeUIntLE(height - 1, 27, 3);
} else {
throw new Error(`Unbekannte WebP-Art: ${kind}`);
}
return head;
}
const COLUMNS = {
gallery: {
title: { size: 60, required: true },
issuer: { size: 40 },
tags: { size: 120 },
displayName: { size: 30 },
imageFileId: { size: 36, required: true },
previewFileId: { size: 36, required: true },
sourceCardId: { size: 36 },
ownerId: { size: 36, required: true },
hidden: { type: 'boolean' },
reportCount: { type: 'integer' },
searchText: { size: 300 },
},
reports: {
galleryId: { size: 36, required: true },
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
reporterHash: { size: 64, required: true },
},
// Spalten wie in web/scripts/appwrite-setup.mjs (Community-Logos)
logos: {
name: { size: 40, required: true },
displayName: { size: 30 },
fileId: { size: 36, required: true },
ownerId: { size: 36, required: true },
mono: { type: 'boolean' },
likeCount: { type: 'integer', min: 0 },
hidden: { type: 'boolean' },
reportCount: { type: 'integer', min: 0 },
},
logo_likes: {
logoId: { size: 36, required: true },
userId: { size: 36, required: true },
},
logo_reports: {
logoId: { size: 36, required: true },
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
reporterHash: { size: 64, required: true },
},
// Benutzernamen: Zeilen-ID = Konto-ID, `usernameKey` = der Name klein geschrieben (Unique-Index)
profiles: {
username: { size: 20, required: true },
usernameKey: { size: 20, required: true },
},
};
/** Unique-Indizes wie in web/scripts/appwrite-setup.mjs: je Tabelle die Spalten, deren Werte nur in einer Zeile vorkommen dürfen. */
const UNIQUE_INDEXES = {
profiles: ['usernameKey'],
};
/** Standardwerte der Spalten, die Appwrite beim Anlegen einsetzt, wenn die Anfrage sie weglässt. */
const DEFAULTS = {
gallery: { hidden: false, reportCount: 0 },
logos: { mono: false, likeCount: 0, hidden: false, reportCount: 0 },
};
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) {
const tables = {
gallery: new Map(),
reports: new Map(),
cards: new Map(),
logos: new Map(),
logo_likes: new Map(),
logo_reports: new Map(),
profiles: new Map(),
};
const files = new Map();
const contents = new Map();
const users = new Map();
const calls = [];
const injections = [];
const swaps = new Map();
let sequence = 0;
function addUser(jwt, { id, labels = [] }) {
users.set(jwt, { $id: id, email: `${id}@example.test`, labels });
}
/** Beliebige Konto-Antwort für `GET /account` (z. B. ohne labels oder mit ungültiger ID). */
function addRawUser(jwt, account) {
users.set(jwt, account);
}
/**
* Datei mit Metadaten und Inhalt. `role` bestimmt den Standardinhalt: 'image' = Kartenbild 1536 x 969, 'preview' =
* Vorschau 480 x 303 (Standard: WebP ist die Vorschau, alles andere das Bild); `content` überschreibt ihn.
*/
function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000, role, content }) {
const kind = role || (mimeType === 'image/webp' ? 'preview' : 'image');
const [width, height] = kind === 'preview' ? [480, 303] : [1536, 969];
files.set(id, {
$id: id,
bucketId: bucket,
name: `${id}.png`,
$permissions: permissions || ['read("any")', `delete("user:${owner}")`],
mimeType,
sizeOriginal: size,
});
contents.set(id, content || (mimeType === 'image/webp' ? webpHeader('VP8 ', width, height) : pngHeader(width, height)));
}
/** Private Karte (Tabelle `cards`) mit den Rechten, die Appwrite dem Besitzer beim Anlegen gibt. */
function addCard(id, owner, permissions) {
return addRow('cards', id, { name: 'Karte' }, permissions || [`read("user:${owner}")`, `update("user:${owner}")`, `delete("user:${owner}")`]);
}
/** Benutzername eines Kontos (Tabelle `profiles`) mit den Rechten, die die API vergibt: Zeilen-ID = Konto-ID, öffentlich lesbar. */
function addProfile(userId, username = userId) {
return addRow('profiles', userId, { username, usernameKey: username.toLowerCase() }, ['read("any")']);
}
function addRow(table, id, data, permissions) {
const row = { $id: id, $createdAt: new Date(1_700_000_000_000 + (++sequence) * 1000).toISOString(), $permissions: permissions, ...data };
tables[table].set(id, row);
return row;
}
/** Nächster passender Aufruf (predicate(call) -> boolean) scheitert mit Status/Typ. */
function failNext(predicate, { status = 500, type = 'general_server_error' } = {}) {
injections.push({ predicate, status, type, hang: false });
}
/** Nächster passender Aufruf bekommt nie eine Antwort. */
function hangNext(predicate) {
injections.push({ predicate, hang: true });
}
/** Nächster passender Aufruf wird erst nach `delayMs` bearbeitet (Zustand ändert sich erst dann). */
function delayNext(predicate, delayMs) {
injections.push({ predicate, delayMs });
}
/**
* Nächster passender Aufruf wartet, bis die zurückgegebene Funktion aufgerufen wird (er steht dann schon in `calls`). So erzwingt
* ein Test eine Reihenfolge ohne Zeitannahmen, etwa: beide Anfragen haben vorab geprüft, bevor eine von ihnen schreibt.
* @returns {() => void} gibt den Aufruf frei
*/
function holdNext(predicate) {
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
injections.push({ predicate, gate });
return release;
}
/**
* Nach dem nächsten Rechte-Update (PUT) der Datei `id` hat sie den Inhalt `content` (einmalig). So lässt sich eine Datei
* nachstellen, die zwischen der ersten Prüfung und dem Festlegen der Rechte ausgetauscht wurde: der Anfang ist beim ersten
* Lesen gültig und danach ein anderer.
*/
function swapContentAfterPermissions(id, content) {
swaps.set(id, content);
}
function validateData(table, data, { partial }) {
const columns = COLUMNS[table];
for (const [name, value] of Object.entries(data)) {
const column = columns[name];
if (!column) return `Unknown attribute: ${name}`;
if (column.type === 'boolean' && typeof value !== 'boolean') return `Invalid ${name}`;
if (column.type === 'integer' && (!Number.isInteger(value) || (column.min !== undefined && value < column.min))) return `Invalid ${name}`;
if (column.enum && !column.enum.includes(value)) return `Invalid ${name}`;
if (column.size !== undefined) {
if (typeof value !== 'string') return `Invalid ${name}`;
if (value.length > column.size) return `Invalid ${name}: too long`;
}
}
if (!partial) {
for (const [name, column] of Object.entries(columns)) {
if (column.required && data[name] === undefined) return `Missing required attribute: ${name}`;
}
}
return null;
}
/** Hat eine andere Zeile der Tabelle schon diese Werte in den Spalten eines Unique-Index? (`values` = die Zeile, wie sie danach aussähe.) */
function violatesUnique(table, id, values) {
const columns = UNIQUE_INDEXES[table];
if (!columns) return false;
return [...tables[table].values()].some((row) => row.$id !== id && columns.every((name) => row[name] === values[name]));
}
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
const raw = Buffer.concat(chunks).toString('utf8');
const [pathname, search = ''] = req.url.split('?');
const call = {
method: req.method,
path: pathname,
search,
headers: req.headers,
body: raw === '' ? null : safeParse(raw),
};
calls.push(call);
const reply = (status, payload) => {
if (status === 204) {
res.writeHead(204);
res.end();
return;
}
const text = JSON.stringify(payload);
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
res.end(text);
};
const error = (status, type, message = type) => reply(status, { message, code: status, type });
const injectionIndex = injections.findIndex((entry) => entry.predicate(call));
if (injectionIndex !== -1) {
const [entry] = injections.splice(injectionIndex, 1);
if (entry.hang) return;
if (entry.gate) {
await entry.gate;
} else if (entry.delayMs) {
await new Promise((resolve) => setTimeout(resolve, entry.delayMs));
} else {
return error(entry.status, entry.type);
}
}
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
if (segments[0] !== 'v1' || req.headers['x-appwrite-project'] !== project) {
return error(401, 'general_unauthorized_scope', 'Missing or wrong project');
}
// GET /v1/account (JWT, ohne Key)
if (segments.length === 2 && segments[1] === 'account' && req.method === 'GET') {
const user = users.get(req.headers['x-appwrite-jwt']);
if (!user) return error(401, 'user_jwt_invalid', 'Invalid JWT');
return reply(200, user);
}
if (req.headers['x-appwrite-key'] !== key) return error(401, 'general_unauthorized_scope', 'Missing or wrong key');
// Zeilen
if (segments[1] === 'tablesdb' && segments[3] === 'tables' && segments[5] === 'rows') {
const table = segments[4];
if (segments[2] !== database) return error(404, 'database_not_found');
if (!tables[table]) return error(404, 'table_not_found');
const store = tables[table];
if (segments.length === 6 && req.method === 'POST') {
const body = call.body || {};
if (typeof body.rowId !== 'string' || body.rowId === '') return error(400, 'general_argument_invalid', 'rowId is required');
if (store.has(body.rowId)) return error(409, 'row_already_exists');
const data = body.data || {};
const problem = validateData(table, data, { partial: false });
if (problem) return error(400, 'document_invalid_structure', problem);
const values = { ...DEFAULTS[table], ...data };
if (violatesUnique(table, body.rowId, values)) return error(409, 'row_already_exists');
const row = addRow(table, body.rowId, values, body.permissions || []);
return reply(201, row);
}
if (segments.length === 6 && req.method === 'GET') {
const found = runQueries([...store.values()], search);
if (found === null) return error(400, 'general_query_invalid');
return reply(200, found);
}
if (segments.length === 7) {
const row = store.get(segments[6]);
if (!row) return error(404, 'row_not_found');
if (req.method === 'GET') return reply(200, row);
if (req.method === 'DELETE') {
store.delete(segments[6]);
return reply(204);
}
if (req.method === 'PATCH') {
const body = call.body || {};
const data = body.data || {};
const problem = validateData(table, data, { partial: true });
if (problem) return error(400, 'document_invalid_structure', problem);
if (violatesUnique(table, segments[6], { ...row, ...data })) return error(409, 'row_already_exists');
Object.assign(row, data);
if (Array.isArray(body.permissions)) row.$permissions = body.permissions;
return reply(200, row);
}
}
}
// Dateiinhalt: GET .../files/{id}/view
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 7 && segments[6] === 'view' && req.method === 'GET') {
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
const file = files.get(segments[5]);
if (!file) return error(404, 'storage_file_not_found');
const content = contents.get(segments[5]);
res.writeHead(200, { 'Content-Type': file.mimeType, 'Content-Length': content.length });
res.end(content);
return;
}
// Dateien
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) {
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
const file = files.get(segments[5]);
if (!file) return error(404, 'storage_file_not_found');
if (req.method === 'GET') return reply(200, file);
if (req.method === 'DELETE') {
files.delete(segments[5]);
contents.delete(segments[5]);
return reply(204);
}
if (req.method === 'PUT') {
const body = call.body || {};
if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid');
file.$permissions = body.permissions;
if (swaps.has(segments[5])) {
contents.set(segments[5], swaps.get(segments[5]));
swaps.delete(segments[5]);
}
return reply(200, file);
}
}
return error(404, 'general_route_not_found');
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
return {
url: `http://127.0.0.1:${port}`,
endpoint: `http://127.0.0.1:${port}/v1`,
project,
key,
calls,
addUser,
addRawUser,
addFile,
addCard,
addProfile,
addRow,
failNext,
hangNext,
delayNext,
holdNext,
swapContentAfterPermissions,
row: (table, id) => tables[table].get(id) || null,
rows: (table) => [...tables[table].values()],
file: (id) => files.get(id) || null,
close: async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
},
};
}
function decodeSegment(segment) {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function safeParse(text) {
try {
return JSON.parse(text);
} catch {
return { _unparsable: text };
}
}
/**
* Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage.
* Liefert {total, rows}: `total` zählt alle Treffer der equal-Abfragen, `rows` ist die Seite nach cursorAfter und limit.
*/
function runQueries(rows, search) {
let result = rows;
let limit = 25;
let cursor = null;
for (const part of search.split('&').filter(Boolean)) {
const eq = part.indexOf('=');
const name = decodeURIComponent(part.slice(0, eq));
if (name !== 'queries[]') return null;
let query;
try {
query = JSON.parse(decodeURIComponent(part.slice(eq + 1)));
} catch {
return null;
}
if (query.method === 'equal') {
result = result.filter((row) => query.values.includes(row[query.attribute]));
} else if (query.method === 'limit') {
limit = query.values[0];
} else if (query.method === 'cursorAfter') {
cursor = query.values[0];
} else {
return null;
}
}
const total = result.length;
if (cursor !== null) {
const index = result.findIndex((row) => row.$id === cursor);
if (index === -1) return null;
result = result.slice(index + 1);
}
return { total, rows: result.slice(0, limit) };
}
module.exports = { startFakeAppwrite, pngHeader, webpHeader };
+437
View File
@@ -0,0 +1,437 @@
'use strict';
/**
* Endpunkte der Community-Logos im API-Dienst `aircard-api` (Node 22, nur Bordmittel). Sie folgen den Karten-Endpunkten
* in app.js: gleiche Reihenfolge der Prüfungen, gleiche Rücknahme beim Hochladen, gleiche Melde- und Ausblend-Logik.
* app.js hängt die Routen an und gibt Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log mit.
*
* POST /api/logos/hochladen Nutzer-JWT {name, displayName?, mono?, fileId}
* 401 · 403 `usernameRequired` (das Konto hat noch keinen Benutzernamen; vor allen anderen Prüfungen) · 400 Validierung,
* Datei fehlt, kein PNG, größer als 1 MiB, Maße nicht 8 × 8 bis 720 × 250 · 403 Datei nicht
* meine / falscher Bucket / Datei gesperrt (kein read("any")) · 409 Datei schon für ein Logo verwendet · 429 schon
* 30 Logos · 201 {id} (id = Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID)
* Nach dem Festlegen der Datei auf read("any") wird ihr Anfang noch einmal geprüft (der Hochlader kann sie dann nicht
* mehr ändern): fällt sie durch oder fehlt sie, werden Datei und Zeile gelöscht und die Antwort ist 400.
* POST /api/logos/herz Nutzer-JWT {id, herz}
* 401 · 400 · 404 fehlt oder ausgeblendet · 200 {herz, likeCount}
* POST /api/logos/melden ohne Anmeldung {id, reason} (wie /api/melden, Rate-Limit gemeinsam)
* 400 · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
* POST /api/logos/loeschen Nutzer-JWT (Hochlader oder Admin) {id}
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
* Blendet das Logo zuerst aus (Datei und Zeile), löscht dann Datei, Herzen, Meldungen und zuletzt die Zeile: ein Abbruch
* hinterlässt höchstens eine unsichtbare Zeile, nie eine öffentliche mit gelöschter Datei.
* POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
*
* Tabellen `logos` (Zeilen-ID = hash32(`<Konto>|<Datei>`), siehe logoRowId; Altlogos haben noch Zeilen-ID = Datei-ID), `logo_likes`,
* `logo_reports`, Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows),
* nie hoch- oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo (auch beim Hochladen,
* vom Anlegen der Zeile bis zur zweiten Prüfung, innerhalb der Sperre des Kontos).
* Logs nennen nur Logo-IDs und Zahlen, nie Namen, IPs, Hashes oder JWTs; Herzen werden nicht geloggt.
*/
const { queryEqual, queryLimit } = require('./appwrite.js');
const {
MAX_LOGOS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
CONTROL_CHARACTERS,
MSG,
HttpError,
isId,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
likeRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
} = require('./common.js');
const { hasProfile } = require('./profiles.js');
const TABLE_LOGOS = 'logos';
const TABLE_LIKES = 'logo_likes';
const TABLE_REPORTS = 'logo_reports';
const BUCKET = 'logo-images';
const MAX_LOGO_BYTES = 1024 * 1024;
/** Maße des fertigen Logos (wie die Verarbeitung im Editor): 8 × 8 bis 720 × 250 Pixel. */
const LOGO_MIN_SIDE = 8;
const LOGO_MAX_WIDTH = 720;
const LOGO_MAX_HEIGHT = 250;
/** Herzen und Meldungen eines Logos werden in Runden zu 100 gelöscht, höchstens 50 Runden. */
const ROW_BATCH = 100;
const ROW_ROUNDS = 50;
const LOGO_TEXT_FIELDS = [
{ key: 'name', label: 'Der Name', max: 40, required: true },
{ key: 'displayName', label: 'Der Anzeigename', max: 30, required: false },
];
// ---- Reine Helfer ----------------------------------------------------------------------------------
/**
* Feste Zeilen-ID je Konto und Datei: dieselbe Datei desselben Kontos ergibt dieselbe ID (Appwrite-409 beim Anlegen). Die ID ist
* nicht die Datei-ID: die Zeilen-ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten, und jeder
* könnte eine Datei mit genau dieser ID anlegen und als Logo hochladen. Mit dem Konto im Hash lässt sich die alte ID so nicht neu belegen.
* Ältere Logos haben noch Zeilen-ID = Datei-ID; alle anderen Endpunkte adressieren die Zeile nur über ihre ID und lesen die Datei
* aus `fileId`, daher gehen beide Arten.
*/
function logoRowId(ownerId, fileId) {
return hash32(`${ownerId}|${fileId}`);
}
/** Feste Zeilen-ID je Logo und Nutzer: ein Herz pro Konto, Appwrite-409 ersetzt jede Doppelt-Prüfung. */
function logoLikeId(logoId, userId) {
return hash32(`${logoId}|${userId}`);
}
/** Feste Zeilen-ID je Logo und Melder (`logo|` trennt sie von den Karten-Meldungen): eine Meldung pro Melder und Logo. */
function logoReportId(logoId, reporter) {
return hash32(`logo|${logoId}|${reporter}`);
}
function readLogoText(raw, field) {
if (raw === undefined || raw === null) return field.required ? { message: MSG.logoNameMissing } : { value: '' };
if (typeof raw !== 'string') return { message: `${field.label} ist ungültig.` };
const value = raw.trim();
if (value === '' && field.required) return { message: MSG.logoNameMissing };
if (CONTROL_CHARACTERS.test(value)) return { message: `${field.label} enthält ungültige Zeichen.` };
if (value.length > field.max) return { message: `${field.label} ist zu lang (höchstens ${field.max} Zeichen).` };
return { value };
}
/**
* Prüft den Hochladen-Body: Name 1-40, Anzeigename ≤ 30 (getrimmt, ohne Steuer- und Bidi-Zeichen), `mono` fehlt = false
* (sonst muss es ein Boolean sein), `fileId` nach ID_PATTERN.
* @returns {{ok: true, data: {name: string, displayName: string, mono: boolean, fileId: string}} | {ok: false, message: string}}
*/
function validateLogoUpload(body) {
if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest };
const data = {};
for (const field of LOGO_TEXT_FIELDS) {
const result = readLogoText(body[field.key], field);
if (result.message) return { ok: false, message: result.message };
data[field.key] = result.value;
}
if (body.mono !== undefined && typeof body.mono !== 'boolean') return { ok: false, message: MSG.badMono };
data.mono = body.mono === true;
if (!isId(body.fileId)) return { ok: false, message: 'Ungültige Datei-ID.' };
data.fileId = body.fileId;
return { ok: true, data };
}
/** Typ und Größe laut Appwrite (erst nach der Besitzprüfung, damit fremde Dateien nichts darüber verraten). */
function checkLogoContent(file) {
if (file.mimeType !== 'image/png') throw new HttpError(400, MSG.logoFileType);
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_LOGO_BYTES) throw new HttpError(400, MSG.logoFileSize);
}
function isLogoSize(head) {
// readImageSize kennt auch WebP: ein Logo muss einen PNG-Kopf haben, nicht nur den Dateityp laut Appwrite
if (head.toString('latin1', 1, 4) !== 'PNG') return false;
const size = readImageSize(head);
return (
size !== null &&
size.width >= LOGO_MIN_SIDE &&
size.height >= LOGO_MIN_SIDE &&
size.width <= LOGO_MAX_WIDTH &&
size.height <= LOGO_MAX_HEIGHT
);
}
// ---- Die Routen ------------------------------------------------------------------------------------
/**
* @param {object} options
* @param {object} options.appwrite Objekt aus createAppwrite
* @param {string} options.salt Salz für die Melder-Hashes
* @param {(message: string) => void} options.say Ereigniszeile ins Log (mit Zeilenbudget)
* @param {(key: string, fn: () => Promise<any>) => Promise<any>} options.withLock Je Schlüssel nacheinander
* @param {(key: string) => {allowed: boolean, retryAfter?: number}} options.rateLimit das Rate-Limit der Meldungen (gemeinsam mit den Karten)
* @param {(req) => Promise<{id: string, isAdmin: boolean}>} options.requireUser wirft 401 ohne gültiges JWT
* @returns {Array<[string, {name: string, json: true, run: (ctx: {req, raw: string}) => Promise<{status: number, body: object}>}]>}
*/
function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) {
const withLogoLock = (id, fn) => withLock(`logo:${id}`, fn);
/** Setzt die Rechte der Logo-Datei; null heißt: die Datei gibt es nicht (mehr). */
function setFilePermissions(row, permissions) {
return isId(row.fileId) ? appwrite.updateFilePermissions(BUCKET, row.fileId, permissions) : Promise.resolve(null);
}
/** Datei zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
async function hideLogo(id, row, extra = {}) {
await setFilePermissions(row, hiddenFilePermissions(row.ownerId));
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId));
}
/** Löscht alle Zeilen der Tabelle zu diesem Logo, in Runden (die Zeile des Logos selbst bleibt der Anker für eine Wiederholung). */
async function deleteRowsOf(table, logoId) {
for (let round = 0; round < ROW_ROUNDS; round += 1) {
const rows = await appwrite.listRows(table, [queryEqual('logoId', [logoId]), queryLimit(ROW_BATCH)]);
if (rows.length === 0) return;
for (const row of rows) await appwrite.deleteRow(table, row.$id);
}
}
/** reportCount = Anzahl Meldungszeilen des Logos (neu gezählt); ab 3 wird ausgeblendet. */
async function syncReports(id, row) {
const count = await appwrite.countRows(TABLE_REPORTS, [queryEqual('logoId', [id])]);
if (count >= HIDE_AT_REPORTS) {
await hideLogo(id, row, { reportCount: count });
say(`logo ${id} hidden automatically (${count} reports)`);
} else if (row.reportCount !== count) {
await appwrite.updateRow(TABLE_LOGOS, id, { reportCount: count });
}
}
/**
* Liest den Anfang der Datei und verlangt einen PNG-Kopf mit Maßen von 8 × 8 bis 720 × 250.
* @returns {Promise<HttpError | null>} das Urteil (400 fileMissing bzw. logoFormat), null = in Ordnung; Appwrite-Fehler werfen
*/
async function logoSizeProblem(fileId) {
const head = await appwrite.readFileHead(BUCKET, fileId, IMAGE_HEAD_BYTES);
if (head === null) return new HttpError(400, MSG.fileMissing);
return isLogoSize(head) ? null : new HttpError(400, MSG.logoFormat);
}
async function checkLogoSize(fileId) {
const problem = await logoSizeProblem(fileId);
if (problem !== null) throw problem;
}
/**
* Das Logo ist nach dem Festlegen der Rechte durchgefallen (Inhalt ausgetauscht oder Datei weg): die Datei mit dem
* Server-Key löschen, dann die Zeile. Beides wird versucht, auch wenn das erste scheitert: die Zeile macht das Logo für
* andere sichtbar. Bleibt etwas stehen, steht `rollback failed for logo <id> (file <id>)` im Log (mit der Datei-ID: sie
* folgt nicht aus der Zeilen-ID, und bleibt nur die Datei liegen, gibt es keine Zeile mehr, an der man sie fände).
*/
async function removeRejectedLogo(rowId, fileId, problem) {
let clean = true;
await appwrite.deleteFile(BUCKET, fileId).catch(() => {
clean = false;
});
await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
clean = false;
});
say(clean ? `logo ${rowId} removed after upload check (${problem.code})` : `rollback failed for logo ${rowId} (file ${fileId})`);
}
async function uploadLogo({ req, raw }) {
const user = await requireUser(req);
// Ohne Benutzernamen geht nichts weiter: vor allen anderen Prüfungen, es wird nichts angelegt oder angefasst.
if (!(await hasProfile(appwrite, user.id))) throw new HttpError(403, MSG.usernameRequired);
const validation = validateLogoUpload(parseJsonObject(raw));
if (!validation.ok) throw new HttpError(400, validation.message);
const data = validation.data;
const file = await appwrite.getFile(BUCKET, data.fileId);
checkFileExists(file, BUCKET);
const rowId = logoRowId(user.id, data.fileId);
// Je Konto nacheinander: Limit, Prüfung „Datei schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
return withLock(`logo-owner:${user.id}`, async () => {
// Die Zeilen dieses Kontos (auch ausgeblendete), höchstens MAX_LOGOS: ab dort ist das Limit erreicht.
const rows = await appwrite.listRows(TABLE_LOGOS, [queryEqual('ownerId', [user.id]), queryLimit(MAX_LOGOS)]);
if (rows.length >= MAX_LOGOS) throw new HttpError(429, MSG.tooManyLogos);
// Dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung): entweder unter dieser
// Zeilen-ID oder, bei einem Altlogo mit Zeilen-ID = Datei-ID, über die `fileId` der Zeile.
if (rows.some((row) => row.$id === rowId || row.fileId === data.fileId)) throw new HttpError(409, MSG.logoTaken);
checkFileOwnership(file, user.id);
checkLogoContent(file);
await checkLogoSize(data.fileId);
// Ab dem Anlegen steht die Zeile öffentlich in der Liste. Solange die Prüfung das Logo noch entfernen kann, dürfen Herz, Meldung
// und Löschen nicht dazwischenkommen (ihre Zeilen blieben ohne Logo zurück): der Upload hält die Sperre des Logos bis zum Ende.
// Nur das Hochladen nimmt zwei Sperren, immer erst die des Kontos, dann die des Logos; alle anderen Anfragen nehmen nur die des
// Logos, so kann sich nichts verklemmen.
return withLogoLock(rowId, async () => {
try {
await appwrite.createRow(
TABLE_LOGOS,
rowId,
{
name: data.name,
displayName: data.displayName,
fileId: data.fileId,
ownerId: user.id,
mono: data.mono,
likeCount: 0,
hidden: false,
reportCount: 0,
},
visibleRowPermissions(),
);
} catch (err) {
// Eine Zeile mit dieser ID gibt es schon, steht aber nicht in der Liste des Kontos (ein Rest): das Logo ist vergeben.
if (isDuplicate(err)) throw new HttpError(409, MSG.logoTaken);
throw err;
}
// Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen).
// Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen.
let problem = null;
try {
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
// Jetzt hat der Hochlader kein delete mehr und kann die Datei nicht mehr ändern: den Anfang noch einmal prüfen. So
// lässt sich die Datei nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen. Ein
// Appwrite-Fehler hier wird wie ein Fehler beim Festlegen behandelt (Rücknahme unten, nichts bleibt veröffentlicht).
problem = await logoSizeProblem(data.fileId);
} catch (err) {
let rolledBack = true;
await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
rolledBack = false;
say(`rollback failed for logo ${rowId} (file ${data.fileId})`);
});
// Nur wenn keine Zeile mehr auf die Datei zeigt, bekommt sie delete(owner) zurück (der Client räumt damit auf). Scheitert
// auch das, bleibt die Datei ohne Zeile und ohne delete(owner) liegen: die Logzeile nennt ihre ID, nur so findet man sie.
if (rolledBack) {
await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {
say(`restore failed for logo ${rowId} (file ${data.fileId})`);
});
}
throw err;
}
if (problem !== null) {
await removeRejectedLogo(rowId, data.fileId, problem);
throw problem;
}
say(`logo ${rowId} uploaded`);
return { status: 201, body: { id: rowId } };
});
});
}
async function setHeart({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.herz !== 'boolean') throw new HttpError(400, MSG.badHeart);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
const likeId = logoLikeId(id, user.id);
if (body.herz) {
try {
await appwrite.createRow(TABLE_LIKES, likeId, { logoId: id, userId: user.id }, likeRowPermissions(user.id));
} catch (err) {
if (!isDuplicate(err)) throw err; // schon da: kein Fehler
}
} else {
await appwrite.deleteRow(TABLE_LIKES, likeId); // schon weg (false): kein Fehler
}
// Immer neu zählen statt hoch- oder runterzählen: das heilt einen früher steckengebliebenen Zählerstand.
const likeCount = await appwrite.countRows(TABLE_LIKES, [queryEqual('logoId', [id])]);
if (row.likeCount !== likeCount) await appwrite.updateRow(TABLE_LOGOS, id, { likeCount });
return { status: 200, body: { herz: body.herz, likeCount } };
});
}
async function reportLogo({ req, raw }) {
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
const id = body.id;
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt je Anschluss und gilt für Karten und Logos zusammen.
const ip = clientIp(req);
const reporter = reporterHash(salt, ip);
const limit = rateLimit(hmacHex(salt, normalizeIp(ip)));
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
let isNew = true;
try {
await appwrite.createRow(TABLE_REPORTS, logoReportId(id, reporter), { logoId: id, reason: body.reason, reporterHash: reporter }, []);
} catch (err) {
if (!isDuplicate(err)) throw err;
isNew = false;
}
// Auch bei einer doppelten Meldung neu zählen: heilt einen früher steckengebliebenen Zählerstand.
await syncReports(id, row);
if (isNew) say(`logo ${id} reported (${body.reason})`);
return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } };
});
}
async function deleteLogo({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row) throw new HttpError(404, MSG.logoGone);
if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden);
// Zuerst nicht mehr öffentlich (wie beim Ausblenden: Datei, dann Zeile). Bricht das Löschen ab, nachdem die Datei weg ist, bliebe
// sonst eine öffentliche Zeile mit einer Datei-ID zurück, unter der jedes Konto eine neue Datei anlegen könnte: alle gespeicherten
// Karten zeigten dann deren Bild. So bleibt höchstens eine unsichtbare Zeile; die Wiederholung (Hochlader oder Admin) vollendet es.
if (row.hidden !== true) await hideLogo(id, row);
// Dann Datei, Herzen, Meldungen, die Zeile zuletzt: scheitert etwas, bleibt sie als Anker für die Wiederholung stehen.
if (isId(row.fileId)) await appwrite.deleteFile(BUCKET, row.fileId);
await deleteRowsOf(TABLE_LIKES, id);
await deleteRowsOf(TABLE_REPORTS, id);
await appwrite.deleteRow(TABLE_LOGOS, id);
say(`logo ${id} deleted${row.ownerId === user.id ? '' : ' by admin'}`);
return { status: 200, body: { ok: true } };
});
}
async function setVisibility({ req, raw }) {
const user = await requireUser(req);
if (!user.isAdmin) throw new HttpError(403, MSG.adminOnly);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row) throw new HttpError(404, MSG.logoGone);
if (body.sichtbar) {
// Datei, dann Meldungen, Zeile zuletzt: sichtbar wird das Logo erst, wenn alles andere steht.
await setFilePermissions(row, visibleFilePermissions());
await deleteRowsOf(TABLE_REPORTS, id);
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: false, reportCount: 0 }, visibleRowPermissions());
say(`logo ${id} made visible by admin`);
} else {
await hideLogo(id, row);
say(`logo ${id} hidden by admin`);
}
return { status: 200, body: { ok: true } };
});
}
return [
['POST /api/logos/hochladen', { name: 'logos/hochladen', json: true, run: uploadLogo }],
['POST /api/logos/herz', { name: 'logos/herz', json: true, run: setHeart }],
['POST /api/logos/melden', { name: 'logos/melden', json: true, run: reportLogo }],
['POST /api/logos/loeschen', { name: 'logos/loeschen', json: true, run: deleteLogo }],
['POST /api/admin/logo-sichtbarkeit', { name: 'admin/logo-sichtbarkeit', json: true, run: setVisibility }],
];
}
module.exports = { createLogoRoutes, logoRowId, logoLikeId, logoReportId, validateLogoUpload };
File diff suppressed because it is too large. Load diff
+88
View File
@@ -0,0 +1,88 @@
'use strict';
/**
* Benutzernamen im API-Dienst `aircard-api` (Node 22, nur Bordmittel): ein Name je Konto, eindeutig ohne Rücksicht auf Groß- und
* Kleinschreibung. app.js hängt die Route an und gibt Anmeldung, Sperre je Schlüssel und das Log mit (wie bei logos.js).
*
* POST /api/profil Nutzer-JWT {username}
* 401 · 400 `usernameInvalid` (Muster) · 400 `usernameReserved` (Sperrliste) · 409 `usernameTaken` (ein anderes Konto hat den
* Namen) · 200 {username}
* Legt die Zeile des Kontos an (Zeilen-ID = Konto-ID, Recht genau read("any")) oder ändert sie: der alte Name ist damit sofort
* wieder frei. Die eigene Schreibweise zu ändern („kenso“ → „Kenso“) ist erlaubt.
*
* Tabelle `profiles`: `username` (der Name, wie eingegeben) und `usernameKey` (klein geschrieben). Eindeutig ist `usernameKey`: die
* Vorabprüfung fängt den Normalfall mit einer klaren Antwort ab, bei einem Rennen zweier Konten entscheidet der Unique-Index
* `username_key` (Appwrite antwortet 409, das wird ebenfalls `usernameTaken`). Sperre je Konto: `profile:<Konto>`.
* Hochladen und Veröffentlichen verlangen ein Profil (`hasProfile`, 403 `usernameRequired`), siehe logos.js und app.js.
* Logs nennen nie Namen, Schlüssel, Konto-IDs oder JWTs: nur „profile created“ bzw. „profile updated“.
*/
const { queryEqual, queryLimit } = require('./appwrite.js');
const {
MSG,
USERNAME_PATTERN,
HttpError,
isDuplicate,
parseJsonObject,
visibleRowPermissions,
usernameKey,
isReservedUsername,
} = require('./common.js');
const TABLE_PROFILES = 'profiles';
/**
* Hat das Konto einen Benutzernamen? Die Zeilen-ID des Profils ist die Konto-ID. Ein Appwrite-Fehler wird nicht verschluckt
* (auch eine fehlende Tabelle nicht): der Aufrufer antwortet dann mit 502 bzw. 500, nie mit „kein Profil“.
* @param {object} appwrite Objekt aus createAppwrite
* @param {string} userId Konto-ID
* @returns {Promise<boolean>}
*/
async function hasProfile(appwrite, userId) {
return (await appwrite.getRow(TABLE_PROFILES, userId)) !== null;
}
/**
* @param {object} options
* @param {object} options.appwrite Objekt aus createAppwrite
* @param {(message: string) => void} options.say Ereigniszeile ins Log (mit Zeilenbudget)
* @param {(key: string, fn: () => Promise<any>) => Promise<any>} options.withLock Je Schlüssel nacheinander
* @param {(req) => Promise<{id: string, isAdmin: boolean}>} options.requireUser wirft 401 ohne gültiges JWT
* @returns {Array<[string, {name: string, json: true, run: (ctx: {req, raw: string}) => Promise<{status: number, body: object}>}]>}
*/
function createProfileRoutes({ appwrite, say, withLock, requireUser }) {
async function saveProfile({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
// Kein Trimmen: das Muster lässt keine Leerzeichen zu, der Client schickt den Namen schon bereinigt.
const { username } = body;
if (typeof username !== 'string' || !USERNAME_PATTERN.test(username)) throw new HttpError(400, MSG.usernameInvalid);
if (isReservedUsername(username)) throw new HttpError(400, MSG.usernameReserved);
const key = usernameKey(username);
// Je Konto nacheinander: Nachsehen, ob es schon eine Zeile gibt, und Anlegen bzw. Ändern dürfen sich nicht überholen
// (zwei Anfragen desselben Kontos würden sonst beide anlegen wollen und die zweite fälschlich „vergeben“ melden).
return withLock(`profile:${user.id}`, async () => {
// Vorabprüfung: hat ein anderes Konto diesen Schlüssel? Der eigene Eintrag zählt nicht (nur die Schreibweise ändern ist erlaubt).
const holders = await appwrite.listRows(TABLE_PROFILES, [queryEqual('usernameKey', [key]), queryLimit(1)]);
if (holders.some((row) => row.$id !== user.id)) throw new HttpError(409, MSG.usernameTaken);
const own = await appwrite.getRow(TABLE_PROFILES, user.id);
try {
if (own) await appwrite.updateRow(TABLE_PROFILES, user.id, { username, usernameKey: key });
else await appwrite.createRow(TABLE_PROFILES, user.id, { username, usernameKey: key }, visibleRowPermissions());
} catch (err) {
// Ein anderes Konto war schneller: der Unique-Index auf usernameKey meldet 409, beim Anlegen wie beim Ändern. Ein 409 wegen der
// eigenen Zeilen-ID gibt es nicht: unter der Sperre des Kontos gibt es die Zeile jetzt (dann ändern wir) oder nicht (dann legen wir an).
if (isDuplicate(err)) throw new HttpError(409, MSG.usernameTaken);
throw err;
}
say(own ? 'profile updated' : 'profile created');
return { status: 200, body: { username } };
});
}
return [['POST /api/profil', { name: 'profil', json: true, run: saveProfile }]];
}
module.exports = { createProfileRoutes, hasProfile };
+496
View File
@@ -0,0 +1,496 @@
'use strict';
// Tests für deploy/api/profiles.js (Benutzernamen): echte HTTP-Anfragen auf Port 0 gegen createApp, dahinter ein Fake-Appwrite im
// Speicher (fake-appwrite.js, Tabelle `profiles` mit Unique-Index auf `usernameKey`) und das echte appwrite.js.
// Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { createAppwrite, AppwriteError } = require('./appwrite.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const { createApp } = require('./app.js');
const { createProfileRoutes, hasProfile } = require('./profiles.js');
const { MSG, USERNAME_PATTERN, RESERVED_USERNAMES, usernameKey, isReservedUsername } = require('./common.js');
const SALT = 'test-salt-0123456789abcdef0123456789abcdef';
const ME = 'user-me';
const OTHER = 'user-other';
const JWT_ME = 'eyJhbGciOiJI.payload-me.signature-me';
const JWT_OTHER = 'eyJhbGciOiJI.payload-other.signature-other';
/** Die Sperrliste der Spec (§ 2), wörtlich: der Test hält sie fest, damit eine Änderung auffällt. */
const SPEC_RESERVED = [
'admin',
'administrator',
'root',
'system',
'support',
'moderator',
'mod',
'aircard',
'aircardios',
'aircard-ios',
'aircard.ios',
'aircard_ios',
'webklar',
'anonym',
'anonymous',
'null',
'undefined',
'team',
'staff',
'official',
'offiziell',
'hilfe',
'help',
'kontakt',
'contact',
'info',
];
// ---- Aufbau ---------------------------------------------------------------------------------
const cleanups = [];
afterEach(async () => {
for (const fn of cleanups.splice(0).reverse()) await fn();
});
async function setup() {
const fake = await startFakeAppwrite();
fake.addUser(JWT_ME, { id: ME });
fake.addUser(JWT_OTHER, { id: OTHER });
const logs = [];
const appwrite = createAppwrite({ endpoint: fake.endpoint, project: fake.project, key: fake.key });
const app = createApp({ appwrite, salt: SALT, log: (line) => logs.push(String(line)) });
const server = http.createServer(app);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
cleanups.push(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
await fake.close();
});
const call = (method, path, options) => request(port, method, path, options);
const post = (path, body, jwt) => call('POST', path, { body, headers: jwt ? { 'X-Aircard-Jwt': jwt } : {} });
/** POST /api/profil mit dem Namen (Standard: als ME). */
const profile = (username, jwt = JWT_ME) => post('/api/profil', { username }, jwt);
return { fake, appwrite, logs, call, post, profile };
}
function request(port, method, path, { headers = {}, body, raw, contentType = 'application/json' } = {}) {
return new Promise((resolve, reject) => {
const payload = raw !== undefined ? raw : body === undefined ? undefined : JSON.stringify(body);
const requestHeaders = { ...headers };
if (payload !== undefined && contentType) requestHeaders['Content-Type'] = contentType;
if (payload !== undefined) requestHeaders['Content-Length'] = Buffer.byteLength(payload);
const req = http.request({ host: '127.0.0.1', port, method, path, headers: requestHeaders }, (res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
let json = null;
try {
json = JSON.parse(text);
} catch {
// kein JSON
}
resolve({ status: res.statusCode, headers: res.headers, text, json });
});
});
req.on('error', reject);
if (payload !== undefined) req.write(payload);
req.end();
});
}
/** Erwartet Status und genau {error: <Text des MSG-Schlüssels>, code: <Schlüssel>}. */
function expectError(res, status, code, label = code) {
assert.equal(res.status, status, `${label}: Status`);
assert.deepEqual(res.json, { error: MSG[code], code }, label);
}
const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/account');
/** Aufrufe bei Appwrite auf `profiles`: Zeile anlegen, per Abfrage suchen (die Vorabprüfung), Zeile ändern. */
const isCreate = (c) => c.method === 'POST' && c.path.endsWith('/tables/profiles/rows');
const isList = (c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows');
const isUpdate = (c) => c.method === 'PATCH' && c.path.includes('/tables/profiles/rows/');
/** Wartet (höchstens etwa 2 s), bis `condition()` stimmt: so fängt ein Test den Moment ab, in dem ein Aufruf bei Appwrite noch läuft. */
async function until(condition) {
for (let i = 0; i < 400 && !condition(); i += 1) await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(condition(), 'der erwartete Zustand wurde nicht erreicht');
}
/**
* Zwei Anfragen, die beide vorab geprüft haben („Name frei“), bevor eine davon schreibt: das Schreiben (`isWrite`) wird festgehalten, bis
* beide Abfragen bei Appwrite angekommen sind. So entscheidet erst der Unique-Index, nicht die Vorabprüfung.
*/
async function raceTwo(fake, isWrite, first, second) {
const releases = [fake.holdNext(isWrite), fake.holdNext(isWrite)];
const pending = Promise.all([first(), second()]);
await until(() => fake.calls.filter(isList).length === 2);
for (const release of releases) release();
return pending;
}
// ---- Reine Helfer (common.js) -----------------------------------------------------------------------
test('USERNAME_PATTERN: 3–20 Zeichen, erstes ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich', () => {
assert.equal(USERNAME_PATTERN.source, '^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$');
for (const ok of ['abc', 'Kenso', 'k3nso', '007', 'a.b', 'a_b', 'a-b', 'A.b_c-d9', 'a'.repeat(20), 'Z9.']) {
assert.ok(USERNAME_PATTERN.test(ok), ok);
}
const bad = ['', 'a', 'ab', '_abc', '.abc', '-abc', 'a'.repeat(21), 'a b', ' abc', 'abc ', 'abc\n', 'a\nbc', 'Jürgen', 'abc', 'a@b', 'a/b', 'a+b', 'abc😀'];
for (const name of bad) assert.ok(!USERNAME_PATTERN.test(name), JSON.stringify(name));
});
test('usernameKey: der Name klein geschrieben, sonst unverändert', () => {
assert.equal(usernameKey('Kenso'), 'kenso');
assert.equal(usernameKey('AirCard-IOS'), 'aircard-ios');
assert.equal(usernameKey('a.B_c-D9'), 'a.b_c-d9');
assert.equal(usernameKey('kenso'), 'kenso');
});
test('Sperrliste: genau die Schlüssel der Spec (§ 2), gesperrt in jeder Schreibweise; andere Namen sind frei', () => {
assert.deepEqual([...RESERVED_USERNAMES].sort(), [...SPEC_RESERVED].sort());
for (const key of SPEC_RESERVED) {
assert.ok(isReservedUsername(key), key);
assert.ok(isReservedUsername(key.toUpperCase()), key.toUpperCase());
assert.ok(isReservedUsername(`${key[0].toUpperCase()}${key.slice(1)}`), `${key} mit großem Anfang`);
// jeder gesperrte Name passt zum Muster (sonst wäre der Eintrag wirkungslos: er scheiterte schon als „ungültig“)
assert.match(key, USERNAME_PATTERN);
}
for (const free of ['kenso', 'admins', 'admin1', 'root_', 'air-card-ios', 'aircard-ios2', 'team.x', 'xinfo', 'helpme']) {
assert.ok(!isReservedUsername(free), free);
}
});
test('MSG: die vier Benutzernamen-Texte stehen wörtlich fest (Spec § 4)', () => {
assert.equal(
MSG.usernameInvalid,
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.',
);
assert.equal(MSG.usernameReserved, 'Dieser Benutzername ist nicht erlaubt.');
assert.equal(MSG.usernameTaken, 'Dieser Benutzername ist schon vergeben.');
assert.equal(MSG.usernameRequired, 'Bitte wähle zuerst einen Benutzernamen.');
});
test('createProfileRoutes liefert genau die Route POST /api/profil mit Name, json und run', () => {
const routes = createProfileRoutes({
appwrite: {},
say: () => {},
withLock: (key, fn) => fn(),
requireUser: async () => ({ id: ME, isAdmin: false }),
});
assert.deepEqual(
routes.map(([key]) => key),
['POST /api/profil'],
);
const [[, route]] = routes;
assert.equal(route.name, 'profil');
assert.equal(route.json, true);
assert.equal(typeof route.run, 'function');
});
test('hasProfile: true mit Zeile (Zeilen-ID = Konto-ID), false ohne; ein Appwrite-Fehler wird nicht verschluckt', async () => {
const { fake, appwrite } = await setup();
assert.equal(await hasProfile(appwrite, ME), false);
fake.addProfile(ME, 'Kenso');
assert.equal(await hasProfile(appwrite, ME), true);
assert.equal(await hasProfile(appwrite, OTHER), false, 'ein anderes Konto hat nichts davon');
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-other'), { status: 500 });
await assert.rejects(hasProfile(appwrite, OTHER), (err) => err instanceof AppwriteError && err.status === 500);
// fehlt die Tabelle ganz (Schema nicht eingespielt), ist das ein Fehler und kein „kein Profil“
fake.failNext((c) => c.method === 'GET' && c.path.endsWith('/tables/profiles/rows/user-other'), { status: 404, type: 'table_not_found' });
await assert.rejects(hasProfile(appwrite, OTHER), (err) => err instanceof AppwriteError && err.status === 404);
});
test('Fake-Appwrite: der Unique-Index auf usernameKey weist eine zweite Zeile (Anlegen und Ändern) mit 409 ab', async () => {
const { fake, appwrite } = await setup();
const isConflict = (err) => err instanceof AppwriteError && err.status === 409;
await appwrite.createRow('profiles', 'a', { username: 'Kenso', usernameKey: 'kenso' }, ['read("any")']);
await assert.rejects(appwrite.createRow('profiles', 'b', { username: 'KENSO', usernameKey: 'kenso' }, ['read("any")']), isConflict);
assert.equal(fake.row('profiles', 'b'), null);
await appwrite.createRow('profiles', 'b', { username: 'Other', usernameKey: 'other' }, ['read("any")']);
await assert.rejects(appwrite.updateRow('profiles', 'b', { username: 'Kenso', usernameKey: 'kenso' }), isConflict);
assert.equal(fake.row('profiles', 'b').usernameKey, 'other');
// die eigene Zeile ändern (nur die Schreibweise) ist kein Verstoß
await appwrite.updateRow('profiles', 'a', { username: 'KENSO', usernameKey: 'kenso' });
assert.equal(fake.row('profiles', 'a').username, 'KENSO');
// Spaltenlängen wie in der echten Tabelle (username und usernameKey: 20)
await assert.rejects(appwrite.createRow('profiles', 'c', { username: 'x'.repeat(21), usernameKey: 'x' }, []), (err) => err.status === 400);
});
// ---- Anlegen und Ändern ----------------------------------------------------------------------------
test('Profil anlegen: 200 {username}, Zeilen-ID = Konto-ID, Rechte genau read("any"), usernameKey klein, der Name wie eingegeben', async () => {
const { profile, fake } = await setup();
const res = await profile('Kenso.IOS');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { username: 'Kenso.IOS' });
assert.equal(fake.rows('profiles').length, 1);
const row = fake.row('profiles', ME);
assert.equal(row.username, 'Kenso.IOS');
assert.equal(row.usernameKey, 'kenso.ios');
assert.deepEqual(row.$permissions, ['read("any")']);
// der Server-Key hängt an jedem Datenaufruf, das JWT nur am Konto-Aufruf
assert.equal(fake.calls.find((c) => c.path === '/v1/account').headers['x-appwrite-jwt'], JWT_ME);
for (const c of nonAccountCalls(fake)) {
assert.equal(c.headers['x-appwrite-key'], fake.key);
assert.equal(c.headers['x-appwrite-jwt'], undefined);
}
});
test('Profil anlegen: die Zeile gehört dem Konto aus dem JWT, andere Felder im Body (Konto-ID, Schlüssel, Rechte) werden ignoriert', async () => {
const { post, fake } = await setup();
const res = await post(
'/api/profil',
{ username: 'Kenso', usernameKey: 'anders', userId: OTHER, ownerId: OTHER, $id: OTHER, $permissions: ['read("any")', 'update("any")'] },
JWT_ME,
);
assert.deepEqual(res.json, { username: 'Kenso' });
assert.equal(fake.row('profiles', OTHER), null);
const row = fake.row('profiles', ME);
assert.equal(row.usernameKey, 'kenso');
assert.deepEqual(row.$permissions, ['read("any")']);
assert.deepEqual(Object.keys(row).filter((key) => !key.startsWith('$')).sort(), ['username', 'usernameKey']);
});
test('Profil anlegen an den Grenzen: 3 und 20 Zeichen gehen', async () => {
const { profile, fake } = await setup();
assert.deepEqual((await profile('abc')).json, { username: 'abc' });
assert.deepEqual((await profile('X'.repeat(20), JWT_OTHER)).json, { username: 'X'.repeat(20) });
assert.equal(fake.row('profiles', OTHER).usernameKey, 'x'.repeat(20));
});
test('Profil ändern: dieselbe Zeile wird aktualisiert, der alte Schlüssel ist sofort für ein anderes Konto frei, der neue vergeben', async () => {
const { profile, fake } = await setup();
assert.equal((await profile('Kenso')).status, 200);
expectError(await profile('kenso', JWT_OTHER), 409, 'usernameTaken', 'solange der Name vergeben ist');
const res = await profile('Kenso.neu');
assert.equal(res.status, 200);
assert.deepEqual(res.json, { username: 'Kenso.neu' });
assert.equal(fake.rows('profiles').length, 1, 'eine Zeile je Konto');
assert.equal(fake.row('profiles', ME).username, 'Kenso.neu');
assert.equal(fake.row('profiles', ME).usernameKey, 'kenso.neu');
assert.deepEqual(fake.row('profiles', ME).$permissions, ['read("any")']);
assert.equal(fake.calls.filter(isCreate).length, 1, 'beim Ändern wird nichts neu angelegt');
assert.equal(fake.calls.filter(isUpdate).length, 1);
// der alte Name ist frei: ein anderes Konto nimmt ihn, das alte Konto bekommt ihn nicht mehr zurück
assert.deepEqual((await profile('kenso', JWT_OTHER)).json, { username: 'kenso' });
expectError(await profile('Kenso'), 409, 'usernameTaken', 'zurück');
assert.equal(fake.row('profiles', ME).username, 'Kenso.neu', 'nach dem Fehlschlag bleibt das Profil, wie es war');
assert.equal(fake.row('profiles', OTHER).usernameKey, 'kenso');
});
test('„kenso“ → „Kenso“ beim selben Konto ist erlaubt (nur die Schreibweise), ebenso derselbe Name nochmal', async () => {
const { profile, fake } = await setup();
assert.deepEqual((await profile('kenso')).json, { username: 'kenso' });
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' });
assert.equal(fake.row('profiles', ME).username, 'Kenso');
assert.equal(fake.row('profiles', ME).usernameKey, 'kenso');
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' }, 'derselbe Name nochmal');
assert.deepEqual((await profile('KENSO')).json, { username: 'KENSO' });
assert.equal(fake.rows('profiles').length, 1);
assert.equal(fake.row('profiles', ME).username, 'KENSO');
});
test('vergebener Name (in jeder Schreibweise): 409 usernameTaken, es wird nichts angelegt oder geändert', async () => {
const { profile, fake } = await setup();
fake.addProfile(OTHER, 'Kenso');
for (const name of ['kenso', 'KENSO', 'Kenso', 'kEnSo']) expectError(await profile(name), 409, 'usernameTaken', name);
assert.equal(fake.row('profiles', ME), null, 'kein Profil angelegt');
assert.equal(fake.rows('profiles').length, 1);
// wer schon ein Profil hat, behält es
fake.addProfile(ME, 'Me.me');
expectError(await profile('kenso'), 409, 'usernameTaken');
assert.equal(fake.row('profiles', ME).username, 'Me.me');
assert.equal(fake.row('profiles', OTHER).username, 'Kenso', 'das andere Profil ist unberührt');
});
// ---- Gleichzeitigkeit --------------------------------------------------------------------------------
test('Rennen: zwei Konten wollen gleichzeitig denselben Namen – genau eines bekommt ihn, das andere 409 (Unique-Index, nicht nur die Vorabprüfung)', async () => {
const { profile, fake } = await setup();
const results = await raceTwo(
fake,
isCreate,
() => profile('Rennen', JWT_ME),
() => profile('rennen', JWT_OTHER),
);
assert.deepEqual(results.map((res) => res.status).sort(), [200, 409]);
const winner = results.findIndex((res) => res.status === 200);
const loser = results.find((res) => res.status === 409);
assert.deepEqual(loser.json, { error: MSG.usernameTaken, code: 'usernameTaken' });
assert.equal(fake.rows('profiles').length, 1, 'genau eine Zeile');
const [row] = fake.rows('profiles');
assert.equal(row.$id, winner === 0 ? ME : OTHER);
assert.equal(row.usernameKey, 'rennen');
assert.equal(results[winner].json.username, row.username);
// beide Vorabprüfungen haben den Namen für frei gehalten und beide haben angelegt: erst der Unique-Index hat entschieden
assert.equal(fake.calls.filter(isCreate).length, 2);
});
test('Rennen beim Umbenennen: zwei Konten wollen denselben neuen Namen – genau eines bekommt ihn, das andere behält seinen alten', async () => {
const { profile, fake } = await setup();
fake.addProfile(ME, 'Alt.eins');
fake.addProfile(OTHER, 'Alt.zwei');
const results = await raceTwo(
fake,
isUpdate,
() => profile('Gemeinsam', JWT_ME),
() => profile('GEMEINSAM', JWT_OTHER),
);
assert.deepEqual(results.map((res) => res.status).sort(), [200, 409]);
assert.deepEqual(results.find((res) => res.status === 409).json, { error: MSG.usernameTaken, code: 'usernameTaken' });
assert.equal(fake.calls.filter(isUpdate).length, 2, 'beide haben ändern wollen: erst der Unique-Index hat entschieden');
const [winner, loser] = results[0].status === 200 ? [ME, OTHER] : [OTHER, ME];
assert.equal(fake.row('profiles', winner).usernameKey, 'gemeinsam');
assert.equal(fake.row('profiles', loser).username, loser === ME ? 'Alt.eins' : 'Alt.zwei', 'wer verliert, behält den alten Namen');
assert.equal(fake.rows('profiles').length, 2);
});
test('Sperre je Konto (profile:<id>): zwei gleichzeitige Anfragen desselben Kontos laufen nacheinander, beide gelingen, am Ende gilt die zweite', async () => {
const { profile, fake } = await setup();
const release = fake.holdNext(isCreate);
const first = profile('Alpha');
await until(() => fake.calls.some(isCreate)); // die erste steht beim Anlegen
const second = profile('Beta');
await until(() => fake.calls.filter((c) => c.path === '/v1/account').length === 2); // die zweite ist angemeldet ...
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(fake.calls.filter(isList).length, 1, '... wartet aber auf die Sperre, statt schon abzufragen');
release();
assert.deepEqual((await first).json, { username: 'Alpha' });
assert.deepEqual((await second).json, { username: 'Beta' });
assert.equal(fake.rows('profiles').length, 1);
assert.equal(fake.row('profiles', ME).username, 'Beta');
assert.equal(fake.row('profiles', ME).usernameKey, 'beta');
assert.equal(fake.calls.filter(isCreate).length, 1, 'die zweite hat die Zeile der ersten geändert, nicht noch einmal angelegt');
assert.equal(fake.calls.filter(isUpdate).length, 1);
});
// ---- Prüfungen -----------------------------------------------------------------------------------------
test('gesperrte Namen (Spec § 2, in jeder Schreibweise): 400 usernameReserved, ohne Abfrage bei Appwrite', async () => {
const { profile, fake } = await setup();
for (const name of ['Admin', 'AirCard-IOS', 'AIRCARD.IOS', 'Aircard_Ios', 'ROOT', 'Null', ...SPEC_RESERVED]) {
expectError(await profile(name), 400, 'usernameReserved', name);
}
assert.equal(fake.rows('profiles').length, 0);
assert.equal(nonAccountCalls(fake).length, 0, 'die Prüfung braucht Appwrite nur für das JWT');
});
test('ungültige Namen (Muster): 400 usernameInvalid, nichts angelegt, ohne Abfrage bei Appwrite', async () => {
const { profile, fake } = await setup();
const cases = [
['zu kurz', 'ab'],
['ein Zeichen', 'a'],
['leer', ''],
['Unterstrich vorn', '_abc'],
['Punkt vorn', '.abc'],
['Bindestrich vorn', '-abc'],
['21 Zeichen', 'a'.repeat(21)],
['Leerzeichen', 'a b'],
['Leerzeichen vorn', ' abc'],
['Leerzeichen hinten', 'abc '],
['Zeilenumbruch', 'abc\n'],
['Umlaut', 'Jürgen'],
['Emoji', 'abc😀'],
['Klammeraffe', '@kenso'],
['Schrägstrich', 'a/b'],
];
for (const [label, name] of cases) expectError(await profile(name), 400, 'usernameInvalid', label);
assert.equal(fake.rows('profiles').length, 0);
assert.equal(nonAccountCalls(fake).length, 0);
});
test('kein Text als Name (fehlt, null, Zahl, Liste, Objekt, Wahrheitswert): 400 usernameInvalid; kein JSON-Objekt: 400 badRequest', async () => {
const { post, call, fake } = await setup();
for (const body of [{}, { username: null }, { username: 7 }, { username: ['abc'] }, { username: { a: 1 } }, { username: true }, { name: 'Kenso' }]) {
expectError(await post('/api/profil', body, JWT_ME), 400, 'usernameInvalid', JSON.stringify(body));
}
for (const raw of ['[]', '"abc"', '7', '{kaputt']) {
const res = await call('POST', '/api/profil', { raw, headers: { 'X-Aircard-Jwt': JWT_ME } });
expectError(res, 400, 'badRequest', raw);
}
assert.equal(fake.rows('profiles').length, 0);
});
test('ohne oder mit ungültigem JWT: 401, noch vor der Prüfung des Namens, nichts angelegt', async () => {
const { post, profile, fake } = await setup();
expectError(await post('/api/profil', { username: 'Kenso' }), 401, 'unauthorized', 'ohne JWT');
expectError(await post('/api/profil', {}), 401, 'unauthorized', 'ohne JWT, ungültiger Body');
expectError(await profile('Kenso', 'xxx.yyy.zzz'), 401, 'unauthorized', 'unbekanntes JWT');
expectError(await profile('Kenso', 'zwei teile'), 401, 'unauthorized', 'unzulässige Zeichen');
expectError(await profile('Admin', ''), 401, 'unauthorized', 'leeres JWT');
assert.equal(fake.rows('profiles').length, 0);
assert.equal(nonAccountCalls(fake).length, 0, 'ohne gültiges JWT wird nichts abgefragt oder geschrieben');
});
test('nur POST: GET /api/profil gibt es nicht (404)', async () => {
const { call } = await setup();
expectError(await call('GET', '/api/profil'), 404, 'notFound');
});
// ---- Fehler bei Appwrite -----------------------------------------------------------------------------------
test('Appwrite antwortet mit Fehler: 502 unavailable ohne halben Zustand, der nächste Versuch gelingt', async () => {
const { profile, fake } = await setup();
// die Vorabprüfung scheitert
fake.failNext(isList, { status: 500 });
expectError(await profile('Kenso'), 502, 'unavailable', 'Vorabprüfung');
assert.equal(fake.rows('profiles').length, 0);
// das Anlegen scheitert
fake.failNext(isCreate, { status: 503 });
expectError(await profile('Kenso'), 502, 'unavailable', 'Anlegen');
assert.equal(fake.rows('profiles').length, 0);
assert.deepEqual((await profile('Kenso')).json, { username: 'Kenso' });
// das Ändern scheitert: das alte Profil bleibt
fake.failNext(isUpdate, { status: 500 });
expectError(await profile('Neu.name'), 502, 'unavailable', 'Ändern');
assert.equal(fake.row('profiles', ME).username, 'Kenso');
assert.deepEqual((await profile('Neu.name')).json, { username: 'Neu.name' });
assert.equal(fake.row('profiles', ME).usernameKey, 'neu.name');
});
test('fehlt die Tabelle `profiles` (Schema nicht eingespielt): 500 internal, kein Absturz', async () => {
const { profile, fake } = await setup();
fake.failNext(isList, { status: 404, type: 'table_not_found' });
expectError(await profile('Kenso'), 500, 'internal');
});
// ---- Logs -----------------------------------------------------------------------------------------------------
test('Logs: eine Ereigniszeile je Anlegen und Ändern, nie Name, Konto-ID, Schlüssel oder JWT', async () => {
const { profile, logs, fake } = await setup();
await profile('GEHEIMER.Name'); // anlegen
await profile('GEHEIMER.Neu'); // ändern
await profile('ab'); // 400
await profile('Admin'); // 400
await profile('geheimer.neu', JWT_OTHER); // 409
await profile('x', 'xxx.yyy.zzz'); // 401
const lines = logs.map((line) => line.replace(/^\[aircard-api\] \S+ /, ''));
assert.deepEqual(lines, ['profile created', 'profile updated']);
assert.ok(logs.every((line) => line.startsWith('[aircard-api] ')));
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, 'GEHEIMER', 'geheimer', ME, OTHER]) {
assert.ok(!logs.join('\n').includes(secret), `Log enthält „${secret}“`);
}
});
+99
View File
@@ -0,0 +1,99 @@
'use strict';
/**
* Start des API-Dienstes `aircard-api` (Node 22, nur Bordmittel): liest die Umgebung, baut createApp und
* lauscht auf einem Port. Beschreibung der Endpunkte: app.js und README.md.
*
* Umgebung:
* AIRCARD_API_KEY Pflicht Appwrite-Server-Key `aircard-api` (Console-Scopes documents.read/write = rows.* der API, files.read/write)
* AIRCARD_API_SALT Pflicht Salz für die Melder-Hashes (32 Byte hex)
* AIRCARD_APPWRITE_PROJECT_ID Pflicht Projekt-ID
* AIRCARD_APPWRITE_ENDPOINT http://webklar-appwrite/v1
* AIRCARD_API_PORT 8088
* Fehlt etwas Pflichtiges, nennt die Meldung nur den Namen der Variable (nie Werte) und der Prozess endet mit Exit-Code 1.
*/
const http = require('node:http');
const { createAppwrite } = require('./appwrite.js');
const { createApp } = require('./app.js');
const DEFAULT_PORT = 8088;
const DEFAULT_ENDPOINT = 'http://webklar-appwrite/v1';
/**
* Prüft die Umgebung, ohne etwas zu starten.
* @returns {{ok: true, config: object, warnings: string[]} | {ok: false, error: string}}
*/
function loadConfig(env) {
const read = (name) => (typeof env[name] === 'string' ? env[name].trim() : '');
const missing = ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID'].filter((name) => read(name) === '');
if (missing.length > 0) {
return { ok: false, error: `${missing.join(', ')} ${missing.length > 1 ? 'sind' : 'ist'} nicht gesetzt - Abbruch` };
}
const endpoint = read('AIRCARD_APPWRITE_ENDPOINT') || DEFAULT_ENDPOINT;
if (!/^https?:\/\/\S+$/.test(endpoint)) {
return { ok: false, error: 'AIRCARD_APPWRITE_ENDPOINT muss mit http:// oder https:// beginnen - Abbruch' };
}
const portText = read('AIRCARD_API_PORT');
const port = portText === '' ? DEFAULT_PORT : Number(portText);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
return { ok: false, error: 'AIRCARD_API_PORT ist keine gültige Portnummer - Abbruch' };
}
const warnings = [];
if (read('AIRCARD_API_SALT').length < 16) warnings.push('AIRCARD_API_SALT ist sehr kurz (empfohlen: 32 Byte hex)');
return {
ok: true,
warnings,
config: {
port,
endpoint,
project: read('AIRCARD_APPWRITE_PROJECT_ID'),
key: read('AIRCARD_API_KEY'),
salt: read('AIRCARD_API_SALT'),
},
};
}
function main() {
const say = (message) => console.log(`[aircard-api] ${new Date().toISOString()} ${message}`);
const loaded = loadConfig(process.env);
if (!loaded.ok) {
console.error(`[aircard-api] ${loaded.error}`);
process.exit(1);
}
for (const warning of loaded.warnings) say(`Warnung: ${warning}`);
const { port, endpoint, project, key, salt } = loaded.config;
const appwrite = createAppwrite({ endpoint, project, key });
const app = createApp({ appwrite, salt, log: console.log });
const server = http.createServer(app);
// Langsame oder hängende Clients nicht ewig halten; Keep-Alive länger als Traefiks Leerlauf (90 s),
// damit Traefik keine Verbindung wiederverwendet, die Node gerade schließt.
server.headersTimeout = 10_000;
server.requestTimeout = 15_000;
server.keepAliveTimeout = 95_000;
server.listen(port, () => say(`listening on :${server.address().port} (Appwrite: ${endpoint})`));
const shutdown = (signal) => {
say(`${signal} received, shutting down`);
server.close(() => process.exit(0));
server.closeIdleConnections();
setTimeout(() => process.exit(0), 5000).unref();
};
process.once('SIGTERM', () => shutdown('SIGTERM'));
process.once('SIGINT', () => shutdown('SIGINT'));
}
module.exports = { loadConfig };
if (require.main === module) {
main();
}
+172
View File
@@ -0,0 +1,172 @@
'use strict';
// Tests für deploy/api/server.js: Umgebungsprüfung und ein Start als echter Prozess gegen einen Fake-Appwrite.
// Aufruf: node --test "deploy/api/*.test.js"
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { loadConfig } = require('./server.js');
const { startFakeAppwrite } = require('./fake-appwrite.js');
const SERVER = path.join(__dirname, 'server.js');
const KEY = 'KEY-test-secret-value';
const SALT = 'salt-test-secret-0123456789abcdef0123456789';
const children = [];
const fakes = [];
afterEach(async () => {
for (const child of children.splice(0)) {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
await new Promise((resolve) => child.once('exit', resolve));
}
}
for (const fake of fakes.splice(0)) await fake.close();
});
function validEnv(extra = {}) {
return {
AIRCARD_API_KEY: KEY,
AIRCARD_API_SALT: SALT,
AIRCARD_APPWRITE_PROJECT_ID: 'proj-test',
...extra,
};
}
function startProcess(env) {
const child = spawn(process.execPath, [SERVER], { env: { PATH: process.env.PATH, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
children.push(child);
const output = { text: '' };
child.stdout.on('data', (chunk) => (output.text += chunk));
child.stderr.on('data', (chunk) => (output.text += chunk));
const exited = new Promise((resolve) => child.once('exit', (code, signal) => resolve({ code, signal })));
return { child, output, exited };
}
async function waitFor(condition, what, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (!condition()) {
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`);
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
function httpRequest(port, method, urlPath, { headers = {}, body } = {}) {
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const req = http.request(
{
host: '127.0.0.1',
port,
method,
path: urlPath,
headers: payload === undefined ? headers : { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), ...headers },
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') }));
},
);
req.on('error', reject);
if (payload !== undefined) req.write(payload);
req.end();
});
}
test('loadConfig: Standardwerte und gelesene Werte', () => {
const result = loadConfig(validEnv());
assert.equal(result.ok, true);
assert.deepEqual(result.config, {
port: 8088,
endpoint: 'http://webklar-appwrite/v1',
project: 'proj-test',
key: KEY,
salt: SALT,
});
assert.deepEqual(result.warnings, []);
const custom = loadConfig(validEnv({ AIRCARD_API_PORT: '9100', AIRCARD_APPWRITE_ENDPOINT: 'https://appwrite.example/v1' }));
assert.equal(custom.config.port, 9100);
assert.equal(custom.config.endpoint, 'https://appwrite.example/v1');
});
test('loadConfig: fehlender Key, Salz oder Projekt wird mit dem Namen der Variable gemeldet, nie mit Werten', () => {
for (const name of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT', 'AIRCARD_APPWRITE_PROJECT_ID']) {
for (const value of [undefined, '', ' ']) {
const env = validEnv();
if (value === undefined) delete env[name];
else env[name] = value;
const result = loadConfig(env);
assert.equal(result.ok, false, `${name}=${JSON.stringify(value)}`);
assert.ok(result.error.includes(name));
assert.ok(!result.error.includes(KEY) && !result.error.includes(SALT));
}
}
const none = loadConfig({});
assert.equal(none.ok, false);
assert.ok(none.error.includes('AIRCARD_API_KEY') && none.error.includes('AIRCARD_API_SALT'));
});
test('loadConfig: ungültiger Port oder Endpunkt, kurzes Salz warnt', () => {
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: 'abc' })).ok, false);
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '70000' })).ok, false);
assert.equal(loadConfig(validEnv({ AIRCARD_API_PORT: '-1' })).ok, false);
assert.equal(loadConfig(validEnv({ AIRCARD_APPWRITE_ENDPOINT: 'webklar-appwrite/v1' })).ok, false);
const short = loadConfig(validEnv({ AIRCARD_API_SALT: 'xyz-9' }));
assert.equal(short.ok, true);
assert.equal(short.warnings.length, 1);
assert.ok(short.warnings[0].includes('AIRCARD_API_SALT') && !short.warnings[0].includes('xyz-9'));
});
test('Start ohne Key oder ohne Salz: Exit-Code 1, Meldung ohne Werte', async () => {
for (const missing of ['AIRCARD_API_KEY', 'AIRCARD_API_SALT']) {
const env = validEnv({ AIRCARD_API_PORT: '0' });
delete env[missing];
const { output, exited } = startProcess(env);
const { code } = await exited;
assert.equal(code, 1, missing);
assert.ok(output.text.includes(missing));
assert.ok(output.text.includes('[aircard-api]'));
assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT));
}
});
test('Start als Prozess: Gesundheit, Melden über Fake-Appwrite mit Server-Key, sauberes Beenden bei SIGTERM', async () => {
const fake = await startFakeAppwrite({ project: 'proj-test', key: KEY });
fakes.push(fake);
fake.addRow(
'gallery',
'card-1',
{ title: 'Karte', imageFileId: 'i', previewFileId: 'p', ownerId: 'user-me', hidden: false, reportCount: 0 },
['read("any")'],
);
const { child, output, exited } = startProcess(validEnv({ AIRCARD_API_PORT: '0', AIRCARD_APPWRITE_ENDPOINT: fake.endpoint }));
await waitFor(() => /listening on :\d+/.test(output.text), 'Startmeldung');
const port = Number(/listening on :(\d+)/.exec(output.text)[1]);
const health = await httpRequest(port, 'GET', '/api/gesundheit');
assert.deepEqual(health.json, { ok: true });
const reported = await httpRequest(port, 'POST', '/api/melden', {
body: { id: 'card-1', reason: 'spam' },
headers: { 'X-Real-Ip': '203.0.113.5' },
});
assert.equal(reported.status, 200);
assert.deepEqual(reported.json, { ok: true });
assert.equal(fake.row('gallery', 'card-1').reportCount, 1);
assert.ok(fake.calls.length > 0);
for (const call of fake.calls) assert.equal(call.headers['x-appwrite-key'], KEY);
child.kill('SIGTERM');
const { code } = await exited;
assert.equal(code, 0);
assert.ok(!output.text.includes(KEY) && !output.text.includes(SALT), 'Ausgabe enthält weder Key noch Salz');
assert.ok(!output.text.includes('203.0.113.5'), 'Ausgabe enthält keine IP');
for (const line of output.text.trim().split('\n')) assert.ok(line.startsWith('[aircard-api]'), line);
});
+48 -3
View File
@@ -3,10 +3,12 @@
# URL: aircard-ios.org
# Repository: https://git.webklar.com/knso/AirCardIOS
# Einfügen unter `services:` direkt nach dem eship-Block (siehe deploy/README.md).
# Starten NUR mit: docker compose up -d aircard-build aircard
# Starten NUR mit: docker compose up -d aircard-build aircard aircard-api
# (ein nacktes `up -d` würde andere Dienste wieder anlegen)
# Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml.
# AIRCARD_APPWRITE_PROJECT_ID kommt aus /home/webklar/apps/.env.
# Die Appwrite-Route liegt als Datei in traefik/dynamic/aircard.yml (Router `aircard-api@file`, Pfad /v1).
# Der API-Dienst `aircard-api` hängt per Label am Pfad /api (Router `aircard-gallery-api`, ein anderer Name,
# damit er nicht mit der Datei-Route kollidiert).
# AIRCARD_APPWRITE_PROJECT_ID, AIRCARD_API_KEY und AIRCARD_API_SALT kommen aus /home/webklar/apps/.env.
# ==============================================
aircard-build:
image: node:22-alpine
@@ -80,3 +82,46 @@
# - "traefik.http.middlewares.aircard-www.redirectregex.regex=^https?://www\\.aircard-ios\\.org(.*)"
# - "traefik.http.middlewares.aircard-www.redirectregex.replacement=https://aircard-ios.org$${1}"
# - "traefik.http.middlewares.aircard-www.redirectregex.permanent=true"
# --- API-Dienst der Community-Galerie (deploy/api/, siehe deploy/api/README.md) ---
# Ohne AIRCARD_API_KEY oder AIRCARD_API_SALT in der .env beendet sich der Dienst mit Exit-Code 1.
# Kein `ports:` (veröffentlicht wird der Dienst nur über Traefik). Im Docker-Netz erreichen ihn andere Container, und er
# vertraut dort dem Header X-Real-Ip: akzeptiertes Risiko, siehe deploy/api/README.md (Client-IP).
aircard-api:
image: node:22-alpine
container_name: webklar-aircard-api
restart: unless-stopped
init: true
mem_limit: 128m
user: node
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
working_dir: /srv
# Log-Größe deckeln: /api/melden ist ohne Anmeldung erreichbar, der Docker-Daemon hat keine eigenen Log-Grenzen.
logging:
driver: json-file
options:
max-size: "5m"
max-file: "2"
environment:
- AIRCARD_API_PORT=8088
- AIRCARD_APPWRITE_ENDPOINT=http://webklar-appwrite/v1
- AIRCARD_APPWRITE_PROJECT_ID=${AIRCARD_APPWRITE_PROJECT_ID}
- AIRCARD_API_KEY=${AIRCARD_API_KEY}
- AIRCARD_API_SALT=${AIRCARD_API_SALT}
- NODE_OPTIONS=--max-old-space-size=64
volumes:
- ./aircard-ios.org/api:/srv:ro
networks:
- webklar-network
command: node /srv/server.js
labels:
- "traefik.enable=true"
- "traefik.http.routers.aircard-gallery-api.rule=Host(`aircard-ios.org`) && PathPrefix(`/api`)"
- "traefik.http.routers.aircard-gallery-api.entrypoints=websecure"
- "traefik.http.routers.aircard-gallery-api.tls.certresolver=letsencrypt"
- "traefik.http.routers.aircard-gallery-api.priority=200"
- "traefik.http.services.aircard-gallery-api.loadbalancer.server.port=8088"
+59 -6
View File
@@ -13,17 +13,32 @@
# DEPLOY_TRIGGER (leer = manual)
#
# Fehlerzeilen beginnen mit "ERROR:"; der letzte Satz eines erfolgreichen Builds ist
# "Deployment abgeschlossen".
# "Deployment abgeschlossen". Läuft schon ein Deployment (Sperrverzeichnis in .git), meldet das
# Skript "läuft bereits" und endet mit 0. npm ci und der Build sind auf 15 Minuten begrenzt (timeout).
set -e
# Jeder Abbruch soll eine ERROR-Zeile hinterlassen, auch wenn set -e ihn auslöst.
# Die eigene Sperre (LOCK_HELD) wird bei jedem Ende freigegeben, eine fremde nie.
ERROR_PRINTED=""
LOCK_HELD=""
LOCK_DIR=""
fail() {
echo "ERROR: $*"
ERROR_PRINTED=1
exit 1
}
trap 'status=$?; if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})"; fi' EXIT
cleanup() {
status=$?
if [ "${status}" -ne 0 ] && [ -z "${ERROR_PRINTED}" ]; then
echo "ERROR: Deployment unerwartet abgebrochen (Exit-Code ${status})"
fi
if [ -n "${LOCK_HELD}" ] && [ -n "${LOCK_DIR}" ]; then
rm -rf "${LOCK_DIR}"
fi
}
trap cleanup EXIT
# Ein Signal (z. B. Zeitlimit des Webhook-Servers) soll ebenfalls über den EXIT-Trap aufräumen.
trap 'exit 143' HUP INT TERM
REPO_DIR="${REPO_DIR:-/app}"
OUT_DIR="${OUT_DIR:-/out}"
@@ -78,6 +93,33 @@ fi
cd "${REPO_DIR}" || fail "Verzeichnis ${REPO_DIR} nicht erreichbar"
# Nur ein Deployment gleichzeitig (Webhook, Polling und der manuelle Aufruf teilen sich REPO_DIR und OUT_DIR).
# mkdir ist atomar. Eine verwaiste Sperre (Prozess weg oder über 60 Minuten alt) wird übernommen.
LOCK_DIR="${REPO_DIR}/.git/aircard-deploy.lock"
acquire_lock() {
mkdir "${LOCK_DIR}" 2>/dev/null || return 1
echo "$$" >"${LOCK_DIR}/pid"
LOCK_HELD=1
}
if ! acquire_lock; then
_lock_pid="$(cat "${LOCK_DIR}/pid" 2>/dev/null || true)"
_lock_stale=""
if [ -n "${_lock_pid}" ]; then
kill -0 "${_lock_pid}" 2>/dev/null || _lock_stale=1
fi
if [ -n "$(find "${LOCK_DIR}" -maxdepth 0 -mmin +60 2>/dev/null)" ]; then
_lock_stale=1
fi
if [ -n "${_lock_stale}" ]; then
echo "Verwaiste Sperre gefunden, übernehme sie."
rm -rf "${LOCK_DIR}"
acquire_lock || { echo "Deployment läuft bereits, dieser Lauf wird übersprungen."; exit 0; }
else
echo "Deployment läuft bereits, dieser Lauf wird übersprungen."
exit 0
fi
fi
echo "Aktualisiere Repository..."
# Remote-URL bei jedem Lauf neu setzen, damit ein getauschtes Token greift.
git remote set-url origin "${REPO_URL}" || fail "git remote set-url fehlgeschlagen"
@@ -111,18 +153,29 @@ echo "${CURRENT_COMMIT}" >"${MARKER}" || fail "Marker ${MARKER} nicht schreibbar
[ -d "${REPO_DIR}/${APP_SUBDIR}" ] || fail "Ordner ${APP_SUBDIR} fehlt im Repository"
cd "${REPO_DIR}/${APP_SUBDIR}"
# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen.
unset GITEA_TOKEN REPO_URL
# Zugangsdaten nicht an npm und die Build-Skripte weiterreichen (auch das Webhook-Geheimnis steht im
# Container und käme sonst beim manuellen Aufruf `docker exec ... sh /deploy.sh` bei npm an).
unset GITEA_TOKEN REPO_URL AIRCARD_WEBHOOK_TOKEN
# Ein hängender Build soll nicht ewig laufen (und alle späteren Deployments sperren): 15 Minuten.
# `timeout` gibt es in busybox (node:22-alpine); fehlt es, läuft der Build ohne Limit.
if command -v timeout >/dev/null 2>&1; then
LIMIT="timeout 900"
else
LIMIT=""
fi
echo ""
echo "Installiere Abhängigkeiten..."
npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen"
# shellcheck disable=SC2086 # LIMIT besteht absichtlich aus zwei Wörtern
${LIMIT} npm ci --no-audit --no-fund || fail "npm ci fehlgeschlagen (oder das Zeitlimit von 15 Minuten ist erreicht)"
echo ""
echo "Baue die Seite..."
# VITE_*-Variablen kommen direkt aus der Container-Umgebung (compose).
rm -rf dist
npm run build || fail "Build fehlgeschlagen"
# shellcheck disable=SC2086
${LIMIT} npm run build || fail "Build fehlgeschlagen (oder das Zeitlimit von 15 Minuten ist erreicht)"
[ -f dist/index.html ] || fail "dist/index.html fehlt nach dem Build"
echo ""
+233
View File
@@ -0,0 +1,233 @@
'use strict';
// Tests für deploy/deploy.sh (das echte Skript, mit einem lokalen git-Remote und einem npm-Stub).
// Aufruf: node --test deploy/deploy.test.js
// Voraussetzung: git und sh auf dem Host. Der Stub-npm legt dist/index.html an und schreibt seine Umgebung weg.
const { test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process');
const DEPLOY_SH = path.join(__dirname, 'deploy.sh');
// Standard: das sh des Hosts. Zum Gegenprüfen mit busybox (wie im node:alpine-Container): DEPLOY_TEST_SH="busybox sh".
const [SHELL_CMD, ...SHELL_ARGS] = (process.env.DEPLOY_TEST_SH || 'sh').split(' ');
const GIT_ENV = {
GIT_AUTHOR_NAME: 't',
GIT_AUTHOR_EMAIL: 't@example.invalid',
GIT_COMMITTER_NAME: 't',
GIT_COMMITTER_EMAIL: 't@example.invalid',
};
const tmpDirs = [];
const children = [];
afterEach(() => {
for (const child of children.splice(0)) {
// Prozessgruppe beenden: auch der wartende npm-Stub unter dem Skript.
if (child.exitCode === null && child.signalCode === null) process.kill(-child.pid, 'SIGKILL');
}
for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitFor(condition, what, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (!condition()) {
if (Date.now() > deadline) throw new Error(`Zeitüberschreitung beim Warten auf: ${what}`);
await pause(20);
}
}
function git(args, cwd) {
const res = spawnSync('git', args, { cwd, env: { ...process.env, ...GIT_ENV }, encoding: 'utf8' });
assert.equal(res.status, 0, `git ${args.join(' ')}: ${res.stderr}`);
return res.stdout;
}
/** Ein Arbeitsverzeichnis mit Remote (file://), Stub-npm und optional Stub-timeout. */
function setup({ withTimeoutStub = false } = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'aircard-deploy-test-'));
tmpDirs.push(dir);
const bare = path.join(dir, 'remote', 'u', 'r.git');
fs.mkdirSync(bare, { recursive: true });
git(['init', '--bare', '--quiet', '-b', 'main', bare]);
const work = path.join(dir, 'seed');
git(['clone', '--quiet', bare, work]);
fs.mkdirSync(path.join(work, 'web'));
fs.writeFileSync(path.join(work, 'web', 'package.json'), '{}\n');
git(['add', '-A'], work);
git(['commit', '--quiet', '-m', 'seed'], work);
git(['push', '--quiet', 'origin', 'HEAD:main'], work);
const bin = path.join(dir, 'bin');
fs.mkdirSync(bin);
const npmLog = path.join(dir, 'npm.log');
const release = path.join(dir, 'release');
fs.writeFileSync(
path.join(bin, 'npm'),
[
'#!/bin/sh',
`echo "npm $* | WEBHOOK=\${AIRCARD_WEBHOOK_TOKEN-unset} GITEA=\${GITEA_TOKEN-unset} VITE=\${VITE_TEST_VAR-unset}" >> "${npmLog}"`,
// Blockiert, bis die Testdatei "release" existiert (für den Test mit zwei gleichzeitigen Läufen).
`if [ -n "\${STUB_NPM_BLOCK:-}" ]; then i=0; while [ ! -f "${release}" ] && [ "$i" -lt 400 ]; do sleep 0.05; i=$((i+1)); done; fi`,
'if [ -n "${STUB_NPM_FAIL:-}" ]; then echo "npm kaputt" >&2; exit 1; fi',
'if [ "$1" = run ]; then mkdir -p dist && echo "<html>ok</html>" > dist/index.html; fi',
'exit 0',
'',
].join('\n'),
{ mode: 0o755 },
);
const timeoutLog = path.join(dir, 'timeout.log');
if (withTimeoutStub) {
fs.writeFileSync(
path.join(bin, 'timeout'),
['#!/bin/sh', `echo "timeout $*" >> "${timeoutLog}"`, 'shift', 'exec "$@"', ''].join('\n'),
{ mode: 0o755 },
);
}
const repoDir = path.join(dir, 'app');
const outDir = path.join(dir, 'out');
fs.mkdirSync(repoDir);
fs.mkdirSync(outDir);
const env = {
...process.env,
...GIT_ENV,
PATH: `${bin}:${process.env.PATH}`,
GITEA_URL: `file://${path.join(dir, 'remote')}`,
GITEA_USER: 'u',
GITEA_REPO: 'r',
GITEA_TOKEN: '',
REPO_DIR: repoDir,
OUT_DIR: outDir,
DEPLOY_BRANCH: 'main',
DEPLOY_TRIGGER: 'webhook',
AIRCARD_WEBHOOK_TOKEN: 'geheim-webhook',
VITE_TEST_VAR: 'bleibt',
};
const lockDir = path.join(repoDir, '.git', 'aircard-deploy.lock');
const npmCalls = () => (fs.existsSync(npmLog) ? fs.readFileSync(npmLog, 'utf8').trim().split('\n') : []);
const timeoutCalls = () => (fs.existsSync(timeoutLog) ? fs.readFileSync(timeoutLog, 'utf8').trim().split('\n') : []);
return { dir, env, repoDir, outDir, lockDir, release, npmCalls, timeoutCalls };
}
function runSync(ctx, extraEnv = {}) {
const res = spawnSync(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], { env: { ...ctx.env, ...extraEnv }, encoding: 'utf8', timeout: 60000 });
return { status: res.status, out: `${res.stdout}${res.stderr}` };
}
function runAsync(ctx, extraEnv = {}) {
const child = spawn(SHELL_CMD, [...SHELL_ARGS, DEPLOY_SH], {
env: { ...ctx.env, ...extraEnv },
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
children.push(child);
const result = { out: '', status: null, done: null };
child.stdout.on('data', (d) => (result.out += d));
child.stderr.on('data', (d) => (result.out += d));
result.done = new Promise((resolve) =>
child.once('exit', (code) => {
result.status = code;
resolve(result);
}),
);
return result;
}
test('ein Lauf baut, kopiert nach out/ und gibt das Webhook-Geheimnis nicht an npm weiter', () => {
const ctx = setup();
const res = runSync(ctx);
assert.equal(res.status, 0, res.out);
assert.match(res.out, /Deployment abgeschlossen/);
assert.equal(fs.readFileSync(path.join(ctx.outDir, 'index.html'), 'utf8').trim(), '<html>ok</html>');
const calls = ctx.npmCalls();
assert.equal(calls.length, 2, calls.join('\n'));
for (const call of calls) {
assert.match(call, /WEBHOOK=unset/, `AIRCARD_WEBHOOK_TOKEN darf npm nicht erreichen: ${call}`);
assert.match(call, /GITEA=unset/);
assert.match(call, /VITE=bleibt/, 'andere Variablen (VITE_*) bleiben erhalten');
}
assert.ok(!res.out.includes('geheim-webhook'));
});
test('das Sperrverzeichnis wird nach einem erfolgreichen Lauf entfernt', () => {
const ctx = setup();
assert.equal(runSync(ctx).status, 0);
assert.equal(fs.existsSync(ctx.lockDir), false);
});
test('das Sperrverzeichnis wird auch nach einem fehlgeschlagenen Build entfernt', () => {
const ctx = setup();
const res = runSync(ctx, { STUB_NPM_FAIL: '1' });
assert.equal(res.status, 1);
assert.match(res.out, /ERROR: /);
assert.equal(fs.existsSync(ctx.lockDir), false);
});
test('ein zweiter gleichzeitiger Lauf meldet „läuft bereits“, beendet mit 0 und baut nicht', async () => {
const ctx = setup();
const first = runAsync(ctx, { STUB_NPM_BLOCK: '1' });
await waitFor(() => ctx.npmCalls().length >= 1, 'npm des ersten Laufs');
assert.equal(fs.existsSync(ctx.lockDir), true);
const second = runSync(ctx);
assert.equal(second.status, 0, second.out);
assert.match(second.out, /läuft bereits/);
assert.doesNotMatch(second.out, /Deployment abgeschlossen/);
assert.equal(ctx.npmCalls().length, 1, 'der zweite Lauf darf npm nicht starten');
assert.equal(fs.existsSync(ctx.lockDir), true, 'der zweite Lauf darf die Sperre des ersten nicht freigeben');
fs.writeFileSync(ctx.release, '');
await first.done;
assert.equal(first.status, 0, first.out);
assert.match(first.out, /Deployment abgeschlossen/);
assert.equal(fs.existsSync(ctx.lockDir), false);
});
test('eine verwaiste Sperre (Prozess gibt es nicht mehr) wird übernommen', () => {
const ctx = setup();
// Erster Lauf legt das Repo an.
assert.equal(runSync(ctx).status, 0);
fs.mkdirSync(ctx.lockDir);
fs.writeFileSync(path.join(ctx.lockDir, 'pid'), '2147483646\n');
const res = runSync(ctx);
assert.equal(res.status, 0, res.out);
assert.match(res.out, /Deployment abgeschlossen/);
assert.equal(fs.existsSync(ctx.lockDir), false);
});
test('eine sehr alte Sperre ohne lebenden Besitzer-Prozess-Nachweis gilt als verwaist', () => {
const ctx = setup();
assert.equal(runSync(ctx).status, 0);
fs.mkdirSync(ctx.lockDir);
// Kein pid-Eintrag, aber alt genug (über 60 Minuten).
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
fs.utimesSync(ctx.lockDir, old, old);
const res = runSync(ctx);
assert.equal(res.status, 0, res.out);
assert.match(res.out, /Deployment abgeschlossen/);
});
const busyboxApplets = SHELL_CMD === 'busybox' && 'busybox löst timeout als eigenes Applet auf, der Stub wird nicht gerufen';
test('npm ci und npm run build laufen unter timeout 900, wenn timeout verfügbar ist', { skip: busyboxApplets }, () => {
const ctx = setup({ withTimeoutStub: true });
const res = runSync(ctx);
assert.equal(res.status, 0, res.out);
const calls = ctx.timeoutCalls();
assert.equal(calls.length, 2, calls.join('\n'));
assert.match(calls[0], /^timeout 900 npm ci /);
assert.match(calls[1], /^timeout 900 npm run build/);
});
test('ein Build-Fehler unter timeout bleibt ein ERROR mit Exit-Code 1', () => {
const ctx = setup({ withTimeoutStub: true });
const res = runSync(ctx, { STUB_NPM_FAIL: '1' });
assert.equal(res.status, 1);
assert.match(res.out, /ERROR: npm ci fehlgeschlagen/);
});
+16 -2
View File
@@ -7,6 +7,7 @@
server {
listen 80;
server_name _;
server_tokens off;
root /usr/share/nginx/html;
index index.html;
@@ -15,6 +16,8 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# TLS endet bei Traefik; der Header läuft durch und gilt für den Browser (kein includeSubDomains).
add_header Strict-Transport-Security "max-age=31536000" always;
gzip on;
gzip_vary on;
@@ -39,11 +42,22 @@ server {
}
# Die Appwrite-API liegt nur über Traefik (Header X-Appwrite-Project) auf /v1.
# Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern.
location ^~ /v1/ {
# Ohne diesen Header darf nginx hier nie die App (index.html) ausliefern, auch nicht für genau /v1.
# Regex-Block: steht vor den anderen Regex-Blöcken (die erste passende Regel gewinnt, /v1/x.png
# soll also 404 liefern); /assets/ bleibt durch das ^~ oben unberührt.
location ~ ^/v1(/|$) {
return 404;
}
# Die Community-API (aircard-api) liegt ebenfalls nur über Traefik auf /api. Fehlt der Dienst (Container weg, damit
# auch sein Traefik-Router), darf /api/* nie die App ausliefern: der Client bekäme HTML mit 200 statt eines Fehlers.
# Stattdessen 503 mit JSON-Text, den die Seite wie eine API-Antwort anzeigt. Gleiche Reihenfolge-Regel wie bei /v1.
# (Kein add_header: sonst entfallen die Sicherheits-Header des server-Blocks; default_type setzt den Content-Type.)
location ~ ^/api(/|$) {
default_type application/json;
return 503 '{"error":"Der Dienst ist gerade nicht erreichbar."}';
}
location = /index.html {
expires -1;
}
+55 -13
View File
@@ -2,18 +2,22 @@
Klickliste für Kenso. Dauer: etwa 5 Minuten. Console: <https://appwrite.webklar.com/console>
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabelle, Bucket) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
Alles passiert in der bestehenden Organisation. Das Schema (Datenbank, Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` mit Spalten und Indizes, Buckets `card-images`, `gallery-images` und `logo-images`) legt danach das Skript `web/scripts/appwrite-setup.mjs` an – dafür musst du nichts anklicken.
**Hinweis:** Das Projekt wird mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt und dort in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID` gespeichert. Das Skript liest diese ID aus der Umgebungsvariablen; der Wert muss beim Aufruf verfügbar sein.
**Hinweis:** Das Projekt wurde mit der automatisch generierten ID `6ac2130a00289343d6d5` angelegt (nicht mit einer selbst gewählten ID). Diese ID steht in `/home/webklar/apps/.env` unter `AIRCARD_APPWRITE_PROJECT_ID`; das Skript liest sie von dort, der Wert muss beim Aufruf verfügbar sein.
**Stand der Einstellungen:** Schritt 2 (Web-Plattform `aircard-ios.org`) ist Voraussetzung, ohne sie lehnt Appwrite die Anfragen der Seite ab. Die Schritte 3 und 4 sind empfohlene Härtung. Ob sie in der Console umgesetzt sind, prüft kein Skript (`--check` deckt nur das Schema ab: Tabellen, Spalten, Indizes, Buckets). Solange du sie nicht selbst gesetzt hast, läuft das Live-Projekt mit den Appwrite-Standards für Auth und Dienste. Das Schema und die Zeilen-/Dateirechte sind davon unabhängig.
## 1. Projekt anlegen
1. In der Console anmelden und die bestehende Organisation öffnen.
2. **Create project** klicken.
3. Name: `AirCard`.
4. Beim Feld **Project ID** auf das Stift-Symbol (bzw. „Edit“) klicken und `aircard` eintragen. Die ID lässt sich später nicht mehr ändern; sie muss genau `aircard` heißen.
4. Das Feld **Project ID** unverändert lassen: Appwrite erzeugt die ID selbst. Nach dem Anlegen steht sie unter **Settings** (und in der Adresszeile der Console). Sie ist öffentlich, steckt im JavaScript der Seite.
5. **Create** klicken.
Bei einem Neuaufbau bekommt das neue Projekt eine neue ID. Dann müssen drei Stellen dazu passen: die Traefik-Regel (`deploy/traefik/aircard.yml`, dort steht die ID im `Header(...)`), `AIRCARD_APPWRITE_PROJECT_ID` in `/home/webklar/apps/.env` und der Standardwert in `web/src/account/appwrite.ts`.
## 2. Web-Plattformen eintragen
Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **Add platform** → **Web**.
@@ -21,11 +25,11 @@ Projekt `AirCard` → **Overview** → **Integrations** → **Platforms** → **
1. Name `AirCard Website`, Hostname `aircard-ios.org` → **Create**.
2. Noch einmal **Add platform** → **Web**: Name `Entwicklung`, Hostname `localhost` → **Create**.
## 3. Auth-Einstellungen
## 3. Auth-Einstellungen (empfohlene Härtung, keine Voraussetzung für den Betrieb)
Linke Leiste → **Auth** → **Settings**.
Linke Leiste → **Auth** → **Settings**. Registrieren und Anmelden funktionieren auch mit den Standardwerten; die folgenden Einstellungen begrenzen nur, was Besucher mit dem Projekt anstellen können.
1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous**, **JWT** (steht dort auch **Invites**: ebenfalls aus).
1. Bei **Auth methods** nur **Email/Password** eingeschaltet lassen. Ausschalten: **Magic URL**, **Email OTP**, **Phone**, **Anonymous** (steht dort auch **Invites**: ebenfalls aus). **JWT** bleibt **an** – ohne JWT kann die Community-API (Abschnitt „Community“) die Nutzer nicht prüfen.
2. **Session duration**: `30` Tage → **Update**.
3. **Max sessions** (Sitzungen pro Nutzer): `5` → **Update**.
4. **Limit** (Nutzerlimit): `2000` → **Update**.
@@ -33,11 +37,11 @@ Linke Leiste → **Auth** → **Settings**.
Keine E-Mail-Bestätigung nötig, Mindestlänge 8 Zeichen ist Standard – dafür ist nichts einzustellen. Die „Passwort vergessen“-Mail geht über den globalen Server-SMTP (Absender WEBklar), auch dafür ist nichts einzustellen.
## 4. Nicht benötigte Dienste abschalten
## 4. Nicht benötigte Dienste abschalten (optional)
Linke Leiste → **Settings** → Reiter **Services**.
Linke Leiste → **Settings** → Reiter **Services**. Das ist optionale Härtung: Die Website braucht diese Dienste nicht, schadet aber auch nicht, wenn sie an bleiben. Solange du das nicht gemacht hast, sind sie am Live-Projekt an (z. B. antwortet GraphQL).
1. Ausschalten: **Functions**, **Sites**, **Messaging**, **Teams**, **GraphQL**.
1. Ausschalten, soweit die Console es dort anbietet: **Functions**, **Sites**, **Messaging**, **Teams**, **GraphQL**.
2. Alles andere (Account, Avatars, Databases, Locale, Storage …) bleibt an.
## 5. API-Key `aircard-provision` anlegen
@@ -54,20 +58,58 @@ Dieser Key gehört nur dem Setup-Skript. Die Website selbst braucht ihn nicht.
4. **Create** klicken.
5. Auf der Seite des neuen Keys den **Secret**-Wert kopieren (Auge-Symbol zum Einblenden, dann kopieren).
Die Indizes der Tabellen `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` legt das Skript mit denselben Scopes an; ein eigener Index-Scope ist nicht nötig.
## 6. Key auf dem Server eintragen
Der Key gehört in die Datei `/home/webklar/apps/.env`, nirgendwo sonst.
1. Auf dem Server die Datei öffnen, z. B. `nano /home/webklar/apps/.env`.
2. Ans Ende springen und eine neue Zeile anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=`:
2. Ans Ende springen. Steht der Cursor am Ende der letzten Zeile, einmal Enter drücken, damit die neuen Zeilen in eigenen Zeilen stehen (sonst klebt der erste neue Eintrag an einem bestehenden Wert und macht ihn kaputt). Dann neue Zeilen anfügen, ohne Anführungszeichen und ohne Leerzeichen um das `=` (die Projekt-ID ist die in Schritt 1 notierte, siehe Hinweis oben):
```
AIRCARD_APPWRITE_PROJECT_ID=<Projekt-ID>
AIRCARD_APPWRITE_API_KEY=<hier den kopierten Secret-Wert einfügen>
```
3. Speichern (in `nano`: Strg+O, Enter, Strg+X). Andere Zeilen nicht verändern.
4. Den Key **nicht im Chat teilen**, nicht in Screenshots zeigen und nirgends einchecken. Claude liest ihn nie selbst, das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`.
4. Den Key möglichst nicht im Chat teilen (wer ihn dort einfügt, gibt ihn in den Verlauf), nicht in Screenshots zeigen und nirgends einchecken. Das Skript bekommt ihn über `node --env-file=/home/webklar/apps/.env`, dafür muss ihn niemand sonst sehen.
## 7. Bescheid geben
## 7. Community
Schreib Claude „Console fertig“. Danach legt Claude Datenbank `aircard`, Tabelle `cards` und Bucket `card-images` mit dem Skript an und prüft sie.
Für die Community-Galerie und die Community-Logos (Tabellen `gallery`, `reports`, `logos`, `logo_likes` und `logo_reports`, Buckets `gallery-images` und `logo-images`, API-Dienst `aircard-api`) sind drei Dinge in der Console nötig. Das Schema dazu legt dasselbe Skript an (`node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs`, vorher mit `--check` ansehen, was fehlt).
### 7.1 JWT muss an sein
JWT muss **an** sein – falls in Schritt 3 ausgeschaltet, wieder einschalten. Auth → **Settings** → **Auth methods** → **JWT** einschalten. Die API prüft damit, wer Veröffentlichen, Zurückziehen oder eine Admin-Aktion auslöst.
### 7.2 Label `admin` am eigenen Konto
Das Label `admin` macht dich zum Admin der Galerie und der Community-Logos: Die Website zeigt dir dann Ein-/Ausblenden-Knöpfe und die Filter „Gemeldet“/„Ausgeblendet“ (in der Galerie und im Logo-Bereich des Editors).
1. Linke Leiste → **Auth** → **Users** → dein Konto öffnen.
2. Bei **Labels** den Wert `admin` eintragen → **Update**.
### 7.3 API-Key `aircard-api` anlegen
Dieser Key gehört nur dem API-Dienst `aircard-api`, nicht dem Setup-Skript und nicht der Website.
1. **Overview** → **Integrations** → **API keys** → **Create API key**.
2. Name: `aircard-api`. Ablauf (Expiration): **Never**.
3. Unter **Scopes** genau diese vier ankreuzen:
- `documents.read` und `documents.write`
- `files.read` und `files.write`
Die Console beschriftet die Zeilen-Scopes mit `documents.*`; das sind dieselben wie die `rows.read`/`rows.write` der Appwrite-API (Tabellen heißen dort Zeilen). Einen Eintrag `rows.*` gibt es in der Console nicht. Dieselben vier Scopes genügen für die Logo-Endpunkte (Tabellen `logos`, `logo_likes`, `logo_reports`, Bucket `logo-images`).
4. **Create** klicken und auf der Seite des Keys den **Secret**-Wert kopieren.
5. Auf dem Server den Wert verdeckt in die `.env` schreiben. Der Befehl fragt den Wert ohne Anzeige ab (`read -rs`), schreibt bei leerer Eingabe nichts, beginnt mit einem Zeilenumbruch (so klebt die Zeile nie an einer letzten Zeile ohne Umbruch) und zeigt den Key nirgends an:
```
read -rs -p "AIRCARD_API_KEY: " K; echo; if [ -n "$K" ]; then printf '\nAIRCARD_API_KEY=%s\n' "$K" >> /home/webklar/apps/.env; else echo "Leer – nichts geschrieben."; fi; unset K
```
Den Wert nicht im Chat teilen. Wurde der Provisioning-Key aus Schritt 5 einmal im Chat geteilt, ihn in der Console unter **API keys** löschen und bei Bedarf neu anlegen.
## 8. Bescheid geben
Schreib Claude „Console fertig”. Danach legt Claude Datenbank `aircard`, die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` (mit Spalten und Indizes) und die Buckets `card-images`, `gallery-images`, `logo-images` mit dem Skript an und prüft sie. Für die Community-Galerie schreib zusätzlich „Label und Key sind gesetzt”.
@@ -0,0 +1,188 @@
# AirCard-IOS Startseite & Community-Galerie – Implementierungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Kompakte Startseite mit Community-Galerie, Veröffentlichen/Melden/Admin, Spenden-Banner und Hell/Dunkel-Schalter für https://aircard-ios.org.
**Architecture:** Der bestehende Vite-+-TS-Client bekommt neue Routen und Module (`web/src/community/*`, `web/src/shell/*`). Lesen der Galerie direkt aus Appwrite (öffentliche Zeilen), alle Galerie-Schreibvorgänge über einen neuen kleinen Node-Dienst `aircard-api` (`deploy/api/`) mit Server-Key, erreichbar unter `/api` per Traefik. Schema-Erweiterung über das bestehende Setup-Skript.
**Tech Stack:** wie bisher (TypeScript 6.0.3, Vite 8.3.2, Vitest 5.0.3, happy-dom, appwrite 22.4.1), Node 22 für `aircard-api` (nur Built-ins), Appwrite 1.8.1 TablesDB, Traefik v3.6.
**Spec:** `docs/superpowers/specs/2026-10-04-aircard-community-design.md` (Commit 970e68e). Basis: `docs/superpowers/specs/2026-10-04-aircard-website-design.md`.
## Global Constraints
- Arbeitskopie `/home/webklar/apps/aircard-ios.org/dev`, Branch `main`, direkt committen, **nicht pushen** (Controller pusht).
- Versionen unverändert gepinnt; keine neuen npm-Abhängigkeiten im Client; `deploy/api/` ohne Abhängigkeiten.
- Alle sichtbaren Texte Deutsch, Wortlaut aus der Spec.
- Appwrite: Projekt `6ac2130a00289343d6d5`; Datenbank `aircard`; neue Tabellen `gallery`, `reports`; neuer Bucket `gallery-images`; bestehende `cards`/`card-images` unverändert.
- Galerie-Zeilenrechte: sichtbar genau `['read("any")']`; ausgeblendet genau `['read("user:<owner>")','read("label:admin")']`. Galerie-Dateirechte sichtbar `['read("any")','delete("user:<owner>")']`, ausgeblendet `['read("user:<owner>")','read("label:admin")','delete("user:<owner>")']`.
- Auto-Ausblenden ab **3** verschiedenen Meldern; Rate-Limit **10** Meldungen pro Stunde und IP; Seiten zu **24** Karten.
- Admin = Appwrite-Label `admin` (`account.get().labels`).
- `DONATE_URL` in `web/src/config.ts`; leer ⇒ Banner und Spenden-Knopf unsichtbar.
- Theme: `data-theme` auf `<html>`, `localStorage` `aircard.theme`; Banner `localStorage` `aircard.donateBannerClosed`. Jeder `localStorage`-Zugriff in try/catch.
- Hero: bei 1440 × 900 und 390 × 844 müssen „Community-Karten“-Überschrift und der obere Rand der ersten Kartenreihe im ersten Bildschirm sichtbar sein.
- Keine Secrets in Ausgaben, Logs, Commits; `.env` nie öffnen (Ausnahme: Controller-Ops).
- Tests: `cd web && npx vitest run`, `npx tsc --noEmit`, `npm run build`; `node --test deploy/api/*.test.js` (Glob in Anführungszeichen), bestehende node-Tests grün halten.
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Subagents: `sonnet` (Implementierung/Review), `haiku` für kleine Nachprüfungen; nie Subagents aus Subagents.
## Review Focus
- **Fremdes Veröffentlichen**: Nutzer schickt `imageFileId` einer fremden Datei an `/api/veroeffentlichen` → 403, nichts angelegt. Test in Task 3.
- **Meldungs-Flut / Selbst-Ausblenden**: dieselbe IP meldet 5× → zählt 1; 11. Meldung in einer Stunde → 429. Test in Task 3.
- **Ausgeblendete Karte per Direkt-Link**: Gast öffnet `/community/<id>` einer ausgeblendeten Karte → „nicht (mehr) verfügbar“, kein Bild. Test in Task 6 (Client) und Task 3 (Rechte).
- **Leere Galerie / leeres `DONATE_URL`**: Startseite ohne Karten zeigt Beispielmotiv und Leertext; ohne Link kein toter Spenden-Knopf. Tests in Tasks 1 und 5.
- **Theme-Wechsel mit offenem Editor**: Umschalten ändert nur UI-Tokens, nicht die Kartenfarben/PNG. Test in Task 1 (render unabhängig von Theme).
---
### Task 1: Shell – Routen, Kopfzeile, Theme, Spenden-Banner
**Files:**
- Create: `web/src/config.ts`, `web/src/shell/theme.ts`, `web/src/shell/donate-banner.ts`, `web/src/shell/header.ts`, `web/src/shell/shell.css`, `web/src/pages/home-page.ts` (Platzhalter mit Überschrift, Task 5 füllt), `web/src/pages/community-page.ts` (Platzhalter, Task 6 füllt)
- Modify: `web/index.html` (Kopfzeilen-Gerüst, Banner-Slot `#donate-banner`, Theme-Init-Skript gegen Aufblitzen), `web/src/pages/router.ts`, `web/src/main.ts`, `web/src/styles.css` (helle Tokens), `web/src/account/account-menu.ts` (Avatar-Kreis + Menü), `web/src/account/auth.ts` (`User.labels`, `isAdmin`), `web/src/editor/card-url.ts` und `web/src/pages/cards-page.ts` (Pfad `/gestalten?karte=`)
- Test: `web/src/pages/router.test.ts`, `web/src/shell/theme.test.ts`, `web/src/shell/donate-banner.test.ts`, `web/src/shell/header.test.ts`, `web/src/account/auth.test.ts` (labels)
**Interfaces:**
- Produces:
```ts
// router.ts (ersetzt RouteId)
type RouteId = 'home' | 'gestalten' | 'cards' | 'community' | 'reset'
interface Route { id: RouteId; params: { id?: string } }
matchRoute(pathname: string): Route // '/' home, '/gestalten' gestalten, '/karten' cards, '/community/<id>' community {id}, '/passwort-neu' reset, sonst home
legacyRedirect(url: URL): string | null // '/?karte=X' → '/gestalten?karte=X', sonst null
navigate(path: string): void // pushState, render, scrollTo(0,0) außer bei Hash-Ziel (dann scrollIntoView des Ankers)
startRouter(render: (route: Route, root: HTMLElement) => void | (() => void)): void
ROUTE_TITLES / titleFor(route: Route, detailTitle?: string): string // Wortlaut Spec §2
// config.ts
export const DONATE_URL: string = '' // Kenso liefert den Link
// theme.ts
type Theme = 'light' | 'dark'
initialTheme(stored: string | null, prefersDark: boolean): Theme
applyTheme(t: Theme): void; toggleTheme(): Theme // speichert in localStorage (try/catch)
// donate-banner.ts
mountDonateBanner(slot: HTMLElement, url: string): void // nichts rendern wenn url leer oder geschlossen
// header.ts
mountHeader(el: HTMLElement, opts: { donateUrl: string }): () => void // Navigation, Suchfeld (Enter → navigate('/?q=…#community')), ☕-Knopf, Theme-Schalter, Menü-Icon < 900 px
// auth.ts
type User = { $id: string; email: string; labels: string[] }
isAdmin(user: User | null): boolean
```
- Kopfzeile: Logo `/favicon.svg?v=2` + „AirCard-IOS“ (→ `/`), Links „Community“ (`/#community`), „Gestalten“ (`/gestalten`), „Meine Karten“ (`/karten`), Suchfeld Platzhalter „Titel, Tags oder Herausgeber“, Knopf „☕ Unterstützen“, Theme-Knopf (`aria-label` „Hell/Dunkel umschalten“), Konto-Slot. Banner-Text exakt aus Spec §3.
- `index.html`: kleines Inline-Skript im `<head>` setzt `data-theme` aus `localStorage`/System vor dem ersten Paint (in try/catch).
- **Ergänzungen aus Plan-Review (verbindlich):** Helles Theme: Tokens `--danger-text`, `--warn-text`, `--ok-text` in beiden Themes; die harten Farben (#ff9a9d, #f2c66d, #8fd9a8) in `web/src/pages/cards.css`, `web/src/editor/editor.css`, `web/src/account/account.css` durch diese Tokens ersetzen; `color-scheme` je Theme. `User.labels` optional (`labels?: string[]`), `getCurrentUser` setzt `labels ?? []`, `isAdmin` nutzt `?? []`; betroffene Tests anpassen (auth.test.ts `toEqual`, cards-page.test.ts erwartet `navigate('/gestalten?karte=a')`). `cards-page.ts` „Zum Editor“-Link → `/gestalten`. Marken-Link bleibt statisch in `index.html` (header.ts ergänzt nur Navigation/Suche/Knöpfe drumherum); statischer `<title>` wird „AirCard-IOS – Kartendesigns für Apple Wallet“, `branding.test.ts` entsprechend anpassen. Platzhalter-Signaturen: `mountHomePage(root)`, `mountCommunityPage(root, id)`.
- [ ] **Step 1: Tests schreiben** – router: alle Routen inkl. `/community/abc123` → `{id:'community', params:{id:'abc123'}}`, `/community/` → home, unbekannt → home; `legacyRedirect(new URL('https://x/?karte=K1'))` → `/gestalten?karte=K1`, ohne `karte` → null; `navigate('/karten')` ruft `scrollTo(0,0)`; Titel je Route. theme: `initialTheme(null,true)` → dark, `('light',true)` → light, ungültiger Wert → System; `toggleTheme` setzt `data-theme` und speichert; `localStorage` wirft → kein Fehler. donate-banner: leere URL → nichts gerendert; Schließen setzt Flag und entfernt; Flag gesetzt → nichts. header: Suche Enter mit „ visa “ → `navigate('/?q=visa#community')`; leere Eingabe → nichts; kein ☕-Knopf bei leerem `donateUrl`. auth: `getCurrentUser` übernimmt `labels` (fehlend → `[]`), `isAdmin` true nur mit `'admin'`.
- [ ] **Step 2: Tests laufen lassen** → FAIL.
- [ ] **Step 3: Implementieren**; `main.ts`: Legacy-Umleitung vor `startRouter` (replaceState), Routen auf die Mount-Funktionen; Editor jetzt unter `gestalten`; `document.title` per `titleFor`.
- [ ] **Step 4: Prüfen** – Suite grün, `tsc`, `build`.
- [ ] **Step 5: Commit** – `feat(shell): Routen, Kopfzeile, Hell/Dunkel, Spenden-Banner`
### Task 2: Schema-Erweiterung (Setup-Skript)
**Files:**
- Modify: `web/scripts/appwrite-setup.mjs`, `web/scripts/appwrite-setup.test.mjs`, `docs/appwrite-console.md`
**Interfaces:**
- `DESIRED` bekommt: Tabelle `gallery` (rowSecurity true, Tabellenrechte `[]`), Spalten gemäß Spec §7 (Typen string/boolean/integer, Standardwerte `hidden=false`, `reportCount=0`), Indizes `search_text` (fulltext, `[searchText]`), `owner` (key, `[ownerId]`), `hidden` (key, `[hidden]`); Tabelle `reports` (rowSecurity false, Tabellenrechte `['read("label:admin")']`), Spalten `galleryId` string 36 Pflicht, `reason` enum `[unangemessen, kartendaten, urheberrecht, spam, sonstiges]` Pflicht, `reporterHash` string 64 Pflicht, Index `gallery` (key, `[galleryId]`); Bucket `gallery-images` (Rechte `['create("users")']`, fileSecurity true, maximumFileSize 5242880, Endungen `[png, webp]`, compression none, antivirus false).
- REST: `POST …/columns/boolean {key, required:false, default:false}`, `…/columns/integer {key, required:false, default:0, min:0}`, `…/columns/enum {key, elements, required:true}`, Indizes `POST /tablesdb/{db}/tables/{t}/indexes {key, type: 'fulltext'|'key', columns}` erst nachdem alle Spalten `available`; Index-Status ebenfalls auf `available` warten. Vergleich (`--check`) deckt Spaltentyp, Standard, Enum-Elemente, Index-Typ/Spalten ab.
- `docs/appwrite-console.md`: Abschnitt „Community“: JWT muss **an** bleiben; Label `admin` setzen; Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) + `read -s`-Befehl für `AIRCARD_API_KEY`; Hinweis, dass der Provisioning-Key für Indizes zusätzlich `indexes.read/write` braucht.
- **Ergänzungen aus Plan-Review (verbindlich):** `DESIRED` umbauen auf `tables: [...]` (cards zuerst, dann gallery, reports) und `buckets: [...]` (card-images, gallery-images); `ensureTable/ensureColumns/waitAvailable/ensureBucket` parametrisieren, betroffene Tests umschreiben. `compareColumn` je Typ: string → size/required; boolean → required/default; integer → required/default (min/max der Antwort NICHT vergleichen); enum → elements (sortiert)/required. Index-Routen brauchen nur `tables.*`-Scopes (kein `indexes`-Scope) – in den Docs nicht verlangen. Docs: „JWT muss **an** sein – falls in Schritt 3 ausgeschaltet, wieder einschalten.“
- [ ] **Step 1: Tests** – Fake-Server um boolean/integer/enum/index-Routen erweitern; leerer Zustand → `--check` meldet alle neuen Objekte als FEHLT; Anlegen → alles da, Indizes erst nach `available`-Spalten; zweiter Lauf legt nichts an; abweichender Index-Typ → ABWEICHUNG.
- [ ] **Step 2: FAIL**, **Step 3: Implementieren**, **Step 4:** `node --test web/scripts/appwrite-setup.test.mjs` grün, **Step 5: Commit** `feat(appwrite): Schema für Galerie, Meldungen und Galerie-Bilder`.
### Task 3: API-Dienst `aircard-api`
**Files:**
- Create: `deploy/api/appwrite.js` (REST-Helfer mit Server-Key), `deploy/api/app.js` (Routen, Validierung, Logik), `deploy/api/server.js` (Start: Env lesen, `http.createServer`), `deploy/api/app.test.js`, `deploy/api/README.md`
- Modify: `deploy/compose-snippet.yml` (Service `aircard-api`), `deploy/README.md`
**Interfaces:**
- `appwrite.js`: `createAppwrite({ endpoint, project, key, fetchImpl })` → `{ getRow, listRows(table, queries), createRow, updateRow(table, id, data, permissions?), deleteRow, getFile(bucket, id), updateFilePermissions(bucket, id, permissions), deleteFile, accountFromJwt(jwt) }` (letzteres `GET /account` mit `X-Appwrite-JWT`, ohne Key; der Client schickt das JWT im Header `X-Aircard-Jwt`). Queries als Appwrite-JSON-Strings (`{"method":"equal","attribute":"…","values":[…]}`).
- `app.js`: `createApp({ appwrite, salt, now = Date.now, log })` → `(req, res) => Promise<void>`; reine Helfer exportiert: `visibleRowPermissions()`, `hiddenRowPermissions(owner)`, `visibleFilePermissions(owner)`, `hiddenFilePermissions(owner)`, `reporterHash(salt, ip)` (HMAC-SHA256 hex), `clientIp(req)` (`x-real-ip`, sonst erstes `x-forwarded-for`, sonst Socket), `validatePublish(body)` → `{ok, data|message}` (Längen Spec §6, Tags normalisiert: trim, klein, leer raus, max. 5, je ≤ 20), `buildSearchText({title, issuer, tags})` (klein, ≤ 300).
- Endpunkte, Statuscodes und Texte exakt Spec §8/§11: `POST /api/veroeffentlichen` (401 ohne/ungültiges JWT, 400 Validierung, 403 Datei nicht meine/falscher Bucket, 409 „Diese Karte ist schon veröffentlicht.“, 201 `{id}`), `POST /api/zurueckziehen` (403 fremd, 404 fehlt, 200), `POST /api/melden` (400 Grund, 404 fehlt/ausgeblendet, 200 `{ok:true}` bzw. `{schonGemeldet:true}`, 429 „Zu viele Meldungen – bitte später nochmal.“), `POST /api/admin/sichtbarkeit` (403 kein Admin, 200), `GET /api/gesundheit`. Body max. 8 KB (413), nur `application/json` (415), sonst 404.
- `server.js`: Env `AIRCARD_API_PORT` (8088), `AIRCARD_APPWRITE_ENDPOINT` (`http://webklar-appwrite/v1`), `AIRCARD_APPWRITE_PROJECT_ID`, `AIRCARD_API_KEY`, `AIRCARD_API_SALT`; fehlt Key oder Salt → Fehlermeldung ohne Wert, `exit(1)`.
- Compose-Service: `aircard-api` = `node:22-alpine`, `container_name webklar-aircard-api`, `init: true`, `mem_limit 128m`, `restart unless-stopped`, Volume `./aircard-ios.org/api:/srv:ro`, Command `node /srv/server.js`, Env wie oben aus `${…}`, Netz `webklar-network`, Labels Router `aircard-api` ``Host(`aircard-ios.org`) && PathPrefix(`/api`)`` websecure/letsencrypt Priorität 200, Port 8088.
- **Ergänzungen aus Plan-Review (verbindlich):** (a) Jede ID im Body (`id`, `imageFileId`, `previewFileId`, `sourceCardId`) muss `^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$` erfüllen, sonst 400; in `appwrite.js` jedes Pfadsegment `encodeURIComponent`; Tests mit `../x`, `..`, Zahl statt String. (b) Deterministische Zeilen-IDs: Galerie `rowId` = erste 32 Hex von `sha256(ownerId + '|' + sourceCardId)` → Appwrite-409 (doppelt) → API 409 „Diese Karte ist schon veröffentlicht.“; Meldung `rowId` = erste 32 Hex von `sha256(galleryId + '|' + reporterHash)` → 409 → `schonGemeldet`; `reportCount` = Anzahl Meldungszeilen der Karte (limit 100). (c) Dateien: fehlt → 400 „Bild nicht gefunden.“; anderer Bucket oder fremd → 403; `mimeType` ∈ {image/png, image/webp} und `sizeOriginal` ≤ 5 MiB für beide (Vorschau darf PNG sein). (d) Body: `tags` ist `string[]` (Server normalisiert erneut, > 5 oder > 20 Zeichen → 400); Admin-Body `{id, sichtbar}`. (e) IPv6-Adressen vor dem Hashen auf /64 kürzen; abgelaufene Rate-Limit-Einträge entfernen; Appwrite-Aufrufe mit `AbortSignal.timeout(10000)`. (f) Traefik-Router heißt `aircard-gallery-api`; Kommentare „Starten NUR mit …“ in compose-snippet.yml/README um `aircard-api` ergänzen.
- [ ] **Step 1: Tests** (`node:test`, Fake-Appwrite im Speicher, echte HTTP-Requests auf Port 0): Veröffentlichen ok (Zeile mit sichtbaren Rechten, `searchText`, `ownerId`); ohne JWT 401; fremde Datei (Rechte ohne `delete("user:me")`) 403; Datei in falschem Bucket 403; doppelt 409; Validierung (Titel leer/zu lang, 6 Tags) 400. Melden: 3 verschiedene IPs → `hidden=true`, Zeilen- und Dateirechte = ausgeblendet; dieselbe IP 5× → `reportCount` 1, Antworten `schonGemeldet`; 11. Meldung derselben IP in 1 h → 429, nach `now`+1 h wieder erlaubt; ausgeblendete Karte melden → 404. Zurückziehen: Autor ok (Zeile, 2 Dateien, Meldungen weg), Admin ok, fremd 403. Admin: Nicht-Admin 403; Einblenden setzt sichtbare Rechte, `hidden=false`, `reportCount=0`, löscht Meldungen. Body > 8 KB → 413; Log enthält nie Key/Salt/JWT.
- [ ] **Step 2: FAIL** (`node --test "deploy/api/*.test.js"`), **Step 3: Implementieren**, **Step 4: PASS**, **Step 5: Commit** `feat(api): aircard-api für Veröffentlichen, Melden und Admin`.
### Task 4: Client-Datenschicht Community
**Files:**
- Create: `web/src/community/gallery.ts`, `web/src/community/api.ts`, `web/src/community/publish.ts`
- Modify: `web/src/account/appwrite.ts` (`IDS.gallery='gallery'`, `IDS.reports='reports'`, `IDS.galleryBucket='gallery-images'`), `web/src/account/cards.ts` (`fetchFile(bucketId, fileId)` verallgemeinern, bestehende Aufrufer anpassen)
- Test: `web/src/community/gallery.test.ts`, `api.test.ts`, `publish.test.ts`
**Interfaces:**
- `gallery.ts`: `interface GalleryCard { id; title; issuer; tags: string[]; displayName; imageFileId; previewFileId; sourceCardId; ownerId; hidden: boolean; reportCount: number; createdAt: string }`; `toGalleryCard(row)`; `galleryQueries(opts: { q?: string; cursor?: string; filter?: 'all'|'reported'|'hidden' }): string[]` (öffentlich immer `equal('hidden', false)`, `orderDesc('$createdAt')`, `limit(24)`, Suche `search('searchText', q)`, Admin-Filter laut Spec §7); `listGallery(opts)` → `{ cards, nextCursor | null }`; `getGalleryCard(id)` → `GalleryCard | null` (404 → null); `myPublications(userId)` → `Map<sourceCardId, GalleryCard>`; `listReports(galleryId)` (Admin); `loadGalleryImage(card, 'full'|'preview')` → Blob (über `fetchFile`).
- `api.ts`: `apiPost(path, body, { auth: boolean })` → JSON; mit `auth` holt `account.createJWT()` (Cache 10 min); Fehler als `ApiError { status, message }` mit Server-Text; Netzfehler → „Der Dienst ist gerade nicht erreichbar.“; `publishCard(input)`, `withdrawCard(id)`, `reportCard(id, reason)`, `setVisibility(id, visible)`.
- `publish.ts`: `publishSavedCard(card: SavedCard, form: { title; issuer; tags; displayName }, deps)` → `{ id }`: Bild laden, rendern (PNG 1536 + WebP 480 via `exportCard`), zwei Uploads in `gallery-images` mit `[Permission.read(Role.any()), Permission.delete(Role.user(uid))]`, dann `publishCard`; bei Fehler beide Dateien löschen (best effort) und Fehler weiterwerfen. `normalizeTags(input: string): string[]` (wie Server).
- **Ergänzungen aus Plan-Review (verbindlich):** JWT-Cache je Nutzer-ID, geleert bei `onAuthChange`/`onLogout`; API-401 → neues JWT holen, genau einmal wiederholen; kein `duration`. `ApiError` hat numerisches `code` (= HTTP-Status) und `message` (Servertext), damit `isUnauthorized`/`withLogin`/`errorMessage` greifen; Nicht-JSON, 502/503/504 oder HTML-200 → `code 503` „Der Dienst ist gerade nicht erreichbar.“; `createJWT`-Fehler `user_auth_method_unsupported` → „Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.“ In `cards.ts` eine eigenständige exportierte `fetchFile(bucketId, fileId)`; `CardsApi.fetchFile(fileId)` bleibt als Hülle über `IDS.bucket` (bestehende Tests unverändert). Tags an die API als `string[]`. PNG > 5 MiB vor dem Upload → „Das Kartenbild ist zu groß für die Galerie (max. 5 MB).“; Vorschau-Datei nach echtem Blob-Typ benennen. `myPublications`/`listReports` mit `limit(100)`. `nextCursor` = `cards.length === 24 ? letzte id : null`. Admin-Filter „Gemeldet“ = `greaterThan('reportCount', 0)` (ohne hidden-Bedingung). `getGalleryCard` mit ungültiger ID (Muster wie API) → `null` ohne Anfrage.
- [ ] **Step 1: Tests** – Queries exakt (inkl. Suche + Cursor + Admin-Filter); `toGalleryCard` mit fehlenden Feldern; `getGalleryCard` 404 → null; `apiPost` mit `auth` sendet Header `X-Aircard-Jwt: <jwt>`; JWT-Cache (zweiter Aufruf innerhalb 10 min ohne neues `createJWT`); Fehlertext aus JSON `message`; Netzfehler-Text. `publishSavedCard`: Reihenfolge Upload, Upload, API; API-Fehler → 2× `deleteFile`, Fehler kommt an; zweiter Upload scheitert → erster gelöscht.
- [ ] **Step 2: FAIL**, **Step 3: Implementieren**, **Step 4: PASS + tsc + build**, **Step 5: Commit** `feat(community): Datenschicht für Galerie, API und Veröffentlichen`.
### Task 5: Startseite (Hero + Galerie)
**Files:**
- Modify: `web/src/pages/home-page.ts` (Platzhalter aus Task 1 ersetzen)
- Create: `web/src/community/gallery-grid.ts`, `web/src/community/community.css`, `web/public/hero-beispiel.png` (1536 × 969, eigenes markenfreies Motiv: lila/blauer Verlauf mit abstrakten Wellen + AirCard-IOS-Symbol, erzeugt mit einem Skript im Scratchpad)
- Test: `web/src/pages/home-page.test.ts`, `web/src/community/gallery-grid.test.ts`
**Interfaces:**
- `mountHomePage(root, deps = defaultDeps)` – Hero laut Spec §4 (Texte exakt), Hero-Karte = Vollbild der neuesten sichtbaren Karte oder `/hero-beispiel.png`; Galerie-Abschnitt `id="community"`; liest `?q=` aus der URL; „Mehr laden“ hängt an; Leer-/Kein-Treffer-Texte exakt; Admin sieht Filter-Chips „Alle/Gemeldet/Ausgeblendet“.
- `mountGalleryGrid(el, { load, onTag })` – Kacheln (Vorschau per Object-URL, beim Cleanup freigeben), Titel, Herausgeber, ≤ 3 Tags als Knöpfe; Kachel verlinkt `/community/<id>` (`data-link`).
- CSS: Hero `min-height: auto; max-height: 60vh`, Raster `repeat(auto-fill, minmax(260px, 1fr))`; Hero-Karte schwebt (`@media (prefers-reduced-motion: no-preference)`).
- **Ergänzungen aus Plan-Review (verbindlich):** Tag-Klick sucht nicht per Volltext (min. 3 Zeichen, Stoppwörter), sondern navigiert zu `/?tag=<tag>#community` → Query `contains('tags', tag)`; Suchfeld nutzt Volltext ab 3 Zeichen, kürzere Begriffe → `contains('searchText', q)`. Mehrwort-Suche = ODER (akzeptiert).
- [ ] **Step 1: Tests** – leere Galerie → Beispielbild + Leertext; Suche aus `?q=` landet in Queries und zeigt aktiven Begriff mit „ד; „Mehr laden“ nur bei `nextCursor`; Tag-Klick navigiert zu `/?q=<tag>#community`; Object-URLs werden beim Cleanup freigegeben.
- [ ] **Step 2: FAIL**, **Step 3: Implementieren** (inkl. Hero-Bild), **Step 4: PASS + build**; Sichtprüfung headless Chromium (`npx vite preview`) bei 1440 × 900 und 390 × 844 mit gemockter Galerie (Playwright `page.route` beantwortet `/v1/tablesdb/…/rows` mit 30 Fake-Zeilen und `/v1/storage/…/view` mit einem PNG) – Überschrift „Community-Karten“ und Oberkante der ersten Reihe im Viewport; Screenshots im Scratchpad. **Step 5: Commit** `feat(home): kompakter Hero und Community-Galerie`.
### Task 6: Detailseite `/community/<id>` und „Als Vorlage“
**Files:**
- Modify: `web/src/pages/community-page.ts` (Platzhalter aus Task 1 ersetzen), `web/src/editor/editor-page.ts` (`?vorlage=<id>`)
- Create: `web/src/community/report-dialog.ts`
- Test: `web/src/pages/community-page.test.ts`, `web/src/community/report-dialog.test.ts`, Ergänzung `web/src/editor/editor-page.test.ts`
**Interfaces:**
- Detailseite laut Spec §5 (Texte exakt): Herunterladen (Blob → `aircard-<slug>.png`), „Als Vorlage gestalten“ → `navigate('/gestalten?vorlage=<id>')`, Melden-Dialog (Gründe exakt, Ergebnis-Texte), Autor „Zurückziehen“ (Bestätigung via `confirmDialog`, danach `navigate('/')`), Admin „Ausblenden/Einblenden“, „Löschen“, „<n> Meldungen“ + Gründe (`listReports`). `document.title` = `titleFor(route, card.title)`.
- Editor `?vorlage=<id>`: Galerie-Vollbild laden → `setImage`, Vorlage `image`, kein `saved`; ungespeicherte Arbeit → bestehende Rückfrage; Fehler → Hinweis „Diese Vorlage ist nicht (mehr) verfügbar.“
- **Ergänzungen aus Plan-Review (verbindlich):** Nach dem `?vorlage=`-Import `history.replaceState` auf `/gestalten` (kein erneuter Import bei Zurück) und `saved`, `savedConfig`, `cardName` ausdrücklich zurücksetzen (sonst überschreibt Speichern eine zuvor geöffnete Karte).
- [ ] **Step 1: Tests** – nicht gefunden/ausgeblendet für Gast → Text + kein Bild; Autor sieht Zurückziehen, Fremder nicht, Admin sieht Admin-Knöpfe + Meldungszahl; Melden: doppelt → „Danke, du hast diese Karte schon gemeldet.“, 429 → Rate-Text; Vorlage setzt Template `image` und lässt `saved` null.
- [ ] **Step 2–5** wie üblich; Commit `feat(community): Detailseite, Melden, Admin-Aktionen, Vorlage`.
### Task 7: Veröffentlichen aus „Meine Karten“
**Files:**
- Create: `web/src/community/publish-dialog.ts`
- Modify: `web/src/pages/cards-page.ts`, `web/src/pages/cards.css`
- Test: `web/src/community/publish-dialog.test.ts`, Ergänzung `web/src/pages/cards-page.test.ts`
**Interfaces:**
- Dialog laut Spec §6 (Felder, Grenzen, Pflicht-Checkbox-Text exakt), Absenden über `publishSavedCard`; Kachel-Status aus `myPublications(uid)`: „Veröffentlicht“ + „Ansehen“ (`/community/<id>`) + „Zurückziehen“ bzw. „Ausgeblendet (gemeldet)“; nicht veröffentlicht → „Veröffentlichen“. Nicht angemeldet → bestehendes `withLogin`.
- **Ergänzungen aus Plan-Review (verbindlich):** Ausgeblendete Kacheln behalten „Zurückziehen“ (Autor darf aufräumen und neu veröffentlichen).
- [ ] **Step 1: Tests** – Validierung im Dialog (Titel leer, > 60, 6 Tags, Checkbox fehlt); Erfolg → Badge + Ansehen-Link; 409 → Text; Zurückziehen → Status zurück.
- [ ] **Step 2–5**; Commit `feat(community): Veröffentlichen aus Meine Karten`.
### Task 8: Betrieb (Controller) und Abnahme
- **Ergänzungen aus Plan-Review (verbindlich):** Kenso prüft, dass JWT in den Auth-Methoden **an** ist (Live-Check: Veröffentlichen klappt). Kein `indexes`-Scope nötig. Nach erfolgreichem Schema-Lauf den alten, im Chat geposteten Provisioning-Key löschen lassen. Spoofing-Test: zweimal `/api/melden` über die öffentliche URL mit unterschiedlichen gefälschten `X-Real-Ip` → zweite Antwort `schonGemeldet` (Traefik überschreibt den Header).
- [ ] Kenso: Label `admin`, Key `aircard-api` per `read -s` als `AIRCARD_API_KEY` in `.env` (Controller liefert den Befehl); Provisioning-Key um `indexes.read/write` ergänzen oder Controller legt Indizes nach Kensos OK an.
- [ ] Controller: `AIRCARD_API_SALT` (32 Byte hex) in `.env` (Backup), Setup-Skript `--check` → anlegen → `--check` OK; `/home/webklar/apps/aircard-ios.org/api/` mit `deploy/api/*.js` füllen (`cp`); Compose-Backup, Service einfügen, `docker compose config -q`, `docker compose up -d aircard-api`; `curl https://aircard-ios.org/api/gesundheit` → `{"ok":true}`; Push → Webhook-Build.
- [ ] Live-Abnahme (Claude in Chrome, Kensos Sitzung): Startseite 1440 × 900 Karten angeschnitten; Hell/Dunkel; Veröffentlichen einer Testkarte; Suche nach Tag; Detailseite, Herunterladen, Als Vorlage; Melden (3 verschiedene `X-Real-Ip` per `docker exec webklar-aircard-api` lokal gegen `127.0.0.1:8088`) → ausgeblendet, Gast sieht „nicht (mehr) verfügbar“; Admin Einblenden; Zurückziehen.
- [ ] Final-Review (Workflow, 3 Linsen) → eine Fix-Welle → Push; Memory aktualisieren; Bericht an Kenso.
@@ -0,0 +1,436 @@
# AirCard-IOS Community-Logos, Englisch und GitHub-Link – Implementierungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eingebaute Marken-Logos durch Community-Logos (Hochladen, Herzen, Melden) ersetzen, die Seite zweisprachig (DE/EN) machen und GitHub-Link + Sprachknopf in die Kopfzeile setzen.
**Architecture:** Der Vite-+-TS-Client bekommt ein kleines i18n-Modul im gettext-Stil (`t('Deutscher Text')`, Wörterbuch Deutsch → Englisch) und neue Module unter `web/src/logos/`. Gelesen wird direkt aus Appwrite (öffentliche Zeilen), geschrieben nur über den Node-Dienst `aircard-api`, der ein neues Modul `deploy/api/logos.js` bekommt; gemeinsame Helfer wandern nach `deploy/api/common.js`. Das Schema wächst über das bestehende Setup-Skript.
**Tech Stack:** wie bisher (TypeScript 6.0.3, Vite 8.3.2, Vitest 5.0.3, happy-dom, appwrite 22.4.1), Node 22 nur mit Built-ins für `aircard-api`, Appwrite 1.8.1 TablesDB.
**Spec:** `docs/superpowers/specs/2026-10-04-aircard-logos-sprache-design.md` (Commit 067e428). Basis: die Website- und Community-Specs im selben Ordner.
## Global Constraints
- Arbeitskopie `/home/webklar/apps/aircard-ios.org/dev`, Branch `main`, direkt committen, **nicht pushen** (Controller pusht).
- Keine neuen npm-Abhängigkeiten; `deploy/api/` bleibt ohne Abhängigkeiten. Versionen unverändert.
- Kommentare und Bezeichner im Stil des Bestands (deutsche Kommentare, gleiche Idiome, `el()`/`icon()` aus `editor/dom.ts`).
- Ab Task 1 gehen **alle neuen sichtbaren Texte** (Text, `aria-label`, `title`, Platzhalter, Meldungen) durch `t()`; Konstanten werden mit `msg()` markiert und beim Anzeigen mit `t()` übersetzt. Jeder neue Text bekommt seinen Eintrag in `web/src/i18n/en.ts` (kurz, freundlich, US-Englisch). Deutscher Wortlaut aus der Spec.
- In Tests ist die Sprache Deutsch (`web/src/test-setup.ts`); bestehende Tests bleiben unverändert grün, außer wo der Task sie ausdrücklich anpasst.
- Appwrite: Projekt `6ac2130a00289343d6d5`, Datenbank `aircard`; neue Tabellen `logos`, `logo_likes`, `logo_reports`; neuer Bucket `logo-images`; Bestehendes unverändert.
- Logo-Zeilenrechte: sichtbar genau `['read("any")']`, ausgeblendet genau `['read("user:<owner>")','read("label:admin")']`. Logo-Dateirechte: beim Hochladen `['read("any")','delete("user:<owner>")']`, nach dem Anlegen genau `['read("any")']`, ausgeblendet `['read("user:<owner>")','read("label:admin")']`. Like-Zeilen: ID `hash32("<logoId>|<userId>")`, Rechte `['read("user:<userId>")']`.
- Grenzen: 30 Logos je Konto; Logo-PNG höchstens 1 MiB (1048576 Byte), 8 × 8 bis 720 × 250 px; Name ≤ 40, Anzeigename ≤ 30 Zeichen; Seiten zu 24; Ausblenden ab 3 Meldern; Rate-Limit 10 Meldungen/Stunde je Anschluss, gemeinsam für Karten und Logos.
- `localStorage`: `aircard.lang` (`de`|`en`), `aircard.displayName`; jeder Zugriff in try/catch.
- GitHub-Ziel `https://github.com/Mak5er/AirCard-iOS` (Konstante `GITHUB_URL` in `web/src/config.ts`).
- Tests: `cd web && npx vitest run`, `npx tsc --noEmit`, `npm run build`; `node --test "deploy/api/*.test.js"` (Glob in Anführungszeichen); `node --test web/scripts/appwrite-setup.test.mjs`.
- Keine Secrets in Ausgaben, Logs, Commits; `.env` nie öffnen (Ausnahme: Controller-Betrieb in Task 12).
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Subagents: `sonnet` für Umsetzung und Review, `haiku` für kleine Nachprüfungen; nie Subagents aus Subagents.
## Review Focus
- **Fremde oder falsche Datei beim Hochladen**: `fileId` einer fremden Logo-Datei, einer Datei aus `gallery-images` oder einer schon veröffentlichten Logo-Datei an `/api/logos/hochladen` → 403, keine Zeile, Datei unverändert. Test in Task 3.
- **Gespeicherte Karte mit gelöschtem/ausgeblendetem Logo**: Öffnen zeichnet die Karte ohne Logo, zeigt „Das gewählte Logo ist nicht mehr verfügbar.“, kein Fehler-Loop, Download funktioniert; Veröffentlichen bricht mit dem Text aus Spec § 2 ab. Tests in Task 5.
- **Sprachwechsel mit ungespeichertem Editor-Entwurf**: Bild, Ausschnitt und Einstellungen bleiben nach dem Umschalten erhalten, die Seite wird neu aufgebaut, nicht neu geladen. Test in Task 1 (Router baut neu auf, Store bleibt) und Live in Task 12.
- **Herz doppelt geklickt / Gast bricht Login ab**: Solange eine Herz-Anfrage für ein Logo läuft, sind weitere Klicks auf dieses Herz wirkungslos; bricht ein Gast den Login ab, geht keine Anfrage raus und das Herz bleibt aus, ohne Fehlertext. Tests in Task 8.
- **SVG ohne Größe oder mit riesiger viewBox**: Das Seitenverhältnis kommt aus der viewBox (ohne brauchbare viewBox: quadratisch, entspricht den 1000 × 1000 der Spec); gerastert wird immer mit genau 2048 px an der längsten Seite, nie größer; Skripte im SVG laufen nie (Laden nur als Bild). Test in Task 6 (`svgRasterSize`).
---
### Task 1: i18n-Kern, Kopfzeile (GitHub + Sprache), Fußzeile, Sprachwechsel ohne Neuladen
**Files:**
- Create: `web/src/i18n/i18n.ts`, `web/src/i18n/en.ts`, `web/src/i18n/i18n.test.ts`, `web/src/i18n/coverage.test.ts`, `web/src/test-setup.ts`, `web/src/shell/footer.ts`, `web/src/shell/footer.test.ts`
- Modify: `web/vite.config.ts` (`test.setupFiles: ['src/test-setup.ts']`), `web/index.html`, `web/src/config.ts`, `web/src/editor/dom.ts` (Icons `github`, `globe`), `web/src/shell/header.ts`, `web/src/shell/shell.css`, `web/src/shell/donate-banner.ts`, `web/src/pages/router.ts`, `web/src/main.ts`, `web/src/branding.test.ts` (falls es den Fußzeilentext oder `<title>` prüft)
- Test: `web/src/shell/header.test.ts`, `web/src/pages/router.test.ts`
**Interfaces:**
- Produces:
```ts
// web/src/i18n/i18n.ts
export type Lang = 'de' | 'en';
export const LANG_KEY = 'aircard.lang';
export function initialLang(stored: string | null, browserLanguages: readonly string[]): Lang;
export function currentLang(): Lang; // beim ersten Aufruf: initialLang(localStorage, navigator.languages bzw. [navigator.language])
export function setLang(lang: Lang, opts?: { persist?: boolean }): void; // persist Standard true; setzt <html lang>; meldet nur bei Wechsel
export function onLangChange(fn: (lang: Lang) => void): () => void;
export function t(text: string, params?: Readonly<Record<string, string | number>>): string;
export function msg<T extends string>(text: T): T; // Identität, markiert Konstanten für den Abdeckungstest
// web/src/i18n/en.ts
export const EN: Readonly<Record<string, string>>; // Deutsch → Englisch
// web/src/config.ts
export const GITHUB_URL = 'https://github.com/Mak5er/AirCard-iOS';
// web/src/pages/router.ts
export function rerender(): void; // räumt die aktuelle Seite auf und baut sie neu (ohne Scrollen); ohne Router: nichts
// web/src/shell/footer.ts
export function mountFooter(footer: HTMLElement): void; // ersetzt den Inhalt durch <p> mit dem Fußzeilentext der aktuellen Sprache
```
- [ ] **Step 1: Tests schreiben** (`i18n.test.ts`, `footer.test.ts`, Ergänzungen in `header.test.ts` und `router.test.ts`):
- `initialLang(null, ['de-DE','en'])` → `'de'`; `(null, ['en-US','de'])` → `'en'`; `('en', ['de-DE'])` → `'en'`; `('xx', ['de-AT'])` → `'de'`; `(null, [])` → `'en'`.
- `setLang('en')`: `t('Gestalten')` → `'Create'`; unbekannter Text bleibt deutsch; `t('Nichts gefunden für „{q}“', { q: 'x' })` ersetzt den Platzhalter in beiden Sprachen; unbekannter Platzhalter bleibt `{name}` stehen.
- `setLang` setzt `document.documentElement.lang`, schreibt mit `persist: false` nichts in `localStorage`, ruft Listener genau einmal je echtem Wechsel.
- Fußzeile: deutsch der Spec-Text (§ 7); nach `setLang('en')` und erneutem `mountFooter` der englische.
- Kopfzeile: Link mit `href` = `GITHUB_URL`, `target="_blank"`, `rel="noopener noreferrer"`, `title` „AirCard-iOS auf GitHub“; Sprachknopf zeigt bei Deutsch „EN“ mit `aria-label` „Switch to English“ und `lang="en"`, Klick → `currentLang() === 'en'`; danach (neu gemountet) „DE“ mit „Auf Deutsch umschalten“; das Menü enthält einen zweiten GitHub-Link (Klasse `nav-github`).
- Router: `rerender()` ruft das Aufräumen der aktuellen Seite und dann `render` mit derselben Route erneut auf; `scrollTo` wird nicht aufgerufen; nach `setLang('en')` liefert `titleFor({id:'gestalten',params:{}})` „Create – AirCard-IOS“.
- [ ] **Step 2:** `cd web && npx vitest run src/i18n src/shell src/pages/router.test.ts` → FAIL (Module fehlen).
- [ ] **Step 3: Umsetzen.**
- `t()`: Basistext = bei `en` `EN[text] ?? text`, sonst `text`; dann `{name}` durch `String(params[name])` ersetzen, wenn vorhanden.
- `coverage.test.ts` (Muster wie `single-sources.test.ts`): sammelt in allen Nicht-Test-`.ts` unter `src/` die String-Literale aus `t(…)`/`msg(…)` (`'…'`, `"…"`, `` `…` `` ohne `${`) und prüft: (a) jedes hat einen Eintrag in `EN`, (b) jeder `EN`-Eintrag wird benutzt, (c) Platzhalter `{…}` in Schlüssel und Übersetzung sind dieselbe Menge, (d) kein `t(`/`msg(` mit Template-Literal, das `${` enthält.
- `test-setup.ts`: `beforeEach(() => setLang('de', { persist: false }))`.
- `index.html`: `<title>AirCard-IOS – Card designs for Apple Wallet</title>`, `<meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG.">`; im bestehenden Inline-Skript zusätzlich `lang` setzen (gleiche Regel wie `initialLang`, Kommentar verweist darauf); `<footer id="app-footer"></footer>` leer (Text kommt aus `mountFooter`).
- Icons in `dom.ts` (Lucide, Strich): `github: '<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/>'`, `globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>'`.
- Kopfzeile: Reihenfolge in `.header-tools`: GitHub (`a.header-github` mit Icon + `span.header-github-text` „GitHub“), Sprachknopf (`button.header-btn.lang-btn`), Spenden, Hell/Dunkel, Menü. CSS: zwischen 600 und 1179 px `.header-github-text` ausblenden (wie `.header-donate-text`); unter 600 px `.header-github` ausblenden und `.nav-github` im Menü zeigen (sonst `.nav-github` ausgeblendet). Alle übrigen Kopfzeilen- und Banner-Texte durch `t()`/`msg()`.
- Router: `ROUTE_TITLES`-Werte mit `msg()`, `titleFor` liefert `t(…)`; `rerender()` ruft die interne `renderCurrent` auf.
- `main.ts`: beim Start `setLang(currentLang(), { persist: false })` und `client.setLocale(lang)`; `onLangChange(lang => …)`: `client.setLocale(lang)`, Kopfzeile aufräumen und neu mounten, `mountDonateBanner` erneut, Konto-Menü `dispose()` + neu mounten (+ `ready()`, wenn der Anmeldestatus schon geholt war), `mountFooter`, dann `rerender()`. Der Editor-Store bleibt dasselbe Objekt.
- Englische Einträge für alle neuen `t()`/`msg()`-Texte in `en.ts` (Seitentitel: „Card designs for Apple Wallet“, „Create“, „My cards“, „Community“, „Set new password“).
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → alles grün.
- [ ] **Step 5:** Commit `feat(i18n): Deutsch/Englisch, Sprachknopf und GitHub-Link in der Kopfzeile`.
### Task 2: Schema für Logos (Setup-Skript)
**Files:**
- Modify: `web/scripts/appwrite-setup.mjs` (`DESIRED`, Kopfkommentar), `web/scripts/appwrite-setup.test.mjs`
**Interfaces:**
- Produces: `DESIRED.tables` zusätzlich (in dieser Reihenfolge nach `reports`) und `DESIRED.buckets` zusätzlich:
```js
{ id: 'logos', name: 'Logos', permissions: [], rowSecurity: true,
columns: [str('name', 40, true), str('displayName', 30, false), str('fileId', 36, true), str('ownerId', 36, true),
{ key: 'mono', type: 'boolean', required: false, default: false },
{ key: 'likeCount', type: 'integer', required: false, default: 0, min: 0 },
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 }],
indexes: [{ key: 'owner', type: 'key', columns: ['ownerId'] }, { key: 'hidden', type: 'key', columns: ['hidden'] },
{ key: 'likes', type: 'key', columns: ['likeCount'] }] },
{ id: 'logo_likes', name: 'Logo Likes', permissions: [], rowSecurity: true,
columns: [str('logoId', 36, true), str('userId', 36, true)],
indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }, { key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] }] },
{ id: 'logo_reports', name: 'Logo Reports', permissions: ['read("label:admin")'], rowSecurity: false,
columns: [str('logoId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true)],
indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }] },
// Bucket
{ id: 'logo-images', name: 'Logo Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 1048576,
allowedFileExtensions: ['png'], compression: 'none', antivirus: false },
```
- [ ] **Step 1: Tests** nach dem Muster der bestehenden: `DESIRED` enthält die drei Tabellen und den Bucket mit genau diesen Werten; ein Lauf gegen den Test-Server legt sie samt Indizes an (Mehrspalten-Index `user_logo` mit `columns: ['userId','logoId']` im Request); `--check` meldet `FEHLT` für jedes fehlende neue Objekt und Exit 1; bestehende Tabellen bleiben unberührt.
- [ ] **Step 2:** `node --test web/scripts/appwrite-setup.test.mjs` → FAIL.
- [ ] **Step 3:** `DESIRED` ergänzen; Kopfkommentar nennt die neuen Objekte. Falls der Index-Request heute nur eine Spalte unterstützt, auf Listen erweitern.
- [ ] **Step 4:** `node --test web/scripts/appwrite-setup.test.mjs` → PASS.
- [ ] **Step 5:** Commit `feat(appwrite): Schema für Community-Logos, Herzen und Logo-Meldungen`.
### Task 3: API – Fehlercodes, gemeinsame Helfer, Logo-Endpunkte
**Files:**
- Create: `deploy/api/common.js` (aus `app.js` herausgelöst: `MSG`, `HttpError`, `isId`, `isPlainObject`, `parseJsonObject`, `readImageSize`, `hash32`, `hmacHex`, `reporterHash`, `reporterNetwork`, `normalizeIp`, `clientIp`, `CONTROL_CHARACTERS`, `ID_PATTERN`, `isDuplicate`, `visibleRowPermissions`, `hiddenRowPermissions`, `uploadFilePermissions`, `visibleFilePermissions`, `hiddenFilePermissions`, `REASONS`), `deploy/api/logos.js`, `deploy/api/logos.test.js`
- Modify: `deploy/api/app.js` (importiert aus `common.js`, exportiert weiter alles wie bisher, hängt die Logo-Routen an, sendet `code`), `deploy/api/appwrite.js` (+ `countRows`), `deploy/api/fake-appwrite.js` (Tabellen `logos`, `logo_likes`, `logo_reports` mit Spaltenlängen, Bucket `logo-images`, `total` in Listen-Antworten), `deploy/api/app.test.js`, `deploy/api/appwrite.test.js`, `deploy/api/README.md`, `deploy/README.md` (Betrieb kopiert fünf Dateien: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`)
**Interfaces:**
- Produces:
```js
// common.js
class HttpError extends Error { constructor(status, message, headers = {}, code = codeFor(status, message)) } // .status .headers .code
function codeFor(status, message) // Schlüssel aus MSG mit genau diesem Text, sonst status 400 → 'badRequest', sonst 'error'
// appwrite.js
countRows(table, queries = []) // GET rows mit queries + limit(1), liefert body.total (Zahl), sonst AppwriteError(502, 'invalid_response')
// logos.js
function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) // → Array<[routeKey, {name, json: true, run}]>
function logoLikeId(logoId, userId) // hash32(`${logoId}|${userId}`)
function logoReportId(logoId, reporter) // hash32(`logo|${logoId}|${reporter}`)
function validateLogoUpload(body) // {ok:true, data:{name, displayName, mono, fileId}} | {ok:false, message}
```
- Neue `MSG`-Einträge (Schlüssel = späterer `code`, Texte genau so): `logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.'`, `badLogoId: 'Ungültige Logo-ID.'`, `logoNameMissing: 'Bitte gib einen Namen an.'`, `logoFileType: 'Das Logo muss eine PNG-Datei sein.'`, `logoFileSize: 'Das Logo ist größer als 1 MB.'`, `logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).'`, `logoTaken: 'Diese Datei wird schon für ein Logo verwendet.'`, `tooManyLogos: 'Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.'`, `badHeart: 'Ungültige Angabe zum Herz.'`, `badMono: 'Ungültige Angabe zu „einfarbig“.'`. Alle `MSG`-Texte bleiben paarweise verschieden.
- Routen: `POST /api/logos/hochladen`, `POST /api/logos/herz`, `POST /api/logos/melden`, `POST /api/logos/loeschen`, `POST /api/admin/logo-sichtbarkeit`. Verhalten, Statuscodes, Reihenfolge der Schritte und Rücknahme genau wie Spec § 9 und die bestehenden Karten-Endpunkte (`publish`, `reportCard`, `withdraw`, `setVisibility` sind das Vorbild). Sperren: `logo-owner:<userId>` beim Hochladen, `logo:<id>` für alles je Logo. Gelöscht werden Herzen und Meldungen in Runden (`listRows` mit `equal logoId`, `limit 100`, höchstens 50 Runden). `likeCount` und `reportCount` immer aus `countRows`.
- [ ] **Step 1: Tests schreiben** (`logos.test.js` im Stil von `app.test.js`; dort zusätzlich die `code`-Fälle):
- Hochladen: gültig → 201 `{id: fileId}`, Zeile sichtbar mit `likeCount 0`, Datei-Rechte danach genau `['read("any")']`; fremde Datei → 403 `code:'foreignFile'`; Datei aus `gallery-images` → 403; Datei ohne `read("any")` → 403 `filesTaken`; WebP → 400 `logoFileType`; PNG 721 × 100 oder 7 × 7 → 400 `logoFormat`; > 1 MiB → 400 `logoFileSize`; dieselbe Datei zweimal → 409 `logoTaken`; 31. Logo → 429 `tooManyLogos`; scheitert das Setzen der Dateirechte → Zeile gelöscht, Datei hat wieder `delete("user:<ich>")`; ohne JWT → 401 `unauthorized`; Name leer → 400 `logoNameMissing`; `mono: 'ja'` → 400 `badMono`.
- Herz: an → `{herz:true, likeCount:1}`, nochmal an → weiter 1; aus → 0, nochmal aus → 0; zwei Nutzer → 2; ausgeblendetes oder fehlendes Logo → 404 `logoGone`; `herz` kein Boolean → 400 `badHeart`; Like-Zeile hat Rechte `['read("user:<id>")']` und ID `logoLikeId`.
- Melden: Dedup je Netz (`{schonGemeldet:true}`), 3 Netze → `hidden: true`, Zeile/Datei mit ausgeblendeten Rechten; Rate-Limit greift auch, wenn 6 Karten- und 5 Logo-Meldungen vom selben Anschluss kommen (11. → 429).
- Löschen: Hochlader → 200, Datei, Herzen, Meldungen, Zeile weg; Admin → 200; Fremder → 403 `forbidden`; fehlt → 404 `logoGone`.
- Admin-Sichtbarkeit: ausblenden/einblenden (Einblenden: Meldungen weg, `reportCount 0`, Rechte sichtbar); Nicht-Admin → 403 `adminOnly`.
- Bestehende Endpunkte: jede Fehlerantwort hat `code` (z. B. `/api/melden` mit unbekannter ID → `{error:'Diese Karte ist nicht (mehr) verfügbar.', code:'cardGone'}`); `MSG`-Texte sind eindeutig.
- `countRows` in `appwrite.test.js`: liefert `total`, wirft bei fehlendem `total`.
- [ ] **Step 2:** `node --test "deploy/api/*.test.js"` → FAIL.
- [ ] **Step 3:** Umsetzen: `common.js` herauslösen (reine Verschiebung, Verhalten gleich), `handle()` sendet `{error: err.message, code: err.code}` (Appwrite-Fehler: `code` `unavailable` bzw. `internal`), Logo-Modul, Fake-Erweiterung, Doku (Endpunkt-Tabelle in `api/README.md`, Kopierbefehl mit fünf Dateien in `deploy/README.md`).
- [ ] **Step 4:** `node --test "deploy/api/*.test.js"` → PASS (alte und neue Tests).
- [ ] **Step 5:** Commit `feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer`.
### Task 4: Client-Datenschicht für Logos und übersetzbare API-Fehler
**Files:**
- Create: `web/src/logos/logos.ts`, `web/src/logos/logos.test.ts`, `web/src/logos/logo-api.ts`, `web/src/logos/logo-api.test.ts`, `web/src/i18n/api-messages.ts`, `web/src/i18n/api-messages.test.ts`
- Modify: `web/src/account/appwrite.ts` (`IDS`), `web/src/community/api.ts` (+ Test)
**Interfaces:**
- Consumes: `t`, `msg`, `currentLang` (Task 1); API-Routen und `code` (Task 3); `fetchFile`, `ID_PATTERN` aus `account/cards.ts`; `apiPost`, `ApiError`, `isDefinitiveFailure` aus `community/api.ts`.
- Produces:
```ts
// account/appwrite.ts – IDS zusätzlich
logos: 'logos', logoLikes: 'logo_likes', logoReports: 'logo_reports', logoBucket: 'logo-images'
// logos/logos.ts
export interface CommunityLogo { id: string; name: string; displayName: string; fileId: string; ownerId: string;
mono: boolean; likeCount: number; hidden: boolean; reportCount: number; createdAt: string }
export type LogoSort = 'popular' | 'new';
export type LogoFilter = 'all' | 'reported' | 'hidden';
export interface LogoListOptions { sort: LogoSort; filter?: LogoFilter; cursor?: string }
export interface LogoReport { id: string; logoId: string; reason: string; createdAt: string }
export const LOGO_PAGE_SIZE = 24;
export function toLogo(row: Record<string, unknown>): CommunityLogo;
export function logoQueries(opts: LogoListOptions): string[];
export function listLogos(opts: LogoListOptions): Promise<{ logos: CommunityLogo[]; nextCursor: string | null }>;
export function getLogo(id: string): Promise<CommunityLogo | null>; // ungültige ID oder 404 → null
export function myHearts(userId: string, logoIds: readonly string[]): Promise<Set<string>>;
export function listLogoReports(logoId: string): Promise<LogoReport[]>; // neueste zuerst, limit 100
export function loadLogoBlob(logo: CommunityLogo): Promise<Blob>; // fetchFile(IDS.logoBucket, logo.fileId)
// logos/logo-api.ts
export interface LogoUploadInput { name: string; displayName: string; mono: boolean; fileId: string }
export function uploadLogo(input: LogoUploadInput): Promise<{ id: string }>;
export function setHeart(id: string, herz: boolean): Promise<{ herz: boolean; likeCount: number }>;
export function reportLogo(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }>;
export function deleteLogo(id: string): Promise<void>;
export function setLogoVisibility(id: string, visible: boolean): Promise<void>; // Body {id, sichtbar}
// community/api.ts
class ApiError { constructor(code: number, message: string, definitive?: boolean, serverCode?: string | null); readonly serverCode: string | null }
export function apiErrorText(err: ApiError): string; // = apiMessage(err.serverCode, err.code, err.message)
// i18n/api-messages.ts
export const API_MESSAGES: Readonly<Record<string, string>>; // code → msg('deutscher Servertext'); dazu Client-Codes 'generic', 'jwtUnsupported'
export function apiMessage(serverCode: string | null, status: number, serverText: string): string;
```
- [ ] **Step 1: Tests schreiben:**
- `logoQueries({sort:'popular'})` = `[equal('hidden',false), orderDesc('likeCount'), orderDesc('$createdAt'), limit(24)]` (als Query-Strings des SDK); `'new'` ohne `likeCount`-Sortierung; `filter:'reported'` → `greaterThan('reportCount',0)`, `'hidden'` → `equal('hidden',true)`; mit `cursor` zusätzlich `cursorAfter`.
- `toLogo` macht aus kaputten Feldern leere Werte, `likeCount`/`reportCount` < 0 oder kein Zahlwert → 0, `mono`/`hidden` nur bei `true`.
- `listLogos`: `nextCursor` nur bei voller Seite; `getLogo('kaputt id')` → null ohne Anfrage, 404 → null, 500 → wirft.
- `myHearts('u', [])` → leere Menge ohne Anfrage; sonst Abfrage `equal('userId','u')`, `equal('logoId',[…])`, `limit(100)`.
- `logo-api`: richtige Pfade und Bodies (`/api/logos/herz` mit `{id, herz}`, `/api/admin/logo-sichtbarkeit` mit `{id, sichtbar}`); Antwort ohne `id` bei `uploadLogo` → `ApiError` 503, nicht eindeutig.
- `ApiError.serverCode` wird aus `{error, code}` gelesen; Client-Fehler tragen `unavailable`, `generic`, `jwtUnsupported`, `alreadyPublished`, `filesTaken`, `tooManyPublications` passend.
- `apiMessage`: bekannter Code → `t(Text)` (deutsch und englisch); unbekannter Code deutsch → Servertext; unbekannter Code englisch → „Invalid request.“ (400), „You are not allowed to do that.“ (403), „Not found.“ (404), sonst „Something went wrong. Please try again.“
- **Gleichlauf**: `api-messages.test.ts` lädt `MSG` aus `deploy/api/common.js` (`createRequire(import.meta.url)`) und prüft für jeden Schlüssel `API_MESSAGES[key] === MSG[key]`.
- [ ] **Step 2:** `cd web && npx vitest run src/logos src/i18n src/community/api.test.ts` → FAIL.
- [ ] **Step 3:** Umsetzen nach dem Muster von `community/gallery.ts` und `community/api.ts`; `en.ts` um alle `API_MESSAGES`-Texte ergänzen.
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün.
- [ ] **Step 5:** Commit `feat(logos): Datenschicht, API-Aufrufe und übersetzbare API-Fehler`.
### Task 5: Kartenmodell ohne Marken-Logos, Renderer mit Community-Logo
**Files:**
- Create: `web/src/logos/logo-asset.ts`, `web/src/logos/logo-asset.test.ts`
- Modify: `web/src/card/config.ts`, `web/src/card/marks.ts`, `web/src/card/geometry.ts`, `web/src/card/render.ts` (+ ihre Tests), `web/src/editor/options-panel.ts`, `web/src/editor/editor-page.ts` (+ Test), `web/src/community/publish.ts` (+ Test), `web/src/branding.test.ts`, `web/src/single-sources.test.ts` (falls betroffen)
- Delete: in `web/src/assets/marks/` alle SVGs außer `aircards-symbol.svg` und `aircards-text.svg` (19 Dateien, `git rm`)
**Interfaces:**
- Consumes: `getLogo`, `loadLogoBlob`, `CommunityLogo` (Task 4); `t`, `msg` (Task 1).
- Produces:
```ts
// card/config.ts
export type BrandId = 'aircards-symbol' | 'aircards-text' | 'none';
export interface CardConfig { v: 1; template: TemplateId; logo: string | null; details: DetailsTone; brand: BrandId; crop: Crop }
// card/geometry.ts (ersetzt fitNetwork und Layout.network)
export const LOGO_MAX_H = 125;
export function fitLogo(aspect: number, maxH?: number): Box; // rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch
// Layout.logo: boolean (image: false; minimal, classic, signature: true)
// card/render.ts
export interface LogoAsset { source: CanvasImageSource; w: number; h: number }
export interface CardAssets { image: CardImage | null; logo: LogoAsset | null; brand: CanvasImageSource | null }
export interface LoadedAssets extends CardAssets { logoMissing: boolean }
export function loadCardAssets(config: CardConfig, image: CardImage | null,
loadLogo?: (id: string, tone: DetailsTone) => Promise<LogoAsset | null>): Promise<LoadedAssets>; // Standard: loadLogoAsset
// logos/logo-asset.ts
export const LOGO_MISSING = msg('Das gewählte Logo ist nicht mehr verfügbar.');
export function loadLogoAsset(id: string, tone: DetailsTone): Promise<LogoAsset | null>;
export function rememberLogo(logo: CommunityLogo): void; // legt die Zeile in den Cache (spart getLogo)
export function forgetLogo(id: string): void; // nach Löschen/Ausblenden: Zeile und Bilder aus dem Cache
// editor/options-panel.ts
export interface OptionsHandlers { onDetails(tone: DetailsTone): void; onBrand(id: BrandId): void }
export interface OptionsPanel { el: HTMLElement; select(config: CardConfig): void; logoSlot: HTMLElement } // logoSlot: leerer Platz oben, Task 8 füllt ihn
// community/publish.ts
export const LOGO_MISSING_PUBLISH = msg('Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.');
```
- [ ] **Step 1: Tests schreiben:**
- `parseConfig('{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}')` → `logo: null`, `brand: 'aircards-symbol'`, Rest übernommen; `logo: 'abc123'` bleibt; `logo: 'kaputt id'`/Zahl → null; `serializeConfig` schreibt genau `{v,template,logo,details,brand,crop}` in dieser Reihenfolge.
- `fitLogo(2, 125)` → `{x: 1230, y: 788, w: 250, h: 125}`; `fitLogo(10, 125)` → Breite 360.
- Renderer: mit `assets.logo` (w 200, h 100) und Vorlage `classic` wird `drawImage(source, fitLogo(2).x, …)` aufgerufen; mit `image` nicht; ohne `assets.logo` nicht.
- `loadCardAssets`: `logo: null` → `logoMissing false` ohne Loader-Aufruf; Loader liefert null → `logo null, logoMissing true`; Loader wirft → `loadCardAssets` wirft.
- `loadLogoAsset` (Fakes für `getLogo`, `loadLogoBlob`, Bilddekodierung und Canvas über Parameter oder Modul-Mocks): fehlende Zeile oder 404-Datei → null; einfarbig → Canvas mit `globalCompositeOperation 'source-in'` und `fillStyle` `#FFFFFF` (light) bzw. `#111111` (dark); gleiche (id, tone) lädt nur einmal; nach `forgetLogo` wird neu geladen; ein fehlgeschlagener Ladevorgang bleibt nicht im Cache.
- Editor: gespeicherte Karte mit Logo, Loader liefert null → Vorschau-Warnung `LOGO_MISSING`, Karte wird gezeichnet; Wechsel auf ein vorhandenes Logo → Warnung weg; kein `LOGO_ERROR` bei fehlendem Logo (nur bei echtem Ladefehler).
- Veröffentlichen: `render` mit `logoMissing` → Fehler mit Text `LOGO_MISSING_PUBLISH`, keine Datei hochgeladen.
- Options-Panel: keine Netzwerk-Kacheln mehr, Überschrift „Logo oben links“ mit drei Kacheln (Symbol, Text, Kein Logo), kein Apple.
- [ ] **Step 2:** `cd web && npx vitest run src/card src/logos src/editor src/community/publish.test.ts` → FAIL.
- [ ] **Step 3:** Umsetzen. `logoKey` im Editor = `` `${c.logo ?? ''}|${c.details}|${c.brand}` ``; der Logo-Zustand hält zusätzlich `missing`. Bilddekodierung in `logo-asset.ts`: `createImageBitmap(blob)`, sonst `Image` über Object-URL; einfarbig = eigenes Canvas in Bildgröße, Bild zeichnen, `source-in`, mit `TONE_COLOR[tone]` füllen. `logo-asset.ts` importiert `LogoAsset` nur als `import type` aus `card/render.ts` (kein Laufzeit-Kreis). `marks.ts` behält nur die AirCard-Marken (`BRAND_MARKS`, `getBrandMark`, `svgAspect`, `tintSvg`, `loadMarkImage`). Neue Texte in `en.ts`.
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün; `grep -rn "visa\|mastercard\|unionpay\|amex\|discover\|jcb\|apple.svg" web/src` findet nur noch Kommentare/Tests ohne Dateibezug oder nichts.
- [ ] **Step 5:** Commit `feat(card): Marken-Logos entfernt, Community-Logo unten rechts im Renderer`.
### Task 6: Logo verarbeiten und hochladen (Ablauf ohne Oberfläche)
**Files:**
- Create: `web/src/logos/prepare-logo.ts`, `web/src/logos/prepare-logo.test.ts`, `web/src/logos/upload-logo.ts`, `web/src/logos/upload-logo.test.ts`
**Interfaces:**
- Consumes: `uploadLogo`, `LogoUploadInput`, `CommunityLogo` (Task 4); `isDefinitiveFailure` aus `community/api.ts`; `storage`, `IDS` aus `account/appwrite.ts`; `requireUserId` aus `account/cards.ts`.
- Produces:
```ts
// logos/prepare-logo.ts
export const LOGO_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,.svg';
export const LOGO_MAX_W = 720, LOGO_MAX_H = 250, LOGO_MIN = 8, ALPHA_EMPTY = 8, SVG_FALLBACK_SIZE = 1000, SVG_RASTER_MAX = 2048;
export const LOGO_SOURCE_MAX_BYTES = 5_242_880, LOGO_SOURCE_MAX_PIXELS = 40_000_000;
export interface PreparedLogo { png: Blob; width: number; height: number; hasTransparency: boolean }
export function validateLogoFile(f: { type: string; size: number; name: string }): { ok: true } | { ok: false; message: string };
export function trimBox(data: Uint8ClampedArray, w: number, h: number, threshold?: number): { x: number; y: number; w: number; h: number } | null;
export function fitWithin(w: number, h: number, maxW: number, maxH: number): { w: number; h: number }; // nie > 1, gerundet, mind. 1
export function svgRasterSize(aspect: number): { w: number; h: number }; // längste Seite genau 2048; ungültiges Seitenverhältnis → 2048 × 2048
export function hasTransparency(data: Uint8ClampedArray): boolean;
export function prepareLogo(file: File): Promise<PreparedLogo>; // wirft Error mit übersetztem Text (Spec § 4/§ 11)
// logos/upload-logo.ts
export interface LogoForm { name: string; displayName: string; mono: boolean }
export interface UploadLogoDeps { userId(): string; createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
deleteFile(fileId: string): Promise<void>; uploadLogo(input: LogoUploadInput): Promise<{ id: string }> }
export function uploadPreparedLogo(prepared: PreparedLogo, form: LogoForm, deps?: UploadLogoDeps): Promise<CommunityLogo>;
```
- [ ] **Step 1: Tests schreiben:**
- `validateLogoFile`: `image/gif` → „Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.“; leerer Typ + Name `logo.SVG` → ok; 5 242 881 Byte → „Die Datei ist größer als 5 MB.“
- `trimBox`: 4 × 3-Bild nur mit einem Pixel (2,1) Alpha 9 → `{x:2,y:1,w:1,h:1}`; Alpha ≤ 8 überall → null; volles Bild → ganze Fläche.
- `fitWithin(1440, 500, 720, 250)` → `{w:720,h:250}`; `(300, 100, 720, 250)` → unverändert; `(2000, 20, 720, 250)` → `{w:720,h:7}`.
- `svgRasterSize(1)` → `{w:2048,h:2048}`; `(2)` → `{w:2048,h:1024}`; `(3)` → `{w:2048,h:683}`; `(0.5)` → `{w:1024,h:2048}`; `(NaN)`/`(0)` → `{w:2048,h:2048}`.
- `hasTransparency`: alles 255 → false; ein Alpha 254 → true.
- `uploadPreparedLogo`: `createFile` mit Dateiname `logo.png`, Typ `image/png`, Rechten `['read("any")','delete("user:u1")']`; `uploadLogo` bekommt getrimmten Namen/Anzeigenamen, `mono` nur `true`, wenn `hasTransparency`; Erfolg → `CommunityLogo` mit `id`, `fileId = id`, `ownerId u1`, `likeCount 0`, keine Löschung; eindeutige Ablehnung (403) → Datei gelöscht, Fehler weitergeworfen; offener Ausgang (503) → Datei bleibt, Fehler weitergeworfen; Fehler beim Löschen überdeckt nie den eigentlichen Fehler.
- [ ] **Step 2:** `cd web && npx vitest run src/logos/prepare-logo.test.ts src/logos/upload-logo.test.ts` → FAIL.
- [ ] **Step 3:** Umsetzen. `prepareLogo`: Rasterbilder mit `createImageBitmap(file)` (Maße vor dem Zeichnen gegen `LOGO_SOURCE_MAX_PIXELS` prüfen, sonst „Das Bild ist zu groß (max. 40 Megapixel).“), SVG: Text lesen, Seitenverhältnis mit `svgAspect` aus `card/marks.ts` (viewBox), `width`/`height` aus `svgRasterSize` ins Wurzelelement schreiben (wie `withIntrinsicSize` in `marks.ts`), dann über `Image` aus einer Object-URL laden (nur als Bild); zeichnen, `getImageData`, `trimBox` (null → „Das Logo ist leer.“), ausschneiden, `fitWithin(…, 720, 250)`, unter 8 × 8 → „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“, `toBlob('image/png')`, `hasTransparency` auf den Endpixeln; nicht lesbar → „Das Logo konnte nicht gelesen werden.“ Neue Texte in `en.ts`. (`prepareLogo` selbst prüft Task 12 live; die reinen Helfer tragen die Tests.)
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün.
- [ ] **Step 5:** Commit `feat(logos): Logo zuschneiden, verkleinern und hochladen`.
### Task 7: Dialog „Logo hochladen“
**Files:**
- Create: `web/src/logos/logo-dialog.ts`, `web/src/logos/logo-dialog.test.ts`, `web/src/logos/logos.css`, `web/src/account/display-name.ts`, `web/src/account/display-name.test.ts`
- Modify: `web/src/community/publish-dialog.ts` (+ Test: Vorbelegung und Speichern des Anzeigenamens)
**Interfaces:**
- Consumes: `prepareLogo`, `PreparedLogo`, `validateLogoFile`, `LOGO_ACCEPT`, `uploadPreparedLogo`, `LogoForm` (Task 6); `CommunityLogo` (Task 4); `apiErrorText`, `ApiError` (Task 4); `errorMessage` aus `account/errors.ts`.
- Produces:
```ts
// account/display-name.ts
export const DISPLAY_NAME_KEY = 'aircard.displayName';
export function rememberedDisplayName(): string; // '' bei Fehler/leer
export function rememberDisplayName(name: string): void; // leer → Schlüssel entfernen
// logos/logo-dialog.ts
export interface LogoDialogOptions { signal?: AbortSignal;
prepare?: (file: File) => Promise<PreparedLogo>; upload?: (p: PreparedLogo, form: LogoForm) => Promise<CommunityLogo> }
export function validateLogoForm(v: { hasFile: boolean; name: string; displayName: string; consent: boolean }):
{ field: 'file' | 'name' | 'displayName' | 'consent'; message: string } | null;
export function logoUploadDialog(opts?: LogoDialogOptions): Promise<CommunityLogo | null>; // null = abgebrochen
```
- [ ] **Step 1: Tests schreiben:**
- `validateLogoForm`: ohne Datei → `file` „Bitte wähle eine Datei.“; Name leer → „Bitte gib einen Namen an.“; 41 Zeichen → „Der Name ist zu lang (höchstens 40 Zeichen).“; Steuerzeichen → „Der Name enthält ungültige Zeichen.“; Anzeigename 31 → „Der Anzeigename ist zu lang (höchstens 30 Zeichen).“; ohne Häkchen → „Bitte bestätige, dass du das Logo teilen darfst.“
- Dialog: Datei wählen ruft `prepare`, zeigt zwei Vorschauen (dunkel/hell); Name wird mit dem Dateinamen ohne Endung vorbelegt (gekürzt auf 40); Anzeigename aus `rememberedDisplayName()`; „Einfarbig“ ist deaktiviert, wenn `hasTransparency` false; Absenden mit gültigen Werten ruft `upload` genau einmal (Doppelklick sendet nicht zweimal), speichert den Anzeigenamen und löst mit dem Logo auf; Fehler → „Hochladen fehlgeschlagen – <Text>“ (bei `ApiError` über `apiErrorText`), Dialog bleibt offen; während des Sendens sind Abbrechen/Esc wirkungslos; `signal` schließt immer (→ null).
- Veröffentlichen-Dialog: Anzeigename vorbelegt aus `aircard.displayName`, nach Erfolg gespeichert.
- [ ] **Step 2:** `cd web && npx vitest run src/logos/logo-dialog.test.ts src/account/display-name.test.ts src/community/publish-dialog.test.ts` → FAIL.
- [ ] **Step 3:** Umsetzen nach dem Muster von `publish-dialog.ts` (eigenes `<dialog>`, `showModal`, Fokus zurück an den Auslöser, Texte nur als Text). Vorschau: PNG als Object-URL in `<img>`; bei „Einfarbig“ stattdessen ein Element mit `mask-image`/`-webkit-mask-image` auf dieselbe URL und Hintergrundfarbe Weiß (dunkle Fläche) bzw. `#111111` (helle Fläche). Object-URLs beim Schließen freigeben. Pflicht-Häkchen-Text und alle Texte aus Spec § 4/§ 11; neue Texte in `en.ts`.
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün.
- [ ] **Step 5:** Commit `feat(logos): Dialog „Logo hochladen“ und gemerkter Anzeigename`.
### Task 8: Editor – Bereich „Logo unten rechts“ mit Raster, Sortierung, Auswahl und Herzen
**Files:**
- Create: `web/src/logos/logo-picker.ts`, `web/src/logos/logo-picker.test.ts`
- Modify: `web/src/logos/logos.css`, `web/src/editor/dom.ts` (Icons `heart`, `plus`), `web/src/editor/editor-page.ts` (+ Test), `web/src/editor/options-panel.ts` (nur falls `logoSlot` angepasst werden muss)
**Interfaces:**
- Consumes: Tasks 4–7 (`listLogos`, `getLogo`, `myHearts`, `loadLogoBlob`, `setHeart`, `rememberLogo`, `logoUploadDialog`, `CommunityLogo`, `LogoSort`, `LogoFilter`); `currentUser`, `currentUserId`, `isAdmin`, `onAuthChange` aus `account/auth.ts`; `requireLogin` aus `account/login-dialog.ts`.
- Produces:
```ts
export interface LogoPickerDeps { listLogos; getLogo; myHearts; setHeart; loadLogoBlob; // Typen wie in Task 4
userId(): string | null; isAdmin(): boolean; requireLogin(): Promise<boolean>;
onAuthChange(fn: () => void): () => void; uploadDialog(): Promise<CommunityLogo | null> }
export interface LogoPicker {
el: HTMLElement;
select(logoId: string | null): void; // markiert die Auswahl, holt ein nicht geladenes Logo per getLogo und heftet es an
/** Die Zeile des gewählten Logos, sobald bekannt (für die Infozeile in Task 9); null = kein Logo oder unbekannt. */
selected(): CommunityLogo | null;
dispose(): void;
}
export function createLogoPicker(handlers: { onSelect(id: string | null): void }, deps?: LogoPickerDeps): LogoPicker;
```
- Icons: `heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>'`, `plus: '<path d="M5 12h14"/><path d="M12 5v14"/>'`.
- [ ] **Step 1: Tests schreiben** (alles mit Fake-Deps):
- Aufbau: Überschrift „Logo unten rechts“, Plakette „Community“, Knopf „Hochladen“, Satz „Logos werden von der Community hochgeladen.“, Umschalter „Beliebt“ (gedrückt) / „Neu“; erste Kachel „Kein Logo“; je Logo eine Kachel mit Name, „von <Anzeigename>“ bzw. „von Anonym“ und Herz-Knopf „<Zahl>“ (eigener `button`, nicht im Auswahlknopf).
- Sortierung: Klick auf „Neu“ lädt mit `sort:'new'` neu; Admin sieht zusätzlich „Gemeldet“/„Ausgeblendet“ (`filter`), Nicht-Admin nicht.
- „Mehr laden“ erscheint nur bei `nextCursor` und hängt die nächste Seite an.
- Auswahl: Klick auf eine Kachel ruft `onSelect(id)`; „Kein Logo“ → `onSelect(null)`; `select('x')` mit nicht geladenem Logo → `getLogo('x')` und Kachel direkt nach „Kein Logo“; `aria-pressed` folgt der Auswahl.
- Herzen: angemeldet → Klick schaltet sofort (`aria-pressed`, Zahl ± 1), ruft `setHeart(id, true)`, übernimmt `likeCount` der Antwort; Fehler → Rücksprung + Text im Statusbereich (`role="alert"`); zweiter Klick während laufender Anfrage → kein zweiter Aufruf; Gast → `requireLogin()`; `false` → kein `setHeart`, Herz aus, kein Fehlertext; `true` → `setHeart(id, true)`; nach dem Laden einer Seite (angemeldet) `myHearts` mit genau den IDs der Seite; Abmelden → alle Herzen aus.
- Leer → „Noch keine Community-Logos – lade das erste hoch.“; Ladefehler → „Die Logos konnten nicht geladen werden.“ + „Nochmal versuchen“ (lädt neu).
- Hochladen: Gast → zuerst `requireLogin()`; danach `uploadDialog()`; Ergebnis-Logo steht als erste Logo-Kachel, `rememberLogo` und `onSelect(id)` werden gerufen.
- Editor: Picker sitzt in `options.logoSlot`; Auswahl setzt `config.logo`; `sync` ruft `picker.select(config.logo)`; Seite verlassen ruft `dispose`.
- [ ] **Step 2:** `cd web && npx vitest run src/logos/logo-picker.test.ts src/editor/editor-page.test.ts` → FAIL.
- [ ] **Step 3:** Umsetzen. Kachelbild: Object-URL aus `loadLogoBlob`; mehrfarbig `<img>`, einfarbig Element mit `mask-image` in Weiß; Bildfläche immer auf dunklem Kartengrund `#1b2738` (beide Themes, wie bisher `.mark-img` im hellen Theme). Raster 2 Spalten, unter 900 px `repeat(auto-fill, minmax(140px, 1fr))`. Herz gefüllt, wenn gedrückt (`fill: currentColor`). Object-URLs bei `dispose` freigeben. Alle Texte mit `t()`, Einträge in `en.ts`.
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün.
- [ ] **Step 5:** Commit `feat(editor): Community-Logos unten rechts mit Herzen und Sortierung`.
### Task 9: Editor – Infozeile, Melden, Löschen und Admin für Logos
**Files:**
- Modify: `web/src/logos/logo-picker.ts` (+ Test), `web/src/logos/logos.css`, `web/src/community/report-dialog.ts` (+ Test)
**Interfaces:**
- Consumes: `reportLogo`, `deleteLogo`, `setLogoVisibility`, `listLogoReports` (Task 4); `forgetLogo` (Task 5); `confirmDialog` aus `editor/confirm-dialog.ts`; `apiErrorText` (Task 4).
- Produces:
```ts
// community/report-dialog.ts – Erweiterung, Standard bleibt die Karte
export interface ReportDialogOptions { send; cardTitle?; signal?; subject?: 'card' | 'logo' }
// logo: Gründe ohne „Echte Kartendaten“; Danke-Text bei Duplikat „Danke, du hast dieses Logo schon gemeldet.“; Einleitung nennt „dieses Logo“
// LogoPickerDeps zusätzlich: reportLogo, deleteLogo, setLogoVisibility, listLogoReports, confirm(opts): Promise<boolean>, reportDialog(opts): Promise<ReportOutcome>
```
- [ ] **Step 1: Tests schreiben:**
- Infozeile für das gewählte Logo: „<Name> · von <Anzeigename> · ♥ <Zahl>“ und „Melden“ (auch für Gäste); ohne Auswahl leer; gewähltes Logo nicht auffindbar → „Das gewählte Logo ist nicht mehr verfügbar.“
- Melden öffnet den Dialog mit `subject:'logo'` und sendet `reportLogo(id, reason)`; der Logo-Dialog zeigt keine Option „Echte Kartendaten“.
- Hochlader (`ownerId === userId()`): „Löschen“ → Rückfrage; bestätigt → `deleteLogo(id)`, `forgetLogo(id)`, Kachel weg, `onSelect(null)`; abgelehnt → nichts; Fehler → Text im Statusbereich.
- Admin: „<n> Meldungen“ (`listLogoReports`, Gründe als Text), „Ausblenden“ bzw. bei `hidden` „Einblenden“ → `setLogoVisibility(id, false|true)` und Anzeige aktualisiert, „Löschen“ wie oben; Nicht-Admin und Nicht-Hochlader sehen weder Löschen noch Admin-Knöpfe.
- Karten-Meldedialog unverändert (bestehende Tests grün).
- [ ] **Step 2:** `cd web && npx vitest run src/logos/logo-picker.test.ts src/community/report-dialog.test.ts` → FAIL.
- [ ] **Step 3:** Umsetzen; Texte mit `t()`, Einträge in `en.ts`.
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit` → grün.
- [ ] **Step 5:** Commit `feat(logos): Infozeile mit Melden, Löschen und Admin-Aktionen`.
### Task 10: Übersetzung des Bestands, Teil A – Konto, Editor, Karte, Passwort-Seite
**Files:**
- Modify: `web/src/account/*.ts` (ohne Tests), `web/src/editor/*.ts` (ohne Tests), `web/src/card/*.ts` (ohne Tests), `web/src/pages/reset-page.ts`, `web/src/i18n/en.ts`; Tests nur, wo ein Test einen jetzt mit `msg()` markierten Export anders erwartet
**Interfaces:**
- Consumes: `t`, `msg` (Task 1). Exportierte Text-Konstanten bleiben deutsche Strings mit gleichem Namen (jetzt `msg('…')`), damit Vergleiche und Tests weiter funktionieren; übersetzt wird an der Stelle der Anzeige mit `t(KONSTANTE)`.
- [ ] **Step 1: Test schreiben** in `web/src/i18n/coverage.test.ts`: Für die Dateien dieses Tasks (`account/`, `editor/`, `card/`, `pages/reset-page.ts`) steht kein deutsches Text-Literal mehr außerhalb von `t()`/`msg()` – Heuristik: String-Literale mit Umlaut/ß oder mit mindestens zwei Wörtern aus Kleinbuchstaben, ausgenommen Kommentare, Import-Pfade, CSS-Klassen, `data-`/`aria-`-Attributnamen, Appwrite-Fehlertypen und `console.*`-Aufrufe; eine kleine Ausnahmeliste im Test ist erlaubt (je Eintrag mit Grund).
- [ ] **Step 2:** `cd web && npx vitest run src/i18n/coverage.test.ts` → FAIL (zeigt die noch offenen Texte).
- [ ] **Step 3:** Alle sichtbaren Texte der genannten Dateien durch `t()`/`msg()`; englische Einträge in `en.ts`. Seitentitel/`aria-label`/Platzhalter/`title` eingeschlossen. Fehlermeldungen in `errors.ts` werden beim Zurückgeben übersetzt.
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün.
- [ ] **Step 5:** Commit `feat(i18n): Konto, Editor und Karte auf Englisch`.
### Task 11: Übersetzung des Bestands, Teil B – Community, Startseite, Detailseite, Meine Karten, API-Fehler
**Files:**
- Modify: `web/src/community/*.ts` (ohne Tests), `web/src/pages/home-page.ts`, `web/src/pages/community-page.ts`, `web/src/pages/cards-page.ts`, `web/src/pages/format.ts` (+ `format.test.ts`), `web/src/i18n/en.ts`, `web/src/i18n/coverage.test.ts` (Heuristik aus Task 10 auf diese Dateien und `shell/`, `logos/` ausweiten)
**Interfaces:**
- Consumes: `t`, `msg`, `currentLang` (Task 1); `apiErrorText` (Task 4).
- Produces: `formatDate(iso)` – Deutsch `dd.mm.yyyy` (wie bisher), Englisch `Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' })` („Oct 4, 2026“); `reportsLabel(count)` übersetzt („1 report“, „3 reports“).
- [ ] **Step 1: Tests schreiben:** `formatDate('2026-10-04T12:00:00Z')` → `04.10.2026` (de) und `Oct 4, 2026` (en); `reportsLabel(1)`/`(3)` in beiden Sprachen; Anzeige eines `ApiError` mit `serverCode:'alreadyPublished'` im Veröffentlichen-Dialog bei Englisch → „Publishing failed – This card is already published.“ (Wortlaut aus `en.ts`); Heuristik-Test aus Task 10 deckt jetzt auch diese Dateien ab.
- [ ] **Step 2:** `cd web && npx vitest run src/pages/format.test.ts src/i18n src/community` → FAIL.
- [ ] **Step 3:** Alle sichtbaren Texte durch `t()`/`msg()`; jede Anzeige eines `ApiError`-Textes (`err.message`) durch `apiErrorText(err)` ersetzen (Logik, die `err.message` vergleicht, bleibt beim deutschen Text); englische Einträge in `en.ts`.
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` → grün.
- [ ] **Step 5:** Commit `feat(i18n): Community, Startseite und Meine Karten auf Englisch`.
### Task 12: Betrieb und Abnahme (Controller)
- [ ] **Schema:** `cd /home/webklar/apps/aircard-ios.org/dev && node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs --check` (erwartet FEHLT für die neuen Objekte) → ohne `--check` anlegen → `--check` Exit 0.
- [ ] **API:** Backup `aircard-ios.org/api/` → die fünf Dateien kopieren → `cd /home/webklar/apps && docker compose restart aircard-api` → `curl -s https://aircard-ios.org/api/gesundheit` = `{"ok":true}`; `curl` auf `/api/logos/melden` mit unbekannter ID → 404 mit `code:"logoGone"`; `/api/logos/herz` ohne JWT → 401 `code:"unauthorized"`.
- [ ] **Web:** Push `main` → Webhook-Build → neue Bundles live (Hash im HTML geändert).
- [ ] **Live-Abnahme** (Claude in Chrome in Kensos Sitzung; Konten/Passwörter gibt nur Kenso ein): Logo hochladen (PNG transparent + einfarbig, SVG, JPG) → Kachel, Karte, Download-PNG enthält Logo; Gast (zweiter Tab ohne Sitzung bzw. `curl` auf die Tabelle) sieht das Logo; Herz an/aus, Zahl und Sortierung „Beliebt“; Melden aus 3 Netzen (per `docker exec webklar-aircard-api` gegen `127.0.0.1:8088` mit `X-Real-Ip`) → ausgeblendet; Löschen; Sprachwechsel mit offenem Entwurf (bleibt), englische Startsprache bei englischem Browser, `<html lang>`, Titel; GitHub-Link; 1440 × 900 und 390 × 844, Hell/Dunkel; keine Marken-Logos mehr im Editor.
- [ ] **Abschluss:** Whole-Branch-Review (ein `sonnet`-Reviewer), eine Fix-Welle nur für Critical/Important, Push, `README.md` (Funktionsliste, Sprachen, Logos), Memory aktualisieren, Bericht an Kenso.
@@ -0,0 +1,113 @@
# AirCard-IOS Benutzernamen – Implementierungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eindeutige Benutzernamen pro Konto (Pflicht, änderbar), angezeigt als „von @name“ an Logos und Community-Karten.
**Architecture:** Neue öffentliche Tabelle `profiles` (Zeile = Konto, Unique-Index auf dem kleingeschriebenen Namen), geschrieben nur über `aircard-api` (`POST /api/profil`, neues Modul `deploy/api/profiles.js`); Hochladen/Veröffentlichen verlangen serverseitig ein Profil. Der Client liest Profile direkt aus Appwrite, fragt den Namen beim Registrieren bzw. nach dem Anmelden ab und zeigt ihn statt des alten Anzeigenamens.
**Tech Stack:** wie bisher (TypeScript, Vite, Vitest + happy-dom, appwrite 22.4.1; Node 22 ohne Abhängigkeiten; Appwrite 1.8.1 TablesDB).
**Spec:** `docs/superpowers/specs/2026-10-05-aircard-benutzernamen-design.md`
## Global Constraints
- Arbeitskopie `/home/webklar/apps/aircard-ios.org/dev`, Branch `main`, direkt committen, **nicht pushen** (Controller pusht).
- Keine neuen Abhängigkeiten; Versionen unverändert; deutsche Kommentare im Stil des Bestands.
- Alle neuen sichtbaren Texte über `t()`/`msg()` mit Eintrag in `web/src/i18n/en.ts` (Schlüssel eindeutig, Vorhandenes wiederverwenden); Tests laufen auf Deutsch; `coverage.test.ts` (Abdeckung, Heuristik, AST-Wache) bleibt grün.
- Namensmuster genau `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$`; Schlüssel = `toLowerCase()`; Sperrliste genau wie Spec § 2.
- Tabelle `profiles`: Zeilen-ID = Nutzer-ID, Spalten `username` (string 20, Pflicht), `usernameKey` (string 20, Pflicht), Index `username_key` unique auf `usernameKey`, rowSecurity an, keine Tabellenrechte, Zeilenrecht genau `['read("any")']`.
- API-Codes und Texte genau wie Spec § 4 (`usernameInvalid`, `usernameReserved`, `usernameTaken`, `usernameRequired`); Client-Tabelle `API_MESSAGES` bleibt im Gleichlauf mit `deploy/api/common.js` `MSG`.
- `sessionStorage`-Schlüssel `aircard.usernamePrompted`; jeder Storage-Zugriff in try/catch.
- Tests: `cd web && npx vitest run && npx tsc --noEmit && npm run build`; `node --test "deploy/api/*.test.js"`; `node --test web/scripts/appwrite-setup.test.mjs`.
- Commits enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
- Subagents: `sonnet` (Umsetzung/Review), `haiku` für Kleinstes; nie Subagents aus Subagents; keine Browser-/Headless-Prüfungen (macht der Controller live).
## Review Focus
- **Zwei Konten wollen gleichzeitig denselben Namen**: genau eines bekommt ihn, das andere 409 `usernameTaken` (Unique-Index, nicht nur Vorabprüfung). Test in Task 1.
- **Registrierung mit vergebenem Namen**: Fehler am Feld, **bevor** das Konto angelegt wird (kein halbes Konto). Test in Task 2.
- **Altes Konto ohne Namen lädt hoch / veröffentlicht**: erst der Namensdialog; „Abbrechen“ → nichts passiert; Server würde ohne Profil 403 liefern. Tests in Tasks 1 und 3.
- **Umbenennen**: alter Name sofort für andere frei, eigene Kacheln/Infozeile/Detailseite zeigen sofort den neuen. Tests in Tasks 1 und 3.
- **Profil nicht lesbar / gelöscht / Netzfehler bei der Anzeige**: Fallback auf alten Anzeigenamen bzw. „Anonym“, keine Fehlermeldung, kein Absturz. Test in Task 3.
---
### Task 1: Server – Schema `profiles`, `POST /api/profil`, Pflicht beim Hochladen/Veröffentlichen
**Files:**
- Modify: `web/scripts/appwrite-setup.mjs` (+ Test): Tabelle `profiles` nach `logo_reports` mit Spalten und Unique-Index aus den Global Constraints.
- Create: `deploy/api/profiles.js`, `deploy/api/profiles.test.js`
- Modify: `deploy/api/common.js` (`MSG` + 4 Texte, Muster/Sperrliste/Schlüssel-Helfer), `deploy/api/app.js` (Routen einhängen; `publish` prüft Profil), `deploy/api/logos.js` (`hochladen` prüft Profil), `deploy/api/fake-appwrite.js` (Tabelle `profiles`, Unique-Index → 409 bei doppeltem `usernameKey`), `deploy/api/app.test.js`/`logos.test.js` (Profil in bestehenden Hochladen-/Veröffentlichen-Tests anlegen; neue 403-Tests), `deploy/api/README.md`, `deploy/README.md` (sechs Dateien kopieren)
**Interfaces:**
- Produces:
```js
// common.js
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
function usernameKey(name) // name.toLowerCase()
function isReservedUsername(name) // Schlüssel in der Sperrliste
// profiles.js
function createProfileRoutes({ appwrite, say, withLock, requireUser }) // → [['POST /api/profil', {name:'profil', json:true, run}]]
async function hasProfile(appwrite, userId) // getRow('profiles', userId) !== null — von logos.js und app.js genutzt
```
- [ ] **Step 1: Tests schreiben:** anlegen → 200 `{username}`, Zeile ID = Nutzer-ID, Rechte genau `['read("any")']`, `usernameKey` klein; ändern → alter Schlüssel frei (anderes Konto kann ihn danach nehmen); „kenso“ → „Kenso“ beim selben Konto erlaubt; vergeben → 409 `usernameTaken`; Rennen zweier Konten (Verzögerung im Fake) → genau ein 200, ein 409; gesperrt (`Admin`, `AirCard-IOS`) → 400 `usernameReserved`; Muster (`ab`, `_abc`, 21 Zeichen, `a b`, Umlaut) → 400 `usernameInvalid`; ohne JWT 401; `hochladen` und `veroeffentlichen` ohne Profil → 403 `usernameRequired` (vor allen anderen Prüfungen, nichts angelegt); Setup-Skript: `profiles` samt Unique-Index wird angelegt/geprüft.
- [ ] **Step 2:** `node --test "deploy/api/*.test.js"` und `node --test web/scripts/appwrite-setup.test.mjs` → FAIL.
- [ ] **Step 3:** Umsetzen.
- [ ] **Step 4:** beide Suiten grün; `cd web && npx vitest run` grün (Gleichlauftest `API_MESSAGES` erst in Task 2 — falls er hier rot wird, die vier Texte in `web/src/i18n/api-messages.ts` + `en.ts` gleich mit ergänzen).
- [ ] **Step 5:** Commit `feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)`.
### Task 2: Client – Profil-Daten, Dialog „Benutzername wählen“, Registrieren, Konto-Menü
**Files:**
- Create: `web/src/account/profile.ts` (+ Test), `web/src/account/username-dialog.ts` (+ Test)
- Modify: `web/src/account/appwrite.ts` (`IDS.profiles = 'profiles'`), `web/src/account/login-dialog.ts` (+ Test), `web/src/account/account-menu.ts` (+ Test), `web/src/main.ts` (+ Test: Abfrage einmal pro Sitzung nach Start/Anmeldung), `web/src/i18n/api-messages.ts`, `web/src/i18n/en.ts`, `web/src/account/account.css`
**Interfaces:**
- Produces:
```ts
// account/profile.ts
export const USERNAME_PATTERN: RegExp;
export function usernameKey(name: string): string;
export function validateUsername(name: string): string | null; // übersetzter Text oder null (Muster, Sperrliste)
export function getUsername(userId: string): Promise<string | null>; // Profil lesen, 404 → null
export function usernamesFor(userIds: readonly string[]): Promise<Map<string, string>>; // gesammelt, zwischengespeichert, Fehler → leere Map
export function isUsernameTaken(name: string): Promise<boolean>; // Abfrage equal('usernameKey', key)
export function saveUsername(name: string): Promise<string>; // POST /api/profil, aktualisiert Cache + meldet Änderung
export function myUsername(): string | null; // Cache des angemeldeten Kontos (null = keiner/unbekannt)
export function loadMyUsername(): Promise<string | null>; // nach Anmeldung/Start
export function onUsernameChange(fn: (userId: string, username: string) => void): () => void;
// account/username-dialog.ts
export function usernameDialog(opts: { mode: 'prompt' | 'required' | 'change'; signal?: AbortSignal }): Promise<string | null>; // null = Später/Abbrechen
export function requireUsername(): Promise<boolean>; // hat Namen → true; sonst Dialog 'required'
```
- [ ] **Step 1: Tests:** `validateUsername` (Grenzen 3/20, erstes Zeichen, erlaubte Zeichen, Sperrliste case-insensitiv); `usernamesFor` sammelt IDs, eine Abfrage pro Aufruf, Cache, Fehler → leere Map; Registrieren: Feld „Benutzername“ Pflicht, Vorabprüfung (Muster, Sperre, `isUsernameTaken`) **vor** `register()`, Fehler am Feld; nach erfolgreichem `register` → `saveUsername`; scheitert das, schließt der Login-Dialog trotzdem erfolgreich und der Namensdialog erscheint; Dialog: Modi prompt („Später“), required („Abbrechen“), change (vorbelegt), Speichern ruft `saveUsername`, 409 → Text am Feld, Busy-Sperre; Konto-Menü: „@name“ + E-Mail klein, Eintrag „Benutzername ändern“ bzw. „Benutzername wählen“; `main.ts`: nach Start mit Sitzung bzw. nach Anmeldung ohne Namen genau einmal pro Sitzung Dialog `prompt`.
- [ ] **Step 2:** `cd web && npx vitest run src/account src/main.test.ts` → FAIL.
- [ ] **Step 3:** Umsetzen (Dialog nach dem Muster von `logo-dialog.ts`/`publish-dialog.ts`, Texte nur als Text).
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` grün.
- [ ] **Step 5:** Commit `feat(account): Benutzername beim Registrieren, im Konto-Menü und als einmalige Abfrage`.
### Task 3: Client – Anzeige „von @name“, Anzeigename-Felder weg, Pflicht vor Hochladen/Veröffentlichen
**Files:**
- Modify: `web/src/logos/logo-picker.ts`, `web/src/logos/logo-info.ts`, `web/src/pages/community-page.ts`, `web/src/logos/logo-dialog.ts`, `web/src/logos/upload-logo.ts`, `web/src/community/publish-dialog.ts`, `web/src/community/publish.ts`, `web/src/pages/cards-page.ts` (Veröffentlichen-Aufruf), Tests dazu; `web/src/i18n/en.ts`
- Delete: `web/src/account/display-name.ts` (+ Test)
**Interfaces:**
- Consumes: Task 2 (`usernamesFor`, `myUsername`, `onUsernameChange`, `requireUsername`, `currentUserId`).
- Byline-Regel (eine Hilfsfunktion, z. B. in `profile.ts`): `byline(ownerId, legacyDisplayName, names)` → `t('von @{name}', {name})` wenn Profil, sonst `t('von {name}', {name: legacy})` wenn Altname, sonst `t('von {name}', {name: t('Anonym')})`.
- [ ] **Step 1: Tests:** Kacheln/Infozeile/Detailseite zeigen „von @name“ (eine `usernamesFor`-Abfrage pro geladener Seite), Fallback Altname bzw. „Anonym“, Fehler beim Nachschlagen → Fallback ohne Fehlertext; eigene Umbenennung (`onUsernameChange`) aktualisiert eigene Einträge sofort; Dialoge „Logo hochladen“ und „Veröffentlichen“ ohne Anzeigename-Feld, senden `displayName: ''`; vor Hochladen bzw. Veröffentlichen `requireUsername()` → false = nichts passiert; Serverantwort 403 `usernameRequired` → Namensdialog `required`, danach erneuter Versuch möglich; `display-name.ts` und `aircard.displayName` nirgends mehr genutzt.
- [ ] **Step 2:** `cd web && npx vitest run src/logos src/community src/pages` → FAIL.
- [ ] **Step 3:** Umsetzen.
- [ ] **Step 4:** `cd web && npx vitest run && npx tsc --noEmit && npm run build` grün.
- [ ] **Step 5:** Commit `feat(community): „von @name“ statt Anzeigename, Benutzername vor dem Hochladen und Veröffentlichen`.
### Task 4: Betrieb und Abnahme (Controller)
- [ ] Schema: Setup-Skript `--check` → anlegen → `--check` Exit 0 (Unique-Index `username_key`).
- [ ] API: Backup, sechs Dateien kopieren, `docker compose restart aircard-api`, Gesundheit + `POST /api/profil` ohne JWT = 401 `unauthorized`.
- [ ] Push `main` → Build → neues Bundle live.
- [ ] Live als Gast: Kacheln/Detailseite zeigen „von …“ (Altdaten „Anonym“). Kenso: registrieren bzw. Abfrage nach Anmeldung, Name ändern, Name am eigenen Logo sehen.
- [ ] Review (ein `sonnet`-Reviewer über alles), Fix-Welle nur für Critical/Important, Memory, Bericht.
@@ -0,0 +1,131 @@
# AirCard-IOS – Startseite & Community-Galerie – Design
Stand: 2026-10-04 · Status: zur Freigabe durch Kenso · baut auf `2026-10-04-aircard-website-design.md` auf (live, main 562fac0)
## 1. Ziel und Rahmen
Die Website bekommt eine Startseite im Stil von Kensos Vorbild-Screenshot (bestehende „AirCards“-Seite), aber im eigenen AirCard-IOS-Look:
kompakter Hero, darunter sofort die **Community-Galerie**, Spenden-Banner und -Knopf, Hell/Dunkel-Schalter.
Nutzer können ihre gespeicherten Karten **sofort öffentlich veröffentlichen**; Besucher können melden; ab 3 Meldungen wird eine Karte automatisch ausgeblendet; Kenso moderiert als Admin.
| Punkt | Entscheidung (Kenso) |
|---|---|
| Umfang | Startseite + Community-Galerie; keine „Offiziellen Karten“ (echte Bank-Kartenmotive), keine Texte/Bilder vom Vorbild |
| Galerie-Inhalt | nur Community-Karten |
| Freigabe | sofort öffentlich + „Melden“; Auto-Ausblenden ab 3 verschiedenen Meldern |
| Extras | nur Hell/Dunkel-Schalter (kein Englisch, keine Anleitung-Seite, keine Highlights) |
| Hero | **nicht** Vollbild: erste Reihe Community-Karten muss im ersten Bildschirm anschneiden |
| Spenden | Buy Me a Coffee; öffentlicher Link kommt noch (Konfig-Wert) |
| Technik | Ansatz A: Appwrite + kleiner eigener API-Dienst `aircard-api` |
## 2. Seiten und Routen
| Route | Inhalt |
|---|---|
| `/` | Startseite: Hero + Community-Galerie (Suche über `?q=`, Anker `#community`) |
| `/gestalten` | der bisherige Editor (`?karte=<id>` öffnet eine gespeicherte Karte, `?vorlage=<galleryId>` übernimmt ein Community-Bild als „Nur Bild“) |
| `/karten` | Meine Karten (+ Veröffentlichen / Veröffentlicht-Status / Zurückziehen) |
| `/community/<id>` | Detailansicht einer Community-Karte |
| `/passwort-neu` | unverändert |
Alte Links `/?karte=<id>` leiten clientseitig auf `/gestalten?karte=<id>` um. `document.title` je Route („AirCard-IOS – Kartendesigns für Apple Wallet“, „Gestalten – AirCard-IOS“, „Meine Karten – AirCard-IOS“, „<Titel> – AirCard-IOS“).
## 3. Kopfzeile, Banner, Theme
- **Spenden-Banner** (ganz oben, schließbar, Zustand in `localStorage` `aircard.donateBannerClosed`): „☕ Gefällt dir AirCard-IOS? Spendier uns einen Kaffee – damit das Projekt weiterläuft.“ + Link „Unterstützen →“. Ziel = `DONATE_URL` aus `web/src/config.ts`. **Ist `DONATE_URL` leer, werden Banner und Spenden-Knopf nicht angezeigt** (bis Kenso den öffentlichen Link `buymeacoffee.com/<name>` liefert).
- **Kopfzeile**: Logo + „AirCard-IOS“ (→ `/`), Navigation „Community“ (→ `/#community`), „Gestalten“, „Meine Karten“; Suchfeld „Titel, Tags oder Herausgeber“ (Enter → `/?q=…#community`); Knopf „☕ Unterstützen“ (wenn `DONATE_URL`); Hell/Dunkel-Schalter (Sonne/Mond-Icon); Konto: abgemeldet „Anmelden“, angemeldet Avatar-Kreis mit Initiale (Menü: E-Mail, „Meine Karten“, „Abmelden“).
- Unter 900 px: Navigation in ein Menü-Icon, Suchfeld als eigene Zeile unter der Kopfzeile.
- **Theme**: `data-theme="light|dark"` auf `<html>`; Standard = System (`prefers-color-scheme`); Schalter speichert in `localStorage` `aircard.theme`. Helle Tokens: `--bg #F4F6FB; --panel #FFFFFF; --panel-2 #EEF1F7; --line #D9DFEA; --line-strong #B8C2D3; --sel-bg #E3ECFB; --sel-line #3A6FD8; --text #0F1828; --muted #5B6B82; --primary #2A4566; --primary-hover #34557C`. Die Kartenvorschau selbst bleibt unverändert (Kartenfarben hängen nicht vom Theme ab).
## 4. Startseite
- **Hero (kompakt)**: links Zeile „Ein bisschen Kunst für dein Wallet“, Überschrift „Kartendesigns für Apple Wallet“, ein Satz „Entdecke Motive der Community, gestalte dein eigenes und lade es als PNG herunter.“, Knöpfe **„Karten entdecken ↓“** (scrollt zu `#community`) und **„Gestalten“**. Rechts eine große Karte (neueste sichtbare Community-Karte; ist die Galerie leer: eigenes Beispielmotiv `web/public/hero-beispiel.png`, markenfrei, von Claude gestaltet) mit dezenter Schwebe-Animation (`prefers-reduced-motion` respektieren).
- **Höhe**: Hero höchstens 60 vh (Desktop) bzw. so, dass bei 1440 × 900 und 390 × 844 die Überschrift „Community-Karten“ **und** der obere Rand der ersten Kartenreihe im ersten Bildschirm sichtbar sind (Abnahmekriterium).
- **Community-Galerie** (`#community`): Überschrift „Community-Karten“, Untertitel „Entdecke Motive der Community – und veröffentliche deine eigenen.“, Knopf „Gestalten & veröffentlichen“. Suchzeile mit aktivem Suchbegriff + „ד. Raster aus Kacheln (Kartenformat 1536:969, abgerundet): Vorschau, Titel, Herausgeber, bis 3 Tag-Chips (Klick = Suche). Sortierung neueste zuerst, 24 pro Seite, „Mehr laden“ (Cursor). Leer: „Noch keine Community-Karten – sei die erste!“ + „Gestalten“. Keine Treffer: „Nichts gefunden für „…““.
## 5. Detailseite `/community/<id>`
Große Karte, Titel, Herausgeber, Tags, Datum (`dd.mm.yyyy`), Anzeigename („von <Name>“ bzw. „von Anonym“). Knöpfe: **Herunterladen** (PNG 1536 × 969, Dateiname `aircard-<titel-slug>.png`), **Als Vorlage gestalten** (→ `/gestalten?vorlage=<id>`), **Melden** (Dialog mit Grund: „Unangemessen“, „Echte Kartendaten“, „Urheberrecht/Marke“, „Spam“, „Sonstiges“).
Autor (angemeldet, `ownerId` = ich): zusätzlich **Zurückziehen**. Admin (Label `admin`): **Ausblenden/Einblenden**, **Löschen**, Anzeige „<n> Meldungen“ und Liste der Gründe.
Nicht vorhanden/ausgeblendet für mich: „Diese Karte ist nicht (mehr) verfügbar.“ + Link zur Galerie.
## 6. Veröffentlichen (aus „Meine Karten“)
Kachel-Aktion **„Veröffentlichen“** → Dialog: Titel (Pflicht, ≤ 60), Herausgeber/Bank (optional, ≤ 40), Tags (optional, bis 5, je ≤ 20, Komma-getrennt, kleingeschrieben gespeichert), Anzeigename (optional, ≤ 30, Standard leer → „Anonym“; **nie** die E-Mail), Pflicht-Checkbox „Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.“
Ablauf: Karte rendern (PNG 1536 × 969 + WebP-Vorschau 480) → beide Dateien in Bucket `gallery-images` hochladen (Rechte `read("any")`, `delete("user:<ich>")`) → `POST /api/veroeffentlichen` → Zeile entsteht. Scheitert die API, löscht der Client die zwei Dateien wieder.
Danach zeigt die Kachel „Veröffentlicht“ + „Ansehen“ + „Zurückziehen“; ist die Karte ausgeblendet: „Ausgeblendet (gemeldet)“. Eine private Karte kann **einmal** veröffentlicht sein; „Erneut veröffentlichen“ = Zurückziehen + Veröffentlichen.
## 7. Daten (Appwrite, Projekt `6ac2130a00289343d6d5`, Setup-Skript erweitert)
**Tabelle `gallery`** (rowSecurity an, **keine** Tabellenrechte für Clients):
| Spalte | Typ | Inhalt |
|---|---|---|
| `title` | string 60, Pflicht | Titel |
| `issuer` | string 40 | Herausgeber/Bank |
| `tags` | string 120 | Tags, Komma-getrennt, klein |
| `displayName` | string 30 | Anzeigename oder leer |
| `imageFileId` | string 36, Pflicht | PNG im Bucket `gallery-images` |
| `previewFileId` | string 36, Pflicht | WebP-Vorschau |
| `sourceCardId` | string 36 | private Karte, aus der veröffentlicht wurde |
| `ownerId` | string 36, Pflicht | Nutzer-ID des Autors |
| `hidden` | boolean, Standard false | ausgeblendet |
| `reportCount` | integer, Standard 0 | Anzahl verschiedener Melder |
| `searchText` | string 300 | `title + issuer + tags`, klein; **Volltext-Index** |
Indizes: Volltext auf `searchText`; Key-Index auf `ownerId`; Key-Index auf `hidden`.
Zeilenrechte (setzt nur die API): sichtbar `["read(\"any\")"]`; ausgeblendet `["read(\"user:<owner>\")","read(\"label:admin\")"]`.
**Bucket `gallery-images`**: Bucket-Recht `create("users")`, fileSecurity an, max. 5 MiB, Endungen png/webp. Dateirechte beim Hochladen `read("any")`, `delete("user:<owner>")`; beim Ausblenden setzt die API `read("user:<owner>")`, `read("label:admin")`, `delete("user:<owner>")`.
**Tabelle `reports`** (rowSecurity aus, Tabellenrecht `read("label:admin")`, Schreiben nur API): `galleryId` string 36, `reason` enum (unangemessen, kartendaten, urheberrecht, spam, sonstiges), `reporterHash` string 64 (HMAC-SHA256 der IP mit Server-Salz), Index auf `galleryId`.
**Lesen im Client**: Galerie `listRows(gallery, [equal('hidden', false), orderDesc('$createdAt'), limit(24), cursorAfter])`, Suche zusätzlich `search('searchText', q)`; eigene Veröffentlichungen `equal('ownerId', me)`; Admin-Filter „Gemeldet/Ausgeblendet“ `greaterThan('reportCount', 0)` bzw. `equal('hidden', true)`.
## 8. API-Dienst `aircard-api`
Node 22, ohne Abhängigkeiten, Container `webklar-aircard-api` (`node:22-alpine`, `init: true`, `mem_limit 128m`), Traefik-Router ``Host(`aircard-ios.org`) && PathPrefix(`/api`)`` Priorität 200, Port 8088. Spricht Appwrite intern (`http://webklar-appwrite/v1`, Header `X-Appwrite-Project`, `X-Appwrite-Key` = `AIRCARD_API_KEY`). Secrets aus `.env`: `AIRCARD_API_KEY` (neuer Key `aircard-api`, Scopes `rows.read/write`, `files.read/write`), `AIRCARD_API_SALT` (zufällig, 32 Byte hex). Antworten JSON, Fehlertexte deutsch.
| Endpunkt | Auth | Wirkung |
|---|---|---|
| `POST /api/veroeffentlichen` `{title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}` | Nutzer-JWT | prüft Nutzer, Feldlängen, dass beide Dateien existieren, im Bucket `gallery-images` liegen und `delete("user:<ich>")` tragen, und dass für `sourceCardId` noch keine Veröffentlichung existiert → legt Zeile an (sichtbar) → `201 {id}` |
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor **oder** Admin) | löscht Zeile, beide Dateien und ihre Meldungen |
| `POST /api/melden` `{id, reason}` | keine | Rate-Limit 10/Stunde je IP (Speicher), ein Bericht je Melder-Hash und Karte (sonst `200 {schonGemeldet:true}`), speichert Meldung, setzt `reportCount` = Anzahl verschiedener Melder; ab **3** → Ausblenden (Zeilen- und Dateirechte, `hidden=true`) |
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | Einblenden (öffentliche Rechte, `hidden=false`, `reportCount=0`, Meldungen löschen) oder Ausblenden |
| `GET /api/gesundheit` | keine | `{ok:true}` |
JWT-Prüfung: `GET /v1/account` mit `X-Appwrite-JWT` → Nutzer (`$id`, `labels`); Admin = `labels` enthält `admin`. Echte Client-IP aus `X-Real-Ip` (Traefik). Körper max. 8 KB. Alles andere 404.
**Wichtig**: JWT muss in den Auth-Methoden des Projekts **an** bleiben (die frühere Klickliste sagte „aus“ – wird korrigiert).
## 9. Admin (Kenso)
Kenso bekommt in der Appwrite-Console das Label `admin` (Auth → Users → Konto → Labels). Die Website erkennt es über `account.get().labels` und zeigt Admin-Knöpfe und den Galerie-Filter „Gemeldet“/„Ausgeblendet“.
## 10. Was Kenso selbst erledigt
1. Label `admin` am eigenen Konto setzen.
2. Neuen API-Key `aircard-api` (Scopes `rows.read`, `rows.write`, `files.read`, `files.write`) anlegen und mit dem verdeckten `read -s`-Befehl in `.env` eintragen (nicht im Chat).
3. Danach den alten, im Chat geposteten Key löschen (Setup-Skript nutzt ab dann `aircard-api` nicht – es bekommt bei Bedarf einen neuen Provisioning-Key).
4. Öffentlichen Buy-Me-a-Coffee-Link schicken.
## 11. Fehlerbehandlung
Veröffentlichen scheitert (Netz/API/Validierung) → hochgeladene Dateien löschen, Meldung im Dialog („Veröffentlichen fehlgeschlagen – …“). Doppelt veröffentlicht → „Diese Karte ist schon veröffentlicht.“ Melden doppelt → „Danke, du hast diese Karte schon gemeldet.“ Rate-Limit → „Zu viele Meldungen – bitte später nochmal.“ Nicht angemeldet bei Veröffentlichen → Login-Dialog (bestehendes `withLogin`). API nicht erreichbar → „Der Dienst ist gerade nicht erreichbar.“ Galerie-Ladefehler → Hinweis + „Nochmal versuchen“.
## 12. Tests
- Vitest: Router (neue Routen, Umleitung `/?karte=`), Theme (System/Speichern), Banner (ausblenden, leeres `DONATE_URL`), Galerie-Abfragen (Queries für Liste/Suche/Admin-Filter), Veröffentlichen-Ablauf mit Fake-API (Aufräumen bei Fehler), Such-Text-Bildung, Tags-Normalisierung.
- node:test für `aircard-api`: JWT-Prüfung (Fake-Appwrite), Validierung, Datei-Eigentum, doppelte Veröffentlichung, Melden (Dedup, Rate-Limit, Auto-Ausblenden bei 3, Rechte-Strings), Admin ein/aus, Zurückziehen (Autor/Admin/fremd → 403).
- Setup-Skript: `--check` deckt `gallery`, `reports`, `gallery-images` inkl. Indizes ab.
- Live: Veröffentlichen, Suche, Detailseite, Herunterladen, Als Vorlage, Melden aus 3 verschiedenen Netzen/Fake-IPs (über die API mit `X-Real-Ip`-Test im Container), Admin aus-/einblenden, Startseite bei 1440 × 900 und 390 × 844 (erste Kartenreihe angeschnitten), Hell/Dunkel.
## 13. Nicht im Umfang
Offizielle/Bank-Karten, Likes/„Beliebt“-Sortierung, Kommentare, Englisch, Anleitung-Seite, Highlights, öffentliche Profilseiten, Bearbeiten veröffentlichter Karten (nur zurückziehen + neu), E-Mail-Benachrichtigung an Kenso bei Meldungen.
## 14. Risiken
- **Öffentliche Inhalte ohne Vorab-Freigabe**: Kenso haftet ab Kenntnis; Melden + Auto-Ausblenden + Admin-Löschen mildern. Ohne Kontaktadresse/Impressum fehlt ein Weg für Beschwerden von außen (Kenso-Entscheidung, hier vermerkt).
- **Marken/Urheberrecht**: Nutzer können Bank-Kartenmotive hochladen; Pflicht-Checkbox + Melden-Grund „Urheberrecht/Marke“.
- **Speicher**: keine Kontingente je Nutzer; Bucket 5 MiB je Datei. Missbrauch → Admin löscht; Auth-Limits in der Console (Kensos Schritt 3) empfohlen.
- **Rate-Limit im Speicher** geht bei Neustart verloren (Dedup über `reports` bleibt).
@@ -0,0 +1,275 @@
# AirCard-IOS – Community-Logos, Englisch und GitHub-Link – Design
Stand: 2026-10-04 · Status: Entwurf im Chat von Kenso freigegeben („ja“), Spec + Plan + Umsetzung ohne weitere Rückfrage · baut auf `2026-10-04-aircard-website-design.md` und `2026-10-04-aircard-community-design.md` auf (live, main 8fb28eb)
## 1. Ziel und Rahmen
AirCard-IOS soll in die internationale Community der App [AirCard-iOS](https://github.com/Mak5er/AirCard-iOS), ohne dass Kenso fremde Marken-Logos selbst anbietet. Dafür:
1. **Keine eingebauten Marken-Logos mehr.** Stattdessen laden Nutzer Logos hoch (Community-Logos), alle können sie benutzen, Herzen zeigen die beliebtesten.
2. **Die Seite gibt es auf Deutsch und Englisch.**
3. **GitHub-Link zum Original-Repo** der App, daneben der Sprachknopf.
| Punkt | Entscheidung (Kenso) |
|---|---|
| Eingebaute Logos | Alle 18 Netzwerk-Logos (Visa ×4, Mastercard ×5 inkl. „Klassisch“, UnionPay ×4, American Express ×3, JCB, Discover) und das Apple-Logo entfallen, auch ihre SVG-Dateien. |
| „Klassik kann so bleiben“ | Gemeint ist die **Vorlage „Klassisch“** (Chip, Kontaktlos-Zeichen, Nummer, PLATINUM). Sie bleibt unverändert. |
| Logo oben links | Bleibt: AirCard-IOS Symbol, AirCard-IOS Text, Kein Logo (ohne Apple). |
| Platz der Community-Logos | **Unten rechts**, wo bisher das Kartennetzwerk saß. |
| Hochladen | Nur angemeldet; sofort öffentlich; Melden, Auto-Ausblenden ab 3 Meldern, Admin. |
| Herzen | **Nur angemeldet**, eins pro Konto und Logo, wieder entfernbar; Sortierung „Beliebt“ zuerst. |
| Sprache | **Automatisch** nach Browsersprache, Umschalter merkt sich die Wahl. |
| GitHub | Link `https://github.com/Mak5er/AirCard-iOS` in der Kopfzeile, Sprachknopf direkt daneben. |
Live-Stand beim Schreiben: 0 veröffentlichte Community-Karten, 1 gespeicherte Testkarte. Es gibt keinen Altbestand mit Marken-Logos, der aufgeräumt werden müsste.
## 2. Karte und Konfiguration
- `CardConfig`: Das Feld `network` entfällt. Neu ist `logo: string | null`, die ID eines Community-Logos (Muster `ID_PATTERN`). `brand` ist nur noch `'aircards-symbol' | 'aircards-text' | 'none'`. `v` bleibt `1`.
- `parseConfig`: Ein altes `network` wird ignoriert. Ein fehlendes oder ungültiges `logo` ergibt `null`. `brand: 'apple'` und andere Unbekannte ergeben den Standard (`aircards-symbol`). `serializeConfig` schreibt `{v, template, logo, details, brand, crop}`.
- `DEFAULT_CONFIG.logo = null`, eine neue Karte hat also kein Logo unten rechts.
- In den Vorlagen-Layouts heißt `network` jetzt `logo`: „Nur Bild“ ohne, „Minimalistisch“, „Klassisch“ und „Signature“ mit Logo.
- Das Logo sitzt wie bisher das Netzwerk-Logo: rechte Kante 1480, Unterkante 913, höchstens 360 px breit und 125 px hoch (Seitenverhältnis des Bildes bleibt).
- **Einfarbige Logos** (`mono = true`) werden beim Zeichnen in der Details-Farbe gefüllt (Hell `#FFFFFF`, Dunkel `#111111`). Die Form kommt aus dem Alphakanal des PNG. Mehrfarbige Logos erscheinen unverändert.
- **Gelöscht** werden 19 SVGs in `web/src/assets/marks/` (alle außer `aircards-symbol.svg` und `aircards-text.svg`) sowie alles zu Netzwerk-Marken in `marks.ts` und `config.ts`.
- **Fehlt ein Logo** (gelöscht oder ausgeblendet), zeichnet der Editor die Karte ohne Logo und zeigt den Hinweis „Das gewählte Logo ist nicht mehr verfügbar.“. Die Auswahl bleibt, bis der Nutzer ein anderes Logo wählt. Beim Veröffentlichen aus „Meine Karten“ führt ein fehlendes Logo zum Abbruch mit „Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.“.
## 3. Editor: Bereich „Logo unten rechts“
Die rechte Spalte „Individuell gestalten“ zeigt von oben nach unten: **Logo unten rechts**, Details (Hell/Dunkel), **Logo oben links** (bisher „Logo“).
Der Bereich „Logo unten rechts“ besteht aus:
- **Kopf:** Überschrift „Logo unten rechts“, Plakette „Community“ und der Knopf „+ Hochladen“. Darunter ein kleiner Satz: „Logos werden von der Community hochgeladen.“
- **Sortierung:** Umschalter „Beliebt“ (Standard: meiste Herzen, bei Gleichstand neueste zuerst) und „Neu“ (neueste zuerst). Admins sehen zusätzlich „Gemeldet“ und „Ausgeblendet“.
- **Raster** (Desktop 2 Spalten):
- Erste Kachel „Kein Logo“.
- Jede Logo-Kachel zeigt das Logo so, wie es auf der Karte erscheint. Einfarbige Logos stehen wie die bisherigen Kacheln in Weiß auf dunklem Grund.
- Darunter folgen der Name (eine Zeile, gekürzt) und „von <Anzeigename>“ bzw. „von Anonym“ (klein, gedämpft).
- Der Herz-Knopf „♥ <Zahl>“ ist ein eigener Knopf, nicht in den Auswahlknopf verschachtelt. `aria-pressed` zeigt, ob ich das Logo mit einem Herz markiert habe.
- Ein Klick auf die Kachel setzt `config.logo`. Karte und Vorlagen-Miniaturen zeigen das Logo sofort.
- Ist das gewählte Logo nicht in der geladenen Liste (z. B. nach dem Öffnen einer gespeicherten Karte), steht es als zweite Kachel direkt nach „Kein Logo“.
- 24 Logos je Seite, darunter „Mehr laden“ (Cursor).
- **Infozeile zum gewählten Logo** unter dem Raster: „<Name> · von <Anzeigename> · ♥ <Zahl>“ und der Knopf „Melden“.
- Wer das Logo hochgeladen hat, sieht zusätzlich „Löschen“ (mit Rückfrage).
- Admins sehen „<n> Meldungen“ mit den Gründen sowie „Ausblenden“/„Einblenden“ und „Löschen“.
- **Leer:** „Noch keine Community-Logos – lade das erste hoch.“
- **Ladefehler:** „Die Logos konnten nicht geladen werden.“ mit „Nochmal versuchen“.
- **Herz ohne Anmeldung:** Ein Klick öffnet den Login-Dialog, danach wird das Herz gesetzt. Das Herz schaltet sofort um (optimistisch). Scheitert die Anfrage, springt es zurück, und die Infozeile zeigt die Fehlermeldung.
- **Handy (unter 900 px):** Das Raster nutzt die volle Breite (`auto-fill`, mindestens 140 px je Kachel).
## 4. Logo hochladen
Der Dialog „Logo hochladen“ (eigenes `<dialog>` wie die bisherigen Dialoge, alle Texte als Text, nie als HTML) enthält:
- **Datei:** PNG, JPG, WebP oder SVG, höchstens 5 MB und 40 Megapixel.
- **Vorschau:** das fertig verarbeitete Logo, einmal auf dunklem und einmal auf hellem Grund. Bei „Einfarbig“ ist es in Weiß bzw. Schwarz gefüllt.
- **Name:** Pflicht, höchstens 40 Zeichen.
- **Anzeigename:** optional, höchstens 30 Zeichen. Leer bedeutet „Anonym“, die E-Mail erscheint nie. Vorbelegt mit dem zuletzt benutzten Namen aus `localStorage` (`aircard.displayName`; Zugriffe in try/catch). Der Veröffentlichen-Dialog liest und schreibt denselben Schlüssel.
- **Häkchen „Einfarbig – passt sich Hell/Dunkel an“:** nur wählbar, wenn das Logo transparente Bereiche hat.
- **Pflicht-Häkchen:** „Ich habe die Rechte an diesem Logo oder die Erlaubnis des Inhabers, und es darf öffentlich geteilt werden.“
- **Knöpfe:** „Hochladen“ und „Abbrechen“.
**Verarbeitung im Browser** (der Server prüft trotzdem):
1. Datei dekodieren. SVG wird als Bild geladen, also ohne Skripte. Hat ein SVG keine eigene Größe, gilt 1000 × 1000.
2. Transparente Ränder abschneiden (Alpha ≤ 8 gilt als leer).
3. Auf höchstens 720 × 250 px verkleinern, nie vergrößern. SVG wird direkt in der Zielgröße gerastert.
4. Als PNG ausgeben.
5. Abbruch mit „Das Logo ist leer.“, wenn nichts Sichtbares bleibt, und mit „Das Logo ist zu klein (mindestens 8 × 8 Pixel).“ unter 8 × 8 px.
**Ablauf:**
1. Das PNG landet im Bucket `logo-images` mit den Rechten `read("any")` und `delete("user:<ich>")`.
2. `POST /api/logos/hochladen` wird aufgerufen.
3. Scheitert der Aufruf eindeutig, löscht der Client die Datei wieder (gleiche Regel wie beim Veröffentlichen). Bleibt der Ausgang offen, bleibt die Datei liegen.
4. Danach ist das Logo für alle sichtbar, wird gewählt (`config.logo`) und steht als erste Logo-Kachel.
Pro Konto sind höchstens **30 Logos** erlaubt, ausgeblendete zählen mit. Darüber hinaus erscheint: „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.“
## 5. Herzen
- Die Tabelle `logo_likes` hat eine Zeile je Konto und Logo (feste Zeilen-ID aus Logo und Nutzer). Der Zähler `likeCount` in `logos` wird nur von der API gesetzt, und zwar immer als Anzahl der Zeilen (neu gezählt, nicht hoch- oder runtergezählt).
- Der Client lädt den Herz-Zustand für die angezeigten Logos mit `equal('userId', me)` und `equal('logoId', [ids])`. Beim An- oder Abmelden wird er neu geladen bzw. geleert.
- Für ausgeblendete oder gelöschte Logos gibt es kein Herz (404 „Dieses Logo ist nicht (mehr) verfügbar.“).
## 6. Melden, Löschen, Admin
- **Melden** funktioniert wie bei Karten (gleicher Dialog, Text „dieses Logo“).
- Gründe für Logos: „Unangemessen“, „Urheberrecht/Marke“, „Spam“, „Sonstiges“.
- Ein Melder ist ein IP-Netz (IPv4 /24, IPv6 /48), gespeichert nur als Hash.
- Rate-Limit: 10 Meldungen pro Stunde je Anschluss, gemeinsam mit den Karten-Meldungen.
- Ab **3** verschiedenen Meldern wird das Logo ausgeblendet. Zeile und Datei sind dann nur noch für den Hochlader und Admins lesbar.
- **Löschen** (Hochlader oder Admin) blendet das Logo zuerst aus (Datei und Zeile), damit nach einem Abbruch keine öffentliche Zeile mit gelöschter Datei zurückbleibt, und entfernt dann Datei, Herzen, Meldungen und zuletzt die Zeile. Gespeicherte Karten zeigen das Logo danach nicht mehr (Hinweis siehe § 2). Bereits veröffentlichte Kartenbilder bleiben unverändert, weil das Logo im Bild steckt.
- **Admin** (Label `admin`):
- Filter „Gemeldet“ (`reportCount > 0`) und „Ausgeblendet“.
- Ein- und Ausblenden. Einblenden löscht die Meldungen und setzt `reportCount` auf 0.
- Löschen.
## 7. Sprache und Kopfzeile
**Kopfzeile, rechte Seite:** GitHub · Sprache · ☕ Unterstützen · Hell/Dunkel · Menü.
- **GitHub:** GitHub-Zeichen und der Text „GitHub“.
- Ziel ist `https://github.com/Mak5er/AirCard-iOS` mit `target="_blank" rel="noopener noreferrer"`. Der Title lautet „AirCard-iOS auf GitHub“ bzw. „AirCard-iOS on GitHub“.
- Zwischen 600 und 1179 px zeigt der Link nur das Zeichen (wie der Spenden-Knopf).
- Unter 600 px steht der Link im aufgeklappten Menü statt in der Kopfzeile.
- **Sprachknopf:** Globus-Zeichen plus das Kürzel der Zielsprache, also „EN“ auf der deutschen und „DE“ auf der englischen Seite.
- `aria-label` und `title` stehen in der Zielsprache („Switch to English“ bzw. „Auf Deutsch umschalten“), dazu ein passendes `lang` am Knopf.
- Der Knopf bleibt bei jeder Breite in der Kopfzeile.
**Startsprache:** zuerst `localStorage` `aircard.lang` (`de` oder `en`). Sonst Deutsch, wenn die erste bevorzugte Browsersprache (`navigator.languages[0]`, ersatzweise `navigator.language`) mit `de` beginnt, andernfalls Englisch. `<html lang>` folgt der Sprache und wird wie das Theme vor dem ersten Paint per Inline-Skript gesetzt.
**Umschalten** geht ohne Neuladen:
- Kopfzeile, Spenden-Banner, Konto-Menü, Fußzeile und die aktuelle Seite werden neu aufgebaut. Der Editor-Entwurf bleibt erhalten, weil er im Store liegt.
- `document.title` wird neu gesetzt.
- Der Appwrite-Client bekommt die Sprache (`client.setLocale`), damit Passwort-Mails in dieser Sprache kommen.
**Übersetzt** wird alles Sichtbare: Texte, `aria-label`, Platzhalter, `title`, Seitentitel, Fehlermeldungen (Client, Appwrite, API) und Datumsangaben (Deutsch `04.10.2026`, Englisch `Oct 4, 2026`).
**Nicht übersetzt** werden:
- Nutzerinhalte: Titel, Herausgeber, Tags, Logo-Namen, Anzeigenamen.
- Routen: Sie bleiben `/gestalten`, `/karten`, `/community/<id>`, `/passwort-neu`.
- Download-Dateinamen, README und Dokumentation.
- Die Werte der Melde-Gründe in der API.
**Technik (gettext-Stil):**
- `t('Deutscher Text', { name })` übersetzt beim Aufruf. Platzhalter stehen als `{name}` im Text.
- `msg('…')` markiert Texte, die vorab in Konstanten stehen. Sie gehen erst beim Anzeigen durch `t()`.
- Das Wörterbuch `web/src/i18n/en.ts` ordnet Deutsch → Englisch zu. Fehlt ein Eintrag, bleibt der deutsche Text.
- Ein Test sammelt alle Literale aus `t()`/`msg()` in `web/src/**` (ohne Tests). Er prüft, dass jedes eine englische Übersetzung hat, dass `en.ts` keine ungenutzten Einträge enthält und dass kein `t()` mit `${…}` aufgerufen wird.
- In Tests ist die Sprache Deutsch (Vitest-Setup).
- Englisch: kurz, freundlich, US-Schreibweise. „AirCard-IOS“ bleibt der Name der Seite, „AirCard-iOS“ der Name der App.
**Fehler der API:** Antworten tragen zusätzlich `code`, den Schlüssel aus `MSG`, z. B. `{"error":"…","code":"alreadyPublished"}`. Der Client übersetzt über den Code. Ohne bekannten Code zeigt er auf Deutsch den Servertext und auf Englisch eine allgemeine Meldung je Status (400 „Invalid request.“, 403 „You are not allowed to do that.“, 404 „Not found.“, sonst „Something went wrong. Please try again.“).
**Statisches `index.html`:** `<title>AirCard-IOS – Card designs for Apple Wallet</title>` und `<meta name="description" content="Discover community card designs for AirCard-iOS, create your own and download it as PNG.">`, damit Link-Vorschauen englisch sind. Der Router setzt danach den Titel in der gewählten Sprache.
**Fußzeile:**
- Deutsch: „Inoffizielle Community-Seite für die App AirCard-iOS – kein Teil des Original-Projekts und nicht mit Apple verbunden. Logos und Motive stammen von Nutzern; alle Marken gehören ihren Inhabern.“
- Englisch: „Unofficial community site for the AirCard-iOS app – not part of the original project and not affiliated with Apple. Logos and artwork are uploaded by users; all trademarks belong to their owners.“
## 8. Daten (Appwrite, Projekt `6ac2130a00289343d6d5`, Datenbank `aircard`, Setup-Skript erweitert)
**Tabelle `logos`** (rowSecurity an, **keine** Tabellenrechte für Clients):
| Spalte | Typ | Inhalt |
|---|---|---|
| `name` | string 40, Pflicht | Name des Logos |
| `displayName` | string 30 | Anzeigename oder leer |
| `fileId` | string 36, Pflicht | PNG im Bucket `logo-images` |
| `ownerId` | string 36, Pflicht | Nutzer-ID des Hochladers |
| `mono` | boolean, Standard false | einfarbig |
| `likeCount` | integer, Standard 0, min 0 | Anzahl Herzen |
| `hidden` | boolean, Standard false | ausgeblendet |
| `reportCount` | integer, Standard 0, min 0 | Anzahl verschiedener Melder |
Indizes: `owner` (key, `ownerId`), `hidden` (key, `hidden`), `likes` (key, `likeCount`).
Zeilen-ID = `hash32("<ownerId>|<fileId>")` (seit 2026-10-05; zuerst war sie die Datei-ID, die Altlogos behalten sie). Dieselbe Datei desselben Kontos ergibt dieselbe ID, ein zweiter Versuch also 409. Die ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten und lässt sich so nicht mit einer Datei gleicher ID neu belegen. Dass eine Datei nie zwei Logos trägt, sichert der Besitz: nach dem Anlegen hat sie kein `delete(owner)` mehr.
Zeilenrechte (setzt nur die API): sichtbar `['read("any")']`, ausgeblendet `['read("user:<owner>")','read("label:admin")']`.
**Tabelle `logo_likes`** (rowSecurity an, keine Tabellenrechte):
- Spalten: `logoId` string 36 Pflicht, `userId` string 36 Pflicht.
- Zeilen-ID = `hash32("<logoId>|<userId>")`.
- Zeilenrecht `['read("user:<userId>")']`.
- Indizes: `logo` (key, `logoId`), `user_logo` (key, `userId`, `logoId`).
**Tabelle `logo_reports`** (rowSecurity aus, Tabellenrecht `read("label:admin")`, Schreiben nur API):
- Spalten: `logoId` string 36 Pflicht, `reason` enum (unangemessen, kartendaten, urheberrecht, spam, sonstiges) Pflicht, `reporterHash` string 64 Pflicht.
- Zeilen-ID = `hash32("logo|<logoId>|<reporterHash>")`.
- Index `logo` (key, `logoId`).
**Bucket `logo-images`:**
- Bucket-Recht `create("users")`, fileSecurity an, höchstens 1 MiB, Endung nur `png`, ohne Kompression und ohne Virenscan.
- Dateirechte beim Hochladen: `read("any")`, `delete("user:<owner>")`.
- Nach dem Anlegen setzt die API genau `read("any")`. Der Hochlader verliert damit `delete`, sonst könnte er das Bild unter derselben ID tauschen.
- Ausgeblendet: `read("user:<owner>")`, `read("label:admin")`.
Bestehende Tabellen und Buckets bleiben unverändert.
## 9. API-Dienst `aircard-api` (Erweiterung)
Die neuen Endpunkte stehen in einem eigenen Modul `deploy/api/logos.js`. Reine gemeinsame Helfer (Meldetexte, Prüfungen, Rechte, Melder-Hash) wandern nach `deploy/api/common.js`. `app.js` reicht dem Logo-Modul seine laufenden Helfer (Anmeldung, Sperre je Schlüssel, Rate-Limit, Log) und hängt dessen Routen an. Der Betrieb kopiert künftig fünf Dateien (`appwrite.js`, `common.js`, `app.js`, `logos.js`, `server.js`). `appwrite.js` bekommt `countRows(table, queries)`, das `total` einer Abfrage mit `limit(1)` liefert.
| Endpunkt | Auth | Wirkung |
|---|---|---|
| `POST /api/logos/hochladen` `{name, displayName?, mono, fileId}` | Nutzer-JWT | Prüft Felder; die Datei existiert, liegt in `logo-images`, trägt `delete("user:<ich>")` und `read("any")`, ist PNG, höchstens 1 MiB, Maße zwischen 8 × 8 und 720 × 250 (aus dem PNG-Kopf); höchstens 30 Logos je Konto. Legt die Zeile an (sichtbar), setzt die Datei auf `read("any")` und nimmt die Zeile zurück, wenn das scheitert. → `201 {id}`; 409 bei schon verwendeter Datei; 429 bei Limit. |
| `POST /api/logos/herz` `{id, herz}` | Nutzer-JWT | 404 bei fehlendem oder ausgeblendetem Logo. `herz: true` legt die Like-Zeile an (409 = schon da, kein Fehler), `false` löscht sie (404 = schon weg, kein Fehler). Danach `likeCount = countRows`. → `200 {herz, likeCount}` |
| `POST /api/logos/melden` `{id, reason}` | keine | Wie `/api/melden`: Rate-Limit (gemeinsam), Melder-Hash, Duplikat → `200 {schonGemeldet:true}`, `reportCount` = Zeilen, ab 3 ausblenden. 404 bei fehlendem oder ausgeblendetem Logo. |
| `POST /api/logos/loeschen` `{id}` | Nutzer-JWT (Hochlader **oder** Admin) | Blendet das Logo zuerst aus, löscht dann Datei, Herzen, Meldungen und zuletzt die Zeile. 403 bei fremdem Logo, 404 bei fehlendem. → `200 {ok:true}` |
| `POST /api/admin/logo-sichtbarkeit` `{id, sichtbar}` | Admin-JWT | Wie `/api/admin/sichtbarkeit` für Logos (Datei- und Zeilenrechte, `hidden`; Einblenden löscht Meldungen, `reportCount = 0`). |
Alle Fehlerantworten (auch die bestehenden) haben die Form `{error: <deutscher Text>, code: <MSG-Schlüssel>}`. Dynamische Prüftexte ohne festen Schlüssel bekommen `code: 'badRequest'`.
Body höchstens 8 KB, nur `application/json`, sonst unverändert wie in der Community-Spec § 8.
## 10. Was Kenso tun muss
Nichts Pflicht. Der vorhandene Provisioning-Key (`AIRCARD_APPWRITE_API_KEY`) legt das Schema an. Der API-Key `aircard-api` hat die nötigen Scopes (Zeilen und Dateien). Fürs Testen der Admin-Knöpfe braucht Kensos Konto weiterhin das Label `admin` (steht noch aus der Community-Runde offen).
## 11. Fehlerbehandlung
| Fall | Meldung |
|---|---|
| Datei falscher Typ / zu groß / zu viele Pixel | „Bitte ein PNG-, JPG-, WebP- oder SVG-Bild wählen.“ / „Die Datei ist größer als 5 MB.“ / „Das Bild ist zu groß (max. 40 Megapixel).“ |
| Datei nicht lesbar | „Das Logo konnte nicht gelesen werden.“ |
| Logo leer / zu klein | siehe § 4 |
| Name fehlt / zu lang | „Bitte gib einen Namen an.“ / „Der Name ist zu lang (höchstens 40 Zeichen).“ |
| Häkchen fehlt | „Bitte bestätige, dass du das Logo teilen darfst.“ |
| Limit 30 | siehe § 4 |
| Hochladen gescheitert | „Hochladen fehlgeschlagen – <Grund>“, Datei wird bei eindeutigem Fehler gelöscht |
| Herz/Melden für weg/ausgeblendet | „Dieses Logo ist nicht (mehr) verfügbar.“ |
| Melden doppelt / Rate-Limit | „Danke, du hast dieses Logo schon gemeldet.“ / „Zu viele Meldungen – bitte später nochmal.“ |
| API nicht erreichbar | „Der Dienst ist gerade nicht erreichbar.“ |
| Nicht angemeldet (Hochladen, Herz) | Login-Dialog (`withLogin`/`requireLogin`), Abbruch → nichts passiert |
Alle Texte gibt es auf Deutsch und Englisch.
## 12. Tests
- **Vitest:**
- i18n: Startsprache, Umschalten, Platzhalter, Abdeckung aller `t()`/`msg()`-Texte, Datum.
- Konfiguration: alte Konfiguration mit `network`/`apple`, `logo`.
- Renderer: Logo-Box, einfarbig, ohne Logo.
- Logo-Verarbeitung: Rand-Zuschnitt, Skalierung, leer, zu klein, Transparenz-Erkennung.
- Logo-Abfragen: Beliebt/Neu/Admin-Filter, Cursor, eigene Herzen.
- Hochladen-Ablauf mit Fakes: Aufräumen nur bei eindeutigem Fehler.
- Logo-Bereich: Kacheln, Auswahl, Herz optimistisch und Rücksprung, Login-Pflicht, angeheftete Auswahl, Infozeile je Rolle.
- Kopfzeile: GitHub-Link, Sprachknopf.
- Bestehende Tests bleiben grün (Sprache im Test = Deutsch).
- **node:test (`aircard-api`):**
- Hochladen: fremde Datei 403, falsches Format/Maße 400, Limit 429, doppelt 409, Rechte nachher genau `read("any")`, Rücknahme bei Fehler.
- Herz: an/aus idempotent, Zähler = Zeilen, ausgeblendet 404.
- Melden: Dedup, gemeinsames Rate-Limit, ab 3 ausgeblendet.
- Löschen: Hochlader/Admin/fremd.
- Admin-Sichtbarkeit.
- `code` in allen Fehlerantworten.
- **Setup-Skript:** `--check` deckt `logos`, `logo_likes`, `logo_reports` und `logo-images` inkl. Indizes ab.
- **Live (aircard-ios.org):**
- Hochladen (PNG transparent, einfarbig; SVG; JPG ohne Transparenz). Das Logo ist für Gäste sichtbar und sitzt auf der Karte; Download-PNG enthält es.
- Herz an/aus (Zahl, Sortierung).
- Melden über 3 Netze → ausgeblendet; Gast sieht es nicht mehr.
- Löschen.
- Sprachwechsel mit offenem Editor-Entwurf (bleibt erhalten).
- Englisch nach Browsersprache; GitHub-Link.
- Breiten 1440 und 390 px, Hell/Dunkel.
## 13. Nicht im Umfang
Logo-Suche, Community-Logos oben links, Herzen für Community-Karten, Zähler „wie oft benutzt“, Logos bearbeiten (nur löschen und neu hochladen), englische Routen, weitere Sprachen, Mail an Kenso bei Meldungen, SVG-Speicherung (gespeichert wird immer PNG).
## 14. Risiken
- **Marken über Nutzer:** Nutzer können Visa & Co. selbst hochladen. Dagegen stehen das Pflicht-Häkchen, der Melde-Grund „Urheberrecht/Marke“, das Auto-Ausblenden und der Admin. Kenso haftet ab Kenntnis, sollte also gemeldete Logos regelmäßig ansehen. Ohne Kontaktadresse/Impressum fehlt ein Beschwerdeweg außerhalb von „Melden“ (Kenso-Entscheidung aus der Website-Runde, bekannt).
- **Herzen:** Konten sind kostenlos, viele Konten ergeben viele Herzen (akzeptiert). Auth-Limits in der Console bleiben Kensos offener Schritt.
- **Zählung:** `countRows` nutzt `total` von Appwrite (zählt bis 5000). Das reicht.
- **Übersetzungslücken:** Der Abdeckungstest fängt fehlende Einträge. Server-Prüftexte ohne Code erscheinen auf Englisch als allgemeine Meldung.
@@ -0,0 +1,109 @@
# AirCard-IOS – Benutzernamen – Design
Stand: 2026-10-05 · Status: Entwurf im Chat von Kenso freigegeben („ja passt, mach weiter“), Spec + Plan + Umsetzung ohne weitere Rückfrage · baut auf `2026-10-04-aircard-logos-sprache-design.md` auf (live, main 13b46da)
## 1. Ziel und Rahmen
Jedes Konto bekommt einen eindeutigen Benutzernamen. Logos und Community-Karten zeigen „von @name“ statt „Anonym“, damit man Leute wiedererkennt.
| Punkt | Entscheidung (Kenso) |
|---|---|
| Eindeutig | Ja, jeder Name nur einmal; Groß-/Kleinschreibung zählt beim Vergleich nicht. |
| Pflicht | Ja, auch für bestehende Konten. Registrieren nur mit Name; alte Konten werden nach dem Anmelden einmal gefragt; Hochladen und Veröffentlichen gehen erst mit Name. |
| Anzeige | Immer der Benutzername; das Feld „Anzeigename“ fällt weg; gilt auch für vorhandene Einträge des Kontos. |
| Ändern | Ja, im Konto-Menü; danach überall der neue Name, der alte wird frei. |
## 2. Regeln für den Namen
- Muster: `^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$`. Das sind 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`.
- Schlüssel für die Eindeutigkeit: der Name in Kleinbuchstaben (`usernameKey`). Gespeichert und angezeigt wird der Name, wie er eingegeben wurde.
- Gesperrte Schlüssel: `admin`, `administrator`, `root`, `system`, `support`, `moderator`, `mod`, `aircard`, `aircardios`, `aircard-ios`, `aircard.ios`, `aircard_ios`, `webklar`, `anonym`, `anonymous`, `null`, `undefined`, `team`, `staff`, `official`, `offiziell`, `hilfe`, `help`, `kontakt`, `contact`, `info`.
- Anzeige mit `@` davor: „von @name“ bzw. „by @name“.
## 3. Daten (Appwrite, Datenbank `aircard`, Setup-Skript erweitert)
**Tabelle `profiles`** (rowSecurity an, keine Tabellenrechte für Clients):
| Spalte | Typ | Inhalt |
|---|---|---|
| `username` | string 20, Pflicht | Name wie eingegeben |
| `usernameKey` | string 20, Pflicht | Name klein geschrieben |
- Zeilen-ID = Nutzer-ID (eine Zeile pro Konto).
- Index `username_key`: **unique** auf `usernameKey`. Damit kann auch bei Gleichzeitigkeit kein Name doppelt vergeben werden.
- Zeilenrecht genau `['read("any")']`, öffentlich lesbar. Schreiben nur über die API.
## 4. API-Dienst `aircard-api`
Eigenes Modul `deploy/api/profiles.js`, eingebunden wie `logos.js`. Der Betrieb kopiert künftig sechs Dateien: `appwrite.js`, `common.js`, `app.js`, `logos.js`, `profiles.js`, `server.js`.
`POST /api/profil` `{username}`, Nutzer-JWT:
- 401 ohne Anmeldung.
- 400 `usernameInvalid` bei falschem Muster.
- 400 `usernameReserved` bei gesperrtem Schlüssel.
- 409 `usernameTaken`, wenn ein anderes Konto den Schlüssel hat. Das wird vorab per Abfrage geprüft; bei einem Rennen meldet Appwrite 409 auf dem Unique-Index, das wird ebenfalls zu `usernameTaken`.
- Sonst: Hat das Konto schon eine Zeile, wird sie aktualisiert (der alte Name ist damit frei); sonst wird sie mit ID = Nutzer-ID und `read("any")` angelegt. Antwort `200 {username}`.
- Groß-/Kleinschreibung des eigenen Namens ändern („kenso“ → „Kenso“) ist erlaubt.
- Sperre je Konto (`profile:<userId>`).
**Pflicht serverseitig:** `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten **403 `usernameRequired`**, wenn das Konto keine Zeile in `profiles` hat. Die Prüfung läuft nach der Anmeldung und vor allen anderen Prüfungen. Bestehende Logos und Karten bleiben unberührt.
Neue `MSG`-Texte:
- `usernameInvalid`: „Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.“
- `usernameReserved`: „Dieser Benutzername ist nicht erlaubt.“
- `usernameTaken`: „Dieser Benutzername ist schon vergeben.“
- `usernameRequired`: „Bitte wähle zuerst einen Benutzernamen.“
## 5. Oberfläche
- **Registrieren** (Login-Dialog, Reiter „Registrieren“): neues Pflichtfeld „Benutzername“ vor der E-Mail, `autocomplete="nickname"` (angemeldet wird per E-Mail; so speichern Passwortmanager weiter die E-Mail), Hinweis „3–20 Zeichen: Buchstaben, Zahlen, . _ -“.
- Vor dem Anlegen des Kontos werden Muster, Sperrliste und Verfügbarkeit geprüft (Abfrage auf `profiles`). Ein Fehler steht am Feld.
- Danach: Konto anlegen → anmelden → `POST /api/profil`.
- Scheitert dieser letzte Schritt (Rennen, Netz), ist man trotzdem angemeldet, und der Dialog „Benutzername wählen“ erscheint.
- **Dialog „Benutzername wählen“** mit Feld, „Speichern“ und „Später“ bzw. „Abbrechen“:
- Nach dem Anmelden oder beim Seitenstart mit Sitzung, wenn das Konto keinen Namen hat. Einmal pro Sitzung (Merker in `sessionStorage` `aircard.usernamePrompted`, Zugriffe in try/catch), mit „Später“.
- Vor dem Hochladen eines Logos und vor dem Veröffentlichen, wenn kein Name da ist. Hier gibt es „Abbrechen“ statt „Später“; ohne Namen geht es nicht weiter.
- Antwortet der Server mit `usernameRequired`, erscheint der Dialog ebenfalls.
- **Konto-Menü**: Statt der E-Mail steht „@name“ oben (die E-Mail darunter klein). Neuer Eintrag „Benutzername ändern“, der den Dialog vorbelegt öffnet. Ohne Namen heißt der Eintrag „Benutzername wählen“.
- **Anzeige „von @name“**: Logo-Kacheln, Logo-Infozeile, Detailseite einer Community-Karte.
- Der Name wird über `ownerId` in `profiles` nachgeschlagen, gesammelt je Seite (`equal('$id', ids)`) und zwischengespeichert.
- Ohne Profil: der früher gespeicherte `displayName` (ohne `@`), sonst „Anonym“.
- Nach einer eigenen Namensänderung aktualisieren Kacheln, Infozeile und Detailseite die eigenen Einträge sofort.
- **Weg fällt** das Feld „Anzeigename“ in den Dialogen „Logo hochladen“ und „Veröffentlichen“ samt `account/display-name.ts` und dem `localStorage`-Schlüssel `aircard.displayName`. Der Client schickt `displayName: ''`; die API nimmt das Feld weiter an (Altbestand).
Alle Texte gibt es auf Deutsch und Englisch.
## 6. Fehlerbehandlung
| Fall | Meldung |
|---|---|
| Muster falsch / gesperrt / vergeben | die drei `MSG`-Texte aus § 4, am Feld |
| Server meldet `usernameRequired` | Dialog „Benutzername wählen“ |
| Netz/API weg beim Speichern | „Der Dienst ist gerade nicht erreichbar.“, Dialog bleibt offen |
| Profil lässt sich nicht lesen (Anzeige) | Fallback `displayName` bzw. „Anonym“, kein Fehlertext |
## 7. Tests
- **node:test:**
- `/api/profil`: anlegen, ändern, Groß/klein am eigenen Namen, vergeben 409, gesperrt 400, Muster 400, ohne JWT 401.
- Rennen mit zwei Konten: genau eines bekommt den Namen.
- Zeilenrechte genau `read("any")`.
- Hochladen und Veröffentlichen ohne Profil → 403 `usernameRequired`.
- Setup-Skript deckt `profiles` mit Unique-Index ab.
- **Vitest:**
- Prüfung des Namens (Muster, Sperrliste, Schlüssel).
- Registrieren mit Vorabprüfung und Fehlern am Feld.
- Dialog: Später, Abbrechen, Speichern, Fehler.
- Einmal-pro-Sitzung-Abfrage.
- Konto-Menü mit „@name“ und Ändern.
- Anzeige „von @name“ mit Fallbacks; sofortige Aktualisierung nach Umbenennen.
- Dialoge ohne „Anzeigename“.
- Hochladen und Veröffentlichen fragen vorher nach dem Namen.
- Gleichlauf der API-Codes, i18n-Abdeckung.
- **Live:** Gast sieht „von …“-Zeilen. Kenso registriert bzw. wird gefragt, ändert den Namen und sieht ihn an seinem Logo.
## 8. Nicht im Umfang
Profilseiten, Suche nach Benutzernamen, Moderation von Namen, Konto löschen, Avatar-Bilder, Benutzername als Anmeldename (angemeldet wird weiter per E-Mail).
+38 -12
View File
@@ -1,27 +1,53 @@
<!doctype html>
<html lang="de">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AirCard-IOS – Kartenmotiv gestalten</title>
<title>AirCard-IOS – Card designs for Apple Wallet</title>
<meta name="description" content="Explore community card designs for AirCard-iOS, create your own and download it as PNG." />
<meta name="theme-color" content="#0F1828" />
<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />
<script>
// Hell/Dunkel und Sprache (<html lang>) vor dem ersten Paint setzen (kein Aufblitzen); dieselben Regeln wie
// initialTheme() in src/shell/theme.ts und initialLang() in src/i18n/i18n.ts.
// In einer sofort aufgerufenen Funktion, damit keine globalen Variablen `theme` und `lang` entstehen.
(function () {
try {
var theme = null;
try {
theme = localStorage.getItem('aircard.theme');
} catch (e) {}
if (theme !== 'light' && theme !== 'dark') {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {}
try {
var lang = null;
try {
lang = localStorage.getItem('aircard.lang');
} catch (e) {}
if (lang !== 'de' && lang !== 'en') {
var browser = window.navigator;
var first = (browser.languages && browser.languages[0]) || browser.language || '';
lang = /^de/i.test(first) ? 'de' : 'en';
}
document.documentElement.setAttribute('lang', lang);
} catch (e) {}
})();
</script>
</head>
<body>
<div id="donate-banner"></div>
<header id="app-header">
<a class="brand" href="/" data-link><img src="/favicon.svg" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>
<nav class="app-nav" aria-label="Hauptnavigation">
<a href="/karten" data-link>Meine Karten</a>
</nav>
<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>
<div id="account-slot"></div>
</header>
<main id="app-root"></main>
<footer id="app-footer">
<p>Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.</p>
</footer>
<footer id="app-footer"></footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit -p tsconfig.build.json && vite build",
"test": "vitest run",
"preview": "vite preview"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

+280 -68
View File
@@ -2,8 +2,10 @@
/**
* AirCard – Appwrite-Schema-Setup (idempotent)
*
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle
* `cards` (TablesDB) mit vier Spalten und den Bucket `card-images` an.
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
* die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`,
* `logo_reports` und `profiles` (TablesDB, mit Spalten und Indizes) und die
* Buckets `card-images`, `gallery-images` und `logo-images` an.
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
*
* Aufruf (vom Repo-Wurzelverzeichnis):
@@ -13,8 +15,9 @@
* AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht)
* AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1
*
* Ohne --check: fehlende Objekte werden angelegt. Bestehende Objekte werden
* nur verglichen – Abweichungen werden gemeldet, nie geändert.
* Ohne --check: fehlende Objekte werden angelegt (Indizes erst, wenn alle Spalten
* der Tabelle `available` sind). Bestehende Objekte werden nur verglichen –
* Abweichungen werden gemeldet, nie geändert.
* Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht.
*
* Ausgabe: eine Zeile je Objekt –
@@ -34,30 +37,150 @@ export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1';
// Soll-Zustand
// ---------------------------------------------------------------------------
const str = (key, size, required) => ({ key, type: 'string', size, required });
export const DESIRED = {
database: { id: 'aircard', name: 'AirCard' },
table: {
id: 'cards',
name: 'Cards',
permissions: ['create("users")'],
rowSecurity: true,
columns: [
{ key: 'name', size: 80, required: true },
{ key: 'config', size: 2000, required: true },
{ key: 'imageFileId', size: 36, required: true },
{ key: 'previewFileId', size: 36, required: true },
],
},
bucket: {
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none',
antivirus: false,
},
// Reihenfolge ist Ablauf: cards, gallery, reports, logos, logo_likes, logo_reports, profiles (je Tabelle: Spalten, dann Indizes).
tables: [
{
id: 'cards',
name: 'Cards',
permissions: ['create("users")'],
rowSecurity: true,
columns: [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)],
indexes: [],
},
{
// Zeilenrechte setzt nur die API (aircard-api), Clients dürfen nichts auf Tabellenebene.
id: 'gallery',
name: 'Gallery',
permissions: [],
rowSecurity: true,
columns: [
str('title', 60, true),
str('issuer', 40, false),
str('tags', 120, false),
str('displayName', 30, false),
str('imageFileId', 36, true),
str('previewFileId', 36, true),
str('sourceCardId', 36, false),
str('ownerId', 36, true),
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
str('searchText', 300, false),
],
indexes: [
{ key: 'search_text', type: 'fulltext', columns: ['searchText'] },
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
],
},
{
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
id: 'reports',
name: 'Reports',
permissions: ['read("label:admin")'],
rowSecurity: false,
columns: [
str('galleryId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
],
indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }],
},
{
// Community-Logos. Wie `gallery`: Zeilenrechte setzt nur die API, Clients dürfen nichts auf Tabellenebene.
id: 'logos',
name: 'Logos',
permissions: [],
rowSecurity: true,
columns: [
str('name', 40, true),
str('displayName', 30, false),
str('fileId', 36, true),
str('ownerId', 36, true),
{ key: 'mono', type: 'boolean', required: false, default: false },
{ key: 'likeCount', type: 'integer', required: false, default: 0, min: 0 },
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
],
indexes: [
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
{ key: 'likes', type: 'key', columns: ['likeCount'] },
],
},
{
// Ein Herz je Konto und Logo: Zeilen-ID hash32("<logoId>|<userId>"), lesen darf nur das Konto selbst; geschrieben wird nur über die API.
id: 'logo_likes',
name: 'Logo Likes',
permissions: [],
rowSecurity: true,
columns: [str('logoId', 36, true), str('userId', 36, true)],
indexes: [
{ key: 'logo', type: 'key', columns: ['logoId'] },
// Mehrspalten-Index: die Reihenfolge der Spalten zählt (und wird verglichen).
{ key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] },
],
},
{
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
id: 'logo_reports',
name: 'Logo Reports',
permissions: ['read("label:admin")'],
rowSecurity: false,
columns: [
str('logoId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
],
indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }],
},
{
// Benutzernamen: eine Zeile je Konto, Zeilen-ID = Konto-ID, öffentlich lesbar (Zeilenrecht read("any") setzt die API); geschrieben wird nur
// über die API. `usernameKey` ist der Name klein geschrieben: der Unique-Index macht ihn auch bei gleichzeitigen Anfragen nur einmal vergebbar.
id: 'profiles',
name: 'Profiles',
permissions: [],
rowSecurity: true,
columns: [str('username', 20, true), str('usernameKey', 20, true)],
indexes: [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
},
],
buckets: [
{
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
compression: 'none',
antivirus: false,
},
{
id: 'gallery-images',
name: 'Gallery Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'webp'],
compression: 'none',
antivirus: false,
},
{
// Logo-PNG (höchstens 1 MiB); die Dateirechte setzt die API je Datei.
id: 'logo-images',
name: 'Logo Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 1048576,
allowedFileExtensions: ['png'],
compression: 'none',
antivirus: false,
},
],
};
// ---------------------------------------------------------------------------
@@ -94,11 +217,35 @@ export function compareTable(actual, desired) {
return out;
}
/** Vergleich einer Spalte: `size`, `required`, `status` (erwartet `available`). */
/**
* Vergleich einer Spalte, je nach Typ:
* string → type, size, required
* boolean → type, required, default
* integer → type, required, default (min/max der Antwort sind riesige Standardwerte, werden nicht verglichen)
* enum → elements (sortiert), required (Appwrite meldet Enum-Spalten als type `string` + format `enum`)
* Außerdem immer `status` (erwartet `available`).
*/
export function compareColumn(actual, desired) {
const out = [];
if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
if (desired.type !== 'enum' && actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
if (desired.type === 'string' && actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
if (desired.type === 'enum' && !sameList(actual.elements, desired.elements)) {
out.push(mismatch('elements', sorted(actual.elements), sorted(desired.elements)));
}
if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required));
if ((desired.type === 'boolean' || desired.type === 'integer') && actual.default !== desired.default) {
out.push(mismatch('default', actual.default, desired.default));
}
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
return out;
}
/** Vergleich eines Index: `type`, `columns` (ältere Antworten: `attributes`; Reihenfolge zählt), `status`. */
export function compareIndex(actual, desired) {
const out = [];
const columns = Array.isArray(actual.columns) ? actual.columns : (actual.attributes ?? []);
if (actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
if (JSON.stringify(columns) !== JSON.stringify(desired.columns)) out.push(mismatch('columns', columns, desired.columns));
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
return out;
}
@@ -197,7 +344,9 @@ async function createObject(ctx, path, body, what) {
// ---------------------------------------------------------------------------
const DB_ID = DESIRED.database.id;
const columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${key}`;
const tablePath = (tableId) => `/tablesdb/${DB_ID}/tables/${tableId}`;
const columnPath = (tableId, key) => `${tablePath(tableId)}/columns/${key}`;
const indexPath = (tableId, key) => `${tablePath(tableId)}/indexes/${key}`;
/** Gibt zurück, ob die Datenbank (jetzt) existiert. */
export async function ensureDatabase(ctx) {
@@ -217,13 +366,12 @@ export async function ensureDatabase(ctx) {
}
/** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */
export async function ensureTable(ctx, parentExists) {
const t = DESIRED.table;
const label = `Tabelle ${t.id}`;
const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null;
export async function ensureTable(ctx, table, parentExists) {
const label = `Tabelle ${table.id}`;
const actual = parentExists ? await getObject(ctx, tablePath(table.id), label) : null;
if (actual) {
const deviations = compareTable(actual, t);
if (deviations.length) ctx.deviations(t.id, deviations);
const deviations = compareTable(actual, table);
if (deviations.length) ctx.deviations(table.id, deviations);
else ctx.ok(label);
return true;
}
@@ -234,31 +382,39 @@ export async function ensureTable(ctx, parentExists) {
await createObject(
ctx,
`/tablesdb/${DB_ID}/tables`,
{ tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity },
{ tableId: table.id, name: table.name, permissions: table.permissions, rowSecurity: table.rowSecurity },
label,
);
ctx.created(label);
return true;
}
/** Spalten und Indizes entstehen asynchron (`processing`) und werden über ihren eigenen Pfad abgefragt. */
const CHILD_KINDS = {
column: { noun: 'Spalte', plural: 'Spalte(n)', path: columnPath },
index: { noun: 'Index', plural: 'Index(e)', path: indexPath },
};
/**
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind.
* Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück.
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten (`kind` = 'column')
* bzw. Indizes (`kind` = 'index') der Tabelle `available` sind.
* Gibt eine Map Schlüssel → aktueller Zustand zurück.
*/
export async function waitAvailable(ctx, tableId, keys) {
export async function waitAvailable(ctx, tableId, keys, kind = 'column') {
const { noun, plural, path } = CHILD_KINDS[kind];
const deadline = Date.now() + ctx.timeoutMs;
const done = new Map();
let pending = [...keys];
for (;;) {
const still = [];
for (const key of pending) {
const label = `Spalte ${tableId}.${key}`;
const actual = await getObject(ctx, columnPath(key), label);
const label = `${noun} ${tableId}.${key}`;
const actual = await getObject(ctx, path(tableId, key), label);
if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`);
if (actual.status === 'available') {
done.set(key, actual);
} else if (actual.status === 'failed' || actual.status === 'stuck') {
throw new SetupError(`${label}: Status „${actual.status}“. Spalte in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
throw new SetupError(`${label}: Status „${actual.status}“. ${noun} in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
} else {
still.push(key);
}
@@ -266,7 +422,7 @@ export async function waitAvailable(ctx, tableId, keys) {
if (!still.length) return done;
if (Date.now() >= deadline) {
throw new SetupError(
`Spalte(n) ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
`${plural} ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
);
}
pending = still;
@@ -274,49 +430,102 @@ export async function waitAvailable(ctx, tableId, keys) {
}
}
/** REST-Pfad und Body zum Anlegen einer Spalte (je Typ eine eigene Route). */
function columnRequest(table, col) {
const path = `${tablePath(table.id)}/columns/${col.type}`;
switch (col.type) {
case 'string':
return { path, body: { key: col.key, size: col.size, required: col.required } };
case 'boolean':
return { path, body: { key: col.key, required: col.required, default: col.default } };
case 'integer':
return { path, body: { key: col.key, required: col.required, default: col.default, min: col.min } };
case 'enum':
return { path, body: { key: col.key, elements: col.elements, required: col.required } };
default:
throw new SetupError(`Unbekannter Spaltentyp „${col.type}“ für ${table.id}.${col.key}.`);
}
}
/**
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
* sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden.
* Gemeinsamer Ablauf für Spalten und Indizes einer Tabelle: vorhandene lesen,
* fehlende nacheinander anlegen, danach auf `available` warten (nur ohne
* --check) und alles mit dem Soll vergleichen. Gibt die Einträge zurück
* (`actual` ist dort gesetzt, wo das Objekt existiert).
* `canCreate: false` → fehlende Objekte werden nur gemeldet, auch ohne --check.
*/
export async function ensureColumns(ctx, parentExists) {
const t = DESIRED.table;
async function ensureChildren(ctx, table, parentExists, { kind, items, request, compare, canCreate = true }) {
const { noun, path } = CHILD_KINDS[kind];
const entries = [];
for (const col of t.columns) {
const label = `Spalte ${t.id}.${col.key}`;
const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null;
for (const item of items) {
const label = `${noun} ${table.id}.${item.key}`;
const actual = parentExists ? await getObject(ctx, path(table.id, item.key), label) : null;
if (actual) {
entries.push({ col, label, actual, created: false });
} else if (ctx.check) {
entries.push({ item, label, actual, created: false });
} else if (ctx.check || !canCreate) {
ctx.missing(label);
} else {
await createObject(
ctx,
`/tablesdb/${DB_ID}/tables/${t.id}/columns/string`,
{ key: col.key, size: col.size, required: col.required },
label,
);
const { path: createPath, body } = request(item);
await createObject(ctx, createPath, body, label);
ctx.created(label);
entries.push({ col, label, actual: null, created: true });
entries.push({ item, label, actual: null, created: true });
}
}
if (!ctx.check) {
const waiting = entries.filter((e) => e.created || e.actual.status === 'processing');
if (waiting.length) {
const ready = await waitAvailable(ctx, t.id, waiting.map((e) => e.col.key));
for (const e of waiting) e.actual = ready.get(e.col.key);
const ready = await waitAvailable(ctx, table.id, waiting.map((e) => e.item.key), kind);
for (const e of waiting) e.actual = ready.get(e.item.key);
}
}
for (const e of entries) {
const deviations = compareColumn(e.actual, e.col);
if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations);
const deviations = compare(e.actual, e.item);
if (deviations.length) ctx.deviations(`${table.id}.${e.item.key}`, deviations);
else if (!e.created) ctx.ok(e.label);
}
return entries;
}
export async function ensureBucket(ctx) {
const b = DESIRED.bucket;
/**
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
* sie bereit, bevor jemand schreibt (und bevor Indizes angelegt werden).
* `parentExists`: Tabelle vorhanden. Gibt zurück, ob alle Spalten vorhanden
* und `available` sind (Voraussetzung für die Indizes).
*/
export async function ensureColumns(ctx, table, parentExists) {
const entries = await ensureChildren(ctx, table, parentExists, {
kind: 'column',
items: table.columns,
request: (col) => columnRequest(table, col),
compare: compareColumn,
});
return entries.length === table.columns.length && entries.every((e) => e.actual.status === 'available');
}
/**
* Indizes der Tabelle sicherstellen. Angelegt wird erst, wenn alle Spalten
* `available` sind (`columnsReady`), sonst wird der Index nur als fehlend
* gemeldet (die Spalte steht dann schon als ABWEICHUNG/FEHLT in der Ausgabe).
*/
export async function ensureIndexes(ctx, table, parentExists, columnsReady = true) {
const indexes = table.indexes ?? [];
if (!indexes.length) return;
await ensureChildren(ctx, table, parentExists, {
kind: 'index',
items: indexes,
request: (idx) => ({
path: `${tablePath(table.id)}/indexes`,
body: { key: idx.key, type: idx.type, columns: idx.columns },
}),
compare: compareIndex,
canCreate: columnsReady,
});
}
export async function ensureBucket(ctx, bucket) {
const b = bucket;
const label = `Bucket ${b.id}`;
const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label);
if (actual) {
@@ -391,9 +600,12 @@ export async function main({
log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`);
try {
const databaseExists = await ensureDatabase(ctx);
const tableExists = await ensureTable(ctx, databaseExists);
await ensureColumns(ctx, tableExists);
await ensureBucket(ctx);
for (const table of DESIRED.tables) {
const tableExists = await ensureTable(ctx, table, databaseExists);
const columnsReady = await ensureColumns(ctx, table, tableExists);
await ensureIndexes(ctx, table, tableExists, columnsReady);
}
for (const bucket of DESIRED.buckets) await ensureBucket(ctx, bucket);
} catch (err) {
if (!(err instanceof SetupError)) throw err;
log(`ABBRUCH: ${err.message}`);
File diff suppressed because it is too large. Load diff
+358
View File
@@ -0,0 +1,358 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
type TestUser = { $id: string; email: string; labels?: string[] };
const mocks = vi.hoisted(() => ({
user: null as TestUser | null,
listeners: new Set<(user: TestUser | null) => void>(),
logout: vi.fn(),
requireLogin: vi.fn(),
/** Der Benutzername des angemeldeten Kontos laut Zwischenspeicher (null: keiner oder unbekannt). */
name: null as string | null,
nameListeners: new Set<(userId: string, username: string) => void>(),
usernameDialog: vi.fn(),
}));
vi.mock('./auth', () => ({
currentUser: () => mocks.user,
logout: mocks.logout,
onAuthChange: (fn: (user: TestUser | null) => void) => {
mocks.listeners.add(fn);
return () => mocks.listeners.delete(fn);
},
}));
vi.mock('./login-dialog', () => ({ requireLogin: mocks.requireLogin }));
vi.mock('./profile', () => ({
myUsername: () => mocks.name,
onUsernameChange: (fn: (userId: string, username: string) => void) => {
mocks.nameListeners.add(fn);
return () => mocks.nameListeners.delete(fn);
},
}));
vi.mock('./username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
import { mountAccountMenu, type AccountMenu } from './account-menu';
let slot: HTMLElement;
let menu: AccountMenu;
const avatar = () => slot.querySelector<HTMLButtonElement>('button.account-avatar');
const panel = () => slot.querySelector<HTMLElement>('.account-dropdown');
const buttonByText = (text: string) => [...slot.querySelectorAll('button')].find((b) => b.textContent === text);
function setUser(user: TestUser | null) {
mocks.user = user;
for (const fn of [...mocks.listeners]) fn(user);
}
/** Der Name des Kontos ändert sich (eigene Änderung oder nach dem Lesen), wie `onUsernameChange` es meldet. */
function setName(userId: string, name: string) {
mocks.name = name;
for (const fn of [...mocks.nameListeners]) fn(userId, name);
}
beforeEach(() => {
mocks.user = null;
mocks.name = null;
mocks.listeners.clear();
mocks.nameListeners.clear();
mocks.logout.mockReset();
mocks.requireLogin.mockReset();
mocks.usernameDialog.mockReset();
mocks.usernameDialog.mockResolvedValue(null);
document.body.innerHTML = '<div id="account-slot"></div>';
slot = document.getElementById('account-slot') as HTMLElement;
menu = mountAccountMenu(slot);
});
describe('Konto-Menü', () => {
it('zeigt vor ready() nichts (kein Aufblitzen von „Anmelden“)', () => {
expect(slot.childElementCount).toBe(0);
});
it('abgemeldet: „Anmelden“ öffnet den Login-Dialog', () => {
menu.ready();
buttonByText('Anmelden')!.click();
expect(mocks.requireLogin).toHaveBeenCalledWith('login');
expect(avatar()).toBeNull();
});
it('angemeldet: Avatar-Kreis mit Initiale, Menü zunächst zu', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
expect(avatar()?.textContent).toBe('K');
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
expect(avatar()?.getAttribute('aria-label')).toBe('Konto-Menü');
expect(panel()?.hidden).toBe(true);
});
it('Klick öffnet das Menü: E-Mail, „Meine Karten“, „Abmelden“', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
expect(panel()?.hidden).toBe(false);
expect(panel()?.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
const link = panel()!.querySelector<HTMLAnchorElement>('a[data-link]')!;
expect(link.textContent).toBe('Meine Karten');
expect(link.getAttribute('href')).toBe('/karten');
expect(buttonByText('Abmelden')).toBeDefined();
});
it('Escape schließt das Menü und gibt den Fokus an den Avatar', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(panel()?.hidden).toBe(true);
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
expect(document.activeElement).toBe(avatar());
});
it('Klick außerhalb oder auf „Meine Karten“ schließt das Menü', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(panel()?.hidden).toBe(true);
avatar()!.click();
panel()!.querySelector('a')!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(panel()?.hidden).toBe(true);
});
it('Klick im Menü selbst (auf die E-Mail) schließt es nicht', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
panel()!.querySelector('.account-email')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(panel()?.hidden).toBe(false);
});
it('Abmelden ruft logout; bei einem Fehler bleibt das Menü offen und zeigt die Meldung', async () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
mocks.logout.mockRejectedValueOnce(new TypeError('offline'));
menu.ready();
avatar()!.click();
const signOut = buttonByText('Abmelden')!;
signOut.click();
expect(mocks.logout).toHaveBeenCalledOnce();
expect(signOut.disabled).toBe(true);
await vi.waitFor(() => expect(signOut.disabled).toBe(false));
expect(slot.querySelector('[role="alert"]')?.textContent).toBe('Keine Verbindung zum Server.');
expect(panel()?.hidden).toBe(false);
});
it('nach dem Abmelden (Auth-Änderung) steht wieder „Anmelden“ da', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
avatar()!.click();
setUser(null);
expect(avatar()).toBeNull();
expect(buttonByText('Anmelden')).toBeDefined();
});
it('dispose leert den Slot und meldet die Listener ab', () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
menu.ready();
menu.dispose();
expect(slot.childElementCount).toBe(0);
expect(mocks.listeners.size).toBe(0);
expect(mocks.nameListeners.size).toBe(0);
});
});
describe('Konto-Menü: Benutzername', () => {
const signedIn = () => {
mocks.user = { $id: 'u1', email: 'kenso@example.com' };
};
const head = () => panel()!.querySelector<HTMLElement>('.account-head')!;
const nameLine = () => panel()!.querySelector('.account-name');
const items = () => [...panel()!.querySelectorAll('.account-item')].map((item) => item.textContent);
it('mit Namen steht „@name“ oben und die E-Mail klein darunter', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(nameLine()?.textContent).toBe('@Kenso');
expect(panel()!.querySelector('.account-email')?.textContent).toBe('kenso@example.com');
expect([...head().children].map((child) => child.className)).toEqual(['account-name', 'account-email']); // Name zuerst
expect(head().parentElement).toBe(panel());
expect(head().nextElementSibling?.textContent).toBe('Meine Karten'); // die Kopfzeile steht vor den Einträgen
});
it('der Avatar bleibt bei der Initiale der E-Mail', () => {
signedIn();
mocks.name = 'Zebra';
menu.ready();
expect(avatar()?.textContent).toBe('K');
});
it('ohne Namen steht nur die E-Mail oben (wie bisher)', () => {
signedIn();
menu.ready();
expect(nameLine()).toBeNull();
expect(head().textContent).toBe('kenso@example.com');
});
it('Name und E-Mail erscheinen als Text, nie als HTML', () => {
mocks.user = { $id: 'u1', email: '<i>x</i>@example.com' };
mocks.name = '<b>Kenso</b>';
menu.ready();
expect(head().querySelector('b, i')).toBeNull();
expect(nameLine()?.textContent).toBe('@<b>Kenso</b>');
expect(nameLine()?.getAttribute('title')).toBe('@<b>Kenso</b>');
});
it('Eintrag „Benutzername ändern“ mit Namen, „Benutzername wählen“ ohne – zwischen „Meine Karten“ und „Abmelden“', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(items()).toEqual(['Meine Karten', 'Benutzername ändern', 'Abmelden']);
setUser(null);
mocks.name = null;
setUser({ $id: 'u1', email: 'kenso@example.com' });
expect(items()).toEqual(['Meine Karten', 'Benutzername wählen', 'Abmelden']);
});
it('der Eintrag öffnet den Dialog im Modus „change“ und schließt das Menü', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
avatar()!.click();
buttonByText('Benutzername ändern')!.click();
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
expect(panel()?.hidden).toBe(true);
expect(avatar()?.getAttribute('aria-expanded')).toBe('false');
});
it('ohne Namen öffnet „Benutzername wählen“ denselben Dialog', () => {
signedIn();
menu.ready();
buttonByText('Benutzername wählen')!.click();
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'change' });
});
it('nach dem Dialog geht der Fokus zurück an den Avatar (der Eintrag im geschlossenen Menü kann ihn nicht halten)', async () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
avatar()!.click();
buttonByText('Benutzername ändern')!.click();
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
});
it('ändert sich der Name (Speichern im Dialog), zeigt das Menü sofort den neuen', () => {
signedIn();
mocks.name = 'Alt';
menu.ready();
expect(nameLine()?.textContent).toBe('@Alt');
setName('u1', 'Neu');
expect(nameLine()?.textContent).toBe('@Neu');
expect(items()).toContain('Benutzername ändern');
});
it('ein erst später gelesener Name erscheint ohne weiteres Zutun (Menü steht schon, der Name kommt nach)', () => {
signedIn();
menu.ready();
expect(nameLine()).toBeNull();
expect(buttonByText('Benutzername wählen')).toBeDefined();
setName('u1', 'Kenso');
expect(nameLine()?.textContent).toBe('@Kenso');
expect(buttonByText('Benutzername ändern')).toBeDefined();
});
it('meldet onUsernameChange denselben Namen noch einmal (Lesen des eigenen Profils nach dem Start), bleibt das Menü unangetastet: offen und mit dem Fokus', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
avatar()!.click();
const before = { avatar: avatar(), panel: panel(), head: head() };
const signOut = buttonByText('Abmelden')!;
signOut.focus();
setName('u1', 'Kenso');
expect(avatar()).toBe(before.avatar); // dieselben Elemente, nichts wurde neu gebaut
expect(panel()).toBe(before.panel);
expect(head()).toBe(before.head);
expect(panel()?.hidden).toBe(false);
expect(avatar()?.getAttribute('aria-expanded')).toBe('true');
expect(document.activeElement).toBe(signOut);
expect(nameLine()?.textContent).toBe('@Kenso');
});
it('ein anderer Name oder nur eine andere Schreibweise zeichnet dagegen neu', () => {
signedIn();
mocks.name = 'kenso';
menu.ready();
const before = avatar();
setName('u1', 'Kenso'); // „kenso“ → „Kenso“ ist erlaubt (Spec § 4) und soll zu sehen sein
expect(avatar()).not.toBe(before);
expect(nameLine()?.textContent).toBe('@Kenso');
const second = avatar();
setName('u1', 'Mia');
expect(avatar()).not.toBe(second);
expect(nameLine()?.textContent).toBe('@Mia');
});
it('die Namensmeldung eines anderen Kontos ändert nichts am Menü des angemeldeten', () => {
signedIn();
mocks.name = 'Kenso'; // der Name des angemeldeten Kontos bleibt
menu.ready();
avatar()!.click();
const before = panel();
for (const fn of [...mocks.nameListeners]) fn('u2', 'Mia'); // das Konto u2 hat sich umbenannt (etwa in einem eben beendeten Aufruf)
expect(panel()).toBe(before);
expect(panel()?.hidden).toBe(false);
expect(nameLine()?.textContent).toBe('@Kenso');
});
it('abgemeldet bleibt der Anmelden-Knopf stehen, auch wenn ein Name gemeldet wird (das Konto, das speicherte, ist nicht mehr angemeldet)', () => {
menu.ready();
const signIn = buttonByText('Anmelden');
for (const fn of [...mocks.nameListeners]) fn('u2', 'Mia');
expect(buttonByText('Anmelden')).toBe(signIn);
});
it('nach dem Abmelden ist das Menü unabhängig vom Namen wieder „Anmelden“', () => {
signedIn();
mocks.name = 'Kenso';
menu.ready();
setUser(null);
expect(slot.querySelector('.account-name')).toBeNull();
expect(buttonByText('Anmelden')).toBeDefined();
});
it('vor ready() und nach dispose zeichnet auch eine Namensänderung nichts', () => {
signedIn();
setName('u1', 'Kenso');
expect(slot.childElementCount).toBe(0);
menu.ready();
menu.dispose();
setName('u1', 'Neu');
expect(slot.childElementCount).toBe(0);
});
it('lässt sich der Dialog nicht öffnen, bleibt es bei einer Meldung in der Konsole (kein unbehandelter Fehler), der Fokus geht trotzdem zurück', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
signedIn();
menu.ready();
avatar()!.click();
buttonByText('Benutzername wählen')!.click();
await vi.waitFor(() => expect(error).toHaveBeenCalled());
await vi.waitFor(() => expect(document.activeElement).toBe(avatar()));
error.mockRestore();
});
it('auf Englisch: Einträge in der Sprache der Seite', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
signedIn();
mocks.name = 'Kenso';
menu.ready();
expect(items()).toEqual(['My cards', 'Change username', 'Sign out']);
expect(nameLine()?.textContent).toBe('@Kenso');
mocks.name = null;
setUser({ $id: 'u1', email: 'kenso@example.com' });
expect(items()).toEqual(['My cards', 'Choose a username', 'Sign out']);
});
});
+86 -11
View File
@@ -1,9 +1,14 @@
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet E-Mail und „Abmelden“.
// Konto-Menü im Seitenkopf: abgemeldet „Anmelden“, angemeldet ein Avatar-Kreis mit Initiale und einem Menü (Benutzername und E-Mail,
// „Meine Karten“, „Benutzername ändern“, „Abmelden“). Die Texte stehen in der Sprache der Seite; nach einem Sprachwechsel
// baut main.ts das Menü neu auf.
import './account.css';
import { el } from '../editor/dom';
import { t } from '../i18n/i18n';
import { currentUser, logout, onAuthChange } from './auth';
import { errorMessage } from './errors';
import { requireLogin } from './login-dialog';
import { myUsername, onUsernameChange } from './profile';
import { usernameDialog } from './username-dialog';
export interface AccountMenu {
/** Zeigt das Menü erst, wenn der Anmeldestatus bekannt ist (kein Aufblitzen von „Anmelden“ bei angemeldeten Nutzern). */
@@ -13,22 +18,91 @@ export interface AccountMenu {
export function mountAccountMenu(slot: HTMLElement): AccountMenu {
let isReady = false;
// Hebt die Dokument-Listener des gerade offenen Menüs wieder auf (null: kein Menü gezeichnet).
let detach: (() => void) | null = null;
// Der Name, den das gezeichnete Menü zeigt (null: keiner oder gar kein Konto-Menü). Meldet `onUsernameChange` denselben Namen noch einmal
// (etwa das Lesen des eigenen Profils nach dem Start), bleibt das Menü stehen: ein neu gebautes Dropdown wäre zu und verlöre den Fokus.
let shownName: string | null = null;
function render(): void {
detach?.();
detach = null;
shownName = null;
if (!isReady) {
slot.replaceChildren();
return;
}
const user = currentUser();
if (!user) {
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Anmelden');
const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, t('Anmelden'));
signIn.addEventListener('click', () => void requireLogin('login'));
slot.replaceChildren(el('div', { class: 'account-menu' }, signIn));
return;
}
const initial = Array.from(user.email)[0]?.toUpperCase() ?? '?';
const avatar = el(
'button',
{
type: 'button',
class: 'account-avatar',
'aria-label': t('Konto-Menü'),
'aria-expanded': 'false',
'aria-controls': 'account-dropdown',
},
initial,
);
// Mit Benutzernamen steht „@name“ oben und die E-Mail klein darunter; ohne Namen (noch nicht gelesen oder nicht gewählt) nur die E-Mail.
const name = myUsername();
shownName = name;
const email = el('span', { class: 'account-email', title: user.email }, user.email);
const signOut = el('button', { type: 'button', class: 'auth-btn auth-btn-small' }, 'Abmelden');
const head = el(
'div',
{ class: 'account-head' },
...(name ? [el('span', { class: 'account-name', title: `@${name}` }, `@${name}`)] : []),
email,
);
const myCards = el('a', { class: 'account-item', href: '/karten', 'data-link': true }, t('Meine Karten'));
const changeName = el('button', { type: 'button', class: 'account-item' }, name ? t('Benutzername ändern') : t('Benutzername wählen'));
const signOut = el('button', { type: 'button', class: 'account-item' }, t('Abmelden'));
const error = el('span', { class: 'account-error', role: 'alert' });
const dropdown = el('div', { class: 'account-dropdown', id: 'account-dropdown', hidden: true }, head, myCards, changeName, signOut, error);
const wrap = el('div', { class: 'account-menu', role: 'group', 'aria-label': t('Konto') }, avatar, dropdown);
let isOpen = false;
function setOpen(open: boolean): void {
isOpen = open;
dropdown.hidden = !open;
avatar.setAttribute('aria-expanded', String(open));
}
avatar.addEventListener('click', () => setOpen(!isOpen));
const onDocumentClick = (event: MouseEvent) => {
if (!isOpen) return;
const target = event.target as Element | null;
if (target && wrap.contains(target) && !target.closest('a')) return; // nur Links schließen das Menü selbst
setOpen(false);
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || !isOpen) return;
setOpen(false);
avatar.focus();
};
document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onKeydown);
detach = () => {
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onKeydown);
};
changeName.addEventListener('click', () => {
setOpen(false);
// Der Eintrag im geschlossenen (und nach dem Speichern neu gezeichneten) Menü kann den Fokus nicht halten: er geht an den Avatar.
usernameDialog({ mode: 'change' })
.catch((err: unknown) => console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err))
.finally(() => slot.querySelector<HTMLElement>('.account-avatar')?.focus());
});
signOut.addEventListener('click', () => {
signOut.disabled = true;
error.textContent = '';
@@ -38,17 +112,15 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
signOut.disabled = false;
});
});
slot.replaceChildren(
el(
'div',
{ class: 'account-menu', role: 'group', 'aria-label': 'Konto' },
el('div', { class: 'account-row' }, email, signOut),
error,
),
);
slot.replaceChildren(wrap);
}
const off = onAuthChange(render);
// Der Name steht fest oder ändert sich (gelesen nach dem Start, gespeichert im Dialog): das Menü zeigt gleich den neuen.
const offName = onUsernameChange(() => {
if (myUsername() === shownName) return; // nichts Neues zu zeigen (derselbe Name, oder ein anderes Konto hat gespeichert)
render();
});
render();
return {
@@ -58,6 +130,9 @@ export function mountAccountMenu(slot: HTMLElement): AccountMenu {
},
dispose() {
off();
offName();
detach?.();
detach = null;
slot.replaceChildren();
},
};
+139 -43
View File
@@ -25,6 +25,7 @@
.auth-btn-primary {
border-color: var(--primary);
background: var(--primary);
color: var(--on-primary);
font-weight: 700;
}
@@ -35,7 +36,7 @@
.auth-btn-danger {
border-color: var(--danger);
color: #ff9a9d;
color: var(--danger-text);
}
.auth-btn-danger:hover:not(:disabled) {
@@ -70,43 +71,124 @@
cursor: not-allowed;
}
/* Konto-Menü im Seitenkopf */
#account-slot {
min-width: 0;
}
/* Konto-Menü im Seitenkopf: „Anmelden“ bzw. Avatar-Kreis mit aufklappbarem Menü */
.account-menu {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
min-width: 0;
max-width: 100%;
}
.account-row {
position: relative;
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
max-width: 100%;
}
.account-email {
.account-avatar {
appearance: none;
display: grid;
place-items: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 2px solid transparent;
border-radius: 50%;
background: var(--primary);
color: var(--on-primary);
font: inherit;
font-size: 1.05rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
transition: border-color 0.12s;
}
.account-avatar:hover,
.account-avatar[aria-expanded='true'] {
border-color: var(--sel-line);
}
.account-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 40;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 220px;
max-width: min(320px, calc(100vw - 32px));
padding: 8px;
border: 1px solid var(--line-strong);
border-radius: 14px;
background: var(--panel);
box-shadow: var(--shadow-lg);
}
.account-dropdown[hidden] {
display: none;
}
/* Kopf des Menüs: „@name“ (falls das Konto einen hat) und darunter die E-Mail */
.account-head {
display: flex;
flex-direction: column;
min-width: 0;
margin-bottom: 4px;
padding: 6px 12px 10px;
border-bottom: 1px solid var(--line);
}
.account-name {
min-width: 0;
max-width: 28ch;
overflow: hidden;
color: var(--muted);
font-size: 0.95rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-email {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-name + .account-email {
font-size: 0.8rem; /* mit Namen steht die E-Mail klein darunter */
}
.account-item {
appearance: none;
display: block;
width: 100%;
margin: 0;
padding: 10px 12px;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 600;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.account-item:hover:not(:disabled) {
background: var(--panel-2);
}
.account-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.account-error {
color: #ff9a9d;
padding: 4px 12px 0;
color: var(--danger-text);
font-size: 0.85rem;
text-align: right;
}
.account-error:empty {
@@ -124,11 +206,11 @@
border-radius: 24px;
background: var(--panel);
color: var(--text);
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
box-shadow: var(--shadow-lg);
}
.auth-dialog::backdrop {
background: rgb(6 11 20 / 0.72);
background: var(--backdrop);
}
.auth-card {
@@ -256,16 +338,25 @@
font-size: 0.95rem;
}
/* Leer bleibt die Zeile gerendert (Live-Region, role alert/status), nimmt aber keinen Platz ein: display: none nähme sie aus
dem Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
.auth-msg:empty {
display: none;
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.auth-msg-error {
color: #ff9a9d;
color: var(--danger-text);
}
.auth-msg-ok {
color: #8fd9a8;
color: var(--ok-text);
}
.auth-aside {
@@ -275,6 +366,25 @@
text-align: center;
}
/* Dialog „Benutzername wählen“: Hülle, Felder, Hinweise, Meldungen und Knöpfe wie der Login-Dialog */
.username-dialog .auth-title {
margin-right: 0; /* kein Schließen-Knopf in der Ecke */
}
.username-text {
margin: 0;
color: var(--muted);
overflow-wrap: anywhere;
}
.username-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
/* Seite /passwort-neu */
.reset-page {
@@ -289,29 +399,15 @@
margin: 0;
}
/* Schmale Bildschirme: Konto-Menü rutscht bei Bedarf in eine zweite Kopfzeile */
@media (max-width: 599px) {
#app-header {
flex-wrap: wrap;
gap: 8px 16px;
}
#account-slot {
max-width: 100%;
}
.account-email {
max-width: none;
}
.auth-card {
padding: 24px 20px 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.auth-btn {
.auth-btn,
.account-avatar {
transition: none;
}
}
+19 -2
View File
@@ -5,8 +5,25 @@ import { Account, Client, Storage, TablesDB } from 'appwrite';
export const APPWRITE_ENDPOINT: string = import.meta.env.VITE_APPWRITE_ENDPOINT || `${location.origin}/v1`;
export const APPWRITE_PROJECT: string = import.meta.env.VITE_APPWRITE_PROJECT_ID || '6ac2130a00289343d6d5';
/** Datenbank, Tabelle und Bucket des Projekts (siehe web/scripts/appwrite-setup.mjs). */
export const IDS = { database: 'aircard', table: 'cards', bucket: 'card-images' } as const;
/**
* Datenbank, Tabellen und Buckets des Projekts (siehe web/scripts/appwrite-setup.mjs).
* `table`/`bucket` sind die privaten Karten; `gallery`/`reports`/`galleryBucket` gehören zur Community-Galerie,
* `logos`/`logoLikes`/`logoReports`/`logoBucket` zu den Community-Logos (Logos, Herzen, Meldungen, Logo-Dateien),
* `profiles` zu den Benutzernamen (eine öffentlich lesbare Zeile je Konto; geschrieben wird nur über den API-Dienst).
*/
export const IDS = {
database: 'aircard',
table: 'cards',
bucket: 'card-images',
gallery: 'gallery',
reports: 'reports',
galleryBucket: 'gallery-images',
logos: 'logos',
logoLikes: 'logo_likes',
logoReports: 'logo_reports',
logoBucket: 'logo-images',
profiles: 'profiles',
} as const;
export const client: Client = new Client().setEndpoint(APPWRITE_ENDPOINT).setProject(APPWRITE_PROJECT);
export const account: Account = new Account(client);
+54 -8
View File
@@ -29,7 +29,7 @@ describe('getCurrentUser', () => {
it('liefert den Nutzer und merkt sich die ID', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', name: 'a' });
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(auth.currentUserId()).toBe('u1');
});
@@ -71,7 +71,7 @@ describe('getCurrentUser', () => {
await auth.getCurrentUser();
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de' });
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
mocks.get.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
await auth.getCurrentUser();
await auth.getCurrentUser();
@@ -80,6 +80,52 @@ describe('getCurrentUser', () => {
});
});
describe('Labels und isAdmin', () => {
it('getCurrentUser übernimmt die Labels; fehlen sie (oder sind sie kein Array aus Texten), sind es []', async () => {
const auth = await freshAuth();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
await expect(auth.getCurrentUser()).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: ['admin', 'x'] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: 'admin' });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: [] });
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin', 7, null] });
await expect(auth.getCurrentUser()).resolves.toMatchObject({ labels: ['admin'] });
});
it('login liefert die Labels ebenfalls', async () => {
const auth = await freshAuth();
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toMatchObject({ labels: ['admin'] });
expect(auth.currentUser()?.labels).toEqual(['admin']);
});
it('eine Änderung nur der Labels benachrichtigt die Abonnenten', async () => {
const auth = await freshAuth();
const listener = vi.fn();
auth.onAuthChange(listener);
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
await auth.getCurrentUser();
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: [] });
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(1);
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
await auth.getCurrentUser();
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenLastCalledWith({ $id: 'u1', email: 'a@b.de', labels: ['admin'] });
});
it('isAdmin ist nur mit dem Label „admin“ wahr', async () => {
const auth = await freshAuth();
expect(auth.isAdmin(null)).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de' })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: [] })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['administrator', 'Admin'] })).toBe(false);
expect(auth.isAdmin({ $id: 'u1', email: 'a@b.de', labels: ['x', 'admin'] })).toBe(true);
});
});
describe('login', () => {
it('legt die Sitzung an, holt den Nutzer und benachrichtigt', async () => {
const auth = await freshAuth();
@@ -87,10 +133,10 @@ describe('login', () => {
auth.onAuthChange(listener);
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledWith({ email: 'a@b.de', password: 'geheim123' });
expect(auth.currentUserId()).toBe('u1');
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de' });
expect(listener).toHaveBeenCalledWith({ $id: 'u1', email: 'a@b.de', labels: [] });
});
it('bei falschem Passwort fliegt der Fehler weiter und nichts ändert sich', async () => {
@@ -114,7 +160,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
@@ -127,7 +173,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope'));
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' });
await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de', labels: [] });
expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2);
});
@@ -177,7 +223,7 @@ describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () =
mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({});
mocks.deleteSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
expect(mocks.deleteSession).toHaveBeenCalledTimes(1);
});
});
@@ -188,7 +234,7 @@ describe('register', () => {
mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' });
await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de', labels: [] });
const params = mocks.create.mock.calls[0][0];
expect(params).toMatchObject({ email: 'neu@b.de', password: 'geheim123', name: 'neu' });
expect(typeof params.userId).toBe('string');
+17 -6
View File
@@ -1,10 +1,11 @@
// Anmelden, Registrieren, Abmelden, Passwort zurücksetzen. Hält den angemeldeten Nutzer im Speicher
// und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite).
import { ID } from 'appwrite';
import { t } from '../i18n/i18n';
import { account } from './appwrite';
import { errorType, isUnauthorized } from './errors';
export type User = { $id: string; email: string };
export type User = { $id: string; email: string; labels?: string[] };
const PASSWORD_MIN = 8;
const PASSWORD_MAX = 256;
@@ -27,10 +28,16 @@ function setUser(user: User | null): void {
function toUser(res: unknown): User {
const data = (typeof res === 'object' && res !== null ? res : {}) as Record<string, unknown>;
if (typeof data.$id !== 'string' || typeof data.email !== 'string') throw new Error('Unerwartete Antwort');
return { $id: data.$id, email: data.email };
const labels = Array.isArray(data.labels) ? data.labels.filter((l): l is string => typeof l === 'string') : [];
return { $id: data.$id, email: data.email, labels };
}
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen. */
/** Wahr nur für Konten mit dem Appwrite-Label `admin` (die Rechte prüft der Server; das hier steuert nur die Oberfläche). */
export function isAdmin(user: User | null): boolean {
return (user?.labels ?? []).includes('admin');
}
/** Der angemeldete Nutzer laut Server; 401 heißt „nicht angemeldet“. Abonnenten hören nur echte Änderungen (auch der Labels). */
export async function getCurrentUser(): Promise<User | null> {
let next: User | null;
try {
@@ -39,7 +46,11 @@ export async function getCurrentUser(): Promise<User | null> {
if (!isUnauthorized(err)) throw err;
next = null;
}
if (next?.$id !== cachedUser?.$id || next?.email !== cachedUser?.email) setUser(next);
const same =
next?.$id === cachedUser?.$id &&
next?.email === cachedUser?.email &&
(next?.labels ?? []).join('\n') === (cachedUser?.labels ?? []).join('\n');
if (!same) setUser(next);
return next;
}
@@ -122,9 +133,9 @@ export function onLogout(fn: () => void): () => void {
};
}
/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer. */
/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer (in der Sprache der Seite). */
export function validatePassword(pw: string): string | null {
return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX
? null
: `Das Passwort muss ${PASSWORD_MIN} bis ${PASSWORD_MAX} Zeichen lang sein.`;
: t('Das Passwort muss {min} bis {max} Zeichen lang sein.', { min: PASSWORD_MIN, max: PASSWORD_MAX });
}
+124 -10
View File
@@ -32,6 +32,7 @@ vi.mock('./appwrite', () => ({
import {
appwriteCardsApi,
createCardsService,
fetchFile,
normalizeName,
ownerPermissions,
toUploadFile,
@@ -147,6 +148,9 @@ const newInput = (patch: Partial<SaveInput> = {}): SaveInput => ({
const service = (fake: ReturnType<typeof fakeApi>, userId: () => string = () => 'u1') =>
createCardsService(fake.api, userId);
/** Der Server hat die Anfrage eindeutig abgelehnt (4xx): es wurde nichts geschrieben. */
const rejected = (code = 400) => new AppwriteException('Abgelehnt', code, 'document_invalid_structure');
describe('normalizeName', () => {
it('trimmt und fasst Leerraum zusammen', () => {
expect(normalizeName(' Mein Urlaub ')).toBe('Mein Urlaub');
@@ -240,14 +244,60 @@ describe('save: neue Karte', () => {
expect((fake.methods('createFile')[1].args[0] as File).name).toBe('preview.png');
});
it('scheitert createRow, werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => {
it('lehnt der Server createRow eindeutig ab (4xx), werden beide neuen Dateien gelöscht und der Fehler kommt an', async () => {
const fake = fakeApi();
const boom = new Error('createRow');
const boom = rejected(400);
fake.failOnce.set('createRow', boom);
await expect(service(fake).save(newInput())).rejects.toBe(boom);
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
});
it('auch 401 (Sitzung weg) und andere 4xx gelten als eindeutig abgelehnt', async () => {
for (const code of [401, 403, 404, 409, 413]) {
const fake = fakeApi();
fake.failOnce.set('createRow', rejected(code));
await expect(service(fake).save(newInput())).rejects.toThrow();
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
}
});
describe('unklarer Ausgang beim Schreiben der Zeile: Uploads bleiben liegen', () => {
const ambiguous: [string, () => unknown][] = [
['Netzfehler (TypeError)', () => new TypeError('Failed to fetch')],
['503', () => new AppwriteException('Server', 503, 'general_server_error')],
['500', () => new AppwriteException('Server', 500, 'general_server_error')],
['429 (zu viele Anfragen)', () => new AppwriteException('Rate', 429, 'general_rate_limit_exceeded')],
['408 (Zeitüberschreitung)', () => new AppwriteException('Timeout', 408, 'general_timeout')],
['ein Fehler ohne Statuscode', () => new Error('unbekannt')],
];
for (const [label, make] of ambiguous) {
it(`neue Karte, createRow: ${label}`, async () => {
const fake = fakeApi();
const boom = make();
fake.failOnce.set('createRow', boom);
await expect(service(fake).save(newInput())).rejects.toBe(boom);
expect(fake.methods('deleteFile')).toHaveLength(0);
});
}
it('bestehende Karte, updateRow: Netzfehler löscht weder neue noch alte Dateien', async () => {
const fake = fakeApi([{ $id: 'row1', $updatedAt: NOW, name: 'Alt', config: serializeConfig(config()), imageFileId: 'oldImage', previewFileId: 'oldPreview' }]);
const boom = new TypeError('Failed to fetch');
fake.failOnce.set('updateRow', boom);
await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom);
expect(fake.methods('deleteFile')).toHaveLength(0);
});
it('Zeile verschwunden, dann Netzfehler beim Anlegen: nichts wird gelöscht', async () => {
const fake = fakeApi();
fake.failOnce.set('updateRow', new AppwriteException('Row not found', 404, 'row_not_found'));
fake.failOnce.set('createRow', new TypeError('Failed to fetch'));
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow(TypeError);
expect(fake.methods('deleteFile')).toHaveLength(0);
});
});
it('scheitert die Vorschau, wird das Bild gelöscht und keine Zeile angelegt', async () => {
const fake = fakeApi();
const boom = new Error('preview');
@@ -259,7 +309,7 @@ describe('save: neue Karte', () => {
it('Fehler beim Aufräumen überdecken den Ursprungsfehler nicht', async () => {
const fake = fakeApi();
const boom = new Error('createRow');
const boom = rejected(400);
fake.failOnce.set('createRow', boom);
const original = fake.api.deleteFile;
fake.api.deleteFile = async (id) => {
@@ -300,7 +350,7 @@ describe('save: bestehende Karte', () => {
it('ohne Bildwechsel: nur neue Vorschau, Update, dann alte Vorschau löschen', async () => {
const fake = fakeApi([existing()]);
const c = config({ network: 'jcb' });
const c = config({ logo: 'abc123' });
const saved = await service(fake).save(
newInput({ name: 'Neu', config: c, imageChanged: false, previous: previous() }),
);
@@ -309,15 +359,30 @@ describe('save: bestehende Karte', () => {
const [file] = fake.methods('createFile');
expect((file.args[0] as File).name).toBe('preview.webp');
expect(file.args[1]).toEqual(OWNER);
// Ohne Bildwechsel steht kein imageFileId im Update: ein veralteter Schnappschuss darf die Zeile nicht auf eine
// inzwischen gelöschte Bilddatei zurücksetzen.
expect(fake.methods('updateRow')[0].args).toEqual([
'row1',
{ name: 'Neu', config: serializeConfig(c), imageFileId: 'oldImage', previewFileId: 'file1' },
{ name: 'Neu', config: serializeConfig(c), previewFileId: 'file1' },
]);
expect(fake.methods('updateRow')[0].args[1]).not.toHaveProperty('imageFileId');
expect(fake.methods('deleteFile')[0].args).toEqual(['oldPreview']);
expect(saved.id).toBe('row1');
expect(saved.imageFileId).toBe('oldImage');
expect(saved.previewFileId).toBe('file1');
expect(saved.config.network).toBe('jcb');
expect(saved.config.logo).toBe('abc123');
});
it('veralteter Schnappschuss (Bild in anderem Tab ersetzt): das Update schickt kein altes imageFileId, die Zeile behält das neue Bild', async () => {
// Der Server kennt schon das neue Bild; der Editor hält noch den Stand von vorher.
const fake = fakeApi([{ ...existing(), imageFileId: 'serverImage', previewFileId: 'serverPreview' }]);
const saved = await service(fake).save(
newInput({ imageChanged: false, previous: previous({ imageFileId: 'deletedImage', previewFileId: 'stalePreview' }) }),
);
expect(fake.methods('updateRow')[0].args[1]).not.toHaveProperty('imageFileId');
expect(saved.imageFileId).toBe('serverImage');
// Gelöscht wird nur die Vorschau des Schnappschusses, nie die Bilddatei.
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['stalePreview']);
});
it('mit Bildwechsel: zwei neue Dateien, nach dem Update werden beide alten gelöscht', async () => {
@@ -331,9 +396,9 @@ describe('save: bestehende Karte', () => {
expect(saved.imageFileId).toBe('file1');
});
it('scheitert updateRow, werden nur die neuen Dateien gelöscht', async () => {
it('lehnt der Server updateRow ab, werden nur die neuen Dateien gelöscht', async () => {
const fake = fakeApi([existing()]);
const boom = new Error('updateRow');
const boom = rejected(400);
fake.failOnce.set('updateRow', boom);
await expect(service(fake).save(newInput({ imageChanged: true, previous: previous() }))).rejects.toBe(boom);
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
@@ -341,7 +406,7 @@ describe('save: bestehende Karte', () => {
it('scheitert updateRow ohne Bildwechsel, wird nur die neue Vorschau gelöscht', async () => {
const fake = fakeApi([existing()]);
fake.failOnce.set('updateRow', new Error('updateRow'));
fake.failOnce.set('updateRow', rejected(400));
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toThrow();
expect(fake.methods('deleteFile').map((c) => c.args[0])).toEqual(['file1']);
});
@@ -401,7 +466,7 @@ describe('save: bestehende Karte', () => {
it('scheitert auch das Anlegen, werden alle neuen Dateien gelöscht, die alten bleiben, der Fehler kommt an', async () => {
const fake = fakeApi([existing()]);
const boom = new Error('createRow');
const boom = rejected(400);
fake.failOnce.set('updateRow', gone());
fake.failOnce.set('createRow', boom);
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toBe(boom);
@@ -647,3 +712,52 @@ describe('appwriteCardsApi', () => {
});
});
});
describe('fetchFile (eigenständig, beliebiger Bucket)', () => {
beforeEach(() => {
for (const fn of Object.values(mocks)) fn.mockReset();
vi.unstubAllGlobals();
});
const prepare = () =>
mocks.prepareRequest.mockImplementation((method: string, url: URL) => ({
uri: url.toString(),
options: { method, headers: { 'X-Appwrite-Project': 'proj' }, credentials: 'include' },
}));
it('holt die Datei aus dem übergebenen Bucket, mit Projekt-Header und Cookie', async () => {
prepare();
const fetchMock = vi.fn(async () => new Response(png(), { status: 200, headers: { 'content-type': 'image/png' } }));
vi.stubGlobal('fetch', fetchMock);
const result = await fetchFile('gallery-images', 'f1');
expect(result.type).toBe('image/png');
expect(fetchMock).toHaveBeenCalledWith('https://aircard.test/v1/storage/buckets/gallery-images/files/f1/view', {
method: 'GET',
headers: { 'X-Appwrite-Project': 'proj' },
credentials: 'include',
});
});
it('maskiert Bucket- und Datei-ID im Pfad', async () => {
prepare();
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
await fetchFile('a/b', '../x?y');
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/a%2Fb/files/..%2Fx%3Fy/view');
});
it('Fehlerantwort: AppwriteException mit Status und Typ des Servers', async () => {
prepare();
const body = JSON.stringify({ message: 'File not found', type: 'storage_file_not_found', code: 404 });
vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { status: 404, statusText: 'Not Found' })));
const err = await fetchFile('gallery-images', 'f1').catch((e: unknown) => e);
expect(err).toBeInstanceOf(AppwriteException);
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' });
});
it('CardsApi.fetchFile bleibt die Hülle über den Karten-Bucket', async () => {
prepare();
vi.stubGlobal('fetch', vi.fn(async () => new Response(png(), { headers: { 'content-type': 'image/png' } })));
await appwriteCardsApi().fetchFile('f1');
expect((mocks.prepareRequest.mock.calls[0][1] as URL).pathname).toBe('/v1/storage/buckets/bucket/files/f1/view');
});
});
+42 -18
View File
@@ -1,8 +1,9 @@
// Gespeicherte Karten: Dienst (Regeln fürs Speichern, Aufräumen, Laden) und Anschluss an Appwrite.
// Der Dienst kennt nur das schmale `CardsApi`-Interface und lässt sich dadurch ohne Netzwerk testen.
import { AppwriteException, ID, Permission, Query, Role, type Models } from 'appwrite';
import { parseConfig, serializeConfig, type CardConfig } from '../card/config';
import { ID_PATTERN, parseConfig, serializeConfig, type CardConfig } from '../card/config';
import type { SavedCardRef } from '../editor/state';
import { msg, t } from '../i18n/i18n';
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
import { currentUserId } from './auth';
import { errorType } from './errors';
@@ -44,16 +45,16 @@ export interface SaveInput {
previous: SavedCardRef | null;
}
export const DEFAULT_CARD_NAME = 'Meine Karte';
export const DEFAULT_CARD_NAME = msg('Meine Karte');
const NAME_MAX = 80;
/** Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. */
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
/** Zeilen- und Datei-IDs von Appwrite (definiert in card/config.ts, dort braucht sie auch die Logo-ID der Konfiguration). */
export { ID_PATTERN };
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“. */
/** Trimmt, fasst Leerraum zusammen und kürzt auf 80 Zeichen (Codepoints); leer ergibt „Meine Karte“ (in der Sprache der Seite). */
export function normalizeName(name: string): string {
const collapsed = name.replace(/\s+/g, ' ').trim();
const cut = Array.from(collapsed).slice(0, NAME_MAX).join('').trim();
return cut || DEFAULT_CARD_NAME;
return cut || t(DEFAULT_CARD_NAME);
}
/** Nur der Besitzer darf die Zeile bzw. Datei lesen, ändern und löschen. */
@@ -87,6 +88,15 @@ async function deleteQuietly(api: CardsApi, fileIds: string[]): Promise<void> {
await Promise.allSettled(fileIds.map((id) => api.deleteFile(id)));
}
/**
* Der Server hat eine Anfrage eindeutig abgelehnt (4xx außer Zeitüberschreitung 408 und Drosselung 429): es wurde nichts
* geschrieben. Netzfehler, 5xx und alles ohne Statuscode lassen offen, ob die Zeile doch angekommen ist.
*/
function isDefinitiveFailure(err: unknown): boolean {
const code = typeof err === 'object' && err !== null ? (err as { code?: unknown }).code : undefined;
return typeof code === 'number' && code >= 400 && code < 500 && code !== 408 && code !== 429;
}
export function createCardsService(api: CardsApi, userId: () => string) {
async function save(input: SaveInput): Promise<SavedCard> {
// Ohne Nutzer wirft das hier, bevor etwas hochgeladen wird.
@@ -109,25 +119,36 @@ export function createCardsService(api: CardsApi, userId: () => string) {
let row: CardRow;
// Die Zeile gab es nicht mehr (anderer Tab, anderes Konto): dann wurde eine neue Karte angelegt, die alten Dateien sind Müll.
let replacedMissingCard = false;
// Scheitert ein Upload, hängt noch keine Zeile an den Dateien. Beim Schreiben der Zeile ist der Ausgang bei
// Netzfehler/5xx dagegen offen (sie kann angekommen sein): dann bleiben die Uploads lieber liegen, als dass
// eine Zeile auf gelöschte Dateien zeigt.
let writingRow = false;
try {
const imageFileId = imageFile ? await upload(imageFile) : (prev as SavedCardRef).imageFileId;
const newImageId = imageFile ? await upload(imageFile) : null;
const previewFileId = await upload(previewFile);
const data: CardRowData = { name, config, imageFileId, previewFileId };
writingRow = true;
if (!prev) {
row = await api.createRow(data, permissions);
// Ohne vorherige Karte wird immer ein Bild hochgeladen (uploadImage).
row = await api.createRow({ name, config, imageFileId: newImageId as string, previewFileId }, permissions);
} else {
// Ohne Bildwechsel steht kein imageFileId im Update: der Schnappschuss des Editors kann veraltet sein
// (Bild in einem anderen Tab ersetzt) und würde die Zeile auf eine gelöschte Datei zurücksetzen.
const update: Partial<CardRowData> = { name, config, previewFileId };
if (newImageId) update.imageFileId = newImageId;
try {
row = await api.updateRow(prev.id, data);
row = await api.updateRow(prev.id, update);
} catch (err) {
if (errorType(err) !== 'row_not_found') throw err;
replacedMissingCard = true;
// Ohne Bildwechsel zeigt `data` auf die Datei der verschwundenen Karte; die neue Karte bekommt ein eigenes Bild.
const ownImageId = imageFile ? imageFileId : await upload(toUploadFile(input.image, 'image'));
row = await api.createRow({ ...data, imageFileId: ownImageId }, permissions);
// Ohne Bildwechsel gibt es noch keine Bilddatei der neuen Karte; die der verschwundenen gehört ihr nicht.
writingRow = false;
const ownImageId = newImageId ?? (await upload(toUploadFile(input.image, 'image')));
writingRow = true;
row = await api.createRow({ name, config, imageFileId: ownImageId, previewFileId }, permissions);
}
}
} catch (err) {
await deleteQuietly(api, uploaded);
if (!writingRow || isDefinitiveFailure(err)) await deleteQuietly(api, uploaded);
throw err;
}
@@ -173,11 +194,13 @@ export function createCardsService(api: CardsApi, userId: () => string) {
export type CardsService = ReturnType<typeof createCardsService>;
/** Lädt eine Bilddatei mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
async function fetchFile(fileId: string): Promise<Blob> {
/** Lädt eine Bilddatei aus einem Bucket mit Projekt-Header und Cookie (siehe `client.prepareRequest`). */
export async function fetchFile(bucketId: string, fileId: string): Promise<Blob> {
// Bewusst kein storage.getFileView(): das hängt das Projekt als Query-Parameter an die URL und schickt keinen
// Header; so trifft die Anfrage die Traefik-Route für /v1 mit Header nicht. prepareRequest setzt Header und Cookie.
const url = new URL(`${APPWRITE_ENDPOINT}/storage/buckets/${IDS.bucket}/files/${encodeURIComponent(fileId)}/view`);
const url = new URL(
`${APPWRITE_ENDPOINT}/storage/buckets/${encodeURIComponent(bucketId)}/files/${encodeURIComponent(fileId)}/view`,
);
const { uri, options } = client.prepareRequest('GET', url);
const res = await fetch(uri, options);
if (!res.ok) {
@@ -211,7 +234,8 @@ export function appwriteCardsApi(): CardsApi {
deleteFile: async (fileId) => {
await storage.deleteFile({ bucketId: IDS.bucket, fileId });
},
fetchFile,
// Die privaten Karten liegen im Karten-Bucket; die Galerie holt ihre Bilder über `fetchFile` mit eigenem Bucket.
fetchFile: (fileId) => fetchFile(IDS.bucket, fileId),
createRow: (data, permissions) =>
tables.createRow<TableRow>({ ...target, rowId: ID.unique(), data, permissions }),
// Beim Ändern bleiben die Rechte der Zeile unangetastet.
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { errorMessage, errorType, isInvalidLinkError, isUnauthorized, type ErrorContext } from './errors';
import { errorMessage, errorType, isInvalidLinkError, isRowMissing, isUnauthorized, type ErrorContext } from './errors';
/** Fehler wie vom SDK (AppwriteException): Felder code, type, message. */
const fail = (code: number, type: string, message = '') => ({ code, type, message });
@@ -149,6 +149,33 @@ describe('errorType', () => {
});
});
describe('isRowMissing', () => {
it('404 mit row_not_found heißt: die Zeile gibt es nicht (oder nicht für mich)', () => {
expect(isRowMissing(fail(404, 'row_not_found'))).toBe(true);
});
it('404 ohne Typ (leer oder fehlend) zählt ebenso', () => {
expect(isRowMissing(fail(404, ''))).toBe(true);
expect(isRowMissing({ code: 404 })).toBe(true);
});
it('eine fehlende Tabelle, Datenbank oder Datei ist ein echter Fehler, kein „Zeile weg“', () => {
for (const type of ['table_not_found', 'database_not_found', 'storage_file_not_found']) {
expect(isRowMissing(fail(404, type)), type).toBe(false);
}
});
it('andere Status und Nicht-Fehler nicht', () => {
expect(isRowMissing(fail(401, 'row_not_found'))).toBe(false);
expect(isRowMissing(fail(500, 'row_not_found'))).toBe(false);
expect(isRowMissing({ code: '404', type: 'row_not_found' })).toBe(false);
expect(isRowMissing(new TypeError('Failed to fetch'))).toBe(false);
expect(isRowMissing('row_not_found')).toBe(false);
expect(isRowMissing(null)).toBe(false);
expect(isRowMissing(undefined)).toBe(false);
});
});
describe('isInvalidLinkError (Passwort-neu-Seite)', () => {
it('Link-Fehler', () => {
expect(isInvalidLinkError(fail(401, 'user_invalid_token'))).toBe(true);
+34 -23
View File
@@ -1,11 +1,13 @@
// Deutsche Fehlermeldungen für Appwrite-Fehler. Rein und ohne Appwrite-Import, damit gut testbar.
// Fehlermeldungen für Appwrite-Fehler in der Sprache der Seite: Die deutschen Texte sind die Schlüssel für t() (i18n/en.ts).
// Rein und ohne Appwrite-Import, damit gut testbar.
// Ein AppwriteException trägt `code` (HTTP-Status), `type` (z. B. user_invalid_credentials) und `message`.
import { msg, t } from '../i18n/i18n';
export type ErrorContext = 'login' | 'register' | 'recover' | 'reset' | 'save' | 'load';
const LINK_INVALID = 'Der Link ist ungültig oder abgelaufen.';
const WRONG_CREDENTIALS = 'E-Mail oder Passwort ist falsch.';
const GENERIC = 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.';
const LINK_INVALID = msg('Der Link ist ungültig oder abgelaufen.');
const WRONG_CREDENTIALS = msg('E-Mail oder Passwort ist falsch.');
const GENERIC = msg('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
function field(err: unknown, key: 'code' | 'type' | 'message'): unknown {
return typeof err === 'object' && err !== null ? (err as Record<string, unknown>)[key] : undefined;
@@ -22,6 +24,15 @@ export function isUnauthorized(err: unknown): boolean {
return field(err, 'code') === 401;
}
/**
* „Die Zeile gibt es nicht (oder nicht für mich)“: 404 `row_not_found` (auch ein 404 ohne Typ). Eine fehlende Tabelle, Datenbank oder
* Datei ist dagegen ein echter Fehler. Die Einzel-Abfragen der Tabellen (`getGalleryCard`, `getLogo`, `getUsername`) werten damit 404 als „nicht da“.
*/
export function isRowMissing(err: unknown): boolean {
const type = field(err, 'type');
return field(err, 'code') === 404 && (type === 'row_not_found' || type === undefined || type === '');
}
/** Bei `general_argument_invalid` nennt die Nachricht den Parameter: „Invalid `email` param: …“. */
function invalidParam(err: unknown): string | null {
const message = field(err, 'message');
@@ -40,46 +51,46 @@ export function isInvalidLinkError(err: unknown): boolean {
}
function passwordMessage(err: unknown, ctx: ErrorContext): string {
if (ctx === 'login') return WRONG_CREDENTIALS;
if (ctx === 'login') return t(WRONG_CREDENTIALS);
const message = field(err, 'message');
if (typeof message === 'string' && message.includes('commonly used')) {
return 'Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.';
return t('Dieses Passwort ist zu verbreitet. Bitte wähle ein anderes.');
}
return 'Das Passwort muss 8 bis 256 Zeichen lang sein.';
return t('Das Passwort muss 8 bis 256 Zeichen lang sein.');
}
/** Meldung für Nutzer: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
/** Meldung für Nutzer in der Sprache der Seite: zuerst nach `type`, dann nach `code`, Netzfehler (TypeError) zuletzt. */
export function errorMessage(err: unknown, ctx: ErrorContext): string {
switch (errorType(err)) {
case 'user_invalid_credentials':
return WRONG_CREDENTIALS;
return t(WRONG_CREDENTIALS);
case 'user_already_exists':
return 'Diese E-Mail ist schon registriert. Melde dich an.';
return t('Diese E-Mail ist schon registriert. Melde dich an.');
case 'user_invalid_token':
return LINK_INVALID;
return t(LINK_INVALID);
case 'user_not_found':
if (ctx === 'recover') return 'Zu dieser E-Mail gibt es kein Konto.';
if (ctx === 'reset') return LINK_INVALID;
if (ctx === 'recover') return t('Zu dieser E-Mail gibt es kein Konto.');
if (ctx === 'reset') return t(LINK_INVALID);
break;
case 'general_argument_invalid': {
const param = invalidParam(err);
if (param === 'email') return 'Bitte gib eine gültige E-Mail-Adresse ein.';
if (param === 'email') return t('Bitte gib eine gültige E-Mail-Adresse ein.');
if (param === 'password') return passwordMessage(err, ctx);
if (param === 'userId' || param === 'secret') return LINK_INVALID;
if (param === 'userId' || param === 'secret') return t(LINK_INVALID);
break;
}
case 'row_not_found':
return 'Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.';
return t('Diese Karte gibt es nicht (mehr) oder sie gehört nicht zu deinem Konto.');
case 'storage_file_not_found':
return 'Das Bild dieser Karte fehlt.';
return t('Das Bild dieser Karte fehlt.');
case 'general_unknown_origin':
return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.';
return t('Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.');
case 'user_session_already_exists':
return 'Du bist schon angemeldet. Bitte lade die Seite neu.';
return t('Du bist schon angemeldet. Bitte lade die Seite neu.');
case 'user_count_exceeded':
return 'Zurzeit sind keine neuen Konten möglich.';
return t('Zurzeit sind keine neuen Konten möglich.');
}
if (field(err, 'code') === 429) return 'Zu viele Versuche – bitte kurz warten.';
if (err instanceof TypeError) return 'Keine Verbindung zum Server.';
return GENERIC;
if (field(err, 'code') === 429) return t('Zu viele Versuche – bitte kurz warten.');
if (err instanceof TypeError) return t('Keine Verbindung zum Server.');
return t(GENERIC);
}
+407 -4
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
get: vi.fn(),
@@ -9,9 +9,22 @@ const mocks = vi.hoisted(() => ({
updateRecovery: vi.fn(),
}));
// Die Namensprüfung im Netz und der Namensdialog sind ersetzt; Muster, Sperrliste und der Merker „schon gefragt“ bleiben echt
// (profile.ts wird nach jedem Zurücksetzen der Module mit `doMock` neu ersetzt, siehe beforeEach).
const profileMocks = vi.hoisted(() => ({
isUsernameTaken: vi.fn(),
saveUsername: vi.fn(),
usernameDialog: vi.fn(),
}));
vi.mock('./appwrite', () => ({ account: mocks }));
vi.mock('./username-dialog', () => ({ usernameDialog: profileMocks.usernameDialog }));
const fail = (code: number, type: string, message = '') => Object.assign(new Error(message), { code, type });
const INVALID =
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.';
const TAKEN = 'Dieser Benutzername ist schon vergeben.';
const PROMPTED_KEY = 'aircard.usernamePrompted';
type Dialog = typeof import('./login-dialog');
let requireLogin: Dialog['requireLogin'];
@@ -19,7 +32,17 @@ let opener: HTMLButtonElement;
beforeEach(async () => {
for (const fn of Object.values(mocks)) fn.mockReset();
for (const fn of Object.values(profileMocks)) fn.mockReset();
profileMocks.isUsernameTaken.mockResolvedValue(false); // der Name ist frei
profileMocks.saveUsername.mockImplementation(async (name: string) => name);
profileMocks.usernameDialog.mockResolvedValue(null);
sessionStorage.clear();
vi.resetModules();
vi.doMock('./profile', async () => ({
...(await vi.importActual<typeof import('./profile')>('./profile')),
isUsernameTaken: profileMocks.isUsernameTaken,
saveUsername: profileMocks.saveUsername,
}));
document.body.innerHTML = '<div id="account-slot"></div><button id="opener">Speichern</button>';
opener = document.getElementById('opener') as HTMLButtonElement;
opener.focus();
@@ -51,6 +74,7 @@ describe('requireLogin: Öffnen und Schließen', () => {
it('startet auf Wunsch mit Registrieren oder Passwort vergessen', () => {
void requireLogin('register');
expect(title()).toBe('Registrieren');
expect(input('username').autocomplete).toBe('nickname');
expect(input('password').autocomplete).toBe('new-password');
expect(input('password-repeat').autocomplete).toBe('new-password');
});
@@ -213,13 +237,14 @@ describe('Registrieren', () => {
it('prüft vor dem Senden: Abgleich, dann Länge', () => {
void requireLogin('register');
fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim124' });
submit();
expect(alertText()).toBe('Die Passwörter stimmen nicht überein.');
fill({ password: 'kurz', 'password-repeat': 'kurz' });
submit();
expect(alertText()).toBe('Das Passwort muss 8 bis 256 Zeichen lang sein.');
expect(mocks.create).not.toHaveBeenCalled();
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled(); // erst die Eingaben, die ohne Netz zu prüfen sind
});
it('legt das Konto an, meldet an und löst mit true auf', async () => {
@@ -227,7 +252,7 @@ describe('Registrieren', () => {
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
const result = requireLogin('register');
fill({ email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
fill({ username: 'Neu.Konto', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
submit();
await expect(result).resolves.toBe(true);
expect(mocks.create).toHaveBeenCalledTimes(1);
@@ -237,7 +262,7 @@ describe('Registrieren', () => {
it('bei vorhandener E-Mail steht die Meldung mit einem Link „Anmelden“', async () => {
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
void requireLogin('register');
fill({ email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
submit();
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert. Melde dich an.'));
const link = dialogEl().querySelector<HTMLButtonElement>('[role="alert"] button')!;
@@ -248,6 +273,330 @@ describe('Registrieren', () => {
});
});
describe('Registrieren: Benutzername', () => {
/** Meldet das Konto an wie ein erfolgreicher Server: anlegen, Sitzung, Nutzer. */
function serverAccepts(): void {
mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
}
const fields = () => ({ username: 'Kenso', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
const names = () => [...dialogEl().querySelectorAll('input')].map((i) => i.name);
it('das Pflichtfeld „Benutzername“ steht vor der E-Mail, mit Hinweis, und hat zuerst den Fokus', () => {
void requireLogin('register');
expect(names()).toEqual(['username', 'email', 'password', 'password-repeat']);
const field = input('username');
expect(field.type).toBe('text');
expect(field.required).toBe(true);
expect(field.autocomplete).toBe('nickname');
expect(field.getAttribute('autocapitalize')).toBe('none');
expect(field.getAttribute('spellcheck')).toBe('false');
expect(field.labels![0].textContent).toContain('Benutzername');
const hint = dialogEl().querySelector(`#${field.getAttribute('aria-describedby')}`)!;
expect(hint.textContent).toBe('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
expect(document.activeElement).toBe(field);
});
it('kein Feld trägt autocomplete „username“: angemeldet wird per E-Mail, für Passwortmanager bleibt sie der Anmeldename', () => {
const tokens = () => [...dialogEl().querySelectorAll('input')].map((i) => i.autocomplete);
void requireLogin('register');
expect(tokens()).toEqual(['nickname', 'email', 'new-password', 'new-password']);
buttonByText('Anmelden').click();
expect(tokens()).toEqual(['email', 'current-password']);
buttonByText('Passwort vergessen?').click();
expect(tokens()).toEqual(['email']);
});
it('die Anmelden-Ansicht hat kein Namensfeld', () => {
void requireLogin('login');
expect(names()).toEqual(['email', 'password']);
});
it.each([
['leer', '', INVALID],
['nur Leerzeichen', ' ', INVALID],
['zu kurz', 'ab', INVALID],
['zu lang', 'x'.repeat(21), INVALID],
['falsches erstes Zeichen', '_abc', INVALID],
['Leerzeichen im Namen', 'ke nso', INVALID],
['Umlaut', 'Käse', INVALID],
['gesperrt', 'Admin', 'Dieser Benutzername ist nicht erlaubt.'],
['gesperrt in anderer Schreibweise', 'AirCard-IOS', 'Dieser Benutzername ist nicht erlaubt.'],
])('%s: der Fehler steht am Feld, es gibt keine Anfrage und kein Konto', (_label, name, message) => {
void requireLogin('register');
fill({ ...fields(), username: name });
submit();
expect(alertText()).toBe(message);
const field = input('username');
expect(field.getAttribute('aria-invalid')).toBe('true');
expect(field.getAttribute('aria-describedby')!.split(' ')).toContain(dialogEl().querySelector('[role="alert"]')!.id);
expect(document.activeElement).toBe(field);
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled();
expect(mocks.create).not.toHaveBeenCalled();
expect(dialogEl().open).toBe(true);
});
it('der Name wird vor den Passwörtern geprüft (Reihenfolge der Felder)', () => {
void requireLogin('register');
fill({ ...fields(), username: 'x', 'password-repeat': 'anders1234' });
submit();
expect(alertText()).toBe(INVALID);
expect(document.activeElement).toBe(input('username'));
});
it('die Markierung des Feldes fällt beim nächsten Absenden weg; der Hinweis bleibt verknüpft', async () => {
serverAccepts();
void requireLogin('register');
fill({ ...fields(), username: 'x' });
submit();
expect(input('username').getAttribute('aria-describedby')!.split(' ')).toHaveLength(2);
fill({ username: 'Kenso' });
submit();
expect(input('username').hasAttribute('aria-invalid')).toBe(false);
expect(input('username').getAttribute('aria-describedby')!.split(' ')).toHaveLength(1);
await vi.waitFor(() => expect(mocks.create).toHaveBeenCalled());
});
it('vergebener Name (Review-Fokus): Fehler am Feld, bevor ein Konto angelegt wird – kein halbes Konto', async () => {
profileMocks.isUsernameTaken.mockResolvedValue(true);
void requireLogin('register');
fill({ ...fields(), username: ' kenso ' });
dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.focus(); // der Nutzer hat den Knopf gedrückt
submit();
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
expect(profileMocks.isUsernameTaken).toHaveBeenCalledExactlyOnceWith('kenso'); // getrimmt, so wie er gespeichert würde
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.createEmailPasswordSession).not.toHaveBeenCalled();
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
const field = input('username');
expect(field.getAttribute('aria-invalid')).toBe('true');
expect(field.getAttribute('aria-describedby')!.split(' ')).toContain(dialogEl().querySelector('[role="alert"]')!.id);
expect(document.activeElement).toBe(field); // der Fokus geht an das Feld, nicht zurück auf den Knopf
expect(dialogEl().open).toBe(true);
// das Formular ist wieder bedienbar
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
expect(field.readOnly).toBe(false);
expect(input('password').value).toBe('geheim123'); // nichts wurde verworfen
});
it('nach „vergeben“ geht ein anderer Name: dann wird das Konto angelegt', async () => {
serverAccepts();
profileMocks.isUsernameTaken.mockResolvedValueOnce(true);
const result = requireLogin('register');
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
fill({ username: 'Kenso2' });
submit();
await expect(result).resolves.toBe(true);
expect(profileMocks.isUsernameTaken).toHaveBeenLastCalledWith('Kenso2');
expect(mocks.create).toHaveBeenCalledTimes(1);
expect(profileMocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso2');
});
it('lässt sich die Verfügbarkeit nicht prüfen (kein Netz), wird kein Konto angelegt und der Fehler erklärt', async () => {
profileMocks.isUsernameTaken.mockRejectedValue(new TypeError('Failed to fetch'));
let settled = false;
void requireLogin('register').then(() => (settled = true));
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toBe('Keine Verbindung zum Server.'));
expect(mocks.create).not.toHaveBeenCalled();
expect(settled).toBe(false);
expect(dialogEl().open).toBe(true);
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(false);
});
it('der Ablauf: Verfügbarkeit prüfen, Konto anlegen, anmelden, Namen speichern – erst dann ist der Dialog fertig', async () => {
serverAccepts();
const order: string[] = [];
profileMocks.isUsernameTaken.mockImplementation(async () => (order.push('prüfen'), false));
mocks.create.mockImplementation(async () => (order.push('anlegen'), {}));
mocks.createEmailPasswordSession.mockImplementation(async () => (order.push('anmelden'), {}));
profileMocks.saveUsername.mockImplementation(async (name: string) => (order.push(`speichern ${name}`), name));
const result = requireLogin('register');
fill({ ...fields(), username: ' Kenso ' });
submit();
await expect(result).resolves.toBe(true);
expect(order).toEqual(['prüfen', 'anlegen', 'anmelden', 'speichern Kenso']);
expect(mocks.create.mock.calls[0][0]).toMatchObject({ email: 'neu@b.de', password: 'geheim123' });
expect(dialogEl().open).toBe(false);
expect(profileMocks.usernameDialog).not.toHaveBeenCalled(); // Name gespeichert: nichts mehr zu fragen
});
it('während geprüft und angelegt wird, ist das Formular gesperrt und ein zweites Absenden wird ignoriert', async () => {
serverAccepts();
let finishCheck!: (taken: boolean) => void;
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
const result = requireLogin('register');
fill(fields());
submit();
submit();
expect(profileMocks.isUsernameTaken).toHaveBeenCalledTimes(1);
expect(dialogEl().querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
expect(input('username').readOnly).toBe(true);
finishCheck(false);
await expect(result).resolves.toBe(true);
expect(mocks.create).toHaveBeenCalledTimes(1);
});
it('scheitert das Speichern des Namens (Rennen, Netz), ist man trotzdem angemeldet: der Dialog schließt mit true, der Namensdialog erscheint', async () => {
serverAccepts();
const failure = new Error('409');
profileMocks.saveUsername.mockRejectedValue(failure);
const result = requireLogin('register');
fill(fields());
submit();
await expect(result).resolves.toBe(true);
expect(dialogEl().open).toBe(false);
expect(mocks.get).toHaveBeenCalled(); // angemeldet
// Der Namensdialog öffnet mit dem getippten Namen und dem Grund (er steht im Dialog), nicht leer und stumm.
expect(profileMocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'prompt', initial: 'Kenso', failure });
});
it('der getippte Name geht getrimmt in den Namensdialog, genau der Fehler des Speicherns als Grund', async () => {
serverAccepts();
const taken = Object.assign(new Error('vergeben'), { code: 409 });
profileMocks.saveUsername.mockRejectedValue(taken);
const result = requireLogin('register');
fill({ ...fields(), username: ' Mia.K ' });
submit();
await result;
expect(profileMocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Mia.K');
expect(profileMocks.usernameDialog.mock.calls[0][0]).toMatchObject({ initial: 'Mia.K' });
expect(profileMocks.usernameDialog.mock.calls[0][0].failure).toBe(taken);
});
it('der Namensdialog kommt erst, wenn der Login-Dialog zu ist, und ein Fehler beim Öffnen bleibt ohne Folgen', async () => {
serverAccepts();
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
profileMocks.saveUsername.mockRejectedValue(new Error('offline'));
let loginOpenWhenAsked: boolean | undefined;
profileMocks.usernameDialog.mockImplementation(async () => {
loginOpenWhenAsked = dialogEl().open;
throw new Error('showModal kaputt');
});
const result = requireLogin('register');
fill(fields());
submit();
await expect(result).resolves.toBe(true);
await vi.waitFor(() => expect(error).toHaveBeenCalled());
expect(loginOpenWhenAsked).toBe(false);
error.mockRestore();
});
it('das Konto-Anlegen scheitert (E-Mail vergeben): kein Namensdialog, kein Speichern', async () => {
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
void requireLogin('register');
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toContain('Diese E-Mail ist schon registriert.'));
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
expect(profileMocks.usernameDialog).not.toHaveBeenCalled();
});
it('Merker „in dieser Sitzung schon gefragt“: gesetzt, bevor die Anmeldung läuft (sie löst die einmalige Abfrage aus), und danach noch da', async () => {
serverAccepts();
let flagWhenAccountCreated: string | null | undefined;
mocks.create.mockImplementation(async () => {
flagWhenAccountCreated = sessionStorage.getItem(PROMPTED_KEY);
return {};
});
const result = requireLogin('register');
fill(fields());
submit();
await expect(result).resolves.toBe(true);
expect(flagWhenAccountCreated).toBe('1');
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
});
it('scheitert das Anlegen des Kontos, nimmt der Dialog den Merker wieder weg (wer sich danach anmeldet, wird noch gefragt)', async () => {
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
void requireLogin('register');
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toContain('schon registriert'));
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
});
it('war schon gefragt worden, bleibt der Merker auch nach einem gescheiterten Anlegen', async () => {
sessionStorage.setItem(PROMPTED_KEY, '1');
mocks.create.mockRejectedValue(fail(409, 'user_already_exists'));
void requireLogin('register');
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toContain('schon registriert'));
expect(sessionStorage.getItem(PROMPTED_KEY)).toBe('1');
});
it('wird der Dialog während der Prüfung geschlossen, entsteht kein Konto', async () => {
serverAccepts();
let finishCheck!: (taken: boolean) => void;
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
const result = requireLogin('register');
fill(fields());
submit();
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
await expect(result).resolves.toBe(false);
finishCheck(false);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mocks.create).not.toHaveBeenCalled();
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull();
});
it('wird der Dialog während der Prüfung geschlossen und der Name ist vergeben, erscheint nichts mehr', async () => {
let finishCheck!: (taken: boolean) => void;
profileMocks.isUsernameTaken.mockReturnValue(new Promise<boolean>((resolve) => (finishCheck = resolve)));
const result = requireLogin('register');
fill(fields());
submit();
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
await expect(result).resolves.toBe(false);
finishCheck(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dialogEl().querySelector('[role="alert"]')?.textContent ?? '').toBe('');
expect(mocks.create).not.toHaveBeenCalled();
});
it('der getippte Name wandert beim Wechsel der Reiter mit (wie die E-Mail), Passwörter nie', () => {
void requireLogin('register');
fill({ username: 'Kenso', email: 'a@b.de', password: 'geheim123' });
buttonByText('Anmelden').click();
expect(title()).toBe('Anmelden');
expect(input('email').value).toBe('a@b.de');
buttonByText('Registrieren').click();
expect(input('username').value).toBe('Kenso');
expect(input('email').value).toBe('a@b.de');
expect(input('password').value).toBe('');
});
it('nach dem Schließen sind Name und E-Mail weg, ein neuer Aufruf beginnt leer', async () => {
const first = requireLogin('register');
fill({ username: 'Kenso', email: 'a@b.de' });
dialogEl().querySelector<HTMLButtonElement>('.auth-close')!.click();
await first;
void requireLogin('register');
expect(input('username').value).toBe('');
expect(input('email').value).toBe('');
expect(document.activeElement).toBe(input('username'));
});
it('die anderen Ansichten bleiben unberührt: Anmelden meldet an, ohne Name zu prüfen oder zu speichern', async () => {
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' });
const result = requireLogin();
fill({ email: 'a@b.de', password: 'geheim123' });
submit();
await expect(result).resolves.toBe(true);
expect(profileMocks.isUsernameTaken).not.toHaveBeenCalled();
expect(profileMocks.saveUsername).not.toHaveBeenCalled();
expect(profileMocks.usernameDialog).not.toHaveBeenCalled();
expect(sessionStorage.getItem(PROMPTED_KEY)).toBeNull(); // die einmalige Abfrage ist Sache von main.ts
});
});
describe('Passwort vergessen', () => {
it('führt vom Anmelden zur E-Mail-Eingabe und sendet den Link', async () => {
mocks.createRecovery.mockResolvedValue({});
@@ -277,3 +626,57 @@ describe('Passwort vergessen', () => {
expect(title()).toBe('Anmelden');
});
});
describe('Registrieren: die Fehlerzeile bleibt im Sichtbaren', () => {
const fields = () => ({ username: 'Kenso', email: 'neu@b.de', password: 'geheim123', 'password-repeat': 'geheim123' });
const errorLine = () => dialogEl().querySelector('[role="alert"]');
afterEach(() => {
vi.restoreAllMocks();
});
it('ein Fehler am Namensfeld scrollt die gemeinsame Fehlerzeile (sie steht unter allen Feldern) ins Sichtbare, nur so weit wie nötig', () => {
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
void requireLogin('register');
fill({ ...fields(), username: 'x' });
submit();
expect(alertText()).toBe(INVALID);
expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' });
expect(scroll.mock.contexts[0]).toBe(errorLine());
expect(document.activeElement).toBe(input('username')); // der Fokus bleibt am Feld
});
it('ein gesperrter Name ebenso, jeder neue Fehler scrollt wieder', () => {
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
void requireLogin('register');
fill({ ...fields(), username: 'Admin' });
submit();
fill({ username: 'ab' });
submit();
expect(scroll).toHaveBeenCalledTimes(2);
expect(scroll.mock.contexts.every((node) => node === errorLine())).toBe(true);
});
it('„vergeben“ (nach der Abfrage am Dienst) ebenso', async () => {
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
profileMocks.isUsernameTaken.mockResolvedValue(true);
void requireLogin('register');
fill(fields());
submit();
await vi.waitFor(() => expect(alertText()).toBe(TAKEN));
expect(scroll).toHaveBeenCalledExactlyOnceWith({ block: 'nearest' });
expect(scroll.mock.contexts[0]).toBe(errorLine());
});
it('ohne Fehler am Feld wird nicht gescrollt (gültige Eingabe, Anmelden-Ansicht)', async () => {
const scroll = vi.spyOn(Element.prototype, 'scrollIntoView');
mocks.create.mockResolvedValue({});
mocks.createEmailPasswordSession.mockResolvedValue({});
mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' });
const registered = requireLogin('register');
fill(fields());
submit();
await registered;
expect(scroll).not.toHaveBeenCalled();
});
});
+134 -41
View File
@@ -1,19 +1,33 @@
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren und „Passwort vergessen“.
// Login-Dialog: ein gemeinsames <dialog> mit Anmelden, Registrieren (mit Benutzernamen) und „Passwort vergessen“.
import './account.css';
import { el } from '../editor/dom';
import { el, setFieldInvalid } from '../editor/dom';
import { API_MESSAGES } from '../i18n/api-messages';
import { msg, t } from '../i18n/i18n';
import { login, register, requestPasswordReset, validatePassword } from './auth';
import { errorMessage, errorType, type ErrorContext } from './errors';
import {
USERNAME_HINT,
clearUsernamePrompted,
isUsernameTaken,
markUsernamePrompted,
saveUsername,
usernamePrompted,
validateUsername,
} from './profile';
import { usernameDialog } from './username-dialog';
export type LoginView = 'login' | 'register' | 'forgot';
const TITLES: Record<LoginView, string> = {
login: 'Anmelden',
register: 'Registrieren',
forgot: 'Passwort vergessen',
login: msg('Anmelden'),
register: msg('Registrieren'),
forgot: msg('Passwort vergessen'),
};
const MISMATCH = 'Die Passwörter stimmen nicht überein.';
const RESET_SENT = 'Wir haben dir einen Link geschickt. Er gilt eine Stunde.';
const MISMATCH = msg('Die Passwörter stimmen nicht überein.');
const RESET_SENT = msg('Wir haben dir einen Link geschickt. Er gilt eine Stunde.');
/** Die Meldungszeile des Dialogs: ein Feld mit Fehler verweist mit `aria-describedby` auf sie. */
const ERROR_ID = 'auth-error';
type Field = { label: HTMLLabelElement; input: HTMLInputElement };
@@ -28,6 +42,7 @@ type Panel = {
function createLoginDialog(): (view: LoginView) => Promise<boolean> {
let view: LoginView = 'login';
let draftEmail = '';
let draftUsername = '';
let busy = false;
// Zählt jedes Öffnen/Schließen; eine Antwort, die nach dem Schließen eintrifft, wird verworfen.
let session = 0;
@@ -37,7 +52,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const closeButton = el(
'button',
{ type: 'button', class: 'auth-close', 'aria-label': 'Schließen' },
{ type: 'button', class: 'auth-close', 'aria-label': t('Schließen') },
el('span', { 'aria-hidden': 'true' }, '×'),
);
const title = el('h2', { id: 'auth-title', class: 'auth-title' });
@@ -89,7 +104,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
function field(
labelText: string,
name: string,
type: 'email' | 'password',
type: 'text' | 'email' | 'password',
autocomplete: string,
value = '',
describedBy?: string,
@@ -101,8 +116,9 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
autocomplete,
value,
required: true,
autocapitalize: type === 'email' ? 'none' : null,
spellcheck: type === 'email' ? 'false' : null,
// E-Mail-Adresse und Benutzername: keine automatische Großschreibung, keine Rechtschreibprüfung
autocapitalize: type === 'password' ? null : 'none',
spellcheck: type === 'password' ? null : 'false',
'aria-describedby': describedBy,
});
const label = el('label', { class: 'auth-field' }, el('span', { class: 'auth-label' }, labelText), input);
@@ -135,7 +151,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
'aria-controls': 'auth-panel',
tabindex: selected ? 0 : -1,
},
TITLES[tabView],
t(TITLES[tabView]),
);
tab.addEventListener('click', () => select(tabView));
tab.addEventListener('keydown', (event) => {
@@ -145,7 +161,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
});
return tab;
});
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': 'Konto' }, ...tabs);
return el('div', { class: 'auth-tabs', role: 'tablist', 'aria-label': t('Konto') }, ...tabs);
}
/** Setzt oder sperrt alle Bedienelemente, solange eine Anfrage läuft; Eingabefelder bleiben fokussierbar. */
@@ -161,25 +177,40 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
panel.errorEl.replaceChildren(text, ...(extra ? [' ', extra] : []));
}
/**
* Ein Fehler am Eingabefeld, das ihn verursacht: der Text steht in der Meldungszeile, das Feld gilt als ungültig und bekommt den Fokus.
* Die Meldungszeile steht unter allen Feldern, bei einem Fehler am ersten (dem Namen) kann sie auf einem kleinen Bildschirm außerhalb
* liegen: sie wird ins Sichtbare gerückt, nur so weit wie nötig.
*/
function showFieldError(panel: Panel, input: HTMLInputElement, text: string): void {
showError(panel, text);
setFieldInvalid(input, ERROR_ID, true);
input.focus();
panel.errorEl.scrollIntoView({ block: 'nearest' });
}
function showOk(panel: Panel, text: string): void {
panel.errorEl.textContent = '';
panel.okEl.textContent = text;
}
/** Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück. */
/**
* Führt eine Anfrage aus: sperrt das Formular, zeigt Fehler und gibt danach den Fokus zurück: an das Feld, das die Aktion nennt
* (sie hat dort einen Fehler gezeigt), sonst dorthin, wo er vorher war.
*/
async function run(
panel: Panel,
ctx: ErrorContext,
action: () => Promise<void>,
action: () => Promise<HTMLElement | void>,
onError?: (err: unknown) => Node | undefined,
): Promise<void> {
const mine = session;
const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setBusy(panel, true);
panel.errorEl.textContent = '';
panel.okEl.textContent = '';
try {
await action();
focused = (await action()) ?? focused;
} catch (err) {
if (mine !== session) return;
showError(panel, errorMessage(err, ctx), onError?.(err));
@@ -189,17 +220,63 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
if (focused?.isConnected && dialog.contains(focused)) focused.focus();
}
/**
* Registrieren mit Benutzernamen (Spec § 5): erst die Verfügbarkeit des Namens, dann Konto anlegen und anmelden, zuletzt den Namen speichern.
* Ist der Name vergeben, steht der Fehler am Feld, bevor ein Konto entsteht (sonst bliebe ein Konto ohne Namen zurück). Scheitert nur das
* Speichern (Rennen um den Namen, Netz weg), ist man trotzdem angemeldet: der Dialog schließt, und der Namensdialog fragt noch einmal.
*/
async function registerWithName(
panel: Panel,
nameInput: HTMLInputElement,
name: string,
address: string,
password: string,
): Promise<HTMLElement | void> {
const mine = session;
const taken = await isUsernameTaken(name);
if (mine !== session) return; // der Dialog wurde inzwischen geschlossen: nichts mehr anlegen, nichts mehr zeigen
if (taken) {
showFieldError(panel, nameInput, t(API_MESSAGES.usernameTaken));
return nameInput;
}
// Schon die Anmeldung löst in main.ts die einmalige Abfrage nach dem Namen aus; hier wurde er gerade erst eingegeben. Darum steht der
// Merker „in dieser Sitzung schon gefragt“ vor dem Anlegen. Scheitert es, nimmt der Dialog ihn wieder weg (wer sich danach anmeldet, wird gefragt).
const asked = usernamePrompted();
markUsernamePrompted();
try {
await register(address, password);
} catch (err) {
if (!asked) clearUsernamePrompted();
throw err;
}
let failure: unknown;
let saved = true;
try {
await saveUsername(name);
} catch (err) {
saved = false;
failure = err;
}
finish(true);
if (!saved) {
// Der Namensdialog beginnt mit dem getippten Namen und nennt den Grund (vergeben, kein Netz …), statt leer und stumm zu erscheinen.
usernameDialog({ mode: 'prompt', initial: name, failure }).catch((err: unknown) => {
console.error('Der Dialog „Benutzername“ konnte nicht geöffnet werden', err);
});
}
}
function buildPanel(forView: LoginView): Panel {
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
const okEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
const form = el('form', { class: 'auth-form' });
const submit = (text: string) => el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, text);
if (forView === 'login') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'current-password');
const forgot = linkButton('Passwort vergessen?', () => showView('forgot'));
form.append(email.label, password.label, errorEl, okEl, submit('Anmelden'), el('p', { class: 'auth-aside' }, forgot));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field(t('Passwort'), 'password', 'password', 'current-password');
const forgot = linkButton(t('Passwort vergessen?'), () => showView('forgot'));
form.append(email.label, password.label, errorEl, okEl, submit(t('Anmelden')), el('p', { class: 'auth-aside' }, forgot));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
@@ -214,41 +291,52 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
}
if (forView === 'register') {
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const password = field('Passwort', 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field('Passwort wiederholen', 'password-repeat', 'password', 'new-password');
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, 'Mindestens 8 Zeichen.');
form.append(email.label, password.label, hint, repeat.label, errorEl, okEl, submit('Registrieren'));
const panel: Panel = { form, first: draftEmail ? password.input : email.input, errorEl, okEl };
// `nickname`, nicht `username`: angemeldet wird per E-Mail, und ein Passwortmanager soll das E-Mail-Feld als Anmeldenamen behalten.
const username = field(t('Benutzername'), 'username', 'text', 'nickname', draftUsername, 'auth-name-hint');
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const password = field(t('Passwort'), 'password', 'password', 'new-password', '', 'auth-pw-hint');
const repeat = field(t('Passwort wiederholen'), 'password-repeat', 'password', 'new-password');
const nameHint = el('p', { class: 'auth-hint', id: 'auth-name-hint' }, t(USERNAME_HINT));
const hint = el('p', { class: 'auth-hint', id: 'auth-pw-hint' }, t('Mindestens 8 Zeichen.'));
form.append(username.label, nameHint, email.label, password.label, hint, repeat.label, errorEl, okEl, submit(t('Registrieren')));
// Der Fokus geht an das erste Feld, das noch leer ist.
const first = !draftUsername ? username.input : !draftEmail ? email.input : password.input;
const panel: Panel = { form, first, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy) return;
setFieldInvalid(username.input, ERROR_ID, false);
const name = username.input.value.trim(); // der Dienst trimmt nicht
const address = email.input.value.trim();
const problem = password.input.value !== repeat.input.value ? MISMATCH : validatePassword(password.input.value);
// Der Fehler liegt am Feld, das ihn verursacht (in der Reihenfolge der Felder); verglichen wird nie mit dem (übersetzten) Text.
const nameProblem = validateUsername(name);
if (nameProblem) {
showFieldError(panel, username.input, nameProblem);
return;
}
const mismatch = password.input.value !== repeat.input.value;
const problem = mismatch ? t(MISMATCH) : validatePassword(password.input.value);
if (problem) {
showError(panel, problem);
(problem === MISMATCH ? repeat : password).input.focus();
(mismatch ? repeat : password).input.focus();
return;
}
void run(
panel,
'register',
async () => {
await register(address, password.input.value);
finish(true);
},
() => registerWithName(panel, username.input, name, address, password.input.value),
(err) => {
if (errorType(err) !== 'user_already_exists') return undefined;
return linkButton('Anmelden', () => showView('login'));
return linkButton(t('Anmelden'), () => showView('login'));
},
);
});
return panel;
}
const email = field('E-Mail', 'email', 'email', 'email', draftEmail);
const back = linkButton('Zurück zur Anmeldung', () => showView('login'));
form.append(email.label, errorEl, okEl, submit('Link senden'), el('p', { class: 'auth-aside' }, back));
const email = field(t('E-Mail'), 'email', 'email', 'email', draftEmail);
const back = linkButton(t('Zurück zur Anmeldung'), () => showView('login'));
form.append(email.label, errorEl, okEl, submit(t('Link senden')), el('p', { class: 'auth-aside' }, back));
const panel: Panel = { form, first: email.input, errorEl, okEl };
form.addEventListener('submit', (event) => {
event.preventDefault();
@@ -256,7 +344,7 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
const address = email.input.value.trim();
void run(panel, 'recover', async () => {
await requestPasswordReset(address);
showOk(panel, RESET_SENT);
showOk(panel, t(RESET_SENT));
});
});
return panel;
@@ -264,11 +352,13 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
/** Zeigt eine Ansicht; `focus` bestimmt, ob danach das erste Feld oder der gewählte Reiter den Fokus bekommt. */
function showView(next: LoginView, focus: 'input' | 'tab' = 'input'): void {
// Die eingegebene E-Mail wandert beim Wechsel in die neue Ansicht mit; Passwörter nie.
// Die eingegebene E-Mail und der Benutzername wandern beim Wechsel in die neue Ansicht mit; Passwörter nie.
const typed = content.querySelector<HTMLInputElement>('input[name="email"]');
if (typed) draftEmail = typed.value.trim();
const typedName = content.querySelector<HTMLInputElement>('input[name="username"]');
if (typedName) draftUsername = typedName.value.trim();
view = next;
title.textContent = TITLES[next];
title.textContent = t(TITLES[next]);
const panel = buildPanel(next);
if (next === 'forgot') {
content.replaceChildren(panel.form);
@@ -284,10 +374,13 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
if (pending) return pending;
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
draftEmail = '';
draftUsername = '';
busy = false;
pending = new Promise<boolean>((resolve) => {
settle = resolve;
});
// Der Dialog bleibt zwischen den Aufrufen im DOM; die Sprache der Seite kann sich seit dem Bauen geändert haben.
closeButton.setAttribute('aria-label', t('Schließen'));
showView(initial);
try {
dialog.showModal();
+661
View File
@@ -0,0 +1,661 @@
import { createRequire } from 'node:module';
import { Query } from 'appwrite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
listRows: vi.fn(),
getRow: vi.fn(),
apiPost: vi.fn(),
userId: null as string | null,
}));
vi.mock('./appwrite', () => ({
IDS: { database: 'db', profiles: 'profiles-table' },
tables: { listRows: mocks.listRows, getRow: mocks.getRow },
account: {},
client: {},
storage: {},
APPWRITE_ENDPOINT: '',
}));
vi.mock('./auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: () => () => {}, onLogout: () => () => {} }));
// Nur der Netzaufruf ist ersetzt; ApiError und unavailableError bleiben echt.
vi.mock('../community/api', async (importOriginal) => ({
...(await importOriginal<typeof import('../community/api')>()),
apiPost: mocks.apiPost,
}));
// Muster, Sperrliste und Schlüssel liegen im Dienst (deploy/api/common.js, CommonJS ohne Abhängigkeiten); der Test lädt sie von dort.
const server = createRequire(import.meta.url)('../../../deploy/api/common.js') as {
USERNAME_PATTERN: RegExp;
RESERVED_USERNAMES: readonly string[];
usernameKey: (name: string) => string;
isReservedUsername: (name: string) => boolean;
};
const INVALID = 'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.';
const RESERVED = 'Dieser Benutzername ist nicht erlaubt.';
const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type });
type Profile = typeof import('./profile');
let profile: Profile;
/** Das Modul hält den Zwischenspeicher der Namen: jeder Test bekommt eine frische Instanz (samt i18n und ApiError). */
let ApiError: typeof import('../community/api').ApiError;
beforeEach(async () => {
for (const fn of [mocks.listRows, mocks.getRow, mocks.apiPost]) fn.mockReset();
mocks.userId = null;
sessionStorage.clear();
vi.resetModules();
profile = await import('./profile');
({ ApiError } = await import('../community/api'));
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Regeln im Gleichlauf mit dem API-Dienst (deploy/api/common.js)', () => {
it('der Dienst liefert seine Regeln (sonst bestünde der Vergleich leer)', () => {
expect(server.RESERVED_USERNAMES.length).toBeGreaterThan(20);
expect(server.USERNAME_PATTERN.test('Kenso')).toBe(true);
});
it('das Muster ist dasselbe: genau ^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$', () => {
expect(profile.USERNAME_PATTERN.source).toBe('^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$');
expect(profile.USERNAME_PATTERN.source).toBe(server.USERNAME_PATTERN.source);
expect(profile.USERNAME_PATTERN.flags).toBe(server.USERNAME_PATTERN.flags);
});
it('die Sperrliste ist dieselbe (Einträge und Reihenfolge)', () => {
expect([...profile.RESERVED_USERNAMES]).toEqual([...server.RESERVED_USERNAMES]);
});
it('jeder gesperrte Schlüssel passt zum Muster (sonst wäre er wirkungslos: der Name scheiterte schon als ungültig)', () => {
const unusable = profile.RESERVED_USERNAMES.filter((key) => !profile.USERNAME_PATTERN.test(key));
expect(unusable).toEqual([]);
});
it('Schlüssel und Sperre urteilen wie der Dienst', () => {
for (const name of ['Kenso', 'KENSO', 'Admin', 'AirCard-IOS', 'aircard.ios', 'Mod', 'modx', 'a.b_c-d', 'Hilfe']) {
expect(profile.usernameKey(name)).toBe(server.usernameKey(name));
expect(profile.validateUsername(name) === RESERVED).toBe(server.isReservedUsername(name));
}
});
});
describe('usernameKey', () => {
it('der Name in Kleinbuchstaben', () => {
expect(profile.usernameKey('Kenso')).toBe('kenso');
expect(profile.usernameKey('AirCard-IOS')).toBe('aircard-ios');
expect(profile.usernameKey('a.B_c-9')).toBe('a.b_c-9');
});
});
describe('validateUsername', () => {
it.each(['abc', 'a1b', 'Kenso', 'k.e_n-so', '0abc', '9..', 'x'.repeat(20), 'ABC', 'admin1', 'administrators', 'momo'])('gültig: %s', (name) => {
expect(profile.validateUsername(name)).toBeNull();
});
it('Grenzen: 3 Zeichen sind das Minimum, 20 das Maximum', () => {
expect(profile.validateUsername('ab')).toBe(INVALID);
expect(profile.validateUsername('abc')).toBeNull();
expect(profile.validateUsername('x'.repeat(20))).toBeNull();
expect(profile.validateUsername('x'.repeat(21))).toBe(INVALID);
});
it('das erste Zeichen ist ein Buchstabe oder eine Zahl, kein Satzzeichen', () => {
for (const name of ['_abc', '.abc', '-abc']) expect(profile.validateUsername(name)).toBe(INVALID);
for (const name of ['a_c', 'a.c', 'a-c', '1bc']) expect(profile.validateUsername(name)).toBeNull();
});
it.each(['', ' ', 'ab c', ' abc', 'abc ', 'abc\n', 'ab\tc', 'ab@c', 'ab/c', 'Käse', 'ünder', 'naïve', 'na😀me', 'ab:c', 'abc'])(
'ungültig (Muster): %j',
(name) => {
expect(profile.validateUsername(name)).toBe(INVALID);
},
);
it('gesperrt, in jeder Schreibweise', () => {
for (const key of profile.RESERVED_USERNAMES) {
expect(profile.validateUsername(key), key).toBe(RESERVED);
expect(profile.validateUsername(key.toUpperCase()), key).toBe(RESERVED);
}
expect(profile.validateUsername('Admin')).toBe(RESERVED);
expect(profile.validateUsername('AirCard-IOS')).toBe(RESERVED);
expect(profile.validateUsername('aIrCaRd.IoS')).toBe(RESERVED);
});
it('das Muster geht vor der Sperrliste', () => {
expect(profile.validateUsername('_admin')).toBe(INVALID);
});
it('der Text folgt der Sprache der Seite', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
expect(profile.validateUsername('a')).toBe(
'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.',
);
expect(profile.validateUsername('Admin')).toBe('This username is not allowed.');
});
});
describe('IDS', () => {
it('kennt die Tabelle profiles (Name wie im Setup-Skript)', async () => {
vi.stubEnv('VITE_APPWRITE_ENDPOINT', 'https://aircard.test/v1');
try {
const { IDS } = await vi.importActual<typeof import('./appwrite')>('./appwrite');
expect(IDS.profiles).toBe('profiles');
expect(IDS.database).toBe('aircard'); // und das Bestehende bleibt
expect(IDS.logos).toBe('logos');
} finally {
vi.unstubAllEnvs();
}
});
});
describe('getUsername', () => {
it('liest die Zeile mit der Konto-ID als Zeilen-ID', async () => {
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso', usernameKey: 'kenso' });
await expect(profile.getUsername('u1')).resolves.toBe('Kenso');
expect(mocks.getRow).toHaveBeenCalledExactlyOnceWith({ databaseId: 'db', tableId: 'profiles-table', rowId: 'u1' });
});
it('kein Profil (404) ergibt null', async () => {
mocks.getRow.mockRejectedValue(notFound());
await expect(profile.getUsername('u1')).resolves.toBeNull();
});
it('eine Zeile ohne lesbaren Namen ergibt null', async () => {
mocks.getRow.mockResolvedValue({ $id: 'u1' });
await expect(profile.getUsername('u1')).resolves.toBeNull();
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 42 });
await expect(profile.getUsername('u1')).resolves.toBeNull();
});
it('eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage', async () => {
for (const id of ['', '../x', 'a b', 'x'.repeat(40)]) await expect(profile.getUsername(id)).resolves.toBeNull();
expect(mocks.getRow).not.toHaveBeenCalled();
});
it('andere Fehler (Netz, 500, fehlende Tabelle) gehen durch', async () => {
const offline = new TypeError('Failed to fetch');
mocks.getRow.mockRejectedValueOnce(offline);
await expect(profile.getUsername('u1')).rejects.toBe(offline);
const server500 = Object.assign(new Error('boom'), { code: 500, type: 'general_server_error' });
mocks.getRow.mockRejectedValueOnce(server500);
await expect(profile.getUsername('u1')).rejects.toBe(server500);
const noTable = notFound('table_not_found');
mocks.getRow.mockRejectedValueOnce(noTable);
await expect(profile.getUsername('u1')).rejects.toBe(noTable);
});
});
describe('usernamesFor', () => {
const row = (id: string, username: string) => ({ $id: id, username, usernameKey: username.toLowerCase() });
it('sammelt die IDs in einer einzigen Abfrage (equal auf $id)', async () => {
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso'), row('u2', 'Mia')] });
const names = await profile.usernamesFor(['u1', 'u2']);
expect([...names]).toEqual([
['u1', 'Kenso'],
['u2', 'Mia'],
]);
expect(mocks.listRows).toHaveBeenCalledExactlyOnceWith({
databaseId: 'db',
tableId: 'profiles-table',
queries: [Query.equal('$id', ['u1', 'u2']), Query.limit(2)],
});
});
it('doppelte IDs zählen einmal, Unbrauchbares wird gar nicht erst gefragt', async () => {
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso')] });
await profile.usernamesFor(['u1', 'u1', '', '../x', 'u1']);
expect(mocks.listRows.mock.calls[0][0].queries).toEqual([Query.equal('$id', ['u1']), Query.limit(1)]);
});
it('nichts zu fragen: leere Map ohne Anfrage', async () => {
expect((await profile.usernamesFor([])).size).toBe(0);
expect((await profile.usernamesFor(['', 'a b'])).size).toBe(0);
expect(mocks.listRows).not.toHaveBeenCalled();
});
it('merkt sich die Namen: dieselben IDs fragen nicht noch einmal, neue IDs allein', async () => {
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso'), row('u2', 'Mia')] });
await profile.usernamesFor(['u1', 'u2']);
const again = await profile.usernamesFor(['u2', 'u1']);
expect([...again].sort()).toEqual([
['u1', 'Kenso'],
['u2', 'Mia'],
]);
expect(mocks.listRows).toHaveBeenCalledTimes(1);
mocks.listRows.mockResolvedValueOnce({ rows: [row('u3', 'Lea')] });
const more = await profile.usernamesFor(['u1', 'u3']);
expect(mocks.listRows).toHaveBeenCalledTimes(2);
expect(mocks.listRows.mock.calls[1][0].queries).toEqual([Query.equal('$id', ['u3']), Query.limit(1)]);
expect([...more].sort()).toEqual([
['u1', 'Kenso'],
['u3', 'Lea'],
]);
});
it('Konten ohne Profil fehlen in der Map und werden beim nächsten Mal wieder gefragt (sie können sich später einen Namen wählen)', async () => {
mocks.listRows.mockResolvedValue({ rows: [row('u1', 'Kenso')] });
const names = await profile.usernamesFor(['u1', 'u9']);
expect(names.has('u9')).toBe(false);
await profile.usernamesFor(['u9']);
expect(mocks.listRows).toHaveBeenCalledTimes(2);
});
it('Zeilen ohne lesbaren Namen zählen nicht', async () => {
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u1' }, { $id: 'u2', username: '' }, { $id: 'u3', username: 7 }, row('u4', 'Mia')] });
const names = await profile.usernamesFor(['u1', 'u2', 'u3', 'u4']);
expect([...names]).toEqual([['u4', 'Mia']]);
});
it('Fehler ergeben eine leere Map statt eines Fehlers und merken nichts', async () => {
mocks.listRows.mockRejectedValueOnce(new TypeError('Failed to fetch'));
const names = await profile.usernamesFor(['u1', 'u2']);
expect(names).toBeInstanceOf(Map);
expect(names.size).toBe(0);
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso')] });
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Kenso']]); // der nächste Versuch fragt wieder
});
it('bei einem Fehler bleiben die schon bekannten Namen erhalten', async () => {
mocks.listRows.mockResolvedValueOnce({ rows: [row('u1', 'Kenso')] });
await profile.usernamesFor(['u1']);
mocks.listRows.mockRejectedValueOnce(Object.assign(new Error('boom'), { code: 500 }));
const names = await profile.usernamesFor(['u1', 'u2']);
expect([...names]).toEqual([['u1', 'Kenso']]);
});
it('viele IDs gehen in Gruppen zu je 100 (eine Gruppe pro Abfrage)', async () => {
const ids = Array.from({ length: 250 }, (_, i) => `u${i}`);
mocks.listRows.mockImplementation(async ({ queries }: { queries: string[] }) => {
const asked = (JSON.parse(queries[0]) as { values: string[] }).values;
return { rows: asked.map((id) => row(id, `Name${id}`)) };
});
const names = await profile.usernamesFor(ids);
expect(names.size).toBe(250);
expect(mocks.listRows.mock.calls.map((call) => (JSON.parse(call[0].queries[0]) as { values: string[] }).values.length)).toEqual([100, 100, 50]);
expect(mocks.listRows.mock.calls.map((call) => call[0].queries[1])).toEqual([Query.limit(100), Query.limit(100), Query.limit(50)]);
});
it('ein Name, der während der Abfrage durch Umbenennen im Speicher landet, ist neuer als die Antwort', async () => {
mocks.userId = 'u1';
let answer!: (value: unknown) => void;
mocks.listRows.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const pending = profile.usernamesFor(['u1']);
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
await profile.saveUsername('Neu');
answer({ rows: [row('u1', 'Alt')] });
expect([...(await pending)]).toEqual([['u1', 'Neu']]);
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Neu']]);
});
});
describe('isUsernameTaken', () => {
it('fragt den Schlüssel (klein geschrieben) mit equal ab, eine Zeile genügt', async () => {
mocks.listRows.mockResolvedValue({ rows: [] });
await profile.isUsernameTaken('KenSo');
expect(mocks.listRows).toHaveBeenCalledExactlyOnceWith({
databaseId: 'db',
tableId: 'profiles-table',
queries: [Query.equal('usernameKey', 'kenso'), Query.limit(1)],
});
});
it('vergeben, wenn ein Profil den Schlüssel hat', async () => {
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u2', username: 'kenso', usernameKey: 'kenso' }] });
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(true);
});
it('frei, wenn keine Zeile passt', async () => {
mocks.listRows.mockResolvedValue({ rows: [] });
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(false);
});
it('der eigene Eintrag zählt nicht (nur die Schreibweise des eigenen Namens ändern ist erlaubt)', async () => {
mocks.userId = 'u1';
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u1', username: 'kenso', usernameKey: 'kenso' }] });
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(false);
mocks.listRows.mockResolvedValue({ rows: [{ $id: 'u2', username: 'kenso', usernameKey: 'kenso' }] });
await expect(profile.isUsernameTaken('Kenso')).resolves.toBe(true);
});
it('Fehler gehen durch (die Registrierung zeigt sie am Dialog)', async () => {
const offline = new TypeError('Failed to fetch');
mocks.listRows.mockRejectedValue(offline);
await expect(profile.isUsernameTaken('Kenso')).rejects.toBe(offline);
});
});
describe('saveUsername', () => {
beforeEach(() => {
mocks.userId = 'u1';
});
it('sendet POST /api/profil mit Anmeldung und gibt den Namen der Antwort zurück', async () => {
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
expect(mocks.apiPost).toHaveBeenCalledExactlyOnceWith('/api/profil', { username: 'Kenso' }, { auth: true });
});
it('aktualisiert den Zwischenspeicher: myUsername und usernamesFor kennen den Namen sofort, ohne Abfrage', async () => {
expect(profile.myUsername()).toBeNull();
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await profile.saveUsername('Kenso');
expect(profile.myUsername()).toBe('Kenso');
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Kenso']]);
expect(mocks.listRows).not.toHaveBeenCalled();
});
it('meldet die Änderung mit Konto-ID und Name an alle Hörer; abgemeldete Hörer hören nichts mehr', async () => {
const first = vi.fn();
const second = vi.fn();
const off = profile.onUsernameChange(first);
profile.onUsernameChange(second);
mocks.apiPost.mockResolvedValueOnce({ username: 'Kenso' });
await profile.saveUsername('Kenso');
expect(first).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
expect(second).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
off();
mocks.apiPost.mockResolvedValueOnce({ username: 'Neu' });
await profile.saveUsername('Neu');
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenLastCalledWith('u1', 'Neu');
expect(profile.myUsername()).toBe('Neu');
});
it('die Schreibweise der Antwort gilt (der Dienst speichert den Namen, wie er eingegeben wurde)', async () => {
mocks.apiPost.mockResolvedValue({ username: 'KenSo' });
await expect(profile.saveUsername('kenso')).resolves.toBe('KenSo');
expect(profile.myUsername()).toBe('KenSo');
});
it('eine Antwort ohne Namen ist ein unklarer Ausgang: Fehler 503, nichts gemerkt, nichts gemeldet', async () => {
const listener = vi.fn();
profile.onUsernameChange(listener);
for (const body of [{}, { username: '' }, { username: 5 }]) {
mocks.apiPost.mockResolvedValueOnce(body);
const err = await profile.saveUsername('Kenso').catch((e: unknown) => e);
expect(err).toBeInstanceOf(ApiError);
expect((err as InstanceType<typeof ApiError>).serverCode).toBe('unavailable');
}
expect(profile.myUsername()).toBeNull();
expect(listener).not.toHaveBeenCalled();
});
it('Fehler des Dienstes gehen unverändert durch, der Zwischenspeicher bleibt', async () => {
mocks.apiPost.mockResolvedValueOnce({ username: 'Kenso' });
await profile.saveUsername('Kenso');
const listener = vi.fn();
profile.onUsernameChange(listener);
const taken = new ApiError(409, 'Dieser Benutzername ist schon vergeben.', undefined, 'usernameTaken');
mocks.apiPost.mockRejectedValueOnce(taken);
await expect(profile.saveUsername('Mia')).rejects.toBe(taken);
expect(profile.myUsername()).toBe('Kenso');
expect(listener).not.toHaveBeenCalled();
});
it('ein Hörer, der wirft, stört weder die anderen noch das Ergebnis', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const later = vi.fn();
profile.onUsernameChange(() => {
throw new Error('kaputt');
});
profile.onUsernameChange(later);
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
expect(later).toHaveBeenCalledOnce();
expect(error).toHaveBeenCalledOnce();
});
it('ohne bekanntes Konto geht der Aufruf durch, gemerkt und gemeldet wird nichts', async () => {
mocks.userId = null;
const listener = vi.fn();
profile.onUsernameChange(listener);
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await expect(profile.saveUsername('Kenso')).resolves.toBe('Kenso');
expect(listener).not.toHaveBeenCalled();
expect(profile.myUsername()).toBeNull();
});
it('gemerkt wird für das Konto, das den Aufruf gestartet hat, auch wenn inzwischen ein anderes angemeldet ist', async () => {
let answer!: (value: unknown) => void;
mocks.apiPost.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const saving = profile.saveUsername('Kenso');
mocks.userId = 'u2'; // Kontowechsel, während die Antwort unterwegs ist
answer({ username: 'Kenso' });
await saving;
expect(profile.myUsername()).toBeNull(); // u2 hat (noch) keinen Namen
mocks.userId = 'u1';
expect(profile.myUsername()).toBe('Kenso');
});
});
describe('myUsername', () => {
it('null ohne Konto und null, solange der Name nicht bekannt ist', () => {
expect(profile.myUsername()).toBeNull();
mocks.userId = 'u1';
expect(profile.myUsername()).toBeNull();
});
it('der Name gehört zum angemeldeten Konto: bei einem Wechsel zeigt myUsername den des anderen (oder keinen)', async () => {
mocks.userId = 'u1';
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso' });
await profile.loadMyUsername();
expect(profile.myUsername()).toBe('Kenso');
mocks.userId = 'u2';
expect(profile.myUsername()).toBeNull();
mocks.userId = null;
expect(profile.myUsername()).toBeNull();
mocks.userId = 'u1';
expect(profile.myUsername()).toBe('Kenso');
});
});
describe('loadMyUsername', () => {
it('ohne Konto null und keine Anfrage', async () => {
await expect(profile.loadMyUsername()).resolves.toBeNull();
expect(mocks.getRow).not.toHaveBeenCalled();
});
it('liest das Profil des angemeldeten Kontos, merkt den Namen und meldet ihn', async () => {
mocks.userId = 'u1';
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Kenso' });
const listener = vi.fn();
profile.onUsernameChange(listener);
await expect(profile.loadMyUsername()).resolves.toBe('Kenso');
expect(mocks.getRow).toHaveBeenCalledExactlyOnceWith({ databaseId: 'db', tableId: 'profiles-table', rowId: 'u1' });
expect(profile.myUsername()).toBe('Kenso');
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
});
it('liest bei jedem Aufruf neu (der Name kann auf einem anderen Gerät geändert worden sein)', async () => {
mocks.userId = 'u1';
mocks.getRow.mockResolvedValueOnce({ $id: 'u1', username: 'Alt' }).mockResolvedValueOnce({ $id: 'u1', username: 'Neu' });
await profile.loadMyUsername();
await expect(profile.loadMyUsername()).resolves.toBe('Neu');
expect(profile.myUsername()).toBe('Neu');
});
it('ohne Profil (404) ist das Ergebnis null, nichts wird gemeldet', async () => {
mocks.userId = 'u1';
mocks.getRow.mockRejectedValue(notFound());
const listener = vi.fn();
profile.onUsernameChange(listener);
await expect(profile.loadMyUsername()).resolves.toBeNull();
expect(profile.myUsername()).toBeNull();
expect(listener).not.toHaveBeenCalled();
});
it('ein Name, der inzwischen gespeichert wurde, bleibt, auch wenn die Antwort „kein Profil“ sagt (Registrierung)', async () => {
mocks.userId = 'u1';
let answer!: (error: unknown) => void;
mocks.getRow.mockReturnValue(new Promise((_, reject) => (answer = reject)));
const loading = profile.loadMyUsername();
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await profile.saveUsername('Kenso'); // das Konto wurde gerade angelegt: die Abfrage war schneller als das Speichern
answer(notFound());
await expect(loading).resolves.toBe('Kenso');
expect(profile.myUsername()).toBe('Kenso');
});
it('andere Fehler gehen durch (der Aufrufer entscheidet; „unbekannt“ ist nicht „kein Name“)', async () => {
mocks.userId = 'u1';
const offline = new TypeError('Failed to fetch');
mocks.getRow.mockRejectedValue(offline);
await expect(profile.loadMyUsername()).rejects.toBe(offline);
});
it('eine Antwort, die vor dem Umbenennen abgeschickt wurde, meldet den alten Namen nicht noch einmal: der neuere Name gilt', async () => {
mocks.userId = 'u1';
let answer!: (row: unknown) => void;
mocks.getRow.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const listener = vi.fn();
profile.onUsernameChange(listener);
const loading = profile.loadMyUsername(); // das Lesen läuft (Stand vor dem Umbenennen)
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
await profile.saveUsername('Neu'); // währenddessen umbenannt
answer({ $id: 'u1', username: 'Alt' }); // die Antwort ist älter als das Umbenennen
await expect(loading).resolves.toBe('Neu');
expect(profile.myUsername()).toBe('Neu');
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Neu'); // „Alt“ wurde nie gemeldet
expect([...(await profile.usernamesFor(['u1']))]).toEqual([['u1', 'Neu']]);
});
it('dasselbe gilt für eine Antwort „kein Profil“, die vor dem Speichern abgeschickt wurde (Registrierung)', async () => {
mocks.userId = 'u1';
let answer!: (error: unknown) => void;
mocks.getRow.mockReturnValue(new Promise((_, reject) => (answer = reject)));
const listener = vi.fn();
profile.onUsernameChange(listener);
const loading = profile.loadMyUsername();
mocks.apiPost.mockResolvedValue({ username: 'Kenso' });
await profile.saveUsername('Kenso');
answer(notFound());
await expect(loading).resolves.toBe('Kenso');
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Kenso');
});
it('ein Lesen, das erst nach dem Umbenennen beginnt, gilt wieder (der Name kann auf einem anderen Gerät geändert worden sein)', async () => {
mocks.userId = 'u1';
mocks.apiPost.mockResolvedValue({ username: 'Neu' });
await profile.saveUsername('Neu');
mocks.getRow.mockResolvedValue({ $id: 'u1', username: 'Anderswo' });
const listener = vi.fn();
profile.onUsernameChange(listener);
await expect(profile.loadMyUsername()).resolves.toBe('Anderswo');
expect(profile.myUsername()).toBe('Anderswo');
expect(listener).toHaveBeenCalledExactlyOnceWith('u1', 'Anderswo');
});
it('das Umbenennen eines anderen Kontos macht ein laufendes Lesen nicht hinfällig', async () => {
mocks.userId = 'u1';
let answer!: (row: unknown) => void;
mocks.getRow.mockReturnValue(new Promise((resolve) => (answer = resolve)));
const loading = profile.loadMyUsername();
mocks.userId = 'u2'; // der Aufruf gehört einem anderen Konto
mocks.apiPost.mockResolvedValue({ username: 'Mia' });
await profile.saveUsername('Mia');
mocks.userId = 'u1';
answer({ $id: 'u1', username: 'Kenso' });
await expect(loading).resolves.toBe('Kenso');
expect(profile.myUsername()).toBe('Kenso');
});
});
describe('byline („von @name“)', () => {
const names = new Map([
['u1', 'Kenso'],
['u2', 'Mia.K'],
]);
it('mit Profil: „von @name“ (der Name, wie er gespeichert ist), der alte Anzeigename zählt dann nicht', () => {
expect(profile.byline('u1', '', names)).toBe('von @Kenso');
expect(profile.byline('u2', 'Alter Name', names)).toBe('von @Mia.K');
});
it('ohne Profil: der früher gespeicherte Anzeigename, ohne @', () => {
expect(profile.byline('u9', 'Anna', names)).toBe('von Anna');
expect(profile.byline('u9', 'Anna', new Map())).toBe('von Anna');
});
it('ohne Profil und ohne Anzeigenamen: „von Anonym“', () => {
expect(profile.byline('u9', '', names)).toBe('von Anonym');
expect(profile.byline('u9', '', new Map())).toBe('von Anonym');
});
it('ein Eintrag ohne Konto-ID (Altbestand) fällt ebenso zurück, auch wenn die Map einen leeren Schlüssel hätte', () => {
expect(profile.byline('', 'Anna', names)).toBe('von Anna');
expect(profile.byline('', '', names)).toBe('von Anonym');
expect(profile.byline('', '', new Map([['', 'Spuk']]))).toBe('von Anonym');
});
it('ein leerer Name in der Map zählt nicht als Profil', () => {
expect(profile.byline('u3', 'Anna', new Map([['u3', '']]))).toBe('von Anna');
});
it('Werte mit geschweiften Klammern oder Markup bleiben unverändert (der eingesetzte Text wird nicht noch einmal ausgewertet)', () => {
expect(profile.byline('u9', '{name}', new Map())).toBe('von {name}');
expect(profile.byline('u9', '<b>fett</b>', new Map())).toBe('von <b>fett</b>');
});
it('Englisch: „by @name“, „by Anna“, „by Anonymous“', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
expect(profile.byline('u1', '', names)).toBe('by @Kenso');
expect(profile.byline('u9', 'Anna', names)).toBe('by Anna');
expect(profile.byline('u9', '', names)).toBe('by Anonymous');
});
it('liest nur die übergebene Map: eine Änderung dort zeigt die nächste Zeile sofort (eigene Umbenennung)', () => {
const own = new Map([['u1', 'Alt']]);
expect(profile.byline('u1', '', own)).toBe('von @Alt');
own.set('u1', 'Neu');
expect(profile.byline('u1', '', own)).toBe('von @Neu');
});
});
describe('Merker „in dieser Sitzung schon gefragt“', () => {
it('beginnt leer; markieren schreibt aircard.usernamePrompted in sessionStorage', () => {
expect(profile.usernamePrompted()).toBe(false);
profile.markUsernamePrompted();
expect(profile.usernamePrompted()).toBe(true);
expect(sessionStorage.getItem('aircard.usernamePrompted')).toBe('1');
});
it('gilt für die ganze Sitzung: auch eine frisch geladene Seite sieht ihn', async () => {
profile.markUsernamePrompted();
vi.resetModules();
const reloaded = await import('./profile');
expect(reloaded.usernamePrompted()).toBe(true);
});
it('löschen nimmt ihn wieder weg', () => {
profile.markUsernamePrompted();
profile.clearUsernamePrompted();
expect(profile.usernamePrompted()).toBe(false);
expect(sessionStorage.getItem('aircard.usernamePrompted')).toBeNull();
});
it('ist der Speicher gesperrt, wirft nichts und der Merker gilt bis zum Neuladen der Seite', () => {
const denied = () => {
throw new DOMException('blockiert', 'SecurityError');
};
vi.stubGlobal('sessionStorage', { getItem: denied, setItem: denied, removeItem: denied });
try {
expect(profile.usernamePrompted()).toBe(false);
expect(() => profile.markUsernamePrompted()).not.toThrow();
expect(profile.usernamePrompted()).toBe(true);
expect(() => profile.clearUsernamePrompted()).not.toThrow();
expect(profile.usernamePrompted()).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
});
+269
View File
@@ -0,0 +1,269 @@
// Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine
// öffentlich lesbare Zeile je Konto, Zeilen-ID = Konto-ID) und den eigenen Namen speichern (`POST /api/profil` des Dienstes
// `aircard-api`). Der Browser prüft nur vor; was gilt, entscheidet der Dienst: Er prüft Muster und Sperrliste noch einmal, und der
// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal. Dazu die Zeile „von @name“ für
// Logos und Karten (`byline`).
import { Query, type Models } from 'appwrite';
import { ID_PATTERN } from '../card/config';
import { apiPost, unavailableError } from '../community/api';
import { API_MESSAGES } from '../i18n/api-messages';
import { msg, t } from '../i18n/i18n';
import { IDS, tables } from './appwrite';
import { currentUserId } from './auth';
import { isRowMissing } from './errors';
/** Wie im Dienst (deploy/api/common.js): 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`. */
export const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
/**
* Gesperrte Namen als Schlüssel (klein geschrieben, Spec § 2): Rollen, der Dienst selbst und Platzhalter, die nach einem Konto aussehen.
* Genau die Liste des Dienstes (deploy/api/common.js, RESERVED_USERNAMES); profile.test.ts vergleicht beide.
*/
export const RESERVED_USERNAMES: readonly string[] = [
'admin',
'administrator',
'root',
'system',
'support',
'moderator',
'mod',
'aircard',
'aircardios',
'aircard-ios',
'aircard.ios',
'aircard_ios',
'webklar',
'anonym',
'anonymous',
'null',
'undefined',
'team',
'staff',
'official',
'offiziell',
'hilfe',
'help',
'kontakt',
'contact',
'info',
];
const RESERVED = new Set(RESERVED_USERNAMES);
/** Hinweis unter dem Feld „Benutzername“ (Registrieren und Namensdialog); angezeigt wird er mit `t(USERNAME_HINT)`. */
export const USERNAME_HINT = msg('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
/** Obergrenze der Konten-IDs in einer Abfrage (so viele Werte nimmt ein `equal` sicher an). */
const LOOKUP_MAX = 100;
type TableRow = Models.Row & Record<string, unknown>;
function text(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** Der Schlüssel eines Namens (Spalte `usernameKey`): klein geschrieben. Über ihn ist ein Name nur einmal vergeben („Kenso“ = „kenso“). */
export function usernameKey(name: string): string {
return name.toLowerCase();
}
/**
* Prüft einen Namen vor dem Senden: null, wenn er passt, sonst der Text für den Nutzer in der Sprache der Seite (Muster, dann Sperrliste).
* Geprüft wird genau die Zeichenkette, die übergeben wird; wer sie vom Nutzer hat, trimmt sie vorher (der Dienst tut es nicht).
*/
export function validateUsername(name: string): string | null {
if (!USERNAME_PATTERN.test(name)) return t(API_MESSAGES.usernameInvalid);
if (RESERVED.has(usernameKey(name))) return t(API_MESSAGES.usernameReserved);
return null;
}
/**
* Die Zeile „von @name“ zu einem Eintrag (Logo-Kachel, Infozeile, Detailseite einer Karte): der Benutzername des Kontos `ownerId`, soweit
* `names` (aus `usernamesFor`) ihn kennt. Ohne Profil gilt der früher gespeicherte Anzeigename (ohne @), sonst „Anonym“. In der Sprache der Seite.
*/
export function byline(ownerId: string, legacyDisplayName: string, names: ReadonlyMap<string, string>): string {
const name = ownerId === '' ? '' : (names.get(ownerId) ?? '');
if (name !== '') return t('von @{name}', { name });
return t('von {name}', { name: legacyDisplayName !== '' ? legacyDisplayName : t('Anonym') });
}
// ---- Namen merken und melden ------------------------------------------------------------------------------
/** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */
const names = new Map<string, string>();
/** Zählt die Namen, die diese Seite gespeichert hat; `savedAt` merkt je Konto den Stand beim letzten Speichern (siehe `loadMyUsername`). */
let saves = 0;
const savedAt = new Map<string, number>();
const listeners = new Set<(userId: string, username: string) => void>();
/** Merkt den Namen eines Kontos, dessen Stand diese Seite gerade selbst erfahren hat (Speichern, Lesen des eigenen Profils), und meldet ihn. */
function announce(userId: string, username: string): void {
names.set(userId, username);
// Kopie, damit sich ein Hörer beim Melden abmelden darf; ein werfender Hörer stört weder die anderen noch das Ergebnis.
for (const fn of [...listeners]) {
try {
fn(userId, username);
} catch (err) {
console.error('Ein Hörer der Namensänderung ist gescheitert', err);
}
}
}
/**
* Meldet, wenn der Name des angemeldeten Kontos feststeht oder sich ändert: nach dem Speichern und nach dem Lesen des eigenen Profils
* (`loadMyUsername`). Nicht gemeldet wird, was `usernamesFor` über andere erfährt. Gibt die Abmelde-Funktion zurück.
*/
export function onUsernameChange(fn: (userId: string, username: string) => void): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
/** Der Name des angemeldeten Kontos laut Zwischenspeicher; null, wenn es keinen hat, keiner angemeldet ist oder er noch nicht gelesen wurde. */
export function myUsername(): string | null {
const userId = currentUserId();
return userId ? (names.get(userId) ?? null) : null;
}
// ---- Lesen --------------------------------------------------------------------------------------------------
/**
* Der Name eines Kontos. Hat es kein Profil, ist das Ergebnis null (Appwrite antwortet mit 404); eine ID, die keine Appwrite-ID sein
* kann, ergibt null ohne Anfrage. Andere Fehler (Netz, Server) gehen durch.
*/
export async function getUsername(userId: string): Promise<string | null> {
if (!ID_PATTERN.test(userId)) return null;
try {
const row = await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.profiles, rowId: userId });
return text(row.username) || null;
} catch (err) {
if (isRowMissing(err)) return null;
throw err;
}
}
/**
* Die Namen der Konten für die Anzeige „von @name“: eine Abfrage für alle IDs, die noch nicht bekannt sind (Konten ohne Profil fehlen
* in der Map). Gefundene Namen werden gemerkt. Die Anzeige braucht den Namen nur als Beiwerk: schlägt die Abfrage fehl, ist das kein
* Fehler, es bleibt bei den schon bekannten Namen (ohne die ist die Map leer), und die Aufrufer greifen auf ihre Ersatznamen zurück.
*/
export async function usernamesFor(userIds: readonly string[]): Promise<Map<string, string>> {
const found = new Map<string, string>();
const missing: string[] = [];
for (const id of new Set(userIds)) {
if (!ID_PATTERN.test(id)) continue;
const known = names.get(id);
if (known === undefined) missing.push(id);
else found.set(id, known);
}
try {
for (let from = 0; from < missing.length; from += LOOKUP_MAX) {
const ids = missing.slice(from, from + LOOKUP_MAX);
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.profiles,
queries: [Query.equal('$id', ids), Query.limit(ids.length)],
});
for (const row of list.rows) {
const name = text(row.username);
if (name === '') continue;
// Steht für das Konto inzwischen ein Name im Speicher (es wurde während der Abfrage umbenannt), ist der neuer als die Antwort.
const current = names.get(row.$id) ?? name;
names.set(row.$id, current);
found.set(row.$id, current);
}
}
} catch {
// nur Beiwerk der Anzeige, siehe oben
}
return found;
}
/**
* Ist der Name schon an ein anderes Konto vergeben? Gefragt wird der Schlüssel (klein geschrieben), der eigene Eintrag des angemeldeten
* Kontos zählt nicht. Das ist die Vorabprüfung der Registrierung; bei zwei gleichzeitigen Anfragen entscheidet der Dienst.
* Fehler (Netz, Server) gehen durch.
*/
export async function isUsernameTaken(name: string): Promise<boolean> {
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.profiles,
queries: [Query.equal('usernameKey', usernameKey(name)), Query.limit(1)],
});
const own = currentUserId();
return list.rows.some((row) => row.$id !== own);
}
/**
* Liest den Namen des angemeldeten Kontos (nach dem Start und nach der Anmeldung), merkt und meldet ihn. Ergebnis: der Name, null ohne
* Profil oder ohne angemeldetes Konto. Lässt sich das Profil nicht lesen (Netz, Server), wirft der Aufruf: „unbekannt“ ist nicht „kein
* Name“, und nur bei „kein Name“ wird gefragt.
*/
export async function loadMyUsername(): Promise<string | null> {
const userId = currentUserId();
if (!userId) return null;
const started = saves;
const name = await getUsername(userId);
// Wurde der Name währenddessen gespeichert (Umbenennen, Registrierung: die Abfrage war schneller als das Speichern), ist die Antwort
// älter als der gemerkte Name: er gilt, und der alte wird nicht noch einmal gemeldet. Kein Profil heißt sonst ebenfalls „der gemerkte“.
if (name === null || (savedAt.get(userId) ?? 0) > started) return names.get(userId) ?? null;
announce(userId, name);
return name;
}
// ---- Speichern ----------------------------------------------------------------------------------------------
/**
* Speichert den eigenen Namen (`POST /api/profil`): legt das Profil an oder ändert es, der alte Name ist danach frei. Ergebnis ist der
* Name, wie der Dienst ihn gespeichert hat. Der Zwischenspeicher gilt sofort, und alle Hörer von `onUsernameChange` erfahren es.
* Fehler sind `ApiError`: 400 `usernameInvalid`/`usernameReserved`, 409 `usernameTaken`, sonst Anmeldung, Netz, Dienst.
*/
export async function saveUsername(name: string): Promise<string> {
const userId = currentUserId(); // das Konto, das den Aufruf stellt, auch wenn inzwischen ein anderes angemeldet ist
const res = await apiPost('/api/profil', { username: name }, { auth: true });
const saved = res.username;
// Eine Antwort ohne Namen ist keine Antwort des Dienstes (Proxy, Fehlseite): der Ausgang ist offen.
if (typeof saved !== 'string' || saved === '') throw unavailableError();
if (userId) {
savedAt.set(userId, ++saves);
announce(userId, saved);
}
return saved;
}
// ---- „In dieser Sitzung schon gefragt“ ---------------------------------------------------------------------
/** Merker in sessionStorage (Spec § 5): gilt für die Sitzung des Tabs, nicht für ein Konto. */
const PROMPTED_KEY = 'aircard.usernamePrompted';
/** Zusätzlich im Speicher: ist sessionStorage gesperrt, gilt „schon gefragt“ wenigstens bis die Seite neu geladen wird. */
let promptedInMemory = false;
/** Wurde in dieser Sitzung schon nach einem Benutzernamen gefragt? (Jeder Zugriff auf den Speicher steht in try/catch.) */
export function usernamePrompted(): boolean {
if (promptedInMemory) return true;
try {
return sessionStorage.getItem(PROMPTED_KEY) === '1';
} catch {
return false;
}
}
/** Merkt, dass in dieser Sitzung nach einem Benutzernamen gefragt wird. */
export function markUsernamePrompted(): void {
promptedInMemory = true;
try {
sessionStorage.setItem(PROMPTED_KEY, '1');
} catch {
// gesperrt oder voll: es bleibt beim Merker im Speicher
}
}
/** Nimmt den Merker zurück (die Registrierung hat ihn gesetzt, ist dann aber gescheitert). */
export function clearUsernamePrompted(): void {
promptedInMemory = false;
try {
sessionStorage.removeItem(PROMPTED_KEY);
} catch {
// gesperrt: nichts zu entfernen
}
}
+741
View File
@@ -0,0 +1,741 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { germanLeft } from '../i18n/english-check';
const mocks = vi.hoisted(() => ({
saveUsername: vi.fn(),
loadMyUsername: vi.fn(),
/** Der Name des angemeldeten Kontos laut Zwischenspeicher (myUsername). */
name: null as string | null,
userId: null as string | null,
}));
vi.mock('./appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('./auth', () => ({ currentUserId: () => mocks.userId, onAuthChange: () => () => {}, onLogout: () => () => {} }));
const INVALID =
'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.';
const TAKEN = 'Dieser Benutzername ist schon vergeben.';
type Dialog = typeof import('./username-dialog');
let dialog: Dialog;
/** Der Dialog hält einen Zustand (nur einer zur Zeit): jeder Test bekommt frische Module; ApiError stammt aus derselben Kopie. */
let ApiError: typeof import('../community/api').ApiError;
let opener: HTMLButtonElement;
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.username-dialog');
const input = () => dialogEl()!.querySelector<HTMLInputElement>('input[name="username"]')!;
const form = () => dialogEl()!.querySelector('form')!;
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const errorEl = () => dialogEl()!.querySelector<HTMLElement>('[role="alert"]')!;
const errorText = () => errorEl().textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
const heading = () => dialogEl()!.querySelector('h2')!.textContent;
const intro = () => dialogEl()!.querySelector('.username-text')!.textContent;
/** Die IDs in `aria-describedby` des Feldes. */
const describedBy = () => (input().getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
beforeEach(async () => {
mocks.saveUsername.mockReset();
mocks.loadMyUsername.mockReset();
mocks.name = null;
mocks.userId = null;
document.body.innerHTML = '<button id="opener">Konto</button>';
opener = document.getElementById('opener') as HTMLButtonElement;
opener.focus();
vi.resetModules();
// Nur Netzzugriff und Zwischenspeicher sind ersetzt; Muster, Sperrliste und die Texte bleiben echt. `doMock` nach jedem Zurücksetzen:
// ein einmal berechnetes `vi.mock` bliebe über `resetModules` hinweg bei der ersten Kopie von profile.ts und i18n.ts stehen.
vi.doMock('./profile', async () => ({
...(await vi.importActual<typeof import('./profile')>('./profile')),
saveUsername: mocks.saveUsername,
loadMyUsername: mocks.loadMyUsername,
myUsername: () => mocks.name,
}));
dialog = await import('./username-dialog');
({ ApiError } = await import('../community/api'));
});
afterEach(() => {
document.body.innerHTML = '';
vi.doUnmock('./profile');
vi.restoreAllMocks();
});
describe('Dialog „Benutzername wählen“: Aufbau', () => {
it('prompt: Überschrift, leeres und fokussiertes Feld, Hinweis, „Später“ und „Speichern“', () => {
void dialog.usernameDialog({ mode: 'prompt' });
expect(dialogEl()?.open).toBe(true);
expect(heading()).toBe('Benutzername wählen');
expect(intro()).toBe('Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.');
expect(input().value).toBe('');
expect(document.activeElement).toBe(input());
expect(dialogEl()!.querySelector('label')!.textContent).toBe('Benutzername');
expect(dialogEl()!.querySelector('.auth-hint')!.textContent).toBe('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Später', 'Speichern']);
expect(buttonByText('Speichern')!.type).toBe('submit');
expect(buttonByText('Später')!.type).toBe('button');
});
it('das Feld ist ein Spitzname, kein Anmeldename (autocomplete „nickname“, nie „username“): keine Großschreibung, keine Rechtschreibprüfung', () => {
void dialog.usernameDialog({ mode: 'prompt' });
expect(input().autocomplete).toBe('nickname');
expect(input().getAttribute('autocapitalize')).toBe('none');
expect(input().getAttribute('spellcheck')).toBe('false');
expect(input().type).toBe('text');
});
it('required: „Abbrechen“ statt „Später“ und der Hinweis, dass zuerst ein Name nötig ist', () => {
void dialog.usernameDialog({ mode: 'required' });
expect(heading()).toBe('Benutzername wählen');
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Abbrechen', 'Speichern']);
expect(input().value).toBe('');
});
it('change: das Feld ist mit dem aktuellen Namen vorbelegt (als Wert, nie als HTML), „Abbrechen“, Überschrift „Benutzername ändern“', () => {
mocks.name = '<b>Kenso</b>';
void dialog.usernameDialog({ mode: 'change' });
expect(heading()).toBe('Benutzername ändern');
expect(input().value).toBe('<b>Kenso</b>');
expect(dialogEl()!.querySelector('b')).toBeNull();
expect([...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent)).toEqual(['Abbrechen', 'Speichern']);
expect(document.activeElement).toBe(input());
});
it('change ohne bekannten Namen: wie „wählen“ mit „Abbrechen“, Feld leer', () => {
void dialog.usernameDialog({ mode: 'change' });
expect(heading()).toBe('Benutzername wählen');
expect(input().value).toBe('');
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Später')).toBeUndefined();
});
it('prompt und required bleiben leer, auch wenn ein Name bekannt ist (vorbelegt wird nur beim Ändern)', () => {
mocks.name = 'Kenso';
void dialog.usernameDialog({ mode: 'prompt' });
expect(input().value).toBe('');
});
it('Beschriftung und Verknüpfungen: Label gehört zum Feld, der Hinweis ist verknüpft, alle IDs sind eindeutig, die Überschrift benennt den Dialog', () => {
void dialog.usernameDialog({ mode: 'prompt' });
const label = dialogEl()!.querySelector('label')!;
expect(label.control).toBe(input());
expect(input().labels).toHaveLength(1);
const hint = dialogEl()!.querySelector('.auth-hint')!;
expect(describedBy()).toEqual([hint.id]);
expect(input().getAttribute('aria-required')).toBe('true');
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length);
expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(dialogEl()!.querySelector('h2')!.id);
expect(errorEl().getAttribute('role')).toBe('alert');
expect(form().hasAttribute('novalidate')).toBe(true); // geprüft wird selbst, mit Texten der Seite
});
});
describe('Später, Abbrechen, Schließen', () => {
it('„Später“ löst mit null auf, speichert nichts, entfernt den Dialog und gibt den Fokus zurück', async () => {
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso'; // auch ein getippter Name wird dann nicht gespeichert
buttonByText('Später')!.click();
await expect(result).resolves.toBeNull();
expect(mocks.saveUsername).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
expect(document.activeElement).toBe(opener);
});
it.each(['required', 'change'] as const)('%s: „Abbrechen“ löst mit null auf und speichert nichts', async (mode) => {
const result = dialog.usernameDialog({ mode });
buttonByText('Abbrechen')!.click();
await expect(result).resolves.toBeNull();
expect(mocks.saveUsername).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
});
it('Esc (close-Ereignis des Dialogs) löst mit null auf', async () => {
const result = dialog.usernameDialog({ mode: 'prompt' });
dialogEl()!.close();
await expect(result).resolves.toBeNull();
expect(dialogEl()).toBeNull();
});
it('ein Klick auf den Hintergrund schließt, ein Klick im Formular oder Markieren, das außerhalb endet, nicht', async () => {
const result = dialog.usernameDialog({ mode: 'prompt' });
input().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
input().click();
dialogEl()!.click(); // mousedown im Feld, click auf dem Dialog: Markieren
expect(dialogEl()?.open).toBe(true);
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.click();
await expect(result).resolves.toBeNull();
expect(dialogEl()).toBeNull();
});
it('signal: ein Abbruch schließt den Dialog mit null; ein schon abgebrochenes Signal öffnet gar nichts', async () => {
const controller = new AbortController();
const result = dialog.usernameDialog({ mode: 'prompt', signal: controller.signal });
expect(dialogEl()?.open).toBe(true);
controller.abort();
await expect(result).resolves.toBeNull();
expect(dialogEl()).toBeNull();
controller.abort();
await expect(dialog.usernameDialog({ mode: 'prompt', signal: controller.signal })).resolves.toBeNull();
expect(dialogEl()).toBeNull();
});
it('schlägt showModal fehl, wird aufgeräumt und der Fehler gemeldet; der nächste Aufruf öffnet normal', async () => {
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementationOnce(() => {
throw new Error('showModal kaputt');
});
await expect(dialog.usernameDialog({ mode: 'prompt' })).rejects.toThrow('showModal kaputt');
expect(dialogEl()).toBeNull();
spy.mockRestore();
const again = dialog.usernameDialog({ mode: 'prompt' });
expect(dialogEl()?.open).toBe(true);
buttonByText('Später')!.click();
await expect(again).resolves.toBeNull();
});
});
describe('Speichern', () => {
it('speichert den getrimmten Namen, löst mit dem Namen des Dienstes auf und schließt', async () => {
mocks.saveUsername.mockResolvedValue('KenSo');
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = ' Kenso ';
send();
await expect(result).resolves.toBe('KenSo');
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso');
expect(dialogEl()).toBeNull();
expect(document.activeElement).toBe(opener);
});
it('der Knopf „Speichern“ löst das Absenden aus (Enter im Feld ebenso: es ist ein Formular)', async () => {
mocks.saveUsername.mockResolvedValue('Kenso');
const result = dialog.usernameDialog({ mode: 'change' });
input().value = 'Kenso';
expect(buttonByText('Speichern')!.closest('form')).toBe(form());
send();
await expect(result).resolves.toBe('Kenso');
});
it('prüft vor dem Senden: Muster und Sperrliste, Text am Feld, kein Aufruf', async () => {
void dialog.usernameDialog({ mode: 'prompt' });
const invalid = async (value: string, message: string) => {
input().value = value;
send();
await settle();
expect(errorText()).toBe(message);
expect(input().getAttribute('aria-invalid')).toBe('true');
expect(describedBy()).toContain(errorEl().id); // das Feld verweist auf die Fehlerzeile
expect(document.activeElement).toBe(input());
expect(mocks.saveUsername).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
};
await invalid('', INVALID);
await invalid(' ', INVALID);
await invalid('ab', INVALID);
await invalid('x'.repeat(21), INVALID);
await invalid('_abc', INVALID);
await invalid('na me', INVALID);
await invalid('Käse', INVALID);
await invalid('Admin', 'Dieser Benutzername ist nicht erlaubt.');
await invalid('AirCard-IOS', 'Dieser Benutzername ist nicht erlaubt.');
});
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden; der Hinweis bleibt verknüpft', async () => {
mocks.saveUsername.mockResolvedValue('Kenso');
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'x';
send();
expect(describedBy()).toHaveLength(2);
input().value = 'Kenso';
send();
expect(input().hasAttribute('aria-invalid')).toBe(false);
expect(errorText()).toBe('');
expect(describedBy()).toHaveLength(1);
await result;
});
it('vergeben (409 usernameTaken): der Text steht am Feld, der Dialog bleibt offen, ein neuer Name geht', async () => {
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
await settle();
expect(errorText()).toBe(TAKEN);
expect(input().getAttribute('aria-invalid')).toBe('true');
expect(describedBy()).toContain(errorEl().id);
expect(document.activeElement).toBe(input());
expect(dialogEl()?.open).toBe(true);
expect(input().value).toBe('Kenso');
expect(buttonByText('Speichern')!.disabled).toBe(false);
expect(buttonByText('Später')!.disabled).toBe(false);
expect(input().readOnly).toBe(false);
mocks.saveUsername.mockResolvedValueOnce('Kenso2');
input().value = 'Kenso2';
send();
await expect(result).resolves.toBe('Kenso2');
expect(mocks.saveUsername).toHaveBeenCalledTimes(2);
});
it('Muster und Sperrliste des Dienstes (400) stehen ebenfalls am Feld', async () => {
mocks.saveUsername.mockRejectedValueOnce(new ApiError(400, 'x', undefined, 'usernameReserved'));
void dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
await settle();
expect(errorText()).toBe('Dieser Benutzername ist nicht erlaubt.');
expect(input().getAttribute('aria-invalid')).toBe('true');
mocks.saveUsername.mockRejectedValueOnce(new ApiError(400, 'x', undefined, 'usernameInvalid'));
send();
await settle();
expect(errorText()).toBe(INVALID);
expect(input().getAttribute('aria-invalid')).toBe('true');
});
it('Dienst nicht erreichbar: der Text steht im Dialog, das Feld gilt nicht als ungültig, der Dialog bleibt für einen neuen Versuch offen', async () => {
mocks.saveUsername.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
await settle();
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
expect(input().hasAttribute('aria-invalid')).toBe(false);
expect(describedBy()).not.toContain(errorEl().id);
expect(dialogEl()?.open).toBe(true);
expect(input().value).toBe('Kenso');
expect(statusText()).toBe('');
mocks.saveUsername.mockResolvedValueOnce('Kenso');
send();
await expect(result).resolves.toBe('Kenso');
});
it('andere Fehler: kein Netz, abgelaufene Anmeldung (401) und alles Unbekannte werden erklärt statt verschluckt', async () => {
void dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
mocks.saveUsername.mockRejectedValueOnce(new TypeError('Failed to fetch'));
send();
await settle();
expect(errorText()).toBe('Keine Verbindung zum Server.');
mocks.saveUsername.mockRejectedValueOnce(Object.assign(new Error('unauthorized'), { code: 401, type: 'general_unauthorized_scope' }));
send();
await settle();
expect(errorText()).toBe('Bitte melde dich an.');
mocks.saveUsername.mockRejectedValueOnce(new ApiError(401, 'Bitte melde dich an.', undefined, 'unauthorized'));
send();
await settle();
expect(errorText()).toBe('Bitte melde dich an.');
mocks.saveUsername.mockRejectedValueOnce(new Error('???'));
send();
await settle();
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
expect(dialogEl()?.open).toBe(true);
expect(input().hasAttribute('aria-invalid')).toBe(false);
});
it('der Fehler eines früheren Versuchs verschwindet beim nächsten', async () => {
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
await settle();
mocks.saveUsername.mockResolvedValueOnce('Kenso2');
input().value = 'Kenso2';
send();
expect(errorText()).toBe('');
expect(input().hasAttribute('aria-invalid')).toBe(false);
await result;
});
});
describe('Vorbelegung und Grund beim Öffnen (der Rückfall nach einer Registrierung, deren letzter Schritt scheiterte)', () => {
it('`initial` belegt das Feld vor, auch im Modus „prompt“ (Überschrift „wählen“, „Später“), als Wert und nie als HTML; der Text ist markiert', () => {
void dialog.usernameDialog({ mode: 'prompt', initial: '<b>Kenso</b>' });
expect(input().value).toBe('<b>Kenso</b>');
expect(dialogEl()!.querySelector('b')).toBeNull();
expect(heading()).toBe('Benutzername wählen');
expect(buttonByText('Später')).toBeTruthy();
expect(document.activeElement).toBe(input());
expect(input().selectionStart).toBe(0);
expect(input().selectionEnd).toBe('<b>Kenso</b>'.length);
expect(errorText()).toBe(''); // ohne `failure` keine Meldung
});
it('`initial` geht dem Namen des Kontos im Modus „change“ vor', () => {
mocks.name = 'Alt';
void dialog.usernameDialog({ mode: 'change', initial: 'Getippt' });
expect(input().value).toBe('Getippt');
expect(heading()).toBe('Benutzername ändern');
});
it('`failure` mit einem Fehler am Namen (vergeben): der Grund steht schon da, am Feld, mit Markierung und Fokus', () => {
void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
expect(errorText()).toBe(TAKEN);
expect(input().getAttribute('aria-invalid')).toBe('true');
expect(describedBy()).toContain(errorEl().id);
expect(document.activeElement).toBe(input());
expect(input().value).toBe('Kenso');
expect(dialogEl()?.open).toBe(true);
});
it('`failure` ohne Bezug zum Namen (Netz, Dienst): der Grund steht als Text, das Feld gilt nicht als ungültig, der Name bleibt zum Wiederholen', () => {
void dialog.usernameDialog({
mode: 'prompt',
initial: 'Kenso',
failure: new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'),
});
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
expect(input().hasAttribute('aria-invalid')).toBe(false);
expect(describedBy()).not.toContain(errorEl().id);
expect(input().value).toBe('Kenso');
expect(document.activeElement).toBe(input());
});
it('andere Fehler als `failure` werden wie beim Speichern erklärt (kein Netz, abgelaufene Anmeldung, Unbekanntes)', async () => {
void dialog.usernameDialog({ mode: 'prompt', failure: new TypeError('Failed to fetch') });
expect(errorText()).toBe('Keine Verbindung zum Server.');
buttonByText('Später')!.click();
await settle();
void dialog.usernameDialog({ mode: 'prompt', failure: Object.assign(new Error('x'), { code: 401 }) });
expect(errorText()).toBe('Bitte melde dich an.');
buttonByText('Später')!.click();
await settle();
void dialog.usernameDialog({ mode: 'prompt', failure: new Error('???') });
expect(errorText()).toBe('Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
it('der Grund verschwindet beim nächsten Speichern; ein anderer Name geht durch', async () => {
mocks.saveUsername.mockResolvedValue('Kenso2');
const result = dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
input().value = 'Kenso2';
send();
expect(errorText()).toBe('');
expect(input().hasAttribute('aria-invalid')).toBe(false);
await expect(result).resolves.toBe('Kenso2');
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso2');
});
it('beides gilt nur für diesen Aufruf: der nächste Dialog beginnt leer und ohne Meldung', async () => {
const first = dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new TypeError('offline') });
buttonByText('Später')!.click();
await first;
void dialog.usernameDialog({ mode: 'prompt' });
expect(input().value).toBe('');
expect(errorText()).toBe('');
});
it('Englisch: der Grund erscheint in der Sprache der Seite', async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
void dialog.usernameDialog({ mode: 'prompt', initial: 'Kenso', failure: new ApiError(409, TAKEN, undefined, 'usernameTaken') });
expect(errorText()).toBe('This username is already taken.');
expect(germanLeft(dialogEl()!)).toEqual([]);
});
});
describe('Busy-Sperre beim Speichern', () => {
it('während des Speicherns: Knöpfe und Feld gesperrt, Status sichtbar, ein zweites Absenden wird ignoriert', async () => {
let finish!: (name: string) => void;
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
expect(buttonByText('Speichern')!.disabled).toBe(true);
expect(buttonByText('Später')!.disabled).toBe(true);
expect(input().readOnly).toBe(true);
expect(form().getAttribute('aria-busy')).toBe('true');
expect(statusText()).toBe('Wird gespeichert …');
send();
expect(mocks.saveUsername).toHaveBeenCalledOnce();
finish('Kenso');
await expect(result).resolves.toBe('Kenso');
});
it('Esc und ein Klick auf den Hintergrund schließen den Dialog währenddessen nicht (Abbrechen wirkte sonst nur scheinbar)', async () => {
let finish!: (name: string) => void;
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
const result = dialog.usernameDialog({ mode: 'required' });
input().value = 'Kenso';
send();
const esc = new Event('cancel', { cancelable: true });
dialogEl()!.dispatchEvent(esc);
expect(esc.defaultPrevented).toBe(true);
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
finish('Kenso');
await expect(result).resolves.toBe('Kenso');
});
it('schließt der Browser den Dialog trotzdem, geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
let finish!: (name: string) => void;
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
let resolved: string | null | undefined;
const result = dialog.usernameDialog({ mode: 'prompt' }).then((name) => (resolved = name));
input().value = 'Kenso';
send();
dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet
await settle();
expect(dialogEl()?.open).toBe(true);
dialogEl()!.dispatchEvent(new Event('close'));
await settle();
expect(resolved).toBeUndefined();
expect(dialogEl()?.open).toBe(true);
finish('Kenso');
await result;
expect(resolved).toBe('Kenso');
expect(dialogEl()).toBeNull();
});
it('scheitert das Speichern, nachdem der Dialog zwischendurch zuging, ist er wieder offen und zeigt den Fehler', async () => {
let fail!: (error: Error) => void;
mocks.saveUsername.mockReturnValue(new Promise<string>((_, reject) => (fail = reject)));
void dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
dialogEl()!.close();
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
await settle();
expect(dialogEl()?.open).toBe(true);
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
});
it('nur `signal` schließt immer; eine danach eintreffende Antwort ändert nichts mehr', async () => {
let finish!: (name: string) => void;
mocks.saveUsername.mockReturnValue(new Promise<string>((resolve) => (finish = resolve)));
const controller = new AbortController();
const result = dialog.usernameDialog({ mode: 'prompt', signal: controller.signal });
input().value = 'Kenso';
send();
controller.abort();
await expect(result).resolves.toBeNull();
expect(dialogEl()).toBeNull();
finish('Kenso');
await settle();
expect(dialogEl()).toBeNull();
});
});
describe('ein Dialog zur Zeit', () => {
it('ein zweiter Aufruf, während einer offen ist, teilt sich Dialog und Ergebnis (keine zwei Dialoge übereinander)', async () => {
mocks.saveUsername.mockResolvedValue('Kenso');
const first = dialog.usernameDialog({ mode: 'prompt' });
const second = dialog.usernameDialog({ mode: 'required' });
expect(second).toBe(first);
expect(document.querySelectorAll('dialog.username-dialog')).toHaveLength(1);
expect(heading()).toBe('Benutzername wählen');
input().value = 'Kenso';
send();
await expect(second).resolves.toBe('Kenso');
await expect(first).resolves.toBe('Kenso');
});
it('nach dem Schließen öffnet der nächste Aufruf neu, ohne Reste', async () => {
const first = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
buttonByText('Später')!.click();
await first;
const second = dialog.usernameDialog({ mode: 'required' });
expect(second).not.toBe(first);
expect(dialogEl()?.open).toBe(true);
expect(input().value).toBe('');
expect(errorText()).toBe('');
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
buttonByText('Abbrechen')!.click();
await second;
});
});
describe('Englisch', () => {
beforeEach(async () => {
(await import('../i18n/i18n')).setLang('en', { persist: false });
});
it('alle Texte des Dialogs sind englisch, in jedem Modus', async () => {
mocks.name = 'Kenso';
const texts = () => [heading(), intro(), dialogEl()!.querySelector('label')!.textContent, dialogEl()!.querySelector('.auth-hint')!.textContent];
const labels = () => [...dialogEl()!.querySelectorAll('button')].map((b) => b.textContent);
const prompt = dialog.usernameDialog({ mode: 'prompt' });
expect(texts()).toEqual([
'Choose a username',
'Your username is public and shown on your logos and cards.',
'Username',
'3–20 characters: letters, numbers, . _ -',
]);
expect(labels()).toEqual(['Later', 'Save']);
expect(germanLeft(dialogEl()!)).toEqual([]);
buttonByText('Later')!.click();
await prompt;
const required = dialog.usernameDialog({ mode: 'required' });
expect(intro()).toBe('Please choose a username first.');
expect(labels()).toEqual(['Cancel', 'Save']);
buttonByText('Cancel')!.click();
await required;
const change = dialog.usernameDialog({ mode: 'change' });
expect(heading()).toBe('Change username');
expect(input().value).toBe('Kenso');
expect(germanLeft(dialogEl()!)).toEqual([]);
buttonByText('Cancel')!.click();
await change;
});
it('Fehler und Status ebenso', async () => {
let finish!: (name: string) => void;
mocks.saveUsername.mockReturnValueOnce(new Promise<string>((resolve) => (finish = resolve)));
const result = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'x';
send();
expect(errorText()).toBe(
'The username must be 3–20 characters long, start with a letter or a number, and may only contain letters, numbers, periods, underscores and hyphens.',
);
input().value = 'Admin';
send();
expect(errorText()).toBe('This username is not allowed.');
input().value = 'Kenso';
send();
expect(statusText()).toBe('Saving …');
expect(germanLeft(dialogEl()!)).toEqual([]);
finish('Kenso');
await result;
mocks.saveUsername.mockRejectedValueOnce(new ApiError(409, TAKEN, undefined, 'usernameTaken'));
const again = dialog.usernameDialog({ mode: 'prompt' });
input().value = 'Kenso';
send();
await settle();
expect(errorText()).toBe('This username is already taken.');
buttonByText('Later')!.click();
await again;
});
});
describe('requireUsername', () => {
it('ohne angemeldetes Konto gibt es nichts zu fordern: true, kein Dialog, keine Anfrage (die Anmeldung kommt mit der Aktion selbst)', async () => {
await expect(dialog.requireUsername()).resolves.toBe(true);
expect(dialogEl()).toBeNull();
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
});
it('ein bekannter Name genügt: true ohne Dialog und ohne Anfrage', async () => {
mocks.userId = 'u1';
mocks.name = 'Kenso';
await expect(dialog.requireUsername()).resolves.toBe(true);
expect(dialogEl()).toBeNull();
expect(mocks.loadMyUsername).not.toHaveBeenCalled();
});
it('ist der Name noch nicht gelesen, wird er gelesen; hat das Konto einen, ist es true ohne Dialog', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue('Kenso');
await expect(dialog.requireUsername()).resolves.toBe(true);
expect(mocks.loadMyUsername).toHaveBeenCalledOnce();
expect(dialogEl()).toBeNull();
});
it('ohne Namen erscheint der Dialog „required“; nach dem Speichern ist es true', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
mocks.saveUsername.mockResolvedValue('Kenso');
const result = dialog.requireUsername();
await settle();
expect(dialogEl()?.open).toBe(true);
expect(intro()).toBe('Bitte wähle zuerst einen Benutzernamen.');
expect(buttonByText('Abbrechen')).toBeTruthy();
input().value = 'Kenso';
send();
await expect(result).resolves.toBe(true);
expect(mocks.saveUsername).toHaveBeenCalledExactlyOnceWith('Kenso');
expect(dialogEl()).toBeNull();
});
it('„Abbrechen“ ergibt false: ohne Namen geht es nicht weiter', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const result = dialog.requireUsername();
await settle();
buttonByText('Abbrechen')!.click();
await expect(result).resolves.toBe(false);
expect(mocks.saveUsername).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
});
it('Esc ergibt ebenfalls false', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const result = dialog.requireUsername();
await settle();
dialogEl()!.close();
await expect(result).resolves.toBe(false);
});
it('lässt sich das Profil nicht lesen, entscheidet der Dienst (er antwortet ohne Profil mit 403): true, kein Dialog', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockRejectedValue(new TypeError('Failed to fetch'));
await expect(dialog.requireUsername()).resolves.toBe(true);
expect(dialogEl()).toBeNull();
});
it('das Signal geht an den Dialog: das Verlassen der Seite schließt ihn, das Ergebnis ist false', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const controller = new AbortController();
const result = dialog.requireUsername(controller.signal);
await settle();
expect(dialogEl()?.open).toBe(true);
controller.abort();
await expect(result).resolves.toBe(false);
expect(dialogEl()).toBeNull();
expect(mocks.saveUsername).not.toHaveBeenCalled();
});
it('ein schon abgebrochenes Signal öffnet keinen Dialog (false); mit bekanntem Namen ist es trotzdem true', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const controller = new AbortController();
controller.abort();
await expect(dialog.requireUsername(controller.signal)).resolves.toBe(false);
expect(dialogEl()).toBeNull();
mocks.name = 'Kenso';
await expect(dialog.requireUsername(controller.signal)).resolves.toBe(true);
});
it('läuft schon ein Namensdialog (etwa die einmalige Abfrage), teilt sich requireUsername ihn', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
mocks.saveUsername.mockResolvedValue('Kenso');
const prompt = dialog.usernameDialog({ mode: 'prompt' });
const required = dialog.requireUsername();
await settle();
expect(document.querySelectorAll('dialog.username-dialog')).toHaveLength(1);
input().value = 'Kenso';
send();
await expect(required).resolves.toBe(true);
await expect(prompt).resolves.toBe('Kenso');
});
it('„Später“ in einem geteilten Dialog ergibt für requireUsername false', async () => {
mocks.userId = 'u1';
mocks.loadMyUsername.mockResolvedValue(null);
const prompt = dialog.usernameDialog({ mode: 'prompt' });
const required = dialog.requireUsername();
await settle();
buttonByText('Später')!.click();
await expect(prompt).resolves.toBeNull();
await expect(required).resolves.toBe(false);
});
});
+255
View File
@@ -0,0 +1,255 @@
// Dialog „Benutzername wählen“ (Spec § 5): ein Feld, „Speichern“ und „Später“ bzw. „Abbrechen“. Drei Anlässe: die einmalige Abfrage nach
// dem Anmelden (`prompt`, mit „Später“), die Pflicht vor dem Hochladen und Veröffentlichen (`required`, nur „Abbrechen“) und das Ändern
// aus dem Konto-Menü (`change`, mit dem aktuellen Namen vorbelegt). Eigenes <dialog> nach dem Muster von publish-dialog.ts; alle Texte
// gehen als Text in den DOM, nie als HTML.
import './account.css';
import { ApiError, apiErrorText } from '../community/api';
import { el, setFieldInvalid } from '../editor/dom';
import { API_MESSAGES } from '../i18n/api-messages';
import { msg, t } from '../i18n/i18n';
import { currentUserId } from './auth';
import { errorMessage, isUnauthorized } from './errors';
import { USERNAME_HINT, loadMyUsername, myUsername, saveUsername, validateUsername } from './profile';
const RUNNING = msg('Wird gespeichert …');
const PUBLIC_NOTE = msg('Dein Benutzername ist öffentlich und steht bei deinen Logos und Karten.');
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
const ERROR_ID = 'username-error';
/** Fehlercodes des Dienstes, die am Namen selbst liegen: ihr Text steht am Feld, und das Feld gilt als ungültig. */
const FIELD_CODES: ReadonlySet<string> = new Set(['usernameInvalid', 'usernameReserved', 'usernameTaken']);
/** `prompt`: einmalige Abfrage („Später“); `required`: ohne Namen geht es nicht weiter („Abbrechen“); `change`: aus dem Konto-Menü, vorbelegt. */
export type UsernameDialogMode = 'prompt' | 'required' | 'change';
export interface UsernameDialogOptions {
mode: UsernameDialogMode;
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
signal?: AbortSignal;
/** Der Name, mit dem das Feld beginnt (geht dem aktuellen Namen im Modus `change` vor), etwa der zuvor getippte nach einem Fehler. */
initial?: string;
/** Ein Fehler, dessen Grund beim Öffnen schon im Dialog steht, als hätte das Speichern ihn eben gemeldet. */
failure?: unknown;
}
/** Der Text zu einem fehlgeschlagenen Speichern in der Sprache der Seite; Fehler des Dienstes über ihren Code (`apiErrorText`). */
function failureText(err: unknown): string {
if (err instanceof ApiError) return apiErrorText(err);
if (isUnauthorized(err)) return t(API_MESSAGES.unauthorized); // die Anmeldung ist abgelaufen (schon der Abruf des JWT scheitert)
return errorMessage(err, 'save');
}
function build(opts: UsernameDialogOptions): Promise<string | null> {
return new Promise<string | null>((resolve, reject) => {
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
// Vorbelegt wird nur beim Ändern; kennt das Konto noch keinen Namen, ist es ein gewöhnliches „Wählen“.
const current = opts.mode === 'change' ? myUsername() : null;
let result: string | null = null;
let closed = false;
let busy = false;
const input = el('input', {
class: 'auth-input',
type: 'text',
name: 'username',
id: 'username-name',
// `nickname`, nicht `username`: der Name ist ein Spitzname, kein Anmeldename (angemeldet wird per E-Mail); so bleibt für Passwortmanager
// das E-Mail-Feld der Anmeldung der Anmeldename, und der Browser schlägt hier nicht die gespeicherte Anmeldung vor.
autocomplete: 'nickname',
autocapitalize: 'none',
spellcheck: 'false',
'aria-required': 'true',
'aria-describedby': 'username-name-hint',
});
input.value = opts.initial ?? current ?? '';
const field = el(
'div',
{ class: 'auth-field' },
el('label', { class: 'auth-label', for: input.id }, t('Benutzername')),
input,
el('p', { class: 'auth-hint', id: 'username-name-hint' }, t(USERNAME_HINT)),
);
const intro = el('p', { class: 'username-text' }, opts.mode === 'required' ? t(API_MESSAGES.usernameRequired) : t(PUBLIC_NOTE));
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, opts.mode === 'prompt' ? t('Später') : t('Abbrechen'));
const save = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Speichern'));
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
const form = el(
'form',
{ class: 'auth-form', novalidate: true },
intro,
field,
errorEl,
statusEl,
el('div', { class: 'username-actions' }, cancel, save),
);
const dialog = el(
'dialog',
{ class: 'auth-dialog username-dialog', 'aria-labelledby': 'username-heading' },
el(
'div',
{ class: 'auth-card' },
el('h2', { id: 'username-heading', class: 'auth-title' }, current ? t('Benutzername ändern') : t('Benutzername wählen')),
form,
),
);
const finish = () => {
if (closed) return;
closed = true;
opts.signal?.removeEventListener('abort', finish);
if (dialog.open) dialog.close();
dialog.remove();
if (opener?.isConnected) opener.focus();
resolve(result);
};
/** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */
function reopen(): boolean {
if (dialog.open) return true;
try {
dialog.showModal();
return true;
} catch {
return false;
}
}
function setBusy(value: boolean): void {
busy = value;
form.setAttribute('aria-busy', String(value));
input.readOnly = value; // bleibt fokussierbar, ein Screenreader verliert den Fokus nicht
save.disabled = value;
cancel.disabled = value;
statusEl.textContent = value ? t(RUNNING) : '';
}
/** Ein Problem mit dem Namen: der Text steht in der Fehlerzeile, das Feld gilt als ungültig und bekommt den Fokus. */
function showFieldProblem(text: string): void {
errorEl.textContent = text;
setFieldInvalid(input, ERROR_ID, true);
input.focus();
}
/** Der Grund eines gescheiterten Speicherns: am Namen (vergeben, gesperrt, ungültig) am Feld, sonst (Netz, Dienst, Anmeldung) nur als Text. */
function showFailure(err: unknown): void {
if (err instanceof ApiError && err.serverCode !== null && FIELD_CODES.has(err.serverCode)) {
showFieldProblem(failureText(err));
} else {
errorEl.textContent = failureText(err); // der Name kann richtig sein
input.focus();
}
}
async function submit(): Promise<void> {
setFieldInvalid(input, ERROR_ID, false);
errorEl.textContent = '';
const name = input.value.trim(); // der Dienst trimmt nicht
const problem = validateUsername(name);
if (problem) {
showFieldProblem(problem);
return;
}
setBusy(true);
try {
result = await saveUsername(name);
} catch (err) {
if (closed) return;
setBusy(false);
showFailure(err);
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
return;
}
if (closed) return;
finish();
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy || closed) return;
void submit();
});
cancel.addEventListener('click', finish);
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Speichern darf der Dialog nicht zugehen.
dialog.addEventListener('cancel', (event) => {
if (busy) event.preventDefault();
});
// Schließt der Browser den Dialog trotzdem mitten im Speichern, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis.
dialog.addEventListener('close', () => {
if (busy && !closed) {
reopen();
return;
}
finish();
});
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
let pressedOnBackdrop = false;
dialog.addEventListener('mousedown', (event) => {
pressedOnBackdrop = event.target === dialog;
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog && pressedOnBackdrop && !busy) finish();
pressedOnBackdrop = false;
});
opts.signal?.addEventListener('abort', finish);
document.body.append(dialog);
try {
dialog.showModal();
} catch (err) {
closed = true;
opts.signal?.removeEventListener('abort', finish);
dialog.remove();
reject(err);
return;
}
input.focus();
if (input.value !== '') input.select(); // zum Ändern (oder nach einem Fehler): gleich überschreiben können
if (opts.failure !== undefined) showFailure(opts.failure);
});
}
/** Der offene Dialog; es gibt höchstens einen. */
let running: Promise<string | null> | null = null;
/**
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem gespeicherten Namen (so, wie der Dienst ihn gespeichert hat), sonst mit
* null („Später“, „Abbrechen“, Esc, Hintergrund, `signal`). Fehler erscheinen im Dialog; er bleibt dann für einen neuen Versuch offen.
* Während des Speicherns lässt er sich nicht schließen (Esc und Hintergrund wirkungslos), damit niemand glaubt, etwas abgebrochen zu haben,
* das dann doch gespeichert wird; nur `signal` schließt immer.
* Läuft schon ein Namensdialog (etwa die einmalige Abfrage, wenn gleichzeitig der Dienst „Name nötig“ meldet), teilt sich der Aufruf ihn
* samt Ergebnis, statt einen zweiten darüberzulegen (seine Angaben `signal`, `initial` und `failure` gelten dann nicht); für einen
* Aufrufer mit „Name nötig“ ist null dann ebenso ein „nicht weiter“.
*/
export function usernameDialog(opts: UsernameDialogOptions): Promise<string | null> {
if (opts.signal?.aborted) return Promise.resolve(null);
if (running) return running;
const promise: Promise<string | null> = build(opts).finally(() => {
if (running === promise) running = null;
});
running = promise;
return promise;
}
/**
* Hat das angemeldete Konto einen Benutzernamen? Wenn nicht, fragt der Dialog `required` danach (Spec § 5: vor dem Hochladen eines Logos und
* vor dem Veröffentlichen). true: es kann weitergehen; false: der Nutzer hat „Abbrechen“ gewählt, es geschieht nichts.
* Ohne angemeldetes Konto gibt es nichts zu fordern (true): die Anmeldung kommt mit der Aktion selbst, und fehlt dem Konto dann der Name,
* antwortet der Dienst mit 403 `usernameRequired` (dann zeigt der Aufrufer den Dialog). Dasselbe gilt, wenn sich das Profil nicht lesen lässt:
* der Dienst entscheidet. `signal` schließt den Dialog (Seite verlassen); dann ist das Ergebnis false.
*/
export async function requireUsername(signal?: AbortSignal): Promise<boolean> {
if (currentUserId() === null) return true;
let name = myUsername();
if (name === null) {
try {
name = await loadMyUsername();
} catch {
return true;
}
}
if (name !== null) return true;
return (await usernameDialog({ mode: 'required', signal })) !== null;
}
+79
View File
@@ -3,6 +3,11 @@ import { LoginCancelled, withLogin } from './with-login';
const unauthorized = () => Object.assign(new Error('Unauthorized'), { code: 401, type: 'user_unauthorized' });
// Appwrite antwortet Gästen auf Lesezugriffe mit 404, nicht mit 401 (Zeilen- und Dateirechte).
const rowNotFound = () => Object.assign(new Error('Row not found'), { code: 404, type: 'row_not_found' });
const fileNotFound = () => Object.assign(new Error('File not found'), { code: 404, type: 'storage_file_not_found' });
const user = { $id: 'u1', email: 'a@b.de' };
describe('withLogin', () => {
it('gibt das Ergebnis zurück, ohne den Login-Dialog zu öffnen', async () => {
const fn = vi.fn().mockResolvedValue('ok');
@@ -76,4 +81,78 @@ describe('withLogin', () => {
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ok');
expect(requireLogin).toHaveBeenCalledOnce();
});
describe('404 für Gäste (row_not_found / storage_file_not_found)', () => {
it.each([
['row_not_found', rowNotFound],
['storage_file_not_found', fileNotFound],
])('%s ohne Sitzung: wie eine abgelaufene Sitzung behandeln (anmelden, einmal wiederholen)', async (_name, make) => {
const order: string[] = [];
const fn = vi
.fn()
.mockImplementationOnce(async () => {
order.push('fn1');
throw make();
})
.mockImplementationOnce(async () => {
order.push('fn2');
return 'ergebnis';
});
const refreshUser = vi.fn(async () => {
order.push('refresh');
return null;
});
const requireLogin = vi.fn(async () => {
order.push('login');
return true;
});
await expect(withLogin(fn, requireLogin, refreshUser)).resolves.toBe('ergebnis');
expect(order).toEqual(['fn1', 'refresh', 'login', 'fn2']);
expect(refreshUser).toHaveBeenCalledOnce();
});
it('ist der Nutzer noch angemeldet, ist es ein echtes „nicht gefunden“ und wird durchgereicht', async () => {
const notFound = rowNotFound();
const fn = vi.fn().mockRejectedValue(notFound);
const requireLogin = vi.fn();
const refreshUser = vi.fn().mockResolvedValue(user);
await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(notFound);
expect(fn).toHaveBeenCalledOnce();
expect(requireLogin).not.toHaveBeenCalled();
});
it('scheitert die Nutzerprüfung (Netz), wird das ursprüngliche 404 durchgereicht', async () => {
const notFound = fileNotFound();
const fn = vi.fn().mockRejectedValue(notFound);
const requireLogin = vi.fn();
const refreshUser = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
await expect(withLogin(fn, requireLogin, refreshUser)).rejects.toBe(notFound);
expect(requireLogin).not.toHaveBeenCalled();
});
it('Abbruch im Dialog: LoginCancelled, fn wird nicht erneut aufgerufen', async () => {
const fn = vi.fn().mockRejectedValue(rowNotFound());
const requireLogin = vi.fn().mockResolvedValue(false);
const err = await withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null)).catch((e: unknown) => e);
expect(err).toBeInstanceOf(LoginCancelled);
expect(fn).toHaveBeenCalledOnce();
});
it('ein zweites 404 nach der Anmeldung wird durchgereicht (kein dritter Versuch)', async () => {
const second = rowNotFound();
const fn = vi.fn().mockRejectedValueOnce(rowNotFound()).mockRejectedValueOnce(second);
const requireLogin = vi.fn().mockResolvedValue(true);
await expect(withLogin(fn, requireLogin, vi.fn().mockResolvedValue(null))).rejects.toBe(second);
expect(fn).toHaveBeenCalledTimes(2);
expect(requireLogin).toHaveBeenCalledOnce();
});
it('ein anderes 404 (z. B. falscher Pfad) löst keinen Dialog aus', async () => {
const other = Object.assign(new Error('Route not found'), { code: 404, type: 'general_route_not_found' });
const fn = vi.fn().mockRejectedValue(other);
const refreshUser = vi.fn();
await expect(withLogin(fn, vi.fn(), refreshUser)).rejects.toBe(other);
expect(refreshUser).not.toHaveBeenCalled();
});
});
});
+34 -8
View File
@@ -1,8 +1,12 @@
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401) einmal neu an.
// Führt eine Kartenanfrage aus und meldet bei abgelaufener Sitzung (401, oder 404 für Gäste) einmal neu an.
import { msg } from '../i18n/i18n';
import { getCurrentUser } from './auth';
import { isUnauthorized } from './errors';
import { errorType, isUnauthorized } from './errors';
import { requireLogin } from './login-dialog';
/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert; angezeigt wird er mit `t(LOGIN_NEEDED)`. */
export const LOGIN_NEEDED = msg('Bitte melde dich an, um fortzufahren.');
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
export class LoginCancelled extends Error {
constructor() {
@@ -11,9 +15,20 @@ export class LoginCancelled extends Error {
}
}
/**
* Appwrite beantwortet Lesezugriffe von Gästen auf Zeilen und Dateien mit Zeilen-/Dateirechten nicht mit 401,
* sondern mit 404 (die Daten sind für sie „nicht vorhanden“).
*/
function isMissingForGuest(err: unknown): boolean {
const type = errorType(err);
return type === 'row_not_found' || type === 'storage_file_not_found';
}
/**
* Ruft `fn` auf. Antwortet der Server mit 401, wird der Anmeldestatus neu geholt (damit Konto-Menü und
* Nutzer-ID nicht veraltet sind), einmal der Login-Dialog gezeigt und `fn` danach genau einmal wiederholt.
* Dasselbe gilt für „Zeile/Datei nicht gefunden“, wenn die Prüfung ergibt, dass gar keine Sitzung mehr besteht;
* ist der Nutzer noch angemeldet (oder die Prüfung scheitert), ist es ein echtes „nicht gefunden“ und geht durch.
* Schließt der Nutzer den Dialog, wirft das `LoginCancelled`; jeder andere Fehler geht unverändert durch.
*/
export async function withLogin<T>(
@@ -24,12 +39,23 @@ export async function withLogin<T>(
try {
return await fn();
} catch (err) {
if (!isUnauthorized(err)) throw err;
}
try {
await refreshUser();
} catch {
// Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu.
if (isUnauthorized(err)) {
try {
await refreshUser();
} catch {
// Ein Netzfehler hier darf die Anmeldung nicht verhindern; der Login-Dialog setzt den Nutzer ohnehin neu.
}
} else if (isMissingForGuest(err)) {
let user: unknown;
try {
user = await refreshUser();
} catch {
throw err; // ohne Gewissheit über die Sitzung bleibt es beim ursprünglichen Fehler
}
if (user) throw err;
} else {
throw err;
}
}
if (!(await requireLoginFn())) throw new LoginCancelled();
return fn();
+1 -1
View File
@@ -1 +1 @@
<svg viewBox="172 360 680 730" xmlns="http://www.w3.org/2000/svg"><defs><mask id="aircards-symbol-waves" width="680" height="430" x="172" y="660" maskUnits="userSpaceOnUse"><path fill="#fff" d="M172 660h680v430H172z"/><g fill="none" stroke="#000" stroke-linecap="round" stroke-width="32"><path d="M591.1 843.9a44 44 0 0 1 0 62.2m34-96.2a92 92 0 0 1 0 130.2"/><path d="M659 776a140 140 0 0 1 0 198"/></g></mask></defs><g fill="currentColor"><path fill-opacity=".45" d="M252 360h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V440a80 80 0 0 1 80-80"/><path fill-opacity=".7" d="M252 510h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V590a80 80 0 0 1 80-80"/><rect width="680" height="430" x="172" y="660" mask="url(#aircards-symbol-waves)" rx="80"/></g></svg>
<svg viewBox="172 360 680 730" xmlns="http://www.w3.org/2000/svg"><defs><mask id="aircards-symbol-waves"><path fill="#fff" d="M100 600h1000v600H100z"/><g fill="none" stroke="#000" stroke-linecap="round" stroke-width="32"><path d="M591.1 843.9a44 44 0 0 1 0 62.2m34-96.2a92 92 0 0 1 0 130.2"/><path d="M659 776a140 140 0 0 1 0 198"/></g></mask></defs><g fill="currentColor"><path fill-opacity=".45" d="M252 360h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V440a80 80 0 0 1 80-80"/><path fill-opacity=".7" d="M252 510h520a80 80 0 0 1 80 80v150a80 80 0 0 0-80-80H252a80 80 0 0 0-80 80V590a80 80 0 0 1 80-80"/><rect width="680" height="430" x="172" y="660" mask="url(#aircards-symbol-waves)" rx="80"/></g></svg>

Before

Width:  |  Height:  |  Size: 794 B

After

Width:  |  Height:  |  Size: 727 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="#016fd0" d="M0 0h997.517v538.49l-49.375 77.147 49.375 68.661v313.219H0v-507.63l30.859-35.488L0 420.454Z"/><path fill="#fff" d="M193.64 695.099v-156.61h165.82l17.791 23.193 18.38-23.193h601.886V684.3s-15.74 10.643-33.945 10.8H630.295l-20.058-24.687V695.1h-65.73v-42.142s-8.979 5.883-28.39 5.883h-22.373V695.1h-99.52l-17.766-23.69-18.038 23.69zM0 420.454l37.393-87.177h64.668l21.22 48.833v-48.833h80.388l12.633 35.295 12.247-35.295h360.858v17.744s18.97-17.744 50.146-17.744l117.085.41 20.854 48.193v-48.603h67.273l18.515 27.684v-27.684h67.89v156.61h-67.89l-17.744-27.774v27.774h-98.838l-9.94-24.688h-26.57l-9.779 24.688h-67.028c-26.826 0-43.974-17.382-43.974-17.382v17.382H488.343l-20.058-24.688v24.688H92.481l-9.933-24.688H56.06l-9.86 24.688H0z"/><path fill="#016fd0" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="-66 -67.24 1132.01 1132.01" xmlns="http://www.w3.org/2000/svg"><path fill="none" stroke="currentColor" stroke-width="36" d="M-48-49.24h1096v1096H-48z"/><path fill="currentColor" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 997.53" xmlns="http://www.w3.org/2000/svg"><path fill="#016fd0" d="M0 0h997.517v538.49l-49.375 77.147 49.375 68.661v313.219H0v-507.63l30.859-35.488L0 420.454Z"/><path fill="#fff" d="M193.64 695.099v-156.61h165.82l17.791 23.193 18.38-23.193h601.886V684.3s-15.74 10.643-33.945 10.8H630.295l-20.058-24.687V695.1h-65.73v-42.142s-8.979 5.883-28.39 5.883h-22.373V695.1h-99.52l-17.766-23.69-18.038 23.69zM0 420.454l37.393-87.177h64.668l21.22 48.833v-48.833h80.388l12.633 35.295 12.247-35.295h360.858v17.744s18.97-17.744 50.146-17.744l117.085.41 20.854 48.193v-48.603h67.273l18.515 27.684v-27.684h67.89v156.61h-67.89l-17.744-27.774v27.774h-98.838l-9.94-24.688h-26.57l-9.779 24.688h-67.028c-26.826 0-43.974-17.382-43.974-17.382v17.382H488.343l-20.058-24.688v24.688H92.481l-9.933-24.688H56.06l-9.86 24.688H0z"/><path fill="#016fd0" d="M50.628 352.584.193 469.848h32.836l9.306-23.482h54.1l9.257 23.482h33.56L88.863 352.584zm18.66 27.29 16.49 41.034H52.75zm73.435 89.954V352.564l46.661.173 27.14 75.605 26.49-75.778h46.289v117.264h-29.316v-86.405l-31.076 86.405h-25.71l-31.162-86.405v86.405zm166.638 0V352.564h95.663v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.458zm112.636-117.244v117.264h29.316v-41.66h12.343l35.15 41.66h35.826l-38.574-43.202c15.831-1.336 32.161-14.924 32.161-36.019 0-24.676-19.368-38.043-40.984-38.043zm29.316 26.23h33.51c8.04 0 13.887 6.288 13.887 12.344 0 7.79-7.577 12.343-13.452 12.343h-33.945zm118.807 91.014h-29.933V352.564h29.933zm70.975 0h-6.46c-31.262 0-50.243-24.629-50.243-58.15 0-34.348 18.768-59.114 58.246-59.114h32.402v27.773h-33.586c-16.026 0-27.36 12.507-27.36 31.63 0 22.71 12.96 32.248 31.63 32.248h7.715zm63.792-117.244-50.435 117.264h32.836l9.305-23.482h54.1l9.258 23.482h33.559l-50.387-117.264zm18.66 27.29 16.49 41.034h-33.029zm73.386 89.954V352.564h37.272l47.59 73.676v-73.676h29.317v117.264h-36.067l-48.796-75.604v75.604zM213.699 675.04V557.777h95.662v26.23h-66.038v20.059h64.495v24.687h-64.495v20.83h66.038v25.459zm468.748 0V557.777h95.662v26.23h-66.038v20.059h64.187v24.687H712.07v20.83h66.038v25.459zm-369.373 0 46.578-57.908-47.687-59.356H348.9l28.4 36.694 28.497-36.694h35.488l-47.06 58.633 46.663 58.632H403.96l-27.576-36.115-26.905 36.115zM444.37 557.796V675.06h30.087v-37.03h30.859c26.111 0 45.903-13.853 45.903-40.792 0-22.317-15.523-39.442-42.094-39.442zm30.087 26.52h32.498c8.436 0 14.465 5.17 14.465 13.5 0 7.827-5.999 13.501-14.561 13.501h-32.402zm89.491-26.54v117.265h29.316v-41.66h12.344l35.15 41.66h35.825l-38.573-43.203c15.83-1.336 32.16-14.923 32.16-36.018 0-24.677-19.368-38.044-40.984-38.044zm29.316 26.23h33.511c8.039 0 13.887 6.289 13.887 12.344 0 7.79-7.577 12.344-13.453 12.344h-33.945zm198.423 91.034v-25.458h58.671c8.681 0 12.44-4.691 12.44-9.836 0-4.93-3.747-9.914-12.44-9.914h-26.513c-23.045 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 46-36.935h57.088l-12.343 26.385h-49.375c-9.438 0-12.343 4.952-12.343 9.682 0 4.861 3.59 10.222 10.8 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32zm107.597 0v-25.458h58.67c8.682 0 12.44-4.691 12.44-9.836 0-4.93-3.746-9.914-12.44-9.914h-26.512c-23.046 0-35.88-14.04-35.88-35.121 0-18.803 11.753-36.935 45.999-36.935h57.089l-12.344 26.385h-49.374c-9.438 0-12.344 4.952-12.344 9.682 0 4.861 3.59 10.222 10.801 10.222h27.773c25.69 0 36.838 14.572 36.838 33.655 0 20.517-12.422 37.32-38.236 37.32z"/></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 814.11 999.91" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2m-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3"/></svg>

Before

Width:  |  Height:  |  Size: 655 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 -0.49 220.58 37.13" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.125 0 0 -.125 -.2539 59.38386)"><path d="M145.605 261.297c-17.113-15.453-39.343-22.195-74.535-22.195H56.453v184.644H71.07c35.192 0 56.547-6.297 74.535-22.582 18.836-16.773 30.168-42.762 30.168-69.512 0-26.804-11.332-53.589-30.168-70.355M81.981 471.055H2.03V191.832h79.53c42.289 0 72.824 9.977 99.629 32.234 31.851 26.36 50.687 66.09 50.687 107.184 0 82.402-61.566 139.805-149.894 139.805m174.956-279.223h54.465v279.223h-54.465zm187.64 172.074c-32.683 12.094-42.281 20.071-42.281 35.157 0 17.585 17.098 30.949 40.574 30.949 16.32 0 29.731-6.703 43.922-22.614l28.496 37.317c-23.418 20.484-51.437 30.957-82.051 30.957-49.402 0-87.086-34.309-87.086-80.008 0-38.469 17.547-58.164 68.7-76.574 21.32-7.52 32.171-12.531 37.648-15.902 10.883-7.106 16.332-17.165 16.332-28.891 0-22.625-17.988-39.387-42.289-39.387-25.977 0-46.902 12.992-59.441 37.242l-35.188-33.879c25.094-36.835 55.231-53.16 96.672-53.16 56.602 0 96.305 37.633 96.305 91.688 0 44.363-18.356 64.445-80.313 87.105m97.496-32.656c0-82.074 64.446-145.715 147.383-145.715 23.445 0 43.523 4.61 68.285 16.274v64.113c-21.773-21.793-41.058-30.582-65.754-30.582-54.851 0-93.785 39.769-93.785 96.312 0 53.61 40.164 95.899 91.254 95.899 25.977 0 45.641-9.27 68.285-31.418v64.082c-23.906 12.129-43.566 17.152-67 17.152-82.515 0-148.668-64.929-148.668-146.117m647.516-47.75-74.46 187.555h-59.5l118.49-286.387h29.31l120.63 286.387h-59.04zm159.1-91.668h154.46v47.27h-100.04v75.371h96.35v47.297h-96.35v61.976h100.04v47.309h-154.46zm260.84 150.672h-15.91v84.57h16.77c33.92 0 52.36-14.211 52.36-41.386 0-28.071-18.44-43.184-53.22-43.184m109.27 46.117c0 52.274-36.01 82.434-98.82 82.434h-80.76V191.832h54.4V304h7.1l75.39-112.168h66.98l-87.9 117.633c41.02 8.34 63.61 36.402 63.61 79.156m24.44 68.59h-.99v6.414h1.05c2.9 0 4.4-1.043 4.4-3.156 0-2.172-1.52-3.258-4.46-3.258m10.32 3.348c0 4.894-3.37 7.57-9.29 7.57h-7.88v-24.543h5.86v9.519l6.87-9.519h7.15l-8.07 10.121c3.45.93 5.36 3.445 5.36 6.852" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none"/><path d="M1745.37 438.332c-9.38 0-17.05 7.805-17.05 17.543 0 9.773 7.57 17.574 17.05 17.574 9.33 0 16.94-7.98 16.94-17.574 0-9.656-7.61-17.543-16.94-17.543m.08 38.969c-11.92 0-21.38-9.512-21.38-21.395 0-11.875 9.57-21.398 21.38-21.398 11.61 0 21.12 9.625 21.12 21.398 0 11.715-9.51 21.395-21.12 21.395" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none"/><defs><linearGradient id="discover-g" x1=".3" x2=".7" y1="1" y2="0"><stop offset="0" stop-color="#ffb000"/><stop offset="1" stop-color="#e5600f"/></linearGradient></defs><circle cx="924.03" cy="330.5" r="148.5" fill="url(#discover-g)"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="1 1 298.01 229.52" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-17.94377 -13.02567)scale(1.821516)"><path d="M174 108.3c0 14-11.4 25.4-25.4 25.4H10.4V33.1c0-14 11.4-25.4 25.4-25.4H174z" style="fill:#fff"/><linearGradient id="jcb-g1_" x1="117.386" x2="157.33" y1="81.399" y2="81.399" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M129 82.5h10.5c.3 0 1-.1 1.3-.1 2-.4 3.7-2.2 3.7-4.7 0-2.4-1.7-4.2-3.7-4.7-.3-.1-.9-.1-1.3-.1H129z" style="fill:url(#jcb-g1_)"/><linearGradient id="jcb-g2_" x1="117.384" x2="157.332" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M138.3 16.2c-10 0-18.2 8.1-18.2 18.2v18.9h25.7c.6 0 1.3 0 1.8.1 5.8.3 10.1 3.3 10.1 8.5 0 4.1-2.9 7.6-8.3 8.3v.2c5.9.4 10.4 3.7 10.4 8.8 0 5.5-5 9.1-11.6 9.1H120v37h26.7c10 0 18.2-8.1 18.2-18.2V16.2z" style="fill:url(#jcb-g2_)"/><linearGradient id="jcb-g3_" x1="117.385" x2="157.331" y1="68.399" y2="68.399" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#007940"/><stop offset=".229" style="stop-color:#00873f"/><stop offset=".743" style="stop-color:#40a737"/><stop offset="1" style="stop-color:#5cb531"/></linearGradient><path d="M143.2 63.1c0-2.4-1.7-4-3.7-4.3-.2 0-.7-.1-1-.1H129v8.8h9.5c.3 0 .9 0 1-.1 2-.3 3.7-1.9 3.7-4.3" style="fill:url(#jcb-g3_)"/><linearGradient id="jcb-g4_" x1="27.959" x2="68.522" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#1f286f"/><stop offset=".475" style="stop-color:#004e94"/><stop offset=".826" style="stop-color:#0066b1"/><stop offset="1" style="stop-color:#006fbc"/></linearGradient><path d="M37.7 16.2c-10 0-18.2 8.1-18.2 18.2v44.9c5.1 2.5 10.4 4.1 15.7 4.1 6.3 0 9.7-3.8 9.7-9V53.2h15.6v21.1c0 8.2-5.1 14.9-22.4 14.9-10.5 0-18.7-2.3-18.7-2.3v38.3h26.7c10 0 18.2-8.1 18.2-18.2V16.2z" style="fill:url(#jcb-g4_)"/><linearGradient id="jcb-g5_" x1="72.46" x2="111.855" y1="75.171" y2="75.171" gradientTransform="translate(-11.9755 -13.8615)scale(1.125)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#6c2c2f"/><stop offset=".173" style="stop-color:#882730"/><stop offset=".573" style="stop-color:#be1833"/><stop offset=".859" style="stop-color:#dc0436"/><stop offset="1" style="stop-color:#e60039"/></linearGradient><path d="M88 16.2c-10 0-18.2 8.1-18.2 18.2v23.8c4.6-3.9 12.6-6.4 25.5-5.8 6.9.3 14.3 2.2 14.3 2.2v7.7c-3.7-1.9-8.1-3.6-13.8-4-9.8-.7-15.7 4.1-15.7 12.5 0 8.5 5.9 13.3 15.7 12.5 5.7-.4 10.1-2.2 13.8-4V87s-7.3 1.9-14.3 2.2c-12.9.6-20.9-1.9-25.5-5.8v42h26.7c10 0 18.2-8.1 18.2-18.2v-91z" style="fill:url(#jcb-g5_)"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="1.97 1.87 296.07 177.27" xmlns="http://www.w3.org/2000/svg"><path d="M298.032 90.5c.014 48.936-39.646 88.614-88.582 88.627-48.937.012-88.614-39.646-88.627-88.582V90.5c-.013-48.935 39.647-88.615 88.581-88.628 48.937-.013 88.615 39.647 88.628 88.583z" style="fill:#f90"/><path d="M90.001 1.895C41.355 2.204 1.967 41.781 1.967 90.5c0 48.909 39.695 88.604 88.605 88.604 22.955 0 43.879-8.748 59.624-23.086l-.007-.004h.019a89.4 89.4 0 0 0 8.995-9.488H141.05a87 87 0 0 1-6.606-9.127h31.308a89 89 0 0 0 5.158-9.488h-41.635a87 87 0 0 1-3.703-9.309h49.045a88.4 88.4 0 0 0 4.56-28.103c0-6.512-.706-12.861-2.042-18.974h-54.164a88 88 0 0 1 2.528-9.308h49.063a89 89 0 0 0-3.82-9.487H129.27a85 85 0 0 1 5.084-9.307h31.285a89 89 0 0 0-6.877-9.488h-17.443a82 82 0 0 1 8.889-8.95c-15.746-14.34-36.676-23.09-59.636-23.09z" style="fill:#c00"/><path d="M289.143 136.82q.723.001 1.409.371.688.37 1.07 1.062c.256.456.384.937.384 1.435q0 .739-.379 1.424-.377.683-1.061 1.063-.678.374-1.424.375c-.746.001-.972-.126-1.426-.375a2.7 2.7 0 0 1-1.063-1.063 2.9 2.9 0 0 1-.377-1.424c0-.498.127-.979.384-1.435a2.66 2.66 0 0 1 1.071-1.062 3 3 0 0 1 1.412-.371m0 .475q-.602 0-1.176.311c-.38.207-.677.5-.891.888a2.4 2.4 0 0 0-.325 1.194q0 .62.315 1.188.321.566.888.885.57.316 1.188.315.62 0 1.189-.315.567-.319.884-.885.313-.568.313-1.188 0-.623-.321-1.194a2.2 2.2 0 0 0-.894-.888 2.4 2.4 0 0 0-1.17-.311m-1.256 3.975v-3.082h1.062q.54-.001.784.088a.76.76 0 0 1 .388.297.81.81 0 0 1-.098 1.039.95.95 0 0 1-.639.281.9.9 0 0 1 .264.164q.186.18.455.61l.375.603h-.606l-.272-.485q-.322-.572-.521-.718-.137-.104-.397-.105h-.293v1.311h-.5m.496-1.738h.604q.431 0 .588-.129a.42.42 0 0 0 .159-.342.4.4 0 0 0-.075-.244.47.47 0 0 0-.213-.162q-.134-.054-.497-.055h-.564v.932" style="fill:#fcb340"/><path d="m133.719 99.926 1.18-8.02c-.645 0-1.593.279-2.431.279-3.284 0-3.694-1.755-3.436-3.037l3.236-16.13h4.992l1.029-9.103h-4.705l.958-5.516H124.7c-.208.208-5.568 31.022-5.568 34.776 0 5.555 3.118 8.027 7.516 7.988 3.442-.029 6.125-.982 7.071-1.237m2.987-15.288c0 13.332 8.799 16.499 16.297 16.499 6.921 0 10.55-1.604 10.55-1.604l1.662-9.1s-5.848 2.378-10.601 2.378c-10.131 0-8.355-7.554-8.355-7.554l19.463.059s1.239-6.111 1.239-8.602c0-6.217-3.387-13.849-13.745-13.849-9.486.002-16.51 10.223-16.51 21.773m16.546-13.325c5.324 0 4.342 5.984 4.342 6.469H147.12c0-.62.989-6.469 6.132-6.469m59.738 28.61 1.689-10.284s-4.632 2.321-7.807 2.321c-6.693 0-9.378-5.11-9.378-10.601 0-11.137 5.758-17.265 12.168-17.265 4.808 0 8.665 2.699 8.665 2.699l1.54-9.993s-4.554-3.289-9.456-3.308c-14.745-.058-23.182 10.208-23.182 27.955 0 11.763 6.248 19.768 17.506 19.768 3.183 0 8.255-1.292 8.255-1.292M81.83 63.012c-6.469 0-11.427 2.079-11.427 2.079l-1.37 8.127s4.093-1.663 10.281-1.663c3.513 0 6.083.395 6.083 3.25 0 1.734-.314 2.374-.314 2.374s-2.772-.231-4.056-.231c-9.21 0-16.729 3.482-16.729 13.98 0 8.273 5.623 10.17 9.108 10.17 6.657 0 9.292-4.203 9.444-4.215l-.077 3.488h8.307l3.706-25.98c0-11.025-9.616-11.379-12.956-11.379m1.438 21.096c.181 1.586-.41 9.086-6.092 9.086-2.93 0-3.691-2.24-3.691-3.562 0-2.584 1.403-5.683 8.315-5.683 1.61 0 1.197.116 1.468.159m20.347 16.798c2.125 0 14.272.541 14.272-11.994 0-11.721-11.244-9.404-11.244-14.114 0-2.342 1.833-3.08 5.184-3.08 1.329 0 6.447.423 6.447.423l1.189-8.33c0 .001-3.312-.741-8.704-.741-6.979 0-14.063 2.786-14.063 12.318 0 10.802 11.812 9.717 11.812 14.267 0 3.037-3.3 3.287-5.844 3.287-4.401 0-8.363-1.511-8.377-1.438l-1.259 8.245c.229.07 2.674 1.157 10.587 1.157m187.192-45.451-1.705 12.709s-3.553-4.905-9.112-4.905c-10.459 0-15.849 10.423-15.849 22.396 0 7.73 3.844 15.307 11.699 15.307 5.651 0 8.784-3.941 8.784-3.941l-.415 3.365h9.178l7.207-44.862zm-4.052 24.701c0 4.983-2.468 11.64-7.581 11.64-3.396 0-4.988-2.851-4.988-7.324 0-7.315 3.285-12.14 7.432-12.14 3.394 0 5.137 2.33 5.137 7.824M30.749 100.423l5.743-33.87.844 33.87h6.499l12.125-33.87-5.371 33.87h9.658l7.437-44.922-15.342-.117-9.126 27.504-.25-27.387h-14.06l-7.544 44.922z" style="fill:#006" transform="translate(-13.74405 15.9939)"/><path d="M176.101 100.487c2.746-15.615 3.724-27.947 11.732-25.393 1.15-6.044 3.891-11.3 5.143-13.858 0 0-.396-.589-2.871-.589-4.225 0-9.866 8.574-9.866 8.574l.843-5.301h-8.786l-5.884 36.566h9.689z" style="fill:#006" transform="translate(-13.74405 15.9939)"/><path d="M-612.55 63.012c-6.472 0-11.43 2.079-11.43 2.079l-1.369 8.127s4.095-1.663 10.28-1.663c3.514 0 6.083.395 6.083 3.25 0 1.734-.313 2.374-.313 2.374s-2.771-.231-4.055-.231c-9.211 0-16.729 3.482-16.729 13.98 0 8.273 5.622 10.17 9.107 10.17 6.655 0 9.292-4.203 9.443-4.215l-.078 3.488h8.309l3.705-25.98c.001-11.025-9.615-11.379-12.953-11.379m1.436 21.096c.18 1.586-.411 9.086-6.092 9.086-2.932 0-3.692-2.24-3.692-3.562 0-2.584 1.402-5.683 8.315-5.683 1.611 0 1.199.116 1.469.159" style="fill:#006" transform="translate(831.55605 15.9939)"/><path d="M255.266 100.487c1.508-11.488 4.299-27.616 11.731-25.393 1.149-6.044.041-6.028-2.433-6.028-4.228 0-5.164.154-5.164.154l.844-5.301h-8.785l-5.884 36.567h9.691z" style="fill:#006" Line truncated

Before

Width:  |  Height:  |  Size: 8.9 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 618.01" xmlns="http://www.w3.org/2000/svg"><path fill="#eb001b" d="M308 0a309 309 0 1 0 2 0z"/><path fill="#f79e1b" d="M690 0a309 309 0 1 0 2 0z"/><path fill="#ff5f00" d="M500 66a309 309 0 0 0 0 486 309 309 0 0 0 0-486"/></svg>

Before

Width:  |  Height:  |  Size: 253 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 999.21 776.01" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="mc-silver-text-g1" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient><linearGradient id="mc-silver-text-g2" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="#8c8c8c" d="M364 66.1h270.4v485.8H364z"/><path fill="url(#mc-silver-text-g1)" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="url(#mc-silver-text-g2)" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/><path fill="#fff" d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 1000.01 618.01" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="mc-silver-g1" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient><linearGradient id="mc-silver-g2" x1=".1" x2=".9" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="url(#mc-silver-g1)" d="M308 0a309 309 0 1 0 2 0z"/><path fill="url(#mc-silver-g2)" d="M690 0a309 309 0 1 0 2 0z"/><path fill="#8c8c8c" d="M500 66a309 309 0 0 0 0 486 309 309 0 0 0 0-486"/></svg>

Before

Width:  |  Height:  |  Size: 686 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 999.21 776.01" xmlns="http://www.w3.org/2000/svg"><path fill="#ff5a00" d="M364 66.1h270.4v485.8H364z"/><path fill="#eb001b" d="M382 309c0-98.7 46.4-186.3 117.6-242.9C447.2 24.9 381.1 0 309 0 138.2 0 0 138.2 0 309s138.2 309 309 309c72.1 0 138.2-24.9 190.6-66.1C428.3 496.1 382 407.7 382 309"/><path fill="#f79e1b" d="M999.2 309c0 170.8-138.2 309-309 309-72.1 0-138.2-24.9-190.6-66.1 72.1-56.7 117.6-144.2 117.6-242.9S570.8 122.7 499.6 66.1C551.9 24.9 618 0 690.1 0 861 0 999.2 139.1 999.2 309"/><path fill="#fff" d="M181.1 774.3v-51.5c0-19.7-12-32.6-32.6-32.6-10.3 0-21.5 3.4-29.2 14.6-6-9.4-14.6-14.6-27.5-14.6-8.6 0-17.2 2.6-24 12v-10.3h-18v82.4h18v-45.5c0-14.6 7.7-21.5 19.7-21.5s18 7.7 18 21.5v45.5h18v-45.5c0-14.6 8.6-21.5 19.7-21.5 12 0 18 7.7 18 21.5v45.5zm267-82.4h-29.2V667h-18v24.9h-16.3v16.3h16.3V746c0 18.9 7.7 30 28.3 30 7.7 0 16.3-2.6 22.3-6l-5.2-15.5c-5.2 3.4-11.2 4.3-15.5 4.3-8.6 0-12-5.2-12-13.7v-36.9H448v-16.3zm152.8-1.8c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17.2c-3.6-.7-8.7-.7-12.1-.7M370 698.7c-8.6-6-20.6-8.6-33.5-8.6-20.6 0-34.3 10.3-34.3 26.6 0 13.7 10.3 21.5 28.3 24l8.6.9c9.4 1.7 14.6 4.3 14.6 8.6 0 6-6.9 10.3-18.9 10.3s-21.5-4.3-27.5-8.6l-8.6 13.7c9.4 6.9 22.3 10.3 35.2 10.3 24 0 37.8-11.2 37.8-26.6 0-14.6-11.2-22.3-28.3-24.9l-8.6-.9c-7.7-.9-13.7-2.6-13.7-7.7 0-6 6-9.4 15.5-9.4 10.3 0 20.6 4.3 25.8 6.9zm478.9-8.6c-10.3 0-17.2 5.2-21.5 12v-10.3h-18v82.4h18v-46.4c0-13.7 6-21.5 17.2-21.5 3.4 0 7.7.9 11.2 1.7l5.2-17c-3.5-.9-8.6-.9-12.1-.9m-230 43c0 24.9 17.2 42.9 43.8 42.9 12 0 20.6-2.6 29.2-9.4l-8.6-14.6c-6.9 5.2-13.7 7.7-21.5 7.7-14.6 0-24.9-10.3-24.9-26.6 0-15.5 10.3-25.8 24.9-26.6 7.7 0 14.6 2.6 21.5 7.7l8.6-14.6c-8.6-6.9-17.2-9.4-29.2-9.4-26.6-.1-43.8 18-43.8 42.9m166.5 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.4-.9-24.9-12.1-24.9-26.6m-215.4-43c-24 0-41.2 17.2-41.2 42.9 0 25.8 17.2 42.9 42.1 42.9 12 0 24-3.4 33.5-11.2l-8.6-12.9c-6.9 5.2-15.5 8.6-24 8.6-11.2 0-22.3-5.2-24.9-19.7h60.9v-6.9c.8-26.5-14.7-43.7-37.8-43.7m0 15.5c11.2 0 18.9 6.9 20.6 19.7h-42.9c1.7-11.1 9.4-19.7 22.3-19.7m447.2 27.5v-73.8h-18v42.9c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.1 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6m-602.6 0v-41.2h-18v10.3c-6-7.7-14.6-12-25.8-12-23.2 0-41.2 18-41.2 42.9s18 42.9 41.2 42.9c12 0 20.6-4.3 25.8-12v10.3h18zm-66.9 0c0-14.6 9.4-26.6 24.9-26.6 14.6 0 24.9 11.2 24.9 26.6 0 14.6-10.3 26.6-24.9 26.6-15.5-.9-24.9-12.1-24.9-26.6"/></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.Line truncated

Before

Width:  |  Height:  |  Size: 7.7 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 51.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 54.65 66.613 52.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.Line truncated

Before

Width:  |  Height:  |  Size: 5.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 255.54 159.85" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="up-silver-g1" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#f8f8f8"/><stop offset="1" stop-color="#bdbdbd"/></linearGradient><linearGradient id="up-silver-g2" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#d6d6d6"/><stop offset="1" stop-color="#8a8a8a"/></linearGradient><linearGradient id="up-silver-g3" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#ebebeb"/><stop offset="1" stop-color="#a4a4a4"/></linearGradient><mask id="up-silver-m" width="280" height="180" x="-10" y="-10" maskUnits="userSpaceOnUse"><path fill="#fff" d="M-10-10h280v180H-10z"/><path d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.975-1.5 8.41 3.473 10.347 1.586.674 3.007.875 4.45.802 1.526-.082 2.939-.847 4.248-1.948l-.355 1.359.226.29c3.577-.15 4.687-.15 8.562-.12l.352-.268c.565-3.327 1.099-6.557 2.57-12.922.716-3.048 1.431-6.067 2.166-9.103l-.115-.334c-4.001.741-5.07.9-8.92 1.445l-.292.238c-.039.31-.08.607-.117.904-.598-.967-1.466-1.793-2.804-2.307-1.712-.674-5.732.194-9.187 3.34-2.429 2.247-3.595 5.325-4.257 8.277m8.404.182c.857-3.668 1.84-6.77 4.39-6.77 1.612 0 2.461 1.487 2.288 4.024a129 129 0 0 1-.46 2.054c-.255 1.09-.531 2.17-.8 3.252-.274.74-.593 1.438-.942 1.903-.656.93-2.217 1.506-3.116 1.506-.255 0-1.828 0-1.882-2.32-.013-1.154.225-2.344.522-3.65m43.886-12.109-.31-.353c-3.959.802-4.676.93-8.313 1.421l-.268.267c-.012.044-.023.11-.042.171l-.012-.06c-2.708 6.248-2.629 4.9-4.833 9.818-.013-.224-.013-.363-.025-.601l-.552-10.663-.346-.353c-4.148.802-4.246.93-8.076 1.421l-.299.267c-.042.128Line truncated

Before

Width:  |  Height:  |  Size: 8.4 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="-9.6 -9.6 274.74 179.05" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#fff" stroke="#fff" stroke-linejoin="round" stroke-width="19.2" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#e21836" d="M50.042 0h63.778c8.903 0 14.44 7.257 12.364 16.19L96.49 143.675c-2.095 8.902-11.01 16.165-19.918 16.165H12.799c-8.89 0-14.44-7.263-12.363-16.165L30.142 16.19C32.219 7.257 41.128 0 50.042 0"/><path fill="#00447c" d="M108.513 0h73.344c8.901 0 4.888 7.257 2.794 16.19l-29.69 127.485c-2.082 8.902-1.433 16.165-10.353 16.165H71.265c-8.92 0-14.44-7.263-12.345-16.165L88.607 16.19C90.715 7.257 99.606 0 108.513 0"/><path fill="#007b84" d="M178.948 0h63.778c8.916 0 14.453 7.257 12.36 16.19l-29.69 127.485c-2.095 8.902-11.015 16.165-19.93 16.165h-63.748c-8.92 0-14.453-7.263-12.363-16.165L159.048 16.19C161.125 7.257 170.028 0 178.948 0"/><path fill="#fff" d="M66.698 40.856c-6.558.067-8.495 0-9.114-.146-.238 1.13-4.664 21.546-4.676 21.563-.953 4.13-1.646 7.075-4.002 8.976-1.336 1.105-2.897 1.639-4.707 1.639-2.908 0-4.603-1.445-4.887-4.184l-.055-.94s.886-5.533.886-5.564c0 0 4.645-18.605 5.477-21.064.044-.14.056-.213.067-.28-9.041.08-10.644 0-10.754-.146-.061.2-.285 1.354-.285 1.354l-4.743 20.97-.407 1.778-.788 5.817c0 1.725.339 3.134 1.013 4.325 2.161 3.776 8.325 4.342 11.812 4.342 4.492 0 8.707-.955 11.555-2.698 4.943-2.92 6.237-7.486 7.39-11.544l.535-2.081S65.8 43.65 66.613 41.136c.03-.14.043-.213.085-.28m16.28 15.588c-1.153 0-3.26.28-5.154 1.208-.687.353-1.337.76-2.023 1.166l.619-2.234-.339-.376c-4.015.813-4.914.922-8.623 1.444l-.31.207c-.431 3.57-.814 6.255-2.41 13.274a832 832 0 0 1-1.872 7.78l.17.327c3.802-.2 4.955-.2 8.259-.146l.267-.29c.42-2.15.474-2.654 1.404-7.009.437-2.064 1.348-6.6 1.797-8.215.825-.383 1.64-.759 2.417-.759 1.852 0 1.626 1.615 1.555 2.259-.08 1.08-.754 4.609-1.446 7.639l-.462 1.955c-.321 1.445-.674 2.848-.996 4.28l.14.286c3.746-.2 4.889-.2 8.088-.146l.376-.29c.579-3.358.748-4.257 1.774-9.146l.516-2.246c1.003-4.397 1.507-6.626.748-8.442-.802-2.035-2.727-2.526-4.494-2.526m18.188 4.603c-1.992.383-3.262.637-4.524.802-1.251.2-2.472.383-4.396.65l-.153.138-.14.11c-.2 1.434-.34 2.673-.606 4.13-.225 1.506-.572 3.218-1.136 5.677-.437 1.882-.662 2.538-.911 3.2-.243.663-.51 1.307-1.001 3.158l.115.171.096.157c1.799-.085 2.976-.146 4.185-.157 1.208-.043 2.46 0 4.396.01l.17-.137.182-.153c.28-1.67.321-2.119.492-2.933.17-.874.462-2.083 1.179-5.314.339-1.517.716-3.03 1.068-4.578.365-1.542.747-3.06 1.111-4.578l-.054-.183zm.042-6.206c-1.81-1.068-4.985-.729-7.123.746-2.131 1.446-2.374 3.498-.57 4.58 1.778 1.038 4.966.729 7.085-.759 2.127-1.477 2.393-3.51.608-4.567m10.943 24.734c3.661 0 7.414-1.01 10.239-4.003 2.173-2.428 3.17-6.041 3.515-7.529 1.123-4.931.248-7.233-.85-8.635-1.67-2.138-4.621-2.824-7.682-2.824-1.84 0-6.224.182-9.649 3.34-2.46 2.277-3.596 5.368-4.281 8.33-.692 3.02-1.488 8.454 3.51 10.477 1.542.662 3.765.844 5.198.844m-.286-11.095c.844-3.734 1.841-6.868 4.384-6.868 1.993 0 2.138 2.332 1.252 6.078-.159.832-.886 3.924-1.87 5.24-.688.972-1.5 1.561-2.4 1.561-.267 0-1.857 0-1.882-2.361-.012-1.166.226-2.357.516-3.65m23.191 10.615.286-.291c.406-2.15.473-2.655 1.371-7.008.45-2.065 1.38-6.6 1.816-8.216.827-.384 1.628-.76 2.43-.76 1.839 0 1.615 1.615 1.542 2.259-.067 1.082-.742 4.609-1.446 7.639l-.437 1.955c-.334 1.446-.698 2.848-1.02 4.282l.14.286c3.76-.2 4.858-.2 8.076-.146l.389-.291c.564-3.36.717-4.258 1.774-9.146l.503-2.247c1.008-4.397 1.518-6.624.771-8.44-.825-2.035-2.762-2.526-4.505-2.526-1.155 0-3.273.278-5.156 1.208-.673.352-1.348.758-2.01 1.166l.577-2.234-.31-.378c-4.014.814-4.93.923-8.635 1.446l-.285.206c-.449 3.571-.814 6.254-2.41 13.275a884 884 0 0 1-1.871 7.779l.17.328c3.807-.2 4.943-.2 8.24-.146m27.618.145c.236-1.154 1.64-7.99 1.652-7.99 0 0 1.195-5.017 1.269-5.199 0 0 .376-.522.752-.729h.553c5.222 0 11.119 0 15.74-3.4 3.145-2.332 5.295-5.775 6.254-9.96.249-1.026.432-2.246.432-3.466 0-1.603-.321-3.189-1.251-4.428-2.357-3.297-7.05-3.358-12.468-3.383l-2.67.025c-6.935.086-9.716.061-10.859-.079-.096.505-.278 1.404-.278 1.404s-2.484 11.513-2.484 11.531l-6.225 25.632c6.055-.073 8.538-.073 9.583.042m4.603-20.452s2.64-11.49 2.628-11.446l.086-.59.037-.448 1.056.108s5.447.468 5.574.48c2.15.832 3.036 2.976 2.418 5.774-.565 2.558-2.223 4.708-4.355 5.746-1.755.88-3.905.953-6.12.953h-1.433zm16.444 9.905c-.698 2.Line truncated

Before

Width:  |  Height:  |  Size: 8.5 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 825 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="#1434cb" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 820 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><path fill="#1a1f71" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 820 B

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0.5 0.5 999.01 323.7" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="visa-silver-g" x1=".15" x2=".85" y1="0" y2="1"><stop offset="0" stop-color="#f4f4f4"/><stop offset=".5" stop-color="#a8a8a8"/><stop offset="1" stop-color="#e2e2e2"/></linearGradient></defs><path fill="url(#visa-silver-g)" d="M651.19.5c-70.93 0-134.32 36.77-134.32 104.69 0 77.9 112.42 83.28 112.42 122.42 0 16.48-18.88 31.23-51.14 31.23-45.77 0-79.98-20.61-79.98-20.61l-14.64 68.55s39.41 17.41 91.73 17.41c77.55 0 138.58-38.57 138.58-107.66 0-82.32-112.89-87.54-112.89-123.86 0-12.91 15.5-27.05 47.66-27.05 36.29 0 65.89 14.99 65.89 14.99l14.33-66.2S696.61.5 651.18.5ZM2.22 5.5.5 15.49s29.84 5.46 56.72 16.36c34.61 12.49 37.07 19.77 42.9 42.35l63.51 244.83h85.14L379.93 5.5h-84.94l-84.28 213.17-34.39-180.7c-3.15-20.68-19.13-32.48-38.68-32.48H2.23Zm411.87 0-66.63 313.53h81L494.85 5.5zm451.76 0c-19.53 0-29.88 10.46-37.47 28.73l-118.67 284.8h84.94l16.43-47.47h103.48l9.99 47.47h74.95L934.12 5.5zm11.05 84.71 25.18 117.65h-67.45l42.28-117.65Z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

+114 -9
View File
@@ -1,7 +1,10 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { runInNewContext } from 'node:vm';
import { describe, expect, it } from 'vitest';
import indexHtml from '../index.html?raw';
import { initialLang } from './i18n/i18n';
import { initialTheme } from './shell/theme';
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../public', name));
@@ -14,27 +17,30 @@ function pngInfo(buf: Buffer) {
describe('Name und Kopfbereich', () => {
it('Titel und Meta-Angaben nennen AirCard-IOS', () => {
expect(indexHtml).toContain('<title>AirCard-IOS – Kartenmotiv gestalten</title>');
// Statisch englisch, damit Link-Vorschauen englisch sind; der Router setzt danach den Titel in der gewählten Sprache.
expect(indexHtml).toContain('<title>AirCard-IOS – Card designs for Apple Wallet</title>');
expect(indexHtml).toContain(
'<meta name="description" content="Explore community card designs for AirCard-iOS, create your own and download it as PNG." />',
);
expect(indexHtml).toContain('<meta name="theme-color" content="#0F1828" />');
expect(indexHtml).toContain('<meta name="apple-mobile-web-app-title" content="AirCard-IOS" />');
});
it('Icon-Links: SVG, 32-px-PNG, Apple-Touch-Icon', () => {
expect(indexHtml).toContain('<link rel="icon" href="/favicon.svg" type="image/svg+xml" />');
expect(indexHtml).toContain('<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png" />');
expect(indexHtml).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />');
expect(indexHtml).toContain('<link rel="icon" href="/favicon.svg?v=2" type="image/svg+xml" />');
expect(indexHtml).toContain('<link rel="icon" href="/favicon-32.png?v=2" sizes="32x32" type="image/png" />');
expect(indexHtml).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2" />');
});
it('Kopfzeile: dekoratives Logo (28 x 28) links vom Namen, beides im Markenlink', () => {
expect(indexHtml).toContain(
'<a class="brand" href="/" data-link><img src="/favicon.svg" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>',
'<a class="brand" href="/" data-link><img src="/favicon.svg?v=2" alt="" width="28" height="28" /><span>AirCard-IOS</span></a>',
);
});
it('Fußzeilen-Hinweis bleibt unverändert', () => {
expect(indexHtml).toContain(
'Inoffizielles Hobbyprojekt. Keine Verbindung zu Apple, Visa, Mastercard, UnionPay, American Express, JCB oder Discover. Alle Marken gehören ihren Inhabern.',
);
it('Fußzeile ist leer: ihren Text setzt mountFooter in der gewählten Sprache', () => {
expect(indexHtml).toContain('<footer id="app-footer"></footer>');
expect(indexHtml).not.toContain('Inoffizielles Hobbyprojekt');
});
});
@@ -47,3 +53,102 @@ describe('Icon-Dateien', () => {
expect(pngInfo(publicFile('apple-touch-icon.png'))).toEqual({ width: 180, height: 180, colorType: 2 });
});
});
describe('Inline-Skript für Hell/Dunkel und Sprache in index.html', () => {
const script = /<script>([\s\S]*?)<\/script>/.exec(indexHtml)?.[1] ?? '';
/** Was der Browser zur Sprache hergibt: der gespeicherte Wert von `aircard.lang`, `navigator.languages` und `navigator.language`. */
interface BrowserLang {
stored?: string | null;
languages?: readonly string[];
language?: string;
}
/**
* Führt das Skript in einer leeren Umgebung aus (kein Browser) und liefert, was es gesetzt hat.
* `stored` ist der Wert von `aircard.theme`; die Sprache steht in `browser`.
*/
function run(stored: string | null, prefersDark: boolean, storageThrows = false, browser: BrowserLang = {}) {
const attributes: Record<string, string> = {};
const sandbox: Record<string, unknown> = {
localStorage: {
getItem: (key: string) => {
if (storageThrows) throw new Error('gesperrt');
return key === 'aircard.lang' ? (browser.stored ?? null) : stored;
},
},
window: {
matchMedia: () => ({ matches: prefersDark }),
navigator: { languages: browser.languages, language: browser.language },
},
document: { documentElement: { setAttribute: (name: string, value: string) => (attributes[name] = value) } },
};
runInNewContext(script, sandbox);
return { attributes, sandbox };
}
it('ist da und läuft ohne Browser-Umgebung durch', () => {
expect(script).toContain('data-theme');
expect(() => run(null, true)).not.toThrow();
});
it('legt keine globale Variable an (Skript steckt in einer sofort aufgerufenen Funktion)', () => {
const { sandbox } = run('dark', false);
expect(Object.keys(sandbox).sort()).toEqual(['document', 'localStorage', 'window']);
expect('theme' in sandbox).toBe(false);
expect('lang' in sandbox).toBe(false);
});
it.each([
['dark', false],
['light', true],
['dark', true],
['light', false],
['blau', true],
['blau', false],
['', true],
[null, true],
[null, false],
] as const)('gleiche Regel wie initialTheme(): gespeichert %j, Systemvorgabe dunkel %s', (stored, prefersDark) => {
expect(run(stored, prefersDark).attributes['data-theme']).toBe(initialTheme(stored, prefersDark));
});
it('gesperrter localStorage: es gilt die Systemvorgabe', () => {
expect(run(null, true, true).attributes['data-theme']).toBe('dark');
expect(run(null, false, true).attributes['data-theme']).toBe('light');
});
it.each([
[null, ['de-DE', 'en']],
[null, ['en-US', 'de']],
['en', ['de-DE']],
['de', ['en-US']],
['xx', ['de-AT']],
['xx', ['fr-FR']],
['', ['de']],
[null, ['DE']],
[null, ['de_CH']],
[null, []],
] as const)('setzt <html lang> nach derselben Regel wie initialLang(): gespeichert %j, Browsersprachen %j', (stored, languages) => {
const { attributes } = run(null, true, false, { stored, languages });
expect(attributes.lang).toBe(initialLang(stored, languages));
});
it('ohne navigator.languages gilt navigator.language (wie currentLang), ohne beides Englisch', () => {
expect(run(null, true, false, { language: 'de-CH' }).attributes.lang).toBe('de');
expect(run(null, true, false, { language: 'en-GB' }).attributes.lang).toBe('en');
expect(run(null, true, false, { languages: [], language: 'de' }).attributes.lang).toBe('de');
expect(run(null, true, false, {}).attributes.lang).toBe('en');
});
it('gesperrter localStorage: es gilt die Browsersprache', () => {
expect(run(null, true, true, { stored: 'en', languages: ['de-DE'] }).attributes.lang).toBe('de');
expect(run(null, true, true, { stored: 'de', languages: ['en-US'] }).attributes.lang).toBe('en');
});
it('die gespeicherte Sprache hat mit dem Theme nichts zu tun (und umgekehrt)', () => {
const { attributes } = run('light', false, false, { stored: 'en', languages: ['de'] });
expect(attributes['data-theme']).toBe('light');
expect(attributes.lang).toBe('en');
});
});
+49 -22
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
DEFAULT_CONFIG,
DEFAULT_CROP,
NETWORK_IDS,
ID_PATTERN,
TONE_COLOR,
parseConfig,
serializeConfig,
@@ -12,9 +12,9 @@ import {
const custom: CardConfig = {
v: 1,
template: 'classic',
network: 'jcb',
logo: 'abc123',
details: 'dark',
brand: 'apple',
brand: 'aircards-text',
crop: { x: -120.5, y: 33, zoom: 2.5 },
};
@@ -23,7 +23,7 @@ describe('Standardwerte', () => {
expect(DEFAULT_CONFIG).toEqual({
v: 1,
template: 'signature',
network: 'visa-auto',
logo: null,
details: 'light',
brand: 'aircards-symbol',
crop: { x: 0, y: 0, zoom: 1 },
@@ -36,16 +36,13 @@ describe('Standardwerte', () => {
expect(DEFAULT_CONFIG.crop).not.toBe(DEFAULT_CROP);
});
it('NETWORK_IDS: 18 Logos in der vereinbarten Reihenfolge', () => {
expect(NETWORK_IDS.length).toBe(18);
expect([...NETWORK_IDS]).toEqual([
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
'up-color', 'up-silver', 'up-white-frame', 'up-english',
'amex-blue', 'amex-outline', 'amex-select',
'jcb', 'discover',
]);
expect(new Set(NETWORK_IDS).size).toBe(18);
it('eine neue Karte hat kein Logo unten rechts', () => {
expect(DEFAULT_CONFIG.logo).toBeNull();
});
it('ID_PATTERN: Appwrite-IDs mit höchstens 36 Zeichen und ohne Sonderzeichen am Anfang', () => {
for (const ok of ['abc123', 'A', '0', 'a.b_c-d', 'x'.repeat(36)]) expect(ID_PATTERN.test(ok), ok).toBe(true);
for (const bad of ['', 'kaputt id', '-abc', '_abc', '.abc', 'ä', 'a/b', 'x'.repeat(37)]) expect(ID_PATTERN.test(bad), bad).toBe(false);
});
it('TONE_COLOR: Hell weiß, Dunkel fast schwarz', () => {
@@ -72,9 +69,31 @@ describe('parseConfig / serializeConfig', () => {
}
});
it('unbekanntes Netzwerk setzt nur dieses Feld auf den Standard', () => {
const raw = JSON.stringify({ ...custom, network: 'paypal' });
expect(parseConfig(raw)).toEqual({ ...custom, network: DEFAULT_CONFIG.network });
it('eine alte Konfiguration mit network und Apple-Logo: network entfällt, Logo oben links fällt auf den Standard', () => {
const raw = '{"v":1,"template":"classic","network":"mc-color","brand":"apple","details":"dark"}';
expect(parseConfig(raw)).toEqual({
v: 1,
template: 'classic',
logo: null,
details: 'dark',
brand: 'aircards-symbol',
crop: { x: 0, y: 0, zoom: 1 },
});
// andere unbekannte Marken ebenso
expect(parseConfig('{"brand":"visa"}').brand).toBe('aircards-symbol');
});
it('logo: eine gültige ID bleibt, alles andere ergibt null', () => {
expect(parseConfig('{"logo":"abc123"}').logo).toBe('abc123');
expect(parseConfig(JSON.stringify({ logo: 'x'.repeat(36) })).logo).toBe('x'.repeat(36));
for (const bad of ['kaputt id', '', '-abc', 'x'.repeat(37), 42, true, null, ['abc'], { id: 'abc' }]) {
expect(parseConfig(JSON.stringify({ logo: bad })).logo, JSON.stringify(bad)).toBeNull();
}
expect(parseConfig('{}').logo).toBeNull();
});
it('das gewählte Logo bleibt nur dem Feld logo zugeordnet: ein altes network wird nie zum Logo', () => {
expect(parseConfig('{"network":"abc123"}').logo).toBeNull();
});
it('jedes Feld wird einzeln validiert', () => {
@@ -88,11 +107,11 @@ describe('parseConfig / serializeConfig', () => {
});
it('Namen aus Object.prototype gelten nicht als gültige Werte', () => {
const raw = JSON.stringify({ ...custom, network: 'constructor', template: '__proto__', brand: 'toString' });
const raw = JSON.stringify({ ...custom, template: '__proto__', details: 'constructor', brand: 'toString' });
expect(parseConfig(raw)).toEqual({
...custom,
network: DEFAULT_CONFIG.network,
template: DEFAULT_CONFIG.template,
details: DEFAULT_CONFIG.details,
brand: DEFAULT_CONFIG.brand,
});
});
@@ -137,21 +156,29 @@ describe('parseConfig / serializeConfig', () => {
const raw = JSON.stringify({ ...custom, evil: '<script>', crop: { ...custom.crop, extra: 1 } });
const c = parseConfig(raw);
expect(c).toEqual(custom);
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'network', 'template', 'v']);
expect(Object.keys(c).sort()).toEqual(['brand', 'crop', 'details', 'logo', 'template', 'v']);
expect(Object.keys(c.crop).sort()).toEqual(['x', 'y', 'zoom']);
});
it('serializeConfig schreibt nur bekannte Felder', () => {
const s = serializeConfig({ ...custom, evil: 1 } as CardConfig);
const s = serializeConfig({ ...custom, evil: 1, network: 'jcb' } as CardConfig);
expect(s).not.toContain('evil');
expect(s).not.toContain('network');
expect(JSON.parse(s)).toEqual(custom);
});
it('serializeConfig schreibt genau {v, template, logo, details, brand, crop} in dieser Reihenfolge', () => {
expect(Object.keys(JSON.parse(serializeConfig(custom)))).toEqual(['v', 'template', 'logo', 'details', 'brand', 'crop']);
expect(serializeConfig(DEFAULT_CONFIG)).toBe(
'{"v":1,"template":"signature","logo":null,"details":"light","brand":"aircards-symbol","crop":{"x":0,"y":0,"zoom":1}}',
);
});
it('serializeConfig bleibt unter 2000 Zeichen', () => {
expect(serializeConfig(DEFAULT_CONFIG).length).toBeLessThan(2000);
const worst: CardConfig = {
...custom,
network: 'mc-silver-text',
logo: 'x'.repeat(36),
brand: 'aircards-symbol',
crop: { x: -1.2345678901234567e-300, y: -9.87654321098765e300, zoom: 3.3333333333333335 },
};
+19 -14
View File
@@ -3,21 +3,17 @@
const TEMPLATES = ['image', 'minimal', 'classic', 'signature'] as const;
const TONES = ['light', 'dark'] as const;
const BRANDS = ['aircards-symbol', 'aircards-text', 'apple', 'none'] as const;
const NETWORKS = [
'visa-auto', 'visa-blue', 'visa-navy', 'visa-silver',
'mc-color', 'mc-silver', 'mc-wordmark', 'mc-silver-text', 'mc-classic',
'up-color', 'up-silver', 'up-white-frame', 'up-english',
'amex-blue', 'amex-outline', 'amex-select',
'jcb', 'discover',
] as const;
const BRANDS = ['aircards-symbol', 'aircards-text', 'none'] as const;
export type TemplateId = (typeof TEMPLATES)[number];
export type DetailsTone = (typeof TONES)[number];
export type BrandId = (typeof BRANDS)[number];
export type NetworkId = (typeof NETWORKS)[number];
export const NETWORK_IDS: readonly NetworkId[] = NETWORKS;
/**
* Zeilen- und Datei-IDs von Appwrite: höchstens 36 Zeichen, kein Sonderzeichen am Anfang. Steht hier, damit die reine
* Konfiguration die ID des Community-Logos prüfen kann, ohne Appwrite zu laden (account/cards.ts reicht sie weiter).
*/
export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/;
/** Zoom-Grenzen relativ zum Cover-Maßstab. */
export const MIN_ZOOM = 1;
@@ -33,8 +29,10 @@ export interface Crop {
export interface CardConfig {
v: 1;
template: TemplateId;
network: NetworkId;
/** ID des Community-Logos unten rechts; null = keins. */
logo: string | null;
details: DetailsTone;
/** Das Logo oben links (AirCard-IOS Symbol oder Text, oder keins). */
brand: BrandId;
crop: Crop;
}
@@ -44,7 +42,7 @@ export const DEFAULT_CROP: Crop = { x: 0, y: 0, zoom: 1 };
export const DEFAULT_CONFIG: CardConfig = {
v: 1,
template: 'signature',
network: 'visa-auto',
logo: null,
details: 'light',
brand: 'aircards-symbol',
crop: { ...DEFAULT_CROP },
@@ -61,6 +59,11 @@ function pick<T extends string>(value: unknown, allowed: readonly T[], fallback:
: fallback;
}
/** Die ID eines Community-Logos; alles, was keine Appwrite-ID sein kann, ergibt null (kein Logo). */
function parseLogo(value: unknown): string | null {
return typeof value === 'string' && ID_PATTERN.test(value) ? value : null;
}
function finite(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
@@ -82,6 +85,8 @@ function parseCrop(value: unknown): Crop {
/**
* Liest eine gespeicherte Konfiguration. Jedes Feld wird einzeln geprüft, Unbekanntes oder
* Kaputtes fällt auf den Standard dieses Feldes zurück. Liefert immer ein neues Objekt.
* Ältere Karten kennen noch das Feld `network` (Kartennetzwerk) und das Apple-Logo: `network` wird ignoriert,
* `brand: 'apple'` fällt auf den Standard.
*/
export function parseConfig(json: string | null | undefined): CardConfig {
let raw: unknown = null;
@@ -96,7 +101,7 @@ export function parseConfig(json: string | null | undefined): CardConfig {
return {
v: 1,
template: pick(r.template, TEMPLATES, DEFAULT_CONFIG.template),
network: pick(r.network, NETWORKS, DEFAULT_CONFIG.network),
logo: parseLogo(r.logo),
details: pick(r.details, TONES, DEFAULT_CONFIG.details),
brand: pick(r.brand, BRANDS, DEFAULT_CONFIG.brand),
crop: parseCrop(r.crop),
@@ -108,7 +113,7 @@ export function serializeConfig(c: CardConfig): string {
return JSON.stringify({
v: 1,
template: c.template,
network: c.network,
logo: c.logo,
details: c.details,
brand: c.brand,
crop: { x: c.crop.x, y: c.crop.y, zoom: c.crop.zoom },
+40 -27
View File
@@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
import {
CARD_H,
CARD_W,
LOGO_MAX_H,
MARGIN,
PREVIEW_RADIUS,
WALLET_KEEP_CLEAR,
fitBrand,
fitNetwork,
fitLogo,
layoutFor,
type Box,
} from './geometry';
@@ -41,36 +42,48 @@ describe('Konstanten', () => {
});
});
describe('fitNetwork', () => {
it('Standardbreite 330 × 109 sitzt unten rechts auf dem Rand', () => {
const b = fitNetwork(330 / 109, 109);
expect(b.x).toBeCloseTo(1150, 1);
expect(b.y).toBeCloseTo(804, 1);
expect(b.w).toBeCloseTo(330, 1);
expect(b.h).toBeCloseTo(109, 1);
describe('fitLogo', () => {
it('LOGO_MAX_H ist 125', () => {
expect(LOGO_MAX_H).toBe(125);
});
it('Seitenverhältnis 2 bei Höhe 125: 250 × 125, rechte Kante 1480, Unterkante 913', () => {
expect(fitLogo(2, 125)).toEqual({ x: 1230, y: 788, w: 250, h: 125 });
});
it('ohne maxH gilt LOGO_MAX_H', () => {
expect(fitLogo(2)).toEqual(fitLogo(2, LOGO_MAX_H));
expect(fitLogo(1)).toEqual({ x: 1355, y: 788, w: 125, h: 125 });
});
it('sehr breite Logos werden auf 360 begrenzt', () => {
const b = fitNetwork(6, 109);
const b = fitLogo(10, 125);
expect(b.w).toBeCloseTo(360, 1);
expect(b.h).toBeCloseTo(60, 1);
expect(b.h).toBeCloseTo(36, 1);
expect(b.x + b.w).toBeCloseTo(1480, 1);
expect(b.y + b.h).toBeCloseTo(913, 1);
});
it('reine Symbole dürfen bis 125 px hoch sein', () => {
const b = fitNetwork(1, 125);
expect(b.w).toBeCloseTo(125, 1);
it('hohe Logos werden auf die Höhe begrenzt', () => {
const b = fitLogo(0.5, 125);
expect(b.h).toBeCloseTo(125, 1);
expect(b.w).toBeCloseTo(62.5, 1);
expect(b.x + b.w).toBeCloseTo(1480, 1);
expect(b.y + b.h).toBeCloseTo(913, 1);
});
it('maxH ist einstellbar', () => {
const b = fitLogo(1, 100);
expect(b.w).toBeCloseTo(100, 1);
expect(b.h).toBeCloseTo(100, 1);
expect(b.y + b.h).toBeCloseTo(913, 1);
});
it('Seitenverhältnis bleibt erhalten', () => {
for (const aspect of [0.8, 1, 1.5, 3, 6, 10]) {
const b = fitNetwork(aspect, 109);
const b = fitLogo(aspect);
expect(b.w / b.h).toBeCloseTo(aspect, 1);
expect(b.h).toBeLessThanOrEqual(109 + 1e-9);
expect(b.h).toBeLessThanOrEqual(LOGO_MAX_H + 1e-9);
expect(b.w).toBeLessThanOrEqual(360 + 1e-9);
}
});
@@ -105,23 +118,23 @@ describe('fitBrand', () => {
});
describe('layoutFor', () => {
it('image: kein Netzwerk, keine Marke, keine optionalen Felder', () => {
it('image: kein Logo, keine Marke, keine optionalen Felder', () => {
const l = layoutFor('image');
expect(l.network).toBe(false);
expect(l.logo).toBe(false);
expect(l.brand).toBe(false);
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
});
it('minimal: nur Netzwerk', () => {
it('minimal: nur Logo', () => {
const l = layoutFor('minimal');
expect(l.network).toBe(true);
expect(l.logo).toBe(true);
expect(l.brand).toBe(false);
expect(Object.keys(l).sort()).toEqual(['brand', 'network']);
expect(Object.keys(l).sort()).toEqual(['brand', 'logo']);
});
it('classic: alles', () => {
const l = layoutFor('classic');
expect(l.network).toBe(true);
expect(l.logo).toBe(true);
expect(l.brand).toBe(true);
expect(l.platinum).toEqual({ x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' });
expect(l.chip).toEqual({
@@ -141,9 +154,9 @@ describe('layoutFor', () => {
});
});
it('signature: Netzwerk, Marke und PLATINUM links', () => {
it('signature: Logo, Marke und PLATINUM links', () => {
const l = layoutFor('signature');
expect(l.network).toBe(true);
expect(l.logo).toBe(true);
expect(l.brand).toBe(true);
expect(l.platinum).toEqual({ x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' });
expect(l.chip).toBeUndefined();
@@ -193,9 +206,9 @@ describe.each(TEMPLATES)('Vorlage %s: Wallet-Bereich unten links bleibt frei', (
if (l.contactless) boxes.contactless = l.contactless.box;
if (l.number) boxes.number = NUMBER_BOX;
if (l.platinum) boxes.platinum = PLATINUM_BOX[t]!;
if (l.network) {
boxes.networkSymbol = fitNetwork(1, 125);
boxes.networkWide = fitNetwork(6, 109);
if (l.logo) {
boxes.logoSquare = fitLogo(1);
boxes.logoWide = fitLogo(10);
}
if (l.brand) boxes.brand = fitBrand(3.6);
+20 -13
View File
@@ -6,6 +6,9 @@ export const CARD_H = 969;
export const MARGIN = 56;
/** Nur für die Vorschau (CSS), nie im Export. */
export const PREVIEW_RADIUS = 57;
/** Vorschaudatei (WebP) einer gespeicherten oder veröffentlichten Karte: Breite in px und Qualität des Exports. */
export const PREVIEW_WIDTH = 480;
export const PREVIEW_QUALITY = 0.85;
export interface Box {
x: number;
@@ -17,19 +20,21 @@ export interface Box {
/** Unten links blendet Wallet die letzten vier Ziffern ein, hier darf nichts liegen. */
export const WALLET_KEEP_CLEAR: Box = { x: 0, y: 779, w: 620, h: 190 };
const NETWORK_MAX_W = 360;
const NETWORK_RIGHT = CARD_W - MARGIN; // 1480
const NETWORK_BOTTOM = CARD_H - MARGIN; // 913
/** Höhe, bis zu der ein Community-Logo unten rechts auf der Karte wachsen darf. */
export const LOGO_MAX_H = 125;
const LOGO_MAX_W = 360;
const LOGO_RIGHT = CARD_W - MARGIN; // 1480
const LOGO_BOTTOM = CARD_H - MARGIN; // 913
const BRAND_MAX_W = 360;
/** Netzwerk-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
export function fitNetwork(aspect: number, maxH: number): Box {
const w = Math.min(NETWORK_MAX_W, maxH * aspect);
/** Community-Logo unten rechts, rechte Kante 1480, Unterkante 913, höchstens 360 breit und maxH hoch. */
export function fitLogo(aspect: number, maxH = LOGO_MAX_H): Box {
const w = Math.min(LOGO_MAX_W, maxH * aspect);
const h = w / aspect;
return { x: NETWORK_RIGHT - w, y: NETWORK_BOTTOM - h, w, h };
return { x: LOGO_RIGHT - w, y: LOGO_BOTTOM - h, w, h };
}
/** AirCard-/Apple-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
/** AirCard-IOS-Logo links oben ab (56, 56), höchstens 360 breit und maxH hoch. */
export function fitBrand(aspect: number, maxH = 100): Box {
const w = Math.min(BRAND_MAX_W, maxH * aspect);
const h = w / aspect;
@@ -37,7 +42,9 @@ export function fitBrand(aspect: number, maxH = 100): Box {
}
export interface Layout {
network: boolean;
/** Community-Logo unten rechts. */
logo: boolean;
/** AirCard-IOS-Logo oben links. */
brand: boolean;
platinum?: { x: number; baseline: number; size: number; trackingPx: number; align: 'left' | 'right' };
chip?: {
@@ -52,10 +59,10 @@ export interface Layout {
}
const LAYOUTS: Record<TemplateId, Layout> = {
image: { network: false, brand: false },
minimal: { network: true, brand: false },
image: { logo: false, brand: false },
minimal: { logo: true, brand: false },
classic: {
network: true,
logo: true,
brand: true,
platinum: { x: 1480, baseline: 123, size: 46, trackingPx: 9, align: 'right' },
chip: {
@@ -77,7 +84,7 @@ const LAYOUTS: Record<TemplateId, Layout> = {
},
},
signature: {
network: true,
logo: true,
brand: true,
platinum: { x: 60, baseline: 226, size: 44, trackingPx: 8, align: 'left' },
},
+70 -107
View File
@@ -1,62 +1,39 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { NETWORK_IDS } from './config';
import favicon from '../../public/favicon.svg?raw';
import {
BRAND_MARKS,
NETWORK_MARKS,
getBrandMark,
getNetworkMark,
loadMarkImage,
svgAspect,
tintSvg,
} from './marks';
import { BRAND_MARKS, getBrandMark, loadMarkImage, svgAspect, tintSvg, viewBoxAspect } from './marks';
const NETWORK_LABELS = [
'Automatisch', 'Blau', 'Marineblau', 'Metallisches Silber',
'Farbe', 'Metallisches Silber', 'Wortmarke', 'Silber + Text', 'Klassisch',
'Farbe', 'Metallisches Silber', 'Weißer Rahmen', 'Englisch',
'Blau', 'Umriss', 'Select',
'JCB', 'Discover',
];
const BRAND_IDS = ['aircards-symbol', 'aircards-text'];
const ALL = [...NETWORK_MARKS, ...BRAND_MARKS];
const TINT_IDS = ['visa-auto', 'amex-outline', 'aircards-symbol', 'aircards-text', 'apple'];
describe('Registers', () => {
it('NETWORK_MARKS folgt NETWORK_IDS', () => {
expect(NETWORK_MARKS.map((m) => m.id)).toEqual([...NETWORK_IDS]);
});
it('Labels stehen im Wortlaut der Vorlage in Reihenfolge', () => {
expect(NETWORK_MARKS.map((m) => m.label)).toEqual(NETWORK_LABELS);
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text', 'Apple']);
});
it('BRAND_MARKS: Logo-Gruppe, feste Reihenfolge', () => {
expect(BRAND_MARKS.map((m) => m.id)).toEqual(['aircards-symbol', 'aircards-text', 'apple']);
describe('Register', () => {
it('BRAND_MARKS: nur die zwei AirCard-IOS-Marken, in fester Reihenfolge, Logo-Gruppe', () => {
expect(BRAND_MARKS.map((m) => m.id)).toEqual(BRAND_IDS);
expect(BRAND_MARKS.every((m) => m.group === 'Logo')).toBe(true);
});
it('Gruppen der Netzwerke', () => {
const groups = NETWORK_MARKS.map((m) => m.group);
expect(groups).toEqual([
'Visa', 'Visa', 'Visa', 'Visa',
'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard', 'Mastercard',
'UnionPay', 'UnionPay', 'UnionPay', 'UnionPay',
'American Express', 'American Express', 'American Express',
'JCB', 'Discover',
]);
it('Labels stehen im Wortlaut der Vorlage', () => {
expect(BRAND_MARKS.map((m) => m.label)).toEqual(['AirCard-IOS · Symbol', 'AirCard-IOS · Text']);
});
it('getNetworkMark / getBrandMark finden den Eintrag', () => {
for (const id of NETWORK_IDS) expect(getNetworkMark(id).id).toBe(id);
expect(getBrandMark('apple').id).toBe('apple');
it('getBrandMark findet den Eintrag', () => {
for (const id of BRAND_IDS) expect(getBrandMark(id as 'aircards-symbol').id).toBe(id);
expect(getBrandMark('aircards-text').label).toBe('AirCard-IOS · Text');
});
it('keine Netzwerk- und keine Apple-Marken mehr im Register', () => {
const registry = BRAND_MARKS.map((m) => `${m.id} ${m.group} ${m.label}`).join('\n');
expect(registry).not.toMatch(/visa|mastercard|unionpay|american express|jcb|discover|apple/i);
});
it('assets/marks enthält nur noch die zwei AirCard-IOS-Dateien (keine fremden Marken-Logos)', () => {
const files = readdirSync(join(import.meta.dirname, '../assets/marks')).sort();
expect(files).toEqual(['aircards-symbol.svg', 'aircards-text.svg']);
});
});
describe('SVG-Inhalt', () => {
it.each(ALL.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s ist ein sauberes SVG', (_id, m) => {
expect(m.svg.trimStart().startsWith('<svg')).toBe(true);
expect(m.svg).toMatch(/viewBox="[^"]+"/);
expect(m.svg).not.toContain('<image');
@@ -67,7 +44,7 @@ describe('SVG-Inhalt', () => {
});
it('Wurzelelement: xmlns für Blob-Bilder, keine feste Größe (die viewBox bestimmt die Größe)', () => {
for (const m of ALL) {
for (const m of BRAND_MARKS) {
const root = /^<svg\b[^>]*>/.exec(m.svg.trimStart())![0];
expect(root, m.id).toContain('xmlns="http://www.w3.org/2000/svg"');
expect(root, m.id).not.toMatch(/\s(width|height)=/);
@@ -84,24 +61,24 @@ describe('SVG-Inhalt', () => {
expect(svgAspect('<svg viewBox="10 20 300 100"></svg>')).toBeCloseTo(3, 1);
});
it('svgAspect: Visa ist ein breites Wortlogo', () => {
expect(svgAspect(getNetworkMark('visa-auto').svg)).toBeCloseTo(1000 / 324.68, 1);
it('viewBoxAspect: Breite durch Höhe aus dem Wert einer viewBox (Leerraum oder Kommas), sonst null', () => {
expect(viewBoxAspect('0 0 300 100')).toBeCloseTo(3, 5);
expect(viewBoxAspect(' 10,20,200,100 ')).toBeCloseTo(2, 5);
expect(viewBoxAspect('0 0 100 100')).toBe(1);
for (const unusable of ['', 'none', '0 0 0 10', '0 0 10 0', '0 0 -10 5', '0 0 10', '0 0 1 2 3', '0 0 a b', '0 0 Infinity 5']) {
expect(viewBoxAspect(unusable), JSON.stringify(unusable)).toBeNull();
}
});
it('Einfärbbar genau bei den fünf Tint-Marken', () => {
const tinted = ALL.filter((m) => m.tint).map((m) => m.id).sort();
expect(tinted).toEqual([...TINT_IDS].sort());
});
it('Tint-Marken enthalten currentColor, alle anderen nicht', () => {
for (const m of ALL) {
expect(m.svg.includes('currentColor'), m.id).toBe(m.tint);
it('beide Marken sind einfärbbar und enthalten currentColor', () => {
for (const m of BRAND_MARKS) {
expect(m.tint, m.id).toBe(true);
expect(m.svg.includes('currentColor'), m.id).toBe(true);
}
});
it('tintSvg ersetzt jedes currentColor', () => {
for (const id of TINT_IDS) {
const m = [...NETWORK_MARKS, ...BRAND_MARKS].find((x) => x.id === id)!;
for (const m of BRAND_MARKS) {
const dark = tintSvg(m.svg, 'dark');
expect(dark).toContain('#111111');
expect(dark).not.toContain('currentColor');
@@ -111,14 +88,14 @@ describe('SVG-Inhalt', () => {
}
});
it('tintSvg lässt Marken ohne currentColor unverändert', () => {
const m = getNetworkMark('jcb');
expect(tintSvg(m.svg, 'dark')).toBe(m.svg);
it('tintSvg lässt ein SVG ohne currentColor unverändert', () => {
const svg = '<svg viewBox="0 0 10 10"><path fill="#123456" d="M0 0h10v10z"/></svg>';
expect(tintSvg(svg, 'dark')).toBe(svg);
});
it('Verlauf-IDs sind je Datei eindeutig (Präfix = Marken-ID)', () => {
const seen = new Map<string, string>();
for (const m of ALL) {
for (const m of BRAND_MARKS) {
for (const hit of m.svg.matchAll(/\bid="([^"]+)"/g)) {
const id = hit[1];
expect(seen.has(id), `${id} doppelt in ${m.id} und ${seen.get(id)}`).toBe(false);
@@ -129,36 +106,15 @@ describe('SVG-Inhalt', () => {
});
describe('Verweise innerhalb der Dateien', () => {
it.each(ALL.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
it.each(BRAND_MARKS.map((m) => [m.id, m] as const))('%s: jedes url(#…) zeigt auf eine id derselben Datei', (_id, m) => {
const defined = new Set([...m.svg.matchAll(/\bid="([^"]+)"/g)].map((h) => h[1]));
const refs = [...m.svg.matchAll(/url\(\s*['"]?#([^)'"\s]+)/g)].map((h) => h[1]);
for (const ref of refs) expect(defined.has(ref), `${ref} fehlt in ${m.id}`).toBe(true);
});
it('die Silber-/Verlaufs-Marken verwenden überhaupt Verweise', () => {
for (const id of ['visa-silver', 'mc-silver', 'mc-silver-text', 'up-silver', 'jcb', 'discover']) {
expect(getNetworkMark(id as never).svg, id).toMatch(/url\(#/);
}
});
});
describe('maxH', () => {
const h = (id: string) => NETWORK_MARKS.find((m) => m.id === id)!.maxH;
it('109 für Visa, Mastercard-Wortmarken, UnionPay English, Discover', () => {
for (const id of ['visa-auto', 'visa-blue', 'visa-navy', 'visa-silver', 'mc-wordmark', 'mc-silver-text', 'up-english', 'discover']) {
expect(h(id), id).toBe(109);
}
});
it('125 für Symbol-Logos', () => {
for (const id of ['mc-color', 'mc-silver', 'mc-classic', 'up-color', 'up-silver', 'up-white-frame', 'amex-blue', 'amex-outline', 'jcb']) {
expect(h(id), id).toBe(125);
}
});
it('90 für Amex Select, 100 für alle Marken', () => {
expect(h('amex-select')).toBe(90);
it('100 für beide Marken', () => {
expect(BRAND_MARKS.every((m) => m.maxH === 100)).toBe(true);
});
});
@@ -175,6 +131,21 @@ describe('AirCard-IOS-Marken', () => {
expect(symbol).not.toMatch(/fill-opacity="1"/);
});
it('Symbol: die Maske hat keine eigene Region und ihr weißes Feld ist größer als die vordere Karte (kein Haarstrich an der Naht)', () => {
const tag = /<mask\b[^>]*>/.exec(symbol)?.[0] ?? '';
// Ohne x/y/width/height gilt die Standardregion (-10 % / 120 % der Karte); eine Region exakt auf der Kartenkante
// würde deren Kantenglättung doppelt anwenden und bei Vergrößerung einen hellen Strich lassen.
expect(tag).not.toMatch(/\s(?:x|y|width|height|maskUnits)=/);
const white = /<mask\b[^>]*>\s*<path\b[^>]*fill="#fff"[^>]*\bd="M(-?[\d.]+)[ ,](-?[\d.]+)h([\d.]+)v([\d.]+)H/.exec(symbol);
expect(white, 'weißes Rechteck am Anfang der Maske').not.toBeNull();
const [x, y, w, h] = white!.slice(1).map(Number);
// Die vordere Karte: x 172–852, y 660–1090.
expect(x).toBeLessThan(172);
expect(y).toBeLessThan(660);
expect(x + w).toBeGreaterThan(852);
expect(y + h).toBeGreaterThan(1090);
});
it('Symbol: enge viewBox um die drei Karten (680 x 730)', () => {
expect(svgAspect(symbol)).toBeCloseTo(680 / 730, 3);
});
@@ -275,11 +246,11 @@ describe('loadMarkImage', () => {
it('gleiches (id, tone) liefert dieselbe Promise und lädt nur einmal, anderer Ton eine andere', async () => {
const m = await fresh();
const visa = m.getNetworkMark('visa-auto');
const a = m.loadMarkImage(visa, 'dark');
const b = m.loadMarkImage(visa, 'dark');
const c = m.loadMarkImage(visa, 'light');
const d = m.loadMarkImage(m.getNetworkMark('jcb'), 'dark');
const symbol = m.getBrandMark('aircards-symbol');
const a = m.loadMarkImage(symbol, 'dark');
const b = m.loadMarkImage(symbol, 'dark');
const c = m.loadMarkImage(symbol, 'light');
const d = m.loadMarkImage(m.getBrandMark('aircards-text'), 'dark');
expect(b).toBe(a);
expect(c).not.toBe(a);
expect(d).not.toBe(a);
@@ -287,15 +258,15 @@ describe('loadMarkImage', () => {
expect(imgs[0]).toBeInstanceOf(FakeImage);
expect(blobs).toHaveLength(3);
// nach dem Laden bleibt der Eintrag im Cache
expect(m.loadMarkImage(visa, 'dark')).toBe(a);
expect(m.loadMarkImage(symbol, 'dark')).toBe(a);
expect(blobs).toHaveLength(3);
});
it('Blob ist ein eingefärbtes SVG mit eigener Größe und dem richtigen MIME-Typ', async () => {
const m = await fresh();
const visa = m.getNetworkMark('visa-auto');
await m.loadMarkImage(visa, 'dark');
await m.loadMarkImage(visa, 'light');
const symbol = m.getBrandMark('aircards-symbol');
await m.loadMarkImage(symbol, 'dark');
await m.loadMarkImage(symbol, 'light');
const [dark, light] = await Promise.all(blobs.map((b) => b.text()));
expect(blobs[0].type).toBe('image/svg+xml');
expect(dark).toContain('#111111');
@@ -305,33 +276,25 @@ describe('loadMarkImage', () => {
const root = /^<svg\b[^>]*>/.exec(dark)![0];
const w = Number(/\swidth="([\d.]+)"/.exec(root)![1]);
const h = Number(/\sheight="([\d.]+)"/.exec(root)![1]);
expect(h).toBe(visa.maxH * 4);
expect(w / h).toBeCloseTo(m.svgAspect(visa.svg), 1);
expect(h).toBe(symbol.maxH * 4);
expect(w / h).toBeCloseTo(m.svgAspect(symbol.svg), 1);
// Bild bekommt die Blob-URL
expect(env.images[0].src).toBe('blob:fake/1');
expect(env.images[1].src).toBe('blob:fake/2');
});
it('Marken ohne currentColor bleiben im Blob unverändert (bis auf die Größe)', async () => {
const m = await fresh();
const jcb = m.getNetworkMark('jcb');
await m.loadMarkImage(jcb, 'dark');
const text = await blobs[0].text();
expect(text.replace(/^<svg width="[\d.]+" height="[\d.]+"/, '<svg')).toBe(jcb.svg);
});
it('gibt die Blob-URL nach dem Laden frei', async () => {
const m = await fresh();
await m.loadMarkImage(m.getNetworkMark('visa-blue'), 'light');
await m.loadMarkImage(m.getBrandMark('aircards-text'), 'light');
expect(revoked).toEqual(['blob:fake/1']);
});
it('Fehler: Promise wird abgelehnt, URL freigegeben, Cache-Eintrag verworfen, zweiter Aufruf versucht es neu', async () => {
const m = await fresh();
const def = m.getNetworkMark('visa-auto');
const def = m.getBrandMark('aircards-symbol');
env.mode = 'error';
const first = m.loadMarkImage(def, 'dark');
await expect(first).rejects.toThrow(/visa-auto/);
await expect(first).rejects.toThrow(/aircards-symbol/);
expect(revoked).toEqual(['blob:fake/1']);
env.mode = 'load';
+22 -60
View File
@@ -1,35 +1,21 @@
// Logo-Register: SVG-Dateien der Netzwerke und Marken, Einfärben und Laden als Bild für den Canvas.
// Logo-Register: SVG-Dateien der AirCard-IOS-Marken (Logo oben links), Einfärben und Laden als Bild für den Canvas.
// Reine Daten plus ein kleiner Browser-Lader; die Größe der Logos bestimmt allein die viewBox.
// Die Logos unten rechts kommen von der Community (logos/logo-asset.ts), hier steht keine fremde Marke.
import { TONE_COLOR, type BrandId, type DetailsTone, type NetworkId, NETWORK_IDS } from './config';
import { msg } from '../i18n/i18n';
import { TONE_COLOR, type BrandId, type DetailsTone } from './config';
import visaAuto from '../assets/marks/visa-auto.svg?raw';
import visaBlue from '../assets/marks/visa-blue.svg?raw';
import visaNavy from '../assets/marks/visa-navy.svg?raw';
import visaSilver from '../assets/marks/visa-silver.svg?raw';
import mcColor from '../assets/marks/mc-color.svg?raw';
import mcSilver from '../assets/marks/mc-silver.svg?raw';
import mcWordmark from '../assets/marks/mc-wordmark.svg?raw';
import mcSilverText from '../assets/marks/mc-silver-text.svg?raw';
import mcClassic from '../assets/marks/mc-classic.svg?raw';
import upColor from '../assets/marks/up-color.svg?raw';
import upSilver from '../assets/marks/up-silver.svg?raw';
import upWhiteFrame from '../assets/marks/up-white-frame.svg?raw';
import upEnglish from '../assets/marks/up-english.svg?raw';
import amexBlue from '../assets/marks/amex-blue.svg?raw';
import amexOutline from '../assets/marks/amex-outline.svg?raw';
import amexSelect from '../assets/marks/amex-select.svg?raw';
import jcb from '../assets/marks/jcb.svg?raw';
import discover from '../assets/marks/discover.svg?raw';
import aircardsSymbol from '../assets/marks/aircards-symbol.svg?raw';
import aircardsText from '../assets/marks/aircards-text.svg?raw';
import apple from '../assets/marks/apple.svg?raw';
export type MarkGroup = 'Visa' | 'Mastercard' | 'UnionPay' | 'American Express' | 'JCB' | 'Discover' | 'Logo';
export type MarkGroup = 'Logo';
export interface MarkDef {
id: NetworkId | Exclude<BrandId, 'none'>;
/** Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Visa – Blau“). */
id: Exclude<BrandId, 'none'>;
/**
* Gruppenname; zusammen mit dem Label der Text für `aria-label`/`title` („Logo – AirCard-IOS · Symbol“).
* Beide sind deutsch markiert (`msg`) und werden erst beim Anzeigen mit `t()` übersetzt.
*/
group: MarkGroup;
label: string;
svg: string;
@@ -39,56 +25,32 @@ export interface MarkDef {
maxH: number;
}
type NetworkEntry = Omit<MarkDef, 'id'>;
type MarkEntry = Omit<MarkDef, 'id'>;
const NETWORK_TABLE: Record<NetworkId, NetworkEntry> = {
'visa-auto': { group: 'Visa', label: 'Automatisch', svg: visaAuto, tint: true, maxH: 109 },
'visa-blue': { group: 'Visa', label: 'Blau', svg: visaBlue, tint: false, maxH: 109 },
'visa-navy': { group: 'Visa', label: 'Marineblau', svg: visaNavy, tint: false, maxH: 109 },
'visa-silver': { group: 'Visa', label: 'Metallisches Silber', svg: visaSilver, tint: false, maxH: 109 },
'mc-color': { group: 'Mastercard', label: 'Farbe', svg: mcColor, tint: false, maxH: 125 },
'mc-silver': { group: 'Mastercard', label: 'Metallisches Silber', svg: mcSilver, tint: false, maxH: 125 },
'mc-wordmark': { group: 'Mastercard', label: 'Wortmarke', svg: mcWordmark, tint: false, maxH: 109 },
'mc-silver-text': { group: 'Mastercard', label: 'Silber + Text', svg: mcSilverText, tint: false, maxH: 109 },
'mc-classic': { group: 'Mastercard', label: 'Klassisch', svg: mcClassic, tint: false, maxH: 125 },
'up-color': { group: 'UnionPay', label: 'Farbe', svg: upColor, tint: false, maxH: 125 },
'up-silver': { group: 'UnionPay', label: 'Metallisches Silber', svg: upSilver, tint: false, maxH: 125 },
'up-white-frame': { group: 'UnionPay', label: 'Weißer Rahmen', svg: upWhiteFrame, tint: false, maxH: 125 },
'up-english': { group: 'UnionPay', label: 'Englisch', svg: upEnglish, tint: false, maxH: 109 },
'amex-blue': { group: 'American Express', label: 'Blau', svg: amexBlue, tint: false, maxH: 125 },
'amex-outline': { group: 'American Express', label: 'Umriss', svg: amexOutline, tint: true, maxH: 125 },
'amex-select': { group: 'American Express', label: 'Select', svg: amexSelect, tint: false, maxH: 90 },
jcb: { group: 'JCB', label: 'JCB', svg: jcb, tint: false, maxH: 125 },
discover: { group: 'Discover', label: 'Discover', svg: discover, tint: false, maxH: 109 },
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, MarkEntry> = {
'aircards-symbol': { group: msg('Logo'), label: msg('AirCard-IOS · Symbol'), svg: aircardsSymbol, tint: true, maxH: 100 },
'aircards-text': { group: msg('Logo'), label: msg('AirCard-IOS · Text'), svg: aircardsText, tint: true, maxH: 100 },
};
const BRAND_TABLE: Record<Exclude<BrandId, 'none'>, NetworkEntry> = {
'aircards-symbol': { group: 'Logo', label: 'AirCard-IOS · Symbol', svg: aircardsSymbol, tint: true, maxH: 100 },
'aircards-text': { group: 'Logo', label: 'AirCard-IOS · Text', svg: aircardsText, tint: true, maxH: 100 },
apple: { group: 'Logo', label: 'Apple', svg: apple, tint: true, maxH: 100 },
};
export const NETWORK_MARKS: readonly MarkDef[] = NETWORK_IDS.map((id) => ({ id, ...NETWORK_TABLE[id] }));
export const BRAND_MARKS: readonly MarkDef[] = (
Object.keys(BRAND_TABLE) as Exclude<BrandId, 'none'>[]
).map((id) => ({ id, ...BRAND_TABLE[id] }));
export function getNetworkMark(id: NetworkId): MarkDef {
return NETWORK_MARKS.find((m) => m.id === id) ?? NETWORK_MARKS[0];
}
export function getBrandMark(id: Exclude<BrandId, 'none'>): MarkDef {
return BRAND_MARKS.find((m) => m.id === id) ?? BRAND_MARKS[0];
}
/** Breite durch Höhe aus dem Wert einer viewBox („x y Breite Höhe“, mit Leerraum oder Kommas); null, wenn er nicht brauchbar ist. */
export function viewBoxAspect(value: string): number | null {
const parts = value.trim().split(/[\s,]+/).map(Number);
const [w, h] = [parts[2], parts[3]];
return parts.length === 4 && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : null;
}
/** Breite durch Höhe aus der viewBox; 1, wenn keine brauchbare viewBox da ist. */
export function svgAspect(svg: string): number {
const hit = /viewBox\s*=\s*"([^"]+)"/.exec(svg);
if (!hit) return 1;
const parts = hit[1].trim().split(/[\s,]+/).map(Number);
const [w, h] = [parts[2], parts[3]];
return parts.length === 4 && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ? w / h : 1;
return (hit && viewBoxAspect(hit[1])) ?? 1;
}
/** Ersetzt jedes `currentColor` durch die Details-Farbe. */
+98 -32
View File
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadLogoAsset } from '../logos/logo-asset';
import { DEFAULT_CONFIG, type CardConfig } from './config';
import { fitLogo } from './geometry';
import { loadMarkImage } from './marks';
import {
drawCard,
@@ -9,6 +11,7 @@ import {
type CardAssets,
type CardImage,
type Ctx,
type LogoAsset,
} from './render';
// marks.ts importiert SVG-Dateien per ?raw; für den Renderer reichen feste viewBoxen.
@@ -22,7 +25,6 @@ vi.mock('./marks', () => {
maxH,
});
return {
getNetworkMark: (id: string) => mark(id, '0 0 330 109', 109),
getBrandMark: (id: string) => mark(id, '0 0 100 100', 100),
svgAspect: (svg: string) => {
const p = /viewBox="([^"]+)"/.exec(svg)![1].split(' ').map(Number);
@@ -32,6 +34,9 @@ vi.mock('./marks', () => {
};
});
// Der Standard-Lader der Logos (Appwrite, Dekodierung) kommt in diesen Tests nie zum Einsatz.
vi.mock('../logos/logo-asset', () => ({ loadLogoAsset: vi.fn() }));
interface Call {
name: string;
args: unknown[];
@@ -99,13 +104,15 @@ function fakeCtx() {
return { ctx: ctx as unknown as Ctx, calls, strokePath };
}
const NETWORK = { tag: 'network' } as unknown as CanvasImageSource;
const LOGO_SOURCE = { tag: 'logo' } as unknown as CanvasImageSource;
const BRAND = { tag: 'brand' } as unknown as CanvasImageSource;
const PHOTO = { tag: 'photo' } as unknown as CanvasImageSource;
/** Ein Community-Logo mit Seitenverhältnis 2 : 1 (die Größe in Pixeln spielt für den Platz auf der Karte keine Rolle). */
const LOGO: LogoAsset = { source: LOGO_SOURCE, w: 200, h: 100 };
const assets = (over: Partial<CardAssets> = {}): CardAssets => ({
image: null,
network: NETWORK,
logo: LOGO,
brand: BRAND,
...over,
});
@@ -187,18 +194,42 @@ describe('renderCard: Grund und Bild', () => {
});
describe('renderCard: Vorlagen', () => {
it('minimal: nur das Netzwerk-Logo unten rechts', () => {
it('minimal: nur das Community-Logo unten rechts (Seitenverhältnis 2 : 1 → 250 × 125, rechte Kante 1480, Unterkante 913)', () => {
const calls = render(cfg({ template: 'minimal' }), assets());
expect(named(calls, 'drawImage')).toHaveLength(1);
const [src, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
expect(src).toBe(NETWORK);
expect(x).toBeCloseTo(1150, 1);
expect(y).toBeCloseTo(804, 1);
expect(w).toBeCloseTo(330, 1);
expect(h).toBeCloseTo(109, 1);
const [src, x, y, w, h] = drawsOf(calls, LOGO_SOURCE)[0].args as number[];
expect(src).toBe(LOGO_SOURCE);
expect([x, y, w, h]).toEqual([1230, 788, 250, 125]);
expect(named(calls, 'fillText')).toHaveLength(0);
});
it('das Logo sitzt dort, wo fitLogo es hinlegt (Seitenverhältnis aus w und h des Logos)', () => {
const wide: LogoAsset = { source: LOGO_SOURCE, w: 720, h: 72 };
const [src, x, y, w, h] = drawsOf(render(cfg({ template: 'classic' }), assets({ logo: wide })), LOGO_SOURCE)[0].args as number[];
const box = fitLogo(10);
expect(src).toBe(LOGO_SOURCE);
expect([x, y, w, h]).toEqual([box.x, box.y, box.w, box.h]);
expect(w).toBe(360);
});
it('Vorlage image: auch mit Logo wird es nicht gezeichnet', () => {
const calls = render(cfg({ template: 'image' }), assets({ image: photo(1536, 969) }));
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(0);
expect(named(calls, 'drawImage')).toHaveLength(1);
});
it('ohne Logo (assets.logo null) wird nichts unten rechts gezeichnet, auch wenn die Konfiguration eines wählt', () => {
for (const template of ['minimal', 'classic', 'signature'] as const) {
const calls = render(cfg({ template, logo: 'abc123' }), assets({ logo: null }));
expect(drawsOf(calls, LOGO_SOURCE), template).toHaveLength(0);
}
});
it('das Logo hängt nur an den Bildern, nicht an config.logo (die Bilder gehören zur Konfiguration, mit der sie geladen wurden)', () => {
const calls = render(cfg({ template: 'minimal', logo: null }), assets());
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
});
it('classic dunkel: Text in #111111, PLATINUM endet bei x 1480, Ziffern und Punkte stimmen', () => {
const calls = render(cfg({ template: 'classic', details: 'dark' }), assets());
@@ -238,11 +269,10 @@ describe('renderCard: Vorlagen', () => {
expect(named(calls, 'arc').every((c) => c.fillStyle === '#FFFFFF')).toBe(true);
});
it('classic: Brand-Logo links oben, Netzwerk-Logo unten rechts', () => {
it('classic: Brand-Logo links oben, Community-Logo unten rechts', () => {
const calls = render(cfg({ template: 'classic' }), assets());
expect(drawsOf(calls, BRAND)[0].args).toEqual([BRAND, 56, 56, 100, 100]);
const [, x, y, w, h] = drawsOf(calls, NETWORK)[0].args as number[];
expect([x, y, w, h].map((v) => Math.round(v))).toEqual([1150, 804, 330, 109]);
expect(drawsOf(calls, LOGO_SOURCE)[0].args).toEqual([LOGO_SOURCE, 1230, 788, 250, 125]);
});
it('classic: Kontaktlos-Bögen in Details-Farbe mit runden Enden', () => {
@@ -256,7 +286,7 @@ describe('renderCard: Vorlagen', () => {
it('signature, Brand none: kein Brand-drawImage, PLATINUM beginnt bei x 60', () => {
const calls = render(cfg({ template: 'signature', brand: 'none' }), assets());
expect(drawsOf(calls, BRAND)).toHaveLength(0);
expect(drawsOf(calls, NETWORK)).toHaveLength(1);
expect(drawsOf(calls, LOGO_SOURCE)).toHaveLength(1);
const plat = letters(calls);
expect(plat[0].args).toEqual(['P', 60, 226]);
expect(plat[0].font).toBe('700 44px "Nunito Sans Variable"');
@@ -273,7 +303,7 @@ describe('renderCard: Vorlagen', () => {
});
it('fehlende Logo-Bilder werden übersprungen', () => {
const calls = render(cfg({ template: 'classic' }), assets({ network: null, brand: null }));
const calls = render(cfg({ template: 'classic' }), assets({ logo: null, brand: null }));
expect(named(calls, 'drawImage')).toHaveLength(0);
expect(letters(calls)).toHaveLength(8);
});
@@ -319,7 +349,7 @@ describe('renderCard: Chip', () => {
});
describe('renderCard: Reihenfolge', () => {
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Netzwerk', () => {
it('Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Brand, Logo', () => {
const calls = render(cfg({ template: 'classic' }), assets({ image: photo(1536, 969) }));
const at = (pred: (c: Call) => boolean) => calls.findIndex(pred);
const order = [
@@ -330,7 +360,7 @@ describe('renderCard: Reihenfolge', () => {
at((c) => c.name === 'arc'),
at((c) => c.name === 'fillText' && c.args[0] === 'P'),
at((c) => c.name === 'drawImage' && c.args[0] === BRAND),
at((c) => c.name === 'drawImage' && c.args[0] === NETWORK),
at((c) => c.name === 'drawImage' && c.args[0] === LOGO_SOURCE),
];
expect(order.every((i) => i >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
@@ -386,14 +416,17 @@ describe('drawCard', () => {
});
describe('loadCardAssets', () => {
const NET_IMG = { tag: 'net-img' };
const BRAND_IMG = { tag: 'brand-img' };
const fontsLoad = vi.fn();
const mocked = vi.mocked(loadMarkImage);
const defaultLoader = vi.mocked(loadLogoAsset);
const loadLogo = vi.fn<(id: string, tone: 'light' | 'dark') => Promise<LogoAsset | null>>();
beforeEach(() => {
fontsLoad.mockReset().mockResolvedValue([]);
mocked.mockReset().mockImplementation(async (def) => (def.id === 'apple' || def.id.startsWith('aircards') ? BRAND_IMG : NET_IMG) as unknown as HTMLImageElement);
mocked.mockReset().mockImplementation(async () => BRAND_IMG as unknown as HTMLImageElement);
defaultLoader.mockReset().mockResolvedValue(LOGO);
loadLogo.mockReset().mockResolvedValue(LOGO);
Object.defineProperty(document, 'fonts', { configurable: true, value: { load: fontsLoad } });
});
@@ -401,35 +434,68 @@ describe('loadCardAssets', () => {
Reflect.deleteProperty(document, 'fonts');
});
it('lädt Netzwerk- und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
it('lädt Community-Logo und Brand-Logo in Details-Farbe und wartet auf die Schrift', async () => {
const image = photo(100, 100);
const result = await loadCardAssets(cfg({ network: 'jcb', brand: 'apple', details: 'dark', template: 'image' }), image);
const result = await loadCardAssets(cfg({ logo: 'abc123', brand: 'aircards-text', details: 'dark', template: 'image' }), image, loadLogo);
expect(result).toEqual({ image, logo: LOGO, brand: BRAND_IMG, logoMissing: false });
expect(result.image).toBe(image);
expect(result.network).toBe(NET_IMG);
expect(result.brand).toBe(BRAND_IMG);
expect(mocked).toHaveBeenCalledTimes(2);
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['jcb', 'dark'], ['apple', 'dark']]);
expect(loadLogo).toHaveBeenCalledTimes(1);
expect(loadLogo).toHaveBeenCalledWith('abc123', 'dark');
expect(mocked).toHaveBeenCalledTimes(1);
expect(mocked.mock.calls.map(([def, tone]) => [def.id, tone])).toEqual([['aircards-text', 'dark']]);
expect(fontsLoad).toHaveBeenCalledWith('700 46px "Nunito Sans Variable"');
});
it('ohne Logo (logo null): kein Lader-Aufruf, kein fehlendes Logo', async () => {
const result = await loadCardAssets(cfg({ logo: null }), null, loadLogo);
expect(loadLogo).not.toHaveBeenCalled();
expect(result.logo).toBeNull();
expect(result.logoMissing).toBe(false);
});
it('eine leere logo-Angabe zählt wie kein Logo', async () => {
const result = await loadCardAssets(cfg({ logo: '' }), null, loadLogo);
expect(loadLogo).not.toHaveBeenCalled();
expect(result.logoMissing).toBe(false);
});
it('der Lader liefert null (gelöscht, ausgeblendet): logo null und logoMissing true, Brand-Logo wird trotzdem geladen', async () => {
loadLogo.mockResolvedValue(null);
const result = await loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo);
expect(result.logo).toBeNull();
expect(result.logoMissing).toBe(true);
expect(result.brand).toBe(BRAND_IMG);
});
it('der Lader wirft: loadCardAssets wirft (ein echter Ladefehler ist kein fehlendes Logo)', async () => {
loadLogo.mockRejectedValue(new Error('offline'));
await expect(loadCardAssets(cfg({ logo: 'abc123' }), null, loadLogo)).rejects.toThrow('offline');
});
it('ohne dritten Parameter lädt loadLogoAsset', async () => {
const result = await loadCardAssets(cfg({ logo: 'abc123', details: 'light' }), null);
expect(defaultLoader).toHaveBeenCalledWith('abc123', 'light');
expect(result.logo).toBe(LOGO);
});
it('Brand none: kein Brand-Logo laden', async () => {
const result = await loadCardAssets(cfg({ brand: 'none' }), null);
const result = await loadCardAssets(cfg({ brand: 'none' }), null, loadLogo);
expect(result.image).toBeNull();
expect(result.brand).toBeNull();
expect(mocked).toHaveBeenCalledTimes(1);
expect(mocked).not.toHaveBeenCalled();
});
it('Schrift lädt nicht oder document.fonts fehlt: Karte wird trotzdem fertig', async () => {
fontsLoad.mockRejectedValue(new Error('offline'));
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
Reflect.deleteProperty(document, 'fonts');
Object.defineProperty(document, 'fonts', { configurable: true, value: undefined });
await expect(loadCardAssets(cfg(), null)).resolves.toMatchObject({ network: NET_IMG });
await expect(loadCardAssets(cfg(), null, loadLogo)).resolves.toMatchObject({ brand: BRAND_IMG });
});
it('Logo-Fehler wird weitergereicht', async () => {
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: jcb'));
await expect(loadCardAssets(cfg({ network: 'jcb' }), null)).rejects.toThrow(/Logo/);
it('Marken-Fehler wird weitergereicht', async () => {
mocked.mockRejectedValue(new Error('Logo konnte nicht geladen werden: aircards-symbol'));
await expect(loadCardAssets(cfg(), null, loadLogo)).rejects.toThrow(/Logo/);
});
});
+40 -15
View File
@@ -1,18 +1,34 @@
// Kartenrenderer: zeichnet eine Karte in 1536 x 969-Koordinaten auf einen Canvas-Kontext.
// Vorschau, Vorlagen-Miniaturen, PNG-Download und Vorschaubild beim Speichern benutzen alle drawCard.
import { msg } from '../i18n/i18n';
import { loadLogoAsset } from '../logos/logo-asset';
import { TONE_COLOR, type CardConfig, type DetailsTone } from './config';
import { clampCrop, imageRect } from './crop';
import { CARD_H, CARD_W, fitBrand, fitNetwork, layoutFor, type Layout } from './geometry';
import { getBrandMark, getNetworkMark, loadMarkImage, svgAspect } from './marks';
import { CARD_H, CARD_W, fitBrand, fitLogo, layoutFor, type Layout } from './geometry';
import { getBrandMark, loadMarkImage, svgAspect } from './marks';
export type CardImage = { source: CanvasImageSource; w: number; h: number };
/** Ein Community-Logo, fertig zum Zeichnen (einfarbige schon in der Details-Farbe); w/h = Pixel der Quelle, daraus folgt das Seitenverhältnis. */
export interface LogoAsset {
source: CanvasImageSource;
w: number;
h: number;
}
export interface CardAssets {
image: CardImage | null;
network: CanvasImageSource | null;
/** Das Community-Logo unten rechts; null = keins oder nicht verfügbar. */
logo: LogoAsset | null;
/** Das AirCard-IOS-Logo oben links. */
brand: CanvasImageSource | null;
}
/** Was `loadCardAssets` liefert: dazu die Auskunft, ob die Karte ein Logo wählt, das es nicht (mehr) gibt. */
export interface LoadedAssets extends CardAssets {
logoMissing: boolean;
}
/** Der kleine Ausschnitt von CanvasRenderingContext2D, den der Renderer braucht (leicht zu faken). */
export type Ctx = Pick<
CanvasRenderingContext2D,
@@ -112,7 +128,7 @@ function drawPlatinum(ctx: Ctx, p: NonNullable<Layout['platinum']>, color: strin
});
}
/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Logo, Netzwerk. */
/** Zeichnet die Karte in 1536 x 969-Koordinaten. Reihenfolge fest: Grund, Bild, Chip, Kontaktlos, Nummer, PLATINUM, Marke oben links, Logo unten rechts. */
export function renderCard(
ctx: Ctx,
config: CardConfig,
@@ -150,10 +166,10 @@ export function renderCard(
ctx.drawImage(assets.brand, b.x, b.y, b.w, b.h);
}
if (layout.network && assets.network) {
const mark = getNetworkMark(config.network);
const n = fitNetwork(svgAspect(mark.svg), mark.maxH);
ctx.drawImage(assets.network, n.x, n.y, n.w, n.h);
// Gezeichnet wird, was die Bilder mitbringen (sie gehören zu der Konfiguration, mit der sie geladen wurden).
if (layout.logo && assets.logo) {
const l = fitLogo(assets.logo.w / assets.logo.h);
ctx.drawImage(assets.logo.source, l.x, l.y, l.w, l.h);
}
}
@@ -165,7 +181,8 @@ export function drawCard(
): void {
// Die Überladungen beider Canvas-Typen lassen sich als Union nicht aufrufen; zur Laufzeit gleich.
const ctx = (canvas as HTMLCanvasElement).getContext('2d', { alpha: false });
if (!ctx) throw new Error('Canvas wird in diesem Browser nicht unterstützt.');
// Die Meldung geht deutsch markiert in den Fehler; der Editor zeigt sie mit `t(error.message)`.
if (!ctx) throw new Error(msg('Canvas wird in diesem Browser nicht unterstützt.'));
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(canvas.width / CARD_W, canvas.height / CARD_H);
// Fotos werden stark verkleinert; die Standardqualität (low) zeigt dabei Treppenstufen.
@@ -173,17 +190,25 @@ export function drawCard(
renderCard(ctx, config, assets);
}
/** Lädt Schrift und Logos (in Details-Farbe). Das Bild reicht der Aufrufer durch. */
export async function loadCardAssets(config: CardConfig, image: CardImage | null): Promise<CardAssets> {
/**
* Lädt Schrift und Logos (in Details-Farbe). Das Bild reicht der Aufrufer durch.
* Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet), ist `logo` null und `logoMissing` true; die Karte
* lässt sich dann ohne Logo zeichnen. Ein echter Ladefehler (Netz, Server) wirft dagegen.
*/
export async function loadCardAssets(
config: CardConfig,
image: CardImage | null,
loadLogo: (id: string, tone: DetailsTone) => Promise<LogoAsset | null> = loadLogoAsset,
): Promise<LoadedAssets> {
// Ohne geladene Schrift misst und zeichnet der Canvas mit der Ersatzschrift.
const fontReady = Promise.resolve(typeof document !== 'undefined' ? document.fonts?.load(FONT_LOAD) : undefined)
.catch(() => undefined);
const [network, brand] = await Promise.all([
loadMarkImage(getNetworkMark(config.network), config.details),
const [logo, brand] = await Promise.all([
config.logo ? loadLogo(config.logo, config.details) : null,
config.brand === 'none' ? null : loadMarkImage(getBrandMark(config.brand), config.details),
fontReady,
]);
return { image, network, brand };
return { image, logo, brand, logoMissing: Boolean(config.logo) && logo === null };
}
/** Rendert die Karte offscreen in der gewünschten Breite (Höhe nach 1536 : 969) und liefert die Bilddatei. */
@@ -198,7 +223,7 @@ export async function exportCard(
drawCard(canvas, config, assets);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('Das Bild konnte nicht erzeugt werden.'))),
(blob) => (blob ? resolve(blob) : reject(new Error(msg('Das Bild konnte nicht erzeugt werden.')))),
opts.type,
opts.quality,
);
+887
View File
@@ -0,0 +1,887 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
createJWT: vi.fn(),
userId: null as string | null,
authListeners: [] as Array<(user: unknown) => void>,
logoutListeners: [] as Array<() => void>,
}));
vi.mock('../account/appwrite', () => ({ account: { createJWT: mocks.createJWT } }));
vi.mock('../account/auth', () => ({
currentUserId: () => mocks.userId,
onAuthChange: (fn: (user: unknown) => void) => {
mocks.authListeners.push(fn);
return () => undefined;
},
onLogout: (fn: () => void) => {
mocks.logoutListeners.push(fn);
return () => undefined;
},
}));
type Api = typeof import('./api');
const UNAVAILABLE = 'Der Dienst ist gerade nicht erreichbar.';
const MINUTE = 60_000;
// Der JWT-Cache lebt im Modul; jeder Test bekommt eine frische Instanz. Das gilt auch für i18n: die frische Instanz
// beginnt mit der Browsersprache (im Test Englisch) und wird wie im Test-Setup auf Deutsch gestellt.
async function freshApi(): Promise<Api> {
vi.resetModules();
const api = await import('./api');
(await import('../i18n/i18n')).setLang('de', { persist: false });
return api;
}
/** Stellt die Sprache der frischen Instanz um (dieselbe, die `freshApi` geladen hat). */
async function setLanguage(lang: 'de' | 'en'): Promise<void> {
(await import('../i18n/i18n')).setLang(lang, { persist: false });
}
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise<Response>>();
function sentHeaders(call = 0): Record<string, string> {
return fetchMock.mock.calls[call][1].headers as Record<string, string>;
}
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-04T10:00:00Z'));
mocks.createJWT.mockReset();
mocks.userId = 'u1';
mocks.authListeners.length = 0;
mocks.logoutListeners.length = 0;
let n = 0;
mocks.createJWT.mockImplementation(async () => ({ jwt: `jwt${++n}` }));
fetchMock.mockReset();
fetchMock.mockImplementation(async () => json(200, { ok: true }));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('apiPost', () => {
it('sendet JSON per POST an den Pfad und liefert die JSON-Antwort', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(200, { schonGemeldet: true }));
await expect(api.apiPost('/api/melden', { id: 'c1', reason: 'spam' }, { auth: false })).resolves.toEqual({
schonGemeldet: true,
});
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/melden');
expect(init.method).toBe('POST');
expect(sentHeaders()['Content-Type']).toBe('application/json');
expect(init.body).toBe(JSON.stringify({ id: 'c1', reason: 'spam' }));
});
it('ohne auth: kein JWT, kein createJWT', async () => {
const api = await freshApi();
await api.apiPost('/api/melden', {}, { auth: false });
expect(mocks.createJWT).not.toHaveBeenCalled();
expect(sentHeaders()['X-Aircard-Jwt']).toBeUndefined();
});
it('mit auth: sendet den Header X-Aircard-Jwt mit dem JWT von createJWT (ohne duration)', async () => {
const api = await freshApi();
await api.apiPost('/api/zurueckziehen', { id: 'c1' }, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledOnce();
expect(mocks.createJWT.mock.calls[0]).toEqual([]);
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
describe('JWT-Cache', () => {
it('der zweite Aufruf innerhalb von 10 Minuten holt kein neues JWT', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
vi.setSystemTime(Date.now() + 9 * MINUTE + 59_000);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledOnce();
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1');
});
it('nach 10 Minuten wird ein neues JWT geholt', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
vi.setSystemTime(Date.now() + 10 * MINUTE);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
});
it('gilt je Nutzer-ID: ein anderer Nutzer bekommt nie das JWT des vorigen', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
mocks.userId = 'u2';
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
});
it('wird bei einer Änderung des Nutzers (onAuthChange) geleert', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.authListeners).toHaveLength(1);
mocks.authListeners[0](null);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('wird beim Abmelden (onLogout) geleert', async () => {
const api = await freshApi();
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.logoutListeners).toHaveLength(1);
mocks.logoutListeners[0]();
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('ein JWT, das erst nach einem Nutzerwechsel eintrifft, wird nicht gemerkt', async () => {
const api = await freshApi();
let release: (value: { jwt: string }) => void = () => undefined;
mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
const first = api.apiPost('/api/x', {}, { auth: true });
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
mocks.authListeners[0](null); // Abmelden, während createJWT noch läuft
release({ jwt: 'late' });
await first;
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt1');
});
it('gleichzeitige Aufrufe mit leerem Cache teilen sich ein einziges createJWT', async () => {
const api = await freshApi();
let release: (value: { jwt: string }) => void = () => undefined;
mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
const first = api.apiPost('/api/x', { n: 1 }, { auth: true });
const second = api.apiPost('/api/x', { n: 2 }, { auth: true });
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
release({ jwt: 'shared' });
await Promise.all([first, second]);
expect(mocks.createJWT).toHaveBeenCalledOnce();
expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('shared');
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('shared');
});
it('scheitert das geteilte createJWT, bekommen beide den Fehler; der nächste Aufruf versucht es neu', async () => {
const api = await freshApi();
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
let reject: (reason: unknown) => void = () => undefined;
mocks.createJWT.mockImplementationOnce(() => new Promise((_resolve, rej) => (reject = rej)));
const first = api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
const second = api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
reject(failure);
expect(await Promise.all([first, second])).toEqual([failure, failure]);
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('ein anderer Nutzer teilt sich kein laufendes createJWT', async () => {
const api = await freshApi();
let release: (value: { jwt: string }) => void = () => undefined;
mocks.createJWT.mockImplementationOnce(() => new Promise((resolve) => (release = resolve)));
const first = api.apiPost('/api/x', {}, { auth: true });
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledOnce());
mocks.userId = 'u2';
const second = api.apiPost('/api/x', {}, { auth: true });
await vi.waitFor(() => expect(mocks.createJWT).toHaveBeenCalledTimes(2));
release({ jwt: 'for-u1' });
await Promise.all([first, second]);
expect(sentHeaders(0)['X-Aircard-Jwt']).not.toBe(sentHeaders(1)['X-Aircard-Jwt']);
});
it('ohne bekannte Nutzer-ID wird nichts gemerkt', async () => {
const api = await freshApi();
mocks.userId = null;
await api.apiPost('/api/x', {}, { auth: true });
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
});
describe('401 von der API', () => {
it('holt ein neues JWT und wiederholt genau einmal', async () => {
const api = await freshApi();
fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' }));
fetchMock.mockResolvedValueOnce(json(200, { ok: true }));
await expect(api.apiPost('/api/x', { a: 1 }, { auth: true })).resolves.toEqual({ ok: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(sentHeaders(0)['X-Aircard-Jwt']).toBe('jwt1');
expect(sentHeaders(1)['X-Aircard-Jwt']).toBe('jwt2');
expect(fetchMock.mock.calls[1][1].body).toBe(JSON.stringify({ a: 1 }));
});
it('das neue JWT wird gemerkt, das alte nicht mehr', async () => {
const api = await freshApi();
fetchMock.mockResolvedValueOnce(json(401, { error: 'Bitte melde dich an.' }));
await api.apiPost('/api/x', {}, { auth: true });
await api.apiPost('/api/x', {}, { auth: true });
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
expect(sentHeaders(2)['X-Aircard-Jwt']).toBe('jwt2');
});
it('bei einem zweiten 401 ist Schluss: ApiError mit code 401, keine dritte Anfrage', async () => {
const api = await freshApi();
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.' });
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(mocks.createJWT).toHaveBeenCalledTimes(2);
});
it('ohne auth wird nicht wiederholt', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(401, { error: 'Nein.' }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 401 });
expect(fetchMock).toHaveBeenCalledOnce();
});
it('andere Fehler (403) lösen keinen Wiederholungsversuch aus', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toMatchObject({ code: 403 });
expect(fetchMock).toHaveBeenCalledOnce();
expect(mocks.createJWT).toHaveBeenCalledOnce();
});
});
describe('Zeitlimit', () => {
it('jede Anfrage trägt ein Zeitlimit von 15 Sekunden (AbortSignal.timeout)', async () => {
const api = await freshApi();
const timeout = vi.spyOn(AbortSignal, 'timeout');
await api.apiPost('/api/x', {}, { auth: false });
expect(timeout).toHaveBeenCalledWith(15_000);
expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
});
it('Zeitüberschreitung gilt als verlorene Antwort: code 503, Ausgang offen', async () => {
const api = await freshApi();
vi.spyOn(AbortSignal, 'timeout').mockReturnValue(
AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError')),
);
fetchMock.mockImplementation(async (_url, init) => {
throw init.signal?.reason;
});
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
});
describe('Fehler', () => {
it('ApiError trägt Statuscode (code = status) und den deutschen Servertext aus `message`', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(400, { message: 'Bitte gib einen Titel an.' }));
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toBeInstanceOf(Error);
expect(err).toMatchObject({ code: 400, status: 400, message: 'Bitte gib einen Titel an.' });
});
it('liest den Text auch aus `error` (so antwortet aircard-api tatsächlich)', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' }));
await expect(api.apiPost('/api/melden', {}, { auth: false })).rejects.toMatchObject({
code: 429,
message: 'Zu viele Meldungen – bitte später nochmal.',
});
});
it('JSON ohne Text: allgemeine Meldung mit dem Statuscode', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(500, {}));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({
code: 500,
message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.',
});
});
it('der Statuscode lässt isUnauthorized aus errors.ts greifen', async () => {
const api = await freshApi();
const { isUnauthorized } = await import('../account/errors');
fetchMock.mockResolvedValue(json(401, { error: 'x' }));
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(isUnauthorized(err)).toBe(true);
});
it('Netzfehler: code 503 und „Der Dienst ist gerade nicht erreichbar.“', async () => {
const api = await freshApi();
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
const err = await api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE });
});
it.each([502, 503, 504])('Status %i (auch mit JSON) gilt als nicht erreichbar: code 503', async (status) => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(status, { error: 'irgendwas' }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
});
it('eine Fehlerseite ohne JSON (z. B. Proxy) gilt als nicht erreichbar', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(new Response('<html>404 page not found</html>', { status: 404 }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
});
it('eine HTML-Antwort mit Status 200 (Seitenfallback ohne API-Route) gilt als nicht erreichbar', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(new Response('<!doctype html><title>AirCard</title>', { status: 200 }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503, message: UNAVAILABLE });
});
it('JSON, das kein Objekt ist, gilt als nicht erreichbar', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(new Response('"ok"', { status: 200 }));
await expect(api.apiPost('/api/x', {}, { auth: false })).rejects.toMatchObject({ code: 503 });
});
it('createJWT mit user_auth_method_unsupported: Hinweis an den Betreiber, keine Anfrage', async () => {
const api = await freshApi();
mocks.createJWT.mockRejectedValue(
Object.assign(new Error('JWT unsupported'), { code: 501, type: 'user_auth_method_unsupported' }),
);
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({
code: 501,
message: 'Anmeldung per Token ist auf dem Server ausgeschaltet – bitte den Betreiber informieren.',
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('createJWT mit 401 (keine Sitzung) geht unverändert durch, damit withLogin greift', async () => {
const api = await freshApi();
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
mocks.createJWT.mockRejectedValue(failure);
await expect(api.apiPost('/api/x', {}, { auth: true })).rejects.toBe(failure);
expect(fetchMock).not.toHaveBeenCalled();
});
});
});
describe('isUsernameRequired: verlangt der Dienst einen Benutzernamen?', () => {
it('ja bei 403 mit dem Schlüssel usernameRequired (Hochladen und Veröffentlichen ohne Profil)', async () => {
const api = await freshApi();
const err = await (async () => {
fetchMock.mockImplementation(async () => json(403, { error: 'Bitte wähle zuerst einen Benutzernamen.', code: 'usernameRequired' }));
return api.apiPost('/api/veroeffentlichen', {}, { auth: true }).catch((e: unknown) => e);
})();
expect(err).toMatchObject({ code: 403, serverCode: 'usernameRequired' });
expect(api.isUsernameRequired(err)).toBe(true);
expect(api.isDefinitiveFailure(err)).toBe(true); // es wurde nichts angelegt: die Dateien dieses Versuchs dürfen aufgeräumt werden
});
it('der Schlüssel entscheidet, nicht der Status und nicht der Text', async () => {
const api = await freshApi();
expect(api.isUsernameRequired(new api.ApiError(403, 'x', undefined, 'usernameRequired'))).toBe(true);
expect(api.isUsernameRequired(new api.ApiError(400, 'x', undefined, 'usernameRequired'))).toBe(true);
expect(api.isUsernameRequired(new api.ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.'))).toBe(false); // ohne Schlüssel
});
it('andere Fehler nicht: andere 403, Netz, Dienst nicht erreichbar, kein ApiError', async () => {
const api = await freshApi();
expect(api.isUsernameRequired(new api.ApiError(403, 'x', undefined, 'forbidden'))).toBe(false);
expect(api.isUsernameRequired(new api.ApiError(409, 'x', undefined, 'usernameTaken'))).toBe(false);
expect(api.isUsernameRequired(api.unavailableError())).toBe(false);
expect(api.isUsernameRequired(new TypeError('Failed to fetch'))).toBe(false);
expect(api.isUsernameRequired(Object.assign(new Error('x'), { code: 403, serverCode: 'usernameRequired' }))).toBe(false);
expect(api.isUsernameRequired(null)).toBe(false);
expect(api.isUsernameRequired(undefined)).toBe(false);
});
});
describe('isDefinitiveFailure: hat der Server die Anfrage eindeutig abgelehnt?', () => {
it.each([400, 401, 403, 404, 409, 422])('ApiError %i: ja', async (code) => {
const api = await freshApi();
expect(new api.ApiError(code, 'x').definitive).toBe(true);
expect(api.isDefinitiveFailure(new api.ApiError(code, 'x'))).toBe(true);
});
it.each([408, 429, 500, 501, 502, 503, 504])('ApiError %i (Zeitüberschreitung, Drosselung, Serverfehler): nein', async (code) => {
const api = await freshApi();
expect(api.isDefinitiveFailure(new api.ApiError(code, 'x'))).toBe(false);
});
it('das Flag lässt sich setzen (JWT-Fehler: es ging keine Anfrage raus)', async () => {
const api = await freshApi();
expect(api.isDefinitiveFailure(new api.ApiError(501, 'x', true))).toBe(true);
expect(api.isDefinitiveFailure(new api.ApiError(400, 'x', false))).toBe(false);
});
it('ein anderer Fehler (z. B. aus createJWT vor der Anfrage) ist eindeutig', async () => {
const api = await freshApi();
expect(api.isDefinitiveFailure(Object.assign(new Error('x'), { code: 401 }))).toBe(true);
expect(api.isDefinitiveFailure(new TypeError('Failed to fetch'))).toBe(true);
});
const outcome = async (respond: () => Promise<Response>, api: Api) => {
fetchMock.mockImplementation(respond);
return api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
};
it.each([400, 401, 403, 409])('Antwort %i vom Dienst: eindeutig', async (status) => {
const api = await freshApi();
const err = await outcome(async () => json(status, { error: 'Nein.' }), api);
expect(err).toMatchObject({ code: status });
expect(api.isDefinitiveFailure(err)).toBe(true);
});
it.each([408, 429, 500])('Antwort %i vom Dienst: Ausgang offen', async (status) => {
const api = await freshApi();
const err = await outcome(async () => json(status, { error: 'Nein.' }), api);
expect(err).toMatchObject({ code: status });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
it.each([502, 503, 504])('Antwort %i (Proxy): Ausgang offen', async (status) => {
const api = await freshApi();
const err = await outcome(async () => json(status, { error: 'x' }), api);
expect(api.isDefinitiveFailure(err)).toBe(false);
});
it('Netzfehler, Fehlerseite ohne JSON und HTML-200: Ausgang offen', async () => {
const api = await freshApi();
for (const respond of [
async () => Promise.reject(new TypeError('Failed to fetch')),
async () => new Response('<html>Bad Gateway</html>', { status: 404 }),
async () => new Response('<!doctype html>', { status: 200 }),
]) {
expect(api.isDefinitiveFailure(await outcome(respond, api))).toBe(false);
}
});
it('der Text einer abgebrochenen Antwort (res.text() scheitert): Ausgang offen', async () => {
const api = await freshApi();
const broken = { ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response;
expect(api.isDefinitiveFailure(await outcome(async () => broken, api))).toBe(false);
});
it('JWT-Methode am Server aus: eindeutig (es wurde nichts gesendet)', async () => {
const api = await freshApi();
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(api.isDefinitiveFailure(err)).toBe(true);
});
it('createJWT scheitert mit 401 vor der Anfrage: eindeutig', async () => {
const api = await freshApi();
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 401, type: 'general_unauthorized_scope' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(api.isDefinitiveFailure(err)).toBe(true);
});
});
describe('publishCard', () => {
const input = {
title: 'Meine Karte',
issuer: 'Beispielbank',
tags: ['visa', 'gold'],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'pre1',
sourceCardId: 'card1',
};
it('POST /api/veroeffentlichen mit JWT, Tags als Liste; liefert { id }', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(201, { id: 'g1' }));
await expect(api.publishCard(input)).resolves.toEqual({ id: 'g1' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/veroeffentlichen');
expect(JSON.parse(init.body as string)).toEqual(input);
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('409: „Diese Karte ist schon veröffentlicht.“', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' });
expect(api.isAlreadyPublished(err)).toBe(true);
});
it('403 „bereits veröffentlicht oder gesperrt“ (Dateien belegt) gilt ebenfalls als schon veröffentlicht', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.' });
expect(api.isAlreadyPublished(err)).toBe(true);
});
it('ein anderes 403 bleibt ein 403 mit dem Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 403, message: 'Diese Datei gehört dir nicht.' });
expect(api.isAlreadyPublished(err)).toBe(false);
});
it('400 (Validierung) behält den Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(400, { error: 'Bitte gib höchstens 5 Tags an.' }));
await expect(api.publishCard(input)).rejects.toMatchObject({ code: 400, message: 'Bitte gib höchstens 5 Tags an.' });
});
it('eine Antwort ohne id ist ein Fehler mit offenem Ausgang (die Zeile kann angelegt sein)', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(201, {}));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
it('die umgedeutete 409 (Dateien belegt) ist eindeutig', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
expect(api.isDefinitiveFailure(await api.publishCard(input).catch((e: unknown) => e))).toBe(true);
});
it('429 „schon 20 Karten veröffentlicht“ (Limit des Dienstes) behält den Text und ist eindeutig: es wurde nichts angelegt', async () => {
const api = await freshApi();
const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
fetchMock.mockResolvedValue(json(429, { error: text }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 429, message: text });
expect(api.isAlreadyPublished(err)).toBe(false);
// damit räumt der Client die zwei hochgeladenen Dateien auf (ein anderes 429, z. B. vom Proxy, bleibt offen)
expect(api.isDefinitiveFailure(err)).toBe(true);
});
it('ein anderes 429 beim Veröffentlichen bleibt ein Fehler mit offenem Ausgang', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Anfragen.' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 429, message: 'Zu viele Anfragen.' });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
it('isAlreadyPublished: nur ApiError mit code 409', async () => {
const api = await freshApi();
expect(api.isAlreadyPublished(new api.ApiError(409, 'x'))).toBe(true);
expect(api.isAlreadyPublished(new api.ApiError(400, 'x'))).toBe(false);
expect(api.isAlreadyPublished(Object.assign(new Error('x'), { code: 409 }))).toBe(false);
expect(api.isAlreadyPublished(null)).toBe(false);
});
});
describe('withdrawCard, reportCard, setVisibility', () => {
it('withdrawCard: POST /api/zurueckziehen { id } mit JWT', async () => {
const api = await freshApi();
await expect(api.withdrawCard('g1')).resolves.toBeUndefined();
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/zurueckziehen');
expect(JSON.parse(init.body as string)).toEqual({ id: 'g1' });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('reportCard: POST /api/melden { id, reason } ohne JWT; { schonGemeldet: false } bei neuer Meldung', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(200, { ok: true }));
await expect(api.reportCard('g1', 'spam')).resolves.toEqual({ schonGemeldet: false });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('/api/melden');
expect(JSON.parse(init.body as string)).toEqual({ id: 'g1', reason: 'spam' });
expect(mocks.createJWT).not.toHaveBeenCalled();
});
it('reportCard: { schonGemeldet: true } bei doppelter Meldung', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(200, { schonGemeldet: true }));
await expect(api.reportCard('g1', 'urheberrecht')).resolves.toEqual({ schonGemeldet: true });
});
it('reportCard: 429 mit dem Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(429, { error: 'Zu viele Meldungen – bitte später nochmal.' }));
await expect(api.reportCard('g1', 'spam')).rejects.toMatchObject({
code: 429,
message: 'Zu viele Meldungen – bitte später nochmal.',
});
});
it('setVisibility: POST /api/admin/sichtbarkeit { id, sichtbar } mit JWT', async () => {
const api = await freshApi();
await expect(api.setVisibility('g1', false)).resolves.toBeUndefined();
await api.setVisibility('g1', true);
expect(fetchMock.mock.calls[0][0]).toBe('/api/admin/sichtbarkeit');
expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).toEqual({ id: 'g1', sichtbar: false });
expect(JSON.parse(fetchMock.mock.calls[1][1].body as string)).toEqual({ id: 'g1', sichtbar: true });
expect(sentHeaders()['X-Aircard-Jwt']).toBe('jwt1');
});
it('setVisibility: 403 für Nicht-Admins mit dem Servertext', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
await expect(api.setVisibility('g1', true)).rejects.toMatchObject({ code: 403, message: 'Nur für Admins.' });
});
});
describe('serverCode: der Schlüssel der Fehlerantwort', () => {
const fail = (api: Api) => api.apiPost('/api/x', {}, { auth: false }).catch((e: unknown) => e);
it('ApiError trägt ohne Angabe serverCode null; das Feld lässt sich setzen, ohne code und definitive zu ändern', async () => {
const api = await freshApi();
expect(new api.ApiError(400, 'x').serverCode).toBeNull();
expect(new api.ApiError(400, 'x', undefined, null).serverCode).toBeNull();
const err = new api.ApiError(409, 'x', undefined, 'alreadyPublished');
expect(err).toMatchObject({ code: 409, status: 409, message: 'x', definitive: true, serverCode: 'alreadyPublished' });
expect(new api.ApiError(429, 'x', true, 'tooManyPublications')).toMatchObject({ definitive: true, serverCode: 'tooManyPublications' });
});
it('wird aus {error, code} der Antwort gelesen; code bleibt der Status, message der deutsche Text', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code: 'adminOnly' }));
const err = await fail(api);
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 403, status: 403, message: 'Nur für Admins.', serverCode: 'adminOnly' });
});
it('ohne code in der Antwort (älterer Dienst, Proxy) bleibt serverCode null', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.' }));
expect(await fail(api)).toMatchObject({ code: 403, message: 'Nur für Admins.', serverCode: null });
});
it.each([[42], [''], [null], [true], [['adminOnly']], [{ a: 1 }]])('ein code, der kein nichtleerer Text ist (%j), zählt nicht', async (code) => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Nur für Admins.', code }));
expect(await fail(api)).toMatchObject({ message: 'Nur für Admins.', serverCode: null });
});
it('der Dienst sendet Dienst-Fehler mit ihrem Schlüssel: 429, 404, 409', async () => {
const api = await freshApi();
for (const [status, error, code] of [
[429, 'Zu viele Meldungen – bitte später nochmal.', 'tooManyReports'],
[404, 'Dieses Logo ist nicht (mehr) verfügbar.', 'logoGone'],
[409, 'Diese Datei wird schon für ein Logo verwendet.', 'logoTaken'],
] as const) {
fetchMock.mockResolvedValueOnce(json(status, { error, code }));
expect(await fail(api)).toMatchObject({ code: status, message: error, serverCode: code });
}
});
it('Netzfehler und Zeitlimit: unavailable', async () => {
const api = await freshApi();
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
vi.spyOn(AbortSignal, 'timeout').mockReturnValue(AbortSignal.abort(new DOMException('The operation timed out.', 'TimeoutError')));
fetchMock.mockImplementation(async (_url, init) => {
throw init.signal?.reason;
});
expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' });
});
it.each([502, 503, 504])('Status %i vom Proxy (ohne code im Body, auch mit fremdem code): unavailable', async (status) => {
const api = await freshApi();
fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas' }));
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
fetchMock.mockResolvedValueOnce(json(status, { error: 'irgendwas', code: 'internal' }));
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
});
it('Fehlerseite ohne JSON, HTML mit Status 200 und JSON, das kein Objekt ist: unavailable', async () => {
const api = await freshApi();
for (const response of [
new Response('<html>404 page not found</html>', { status: 404 }),
new Response('<!doctype html><title>AirCard</title>', { status: 200 }),
new Response('"ok"', { status: 200 }),
]) {
fetchMock.mockResolvedValueOnce(response);
expect(await fail(api)).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable' });
}
});
it('eine abgebrochene Antwort (res.text() scheitert): unavailable', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue({ ok: true, status: 200, text: () => Promise.reject(new TypeError('network error')) } as unknown as Response);
expect(await fail(api)).toMatchObject({ code: 503, serverCode: 'unavailable' });
});
it('JSON-Fehler ohne Text und ohne code: generic', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(500, {}));
expect(await fail(api)).toMatchObject({ code: 500, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'generic' });
});
it('JSON-Fehler ohne Text, aber mit code: der code des Dienstes', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(404, { code: 'logoGone' }));
expect(await fail(api)).toMatchObject({ code: 404, message: 'Etwas ist schiefgelaufen. Bitte nochmal versuchen.', serverCode: 'logoGone' });
});
it('createJWT mit user_auth_method_unsupported: jwtUnsupported (und eindeutig)', async () => {
const api = await freshApi();
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 501, serverCode: 'jwtUnsupported', definitive: true });
});
it('der zweite Versuch nach einem 401 liefert den Schlüssel der zweiten Antwort', async () => {
const api = await freshApi();
fetchMock.mockImplementation(async () => json(401, { error: 'Bitte melde dich an.', code: 'unauthorized' }));
const err = await api.apiPost('/api/x', {}, { auth: true }).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 401, message: 'Bitte melde dich an.', serverCode: 'unauthorized' });
});
it('unavailableError(): 503, Ausgang offen, Schlüssel unavailable', async () => {
const api = await freshApi();
const err = api.unavailableError();
expect(err).toBeInstanceOf(api.ApiError);
expect(err).toMatchObject({ code: 503, message: UNAVAILABLE, serverCode: 'unavailable', definitive: false });
expect(api.isDefinitiveFailure(err)).toBe(false);
});
describe('beim Veröffentlichen', () => {
const input = {
title: 'Meine Karte',
issuer: 'Beispielbank',
tags: ['visa'],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'pre1',
sourceCardId: 'card1',
};
it('403 „Dateien belegt“: der Dienst sendet filesTaken, publishCard macht daraus 409 alreadyPublished', async () => {
const api = await freshApi();
const text = 'Diese Bilder sind bereits veröffentlicht oder gesperrt.';
fetchMock.mockImplementation(async () => json(403, { error: text, code: 'filesTaken' })); // je Aufruf eine neue Response
expect(await api.apiPost('/api/veroeffentlichen', input, { auth: true }).catch((e: unknown) => e)).toMatchObject({
code: 403,
message: text,
serverCode: 'filesTaken',
});
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 409, message: 'Diese Karte ist schon veröffentlicht.', serverCode: 'alreadyPublished' });
expect(api.isAlreadyPublished(err)).toBe(true);
});
it('409 vom Dienst: alreadyPublished', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(409, { error: 'Diese Karte ist schon veröffentlicht.', code: 'alreadyPublished' }));
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' });
});
it('429 „schon 20 Karten“: tooManyPublications, eindeutig', async () => {
const api = await freshApi();
const text = 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.';
fetchMock.mockResolvedValue(json(429, { error: text, code: 'tooManyPublications' }));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 429, message: text, serverCode: 'tooManyPublications', definitive: true });
});
it('ohne die Schlüssel (Dienst von vorher) klappt die Umdeutung weiter über den deutschen Text', async () => {
const api = await freshApi();
fetchMock.mockResolvedValueOnce(json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' }));
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 409, serverCode: 'alreadyPublished' });
fetchMock.mockResolvedValueOnce(
json(429, { error: 'Du hast schon 20 Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.' }),
);
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 429, serverCode: 'tooManyPublications', definitive: true });
});
it('eine Antwort ohne id: unavailable, Ausgang offen', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(201, {}));
const err = await api.publishCard(input).catch((e: unknown) => e);
expect(err).toMatchObject({ code: 503, serverCode: 'unavailable', definitive: false });
});
it('ein anderes 403 behält seinen Schlüssel', async () => {
const api = await freshApi();
fetchMock.mockResolvedValue(json(403, { error: 'Diese Datei gehört dir nicht.', code: 'foreignFile' }));
expect(await api.publishCard(input).catch((e: unknown) => e)).toMatchObject({ code: 403, serverCode: 'foreignFile' });
});
});
});
type ApiErr = InstanceType<Api['ApiError']>;
/** Der ApiError, mit dem `promise` scheitert. */
async function rejection(promise: Promise<unknown>): Promise<ApiErr> {
try {
await promise;
} catch (err) {
return err as ApiErr;
}
throw new Error('Die Anfrage ist nicht gescheitert.');
}
describe('apiErrorText: die Fehlermeldung zum Anzeigen', () => {
it('Deutsch: der Text des Dienstes, auch bei Fehlern ohne bekannten Schlüssel', async () => {
const api = await freshApi();
expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe(
'Diese Karte ist schon veröffentlicht.',
);
expect(api.apiErrorText(new api.ApiError(400, 'Der Titel ist zu lang (höchstens 60 Zeichen).', undefined, 'badRequest'))).toBe(
'Der Titel ist zu lang (höchstens 60 Zeichen).',
);
expect(api.apiErrorText(new api.ApiError(418, 'Ich bin eine Teekanne.'))).toBe('Ich bin eine Teekanne.');
});
it('Englisch: übersetzt über den Schlüssel', async () => {
const api = await freshApi();
await setLanguage('en');
expect(api.apiErrorText(new api.ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'))).toBe(
'This card is already published.',
);
expect(api.apiErrorText(new api.ApiError(403, 'Nur für Admins.', undefined, 'adminOnly'))).toBe('Admins only.');
});
it('Englisch: Fehler, die der Client selbst erzeugt (Netz, JWT, Veröffentlichen), sind übersetzt', async () => {
const api = await freshApi();
await setLanguage('en');
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
const network = await rejection(api.apiPost('/api/x', {}, { auth: false }));
expect(api.apiErrorText(network)).toBe('The service is currently unavailable.');
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
const jwt = await rejection(api.apiPost('/api/x', {}, { auth: true }));
expect(api.apiErrorText(jwt)).toBe('Token sign-in is turned off on the server – please let the site owner know.');
fetchMock.mockResolvedValue(json(500, {}));
const generic = await rejection(api.apiPost('/api/x', {}, { auth: false }));
expect(api.apiErrorText(generic)).toBe('Something went wrong. Please try again.');
});
it('Englisch: unbekannter oder fehlender Schlüssel, allgemeine Meldung je Status (die ApiError.code)', async () => {
const api = await freshApi();
await setLanguage('en');
expect(api.apiErrorText(new api.ApiError(400, 'Das gibt es nur auf Deutsch.'))).toBe('Invalid request.');
expect(api.apiErrorText(new api.ApiError(403, 'Das gibt es nur auf Deutsch.', undefined, 'neuerCode'))).toBe(
'You are not allowed to do that.',
);
expect(api.apiErrorText(new api.ApiError(404, 'Das gibt es nur auf Deutsch.'))).toBe('Not found.');
expect(api.apiErrorText(new api.ApiError(500, 'Das gibt es nur auf Deutsch.'))).toBe('Something went wrong. Please try again.');
});
it('übergibt Schlüssel, Status und Text an apiMessage', async () => {
const api = await freshApi();
const { apiMessage } = await import('../i18n/api-messages');
await setLanguage('en');
for (const err of [
new api.ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', undefined, 'logoGone'),
new api.ApiError(400, 'Der Name ist ungültig.', undefined, 'badRequest'),
new api.ApiError(403, 'Text', undefined, null),
]) {
expect(api.apiErrorText(err)).toBe(apiMessage(err.serverCode, err.code, err.message));
}
});
});
+265
View File
@@ -0,0 +1,265 @@
// Schreibzugriffe der Community-Galerie: Aufrufe des Dienstes `aircard-api` (deploy/api) mit dem Nutzer-JWT.
// Lesen läuft direkt über Appwrite (gallery.ts). Fehler sind `ApiError` mit HTTP-Status als `code`, damit
// `isUnauthorized`/`withLogin`/`errorMessage` aus account/ sie wie Appwrite-Fehler behandeln.
import { account } from '../account/appwrite';
import { currentUserId, onAuthChange, onLogout } from '../account/auth';
import { errorType } from '../account/errors';
import { API_MESSAGES, apiMessage } from '../i18n/api-messages';
export type ReportReason = 'unangemessen' | 'kartendaten' | 'urheberrecht' | 'spam' | 'sonstiges';
export interface PublishInput {
title: string;
issuer: string;
tags: string[];
displayName: string;
imageFileId: string;
previewFileId: string;
sourceCardId: string;
}
// Die deutschen Texte stehen einmal in i18n/api-messages.ts (dort mit MSG des Dienstes abgeglichen); `ApiError.message` bleibt
// Deutsch, damit der Vergleich mit ihnen weiter geht. Angezeigt wird über `apiErrorText`.
const UNAVAILABLE = API_MESSAGES.unavailable;
const GENERIC = API_MESSAGES.generic;
const JWT_UNSUPPORTED = API_MESSAGES.jwtUnsupported;
const ALREADY_PUBLISHED = API_MESSAGES.alreadyPublished;
/** Wortlaut der API (deploy/api/common.js, MSG.filesTaken): die hochgeladenen Dateien sind schon belegt oder gesperrt. */
const FILES_TAKEN = API_MESSAGES.filesTaken;
/** Wortlaut der API (MSG.tooManyPublications): das Limit von 20 Veröffentlichungen je Konto ist erreicht. */
const TOO_MANY_PUBLICATIONS = API_MESSAGES.tooManyPublications;
/** Appwrite-JWTs gelten 15 Minuten; wir verwenden eines höchstens 10 Minuten. */
const JWT_TTL_MS = 10 * 60 * 1000;
/** Zeitlimit je Anfrage; danach gilt die Antwort als verloren (503, Ausgang offen). */
const REQUEST_TIMEOUT_MS = 15_000;
/**
* Fehler des API-Dienstes. `code` ist der HTTP-Status (`status` dasselbe), `message` der deutsche Servertext.
* `serverCode` ist der Schlüssel der Fehlerantwort (`code` in `{error, code}`, siehe MSG in deploy/api/common.js; Fehler, die
* der Client selbst erzeugt, tragen die passenden Schlüssel `unavailable`, `generic`, `jwtUnsupported`, …), null ohne Schlüssel.
* Übersetzt wird der Text nach `serverCode` (`apiErrorText`), nie nach `message`.
* `definitive`: der Dienst hat die Anfrage eindeutig abgelehnt oder es ging gar keine raus, also wurde nichts angelegt.
* Ohne Angabe gilt das für 4xx außer Zeitüberschreitung (408) und Drosselung (429); 5xx, 408, 429 und alles, was
* keine verwertbare Antwort ergab (Netzfehler, Zeitlimit, Fehlerseite, fehlende `id`), lassen offen, ob die
* Anfrage trotzdem gewirkt hat.
*/
export class ApiError extends Error {
readonly code: number;
readonly definitive: boolean;
readonly serverCode: string | null;
constructor(code: number, message: string, definitive?: boolean, serverCode?: string | null) {
super(message);
this.name = 'ApiError';
this.code = code;
this.definitive = definitive ?? (code >= 400 && code < 500 && code !== 408 && code !== 429);
this.serverCode = serverCode ?? null;
}
get status(): number {
return this.code;
}
}
/** Der Dienst gab keine verwertbare Antwort: 503, Ausgang offen (die Anfrage kann trotzdem gewirkt haben). */
export function unavailableError(): ApiError {
return new ApiError(503, UNAVAILABLE, false, 'unavailable');
}
/** Der Text eines Fehlers zum Anzeigen, in der Sprache der Seite (bekannter `serverCode` übersetzt, siehe `apiMessage`). */
export function apiErrorText(err: ApiError): string {
return apiMessage(err.serverCode, err.code, err.message);
}
/**
* Wahr, wenn eine fehlgeschlagene `apiPost`-Anfrage sicher nichts bewirkt hat: eindeutige Ablehnung des Dienstes,
* oder ein Fehler vor der Anfrage (JWT holen). Falsch bei offenem Ausgang (siehe `ApiError.definitive`).
* Andere Fehler als `ApiError` kommen aus `apiPost` nur aus dem JWT-Abruf, also vor der Anfrage.
*/
export function isDefinitiveFailure(err: unknown): boolean {
return err instanceof ApiError ? err.definitive : true;
}
// ---- JWT (je Nutzer-ID gemerkt) ----------------------------------------------------------------------
let cached: { userId: string; jwt: string; expires: number } | null = null;
/** Zählt Leerungen: ein JWT, das erst nach einem Nutzerwechsel eintrifft, gehört dem alten Nutzer und wird nicht gemerkt. */
let generation = 0;
function clearJwt(): void {
cached = null;
generation++;
}
let subscribed = false;
/** Erst beim ersten JWT: so lässt sich das Modul importieren, ohne den Anmeldedienst zu berühren. */
function subscribe(): void {
if (subscribed) return;
subscribed = true;
onAuthChange(clearJwt);
onLogout(clearJwt);
}
/** Das gerade laufende createJWT, damit gleichzeitige Aufrufe mit leerem Cache nur eines auslösen. */
let pending: { userId: string | null; generation: number; promise: Promise<string> } | null = null;
async function mintJwt(userId: string | null, started: number): Promise<string> {
let jwt: string;
try {
// Ohne `duration`: die Standarddauer (15 Minuten) genügt.
jwt = (await account.createJWT()).jwt;
} catch (err) {
if (errorType(err) === 'user_auth_method_unsupported') {
const code = (err as { code?: unknown }).code;
// Es ging keine Anfrage an den API-Dienst raus: eindeutig.
throw new ApiError(typeof code === 'number' ? code : 501, JWT_UNSUPPORTED, true, 'jwtUnsupported');
}
throw err; // z. B. 401 ohne Sitzung: withLogin erkennt es am Statuscode
}
if (userId && started === generation) cached = { userId, jwt, expires: Date.now() + JWT_TTL_MS };
return jwt;
}
function getJwt(): Promise<string> {
subscribe();
const userId = currentUserId();
if (userId && cached && cached.userId === userId && Date.now() < cached.expires) return Promise.resolve(cached.jwt);
if (pending && pending.userId === userId && pending.generation === generation) return pending.promise;
const started = generation;
const promise: Promise<string> = mintJwt(userId, started).finally(() => {
if (pending?.promise === promise) pending = null;
});
pending = { userId, generation: started, promise };
return promise;
}
// ---- Anfrage ------------------------------------------------------------------------------------------
type Json = Record<string, unknown>;
function isRecord(value: unknown): value is Json {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Der deutsche Text einer Fehlerantwort: aircard-api sendet `{error}`, `{message}` wird ebenso akzeptiert. */
function serverMessage(body: Json): string | null {
for (const key of ['message', 'error']) {
const value = body[key];
if (typeof value === 'string' && value.trim() !== '') return value;
}
return null;
}
/** Der Schlüssel einer Fehlerantwort (`code` neben `error`), sonst null. */
function serverCodeOf(body: Json): string | null {
const value = body.code;
return typeof value === 'string' && value !== '' ? value : null;
}
async function send(path: string, body: unknown, jwt: string | null): Promise<Json> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (jwt) headers['X-Aircard-Jwt'] = jwt;
const payload = JSON.stringify(body);
let res: Response;
let text: string;
try {
res = await fetch(path, {
method: 'POST',
headers,
body: payload,
credentials: 'omit',
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
text = await res.text();
} catch {
// Netzfehler, Zeitlimit oder abgebrochene Antwort: die Anfrage kann trotzdem angekommen sein.
throw unavailableError();
}
// Der Proxy antwortet, wenn der Dienst fehlt oder steht (502/503/504, Fehlerseite, Seitenfallback als HTML-200). Sein Body
// trägt keinen `code`; darum wird schon der Status (vor dem Lesen des Bodys) zu `unavailable`.
if (res.status === 502 || res.status === 503 || res.status === 504) throw unavailableError();
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw unavailableError();
}
if (!isRecord(data)) throw unavailableError();
if (!res.ok) {
const message = serverMessage(data);
// Ohne Text und ohne Schlüssel: der allgemeine Fehler.
throw new ApiError(res.status, message ?? GENERIC, undefined, serverCodeOf(data) ?? (message === null ? 'generic' : null));
}
return data;
}
/**
* POST mit JSON-Body an den API-Dienst. Mit `auth` geht das Nutzer-JWT im Header `X-Aircard-Jwt` mit;
* antwortet der Dienst mit 401, wird genau einmal mit einem frischen JWT wiederholt.
*/
export async function apiPost(path: string, body: unknown, opts: { auth: boolean }): Promise<Json> {
if (!opts.auth) return send(path, body, null);
try {
return await send(path, body, await getJwt());
} catch (err) {
if (!(err instanceof ApiError) || err.code !== 401) throw err;
clearJwt();
return send(path, body, await getJwt());
}
}
// ---- Endpunkte ----------------------------------------------------------------------------------------
/** Wahr, wenn die Karte schon veröffentlicht ist (`publishCard` meldet das immer als 409). */
export function isAlreadyPublished(err: unknown): boolean {
return err instanceof ApiError && err.code === 409;
}
/**
* Wahr, wenn der Dienst einen Benutzernamen verlangt (403 `usernameRequired`): das Konto hat noch keinen, und Hochladen und
* Veröffentlichen gehen erst mit Namen (Spec § 4). Der Aufrufer zeigt dann den Namensdialog; angelegt wurde nichts.
*/
export function isUsernameRequired(err: unknown): boolean {
return err instanceof ApiError && err.serverCode === 'usernameRequired';
}
/**
* Veröffentlicht eine Karte. Doppelt veröffentlicht (409) und „Dateien schon belegt oder gesperrt“ (403) kommen
* beide als ApiError 409 „Diese Karte ist schon veröffentlicht.“ an. Das Limit des Dienstes (429 mit dem Limit-Text) ist
* eine eindeutige Ablehnung (es wurde nichts angelegt, die Dateien dürfen aufgeräumt werden); alle anderen Fehler unverändert.
*/
export async function publishCard(input: PublishInput): Promise<{ id: string }> {
let res: Json;
try {
res = await apiPost('/api/veroeffentlichen', input, { auth: true });
} catch (err) {
if (err instanceof ApiError && err.code === 403 && err.message === FILES_TAKEN) {
throw new ApiError(409, ALREADY_PUBLISHED, undefined, 'alreadyPublished');
}
if (err instanceof ApiError && err.code === 429 && err.message === TOO_MANY_PUBLICATIONS) {
throw new ApiError(429, TOO_MANY_PUBLICATIONS, true, 'tooManyPublications');
}
throw err;
}
if (typeof res.id !== 'string' || res.id === '') throw unavailableError();
return { id: res.id };
}
/** Zieht eine Veröffentlichung zurück (Autor oder Admin): löscht Zeile, Dateien und Meldungen. */
export async function withdrawCard(id: string): Promise<void> {
await apiPost('/api/zurueckziehen', { id }, { auth: true });
}
/** Meldet eine Karte (ohne Anmeldung). `schonGemeldet`: dieser Melder hatte sie schon gemeldet. */
export async function reportCard(id: string, reason: ReportReason): Promise<{ schonGemeldet: boolean }> {
const res = await apiPost('/api/melden', { id, reason }, { auth: false });
return { schonGemeldet: res.schonGemeldet === true };
}
/** Admin: blendet eine Karte ein oder aus. */
export async function setVisibility(id: string, visible: boolean): Promise<void> {
await apiPost('/api/admin/sichtbarkeit', { id, sichtbar: visible }, { auth: true });
}
+43
View File
@@ -0,0 +1,43 @@
// Direkt veröffentlichen: nach dem Speichern (Editor) und beim Mehrfach-Upload („Meine Karten“) ohne Dialog.
// Titel = Name der Karte. Ist die Karte schon veröffentlicht, wird die alte Veröffentlichung zurückgezogen und die neue Fassung
// veröffentlicht, damit die Galerie immer den gespeicherten Stand zeigt. Fehlt der Benutzername, fragt der Namensdialog einmal nach.
import type { SavedCard } from '../account/cards';
import { requireUserId } from '../account/cards';
import { usernameDialog } from '../account/username-dialog';
import { withLogin } from '../account/with-login';
import { isAlreadyPublished, isUsernameRequired, withdrawCard } from './api';
import { myPublications } from './gallery';
import { publishSavedCard, type PublishForm } from './publish';
const TITLE_MAX = 60;
export function autoPublishForm(name: string): PublishForm {
const title = name.trim().slice(0, TITLE_MAX).trim();
return { title: title === '' ? 'AirCard' : title, issuer: '', tags: '' };
}
export async function autoPublish(card: SavedCard, signal?: AbortSignal): Promise<{ id: string }> {
const form = autoPublishForm(card.name);
const publish = () => withLogin(() => publishSavedCard(card, form));
let askedName = false;
let replaced = false;
for (;;) {
try {
return await publish();
} catch (err) {
if (isUsernameRequired(err) && !askedName) {
askedName = true;
if ((await usernameDialog({ mode: 'required', signal })) === null) throw err;
continue;
}
if (isAlreadyPublished(err) && !replaced) {
replaced = true;
const old = (await myPublications(requireUserId())).get(card.id);
if (!old) throw err;
await withLogin(() => withdrawCard(old.id));
continue;
}
throw err;
}
}
}
+913
View File
@@ -0,0 +1,913 @@
/* Startseite: kompakter Hero mit schwebender Karte und die Community-Galerie darunter.
Wichtig (Abnahmekriterium): bei 1440 x 900 und 390 x 844 stehen die Überschrift „Community-Karten“ und
die Oberkante der ersten Kartenreihe schon im ersten Bildschirm. Der Hero bleibt deshalb niedrig. */
/* Knöpfe der Startseite (unabhängig von editor.css / account.css) */
.home-btn {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
margin: 0;
padding: 0 22px;
border: 1px solid var(--line-strong);
border-radius: 12px;
background: transparent;
color: var(--text);
font: inherit;
font-weight: 700;
line-height: 1.2;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s;
}
.home-btn:hover:not(:disabled) {
border-color: var(--sel-line);
background: var(--panel);
}
.home-btn-primary {
border-color: var(--primary);
background: var(--primary);
color: var(--on-primary);
}
.home-btn-primary:hover:not(:disabled) {
border-color: var(--primary-hover);
background: var(--primary-hover);
}
.home-btn:disabled {
opacity: 0.55;
cursor: progress;
}
/* Hero */
.home-hero {
--hero-card-max: 540px;
--card-radius: 3.711% / 5.882%; /* 57 px von 1536 x 969, wie in der Editor-Vorschau */
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
align-items: center;
gap: 24px 56px;
min-height: auto;
padding: 14px 0 10px;
}
/* Nur auf breiten, hohen Fenstern: der Hero nimmt höchstens 60 % der Höhe. Auf schmaleren oder niedrigeren Fenstern
bräche der Text in mehr Zeilen um und würde über die Grenze hinausragen (und die Galerie-Überschrift überdecken). */
@media (min-width: 1100px) and (min-height: 700px) {
.home-hero {
max-height: 60vh;
--hero-card-max: min(540px, calc((60vh - 48px) * 1536 / 969));
}
}
.home-hero-copy {
min-width: 0;
}
.home-hero-eyebrow {
margin: 0 0 10px;
color: var(--muted);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.02em;
}
#app-root .home-hero-title {
margin: 0 0 14px;
font-size: clamp(1.95rem, 4.4vw, 3.15rem);
font-weight: 800;
line-height: 1.08;
letter-spacing: -0.01em;
text-wrap: balance;
}
.home-hero-lead {
max-width: 34em;
margin: 0 0 22px;
color: var(--muted);
font-size: 1.1rem;
line-height: 1.5;
}
.home-hero-actions {
display: flex;
flex-wrap: wrap;
gap: 10px 12px;
}
.home-hero-visual {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
padding: 10px 0;
}
/* weicher Lichtschein hinter der Karte */
.home-hero-visual::before {
content: '';
position: absolute;
inset: -10% 0; /* seitlich nicht über den Hero hinaus: sonst entsteht waagerechtes Scrollen */
border-radius: 50%;
background: radial-gradient(closest-side, rgb(112 92 255 / 0.32), rgb(60 140 255 / 0.12) 60%, transparent);
filter: blur(14px);
pointer-events: none;
}
:root[data-theme='light'] .home-hero-visual::before {
background: radial-gradient(closest-side, rgb(112 92 255 / 0.22), rgb(60 140 255 / 0.1) 60%, transparent);
}
.hero-card {
position: relative;
width: min(100%, var(--hero-card-max));
aspect-ratio: 1536 / 969;
overflow: hidden;
border-radius: var(--card-radius);
background: var(--panel-2);
box-shadow: var(--shadow-card), 0 0 0 1px rgb(255 255 255 / 0.06);
transform: rotate(-1.5deg);
}
.hero-card img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-card-link {
display: block;
width: 100%;
height: 100%;
}
/* Schwebe-Animation: nur ohne „Bewegung reduzieren“ */
@media (prefers-reduced-motion: no-preference) {
.hero-card {
animation: hero-float 7s ease-in-out infinite;
}
}
@keyframes hero-float {
0%,
100% {
transform: translateY(0) rotate(-1.5deg);
}
50% {
transform: translateY(-10px) rotate(-0.7deg);
}
}
/* Galerie-Abschnitt */
.home-community {
margin-top: 36px;
scroll-margin-top: 16px;
}
.community-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 12px 24px;
}
.community-head-text {
min-width: 0;
}
.community-title {
margin: 0;
font-size: clamp(1.5rem, 3vw, 1.95rem);
font-weight: 800;
line-height: 1.15;
}
.community-sub {
margin: 4px 0 0;
color: var(--muted);
}
.community-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 16px;
}
.community-toolbar > :empty {
display: none;
}
.community-active,
.community-filter {
margin-top: 16px;
}
.community-active {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.community-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 3px 4px 3px 14px;
border: 1px solid var(--sel-line);
border-radius: 999px;
background: var(--sel-bg);
font-size: 0.95rem;
}
.community-chip-label {
color: var(--muted);
}
.community-chip-value {
min-width: 0;
overflow-wrap: anywhere;
}
.community-chip-clear {
appearance: none;
display: grid;
flex: none;
place-items: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: inherit;
font: inherit;
font-size: 1.35rem;
line-height: 1;
cursor: pointer;
}
.community-chip-clear:hover {
background: rgb(127 127 127 / 0.22);
}
.community-filter {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
}
.community-filter-btn {
appearance: none;
min-height: 36px;
margin: 0;
padding: 0 16px;
border: 1px solid var(--line-strong);
border-radius: 999px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
.community-filter-btn:hover {
border-color: var(--sel-line);
}
.community-filter-btn[aria-pressed='true'] {
border-color: var(--primary);
background: var(--primary);
color: var(--on-primary);
}
.community-status {
margin: 24px 0 12px;
color: var(--muted);
}
.community-status.is-error,
.community-more-msg.is-error {
color: var(--danger-text);
}
.community-empty {
margin-top: 20px;
padding: 36px 20px;
border: 1px dashed var(--line-strong);
border-radius: 18px;
color: var(--muted);
font-size: 1.05rem;
text-align: center;
}
.community-empty p {
margin: 0 0 16px;
}
.community-empty p:last-child {
margin-bottom: 0;
}
.community-more {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-top: 28px;
}
.community-more:empty {
margin-top: 0;
}
.community-more-msg {
margin: 0;
}
/* Kachelraster */
.gallery-grid {
--card-radius: 3.711% / 5.882%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
gap: 20px;
margin: 20px 0 0;
padding: 0;
list-style: none;
}
.gallery-tile {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 10px 10px 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
transition: border-color 0.12s, box-shadow 0.12s;
}
.gallery-tile:hover {
border-color: var(--line-strong);
box-shadow: var(--shadow-card);
}
.gallery-tile-link {
display: flex;
flex-direction: column;
gap: 10px;
border-radius: 12px;
color: inherit;
text-decoration: none;
}
.gallery-thumb {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1536 / 969;
overflow: hidden;
border-radius: var(--card-radius);
background: var(--panel-2);
color: var(--muted);
font-size: 0.9rem;
text-align: center;
}
.gallery-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.gallery-title {
display: -webkit-box;
margin: 0;
padding: 0 4px;
overflow: hidden;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.25;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.gallery-issuer {
margin: 0;
padding: 0 4px;
overflow: hidden;
color: var(--muted);
font-size: 0.9rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.gallery-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: auto;
padding: 6px 4px 0;
}
.gallery-tag {
appearance: none;
min-height: 28px;
margin: 0;
padding: 2px 11px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-2);
color: var(--muted);
font: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
}
.gallery-tag:hover {
border-color: var(--sel-line);
color: var(--text);
}
.gallery-badges {
position: absolute;
top: 18px;
left: 18px;
display: flex;
flex-wrap: wrap;
gap: 6px;
pointer-events: none;
}
.gallery-badge {
padding: 2px 9px;
border-radius: 999px;
background: rgb(15 24 40 / 0.85);
color: #fff;
font-size: 0.78rem;
font-weight: 700;
}
.gallery-badge-hidden {
background: #b42318;
}
/* Detailseite /community/<id> */
.detail-page {
--card-radius: 3.711% / 5.882%;
}
.detail-back {
margin: 0 0 16px;
}
.detail-back a {
display: inline-flex;
align-items: center;
min-height: 36px;
font-weight: 600;
text-decoration: none;
}
.detail-back a:hover {
text-decoration: underline;
}
.detail-layout {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 1fr);
align-items: start;
gap: 24px 36px;
}
.detail-card {
position: relative;
aspect-ratio: 1536 / 969;
overflow: hidden;
border-radius: var(--card-radius);
background: var(--panel-2);
box-shadow: var(--shadow-card), 0 0 0 1px rgb(255 255 255 / 0.06);
}
.detail-card img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.detail-card-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 100%;
padding: 16px;
color: var(--muted);
text-align: center;
}
.detail-card-empty p {
margin: 0;
}
.detail-info {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
#app-root .detail-title {
margin: 0;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 800;
line-height: 1.15;
overflow-wrap: anywhere;
}
.detail-issuer {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.detail-meta {
margin: 0;
color: var(--muted);
overflow-wrap: anywhere;
}
/* Solange der Name des Autors noch gesucht wird, ist die Zeile leer (kein Knoten darin). Ein Zeichen ohne Breite (U+200B) hält die Zeile
in der Höhe des Textes, damit die Seite nicht springt, wenn „von @name · Datum“ dazukommt. */
.detail-meta:empty::before {
content: '\200b';
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.detail-tag {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 2px 13px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-2);
color: var(--muted);
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
overflow-wrap: anywhere;
}
.detail-tag:hover {
border-color: var(--sel-line);
color: var(--text);
}
.detail-notice-slot:empty,
.detail-admin-slot:empty {
display: none;
}
.detail-hidden {
margin: 0;
padding: 10px 14px;
border: 1px solid var(--danger);
border-radius: 12px;
background: rgb(229 72 77 / 0.12);
}
.detail-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 6px;
}
.detail-btn-danger {
border-color: var(--danger);
color: var(--danger-text);
}
.detail-btn-danger:hover:not(:disabled) {
border-color: var(--danger);
background: rgb(229 72 77 / 0.16);
}
.detail-msg {
margin: 0;
color: var(--ok-text);
}
.detail-msg.is-error {
color: var(--danger-text);
}
/* Leer bleibt die Meldungszeile gerendert (Live-Region), nimmt aber keinen Platz ein: mit display: none stünde sie nicht im
Zugänglichkeitsbaum, und Screenreader meldeten den Text nicht, der später hineinkommt. */
.detail-msg:empty {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.detail-admin {
margin-top: 8px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
}
.detail-admin-title {
margin: 0 0 8px;
font-size: 1.05rem;
}
.detail-reports-count {
margin: 0 0 8px;
font-weight: 700;
}
.detail-reasons {
margin: 0 0 12px;
padding-left: 20px;
color: var(--muted);
}
.detail-admin-note {
margin: 0 0 12px;
color: var(--warn-text);
}
.detail-admin-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.detail-unavailable {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
max-width: 560px;
padding: 24px 0;
}
.detail-load-error,
.detail-status {
margin: 24px 0;
color: var(--muted);
}
.detail-load-error {
margin: 0;
color: var(--danger-text);
}
/* Melden-Dialog (Knöpfe und Meldungen kommen aus account.css) */
.report-dialog {
width: min(420px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: 0;
overflow: auto;
border: 1px solid var(--line-strong);
border-radius: 20px;
background: var(--panel);
color: var(--text);
box-shadow: var(--shadow-lg);
}
.report-dialog::backdrop {
background: var(--backdrop);
}
.report-card {
padding: 24px;
}
.report-title {
margin: 0 0 8px;
font-size: 1.25rem;
line-height: 1.25;
}
.report-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.report-text {
margin: 0;
color: var(--muted);
overflow-wrap: anywhere;
}
.report-reasons {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.report-legend {
margin-bottom: 4px;
padding: 0;
color: var(--muted);
font-size: 0.9rem;
font-weight: 600;
}
.report-reason {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 12px;
cursor: pointer;
}
.report-reason:hover {
border-color: var(--sel-line);
}
.report-reason:has(input:checked) {
border-color: var(--sel-line);
background: var(--sel-bg);
}
.report-reason input {
flex: none;
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--sel-line);
}
.report-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
/* Veröffentlichen-Dialog: Hülle, Meldungen und Knöpfe wie beim Melden-Dialog, dazu Felder und die Pflicht-Checkbox */
.publish-dialog {
width: min(480px, calc(100vw - 32px));
}
.publish-fields {
display: flex;
flex-direction: column;
gap: 14px;
}
.publish-consent {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
cursor: pointer;
}
.publish-consent:has(input:checked) {
border-color: var(--sel-line);
background: var(--sel-bg);
}
.publish-consent input {
flex: none;
width: 18px;
height: 18px;
margin: 2px 0 0;
accent-color: var(--sel-line);
}
.publish-consent span {
overflow-wrap: anywhere;
}
/* Tablet und Handy: eine Spalte, der Hero bleibt niedrig */
@media (max-width: 899px) {
.detail-layout {
grid-template-columns: minmax(0, 1fr);
}
.home-hero {
--hero-card-max: 400px;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
padding: 4px 0 6px;
}
.home-hero-visual {
justify-content: center;
}
}
@media (max-width: 599px) {
.detail-actions .home-btn {
flex: 1 1 100%;
}
.home-hero {
--hero-card-max: 250px;
gap: 8px;
margin-top: -4px;
padding: 0;
}
.home-hero-eyebrow {
margin-bottom: 4px;
font-size: 0.9rem;
}
#app-root .home-hero-title {
margin-bottom: 8px;
font-size: 1.85rem;
}
.home-hero-lead {
margin-bottom: 14px;
font-size: 0.97rem;
line-height: 1.45;
}
.home-btn {
min-height: 44px;
padding: 0 18px;
}
.home-hero-visual {
padding: 10px 0 4px; /* Luft nach oben für die Schwebe-Animation (10 px nach oben) */
}
.home-community {
margin-top: 20px;
}
.community-head {
gap: 10px;
}
.community-publish {
width: 100%;
min-height: 42px;
}
}
@media (prefers-reduced-motion: reduce) {
.home-btn,
.gallery-tile {
transition: none;
}
}
+228
View File
@@ -0,0 +1,228 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { germanLeft } from '../i18n/english-check';
import { setLang } from '../i18n/i18n';
import type { CheckedImage, GalleryCard } from './gallery';
import { mountGalleryGrid, type GalleryGrid } from './gallery-grid';
const card = (n: number, patch: Partial<GalleryCard> = {}): GalleryCard => ({
id: `g${n}`,
title: `Karte ${n}`,
issuer: 'Beispielbank',
tags: ['visa', 'gold'],
displayName: 'Kenso',
imageFileId: `img${n}`,
previewFileId: `pre${n}`,
sourceCardId: `src${n}`,
ownerId: 'u1',
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
let counter: number;
/** Ein geprüftes Bild, wie es `loadGalleryImageChecked` liefert: Blob plus fertige Object-URL. */
const image = (): CheckedImage => ({ blob: new Blob(['x'], { type: 'image/webp' }), url: `blob:t-${++counter}` });
const flush = async () => {
for (let i = 0; i < 5; i++) await Promise.resolve();
};
let host: HTMLElement;
let grid: GalleryGrid | null;
const revoked: string[] = [];
beforeEach(() => {
host = document.createElement('div');
document.body.append(host);
grid = null;
counter = 0;
revoked.length = 0;
URL.revokeObjectURL = vi.fn((url: string) => {
revoked.push(url);
});
});
afterEach(() => {
grid?.dispose();
host.remove();
});
const tiles = () => [...host.querySelectorAll<HTMLElement>('.gallery-tile')];
describe('mountGalleryGrid: Kacheln', () => {
it('zeigt Titel, Herausgeber und Kachel-Link auf die Detailseite', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1), card(2)]);
expect(tiles()).toHaveLength(2);
const first = tiles()[0];
const link = first.querySelector<HTMLAnchorElement>('a[data-link]');
expect(link?.getAttribute('href')).toBe('/community/g1');
expect(first.querySelector('.gallery-title')?.textContent).toBe('Karte 1');
expect(first.querySelector('.gallery-issuer')?.textContent).toBe('Beispielbank');
});
it('kodiert die ID im Link und lässt den Herausgeber weg, wenn er fehlt', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1, { id: 'a/b c', issuer: '' })]);
expect(host.querySelector('a[data-link]')?.getAttribute('href')).toBe('/community/a%2Fb%20c');
expect(host.querySelector('.gallery-issuer')).toBeNull();
});
it('zeigt höchstens drei Tags als Knöpfe; Klick meldet den Tag', () => {
const onTag = vi.fn();
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag });
grid.append([card(1, { tags: ['visa', 'gold', 'dunkel', 'extra'] })]);
const buttons = [...host.querySelectorAll<HTMLButtonElement>('button.gallery-tag')];
expect(buttons.map((b) => b.textContent)).toEqual(['visa', 'gold', 'dunkel']);
expect(buttons.every((b) => b.type === 'button')).toBe(true);
buttons[1].click();
expect(onTag).toHaveBeenCalledWith('gold');
});
it('die Tags stehen außerhalb des Kachel-Links (kein Knopf im Link)', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1)]);
expect(host.querySelector('a button')).toBeNull();
});
it('setzt Titel und Herausgeber als Text, nie als HTML', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1, { title: '<img src=x onerror=alert(1)>', issuer: '<b>fett</b>', tags: ['<i>x</i>'] })]);
expect(host.querySelector('.gallery-title')?.textContent).toBe('<img src=x onerror=alert(1)>');
expect(host.querySelector('.gallery-tile img[src="x"]')).toBeNull();
expect(host.querySelector('.gallery-issuer b')).toBeNull();
expect(host.querySelector('.gallery-tag i')).toBeNull();
});
it('append hängt an, ohne die vorhandenen Kacheln zu ersetzen', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1)]);
const firstTile = tiles()[0];
grid.append([card(2), card(3)]);
expect(tiles()).toHaveLength(3);
expect(tiles()[0]).toBe(firstTile);
});
it('Status-Marken (Ausgeblendet, Meldungen) nur mit showStatus', () => {
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {}, showStatus: true });
grid.append([card(1, { hidden: true, reportCount: 3 }), card(2, { reportCount: 1 }), card(3)]);
const badges = tiles().map((t) => [...t.querySelectorAll('.gallery-badge')].map((b) => b.textContent));
expect(badges).toEqual([['Ausgeblendet', '3 Meldungen'], ['1 Meldung'], []]);
grid.dispose();
host.replaceChildren();
grid = mountGalleryGrid(host, { load: () => new Promise(() => {}), onTag: () => {} });
grid.append([card(1, { hidden: true, reportCount: 3 })]);
expect(host.querySelector('.gallery-badge')).toBeNull();
});
});
describe('mountGalleryGrid: Vorschaubilder', () => {
it('lädt die Vorschau jeder Karte und zeigt sie per Object-URL', async () => {
const load = vi.fn(async () => image());
grid = mountGalleryGrid(host, { load, onTag: () => {} });
const cards = [card(1), card(2)];
grid.append(cards);
await flush();
expect(load).toHaveBeenCalledTimes(2);
expect(load.mock.calls.map((c) => (c as unknown as [GalleryCard])[0].id)).toEqual(['g1', 'g2']);
const srcs = [...host.querySelectorAll<HTMLImageElement>('.gallery-thumb img')].map((i) => i.getAttribute('src'));
expect(srcs).toEqual(['blob:t-1', 'blob:t-2']);
// Vorschau 480 breit, Höhe nach 1536 : 969 (wie exportCard sie rendert)
const first = host.querySelector('.gallery-thumb img');
expect([first?.getAttribute('width'), first?.getAttribute('height')]).toEqual(['480', '303']);
});
it('gibt alle Object-URLs beim Aufräumen frei', async () => {
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
grid.append([card(1), card(2), card(3)]);
await flush();
expect(counter).toBe(3);
expect(revoked).toEqual([]);
grid.dispose();
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2', 'blob:t-3']);
});
it('eine nach dem Aufräumen eintreffende URL wird sofort freigegeben (späte Antwort), neue Ladungen starten nicht mehr', async () => {
const resolvers: Array<(i: CheckedImage) => void> = [];
const load = vi.fn(() => new Promise<CheckedImage>((r) => resolvers.push(r)));
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1), card(2)]);
expect(load).toHaveBeenCalledTimes(2);
grid.dispose();
for (const resolve of resolvers) resolve(image());
await flush();
expect(revoked.sort()).toEqual(['blob:t-1', 'blob:t-2']);
expect(host.querySelector('img')).toBeNull();
grid.append([card(3)]);
expect(load).toHaveBeenCalledTimes(2);
expect(tiles()).toHaveLength(2); // die Kachel nach dem Aufräumen kommt nicht mehr dazu
});
it('lädt höchstens sechs Vorschauen gleichzeitig', async () => {
const pending: Array<(i: CheckedImage) => void> = [];
const load = vi.fn(() => new Promise<CheckedImage>((r) => pending.push(r)));
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append(Array.from({ length: 10 }, (_, i) => card(i + 1)));
expect(load).toHaveBeenCalledTimes(6);
pending[0](image());
await flush();
expect(load).toHaveBeenCalledTimes(7);
});
it('eine fehlgeschlagene Vorschau zeigt „Keine Vorschau“ und hält die anderen nicht auf', async () => {
const load = vi.fn(async (c: GalleryCard) => {
if (c.id === 'g1') throw new Error('404');
return image();
});
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1), card(2)]);
await flush();
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
expect(tiles()[1].querySelector('.gallery-thumb img')).not.toBeNull();
expect(tiles()[0].querySelector('.gallery-thumb')?.hasAttribute('aria-busy')).toBe(false);
});
it('eine abgelehnte Vorschau (zu viele Pixel, nicht dekodierbar) zeigt „Keine Vorschau“, nie ein defektes Bild', async () => {
const load = vi.fn(async () => {
throw new Error('Das Bild konnte nicht geladen werden.'); // so lehnt checkGalleryImage ab
});
grid = mountGalleryGrid(host, { load, onTag: () => {} });
grid.append([card(1)]);
await flush();
expect(host.querySelector('.gallery-thumb img')).toBeNull();
expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
});
it('zeigt das Bild nur, wenn es im Browser wirklich lädt: Ladefehler des <img> ersetzt es durch „Keine Vorschau“ und gibt die URL frei', async () => {
grid = mountGalleryGrid(host, { load: async () => image(), onTag: () => {} });
grid.append([card(1)]);
await flush();
host.querySelector('.gallery-thumb img')?.dispatchEvent(new Event('error'));
expect(host.querySelector('.gallery-thumb img')).toBeNull();
expect(host.querySelector('.gallery-thumb')?.textContent).toBe('Keine Vorschau');
expect(revoked).toEqual(['blob:t-1']);
grid.dispose();
expect(revoked).toEqual(['blob:t-1']); // nicht doppelt freigeben
});
});
describe('mountGalleryGrid auf Englisch', () => {
it('Tag-Titel, „No preview“ und die Status-Marken der Admin-Ansicht; Nutzerinhalte bleiben unverändert', async () => {
setLang('en', { persist: false });
const load = vi.fn(async (c: GalleryCard) => {
if (c.id === 'g1') throw new Error('404');
return image();
});
grid = mountGalleryGrid(host, { load, onTag: () => {}, showStatus: true });
grid.append([card(1, { hidden: true, reportCount: 3 }), card(2, { reportCount: 1 })]);
await flush();
expect(tiles()[0].querySelector('.gallery-thumb')?.textContent).toBe('No preview');
expect(tiles()[0].querySelector('.gallery-tag')?.getAttribute('title')).toBe('Show cards with this tag');
const badges = tiles().map((t) => [...t.querySelectorAll('.gallery-badge')].map((b) => b.textContent));
expect(badges).toEqual([['Hidden', '3 reports'], ['1 report']]);
expect(tiles()[0].querySelector('.gallery-title')?.textContent).toBe('Karte 1');
expect(tiles()[0].querySelector('.gallery-issuer')?.textContent).toBe('Beispielbank');
expect(germanLeft(host)).toEqual([]);
});
});
+125
View File
@@ -0,0 +1,125 @@
// Kachelraster der Community-Galerie: Vorschau, Titel, Herausgeber, bis zu drei Tags.
// Die Vorschaubilder kommen über `load` (WebP 480 px aus dem Galerie-Bucket, mit Pixelgrenze geprüft: loadGalleryImageChecked)
// als fertige Object-URL; `dispose()` gibt alle URLs wieder frei. Alle Texte gehen als Text in den DOM, nie als HTML; übersetzt wird beim Aufbau.
import { CARD_H, CARD_W, PREVIEW_WIDTH } from '../card/geometry';
import { el } from '../editor/dom';
import { t } from '../i18n/i18n';
import { reportsLabel } from '../pages/format';
import type { CheckedImage, GalleryCard } from './gallery';
export interface GalleryGridOptions {
/** Lädt und prüft die Vorschau einer Karte; wirft, wenn sie sich nicht anzeigen lässt („Keine Vorschau“). */
load: (card: GalleryCard) => Promise<CheckedImage>;
/** Ein Tag-Knopf wurde angeklickt. */
onTag: (tag: string) => void;
/** Admin-Ansicht: „Ausgeblendet“ und die Zahl der Meldungen an der Kachel zeigen (für alle anderen bleibt das verborgen). */
showStatus?: boolean;
}
export interface GalleryGrid {
/** Hängt Kacheln hinten an. */
append(cards: readonly GalleryCard[]): void;
/** Gibt alle Object-URLs frei und beendet laufende Ladungen (späte Antworten werden verworfen). */
dispose(): void;
}
const PARALLEL = 6;
const MAX_TAGS = 3;
const THUMB_W = PREVIEW_WIDTH;
const THUMB_H = Math.round((THUMB_W * CARD_H) / CARD_W); // wie exportCard: 480 x 1536:969 = 303
export function mountGalleryGrid(container: HTMLElement, opts: GalleryGridOptions): GalleryGrid {
const list = el('ul', { class: 'gallery-grid' });
container.replaceChildren(list);
let disposed = false;
const urls = new Set<string>();
const queue: Array<{ card: GalleryCard; thumb: HTMLElement }> = [];
let active = 0;
const showEmpty = (thumb: HTMLElement) =>
thumb.replaceChildren(el('span', { class: 'gallery-thumb-empty' }, t('Keine Vorschau')));
async function loadThumb(card: GalleryCard, thumb: HTMLElement): Promise<void> {
try {
const { url } = await opts.load(card);
if (disposed) {
URL.revokeObjectURL(url); // zu spät: niemand gibt diese URL mehr frei
return;
}
urls.add(url);
const img = el('img', { src: url, alt: '', width: THUMB_W, height: THUMB_H, decoding: 'async' });
// Die Datei kam an und wurde geprüft, lässt sich aber nicht darstellen: lieber der Hinweis als ein defektes Bild.
img.addEventListener('error', () => {
if (disposed) return;
urls.delete(url);
URL.revokeObjectURL(url);
showEmpty(thumb);
});
thumb.replaceChildren(img);
} catch {
if (!disposed) showEmpty(thumb);
} finally {
thumb.removeAttribute('aria-busy');
}
}
function pump(): void {
while (!disposed && active < PARALLEL && queue.length > 0) {
const job = queue.shift() as { card: GalleryCard; thumb: HTMLElement };
active += 1;
void loadThumb(job.card, job.thumb).finally(() => {
active -= 1;
pump();
});
}
}
function buildTile(card: GalleryCard): HTMLElement {
const thumb = el('span', { class: 'gallery-thumb', 'aria-busy': 'true' });
const link = el(
'a',
{ class: 'gallery-tile-link', href: `/community/${encodeURIComponent(card.id)}`, 'data-link': true },
thumb,
el('h3', { class: 'gallery-title' }, card.title),
);
const tile = el('li', { class: 'gallery-tile' }, link);
if (card.issuer) tile.append(el('p', { class: 'gallery-issuer' }, card.issuer));
const tags = card.tags.slice(0, MAX_TAGS);
if (tags.length > 0) {
const row = el('div', { class: 'gallery-tags' });
for (const tag of tags) {
const button = el('button', { type: 'button', class: 'gallery-tag', title: t('Karten mit diesem Tag zeigen') }, tag);
button.addEventListener('click', () => opts.onTag(tag));
row.append(button);
}
tile.append(row);
}
if (opts.showStatus && (card.hidden || card.reportCount > 0)) {
const badges = el('div', { class: 'gallery-badges' });
if (card.hidden) badges.append(el('span', { class: 'gallery-badge gallery-badge-hidden' }, t('Ausgeblendet')));
if (card.reportCount > 0) badges.append(el('span', { class: 'gallery-badge' }, reportsLabel(card.reportCount)));
tile.append(badges);
}
queue.push({ card, thumb });
return tile;
}
return {
append(cards) {
if (disposed) return;
list.append(...cards.map((card) => buildTile(card)));
pump();
},
dispose() {
if (disposed) return;
disposed = true;
queue.length = 0;
for (const url of urls) URL.revokeObjectURL(url);
urls.clear();
},
};
}
+603
View File
@@ -0,0 +1,603 @@
import { AppwriteException, Query } from 'appwrite';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
listRows: vi.fn(),
getRow: vi.fn(),
fetchFile: vi.fn(),
}));
vi.mock('../account/appwrite', () => ({
IDS: {
database: 'db',
table: 'cards-table',
bucket: 'cards-bucket',
gallery: 'gallery-table',
reports: 'reports-table',
galleryBucket: 'gallery-bucket',
},
account: {},
client: {},
storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
tables: { listRows: mocks.listRows, getRow: mocks.getRow },
}));
// ID_PATTERN & Co. bleiben echt; nur das Laden der Datei geht nicht ins Netz.
vi.mock('../account/cards', async (importOriginal) => ({
...(await importOriginal<typeof import('../account/cards')>()),
fetchFile: mocks.fetchFile,
}));
import {
MAX_GALLERY_PIXELS,
PAGE_SIZE,
checkGalleryImage,
galleryQueries,
getGalleryCard,
listGallery,
listReports,
loadGalleryImage,
loadGalleryImageChecked,
myPublications,
toGalleryCard,
type GalleryCard,
} from './gallery';
const row = (n: number, patch: Record<string, unknown> = {}) => ({
$id: `g${n}`,
$createdAt: `2026-10-0${(n % 9) + 1}T10:00:00.000Z`,
title: `Karte ${n}`,
issuer: 'Beispielbank',
tags: 'visa,gold',
displayName: 'Kenso',
imageFileId: `img${n}`,
previewFileId: `pre${n}`,
sourceCardId: `src${n}`,
ownerId: 'u1',
hidden: false,
reportCount: 0,
searchText: 'karte beispielbank visa gold',
...patch,
});
const notFound = (type = 'row_not_found') => Object.assign(new Error('Row not found'), { code: 404, type });
beforeEach(() => {
mocks.listRows.mockReset();
mocks.getRow.mockReset();
mocks.fetchFile.mockReset();
});
describe('galleryQueries', () => {
it('öffentlich: nur sichtbare Karten, neueste zuerst, 24 pro Seite', () => {
expect(galleryQueries({})).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
]);
});
it('die Abfragen sind genau die Appwrite-JSON-Strings', () => {
expect(galleryQueries({})).toEqual([
JSON.stringify({ method: 'equal', attribute: 'hidden', values: [false] }),
JSON.stringify({ method: 'orderDesc', attribute: '$createdAt' }),
JSON.stringify({ method: 'limit', values: [24] }),
]);
expect(PAGE_SIZE).toBe(24);
});
it('mit Cursor: cursorAfter(letzte ID)', () => {
expect(galleryQueries({ cursor: 'g24' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g24'),
]);
});
it('mit Suche: Volltext auf searchText, Leerraum zusammengefasst', () => {
expect(galleryQueries({ q: ' Visa Gold ' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.search('searchText', 'Visa Gold'),
]);
});
it('Suche mit höchstens 2 Zeichen: Teilstring (contains, klein) statt Volltext, der so kurze Wörter nicht findet', () => {
expect(galleryQueries({ q: 'Vi' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('searchText', 'vi'),
]);
expect(galleryQueries({ q: ' A ' })).toContain(Query.contains('searchText', 'a'));
});
it('ab 3 Zeichen Volltext (die Grenze liegt bei 3)', () => {
expect(galleryQueries({ q: 'ab' })).toContain(Query.contains('searchText', 'ab'));
expect(galleryQueries({ q: 'abc' })).toContain(Query.search('searchText', 'abc'));
expect(galleryQueries({ q: 'abc' }).join('')).not.toContain('contains');
expect(galleryQueries({ q: 'ä ö' })).toContain(Query.search('searchText', 'ä ö'));
});
it('Tag: exakter Treffer auf ",tag," in tags (der Server speichert ",a,b,"), getrimmt und klein; nur sichtbare Karten', () => {
expect(galleryQueries({ tag: ' Visa ' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', ',visa,'),
]);
expect(JSON.stringify(Query.contains('tags', ',visa,'))).toContain('contains');
});
it('Tag: "gold" trifft nicht "marigold" (Trennzeichen an beiden Seiten)', () => {
const [, , , tagQuery] = galleryQueries({ tag: 'gold' });
expect(JSON.parse(tagQuery)).toEqual({ method: 'contains', attribute: 'tags', values: [',gold,'] });
const stored = (tags: string) => tags.includes(JSON.parse(tagQuery).values[0] as string);
expect(stored(',visa,gold,')).toBe(true);
expect(stored(',gold,')).toBe(true);
expect(stored(',marigold,')).toBe(false);
expect(stored(',goldig,')).toBe(false);
});
it('ein leerer oder nur aus Leerraum bestehender Tag wird ignoriert', () => {
expect(galleryQueries({ tag: '' })).toEqual(galleryQueries({}));
expect(galleryQueries({ tag: ' ' })).toEqual(galleryQueries({}));
});
it('Tag, Suche und Cursor zusammen: immer mit hidden = false', () => {
expect(galleryQueries({ tag: 'Gold', q: 'visa', cursor: 'g24' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g24'),
Query.contains('tags', ',gold,'),
Query.search('searchText', 'visa'),
]);
});
it('Suche und Cursor zusammen', () => {
expect(galleryQueries({ q: 'visa', cursor: 'g24' })).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g24'),
Query.search('searchText', 'visa'),
]);
});
it('eine leere oder nur aus Leerraum bestehende Suche wird ignoriert', () => {
expect(galleryQueries({ q: '' })).toEqual(galleryQueries({}));
expect(galleryQueries({ q: ' \t ' })).toEqual(galleryQueries({}));
});
it("Admin-Filter 'all' ist die öffentliche Ansicht", () => {
expect(galleryQueries({ filter: 'all' })).toEqual(galleryQueries({}));
});
it("Admin-Filter 'reported': reportCount > 0, ohne hidden-Bedingung (gemeldet und schon ausgeblendet)", () => {
const queries = galleryQueries({ filter: 'reported' });
expect(queries).toEqual([Query.greaterThan('reportCount', 0), Query.orderDesc('$createdAt'), Query.limit(24)]);
expect(queries.join('')).not.toContain('hidden');
});
it("Admin-Filter 'hidden': nur ausgeblendete Karten", () => {
expect(galleryQueries({ filter: 'hidden' })).toEqual([
Query.equal('hidden', true),
Query.orderDesc('$createdAt'),
Query.limit(24),
]);
});
it('Tag und kurze Suche mit Admin-Filter', () => {
expect(galleryQueries({ filter: 'hidden', tag: 'visa', q: 'x' })).toEqual([
Query.equal('hidden', true),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', ',visa,'),
Query.contains('searchText', 'x'),
]);
});
it('Admin-Filter lassen sich mit Suche und Cursor kombinieren', () => {
expect(galleryQueries({ filter: 'reported', q: 'visa', cursor: 'g5' })).toEqual([
Query.greaterThan('reportCount', 0),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.cursorAfter('g5'),
Query.search('searchText', 'visa'),
]);
});
});
describe('toGalleryCard', () => {
it('übernimmt alle Felder; Tags werden zur Liste', () => {
expect(toGalleryCard(row(1, { hidden: true, reportCount: 3 }))).toEqual({
id: 'g1',
title: 'Karte 1',
issuer: 'Beispielbank',
tags: ['visa', 'gold'],
displayName: 'Kenso',
imageFileId: 'img1',
previewFileId: 'pre1',
sourceCardId: 'src1',
ownerId: 'u1',
hidden: true,
reportCount: 3,
createdAt: row(1).$createdAt,
} satisfies GalleryCard);
});
it('fehlende Felder bekommen leere Standardwerte', () => {
expect(toGalleryCard({ $id: 'g9' })).toEqual({
id: 'g9',
title: '',
issuer: '',
tags: [],
displayName: '',
imageFileId: '',
previewFileId: '',
sourceCardId: '',
ownerId: '',
hidden: false,
reportCount: 0,
createdAt: '',
});
});
it('Felder mit falschem Typ gelten als fehlend', () => {
const card = toGalleryCard({
$id: 'g9',
title: 42,
tags: ['x'],
hidden: 'ja',
reportCount: 'viele',
displayName: null,
$createdAt: 5,
});
expect(card).toMatchObject({ title: '', tags: [], hidden: false, reportCount: 0, displayName: '', createdAt: '' });
});
it('Tags: getrimmt, Leere raus', () => {
expect(toGalleryCard(row(1, { tags: ' visa , ,gold,, ' })).tags).toEqual(['visa', 'gold']);
expect(toGalleryCard(row(1, { tags: '' })).tags).toEqual([]);
});
it('Tags im Speicherformat ",a,b," (Komma an beiden Enden) werden zur sauberen Liste', () => {
expect(toGalleryCard(row(1, { tags: ',visa,gold,' })).tags).toEqual(['visa', 'gold']);
expect(toGalleryCard(row(1, { tags: ',blau,' })).tags).toEqual(['blau']);
expect(toGalleryCard(row(1, { tags: ',' })).tags).toEqual([]);
});
it('reportCount: nur endliche, nicht negative Zahlen', () => {
expect(toGalleryCard(row(1, { reportCount: Number.NaN })).reportCount).toBe(0);
expect(toGalleryCard(row(1, { reportCount: -2 })).reportCount).toBe(0);
expect(toGalleryCard(row(1, { reportCount: 2 })).reportCount).toBe(2);
});
});
describe('listGallery', () => {
it('fragt die Galerie-Tabelle mit den Abfragen ab und liefert Karten', async () => {
mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1), row(2)] });
const page = await listGallery({ q: 'visa' });
expect(mocks.listRows).toHaveBeenCalledOnce();
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'gallery-table',
queries: galleryQueries({ q: 'visa' }),
});
expect(page.cards.map((c) => c.id)).toEqual(['g1', 'g2']);
expect(page.cards[0].tags).toEqual(['visa', 'gold']);
});
it('weniger als 24 Zeilen: keine weitere Seite (nextCursor null)', async () => {
mocks.listRows.mockResolvedValue({ total: 23, rows: Array.from({ length: 23 }, (_, i) => row(i + 1)) });
expect((await listGallery({})).nextCursor).toBeNull();
});
it('keine Zeilen: leere Liste, nextCursor null', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await expect(listGallery({})).resolves.toEqual({ cards: [], nextCursor: null });
});
it('genau 24 Zeilen: nextCursor ist die ID der letzten Karte', async () => {
mocks.listRows.mockResolvedValue({ total: 60, rows: Array.from({ length: 24 }, (_, i) => row(i + 1)) });
const page = await listGallery({});
expect(page.cards).toHaveLength(24);
expect(page.nextCursor).toBe('g24');
});
it('Tag und kurze Suche gehen an die Abfrage', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await listGallery({ tag: 'Visa', q: 'ab' });
expect(mocks.listRows.mock.calls[0][0].queries).toEqual([
Query.equal('hidden', false),
Query.orderDesc('$createdAt'),
Query.limit(24),
Query.contains('tags', ',visa,'),
Query.contains('searchText', 'ab'),
]);
});
it('die nächste Seite schickt den Cursor mit', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
await listGallery({ cursor: 'g24', filter: 'hidden' });
expect(mocks.listRows.mock.calls[0][0].queries).toEqual(galleryQueries({ cursor: 'g24', filter: 'hidden' }));
});
it('Fehler gehen durch', async () => {
const failure = Object.assign(new Error('down'), { code: 503 });
mocks.listRows.mockRejectedValue(failure);
await expect(listGallery({})).rejects.toBe(failure);
});
});
describe('getGalleryCard', () => {
it('liefert die Karte', async () => {
mocks.getRow.mockResolvedValue(row(7));
const card = await getGalleryCard('g7');
expect(card?.id).toBe('g7');
expect(mocks.getRow).toHaveBeenCalledWith({ databaseId: 'db', tableId: 'gallery-table', rowId: 'g7' });
});
it('404 (nicht vorhanden oder ausgeblendet für mich) ergibt null', async () => {
mocks.getRow.mockRejectedValue(notFound());
await expect(getGalleryCard('g7')).resolves.toBeNull();
});
it('404 ohne Typ (z. B. Proxy) ergibt ebenfalls null', async () => {
mocks.getRow.mockRejectedValue(Object.assign(new Error('not found'), { code: 404 }));
await expect(getGalleryCard('g7')).resolves.toBeNull();
});
it('eine fehlende Tabelle (404 table_not_found) ist ein Konfigurationsfehler, kein „Karte weg“', async () => {
const failure = notFound('table_not_found');
mocks.getRow.mockRejectedValue(failure);
await expect(getGalleryCard('g7')).rejects.toBe(failure);
});
it.each([500, 503, 401, 429])('Fehler %i geht durch', async (code) => {
const failure = Object.assign(new Error('x'), { code, type: 'general_server_error' });
mocks.getRow.mockRejectedValue(failure);
await expect(getGalleryCard('g7')).rejects.toBe(failure);
});
it('Netzfehler gehen durch', async () => {
const failure = new TypeError('Failed to fetch');
mocks.getRow.mockRejectedValue(failure);
await expect(getGalleryCard('g7')).rejects.toBe(failure);
});
it.each(['', '../x', 'a/b', 'a?b', '.hidden', '-x', 'x'.repeat(37), 'ä'])(
'ungültige ID %j: null, ohne Anfrage',
async (id) => {
await expect(getGalleryCard(id)).resolves.toBeNull();
expect(mocks.getRow).not.toHaveBeenCalled();
},
);
});
describe('myPublications', () => {
it('liest die Zeilen des Nutzers (höchstens 100) und ordnet sie nach sourceCardId', async () => {
mocks.listRows.mockResolvedValue({
total: 2,
rows: [row(1, { sourceCardId: 'cardA' }), row(2, { sourceCardId: 'cardB', hidden: true, reportCount: 3 })],
});
const map = await myPublications('u1');
expect(mocks.listRows).toHaveBeenCalledOnce();
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'gallery-table',
queries: [Query.equal('ownerId', 'u1'), Query.limit(100)],
});
expect(map).toBeInstanceOf(Map);
expect([...map.keys()]).toEqual(['cardA', 'cardB']);
expect(map.get('cardA')?.id).toBe('g1');
expect(map.get('cardB')).toMatchObject({ id: 'g2', hidden: true, reportCount: 3 });
});
it('ohne Veröffentlichungen: leere Map', async () => {
mocks.listRows.mockResolvedValue({ total: 0, rows: [] });
expect((await myPublications('u1')).size).toBe(0);
});
it('Zeilen ohne sourceCardId (Altdaten) werden übersprungen', async () => {
mocks.listRows.mockResolvedValue({ total: 2, rows: [row(1, { sourceCardId: '' }), row(2, { sourceCardId: 'cardB' })] });
expect([...(await myPublications('u1')).keys()]).toEqual(['cardB']);
});
it('ohne Nutzer-ID: leere Map, ohne Anfrage', async () => {
expect((await myPublications('')).size).toBe(0);
expect(mocks.listRows).not.toHaveBeenCalled();
});
});
describe('listReports', () => {
it('liest die Meldungen einer Karte (höchstens 100), neueste zuerst', async () => {
mocks.listRows.mockResolvedValue({
total: 2,
rows: [
{ $id: 'r2', $createdAt: '2026-10-04T11:00:00.000Z', galleryId: 'g1', reason: 'spam', reporterHash: 'abc' },
{ $id: 'r1', $createdAt: '2026-10-04T10:00:00.000Z', galleryId: 'g1', reason: 'urheberrecht', reporterHash: 'def' },
],
});
const reports = await listReports('g1');
expect(mocks.listRows.mock.calls[0][0]).toEqual({
databaseId: 'db',
tableId: 'reports-table',
queries: [Query.equal('galleryId', 'g1'), Query.orderDesc('$createdAt'), Query.limit(100)],
});
expect(reports).toEqual([
{ id: 'r2', galleryId: 'g1', reason: 'spam', createdAt: '2026-10-04T11:00:00.000Z' },
{ id: 'r1', galleryId: 'g1', reason: 'urheberrecht', createdAt: '2026-10-04T10:00:00.000Z' },
]);
});
it('der Melder-Hash wird nicht weitergereicht', async () => {
mocks.listRows.mockResolvedValue({ total: 1, rows: [{ $id: 'r1', galleryId: 'g1', reason: 'spam', reporterHash: 'abc' }] });
expect(JSON.stringify(await listReports('g1'))).not.toContain('abc');
});
it('ungültige ID: leere Liste, ohne Anfrage', async () => {
await expect(listReports('../x')).resolves.toEqual([]);
expect(mocks.listRows).not.toHaveBeenCalled();
});
});
describe('loadGalleryImage', () => {
const card = toGalleryCard(row(3));
const blob = new Blob(['x'], { type: 'image/png' });
it("'full' lädt das Bild aus dem Galerie-Bucket", async () => {
mocks.fetchFile.mockResolvedValue(blob);
await expect(loadGalleryImage(card, 'full')).resolves.toBe(blob);
expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'img3');
});
it("'preview' lädt die Vorschau aus dem Galerie-Bucket", async () => {
mocks.fetchFile.mockResolvedValue(blob);
await loadGalleryImage(card, 'preview');
expect(mocks.fetchFile).toHaveBeenCalledWith('gallery-bucket', 'pre3');
});
it.each(['', '../x', 'a/b', 'a?b', '.hidden', 'x'.repeat(37)])(
'eine Datei-ID, die keine Appwrite-ID sein kann (%j): 404 „Datei fehlt“, ohne Anfrage',
async (fileId) => {
for (const size of ['full', 'preview'] as const) {
const err = await loadGalleryImage({ ...card, imageFileId: fileId, previewFileId: fileId }, size).catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(AppwriteException);
expect(err).toMatchObject({ code: 404, type: 'storage_file_not_found' });
}
expect(mocks.fetchFile).not.toHaveBeenCalled();
},
);
});
describe('checkGalleryImage (Pixelgrenze beim Anzeigen)', () => {
interface Behaviour {
load: 'ok' | 'fail';
width: number;
height: number;
}
let behaviour: Behaviour;
let created: FakeImage[];
let urlCounter: number;
let revoked: string[];
class FakeImage {
naturalWidth = 0;
naturalHeight = 0;
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
#src = '';
constructor() {
created.push(this);
}
set src(value: string) {
this.#src = value;
queueMicrotask(() => {
if (behaviour.load === 'ok') {
this.naturalWidth = behaviour.width;
this.naturalHeight = behaviour.height;
this.onload?.();
} else {
this.onerror?.();
}
});
}
get src(): string {
return this.#src;
}
}
const blob = new Blob(['x'], { type: 'image/png' });
beforeEach(() => {
behaviour = { load: 'ok', width: 1536, height: 969 };
created = [];
urlCounter = 0;
revoked = [];
vi.stubGlobal('Image', FakeImage);
vi.stubGlobal(
'URL',
Object.assign(URL, {
createObjectURL: vi.fn(() => `blob:check-${++urlCounter}`),
revokeObjectURL: vi.fn((url: string) => {
revoked.push(url);
}),
}),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('Grenze: 4 Megapixel (die Karte hat 1536 x 969, rund 1,5)', () => {
expect(MAX_GALLERY_PIXELS).toBe(4_000_000);
});
it('eine Karte in Normalgröße: liefert Blob und fertige Object-URL, die nicht freigegeben wird (der Aufrufer macht das)', async () => {
const image = await checkGalleryImage(blob);
expect(image).toEqual({ blob, url: 'blob:check-1' });
expect(created).toHaveLength(1);
expect(created[0].src).toBe('blob:check-1');
expect(revoked).toEqual([]);
});
it('genau 4 000 000 Pixel gehen noch durch', async () => {
behaviour = { ...behaviour, width: 2000, height: 2000 };
await expect(checkGalleryImage(blob)).resolves.toMatchObject({ url: 'blob:check-1' });
});
it('mehr als 4 000 000 Pixel: abgelehnt, die URL wird freigegeben (Dekompressionsbombe)', async () => {
behaviour = { ...behaviour, width: 2000, height: 2001 };
await expect(checkGalleryImage(blob)).rejects.toThrow();
expect(revoked).toEqual(['blob:check-1']);
});
it('riesige Maße (60 000 x 60 000): abgelehnt, ohne dass das Bild je angezeigt würde', async () => {
behaviour = { ...behaviour, width: 60_000, height: 60_000 };
await expect(checkGalleryImage(blob)).rejects.toThrow();
expect(revoked).toEqual(['blob:check-1']);
});
it('nicht dekodierbar (Ladefehler): abgelehnt, URL freigegeben', async () => {
behaviour = { ...behaviour, load: 'fail' };
await expect(checkGalleryImage(blob)).rejects.toThrow();
expect(revoked).toEqual(['blob:check-1']);
});
it('Maße 0 (kein echtes Bild): abgelehnt, URL freigegeben', async () => {
behaviour = { ...behaviour, width: 0, height: 0 };
await expect(checkGalleryImage(blob)).rejects.toThrow();
expect(revoked).toEqual(['blob:check-1']);
});
it('loadGalleryImageChecked lädt die Datei und prüft sie; Vorschau und Vollbild nutzen dieselbe Prüfung', async () => {
const card = toGalleryCard(row(3));
mocks.fetchFile.mockResolvedValue(blob);
await expect(loadGalleryImageChecked(card, 'preview')).resolves.toEqual({ blob, url: 'blob:check-1' });
expect(mocks.fetchFile).toHaveBeenLastCalledWith('gallery-bucket', 'pre3');
await expect(loadGalleryImageChecked(card, 'full')).resolves.toEqual({ blob, url: 'blob:check-2' });
expect(mocks.fetchFile).toHaveBeenLastCalledWith('gallery-bucket', 'img3');
behaviour = { ...behaviour, width: 8000, height: 8000 };
await expect(loadGalleryImageChecked(card, 'full')).rejects.toThrow();
expect(revoked).toEqual(['blob:check-3']);
});
it('ein Fehler beim Laden der Datei geht durch, ohne dass eine URL angelegt wird', async () => {
const card = toGalleryCard(row(3));
mocks.fetchFile.mockRejectedValue(notFound('storage_file_not_found'));
await expect(loadGalleryImageChecked(card, 'full')).rejects.toMatchObject({ code: 404 });
expect(urlCounter).toBe(0);
});
});
+212
View File
@@ -0,0 +1,212 @@
// Die öffentliche Galerie lesen: Tabelle `gallery` direkt aus Appwrite (Gäste dürfen sichtbare Zeilen lesen).
// Schreiben (Veröffentlichen, Zurückziehen, Melden, Ausblenden) läuft über den API-Dienst, siehe api.ts.
import { AppwriteException, Query, type Models } from 'appwrite';
import { ID_PATTERN, fetchFile } from '../account/cards';
import { IDS, tables } from '../account/appwrite';
import { isRowMissing } from '../account/errors';
import { msg } from '../i18n/i18n';
export interface GalleryCard {
id: string;
title: string;
issuer: string;
tags: string[];
displayName: string;
imageFileId: string;
previewFileId: string;
sourceCardId: string;
ownerId: string;
hidden: boolean;
reportCount: number;
createdAt: string;
}
export interface GalleryReport {
id: string;
galleryId: string;
reason: string;
createdAt: string;
}
/** Ansicht der Galerie: öffentlich `all`; `reported`/`hidden` zeigen nur Admins etwas (die Rechte prüft der Server). */
export type GalleryFilter = 'all' | 'reported' | 'hidden';
export interface GalleryOptions {
/** Suchtext; ab 3 Zeichen Volltext, kürzer als Teilstring (der Volltext-Index findet so kurze Wörter nicht). */
q?: string;
/** Nur Karten mit genau diesem Tag (klein; der Server speichert `tags` als `,a,b,`, gesucht wird `,<tag>,`). */
tag?: string;
cursor?: string;
filter?: GalleryFilter;
}
export const PAGE_SIZE = 24;
/** Obergrenze für eigene Veröffentlichungen und Meldungen einer Karte (eine Seite genügt). */
const LIST_MAX = 100;
type Row = Record<string, unknown>;
/** Eine Zeile, wie Appwrite sie liefert: Systemfelder (`$id`, …) plus die Spalten der Tabelle. */
type TableRow = Models.Row & Row;
function text(value: unknown): string {
return typeof value === 'string' ? value : '';
}
/** Eine Zeile der Tabelle `gallery` als Karte; fehlende oder falsch typisierte Felder werden leer. */
export function toGalleryCard(row: Row): GalleryCard {
const count = row.reportCount;
return {
id: text(row.$id),
title: text(row.title),
issuer: text(row.issuer),
tags: text(row.tags)
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag !== ''),
displayName: text(row.displayName),
imageFileId: text(row.imageFileId),
previewFileId: text(row.previewFileId),
sourceCardId: text(row.sourceCardId),
ownerId: text(row.ownerId),
hidden: row.hidden === true,
reportCount: typeof count === 'number' && Number.isFinite(count) && count > 0 ? count : 0,
createdAt: text(row.$createdAt),
};
}
/** Ab dieser Länge sucht `q` im Volltext-Index; kürzere Texte finden dort nichts. */
const FULLTEXT_MIN = 3;
/**
* Abfragen für eine Seite der Galerie, neueste zuerst. Öffentlich immer nur sichtbare Karten;
* Admin-Filter: „Gemeldet“ = reportCount > 0 (auch schon ausgeblendete), „Ausgeblendet“ = hidden.
* Optional: Cursor, Tag (`contains` auf `tags`) und Suchtext (Volltext ab 3 Zeichen, sonst `contains` auf `searchText`).
*/
export function galleryQueries(opts: GalleryOptions): string[] {
const base =
opts.filter === 'reported'
? Query.greaterThan('reportCount', 0)
: opts.filter === 'hidden'
? Query.equal('hidden', true)
: Query.equal('hidden', false);
const queries = [base, Query.orderDesc('$createdAt'), Query.limit(PAGE_SIZE)];
if (opts.cursor) queries.push(Query.cursorAfter(opts.cursor));
const tag = (opts.tag ?? '').trim().toLowerCase();
if (tag) queries.push(Query.contains('tags', `,${tag},`)); // mit Trennzeichen: „gold“ trifft nicht „marigold“
const q = (opts.q ?? '').replace(/\s+/g, ' ').trim();
if (q) {
queries.push(
[...q].length >= FULLTEXT_MIN ? Query.search('searchText', q) : Query.contains('searchText', q.toLowerCase()),
);
}
return queries;
}
/** Eine Seite der Galerie. `nextCursor` ist die ID der letzten Karte, solange die Seite voll war, sonst null. */
export async function listGallery(opts: GalleryOptions): Promise<{ cards: GalleryCard[]; nextCursor: string | null }> {
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.gallery,
queries: galleryQueries(opts),
});
const cards = list.rows.map((row) => toGalleryCard(row));
return { cards, nextCursor: cards.length === PAGE_SIZE ? cards[cards.length - 1].id : null };
}
/**
* Eine Karte per ID. Gibt es sie nicht oder ist sie für mich ausgeblendet (Appwrite antwortet beides mit 404), ist das
* Ergebnis null. Eine ID, die keine Appwrite-ID sein kann, ergibt null ohne Anfrage.
*/
export async function getGalleryCard(id: string): Promise<GalleryCard | null> {
if (!ID_PATTERN.test(id)) return null;
try {
return toGalleryCard(await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.gallery, rowId: id }));
} catch (err) {
if (isRowMissing(err)) return null;
throw err;
}
}
/** Die eigenen Veröffentlichungen (auch ausgeblendete), nach privater Karte (`sourceCardId`) geordnet. */
export async function myPublications(userId: string): Promise<Map<string, GalleryCard>> {
const map = new Map<string, GalleryCard>();
if (!userId) return map;
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.gallery,
queries: [Query.equal('ownerId', userId), Query.limit(LIST_MAX)],
});
for (const row of list.rows) {
const card = toGalleryCard(row);
if (card.sourceCardId) map.set(card.sourceCardId, card);
}
return map;
}
/** Admin: die Meldungen einer Karte, neueste zuerst. Der Melder-Hash bleibt außen vor. */
export async function listReports(galleryId: string): Promise<GalleryReport[]> {
if (!ID_PATTERN.test(galleryId)) return [];
const list = await tables.listRows<TableRow>({
databaseId: IDS.database,
tableId: IDS.reports,
queries: [Query.equal('galleryId', galleryId), Query.orderDesc('$createdAt'), Query.limit(LIST_MAX)],
});
return list.rows.map((row) => ({
id: text(row.$id),
galleryId: text(row.galleryId),
reason: text(row.reason),
createdAt: text(row.$createdAt),
}));
}
/** Lädt das Kartenbild (PNG 1536 px) oder die Vorschau (WebP 480 px) aus dem Galerie-Bucket. */
export async function loadGalleryImage(card: GalleryCard, size: 'full' | 'preview'): Promise<Blob> {
const fileId = size === 'full' ? card.imageFileId : card.previewFileId;
// Die ID landet im Pfad der Anfrage; was keine Appwrite-ID sein kann, gibt es nicht.
if (!ID_PATTERN.test(fileId)) throw new AppwriteException('File not found', 404, 'storage_file_not_found', '');
return fetchFile(IDS.galleryBucket, fileId);
}
/** Obergrenze für Breite × Höhe eines Galerie-Bildes: die Karte hat 1536 × 969 (rund 1,5 Megapixel). */
export const MAX_GALLERY_PIXELS = 4_000_000;
const IMAGE_UNUSABLE = msg('Das Bild konnte nicht geladen werden.');
/** Ein geprüftes Galerie-Bild; `url` ist eine Object-URL auf `blob`, die der Aufrufer mit `URL.revokeObjectURL` freigibt. */
export interface CheckedImage {
blob: Blob;
url: string;
}
/**
* Prüft ein Galerie-Bild, bevor es angezeigt wird: Das Bild wird über eine Object-URL in ein `Image` geladen (dann sind
* die Maße bekannt, es ist aber noch nicht dekodiert) und abgelehnt, wenn das nicht klappt oder Breite × Höhe über
* `MAX_GALLERY_PIXELS` liegt. Ein kleines, stark komprimiertes PNG/WebP kann sonst Gigabytes an Pixeln enthalten und den
* Tab einfrieren (Startseite und Galerie zeigen fremde Bilder). Die Server-Prüfung beim Veröffentlichen ersetzt das nicht.
* Bei Erfolg liefert es dieselbe Object-URL, die dann angezeigt wird; bei Ablehnung ist sie schon freigegeben.
*/
export async function checkGalleryImage(blob: Blob): Promise<CheckedImage> {
const url = URL.createObjectURL(blob);
try {
const img = new Image();
const loaded = new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error(IMAGE_UNUSABLE));
});
img.src = url;
await loaded;
const { naturalWidth: w, naturalHeight: h } = img;
if (!w || !h || w * h > MAX_GALLERY_PIXELS) throw new Error(IMAGE_UNUSABLE);
return { blob, url };
} catch (err) {
URL.revokeObjectURL(url);
throw err;
}
}
/**
* Wie `loadGalleryImage`, aber geprüft (`checkGalleryImage`) und mit fertiger Object-URL: der gemeinsame Weg für Raster,
* Hero und Detailseite. Wirft bei Ladefehler, zu großen Maßen und nicht dekodierbaren Dateien.
*/
export async function loadGalleryImageChecked(card: GalleryCard, size: 'full' | 'preview'): Promise<CheckedImage> {
return checkGalleryImage(await loadGalleryImage(card, size));
}
+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/);
});
});
+744
View File
@@ -0,0 +1,744 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ usernameDialog: vi.fn() }));
// publish.ts (normalizeTags) und api.ts hängen am Appwrite-Client; der Dialog bekommt `submit` von außen. Der Namensdialog ist eine Attrappe.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
vi.mock('../account/username-dialog', () => ({ usernameDialog: mocks.usernameDialog }));
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { germanLeft } from '../i18n/english-check';
import { setLang } from '../i18n/i18n';
import { ApiError } from './api';
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, type PublishForm } from './publish';
import {
CONSENT_TEXT,
publishDialog,
publishFailureText,
validatePublishForm,
type PublishFormValues,
type PublishOutcome,
} from './publish-dialog';
type Submit = (form: PublishForm) => Promise<{ id: string }>;
const submit = vi.fn<Submit>();
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.publish-dialog');
const input = (name: string) => dialogEl()!.querySelector<HTMLInputElement>(`input[name="${name}"]`)!;
const consent = () => input('consent');
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const form = () => dialogEl()!.querySelector('form')!;
const send = () => form().dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
const errorLine = () => dialogEl()!.querySelector<HTMLElement>('[role="alert"]')!;
/** Die IDs in `aria-describedby` eines Feldes (leer, wenn es das Attribut nicht hat). */
const describedBy = (name: string) => (input(name).getAttribute('aria-describedby') ?? '').split(' ').filter(Boolean);
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
function fill(values: Partial<PublishFormValues> = {}): void {
const v = { title: 'Meine Karte', issuer: '', tags: '', consent: true, ...values };
input('title').value = v.title;
input('issuer').value = v.issuer;
input('tags').value = v.tags;
consent().checked = v.consent;
}
function open(opts: { cardName?: string; signal?: AbortSignal } = {}): Promise<PublishOutcome> {
return publishDialog({ submit, ...opts });
}
beforeEach(() => {
submit.mockReset();
mocks.usernameDialog.mockReset();
mocks.usernameDialog.mockResolvedValue(null);
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
vi.restoreAllMocks();
});
describe('Veröffentlichen-Dialog: Felder', () => {
it('zeigt die Felder der Spec in Reihenfolge, alle leer, die Checkbox ungesetzt', () => {
void open({ cardName: 'Urlaub' });
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte veröffentlichen');
expect([...form().querySelectorAll('input')].map((i) => i.name)).toEqual(['title', 'issuer', 'tags', 'consent']);
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
'Titel (Pflicht)',
'Herausgeber / Bank (optional)',
'Tags (optional)',
]);
for (const name of ['title', 'issuer', 'tags']) expect(input(name).value).toBe('');
expect(consent().type).toBe('checkbox');
expect(consent().checked).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Veröffentlichen')).toBeTruthy();
});
it('es gibt kein Feld „Anzeigename“ mehr (die Karte zeigt den Benutzernamen, Spec § 5), und nie eine E-Mail im Dialog', () => {
void open();
expect(dialogEl()!.querySelector('input[name="displayName"]')).toBeNull();
expect(dialogEl()!.textContent).not.toMatch(/Anzeigename|Anonym/);
expect(dialogEl()!.textContent).not.toMatch(/@/);
});
it('die Pflicht-Checkbox hat genau den Wortlaut der Spec', () => {
void open();
expect(CONSENT_TEXT).toBe('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
expect(consent().closest('label')!.textContent).toBe(CONSENT_TEXT);
});
it('jedes Feld hat sein Label (label.control === Eingabe), alle IDs sind eindeutig, der Titel des Dialogs ist die Überschrift', () => {
void open();
const labels = [...form().querySelectorAll('label')];
const inputs = [...form().querySelectorAll('input')];
expect(labels).toHaveLength(4);
expect(labels.map((l) => l.control)).toEqual(inputs);
for (const control of inputs) expect(control.labels).toHaveLength(1);
const ids = [...dialogEl()!.querySelectorAll('[id]')].map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length);
const heading = dialogEl()!.querySelector('h2')!;
expect(dialogEl()!.getAttribute('aria-labelledby')).toBe(heading.id);
expect(inputs.map((i) => i.id)).not.toContain(heading.id);
});
it('nennt den Kartennamen als Text, nie als HTML', () => {
void open({ cardName: '<img src=x onerror=alert(1)>' });
expect(dialogEl()!.querySelector('img')).toBeNull();
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
});
describe('Veröffentlichen-Dialog: Prüfung vor dem Senden', () => {
const invalid = async (values: Partial<PublishFormValues>, message: string, field: string) => {
void open();
fill(values);
send();
await settle();
expect(errorText()).toBe(message);
expect(submit).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
expect(input(field).getAttribute('aria-invalid')).toBe('true');
expect(describedBy(field)).toContain(errorLine().id); // das ungültige Feld verweist auf die Fehlerzeile
expect(document.activeElement).toBe(input(field));
};
it('Titel leer (auch nur Leerzeichen)', async () => {
await invalid({ title: ' ' }, 'Bitte gib einen Titel ein.', 'title');
});
it('Titel länger als 60 Zeichen', async () => {
await invalid({ title: 'x'.repeat(61) }, 'Der Titel ist zu lang (höchstens 60 Zeichen).', 'title');
});
it('Herausgeber länger als 40 Zeichen', async () => {
await invalid({ issuer: 'x'.repeat(41) }, 'Herausgeber / Bank ist zu lang (höchstens 40 Zeichen).', 'issuer');
});
it('sechs Tags', async () => {
await invalid({ tags: 'a, b, c, d, e, f' }, 'Bitte gib höchstens 5 Tags an.', 'tags');
});
it('ein Tag länger als 20 Zeichen', async () => {
await invalid({ tags: `visa, ${'x'.repeat(21)}` }, 'Ein Tag darf höchstens 20 Zeichen lang sein.', 'tags');
});
it('Checkbox fehlt', async () => {
await invalid({ consent: false }, 'Bitte bestätige die Erklärung zum Bild.', 'consent');
});
it('Steuerzeichen werden abgelehnt (der Server täte es auch)', async () => {
await invalid({ title: 'a\tb' }, 'Der Titel enthält ungültige Zeichen.', 'title');
});
// Dasselbe Muster wie deploy/api/app.js: C1-Steuerzeichen, Nullbreiten-, Bidi-Zeichen und BOM sind unsichtbar oder ordnen Text um.
it.each([
['Nullbreiten-Leerzeichen', 'a\u200bb'],
['Rechts-nach-links-Überschreibung', 'foto\u202egnp.exe'],
['Isolate', 'a\u2066b'],
['Wortverbinder', 'a\u2060b'],
['C1-Steuerzeichen', 'a\u0085b'],
['BOM mitten im Titel', 'a\ufeffb'],
])('unsichtbare Zeichen werden abgelehnt: %s (Titel)', async (_name, title) => {
await invalid({ title }, 'Der Titel enthält ungültige Zeichen.', 'title');
});
it('ein Titel nur aus unsichtbaren Zeichen gilt als ungültig', async () => {
await invalid({ title: '\u200b\u200d' }, 'Der Titel enthält ungültige Zeichen.', 'title');
});
it('unsichtbare Zeichen im Herausgeber', async () => {
await invalid({ issuer: 'Bank\u200f' }, 'Herausgeber / Bank enthält ungültige Zeichen.', 'issuer');
});
it('unsichtbares Zeichen in einem Tag', async () => {
await invalid({ tags: 'visa, gol\u200bd' }, 'Tags enthalten ungültige Zeichen.', 'tags');
});
it('normale Sonderzeichen (Umlaute, Emoji, Bindestrich, Zeilenabstand durch Leerzeichen) bleiben erlaubt', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'Größe 😀 – Möwe', issuer: 'Café-Bank', tags: 'grün, ✨' });
send();
await expect(outcome).resolves.toBe('published');
});
it('Grenzen sind inklusive: 60/40 Zeichen und fünf Tags gehen durch; doppelte und leere Tags zählen nicht', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'x'.repeat(60), issuer: 'y'.repeat(40), tags: 'A, a, , b, c, d, e,' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledOnce();
});
it('eine behobene Eingabe verliert die Markierung beim nächsten Senden', async () => {
submit.mockResolvedValue({ id: 'g1' });
void open();
fill({ title: '' });
send();
expect(input('title').getAttribute('aria-invalid')).toBe('true');
fill({ title: 'Ok' });
send();
expect(input('title').getAttribute('aria-invalid')).toBeNull();
expect(errorText()).toBe('');
});
describe('Fehlerzeile und Felder (Barrierefreiheit)', () => {
it('die Fehlerzeile hat eine ID, die im Dialog einmalig ist', () => {
void open();
expect(errorLine().id).toBe('publish-error');
expect(dialogEl()!.querySelectorAll(`#${errorLine().id}`)).toHaveLength(1);
});
it('ohne Fehler verweist kein Feld auf die Fehlerzeile; die Hinweise bleiben', () => {
void open();
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
expect(describedBy('title')).toEqual([]);
expect(describedBy('consent')).toEqual([]);
});
it('ein ungültiges Feld verweist mit aria-describedby auf die Fehlerzeile, hinter seinem Hinweis, wenn es einen hat', () => {
void open();
fill({ title: '' });
send();
expect(describedBy('title')).toEqual(['publish-error']);
fill({ tags: 'a,b,c,d,e,f' });
send();
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
expect(describedBy('title')).toEqual([]); // nur das Feld mit dem Problem
fill({ consent: false });
send();
expect(describedBy('consent')).toEqual(['publish-error']);
expect(describedBy('tags')).toEqual(['publish-tags-hint']); // nur das Feld mit dem aktuellen Problem
});
it('ist das Feld behoben, fällt die Verknüpfung beim nächsten Senden weg und der Hinweis bleibt', () => {
submit.mockResolvedValue({ id: 'g1' });
void open();
fill({ tags: 'a,b,c,d,e,f' });
send();
expect(describedBy('tags')).toEqual(['publish-tags-hint', 'publish-error']);
fill({ tags: 'a,b' });
send();
expect(describedBy('tags')).toEqual(['publish-tags-hint']);
expect(input('tags').hasAttribute('aria-invalid')).toBe(false);
});
});
it('validatePublishForm meldet das erste Problem in Formularreihenfolge, sonst null', () => {
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', consent: true };
expect(validatePublishForm(ok)).toBeNull();
expect(validatePublishForm({ ...ok, title: '', consent: false })?.field).toBe('title');
expect(validatePublishForm({ ...ok, tags: 'a,b,c,d,e,f', consent: false })?.field).toBe('tags');
expect(validatePublishForm({ ...ok, consent: false })?.field).toBe('consent');
});
});
describe('Veröffentlichen-Dialog: Senden', () => {
it('sendet die getrimmten Eingaben (Tags roh, Normalisierung macht publishSavedCard) und schließt bei Erfolg; einen Anzeigenamen gibt es nicht mehr', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: ' Mein Motiv ', issuer: ' Beispielbank ', tags: 'Visa, Gold' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Mein Motiv', issuer: 'Beispielbank', tags: 'Visa, Gold' });
expect(dialogEl()).toBeNull();
});
it('leere optionale Felder werden leer gesendet', async () => {
submit.mockResolvedValue({ id: 'g1' });
const outcome = open();
fill({ title: 'Nur Titel' });
send();
await outcome;
expect(submit).toHaveBeenCalledExactlyOnceWith({ title: 'Nur Titel', issuer: '', tags: '' });
});
it('während des Sendens: „Veröffentlichen“ und „Abbrechen“ gesperrt, Felder gesperrt, zweites Senden wird ignoriert', async () => {
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open();
fill();
send();
expect(buttonByText('Veröffentlichen')!.disabled).toBe(true);
expect(buttonByText('Abbrechen')!.disabled).toBe(true);
expect(input('title').disabled).toBe(true);
expect(consent().disabled).toBe(true);
expect(form().getAttribute('aria-busy')).toBe('true');
expect(statusText()).toBe('Wird veröffentlicht …');
send();
expect(submit).toHaveBeenCalledOnce();
finish({ id: 'g1' });
await expect(outcome).resolves.toBe('published');
});
it('während des Sendens schließen weder Esc noch ein Klick auf den Hintergrund den Dialog', async () => {
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open();
fill();
send();
const esc = new Event('cancel', { cancelable: true });
dialogEl()!.dispatchEvent(esc);
expect(esc.defaultPrevented).toBe(true);
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
finish({ id: 'g1' });
await outcome;
});
it('schließt der Browser den Dialog mitten im Senden (close-Ereignis), geht er wieder auf und löst erst mit dem echten Ergebnis auf', async () => {
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
let resolved: PublishOutcome | null = null;
const outcome = open().then((o) => (resolved = o));
fill();
send();
dialogEl()!.close(); // wie ein Browser, der Esc trotz verhindertem `cancel` auswertet
await settle();
expect(dialogEl()?.open).toBe(true);
dialogEl()!.dispatchEvent(new Event('close'));
await settle();
expect(resolved).toBeNull();
expect(dialogEl()?.open).toBe(true);
finish({ id: 'g1' });
await outcome;
expect(resolved).toBe('published');
expect(dialogEl()).toBeNull();
});
it('wurde der Dialog mitten im Senden geschlossen und das Senden scheitert, ist der Fehler sichtbar (Dialog wieder offen)', async () => {
let fail!: (e: Error) => void;
submit.mockReturnValue(new Promise((_, r) => (fail = r)));
void open();
fill();
send();
dialogEl()!.close();
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
await settle();
expect(dialogEl()?.open).toBe(true);
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
});
it('Fehler des Dienstes: Servertext im Dialog, der Dialog bleibt offen, ein neuer Versuch ist möglich', async () => {
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
const outcome = open();
fill();
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Dienst ist gerade nicht erreichbar.');
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
expect(buttonByText('Abbrechen')!.disabled).toBe(false);
expect(input('title').disabled).toBe(false);
expect(input('title').value).toBe('Meine Karte');
expect(consent().checked).toBe(true);
expect(statusText()).toBe('');
submit.mockResolvedValueOnce({ id: 'g1' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledTimes(2);
});
it('Validierungsfehler des Dienstes (400) erscheinen mit ihrem Text', async () => {
submit.mockRejectedValue(new ApiError(400, 'Der Titel enthält ungültige Zeichen.'));
void open();
fill();
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Der Titel enthält ungültige Zeichen.');
});
it('409: „Diese Karte ist schon veröffentlicht.“, kein neuer Versuch, Ergebnis „already“ nach „Schließen“', async () => {
submit.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.'));
const outcome = open();
fill();
send();
await settle();
expect(statusText()).toBe('Diese Karte ist schon veröffentlicht.');
expect(errorText()).toBe('');
expect(form().querySelector('input')).toBeNull();
expect(buttonByText('Veröffentlichen')).toBeUndefined();
send();
expect(submit).toHaveBeenCalledOnce();
buttonByText('Schließen')!.click();
await expect(outcome).resolves.toBe('already');
expect(dialogEl()).toBeNull();
});
it('abgebrochene Anmeldung: Hinweis „Bitte melde dich an“', async () => {
submit.mockRejectedValue(new LoginCancelled());
void open();
fill();
send();
await settle();
expect(errorText()).toBe(LOGIN_NEEDED);
});
it('deutsche Meldungen aus dem Rendern und andere Fehler werden übersetzt', async () => {
submit.mockRejectedValueOnce(new Error(TOO_LARGE));
void open();
fill();
send();
await settle();
expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${TOO_LARGE}`);
submit.mockRejectedValueOnce(new Error(NOT_RENDERED));
send();
await settle();
expect(errorText()).toBe(`Veröffentlichen fehlgeschlagen – ${NOT_RENDERED}`);
submit.mockRejectedValueOnce(new TypeError('Failed to fetch'));
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Keine Verbindung zum Server.');
submit.mockRejectedValueOnce(new Error('boom: interne Details'));
send();
await settle();
expect(errorText()).toBe('Veröffentlichen fehlgeschlagen – Etwas ist schiefgelaufen. Bitte nochmal versuchen.');
});
});
describe('Veröffentlichen-Dialog: Benutzername nötig (403 usernameRequired)', () => {
const required = () => new ApiError(403, 'Bitte wähle zuerst einen Benutzernamen.', true, 'usernameRequired');
const REASON = 'Veröffentlichen fehlgeschlagen – Bitte wähle zuerst einen Benutzernamen.';
it('der Namensdialog „required“ öffnet sich (mit dem Signal der Seite); der Dialog bleibt offen und nennt den Grund', async () => {
mocks.usernameDialog.mockReturnValue(new Promise(() => {})); // der Nutzer überlegt noch
submit.mockRejectedValueOnce(required());
const controller = new AbortController();
void open({ signal: controller.signal });
fill();
send();
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledExactlyOnceWith({ mode: 'required', signal: controller.signal });
expect(dialogEl()?.open).toBe(true);
expect(errorText()).toBe(REASON);
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
});
it('nach dem gespeicherten Namen ist der Grund weg, die Eingaben bleiben, der Fokus liegt auf „Veröffentlichen“ und ein neuer Versuch geht durch', async () => {
mocks.usernameDialog.mockResolvedValue('Kenso');
submit.mockRejectedValueOnce(required());
const outcome = open();
fill({ title: 'Mein Motiv', issuer: 'Bank', tags: 'visa' });
send();
await settle();
expect(errorText()).toBe('');
expect(input('title').value).toBe('Mein Motiv');
expect(input('issuer').value).toBe('Bank');
expect(consent().checked).toBe(true);
expect(dialogEl()?.open).toBe(true);
expect(document.activeElement).toBe(buttonByText('Veröffentlichen'));
submit.mockResolvedValueOnce({ id: 'g1' });
send();
await expect(outcome).resolves.toBe('published');
expect(submit).toHaveBeenCalledTimes(2);
expect(mocks.usernameDialog).toHaveBeenCalledTimes(1);
});
it('„Abbrechen“ im Namensdialog: der Grund bleibt stehen, nichts ist veröffentlicht, der Dialog bleibt zum Abbrechen oder Wiederholen', async () => {
mocks.usernameDialog.mockResolvedValue(null);
submit.mockRejectedValueOnce(required());
void open();
fill();
send();
await settle();
expect(errorText()).toBe(REASON);
expect(dialogEl()?.open).toBe(true);
expect(submit).toHaveBeenCalledOnce();
// ein neuer Versuch fragt der Dienst wieder, und der Namensdialog erscheint wieder
submit.mockRejectedValueOnce(required());
send();
await settle();
expect(mocks.usernameDialog).toHaveBeenCalledTimes(2);
});
it('lässt sich der Namensdialog nicht öffnen, bleibt der Grund stehen und nichts wirft', async () => {
mocks.usernameDialog.mockRejectedValue(new Error('showModal kaputt'));
submit.mockRejectedValueOnce(required());
void open();
fill();
send();
await settle();
expect(errorText()).toBe(REASON);
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Veröffentlichen')!.disabled).toBe(false);
});
it('wird der Dialog währenddessen geschlossen (Seite verlassen), bleibt die Antwort des Namensdialogs folgenlos', async () => {
let answer!: (name: string | null) => void;
mocks.usernameDialog.mockReturnValue(new Promise<string | null>((resolve) => (answer = resolve)));
submit.mockRejectedValueOnce(required());
const controller = new AbortController();
const outcome = open({ signal: controller.signal });
fill();
send();
await settle();
controller.abort();
await expect(outcome).resolves.toBe('cancelled');
answer('Kenso');
await settle();
expect(dialogEl()).toBeNull();
expect(submit).toHaveBeenCalledOnce();
});
it('andere Fehler (auch andere 403) öffnen den Namensdialog nicht', async () => {
submit.mockRejectedValueOnce(new ApiError(403, 'Dazu hast du keine Berechtigung.', true, 'forbidden'));
void open();
fill();
send();
await settle();
submit.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', false, 'unavailable'));
send();
await settle();
submit.mockRejectedValueOnce(new Error('boom'));
send();
await settle();
expect(mocks.usernameDialog).not.toHaveBeenCalled();
});
it('Englisch: der Grund erscheint auf Englisch', async () => {
setLang('en', { persist: false });
mocks.usernameDialog.mockResolvedValue(null);
submit.mockRejectedValueOnce(required());
void open();
fill();
send();
await settle();
expect(errorText()).toBe('Publishing failed – Please choose a username first.');
expect(germanLeft(dialogEl()!)).toEqual([]);
});
});
describe('Veröffentlichen-Dialog: fehlendes Logo der Karte', () => {
it('der Text zum fehlenden Logo kommt im Wortlaut an (nicht als „Etwas ist schiefgelaufen“)', async () => {
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
void open();
fill();
send();
await settle();
expect(errorText()).toBe(
'Veröffentlichen fehlgeschlagen – Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
expect(errorText()).toContain(LOGO_MISSING_PUBLISH);
expect(errorText()).not.toContain('schiefgelaufen');
});
it('publishFailureText reicht ihn ebenso durch', () => {
expect(publishFailureText(new Error(LOGO_MISSING_PUBLISH))).toBe(`Veröffentlichen fehlgeschlagen – ${LOGO_MISSING_PUBLISH}`);
});
});
describe('Veröffentlichen-Dialog: Schließen', () => {
it('„Abbrechen“ schließt ohne zu senden', async () => {
const outcome = open();
fill();
buttonByText('Abbrechen')!.click();
await expect(outcome).resolves.toBe('cancelled');
expect(submit).not.toHaveBeenCalled();
expect(dialogEl()).toBeNull();
});
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen', async () => {
const first = open();
dialogEl()!.dispatchEvent(new Event('close'));
await expect(first).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
const second = open();
dialogEl()!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(second).resolves.toBe('cancelled');
});
it('ein Markieren, das außerhalb endet, schließt nicht (Drücken im Formular, Loslassen auf dem Hintergrund)', () => {
void open();
input('title').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialogEl()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
});
it('das Abbrechen-Signal (Seite verlassen) schließt den Dialog, auch während des Sendens', async () => {
const controller = new AbortController();
let finish!: (r: { id: string }) => void;
submit.mockReturnValue(new Promise((r) => (finish = r)));
const outcome = open({ signal: controller.signal });
fill();
send();
controller.abort();
await expect(outcome).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
// Eine danach eintreffende Antwort bleibt folgenlos.
finish({ id: 'g1' });
await settle();
});
it('ein schon abgebrochenes Signal öffnet gar nichts', async () => {
const controller = new AbortController();
controller.abort();
await expect(open({ signal: controller.signal })).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
});
it('ein Fehler nach dem Schließen durch das Signal wird verschluckt', async () => {
const controller = new AbortController();
let fail!: (e: Error) => void;
submit.mockReturnValue(new Promise((_, r) => (fail = r)));
const outcome = open({ signal: controller.signal });
fill();
send();
controller.abort();
await outcome;
fail(new ApiError(503, 'weg'));
await settle();
expect(dialogEl()).toBeNull();
});
it('der Fokus kehrt zum auslösenden Knopf zurück', async () => {
const opener = document.createElement('button');
document.body.append(opener);
opener.focus();
const outcome = open();
buttonByText('Abbrechen')!.click();
await outcome;
expect(document.activeElement).toBe(opener);
});
});
describe('Veröffentlichen-Dialog auf Englisch', () => {
beforeEach(() => setLang('en', { persist: false }));
it('Überschrift, Einleitung, Felder, Hinweise, Einwilligung und Knöpfe', () => {
void open({ cardName: 'Urlaub' });
const dialog = dialogEl()!;
expect(dialog.querySelector('h2')!.textContent).toBe('Publish card');
expect(dialog.querySelector('.report-text')!.textContent).toBe('“Urlaub” will be shown publicly in the community gallery, including its image.');
expect([...form().querySelectorAll('.auth-label')].map((l) => l.textContent)).toEqual([
'Title (required)',
'Issuer / bank (optional)',
'Tags (optional)',
]);
expect(dialog.querySelector('#publish-tags-hint')!.textContent).toBe('Up to 5, separated by commas, e.g. “visa, gold”.');
expect(consent().closest('label')!.textContent).toBe('The image is mine or I’m allowed to use it, and it contains no real card data.');
expect(buttonByText('Cancel')).toBeTruthy();
expect(buttonByText('Publish')).toBeTruthy();
expect(germanLeft(dialog)).toEqual([]);
});
it('ohne Kartennamen eine allgemeine Einleitung; die Konstante bleibt der deutsche Schlüssel', () => {
void open();
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('The card will be shown publicly in the community gallery, including its image.');
expect(CONSENT_TEXT).toBe('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
});
it('die Prüfung vor dem Senden meldet in der Sprache der Seite', () => {
const ok: PublishFormValues = { title: 'T', issuer: '', tags: '', consent: true };
const problem = (values: Partial<PublishFormValues>) => validatePublishForm({ ...ok, ...values })?.message;
expect(problem({ title: ' ' })).toBe('Please enter a title.');
expect(problem({ title: 'a\tb' })).toBe('The title contains invalid characters.');
expect(problem({ title: 'x'.repeat(61) })).toBe('The title is too long (at most 60 characters).');
expect(problem({ issuer: 'Bank\u200f' })).toBe('Issuer / bank contains invalid characters.');
expect(problem({ issuer: 'x'.repeat(41) })).toBe('Issuer / bank is too long (at most 40 characters).');
expect(problem({ tags: 'visa, gol\u200bd' })).toBe('Tags contain invalid characters.');
expect(problem({ tags: `visa, ${'x'.repeat(21)}` })).toBe('A tag can be at most 20 characters long.');
expect(problem({ tags: 'a, b, c, d, e, f' })).toBe('Please enter at most 5 tags.');
expect(problem({ consent: false })).toBe('Please confirm the statement about the image.');
});
it('Laufzeit, Fehler des Dienstes (über ihren Code), Rendern, Netz und abgebrochene Anmeldung erscheinen auf Englisch', async () => {
let fail!: (e: Error) => void;
submit.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)));
void open();
fill();
send();
expect(statusText()).toBe('Publishing …');
fail(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.', undefined, 'unavailable'));
await settle();
expect(errorText()).toBe('Publishing failed – The service is currently unavailable.');
expect(statusText()).toBe('');
// ohne bekannten Code zeigt Englisch die allgemeine Meldung zum Status, nicht den deutschen Servertext
submit.mockRejectedValueOnce(new ApiError(403, 'Irgendein Servertext.'));
send();
await settle();
expect(errorText()).toBe('Publishing failed – You are not allowed to do that.');
submit.mockRejectedValueOnce(new Error(TOO_LARGE));
send();
await settle();
expect(errorText()).toBe('Publishing failed – The card image is too large for the gallery (max. 5 MB).');
submit.mockRejectedValueOnce(new Error(LOGO_MISSING_PUBLISH));
send();
await settle();
expect(errorText()).toBe('Publishing failed – This card’s logo is no longer available. Open the card in the editor and choose another one.');
submit.mockRejectedValueOnce(new TypeError('Failed to fetch'));
send();
await settle();
expect(errorText()).toBe('Publishing failed – Can’t reach the server.');
submit.mockRejectedValueOnce(new LoginCancelled());
send();
await settle();
expect(errorText()).toBe('Please sign in to continue.');
expect(germanLeft(dialogEl()!)).toEqual([]);
});
it('„Publishing failed – This card is already published.“: ein ApiError mit dem Code alreadyPublished', () => {
const err = new ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished');
expect(publishFailureText(err)).toBe('Publishing failed – This card is already published.');
// in der anderen Sprache derselbe Fehler mit dem deutschen Text
setLang('de', { persist: false });
expect(publishFailureText(err)).toBe('Veröffentlichen fehlgeschlagen – Diese Karte ist schon veröffentlicht.');
});
it('ein Fehler mit dem Code alreadyPublished, aber anderem Status, zeigt ihn im Dialog mit dem Vorsatz', async () => {
submit.mockRejectedValueOnce(new ApiError(400, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'));
void open();
fill();
send();
await settle();
expect(errorText()).toBe('Publishing failed – This card is already published.');
});
it('409: „This card is already published.“ und „Close“', async () => {
submit.mockRejectedValue(new ApiError(409, 'Diese Karte ist schon veröffentlicht.', undefined, 'alreadyPublished'));
const outcome = open();
fill();
send();
await settle();
expect(statusText()).toBe('This card is already published.');
buttonByText('Close')!.click();
await expect(outcome).resolves.toBe('already');
});
});
+306
View File
@@ -0,0 +1,306 @@
// Veröffentlichen-Dialog von „Meine Karten“ (Spec § 6): Titel, Herausgeber/Bank, Tags und die Pflicht-Checkbox. Einen Anzeigenamen gibt
// es nicht mehr: die Detailseite zeigt den Benutzernamen des Kontos (Spec § 5). Hat das Konto keinen, antwortet der Dienst mit 403
// `usernameRequired`: dann öffnet sich der Namensdialog, und mit dem gespeicherten Namen lässt es sich noch einmal versuchen.
// Eigenes <dialog> wie report-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
import '../account/account.css';
import './community.css';
import { errorMessage } from '../account/errors';
import { usernameDialog } from '../account/username-dialog';
import { LOGIN_NEEDED, LoginCancelled } from '../account/with-login';
import { el, setFieldInvalid } from '../editor/dom';
import { PIXELS_MESSAGE, READ_MESSAGE } from '../editor/upload';
import { msg, t } from '../i18n/i18n';
import { ApiError, apiErrorText, isAlreadyPublished, isUsernameRequired } from './api';
import { LOGO_MISSING_PUBLISH, NOT_RENDERED, TOO_LARGE, normalizeTags, type PublishForm } from './publish';
/** Wortlaut aus Spec § 6. */
export const CONSENT_TEXT = msg('Das Bild gehört mir bzw. ich darf es verwenden, und es enthält keine echten Kartendaten.');
/** Wortlaut aus Spec § 11. */
export const ALREADY_PUBLISHED = msg('Diese Karte ist schon veröffentlicht.');
const RUNNING = msg('Wird veröffentlicht …');
/** Grenzen wie der Server (deploy/api/app.js): Länge in UTF-16-Einheiten, nach dem Trimmen. */
const TITLE_MAX = 60;
const ISSUER_MAX = 40;
const TAGS_MAX = 5;
const TAG_MAX = 20;
/** Die Fehlerzeile des Dialogs: ein ungültiges Feld verweist mit `aria-describedby` auf sie. */
const ERROR_ID = 'publish-error';
/**
* Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
* unsichtbar oder umordnend. Dasselbe Muster prüft der Server (deploy/api/app.js, CONTROL_CHARACTERS).
* Auch für den Namen im Dialog „Logo hochladen“ (logos/logo-dialog.ts).
*/
export const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
export interface PublishFormValues extends PublishForm {
consent: boolean;
}
export type PublishField = 'title' | 'issuer' | 'tags' | 'consent';
/** Das erste Problem in Formularreihenfolge (mit Text in der Sprache der Seite) oder null, wenn alles passt. Die Grenzen prüft der Server noch einmal. */
export function validatePublishForm(values: PublishFormValues): { field: PublishField; message: string } | null {
const title = values.title.trim();
if (title === '') return { field: 'title', message: t('Bitte gib einen Titel ein.') };
if (CONTROL_CHARACTERS.test(title)) return { field: 'title', message: t('Der Titel enthält ungültige Zeichen.') };
if (title.length > TITLE_MAX) {
return { field: 'title', message: t('Der Titel ist zu lang (höchstens {max} Zeichen).', { max: TITLE_MAX }) };
}
const issuer = values.issuer.trim();
if (CONTROL_CHARACTERS.test(issuer)) return { field: 'issuer', message: t('Herausgeber / Bank enthält ungültige Zeichen.') };
if (issuer.length > ISSUER_MAX) {
return { field: 'issuer', message: t('Herausgeber / Bank ist zu lang (höchstens {max} Zeichen).', { max: ISSUER_MAX }) };
}
const tags = normalizeTags(values.tags);
if (tags.some((tag) => CONTROL_CHARACTERS.test(tag))) return { field: 'tags', message: t('Tags enthalten ungültige Zeichen.') };
if (tags.some((tag) => tag.length > TAG_MAX)) {
return { field: 'tags', message: t('Ein Tag darf höchstens {max} Zeichen lang sein.', { max: TAG_MAX }) };
}
if (tags.length > TAGS_MAX) return { field: 'tags', message: t('Bitte gib höchstens {max} Tags an.', { max: TAGS_MAX }) };
if (!values.consent) return { field: 'consent', message: t('Bitte bestätige die Erklärung zum Bild.') };
return null;
}
/** Meldungen aus dem Rendern und dem Bildlesen tragen den deutschen Text (msg); angezeigt werden sie mit t(). Alles andere übersetzt `errorMessage`. */
const GERMAN_MESSAGES = new Set<string>([TOO_LARGE, NOT_RENDERED, LOGO_MISSING_PUBLISH, READ_MESSAGE, PIXELS_MESSAGE]);
/**
* Der Text zu einem fehlgeschlagenen Veröffentlichen in der Sprache der Seite (Spec § 11: „Veröffentlichen fehlgeschlagen – …“).
* Fehler des Dienstes kommen über ihren Code (`apiErrorText`).
*/
export function publishFailureText(err: unknown): string {
if (err instanceof LoginCancelled) return t(LOGIN_NEEDED);
let reason: string;
if (err instanceof ApiError) reason = apiErrorText(err);
else if (err instanceof Error && GERMAN_MESSAGES.has(err.message)) reason = t(err.message);
else reason = errorMessage(err, 'save');
return t('Veröffentlichen fehlgeschlagen – {reason}', { reason });
}
/** `published`: veröffentlicht; `already`: die Karte war schon veröffentlicht (409); `cancelled`: nichts veröffentlicht oder Dialog verlassen. */
export type PublishOutcome = 'published' | 'already' | 'cancelled';
export interface PublishDialogOptions {
/** Veröffentlicht die Karte (rendern, hochladen, API); wirft bei Fehler. */
submit: (form: PublishForm) => Promise<{ id: string }>;
/** Name der privaten Karte für den Einleitungstext (als Text). */
cardName?: string;
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
signal?: AbortSignal;
}
/**
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist. Fehler erscheinen im Dialog; er bleibt dann für einen neuen
* Versuch offen. Während des Sendens lässt er sich nicht schließen (Abbrechen gesperrt, Esc und Hintergrund wirkungslos),
* damit niemand glaubt, etwas abgebrochen zu haben, das dann doch veröffentlicht wird; nur `signal` schließt immer.
*/
export function publishDialog(opts: PublishDialogOptions): Promise<PublishOutcome> {
return new Promise<PublishOutcome>((resolve, reject) => {
if (opts.signal?.aborted) {
resolve('cancelled');
return;
}
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
let outcome: PublishOutcome = 'cancelled';
let closed = false;
let busy = false;
const text = (name: string, extra: Record<string, string> = {}) =>
el('input', { class: 'auth-input', type: 'text', name, id: `publish-${name}`, autocomplete: 'off', ...extra });
const titleInput = text('title', { 'aria-required': 'true' });
const issuerInput = text('issuer');
const tagsInput = text('tags', { 'aria-describedby': 'publish-tags-hint' });
const consentInput = el('input', { type: 'checkbox', name: 'consent', id: 'publish-consent' });
const inputs: Record<PublishField, HTMLInputElement> = {
title: titleInput,
issuer: issuerInput,
tags: tagsInput,
consent: consentInput,
};
const field = (label: string, control: HTMLInputElement, hint?: string) =>
el(
'div',
{ class: 'auth-field' },
el('label', { class: 'auth-label', for: control.id }, label),
control,
hint ? el('p', { class: 'auth-hint', id: `${control.id}-hint` }, hint) : '',
);
const intro = el(
'p',
{ class: 'report-text' },
opts.cardName
? t('„{name}“ wird mit Bild öffentlich in der Community-Galerie gezeigt.', { name: opts.cardName })
: t('Die Karte wird mit Bild öffentlich in der Community-Galerie gezeigt.'),
);
const fields = el(
'div',
{ class: 'publish-fields' },
field(t('Titel (Pflicht)'), titleInput),
field(t('Herausgeber / Bank (optional)'), issuerInput),
field(t('Tags (optional)'), tagsInput, t('Bis zu 5, mit Komma getrennt, z. B. „visa, gold“.')),
el('label', { class: 'publish-consent' }, consentInput, el('span', {}, t(CONSENT_TEXT))),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', id: ERROR_ID, role: 'alert' });
const statusEl = el('p', { class: 'auth-msg', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Veröffentlichen'));
const actions = el('div', { class: 'report-actions' }, cancel, send);
// novalidate: die Prüfung läuft selbst, mit Texten der Seite statt der des Browsers.
const form = el('form', { class: 'report-form', novalidate: true }, intro, fields, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog publish-dialog', 'aria-labelledby': 'publish-heading' },
// Eigene ID für die Überschrift: `publish-title` gehört dem Titelfeld (`publish-<name>`), sonst zeigt sein Label auf die Überschrift.
el('div', { class: 'report-card' }, el('h2', { id: 'publish-heading', class: 'report-title' }, t('Karte veröffentlichen')), form),
);
const finish = () => {
if (closed) return;
closed = true;
opts.signal?.removeEventListener('abort', finish);
if (dialog.open) dialog.close();
dialog.remove();
if (opener?.isConnected) opener.focus();
resolve(outcome);
};
/** Öffnet den Dialog wieder, falls ihn der Browser geschlossen hat (Esc trotz verhindertem `cancel`). */
function reopen(): boolean {
if (dialog.open) return true;
try {
dialog.showModal();
return true;
} catch {
return false;
}
}
function setBusy(value: boolean): void {
busy = value;
form.setAttribute('aria-busy', String(value));
for (const control of Object.values(inputs)) control.disabled = value;
send.disabled = value;
cancel.disabled = value;
statusEl.textContent = value ? t(RUNNING) : '';
}
function clearInvalid(): void {
for (const control of Object.values(inputs)) setFieldInvalid(control, ERROR_ID, false);
}
function showAlready(): void {
outcome = 'already';
errorEl.textContent = '';
statusEl.textContent = t(ALREADY_PUBLISHED);
intro.remove();
fields.remove();
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
close.addEventListener('click', finish);
actions.replaceChildren(close);
close.focus();
}
/**
* Der Dienst verlangt einen Benutzernamen: der Namensdialog öffnet sich über dem Dialog. Mit gespeichertem Namen ist der Hinweis erledigt
* und der Fokus liegt auf „Veröffentlichen“ (erneut versuchen); bei „Abbrechen“ oder wenn der Namensdialog nicht aufgeht, bleibt der Grund stehen.
*/
async function askForUsername(): Promise<void> {
let saved: string | null = null;
try {
saved = await usernameDialog({ mode: 'required', signal: opts.signal });
} catch {
// der Namensdialog lässt sich nicht öffnen: der Grund oben steht, der Nutzer kann den Namen im Konto-Menü wählen
}
if (closed || saved === null) return;
errorEl.textContent = '';
send.focus();
}
async function submit(): Promise<void> {
const values: PublishFormValues = {
title: titleInput.value.trim(),
issuer: issuerInput.value.trim(),
tags: tagsInput.value,
consent: consentInput.checked,
};
clearInvalid();
errorEl.textContent = '';
const problem = validatePublishForm(values);
if (problem) {
errorEl.textContent = problem.message;
setFieldInvalid(inputs[problem.field], ERROR_ID, true);
inputs[problem.field].focus();
return;
}
setBusy(true);
try {
await opts.submit({ title: values.title, issuer: values.issuer, tags: values.tags });
} catch (err) {
if (closed) return;
if (isAlreadyPublished(err)) {
setBusy(false);
reopen();
showAlready();
return;
}
setBusy(false);
errorEl.textContent = publishFailureText(err);
reopen(); // war der Dialog zwischendurch zu, soll der Fehler zu sehen sein
if (isUsernameRequired(err)) await askForUsername();
return;
}
if (closed) return;
outcome = 'published';
finish();
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy || outcome !== 'cancelled') return;
void submit();
});
cancel.addEventListener('click', finish);
// Esc löst `cancel` aus (abbrechbar) und danach `close`; mitten im Senden darf der Dialog nicht zugehen.
dialog.addEventListener('cancel', (event) => {
if (busy) event.preventDefault();
});
// Schließt der Browser den Dialog trotzdem mitten im Senden, wird er wieder geöffnet; aufgelöst wird erst mit dem echten Ergebnis.
dialog.addEventListener('close', () => {
if (busy && !closed) {
reopen();
return;
}
finish();
});
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
let pressedOnBackdrop = false;
dialog.addEventListener('mousedown', (event) => {
pressedOnBackdrop = event.target === dialog;
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog && pressedOnBackdrop && !busy) finish();
pressedOnBackdrop = false;
});
opts.signal?.addEventListener('abort', finish);
document.body.append(dialog);
try {
dialog.showModal();
} catch (err) {
closed = true;
opts.signal?.removeEventListener('abort', finish);
dialog.remove();
reject(err);
return;
}
titleInput.focus();
});
}
+579
View File
@@ -0,0 +1,579 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_CONFIG, type TemplateId } from '../card/config';
import type { SavedCard } from '../account/cards';
const mocks = vi.hoisted(() => ({
createJWT: vi.fn(),
decodeImage: vi.fn(),
loadCardAssets: vi.fn(),
exportCard: vi.fn(),
getLogo: vi.fn(),
loadLogoBlob: vi.fn(),
}));
// Die Standard-Abhängigkeiten (Appwrite, Canvas) kommen in diesen Tests nie zum Einsatz;
// nur createJWT wird für den Test mit der echten API-Schicht gebraucht.
vi.mock('../account/appwrite', () => ({
IDS: { galleryBucket: 'gallery-bucket' },
account: { createJWT: mocks.createJWT },
client: {},
tables: {},
storage: {},
APPWRITE_ENDPOINT: 'https://aircard.test/v1',
}));
// Rendern (Bild dekodieren, Logos laden, Canvas) ist für renderForGallery ersetzt.
vi.mock('../card/render', () => ({ loadCardAssets: mocks.loadCardAssets, exportCard: mocks.exportCard }));
// Zeile und Datei eines Community-Logos kommen aus der Datenschicht; nur der Zwischenspeicher darüber (logo-asset.ts) ist echt.
vi.mock('../logos/logos', () => ({ getLogo: mocks.getLogo, loadLogoBlob: mocks.loadLogoBlob }));
vi.mock('../editor/upload', async (importOriginal) => ({
...(await importOriginal<typeof import('../editor/upload')>()),
decodeImage: mocks.decodeImage,
}));
import { forgetLogo, loadLogoAsset } from '../logos/logo-asset';
import type { CommunityLogo } from '../logos/logos';
import { ApiError, publishCard } from './api';
import {
LOGO_MISSING_PUBLISH,
PNG_MAX_BYTES,
assertLogoAvailable,
normalizeTags,
publishSavedCard,
renderForGallery,
type PublishDeps,
} from './publish';
const card: SavedCard = {
id: 'card1',
name: 'Meine Karte',
imageFileId: 'privImage',
previewFileId: 'privPreview',
updatedAt: '2026-10-04T10:00:00.000Z',
config: DEFAULT_CONFIG,
};
const form = { title: 'Meine Karte', issuer: 'Beispielbank', tags: 'Visa, Gold' };
const png = () => new Blob(['png'], { type: 'image/png' });
const webp = () => new Blob(['webp'], { type: 'image/webp' });
type Call = { name: string; args: unknown[] };
/** Fake-Abhängigkeiten, die jeden Aufruf in Reihenfolge aufzeichnen; Dateien bekommen die IDs f1, f2, … */
function fakeDeps(overrides: Partial<PublishDeps> = {}) {
const calls: Call[] = [];
let files = 0;
const log = <T>(name: string, args: unknown[], result: T): T => {
calls.push({ name, args });
return result;
};
const deps: PublishDeps = {
userId: () => log('userId', [], 'u1'),
loadImage: async (c) => log('loadImage', [c], new Blob(['src'], { type: 'image/jpeg' })),
render: async (c, image) => log('render', [c, image], { png: png(), preview: webp() }),
createFile: async (file, permissions) => log('createFile', [file, permissions], { $id: `f${++files}` }),
deleteFile: async (id) => log('deleteFile', [id], undefined),
publishCard: async (input) => log('publishCard', [input], { id: 'g1' }),
...overrides,
};
return {
deps,
calls,
names: () => calls.map((c) => c.name),
of: (name: string) => calls.filter((c) => c.name === name),
};
}
describe('publishSavedCard', () => {
it('Reihenfolge: Nutzer, Bild laden, rendern, Upload, Upload, API; liefert { id }', async () => {
const fake = fakeDeps();
await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' });
expect(fake.names()).toEqual(['userId', 'loadImage', 'render', 'createFile', 'createFile', 'publishCard']);
expect(fake.of('loadImage')[0].args[0]).toBe(card);
expect(fake.of('render')[0].args[0]).toBe(card);
});
it('lädt zuerst das PNG, dann die Vorschau hoch; beide mit read(any) und delete(user:<ich>)', async () => {
const fake = fakeDeps();
await publishSavedCard(card, form, fake.deps);
const [first, second] = fake.of('createFile');
expect((first.args[0] as File).name).toBe('image.png');
expect((first.args[0] as File).type).toBe('image/png');
expect((second.args[0] as File).name).toBe('preview.webp');
expect((second.args[0] as File).type).toBe('image/webp');
for (const call of [first, second]) {
expect(call.args[1]).toEqual(['read("any")', 'delete("user:u1")']);
}
});
it('benennt die Vorschau nach dem echten Blob-Typ (Browser ohne WebP-Export liefern PNG)', async () => {
const fake = fakeDeps({ render: async () => ({ png: png(), preview: png() }) });
await publishSavedCard(card, form, fake.deps);
const preview = fake.of('createFile')[1].args[0] as File;
expect(preview.name).toBe('preview.png');
expect(preview.type).toBe('image/png');
});
it('schickt der API beide neuen Datei-IDs, die Karten-ID und die bereinigten Felder (Tags als Liste); der Anzeigename ist immer leer (die Seite zeigt den Benutzernamen)', async () => {
const fake = fakeDeps();
await publishSavedCard(card, { title: ' Meine Karte ', issuer: ' Bank ', tags: 'Visa, Gold , visa,,' }, fake.deps);
expect(fake.of('publishCard')[0].args[0]).toEqual({
title: 'Meine Karte',
issuer: 'Bank',
tags: ['visa', 'gold'],
displayName: '',
imageFileId: 'f1',
previewFileId: 'f2',
sourceCardId: 'card1',
});
});
it('ein von einem alten Aufrufer mitgegebener Anzeigename geht nicht mehr mit', async () => {
const fake = fakeDeps();
await publishSavedCard(card, { ...form, displayName: 'Kenso' } as typeof form, fake.deps);
expect(fake.of('publishCard')[0].args[0]).toMatchObject({ displayName: '' });
});
describe('Fehler der API: gelöscht wird nur bei eindeutiger Ablehnung', () => {
it.each([400, 401, 403, 404, 409])(
'eindeutig abgelehnt (%i): beide Dateien dieses Versuchs werden gelöscht, der Fehler kommt an',
async (code) => {
const failure = new ApiError(code, 'Abgelehnt.');
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile').map((c) => c.args[0]).sort()).toEqual(['f1', 'f2']);
},
);
it.each([408, 429, 500, 502, 503, 504])(
'unklarer Ausgang (%i): die Zeile kann angelegt sein, die Dateien bleiben liegen, der Fehler kommt an',
async (code) => {
const failure = new ApiError(code, 'Unklar.');
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(0);
},
);
it('JWT-Methode am Server aus (ApiError mit definitive-Flag, es ging keine Anfrage raus): gelöscht', async () => {
const failure = new ApiError(501, 'Anmeldung per Token ist auf dem Server ausgeschaltet.', true);
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(2);
});
it('ein anderer Fehler vor der Anfrage (z. B. createJWT mit 401): gelöscht', async () => {
const failure = Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' });
const fake = fakeDeps({ publishCard: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(2);
});
});
it('zweiter Upload scheitert: nur der erste wird gelöscht, die API wird nicht gerufen', async () => {
const failure = new Error('upload kaputt');
let uploads = 0;
const fake = fakeDeps({
createFile: async (file, permissions) => {
fake.calls.push({ name: 'createFile', args: [file, permissions] });
if (++uploads === 2) throw failure;
return { $id: 'f1' };
},
});
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile').map((c) => c.args[0])).toEqual(['f1']);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('erster Upload scheitert: nichts zu löschen, kein zweiter Upload', async () => {
const failure = new Error('upload kaputt');
const fake = fakeDeps({ createFile: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.of('deleteFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('löscht nie etwas anderes als die zwei Dateien dieses Versuchs (nicht die privaten Dateien der Karte)', async () => {
const fake = fakeDeps({ publishCard: async () => Promise.reject(new ApiError(403, 'Dazu hast du keine Berechtigung.')) });
await publishSavedCard(card, form, fake.deps).catch(() => undefined);
const deleted = fake.of('deleteFile').map((c) => c.args[0]);
expect(deleted).not.toContain('privImage');
expect(deleted).not.toContain('privPreview');
expect(deleted.every((id) => id === 'f1' || id === 'f2')).toBe(true);
});
it('ein Fehler beim Aufräumen überdeckt den ursprünglichen Fehler nicht und stoppt das Aufräumen nicht', async () => {
const failure = new ApiError(400, 'Bitte gib einen Titel an.');
const attempted: string[] = [];
const fake = fakeDeps({
publishCard: async () => Promise.reject(failure),
deleteFile: async (id) => {
attempted.push(id);
throw new Error('delete kaputt');
},
});
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(attempted.sort()).toEqual(['f1', 'f2']);
});
it('bei Erfolg wird nichts gelöscht', async () => {
const fake = fakeDeps();
await publishSavedCard(card, form, fake.deps);
expect(fake.of('deleteFile')).toHaveLength(0);
});
describe('vor dem Hochladen', () => {
it('ein PNG über 5 MiB wird nicht hochgeladen: „Das Kartenbild ist zu groß für die Galerie (max. 5 MB).“', async () => {
const big = new Blob([new Uint8Array(PNG_MAX_BYTES + 1)], { type: 'image/png' });
const fake = fakeDeps({ render: async () => ({ png: big, preview: webp() }) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow(
'Das Kartenbild ist zu groß für die Galerie (max. 5 MB).',
);
expect(fake.of('createFile')).toHaveLength(0);
expect(fake.of('deleteFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('genau 5 MiB sind erlaubt', async () => {
const exact = new Blob([new Uint8Array(PNG_MAX_BYTES)], { type: 'image/png' });
const fake = fakeDeps({ render: async () => ({ png: exact, preview: webp() }) });
await expect(publishSavedCard(card, form, fake.deps)).resolves.toEqual({ id: 'g1' });
});
it('ein Bild mit unzulässigem Typ wird nicht hochgeladen', async () => {
const fake = fakeDeps({ render: async () => ({ png: new Blob(['x'], { type: 'image/jpeg' }), preview: webp() }) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toThrow('Das Kartenbild konnte nicht erzeugt werden.');
expect(fake.of('createFile')).toHaveLength(0);
});
it('scheitert das Rendern, wird nichts hochgeladen oder gelöscht', async () => {
const failure = new Error('Das Bild konnte nicht gelesen werden.');
const fake = fakeDeps({ render: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.names()).toEqual(['userId', 'loadImage']);
});
it('scheitert das Laden des Bildes, passiert nichts weiter', async () => {
const failure = Object.assign(new Error('weg'), { code: 404, type: 'storage_file_not_found' });
const fake = fakeDeps({ loadImage: async () => Promise.reject(failure) });
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.names()).toEqual(['userId']);
});
it('ohne angemeldeten Nutzer wird weder geladen noch gerendert', async () => {
const failure = Object.assign(new Error('unauthorized'), { code: 401 });
const fake = fakeDeps({
userId: () => {
throw failure;
},
});
await expect(publishSavedCard(card, form, fake.deps)).rejects.toBe(failure);
expect(fake.names()).toEqual([]);
});
});
});
describe('Logo der Karte beim Veröffentlichen', () => {
const photo = { source: {}, w: 3000, h: 2000 };
const source = new Blob(['src'], { type: 'image/jpeg' });
const withLogo = (logo: string | null, template: TemplateId = DEFAULT_CONFIG.template): SavedCard => ({
...card,
config: { ...DEFAULT_CONFIG, template, logo },
});
/** Die Vorlagen, die das Community-Logo zeichnen (alle außer „Nur Bild“). */
const DRAWING = ['minimal', 'classic', 'signature'] as const;
const assetsFor = (patch: Record<string, unknown> = {}) => ({ image: photo, logo: null, brand: null, logoMissing: false, ...patch });
beforeEach(() => {
mocks.decodeImage.mockReset().mockResolvedValue(photo);
mocks.loadCardAssets.mockReset().mockResolvedValue(assetsFor());
mocks.exportCard
.mockReset()
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
});
it('LOGO_MISSING_PUBLISH: der Wortlaut aus der Spec, auf Englisch übersetzt', async () => {
const { t, setLang } = await import('../i18n/i18n');
expect(LOGO_MISSING_PUBLISH).toBe(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
try {
setLang('en', { persist: false });
expect(t(LOGO_MISSING_PUBLISH)).toBe('This card’s logo is no longer available. Open the card in the editor and choose another one.');
} finally {
setLang('de', { persist: false });
}
});
it('renderForGallery: rendert mit dem Logo der Karte als PNG (1536 px) und WebP-Vorschau (480 px)', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logo: { source: {}, w: 200, h: 100 } }));
const out = await renderForGallery(withLogo('abc123'), source);
expect(mocks.decodeImage).toHaveBeenCalledWith(source);
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: 'abc123' }), photo);
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
expect(mocks.exportCard.mock.calls[1][2]).toEqual({ width: 480, type: 'image/webp', quality: 0.85 });
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
});
it('eine Karte ohne Logo wird ganz normal gerendert', async () => {
const out = await renderForGallery(withLogo(null), source);
expect(mocks.loadCardAssets).toHaveBeenCalledWith(expect.objectContaining({ logo: null }), photo);
expect(out.png.type).toBe('image/png');
});
it.each(DRAWING)(
'fehlendes Logo (gelöscht oder ausgeblendet), Vorlage %s zeichnet es: Abbruch mit LOGO_MISSING_PUBLISH, es wird nichts gerendert',
async (template) => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
await expect(renderForGallery(withLogo('gone', template), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(mocks.exportCard).not.toHaveBeenCalled();
},
);
it('fehlendes Logo bei „Nur Bild“ (image): die Vorlage zeichnet es nie, es wird ganz normal gerendert', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
const out = await renderForGallery(withLogo('gone', 'image'), source);
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][2]).toEqual({ width: 1536, type: 'image/png' });
expect([out.png.type, out.preview.type]).toEqual(['image/png', 'image/webp']);
});
it('ein Ladefehler der Logos (Netz) ist kein fehlendes Logo: der Fehler kommt unverändert an', async () => {
const failure = new TypeError('Failed to fetch');
mocks.loadCardAssets.mockRejectedValue(failure);
await expect(renderForGallery(withLogo('abc123'), source)).rejects.toBe(failure);
});
it('publishSavedCard: classic mit fehlendem Logo – nichts wird hochgeladen, nichts gelöscht, die API nicht gerufen', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('gone', 'classic'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(fake.names()).toEqual(['userId', 'loadImage']);
expect(fake.of('createFile')).toHaveLength(0);
expect(fake.of('deleteFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('publishSavedCard: „Nur Bild“ (image) mit fehlendem Logo – wird veröffentlicht, als wäre nichts', async () => {
mocks.loadCardAssets.mockResolvedValue(assetsFor({ logoMissing: true }));
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('gone', 'image'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
expect(fake.of('deleteFile')).toHaveLength(0);
});
it('publishSavedCard: mit vorhandenem Logo läuft alles wie sonst', async () => {
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('abc123'), form, fake.deps)).resolves.toEqual({ id: 'g1' });
expect(fake.names()).toEqual(['userId', 'loadImage', 'createFile', 'createFile', 'publishCard']);
});
});
describe('Zwischenspeicher der Logos beim Veröffentlichen (ein ausgeblendetes Logo darf nicht in die Galerie)', () => {
const photo = { source: {}, w: 3000, h: 2000 };
const source = new Blob(['src'], { type: 'image/jpeg' });
const logoRow = (patch: Partial<CommunityLogo> = {}): CommunityLogo => ({
id: 'zw1',
name: 'Mein Logo',
displayName: 'Kenso',
fileId: 'zw1',
ownerId: 'u1',
mono: false,
likeCount: 0,
hidden: false,
reportCount: 0,
createdAt: '2026-10-04T10:00:00.000Z',
...patch,
});
const withLogo = (id: string, template: TemplateId = 'classic'): SavedCard => ({ ...card, config: { ...DEFAULT_CONFIG, template, logo: id } });
beforeEach(() => {
mocks.decodeImage.mockReset().mockResolvedValue(photo);
mocks.exportCard
.mockReset()
.mockImplementation(async (_config: unknown, _assets: unknown, opts: { type: string }) => new Blob(['x'], { type: opts.type }));
mocks.getLogo.mockReset().mockResolvedValue(logoRow());
mocks.loadLogoBlob.mockReset().mockResolvedValue(new Blob(['png'], { type: 'image/png' }));
// Wie der echte Lader (card/render.ts: loadCardAssets), mit dem echten Zwischenspeicher der Logos darunter.
mocks.loadCardAssets.mockReset().mockImplementation(async (config: { logo: string | null; details: 'light' | 'dark' }, image: unknown) => {
const logo = config.logo ? await loadLogoAsset(config.logo, config.details) : null;
return { image, logo, brand: null, logoMissing: Boolean(config.logo) && logo === null };
});
vi.stubGlobal('createImageBitmap', async () => ({ width: 200, height: 100 }));
});
afterEach(() => {
forgetLogo('zw1');
vi.unstubAllGlobals();
});
it('ein Logo, das nach dem ersten Laden (Editor, Zwischenspeicher) ausgeblendet wurde, wird neu geprüft: Abbruch, nichts wird hochgeladen', async () => {
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull(); // der Editor hat es geladen: Zeile und Bild stecken im Zwischenspeicher
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true })); // dann hat ein Admin oder die Melder es ausgeblendet
const fake = fakeDeps({ render: renderForGallery });
await expect(publishSavedCard(withLogo('zw1'), form, fake.deps)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
expect(mocks.exportCard).not.toHaveBeenCalled();
expect(fake.of('createFile')).toHaveLength(0);
expect(fake.of('publishCard')).toHaveLength(0);
});
it('ein Logo, das nach dem ersten Laden gelöscht wurde, ebenso', async () => {
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull();
mocks.getLogo.mockResolvedValue(null);
await expect(renderForGallery(withLogo('zw1'), source)).rejects.toThrow(LOGO_MISSING_PUBLISH);
expect(mocks.exportCard).not.toHaveBeenCalled();
});
it('ein weiterhin sichtbares Logo wird mit frisch geholter Zeile und Datei gerendert (kein Zwischenspeicher-Treffer)', async () => {
await expect(loadLogoAsset('zw1', 'light')).resolves.not.toBeNull();
const out = await renderForGallery(withLogo('zw1'), source);
expect(out.png.type).toBe('image/png');
expect(mocks.getLogo).toHaveBeenCalledTimes(2);
expect(mocks.loadLogoBlob).toHaveBeenCalledTimes(2);
expect(mocks.exportCard.mock.calls[0][1]).toMatchObject({ logo: { w: 200, h: 100 } });
});
it('beide Tonarten des Logos werden vergessen, nicht nur die der Karte', async () => {
await loadLogoAsset('zw1', 'light');
await loadLogoAsset('zw1', 'dark');
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true }));
await expect(renderForGallery({ ...withLogo('zw1'), config: { ...withLogo('zw1').config, details: 'dark' } }, source)).rejects.toThrow(
LOGO_MISSING_PUBLISH,
);
await expect(loadLogoAsset('zw1', 'light')).resolves.toBeNull(); // auch das helle Bild ist weg: es wird neu geprüft
});
it('eine Karte ohne Logo vergisst und holt nichts', async () => {
const out = await renderForGallery({ ...card, config: { ...DEFAULT_CONFIG, logo: null } }, source);
expect(out.png.type).toBe('image/png');
expect(mocks.getLogo).not.toHaveBeenCalled();
});
it('„Nur Bild“ (image) zeichnet nie ein Logo: auch ein inzwischen ausgeblendetes Logo hindert das Veröffentlichen nicht', async () => {
await loadLogoAsset('zw1', 'light');
mocks.getLogo.mockResolvedValue(logoRow({ hidden: true }));
const out = await renderForGallery(withLogo('zw1', 'image'), source);
expect(out.png.type).toBe('image/png');
expect(mocks.exportCard).toHaveBeenCalledTimes(2);
});
});
describe('assertLogoAvailable', () => {
const config = (template: TemplateId) => ({ ...DEFAULT_CONFIG, template, logo: 'abc123' });
const assets = (logoMissing: boolean) => ({ image: null, logo: null, brand: null, logoMissing });
it.each(['minimal', 'classic', 'signature'] as const)('Logo fehlt und Vorlage %s zeichnet es: wirft LOGO_MISSING_PUBLISH', (template) => {
expect(() => assertLogoAvailable(config(template), assets(true))).toThrow(LOGO_MISSING_PUBLISH);
});
it('Logo fehlt, aber „Nur Bild“ zeichnet nie ein Logo: wirft nicht', () => {
expect(() => assertLogoAvailable(config('image'), assets(true))).not.toThrow();
});
it.each(['image', 'minimal', 'classic', 'signature'] as const)('Logo ist da (oder keins gewählt): Vorlage %s wirft nicht', (template) => {
expect(() => assertLogoAvailable(config(template), assets(false))).not.toThrow();
});
});
describe('mit der echten API-Schicht (publishCard, apiPost) und gestubbtem fetch', () => {
const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const fetchMock = vi.fn<(url: string, init: RequestInit) => Promise<Response>>();
beforeEach(() => {
mocks.createJWT.mockReset();
mocks.createJWT.mockResolvedValue({ jwt: 'jwt' });
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
/** Läuft den ganzen Ablauf mit der echten publishCard; `respond` erzeugt bei jedem Aufruf eine neue Antwort. */
async function run(respond: () => Promise<Response>) {
fetchMock.mockImplementation(respond);
const fake = fakeDeps({ publishCard });
const result = await publishSavedCard(card, form, fake.deps).catch((e: unknown) => e);
return { fake, result, deleted: fake.of('deleteFile').map((c) => c.args[0]).sort() };
}
it('201 mit id: Erfolg, nichts gelöscht', async () => {
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
expect(result).toEqual({ id: 'g1' });
expect(deleted).toEqual([]);
});
it.each([
['400 Validierung', async () => json(400, { error: 'Bitte gib einen Titel an.' })],
['401 (auch nach der Wiederholung)', async () => json(401, { error: 'Bitte melde dich an.' })],
['403 fremde Datei', async () => json(403, { error: 'Diese Datei gehört dir nicht.' })],
['403 Dateien belegt oder gesperrt', async () => json(403, { error: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.' })],
['409 schon veröffentlicht', async () => json(409, { error: 'Diese Karte ist schon veröffentlicht.' })],
])('%s: beide Dateien werden gelöscht', async (_name, respond) => {
const { result, deleted } = await run(respond);
expect(result).toBeInstanceOf(ApiError);
expect(deleted).toEqual(['f1', 'f2']);
});
it.each([
['429 Drosselung', async () => json(429, { error: 'Zu viele Anfragen.' })],
['408 Zeitüberschreitung', async () => json(408, { error: 'Zeit abgelaufen.' })],
['500 Serverfehler', async () => json(500, { error: 'Interner Fehler.' })],
['502 Dienst nicht erreichbar', async () => json(502, { error: 'Der Dienst ist gerade nicht erreichbar.' })],
['503 vom Proxy', async () => new Response('<html>503</html>', { status: 503 })],
['504 vom Proxy', async () => new Response('<html>504</html>', { status: 504 })],
['Netzfehler (Antwort verloren)', async () => Promise.reject(new TypeError('Failed to fetch'))],
['Zeitüberschreitung des Clients', async () => Promise.reject(new DOMException('timed out', 'TimeoutError'))],
['HTML mit Status 200', async () => new Response('<!doctype html>', { status: 200 })],
['201 ohne id', async () => json(201, {})],
])('%s: Ausgang offen, die Dateien bleiben liegen', async (_name, respond) => {
const { result, deleted } = await run(respond);
expect(result).toBeInstanceOf(ApiError);
expect(deleted).toEqual([]);
});
it('createJWT scheitert mit 401 (keine Sitzung): es ging nichts raus, die Dateien werden gelöscht', async () => {
mocks.createJWT.mockRejectedValue(Object.assign(new Error('missing scope'), { code: 401, type: 'general_unauthorized_scope' }));
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
expect(result).toMatchObject({ code: 401 });
expect(fetchMock).not.toHaveBeenCalled();
expect(deleted).toEqual(['f1', 'f2']);
});
it('JWT-Methode am Server aus: es ging nichts raus, die Dateien werden gelöscht', async () => {
mocks.createJWT.mockRejectedValue(Object.assign(new Error('x'), { code: 501, type: 'user_auth_method_unsupported' }));
const { result, deleted } = await run(async () => json(201, { id: 'g1' }));
expect(result).toBeInstanceOf(ApiError);
expect(fetchMock).not.toHaveBeenCalled();
expect(deleted).toEqual(['f1', 'f2']);
});
});
describe('normalizeTags', () => {
it('trennt an Kommas, trimmt, schreibt klein und entfernt Leere und Doppelte', () => {
expect(normalizeTags(' Visa, Gold ,, visa ,GOLD, Karte ')).toEqual(['visa', 'gold', 'karte']);
});
it('leere Eingabe ergibt eine leere Liste', () => {
expect(normalizeTags('')).toEqual([]);
expect(normalizeTags(' , , ')).toEqual([]);
});
it('behält die Reihenfolge der ersten Nennung', () => {
expect(normalizeTags('b, a, b, c, a')).toEqual(['b', 'a', 'c']);
});
it('kürzt nichts: Grenzen (5 Tags, 20 Zeichen) prüft der Server mit deutscher Meldung', () => {
const long = 'x'.repeat(30);
expect(normalizeTags(`a,b,c,d,e,f,${long}`)).toEqual(['a', 'b', 'c', 'd', 'e', 'f', long]);
});
it('Umlaute werden mit klein geschrieben', () => {
expect(normalizeTags('ÄÖÜ, Straße')).toEqual(['äöü', 'straße']);
});
});
+162
View File
@@ -0,0 +1,162 @@
// Eine gespeicherte Karte in der Community-Galerie veröffentlichen: rendern, zwei Dateien hochladen, API rufen.
// Aufgeräumt wird nur bei eindeutigem Scheitern (Upload-Fehler, eindeutige Ablehnung der API): dann löscht der Client genau
// die zwei Dateien dieses Versuchs wieder. Bleibt der Ausgang offen, bleiben sie liegen (siehe publishSavedCard).
import { ID, Permission, Role } from 'appwrite';
import { IDS, storage } from '../account/appwrite';
import { cardsService, requireUserId, toUploadFile, type SavedCard } from '../account/cards';
import type { CardConfig } from '../card/config';
import { CARD_W, PREVIEW_QUALITY, PREVIEW_WIDTH, layoutFor } from '../card/geometry';
import { clampCrop } from '../card/crop';
import { exportCard, loadCardAssets, type LoadedAssets } from '../card/render';
import { decodeImage } from '../editor/upload';
import { msg } from '../i18n/i18n';
import { forgetLogo } from '../logos/logo-asset';
import { isDefinitiveFailure, publishCard, type PublishInput } from './api';
/** Der Galerie-Bucket nimmt Dateien bis 5 MiB an (wie der Server prüft). */
export const PNG_MAX_BYTES = 5_242_880;
// Die Meldungen dieser Datei gehen deutsch markiert in den Fehler; der Veröffentlichen-Dialog zeigt sie mit t().
export const TOO_LARGE = msg('Das Kartenbild ist zu groß für die Galerie (max. 5 MB).');
export const NOT_RENDERED = msg('Das Kartenbild konnte nicht erzeugt werden.');
/** Die Karte wählt ein Logo, das gelöscht oder ausgeblendet wurde: ohne dieses Logo wird sie weder veröffentlicht noch heruntergeladen. */
export const LOGO_MISSING_PUBLISH = msg(
'Das Logo dieser Karte ist nicht mehr verfügbar. Öffne die Karte im Editor und wähle ein anderes.',
);
const GALLERY_TYPES = ['image/png', 'image/webp'];
export interface PublishForm {
title: string;
issuer: string;
/** Rohe Eingabe, Komma-getrennt; wird mit `normalizeTags` zur Liste. */
tags: string;
}
/**
* Der Anzeigename, den der Client mitschickt: keiner. Seit den Benutzernamen (Spec § 5) zeigt die Detailseite den Namen des Kontos;
* der Dienst nimmt das Feld weiter an (der Altbestand trägt noch Anzeigenamen).
*/
const NO_DISPLAY_NAME = '';
/** Alles, was `publishSavedCard` von außen braucht (im Test ersetzbar, ohne Netz und Canvas). */
export interface PublishDeps {
/** Die ID des angemeldeten Nutzers; ohne Nutzer ein Fehler mit `code` 401. */
userId(): string;
/** Das Originalbild der privaten Karte. */
loadImage(card: SavedCard): Promise<Blob>;
/** Rendert Kartenbild (PNG, 1536 px) und Vorschau (WebP, 480 px). */
render(card: SavedCard, image: Blob): Promise<{ png: Blob; preview: Blob }>;
createFile(file: File, permissions: string[]): Promise<{ $id: string }>;
deleteFile(fileId: string): Promise<void>;
publishCard(input: PublishInput): Promise<{ id: string }>;
}
/** Wie der Server: an Kommas trennen, trimmen, klein schreiben, Leere und Doppelte entfernen. Grenzen prüft der Server. */
export function normalizeTags(input: string): string[] {
const tags: string[] = [];
for (const part of input.split(',')) {
const tag = part.trim().toLowerCase();
if (tag !== '' && !tags.includes(tag)) tags.push(tag);
}
return tags;
}
/**
* Prüft nach dem Laden der Bilder einer gespeicherten Karte, ob ihr beim Rendern etwas fehlt (Veröffentlichen und Herunterladen
* aus „Meine Karten“): Gibt es das gewählte Community-Logo nicht mehr (gelöscht, ausgeblendet) und zeichnet die Vorlage es, wirft
* das LOGO_MISSING_PUBLISH. So wird nie eine andere Karte veröffentlicht oder heruntergeladen, als der Nutzer sie gestaltet hat.
* Bei „Nur Bild“ wird ein Logo nie gezeichnet: dort fehlt nichts, und es wird ganz normal gerendert.
*/
export function assertLogoAvailable(config: CardConfig, assets: LoadedAssets): void {
if (assets.logoMissing && layoutFor(config.template).logo) throw new Error(LOGO_MISSING_PUBLISH);
}
export async function renderForGallery(card: SavedCard, imageBlob: Blob): Promise<{ png: Blob; preview: Blob }> {
const image = await decodeImage(imageBlob);
const config = { ...card.config, crop: clampCrop(image, card.config.crop) };
// Zeile und Bild des Logos neu vom Server prüfen: der Zwischenspeicher lebt so lange wie die Seite, ein inzwischen ausgeblendetes oder
// gelöschtes Logo käme sonst aus ihm in ein öffentliches Kartenbild.
if (config.logo) forgetLogo(config.logo);
const assets = await loadCardAssets(config, image);
// Lieber abbrechen, als eine Karte ohne ihr Logo zu veröffentlichen.
assertLogoAvailable(config, assets);
const png = await exportCard(config, assets, { width: CARD_W, type: 'image/png' });
const preview = await exportCard(config, assets, { width: PREVIEW_WIDTH, type: 'image/webp', quality: PREVIEW_QUALITY });
return { png, preview };
}
function defaultDeps(): PublishDeps {
const service = cardsService();
return {
userId: requireUserId,
loadImage: (card) => service.loadImage(card),
render: renderForGallery,
// Immer eine frische ID: aufgeräumt wird nur, was dieser Versuch selbst angelegt hat.
createFile: (file, permissions) =>
storage.createFile({ bucketId: IDS.galleryBucket, fileId: ID.unique(), file, permissions }),
deleteFile: async (fileId) => {
await storage.deleteFile({ bucketId: IDS.galleryBucket, fileId });
},
publishCard,
};
}
/** Räumt auf, soweit es geht; ein Fehler (auch 404) darf den eigentlichen Fehler nie überdecken. */
async function deleteQuietly(deps: PublishDeps, fileIds: string[]): Promise<void> {
await Promise.allSettled(fileIds.map((id) => deps.deleteFile(id)));
}
/**
* Veröffentlicht die Karte: Bild laden, rendern, PNG und Vorschau in den Galerie-Bucket laden, API rufen.
*
* Aufräumen (nur die zwei Dateien dieses Versuchs, nie etwas anderes):
* - Scheitert ein Upload, ist noch nichts veröffentlicht: der erste Upload wird gelöscht.
* - Scheitert die API eindeutig (4xx außer 408/429, auch 409 und 403 `usernameRequired`, oder schon der JWT-Abruf), wurde nichts
* angelegt: beide Dateien werden gelöscht.
* - Bleibt der Ausgang offen (Netzfehler, Zeitlimit, 5xx, 408, 429, Antwort ohne `id`), kann die API die Zeile schon
* angelegt haben. Dann bleiben die Dateien liegen: sonst zeigte eine öffentliche Karte auf gelöschte Bilder.
* (Ein Waisen-Upload ist harmlos: unverlinkt, mit Zufalls-ID, vom Nutzer jederzeit löschbar.)
* Der Fehler wird in jedem Fall weitergeworfen.
*/
export async function publishSavedCard(
card: SavedCard,
form: PublishForm,
deps: PublishDeps = defaultDeps(),
): Promise<{ id: string }> {
// Ohne Nutzer wirft das hier, bevor etwas geladen oder gerendert wird.
const userId = deps.userId();
const permissions = [Permission.read(Role.any()), Permission.delete(Role.user(userId))];
const source = await deps.loadImage(card);
const { png, preview } = await deps.render(card, source);
// Alles vorab prüfen: ein unzulässiges oder zu großes Bild darf keine halben Uploads hinterlassen.
if (png.type !== 'image/png' || !GALLERY_TYPES.includes(preview.type)) throw new Error(NOT_RENDERED);
if (png.size > PNG_MAX_BYTES || preview.size > PNG_MAX_BYTES) throw new Error(TOO_LARGE);
const imageFile = toUploadFile(png, 'image');
// Der Name folgt dem echten Typ: Browser ohne WebP-Export liefern für „webp“ ein PNG.
const previewFile = toUploadFile(preview, 'preview');
const uploaded: string[] = [];
try {
uploaded.push((await deps.createFile(imageFile, permissions)).$id);
uploaded.push((await deps.createFile(previewFile, permissions)).$id);
} catch (err) {
// Die API wurde noch nicht gerufen: es gibt keine Zeile, die auf die Dateien zeigen könnte.
await deleteQuietly(deps, uploaded);
throw err;
}
try {
return await deps.publishCard({
title: form.title.trim(),
issuer: form.issuer.trim(),
tags: normalizeTags(form.tags),
displayName: NO_DISPLAY_NAME,
imageFileId: uploaded[0],
previewFileId: uploaded[1],
sourceCardId: card.id,
});
} catch (err) {
if (isDefinitiveFailure(err)) await deleteQuietly(deps, uploaded);
throw err;
}
}
+397
View File
@@ -0,0 +1,397 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// api.ts hängt am Appwrite-Client; der Dialog bekommt `send` von außen, ApiError wird nur als Klasse gebraucht.
vi.mock('../account/appwrite', () => ({ account: {}, client: {}, tables: {}, storage: {}, IDS: {}, APPWRITE_ENDPOINT: '' }));
vi.mock('../account/auth', () => ({ currentUserId: () => null, onAuthChange: () => () => {}, onLogout: () => () => {} }));
import { setLang } from '../i18n/i18n';
import { ApiError, type ReportReason } from './api';
import { REPORT_REASONS, reportDialog, type ReportOutcome } from './report-dialog';
type Send = (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
const send = vi.fn<Send>();
const dialogEl = () => document.querySelector<HTMLDialogElement>('dialog.report-dialog');
const buttonByText = (text: string) =>
[...(dialogEl()?.querySelectorAll('button') ?? [])].find((b) => b.textContent === text) as HTMLButtonElement | undefined;
const radios = () => [...(dialogEl()?.querySelectorAll<HTMLInputElement>('input[type="radio"]') ?? [])];
const choose = (value: string) => {
const radio = radios().find((r) => r.value === value) as HTMLInputElement;
radio.checked = true;
radio.dispatchEvent(new Event('change', { bubbles: true }));
};
const submit = () => dialogEl()!.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
const errorText = () => dialogEl()!.querySelector('[role="alert"]')!.textContent;
const statusText = () => dialogEl()!.querySelector('[role="status"]')!.textContent;
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
function open(opts: { cardTitle?: string; signal?: AbortSignal; subject?: 'card' | 'logo' } = {}): Promise<ReportOutcome> {
return reportDialog({ send, ...opts });
}
beforeEach(() => {
send.mockReset();
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('Melden-Dialog', () => {
it('zeigt die fünf Gründe in der Reihenfolge der Spec, keiner ist vorgewählt', () => {
void open();
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
expect(REPORT_REASONS.map((r) => r.label)).toEqual([
'Unangemessen',
'Echte Kartendaten',
'Urheberrecht/Marke',
'Spam',
'Sonstiges',
]);
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Unangemessen',
'Echte Kartendaten',
'Urheberrecht/Marke',
'Spam',
'Sonstiges',
]);
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
expect(radios().some((r) => r.checked)).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Melden')).toBeTruthy();
});
it('nennt den Kartentitel als Text, nie als HTML', () => {
void open({ cardTitle: '<img src=x onerror=alert(1)>' });
expect(dialogEl()!.querySelector('img')).toBeNull();
expect(dialogEl()!.textContent).toContain('<img src=x onerror=alert(1)>');
});
it('ohne Grund: Hinweis, nichts wird gesendet', async () => {
void open();
submit();
await settle();
expect(errorText()).toBe('Bitte wähle einen Grund.');
expect(send).not.toHaveBeenCalled();
expect(dialogEl()?.open).toBe(true);
});
it.each([
['unangemessen', 'unangemessen'],
['kartendaten', 'kartendaten'],
['urheberrecht', 'urheberrecht'],
['spam', 'spam'],
['sonstiges', 'sonstiges'],
])('Grund %s geht als %s an die API', async (value, expected) => {
send.mockResolvedValue({ schonGemeldet: false });
void open();
choose(value);
submit();
await settle();
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(expected);
});
it('neue Meldung: Dank im Dialog, „Schließen“ beendet mit „reported“', async () => {
send.mockResolvedValue({ schonGemeldet: false });
const result = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke für deine Meldung.');
expect(errorText()).toBe('');
expect(radios()).toHaveLength(0);
expect(buttonByText('Melden')).toBeUndefined();
expect(dialogEl()?.open).toBe(true);
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('reported');
expect(dialogEl()).toBeNull();
});
it('doppelte Meldung: „Danke, du hast diese Karte schon gemeldet.“ und „duplicate“', async () => {
send.mockResolvedValue({ schonGemeldet: true });
const result = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke, du hast diese Karte schon gemeldet.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Drosselung (429): der Servertext erscheint, der Dialog bleibt und lässt einen neuen Versuch zu', async () => {
send
.mockRejectedValueOnce(new ApiError(429, 'Zu viele Meldungen – bitte später nochmal.'))
.mockResolvedValueOnce({ schonGemeldet: false });
const result = open();
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Zu viele Meldungen – bitte später nochmal.');
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Melden')!.disabled).toBe(false);
expect(radios().every((r) => !r.disabled)).toBe(true);
// Die Auswahl bleibt stehen.
expect(radios().find((r) => r.checked)?.value).toBe('spam');
submit();
await settle();
expect(send).toHaveBeenCalledTimes(2);
expect(errorText()).toBe('');
expect(statusText()).toBe('Danke für deine Meldung.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('reported');
});
it('andere Fehler des Dienstes zeigen ihren Servertext (z. B. 404, 503)', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Diese Karte gibt es nicht (mehr).'));
void open();
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Diese Karte gibt es nicht (mehr).');
send.mockRejectedValueOnce(new ApiError(503, 'Der Dienst ist gerade nicht erreichbar.'));
submit();
await settle();
expect(errorText()).toBe('Der Dienst ist gerade nicht erreichbar.');
});
it('ein unerwarteter Fehler (kein ApiError) bekommt einen allgemeinen deutschen Text', async () => {
send.mockRejectedValueOnce(new TypeError('offline'));
void open();
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Keine Verbindung zum Server.');
});
it('während des Sendens ist alles gesperrt, ein zweites Absenden sendet nichts', async () => {
let finish!: (v: { schonGemeldet: boolean }) => void;
send.mockReturnValue(new Promise((r) => (finish = r)));
void open();
choose('spam');
submit();
await settle();
expect(buttonByText('Melden')!.disabled).toBe(true);
expect(radios().every((r) => r.disabled)).toBe(true);
expect(dialogEl()!.querySelector('form')!.getAttribute('aria-busy')).toBe('true');
submit();
await settle();
expect(send).toHaveBeenCalledTimes(1);
finish({ schonGemeldet: false });
await settle();
expect(statusText()).toBe('Danke für deine Meldung.');
});
it('Abbrechen schließt ohne zu senden: „cancelled“', async () => {
const result = open();
buttonByText('Abbrechen')!.click();
await expect(result).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
expect(send).not.toHaveBeenCalled();
});
it('Esc (close-Ereignis) und Klick auf den Hintergrund schließen ebenfalls', async () => {
const first = open();
dialogEl()!.dispatchEvent(new Event('close'));
await expect(first).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
const second = open();
const dialog = dialogEl()!;
dialog.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
dialog.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await expect(second).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
});
it('ein Klick im Inneren schließt nicht', () => {
void open();
dialogEl()!.querySelector('.report-card')!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(dialogEl()?.open).toBe(true);
});
it('das Signal (Seite verlassen) schließt den Dialog; eine spät eintreffende Antwort ändert nichts', async () => {
let finish!: (v: { schonGemeldet: boolean }) => void;
send.mockReturnValue(new Promise((r) => (finish = r)));
const controller = new AbortController();
const result = open({ signal: controller.signal });
choose('spam');
submit();
await settle();
controller.abort();
await expect(result).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
finish({ schonGemeldet: false });
await settle();
expect(dialogEl()).toBeNull();
});
it('ein schon abgebrochenes Signal öffnet gar nicht erst', async () => {
const controller = new AbortController();
controller.abort();
await expect(open({ signal: controller.signal })).resolves.toBe('cancelled');
expect(dialogEl()).toBeNull();
});
});
describe('Melden-Dialog für Logos (subject: „logo“)', () => {
const reasonLabels = () => [...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent);
it('Überschrift „Logo melden“, Einleitung nennt „dieses Logo“, vier Gründe ohne „Echte Kartendaten“', () => {
void open({ subject: 'logo' });
expect(dialogEl()?.open).toBe(true);
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Logo melden');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
expect(reasonLabels()).toEqual(['Unangemessen', 'Urheberrecht/Marke', 'Spam', 'Sonstiges']);
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'urheberrecht', 'spam', 'sonstiges']);
expect(radios().some((r) => r.checked)).toBe(false);
expect(buttonByText('Abbrechen')).toBeTruthy();
expect(buttonByText('Melden')).toBeTruthy();
});
it('die Gründe der Karten bleiben unverändert (REPORT_REASONS enthält weiter „Echte Kartendaten“)', () => {
expect(REPORT_REASONS.map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
});
it('der Titel der Karte spielt für Logos keine Rolle: die Einleitung nennt immer „dieses Logo“', () => {
void open({ subject: 'logo', cardTitle: 'Urlaub' });
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du dieses Logo melden?');
});
it.each(['unangemessen', 'urheberrecht', 'spam', 'sonstiges'] as const)('Grund %s geht unverändert an send', async (value) => {
send.mockResolvedValue({ schonGemeldet: false });
void open({ subject: 'logo' });
choose(value);
submit();
await settle();
expect(send).toHaveBeenCalledExactlyOnceWith(value);
});
it('ohne Grund: derselbe Hinweis, nichts wird gesendet', async () => {
void open({ subject: 'logo' });
submit();
await settle();
expect(errorText()).toBe('Bitte wähle einen Grund.');
expect(send).not.toHaveBeenCalled();
});
it('neue Meldung: derselbe Dank, „reported“', async () => {
send.mockResolvedValue({ schonGemeldet: false });
const result = open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke für deine Meldung.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('reported');
});
it('doppelte Meldung: „Danke, du hast dieses Logo schon gemeldet.“ und „duplicate“', async () => {
send.mockResolvedValue({ schonGemeldet: true });
const result = open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Danke, du hast dieses Logo schon gemeldet.');
buttonByText('Schließen')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Fehler des Dienstes erscheinen wie bei Karten im Dialog, der offen bleibt', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
void open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(errorText()).toBe('Dieses Logo ist nicht (mehr) verfügbar.');
expect(dialogEl()?.open).toBe(true);
expect(buttonByText('Melden')!.disabled).toBe(false);
});
it('ein Dialog mit subject „card“ ist derselbe wie ohne Angabe', () => {
void open({ subject: 'card', cardTitle: 'Urlaub' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Karte melden');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Warum möchtest du „Urlaub“ melden?');
expect(radios().map((r) => r.value)).toEqual(['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']);
});
});
describe('Melden-Dialog auf Englisch', () => {
beforeEach(() => setLang('en', { persist: false }));
it('Karte: Überschrift, Einleitung, Gründe, Knöpfe', () => {
void open({ cardTitle: 'Holiday' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report card');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report “Holiday”?');
expect(dialogEl()!.querySelector('legend')!.textContent).toBe('Reason for the report');
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Inappropriate',
'Real card data',
'Copyright/trademark',
'Spam',
'Other',
]);
expect(buttonByText('Cancel')).toBeTruthy();
expect(buttonByText('Report')).toBeTruthy();
// ohne Titel
dialogEl()!.remove();
void open();
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this card?');
});
it('Logo: Überschrift, Einleitung, vier Gründe, Dank und Duplikat', async () => {
send.mockResolvedValueOnce({ schonGemeldet: true });
const result = open({ subject: 'logo' });
expect(dialogEl()!.querySelector('h2')!.textContent).toBe('Report logo');
expect(dialogEl()!.querySelector('.report-text')!.textContent).toBe('Why do you want to report this logo?');
expect([...dialogEl()!.querySelectorAll('.report-reason')].map((l) => l.textContent)).toEqual([
'Inappropriate',
'Copyright/trademark',
'Spam',
'Other',
]);
submit(); // ohne Grund
await settle();
expect(errorText()).toBe('Please choose a reason.');
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks, you’ve already reported this logo.');
buttonByText('Close')!.click();
await expect(result).resolves.toBe('duplicate');
});
it('Dank und Duplikat bei Karten', async () => {
send.mockResolvedValueOnce({ schonGemeldet: false }).mockResolvedValueOnce({ schonGemeldet: true });
const first = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks for your report.');
buttonByText('Close')!.click();
await first;
const second = open();
choose('spam');
submit();
await settle();
expect(statusText()).toBe('Thanks, you’ve already reported this card.');
buttonByText('Close')!.click();
await second;
});
it('Fehler des Dienstes erscheinen übersetzt, wenn der Schlüssel bekannt ist', async () => {
send.mockRejectedValueOnce(new ApiError(404, 'Dieses Logo ist nicht (mehr) verfügbar.', true, 'logoGone'));
void open({ subject: 'logo' });
choose('spam');
submit();
await settle();
expect(errorText()).toBe('This logo is no longer available.');
});
});
+178
View File
@@ -0,0 +1,178 @@
// Melden-Dialog für Karten (Detailseite) und Logos (Editor): Grund wählen, an den API-Dienst senden, das Ergebnis im Dialog zeigen.
// Eigenes <dialog> wie confirm-dialog.ts; alle Texte gehen als Text in den DOM, nie als HTML. Übersetzt wird beim Aufbau mit t().
import '../account/account.css';
import './community.css';
import { errorMessage } from '../account/errors';
import { el } from '../editor/dom';
import { msg, t } from '../i18n/i18n';
import { ApiError, apiErrorText, type ReportReason } from './api';
/** Die Gründe in der Reihenfolge und mit dem Wortlaut der Spec (§ 5), dazu der Wert für die API. */
export const REPORT_REASONS: ReadonlyArray<{ value: ReportReason; label: string }> = [
{ value: 'unangemessen', label: msg('Unangemessen') },
{ value: 'kartendaten', label: msg('Echte Kartendaten') },
{ value: 'urheberrecht', label: msg('Urheberrecht/Marke') },
{ value: 'spam', label: msg('Spam') },
{ value: 'sonstiges', label: msg('Sonstiges') },
];
/** Ein Logo hat keine Kartendaten: für Logos entfällt „Echte Kartendaten“ (Spec § 6). */
const LOGO_REASONS = REPORT_REASONS.filter((reason) => reason.value !== 'kartendaten');
export const REPORT_THANKS = msg('Danke für deine Meldung.');
/** Wortlaut aus Spec § 11. */
export const REPORT_DUPLICATE = msg('Danke, du hast diese Karte schon gemeldet.');
export const LOGO_REPORT_DUPLICATE = msg('Danke, du hast dieses Logo schon gemeldet.');
const NO_REASON = msg('Bitte wähle einen Grund.');
/** `reported`: neue Meldung angenommen; `duplicate`: dieser Melder hatte die Karte (das Logo) schon gemeldet; `cancelled`: nichts gesendet oder Ausgang unbekannt. */
export type ReportOutcome = 'reported' | 'duplicate' | 'cancelled';
/** Was gemeldet wird: eine Karte (Standard) oder ein Logo. */
export type ReportSubject = 'card' | 'logo';
export interface ReportDialogOptions {
/** Sendet die Meldung (`reportCard` bzw. `reportLogo` der API). */
send: (reason: ReportReason) => Promise<{ schonGemeldet: boolean }>;
/** Titel der Karte für die Überschrift des Textes (als Text); für Logos ohne Wirkung (dort heißt es „dieses Logo“). */
cardTitle?: string;
/** Schließt den Dialog, z. B. beim Verlassen der Seite; eine danach eintreffende Antwort wird verworfen. */
signal?: AbortSignal;
/** Karte (Standard) oder Logo: Überschrift, Einleitung, Dank bei Duplikat und die Gründe (ohne „Echte Kartendaten“) folgen daraus. */
subject?: ReportSubject;
}
/**
* Öffnet den Dialog. Löst auf, sobald er geschlossen ist: mit dem Ergebnis der letzten Meldung, sonst mit `cancelled`.
* Fehler des Dienstes erscheinen im Dialog in der Sprache der Seite (`apiErrorText`: der Text zum Schlüssel des Fehlers, nicht der
* Servertext); der Dialog bleibt dann offen für einen neuen Versuch.
*/
export function reportDialog(opts: ReportDialogOptions): Promise<ReportOutcome> {
return new Promise<ReportOutcome>((resolve, reject) => {
if (opts.signal?.aborted) {
resolve('cancelled');
return;
}
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const logo = opts.subject === 'logo';
const choices = logo ? LOGO_REASONS : REPORT_REASONS;
let outcome: ReportOutcome = 'cancelled';
let closed = false;
let busy = false;
const radios = choices.map(({ value }) => el('input', { type: 'radio', name: 'reason', value }));
const reasons = el(
'fieldset',
{ class: 'report-reasons' },
el('legend', { class: 'report-legend' }, t('Grund der Meldung')),
...choices.map(({ label }, i) => el('label', { class: 'report-reason' }, radios[i], t(label))),
);
const intro = el(
'p',
{ class: 'report-text' },
logo
? t('Warum möchtest du dieses Logo melden?')
: opts.cardTitle
? t('Warum möchtest du „{title}“ melden?', { title: opts.cardTitle })
: t('Warum möchtest du diese Karte melden?'),
);
const errorEl = el('p', { class: 'auth-msg auth-msg-error', role: 'alert' });
const statusEl = el('p', { class: 'auth-msg auth-msg-ok', role: 'status' });
const cancel = el('button', { type: 'button', class: 'auth-btn' }, t('Abbrechen'));
const send = el('button', { type: 'submit', class: 'auth-btn auth-btn-primary' }, t('Melden'));
const actions = el('div', { class: 'report-actions' }, cancel, send);
const form = el('form', { class: 'report-form' }, intro, reasons, errorEl, statusEl, actions);
const dialog = el(
'dialog',
{ class: 'report-dialog', 'aria-labelledby': 'report-title' },
el(
'div',
{ class: 'report-card' },
el('h2', { id: 'report-title', class: 'report-title' }, logo ? t('Logo melden') : t('Karte melden')),
form,
),
);
const finish = () => {
if (closed) return;
closed = true;
opts.signal?.removeEventListener('abort', finish);
if (dialog.open) dialog.close();
dialog.remove();
if (opener?.isConnected) opener.focus();
resolve(outcome);
};
function setBusy(value: boolean): void {
busy = value;
form.setAttribute('aria-busy', String(value));
for (const radio of radios) radio.disabled = value;
send.disabled = value;
}
function showResult(duplicate: boolean): void {
outcome = duplicate ? 'duplicate' : 'reported';
errorEl.textContent = '';
statusEl.textContent = t(duplicate ? (logo ? LOGO_REPORT_DUPLICATE : REPORT_DUPLICATE) : REPORT_THANKS);
intro.remove();
reasons.remove();
const close = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Schließen'));
close.addEventListener('click', finish);
actions.replaceChildren(close);
close.focus();
}
async function submit(): Promise<void> {
const chosen = choices.find((_, i) => radios[i].checked);
if (!chosen) {
statusEl.textContent = '';
errorEl.textContent = t(NO_REASON);
return;
}
setBusy(true);
errorEl.textContent = '';
statusEl.textContent = '';
try {
const res = await opts.send(chosen.value);
if (closed) return;
showResult(res.schonGemeldet);
} catch (err) {
if (closed) return;
// Texte des Dienstes (auch für 429) kommen über ihren Schlüssel in der Sprache der Seite; alles andere (Netz, JWT) über errorMessage.
errorEl.textContent = err instanceof ApiError ? apiErrorText(err) : errorMessage(err, 'load');
setBusy(false);
}
}
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy || outcome !== 'cancelled') return;
void submit();
});
cancel.addEventListener('click', finish);
// Esc schließt nativ und löst `close` aus.
dialog.addEventListener('close', finish);
// Hintergrund = Klick auf das <dialog> selbst (die Karte füllt es ganz aus); Drücken und Loslassen müssen beide dort sein,
// sonst schließt ein Textmarkieren, das außerhalb endet, den Dialog.
let pressedOnBackdrop = false;
dialog.addEventListener('mousedown', (event) => {
pressedOnBackdrop = event.target === dialog;
});
dialog.addEventListener('click', (event) => {
if (event.target === dialog && pressedOnBackdrop) finish();
pressedOnBackdrop = false;
});
opts.signal?.addEventListener('abort', finish);
document.body.append(dialog);
try {
dialog.showModal();
} catch (err) {
closed = true;
opts.signal?.removeEventListener('abort', finish);
dialog.remove();
reject(err);
}
});
}
+10
View File
@@ -0,0 +1,10 @@
// Konfiguration der Website.
/**
* Öffentlicher Spenden-Link (Buy Me a Coffee, `https://buymeacoffee.com/<name>`).
* Solange er leer ist, werden Spenden-Banner und -Knopf nicht angezeigt.
*/
export const DONATE_URL: string = 'https://buymeacoffee.com/aircard';
/** Original-Repo der App AirCard-iOS auf GitHub; Ziel des GitHub-Links in der Kopfzeile. */
export const GITHUB_URL = 'https://github.com/Mak5er/AirCard-iOS';
+5 -4
View File
@@ -67,7 +67,7 @@ beforeEach(() => {
describe('bindEditorToAuth', () => {
it('Abmelden: der Editor wird ganz geleert und ?karte= fällt aus der Adresse', () => {
history.replaceState(null, '', '/?karte=k1');
history.replaceState(null, '', '/gestalten?karte=k1');
const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth);
design();
@@ -76,7 +76,7 @@ describe('bindEditorToAuth', () => {
expect(store.get().image).toBeNull();
expect(store.get().saved).toBeNull();
expect(location.search).toBe('');
expect(location.pathname).toBe('/');
expect(location.pathname).toBe('/gestalten');
});
it('Abmelden: der Anfangszustand besteht aus frischen Objekten (nichts geteilt mit dem alten)', () => {
@@ -90,7 +90,7 @@ describe('bindEditorToAuth', () => {
});
it('Kontowechsel A → B: Entwurf bleibt, aber ohne Karte und als „Bild noch nicht gespeichert“', () => {
history.replaceState(null, '', '/?karte=k1');
history.replaceState(null, '', '/gestalten?karte=k1');
const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth);
const { blob, config } = design();
@@ -172,10 +172,11 @@ describe('bindEditorToAuth', () => {
});
it('lässt andere Teile der Adresse stehen', () => {
history.replaceState(null, '', '/?karte=k1&x=1#h');
history.replaceState(null, '', '/gestalten?karte=k1&x=1#h');
const fake = fakeAuth('a');
bindEditorToAuth(store, fake.auth);
fake.logout();
expect(location.pathname).toBe('/gestalten');
expect(location.search).toBe('?x=1');
expect(location.hash).toBe('#h');
});
+16 -5
View File
@@ -1,9 +1,20 @@
// Die Adresse des Editors kann eine Karte nennen (`/?karte=<id>`).
// Die Adresse des Editors kann eine Karte (`/gestalten?karte=<id>`; alte Links `/?karte=<id>` leitet main.ts um)
// oder eine Community-Vorlage (`/gestalten?vorlage=<galleryId>`) nennen.
/** Entfernt `karte` aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen nicht dieselbe Karte öffnet. */
export function dropCardParam(): void {
/** Entfernt den Parameter aus der Adresse (ersetzt den Verlaufseintrag), damit ein Neuladen oder „Zurück“ nicht erneut lädt. */
function dropParam(name: string): void {
const url = new URL(location.href);
if (!url.searchParams.has('karte')) return;
url.searchParams.delete('karte');
if (!url.searchParams.has(name)) return;
url.searchParams.delete(name);
history.replaceState(null, '', url.pathname + url.search + url.hash);
}
/** Entfernt `karte` aus der Adresse. */
export function dropCardParam(): void {
dropParam('karte');
}
/** Entfernt `vorlage` aus der Adresse. */
export function dropTemplateParam(): void {
dropParam('vorlage');
}
+2 -2
View File
@@ -7,11 +7,11 @@
border-radius: 20px;
background: var(--panel);
color: var(--text);
box-shadow: 0 24px 80px rgb(0 0 0 / 0.55);
box-shadow: var(--shadow-lg);
}
.confirm-dialog::backdrop {
background: rgb(6 11 20 / 0.72);
background: var(--backdrop);
}
.confirm-card {
+2 -1
View File
@@ -1,6 +1,7 @@
// Bestätigungsdialog als eigenes <dialog> (kein window.confirm): Löschen einer Karte, Verwerfen von Änderungen.
import '../account/account.css';
import './confirm-dialog.css';
import { t } from '../i18n/i18n';
import { el } from './dom';
export interface ConfirmOptions {
@@ -20,7 +21,7 @@ export function confirmDialog(options: ConfirmOptions, signal?: AbortSignal): Pr
resolve(false);
return;
}
const cancel = el('button', { type: 'button', class: 'auth-btn' }, options.cancelLabel ?? 'Abbrechen');
const cancel = el('button', { type: 'button', class: 'auth-btn' }, options.cancelLabel ?? t('Abbrechen'));
const confirm = el('button', { type: 'button', class: 'auth-btn auth-btn-danger' }, options.confirmLabel);
const dialog = el(
'dialog',
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { el, setFieldInvalid } from './dom';
describe('setFieldInvalid', () => {
const field = (describedBy?: string) => el('input', { 'aria-describedby': describedBy });
it('markiert das Feld und verweist auf die Fehlerzeile', () => {
const input = field();
setFieldInvalid(input, 'fehler', true);
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(input.getAttribute('aria-describedby')).toBe('fehler');
});
it('der Hinweis des Feldes bleibt davor stehen', () => {
const input = field('hinweis');
setFieldInvalid(input, 'fehler', true);
expect(input.getAttribute('aria-describedby')).toBe('hinweis fehler');
});
it('mehrfach markiert verweist es nur einmal auf die Fehlerzeile', () => {
const input = field('a b');
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', true);
expect(input.getAttribute('aria-describedby')).toBe('a b fehler');
});
it('zurücknehmen entfernt Markierung und Verweis, die Hinweise bleiben', () => {
const input = field('hinweis');
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', false);
expect(input.hasAttribute('aria-invalid')).toBe(false);
expect(input.getAttribute('aria-describedby')).toBe('hinweis');
});
it('ohne Hinweis verschwindet aria-describedby ganz', () => {
const input = field();
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', false);
expect(input.hasAttribute('aria-describedby')).toBe(false);
expect(input.hasAttribute('aria-invalid')).toBe(false);
});
it('ein Feld, das nie markiert war, bleibt beim Zurücknehmen unberührt', () => {
const plain = field();
setFieldInvalid(plain, 'fehler', false);
expect(plain.getAttributeNames()).toEqual([]);
const withHint = field('hinweis');
setFieldInvalid(withHint, 'fehler', false);
expect(withHint.getAttribute('aria-describedby')).toBe('hinweis');
});
it('eine andere Fehlerzeile bleibt, wo sie ist (nur die genannte ID wird verwaltet)', () => {
const input = field('hinweis anderer');
setFieldInvalid(input, 'fehler', true);
setFieldInvalid(input, 'fehler', false);
expect(input.getAttribute('aria-describedby')).toBe('hinweis anderer');
});
});
+30
View File
@@ -17,6 +17,23 @@ export function el<K extends keyof HTMLElementTagNameMap>(
return node;
}
/**
* Markiert ein Formularfeld als ungültig (`aria-invalid`) und verknüpft es mit der Fehlerzeile `errorId` (`aria-describedby`, hinter einem
* Hinweis, den das Feld schon hat): ein Screenreader liest den Fehler dann mit dem Feld vor. Mit `invalid = false` fallen beide wieder
* weg, der Hinweis bleibt.
*/
export function setFieldInvalid(control: HTMLElement, errorId: string, invalid: boolean): void {
const described = (control.getAttribute('aria-describedby') ?? '').split(/\s+/).filter((id) => id !== '' && id !== errorId);
if (invalid) {
control.setAttribute('aria-invalid', 'true');
described.push(errorId);
} else {
control.removeAttribute('aria-invalid');
}
if (described.length > 0) control.setAttribute('aria-describedby', described.join(' '));
else control.removeAttribute('aria-describedby');
}
// Feste Symbole (Strichstärke aus dem Stylesheet); die Zeichenketten sind Konstanten, keine Nutzereingaben.
const ICONS = {
imagePlus:
@@ -25,6 +42,19 @@ const ICONS = {
lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
ban: '<circle cx="12" cy="12" r="9"/><path d="m5.6 5.6 12.8 12.8"/>',
sun:
'<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/>' +
'<path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/>' +
'<path d="m19.07 4.93-1.41 1.41"/>',
moon: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
github:
'<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>' +
'<path d="M9 18c-4.51 2-5-2-7-2"/>',
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
} as const;
export type IconName = keyof typeof ICONS;
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more