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
@@ -0,0 +1,335 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Gemeinsame Helfer des API-Dienstes `aircard-api` für die Karten (app.js) und die Logos (logos.js): Fehlertexte und
|
||||
* -codes, Prüfungen, Rechte-Strings, Melder-Hash, Bildmaße. Nur reine Funktionen und Konstanten, kein Zustand.
|
||||
*
|
||||
* Fehlerantworten sind immer {error: <deutscher Text>, code: <Schlüssel in MSG>}. 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}$/;
|
||||
// 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“.',
|
||||
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);
|
||||
}
|
||||
|
||||
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:<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 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,
|
||||
CONTROL_CHARACTERS,
|
||||
MSG,
|
||||
codeFor,
|
||||
HttpError,
|
||||
isId,
|
||||
isPlainObject,
|
||||
isDuplicate,
|
||||
parseJsonObject,
|
||||
readImageSize,
|
||||
hash32,
|
||||
hmacHex,
|
||||
reporterHash,
|
||||
reporterNetwork,
|
||||
normalizeIp,
|
||||
clientIp,
|
||||
visibleRowPermissions,
|
||||
hiddenRowPermissions,
|
||||
likeRowPermissions,
|
||||
uploadFilePermissions,
|
||||
visibleFilePermissions,
|
||||
hiddenFilePermissions,
|
||||
checkFileExists,
|
||||
checkFileOwnership,
|
||||
};
|
||||
Reference in new issue
Block a user