'use strict'; /** * Gemeinsame Helfer des API-Dienstes `aircard-api` für die Karten (app.js), die Logos (logos.js) und die Benutzernamen * (profiles.js): Fehlertexte und -codes, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße, Muster und Sperrliste der Benutzernamen. * Nur reine Funktionen und Konstanten, kein Zustand. * * Fehlerantworten sind immer {error: , code: }. Texte ohne festen Schlüssel (dynamische * Prüftexte wie „Der Titel ist zu lang (höchstens 60 Zeichen).“) haben bei Status 400 den code `badRequest`, sonst `error`. */ const crypto = require('node:crypto'); const net = require('node:net'); const { AppwriteError } = require('./appwrite.js'); /** Höchstens so viele Veröffentlichungen je Konto (ausgeblendete zählen mit); Zurückziehen gibt einen Platz frei. */ const MAX_PUBLICATIONS = 20; /** Höchstens so viele Logos je Konto (ausgeblendete zählen mit); Löschen gibt einen Platz frei. */ const MAX_LOGOS = 30; /** Ab so vielen verschiedenen Meldern wird eine Karte bzw. ein Logo ausgeblendet. */ const HIDE_AT_REPORTS = 3; /** So viele Bytes vom Dateianfang genügen für PNG-IHDR und die WebP-Kopfzeilen. */ const IMAGE_HEAD_BYTES = 64; const REASONS = ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges']; const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/; /** Benutzername: 3-20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, Punkt, Unterstrich, Bindestrich (nur ASCII). */ const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/; /** * Gesperrte Benutzernamen als Schlüssel (klein geschrieben, siehe `usernameKey`): Rollen, der Dienst selbst und Platzhalter, die nach * einem Konto aussehen. Jeder Eintrag passt zu USERNAME_PATTERN (sonst wäre er wirkungslos: der Name scheiterte schon als ungültig). */ const RESERVED_USERNAMES = Object.freeze([ 'admin', 'administrator', 'root', 'system', 'support', 'moderator', 'mod', 'aircard', 'aircardios', 'aircard-ios', 'aircard.ios', 'aircard_ios', 'webklar', 'anonym', 'anonymous', 'null', 'undefined', 'team', 'staff', 'official', 'offiziell', 'hilfe', 'help', 'kontakt', 'contact', 'info', ]); const RESERVED_USERNAME_SET = new Set(RESERVED_USERNAMES); // 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]/; /** * Alle Fehlertexte der API, Schlüssel = `code` der Fehlerantwort. Die Texte sind paarweise verschieden (ein Test prüft das): * `codeFor` findet den Schlüssel über den Text. Die Clients übersetzen Meldungen über den Schlüssel; darum ist MSG ein * schlichtes Objekt aus Texten, das dieses Modul unverändert exportiert. */ 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.', // Logos logoGone: 'Dieses Logo ist nicht (mehr) verfügbar.', badLogoId: 'Ungültige Logo-ID.', logoNameMissing: 'Bitte gib einen Namen an.', logoFileType: 'Das Logo muss eine PNG-Datei sein.', logoFileSize: 'Das Logo ist größer als 1 MB.', logoFormat: 'Das Logo hat ungültige Maße (8 × 8 bis 720 × 250 Pixel).', logoTaken: 'Diese Datei wird schon für ein Logo verwendet.', tooManyLogos: `Du hast schon ${MAX_LOGOS} Logos hochgeladen. Lösche eins, um ein neues hochzuladen.`, badHeart: 'Ungültige Angabe zum Herz.', badMono: 'Ungültige Angabe zu „einfarbig“.', // Benutzernamen usernameInvalid: 'Der Benutzername muss 3–20 Zeichen lang sein, mit einem Buchstaben oder einer Zahl beginnen und darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.', usernameReserved: 'Dieser Benutzername ist nicht erlaubt.', usernameTaken: 'Dieser Benutzername ist schon vergeben.', usernameRequired: 'Bitte wähle zuerst einen Benutzernamen.', tooLarge: 'Die Anfrage ist zu groß.', jsonOnly: 'Nur application/json wird unterstützt.', unavailable: 'Der Dienst ist gerade nicht erreichbar.', internal: 'Interner Fehler.', }; // ---- Fehler ---------------------------------------------------------------------------------------- /** Text -> Schlüssel (die Texte in MSG sind paarweise verschieden, sonst wäre der Code nicht eindeutig). */ const CODE_BY_TEXT = new Map(Object.entries(MSG).map(([key, text]) => [text, key])); /** * Der `code` einer Fehlerantwort: der MSG-Schlüssel mit genau diesem Text, sonst `badRequest` bei Status 400 und * `error` bei jedem anderen Status. */ function codeFor(status, message) { return CODE_BY_TEXT.get(message) || (status === 400 ? 'badRequest' : 'error'); } class HttpError extends Error { constructor(status, message, headers = {}, code = codeFor(status, message)) { super(message); this.status = status; this.headers = headers; this.code = code; } } // ---- Reine Helfer ---------------------------------------------------------------------------------- function isId(value) { return typeof value === 'string' && ID_PATTERN.test(value); } /** * Der Schlüssel eines Benutzernamens (Spalte `usernameKey`): der Name klein geschrieben. Über ihn ist ein Name nur einmal * vergeben, egal wie er geschrieben ist („Kenso“ und „kenso“ sind derselbe). Für Zeichenketten, die schon USERNAME_PATTERN erfüllen. */ function usernameKey(name) { return name.toLowerCase(); } /** Steht der Schlüssel dieses Namens auf der Sperrliste? (In jeder Schreibweise: „Admin“, „AirCard-IOS“.) */ function isReservedUsername(name) { return RESERVED_USERNAME_SET.has(usernameKey(name)); } function assertOwner(owner) { if (!isId(owner)) throw new TypeError('Ungültige Nutzer-ID'); return owner; } /** Sichtbare Zeile (Karte oder Logo): 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")']; } /** Herz (Zeile in `logo_likes`): lesen darf nur das Konto selbst. */ function likeRowPermissions(userId) { assertOwner(userId); return [`read("user:${userId}")`]; } /** * 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` (Karten) und `uploadLogo` (Logos); die API setzt sie danach 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); } 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 keine Karte und kein Logo 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); } 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; } 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; } function isDuplicate(err) { return err instanceof AppwriteError && err.status === 409; } // ---- Dateiprüfungen (Karten und Logos) ----------------------------------------------------------------- /** * Erste Prüfung (ohne Wissen über den Autor): die Datei gibt es und liegt im erwarteten Bucket. Metadaten wie Typ und * Größe prüft der Aufrufer erst nach `checkFileOwnership`, damit fremde Dateien nichts darüber verraten. */ function checkFileExists(file, bucket) { 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:") 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 Dateien schon * verwendeter Zeilen hat der Aufrufer vorher 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 Dateien tragen read("any"); ausgeblendete (gesperrte) Dateien nie. if (!permissions.includes('read("any")')) throw new HttpError(403, MSG.filesTaken); } module.exports = { MAX_PUBLICATIONS, MAX_LOGOS, HIDE_AT_REPORTS, IMAGE_HEAD_BYTES, REASONS, ID_PATTERN, USERNAME_PATTERN, RESERVED_USERNAMES, CONTROL_CHARACTERS, MSG, codeFor, HttpError, isId, usernameKey, isReservedUsername, isPlainObject, isDuplicate, parseJsonObject, readImageSize, hash32, hmacHex, reporterHash, reporterNetwork, normalizeIp, clientIp, visibleRowPermissions, hiddenRowPermissions, likeRowPermissions, uploadFilePermissions, visibleFilePermissions, hiddenFilePermissions, checkFileExists, checkFileOwnership, };