'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 []) * 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 * 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 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)}`; /** Ein HTTP-Aufruf; liefert { status, body } für 2xx und für ein 404 mit einem Typ aus `missingTypes`, sonst wirft er. */ async function send(method, path, { body, headers = {}, withKey = true, missingTypes = [] } = {}) { 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); 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 search = queries.map((query) => `queries[]=${encodeURIComponent(query)}`).join('&'); const result = await send('GET', `${rowsPath(table)}${search === '' ? '' : `?${search}`}`); if (!result.body || !Array.isArray(result.body.rows)) { throw new AppwriteError(502, 'invalid_response', 'Unerwartete Antwort von Appwrite'); } return result.body.rows; }, 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 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 };