Files
AirCardIOS/deploy/api/common.js
T
ClaudeandClaude Opus 5.5 19c8151c7e feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)
- 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>
2026-10-05 06:40:44 +00:00

395 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
};