Files
AirCardIOS/deploy/api/app.js
T
ClaudeandClaude Sonnet 5.5 dd1597862a feat(api): aircard-api für Veröffentlichen, Melden und Admin
Neuer abhängigkeitsfreier Node-22-Dienst in deploy/api/ (appwrite.js, app.js,
server.js), der alle Galerie-Schreibzugriffe mit dem Appwrite-Server-Key
ausführt: POST /api/veroeffentlichen, /zurueckziehen, /melden (Rate-Limit
10/h je Melder, IPv6 auf /64, Auto-Ausblenden ab 3), /admin/sichtbarkeit und
GET /api/gesundheit.

- JWT-Prüfung über GET /account (Header X-Aircard-Jwt), Admin = Label admin
- IDs per Muster geprüft, Pfadsegmente kodiert, "." und ".." abgelehnt
- deterministische Zeilen-IDs (Galerie, Meldung): Appwrite-409 ersetzt jede
  Doppelt-Prüfung; Sperre je Karte gegen verlorene Zählerstände
- Dateiprüfung: Bucket, delete("user:<ich>"), PNG/WebP, höchstens 5 MiB
- Appwrite-Aufrufe mit 10-s-Zeitlimit, Logs ohne Key, Salz, JWT und IPs
- Tests gegen einen Fake-Appwrite im Speicher (70 Tests), Compose-Service
  aircard-api (Router aircard-gallery-api) und README ergänzt

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

651 lines
24 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>}.
*/
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 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);
}
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.
const say = (message) => log(`[aircard-api] ${new Date().toISOString()} ${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);
async function setFilePermissions(row, permissions) {
await Promise.all(fileIdsOf(row).map((fileId) => appwrite.updateFilePermissions(BUCKET, fileId, permissions)));
}
/** 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;
}
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 started = Date.now();
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) {
status = 502;
message = MSG.unavailable;
say(`appwrite error: status=${err.status} type=${String(err.type).replace(/[^\w.-]/g, '').slice(0, 60)}`);
} else {
status = 500;
say(`unexpected error: ${err && err.name ? String(err.name).replace(/[^\w.-]/g, '').slice(0, 60) : 'unknown'}`);
}
if (!res.headersSent) sendJson(res, status, { error: message }, headers);
} finally {
if (route && route.name !== 'gesundheit') say(`${req.method} /api/${route.name} -> ${status} (${Date.now() - started} ms)`);
}
}
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: ${err && err.name ? String(err.name).replace(/[^\w.-]/g, '').slice(0, 60) : 'unknown'}`);
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,
};