'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(`|`), 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:` beim Hochladen, `logo:` 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) => Promise} 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} 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 (file )` 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 };