Merge commit 'bba1df32cb1de7e595a6ecd9bcb84afe8aa178c3' as 'cms/core'

This commit is contained in:
2026-06-30 01:46:20 +02:00
59 changed files with 7398 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
// Local auth provider — file-based users (bcrypt) + self-signed JWTs of the SAME
// claim shape as Supabase/GoTrue (sub / email / app_metadata.role / exp), so the
// rest of the engine (auth.js verifyToken, roleOf) treats local and supabase
// tokens identically. Lets a core instance run DB-less (no Supabase/Postgres):
// Node + Hugo + nginx. Activated by config.auth === 'local'.
//
// Users live in a JSON file (USERS_FILE, default <SITE_DIR>/cms-users.json):
// [{ id, email, password: <bcrypt hash>, role, created_at, last_sign_in_at }]
import path from 'node:path';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { sign } from 'hono/jwt';
const SITE_DIR = process.env.SITE_DIR || '/site';
const USERS_FILE = process.env.USERS_FILE || path.join(SITE_DIR, 'cms-users.json');
const JWT_SECRET = process.env.JWT_SECRET || '';
const ROLES = ['user', 'editor', 'admin'];
// bcryptjs is pure-JS (no native build) and lazily imported — a supabase-auth
// instance never loads it.
async function bcrypt() { return (await import('bcryptjs')).default; }
export async function loadUsers() {
try {
const j = JSON.parse(await readFile(USERS_FILE, 'utf8'));
return Array.isArray(j) ? j : (j.users || []);
} catch { return []; }
}
async function persist(users) {
await mkdir(path.dirname(USERS_FILE), { recursive: true });
await writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf8');
}
export async function hashPassword(pw) { return (await bcrypt()).hash(pw, 10); }
export async function verifyPassword(pw, hash) {
try { return await (await bcrypt()).compare(pw, hash); } catch { return false; }
}
// Mint a GoTrue-shaped HS256 access token (verified by auth.js with JWT_SECRET).
export async function mintToken(user, ttl = 3600) {
if (!JWT_SECRET) throw new Error('JWT_SECRET fehlt — für auth: local nötig');
const now = Math.floor(Date.now() / 1000);
return sign({
sub: user.id, email: user.email, role: 'authenticated',
app_metadata: { role: user.role || 'user' }, iat: now, exp: now + ttl,
}, JWT_SECRET, 'HS256');
}
export async function login(email, password) {
const users = await loadUsers();
const u = users.find((x) => (x.email || '').toLowerCase() === (email || '').toLowerCase());
if (!u || !u.password) return null;
if (!(await verifyPassword(password, u.password))) return null;
u.last_sign_in_at = new Date().toISOString();
await persist(users);
return {
access_token: await mintToken(u),
token_type: 'bearer',
user: { id: u.id, email: u.email, role: u.role || 'user' },
};
}
// ── Admin CRUD (mirrors routes/users.js shapes) ──────────────────────────────
export async function listUsers(adminEmails = []) {
const users = await loadUsers();
return users.map((u) => {
const fixedAdmin = adminEmails.includes((u.email || '').toLowerCase());
const role = u.role || 'user';
return {
id: u.id, email: u.email, created_at: u.created_at || null,
last_sign_in_at: u.last_sign_in_at || null,
role: fixedAdmin ? 'admin' : role, isAdmin: fixedAdmin || role === 'admin', fixedAdmin,
};
});
}
export async function countByRole(adminEmails = []) {
const list = await listUsers(adminEmails);
const out = { total: list.length, admin: 0, editor: 0, user: 0 };
for (const u of list) out[u.role] = (out[u.role] || 0) + 1;
return out;
}
export async function createUser({ email, password, role }) {
if (!email || !password) throw new Error('E-Mail und Passwort nötig');
if (role && !ROLES.includes(role)) throw new Error('Unbekannte Rolle');
const users = await loadUsers();
if (users.some((u) => (u.email || '').toLowerCase() === email.toLowerCase())) {
throw new Error('E-Mail existiert bereits');
}
const u = {
id: randomUUID(), email, password: await hashPassword(password),
role: role || 'user', created_at: new Date().toISOString(), last_sign_in_at: null,
};
users.push(u); await persist(users);
return u.id;
}
export async function updateUser(id, { password, role }) {
const users = await loadUsers();
const u = users.find((x) => x.id === id);
if (!u) throw new Error('Nutzer:in nicht gefunden');
if (password) u.password = await hashPassword(password);
if (role) { if (!ROLES.includes(role)) throw new Error('Unbekannte Rolle'); u.role = role; }
await persist(users);
}
export async function deleteUser(id) {
const users = await loadUsers();
const next = users.filter((u) => u.id !== id);
if (next.length === users.length) throw new Error('Nutzer:in nicht gefunden');
await persist(next);
}
+75
View File
@@ -0,0 +1,75 @@
import { verify } from 'hono/jwt';
import { supabaseAuth } from './supabase.js';
// Supabase-Tokens sind HS256-signiert. Mit dem JWT_SECRET verifizieren wir sie
// lokal (Signatur + Ablauf) — das spart pro Request den Roundtrip zu GoTrue.
// Ohne JWT_SECRET (z.B. Alt-Deploy) fällt requireAuth auf die Remote-Prüfung
// zurück. Tokens sind kurzlebig (1h) und Self-Signup ist aus → kein
// Sperr-Check nötig.
const JWT_SECRET = process.env.JWT_SECRET || '';
// Liefert ein User-Objekt {id,email,app_metadata} oder null.
async function verifyToken(token) {
if (JWT_SECRET) {
try {
const p = await verify(token, JWT_SECRET, 'HS256');
if (!p?.sub) return null;
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
} catch { return null; }
}
// Fallback (kein JWT_SECRET): Remote-Prüfung gegen GoTrue — nur wenn ein
// Supabase-Auth-Client existiert (DB-loser Betrieb hat keinen).
if (!supabaseAuth) return null;
const { data, error } = await supabaseAuth.auth.getUser(token);
if (error || !data?.user) return null;
return data.user;
}
// Rollen-Hierarchie: admin > editor (Redakteur) > user.
// - admin: alles (Foren verwalten, moderieren, Nutzer/Rollen, Inhalte)
// - editor: moderieren (Wortmeldungen ausblenden/löschen, Threads sperren)
// - user: im Forum mitschreiben
// Admins aus der .env (ADMIN_EMAILS=a@x,b@y) sind immer Admin (Bootstrap, damit
// man sich nicht aussperrt). Zusätzlich kann eine Rolle in app_metadata.role
// liegen (im Admin-UI vergeben).
const ADMINS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
export function roleOf(user) {
const email = (user?.email || '').toLowerCase();
const meta = (user?.app_metadata?.role || '').toLowerCase();
if (ADMINS.includes(email) || meta === 'admin') return 'admin';
if (meta === 'editor') return 'editor';
return 'user';
}
// Verifiziert den Supabase-Access-Token und legt user/email/role im Kontext ab.
export async function requireAuth(c, next) {
const header = c.req.header('Authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
const user = await verifyToken(token);
if (!user) return c.json({ error: 'Ungültiges Token' }, 401);
const email = (user.email || '').toLowerCase();
const role = roleOf(user);
c.set('user', user);
c.set('email', email);
c.set('role', role);
c.set('isAdmin', role === 'admin');
c.set('canModerate', role === 'admin' || role === 'editor');
await next();
}
// Nur Admins (nach requireAuth einsetzen).
export async function requireAdmin(c, next) {
if (!c.get('isAdmin')) return c.json({ error: 'Nur für Admins' }, 403);
await next();
}
// Admins + Redakteure — fürs Moderieren (nach requireAuth einsetzen).
export async function requireModerator(c, next) {
if (!c.get('canModerate')) return c.json({ error: 'Nur für Moderation' }, 403);
await next();
}
+39
View File
@@ -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;
}
}
+119
View File
@@ -0,0 +1,119 @@
// Derive content classification, list ordering and path building from a site's
// `config.collections` (see ../../README.md). Pure functions — no config import,
// no filesystem — so they unit-test against any collections array directly.
//
// Fed `examples/openbureau.config.js` these reproduce the engine's original
// hard-coded archiv/library/rubrik/seite behaviour 1:1.
// Parse a `path` pattern ('archiv/:section/:slug') into segments.
function parsePattern(pattern) {
return pattern.split('/').map((seg) =>
seg.startsWith(':') ? { param: seg.slice(1) } : { literal: seg });
}
// Match a content rel-path's segments (no .md) against a `path` pattern.
// Returns captured params, or null. Strict: the segment count must equal the
// pattern's, so 'archiv/:section/:slug' matches exactly three deep — the same
// rule the original classify() enforced for archiv.
function matchPattern(segs, pattern) {
const pat = parsePattern(pattern);
if (segs.length !== pat.length) return null;
const params = {};
for (let i = 0; i < pat.length; i++) {
if (pat[i].literal !== undefined) {
if (pat[i].literal !== segs[i]) return null;
} else {
if (!segs[i]) return null;
params[pat[i].param] = segs[i];
}
}
return params;
}
// First literal segment of a pattern (e.g. 'library' from 'library/:slug').
function rootSegment(pattern) {
const first = pattern.split('/')[0];
return first.startsWith(':') ? null : first;
}
// { kind, section } for a content file, derived from collections.
// Priority: index (_index.md) → path patterns → fallback — mirrors the original
// hard-coded classify() when fed openbureau.config.js.
export function classify(rel, collections) {
const parts = rel.split('/');
const base = parts[parts.length - 1];
const segs = rel.replace(/\.md$/, '').split('/');
// _index.md → the index collection; section = parent dir (or 'home' at root).
if (base === '_index.md') {
const idx = collections.find((c) => c.index);
if (idx) {
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
return { kind: idx.kind, section };
}
}
// path-pattern collections (skip index/fallback).
for (const c of collections) {
if (!c.path || c.index || c.fallback) continue;
const params = matchPattern(segs, c.path);
if (params) {
const section = params.section ?? rootSegment(c.path);
return { kind: c.kind, section: section ?? null };
}
}
// fallback collection (everything not otherwise matched).
const fb = collections.find((c) => c.fallback);
return { kind: fb ? fb.kind : null, section: null };
}
// Sort comparator for listEntries: collection `order`, then date desc, then title.
export function compareEntries(collections) {
const order = {};
collections.forEach((c, i) => { order[c.kind] = c.order ?? 100 + i; });
return (a, b) =>
((order[a.kind] ?? 999) - (order[b.kind] ?? 999)) ||
(b.date || '').localeCompare(a.date || '') ||
a.title.localeCompare(b.title);
}
// Build a content rel-path for a NEW entry of `kind` from form `data`.
// Index collection → '<slug>/_index.md'; a collection without a `path` (fallback)
// → '<slug>.md'; otherwise substitute :params in the collection's `path`
// (archiv/:section/:slug → archiv/<section>/<slug>.md). Returns '' if a required
// segment is missing.
export function buildPath(kind, data, collections) {
const c = collections.find((x) => x.kind === kind);
const slug = String(data.slug || '').trim();
if (c?.index) return slug ? `${slug}/_index.md` : '';
if (!c?.path) return slug ? `${slug}.md` : '';
let ok = true;
const rel = c.path.split('/').map((seg) => {
if (!seg.startsWith(':')) return seg;
const v = String(data[seg.slice(1)] || '').trim();
if (!v) ok = false;
return v;
}).join('/');
return ok && rel ? `${rel}.md` : '';
}
// Build the stats `content` object from classified entries + collections:
// one counter per collection.statKey, plus a drafts counter for any collection
// that declares `draftStatKey`. Mirrors the original hard-coded stats block
// (beitraege/entwuerfe/library/seiten/rubriken) when fed openbureau.config.js.
export function contentStats(entries, collections) {
const content = {};
for (const c of collections) {
if (c.statKey) content[c.statKey] ??= 0;
if (c.draftStatKey) content[c.draftStatKey] ??= 0;
}
const byKind = Object.fromEntries(collections.map((c) => [c.kind, c]));
for (const e of entries) {
const c = byKind[e.kind];
if (!c) continue;
if (c.statKey) content[c.statKey]++;
if (c.draftStatKey && e.draft) content[c.draftStatKey]++;
}
return content;
}
+29
View File
@@ -0,0 +1,29 @@
// Per-site CMS configuration loader.
//
// A site tells the core what it manages through one module, referenced by the
// CMS_CONFIG env var (e.g. CMS_CONFIG=/site/cms.config.js). The module exports
// `default` with { collections, plugins, admins }. Loaded once at startup; the
// engine (files/stats/routes) derives its behaviour from it instead of having
// openbureau-specific content types hard-coded.
//
// See ../../README.md for the schema, and ../../examples/*.config.js.
import { pathToFileURL } from 'node:url';
const p = process.env.CMS_CONFIG;
if (!p) {
console.error('FEHLT: CMS_CONFIG (Pfad zur Site-Config, z. B. /site/cms.config.js)');
process.exit(1);
}
const mod = await import(pathToFileURL(p).href);
export const config = mod.default || mod;
config.collections ||= [];
config.plugins ||= [];
config.admins ||= [];
config.auth ||= 'supabase'; // 'supabase' (GoTrue) | 'local' (file users, DB-less)
export const hasPlugin = (name) => config.plugins.includes(name);
// Collection lookup helpers (used by the generalised files.js / stats.js).
export const collectionOf = (kind) => config.collections.find((c) => c.kind === kind) || null;
export const fallbackCollection = () => config.collections.find((c) => c.fallback) || null;
+105
View File
@@ -0,0 +1,105 @@
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import { classify, compareEntries } from './collections.js';
const SITE_DIR = process.env.SITE_DIR || '/site';
const CONTENT = path.join(SITE_DIR, 'content');
// Pfad-Sicherheit: relativer Pfad innerhalb content/, nur .md.
export function safeRel(rel) {
if (!rel || typeof rel !== 'string') throw new Error('Pfad fehlt');
const norm = path.normalize(rel).split(path.sep).join('/');
if (norm.startsWith('..') || norm.startsWith('/') || norm.includes('../')) {
throw new Error('Ungültiger Pfad');
}
if (!norm.endsWith('.md')) throw new Error('Nur .md erlaubt');
return norm;
}
async function walk(dir) {
const out = [];
for (const e of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) out.push(...(await walk(full)));
else if (e.name.endsWith('.md')) out.push(full);
}
return out;
}
// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt).
export function normAuthors(a) {
if (Array.isArray(a)) return a.map(String).filter(Boolean);
if (a) return [String(a)];
return [];
}
// Hat diese E-Mail Zugriff (steht sie in der authors-Liste)?
export function hasAccess(authors, email) {
const e = (email || '').toLowerCase();
return normAuthors(authors).some((a) => a.toLowerCase() === e);
}
// Hugo-URL aus dem relativen Pfad.
export function urlFor(rel) {
let p = rel.replace(/\.md$/, '');
if (p === '_index') return '/';
p = p.replace(/\/_index$/, '');
return '/' + p + '/';
}
export async function listEntries() {
// Lazy: only listing needs the site config, so importing files.js for its pure
// helpers (safeRel/urlFor/…) never triggers the CMS_CONFIG load.
const { config } = await import('./config.js');
const files = await walk(CONTENT);
const items = [];
for (const full of files) {
const rel = path.relative(CONTENT, full).split(path.sep).join('/');
// Autor-Seiten werden über „Profil" verwaltet, nicht im Inhalts-Editor.
if (rel === 'authors' || rel.startsWith('authors/')) continue;
let data = {};
try { data = matter(await readFile(full, 'utf8')).data || {}; } catch {}
items.push({
path: rel,
title: data.title || rel,
...classify(rel, config.collections),
color: data.color || null,
layout: data.layout || null,
draft: !!data.draft,
date: data.date ? String(data.date).slice(0, 10) : null,
authors: normAuthors(data.authors),
url: urlFor(rel),
});
}
// Reihenfolge aus der Config (collection.order), dann Datum, dann Titel.
items.sort(compareEntries(config.collections));
return items;
}
export async function readEntry(rel) {
rel = safeRel(rel);
const { data, content } = matter(await readFile(path.join(CONTENT, rel), 'utf8'));
return { path: rel, url: urlFor(rel), frontmatter: data || {}, body: content || '' };
}
export async function entryExists(rel) {
try { await stat(path.join(CONTENT, safeRel(rel))); return true; }
catch { return false; }
}
export async function writeEntry(rel, frontmatter = {}, body = '') {
rel = safeRel(rel);
const full = path.join(CONTENT, rel);
await mkdir(path.dirname(full), { recursive: true });
// Leere Werte rauswerfen, damit das Frontmatter sauber bleibt.
const fm = {};
for (const [k, v] of Object.entries(frontmatter)) {
if (v === '' || v === null || v === undefined) continue;
if (Array.isArray(v) && v.length === 0) continue;
fm[k] = v;
}
await writeFile(full, matter.stringify(body || '', fm), 'utf8');
return rel;
}
+49
View File
@@ -0,0 +1,49 @@
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';
// Baut die Site. dest ist relativ zum Repo-Root (z.B. "public" oder "preview").
// drafts:true => --buildDrafts (für die Vorschau).
export async function hugoBuild({ dest, drafts = false } = {}) {
const args = ['--source', SITE_DIR, '--destination', dest, '--cleanDestinationDir'];
if (drafts) args.push('--buildDrafts');
const { stdout, stderr } = await execFileP('hugo', args, {
cwd: SITE_DIR,
maxBuffer: 10 * 1024 * 1024,
});
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) {
if (process.env.GIT_PUBLISH !== 'true') return { skipped: true };
const env = {
...process.env,
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || 'OPENBUREAU CMS',
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || 'cms@openbureau.ch',
GIT_COMMITTER_NAME: process.env.GIT_AUTHOR_NAME || 'OPENBUREAU CMS',
GIT_COMMITTER_EMAIL: process.env.GIT_AUTHOR_EMAIL || 'cms@openbureau.ch',
};
const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { env });
await git('add', 'content');
// Nichts zu committen? Dann ruhig raus.
const status = await git('status', '--porcelain', 'content');
if (!status.stdout.trim()) return { nothing: true };
await git('commit', '-m', message);
await git('push', process.env.GIT_REMOTE || 'origin', process.env.GIT_BRANCH || 'main');
return { committed: true };
}
+146
View File
@@ -0,0 +1,146 @@
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import { secureHeaders } from 'hono/secure-headers';
import { bodyLimit } from 'hono/body-limit';
import { rateLimit } from './ratelimit.js';
import content from './routes/content.js';
import preview from './routes/preview.js';
import publish from './routes/publish.js';
import upload from './routes/upload.js';
import profile from './routes/profile.js';
import users from './routes/users.js';
import stats from './routes/stats.js';
import system from './routes/system.js';
import history from './routes/history.js';
import authRoute from './routes/auth.js';
import { requireAuth, requireAdmin } from './auth.js';
import { config } from './config.js';
import { supabase } from './supabase.js';
import { manager } from './plugins.js';
import { runMigrations } from './migrate.js';
// Fail-fast: auth:'supabase' ohne erreichbaren Service-Client ist ein Fehlstart.
// (auth:'local' braucht keinen — DB-loser Betrieb.)
if (config.auth === 'supabase' && !supabase) {
console.error('FEHLT: SUPABASE_URL/SERVICE_KEY für auth: supabase');
process.exit(1);
}
const SITE_DIR = process.env.SITE_DIR || '/site';
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
const PORT = Number(process.env.PORT || 3000);
const app = new Hono();
// --- Sicherheits-Header (auf allem) ---
// CSP bewusst zurückhaltend: Site + Admin-SPA + Dialog-Widget laufen same-origin.
app.use('*', secureHeaders({
xFrameOptions: 'SAMEORIGIN',
xContentTypeOptions: 'nosniff',
referrerPolicy: 'strict-origin-when-cross-origin',
crossOriginOpenerPolicy: 'same-origin',
// HSTS nur sinnvoll hinter TLS-Proxy; schadet via HTTP nicht (Browser ignoriert).
strictTransportSecurity: 'max-age=31536000; includeSubDomains',
}));
// Hochgeladene Bilder strikt isolieren: ein bösartiges SVG kann so kein
// JavaScript im Origin ausführen (sandbox + keine Skript-Quellen).
app.use('/images/*', secureHeaders({
contentSecurityPolicy: { defaultSrc: ["'none'"], imgSrc: ["'self'"], styleSrc: ["'unsafe-inline'"], sandbox: [] },
xContentTypeOptions: 'nosniff',
}));
// Statische Assets cachen: Hugo fingerprintet CSS/JS, Uploads haben stabile,
// eindeutige Namen. HTML bleibt ungecacht (Antwort ohne Header → immer frisch).
app.use('*', async (c, next) => {
await next();
if (c.req.method === 'GET' && /\.(css|js|mjs|woff2?|ttf|otf|eot|svg|png|jpe?g|webp|avif|gif|ico)$/i.test(c.req.path)) {
c.header('Cache-Control', 'public, max-age=604800'); // 1 Woche
}
});
// --- API ---
// Globales Limit gegen aufgeblähte JSON-Bodies (DoS / DB-Bloat). Der Upload-Pfad
// ist ausgenommen — der bringt sein eigenes, größeres Bild-Limit mit.
const jsonBodyLimit = bodyLimit({ maxSize: 256 * 1024, onError: (c) => c.json({ error: 'Anfrage zu groß' }, 413) });
app.use('/api/*', (c, next) =>
c.req.path.startsWith('/api/upload') ? next() : jsonBodyLimit(c, next));
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
// Öffentliche Plugin-Routen (ohne Login) — je nach config.plugins, z. B. Dialog
// lesen + Widget-Login. Jede Route bringt ihre eigenen Limits mit (dialog drosselt
// den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
manager.mountPublic(app);
// Lokaler Login (nur auth: 'local') — ersetzt GoTrue, öffentlich + gedrosselt.
// Supabase-Sites loggen direkt gegen GoTrue ein und brauchen das nicht.
if (config.auth === 'local') app.route('/api/auth', authRoute);
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
app.route('/api/history', history);
// Alles weitere unter /api/* braucht ein gültiges Token (Supabase oder lokal).
app.use('/api/*', requireAuth);
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
const mutateLimit = rateLimit({
max: 60, windowMs: 60_000,
keyFn: (c) => 'u:' + (c.get('user')?.id || c.req.header('x-forwarded-for') || 'anon'),
});
app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next)));
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
// Authentifizierte Plugin-Routen (nach requireAuth). admin:true-Routen laufen
// hinter requireAdmin; self-guarding Sub-Apps (dialog mod/adminForums) regeln den
// Zugriff selbst und brauchen das Flag nicht.
manager.mountPrivate(app, '/api', requireAdmin);
app.route('/api/content', content);
app.route('/api/preview', preview);
app.route('/api/publish', publish);
app.route('/api/upload', upload);
app.route('/api/profile', profile);
app.route('/api/users', users);
app.route('/api/stats', stats);
app.route('/api/system', system);
// Content-Modell der Site fürs Admin (schema-getriebenes Rendern). Jede:r
// eingeloggte Nutzer:in darf es lesen — der Editor braucht es zum Aufbauen.
app.get('/api/schema', (c) => c.json({
site: config.site || null,
auth: config.auth || 'supabase',
plugins: config.plugins,
collections: config.collections,
}));
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
app.get('/admin', (c) => c.redirect('/admin/'));
app.use(
'/admin/*',
serveStatic({
root: ADMIN_DIR,
rewriteRequestPath: (p) => p.replace(/^\/admin/, '') || '/',
}),
);
// --- Vorschau (gebaut nach preview/ mit --buildDrafts) ---
app.use(
'/_preview/*',
serveStatic({
root: `${SITE_DIR}/preview`,
rewriteRequestPath: (p) => p.replace(/^\/_preview/, ''),
}),
);
// Hochgeladene Bilder direkt aus static/ servieren — sofort sichtbar
// (Vorschau, Cover, Profilbild), ohne auf den nächsten Hugo-Build zu warten.
app.use('/images/*', serveStatic({ root: `${SITE_DIR}/static` }));
// --- Live-Site (gebaut nach public/) ---
app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
serve({ fetch: app.fetch, port: PORT }, async (info) => {
console.log(`CMS läuft auf :${info.port} — Site + API + /_preview`);
// Plugin-Migrationen zuerst anwenden (run-once, getrackt) — die Boot-Hooks
// unten brauchen die Tabellen schon. Ohne deklarierte Migrationen ein No-op.
await runMigrations(manager, { databaseUrl: process.env.DATABASE_URL })
.catch((e) => console.error('migrate:', e?.message || e));
// Plugin-Boot-Hooks anstoßen (dialog z. B. spiegelt Library→Threads), nicht blockierend.
manager.runBoot({}).catch((e) => console.error('plugin onBoot:', e?.message || e));
});
+75
View File
@@ -0,0 +1,75 @@
// Migration runner — applies plugin-declared migrations once each, tracked in
// public.schema_migrations. The plugin manager only *surfaces* migrations()
// (file URLs); this is what actually runs them, at boot.
//
// Design for a lean / DB-optional core:
// * No declared migrations (e.g. kgva: no plugins) → returns immediately, never
// opens a DB connection and never imports `pg`.
// * `exec` is injectable — an async (text, params?) => rows function — so the
// logic unit-tests without a real database. Omit it and the runner connects
// via `pg` to `databaseUrl` (lazy import; clear error if `pg` is missing).
// * Each migration runs once (id = "<plugin>/<file>") inside its own
// transaction; the file SQL is itself idempotent (see 001_dialog.sql), so a
// pre-existing DB (openbureau) is baselined cleanly on first run.
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
export async function runMigrations(manager, { databaseUrl, exec, log = console } = {}) {
const migs = manager.migrations();
if (!migs.length) return { applied: [], skipped: [], pending: false };
let close = async () => {};
if (!exec) {
if (!databaseUrl) {
log.warn?.(`migrate: ${migs.length} Migration(en) deklariert, aber DATABASE_URL fehlt — übersprungen.`);
return { applied: [], skipped: migs.map((m) => `${m.plugin}/${m.file}`), pending: true };
}
({ exec, close } = await pgExecutor(databaseUrl));
}
try {
await exec(`CREATE TABLE IF NOT EXISTS public.schema_migrations (
id text PRIMARY KEY,
plugin text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
)`);
const applied = [], skipped = [];
for (const m of migs) {
const id = `${m.plugin}/${m.file}`;
const done = await exec('SELECT 1 FROM public.schema_migrations WHERE id = $1', [id]);
if (done?.length) { skipped.push(id); continue; }
try {
const sql = await readFile(fileURLToPath(m.url), 'utf8');
await exec('BEGIN');
try {
await exec(sql);
await exec('INSERT INTO public.schema_migrations (id, plugin) VALUES ($1, $2)', [id, m.plugin]);
await exec('COMMIT');
} catch (e) {
await exec('ROLLBACK');
throw e;
}
} catch (e) {
throw new Error(`Migration ${id} fehlgeschlagen: ${e.message}`);
}
applied.push(id);
log.log?.(`migrate: angewandt ${id}`);
}
return { applied, skipped, pending: false };
} finally {
await close();
}
}
// Default executor: one pg client over databaseUrl. `pg` is imported lazily so a
// DB-less instance never pulls it in at runtime.
async function pgExecutor(databaseUrl) {
let pg;
try { pg = (await import('pg')).default; }
catch { throw new Error("migrate: Paket 'pg' fehlt (npm i pg) — für Plugin-Migrationen nötig."); }
const client = new pg.Client({ connectionString: databaseUrl });
await client.connect();
const exec = async (text, params) => (await client.query(text, params)).rows;
return { exec, close: () => client.end() };
}
+95
View File
@@ -0,0 +1,95 @@
// Plugin manager: everything beyond the generic engine is a plugin (see README →
// "Plugins"). A plugin is `api/src/plugins/<name>/index.js` exporting a manifest:
// { name, routes:[…], onBoot, onPublish, onPreview, migrations:[…], stats, admin:{…} }
//
// A route entry is either a mounted sub-app or a single handler:
// { path, app, public?, admin? } // app = Hono instance
// { method, path, handler, public?, admin?, use?:[mw] } // method = get|post|put|delete
// `public: true` → mounted before requireAuth; `admin: true` → guarded with the
// supplied requireAdmin; `use` adds per-route middleware (e.g. a rate limit).
//
// The manager loads the manifests named in config.plugins and wires them in:
// mounts routes by auth tier, runs lifecycle hooks, merges stats, surfaces
// migrations. Pure wiring — plugins import what they need (supabase, files, …).
const PLUGINS_DIR = new URL('./plugins/', import.meta.url);
// Import the manifests for `names` (config.plugins) and return a manager.
export async function loadPlugins(names = [], dir = PLUGINS_DIR) {
const manifests = [];
for (const name of names) {
const url = new URL(`${name}/index.js`, dir);
const mod = await import(url.href);
const manifest = mod.default || mod;
if (!manifest?.name) throw new Error(`Plugin "${name}": ungültiges Manifest (name fehlt)`);
manifest.__dir = new URL(`${name}/`, dir);
manifests.push(manifest);
}
return createManager(manifests);
}
// Mount one route entry (sub-app or handler) at `full`, behind `guards` (+ its
// own `use` middleware).
function mountRoute(app, full, r, guards) {
const mws = [...guards, ...(r.use || [])];
if (r.app) {
for (const mw of mws) { app.use(full, mw); app.use(full + '/*', mw); }
app.route(full, r.app);
} else if (r.method && r.handler) {
app[r.method](full, ...mws, r.handler);
} else {
throw new Error(`Plugin-Route ${full}: braucht entweder app oder method+handler`);
}
}
// Build a manager over already-loaded manifests (kept separate from import so it
// unit-tests without touching the filesystem).
export function createManager(manifests) {
const plugins = (manifests || []).filter(Boolean);
const routesWhere = (pred) =>
plugins.flatMap((p) => (p.routes || []).filter(pred).map((r) => ({ plugin: p.name, ...r })));
return {
plugins,
names: () => plugins.map((p) => p.name),
has: (name) => plugins.some((p) => p.name === name),
// Public routes (no auth) — mount BEFORE `requireAuth` in the pipeline.
mountPublic(app, prefix = '/api') {
for (const r of routesWhere((r) => r.public)) mountRoute(app, prefix + r.path, r, []);
},
// Authenticated routes — mount AFTER `requireAuth`. `admin: true` routes are
// guarded with the supplied requireAdmin (sub-apps that self-guard can omit
// the flag, as dialog's mod/adminForums do).
mountPrivate(app, prefix = '/api', requireAdmin) {
for (const r of routesWhere((r) => !r.public)) {
const guards = r.admin && requireAdmin ? [requireAdmin] : [];
mountRoute(app, prefix + r.path, r, guards);
}
},
// Lifecycle hooks (awaited in plugin order).
async runBoot(ctx) { for (const p of plugins) if (p.onBoot) await p.onBoot(ctx); },
async runPublish(ctx, payload) { for (const p of plugins) if (p.onPublish) await p.onPublish(ctx, payload); },
async runPreview(ctx, payload) { for (const p of plugins) if (p.onPreview) await p.onPreview(ctx, payload); },
// Merge each plugin's stats() under its own name: { dialog: { forums, … } }.
async collectStats(ctx) {
const out = {};
for (const p of plugins) if (p.stats) out[p.name] = await p.stats(ctx);
return out;
},
// Declared migrations resolved to file URLs; EXECUTION is the caller's job
// (needs the live Postgres — wired in stage 5/boot, not here).
migrations() {
return plugins.flatMap((p) =>
(p.migrations || []).map((file) => ({
plugin: p.name,
file,
url: p.__dir ? new URL(`migrations/${file}`, p.__dir).href : null,
})));
},
};
}
+9
View File
@@ -0,0 +1,9 @@
// The plugin manager for this instance, loaded once from config.plugins.
//
// Shared singleton: index.js wires its routes/hooks, publish.js fires onPublish,
// stats.js merges collectStats. A pluginless site (config.plugins = []) gets an
// empty manager — every call is a no-op, zero DB reads.
import { loadPlugins } from './plugin-manager.js';
import { config } from './config.js';
export const manager = await loadPlugins(config.plugins);
@@ -0,0 +1,74 @@
import { supabase, supabaseAuth } from '../../supabase.js';
import { roleOf } from '../../auth.js';
import { profileFor, threadLocked } from './dialog-store.js';
import { serverError } from '../../util.js';
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
const COLS = 'id,thread,parent_id,author_name,author_avatar,author_role,body,created_at,deleted';
const MAX_BODY = 10_000; // Zeichen je Wortmeldung
const MAX_THREAD = 512; // Thread-Key-Länge
// ÖFFENTLICH: Wortmeldungen eines Threads lesen.
export async function listComments(c) {
const thread = c.req.query('thread');
if (!thread) return c.json({ error: 'thread fehlt' }, 400);
const { data, error } = await supabase
.from('comments').select(COLS).eq('thread', thread).order('created_at', { ascending: true });
if (error) return serverError(c, 'listComments', error);
const out = (data || []).map((r) => (r.deleted ? { ...r, body: '[gelöscht]', author_avatar: null } : r));
return c.json(out);
}
// EINGELOGGT: Wortmeldung schreiben.
export async function createComment(c) {
const user = c.get('user');
const email = c.get('email');
const { thread, body, parent_id } = await c.req.json();
if (!thread || !body || !body.trim()) return c.json({ error: 'thread und Text nötig' }, 400);
if (typeof thread !== 'string' || thread.length > MAX_THREAD) return c.json({ error: 'Ungültiger Thread' }, 400);
if (typeof body !== 'string' || body.length > MAX_BODY) return c.json({ error: `Text zu lang (max. ${MAX_BODY} Zeichen)` }, 400);
if (await threadLocked(thread)) return c.json({ error: 'Thread ist gesperrt' }, 403);
const prof = await profileFor(email);
const row = {
thread,
parent_id: parent_id || null,
user_id: user.id,
author_name: prof?.name || email.split('@')[0],
author_avatar: prof?.avatar || null,
author_role: prof?.title || null, // „Position bei OPENBUREAU" (aus data/authors.json)
body: body.trim(),
};
const { data, error } = await supabase.from('comments').insert(row).select(COLS).single();
if (error) return serverError(c, 'createComment', error, 400);
return c.json(data, 201);
}
// EINGELOGGT: eigene Wortmeldung löschen; Moderation (Admin/Redakteur) jede.
export async function deleteComment(c) {
const user = c.get('user');
const canModerate = c.get('canModerate');
const id = c.req.param('id');
const { data: row, error: e1 } = await supabase.from('comments').select('user_id').eq('id', id).single();
if (e1 || !row) return c.json({ error: 'Nicht gefunden' }, 404);
if (!canModerate && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403);
const { error } = await supabase.from('comments').update({ deleted: true }).eq('id', id);
if (error) return serverError(c, 'deleteComment', error, 400);
return c.json({ ok: true });
}
// ÖFFENTLICH: Login fürs Dialog-Widget — gibt das User-Token zurück.
export async function login(c) {
const { email, password } = await c.req.json();
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
const { data, error } = await supabaseAuth.auth.signInWithPassword({ email, password });
if (error) return c.json({ error: error.message }, 401);
const prof = await profileFor((data.user.email || '').toLowerCase());
return c.json({
access_token: data.session.access_token,
email: data.user.email,
name: prof?.name || (data.user.email || '').split('@')[0],
role: roleOf(data.user),
});
}
@@ -0,0 +1,182 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { supabase } from '../../supabase.js';
import { listEntries } from '../../files.js';
// Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen).
// Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS).
const SITE_DIR = process.env.SITE_DIR || '/site';
export const LIBRARY_SLUG = 'beitraege';
export async function profileFor(email) {
try {
const all = JSON.parse(await readFile(path.join(SITE_DIR, 'data', 'authors.json'), 'utf8'));
return all[email] || null;
} catch { return null; }
}
// Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man
// auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key).
//
// 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;
let entries = [];
try { entries = (await listEntries()).filter((e) => e.kind === 'beitrag'); } catch { return; }
if (!entries.length) return;
const rows = entries.map((e) => ({
forum_id: forum.id, key: e.url, title: e.title, url: e.url, kind: 'library',
}));
// Nicht title überschreiben? Doch — Titel kann sich ändern. user_id/locked bleiben.
await supabase.from('threads').upsert(rows, { onConflict: 'key', ignoreDuplicates: false });
}
// Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }.
// Aggregiert in Postgres (View comment_stats) statt alle Zeilen zu laden.
async function commentStats() {
const { data } = await supabase.from('comment_stats').select('thread,count,last');
const map = {};
for (const r of data || []) map[r.thread] = { count: r.count, last: r.last };
return map;
}
// Alle Foren mit Thread-/Wortmeldungs-Zahl und letzter Aktivität.
export async function forumsWithCounts() {
await syncLibrary();
const [{ data: forums }, { data: threads }, stats] = await Promise.all([
supabase.from('forums').select('*').order('sort'),
supabase.from('threads').select('id,forum_id,key,deleted'),
commentStats(),
]);
const byForum = {};
for (const t of threads || []) {
if (t.deleted) continue;
const f = byForum[t.forum_id] || (byForum[t.forum_id] = { threads: 0, posts: 0, last: '' });
f.threads += 1;
const s = stats[t.key];
if (s) { f.posts += s.count; if (s.last > f.last) f.last = s.last; }
}
return (forums || []).map((f) => ({
...f,
thread_count: byForum[f.id]?.threads || 0,
post_count: byForum[f.id]?.posts || 0,
last_at: byForum[f.id]?.last || null,
}));
}
// Ein Forum samt seiner Threads (nach letzter Aktivität sortiert).
export async function forumWithThreads(slug) {
const { data: forum } = await supabase.from('forums').select('*').eq('slug', slug).single();
if (!forum) return null;
if (forum.kind === 'library') await syncLibrary();
const [{ data: threads }, stats] = await Promise.all([
supabase.from('threads').select('*').eq('forum_id', forum.id).eq('deleted', false),
commentStats(),
]);
const list = (threads || []).map((t) => ({
key: t.key, title: t.title, url: t.url, kind: t.kind, locked: t.locked,
author_name: t.author_name, created_at: t.created_at,
count: stats[t.key]?.count || 0, last: stats[t.key]?.last || t.created_at,
})).sort((a, b) => (b.last || '').localeCompare(a.last || ''));
return { forum, threads: list };
}
// Letzte Wortmeldungen über alles — für die linke Spalte der Übersicht.
export async function recentComments(limit = 20) {
await syncLibrary();
const [{ data: comments }, { data: threads }, { data: forums }] = await Promise.all([
supabase.from('comments').select('id,thread,author_name,body,created_at')
.eq('deleted', false).order('created_at', { ascending: false }).limit(limit),
supabase.from('threads').select('key,title,url,forum_id,kind'),
supabase.from('forums').select('id,slug,name'),
]);
const tByKey = {}; for (const t of threads || []) tByKey[t.key] = t;
const fById = {}; for (const f of forums || []) fById[f.id] = f;
return (comments || []).map((c) => {
const t = tByKey[c.thread];
const f = t ? fById[t.forum_id] : null;
return {
id: c.id, body: c.body, author_name: c.author_name, created_at: c.created_at,
thread_title: t?.title || c.thread,
thread_url: t ? (t.kind === 'library' ? t.url : '/dialog/?thread=' + encodeURIComponent(c.thread)) : c.thread,
forum_name: f?.name || null, forum_slug: f?.slug || null,
};
});
}
// Moderations-Überblick: letzte Wortmeldungen + alle Threads (zum Sperren/Löschen).
export async function recentForModeration() {
const [comments, { data: threads }, { data: forums }] = await Promise.all([
recentComments(50),
supabase.from('threads').select('key,title,url,kind,forum_id,locked,deleted,author_name,created_at')
.order('created_at', { ascending: false }),
supabase.from('forums').select('id,name,slug'),
]);
const fById = {}; for (const f of forums || []) fById[f.id] = f;
const stats = await commentStats();
const t = (threads || []).map((x) => ({
key: x.key, title: x.title, url: x.url, kind: x.kind, locked: x.locked, deleted: x.deleted,
author_name: x.author_name, created_at: x.created_at,
forum_name: fById[x.forum_id]?.name || null,
count: stats[x.key]?.count || 0,
}));
return { comments, threads: t };
}
// Neuen Thread in einem Forum anlegen (+ erste Wortmeldung). Gibt den Thread zurück.
export async function createThread({ forumId, forumSlug, title, body, user, email }) {
let fid = forumId;
if (!fid && forumSlug) {
const { data: f } = await supabase.from('forums').select('id,kind').eq('slug', forumSlug).single();
if (!f) return { error: 'Forum unbekannt' };
if (f.kind === 'library') return { error: 'In Beiträge entstehen Threads automatisch' };
fid = f.id;
}
if (!fid) return { error: 'Forum nötig' };
if (!title || !title.trim()) return { error: 'Titel nötig' };
if (!body || !body.trim()) return { error: 'Erster Beitrag nötig' };
const prof = await profileFor(email);
const name = prof?.name || email.split('@')[0];
const key = 't/' + randomUUID();
const { data: thread, error: e1 } = await supabase.from('threads').insert({
forum_id: fid, key, title: title.trim(), url: '/dialog/?thread=' + encodeURIComponent(key),
kind: 'forum', author_name: name, user_id: user.id,
}).select('*').single();
if (e1) return { error: e1.message };
const { error: e2 } = await supabase.from('comments').insert({
thread: key, user_id: user.id, author_name: name,
author_avatar: prof?.avatar || null, body: body.trim(),
});
if (e2) return { error: e2.message };
return { thread };
}
// Ist ein Thread gesperrt? (verhindert neue Wortmeldungen)
export async function threadLocked(key) {
const { data } = await supabase.from('threads').select('locked').eq('key', key).single();
return !!data?.locked;
}
// Thread-Metadaten für die Thread-Ansicht (Titel, Forum-Rücklink, Lock-Status).
export async function threadMeta(key) {
const { data: t } = await supabase
.from('threads').select('title,url,kind,locked,forum_id').eq('key', key).single();
if (!t) return null;
let forum = null;
if (t.forum_id) {
const { data: f } = await supabase.from('forums').select('slug,name').eq('id', t.forum_id).single();
forum = f || null;
}
return { title: t.title, url: t.url, kind: t.kind, locked: !!t.locked, forum };
}
+104
View File
@@ -0,0 +1,104 @@
import { Hono } from 'hono';
import { supabase } from '../../supabase.js';
import { requireAdmin, requireModerator } from '../../auth.js';
import { serverError } from '../../util.js';
import {
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
} from './dialog-store.js';
// Fehlt die Tabelle (Migration noch nicht eingespielt), nicht mit einem rohen
// SQL-Fehler antworten — leer zurückgeben und server-seitig laut loggen.
function softFail(c, e, fallback) {
console.error('[dialog]', e?.message || e);
return c.json(fallback);
}
// ── Öffentliche Lese-Handler ─────────────────────────────────────────────
export async function listForums(c) {
try { return c.json(await forumsWithCounts()); }
catch (e) { return softFail(c, e, []); }
}
export async function showForum(c) {
try {
const data = await forumWithThreads(c.req.param('slug'));
if (!data) return c.json({ error: 'Forum nicht gefunden' }, 404);
return c.json(data);
} catch (e) { return softFail(c, e, { forum: null, threads: [] }); }
}
export async function recent(c) {
try { return c.json(await recentComments(Number(c.req.query('limit')) || 20)); }
catch (e) { return softFail(c, e, []); }
}
export async function threadInfo(c) {
const key = c.req.query('key');
if (!key) return c.json({ error: 'key fehlt' }, 400);
const meta = await threadMeta(key);
if (!meta) return c.json({ error: 'Thread nicht gefunden' }, 404);
return c.json(meta);
}
// ── Eingeloggt: neuen Thread starten ─────────────────────────────────────
export async function newThread(c) {
const user = c.get('user');
const email = c.get('email');
const { forum_id, forum_slug, title, body } = await c.req.json();
const res = await createThread({ forumId: forum_id, forumSlug: forum_slug, title, body, user, email });
if (res.error) return c.json({ error: res.error }, 400);
return c.json(res.thread, 201);
}
// ── Moderation (Admin + Redakteur) ───────────────────────────────────────
export const mod = new Hono();
mod.use('*', requireModerator);
// Feed: letzte Wortmeldungen + alle Threads (zum Moderieren/Sperren).
mod.get('/overview', async (c) => c.json(await recentForModeration()));
// Thread sperren/entsperren.
mod.post('/thread-lock', async (c) => {
const { key, locked } = await c.req.json();
if (!key) return c.json({ error: 'key nötig' }, 400);
const { error } = await supabase.from('threads').update({ locked: !!locked }).eq('key', key);
if (error) return serverError(c, 'dialog', error, 400);
return c.json({ ok: true });
});
// Thread ausblenden (löschen).
mod.post('/thread-delete', async (c) => {
const { key } = await c.req.json();
if (!key) return c.json({ error: 'key nötig' }, 400);
const { error } = await supabase.from('threads').update({ deleted: true }).eq('key', key);
if (error) return serverError(c, 'dialog', error, 400);
return c.json({ ok: true });
});
// ── Foren-Verwaltung (nur Admin) ─────────────────────────────────────────
export const adminForums = new Hono();
adminForums.use('*', requireAdmin);
adminForums.get('/', async (c) => {
const { data, error } = await supabase.from('forums').select('*').order('sort');
if (error) return serverError(c, 'dialog', error, 500);
return c.json(data || []);
});
adminForums.post('/', async (c) => {
const { slug, name, description, color, sort } = await c.req.json();
if (!slug || !name) return c.json({ error: 'slug und name nötig' }, 400);
const row = { slug: String(slug).trim(), name: String(name).trim(),
description: description || '', color: color || null, sort: Number(sort) || 0 };
const { data, error } = await supabase.from('forums').insert(row).select('*').single();
if (error) return serverError(c, 'dialog', error, 400);
return c.json(data, 201);
});
adminForums.put('/:id', async (c) => {
const patch = await c.req.json();
const allowed = {};
for (const k of ['name', 'description', 'color', 'sort', 'slug']) if (k in patch) allowed[k] = patch[k];
const { data, error } = await supabase.from('forums').update(allowed).eq('id', c.req.param('id')).select('*').single();
if (error) return serverError(c, 'dialog', error, 400);
return c.json(data);
});
adminForums.delete('/:id', async (c) => {
const id = c.req.param('id');
const { data: f } = await supabase.from('forums').select('kind').eq('id', id).single();
if (f?.kind === 'library') return c.json({ error: 'Beiträge-Kategorie kann nicht gelöscht werden' }, 400);
const { error } = await supabase.from('forums').delete().eq('id', id);
if (error) return serverError(c, 'dialog', error, 400);
return c.json({ ok: true });
});
+74
View File
@@ -0,0 +1,74 @@
// dialog plugin — openbureau's forum/comment subsystem as a core plugin.
// Wraps the existing dialog modules (no logic change) into a plugin manifest:
// public reads + widget login, authenticated writes, self-guarding moderation /
// forum-admin sub-apps, syncLibrary on boot + after publish, and the forum
// counters merged into /api/stats. kgva does not enable this plugin.
//
// NB: this manifest exists; wiring it into index.js (and removing the hard-coded
// dialog there) is the live-tested cutover (stage 5) — see HANDOVER.
import { rateLimit } from '../../ratelimit.js';
import {
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
} from './dialog.js';
import { listComments, createComment, deleteComment, login } from './comments.js';
import { supabase } from '../../supabase.js';
import { syncLibrary } from './dialog-store.js';
export default {
name: 'dialog',
routes: [
// ── public reads (mounted before requireAuth) ──
{ method: 'get', path: '/comments', handler: listComments, public: true },
{ method: 'get', path: '/forums', handler: listForums, public: true },
{ method: 'get', path: '/forums/:slug', handler: showForum, public: true },
{ method: 'get', path: '/recent', handler: recent, public: true },
{ method: 'get', path: '/thread', handler: threadInfo, public: true },
// widget login — brute-force throttled (10 / 5 min per IP), as in index.js
{
method: 'post', path: '/auth/login', handler: login, public: true,
use: [rateLimit({ max: 10, windowMs: 5 * 60_000 })],
},
// ── authenticated writes (mounted after requireAuth) ──
{ method: 'post', path: '/comments', handler: createComment },
{ method: 'delete', path: '/comments/:id', handler: deleteComment },
{ method: 'post', path: '/threads', handler: newThread },
// ── self-guarding sub-apps (requireModerator / requireAdmin inside) ──
{ path: '/mod', app: mod },
{ path: '/admin/forums', app: adminForums },
],
// Mirror library posts as threads — on boot and after every publish build.
onBoot: async () => {
await syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e));
},
onPublish: async () => {
await syncLibrary({ force: true }).catch(() => {});
},
// Forum/thread/comment counters, merged into /api/stats under "dialog".
stats: async () => {
const count = async (table, filter) => {
try {
let q = supabase.from(table).select('*', { count: 'exact', head: true });
if (filter) q = filter(q);
const { count: n } = await q;
return n || 0;
} catch { return 0; }
};
const [forums, threads, comments] = await Promise.all([
count('forums'),
count('threads', (q) => q.eq('deleted', false)),
count('comments', (q) => q.eq('deleted', false)),
]);
return { forums, threads, comments };
},
// Schema (forums/threads/comments + comment_stats view) — captured from the live
// openbureau dev DB into migrations/001_dialog.sql (idempotent; verified to apply
// on a fresh DB and re-apply as a no-op). The manager surfaces it; a migration
// runner applies it on boot for a fresh instance (execution still TODO).
migrations: ['001_dialog.sql'],
};
@@ -0,0 +1,99 @@
-- dialog plugin schema — forums / threads / comments (+ comment_stats view).
--
-- Captured 2026-06-29 from the live openbureau dev DB (CT 134 → openbureau-db,
-- supabase/postgres 15.8) via `pg_dump --schema-only --no-owner --no-privileges`
-- of public.{forums,threads,comments,comment_stats}. Made idempotent (IF NOT
-- EXISTS / OR REPLACE / constraint guards) so it is a no-op against the existing
-- openbureau DB and safe to apply to a fresh instance.
--
-- Notes:
-- * gen_random_uuid() is built into Postgres 13+ — no pgcrypto extension needed.
-- * RLS is enabled with NO policies: every read/write is mediated by the CMS
-- using the Supabase service_role key (which bypasses RLS). The dialog plugin
-- therefore only makes sense with `auth: supabase`; kgva (local auth) omits it.
-- * `posts` exists in the live DB but the dialog code never touches it — NOT a
-- dialog table, intentionally excluded.
-- ── tables ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.forums (
id uuid DEFAULT gen_random_uuid() NOT NULL,
slug text NOT NULL,
name text NOT NULL,
description text DEFAULT ''::text,
color text,
sort integer DEFAULT 0 NOT NULL,
kind text DEFAULT 'forum'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.threads (
id uuid DEFAULT gen_random_uuid() NOT NULL,
forum_id uuid,
key text NOT NULL,
title text NOT NULL,
url text,
kind text DEFAULT 'forum'::text NOT NULL,
author_name text,
user_id uuid,
locked boolean DEFAULT false NOT NULL,
deleted boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.comments (
id uuid DEFAULT gen_random_uuid() NOT NULL,
thread text NOT NULL,
parent_id uuid,
user_id uuid,
author_name text,
author_avatar text,
body text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
deleted boolean DEFAULT false NOT NULL,
author_role text
);
-- ── constraints (guarded — Postgres has no ADD CONSTRAINT IF NOT EXISTS) ──────
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'forums_pkey') THEN
ALTER TABLE ONLY public.forums ADD CONSTRAINT forums_pkey PRIMARY KEY (id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'forums_slug_key') THEN
ALTER TABLE ONLY public.forums ADD CONSTRAINT forums_slug_key UNIQUE (slug);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_pkey') THEN
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_pkey PRIMARY KEY (id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_key_key') THEN
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_key_key UNIQUE (key);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'comments_pkey') THEN
ALTER TABLE ONLY public.comments ADD CONSTRAINT comments_pkey PRIMARY KEY (id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_forum_id_fkey') THEN
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_forum_id_fkey
FOREIGN KEY (forum_id) REFERENCES public.forums(id) ON DELETE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'comments_parent_id_fkey') THEN
ALTER TABLE ONLY public.comments ADD CONSTRAINT comments_parent_id_fkey
FOREIGN KEY (parent_id) REFERENCES public.comments(id) ON DELETE CASCADE;
END IF;
END $$;
-- ── indexes ───────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS threads_forum_idx ON public.threads USING btree (forum_id);
CREATE INDEX IF NOT EXISTS comments_thread_idx ON public.comments USING btree (thread, created_at);
-- ── view: per-thread comment counts (used by dialog-store) ────────────────────
CREATE OR REPLACE VIEW public.comment_stats AS
SELECT comments.thread,
(count(*))::integer AS count,
max(comments.created_at) AS last
FROM public.comments
WHERE (NOT comments.deleted)
GROUP BY comments.thread;
-- ── row-level security (enabled, no policies — service_role-mediated) ─────────
ALTER TABLE public.forums ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
+34
View File
@@ -0,0 +1,34 @@
// Einfacher In-Memory-Rate-Limiter (ein Container, eine Instanz → genügt).
// Fixed-Window pro Schlüssel (Standard: Client-IP). Bei Überschreitung 429.
// Hinter einem Reverse-Proxy liefert X-Forwarded-For die echte IP.
const buckets = new Map(); // key -> { count, reset }
function clientIp(c) {
const xff = c.req.header('x-forwarded-for');
if (xff) return xff.split(',')[0].trim();
return c.req.header('x-real-ip') || 'unknown';
}
// max Anfragen je windowMs. keyFn erlaubt eigene Schlüssel (z.B. IP+E-Mail).
export function rateLimit({ max = 10, windowMs = 60_000, keyFn = clientIp } = {}) {
return async (c, next) => {
const key = keyFn(c);
const now = Date.now();
let b = buckets.get(key);
if (!b || now > b.reset) { b = { count: 0, reset: now + windowMs }; buckets.set(key, b); }
b.count += 1;
if (b.count > max) {
const retry = Math.ceil((b.reset - now) / 1000);
c.header('Retry-After', String(retry));
return c.json({ error: 'Zu viele Anfragen — bitte später erneut.' }, 429);
}
await next();
};
}
// Speicher sauber halten: abgelaufene Buckets periodisch wegräumen.
setInterval(() => {
const now = Date.now();
for (const [k, b] of buckets) if (now > b.reset) buckets.delete(k);
}, 5 * 60_000).unref?.();
+20
View File
@@ -0,0 +1,20 @@
import { Hono } from 'hono';
import { rateLimit } from '../ratelimit.js';
import { login } from '../auth-local.js';
// Lokaler Login (auth: 'local') — ersetzt GoTrue. Liefert ein GoTrue-förmiges
// Token, das requireAuth genauso prüft wie ein Supabase-Token. Nur gemountet,
// wenn config.auth === 'local' (siehe index.js); Brute-Force gedrosselt.
const auth = new Hono();
auth.post('/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), async (c) => {
let body = {};
try { body = await c.req.json(); } catch { /* leerer/kaputter Body → 400 unten */ }
const { email, password } = body;
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
const res = await login(email, password);
if (!res) return c.json({ error: 'Login fehlgeschlagen' }, 401);
return c.json(res);
});
export default auth;
+50
View File
@@ -0,0 +1,50 @@
import { Hono } from 'hono';
import { listEntries, readEntry, writeEntry, entryExists, hasAccess, normAuthors } from '../files.js';
// Dateibasiert + Rechte: Admin sieht/bearbeitet alles, Autor:innen nur Einträge,
// in denen ihre Mail unter `authors` steht.
const content = new Hono();
content.get('/', async (c) => {
const email = c.get('email'); const isAdmin = c.get('isAdmin');
try {
let items = await listEntries();
if (!isAdmin) items = items.filter((e) => hasAccess(e.authors, email));
return c.json(items);
} catch (e) { return c.json({ error: String(e.message || e) }, 500); }
});
content.get('/entry', async (c) => {
const email = c.get('email'); const isAdmin = c.get('isAdmin');
try {
const entry = await readEntry(c.req.query('path'));
if (!isAdmin && !hasAccess(entry.frontmatter.authors, email)) {
return c.json({ error: 'Kein Zugriff auf diesen Eintrag' }, 403);
}
return c.json(entry);
} catch (e) { return c.json({ error: String(e.message || e) }, 400); }
});
content.put('/entry', async (c) => {
const email = c.get('email'); const isAdmin = c.get('isAdmin');
const { path: rel, frontmatter, body } = await c.req.json();
try {
const exists = await entryExists(rel);
if (exists && !isAdmin) {
const cur = await readEntry(rel);
if (!hasAccess(cur.frontmatter.authors, email)) {
return c.json({ error: 'Kein Zugriff auf diesen Eintrag' }, 403);
}
}
// authors zusammenführen; Ersteller wird beim Anlegen automatisch Autor.
const authors = normAuthors(frontmatter.authors);
if (!exists && email && !authors.some((a) => a.toLowerCase() === email)) {
authors.unshift(email);
}
const fm = { ...frontmatter, authors };
const saved = await writeEntry(rel, fm, body);
return c.json({ ok: true, path: saved, created: !exists });
} catch (e) { return c.json({ error: String(e.message || e) }, 400); }
});
export default content;
+83
View File
@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import matter from 'gray-matter';
import { marked } from 'marked';
import { safeRel } from '../files.js';
// ÖFFENTLICH: Versionsverlauf eines Library-Beitrags aus der Git-History.
// Der Container hat das Repo unter /site gemountet + git installiert. Wir
// holen alte Fassungen on-demand (kein Vorbauen) und zeigen sie auf der Site.
const execFileP = promisify(execFile);
const SITE_DIR = process.env.SITE_DIR || '/site';
const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { maxBuffer: 10 * 1024 * 1024 });
const US = '\x1f'; // Feldtrenner (Unit Separator) — kommt in Commit-Daten nicht vor.
const history = new Hono();
// Liste der Versionen: neueste zuerst.
history.get('/', async (c) => {
let rel;
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
try {
const { stdout } = await git(
'log', '--follow', `--format=%H${US}%h${US}%aI${US}%an${US}%s`, '--', `content/${rel}`);
const versions = stdout.trim().split('\n').filter(Boolean).map((line) => {
const [rev, short, date, author, subject] = line.split(US);
return { rev, short, date, author, subject };
});
return c.json(versions);
} catch { return c.json({ error: 'Verlauf nicht verfügbar' }, 500); }
});
// Eine bestimmte Fassung gerendert (HTML), zum Anzeigen auf der Seite.
history.get('/version', async (c) => {
let rel;
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
const rev = c.req.query('rev') || '';
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
try {
const { stdout } = await git('show', `${rev}:content/${rel}`);
const { data, content } = matter(stdout);
return c.json({
rev,
title: data.title || '',
date: data.date ? new Date(data.date).toISOString().slice(0, 10) : null,
html: renderMarkdown(content),
});
} catch { return c.json({ error: 'Version nicht gefunden' }, 404); }
});
// Unified-Diff einer Fassung (was dieser Commit an der Datei geändert hat) —
// fürs rot/grün-Diff auf der Seite. Roh-Diff; das Frontend färbt +/- ein.
history.get('/diff', async (c) => {
let rel;
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
const rev = c.req.query('rev') || '';
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
try {
const { stdout } = await git('show', '--format=', '--no-color', rev, '--', `content/${rel}`);
return c.json({ rev, diff: stdout });
} catch { return c.json({ error: 'Diff nicht verfügbar' }, 404); }
});
// Markdown → HTML. marked kennt Goldmarks Fußnoten ([^id]) nicht — daher
// vorab: Definitionen einsammeln, Verweise zu <sup>-Nummern, „Quellen" anhängen
// (greift dieselbe .footnotes-CSS wie die Live-Seite).
function renderMarkdown(md) {
const defs = {}; const order = [];
md = md.replace(/^\[\^([^\]]+)\]:[ \t]*(.*)$/gm, (_, id, txt) => { defs[id] = txt; return ''; });
md = md.replace(/\[\^([^\]]+)\]/g, (_, id) => {
if (!order.includes(id)) order.push(id);
return `<sup class="footnote-ref">${order.indexOf(id) + 1}</sup>`;
});
let html = marked.parse(md);
if (order.length) {
html += '<div class="footnotes"><ol>'
+ order.map((id) => `<li>${marked.parseInline(defs[id] || '')}</li>`).join('')
+ '</ol></div>';
}
return html;
}
export default history;
+20
View File
@@ -0,0 +1,20 @@
import { Hono } from 'hono';
import { urlFor, safeRel } from '../files.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).
const preview = new Hono();
preview.post('/', async (c) => {
const { path: rel } = await c.req.json();
try {
const safe = safeRel(rel);
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);
}
});
export default preview;
+56
View File
@@ -0,0 +1,56 @@
import { Hono } from 'hono';
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
import { buildSite } from '../hugo.js';
// Profile als Hugo-Data-Datei (data/authors.json) + öffentliche Autor-Seite
// (content/authors/<slug>.md), gerendert von layouts/authors/single.html.
const SITE_DIR = process.env.SITE_DIR || '/site';
const FILE = path.join(SITE_DIR, 'data', 'authors.json');
const AUTHORS_DIR = path.join(SITE_DIR, 'content', 'authors');
async function readAll() {
try { return JSON.parse(await readFile(FILE, 'utf8')); } catch { return {}; }
}
// Muss zu Hugos `urlize` passen (Byline-Link).
function slugify(s) {
return String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
async function exists(p) { try { await stat(p); return true; } catch { return false; } }
const profile = new Hono();
profile.get('/', async (c) => {
const email = c.get('user')?.email || 'default';
const all = await readAll();
return c.json({ email, name: '', bio: '', avatar: '', ...(all[email] || {}) });
});
profile.put('/', async (c) => {
const email = c.get('user')?.email || 'default';
const { name, bio, avatar } = await c.req.json();
const slug = slugify(name);
const all = await readAll();
all[email] = { name: name || '', bio: bio || '', avatar: avatar || '', slug };
await mkdir(path.dirname(FILE), { recursive: true });
await writeFile(FILE, JSON.stringify(all, null, 2) + '\n', 'utf8');
// Öffentliche Autor-Seite schreiben (nur mit Name).
if (slug) {
await mkdir(AUTHORS_DIR, { recursive: true });
const idx = path.join(AUTHORS_DIR, '_index.md');
if (!(await exists(idx))) {
await writeFile(idx, matter.stringify('', { title: 'Autor:innen' }), 'utf8');
}
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 buildSite({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e));
}
return c.json({ ok: true, slug });
});
export default profile;
+24
View File
@@ -0,0 +1,24 @@
import { Hono } from 'hono';
import { urlFor, safeRel } from '../files.js';
import { buildSite, gitCommit } from '../hugo.js';
import { manager } from '../plugins.js';
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
const publish = new Hono();
publish.post('/', async (c) => {
const { path: rel } = await c.req.json();
try {
const safe = safeRel(rel);
const build = await buildSite({ dest: 'public', drafts: false });
// Plugin-Publish-Hooks anstoßen (dialog spiegelt neue Library-Beiträge sofort
// als Threads). Fehler dürfen das Publish nicht kippen — Hooks fangen selbst ab.
await manager.runPublish({}, { path: safe });
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) {
return c.json({ error: String(e.message || e) }, 500);
}
});
export default publish;
+44
View File
@@ -0,0 +1,44 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { listEntries } from '../files.js';
import { requireAdmin, roleOf } from '../auth.js';
import { config } from '../config.js';
import { contentStats } from '../collections.js';
import { manager } from '../plugins.js';
import { countByRole } from '../auth-local.js';
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
const stats = new Hono();
stats.use('*', requireAdmin);
stats.get('/', async (c) => {
// Inhalte aus dem Dateisystem zählen — Zähler pro collection.statKey.
let content = {};
try { content = contentStats(await listEntries(), config.collections); }
catch { /* Filesystem nicht lesbar → leer */ }
// Nutzer nach Rolle: lokal aus der User-Datei, sonst aus GoTrue.
let users = { total: 0, admin: 0, editor: 0, user: 0 };
if (config.auth === 'local') {
try { users = await countByRole(ADMIN_EMAILS); } catch { /* User-Datei fehlt → 0 */ }
} else if (supabase) {
try {
const { data } = await supabase.auth.admin.listUsers();
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
} catch { /* GoTrue nicht erreichbar */ }
}
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
const pluginStats = await manager.collectStats({});
// Dialog bleibt als eigenes Feld im Vertrag (Admin erwartet { forums, threads,
// comments }); fehlt das Plugin, sind es Nullen.
const dialog = pluginStats.dialog || { forums: 0, threads: 0, comments: 0 };
return c.json({ content, users, dialog });
});
export default stats;
+38
View File
@@ -0,0 +1,38 @@
import { Hono } from 'hono';
import { readFile } from 'node:fs/promises';
import { config } from '../config.js';
import { manager } from '../plugins.js';
import { requireAdmin } from '../auth.js';
// System-/Diagnose-Info für das Admin-Panel: welche core-Version läuft, welche
// Plugins aktiv sind, welches Content-Modell die Site fährt. Rein lesend, Admin.
// Reine Builder-Funktion (testbar) — die Route hängt nur Version + Hugo dran.
export function systemInfo({ version, hugo }) {
return {
core: { name: 'openbureau-core', version },
site: config.site || null,
auth: config.auth || 'supabase',
plugins: manager.plugins.map((p) => ({
name: p.name,
routes: (p.routes || []).length,
migrations: (p.migrations || []).length,
})),
collections: config.collections.map((c) => ({
kind: c.kind, label: c.label, statKey: c.statKey || null, fields: (c.fields || []).length,
})),
hugo,
};
}
const system = new Hono();
system.use('*', requireAdmin);
system.get('/', async (c) => {
let version = '0.0.0';
try {
const pkg = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
version = pkg.version || version;
} catch { /* package.json nicht lesbar → 0.0.0 */ }
return c.json(systemInfo({ version, hugo: '0.161.1+extended' }));
});
export default system;
+83
View File
@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
// Bild-Upload → static/images/. Raster-Bilder werden zu WebP konvertiert
// (kleiner, web-optimiert), auf max. 2000px begrenzt, EXIF-Rotation korrigiert.
// SVG/GIF bleiben unangetastet (Vektor/Animation erhalten).
//
// Sicherheit: hartes Größenlimit (DoS / Decompression-Bombs), Raster wird über
// sharp-Metadaten als echtes Bild verifiziert, SVG nur wenn es wie SVG aussieht.
// Hochgeladene Dateien werden zudem mit strikter CSP (sandbox) ausgeliefert
// (siehe index.js, /images/*) → ein bösartiges SVG kann kein JS im Origin starten.
const SITE_DIR = process.env.SITE_DIR || '/site';
const MAX_UPLOAD = 8 * 1024 * 1024; // 8 MB Rohdatei
const ALLOWED_RASTER = new Set(['jpeg', 'png', 'webp', 'avif', 'tiff']);
const upload = new Hono();
upload.post('/', async (c) => {
const body = await c.req.parseBody();
const file = body['file'];
if (!file || typeof file === 'string') return c.json({ error: 'Keine Datei' }, 400);
if (typeof file.size === 'number' && file.size > MAX_UPLOAD) {
return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413);
}
const buffer = Buffer.from(await file.arrayBuffer());
if (buffer.length > MAX_UPLOAD) return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413);
if (!buffer.length) return c.json({ error: 'Leere Datei' }, 400);
const dir = path.join(SITE_DIR, 'static', 'images');
await mkdir(dir, { recursive: true });
const ext = path.extname(file.name || '').toLowerCase();
const base = `${safeBase(file.name)}-${uid()}`;
let outName, outBuf;
if (ext === '.svg') {
// Muss wie SVG aussehen (Magie/Marker), sonst ablehnen.
const head = buffer.subarray(0, 512).toString('utf8').trimStart().toLowerCase();
if (!head.startsWith('<?xml') && !head.startsWith('<svg')) {
return c.json({ error: 'Keine gültige SVG-Datei' }, 400);
}
outName = `${base}.svg`;
outBuf = buffer;
} else if (ext === '.gif') {
// GIF-Magie prüfen (kann kein Skript ausführen → Passthrough ok).
const sig = buffer.subarray(0, 6).toString('latin1');
if (sig !== 'GIF87a' && sig !== 'GIF89a') return c.json({ error: 'Keine gültige GIF-Datei' }, 400);
outName = `${base}.gif`;
outBuf = buffer;
} else {
// Raster: über sharp als echtes Bild verifizieren, dann zu WebP.
let meta;
try { meta = await sharp(buffer).metadata(); } catch { meta = null; }
if (!meta || !ALLOWED_RASTER.has(meta.format)) {
return c.json({ error: 'Kein unterstütztes Bildformat' }, 400);
}
outName = `${base}.webp`;
outBuf = await sharp(buffer)
.rotate()
.resize({ width: 2000, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
}
await writeFile(path.join(dir, outName), outBuf);
return c.json({ url: `/images/${outName}` });
});
// Sicherer Basisname ohne Endung.
function safeBase(raw) {
const base = path.basename(String(raw || 'bild')).replace(/\.[^.]+$/, '');
const cleaned = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
return cleaned || 'bild';
}
// Kurze eindeutige Endung, damit gleichnamige Uploads nicht kollidieren.
function uid() {
return Date.now().toString(36).slice(-4) + Math.random().toString(36).slice(2, 5);
}
export default upload;
+80
View File
@@ -0,0 +1,80 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { requireAdmin, roleOf } from '../auth.js';
import { config } from '../config.js';
import * as local from '../auth-local.js';
// Autoren-/Nutzerverwaltung. Mit auth:'local' gegen die User-Datei (DB-los),
// sonst über die GoTrue-Admin-API (Service-Key). Nur Admins.
const ADMINS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const isLocal = () => config.auth === 'local';
const users = new Hono();
users.use('*', requireAdmin);
users.get('/', async (c) => {
if (isLocal()) {
try { return c.json(await local.listUsers(ADMINS)); }
catch (e) { return c.json({ error: String(e.message || e) }, 500); }
}
const { data, error } = await supabase.auth.admin.listUsers();
if (error) return c.json({ error: error.message }, 500);
const list = (data?.users || []).map((u) => {
const role = roleOf(u);
return {
id: u.id,
email: u.email,
created_at: u.created_at,
last_sign_in_at: u.last_sign_in_at || null,
role,
isAdmin: role === 'admin',
// Admins aus der .env lassen sich nicht per UI herabstufen.
fixedAdmin: ADMINS.includes((u.email || '').toLowerCase()),
};
});
return c.json(list);
});
users.post('/', async (c) => {
const { email, password, role } = await c.req.json();
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
if (isLocal()) {
try { return c.json({ ok: true, id: await local.createUser({ email, password, role }) }); }
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
}
const payload = { email, password, email_confirm: true };
if (role && role !== 'user') payload.app_metadata = { role };
const { data, error } = await supabase.auth.admin.createUser(payload);
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true, id: data.user.id });
});
users.put('/:id', async (c) => {
const { password, role } = await c.req.json();
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
if (!password && !role) return c.json({ error: 'Nichts zu ändern' }, 400);
if (isLocal()) {
try { await local.updateUser(c.req.param('id'), { password, role }); return c.json({ ok: true }); }
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
}
const patch = {};
if (password) patch.password = password;
if (role) patch.app_metadata = { role };
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true });
});
users.delete('/:id', async (c) => {
if (isLocal()) {
try { await local.deleteUser(c.req.param('id')); return c.json({ ok: true }); }
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
}
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true });
});
export default users;
+29
View File
@@ -0,0 +1,29 @@
import { createClient } from '@supabase/supabase-js';
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_KEY;
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
// Mit Keys: echte Clients. Ohne (DB-loser core, auth:'local'): Clients bleiben
// null; die Aufrufer (auth/stats/users) sind null-sicher und nutzen den lokalen
// Provider. DB-Plugins (dialog) laufen ohnehin nur mit Supabase. Den Fail-Fast
// für auth:'supabase' macht index.js (das kennt die config) — supabase.js bleibt
// bewusst config-frei, damit Unit-Tests es ohne CMS_CONFIG importieren können.
let _supabase = null;
let _supabaseAuth = null;
if (url && key) {
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
_supabase = createClient(url, key, opts);
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
_supabaseAuth = createClient(url, key, opts);
} else {
console.warn('supabase.js: kein SUPABASE_URL/SERVICE_KEY — DB-loser Betrieb (auth: local erwartet).');
}
export const supabase = _supabase;
export const supabaseAuth = _supabaseAuth;
+6
View File
@@ -0,0 +1,6 @@
// Serverfehler protokollieren, aber dem Client nur eine generische Meldung
// geben — keine DB-/Stack-Interna nach außen (Info-Leak vermeiden).
export function serverError(c, where, err, status = 500) {
console.error(`[${where}]`, err?.message || err);
return c.json({ error: 'Serverfehler' }, status);
}