Files
AirCardIOS/deploy/api/app.js
T
ClaudeandClaude Sonnet 5.5 cd55d4c891 fix(api): Log-Flut begrenzen und Appwrite-Fehler strenger behandeln
- Kein Zugriffsprotokoll je Anfrage mehr (abgewiesene Anfragen an das
  unauthentifizierte /api/melden loggten sonst unbegrenzt); nur noch
  Ereignis- und 5xx-Zeilen, höchstens 120 pro Minute mit Sammelzeile
- Compose: logging json-file, max-size 5m, max-file 2
- appwrite.js: 404 nur mit Typ row_not_found/storage_file_not_found als
  "fehlt", listRows ohne rows-Array wirft, redirect: error
- Appwrite-Fehler: 502 bei 0/429/5xx, sonst 500 "Interner Fehler."
- Veröffentlichen legt beide Dateien auf die sichtbaren Rechte fest
  (erst nach dem Anlegen der Zeile, Rollback bei Fehler)
- setFilePermissions mit Promise.allSettled, gemeinsamer safeToken-Helfer
- Tests: 85 (Log-Flut, Log-Budget, 404-Typen, Auth-Header, Konto-Antworten)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:24:05 +00:00

700 lines
27 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';
/**
* 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 · 403 Datei nicht meine / falscher Bucket
* 409 schon veröffentlicht · 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}
* 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`.
* Reihenfolge je Anfrage: Content-Type (415) -> Body lesen (413) -> JWT (401) -> JSON und Felder (400) -> Logik.
* Fehlerantworten sind immer {error: <deutscher Text>}.
* 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 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;
const REPORT_DELETE_ROUNDS = 50;
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}$/;
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
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.',
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")'];
}
function visibleFilePermissions(owner) {
assertOwner(owner);
return ['read("any")', `delete("user:${owner}")`];
}
function hiddenFilePermissions(owner) {
assertOwner(owner);
return [`read("user:${owner}")`, 'read("label:admin")', `delete("user:${owner}")`];
}
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));
}
/**
* Vereinheitlicht eine Client-Adresse für Hash und Rate-Limit: 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) {
if (typeof ip !== 'string') return '';
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 text;
if (net.isIPv6(text)) {
const groups = expandIpv6(text);
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
return `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}`;
}
return `${groups.slice(0, 4).map((group) => group.toString(16)).join(':')}::/64`;
}
return text.toLowerCase().slice(0, 64);
}
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) || '';
}
/** HMAC-SHA256 (Hex) der normalisierten IP mit dem Server-Salz. */
function reporterHash(salt, ip) {
return crypto.createHmac('sha256', salt).update(normalizeIp(ip)).digest('hex');
}
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 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<void>} 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
function checkFile(file, userId) {
if (!file) throw new HttpError(400, MSG.fileMissing);
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
if (file.bucketId !== BUCKET || !permissions.includes(`delete("user:${userId}")`)) {
throw new HttpError(403, MSG.foreignFile);
}
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);
}
const fileIdsOf = (row) => [row.imageFileId, row.previewFileId].filter(isId);
/**
* 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<Array>} 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),
]);
checkFile(image, user.id);
checkFile(preview, user.id);
const id = galleryRowId(user.id, data.sourceCardId);
try {
await appwrite.createRow(
TABLE_GALLERY,
id,
{
title: data.title,
issuer: data.issuer,
tags: 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 die sichtbaren Rechte (kein update, keine fremden Rollen).
// Erst nach dem Anlegen der Zeile: ein 409 darf die Rechte einer schon veröffentlichten (oder ausgeblendeten)
// Karte nie anfassen. Scheitert das Festlegen, wird die Zeile zurückgenommen.
try {
const pinned = await setFilePermissions(data, visibleFilePermissions(user.id));
if (pinned.some((file) => file === null)) throw new HttpError(400, MSG.fileMissing);
} catch (err) {
await appwrite.deleteRow(TABLE_GALLERY, 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;
const reporter = reporterHash(salt, clientIp(req));
const limit = rateLimit(reporter);
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,
visibleFilePermissions,
hiddenFilePermissions,
reporterHash,
clientIp,
normalizeIp,
validatePublish,
buildSearchText,
galleryRowId,
reportRowId,
};