Files
AirCardIOS/deploy/api/appwrite.js
T
ClaudeandClaude Sonnet 5.5 4c90ee49e8 fix(api): Bildmaße prüfen, Dateien ohne delete(owner), Limits, Melder-Netz
- Beim Veröffentlichen liest die API die ersten 64 Bytes beider Dateien (PNG-IHDR, WebP VP8/VP8L/VP8X) und verlangt Bild 1536x969,
  Vorschau 480x302-304, sonst 400 "Das Bild hat nicht das Kartenformat."
- Veröffentlichte und ausgeblendete Dateien tragen kein delete(owner) mehr (Abweichung von Spec §7): kein Löschen und Neuanlegen
  derselben Datei-ID nach dem Veröffentlichen; scheitert das Festlegen, bekommen die Dateien delete(owner) für den Client-Aufräumer zurück
- Melder-Identität aus dem IPv4-/24 bzw. IPv6-/48; das Rate-Limit zählt weiter je Anschluss
- Höchstens 20 Veröffentlichungen je Konto (429); sourceCardId muss eine eigene Zeile in `cards` sein (403)
- Steuer-, Nullbreiten- und Bidi-Zeichen in Titel, Herausgeber, Anzeigename und Tags abgelehnt
- Tags als ",a,b," gespeichert (exakter Tag-Filter)
- fehlgeschlagenes Zurücknehmen wird mit `rollback failed for card <id>` protokolliert
- README: Endpunkt-Tabelle, Regeln, Scopes (documents.*), akzeptiertes Risiko X-Real-Ip im Docker-Netz

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 16:30:28 +00:00

207 lines
9.3 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
* 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)}`;
/** 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 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 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 };