- 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>
222 lines
10 KiB
JavaScript
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 };
|