feat(api): Logo-Endpunkte, Fehlercodes und gemeinsame Helfer
- common.js (aus app.js herausgelöst, Verhalten gleich): MSG, HttpError, Prüfungen, Rechte-Strings, Melder-Hash,
Bildmaße und die Dateiprüfungen checkFileExists/checkFileOwnership. Neu: die Logo-Texte in MSG, codeFor,
likeRowPermissions. app.js exportiert weiter alles wie bisher.
- Fehlerantworten sind jetzt {error, code}: code = MSG-Schlüssel des Textes, dynamische Prüftexte badRequest,
Appwrite-Fehler unavailable (502) bzw. internal (500).
- logos.js: POST /api/logos/hochladen, /herz, /melden, /loeschen und /api/admin/logo-sichtbarkeit nach dem Vorbild der
Karten-Endpunkte (Datei prüfen, Zeile anlegen, Datei auf read("any") festlegen, sonst Rücknahme; likeCount und
reportCount immer aus countRows; ein gemeinsames Rate-Limit für Karten- und Logo-Meldungen).
- appwrite.js: countRows. fake-appwrite.js: Tabellen logos, logo_likes, logo_reports mit Spaltenlängen, Bucket
logo-images und total in den Listen-Antworten.
- Tests: logos.test.js (59), code-Fälle und MSG-Eindeutigkeit in app.test.js (+7), countRows in appwrite.test.js (+2);
alle 111 bisherigen Tests unverändert grün (179 gesamt).
- Doku: api/README.md (Endpunkte, Fehlercodes, Logo-Regeln), deploy/README.md (Betrieb kopiert fünf Dateien).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88abc2061d
commit
dc5de99611
10 files changed
+2181
-286
No files matched your search
+47
-254
@@ -2,7 +2,9 @@
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Community-Galerie und die Community-Logos laufen hier über den Appwrite-Server-Key (siehe appwrite.js).
|
||||
* Gemeinsame Helfer, Fehlertexte und -codes: common.js. Die Logo-Endpunkte stehen in logos.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 · 400 Validierung oder Datei fehlt/falsches Format/falsche Bildmaße · 403 Datei nicht meine /
|
||||
@@ -14,21 +16,50 @@
|
||||
* 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
|
||||
* 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) -> 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).
|
||||
* 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 crypto = require('node:crypto');
|
||||
const net = require('node:net');
|
||||
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 TABLE_GALLERY = 'gallery';
|
||||
const TABLE_REPORTS = 'reports';
|
||||
@@ -38,13 +69,8 @@ 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;
|
||||
/** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */
|
||||
const MAX_PUBLICATIONS = 20;
|
||||
const REPORT_DELETE_ROUNDS = 50;
|
||||
/** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */
|
||||
const IMAGE_HEAD_BYTES = 64;
|
||||
/** 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;
|
||||
@@ -57,90 +83,11 @@ 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}$/;
|
||||
// Steuerzeichen (C0, DEL, C1), Nullbreiten- und Bidi-Zeichen (U+200B-200F, U+202A-202E, U+2060-206F) und das BOM (U+FEFF):
|
||||
// sie sind unsichtbar, ordnen Text um oder lassen Titel leer aussehen. Dasselbe Muster steht in publish-dialog.ts.
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/;
|
||||
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.',
|
||||
filesTaken: 'Diese Bilder sind bereits veröffentlicht oder gesperrt.',
|
||||
imageFormat: 'Das Bild hat nicht das Kartenformat.',
|
||||
notYourCard: 'Diese Karte gehört nicht zu deinem Konto.',
|
||||
tooManyPublications: `Du hast schon ${MAX_PUBLICATIONS} Karten veröffentlicht. Zieh eine zurück, um eine neue zu veröffentlichen.`,
|
||||
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")'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Frisch hochgeladene, noch nicht veröffentlichte Datei: lesen darf jeder, löschen der Autor (der Client räumt damit
|
||||
* Fehlversuche auf). Nur so kommen Dateien in `publish`; die API setzt sie beim Veröffentlichen auf `visibleFilePermissions`.
|
||||
*/
|
||||
function uploadFilePermissions(owner) {
|
||||
assertOwner(owner);
|
||||
return ['read("any")', `delete("user:${owner}")`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Veröffentlichte Datei: nur read("any"), kein delete(owner). Abweichung von Spec §7 (Ruling): sonst könnte der Autor die
|
||||
* Datei nach dem Veröffentlichen löschen und unter derselben ID mit anderem Inhalt neu anlegen (Bild-Tausch). Zurückziehen
|
||||
* löscht die Dateien serverseitig mit dem Key.
|
||||
*/
|
||||
function visibleFilePermissions() {
|
||||
return ['read("any")'];
|
||||
}
|
||||
|
||||
/** Ausgeblendete Datei: nur Autor und Admins lesen; ebenfalls kein delete(owner). */
|
||||
function hiddenFilePermissions(owner) {
|
||||
assertOwner(owner);
|
||||
return [`read("user:${owner}")`, 'read("label:admin")'];
|
||||
}
|
||||
|
||||
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}`);
|
||||
@@ -151,128 +98,12 @@ 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt eine Client-Adresse: { kind: 'v4', text } (auch IPv4-gemappte IPv6), { kind: 'v6', groups } (acht Zahlen),
|
||||
* { kind: 'other', text } (keine Adresse) oder { kind: 'none' } (kein Text).
|
||||
*/
|
||||
function parseIp(ip) {
|
||||
if (typeof ip !== 'string') return { kind: 'none' };
|
||||
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 { kind: 'v4', text };
|
||||
if (net.isIPv6(text)) {
|
||||
const groups = expandIpv6(text);
|
||||
if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff) {
|
||||
return { kind: 'v4', text: `${groups[6] >> 8}.${groups[6] & 255}.${groups[7] >> 8}.${groups[7] & 255}` };
|
||||
}
|
||||
return { kind: 'v6', groups };
|
||||
}
|
||||
return { kind: 'other', text: text.toLowerCase().slice(0, 64) };
|
||||
}
|
||||
|
||||
const hexGroups = (groups, count) => groups.slice(0, count).map((group) => group.toString(16)).join(':');
|
||||
|
||||
/**
|
||||
* Vereinheitlicht eine Client-Adresse für das Rate-Limit (je Anschluss): 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) {
|
||||
const parsed = parseIp(ip);
|
||||
if (parsed.kind === 'none') return '';
|
||||
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 4)}::/64`;
|
||||
return parsed.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Netz einer Client-Adresse für die Melder-Identität: IPv4 -> /24, IPv6 -> /48 (IPv4-gemappte IPv6 wie IPv4).
|
||||
* So zählen drei Adressen aus demselben Netz als ein Melder und blenden eine Karte nicht aus.
|
||||
*/
|
||||
function reporterNetwork(ip) {
|
||||
const parsed = parseIp(ip);
|
||||
if (parsed.kind === 'none') return '';
|
||||
if (parsed.kind === 'v4') return `${parsed.text.split('.').slice(0, 3).join('.')}.0/24`;
|
||||
if (parsed.kind === 'v6') return `${hexGroups(parsed.groups, 3)}::/48`;
|
||||
return parsed.text;
|
||||
}
|
||||
|
||||
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) || '';
|
||||
}
|
||||
|
||||
function hmacHex(salt, text) {
|
||||
return crypto.createHmac('sha256', salt).update(text).digest('hex');
|
||||
}
|
||||
|
||||
/** HMAC-SHA256 (Hex) des Netzes der IP (IPv4 /24, IPv6 /48) mit dem Server-Salz: die Melder-Identität. */
|
||||
function reporterHash(salt, ip) {
|
||||
return hmacHex(salt, reporterNetwork(ip));
|
||||
}
|
||||
|
||||
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 PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
/**
|
||||
* Bildmaße aus den ersten Bytes einer Datei: PNG (IHDR) oder WebP (VP8, VP8L, VP8X); sonst null (auch bei zu kurzem Anfang).
|
||||
* @returns {{width: number, height: number} | null}
|
||||
*/
|
||||
function readImageSize(bytes) {
|
||||
if (!Buffer.isBuffer(bytes)) return null;
|
||||
if (bytes.length >= 24 && bytes.subarray(0, 8).equals(PNG_SIGNATURE) && bytes.toString('latin1', 12, 16) === 'IHDR') {
|
||||
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
||||
}
|
||||
if (bytes.length >= 16 && bytes.toString('latin1', 0, 4) === 'RIFF' && bytes.toString('latin1', 8, 12) === 'WEBP') {
|
||||
const kind = bytes.toString('latin1', 12, 16);
|
||||
if (kind === 'VP8 ' && bytes.length >= 30 && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) {
|
||||
return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff };
|
||||
}
|
||||
if (kind === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
|
||||
const bits = bytes.readUInt32LE(21);
|
||||
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
|
||||
}
|
||||
if (kind === 'VP8X' && bytes.length >= 30) {
|
||||
return { width: bytes.readUIntLE(24, 3) + 1, height: bytes.readUIntLE(27, 3) + 1 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -357,14 +188,6 @@ function buildSearchText({ title = '', issuer = '', tags = [] }) {
|
||||
|
||||
// ---- 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, {
|
||||
@@ -416,17 +239,6 @@ function readRawBody(req) {
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -448,10 +260,6 @@ function createKeyedLock() {
|
||||
};
|
||||
}
|
||||
|
||||
function isDuplicate(err) {
|
||||
return err instanceof AppwriteError && err.status === 409;
|
||||
}
|
||||
|
||||
// ---- Die App ---------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -466,7 +274,7 @@ 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
|
||||
// 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;
|
||||
@@ -489,7 +297,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
}
|
||||
const withLock = createKeyedLock();
|
||||
|
||||
// ---- Rate-Limit je Melder-Hash (im Speicher, geht bei Neustart verloren; Dedup liegt in `reports`)
|
||||
// ---- 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;
|
||||
|
||||
@@ -533,27 +342,6 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
}
|
||||
|
||||
// ---- Dateien und Zeilen
|
||||
/**
|
||||
* Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im Galerie-Bucket. Metadaten wie Typ und
|
||||
* Größe prüft erst `checkFileContent`, damit fremde Dateien nichts darüber verraten.
|
||||
*/
|
||||
function checkFileExists(file) {
|
||||
if (!file) throw new HttpError(400, MSG.fileMissing);
|
||||
if (file.bucketId !== BUCKET) throw new HttpError(403, MSG.foreignFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Besitz: frisch hochgeladene Dateien tragen delete("user:<ich>") und read("any"). Eine Datei ohne delete(ich) gehört
|
||||
* jemand anderem oder ist schon veröffentlicht/gesperrt (dann trägt sie kein delete(owner) mehr; eigene Zeilen-Dateien
|
||||
* hat der Aufrufer vorher mit `used` als „schon verwendet“ abgewiesen); eine ohne read("any") ist ausgeblendet.
|
||||
*/
|
||||
function checkFileOwnership(file, userId) {
|
||||
const permissions = Array.isArray(file.$permissions) ? file.$permissions : [];
|
||||
if (!permissions.includes(`delete("user:${userId}")`)) throw new HttpError(403, MSG.foreignFile);
|
||||
// Frisch hochgeladene Galerie-Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie.
|
||||
if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -637,8 +425,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
appwrite.getFile(BUCKET, data.imageFileId),
|
||||
appwrite.getFile(BUCKET, data.previewFileId),
|
||||
]);
|
||||
checkFileExists(image);
|
||||
checkFileExists(preview);
|
||||
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 () => {
|
||||
@@ -782,6 +570,8 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
['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 }),
|
||||
]);
|
||||
|
||||
async function handle(req, res) {
|
||||
@@ -807,9 +597,11 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
} 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
|
||||
@@ -817,12 +609,13 @@ function createApp({ appwrite, salt, now = Date.now, log = console.log }) {
|
||||
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 }, headers);
|
||||
if (!res.headersSent) sendJson(res, status, { error: message, code }, headers);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user