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:
1 parent
dd1597862a
commit
cd55d4c891
8 files changed
+423
-31
No files matched your search
+60
-11
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user