Squashed 'cms/core/' content from commit 2f52ec7
git-subtree-dir: cms/core git-subtree-split: 2f52ec7536ee87b4f097d169e1cb0f6a85aebe84
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
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; }
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// 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 ||= [];
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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 history from './routes/history.js';
|
||||
import { requireAuth, requireAdmin } from './auth.js';
|
||||
import { manager } from './plugins.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
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);
|
||||
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
|
||||
app.route('/api/history', history);
|
||||
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
||||
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);
|
||||
|
||||
// --- 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));
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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,
|
||||
})));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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?.();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,36 @@
|
||||
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';
|
||||
|
||||
// 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 (GoTrue).
|
||||
const users = { total: 0, admin: 0, editor: 0, user: 0 };
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { requireAdmin, roleOf } from '../auth.js';
|
||||
|
||||
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
const users = new Hono();
|
||||
users.use('*', requireAdmin);
|
||||
|
||||
users.get('/', async (c) => {
|
||||
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);
|
||||
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();
|
||||
const patch = {};
|
||||
if (password) patch.password = password;
|
||||
if (role) {
|
||||
if (!['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||
patch.app_metadata = { role };
|
||||
}
|
||||
if (!Object.keys(patch).length) return c.json({ error: 'Nichts zu ändern' }, 400);
|
||||
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) => {
|
||||
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;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const url = process.env.SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SERVICE_KEY;
|
||||
|
||||
if (!url || !key) {
|
||||
console.error('FEHLT: SUPABASE_URL und/oder SUPABASE_SERVICE_KEY in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
|
||||
|
||||
// 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.
|
||||
export const 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.
|
||||
export const supabaseAuth = createClient(url, key, opts);
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user