diff --git a/cms/README.md b/cms/README.md index fd18b2f..9caa0e8 100644 --- a/cms/README.md +++ b/cms/README.md @@ -92,6 +92,9 @@ Dann: Admin `…:8080/admin/` · Live `…:8080/` · Preview `…:8080/_preview/ `cd admin && npm install && npm run dev` (Vite-Devserver, proxyt `/api` + `/_preview` an den laufenden Container auf :8080). +Tests der API (ohne DB/Container, reine Logik): `cd api && npm test` +(`node --test` — Pfad-Sicherheit, Rollen/Auth, Rate-Limit, Build-Coalescing). + ### Demo-Inhalt fürs Forum (optional) `db/seed-demo.sql` füllt die Forum-Kategorien mit ein paar Beispiel-Threads und diff --git a/cms/api/package.json b/cms/api/package.json index 158abed..61c6a14 100644 --- a/cms/api/package.json +++ b/cms/api/package.json @@ -6,7 +6,8 @@ "description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.", "scripts": { "start": "node src/index.js", - "dev": "node --watch src/index.js" + "dev": "node --watch src/index.js", + "test": "node --test" }, "dependencies": { "@hono/node-server": "^1.13.7", diff --git a/cms/api/src/coalesce.js b/cms/api/src/coalesce.js new file mode 100644 index 0000000..0bf73d2 --- /dev/null +++ b/cms/api/src/coalesce.js @@ -0,0 +1,39 @@ +// Serialisiert asynchrone Aufgaben je `key` und koalesziert Wartende: +// - Es läuft nie mehr als eine Aufgabe pro Key gleichzeitig. +// - Kommen während eines Laufs weitere Aufrufe rein, wird GENAU EIN weiterer +// Durchlauf nachgelagert (egal wie viele warten) — sie teilen sich dessen +// Ergebnis. So sehen alle den jüngsten Stand, ohne einen Lauf-Sturm. +// +// Einsatz: teure, idempotente Vorgänge wie der Hugo-Build (siehe hugo.js). +const state = new Map(); + +export function coalesce(key, fn) { + let s = state.get(key); + if (!s) { s = { running: false, rerun: false, fn, waiters: [] }; state.set(key, s); } + s.fn = fn; // jüngste Variante gewinnt für den nächsten Lauf + return new Promise((resolve, reject) => { + s.waiters.push({ resolve, reject }); + if (!s.running) drain(key); + else s.rerun = true; + }); +} + +async function drain(key) { + const s = state.get(key); + s.running = true; + try { + do { + s.rerun = false; + const waiters = s.waiters; + s.waiters = []; + try { + const r = await s.fn(); + waiters.forEach((w) => w.resolve(r)); + } catch (e) { + waiters.forEach((w) => w.reject(e)); + } + } while (s.rerun); + } finally { + s.running = false; + } +} diff --git a/cms/api/src/dialog-store.js b/cms/api/src/dialog-store.js index c128ff6..1bd94a6 100644 --- a/cms/api/src/dialog-store.js +++ b/cms/api/src/dialog-store.js @@ -19,7 +19,15 @@ export async function profileFor(email) { // Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man // auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key). -export async function syncLibrary() { +// +// Gedrosselt: Reads rufen das bei jedem Forum-Aufruf, aber der eigentliche +// Sync (DB + Filesystem-Walk + Upsert) läuft höchstens alle SYNC_TTL ms. +// `force: true` (z.B. nach Publish) überspringt die Drosselung. +const SYNC_TTL = 60_000; +let lastSync = 0; +export async function syncLibrary({ force = false } = {}) { + if (!force && Date.now() - lastSync < SYNC_TTL) return; + lastSync = Date.now(); const { data: forum } = await supabase .from('forums').select('id').eq('slug', LIBRARY_SLUG).single(); if (!forum) return; diff --git a/cms/api/src/hugo.js b/cms/api/src/hugo.js index 4590c25..f5180eb 100644 --- a/cms/api/src/hugo.js +++ b/cms/api/src/hugo.js @@ -1,5 +1,6 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; +import { coalesce } from './coalesce.js'; const execFileP = promisify(execFile); const SITE_DIR = process.env.SITE_DIR || '/site'; @@ -16,6 +17,13 @@ export async function hugoBuild({ dest, drafts = false } = {}) { return { stdout, stderr }; } +// Koaleszierter Build je Ziel: nie zwei `hugo`-Prozesse für dasselbe dest +// parallel; schnelle Folge-Aufrufe lösen nur einen nachgelagerten Build aus. +// Publish (public), Preview (preview) und Profil teilen sich diesen Weg. +export function buildSite({ dest, drafts = false } = {}) { + return coalesce(`build:${dest}:${drafts ? 'd' : 'p'}`, () => hugoBuild({ dest, drafts })); +} + // Optionaler Git-Backup beim Publish (GIT_PUBLISH=true). Schlägt nie hart fehl — // das Publish soll an einem Git-Problem nicht scheitern. export async function gitCommit(message) { diff --git a/cms/api/src/routes/preview.js b/cms/api/src/routes/preview.js index 50c814e..bf2ec75 100644 --- a/cms/api/src/routes/preview.js +++ b/cms/api/src/routes/preview.js @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { urlFor, safeRel } from '../files.js'; -import { hugoBuild } from '../hugo.js'; +import { buildSite } from '../hugo.js'; // Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die // URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge). @@ -10,7 +10,7 @@ preview.post('/', async (c) => { const { path: rel } = await c.req.json(); try { const safe = safeRel(rel); - const build = await hugoBuild({ dest: 'preview', drafts: true }); + const build = await buildSite({ dest: 'preview', drafts: true }); return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout }); } catch (e) { return c.json({ error: String(e.message || e) }, 500); diff --git a/cms/api/src/routes/profile.js b/cms/api/src/routes/profile.js index 425cf87..68c4a35 100644 --- a/cms/api/src/routes/profile.js +++ b/cms/api/src/routes/profile.js @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import path from 'node:path'; import matter from 'gray-matter'; -import { hugoBuild } from '../hugo.js'; +import { buildSite } from '../hugo.js'; // Profile als Hugo-Data-Datei (data/authors.json) + öffentliche Autor-Seite // (content/authors/.md), gerendert von layouts/authors/single.html. @@ -19,20 +19,6 @@ function slugify(s) { } async function exists(p) { try { await stat(p); return true; } catch { return false; } } -// Hugo-Build koaleszieren: nie zwei parallel, und schnelle Folge-Speicherungen -// lösen nur EINEN nachgelagerten Build aus (verhindert Build-Sturm/DoS). -let building = false, rerun = false; -async function rebuildSite() { - if (building) { rerun = true; return; } - building = true; - try { - do { - rerun = false; - await hugoBuild({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e)); - } while (rerun); - } finally { building = false; } -} - const profile = new Hono(); profile.get('/', async (c) => { @@ -61,7 +47,7 @@ profile.put('/', async (c) => { const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' }); await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8'); // Live bauen (koalesziert), damit die Seite + Byline-Links sofort wirken. - await rebuildSite(); + await buildSite({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e)); } return c.json({ ok: true, slug }); diff --git a/cms/api/src/routes/publish.js b/cms/api/src/routes/publish.js index 3ab3944..efd8fc8 100644 --- a/cms/api/src/routes/publish.js +++ b/cms/api/src/routes/publish.js @@ -1,6 +1,7 @@ import { Hono } from 'hono'; import { urlFor, safeRel } from '../files.js'; -import { hugoBuild, gitCommit } from '../hugo.js'; +import { buildSite, gitCommit } from '../hugo.js'; +import { syncLibrary } from '../dialog-store.js'; // Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit. const publish = new Hono(); @@ -9,7 +10,9 @@ publish.post('/', async (c) => { const { path: rel } = await c.req.json(); try { const safe = safeRel(rel); - const build = await hugoBuild({ dest: 'public', drafts: false }); + const build = await buildSite({ dest: 'public', drafts: false }); + // Neue/aktualisierte Library-Beiträge sofort als Dialog-Threads spiegeln. + await syncLibrary({ force: true }).catch(() => {}); const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) })); return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout }); } catch (e) { diff --git a/cms/api/test/auth.test.js b/cms/api/test/auth.test.js new file mode 100644 index 0000000..a51aeb3 --- /dev/null +++ b/cms/api/test/auth.test.js @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// Env vor dem Import setzen: supabase.js bricht ohne URL/Key ab, ADMIN_EMAILS +// und JWT_SECRET werden beim Modul-Load gelesen. +process.env.SUPABASE_URL ||= 'http://localhost'; +process.env.SUPABASE_SERVICE_KEY ||= 'dummy'; +process.env.JWT_SECRET = 'test-secret'; +process.env.ADMIN_EMAILS = 'boss@x.ch'; + +const { roleOf, requireAuth } = await import('../src/auth.js'); +const { sign } = await import('hono/jwt'); + +test('roleOf: Admin aus ADMIN_EMAILS', () => { + assert.equal(roleOf({ email: 'boss@x.ch' }), 'admin'); + assert.equal(roleOf({ email: 'BOSS@X.CH' }), 'admin'); +}); + +test('roleOf: Rolle aus app_metadata', () => { + assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'admin' } }), 'admin'); + assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'editor' } }), 'editor'); + assert.equal(roleOf({ email: 'a@x.ch' }), 'user'); +}); + +// Minimaler Hono-Kontext-Stub. +function fakeCtx(authHeader) { + const store = {}; + return { + req: { header: (h) => (h === 'Authorization' ? authHeader : undefined) }, + set: (k, v) => { store[k] = v; }, + get: (k) => store[k], + json: (body, status = 200) => ({ __status: status, body }), + }; +} + +test('requireAuth: gültiges Token wird lokal verifiziert', async () => { + const token = await sign( + { sub: 'u1', email: 'A@x.ch', app_metadata: { role: 'editor' }, exp: Math.floor(Date.now() / 1000) + 60 }, + 'test-secret', 'HS256'); + let passed = false; + const c = fakeCtx('Bearer ' + token); + await requireAuth(c, async () => { passed = true; }); + assert.equal(passed, true); + assert.equal(c.get('email'), 'a@x.ch'); // kleingeschrieben + assert.equal(c.get('role'), 'editor'); + assert.equal(c.get('canModerate'), true); + assert.equal(c.get('isAdmin'), false); +}); + +test('requireAuth: fehlendes Token → 401', async () => { + const c = fakeCtx(''); + const r = await requireAuth(c, async () => { throw new Error('darf nicht laufen'); }); + assert.equal(r.__status, 401); +}); + +test('requireAuth: kaputtes/falsch signiertes Token → 401', async () => { + const bad = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) + 60 }, 'falsches-secret', 'HS256'); + for (const t of ['Bearer garbage', 'Bearer ' + bad]) { + const r = await requireAuth(fakeCtx(t), async () => { throw new Error('darf nicht laufen'); }); + assert.equal(r.__status, 401); + } +}); + +test('requireAuth: abgelaufenes Token → 401', async () => { + const expired = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) - 10 }, 'test-secret', 'HS256'); + const r = await requireAuth(fakeCtx('Bearer ' + expired), async () => { throw new Error('darf nicht laufen'); }); + assert.equal(r.__status, 401); +}); diff --git a/cms/api/test/coalesce.test.js b/cms/api/test/coalesce.test.js new file mode 100644 index 0000000..3f9bfeb --- /dev/null +++ b/cms/api/test/coalesce.test.js @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { coalesce } = await import('../src/coalesce.js'); + +const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms)); + +test('coalesce: nie mehr als ein Lauf gleichzeitig pro Key', async () => { + let active = 0, maxActive = 0, runs = 0; + const fn = async () => { active++; maxActive = Math.max(maxActive, active); await tick(10); runs++; active--; return runs; }; + + // 5 gleichzeitige Aufrufe. + await Promise.all(Array.from({ length: 5 }, () => coalesce('k1', fn))); + assert.equal(maxActive, 1, 'parallele Läufe'); + // Erster Lauf bedient den ersten Aufruf; die 4 während des Laufs eingetroffenen + // teilen sich GENAU EINEN nachgelagerten Lauf → insgesamt 2. + assert.equal(runs, 2); +}); + +test('coalesce: Wartende teilen sich das Ergebnis des nachgelagerten Laufs', async () => { + let n = 0; + const fn = async () => { await tick(10); return ++n; }; + const first = coalesce('k2', fn); // startet sofort → Ergebnis 1 + await tick(2); // sicherstellen, dass er läuft + const a = coalesce('k2', fn); // wartet → nachgelagerter Lauf + const b = coalesce('k2', fn); // wartet → selber Lauf wie a + assert.equal(await first, 1); + const [ra, rb] = await Promise.all([a, b]); + assert.equal(ra, 2); + assert.equal(rb, 2); // a und b teilen sich Lauf 2 +}); + +test('coalesce: Fehler wird an die Wartenden propagiert, Key bleibt nutzbar', async () => { + let fail = true; + const fn = async () => { await tick(5); if (fail) throw new Error('boom'); return 'ok'; }; + await assert.rejects(() => coalesce('k3', fn), /boom/); + fail = false; + assert.equal(await coalesce('k3', fn), 'ok'); // danach wieder verwendbar +}); + +test('coalesce: verschiedene Keys laufen unabhängig', async () => { + const fn = async () => { await tick(5); return 'done'; }; + const [x, y] = await Promise.all([coalesce('kA', fn), coalesce('kB', fn)]); + assert.equal(x, 'done'); + assert.equal(y, 'done'); +}); diff --git a/cms/api/test/files.test.js b/cms/api/test/files.test.js new file mode 100644 index 0000000..10b7955 --- /dev/null +++ b/cms/api/test/files.test.js @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { safeRel, normAuthors, hasAccess, urlFor } = await import('../src/files.js'); + +test('safeRel: gültiger relativer .md-Pfad bleibt erhalten', () => { + assert.equal(safeRel('library/software/stack.md'), 'library/software/stack.md'); + assert.equal(safeRel('a/./b.md'), 'a/b.md'); +}); + +test('safeRel: Path-Traversal wird abgelehnt', () => { + assert.throws(() => safeRel('../etc/passwd.md')); + assert.throws(() => safeRel('a/../../b.md')); + assert.throws(() => safeRel('/absolut.md')); +}); + +test('safeRel: nur .md erlaubt, leer/falsch wirft', () => { + assert.throws(() => safeRel('note.txt')); + assert.throws(() => safeRel('')); + assert.throws(() => safeRel(null)); +}); + +test('normAuthors: String/Array/Leer normalisieren', () => { + assert.deepEqual(normAuthors('a@x.ch'), ['a@x.ch']); + assert.deepEqual(normAuthors(['a@x.ch', 'b@y.ch']), ['a@x.ch', 'b@y.ch']); + assert.deepEqual(normAuthors(null), []); + assert.deepEqual(normAuthors([]), []); +}); + +test('hasAccess: case-insensitive Mitgliedschaft', () => { + assert.equal(hasAccess(['Karim@x.ch'], 'karim@x.ch'), true); + assert.equal(hasAccess(['a@x.ch'], 'b@y.ch'), false); + assert.equal(hasAccess([], 'a@x.ch'), false); +}); + +test('urlFor: Hugo-URLs aus relativem Pfad', () => { + assert.equal(urlFor('_index.md'), '/'); + assert.equal(urlFor('manifest.md'), '/manifest/'); + assert.equal(urlFor('library/software/stack.md'), '/library/software/stack/'); + assert.equal(urlFor('software/_index.md'), '/software/'); +}); diff --git a/cms/api/test/ratelimit.test.js b/cms/api/test/ratelimit.test.js new file mode 100644 index 0000000..4907b52 --- /dev/null +++ b/cms/api/test/ratelimit.test.js @@ -0,0 +1,44 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const { rateLimit } = await import('../src/ratelimit.js'); + +function fakeCtx() { + const headers = {}; + return { + req: { header: () => undefined }, + header: (k, v) => { headers[k] = v; }, + json: (body, status = 200) => ({ __status: status, body }), + _headers: headers, + }; +} + +test('rateLimit: blockt nach Überschreiten mit 429 + Retry-After', async () => { + const mw = rateLimit({ max: 2, windowMs: 10_000, keyFn: () => 'fix' }); + let calls = 0; + const run = async () => { const c = fakeCtx(); const r = await mw(c, async () => { calls++; }); return { c, r }; }; + + assert.equal((await run()).r, undefined); // 1 → durch (next, kein Return) + assert.equal((await run()).r, undefined); // 2 → durch + const { c, r } = await run(); // 3 → blockiert + assert.equal(r.__status, 429); + assert.ok(c._headers['Retry-After']); + assert.equal(calls, 2); // next nur zweimal aufgerufen +}); + +test('rateLimit: getrennte Schlüssel zählen getrennt', async () => { + let key = 'a'; + const mw = rateLimit({ max: 1, windowMs: 10_000, keyFn: () => key }); + assert.equal((await mw(fakeCtx(), async () => {})), undefined); // a:1 ok + assert.equal((await mw(fakeCtx(), async () => {})).__status, 429); // a:2 blockiert + key = 'b'; + assert.equal((await mw(fakeCtx(), async () => {})), undefined); // b:1 ok +}); + +test('rateLimit: Fenster läuft ab → wieder frei', async () => { + const mw = rateLimit({ max: 1, windowMs: 30, keyFn: () => 'win' }); + assert.equal((await mw(fakeCtx(), async () => {})), undefined); + assert.equal((await mw(fakeCtx(), async () => {})).__status, 429); + await new Promise((r) => setTimeout(r, 40)); + assert.equal((await mw(fakeCtx(), async () => {})), undefined); // Fenster neu +});