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>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 06:40:44 +00:00
1 parent a65c075004
commit 19c8151c7e
15 files changed
+980 -53

No files matched your search

+52 -5
View File
@@ -10,7 +10,10 @@
* 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` und `logo_reports` und die Buckets `gallery-images`, `card-images`, `logo-images`.
* `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`).
@@ -18,8 +21,8 @@
* 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 (Aufruf scheitert, hängt, wird verzögert) und swapContentAfterPermissions
* (Dateiinhalt ändert sich nach dem nächsten Rechte-Update).
* 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');
@@ -105,6 +108,16 @@ const COLUMNS = {
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. */
@@ -121,6 +134,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
logos: new Map(),
logo_likes: new Map(),
logo_reports: new Map(),
profiles: new Map(),
};
const files = new Map();
const contents = new Map();
@@ -162,6 +176,11 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
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);
@@ -183,6 +202,20 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
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
@@ -213,6 +246,13 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
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));
@@ -244,7 +284,9 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
if (injectionIndex !== -1) {
const [entry] = injections.splice(injectionIndex, 1);
if (entry.hang) return;
if (entry.delayMs) {
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);
@@ -279,7 +321,9 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
const data = body.data || {};
const problem = validateData(table, data, { partial: false });
if (problem) return error(400, 'document_invalid_structure', problem);
const row = addRow(table, body.rowId, { ...DEFAULTS[table], ...data }, body.permissions || []);
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);
}
@@ -302,6 +346,7 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
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);
@@ -360,10 +405,12 @@ async function startFakeAppwrite({ project = 'proj-test', key = 'KEY-test-secret
addRawUser,
addFile,
addCard,
addProfile,
addRow,
failNext,
hangNext,
delayNext,
holdNext,
swapContentAfterPermissions,
row: (table, id) => tables[table].get(id) || null,
rows: (table) => [...tables[table].values()],