Files
AirCardIOS/deploy/api/appwrite.js
T
ClaudeandClaude Sonnet 5.5 7bbe0a9fe6 fix(api): Dateien bereits veröffentlichter oder gesperrter Karten nicht wiederverwenden
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>
2026-10-04 14:27:29 +00:00

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 };