- Schema: Tabelle `profiles` (Zeilen-ID = Konto-ID, `username` und `usernameKey`
je 20 Zeichen, Unique-Index `username_key` auf `usernameKey`, rowSecurity an,
keine Tabellenrechte) im Setup-Skript.
- Neues Modul `deploy/api/profiles.js`: `POST /api/profil` legt das Profil an
oder ändert es (Zeilenrecht genau read("any"), Sperre je Konto). Muster,
Sperrliste und Schlüssel (`usernameKey`) stehen in `common.js`; vergeben ->
409 `usernameTaken`, auch wenn erst der Unique-Index bei einem Rennen
entscheidet. Dazu die MSG-Texte `usernameInvalid`, `usernameReserved`,
`usernameTaken`, `usernameRequired`.
- `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten 403
`usernameRequired`, wenn das Konto kein Profil hat (`hasProfile`, nach der
Anmeldung und vor allen anderen Prüfungen).
- Fake-Appwrite: Tabelle `profiles` mit Unique-Index (409), `addProfile`,
`holdNext`. API-Dienst besteht jetzt aus sechs Dateien (READMEs).
- Client: die vier API-Texte in `API_MESSAGES` und `en.ts` (Gleichlauf mit MSG).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
395 lines
15 KiB
JavaScript
395 lines
15 KiB
JavaScript
'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: <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}$/;
|
||
/** 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:<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,
|
||
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,
|
||
};
|