feat(api): aircard-api für Veröffentlichen, Melden und Admin
Neuer abhängigkeitsfreier Node-22-Dienst in deploy/api/ (appwrite.js, app.js,
server.js), der alle Galerie-Schreibzugriffe mit dem Appwrite-Server-Key
ausführt: POST /api/veroeffentlichen, /zurueckziehen, /melden (Rate-Limit
10/h je Melder, IPv6 auf /64, Auto-Ausblenden ab 3), /admin/sichtbarkeit und
GET /api/gesundheit.
- JWT-Prüfung über GET /account (Header X-Aircard-Jwt), Admin = Label admin
- IDs per Muster geprüft, Pfadsegmente kodiert, "." und ".." abgelehnt
- deterministische Zeilen-IDs (Galerie, Meldung): Appwrite-409 ersetzt jede
Doppelt-Prüfung; Sperre je Karte gegen verlorene Zählerstände
- Dateiprüfung: Bucket, delete("user:<ich>"), PNG/WebP, höchstens 5 MiB
- Appwrite-Aufrufe mit 10-s-Zeitlimit, Logs ohne Key, Salz, JWT und IPs
- Tests gegen einen Fake-Appwrite im Speicher (70 Tests), Compose-Service
aircard-api (Router aircard-gallery-api) und README ergänzt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e4f4ffacc
commit
dd1597862a
11 files changed
+2659
-13
No files matched your search
@@ -0,0 +1,158 @@
|
||||
'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)
|
||||
* listRows(table, queries) Array von Zeilen; queries = JSON-Strings (queryEqual, queryLimit)
|
||||
* 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 (wiederholbar)
|
||||
* getFile(bucket, id) Datei-Metadaten ($permissions, bucketId, mimeType, sizeOriginal) oder null
|
||||
* updateFilePermissions(bucket, id, permissions) Datei oder null (404)
|
||||
* deleteFile(bucket, id) true, false bei 404
|
||||
* accountFromJwt(jwt) Nutzer ($id, labels) oder null (401); OHNE Server-Key
|
||||
*
|
||||
* 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;
|
||||
|
||||
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] });
|
||||
}
|
||||
|
||||
/** 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 404, sonst wirft er. */
|
||||
async function send(method, path, { body, headers = {}, withKey = true, tolerate = [] } = {}) {
|
||||
const requestHeaders = { 'X-Appwrite-Project': project, ...headers };
|
||||
if (withKey) requestHeaders['X-Appwrite-Key'] = key;
|
||||
const init = { method, headers: requestHeaders, 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 };
|
||||
if (tolerate.includes(response.status)) return { status: response.status, body: null };
|
||||
const type = parsed && typeof parsed.type === 'string' ? parsed.type : 'unknown';
|
||||
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)}`, { tolerate: [404] });
|
||||
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}`}`);
|
||||
return Array.isArray(result.body && result.body.rows) ? 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)}`, { tolerate: [404] });
|
||||
return result.status !== 404;
|
||||
},
|
||||
|
||||
async getFile(bucket, id) {
|
||||
const result = await send('GET', filePath(bucket, id), { tolerate: [404] });
|
||||
return result.status === 404 ? null : result.body;
|
||||
},
|
||||
|
||||
async updateFilePermissions(bucket, id, permissions) {
|
||||
const result = await send('PUT', filePath(bucket, id), { body: { permissions }, tolerate: [404] });
|
||||
return result.status === 404 ? null : result.body;
|
||||
},
|
||||
|
||||
async deleteFile(bucket, id) {
|
||||
const result = await send('DELETE', filePath(bucket, id), { tolerate: [404] });
|
||||
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 };
|
||||
Reference in new issue
Block a user