'use strict'; /** * Routen und Logik des API-Dienstes `aircard-api` (Node 22, nur Bordmittel). Alle Schreibzugriffe auf die * Community-Galerie laufen hier über den Appwrite-Server-Key (siehe appwrite.js). * * POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId} * 401 kein/ungültiges JWT · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine / * falscher Bucket / Datei gesperrt (kein read("any")) oder von einer Karte des Autors schon verwendet / Karte nicht * meine (`cards`) · 409 schon veröffentlicht · 429 schon 20 Veröffentlichungen · 201 {id} * POST /api/zurueckziehen Nutzer-JWT (Autor oder Admin) {id} * 401 · 400 · 403 fremd · 404 fehlt · 200 {ok:true} * POST /api/melden ohne Anmeldung {id, reason} (Melder = IPv4-/24 bzw. IPv6-/48, gehasht) * 400 Grund/ID · 404 fehlt oder ausgeblendet · 429 Rate-Limit · 200 {ok:true} oder {schonGemeldet:true} * POST /api/admin/sichtbarkeit Admin-JWT {id, sichtbar} * 401 · 403 kein Admin · 400 · 404 · 200 {ok:true} * GET /api/gesundheit {ok:true} * alles andere 404; Body höchstens 8 KB (413), nur application/json (415) * * Das Nutzer-JWT kommt im Header X-Aircard-Jwt und wird bei Appwrite (GET /account) geprüft; Admin = Label `admin`. * Der Appwrite-Key braucht die Console-Scopes documents.read/documents.write (= rows.* der API) und files.read/files.write. * Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik. * Fehlerantworten sind immer {error: }. * Appwrite-Fehler: 502 (keine Antwort, 429, 5xx) bzw. 500 (alle anderen, z. B. falscher Key oder fehlende Tabelle). * Logs: kein Zugriffsprotokoll je Anfrage (sonst steuert ein Angreifer das Log-Volumen), nur Ereigniszeilen * (veröffentlicht, gemeldet, ausgeblendet, …) und Serverfehler, zusammen höchstens 120 Zeilen pro Minute. */ const crypto = require('node:crypto'); const net = require('node:net'); const { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js'); const TABLE_GALLERY = 'gallery'; const TABLE_REPORTS = 'reports'; const TABLE_CARDS = 'cards'; const BUCKET = 'gallery-images'; const MAX_BODY_BYTES = 8 * 1024; const MAX_FILE_BYTES = 5 * 1024 * 1024; const FILE_MIME_TYPES = new Set(['image/png', 'image/webp']); const HIDE_AT_REPORTS = 3; const REPORT_LIST_LIMIT = 100; /** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */ const MAX_PUBLICATIONS = 20; const REPORT_DELETE_ROUNDS = 50; /** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */ const IMAGE_HEAD_BYTES = 64; /** Kartenmaße wie web/src/card/geometry.ts (CARD_W, CARD_H, PREVIEW_WIDTH; Vorschauhöhe 303 plus/minus 1). */ const IMAGE_WIDTH = 1536; const IMAGE_HEIGHT = 969; const PREVIEW_WIDTH = 480; const PREVIEW_HEIGHT_MIN = 302; const PREVIEW_HEIGHT_MAX = 304; const RATE_LIMIT = 10; const RATE_WINDOW_MS = 60 * 60 * 1000; const RATE_SWEEP_MS = 60 * 1000; const RATE_MAX_KEYS = 50_000; const LOG_BUDGET_PER_MINUTE = 120; const LOG_WINDOW_MS = 60 * 1000; const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']; const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; // Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF): // sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts. const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/; const JWT_CHARACTERS = /^[\x21-\x7e]+$/; const MSG = { unauthorized: 'Bitte melde dich an.', notFound: 'Nicht gefunden.', cardGone: 'Diese Karte ist nicht (mehr) verfügbar.', forbidden: 'Dazu hast du keine Berechtigung.', adminOnly: 'Nur für Admins.', foreignFile: 'Diese Datei gehört dir nicht.', fileMissing: 'Bild nicht gefunden.', fileType: 'Das Bild muss eine PNG- oder WebP-Datei sein.', fileSize: 'Das Bild ist größer als 5 MB.', alreadyPublished: 'Diese Karte ist schon veröffentlicht.', filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.', imageFormat: 'Das Bild hat nicht das Kartenformat.', notYourCard: 'Diese Karte gehört nicht zu deinem Konto.', tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`, tooManyReports: 'Zu viele Meldungen – bitte später nochmal.', badRequest: 'Ungültige Anfrage.', badCardId: 'Ungültige Karten-ID.', badReason: 'Bitte wähle einen gültigen Grund.', badVisibility: 'Ungültige Angabe zur Sichtbarkeit.', tooLarge: 'Die Anfrage ist zu groß.', jsonOnly: 'Nur application/json wird unterstützt.', unavailable: 'Der Dienst ist gerade nicht erreichbar.', internal: 'Interner Fehler.', }; // ---- Reine Helfer ---------------------------------------------------------------------------------- function isId(value) { return typeof value === 'string' && ID_PATTERN.test(value); } function assertOwner(owner) { if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID'); return owner; } /** Sichtbare Galerie-Zeile: lesen darf jeder. */ function visibleRowPermissions() { return ['read("any")']; } /** Ausgeblendete Zeile: nur Autor und Admins. */ function hiddenRowPermissions(owner) { assertOwner(owner); return [`read("user:${owner}")`, 'read("label:admin")']; } /** * Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit * Fehlversuche auf). Nur so kommen Dateien in `publish`; die API setzt sie beim Veröffentlichen auf `visibleFilePermissions`. */ function uploadFilePermissions(owner) { assertOwner(owner); return ['read("any")', `delete("user:${owner}")`]; } /** * Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die * Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen * löscht die Dateien serverseitig mit dem Key. */ function visibleFilePermissions() { return ['read("any")']; } /** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */ function hiddenFilePermissions(owner) { assertOwner(owner); return [`read("user:${owner}")`, 'read("label:admin")']; } function hash32(text) { return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32); } /** Feste Zeilen-ID je Autor und private Karte: Appwrite-409 ersetzt jede Doppelt-Prüfung (auch bei Gleichzeitigkeit). */ function galleryRowId(ownerId, sourceCardId) { return hash32(`${ownerId}|${sourceCardId}`); } /** Feste Zeilen-ID je Karte und Melder: eine Meldung pro Melder und Karte. */ function reportRowId(galleryId, hash) { return hash32(`${galleryId}|${hash}`); } function expandIpv6(address) { let text = address; const lastColon = text.lastIndexOf(':'); const tail = text.slice(lastColon + 1); if (tail.includes('.')) { const o = tail.split('.').map(Number); text = `${text.slice(0, lastColon + 1)}${((o[0] << 8) | o[1]).toString(16)}:${((o[2] << 8) | o[3]).toString(16)}`; } const [head, rest] = text.split('::'); const headGroups = head === '' ? [] : head.split(':'); const restGroups = rest === undefined || rest === '' ? [] : rest.split(':'); const groups = rest === undefined ? headGroups : [...headGroups, ...Array(8 - headGroups.length - restGroups.length).fill('0'), ...restGroups]; return groups.map((group) => Number.parseInt(group, 16)); } /** * Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen), * { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text). */ function parseIp(ip) { if (typeof ip !== 'string') return { kind: 'none' }; let text = ip.trim(); if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1); const zone = text.indexOf('%'); if (zone !== -1) text = text.slice(0, zone); const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(text); if (withPort) text = withPort[1]; if (net.isIPv4(text)) return { kind: 'v4', text }; if (net.isIPv6(text)) { const groups = expandIpv6(text); if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) { return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` }; } return { kind: 'v6', groups }; } return { kind: 'other', text: text.toLowerCase().slice(0, 64) }; } const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':'); /** * Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): IPv4 bleibt, IPv4-gemappte IPv6 wird zu IPv4, * jede andere IPv6-Adresse wird auf ihr /64-Netz gekürzt (ein Anschluss hat Milliarden Adressen). */ function normalizeIp(ip) { const parsed = parseIp(ip); if (parsed.kind === 'none') return ''; if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`; return parsed.text; } /** * Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4). * So zählen drei Adressen aus demselben Netz als ein Melder und blenden eine Karte nicht aus. */ function reporterNetwork(ip) { const parsed = parseIp(ip); if (parsed.kind === 'none') return ''; if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`; if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`; return parsed.text; } function firstHeaderValue(req, name) { const value = req.headers && req.headers[name]; const text = Array.isArray(value) ? value[0] : value; if (typeof text !== 'string') return ''; return text.split(',')[0].trim(); } /** Echte Client-IP: X-Real-Ip (setzt Traefik), sonst erster X-Forwarded-For-Eintrag, sonst Socket. */ function clientIp(req) { return firstHeaderValue(req, 'x-real-ip') || firstHeaderValue(req, 'x-forwarded-for') || (req.socket && req.socket.remoteAddress) || ''; } function hmacHex(salt, text) { return crypto.createHmac('sha256', salt).update(text).digest('hex'); } /** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */ function reporterHash(salt, ip) { return hmacHex(salt, reporterNetwork(ip)); } function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } /** Fehlername oder -typ fürs Log: nur Wortzeichen, Punkt und Bindestrich, höchstens 60 Zeichen. */ function safeToken(value, fallback = 'unknown') { const text = String(value === undefined || value === null ? '' : value).replace(/[^\w.-]/g, '').slice(0, 60); return text === '' ? fallback : text; } const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); /** * Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang). * @returns {{width: number, height: number} | null} */ function readImageSize(bytes) { if (!Buffer.isBuffer(bytes)) return null; if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') { return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; } if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') { const kind = bytes.toString('latin1', 12, 16); if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) { return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff }; } if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) { const bits = bytes.readUInt32LE(21); return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; } if (kind === 'VP8X' && bytes.length >= 30) { return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 }; } } return null; } const isCardImageSize = (size) => size !== null && size.width === IMAGE_WIDTH && size.height === IMAGE_HEIGHT; const isCardPreviewSize = (size) => size !== null && size.width === PREVIEW_WIDTH && size.height >= PREVIEW_HEIGHT_MIN && size.height <= PREVIEW_HEIGHT_MAX; const TEXT_FIELDS = [ { key: 'title', article: 'Der', name: 'Titel', max: 60, required: true }, { key: 'issuer', article: 'Der', name: 'Herausgeber', max: 40, required: false }, { key: 'displayName', article: 'Der', name: 'Anzeigename', max: 30, required: false }, ]; function readText(raw, field) { if (raw === undefined || raw === null) { return field.required ? { message: 'Bitte gib einen Titel an.' } : { value: '' }; } if (typeof raw !== 'string') return { message: `${field.article} ${field.name} ist ungültig.` }; const value = raw.trim(); if (value === '' && field.required) return { message: 'Bitte gib einen Titel an.' }; if (CONTROL_CHARACTERS.test(value)) return { message: `${field.article} ${field.name} enthält ungültige Zeichen.` }; if (value.length > field.max) return { message: `${field.article} ${field.name} ist zu lang (höchstens ${field.max} Zeichen).` }; return { value }; } /** Tags: string[], je getrimmt und klein, Leere raus, Doppelte raus, höchstens 5 mit je höchstens 20 Zeichen. */ function readTags(raw) { if (raw === undefined || raw === null) return { tags: [] }; if (!Array.isArray(raw)) return { message: 'Tags müssen als Liste gesendet werden.' }; const tags = []; for (const entry of raw) { if (typeof entry !== 'string') return { message: 'Ungültige Tags.' }; const tag = entry.trim().toLowerCase(); if (tag === '') continue; if (tag.includes(',')) return { message: 'Tags dürfen kein Komma enthalten.' }; if (CONTROL_CHARACTERS.test(tag)) return { message: 'Tags enthalten ungültige Zeichen.' }; if (tag.length > 20) return { message: 'Ein Tag darf höchstens 20 Zeichen lang sein.' }; if (!tags.includes(tag)) tags.push(tag); } if (tags.length > 5) return { message: 'Bitte gib höchstens 5 Tags an.' }; return { tags }; } /** * Prüft den Veröffentlichen-Body (Längen aus Spec §6, IDs nach ID_PATTERN). * @returns {{ok: true, data: object} | {ok: false, message: string}} */ function validatePublish(body) { if (!isPlainObject(body)) return { ok: false, message: MSG.badRequest }; const data = {}; for (const field of TEXT_FIELDS) { const result = readText(body[field.key], field); if (result.message) return { ok: false, message: result.message }; data[field.key] = result.value; } const tags = readTags(body.tags); if (tags.message) return { ok: false, message: tags.message }; data.tags = tags.tags; for (const [key, message] of [ ['imageFileId', 'Ungültige Bild-ID.'], ['previewFileId', 'Ungültige Vorschau-ID.'], ['sourceCardId', MSG.badCardId], ]) { if (!isId(body[key])) return { ok: false, message }; data[key] = body[key]; } if (data.imageFileId === data.previewFileId) { return { ok: false, message: 'Bild und Vorschau müssen verschiedene Dateien sein.' }; } return { ok: true, data }; } /** `title issuer tags` klein mit Leerzeichen, höchstens 300 Zeichen (Volltext-Index der Galerie). */ function buildSearchText({ title = '', issuer = '', tags = [] }) { const tagList = Array.isArray(tags) ? tags : String(tags).split(','); return [title, issuer, ...tagList] .map((part) => String(part).trim()) .filter((part) => part !== '') .join(' ') .toLowerCase() .slice(0, 300); } // ---- HTTP-Hilfen ------------------------------------------------------------------------------------ class HttpError extends Error { constructor(status, message, headers = {}) { super(message); this.status = status; this.headers = headers; } } function sendJson(res, status, payload, headers = {}) { const body = JSON.stringify(payload); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...headers, }); res.end(body); } const JSON_CONTENT_TYPE = /^application\/json\s*(;|$)/i; /** Liest den Rohbody mit Obergrenze; bei Überschreitung wird der Rest verworfen und die Verbindung geschlossen. */ function readRawBody(req) { return new Promise((resolve, reject) => { const tooLarge = () => new HttpError(413, MSG.tooLarge, { Connection: 'close' }); const declared = Number(req.headers['content-length']); if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) { req.resume(); reject(tooLarge()); return; } const chunks = []; let size = 0; let settled = false; const settle = (fn, value) => { if (settled) return; settled = true; chunks.length = 0; fn(value); }; req.on('data', (chunk) => { if (settled) return; // Rest verwerfen, Verbindung bleibt sauber size += chunk.length; if (size > MAX_BODY_BYTES) settle(reject, tooLarge()); else chunks.push(chunk); }); req.on('end', () => { if (settled) return; const raw = Buffer.concat(chunks).toString('utf8'); settle(resolve, raw); }); req.on('error', () => settle(reject, new HttpError(400, MSG.badRequest))); req.on('close', () => settle(reject, new HttpError(400, MSG.badRequest))); }); } function parseJsonObject(raw) { let parsed; try { parsed = JSON.parse(raw); } catch { throw new HttpError(400, MSG.badRequest); } if (!isPlainObject(parsed)) throw new HttpError(400, MSG.badRequest); return parsed; } /** Je Schlüssel nacheinander abarbeiten (z. B. je Karten-ID), damit Zähler und Rechte nie durcheinanderlaufen. */ function createKeyedLock() { const tails = new Map(); return async function withLock(key, fn) { const previous = tails.get(key) || Promise.resolve(); let release; const gate = new Promise((resolve) => { release = resolve; }); const tail = previous.then(() => gate); tails.set(key, tail); await previous; try { return await fn(); } finally { release(); if (tails.get(key) === tail) tails.delete(key); } }; } function isDuplicate(err) { return err instanceof AppwriteError && err.status === 409; } // ---- Die App --------------------------------------------------------------------------------------- /** * @param {object} options * @param {object} options.appwrite Objekt aus createAppwrite * @param {string} options.salt Salz für die Melder-Hashes * @param {() => number} [options.now] Uhr in ms (für das Rate-Limit) * @param {(line: string) => void} [options.log] bekommt fertige Zeilen mit Präfix [aircard-api] * @returns {(req, res) => Promise} Handler für http.createServer, mit `rateLimitEntries()` für Tests */ function createApp({ appwrite, salt, now = Date.now, log = console.log }) { if (!appwrite || typeof appwrite !== 'object') throw new TypeError('appwrite fehlt'); if (typeof salt !== 'string' || salt === '') throw new TypeError('salt fehlt'); // Logs enthalten nie Key, Salz, JWT oder IPs: nur Karten-IDs, Zahlen und Fehlertypen. Zusätzlich gilt ein // Zeilenbudget je Minute; was darüber liegt, wird gezählt und als eine Sammelzeile nachgetragen. const emit = (message) => log(`[aircard-api] ${new Date().toISOString()} ${message}`); let logWindowStart = 0; let logCount = 0; let logSuppressed = 0; function say(message) { const t = now(); if (t - logWindowStart >= LOG_WINDOW_MS) { if (logSuppressed > 0) emit(`${logSuppressed} log lines suppressed (limit ${LOG_BUDGET_PER_MINUTE} per minute)`); logWindowStart = t; logCount = 0; logSuppressed = 0; } if (logCount >= LOG_BUDGET_PER_MINUTE) { logSuppressed += 1; return; } logCount += 1; emit(message); } const withLock = createKeyedLock(); // ---- Rate-Limit je Melder-Hash (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports`) const hits = new Map(); let lastSweep = 0; function sweepRateLimit(t) { if (t - lastSweep < RATE_SWEEP_MS) return; lastSweep = t; for (const [key, list] of hits) { if (t - list[list.length - 1] >= RATE_WINDOW_MS) hits.delete(key); } } /** @returns {{allowed: boolean, retryAfter?: number}} zählt die Meldung mit, wenn erlaubt */ function rateLimit(key) { const t = now(); sweepRateLimit(t); const list = (hits.get(key) || []).filter((stamp) => t - stamp < RATE_WINDOW_MS); if (list.length >= RATE_LIMIT) { hits.set(key, list); return { allowed: false, retryAfter: Math.max(1, Math.ceil((list[0] + RATE_WINDOW_MS - t) / 1000)) }; } list.push(t); hits.delete(key); // neu einfügen: Reihenfolge = zuletzt benutzt hits.set(key, list); while (hits.size > RATE_MAX_KEYS) hits.delete(hits.keys().next().value); return { allowed: true }; } // ---- Anmeldung async function authenticate(req) { const jwt = req.headers['x-aircard-jwt']; if (typeof jwt !== 'string' || jwt.length > 4096 || !JWT_CHARACTERS.test(jwt)) return null; const account = await appwrite.accountFromJwt(jwt); if (!account || !isId(account.$id)) return null; return { id: account.$id, isAdmin: Array.isArray(account.labels) && account.labels.includes('admin') }; } async function requireUser(req) { const user = await authenticate(req); if (!user) throw new HttpError(401, MSG.unauthorized); return user; } // ---- Dateien und Zeilen /** * Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im Galerie-Bucket. Metadaten wie Typ und * Größe prüft erst `checkFileContent`, damit fremde Dateien nichts darüber verraten. */ function checkFileExists(file) { if (!file) throw new HttpError(400, MSG.fileMissing); if (file.bucketId !== BUCKET) throw new HttpError(403, MSG.foreignFile); } /** * Besitz: frisch hochgeladene Dateien tragen delete("user:") und read("any"). Eine Datei ohne delete(ich) gehört * jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Zeilen-Dateien * hat der Aufrufer vorher mit `used` als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet. */ function checkFileOwnership(file, userId) { const permissions = Array.isArray(file.$permissions) ? file.$permissions : []; if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile); // Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie. if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken); } function checkFileContent(file) { if (!FILE_MIME_TYPES.has(file.mimeType)) throw new HttpError(400, MSG.fileType); if (!Number.isInteger(file.sizeOriginal) || file.sizeOriginal > MAX_FILE_BYTES) throw new HttpError(400, MSG.fileSize); } /** Liest den Anfang beider Dateien und verlangt Kartenformat: Bild 1536 x 969, Vorschau 480 x 302-304. */ async function checkImageSizes(data) { const [image, preview] = await Promise.all([ appwrite.readFileHead(BUCKET, data.imageFileId, IMAGE_HEAD_BYTES), appwrite.readFileHead(BUCKET, data.previewFileId, IMAGE_HEAD_BYTES), ]); if (image === null || preview === null) throw new HttpError(400, MSG.fileMissing); if (!isCardImageSize(readImageSize(image)) || !isCardPreviewSize(readImageSize(preview))) { throw new HttpError(400, MSG.imageFormat); } } const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId); /** * Die Galerie-Zeilen dieses Autors (auch ausgeblendete), höchstens MAX_PUBLICATIONS (mehr braucht der Aufrufer nicht: * ab dort ist das Limit erreicht). Fragt über `ownerId` ab (dafür gibt es einen Index; auf `imageFileId`/`previewFileId` * nicht). Fremde Zeilen müssen nicht geprüft werden: deren Dateien kann dieser Autor nicht verwenden (kein delete-Recht). */ function ownerRows(ownerId) { return appwrite.listRows(TABLE_GALLERY, [queryEqual('ownerId', [ownerId]), queryLimit(MAX_PUBLICATIONS)]); } /** Die private Karte muss es geben und dem Konto gehören (Appwrite gibt dem Besitzer read("user:") auf seine Zeile). */ async function ownsSourceCard(userId, cardId) { const card = await appwrite.getRow(TABLE_CARDS, cardId); return card !== null && Array.isArray(card.$permissions) && card.$permissions.includes(`read("user:${userId}")`); } /** * Setzt die Rechte beider Dateien. Wartet auf beide Aufrufe, auch wenn einer scheitert (die Sperre der Karte darf * nicht freigegeben werden, solange noch ein PUT unterwegs ist), und wirft danach den ersten Fehler. * @returns {Promise} je Datei das Ergebnis; null heißt: Datei gibt es nicht (mehr) */ async function setFilePermissions(row, permissions) { const results = await Promise.allSettled(fileIdsOf(row).map((fileId) => appwrite.updateFilePermissions(BUCKET, fileId, permissions))); const failed = results.find((result) => result.status === 'rejected'); if (failed) throw failed.reason; return results.map((result) => result.value); } /** Dateien zuerst, Zeile zuletzt: bleibt etwas hängen, heilt der nächste Versuch (alles ist wiederholbar). */ async function hideCard(id, row, extra = {}) { await setFilePermissions(row, hiddenFilePermissions(row.ownerId)); await appwrite.updateRow(TABLE_GALLERY, id, { hidden: true, ...extra }, hiddenRowPermissions(row.ownerId)); } async function deleteReports(galleryId) { for (let round = 0; round < REPORT_DELETE_ROUNDS; round += 1) { const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [galleryId]), queryLimit(REPORT_LIST_LIMIT)]); if (reports.length === 0) return; for (const report of reports) await appwrite.deleteRow(TABLE_REPORTS, report.$id); } } /** reportCount = Anzahl Meldungszeilen der Karte; ab 3 wird ausgeblendet. */ async function syncReports(id, row) { const reports = await appwrite.listRows(TABLE_REPORTS, [queryEqual('galleryId', [id]), queryLimit(REPORT_LIST_LIMIT)]); const count = reports.length; if (count >= HIDE_AT_REPORTS) { await hideCard(id, row, { reportCount: count }); say(`card ${id} hidden automatically (${count} reports)`); } else if (row.reportCount !== count) { await appwrite.updateRow(TABLE_GALLERY, id, { reportCount: count }); } } // ---- Endpunkte async function publish({ req, raw }) { const user = await requireUser(req); const validation = validatePublish(parseJsonObject(raw)); if (!validation.ok) throw new HttpError(400, validation.message); const data = validation.data; const [image, preview] = await Promise.all([ appwrite.getFile(BUCKET, data.imageFileId), appwrite.getFile(BUCKET, data.previewFileId), ]); checkFileExists(image); checkFileExists(preview); // Je Autor nacheinander: Limit, Prüfung „Dateien schon verwendet“ und Anlegen der Zeile dürfen sich nicht überholen. return withLock(`owner:${user.id}`, async () => { const rows = await ownerRows(user.id); if (rows.length >= MAX_PUBLICATIONS) throw new HttpError(429, MSG.tooManyPublications); if (!(await ownsSourceCard(user.id, data.sourceCardId))) throw new HttpError(403, MSG.notYourCard); const used = new Set(rows.flatMap(fileIdsOf)); if (used.has(data.imageFileId) || used.has(data.previewFileId)) throw new HttpError(403, MSG.filesTaken); for (const file of [image, preview]) checkFileOwnership(file, user.id); for (const file of [image, preview]) checkFileContent(file); await checkImageSizes(data); const id = galleryRowId(user.id, data.sourceCardId); try { await appwrite.createRow( TABLE_GALLERY, id, { title: data.title, issuer: data.issuer, // ",a,b," statt "a,b": der Tag-Filter der Galerie sucht ",," und trifft so nur ganze Tags. tags: data.tags.length > 0 ? `,${data.tags.join(',')},` : '', displayName: data.displayName, imageFileId: data.imageFileId, previewFileId: data.previewFileId, sourceCardId: data.sourceCardId, ownerId: user.id, hidden: false, reportCount: 0, searchText: buildSearchText(data), }, visibleRowPermissions(), ); } catch (err) { if (isDuplicate(err)) throw new HttpError(409, MSG.alreadyPublished); throw err; } // Kanonischer Zustand: Beide Dateien tragen genau read("any") (kein update/delete, keine fremden Rollen). // Erst nach dem Anlegen der Zeile: ein 409 (dieselbe private Karte nochmal) fasst die Dateien nie an. // Scheitert das Festlegen, wird die Zeile zurückgenommen. try { const pinned = await setFilePermissions(data, visibleFilePermissions()); if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing); } catch (err) { let rolledBack = true; await appwrite.deleteRow(TABLE_GALLERY, id).catch(() => { rolledBack = false; say(`rollback failed for card ${id}`); }); // Nur wenn keine Zeile mehr auf die Dateien zeigt: schon festgelegte Dateien bekommen delete(owner) zurück, // damit der Client den Fehlversuch aufräumen kann (sonst blieben sie öffentlich und unlöschbar liegen). if (rolledBack) await setFilePermissions(data, uploadFilePermissions(user.id)).catch(() => {}); throw err; } say(`card ${id} published`); return { status: 201, body: { id } }; }); } async function withdraw({ req, raw }) { const user = await requireUser(req); const body = parseJsonObject(raw); if (!isId(body.id)) throw new HttpError(400, MSG.badCardId); const id = body.id; return withLock(id, async () => { const row = await appwrite.getRow(TABLE_GALLERY, id); if (!row) throw new HttpError(404, MSG.cardGone); 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. for (const fileId of fileIdsOf(row)) await appwrite.deleteFile(BUCKET, fileId); await deleteReports(id); await appwrite.deleteRow(TABLE_GALLERY, id); say(`card ${id} withdrawn${row.ownerId === user.id ? '' : ' by admin'}`); return { status: 200, body: { ok: true } }; }); } async function reportCard({ req, raw }) { const body = parseJsonObject(raw); if (!isId(body.id)) throw new HttpError(400, MSG.badCardId); 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 weiter je Anschluss (IPv4 voll, IPv6 /64). 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 withLock(id, async () => { const row = await appwrite.getRow(TABLE_GALLERY, id); if (!row || row.hidden === true) throw new HttpError(404, MSG.cardGone); let isNew = true; try { await appwrite.createRow(TABLE_REPORTS, reportRowId(id, reporter), { galleryId: 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(`card ${id} reported (${body.reason})`); return { status: 200, body: isNew ? { ok: true } : { schonGemeldet: 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.badCardId); if (typeof body.sichtbar !== 'boolean') throw new HttpError(400, MSG.badVisibility); const id = body.id; return withLock(id, async () => { const row = await appwrite.getRow(TABLE_GALLERY, id); if (!row) throw new HttpError(404, MSG.cardGone); if (body.sichtbar) { // Dateien, dann Meldungen, Zeile zuletzt: sichtbar wird die Karte erst, wenn alles andere steht. await setFilePermissions(row, visibleFilePermissions(row.ownerId)); await deleteReports(id); await appwrite.updateRow(TABLE_GALLERY, id, { hidden: false, reportCount: 0 }, visibleRowPermissions()); say(`card ${id} made visible by admin`); } else { await hideCard(id, row); say(`card ${id} hidden by admin`); } return { status: 200, body: { ok: true } }; }); } const ROUTES = new Map([ ['GET /api/gesundheit', { name: 'gesundheit', run: async () => ({ status: 200, body: { ok: true } }) }], ['POST /api/veroeffentlichen', { name: 'veroeffentlichen', json: true, run: publish }], ['POST /api/zurueckziehen', { name: 'zurueckziehen', json: true, run: withdraw }], ['POST /api/melden', { name: 'melden', json: true, run: reportCard }], ['POST /api/admin/sichtbarkeit', { name: 'admin/sichtbarkeit', json: true, run: setVisibility }], ]); async function handle(req, res) { const pathname = (req.url || '').split('?')[0]; const route = ROUTES.get(`${req.method} ${pathname}`); let status = 500; try { if (!route) throw new HttpError(404, MSG.notFound); let raw = ''; if (route.json) { if (!JSON_CONTENT_TYPE.test(req.headers['content-type'] || '')) { req.resume(); throw new HttpError(415, MSG.jsonOnly); } raw = await readRawBody(req); } const result = await route.run({ req, raw }); status = result.status; sendJson(res, result.status, result.body); } catch (err) { let headers = {}; let message = MSG.internal; if (err instanceof HttpError) { status = err.status; message = err.message; headers = err.headers; } else if (err instanceof AppwriteError) { // Keine Antwort, Appwrite überlastet (429) oder Serverfehler: nicht erreichbar (502). Alles andere // (falscher Key, fehlende Tabelle, ungültige Struktur) ist ein Fehler hier (500). const unavailable = err.status === 0 || err.status === 429 || err.status >= 500; status = unavailable ? 502 : 500; message = unavailable ? MSG.unavailable : MSG.internal; say(`${req.method} /api/${route.name} -> ${status}: appwrite error status=${err.status} type=${safeToken(err.type)}`); } else { status = 500; say(`${req.method} /api/${route ? route.name : '-'} -> 500: unexpected error ${safeToken(err && err.name)}`); } if (!res.headersSent) sendJson(res, status, { error: message }, headers); } } function handler(req, res) { return handle(req, res).catch((err) => { // handle() fängt alles selbst ab; das hier sichert nur gegen Fehler beim Senden. say(`unexpected error ${safeToken(err && err.name)}`); try { res.destroy(); } catch { // schon geschlossen } }); } handler.rateLimitEntries = () => hits.size; return handler; } module.exports = { createApp, visibleRowPermissions, hiddenRowPermissions, uploadFilePermissions, visibleFilePermissions, hiddenFilePermissions, reporterHash, reporterNetwork, clientIp, normalizeIp, readImageSize, validatePublish, buildSearchText, galleryRowId, reportRowId, };