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>
This commit is contained in:
1 parent
7cb1381566
commit
af05e5f1f1
4 files changed
+98
-5
No files matched your search
@@ -74,12 +74,12 @@ Jede Fehlerantwort trägt neben dem deutschen Text einen `code`: den Schlüssel
|
|||||||
- **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`) → 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.
|
- **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`) → 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.
|
- **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 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) 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
- **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 ebenso (Datei, Herzen, Meldungen, Zeile). Bleibt etwas hängen (502), heilt der nächste Versuch.
|
- **Reihenfolge bei Mehrschritt-Aktionen** (jede Aktion ist wiederholbar): Ausblenden/Einblenden ändert zuerst die Dateirechte, beim Einblenden danach die Meldungen löschen, die Zeile zuletzt. Zurückziehen löscht Dateien, Meldungen, dann die Zeile; 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)`; 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).
|
- **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)`; 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
|
## Tests
|
||||||
|
|||||||
+7
-1
@@ -17,6 +17,8 @@
|
|||||||
* 400 · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
|
* 400 · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
|
||||||
* POST /api/logos/loeschen Nutzer-JWT (Hochlader oder Admin) {id}
|
* POST /api/logos/loeschen Nutzer-JWT (Hochlader oder Admin) {id}
|
||||||
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
|
* 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}
|
* 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}
|
||||||
*
|
*
|
||||||
@@ -379,7 +381,11 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
|
|||||||
if (!row) throw new HttpError(404, MSG.logoGone);
|
if (!row) throw new HttpError(404, MSG.logoGone);
|
||||||
if (row.ownerId !== user.id && !user.isAdmin) throw new HttpError(403, MSG.forbidden);
|
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.
|
// 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);
|
if (isId(row.fileId)) await appwrite.deleteFile(BUCKET, row.fileId);
|
||||||
await deleteRowsOf(TABLE_LIKES, id);
|
await deleteRowsOf(TABLE_LIKES, id);
|
||||||
await deleteRowsOf(TABLE_REPORTS, id);
|
await deleteRowsOf(TABLE_REPORTS, id);
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ 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}`));
|
||||||
const fileReads = (fake, id) => fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith(`/files/${id}/view`));
|
const fileReads = (fake, id) => fake.calls.filter((c) => c.method === 'GET' && c.path.endsWith(`/files/${id}/view`));
|
||||||
|
|
||||||
|
/** Kann ein Gast diese Zeile lesen? (Appwrite-Rollen `any` und `guests`.) */
|
||||||
|
const guestCanRead = (row) => row.$permissions.some((permission) => permission === 'read("any")' || permission === 'read("guests")');
|
||||||
|
|
||||||
/** 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. */
|
/** 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) {
|
async function until(condition) {
|
||||||
for (let i = 0; i < 400 && !condition(); i += 1) await new Promise((resolve) => setTimeout(resolve, 5));
|
for (let i = 0; i < 400 && !condition(); i += 1) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
@@ -1190,6 +1193,7 @@ test('Logo löschen durch Admin geht, auch bei ausgeblendetem Logo', async () =>
|
|||||||
assert.equal(fake.file(fileOf('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);
|
||||||
|
assert.equal(fake.calls.filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0, 'ein schon ausgeblendetes Logo wird nicht noch einmal ausgeblendet');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async () => {
|
test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async () => {
|
||||||
@@ -1201,6 +1205,8 @@ test('Logo löschen durch Fremde: 403 forbidden und nichts gelöscht', async ()
|
|||||||
assert.ok(fake.file(fileOf('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);
|
||||||
|
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")'], 'auch nicht ausgeblendet: wer nicht löschen darf, ändert nichts');
|
||||||
|
assert.equal(fake.calls.filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Logo löschen: 401 ohne JWT, 404 logoGone bei fehlendem Logo, 400 badLogoId bei ungültiger ID', async () => {
|
test('Logo löschen: 401 ohne JWT, 404 logoGone bei fehlendem Logo, 400 badLogoId bei ungültiger ID', async () => {
|
||||||
@@ -1222,12 +1228,93 @@ test('Logo löschen ist wiederholbar, wenn die Zeile zuletzt scheitert (die Zeil
|
|||||||
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(fileOf('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);
|
||||||
|
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, HIDDEN_PERMISSIONS, 'der Anker ist unsichtbar');
|
||||||
|
|
||||||
const retry = await remove('logo-1');
|
const retry = await remove('logo-1');
|
||||||
assert.equal(retry.status, 200, 'die Datei fehlt schon, das ist kein Fehler');
|
assert.equal(retry.status, 200, 'die Datei fehlt schon, das ist kein Fehler');
|
||||||
assert.equal(fake.row('logos', 'logo-1'), null);
|
assert.equal(fake.row('logos', 'logo-1'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Logo löschen: zuerst wird das Logo ausgeblendet (Datei, dann Zeile, genau die ausgeblendeten Rechte), danach folgen Datei, Herzen, Meldungen und zuletzt die Zeile', async () => {
|
||||||
|
const { remove, fake } = await setup();
|
||||||
|
seedLogo(fake);
|
||||||
|
seedLikes(fake, 'logo-1', ['u1']);
|
||||||
|
seedReports(fake, 'logo-1', ['h1']);
|
||||||
|
const file = fileOf('logo-1');
|
||||||
|
assert.equal((await remove('logo-1')).status, 200);
|
||||||
|
|
||||||
|
const at = (predicate) => fake.calls.findIndex(predicate);
|
||||||
|
const hideFile = at((c) => c.method === 'PUT' && c.path.endsWith(`/files/${file}`));
|
||||||
|
const hideRow = at((c) => c.method === 'PATCH' && c.path.endsWith('/tables/logos/rows/logo-1'));
|
||||||
|
const dropFile = at((c) => c.method === 'DELETE' && c.path.endsWith(`/files/${file}`));
|
||||||
|
const dropLike = at((c) => c.method === 'DELETE' && c.path.includes('/tables/logo_likes/rows/'));
|
||||||
|
const dropReport = at((c) => c.method === 'DELETE' && c.path.includes('/tables/logo_reports/rows/'));
|
||||||
|
const dropRow = at((c) => c.method === 'DELETE' && c.path.endsWith('/tables/logos/rows/logo-1'));
|
||||||
|
const order = [hideFile, hideRow, dropFile, dropLike, dropReport, dropRow];
|
||||||
|
assert.ok(order.every((index) => index !== -1), `jeder Schritt kam vor: ${order}`);
|
||||||
|
assert.deepEqual(order, [...order].sort((a, b) => a - b), 'Reihenfolge: Datei ausblenden, Zeile ausblenden, Datei, Herzen, Meldungen, Zeile löschen');
|
||||||
|
assert.deepEqual(fake.calls[hideFile].body, { permissions: HIDDEN_PERMISSIONS });
|
||||||
|
assert.deepEqual(fake.calls[hideRow].body, { data: { hidden: true }, permissions: HIDDEN_PERMISSIONS });
|
||||||
|
assert.equal(fake.row('logos', 'logo-1'), null);
|
||||||
|
assert.equal(fake.file(file), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Logo löschen: bricht es ab, nachdem die Datei gelöscht ist, bleibt höchstens eine unsichtbare Zeile (genau die ausgeblendeten Rechte des Hochladers), die Wiederholung vollendet es', async () => {
|
||||||
|
const failures = [
|
||||||
|
['Herzen lesen', (c) => c.method === 'GET' && c.path.endsWith('/tables/logo_likes/rows')],
|
||||||
|
['Meldungen lesen', (c) => c.method === 'GET' && c.path.endsWith('/tables/logo_reports/rows')],
|
||||||
|
['Zeile löschen', (c) => c.method === 'DELETE' && c.path.includes('/tables/logos/rows/')],
|
||||||
|
];
|
||||||
|
// der Hochlader löscht sein Logo, und ein Admin löscht das Logo eines anderen (die Rechte der Zeile bleiben die des Hochladers)
|
||||||
|
for (const [who, owner, jwt] of [['Hochlader', ME, JWT_ME], ['Admin', OTHER, JWT_ADMIN]]) {
|
||||||
|
for (const [step, failing] of failures) {
|
||||||
|
const label = `${who}, Abbruch bei „${step}“`;
|
||||||
|
const { remove, fake } = await setup();
|
||||||
|
seedLogo(fake, { owner });
|
||||||
|
seedLikes(fake, 'logo-1', ['u1', 'u2']);
|
||||||
|
seedReports(fake, 'logo-1', ['h1']);
|
||||||
|
assert.equal(guestCanRead(fake.row('logos', 'logo-1')), true, `${label}: vorher öffentlich`);
|
||||||
|
|
||||||
|
fake.failNext(failing, { status: 500 });
|
||||||
|
expectError(await remove('logo-1', jwt), 502, 'unavailable', label);
|
||||||
|
|
||||||
|
// die Datei ist weg (unter ihrer ID könnte jemand eine neue anlegen), die Zeile steht noch, aber kein Gast liest sie mehr
|
||||||
|
const row = fake.row('logos', 'logo-1');
|
||||||
|
assert.equal(fake.file(fileOf('logo-1')), null, `${label}: Datei gelöscht`);
|
||||||
|
assert.ok(row, `${label}: die Zeile bleibt als Anker`);
|
||||||
|
assert.equal(row.hidden, true, `${label}: hidden`);
|
||||||
|
assert.deepEqual(row.$permissions, hiddenRowPermissions(owner), `${label}: genau die ausgeblendeten Rechte`);
|
||||||
|
assert.equal(guestCanRead(row), false, `${label}: kein Gast liest die Zeile`);
|
||||||
|
|
||||||
|
// die Wiederholung vollendet das Löschen, ohne noch einmal auszublenden
|
||||||
|
const seen = fake.calls.length;
|
||||||
|
assert.equal((await remove('logo-1', jwt)).status, 200, `${label}: Wiederholung`);
|
||||||
|
assert.equal(fake.row('logos', 'logo-1'), null, `${label}: Zeile weg`);
|
||||||
|
assert.equal(fake.rows('logo_likes').length, 0, `${label}: Herzen weg`);
|
||||||
|
assert.equal(fake.rows('logo_reports').length, 0, `${label}: Meldungen weg`);
|
||||||
|
assert.equal(fake.calls.slice(seen).filter((c) => c.method === 'PUT' || c.method === 'PATCH').length, 0, `${label}: nicht noch einmal ausgeblendet`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Logo löschen: scheitert schon das Ausblenden, ist noch nichts gelöscht (Zeile öffentlich, Datei und Herzen da), die Wiederholung klappt', async () => {
|
||||||
|
const { remove, fake } = await setup();
|
||||||
|
seedLogo(fake);
|
||||||
|
seedLikes(fake, 'logo-1', ['u1']);
|
||||||
|
fake.failNext((c) => c.method === 'PUT' && c.path.endsWith(`/files/${fileOf('logo-1')}`), { status: 500 });
|
||||||
|
expectError(await remove('logo-1'), 502, 'unavailable');
|
||||||
|
assert.deepEqual(fake.row('logos', 'logo-1').$permissions, ['read("any")']);
|
||||||
|
assert.equal(fake.row('logos', 'logo-1').hidden, false);
|
||||||
|
assert.ok(fake.file(fileOf('logo-1')));
|
||||||
|
assert.equal(fake.rows('logo_likes').length, 1);
|
||||||
|
assert.equal(fake.calls.filter((c) => c.method === 'DELETE').length, 0, 'bis hierher wurde nichts gelöscht');
|
||||||
|
|
||||||
|
assert.equal((await remove('logo-1')).status, 200);
|
||||||
|
assert.equal(fake.row('logos', 'logo-1'), null);
|
||||||
|
assert.equal(fake.file(fileOf('logo-1')), null);
|
||||||
|
assert.equal(fake.rows('logo_likes').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Admin: Sichtbarkeit ------------------------------------------------------------------------------
|
// ---- Admin: Sichtbarkeit ------------------------------------------------------------------------------
|
||||||
|
|
||||||
test('Logo-Sichtbarkeit: Nicht-Admin 403 adminOnly, ohne JWT 401, nichts ändert sich', async () => {
|
test('Logo-Sichtbarkeit: Nicht-Admin 403 adminOnly, ohne JWT 401, nichts ändert sich', async () => {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ Pro Konto sind höchstens **30 Logos** erlaubt, ausgeblendete zählen mit. Darü
|
|||||||
- Ein Melder ist ein IP-Netz (IPv4 /24, IPv6 /48), gespeichert nur als Hash.
|
- 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.
|
- 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.
|
- 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) entfernt 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.
|
- **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`):
|
- **Admin** (Label `admin`):
|
||||||
- Filter „Gemeldet“ (`reportCount > 0`) und „Ausgeblendet“.
|
- Filter „Gemeldet“ (`reportCount > 0`) und „Ausgeblendet“.
|
||||||
- Ein- und Ausblenden. Einblenden löscht die Meldungen und setzt `reportCount` auf 0.
|
- Ein- und Ausblenden. Einblenden löscht die Meldungen und setzt `reportCount` auf 0.
|
||||||
@@ -205,7 +205,7 @@ Die neuen Endpunkte stehen in einem eigenen Modul `deploy/api/logos.js`. Reine g
|
|||||||
| `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/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/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/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) | Löscht Datei, Herzen, Meldungen und zuletzt die Zeile. 403 bei fremdem Logo, 404 bei fehlendem. → `200 {ok:true}` |
|
| `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`). |
|
| `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'`.
|
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'`.
|
||||||
|
|||||||
Reference in new issue
Block a user