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
@@ -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;