#!/usr/bin/env node /** * AirCard – Appwrite-Schema-Setup (idempotent) * * Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`, * die Tabellen `cards`, `gallery` und `reports` (TablesDB, mit Spalten und * Indizes) und die Buckets `card-images` und `gallery-images` an. * Reines `fetch`, keine Abhängigkeiten, Node 22+. * * Aufruf (vom Repo-Wurzelverzeichnis): * node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check] * * Umgebung: * AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht) * AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1 * * Ohne --check: fehlende Objekte werden angelegt (Indizes erst, wenn alle Spalten * der Tabelle `available` sind). Bestehende Objekte werden nur verglichen – * Abweichungen werden gemeldet, nie geändert. * Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht. * * Ausgabe: eine Zeile je Objekt – * OK: … FEHLT: … ANGELEGT: … ABWEICHUNG: ist , erwartet * * Exit-Codes: 0 = Schema vollständig, 1 = fehlt/weicht ab, 2 = Abbruch * (fehlender Key, ungültiger Aufruf, Server-/Zugriffsfehler). * * Der API-Key wird nie ausgegeben (weder Wert noch Header). */ import { pathToFileURL } from 'node:url'; export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1'; // --------------------------------------------------------------------------- // Soll-Zustand // --------------------------------------------------------------------------- const str = (key, size, required) => ({ key, type: 'string', size, required }); export const DESIRED = { database: { id: 'aircard', name: 'AirCard' }, // Reihenfolge ist Ablauf: cards, gallery, reports (je Tabelle: Spalten, dann Indizes). tables: [ { id: 'cards', name: 'Cards', permissions: ['create("users")'], rowSecurity: true, columns: [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)], indexes: [], }, { // Zeilenrechte setzt nur die API (aircard-api), Clients dürfen nichts auf Tabellenebene. id: 'gallery', name: 'Gallery', permissions: [], rowSecurity: true, columns: [ str('title', 60, true), str('issuer', 40, false), str('tags', 120, false), str('displayName', 30, false), str('imageFileId', 36, true), str('previewFileId', 36, true), str('sourceCardId', 36, false), str('ownerId', 36, true), { key: 'hidden', type: 'boolean', required: false, default: false }, { key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 }, str('searchText', 300, false), ], indexes: [ { key: 'search_text', type: 'fulltext', columns: ['searchText'] }, { key: 'owner', type: 'key', columns: ['ownerId'] }, { key: 'hidden', type: 'key', columns: ['hidden'] }, ], }, { // Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API. id: 'reports', name: 'Reports', permissions: ['read("label:admin")'], rowSecurity: false, columns: [ str('galleryId', 36, true), { key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true }, str('reporterHash', 64, true), ], indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }], }, ], buckets: [ { id: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'], compression: 'none', antivirus: false, }, { id: 'gallery-images', name: 'Gallery Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 5242880, allowedFileExtensions: ['png', 'webp'], compression: 'none', antivirus: false, }, ], }; // --------------------------------------------------------------------------- // Reine Helfer (ohne Netzwerk, einzeln testbar) // --------------------------------------------------------------------------- /** 404 heißt „Objekt fehlt" – außer der ganze Projekt-Kontext fehlt (project_not_found). */ export function isMissing(status, body) { return status === 404 && body?.type !== 'project_not_found'; } const fmt = (v) => { if (typeof v === 'string') return v; if (Array.isArray(v)) return `[${v.join(', ')}]`; return JSON.stringify(v) ?? 'undefined'; }; const sorted = (list) => [...(list ?? [])].sort(); const sameList = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b)); function mismatch(field, actual, expected) { return `${field} ist ${fmt(actual)}, erwartet ${fmt(expected)}`; } /** Vergleich der Tabelle: `$permissions` (Reihenfolge egal) und `rowSecurity`. */ export function compareTable(actual, desired) { const out = []; if (!sameList(actual.$permissions, desired.permissions)) { out.push(mismatch('$permissions', sorted(actual.$permissions), sorted(desired.permissions))); } if (actual.rowSecurity !== desired.rowSecurity) { out.push(mismatch('rowSecurity', actual.rowSecurity, desired.rowSecurity)); } return out; } /** * Vergleich einer Spalte, je nach Typ: * string → type, size, required * boolean → type, required, default * integer → type, required, default (min/max der Antwort sind riesige Standardwerte, werden nicht verglichen) * enum → elements (sortiert), required (Appwrite meldet Enum-Spalten als type `string` + format `enum`) * Außerdem immer `status` (erwartet `available`). */ export function compareColumn(actual, desired) { const out = []; if (desired.type !== 'enum' && actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type)); if (desired.type === 'string' && actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size)); if (desired.type === 'enum' && !sameList(actual.elements, desired.elements)) { out.push(mismatch('elements', sorted(actual.elements), sorted(desired.elements))); } if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required)); if ((desired.type === 'boolean' || desired.type === 'integer') && actual.default !== desired.default) { out.push(mismatch('default', actual.default, desired.default)); } if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available')); return out; } /** Vergleich eines Index: `type`, `columns` (ältere Antworten: `attributes`; Reihenfolge zählt), `status`. */ export function compareIndex(actual, desired) { const out = []; const columns = Array.isArray(actual.columns) ? actual.columns : (actual.attributes ?? []); if (actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type)); if (JSON.stringify(columns) !== JSON.stringify(desired.columns)) out.push(mismatch('columns', columns, desired.columns)); if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available')); return out; } /** Vergleich des Buckets: Rechte, `fileSecurity`, `maximumFileSize`, `allowedFileExtensions` (sortiert). */ export function compareBucket(actual, desired) { const out = []; if (!sameList(actual.$permissions, desired.permissions)) { out.push(mismatch('$permissions', sorted(actual.$permissions), sorted(desired.permissions))); } if (actual.fileSecurity !== desired.fileSecurity) { out.push(mismatch('fileSecurity', actual.fileSecurity, desired.fileSecurity)); } if (actual.maximumFileSize !== desired.maximumFileSize) { out.push(mismatch('maximumFileSize', actual.maximumFileSize, desired.maximumFileSize)); } if (!sameList(actual.allowedFileExtensions, desired.allowedFileExtensions)) { out.push(mismatch('allowedFileExtensions', sorted(actual.allowedFileExtensions), sorted(desired.allowedFileExtensions))); } return out; } // --------------------------------------------------------------------------- // Zugriff auf die Appwrite-REST-API // --------------------------------------------------------------------------- const REQUEST_TIMEOUT_MS = 20_000; /** Abbruch mit verständlicher Meldung (Exit 2). Enthält nie den Key. */ class SetupError extends Error {} const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** `api(method, path, body?)` → `{ status, body }` für Aufrufe gegen Endpoint + Pfad. */ function createApi({ endpoint, key, project }) { const base = endpoint.replace(/\/+$/, ''); return async function api(method, path, body) { let res; try { res = await fetch(`${base}${path}`, { method, headers: { 'Content-Type': 'application/json', 'X-Appwrite-Project': project, 'X-Appwrite-Key': key, }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (err) { // Nur Fehlercode bzw. -name ausgeben, nie das Request-Objekt. throw new SetupError(`Appwrite nicht erreichbar (${base}): ${err?.cause?.code ?? err?.name ?? 'unbekannter Fehler'}.`); } const text = await res.text(); let json; try { json = text ? JSON.parse(text) : undefined; } catch { json = { message: text.replace(/\s+/g, ' ').slice(0, 200) }; } if (typeof json?.message === 'string') json.message = json.message.split(key).join('***'); return { status: res.status, body: json }; }; } /** Klare deutsche Meldung für eine unerwartete Antwort. */ function describeFailure(status, body, what, project) { const detail = [body?.type, body?.message].filter((part) => typeof part === 'string' && part).join(': '); const tail = detail ? ` Server: ${detail}` : ''; if (status === 404 && body?.type === 'project_not_found') { return `Das Projekt „${project}” existiert auf dem Server nicht. Bitte zuerst docs/appwrite-console.md abarbeiten und die AIRCARD_APPWRITE_PROJECT_ID in /home/webklar/apps/.env prüfen.`; } if (status === 401 || status === 403) { return `${what}: Zugriff verweigert (HTTP ${status}). API-Key und Scopes prüfen (databases, tables, columns, buckets – jeweils read/write).${tail}`; } if (status >= 500) return `${what}: Serverfehler (HTTP ${status}).${tail}`; return `${what}: unerwartete Antwort (HTTP ${status}).${tail}`; } /** GET: Objekt oder `null` (fehlt). Jeder andere Fehler bricht ab. */ async function getObject(ctx, path, what) { const res = await ctx.api('GET', path); if (res.status === 200) return res.body; if (isMissing(res.status, res.body)) return null; throw new SetupError(describeFailure(res.status, res.body, what, ctx.project)); } async function createObject(ctx, path, body, what) { const res = await ctx.api('POST', path, body); if (res.status >= 200 && res.status < 300) return res.body; throw new SetupError(describeFailure(res.status, res.body, what, ctx.project)); } // --------------------------------------------------------------------------- // Objekte sicherstellen // --------------------------------------------------------------------------- const DB_ID = DESIRED.database.id; const tablePath = (tableId) => `/tablesdb/${DB_ID}/tables/${tableId}`; const columnPath = (tableId, key) => `${tablePath(tableId)}/columns/${key}`; const indexPath = (tableId, key) => `${tablePath(tableId)}/indexes/${key}`; /** Gibt zurück, ob die Datenbank (jetzt) existiert. */ export async function ensureDatabase(ctx) { const { id, name } = DESIRED.database; const label = `Datenbank ${id}`; if (await getObject(ctx, `/tablesdb/${id}`, label)) { ctx.ok(label); return true; } if (ctx.check) { ctx.missing(label); return false; } await createObject(ctx, '/tablesdb', { databaseId: id, name }, label); ctx.created(label); return true; } /** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */ export async function ensureTable(ctx, table, parentExists) { const label = `Tabelle ${table.id}`; const actual = parentExists ? await getObject(ctx, tablePath(table.id), label) : null; if (actual) { const deviations = compareTable(actual, table); if (deviations.length) ctx.deviations(table.id, deviations); else ctx.ok(label); return true; } if (ctx.check) { ctx.missing(label); return false; } await createObject( ctx, `/tablesdb/${DB_ID}/tables`, { tableId: table.id, name: table.name, permissions: table.permissions, rowSecurity: table.rowSecurity }, label, ); ctx.created(label); return true; } /** Spalten und Indizes entstehen asynchron (`processing`) und werden über ihren eigenen Pfad abgefragt. */ const CHILD_KINDS = { column: { noun: 'Spalte', plural: 'Spalte(n)', path: columnPath }, index: { noun: 'Index', plural: 'Index(e)', path: indexPath }, }; /** * Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten (`kind` = 'column') * bzw. Indizes (`kind` = 'index') der Tabelle `available` sind. * Gibt eine Map Schlüssel → aktueller Zustand zurück. */ export async function waitAvailable(ctx, tableId, keys, kind = 'column') { const { noun, plural, path } = CHILD_KINDS[kind]; const deadline = Date.now() + ctx.timeoutMs; const done = new Map(); let pending = [...keys]; for (;;) { const still = []; for (const key of pending) { const label = `${noun} ${tableId}.${key}`; const actual = await getObject(ctx, path(tableId, key), label); if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`); if (actual.status === 'available') { done.set(key, actual); } else if (actual.status === 'failed' || actual.status === 'stuck') { throw new SetupError(`${label}: Status „${actual.status}“. ${noun} in der Console prüfen, ggf. löschen, und das Skript erneut starten.`); } else { still.push(key); } } if (!still.length) return done; if (Date.now() >= deadline) { throw new SetupError( `${plural} ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`, ); } pending = still; await sleep(ctx.pollMs); } } /** REST-Pfad und Body zum Anlegen einer Spalte (je Typ eine eigene Route). */ function columnRequest(table, col) { const path = `${tablePath(table.id)}/columns/${col.type}`; switch (col.type) { case 'string': return { path, body: { key: col.key, size: col.size, required: col.required } }; case 'boolean': return { path, body: { key: col.key, required: col.required, default: col.default } }; case 'integer': return { path, body: { key: col.key, required: col.required, default: col.default, min: col.min } }; case 'enum': return { path, body: { key: col.key, elements: col.elements, required: col.required } }; default: throw new SetupError(`Unbekannter Spaltentyp „${col.type}“ für ${table.id}.${col.key}.`); } } /** * Gemeinsamer Ablauf für Spalten und Indizes einer Tabelle: vorhandene lesen, * fehlende nacheinander anlegen, danach auf `available` warten (nur ohne * --check) und alles mit dem Soll vergleichen. Gibt die Einträge zurück * (`actual` ist dort gesetzt, wo das Objekt existiert). * `canCreate: false` → fehlende Objekte werden nur gemeldet, auch ohne --check. */ async function ensureChildren(ctx, table, parentExists, { kind, items, request, compare, canCreate = true }) { const { noun, path } = CHILD_KINDS[kind]; const entries = []; for (const item of items) { const label = `${noun} ${table.id}.${item.key}`; const actual = parentExists ? await getObject(ctx, path(table.id, item.key), label) : null; if (actual) { entries.push({ item, label, actual, created: false }); } else if (ctx.check || !canCreate) { ctx.missing(label); } else { const { path: createPath, body } = request(item); await createObject(ctx, createPath, body, label); ctx.created(label); entries.push({ item, label, actual: null, created: true }); } } if (!ctx.check) { const waiting = entries.filter((e) => e.created || e.actual.status === 'processing'); if (waiting.length) { const ready = await waitAvailable(ctx, table.id, waiting.map((e) => e.item.key), kind); for (const e of waiting) e.actual = ready.get(e.item.key); } } for (const e of entries) { const deviations = compare(e.actual, e.item); if (deviations.length) ctx.deviations(`${table.id}.${e.item.key}`, deviations); else if (!e.created) ctx.ok(e.label); } return entries; } /** * Spalten nacheinander anlegen, danach auf `available` warten – damit sind * sie bereit, bevor jemand schreibt (und bevor Indizes angelegt werden). * `parentExists`: Tabelle vorhanden. Gibt zurück, ob alle Spalten vorhanden * und `available` sind (Voraussetzung für die Indizes). */ export async function ensureColumns(ctx, table, parentExists) { const entries = await ensureChildren(ctx, table, parentExists, { kind: 'column', items: table.columns, request: (col) => columnRequest(table, col), compare: compareColumn, }); return entries.length === table.columns.length && entries.every((e) => e.actual.status === 'available'); } /** * Indizes der Tabelle sicherstellen. Angelegt wird erst, wenn alle Spalten * `available` sind (`columnsReady`), sonst wird der Index nur als fehlend * gemeldet (die Spalte steht dann schon als ABWEICHUNG/FEHLT in der Ausgabe). */ export async function ensureIndexes(ctx, table, parentExists, columnsReady = true) { const indexes = table.indexes ?? []; if (!indexes.length) return; await ensureChildren(ctx, table, parentExists, { kind: 'index', items: indexes, request: (idx) => ({ path: `${tablePath(table.id)}/indexes`, body: { key: idx.key, type: idx.type, columns: idx.columns }, }), compare: compareIndex, canCreate: columnsReady, }); } export async function ensureBucket(ctx, bucket) { const b = bucket; const label = `Bucket ${b.id}`; const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label); if (actual) { const deviations = compareBucket(actual, b); if (deviations.length) ctx.deviations(b.id, deviations); else ctx.ok(label); return; } if (ctx.check) { ctx.missing(label); return; } await createObject( ctx, '/storage/buckets', { bucketId: b.id, name: b.name, permissions: b.permissions, fileSecurity: b.fileSecurity, maximumFileSize: b.maximumFileSize, allowedFileExtensions: b.allowedFileExtensions, compression: b.compression, antivirus: b.antivirus, }, label, ); ctx.created(label); } // --------------------------------------------------------------------------- // Ablauf // --------------------------------------------------------------------------- /** * Führt das Setup (oder mit `check` nur die Prüfung) aus und gibt den Exit-Code * zurück: 0 vollständig, 1 fehlt/weicht ab, 2 Abbruch. */ export async function main({ endpoint = DEFAULT_ENDPOINT, key, project, check = false, log = console.log, pollMs = 1000, timeoutMs = 30_000, } = {}) { if (!project) { throw new SetupError('project-Parameter erforderlich'); } const stats = { missing: 0, deviations: 0 }; const ctx = { api: createApi({ endpoint, key, project }), check, pollMs, timeoutMs, project, ok: (label) => log(`OK: ${label}`), created: (label) => log(`ANGELEGT: ${label}`), missing: (label) => { stats.missing += 1; log(`FEHLT: ${label}`); }, deviations: (prefix, list) => { for (const text of list) { stats.deviations += 1; log(`ABWEICHUNG: ${prefix}.${text}`); } }, }; log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`); try { const databaseExists = await ensureDatabase(ctx); for (const table of DESIRED.tables) { const tableExists = await ensureTable(ctx, table, databaseExists); const columnsReady = await ensureColumns(ctx, table, tableExists); await ensureIndexes(ctx, table, tableExists, columnsReady); } for (const bucket of DESIRED.buckets) await ensureBucket(ctx, bucket); } catch (err) { if (!(err instanceof SetupError)) throw err; log(`ABBRUCH: ${err.message}`); return 2; } if (stats.missing || stats.deviations) { const parts = []; if (stats.missing) parts.push(`${stats.missing} fehlend`); if (stats.deviations) parts.push(`${stats.deviations} Abweichung(en)`); log(`UNVOLLSTÄNDIG: ${parts.join(', ')}.`); if (stats.missing) log('Zum Anlegen das Skript ohne --check starten.'); if (stats.deviations) log('Abweichungen werden nie automatisch geändert – bitte in der Console korrigieren.'); return 1; } log('OK: Schema vollständig'); return 0; } // --------------------------------------------------------------------------- // Kommandozeile // --------------------------------------------------------------------------- async function cli() { const args = process.argv.slice(2); const unknown = args.filter((arg) => arg !== '--check'); if (unknown.length) { console.error(`Unbekannte Option: ${unknown.join(' ')}`); console.error('Aufruf: node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check]'); process.exitCode = 2; return; } const key = process.env.AIRCARD_APPWRITE_API_KEY?.trim(); if (!key) { console.error('Fehler: AIRCARD_APPWRITE_API_KEY fehlt.'); console.error('Den API-Key „aircard-provision” als Zeile AIRCARD_APPWRITE_API_KEY=… in /home/webklar/apps/.env eintragen (siehe docs/appwrite-console.md)'); console.error('und das Skript mit --env-file=/home/webklar/apps/.env starten.'); process.exitCode = 2; return; } const project = process.env.AIRCARD_APPWRITE_PROJECT_ID?.trim(); if (!project) { console.error('Fehler: AIRCARD_APPWRITE_PROJECT_ID fehlt.'); console.error('Die Projekt-ID als Zeile AIRCARD_APPWRITE_PROJECT_ID=6ac2130a00289343d6d5 in /home/webklar/apps/.env eintragen.'); console.error('und das Skript mit --env-file=/home/webklar/apps/.env starten.'); process.exitCode = 2; return; } process.exitCode = await main({ endpoint: process.env.AIRCARD_APPWRITE_ENDPOINT || DEFAULT_ENDPOINT, key, project, check: args.includes('--check'), }); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { await cli(); } catch (err) { console.error(`Unerwarteter Fehler: ${err?.message ?? err}`); process.exitCode = 2; } }