Veröffentlichen lehnt mit 403 "Diese Bilder sind bereits veröffentlicht oder
gesperrt." ab, wenn eine Datei kein read("any") trägt (ausgeblendet) oder von
einer Galerie-Zeile des Autors schon als Bild oder Vorschau verwendet wird
(auch überkreuz, auch ausgeblendete Karten). Die Nutzungsprüfung liest die
Zeilen des Autors über den vorhandenen ownerId-Index seitenweise mit Cursor
(auf die Datei-ID-Spalten gibt es keinen Index) und läuft zusammen mit dem
Anlegen je Autor nacheinander. 92 Tests.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
174 lines
7.9 KiB
JavaScript
174 lines
7.9 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 [])
|
|
* 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 };
|