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/zurueckziehen` `{id}` | Nutzer-JWT (Autor oder Admin) | `200 {ok:true}` · `401` · `400` · `403` fremd · `404` fehlt |
| `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) | | `POST /api/melden` `{id, reason}` | keine | `200 {ok:true}` oder `200 {schonGemeldet:true}` · `400` Grund oder ID · `404` fehlt oder ausgeblendet · `429` „Zu viele Meldungen – bitte später nochmal.“ (mit `Retry-After`) |
| `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` | | `POST /api/admin/sichtbarkeit` `{id, sichtbar}` | Admin-JWT | `200 {ok:true}` · `401` · `403` kein Admin · `400` · `404` |
| `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/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/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/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). - **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)`. - **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.“ - **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-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`) → 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-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. - **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 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). - **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).
+33 -19
View File
@@ -8,7 +8,7 @@
* POST /api/logos/hochladen Nutzer-JWT {name, displayName?, mono?, fileId} * POST /api/logos/hochladen Nutzer-JWT {name, displayName?, mono?, fileId}
* 401 · 400 Validierung, Datei fehlt, kein PNG, größer als 1 MiB, Maße nicht 8 × 8 bis 720 × 250 · 403 Datei nicht * 401 · 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 * meine / falscher Bucket / Datei gesperrt (kein read("any")) · 409 Datei schon für ein Logo verwendet · 429 schon
* 30 Logos · 201 {id} (id = Datei-ID = Zeilen-ID) * 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 * 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. * 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} * POST /api/logos/herz Nutzer-JWT {id, herz}
@@ -20,9 +20,9 @@
* POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar} * POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true} * 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
* *
* Tabellen `logos` (Zeilen-ID = Datei-ID, damit eine Datei nie zwei Logos tragen kann), `logo_likes`, `logo_reports`, * Tabellen `logos` (Zeilen-ID = hash32(`<Konto>|<Datei>`), siehe logoRowId; Altlogos haben noch Zeilen-ID = Datei-ID), `logo_likes`,
* Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows), nie hoch- * `logo_reports`, Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows),
* oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo. * nie hoch- oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo.
* Logs nennen nur Logo-IDs und Zahlen, nie Namen, IPs, Hashes oder JWTs; Herzen werden nicht geloggt. * Logs nennen nur Logo-IDs und Zahlen, nie Namen, IPs, Hashes oder JWTs; Herzen werden nicht geloggt.
*/ */
@@ -76,6 +76,17 @@ const LOGO_TEXT_FIELDS = [
// ---- Reine Helfer ---------------------------------------------------------------------------------- // ---- 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. */ /** Feste Zeilen-ID je Logo und Nutzer: ein Herz pro Konto, Appwrite-409 ersetzt jede Doppelt-Prüfung. */
function logoLikeId(logoId, userId) { function logoLikeId(logoId, userId) {
return hash32(`${logoId}|${userId}`); return hash32(`${logoId}|${userId}`);
@@ -200,17 +211,18 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
/** /**
* Das Logo ist nach dem Festlegen der Rechte durchgefallen (Inhalt ausgetauscht oder Datei weg): die Datei mit dem * 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 * 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>` im Log. * 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(id, problem) { async function removeRejectedLogo(rowId, fileId, problem) {
let clean = true; let clean = true;
await appwrite.deleteFile(BUCKET, id).catch(() => { await appwrite.deleteFile(BUCKET, fileId).catch(() => {
clean = false; clean = false;
}); });
await appwrite.deleteRow(TABLE_LOGOS, id).catch(() => { await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
clean = false; clean = false;
}); });
say(clean ? `logo ${id} removed after upload check (${problem.code})` : `rollback failed for logo ${id}`); say(clean ? `logo ${rowId} removed after upload check (${problem.code})` : `rollback failed for logo ${rowId} (file ${fileId})`);
} }
async function uploadLogo({ req, raw }) { async function uploadLogo({ req, raw }) {
@@ -221,14 +233,16 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
const file = await appwrite.getFile(BUCKET, data.fileId); const file = await appwrite.getFile(BUCKET, data.fileId);
checkFileExists(file, BUCKET); 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. // 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 () => { return withLock(`logo-owner:${user.id}`, async () => {
// Die Zeilen dieses Kontos (auch ausgeblendete), höchstens MAX_LOGOS: ab dort ist das Limit erreicht. // 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)]); 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); if (rows.length >= MAX_LOGOS) throw new HttpError(429, MSG.tooManyLogos);
// Zeilen-ID = Datei-ID: dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung). // Dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung): entweder unter dieser
if (rows.some((row) => row.$id === data.fileId)) throw new HttpError(409, MSG.logoTaken); // 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); checkFileOwnership(file, user.id);
checkLogoContent(file); checkLogoContent(file);
await checkLogoSize(data.fileId); await checkLogoSize(data.fileId);
@@ -236,7 +250,7 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
try { try {
await appwrite.createRow( await appwrite.createRow(
TABLE_LOGOS, TABLE_LOGOS,
data.fileId, rowId,
{ {
name: data.name, name: data.name,
displayName: data.displayName, displayName: data.displayName,
@@ -250,7 +264,7 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
visibleRowPermissions(), visibleRowPermissions(),
); );
} catch (err) { } catch (err) {
// Eine Zeile mit dieser ID gehört schon jemandem (z. B. der liegengebliebene Anker eines halb gelöschten Logos). // 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); if (isDuplicate(err)) throw new HttpError(409, MSG.logoTaken);
throw err; throw err;
} }
@@ -267,20 +281,20 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
problem = await logoSizeProblem(data.fileId); problem = await logoSizeProblem(data.fileId);
} catch (err) { } catch (err) {
let rolledBack = true; let rolledBack = true;
await appwrite.deleteRow(TABLE_LOGOS, data.fileId).catch(() => { await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
rolledBack = false; rolledBack = false;
say(`rollback failed for logo ${data.fileId}`); 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). // Nur wenn keine Zeile mehr auf die Datei zeigt, bekommt sie delete(owner) zurück (der Client räumt damit auf).
if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {}); if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {});
throw err; throw err;
} }
if (problem !== null) { if (problem !== null) {
await removeRejectedLogo(data.fileId, problem); await removeRejectedLogo(rowId, data.fileId, problem);
throw problem; throw problem;
} }
say(`logo ${data.fileId} uploaded`); say(`logo ${rowId} uploaded`);
return { status: 201, body: { id: data.fileId } }; return { status: 201, body: { id: rowId } };
}); });
} }
@@ -398,4 +412,4 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
]; ];
} }
module.exports = { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload }; module.exports = { createLogoRoutes, logoRowId, logoLikeId, logoReportId, validateLogoUpload };
+161 -52
View File
@@ -11,9 +11,10 @@ const http = require('node:http');
const { createAppwrite } = require('./appwrite.js'); const { createAppwrite } = require('./appwrite.js');
const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js'); const { startFakeAppwrite, pngHeader, webpHeader } = require('./fake-appwrite.js');
const { createApp } = require('./app.js'); const { createApp } = require('./app.js');
const { createLogoRoutes, logoLikeId, logoReportId, validateLogoUpload } = require('./logos.js'); const { createLogoRoutes, logoLikeId, logoReportId, logoRowId, validateLogoUpload } = require('./logos.js');
const { const {
MSG, MSG,
isId,
visibleRowPermissions, visibleRowPermissions,
hiddenRowPermissions, hiddenRowPermissions,
likeRowPermissions, likeRowPermissions,
@@ -115,10 +116,18 @@ function logoFile(fake, id, owner = ME, { width = 200, height = 100, ...rest } =
fake.addFile({ id, bucket: LOGO_BUCKET, owner, content: pngHeader(width, height), ...rest }); fake.addFile({ id, bucket: LOGO_BUCKET, owner, content: pngHeader(width, height), ...rest });
} }
/** Veröffentlichtes Logo: Zeile und Datei mit genau den Rechten, die die API vergibt. */ /** Zeilen-ID des Logos, das `owner` aus der Datei `fileId` anlegt (so vergibt sie die API). */
function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount = 0, likeCount = 0, name = 'Logo', mono = false } = {}) { const rowOf = (fileId, owner = ME) => logoRowId(owner, fileId);
/** Datei-ID, die `seedLogo` einer Logo-Zeile gibt: nie gleich der Zeilen-ID (nur Altlogos haben Zeilen-ID = Datei-ID). */
const fileOf = (id) => `file-of-${id}`;
/**
* Veröffentlichtes Logo: Zeile und Datei mit genau den Rechten, die die API vergibt. Die Datei hat die ID `fileId`
* (Standard `fileOf(id)`); ein Altlogo mit Zeilen-ID = Datei-ID legt der Test mit `fileId: id` an.
*/
function seedLogo(fake, { id = 'logo-1', fileId = fileOf(id), owner = ME, hidden = false, reportCount = 0, likeCount = 0, name = 'Logo', mono = false } = {}) {
fake.addFile({ fake.addFile({
id, id: fileId,
bucket: LOGO_BUCKET, bucket: LOGO_BUCKET,
owner, owner,
permissions: hidden ? hiddenFilePermissions(owner) : visibleFilePermissions(), permissions: hidden ? hiddenFilePermissions(owner) : visibleFilePermissions(),
@@ -127,7 +136,7 @@ function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount
fake.addRow( fake.addRow(
'logos', 'logos',
id, id,
{ name, displayName: '', fileId: id, ownerId: owner, mono, likeCount, hidden, reportCount }, { name, displayName: '', fileId, ownerId: owner, mono, likeCount, hidden, reportCount },
hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(), hidden ? hiddenRowPermissions(owner) : visibleRowPermissions(),
); );
} }
@@ -136,7 +145,7 @@ function seedLogo(fake, { id = 'logo-1', owner = ME, hidden = false, reportCount
function seedLogoRows(fake, owner, count) { function seedLogoRows(fake, owner, count) {
for (let i = 0; i < count; i += 1) { for (let i = 0; i < count; i += 1) {
const id = `${owner}-l${i}`; const id = `${owner}-l${i}`;
fake.addRow('logos', id, { name: `L${i}`, displayName: '', fileId: id, ownerId: owner, mono: false, likeCount: 0, hidden: i % 2 === 1, reportCount: 0 }, visibleRowPermissions()); fake.addRow('logos', id, { name: `L${i}`, displayName: '', fileId: fileOf(id), ownerId: owner, mono: false, likeCount: 0, hidden: i % 2 === 1, reportCount: 0 }, visibleRowPermissions());
} }
} }
@@ -153,13 +162,14 @@ const nonAccountCalls = (fake) => fake.calls.filter((c) => c.path !== '/v1/accou
const filePuts = (fake, id) => fake.calls.filter((c) => c.method === 'PUT' && c.path.endsWith(`/files/${id}`)); const filePuts = (fake, id) => fake.calls.filter((c) => c.method === 'PUT' && c.path.endsWith(`/files/${id}`));
/** Die Aufrufe zu einem Logo in der Reihenfolge, in der sie ankamen (Datei abrufen, lesen, festlegen, löschen; Zeile anlegen, löschen). */ /** Die Aufrufe zu einem Logo in der Reihenfolge, in der sie ankamen (Datei abrufen, lesen, festlegen, löschen; Zeile anlegen, löschen). */
function logoSteps(fake, id) { function logoSteps(fake, fileId) {
const rowId = rowOf(fileId);
const steps = []; const steps = [];
for (const c of fake.calls) { for (const c of fake.calls) {
if (c.path.endsWith(`/files/${id}/view`)) steps.push('Anfang lesen'); if (c.path.endsWith(`/files/${fileId}/view`)) steps.push('Anfang lesen');
else if (c.path.endsWith(`/files/${id}`)) steps.push({ GET: 'Datei abrufen', PUT: 'Rechte festlegen', DELETE: 'Datei löschen' }[c.method]); else if (c.path.endsWith(`/files/${fileId}`)) steps.push({ GET: 'Datei abrufen', PUT: 'Rechte festlegen', DELETE: 'Datei löschen' }[c.method]);
else if (c.method === 'POST' && c.path.endsWith('/tables/logos/rows')) steps.push('Zeile anlegen'); else if (c.method === 'POST' && c.path.endsWith('/tables/logos/rows')) steps.push('Zeile anlegen');
else if (c.method === 'DELETE' && c.path.endsWith(`/tables/logos/rows/${id}`)) steps.push('Zeile löschen'); else if (c.method === 'DELETE' && c.path.endsWith(`/tables/logos/rows/${rowId}`)) steps.push('Zeile löschen');
} }
return steps; return steps;
} }
@@ -179,6 +189,16 @@ test('logoLikeId und logoReportId: erste 32 Hex von sha256, Logo-Meldungen mit d
assert.notEqual(logoReportId('l1', 'h1'), logoReportId('l1', 'h2')); assert.notEqual(logoReportId('l1', 'h1'), logoReportId('l1', 'h2'));
}); });
test('logoRowId: erste 32 Hex von sha256("<Konto>|<Datei>"), eine gültige ID, nie die Datei-ID, je Konto und Datei verschieden', () => {
assert.equal(logoRowId('u1', 'f1'), sha32('u1|f1'));
assert.match(logoRowId('u1', 'f1'), /^[0-9a-f]{32}$/);
assert.ok(isId(logoRowId('u1', 'f1')));
assert.notEqual(logoRowId('u1', 'f1'), 'f1');
assert.equal(logoRowId('u1', 'f1'), logoRowId('u1', 'f1'), 'dieselbe Datei desselben Kontos: dieselbe ID');
assert.notEqual(logoRowId('u1', 'f1'), logoRowId('u2', 'f1'), 'dieselbe Datei-ID bei einem anderen Konto: andere Zeilen-ID');
assert.notEqual(logoRowId('u1', 'f1'), logoRowId('u1', 'f2'));
});
test('likeRowPermissions: genau read("user:<id>"), unzulässige Nutzer-IDs werden abgelehnt', () => { test('likeRowPermissions: genau read("user:<id>"), unzulässige Nutzer-IDs werden abgelehnt', () => {
assert.deepEqual(likeRowPermissions('abc'), ['read("user:abc")']); assert.deepEqual(likeRowPermissions('abc'), ['read("user:abc")']);
for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) assert.throws(() => likeRowPermissions(bad)); for (const bad of ['a")', 'x","read("any', '', '../x', 5, null]) assert.throws(() => likeRowPermissions(bad));
@@ -234,15 +254,18 @@ test('createLogoRoutes liefert die fünf Routen mit Name, json und run', () => {
// ---- Hochladen -------------------------------------------------------------------------------------- // ---- Hochladen --------------------------------------------------------------------------------------
test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Datei-ID), die Datei trägt danach genau read("any")', async () => { test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Hash aus Konto und Datei, nicht die Datei-ID), die Datei trägt danach genau read("any")', async () => {
const { upload, fake, logs } = await setup(); const { upload, fake, logs } = await setup();
logoFile(fake, 'logo1'); logoFile(fake, 'logo1');
const res = await upload(uploadBody({ name: ' Mein Logo ', mono: true })); const res = await upload(uploadBody({ name: ' Mein Logo ', mono: true }));
const rowId = rowOf('logo1');
assert.equal(res.status, 201); assert.equal(res.status, 201);
assert.deepEqual(res.json, { id: 'logo1' }); assert.deepEqual(res.json, { id: rowId });
assert.match(rowId, /^[0-9a-f]{32}$/);
assert.equal(fake.row('logos', 'logo1'), null, 'unter der Datei-ID gibt es keine Zeile');
const row = fake.row('logos', 'logo1'); const row = fake.row('logos', rowId);
assert.deepEqual(row.$permissions, ['read("any")']); assert.deepEqual(row.$permissions, ['read("any")']);
assert.equal(row.name, 'Mein Logo'); assert.equal(row.name, 'Mein Logo');
assert.equal(row.displayName, 'Kenso'); assert.equal(row.displayName, 'Kenso');
@@ -260,7 +283,7 @@ test('Logo hochladen: legt eine sichtbare Zeile an (Zeilen-ID = Datei-ID), die D
assert.equal(c.headers['x-appwrite-key'], fake.key); assert.equal(c.headers['x-appwrite-key'], fake.key);
assert.equal(c.headers['x-appwrite-jwt'], undefined); assert.equal(c.headers['x-appwrite-jwt'], undefined);
} }
assert.equal(logs.filter((line) => line.endsWith('logo logo1 uploaded')).length, 1); assert.equal(logs.filter((line) => line.endsWith(`logo ${rowId} uploaded`)).length, 1);
}); });
test('Logo hochladen: Anzeigename ist optional, mono fehlt = false', async () => { test('Logo hochladen: Anzeigename ist optional, mono fehlt = false', async () => {
@@ -268,7 +291,7 @@ test('Logo hochladen: Anzeigename ist optional, mono fehlt = false', async () =>
logoFile(fake, 'logo1'); logoFile(fake, 'logo1');
const res = await upload({ name: 'Nur Name', fileId: 'logo1' }); const res = await upload({ name: 'Nur Name', fileId: 'logo1' });
assert.equal(res.status, 201); assert.equal(res.status, 201);
const row = fake.row('logos', 'logo1'); const row = fake.row('logos', rowOf('logo1'));
assert.equal(row.displayName, ''); assert.equal(row.displayName, '');
assert.equal(row.mono, false); assert.equal(row.mono, false);
}); });
@@ -392,18 +415,29 @@ test('Logo hochladen: dieselbe Datei zweimal ergibt 409 logoTaken, Datei und Zei
expectError(await upload(uploadBody({ name: 'Anderer Name' })), 409, 'logoTaken'); expectError(await upload(uploadBody({ name: 'Anderer Name' })), 409, 'logoTaken');
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']); assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
assert.equal(fake.rows('logos').length, 1); assert.equal(fake.rows('logos').length, 1);
assert.equal(fake.row('logos', 'logo1').name, 'Mein Logo'); assert.equal(fake.row('logos', rowOf('logo1')).name, 'Mein Logo');
// dieselbe ID als frisch hochgeladene Datei (z. B. nach Löschen und Neuanlegen): 409, die Rechte der Datei bleiben unangetastet // dieselbe Datei-ID als frisch hochgeladene Datei (z. B. nach Löschen und Neuanlegen): 409, die Rechte der Datei bleiben unangetastet
fake.addFile({ id: 'logo1', bucket: LOGO_BUCKET, owner: ME, permissions: [...UPLOAD_PERMISSIONS, 'update("user:user-me")'], content: pngHeader(50, 50) }); fake.addFile({ id: 'logo1', bucket: LOGO_BUCKET, owner: ME, permissions: [...UPLOAD_PERMISSIONS, 'update("user:user-me")'], content: pngHeader(50, 50) });
expectError(await upload(uploadBody()), 409, 'logoTaken'); expectError(await upload(uploadBody()), 409, 'logoTaken');
assert.deepEqual(fake.file('logo1').$permissions, [...UPLOAD_PERMISSIONS, 'update("user:user-me")']); assert.deepEqual(fake.file('logo1').$permissions, [...UPLOAD_PERMISSIONS, 'update("user:user-me")']);
assert.equal(filePuts(fake, 'logo1').length, 1, 'nur der erste, erfolgreiche Upload hat Rechte gesetzt'); assert.equal(filePuts(fake, 'logo1').length, 1, 'nur der erste, erfolgreiche Upload hat Rechte gesetzt');
}); });
test('Logo hochladen: eine Zeile mit dieser ID gehört schon jemand anderem (liegengebliebener Anker): 409 logoTaken, Datei unangetastet', async () => { test('Logo hochladen: ein anderes Konto bekommt eine schon verwendete Datei nicht (403), die Zeilen-ID allein hält nicht zwei Konten auseinander', async () => {
const { upload, fake } = await setup(); const { upload, fake } = await setup();
fake.addRow('logos', 'anker', { name: 'Alt', displayName: '', fileId: 'anker', ownerId: OTHER, mono: false, likeCount: 0, hidden: false, reportCount: 0 }, visibleRowPermissions()); logoFile(fake, 'logo1');
assert.equal((await upload(uploadBody())).status, 201);
// je Konto eine eigene Zeilen-ID: dass eine Datei nie zwei Logos trägt, sichert der Besitz (nach dem Anlegen kein delete(owner) mehr)
expectError(await upload(uploadBody(), JWT_OTHER), 403, 'foreignFile');
assert.equal(fake.rows('logos').length, 1);
assert.equal(fake.row('logos', rowOf('logo1', OTHER)), null);
});
test('Logo hochladen: steht unter der Zeilen-ID schon eine Zeile, die nicht in der Liste des Kontos ist (Rest): 409 logoTaken, Datei unangetastet', async () => {
const { upload, fake } = await setup();
fake.addRow('logos', rowOf('anker'), { name: 'Alt', displayName: '', fileId: 'andere-datei', ownerId: OTHER, mono: false, likeCount: 0, hidden: false, reportCount: 0 }, visibleRowPermissions());
logoFile(fake, 'anker'); logoFile(fake, 'anker');
expectError(await upload(uploadBody({ fileId: 'anker' })), 409, 'logoTaken'); expectError(await upload(uploadBody({ fileId: 'anker' })), 409, 'logoTaken');
assert.deepEqual(fake.file('anker').$permissions, UPLOAD_PERMISSIONS); assert.deepEqual(fake.file('anker').$permissions, UPLOAD_PERMISSIONS);
@@ -411,6 +445,23 @@ test('Logo hochladen: eine Zeile mit dieser ID gehört schon jemand anderem (lie
assert.equal(filePuts(fake, 'anker').length, 0, 'ein 409 fasst die Dateirechte nie an'); assert.equal(filePuts(fake, 'anker').length, 0, 'ein 409 fasst die Dateirechte nie an');
}); });
test('Logo hochladen: die ID eines gelöschten Logos lässt sich nicht mit einer Datei gleicher ID neu belegen', async () => {
const { upload, remove, fake } = await setup();
logoFile(fake, 'logo1');
const { id: victimRowId } = (await upload(uploadBody())).json;
assert.equal((await remove(victimRowId)).status, 200);
assert.equal(fake.row('logos', victimRowId), null);
// die Zeilen-ID ist öffentlich (Liste, gespeicherte Karten): jemand legt eine Datei mit genau dieser ID an und lädt sie hoch
logoFile(fake, victimRowId, OTHER);
const res = await upload(uploadBody({ fileId: victimRowId }), JWT_OTHER);
assert.equal(res.status, 201);
assert.notEqual(res.json.id, victimRowId, 'die alte Zeilen-ID bleibt unbelegt');
assert.equal(fake.row('logos', victimRowId), null);
assert.equal(fake.row('logos', res.json.id).fileId, victimRowId);
assert.equal(fake.row('logos', res.json.id).ownerId, OTHER);
});
test('Logo hochladen: höchstens 30 Logos je Konto (ausgeblendete zählen mit), das 31. ergibt 429 tooManyLogos', async () => { test('Logo hochladen: höchstens 30 Logos je Konto (ausgeblendete zählen mit), das 31. ergibt 429 tooManyLogos', async () => {
const { upload, fake } = await setup(); const { upload, fake } = await setup();
seedLogoRows(fake, ME, 30); seedLogoRows(fake, ME, 30);
@@ -481,7 +532,7 @@ test('Logo hochladen: scheitert auch das Zurücknehmen, bleibt die Zeile stehen,
assert.equal(filePuts(fake, 'logo1').length, 1, 'ohne erfolgreiches Zurücknehmen bekommt die Datei delete(owner) nicht zurück'); assert.equal(filePuts(fake, 'logo1').length, 1, 'ohne erfolgreiches Zurücknehmen bekommt die Datei delete(owner) nicht zurück');
const lines = logs.filter((line) => line.includes('rollback failed')); const lines = logs.filter((line) => line.includes('rollback failed'));
assert.equal(lines.length, 1); assert.equal(lines.length, 1);
assert.match(lines[0], /^\[aircard-api\] \S+ rollback failed for logo logo1$/); assert.match(lines[0], new RegExp(`^\\[aircard-api\\] \\S+ rollback failed for logo ${rowOf('logo1')} \\(file logo1\\)$`));
assert.ok(!logs.join('\n').includes(JWT_ME)); assert.ok(!logs.join('\n').includes(JWT_ME));
}); });
@@ -508,7 +559,7 @@ test('Logo hochladen: ein nach dem Festlegen anderer, aber weiterhin gültiger I
logoFile(fake, 'logo1'); logoFile(fake, 'logo1');
fake.swapContentAfterPermissions('logo1', pngHeader(720, 250)); fake.swapContentAfterPermissions('logo1', pngHeader(720, 250));
assert.equal((await upload(uploadBody())).status, 201); assert.equal((await upload(uploadBody())).status, 201);
assert.ok(fake.row('logos', 'logo1')); assert.ok(fake.row('logos', rowOf('logo1')));
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']); assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
}); });
@@ -530,7 +581,7 @@ test('Logo hochladen: wird die Datei zwischen Prüfung und Festlegen ausgetausch
'Zeile löschen', 'Zeile löschen',
]); ]);
// ein Ereignis im Log (nur ID und Code), nie „uploaded“ // ein Ereignis im Log (nur ID und Code), nie „uploaded“
assert.deepEqual(logEvents(logs, 'logo1'), ['logo logo1 removed after upload check (logoFormat)']); assert.deepEqual(logEvents(logs, rowOf('logo1')), [`logo ${rowOf('logo1')} removed after upload check (logoFormat)`]);
}); });
test('Logo hochladen: nach dem Festlegen zählt der Inhalt noch einmal (kein PNG-Kopf, zu klein, zu groß): 400 logoFormat, Datei und Zeile sind weg', async () => { test('Logo hochladen: nach dem Festlegen zählt der Inhalt noch einmal (kein PNG-Kopf, zu klein, zu groß): 400 logoFormat, Datei und Zeile sind weg', async () => {
@@ -550,7 +601,7 @@ test('Logo hochladen: nach dem Festlegen zählt der Inhalt noch einmal (kein PNG
logoFile(fake, id); logoFile(fake, id);
fake.swapContentAfterPermissions(id, content); fake.swapContentAfterPermissions(id, content);
expectError(await upload(uploadBody({ fileId: id })), 400, 'logoFormat', label); expectError(await upload(uploadBody({ fileId: id })), 400, 'logoFormat', label);
assert.equal(fake.row('logos', id), null, `${label}: keine Zeile`); assert.equal(fake.row('logos', rowOf(id)), null, `${label}: keine Zeile`);
assert.equal(fake.file(id), null, `${label}: Datei gelöscht`); assert.equal(fake.file(id), null, `${label}: Datei gelöscht`);
} }
assert.equal(fake.rows('logos').length, 0); assert.equal(fake.rows('logos').length, 0);
@@ -564,7 +615,7 @@ test('Logo hochladen: ist die Datei nach dem Festlegen nicht mehr lesbar, ist da
expectError(await upload(uploadBody()), 400, 'fileMissing'); expectError(await upload(uploadBody()), 400, 'fileMissing');
assert.equal(fake.rows('logos').length, 0); assert.equal(fake.rows('logos').length, 0);
assert.equal(fake.file('logo1'), null); assert.equal(fake.file('logo1'), null);
assert.deepEqual(logEvents(logs, 'logo1'), ['logo logo1 removed after upload check (fileMissing)']); assert.deepEqual(logEvents(logs, rowOf('logo1')), [`logo ${rowOf('logo1')} removed after upload check (fileMissing)`]);
}); });
test('Logo hochladen: Appwrite-Fehler beim zweiten Lesen ist 502, nichts bleibt veröffentlicht, die Datei bekommt delete(owner) zurück, die Wiederholung klappt', async () => { test('Logo hochladen: Appwrite-Fehler beim zweiten Lesen ist 502, nichts bleibt veröffentlicht, die Datei bekommt delete(owner) zurück, die Wiederholung klappt', async () => {
@@ -584,34 +635,35 @@ test('Logo hochladen: Appwrite-Fehler beim zweiten Lesen ist 502, nichts bleibt
'Zeile löschen', 'Zeile löschen',
'Rechte festlegen', 'Rechte festlegen',
]); ]);
assert.deepEqual(logEvents(logs, 'logo1'), [], 'ein Fehler ohne Urteil ist kein Ereignis'); assert.deepEqual(logEvents(logs, rowOf('logo1')), [], 'ein Fehler ohne Urteil ist kein Ereignis');
assert.equal((await upload(uploadBody())).status, 201); assert.equal((await upload(uploadBody())).status, 201);
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']); assert.deepEqual(fake.file('logo1').$permissions, ['read("any")']);
}); });
test('Logo hochladen: scheitert das Aufräumen nach einer Beanstandung, steht `rollback failed for logo <id>` im Log, die Antwort bleibt 400 logoFormat', async () => { test('Logo hochladen: scheitert das Aufräumen nach einer Beanstandung, steht `rollback failed for logo <id> (file <id>)` im Log, die Antwort bleibt 400 logoFormat', async () => {
const { upload, remove, fake, logs } = await setup(); const { upload, remove, fake, logs } = await setup();
// die Datei lässt sich nicht löschen: die Zeile wird trotzdem entfernt (sie macht das Logo für andere sichtbar) // die Datei lässt sich nicht löschen: die Zeile wird trotzdem entfernt (sie macht das Logo für andere sichtbar); die Zeile ist weg,
// darum nennt die Logzeile die Datei-ID, die sich aus der Zeilen-ID nicht ableiten lässt
logoFile(fake, 'logo1'); logoFile(fake, 'logo1');
fake.swapContentAfterPermissions('logo1', pngHeader(4000, 4000)); fake.swapContentAfterPermissions('logo1', pngHeader(4000, 4000));
fake.failNext((c) => c.method === 'DELETE' && c.path.endsWith('/files/logo1'), { status: 500 }); fake.failNext((c) => c.method === 'DELETE' && c.path.endsWith('/files/logo1'), { status: 500 });
expectError(await upload(uploadBody()), 400, 'logoFormat'); expectError(await upload(uploadBody()), 400, 'logoFormat');
assert.equal(fake.row('logos', 'logo1'), null); assert.equal(fake.row('logos', rowOf('logo1')), null);
assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'die Datei blieb liegen'); assert.deepEqual(fake.file('logo1').$permissions, ['read("any")'], 'die Datei blieb liegen');
assert.deepEqual(logEvents(logs, 'logo1'), ['rollback failed for logo logo1']); assert.deepEqual(logEvents(logs, rowOf('logo1')), [`rollback failed for logo ${rowOf('logo1')} (file logo1)`]);
// die Zeile lässt sich nicht löschen: die Datei ist weg, die Zeile bleibt als Anker und der Hochlader räumt sie über /api/logos/loeschen auf // die Zeile lässt sich nicht löschen: die Datei ist weg, die Zeile bleibt als Anker und der Hochlader räumt sie über /api/logos/loeschen auf
logoFile(fake, 'logo2'); logoFile(fake, 'logo2');
fake.swapContentAfterPermissions('logo2', pngHeader(4000, 4000)); fake.swapContentAfterPermissions('logo2', pngHeader(4000, 4000));
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/logo2'), { status: 500 }); fake.failNext((c) => c.method === 'DELETE' && c.path.includes(`/tables/logos/rows/${rowOf('logo2')}`), { status: 500 });
expectError(await upload(uploadBody({ fileId: 'logo2' })), 400, 'logoFormat'); expectError(await upload(uploadBody({ fileId: 'logo2' })), 400, 'logoFormat');
assert.equal(fake.file('logo2'), null); assert.equal(fake.file('logo2'), null);
assert.ok(fake.row('logos', 'logo2')); assert.ok(fake.row('logos', rowOf('logo2')));
assert.deepEqual(logEvents(logs, 'logo2'), ['rollback failed for logo logo2']); assert.deepEqual(logEvents(logs, rowOf('logo2')), [`rollback failed for logo ${rowOf('logo2')} (file logo2)`]);
assert.equal((await remove('logo2')).status, 200); assert.equal((await remove(rowOf('logo2'))).status, 200);
assert.equal(fake.row('logos', 'logo2'), null); assert.equal(fake.row('logos', rowOf('logo2')), null);
}); });
test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer dem Konto) nicht an', async () => { test('Logo hochladen: Validierung liefert 400 mit dem passenden Code und fasst Appwrite (außer dem Konto) nicht an', async () => {
@@ -821,7 +873,7 @@ test('Logo melden: drei verschiedene Netze blenden das Logo aus (Zeilen- und Dat
assert.deepEqual(res.json, { ok: true }); assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('logos', 'logo-1').reportCount, 2); assert.equal(fake.row('logos', 'logo-1').reportCount, 2);
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']);
res = await report('logo-1', '203.0.3.9', 'sonstiges'); res = await report('logo-1', '203.0.3.9', 'sonstiges');
assert.equal(res.status, 200); assert.equal(res.status, 200);
@@ -830,7 +882,7 @@ test('Logo melden: drei verschiedene Netze blenden das Logo aus (Zeilen- und Dat
assert.equal(row.hidden, true); assert.equal(row.hidden, true);
assert.equal(row.reportCount, 3); assert.equal(row.reportCount, 3);
assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS); assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS);
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
assert.equal(fake.rows('logo_reports').length, 3); assert.equal(fake.rows('logo_reports').length, 3);
// ausgeblendetes Logo melden: 404 // ausgeblendetes Logo melden: 404
@@ -964,7 +1016,7 @@ test('Logo melden: eine doppelte Meldung heilt einen früher steckengebliebenen
assert.deepEqual(res.json, { schonGemeldet: true }); assert.deepEqual(res.json, { schonGemeldet: true });
assert.equal(fake.row('logos', 'logo-1').hidden, true); assert.equal(fake.row('logos', 'logo-1').hidden, true);
assert.equal(fake.row('logos', 'logo-1').reportCount, 3); assert.equal(fake.row('logos', 'logo-1').reportCount, 3);
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
}); });
test('Logo melden: Ausfall beim Anlegen der Meldung ist 502 und zählt nicht, Ausfall beim Ausblenden endet mit 502 und der nächste Versuch blendet aus', async () => { test('Logo melden: Ausfall beim Anlegen der Meldung ist 502 und zählt nicht, Ausfall beim Ausblenden endet mit 502 und der nächste Versuch blendet aus', async () => {
@@ -977,13 +1029,13 @@ test('Logo melden: Ausfall beim Anlegen der Meldung ist 502 und zählt nicht, Au
await report('logo-1', '203.0.1.9'); await report('logo-1', '203.0.1.9');
await report('logo-1', '203.0.2.9'); await report('logo-1', '203.0.2.9');
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo-1'), { status: 500 }); fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 });
expectError(await report('logo-1', '203.0.3.9'), 502, 'unavailable'); expectError(await report('logo-1', '203.0.3.9'), 502, 'unavailable');
assert.equal(fake.row('logos', 'logo-1').hidden, false, 'Dateien zuerst, Zeile zuletzt: die Zeile ist noch sichtbar'); assert.equal(fake.row('logos', 'logo-1').hidden, false, 'Dateien zuerst, Zeile zuletzt: die Zeile ist noch sichtbar');
const retry = await report('logo-1', '203.0.3.9'); const retry = await report('logo-1', '203.0.3.9');
assert.equal(retry.status, 200); assert.equal(retry.status, 200);
assert.equal(fake.row('logos', 'logo-1').hidden, true); assert.equal(fake.row('logos', 'logo-1').hidden, true);
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
}); });
// ---- Löschen ---------------------------------------------------------------------------------------- // ---- Löschen ----------------------------------------------------------------------------------------
@@ -1001,16 +1053,16 @@ test('Logo löschen durch den Hochlader: Datei, Herzen, Meldungen und Zeile sind
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.deepEqual(res.json, { ok: true }); assert.deepEqual(res.json, { ok: true });
assert.equal(fake.row('logos', 'logo-1'), null); assert.equal(fake.row('logos', 'logo-1'), null);
assert.equal(fake.file('logo-1'), null); assert.equal(fake.file(fileOf('logo-1')), null);
assert.deepEqual(fake.rows('logo_likes').map((r) => r.logoId), ['logo-2']); assert.deepEqual(fake.rows('logo_likes').map((r) => r.logoId), ['logo-2']);
assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']); assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']);
assert.ok(fake.row('logos', 'logo-2')); assert.ok(fake.row('logos', 'logo-2'));
assert.ok(fake.file('logo-2')); assert.ok(fake.file(fileOf('logo-2')));
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted')).length, 1); assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted')).length, 1);
// Reihenfolge: Datei, Herzen, Meldungen, zuletzt die Zeile // Reihenfolge: Datei, Herzen, Meldungen, zuletzt die Zeile
const order = fake.calls.filter((c) => c.method === 'DELETE').map((c) => c.path.split('/').slice(-3).join('/')); const order = fake.calls.filter((c) => c.method === 'DELETE').map((c) => c.path.split('/').slice(-3).join('/'));
assert.ok(order[0].endsWith('files/logo-1'), order[0]); assert.ok(order[0].endsWith(`files/${fileOf('logo-1')}`), order[0]);
assert.ok(order[order.length - 1].endsWith('logos/rows/logo-1'), order[order.length - 1]); assert.ok(order[order.length - 1].endsWith('logos/rows/logo-1'), order[order.length - 1]);
}); });
@@ -1041,7 +1093,7 @@ test('Logo löschen durch Admin geht, auch bei ausgeblendetem Logo', async () =>
const res = await remove('logo-1', JWT_ADMIN); const res = await remove('logo-1', JWT_ADMIN);
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.equal(fake.row('logos', 'logo-1'), null); assert.equal(fake.row('logos', 'logo-1'), null);
assert.equal(fake.file('logo-1'), null); assert.equal(fake.file(fileOf('logo-1')), null);
assert.equal(fake.rows('logo_reports').length, 0); assert.equal(fake.rows('logo_reports').length, 0);
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted by admin')).length, 1); assert.equal(logs.filter((line) => line.endsWith('logo logo-1 deleted by admin')).length, 1);
}); });
@@ -1052,7 +1104,7 @@ test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async ()
seedLikes(fake, 'logo-1', ['u1']); seedLikes(fake, 'logo-1', ['u1']);
expectError(await remove('logo-1', JWT_OTHER), 403, 'forbidden'); expectError(await remove('logo-1', JWT_OTHER), 403, 'forbidden');
assert.ok(fake.row('logos', 'logo-1')); assert.ok(fake.row('logos', 'logo-1'));
assert.ok(fake.file('logo-1')); assert.ok(fake.file(fileOf('logo-1')));
assert.equal(fake.rows('logo_likes').length, 1); assert.equal(fake.rows('logo_likes').length, 1);
assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0); assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0);
}); });
@@ -1074,7 +1126,7 @@ test('Logo löschen ist wiederholbar, wenn die Zeile zuletzt scheitert (die Zeil
fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/'), { status: 500 }); fake.failNext((c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/'), { status: 500 });
expectError(await remove('logo-1'), 502, 'unavailable'); expectError(await remove('logo-1'), 502, 'unavailable');
assert.ok(fake.row('logos', 'logo-1'), 'Zeile bleibt, bis alles andere weg ist'); assert.ok(fake.row('logos', 'logo-1'), 'Zeile bleibt, bis alles andere weg ist');
assert.equal(fake.file('logo-1'), null); assert.equal(fake.file(fileOf('logo-1')), null);
assert.equal(fake.rows('logo_likes').length, 0); assert.equal(fake.rows('logo_likes').length, 0);
const retry = await remove('logo-1'); const retry = await remove('logo-1');
@@ -1092,7 +1144,7 @@ test('Logo-Sichtbarkeit: Nicht-Admin 403 adminOnly, ohne JWT 401, nichts ändert
expectError(await setVisible('logo-1', false, null), 401, 'unauthorized'); expectError(await setVisible('logo-1', false, null), 401, 'unauthorized');
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
assert.equal(fake.row('logos', 'logo-1').hidden, false); assert.equal(fake.row('logos', 'logo-1').hidden, false);
assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']);
}); });
test('Admin blendet ein Logo aus: Zeilen- und Dateirechte, hidden=true, Meldungen und Herzen bleiben', async () => { test('Admin blendet ein Logo aus: Zeilen- und Dateirechte, hidden=true, Meldungen und Herzen bleiben', async () => {
@@ -1109,7 +1161,7 @@ test('Admin blendet ein Logo aus: Zeilen- und Dateirechte, hidden=true, Meldunge
assert.equal(row.reportCount, 1); assert.equal(row.reportCount, 1);
assert.equal(row.likeCount, 2); assert.equal(row.likeCount, 2);
assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS); assert.deepEqual(row.$permissions, HIDDEN_PERMISSIONS);
assert.deepEqual(fake.file('logo-1').$permissions, HIDDEN_PERMISSIONS); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, HIDDEN_PERMISSIONS);
assert.equal(fake.rows('logo_reports').length, 1); assert.equal(fake.rows('logo_reports').length, 1);
assert.equal(fake.rows('logo_likes').length, 2); assert.equal(fake.rows('logo_likes').length, 2);
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 hidden by admin')).length, 1); assert.equal(logs.filter((line) => line.endsWith('logo logo-1 hidden by admin')).length, 1);
@@ -1130,7 +1182,7 @@ test('Admin blendet ein Logo ein: sichtbare Rechte, hidden=false, reportCount=0,
assert.equal(row.reportCount, 0); assert.equal(row.reportCount, 0);
assert.equal(row.likeCount, 2); assert.equal(row.likeCount, 2);
assert.deepEqual(row.$permissions, ['read("any")']); assert.deepEqual(row.$permissions, ['read("any")']);
assert.deepEqual(fake.file('logo-1').$permissions, ['read("any")']); assert.deepEqual(fake.file(fileOf('logo-1')).$permissions, ['read("any")']);
assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']); assert.deepEqual(fake.rows('logo_reports').map((r) => r.logoId), ['logo-2']);
assert.equal(fake.rows('logo_likes').length, 2); assert.equal(fake.rows('logo_likes').length, 2);
assert.equal(logs.filter((line) => line.endsWith('logo logo-1 made visible by admin')).length, 1); assert.equal(logs.filter((line) => line.endsWith('logo logo-1 made visible by admin')).length, 1);
@@ -1152,7 +1204,7 @@ test('Logo-Sichtbarkeit: Body-Prüfung und fehlendes Logo', async () => {
test('Logo-Sichtbarkeit: scheitert das Setzen der Dateirechte, bleibt die Zeile unverändert (Dateien zuerst, Zeile zuletzt)', async () => { test('Logo-Sichtbarkeit: scheitert das Setzen der Dateirechte, bleibt die Zeile unverändert (Dateien zuerst, Zeile zuletzt)', async () => {
const { setVisible, fake } = await setup(); const { setVisible, fake } = await setup();
seedLogo(fake); seedLogo(fake);
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith('/files/logo-1'), { status: 500 }); fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 });
expectError(await setVisible('logo-1', false), 502, 'unavailable'); expectError(await setVisible('logo-1', false), 502, 'unavailable');
assert.equal(fake.row('logos', 'logo-1').hidden, false); assert.equal(fake.row('logos', 'logo-1').hidden, false);
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']); assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
@@ -1160,6 +1212,63 @@ test('Logo-Sichtbarkeit: scheitert das Setzen der Dateirechte, bleibt die Zeile
assert.equal(fake.row('logos', 'logo-1').hidden, true); assert.equal(fake.row('logos', 'logo-1').hidden, true);
}); });
// ---- Altlogos: Zeilen-ID = Datei-ID (vor dem Hash-Schema angelegt) -------------------------------------------
test('Altlogo (Zeilen-ID = Datei-ID): Herz, Meldung, Ausblenden und Einblenden gehen weiter über die Zeilen-ID, die Datei über fileId', async () => {
const { heart, report, setVisible, fake } = await setup();
seedLogo(fake, { id: 'alt1', fileId: 'alt1' });
assert.deepEqual((await heart('alt1', true)).json, { herz: true, likeCount: 1 });
assert.deepEqual((await heart('alt1', true, JWT_OTHER)).json, { herz: true, likeCount: 2 });
assert.deepEqual((await heart('alt1', false)).json, { herz: false, likeCount: 1 });
assert.equal(fake.row('logos', 'alt1').likeCount, 1);
assert.deepEqual((await report('alt1', '203.0.1.9')).json, { ok: true });
assert.equal(fake.row('logos', 'alt1').reportCount, 1);
assert.equal((await setVisible('alt1', false)).status, 200);
assert.equal(fake.row('logos', 'alt1').hidden, true);
assert.deepEqual(fake.file('alt1').$permissions, HIDDEN_PERMISSIONS);
expectError(await heart('alt1', true, JWT_ADMIN), 404, 'logoGone', 'ausgeblendet');
assert.equal((await setVisible('alt1', true)).status, 200);
assert.equal(fake.row('logos', 'alt1').hidden, false);
assert.deepEqual(fake.file('alt1').$permissions, ['read("any")']);
});
test('Altlogo löschen: Datei, Herzen, Meldungen und Zeile sind weg, die alte ID lässt sich danach nicht neu belegen', async () => {
const { heart, report, remove, upload, fake } = await setup();
seedLogo(fake, { id: 'alt1', fileId: 'alt1' });
seedLogo(fake, { id: 'alt2', fileId: 'alt2', owner: OTHER }); // bleibt unberührt
await heart('alt1', true);
await report('alt1', '203.0.1.9');
assert.equal(fake.rows('logo_likes').length, 1);
assert.equal(fake.rows('logo_reports').length, 1);
assert.equal((await remove('alt1')).status, 200);
assert.equal(fake.row('logos', 'alt1'), null);
assert.equal(fake.file('alt1'), null);
assert.equal(fake.rows('logo_likes').length, 0);
assert.equal(fake.rows('logo_reports').length, 0);
assert.ok(fake.row('logos', 'alt2'));
assert.ok(fake.file('alt2'));
// jemand legt eine Datei mit der alten ID an und lädt sie hoch: es entsteht eine Zeile mit neuer ID, die alte bleibt frei
logoFile(fake, 'alt1', OTHER);
const res = await upload(uploadBody({ fileId: 'alt1' }), JWT_OTHER);
assert.equal(res.status, 201);
assert.equal(res.json.id, rowOf('alt1', OTHER));
assert.equal(fake.row('logos', 'alt1'), null);
});
test('Altlogo: dieselbe Datei noch einmal hochladen ist 409 logoTaken (die Zeile trägt sie schon), keine zweite Zeile', async () => {
const { upload, fake } = await setup();
seedLogo(fake, { id: 'alt1', fileId: 'alt1' });
expectError(await upload(uploadBody({ fileId: 'alt1' })), 409, 'logoTaken');
assert.equal(fake.rows('logos').length, 1);
assert.deepEqual(fake.file('alt1').$permissions, ['read("any")']);
assert.equal(filePuts(fake, 'alt1').length, 0);
});
// ---- Routen, Logs, Fehler --------------------------------------------------------------------------- // ---- Routen, Logs, Fehler ---------------------------------------------------------------------------
test('Logo-Routen: nur POST mit application/json (sonst 404 bzw. 415), Appwrite wird dabei nicht berührt', async () => { test('Logo-Routen: nur POST mit application/json (sonst 404 bzw. 415), Appwrite wird dabei nicht berührt', async () => {
@@ -1215,11 +1324,11 @@ test('Logs der Logo-Endpunkte enthalten nie Key, Salz, JWT, IPs oder Namen, aber
await report('logo-9', '203.0.9.9'); await report('logo-9', '203.0.9.9');
await setVisible('logo-9', true); await setVisible('logo-9', true);
fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/logos/rows/'), { status: 500 }); fake.failNext((c) => c.method === 'GET' && c.path.includes('/tables/logos/rows/'), { status: 500 });
await remove('logo1'); await remove(rowOf('logo1'));
await remove('logo1'); await remove(rowOf('logo1'));
assert.ok(logs.length > 0); assert.ok(logs.length > 0);
assert.ok(logs.some((line) => line.endsWith('logo logo2 removed after upload check (logoFormat)'))); assert.ok(logs.some((line) => line.endsWith(`logo ${rowOf('logo2')} removed after upload check (logoFormat)`)));
const all = logs.join('\n'); const all = logs.join('\n');
for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.', '127.0.0.1', reporterHash(SALT, '203.0.7.9'), 'GEHEIMER']) { for (const secret of [fake.key, SALT, JWT_ME, JWT_OTHER, JWT_ADMIN, 'xxx.yyy.zzz', '203.0.', '127.0.0.1', reporterHash(SALT, '203.0.7.9'), 'GEHEIMER']) {
assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`); assert.ok(!all.includes(secret), `Log enthält ${secret.slice(0, 12)}…`);
@@ -170,7 +170,7 @@ Pro Konto sind höchstens **30 Logos** erlaubt, ausgeblendete zählen mit. Darü
Indizes: `owner` (key, `ownerId`), `hidden` (key, `hidden`), `likes` (key, `likeCount`). Indizes: `owner` (key, `ownerId`), `hidden` (key, `hidden`), `likes` (key, `likeCount`).
Zeilen-ID = Datei-ID. Damit kann eine Datei nie zwei Logos tragen, und ein zweiter Versuch ergibt 409. 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")']`. Zeilenrechte (setzt nur die API): sichtbar `['read("any")']`, ausgeblendet `['read("user:<owner>")','read("label:admin")']`.