Files
AirCardIOS/deploy/api/logos.js
T
ClaudeandClaude Opus 5.5 6d152c11b3 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>
2026-10-05 05:07:28 +00:00

416 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Endpunkte der Community-Logos im API-Dienst `aircard-api` (Node 22, nur Bordmittel). Sie folgen den Karten-Endpunkten
* in app.js: gleiche Reihenfolge der Prüfungen, gleiche Rücknahme beim Hochladen, gleiche Melde- und Ausblend-Logik.
* app.js hängt die Routen an und gibt Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log mit.
*
* 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 = 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}
* 401 · 400 · 404 fehlt oder ausgeblendet · 200 {herz, likeCount}
* POST /api/logos/melden ohne Anmeldung {id, reason} (wie /api/melden, Rate-Limit gemeinsam)
* 400 · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true}
* POST /api/logos/loeschen Nutzer-JWT (Hochlader oder Admin) {id}
* 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true}
* POST /api/admin/logo-sichtbarkeit Admin-JWT {id, sichtbar}
* 401 · 403 kein Admin · 400 · 404 · 200 {ok:true}
*
* 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.
*/
const { queryEqual, queryLimit } = require('./appwrite.js');
const {
MAX_LOGOS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
CONTROL_CHARACTERS,
MSG,
HttpError,
isId,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
likeRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
} = require('./common.js');
const TABLE_LOGOS = 'logos';
const TABLE_LIKES = 'logo_likes';
const TABLE_REPORTS = 'logo_reports';
const BUCKET = 'logo-images';
const MAX_LOGO_BYTES = 1024 * 1024;
/** Maße des fertigen Logos (wie die Verarbeitung im Editor): 8 × 8 bis 720 × 250 Pixel. */
const LOGO_MIN_SIDE = 8;
const LOGO_MAX_WIDTH = 720;
const LOGO_MAX_HEIGHT = 250;
/** Herzen und Meldungen eines Logos werden in Runden zu 100 gelöscht, höchstens 50 Runden. */
const ROW_BATCH = 100;
const ROW_ROUNDS = 50;
const LOGO_TEXT_FIELDS = [
{ key: 'name', label: 'Der Name', max: 40, required: true },
{ key: 'displayName', label: 'Der Anzeigename', max: 30, required: false },
];
// ---- 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}`);
}
/** Feste Zeilen-ID je Logo und Melder (`logo|` trennt sie von den Karten-Meldungen): eine Meldung pro Melder und Logo. */
function logoReportId(logoId, reporter) {
return hash32(`logo|${logoId}|${reporter}`);
}
function readLogoText(raw, field) {
if (raw === undefined || raw === null) return field.required ? { message: MSG.logoNameMissing } : { value: '' };
if (typeof raw !== 'string') return { message: `${field.label} ist ungültig.` };
const value = raw.trim();
if (value === '' && field.required) return { message: MSG.logoNameMissing };
if (CONTROL_CHARACTERS.test(value)) return { message: `${field.label} enthält ungültige Zeichen.` };
if (value.length > field.max) return { message: `${field.label} ist zu lang (höchstens ${field.max} Zeichen).` };
return { value };
}
/**
* Prüft den Hochladen-Body: Name 1-40, Anzeigename ≤ 30 (getrimmt, ohne Steuer- und Bidi-Zeichen), `mono` fehlt = false
* (sonst muss es ein Boolean sein), `fileId` nach ID_PATTERN.
* @returns {{ok: true, data: {name: string, displayName: string, mono: boolean, fileId: string}} | {ok: false, message: string}}
*/
function validateLogoUpload(body) {
if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest };
const data = {};
for (const field of LOGO_TEXT_FIELDS) {
const result = readLogoText(body[field.key], field);
if (result.message) return { ok: false, message: result.message };
data[field.key] = result.value;
}
if (body.mono !== undefined && typeof body.mono !== 'boolean') return { ok: false, message: MSG.badMono };
data.mono = body.mono === true;
if (!isId(body.fileId)) return { ok: false, message: 'Ungültige Datei-ID.' };
data.fileId = body.fileId;
return { ok: true, data };
}
/** Typ und Größe laut Appwrite (erst nach der Besitzprüfung, damit fremde Dateien nichts darüber verraten). */
function checkLogoContent(file) {
if (file.mimeType !== 'image/png') throw new HttpError(400, MSG.logoFileType);
if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_LOGO_BYTES) throw new HttpError(400, MSG.logoFileSize);
}
function isLogoSize(head) {
// readImageSize kennt auch WebP: ein Logo muss einen PNG-Kopf haben, nicht nur den Dateityp laut Appwrite
if (head.toString('latin1', 1, 4) !== 'PNG') return false;
const size = readImageSize(head);
return (
size !== null &&
size.width >= LOGO_MIN_SIDE &&
size.height >= LOGO_MIN_SIDE &&
size.width <= LOGO_MAX_WIDTH &&
size.height <= LOGO_MAX_HEIGHT
);
}
// ---- Die Routen ------------------------------------------------------------------------------------
/**
* @param {object} options
* @param {object} options.appwrite Objekt aus createAppwrite
* @param {string} options.salt Salz für die Melder-Hashes
* @param {(message: string) => void} options.say Ereigniszeile ins Log (mit Zeilenbudget)
* @param {(key: string, fn: () => Promise<any>) => Promise<any>} options.withLock Je Schlüssel nacheinander
* @param {(key: string) => {allowed: boolean, retryAfter?: number}} options.rateLimit das Rate-Limit der Meldungen (gemeinsam mit den Karten)
* @param {(req) => Promise<{id: string, isAdmin: boolean}>} options.requireUser wirft 401 ohne gültiges JWT
* @returns {Array<[string, {name: string, json: true, run: (ctx: {req, raw: string}) => Promise<{status: number, body: object}>}]>}
*/
function createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }) {
const withLogoLock = (id, fn) => withLock(`logo:${id}`, fn);
/** Setzt die Rechte der Logo-Datei; null heißt: die Datei gibt es nicht (mehr). */
function setFilePermissions(row, permissions) {
return isId(row.fileId) ? appwrite.updateFilePermissions(BUCKET, row.fileId, permissions) : Promise.resolve(null);
}
/** Datei zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */
async function hideLogo(id, row, extra = {}) {
await setFilePermissions(row, hiddenFilePermissions(row.ownerId));
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId));
}
/** Löscht alle Zeilen der Tabelle zu diesem Logo, in Runden (die Zeile des Logos selbst bleibt der Anker für eine Wiederholung). */
async function deleteRowsOf(table, logoId) {
for (let round = 0; round < ROW_ROUNDS; round += 1) {
const rows = await appwrite.listRows(table, [queryEqual('logoId', [logoId]), queryLimit(ROW_BATCH)]);
if (rows.length === 0) return;
for (const row of rows) await appwrite.deleteRow(table, row.$id);
}
}
/** reportCount = Anzahl Meldungszeilen des Logos (neu gezählt); ab 3 wird ausgeblendet. */
async function syncReports(id, row) {
const count = await appwrite.countRows(TABLE_REPORTS, [queryEqual('logoId', [id])]);
if (count >= HIDE_AT_REPORTS) {
await hideLogo(id, row, { reportCount: count });
say(`logo ${id} hidden automatically (${count} reports)`);
} else if (row.reportCount !== count) {
await appwrite.updateRow(TABLE_LOGOS, id, { reportCount: count });
}
}
/**
* 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) 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> (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(rowId, fileId, problem) {
let clean = true;
await appwrite.deleteFile(BUCKET, fileId).catch(() => {
clean = false;
});
await appwrite.deleteRow(TABLE_LOGOS, rowId).catch(() => {
clean = false;
});
say(clean ? `logo ${rowId} removed after upload check (${problem.code})` : `rollback failed for logo ${rowId} (file ${fileId})`);
}
async function uploadLogo({ req, raw }) {
const user = await requireUser(req);
const validation = validateLogoUpload(parseJsonObject(raw));
if (!validation.ok) throw new HttpError(400, validation.message);
const data = validation.data;
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);
// 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);
try {
await appwrite.createRow(
TABLE_LOGOS,
rowId,
{
name: data.name,
displayName: data.displayName,
fileId: data.fileId,
ownerId: user.id,
mono: data.mono,
likeCount: 0,
hidden: false,
reportCount: 0,
},
visibleRowPermissions(),
);
} catch (err) {
// 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;
}
// 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, rowId).catch(() => {
rolledBack = false;
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(rowId, data.fileId, problem);
throw problem;
}
say(`logo ${rowId} uploaded`);
return { status: 201, body: { id: rowId } };
});
}
async function setHeart({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.herz !== 'boolean') throw new HttpError(400, MSG.badHeart);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
const likeId = logoLikeId(id, user.id);
if (body.herz) {
try {
await appwrite.createRow(TABLE_LIKES, likeId, { logoId: id, userId: user.id }, likeRowPermissions(user.id));
} catch (err) {
if (!isDuplicate(err)) throw err; // schon da: kein Fehler
}
} else {
await appwrite.deleteRow(TABLE_LIKES, likeId); // schon weg (false): kein Fehler
}
// Immer neu zählen statt hoch- oder runterzählen: das heilt einen früher steckengebliebenen Zählerstand.
const likeCount = await appwrite.countRows(TABLE_LIKES, [queryEqual('logoId', [id])]);
if (row.likeCount !== likeCount) await appwrite.updateRow(TABLE_LOGOS, id, { likeCount });
return { status: 200, body: { herz: body.herz, likeCount } };
});
}
async function reportLogo({ req, raw }) {
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.reason !== 'string' || !REASONS.includes(body.reason)) throw new HttpError(400, MSG.badReason);
const id = body.id;
// Melder-Identität = Netz (IPv4 /24, IPv6 /48); das Rate-Limit zählt je Anschluss und gilt für Karten und Logos zusammen.
const ip = clientIp(req);
const reporter = reporterHash(salt, ip);
const limit = rateLimit(hmacHex(salt, normalizeIp(ip)));
if (!limit.allowed) throw new HttpError(429, MSG.tooManyReports, { 'Retry-After': String(limit.retryAfter) });
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row || row.hidden === true) throw new HttpError(404, MSG.logoGone);
let isNew = true;
try {
await appwrite.createRow(TABLE_REPORTS, logoReportId(id, reporter), { logoId: id, reason: body.reason, reporterHash: reporter }, []);
} catch (err) {
if (!isDuplicate(err)) throw err;
isNew = false;
}
// Auch bei einer doppelten Meldung neu zählen: heilt einen früher steckengebliebenen Zählerstand.
await syncReports(id, row);
if (isNew) say(`logo ${id} reported (${body.reason})`);
return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: true } };
});
}
async function deleteLogo({ req, raw }) {
const user = await requireUser(req);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row) throw new HttpError(404, MSG.logoGone);
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.
if (isId(row.fileId)) await appwrite.deleteFile(BUCKET, row.fileId);
await deleteRowsOf(TABLE_LIKES, id);
await deleteRowsOf(TABLE_REPORTS, id);
await appwrite.deleteRow(TABLE_LOGOS, id);
say(`logo ${id} deleted${row.ownerId === user.id ? '' : ' by admin'}`);
return { status: 200, body: { ok: true } };
});
}
async function setVisibility({ req, raw }) {
const user = await requireUser(req);
if (!user.isAdmin) throw new HttpError(403, MSG.adminOnly);
const body = parseJsonObject(raw);
if (!isId(body.id)) throw new HttpError(400, MSG.badLogoId);
if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility);
const id = body.id;
return withLogoLock(id, async () => {
const row = await appwrite.getRow(TABLE_LOGOS, id);
if (!row) throw new HttpError(404, MSG.logoGone);
if (body.sichtbar) {
// Datei, dann Meldungen, Zeile zuletzt: sichtbar wird das Logo erst, wenn alles andere steht.
await setFilePermissions(row, visibleFilePermissions());
await deleteRowsOf(TABLE_REPORTS, id);
await appwrite.updateRow(TABLE_LOGOS, id, { hidden: false, reportCount: 0 }, visibleRowPermissions());
say(`logo ${id} made visible by admin`);
} else {
await hideLogo(id, row);
say(`logo ${id} hidden by admin`);
}
return { status: 200, body: { ok: true } };
});
}
return [
['POST /api/logos/hochladen', { name: 'logos/hochladen', json: true, run: uploadLogo }],
['POST /api/logos/herz', { name: 'logos/herz', json: true, run: setHeart }],
['POST /api/logos/melden', { name: 'logos/melden', json: true, run: reportLogo }],
['POST /api/logos/loeschen', { name: 'logos/loeschen', json: true, run: deleteLogo }],
['POST /api/admin/logo-sichtbarkeit', { name: 'admin/logo-sichtbarkeit', json: true, run: setVisibility }],
];
}
module.exports = { createLogoRoutes, logoRowId, logoLikeId, logoReportId, validateLogoUpload };