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

+33 -19
View File
@@ -8,7 +8,7 @@
* 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
* 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
* 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}
@@ -20,9 +20,9 @@
* POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar}
* 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`,
* Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows), nie hoch-
* oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo.
* Tabellen `logos` (Zeilen-ID = hash32(`<Konto>|<Datei>`), siehe logoRowId; Altlogos haben noch Zeilen-ID = Datei-ID), `logo_likes`,
* `logo_reports`, Bucket `logo-images`. `likeCount` und `reportCount` werden immer aus der Zahl der Zeilen gesetzt (countRows),
* nie hoch- oder runtergezählt. Sperren: `logo-owner:<Nutzer>` beim Hochladen, `logo:<id>` für alles je Logo.
* 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 ----------------------------------------------------------------------------------
/**
* Feste Zeilen-ID je Konto und Datei: dieselbe Datei desselben Kontos ergibt dieselbe ID (Appwrite-409 beim Anlegen). Die ID ist
* nicht die Datei-ID: die Zeilen-ID eines gelöschten Logos steht öffentlich in der Liste und in gespeicherten Karten, und jeder
* könnte eine Datei mit genau dieser ID anlegen und als Logo hochladen. Mit dem Konto im Hash lässt sich die alte ID so nicht neu belegen.
* Ältere Logos haben noch Zeilen-ID = Datei-ID; alle anderen Endpunkte adressieren die Zeile nur über ihre ID und lesen die Datei
* aus `fileId`, daher gehen beide Arten.
*/
function logoRowId(ownerId, fileId) {
return hash32(`${ownerId}|${fileId}`);
}
/** Feste Zeilen-ID je Logo und Nutzer: ein Herz pro Konto, Appwrite-409 ersetzt jede Doppelt-Prüfung. */
function logoLikeId(logoId, userId) {
return hash32(`${logoId}|${userId}`);
@@ -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
* 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;
await appwrite.deleteFile(BUCKET, id).catch(() => {
await appwrite.deleteFile(BUCKET, fileId).catch(() => {
clean = false;
});
await appwrite.deleteRow(TABLE_LOGOS, id).catch(() => {
await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
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 }) {
@@ -221,14 +233,16 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
const file = await appwrite.getFile(BUCKET, data.fileId);
checkFileExists(file, BUCKET);
const rowId = logoRowId(user.id, data.fileId);
// Je Konto nacheinander: Limit, Prüfung „Datei schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen.
return withLock(`logo-owner:${user.id}`, async () => {
// Die Zeilen dieses Kontos (auch ausgeblendete), höchstens MAX_LOGOS: ab dort ist das Limit erreicht.
const rows = await appwrite.listRows(TABLE_LOGOS, [queryEqual('ownerId', [user.id]), queryLimit(MAX_LOGOS)]);
if (rows.length >= MAX_LOGOS) throw new HttpError(429, MSG.tooManyLogos);
// Zeilen-ID = Datei-ID: dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung).
if (rows.some((row) => row.$id === data.fileId)) throw new HttpError(409, MSG.logoTaken);
// Dieses Logo gibt es schon (seine Datei trägt dann kein delete(owner) mehr, darum vor der Besitzprüfung): entweder unter dieser
// Zeilen-ID oder, bei einem Altlogo mit Zeilen-ID = Datei-ID, über die `fileId` der Zeile.
if (rows.some((row) => row.$id === rowId || row.fileId === data.fileId)) throw new HttpError(409, MSG.logoTaken);
checkFileOwnership(file, user.id);
checkLogoContent(file);
await checkLogoSize(data.fileId);
@@ -236,7 +250,7 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
try {
await appwrite.createRow(
TABLE_LOGOS,
data.fileId,
rowId,
{
name: data.name,
displayName: data.displayName,
@@ -250,7 +264,7 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
visibleRowPermissions(),
);
} 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);
throw err;
}
@@ -267,20 +281,20 @@ function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUse
problem = await logoSizeProblem(data.fileId);
} catch (err) {
let rolledBack = true;
await appwrite.deleteRow(TABLE_LOGOS, data.fileId).catch(() => {
await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
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).
if (rolledBack) await appwrite.updateFilePermissions(BUCKET, data.fileId, uploadFilePermissions(user.id)).catch(() => {});
throw err;
}
if (problem !== null) {
await removeRejectedLogo(data.fileId, problem);
await removeRejectedLogo(rowId, data.fileId, problem);
throw problem;
}
say(`logo ${data.fileId} uploaded`);
return { status: 201, body: { id: data.fileId } };
say(`logo ${rowId} uploaded`);
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 };