- 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>
700 lines
27 KiB
JavaScript
700 lines
27 KiB
JavaScript
'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,
|
||
};
|