Files
ClaudeandClaude Opus 5.5 19c8151c7e feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)
- Schema: Tabelle `profiles` (Zeilen-ID = Konto-ID, `username` und `usernameKey`
  je 20 Zeichen, Unique-Index `username_key` auf `usernameKey`, rowSecurity an,
  keine Tabellenrechte) im Setup-Skript.
- Neues Modul `deploy/api/profiles.js`: `POST /api/profil` legt das Profil an
  oder ändert es (Zeilenrecht genau read("any"), Sperre je Konto). Muster,
  Sperrliste und Schlüssel (`usernameKey`) stehen in `common.js`; vergeben ->
  409 `usernameTaken`, auch wenn erst der Unique-Index bei einem Rennen
  entscheidet. Dazu die MSG-Texte `usernameInvalid`, `usernameReserved`,
  `usernameTaken`, `usernameRequired`.
- `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten 403
  `usernameRequired`, wenn das Konto kein Profil hat (`hasProfile`, nach der
  Anmeldung und vor allen anderen Prüfungen).
- Fake-Appwrite: Tabelle `profiles` mit Unique-Index (409), `addProfile`,
  `holdNext`. API-Dienst besteht jetzt aus sechs Dateien (READMEs).
- Client: die vier API-Texte in `API_MESSAGES` und `en.ts` (Gleichlauf mit MSG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:40:44 +00:00

663 lines
28 KiB
JavaScript

'use strict';
/**
* Routen und Logik des API-Dienstes `aircard-api` (Node 22, nur Bordmittel). Alle Schreibzugriffe auf die
* Community-Galerie, die Community-Logos und die Benutzernamen laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
* Gemeinsame Helfer, Fehlertexte und -codes: common.js. Die Logo-Endpunkte stehen in logos.js, der Endpunkt der Benutzernamen in
* profiles.js; dieses Modul gibt ihnen Anmeldung, Sperre je Schlüssel, das (gemeinsame) Rate-Limit und das Log.
*
* POST /api/veroeffentlichen Nutzer-JWT {title, issuer?, tags?, displayName?, imageFileId, previewFileId, sourceCardId}
* 401 kein/ungültiges JWT · 403 `usernameRequired` (das Konto hat noch keinen Benutzernamen; vor allen anderen Prüfungen) ·
* 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}
* POST /api/logos/hochladen · /api/logos/herz · /api/logos/melden · /api/logos/loeschen · /api/admin/logo-sichtbarkeit
* siehe logos.js
* POST /api/profil siehe profiles.js
* 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) -> Benutzername (403, nur Veröffentlichen und
* Logo hochladen) -> JSON und Felder (400) -> Logik.
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>} (siehe common.js).
* Appwrite-Fehler: 502 (keine Antwort, 429, 5xx; code `unavailable`) bzw. 500 (alle anderen, z. B. falscher Key oder
* fehlende Tabelle; code `internal`).
* 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 { AppwriteError, queryEqual, queryLimit } = require('./appwrite.js');
const {
MAX_PUBLICATIONS,
HIDE_AT_REPORTS,
IMAGE_HEAD_BYTES,
REASONS,
CONTROL_CHARACTERS,
MSG,
HttpError,
isId,
isPlainObject,
isDuplicate,
parseJsonObject,
readImageSize,
hash32,
hmacHex,
reporterHash,
reporterNetwork,
normalizeIp,
clientIp,
visibleRowPermissions,
hiddenRowPermissions,
uploadFilePermissions,
visibleFilePermissions,
hiddenFilePermissions,
checkFileExists,
checkFileOwnership,
} = require('./common.js');
const { createLogoRoutes } = require('./logos.js');
const { createProfileRoutes, hasProfile } = require('./profiles.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 REPORT_LIST_LIMIT = 100;
const REPORT_DELETE_ROUNDS = 50;
/** 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 JWT_CHARACTERS = /^[\x21-\x7e]+$/;
// ---- Reine Helfer ----------------------------------------------------------------------------------
/** 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}`);
}
/** 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 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 ------------------------------------------------------------------------------------
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)));
});
}
/** 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);
}
};
}
// ---- 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- und Logo-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 Anschluss (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports` und `logo_reports`).
// Ein Zähler für Karten- und Logo-Meldungen: logos.js bekommt `rateLimit` von hier.
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 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:<id>") 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<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);
// Ohne Benutzernamen geht nichts weiter: vor allen anderen Prüfungen, es wird nichts angelegt oder angefasst.
if (!(await hasProfile(appwrite, user.id))) throw new HttpError(403, MSG.usernameRequired);
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, BUCKET);
checkFileExists(preview, BUCKET);
// 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 ",<tag>," 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 }],
// Logos (logos.js): bekommen Anmeldung, Sperre je Schlüssel, das gemeinsame Rate-Limit und das Log von hier.
...createLogoRoutes({ appwrite, salt, say, withLock, rateLimit, requireUser }),
// Benutzernamen (profiles.js): bekommen Anmeldung, Sperre je Schlüssel und das Log von hier.
...createProfileRoutes({ appwrite, say, withLock, requireUser }),
]);
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;
let code = 'internal';
if (err instanceof HttpError) {
status = err.status;
message = err.message;
code = err.code;
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;
code = unavailable ? 'unavailable' : '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, code }, 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,
};