Files
AirCardIOS/web/scripts/appwrite-setup.mjs
T
2026-10-04 09:38:32 +00:00

461 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* AirCard – Appwrite-Schema-Setup (idempotent)
*
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle
* `cards` (TablesDB) mit vier Spalten und den Bucket `card-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. 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: <feld> ist <x>, erwartet <y>
*
* 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
// ---------------------------------------------------------------------------
export const DESIRED = {
database: { id: 'aircard', name: 'AirCard' },
table: {
id: 'cards',
name: 'Cards',
permissions: ['create("users")'],
rowSecurity: true,
columns: [
{ key: 'name', size: 80, required: true },
{ key: 'config', size: 2000, required: true },
{ key: 'imageFileId', size: 36, required: true },
{ key: 'previewFileId', size: 36, required: true },
],
},
bucket: {
id: 'card-images',
name: 'Card Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 5242880,
allowedFileExtensions: ['png', 'jpg', 'jpeg', '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: `size`, `required`, `status` (erwartet `available`). */
export function compareColumn(actual, desired) {
const out = [];
if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required));
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 columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${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, parentExists) {
const t = DESIRED.table;
const label = `Tabelle ${t.id}`;
const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null;
if (actual) {
const deviations = compareTable(actual, t);
if (deviations.length) ctx.deviations(t.id, deviations);
else ctx.ok(label);
return true;
}
if (ctx.check) {
ctx.missing(label);
return false;
}
await createObject(
ctx,
`/tablesdb/${DB_ID}/tables`,
{ tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity },
label,
);
ctx.created(label);
return true;
}
/**
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind.
* Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück.
*/
export async function waitAvailable(ctx, tableId, keys) {
const deadline = Date.now() + ctx.timeoutMs;
const done = new Map();
let pending = [...keys];
for (;;) {
const still = [];
for (const key of pending) {
const label = `Spalte ${tableId}.${key}`;
const actual = await getObject(ctx, columnPath(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}“. Spalte 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(
`Spalte(n) ${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);
}
}
/**
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
* sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden.
*/
export async function ensureColumns(ctx, parentExists) {
const t = DESIRED.table;
const entries = [];
for (const col of t.columns) {
const label = `Spalte ${t.id}.${col.key}`;
const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null;
if (actual) {
entries.push({ col, label, actual, created: false });
} else if (ctx.check) {
ctx.missing(label);
} else {
await createObject(
ctx,
`/tablesdb/${DB_ID}/tables/${t.id}/columns/string`,
{ key: col.key, size: col.size, required: col.required },
label,
);
ctx.created(label);
entries.push({ col, 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, t.id, waiting.map((e) => e.col.key));
for (const e of waiting) e.actual = ready.get(e.col.key);
}
}
for (const e of entries) {
const deviations = compareColumn(e.actual, e.col);
if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations);
else if (!e.created) ctx.ok(e.label);
}
}
export async function ensureBucket(ctx) {
const b = DESIRED.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);
const tableExists = await ensureTable(ctx, databaseExists);
await ensureColumns(ctx, tableExists);
await ensureBucket(ctx);
} 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;
}
}