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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:07:28 +00:00
1 parent ec0b51a949
commit 6d152c11b3
4 files changed
+198 -75

No files matched your search

+3 -3
View File
@@ -39,7 +39,7 @@ Alle Antworten sind JSON; Fehler haben die Form `{"error": "<deutscher Text>", "
| `POST /api/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
| `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) |
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
| `POST /api/logos/hochladen` `{name, displayName?, mono?, fileId}` | Nutzer-JWT | `201 {id}` (= Datei-ID) · `401` · `400` Validierung, Datei fehlt („Bild nicht gefunden.“), kein PNG („Das Logo muss eine PNG-Datei sein.“), größer als 1 MiB („Das Logo ist größer als 1 MB.“), Maße außerhalb 8 × 8 bis 720 × 250 („Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).“) · `403` Datei nicht meine / falscher Bucket („Diese Datei gehört dir nicht.“), Datei gesperrt („Diese Bilder sind bereits veröffentlicht oder gesperrt.“) · `409` „Diese Datei wird schon für ein Logo verwendet.“ · `429` „Du hast schon 30 Logos hochgeladen. Lösche eins, um ein neues hochzuladen.“ |
| `POST /api/logos/hochladen` `{name, displayName?, mono?, fileId}` | Nutzer-JWT | `201 {id}` (Zeilen-ID des Logos, ein Hash aus Konto und Datei, nicht die Datei-ID) · `401` · `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 |
@@ -70,8 +70,8 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel
- **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 und `displayName` ≤ 30 Zeichen (getrimmt; leerer Anzeigename = „Anonym“; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. Die Zeilen-ID des Logos ist die Datei-ID: so kann eine Datei nie zwei Logos tragen.
- **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`) → 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`) → Besitz (`403`: kein `delete("user:<ich>")` oder kein `read("any")`) → Typ und Größe (`400`) → Maße (`400`) → 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>` im Log). **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>`. 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.
- **Logo-Felder** (`/api/logos/hochladen`): `name` 1–40 und `displayName` ≤ 30 Zeichen (getrimmt; leerer Anzeigename = „Anonym“; dieselben verbotenen Zeichen wie bei den Karten-Texten), `mono` fehlt = `false`, sonst muss es ein Boolean sein (`badMono`), `fileId` nach dem ID-Muster. **Zeilen-ID des Logos** = `sha256(<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`) → 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`) → 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). **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.
- **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>`). 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) entfernt Datei, Herzen, Meldungen und zuletzt die Zeile; Herzen und Meldungen in Runden zu 100 (`equal logoId`, höchstens 50 Runden). 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).