Files
AirCardIOS/deploy/api/fake-appwrite.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

479 lines
19 KiB
JavaScript

'use strict';
/**
* Nur für Tests: Appwrite im Speicher (Teilmenge der REST-API von 1.8.1) als echter HTTP-Server auf Port 0.
*
* GET /v1/account Header X-Appwrite-JWT (+ Projekt, ohne Key)
* GET|POST /v1/tablesdb/{db}/tables/{t}/rows POST {rowId, data, permissions}; doppelt -> 409
* GET|PATCH|DELETE .../rows/{id} PATCH {data?, permissions?}
* GET|PUT|DELETE /v1/storage/buckets/{b}/files/{f} PUT {permissions}
* GET /v1/storage/buckets/{b}/files/{f}/view Dateiinhalt (Bytes aus `content`)
*
* Die Tabelle `cards` ist nur zum Lesen da (Zeilen legt der Test mit `addCard` an). Es gibt außerdem die Tabellen
* `gallery`, `reports`, `logos`, `logo_likes`, `logo_reports` und `profiles` und die Buckets `gallery-images`, `card-images`,
* `logo-images`. `profiles` hat wie in Appwrite einen Unique-Index auf `usernameKey` (UNIQUE_INDEXES): eine zweite Zeile mit
* demselben Schlüssel, angelegt oder per PATCH geändert, antwortet 409 `row_already_exists` (so meldet Appwrite auch eine
* Verletzung des Unique-Index); Testhilfe: `addProfile`.
*
* Abfragen: `queries[]=<json>` mit den Methoden equal, cursorAfter und limit. Die Antwort enthält wie bei Appwrite `total`:
* die Zahl aller Treffer der equal-Abfragen, unabhängig von limit und cursorAfter (darauf baut `countRows`).
* Zeilen werden gegen die Spaltenlängen der echten Tabellen geprüft (document_invalid_structure), damit zu lange Werte im
* Test auffallen.
* Dateien werden nach ID gesucht und liefern ihren echten `bucketId` zurück (der Bucket in der URL zählt nicht),
* so lässt sich der Fall „Datei liegt in einem anderen Bucket“ nachstellen.
* Testhilfen: failNext/hangNext/delayNext/holdNext (Aufruf scheitert, hängt, wird verzögert, wartet auf die Freigabe des Tests)
* und swapContentAfterPermissions (Dateiinhalt ändert sich nach dem nächsten Rechte-Update).
*/
const http = require('node:http');
const BUCKETS = ['gallery-images', 'card-images', 'logo-images'];
/** PNG-Anfang (Signatur + IHDR) mit den Maßen; reicht für die Größenprüfung der API. */
function pngHeader(width, height) {
const head = Buffer.alloc(33);
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(head, 0);
head.writeUInt32BE(13, 8);
head.write('IHDR', 12, 'latin1');
head.writeUInt32BE(width, 16);
head.writeUInt32BE(height, 20);
head[24] = 8; // Bit-Tiefe
head[25] = 6; // RGBA
return head;
}
/** WebP-Anfang mit den Maßen; kind: 'VP8 ' (verlustbehaftet), 'VP8L' (verlustfrei) oder 'VP8X' (erweitert). */
function webpHeader(kind, width, height) {
const head = Buffer.alloc(40);
head.write('RIFF', 0, 'latin1');
head.writeUInt32LE(32, 4);
head.write('WEBP', 8, 'latin1');
head.write(kind, 12, 'latin1');
if (kind === 'VP8 ') {
head.writeUInt32LE(10, 16);
Buffer.from([0x30, 0x01, 0x00, 0x9d, 0x01, 0x2a]).copy(head, 20);
head.writeUInt16LE(width & 0x3fff, 26);
head.writeUInt16LE(height & 0x3fff, 28);
} else if (kind === 'VP8L') {
head.writeUInt32LE(5, 16);
head[20] = 0x2f;
head.writeUInt32LE(((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14), 21);
} else if (kind === 'VP8X') {
head.writeUInt32LE(10, 16);
head[20] = 0x10; // Alpha-Flag
head.writeUIntLE(width - 1, 24, 3);
head.writeUIntLE(height - 1, 27, 3);
} else {
throw new Error(`Unbekannte WebP-Art: ${kind}`);
}
return head;
}
const COLUMNS = {
gallery: {
title: { size: 60, required: true },
issuer: { size: 40 },
tags: { size: 120 },
displayName: { size: 30 },
imageFileId: { size: 36, required: true },
previewFileId: { size: 36, required: true },
sourceCardId: { size: 36 },
ownerId: { size: 36, required: true },
hidden: { type: 'boolean' },
reportCount: { type: 'integer' },
searchText: { size: 300 },
},
reports: {
galleryId: { size: 36, required: true },
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
reporterHash: { size: 64, required: true },
},
// Spalten wie in web/scripts/appwrite-setup.mjs (Community-Logos)
logos: {
name: { size: 40, required: true },
displayName: { size: 30 },
fileId: { size: 36, required: true },
ownerId: { size: 36, required: true },
mono: { type: 'boolean' },
likeCount: { type: 'integer', min: 0 },
hidden: { type: 'boolean' },
reportCount: { type: 'integer', min: 0 },
},
logo_likes: {
logoId: { size: 36, required: true },
userId: { size: 36, required: true },
},
logo_reports: {
logoId: { size: 36, required: true },
reason: { enum: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
reporterHash: { size: 64, required: true },
},
// Benutzernamen: Zeilen-ID = Konto-ID, `usernameKey` = der Name klein geschrieben (Unique-Index)
profiles: {
username: { size: 20, required: true },
usernameKey: { size: 20, required: true },
},
};
/** Unique-Indizes wie in web/scripts/appwrite-setup.mjs: je Tabelle die Spalten, deren Werte nur in einer Zeile vorkommen dürfen. */
const UNIQUE_INDEXES = {
profiles: ['usernameKey'],
};
/** Standardwerte der Spalten, die Appwrite beim Anlegen einsetzt, wenn die Anfrage sie weglässt. */
const DEFAULTS = {
gallery: { hidden: false, reportCount: 0 },
logos: { mono: false, likeCount: 0, hidden: false, reportCount: 0 },
};
async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret-value', database = 'aircard' } = {}) {
const tables = {
gallery: new Map(),
reports: new Map(),
cards: new Map(),
logos: new Map(),
logo_likes: new Map(),
logo_reports: new Map(),
profiles: new Map(),
};
const files = new Map();
const contents = new Map();
const users = new Map();
const calls = [];
const injections = [];
const swaps = new Map();
let sequence = 0;
function addUser(jwt, { id, labels = [] }) {
users.set(jwt, { $id: id, email: `${id}@example.test`, labels });
}
/** Beliebige Konto-Antwort für `GET /account` (z. B. ohne labels oder mit ungültiger ID). */
function addRawUser(jwt, account) {
users.set(jwt, account);
}
/**
* Datei mit Metadaten und Inhalt. `role` bestimmt den Standardinhalt: 'image' = Kartenbild 1536 x 969, 'preview' =
* Vorschau 480 x 303 (Standard: WebP ist die Vorschau, alles andere das Bild); `content` überschreibt ihn.
*/
function addFile({ id, bucket = 'gallery-images', owner, permissions, mimeType = 'image/png', size = 1000, role, content }) {
const kind = role || (mimeType === 'image/webp' ? 'preview' : 'image');
const [width, height] = kind === 'preview' ? [480, 303] : [1536, 969];
files.set(id, {
$id: id,
bucketId: bucket,
name: `${id}.png`,
$permissions: permissions || ['read("any")', `delete("user:${owner}")`],
mimeType,
sizeOriginal: size,
});
contents.set(id, content || (mimeType === 'image/webp' ? webpHeader('VP8 ', width, height) : pngHeader(width, height)));
}
/** Private Karte (Tabelle `cards`) mit den Rechten, die Appwrite dem Besitzer beim Anlegen gibt. */
function addCard(id, owner, permissions) {
return addRow('cards', id, { name: 'Karte' }, permissions || [`read("user:${owner}")`, `update("user:${owner}")`, `delete("user:${owner}")`]);
}
/** Benutzername eines Kontos (Tabelle `profiles`) mit den Rechten, die die API vergibt: Zeilen-ID = Konto-ID, öffentlich lesbar. */
function addProfile(userId, username = userId) {
return addRow('profiles', userId, { username, usernameKey: username.toLowerCase() }, ['read("any")']);
}
function addRow(table, id, data, permissions) {
const row = { $id: id, $createdAt: new Date(1_700_000_000_000 + (++sequence) * 1000).toISOString(), $permissions: permissions, ...data };
tables[table].set(id, row);
return row;
}
/** Nächster passender Aufruf (predicate(call) -> boolean) scheitert mit Status/Typ. */
function failNext(predicate, { status = 500, type = 'general_server_error' } = {}) {
injections.push({ predicate, status, type, hang: false });
}
/** Nächster passender Aufruf bekommt nie eine Antwort. */
function hangNext(predicate) {
injections.push({ predicate, hang: true });
}
/** Nächster passender Aufruf wird erst nach `delayMs` bearbeitet (Zustand ändert sich erst dann). */
function delayNext(predicate, delayMs) {
injections.push({ predicate, delayMs });
}
/**
* Nächster passender Aufruf wartet, bis die zurückgegebene Funktion aufgerufen wird (er steht dann schon in `calls`). So erzwingt
* ein Test eine Reihenfolge ohne Zeitannahmen, etwa: beide Anfragen haben vorab geprüft, bevor eine von ihnen schreibt.
* @returns {() => void} gibt den Aufruf frei
*/
function holdNext(predicate) {
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
injections.push({ predicate, gate });
return release;
}
/**
* Nach dem nächsten Rechte-Update (PUT) der Datei `id` hat sie den Inhalt `content` (einmalig). So lässt sich eine Datei
* nachstellen, die zwischen der ersten Prüfung und dem Festlegen der Rechte ausgetauscht wurde: der Anfang ist beim ersten
* Lesen gültig und danach ein anderer.
*/
function swapContentAfterPermissions(id, content) {
swaps.set(id, content);
}
function validateData(table, data, { partial }) {
const columns = COLUMNS[table];
for (const [name, value] of Object.entries(data)) {
const column = columns[name];
if (!column) return `Unknown attribute: ${name}`;
if (column.type === 'boolean' && typeof value !== 'boolean') return `Invalid ${name}`;
if (column.type === 'integer' && (!Number.isInteger(value) || (column.min !== undefined && value < column.min))) return `Invalid ${name}`;
if (column.enum && !column.enum.includes(value)) return `Invalid ${name}`;
if (column.size !== undefined) {
if (typeof value !== 'string') return `Invalid ${name}`;
if (value.length > column.size) return `Invalid ${name}: too long`;
}
}
if (!partial) {
for (const [name, column] of Object.entries(columns)) {
if (column.required && data[name] === undefined) return `Missing required attribute: ${name}`;
}
}
return null;
}
/** Hat eine andere Zeile der Tabelle schon diese Werte in den Spalten eines Unique-Index? (`values` = die Zeile, wie sie danach aussähe.) */
function violatesUnique(table, id, values) {
const columns = UNIQUE_INDEXES[table];
if (!columns) return false;
return [...tables[table].values()].some((row) => row.$id !== id && columns.every((name) => row[name] === values[name]));
}
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', async () => {
const raw = Buffer.concat(chunks).toString('utf8');
const [pathname, search = ''] = req.url.split('?');
const call = {
method: req.method,
path: pathname,
search,
headers: req.headers,
body: raw === '' ? null : safeParse(raw),
};
calls.push(call);
const reply = (status, payload) => {
if (status === 204) {
res.writeHead(204);
res.end();
return;
}
const text = JSON.stringify(payload);
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
res.end(text);
};
const error = (status, type, message = type) => reply(status, { message, code: status, type });
const injectionIndex = injections.findIndex((entry) => entry.predicate(call));
if (injectionIndex !== -1) {
const [entry] = injections.splice(injectionIndex, 1);
if (entry.hang) return;
if (entry.gate) {
await entry.gate;
} else if (entry.delayMs) {
await new Promise((resolve) => setTimeout(resolve, entry.delayMs));
} else {
return error(entry.status, entry.type);
}
}
const segments = pathname.split('/').filter(Boolean).map(decodeSegment);
if (segments[0] !== 'v1' || req.headers['x-appwrite-project'] !== project) {
return error(401, 'general_unauthorized_scope', 'Missing or wrong project');
}
// GET /v1/account (JWT, ohne Key)
if (segments.length === 2 && segments[1] === 'account' && req.method === 'GET') {
const user = users.get(req.headers['x-appwrite-jwt']);
if (!user) return error(401, 'user_jwt_invalid', 'Invalid JWT');
return reply(200, user);
}
if (req.headers['x-appwrite-key'] !== key) return error(401, 'general_unauthorized_scope', 'Missing or wrong key');
// Zeilen
if (segments[1] === 'tablesdb' && segments[3] === 'tables' && segments[5] === 'rows') {
const table = segments[4];
if (segments[2] !== database) return error(404, 'database_not_found');
if (!tables[table]) return error(404, 'table_not_found');
const store = tables[table];
if (segments.length === 6 && req.method === 'POST') {
const body = call.body || {};
if (typeof body.rowId !== 'string' || body.rowId === '') return error(400, 'general_argument_invalid', 'rowId is required');
if (store.has(body.rowId)) return error(409, 'row_already_exists');
const data = body.data || {};
const problem = validateData(table, data, { partial: false });
if (problem) return error(400, 'document_invalid_structure', problem);
const values = { ...DEFAULTS[table], ...data };
if (violatesUnique(table, body.rowId, values)) return error(409, 'row_already_exists');
const row = addRow(table, body.rowId, values, body.permissions || []);
return reply(201, row);
}
if (segments.length === 6 && req.method === 'GET') {
const found = runQueries([...store.values()], search);
if (found === null) return error(400, 'general_query_invalid');
return reply(200, found);
}
if (segments.length === 7) {
const row = store.get(segments[6]);
if (!row) return error(404, 'row_not_found');
if (req.method === 'GET') return reply(200, row);
if (req.method === 'DELETE') {
store.delete(segments[6]);
return reply(204);
}
if (req.method === 'PATCH') {
const body = call.body || {};
const data = body.data || {};
const problem = validateData(table, data, { partial: true });
if (problem) return error(400, 'document_invalid_structure', problem);
if (violatesUnique(table, segments[6], { ...row, ...data })) return error(409, 'row_already_exists');
Object.assign(row, data);
if (Array.isArray(body.permissions)) row.$permissions = body.permissions;
return reply(200, row);
}
}
}
// Dateiinhalt: GET .../files/{id}/view
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 7 && segments[6] === 'view' && req.method === 'GET') {
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
const file = files.get(segments[5]);
if (!file) return error(404, 'storage_file_not_found');
const content = contents.get(segments[5]);
res.writeHead(200, { 'Content-Type': file.mimeType, 'Content-Length': content.length });
res.end(content);
return;
}
// Dateien
if (segments[1] === 'storage' && segments[2] === 'buckets' && segments[4] === 'files' && segments.length === 6) {
if (!BUCKETS.includes(segments[3])) return error(404, 'storage_bucket_not_found');
const file = files.get(segments[5]);
if (!file) return error(404, 'storage_file_not_found');
if (req.method === 'GET') return reply(200, file);
if (req.method === 'DELETE') {
files.delete(segments[5]);
contents.delete(segments[5]);
return reply(204);
}
if (req.method === 'PUT') {
const body = call.body || {};
if (!Array.isArray(body.permissions)) return error(400, 'general_argument_invalid');
file.$permissions = body.permissions;
if (swaps.has(segments[5])) {
contents.set(segments[5], swaps.get(segments[5]));
swaps.delete(segments[5]);
}
return reply(200, file);
}
}
return error(404, 'general_route_not_found');
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
return {
url: `http://127.0.0.1:${port}`,
endpoint: `http://127.0.0.1:${port}/v1`,
project,
key,
calls,
addUser,
addRawUser,
addFile,
addCard,
addProfile,
addRow,
failNext,
hangNext,
delayNext,
holdNext,
swapContentAfterPermissions,
row: (table, id) => tables[table].get(id) || null,
rows: (table) => [...tables[table].values()],
file: (id) => files.get(id) || null,
close: async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
},
};
}
function decodeSegment(segment) {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function safeParse(text) {
try {
return JSON.parse(text);
} catch {
return { _unparsable: text };
}
}
/**
* Wendet equal, cursorAfter und limit an (Reihenfolge = Anlegereihenfolge); null bei unbekannter Abfrage.
* Liefert {total, rows}: `total` zählt alle Treffer der equal-Abfragen, `rows` ist die Seite nach cursorAfter und limit.
*/
function runQueries(rows, search) {
let result = rows;
let limit = 25;
let cursor = null;
for (const part of search.split('&').filter(Boolean)) {
const eq = part.indexOf('=');
const name = decodeURIComponent(part.slice(0, eq));
if (name !== 'queries[]') return null;
let query;
try {
query = JSON.parse(decodeURIComponent(part.slice(eq + 1)));
} catch {
return null;
}
if (query.method === 'equal') {
result = result.filter((row) => query.values.includes(row[query.attribute]));
} else if (query.method === 'limit') {
limit = query.values[0];
} else if (query.method === 'cursorAfter') {
cursor = query.values[0];
} else {
return null;
}
}
const total = result.length;
if (cursor !== null) {
const index = result.findIndex((row) => row.$id === cursor);
if (index === -1) return null;
result = result.slice(index + 1);
}
return { total, rows: result.slice(0, limit) };
}
module.exports = { startFakeAppwrite, pngHeader, webpHeader };