Files
ClaudeandClaude Opus 5.5 dc5de99611 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>
2026-10-04 19:20:25 +00:00

222 lines
10 KiB
JavaScript

'use strict';
/**
* Schmaler REST-Client für Appwrite 1.8 (Node 22, nur Bordmittel) mit dem Server-Key des API-Dienstes.
*
* createAppwrite({ endpoint, project, key, fetchImpl?, database?, timeoutMs? })
* getRow(table, id) Zeile oder null (404 row_not_found)
* listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit, queryCursorAfter);
* eine Antwort ohne `rows`-Array ist ein Fehler (kein stilles [])
* countRows(table, queries) Anzahl aller Treffer (`total` einer Abfrage mit limit(1), ohne eigenes limit
* in `queries`); eine Antwort ohne gültiges `total` ist ein Fehler
* createRow(table, rowId, data, permissions) doppelte ID -> AppwriteError mit status 409
* updateRow(table, id, data, permissions?) Rechte nur ändern, wenn übergeben
* deleteRow(table, id) true, false bei 404 row_not_found (wiederholbar)
* getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null
* readFileHead(bucket, id, maxBytes) die ersten Bytes des Dateiinhalts (Buffer) oder null; liest nie mehr als
* nötig und bricht den Rest der Übertragung ab
* updateFilePermissions(bucket, id, permissions) Datei oder null (404 storage_file_not_found)
* deleteFile(bucket, id) true, false bei 404 storage_file_not_found
* accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
*
* Als „fehlt“ gilt nur ein 404 mit dem erwarteten Typ (row_not_found, storage_file_not_found). Jedes andere 404
* (table_not_found, database_not_found, storage_bucket_not_found, unbekannte Route, 404 ohne JSON) ist ein Fehler:
* so verschwindet ein Konfigurationsfehler nicht als „Karte nicht gefunden“.
* Redirects werden nie verfolgt (`redirect: 'error'`), damit der Server-Key nicht an eine andere Adresse geht.
* Jeder Aufruf bricht nach `timeoutMs` (10 s) ab. Jedes Pfadsegment wird mit encodeURIComponent kodiert;
* "." und ".." lehnt der Client ab, weil encodeURIComponent sie nicht verändert und die URL-Normalisierung
* sie sonst als Verzeichniswechsel auslegen würde.
*
* Fehler: AppwriteError mit `status` (0 = keine Antwort), `type` (Appwrite-Typ, "timeout" oder "unreachable").
* Der Text enthält nie Key oder JWT.
*/
const DEFAULT_TIMEOUT_MS = 10_000;
const DEFAULT_HEAD_BYTES = 64;
const ROW_NOT_FOUND = 'row_not_found';
const FILE_NOT_FOUND = 'storage_file_not_found';
class AppwriteError extends Error {
constructor(status, type, message) {
super(message);
this.name = 'AppwriteError';
this.status = status;
this.type = type;
}
}
/** Appwrite-Abfrage als JSON-String. */
function queryEqual(attribute, values) {
return JSON.stringify({ method: 'equal', attribute, values });
}
function queryLimit(count) {
return JSON.stringify({ method: 'limit', values: [count] });
}
/** Nächste Seite nach der Zeile mit dieser ID (Reihenfolge: Anlegereihenfolge). */
function queryCursorAfter(id) {
return JSON.stringify({ method: 'cursorAfter', values: [id] });
}
/** Ein Pfadsegment: nur nichtleere Zeichenketten, nie "." oder "..", immer kodiert. */
function segment(value) {
if (typeof value !== 'string' || value === '' || value === '.' || value === '..') {
throw new TypeError('Ungültiges Pfadsegment');
}
return encodeURIComponent(value);
}
function createAppwrite({ endpoint, project, key, fetchImpl = globalThis.fetch, database = 'aircard', timeoutMs = DEFAULT_TIMEOUT_MS }) {
if (typeof endpoint !== 'string' || endpoint === '') throw new TypeError('endpoint fehlt');
if (typeof project !== 'string' || project === '') throw new TypeError('project fehlt');
if (typeof key !== 'string' || key === '') throw new TypeError('key fehlt');
if (typeof fetchImpl !== 'function') throw new TypeError('fetch fehlt');
const base = endpoint.replace(/\/+$/, '');
const rowsPath = (table) => `/tablesdb/${segment(database)}/tables/${segment(table)}/rows`;
const filePath = (bucket, id) => `/storage/buckets/${segment(bucket)}/files/${segment(id)}`;
const rowsUrl = (table, queries) => {
const search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&');
return `${rowsPath(table)}${search === '' ? '' : `?${search}`}`;
};
/** Liest höchstens `limit` Bytes vom Anfang der Antwort und bricht den Rest ab (Dateien können 5 MiB groß sein). */
async function readHead(response, limit) {
if (!response.body) return Buffer.from(await response.arrayBuffer()).subarray(0, limit);
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (size < limit) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(Buffer.from(value));
size += value.length;
}
} finally {
await reader.cancel().catch(() => {});
}
return Buffer.concat(chunks).subarray(0, limit);
}
/**
* Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er.
* Mit `headBytes` ist `body` bei 2xx ein Buffer mit den ersten Bytes der Antwort statt geparstem JSON.
*/
async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [], headBytes = 0 } = {}) {
const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
const init = { method, headers: requestHeaders, redirect: 'error', signal: AbortSignal.timeout(timeoutMs) };
if (body !== undefined) {
requestHeaders['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
let response;
let text;
try {
response = await fetchImpl(`${base}${path}`, init);
if (headBytes > 0 && response.status >= 200 && response.status < 300) {
return { status: response.status, body: await readHead(response, headBytes) };
}
text = await response.text();
} catch (err) {
// Fehlertexte von fetch bleiben draußen (sie könnten Adresse und Header enthalten).
const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
throw timedOut
? new AppwriteError(0, 'timeout', 'Appwrite antwortet nicht rechtzeitig')
: new AppwriteError(0, 'unreachable', 'Appwrite nicht erreichbar');
}
let parsed = null;
if (text !== '') {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
}
if (response.status >= 200 && response.status < 300) return { status: response.status, body: parsed };
const type = parsed && typeof parsed.type === 'string' ? parsed.type : 'unknown';
if (response.status === 404 && missingTypes.includes(type)) return { status: 404, body: null };
const message = parsed && typeof parsed.message === 'string' ? parsed.message : `HTTP ${response.status}`;
throw new AppwriteError(response.status, type, message);
}
return {
async getRow(table, id) {
const result = await send('GET', `${rowsPath(table)}/${segment(id)}`, { missingTypes: [ROW_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async listRows(table, queries = []) {
const result = await send('GET', rowsUrl(table, queries));
if (!result.body || !Array.isArray(result.body.rows)) {
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
}
return result.body.rows;
},
/** Zählt die Treffer einer Abfrage über `total` (Appwrite zählt bis 5000): es wird höchstens eine Zeile übertragen. */
async countRows(table, queries = []) {
const result = await send('GET', rowsUrl(table, [...queries, queryLimit(1)]));
const total = result.body && result.body.total;
if (!Number.isInteger(total) || total < 0) {
throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite');
}
return total;
},
async createRow(table, rowId, data, permissions) {
const result = await send('POST', rowsPath(table), { body: { rowId, data, permissions } });
return result.body;
},
async updateRow(table, id, data, permissions) {
const body = { data };
if (Array.isArray(permissions)) body.permissions = permissions;
const result = await send('PATCH', `${rowsPath(table)}/${segment(id)}`, { body });
return result.body;
},
async deleteRow(table, id) {
const result = await send('DELETE', `${rowsPath(table)}/${segment(id)}`, { missingTypes: [ROW_NOT_FOUND] });
return result.status !== 404;
},
async getFile(bucket, id) {
const result = await send('GET', filePath(bucket, id), { missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async readFileHead(bucket, id, maxBytes = DEFAULT_HEAD_BYTES) {
const result = await send('GET', `${filePath(bucket, id)}/view`, { missingTypes: [FILE_NOT_FOUND], headBytes: maxBytes });
return result.status === 404 ? null : result.body;
},
async updateFilePermissions(bucket, id, permissions) {
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, missingTypes: [FILE_NOT_FOUND] });
return result.status === 404 ? null : result.body;
},
async deleteFile(bucket, id) {
const result = await send('DELETE', filePath(bucket, id), { missingTypes: [FILE_NOT_FOUND] });
return result.status !== 404;
},
/** Prüft ein Nutzer-JWT: Konto aus GET /account (ohne Server-Key), null bei 401. */
async accountFromJwt(jwt) {
try {
const result = await send('GET', '/account', { headers: { 'X-Appwrite-JWT': jwt }, withKey: false });
return result.body;
} catch (err) {
if (err instanceof AppwriteError && err.status === 401) return null;
throw err;
}
},
};
}
module.exports = { createAppwrite, AppwriteError, queryEqual, queryLimit, queryCursorAfter };