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>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 14:24:05 +00:00
1 parent dd1597862a
commit cd55d4c891
8 files changed
+423 -31

No files matched your search

+60 -11
View File
@@ -19,6 +19,9 @@
* 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');
@@ -39,6 +42,8 @@ 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}$/;
@@ -173,6 +178,12 @@ 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 },
@@ -362,8 +373,27 @@ 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}`);
// 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`)
@@ -422,8 +452,16 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
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) {
await Promise.all(fileIdsOf(row).map((fileId) => appwrite.updateFilePermissions(BUCKET, fileId, 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). */
@@ -490,6 +528,17 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
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 } };
}
@@ -577,7 +626,6 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
]);
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;
@@ -605,23 +653,24 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
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)}`);
// 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(`unexpected error: ${err && err.name ? String(err.name).replace(/[^\w.-]/g, '').slice(0, 60) : 'unknown'}`);
say(`${req.method} /api/${route ? route.name : '-'} -> 500: unexpected error ${safeToken(err && err.name)}`);
}
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'}`);
say(`unexpected error ${safeToken(err && err.name)}`);
try {
res.destroy();
} catch {