fix(api): Logo-Datei nach dem Festlegen der Rechte noch einmal prüfen

Der Hochlader behält delete auf seiner Datei, bis die API sie auf read("any") festlegt. Zwischen der ersten Prüfung
(Typ, Größe, PNG-Kopf, Maße) und dem Festlegen ließ sich die Datei unter derselben ID löschen und mit anderem Inhalt
neu anlegen (z. B. 4000 × 4000). Jetzt liest die API den Dateianfang nach dem Festlegen noch einmal, wenn der
Hochlader die Datei nicht mehr ändern kann:

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 19:28:12 +00:00
1 parent dc5de99611
commit fc9e5f5577
4 files changed
+198 -6

No files matched your search

+39 -4
View File
@@ -9,6 +9,8 @@
* 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
* 30 Logos · 201 {id} (id = Datei-ID = Zeilen-ID)
* Nach dem Festlegen der Datei auf read("any") wird ihr Anfang noch einmal geprüft (der Hochlader kann sie dann nicht
* mehr ändern): fällt sie durch oder fehlt sie, werden Datei und Zeile gelöscht und die Antwort ist 400.
* POST /api/logos/herz Nutzer-JWT {id, herz}
* 401 · 400 · 404 fehlt oder ausgeblendet · 200 {herz, likeCount}
* POST /api/logos/melden ohne Anmeldung {id, reason} (wie /api/melden, Rate-Limit gemeinsam)
@@ -180,11 +182,35 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
}
}
/** Liest den Anfang der Datei und verlangt einen PNG-Kopf mit Maßen von 8 × 8 bis 720 × 250. */
async function checkLogoSize(fileId) {
/**
* Liest den Anfang der Datei und verlangt einen PNG-Kopf mit Maßen von 8 × 8 bis 720 × 250.
* @returns {Promise<HttpError | null>} das Urteil (400 fileMissing bzw. logoFormat), null = in Ordnung; Appwrite-Fehler werfen
*/
async function logoSizeProblem(fileId) {
const head = await appwrite.readFileHead(BUCKET, fileId, IMAGE_HEAD_BYTES);
if (head === null) throw new HttpError(400, MSG.fileMissing);
if (!isLogoSize(head)) throw new HttpError(400, MSG.logoFormat);
if (head === null) return new HttpError(400, MSG.fileMissing);
return isLogoSize(head) ? null : new HttpError(400, MSG.logoFormat);
}
async function checkLogoSize(fileId) {
const problem = await logoSizeProblem(fileId);
if (problem !== null) throw problem;
}
/**
* Das Logo ist nach dem Festlegen der Rechte durchgefallen (Inhalt ausgetauscht oder Datei weg): die Datei mit dem
* Server-Key löschen, dann die Zeile. Beides wird versucht, auch wenn das erste scheitert: die Zeile macht das Logo für
* andere sichtbar. Bleibt etwas stehen, steht `rollback failed for logo <id>` im Log.
*/
async function removeRejectedLogo(id, problem) {
let clean = true;
await appwrite.deleteFile(BUCKET, id).catch(() => {
clean = false;
});
await appwrite.deleteRow(TABLE_LOGOS, id).catch(() => {
clean = false;
});
say(clean ? `logo ${id} removed after upload check (${problem.code})` : `rollback failed for logo ${id}`);
}
async function uploadLogo({ req, raw }) {
@@ -231,9 +257,14 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
// Kanonischer Zustand: die Datei trägt genau read("any") (kein delete(owner): sonst ließe sich das Bild tauschen).
// Erst nach dem Anlegen der Zeile: ein 409 fasst die Datei nie an. Scheitert das Festlegen, wird die Zeile zurückgenommen.
let problem = null;
try {
const pinned = await appwrite.updateFilePermissions(BUCKET, data.fileId, visibleFilePermissions());
if (pinned === null) throw new HttpError(400, MSG.fileMissing);
// Jetzt hat der Hochlader kein delete mehr und kann die Datei nicht mehr ändern: den Anfang noch einmal prüfen. So
// lässt sich die Datei nicht zwischen der ersten Prüfung und dem Festlegen unter derselben ID austauschen. Ein
// Appwrite-Fehler hier wird wie ein Fehler beim Festlegen behandelt (Rücknahme unten, nichts bleibt veröffentlicht).
problem = await logoSizeProblem(data.fileId);
} catch (err) {
let rolledBack = true;
await appwrite.deleteRow(TABLE_LOGOS, data.fileId).catch(() => {
@@ -244,6 +275,10 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {});
throw err;
}
if (problem !== null) {
await removeRejectedLogo(data.fileId, problem);
throw problem;
}
say(`logo ${data.fileId} uploaded`);
return { status: 201, body: { id: data.fileId } };
});