cms: depend on openbureau-core (vendored via git subtree at cms/core)

The CMS engine is now consumed from openbureau-core instead of this repo's own
copy. cms/core = openbureau-core (subtree of its main). docker-compose builds the
cms image from ./core (core's own Dockerfile → core/admin + core/api), and a new
cms/openbureau.config.js (CMS_CONFIG) tells the generic engine this site's content
model + plugins (dialog). Old vendored cms/api + cms/admin removed.

Behaviour-preserving: core's admin/src is byte-identical to the old one, and core's
API was live-verified byte-identical against the running stack. Schema stays owned
by the stack's migrate service (db/schema.sql incl. dialog tables); DATABASE_URL
left unset so core's plugin migration runner is a no-op here. Update core later with
`git subtree pull --prefix=cms/core <core> main --squash`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 00:14:36 +02:00
parent 2bf27f01be
commit ab78c84296
38 changed files with 84 additions and 5451 deletions
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OPENBUREAU — CMS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-2060
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
{
"name": "openbureau-cms-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.47.10",
"@toast-ui/editor": "^3.2.2",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
}
}
-714
View File
@@ -1,714 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import ToastEditor from '@toast-ui/editor';
import '@toast-ui/editor/dist/toastui-editor.css';
import { supabase } from './supabase.js';
import { api } from './api.js';
// OPENBUREAU-Palette (Hex aus assets/css/custom.css) — für Dropdown + Punkte.
const COLORS = [
['', 'keine', 'transparent'],
['ajisai', 'Ajisai · Hortensie', '#A39EC4'],
['sakura', 'Sakura · Kirschblüte', '#C49EC4'],
['suna', 'Suna · Sand', '#C4C19E'],
['ichigo', 'Ichigo · Erdbeere', '#C49EA0'],
['yuyake', 'Yuyake · Sonnenuntergang', '#CEB188'],
['sora', 'Sora · Himmel', '#9EC3C4'],
['kusa', 'Kusa · Gras', '#9EC49F'],
['kori', 'Kori · Eis', '#A5B4CB'],
['amagumo', 'Amagumo · Regenwolke', '#4C4C4C'],
['yuki', 'Yuki · Schnee', '#F0F0F0'],
];
const hexOf = (name) => (COLORS.find((c) => c[0] === name) || [])[2] || 'transparent';
const LAYOUTS = ['', 'text', 'image', 'icon'];
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
const KIND_LABEL = { beitrag: 'Beiträge', biblio: 'Library', seite: 'Seiten', rubrik: 'Rubriken' };
const EMPTY = {
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
color: '', layout: 'text', tags: '', summary: '', description: '',
cover_image: '', external: '', authors: '', group: '', toc: false, draft: true, body: '',
};
export default function App() {
const [session, setSession] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getSession().then(({ data }) => { setSession(data.session); setLoading(false); });
const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s));
return () => sub.subscription.unsubscribe();
}, []);
if (loading) return <div className="center muted"></div>;
if (!session) return <Login />;
return <Dashboard email={session.user.email} />;
}
function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState(null);
async function submit(e) {
e.preventDefault(); setErr(null);
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) setErr(error.message);
}
return (
<div className="center">
<form className="login" onSubmit={submit}>
<div className="login-brand">OPENBUREAU</div>
<div className="login-sub">Redaktion</div>
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} autoFocus />
<input type="password" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Anmelden</button>
{err && <p className="err">{err}</p>}
</form>
</div>
);
}
function Dashboard({ email }) {
const [entries, setEntries] = useState([]);
const [current, setCurrent] = useState(null);
const [query, setQuery] = useState('');
const [view, setView] = useState('content');
const [me, setMe] = useState(null);
const [msg, setMsg] = useState(null);
async function refresh() {
try { setEntries(await api.list()); }
catch (e) { setMsg({ type: 'err', text: e.message }); }
}
useEffect(() => { refresh(); api.getMe().then(setMe).catch(() => {}); }, []);
useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }, [msg]);
async function open(entry) {
try { setCurrent(fromRead(await api.read(entry.path))); }
catch (err) { setMsg({ type: 'err', text: err.message }); }
}
const q = query.trim().toLowerCase();
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
const groups = { beitrag: [], biblio: [], seite: [], rubrik: [] };
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
return (
<div className="app">
<header className="topbar">
<span className="logo">OPENBUREAU</span>
<span className="logo-sub">Redaktion</span>
<nav className="nav">
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
{me?.isAdmin && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
</nav>
<span className="spacer" />
<span className="who">{email}</span>
<button className="ghost" onClick={() => supabase.auth.signOut()}>Abmelden</button>
</header>
<div className="body">
{view === 'overview' ? (
<Overview onMsg={setMsg} go={setView} />
) : view === 'profile' ? (
<Profile onMsg={setMsg} />
) : view === 'users' ? (
<Users onMsg={setMsg} currentEmail={me?.email} />
) : view === 'forums' ? (
<Forums onMsg={setMsg} />
) : view === 'moderation' ? (
<Moderation onMsg={setMsg} />
) : (
<>
<aside>
<button className="new" onClick={() => setCurrent({ ...EMPTY })}> Neuer Beitrag</button>
<div className="search"><span></span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
{['beitrag', 'biblio', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
<div className="group" key={kind}>
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
<ul className="list">
{groups[kind].map((e) => (
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(e)}>
<span className="dot" style={{ background: e.color ? hexOf(e.color) : 'var(--line)' }} />
<span className="t">
<span className="t-title">{e.title}</span>
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
</span>
{e.draft && <span className="draft-tag">Entwurf</span>}
</li>
))}
</ul>
</div>
))}
</aside>
<main>
{current
? <Editor key={current.path || 'new'} initial={current}
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
: <div className="empty"><p>Wähle links einen Eintrag oder leg einen neuen Beitrag an.</p></div>}
</main>
</>
)}
</div>
{msg && <div className={`toast ${msg.type}`} onClick={() => setMsg(null)}>{msg.text}</div>}
</div>
);
}
function Editor({ initial, onSaved, onMsg }) {
const [f, setF] = useState(initial);
const [previewUrl, setPreviewUrl] = useState(null);
const [showPreview, setShowPreview] = useState(false);
const [pw, setPw] = useState(44);
const [busy, setBusy] = useState(false);
const editorRef = useRef(null);
const dragging = useRef(false);
const coverIn = useRef(null);
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
const isWiki = f.type === 'biblio' || (f.path || '').startsWith('library/');
async function pickCover(ev) {
const file = ev.target.files?.[0]; ev.target.value = '';
if (!file) return;
setBusy(true);
try { const { url } = await api.upload(file); setF((p) => ({ ...p, cover_image: url })); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
// Ziehbarer Trenner Editor ↔ Vorschau.
useEffect(() => {
function move(e) {
if (!dragging.current || !editorRef.current) return;
const r = editorRef.current.getBoundingClientRect();
setPw(Math.min(70, Math.max(25, ((r.right - e.clientX) / r.width) * 100)));
}
function up() { dragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
}, []);
function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }
function currentPath(data = f) {
if (!data.isNew) return data.path;
const slug = (data.slug || '').trim();
if (!slug) return '';
if (data.type === 'beitrag') return `archiv/${data.section}/${slug}.md`;
if (data.type === 'biblio') return `library/${slug}.md`;
return `${slug}.md`;
}
// overrides erlauben z.B. { draft: false } beim Publizieren.
async function save(overrides = {}) {
const data = { ...f, ...overrides };
const path = currentPath(data);
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug angeben.' }); return null; }
if (!data.title.trim()) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
setBusy(true);
try {
await api.save(path, buildFrontmatter(data), data.body);
const loaded = fromRead(await api.read(path));
onSaved(loaded); setF(loaded);
return path;
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
finally { setBusy(false); }
}
async function preview() {
const path = await save(); if (!path) return;
setShowPreview(true); setBusy(true);
try { const res = await api.preview(path); setPreviewUrl(`${res.url}?t=${Date.now()}`); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
async function publish() {
if (!confirm('Live publizieren? Der Beitrag wird aus „Entwurf“ genommen.')) return;
const path = await save({ draft: false }); // Publizieren = nicht mehr Entwurf
if (!path) return;
setBusy(true);
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
return (
<div className="editor" ref={editorRef}>
<div className="editor-main">
<div className="editor-head">
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
<span className="spacer" />
{f.draft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>}
<button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
{showPreview ? 'Vorschau ' : 'Vorschau ⤢'}
</button>
<button onClick={() => save().then((p) => p && onMsg({ type: 'ok', text: 'Gespeichert.' }))} disabled={busy}>Speichern</button>
<button onClick={preview} disabled={busy}>Vorschau</button>
<button className="primary" onClick={publish} disabled={busy}>Publizieren</button>
</div>
<div className="fields">
{f.isNew && (
<div className="row">
<label className="sm">Typ
<select value={f.type} onChange={set('type')}>
<option value="beitrag">Beitrag</option>
<option value="biblio">Library-Seite</option>
<option value="seite">Seite</option>
</select>
</label>
{f.type === 'beitrag' && (
<label className="sm">Rubrik
<select value={f.section} onChange={set('section')}>{SECTIONS.map((s) => <option key={s}>{s}</option>)}</select>
</label>
)}
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
</div>
)}
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
<div className="meta">
{isWiki && <label className="sm">Gruppe<input value={f.group} onChange={set('group')} placeholder="z. B. Begriffe" /></label>}
<label className="sm">Datum<input type="date" value={f.date} onChange={set('date')} /></label>
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
<label className="sm">Farbe
<div className="colorpick">
<span className="swatch" style={{ background: hexOf(f.color) }} />
<select value={f.color} onChange={set('color')}>{COLORS.map(([v, label]) => <option key={v} value={v}>{label}</option>)}</select>
</div>
</label>
<label className="sm">Layout
<select value={f.layout} onChange={set('layout')}>{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(automatisch)'}</option>)}</select>
</label>
<label>Tags<input value={f.tags} onChange={set('tags')} placeholder="komma, getrennt" /></label>
<label className="check"><input type="checkbox" checked={f.toc} onChange={set('toc')} /> Inhaltsverz.</label>
<label className="check"><input type="checkbox" checked={f.draft} onChange={set('draft')} /> Entwurf</label>
</div>
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label>
<div className="row">
<label>Cover-Bild
<div className="cover-row">
<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" />
<button type="button" onClick={() => coverIn.current?.click()} disabled={busy}>Hochladen</button>
<input ref={coverIn} type="file" accept="image/*" hidden onChange={pickCover} />
{f.cover_image && <span className="cover-thumb" style={{ backgroundImage: `url(${f.cover_image})` }} />}
</div>
</label>
<label>Externer Link<input value={f.external} onChange={set('external')} placeholder="https://…" /></label>
</div>
<label>Autor:innen (E-Mails, Komma für gemeinsamen Zugriff)
<input value={f.authors} onChange={set('authors')} placeholder="du@…, kollege@…" />
</label>
<div className="rich">
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))}
onUpload={async (file) => (await api.upload(file)).url} />
</div>
</div>
</div>
{showPreview && <div className="splitter" onMouseDown={startDrag} />}
{showPreview && (
<div className="preview" style={{ width: pw + '%' }}>
{previewUrl
? <iframe title="Vorschau" src={previewUrl} />
: <div className="empty small"><p>Auf Vorschau klicken die Seite erscheint hier in deinem echten Theme.</p></div>}
</div>
)}
</div>
);
}
// ── WYSIWYG-Editor (Toast UI, vanilla) — Formatierung live, speichert Markdown ──
function RichEditor({ value, onChange, onUpload }) {
const el = useRef(null);
const inst = useRef(null);
// value/onChange/onUpload in Refs, damit der Editor nur EINMAL erzeugt wird.
const cb = useRef({ onChange, onUpload });
cb.current = { onChange, onUpload };
useEffect(() => {
inst.current = new ToastEditor({
el: el.current,
initialValue: value || '',
initialEditType: 'wysiwyg',
previewStyle: 'tab',
height: '100%',
usageStatistics: false,
autofocus: false,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol'],
['link', 'image'],
['code', 'codeblock'],
],
hooks: {
addImageBlobHook: async (blob, done) => {
try { done(await cb.current.onUpload(blob), blob.name || 'bild'); }
catch { /* Upload fehlgeschlagen */ }
},
},
events: { change: () => cb.current.onChange(inst.current.getMarkdown()) },
});
return () => { inst.current?.destroy(); inst.current = null; };
}, []);
return <div ref={el} className="rich-host" />;
}
// ── Profil ──────────────────────────────────────────────────────────────────
function Profile({ onMsg }) {
const [p, setP] = useState(null);
const [busy, setBusy] = useState(false);
const fileIn = useRef(null);
useEffect(() => { api.getProfile().then(setP).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
if (!p) return <div className="empty"></div>;
const set = (k) => (e) => setP({ ...p, [k]: e.target.value });
async function pickAvatar(ev) {
const file = ev.target.files?.[0]; ev.target.value = '';
if (!file) return;
setBusy(true);
try { const { url } = await api.upload(file); setP((x) => ({ ...x, avatar: url })); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
async function save() {
setBusy(true);
try { await api.saveProfile({ name: p.name, bio: p.bio, avatar: p.avatar }); onMsg({ type: 'ok', text: 'Profil gespeichert.' }); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
return (
<div className="profile">
<div className="profile-card">
<h2>Profil</h2>
<div className="avatar-row">
<div className="avatar" style={{ backgroundImage: p.avatar ? `url(${p.avatar})` : 'none' }}>{!p.avatar && '🙂'}</div>
<div>
<button onClick={() => fileIn.current?.click()} disabled={busy}>Profilbild wählen</button>
<input ref={fileIn} type="file" accept="image/*" hidden onChange={pickAvatar} />
<p className="muted who-mail">{p.email}</p>
</div>
</div>
<label>Name<input value={p.name} onChange={set('name')} placeholder="Dein Name" /></label>
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></label>
<div className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
</div>
</div>
);
}
// ── Übersicht / Dashboard (nur Admin) ───────────────────────────────────────
function Overview({ onMsg, go }) {
const [s, setS] = useState(null);
useEffect(() => { api.stats().then(setS).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
if (!s) return <div className="empty"></div>;
const Card = ({ label, value, hint, to }) => (
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
<span className="stat-value">{value}</span>
<span className="stat-label">{label}</span>
<span className="stat-hint">{hint || ' '}</span>
</button>
);
return (
<div className="overview">
<h2>Übersicht</h2>
<div className="stat-grid">
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" />
<Card label="Library-Seiten" value={s.content.library} to="content" />
<Card label="Seiten" value={s.content.seiten} />
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" />
<Card label="Foren" value={s.dialog.forums} to="forums" />
<Card label="Threads" value={s.dialog.threads} to="moderation" />
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
</div>
<div className="overview-actions">
<h3>Schnellzugriff</h3>
<div className="quick">
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
<button onClick={() => go('forums')}>Foren verwalten</button>
<button onClick={() => go('users')}>Autor:innen &amp; Rollen</button>
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website </a>
<a className="quick-link" href="/dialog/" target="_blank" rel="noreferrer">Dialog </a>
<a className="quick-link" href="/library/" target="_blank" rel="noreferrer">Library </a>
</div>
</div>
</div>
);
}
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
function Users({ onMsg, currentEmail }) {
const [list, setList] = useState(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('user');
const [busy, setBusy] = useState(false);
const [q, setQ] = useState('');
const [pwFor, setPwFor] = useState(null);
const [newPw, setNewPw] = useState('');
async function refresh() {
try { setList(await api.listUsers()); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
useEffect(() => { refresh(); }, []);
async function create(e) {
e.preventDefault(); setBusy(true);
try {
await api.createUser(email, password, role);
onMsg({ type: 'ok', text: 'Autor:in angelegt.' });
setEmail(''); setPassword(''); setRole('user'); refresh();
} catch (err) { onMsg({ type: 'err', text: err.message }); }
finally { setBusy(false); }
}
async function remove(u) {
if (!confirm(`${u.email} wirklich löschen?`)) return;
try { await api.deleteUser(u.id); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
async function savePw(u) {
if (!newPw || newPw.length < 6) { onMsg({ type: 'err', text: 'Passwort zu kurz (min. 6 Zeichen).' }); return; }
try { await api.setPassword(u.id, newPw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); setPwFor(null); setNewPw(''); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
async function changeRole(u, r) {
try { await api.setRole(u.id, r); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[r]}` }); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
if (!list) return <div className="empty"></div>;
const filtered = q ? list.filter((u) => u.email.toLowerCase().includes(q.toLowerCase())) : list;
const RoleSelect = ({ u }) => (
<select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
</select>
);
return (
<div className="profile">
<div className="profile-card wide">
<h2>Autor:innen &amp; Rollen <span className="count-pill">{list.length}</span></h2>
<form className="userform" onSubmit={create}>
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
<input type="text" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} required />
<select className="role-select" value={role} onChange={(e) => setRole(e.target.value)}>
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
</select>
<button className="primary" disabled={busy}>Anlegen</button>
</form>
{list.length > 6 && <input className="userfilter" placeholder="filtern…" value={q} onChange={(e) => setQ(e.target.value)} />}
<ul className="userlist">
{filtered.map((u) => (
<li key={u.id}>
<span className="uavatar" style={avatarStyle(u.email)}>{(u.email || '?').slice(0, 1).toUpperCase()}</span>
<span className="t ucol">
<span className="uemail">{u.email}{u.email === currentEmail && <span className="you"> · du</span>}</span>
<span className="umeta">
angelegt {fmtDate(u.created_at)}
{u.last_sign_in_at ? ` · zuletzt aktiv ${fmtDate(u.last_sign_in_at)}` : ' · nie angemeldet'}
</span>
</span>
{u.fixedAdmin ? <span className="rolebadge admin">Admin · .env</span> : <RoleSelect u={u} />}
{pwFor === u.id ? (
<span className="pwinline">
<input type="text" placeholder="neues Passwort" value={newPw} autoFocus
onChange={(e) => setNewPw(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') savePw(u); if (e.key === 'Escape') { setPwFor(null); setNewPw(''); } }} />
<button onClick={() => savePw(u)}>OK</button>
<button onClick={() => { setPwFor(null); setNewPw(''); }}></button>
</span>
) : (
<button onClick={() => { setPwFor(u.id); setNewPw(''); }}>Passwort</button>
)}
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
</li>
))}
</ul>
<p className="muted who-mail"><b>User</b> schreiben im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
</div>
</div>
);
}
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
function fmtDate(ts) { if (!ts) return '—'; try { return new Date(ts).toLocaleDateString('de-CH'); } catch { return '—'; } }
function uHashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; }
function avatarStyle(s) { const h = uHashHue(s); return { background: `hsl(${h} 36% 82%)`, color: `hsl(${h} 30% 28%)` }; }
// ── Foren-Verwaltung (nur Admin) ────────────────────────────────────────────
function Forums({ onMsg }) {
const [list, setList] = useState(null);
const [draft, setDraft] = useState({ slug: '', name: '', sort: 50 });
const [busy, setBusy] = useState(false);
async function refresh() {
try { setList(await api.listForumsAdmin()); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
useEffect(() => { refresh(); }, []);
async function create(e) {
e.preventDefault();
if (!draft.slug || !draft.name) return;
setBusy(true);
try { await api.createForum(draft); onMsg({ type: 'ok', text: 'Kategorie angelegt.' }); setDraft({ slug: '', name: '', sort: 50 }); refresh(); }
catch (err) { onMsg({ type: 'err', text: err.message }); }
finally { setBusy(false); }
}
async function save(f, patch) {
try { await api.updateForum(f.id, patch); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
async function remove(f) {
if (!confirm(`Kategorie „${f.name}“ löschen? Threads darin verschwinden.`)) return;
try { await api.deleteForum(f.id); onMsg({ type: 'ok', text: 'Gelöscht.' }); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
if (!list) return <div className="empty"></div>;
return (
<div className="profile">
<div className="profile-card wide">
<h2>Foren / Kategorien</h2>
<form className="userform" onSubmit={create}>
<input placeholder="Name (z. B. Wettbewerbe)" value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value, slug: draft.slug || slugify(e.target.value) })} required />
<input placeholder="slug" value={draft.slug} onChange={(e) => setDraft({ ...draft, slug: slugify(e.target.value) })} required />
<input type="number" placeholder="Sort" style={{ width: '5em' }} value={draft.sort} onChange={(e) => setDraft({ ...draft, sort: e.target.value })} />
<button className="primary" disabled={busy}>Anlegen</button>
</form>
<ul className="forumlist">
{list.map((f) => (
<li key={f.id} className={f.kind === 'library' ? 'is-library' : ''}>
<span className="fsort">{f.sort}</span>
<input className="fname" defaultValue={f.name} onBlur={(e) => e.target.value !== f.name && save(f, { name: e.target.value })} />
<input className="fcolor" type="color" value={/^#[0-9a-fA-F]{6}$/.test(f.color || '') ? f.color : '#cccccc'} onChange={(e) => save(f, { color: e.target.value })} title="Akzentfarbe" />
<span className="fslug">/{f.slug}</span>
{f.kind === 'library'
? <span className="status">Library (auto)</span>
: <button onClick={() => remove(f)}>Löschen</button>}
</li>
))}
</ul>
<p className="muted who-mail">Beiträge ist die automatische Library-Kategorie und kann nicht gelöscht werden.</p>
</div>
</div>
);
}
// ── Moderation (Admin + Redakteur) ──────────────────────────────────────────
function Moderation({ onMsg }) {
const [data, setData] = useState(null);
async function refresh() {
try { setData(await api.modOverview()); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
useEffect(() => { refresh(); }, []);
async function delComment(c) {
if (!confirm('Wortmeldung löschen?')) return;
try { await api.deleteComment(c.id); onMsg({ type: 'ok', text: 'Gelöscht.' }); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
async function toggleLock(t) {
try { await api.lockThread(t.key, !t.locked); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
async function delThread(t) {
if (!confirm(`Thread „${t.title}“ ausblenden?`)) return;
try { await api.deleteThread(t.key); onMsg({ type: 'ok', text: 'Ausgeblendet.' }); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
if (!data) return <div className="empty"></div>;
return (
<div className="moderation">
<div className="mod-col">
<h2>Letzte Wortmeldungen</h2>
<ul className="modlist">
{data.comments.map((c) => (
<li key={c.id}>
<div className="mod-head"><b>{c.author_name}</b>
<span className="muted"> · {c.forum_name || '—'} · {c.thread_title}</span></div>
<div className="mod-body">{c.body}</div>
<div className="mod-actions">
<a href={c.thread_url} target="_blank" rel="noreferrer">öffnen</a>
<button onClick={() => delComment(c)}>Löschen</button>
</div>
</li>
))}
{!data.comments.length && <li className="muted">Noch keine Wortmeldungen.</li>}
</ul>
</div>
<div className="mod-col">
<h2>Threads</h2>
<ul className="modlist">
{data.threads.map((t) => (
<li key={t.key} className={t.deleted ? 'gone' : ''}>
<div className="mod-head"><b>{t.title}</b>
<span className="muted"> · {t.forum_name} · {t.count}</span>
{t.locked && <span className="status">gesperrt</span>}
{t.deleted && <span className="status">ausgeblendet</span>}</div>
<div className="mod-actions">
<a href={t.url} target="_blank" rel="noreferrer">öffnen</a>
{t.kind !== 'library' && <button onClick={() => toggleLock(t)}>{t.locked ? 'Entsperren' : 'Sperren'}</button>}
{!t.deleted && <button onClick={() => delThread(t)}>Ausblenden</button>}
</div>
</li>
))}
</ul>
</div>
</div>
);
}
function slugify(s) {
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
function fromRead(r) {
const fm = r.frontmatter || {};
const p = r.path || '';
const type = p.startsWith('archiv/') ? 'beitrag' : p.startsWith('library/') ? 'biblio' : 'seite';
return {
isNew: false, path: r.path, type, section: '', slug: '',
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
summary: fm.summary || '', description: fm.description || '',
cover_image: fm.cover_image || '', external: fm.external || '',
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
group: fm.group || '', toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
};
}
function buildFrontmatter(f) {
const fm = { title: f.title };
if (f.date) fm.date = f.date;
if (f.weight !== '' && f.weight != null) fm.weight = Number(f.weight);
const tags = f.tags ? f.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
if (tags.length) fm.tags = tags;
if (f.summary) fm.summary = f.summary;
if (f.description) fm.description = f.description;
if (f.cover_image) fm.cover_image = f.cover_image;
if (f.layout) fm.layout = f.layout;
if (f.external) fm.external = f.external;
if (f.color) fm.color = f.color;
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
if (authors.length) fm.authors = authors;
if (f.group) fm.group = f.group;
if (f.toc) fm.toc = true;
if (f.draft) fm.draft = true;
return fm;
}
-65
View File
@@ -1,65 +0,0 @@
import { supabase } from './supabase.js';
// Ruft die CMS-API (gleiche Origin) mit dem aktuellen Supabase-Token auf.
async function call(path, options = {}) {
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
const res = await fetch(`/api${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
return json;
}
// Datei-Upload (multipart): Browser setzt den Header selbst.
async function uploadFile(file) {
const { data } = await supabase.auth.getSession();
const token = data?.session?.access_token;
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
return json;
}
export const api = {
list: () => call('/content'),
read: (path) => call(`/content/entry?path=${encodeURIComponent(path)}`),
save: (path, frontmatter, body) =>
call('/content/entry', { method: 'PUT', body: JSON.stringify({ path, frontmatter, body }) }),
preview: (path) => call('/preview', { method: 'POST', body: JSON.stringify({ path }) }),
publish: (path) => call('/publish', { method: 'POST', body: JSON.stringify({ path }) }),
upload: uploadFile,
getProfile: () => call('/profile'),
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
getMe: () => call('/me'),
stats: () => call('/stats'),
listUsers: () => call('/users'),
createUser: (email, password, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }),
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
setRole: (id, role) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role }) }),
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
// Foren-Verwaltung (Admin)
listForumsAdmin: () => call('/admin/forums'),
createForum: (f) => call('/admin/forums', { method: 'POST', body: JSON.stringify(f) }),
updateForum: (id, f) => call(`/admin/forums/${id}`, { method: 'PUT', body: JSON.stringify(f) }),
deleteForum: (id) => call(`/admin/forums/${id}`, { method: 'DELETE' }),
// Moderation (Admin + Redakteur)
modOverview: () => call('/mod/overview'),
lockThread: (key, locked) => call('/mod/thread-lock', { method: 'POST', body: JSON.stringify({ key, locked }) }),
deleteThread: (key) => call('/mod/thread-delete', { method: 'POST', body: JSON.stringify({ key }) }),
deleteComment: (id) => call(`/comments/${id}`, { method: 'DELETE' }),
};
-10
View File
@@ -1,10 +0,0 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
-221
View File
@@ -1,221 +0,0 @@
@import url('https://fonts.bunny.net/css?family=newsreader:400,500,600,700|inter:400,500,600|space-grotesk:500,700|ibm-plex-mono:400,500');
:root {
--serif: 'Newsreader', Georgia, serif;
--sans: 'Inter', system-ui, -apple-system, sans-serif;
--display: 'Space Grotesk', 'Inter', sans-serif;
--mono: 'IBM Plex Mono', ui-monospace, monospace;
--bg: hsl(35 14% 96%);
--panel: #fffdf9;
--panel-2: hsl(35 14% 93%);
--line: hsl(35 14% 86%);
--text: hsl(25 18% 12%);
--muted: hsl(25 8% 42%);
--accent: #b54a2c;
--accent-soft: #d97a5a;
--dark: #191919;
--dark-text: #f0f0f0;
--dark-muted: #a9a9a9;
--ok: #5d7d4b;
--amber: #b8902f;
--radius: 11px;
--pill: 22px;
--shadow: 0 10px 34px -22px rgba(40,20,10,.5);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body { margin: 0; font-family: var(--sans); font-size: 14.5px; color: var(--text); background: var(--bg); }
button, input, select, textarea { font-family: inherit; font-size: inherit; color: var(--text); }
.muted { color: var(--muted); }
.center { display: grid; place-items: center; height: 100%; }
/* ── Login ── */
.login { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 36px 32px; width: 320px; display: flex; flex-direction: column; gap: 12px; box-shadow: var(--shadow); }
.login-brand { font-family: var(--display); font-weight: 700; letter-spacing: .14em; font-size: 20px; }
.login-sub { font-family: var(--serif); font-style: italic; color: var(--muted); margin-bottom: 10px; }
.err { color: var(--accent); margin: 4px 0 0; font-size: 13px; }
/* ── Inputs / Buttons (Pill) ── */
input, select, textarea { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 9px 11px; width: 100%; }
/* Einheitliche, kompakte Höhe für einzeilige Felder (Dropdowns = Textfelder) */
.fields input, .fields select { height: 32px; padding: 0 10px; font-size: 14px; }
.fields label.big input { height: 46px; padding: 0 13px; font-size: 21px; }
.login input, .profile-card input, .userform input { height: 36px; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent-soft); box-shadow: 0 0 0 3px rgba(181,74,44,.12); }
button { background: var(--panel); border: 1px solid var(--line); border-radius: var(--pill); padding: 8px 16px; cursor: pointer; font-weight: 500; transition: .12s; white-space: nowrap; }
button:hover { border-color: var(--accent-soft); }
button:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: #a23f23; }
button.ghost { background: transparent; border-color: transparent; color: var(--dark-muted); }
button.ghost:hover { color: #fff; border-color: var(--dark-muted); }
/* ── Topbar (schwarz wie Site-Masthead) ── */
.app { display: flex; flex-direction: column; height: 100%; }
.topbar { display: flex; align-items: center; gap: 12px; padding: 0 18px; height: 54px; background: var(--dark); color: var(--dark-text); flex: none; }
.topbar .logo { font-family: var(--display); font-weight: 700; letter-spacing: .14em; }
.topbar .logo-sub { font-family: var(--serif); font-style: italic; color: var(--dark-muted); font-size: 13px; }
.topbar .spacer { flex: 1; }
.topbar .who { color: var(--dark-muted); font-size: 13px; }
.nav { display: flex; gap: 4px; margin-left: 16px; }
.nav button { background: transparent; border: none; color: var(--dark-muted); padding: 6px 15px; border-radius: var(--pill); }
.nav button:hover { color: #fff; }
.nav button.active { background: rgba(255,255,255,.12); color: #fff; }
.body { display: flex; flex: 1; min-height: 0; }
/* ── Sidebar ── */
aside { width: 290px; flex: none; border-right: 1px solid var(--line); background: var(--panel-2); padding: 14px; overflow: auto; }
.new { width: 100%; margin-bottom: 12px; background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.new:hover { background: #a23f23; }
.search { display: flex; align-items: center; gap: 7px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--pill); padding: 0 13px; margin-bottom: 16px; }
.search span { color: var(--muted); font-size: 17px; }
.search input { border: none; background: transparent; padding: 9px 0; }
.search input:focus { box-shadow: none; }
.group { margin-bottom: 18px; }
.group-title { display: flex; align-items: center; gap: 7px; font-family: var(--display); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 6px 8px; }
.group-title span { background: var(--line); color: var(--muted); border-radius: 20px; padding: 1px 7px; font-size: 10px; letter-spacing: 0; }
.list { list-style: none; margin: 0; padding: 0; }
.list li { display: flex; align-items: center; gap: 10px; padding: 9px; border-radius: 10px; cursor: pointer; }
.list li:hover { background: var(--panel); }
.list li.active { background: var(--panel); box-shadow: inset 3px 0 0 var(--accent), var(--shadow); }
.list .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; border: 1px solid rgba(0,0,0,.12); }
.list .t { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.list .t-title { font-family: var(--serif); font-size: 15.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list .t-meta { font-size: 11px; color: var(--muted); }
.draft-tag { font-size: 10px; color: var(--amber); border: 1px solid var(--amber); border-radius: 20px; padding: 1px 7px; flex: none; }
main { flex: 1; min-width: 0; }
.empty { display: grid; place-items: center; height: 100%; color: var(--muted); font-family: var(--serif); font-style: italic; padding: 24px; text-align: center; }
.empty.small { font-size: 14px; }
/* ── Editor ── */
.editor { display: flex; height: 100%; }
.editor-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.editor-head { display: flex; align-items: center; gap: 9px; padding: 11px 22px; border-bottom: 1px solid var(--line); background: var(--panel); flex: none; }
.editor-head .crumb { font-family: var(--mono); font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.editor-head .spacer { flex: 1; }
.editor-head .toggle { background: transparent; border-color: transparent; color: var(--muted); }
.editor-head .toggle:hover { color: var(--text); border-color: var(--line); }
.status { font-size: 11px; border-radius: var(--pill); padding: 3px 11px; font-weight: 600; }
.status.draft { color: var(--amber); background: rgba(184,144,47,.12); }
.status.live { color: var(--ok); background: rgba(93,125,75,.14); }
/* Metadaten kompakt oben, Schreibfeld groß darunter */
.fields { flex: 1; min-height: 0; padding: 16px 22px; overflow: auto; display: flex; flex-direction: column; gap: 10px; }
.row { display: flex; gap: 12px; align-items: flex-end; }
.meta { display: flex; flex-wrap: wrap; gap: 9px 12px; align-items: flex-end; }
label { display: flex; flex-direction: column; gap: 3px; font-size: 11.5px; color: var(--muted); flex: 1; }
.meta label { flex: 0 0 auto; }
.meta label.sm { width: 160px; } .meta label.xs { width: 100px; } .meta label:not(.sm):not(.xs):not(.check) { flex: 1; min-width: 140px; }
label.check { flex-direction: row; align-items: center; gap: 7px; white-space: nowrap; padding-bottom: 7px; }
label.check input { width: auto; height: auto; }
label.big input { font-family: var(--serif); font-weight: 600; }
.colorpick { display: flex; align-items: center; gap: 8px; }
.colorpick .swatch { width: 32px; height: 32px; border-radius: 7px; border: 1px solid rgba(0,0,0,.15); flex: none; }
.colorpick select { flex: 1; }
/* Cover-Upload */
.cover-row { display: flex; align-items: center; gap: 8px; }
.cover-row input { flex: 1; }
.cover-row button { height: 32px; flex: none; padding: 0 14px; }
.cover-thumb { width: 32px; height: 32px; border-radius: 7px; border: 1px solid var(--line); background: center/cover no-repeat; flex: none; }
/* Autor:innen-Verwaltung */
.userform { display: flex; gap: 8px; }
.userform input { flex: 1; }
.userlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.userlist li { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; }
.userlist .t { flex: 1; display: flex; align-items: center; gap: 9px; font-family: var(--serif); }
.userlist button { padding: 5px 12px; font-size: 13px; }
.userlist .status { padding: 2px 9px; }
/* WYSIWYG-Editor füllt den meisten Platz */
.rich { flex: 1; min-height: 460px; display: flex; flex-direction: column; }
.rich-host { flex: 1; min-height: 0; }
.rich .toastui-editor-defaultUI { height: 100%; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); font-family: var(--sans); }
.toastui-editor-contents { font-family: var(--serif); font-size: 16px; }
.toastui-editor-defaultUI-toolbar { background: var(--panel-2); }
.toastui-editor-toolbar { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
/* ── Ziehbarer Trenner + Vorschau ── */
.splitter { width: 7px; flex: none; cursor: col-resize; background: var(--line); }
.splitter:hover { background: var(--accent-soft); }
.preview { flex: none; background: #fff; }
.preview iframe { width: 100%; height: 100%; border: 0; }
/* ── Profil ── */
.profile { width: 100%; overflow: auto; display: flex; justify-content: center; padding: 44px 20px; }
.profile-card { width: 100%; max-width: 560px; height: max-content; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 28px 30px; display: flex; flex-direction: column; gap: 16px; }
.profile-card h2 { font-family: var(--serif); margin: 0 0 4px; font-weight: 600; }
.avatar-row { display: flex; align-items: center; gap: 18px; }
.avatar { width: 92px; height: 92px; border-radius: 50%; background: var(--panel-2) center/cover no-repeat; border: 1px solid var(--line); display: grid; place-items: center; font-size: 34px; flex: none; }
.who-mail { font-size: 12px; margin: 9px 0 0; }
.profile-card textarea { font-family: var(--serif); font-size: 15px; line-height: 1.6; resize: vertical; }
.profile-card .actions { display: flex; }
.profile-card.wide { max-width: 760px; }
.role-select { width: auto; height: 32px; padding: 4px 10px; font-size: 13px; }
/* ── Foren-Verwaltung ── */
.forumlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.forumlist li { display: flex; align-items: center; gap: 10px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 10px; }
.forumlist li.is-library { background: var(--panel-2); }
.forumlist .fsort { width: 2.2em; text-align: center; color: var(--muted); font-size: 12px; flex: none; }
.forumlist .fname { flex: 1; height: 32px; }
.forumlist .fcolor { width: 34px; height: 32px; padding: 2px; flex: none; }
.forumlist .fslug { color: var(--muted); font-size: 12px; font-family: var(--mono, monospace); flex: none; }
.forumlist button { padding: 5px 12px; font-size: 13px; }
/* ── Moderation (zweispaltig) ── */
.moderation { width: 100%; overflow: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 30px 24px; align-content: start; }
.mod-col h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 12px; }
.modlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.modlist li { padding: 10px 13px; border: 1px solid var(--line); border-radius: 11px; background: var(--panel); }
.modlist li.gone { opacity: .5; }
.mod-head { font-size: 13.5px; }
.mod-head .status { margin-left: 6px; padding: 1px 8px; background: rgba(184,144,47,.14); color: var(--amber); }
.mod-body { font-family: var(--serif); font-size: 14.5px; margin: 6px 0; color: var(--text); }
.mod-actions { display: flex; align-items: center; gap: 12px; font-size: 13px; }
.mod-actions a { color: var(--muted); }
.mod-actions button { padding: 4px 11px; font-size: 12.5px; }
/* ── Übersicht / Dashboard ── */
.overview { width: 100%; overflow: auto; padding: 30px 28px; }
.overview h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 18px; }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.stat-card { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left;
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); }
.stat-card:not(:disabled):hover { border-color: var(--accent-soft); transform: translateY(-1px); }
.stat-card:disabled { opacity: 1; cursor: default; }
.stat-value { font-family: var(--display); font-weight: 700; font-size: 30px; line-height: 1; color: var(--accent); }
.stat-label { font-family: var(--serif); font-size: 15px; margin-top: 6px; }
.stat-hint { font-size: 11.5px; color: var(--muted); min-height: 1em; }
.overview-actions { margin-top: 30px; }
.overview-actions h3 { font-family: var(--display); font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 0 10px; }
.quick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.quick-link { display: inline-flex; align-items: center; padding: 8px 16px; border: 1px solid var(--line); border-radius: var(--pill); text-decoration: none; color: var(--muted); }
.quick-link:hover { border-color: var(--accent-soft); color: var(--text); }
/* ── Nutzerliste (aufgewertet) ── */
.count-pill { font-family: var(--sans); font-size: 12px; font-weight: 500; color: var(--muted); background: var(--panel-2); border-radius: 20px; padding: 2px 9px; vertical-align: middle; margin-left: 6px; }
.userfilter { margin: 4px 0 2px; height: 34px; }
.userlist .uavatar { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 600; font-size: 13px; flex: none; }
.userlist .ucol { flex-direction: column; align-items: flex-start; gap: 1px; min-width: 0; }
.uemail { font-family: var(--serif); font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.uemail .you { color: var(--accent); font-family: var(--sans); font-size: 12px; }
.umeta { font-size: 11.5px; color: var(--muted); }
.rolebadge { font-size: 11px; border-radius: var(--pill); padding: 3px 10px; font-weight: 600; flex: none; }
.rolebadge.admin { color: var(--accent); background: rgba(181,74,44,.12); }
.pwinline { display: flex; align-items: center; gap: 5px; flex: none; }
.pwinline input { width: 150px; height: 30px; }
.pwinline button { padding: 4px 10px; font-size: 12.5px; }
/* ── Toast ── */
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
.toast.ok { background: var(--ok); }
.toast.err { background: var(--accent); }
-8
View File
@@ -1,8 +0,0 @@
import { createClient } from '@supabase/supabase-js';
// Öffentliche Browser-Werte (zur Build-Zeit von Vite eingesetzt). Der anon-Key
// ist per Design öffentlich; die echte Autorität liegt server-seitig.
const url = import.meta.env.VITE_SUPABASE_URL;
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(url, anonKey);
-15
View File
@@ -1,15 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// base /admin/ — die SPA wird vom CMS-Container unter /admin serviert.
export default defineConfig({
plugins: [react()],
base: '/admin/',
server: {
// Dev: API + /_preview vom laufenden Container durchreichen.
proxy: {
'/api': 'http://localhost:8080',
'/_preview': 'http://localhost:8080',
},
},
});
-51
View File
@@ -1,51 +0,0 @@
# --- Stage 1: Admin-SPA bauen ---
# (Build-Context ist cms/, siehe docker-compose.yml)
FROM node:24-bookworm-slim AS admin
WORKDIR /admin
COPY admin/package.json admin/package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY admin/ ./
# Öffentliche Browser-Werte, zur Build-Zeit eingesetzt.
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
RUN npm run build
# --- Stage 2: API + Hugo + serviert Site/Admin ---
# Debian-slim statt Alpine: Hugo "extended" ist glibc-gelinkt.
FROM node:24-bookworm-slim
ARG HUGO_VERSION=0.161.1
# Von BuildKit automatisch auf die Ziel-Arch gesetzt (amd64 auf dem LXC,
# arm64 z.B. auf Apple-Silicon) — kein fester Default, sonst falsche Binary.
ARG TARGETARCH
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git curl \
&& rm -rf /var/lib/apt/lists/* \
&& case "${TARGETARCH}" in \
arm64) HUGO_ARCH=linux-arm64 ;; \
*) HUGO_ARCH=linux-amd64 ;; \
esac \
&& curl -sSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_${HUGO_ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin hugo \
&& hugo version
WORKDIR /app
COPY api/package.json api/package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund
COPY api/src ./src
COPY api/entrypoint.sh ./entrypoint.sh
COPY --from=admin /admin/dist ./admin-dist
ENV NODE_ENV=production
ENV ADMIN_DIR=/app/admin-dist
# Als non-root laufen (das node-Image bringt den User `node`, uid/gid 1000 mit).
# /app gehört dem Build (root, read-only zur Laufzeit — reicht zum Servieren).
# Das gemountete Repo unter /site muss uid 1000 gehören (siehe Proxmox-Script:
# chown -R 1000:1000), damit Hugo dort public/ bauen und content/ schreiben kann.
USER node
EXPOSE 3000
CMD ["sh", "/app/entrypoint.sh"]
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
# Beim Container-Start die Hugo-Site einmal bauen, damit die Live-Seite (/)
# sofort steht — auch vor dem ersten Publish. public/ ist git-ignored und
# existiert im frischen Clone nicht; ohne diesen Build gäbe es 404 auf /.
set -e
SITE_DIR="${SITE_DIR:-/site}"
echo "→ Initialer Hugo-Build ($SITE_DIR → public/)…"
if hugo --source "$SITE_DIR" --destination "$SITE_DIR/public" --cleanDestinationDir; then
echo "✓ Live-Seite gebaut."
else
echo "WARN: Hugo-Build fehlgeschlagen — Live-Seite bleibt leer bis zum ersten Publish."
fi
exec node src/index.js
-783
View File
@@ -1,783 +0,0 @@
{
"name": "openbureau-cms-api",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openbureau-cms-api",
"version": "0.1.0",
"dependencies": {
"@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10",
"gray-matter": "^4.0.3",
"hono": "^4.6.14",
"marked": "^14.1.4",
"sharp": "^0.33.5"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
},
"peerDependencies": {
"hono": "^4"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz",
"integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz",
"integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz",
"integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz",
"integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz",
"integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz",
"integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.106.2",
"@supabase/functions-js": "2.106.2",
"@supabase/postgrest-js": "2.106.2",
"@supabase/realtime-js": "2.106.2",
"@supabase/storage-js": "2.106.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/marked": {
"version": "14.1.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz",
"integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}
-20
View File
@@ -1,20 +0,0 @@
{
"name": "openbureau-cms-api",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "node --test"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10",
"gray-matter": "^4.0.3",
"hono": "^4.6.14",
"marked": "^14.1.4",
"sharp": "^0.33.5"
}
}
-72
View File
@@ -1,72 +0,0 @@
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();
}
-39
View File
@@ -1,39 +0,0 @@
// 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;
}
}
-182
View File
@@ -1,182 +0,0 @@
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 };
}
-123
View File
@@ -1,123 +0,0 @@
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import matter from 'gray-matter';
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;
}
// Beitrag (archiv/<section>/<slug>.md) | Library-Seite (library/<slug>.md)
// | Rubrik (_index.md) | Seite (sonst).
function classify(rel) {
const base = path.basename(rel);
const parts = rel.split('/');
if (base === '_index.md') {
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
return { kind: 'rubrik', section };
}
if (parts[0] === 'archiv' && parts.length === 3) {
return { kind: 'beitrag', section: parts[1] };
}
if (parts[0] === 'library') {
return { kind: 'biblio', section: 'library' };
}
return { kind: 'seite', section: null };
}
// 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() {
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),
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),
});
}
// Beiträge zuerst, dann Library, Seiten, Rubriken; je nach Datum/Titel.
const order = { beitrag: 0, biblio: 1, seite: 2, rubrik: 3 };
items.sort((a, b) =>
(order[a.kind] - order[b.kind]) ||
(b.date || '').localeCompare(a.date || '') ||
a.title.localeCompare(b.title));
return items;
}
export async function readEntry(rel) {
rel = safeRel(rel);
const { data, content } = matter(await readFile(path.join(CONTENT, rel), 'utf8'));
return { path: rel, url: urlFor(rel), frontmatter: data || {}, body: content || '' };
}
export async function entryExists(rel) {
try { await stat(path.join(CONTENT, safeRel(rel))); return true; }
catch { return false; }
}
export async function writeEntry(rel, frontmatter = {}, body = '') {
rel = safeRel(rel);
const full = path.join(CONTENT, rel);
await mkdir(path.dirname(full), { recursive: true });
// Leere Werte rauswerfen, damit das Frontmatter sauber bleibt.
const fm = {};
for (const [k, v] of Object.entries(frontmatter)) {
if (v === '' || v === null || v === undefined) continue;
if (Array.isArray(v) && v.length === 0) continue;
fm[k] = v;
}
await writeFile(full, matter.stringify(body || '', fm), 'utf8');
return rel;
}
-49
View File
@@ -1,49 +0,0 @@
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 };
}
-127
View File
@@ -1,127 +0,0 @@
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 { listComments, createComment, deleteComment, login } from './routes/comments.js';
import history from './routes/history.js';
import {
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
} from './routes/dialog.js';
import { requireAuth } from './auth.js';
import { syncLibrary } from './dialog-store.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' }));
// Öffentlich (ohne Login): Dialog lesen, Übersicht, Login fürs Dialog-Widget.
app.get('/api/comments', listComments);
app.get('/api/forums', listForums);
app.get('/api/forums/:slug', showForum);
app.get('/api/recent', recent);
app.get('/api/thread', threadInfo);
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
app.route('/api/history', history);
// Login gegen Brute-Force drosseln: max. 10 Versuche/IP pro 5 Minuten.
app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login);
// 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') }));
app.post('/api/comments', createComment);
app.delete('/api/comments/:id', deleteComment);
app.post('/api/threads', newThread);
app.route('/api/mod', mod);
app.route('/api/admin/forums', adminForums);
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 }, (info) => {
console.log(`OPENBUREAU CMS läuft auf :${info.port} — Site + API + /_preview`);
// Library-Beiträge als Threads in „Beiträge" spiegeln (nicht blockierend).
syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e));
});
-34
View File
@@ -1,34 +0,0 @@
// 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?.();
-74
View File
@@ -1,74 +0,0 @@
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),
});
}
-50
View File
@@ -1,50 +0,0 @@
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;
-104
View File
@@ -1,104 +0,0 @@
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 });
});
-83
View File
@@ -1,83 +0,0 @@
import { Hono } from 'hono';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import matter from 'gray-matter';
import { marked } from 'marked';
import { safeRel } from '../files.js';
// ÖFFENTLICH: Versionsverlauf eines Library-Beitrags aus der Git-History.
// Der Container hat das Repo unter /site gemountet + git installiert. Wir
// holen alte Fassungen on-demand (kein Vorbauen) und zeigen sie auf der Site.
const execFileP = promisify(execFile);
const SITE_DIR = process.env.SITE_DIR || '/site';
const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { maxBuffer: 10 * 1024 * 1024 });
const US = '\x1f'; // Feldtrenner (Unit Separator) — kommt in Commit-Daten nicht vor.
const history = new Hono();
// Liste der Versionen: neueste zuerst.
history.get('/', async (c) => {
let rel;
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
try {
const { stdout } = await git(
'log', '--follow', `--format=%H${US}%h${US}%aI${US}%an${US}%s`, '--', `content/${rel}`);
const versions = stdout.trim().split('\n').filter(Boolean).map((line) => {
const [rev, short, date, author, subject] = line.split(US);
return { rev, short, date, author, subject };
});
return c.json(versions);
} catch { return c.json({ error: 'Verlauf nicht verfügbar' }, 500); }
});
// Eine bestimmte Fassung gerendert (HTML), zum Anzeigen auf der Seite.
history.get('/version', async (c) => {
let rel;
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
const rev = c.req.query('rev') || '';
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
try {
const { stdout } = await git('show', `${rev}:content/${rel}`);
const { data, content } = matter(stdout);
return c.json({
rev,
title: data.title || '',
date: data.date ? new Date(data.date).toISOString().slice(0, 10) : null,
html: renderMarkdown(content),
});
} catch { return c.json({ error: 'Version nicht gefunden' }, 404); }
});
// Unified-Diff einer Fassung (was dieser Commit an der Datei geändert hat) —
// fürs rot/grün-Diff auf der Seite. Roh-Diff; das Frontend färbt +/- ein.
history.get('/diff', async (c) => {
let rel;
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
const rev = c.req.query('rev') || '';
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
try {
const { stdout } = await git('show', '--format=', '--no-color', rev, '--', `content/${rel}`);
return c.json({ rev, diff: stdout });
} catch { return c.json({ error: 'Diff nicht verfügbar' }, 404); }
});
// Markdown → HTML. marked kennt Goldmarks Fußnoten ([^id]) nicht — daher
// vorab: Definitionen einsammeln, Verweise zu <sup>-Nummern, „Quellen" anhängen
// (greift dieselbe .footnotes-CSS wie die Live-Seite).
function renderMarkdown(md) {
const defs = {}; const order = [];
md = md.replace(/^\[\^([^\]]+)\]:[ \t]*(.*)$/gm, (_, id, txt) => { defs[id] = txt; return ''; });
md = md.replace(/\[\^([^\]]+)\]/g, (_, id) => {
if (!order.includes(id)) order.push(id);
return `<sup class="footnote-ref">${order.indexOf(id) + 1}</sup>`;
});
let html = marked.parse(md);
if (order.length) {
html += '<div class="footnotes"><ol>'
+ order.map((id) => `<li>${marked.parseInline(defs[id] || '')}</li>`).join('')
+ '</ol></div>';
}
return html;
}
export default history;
-20
View File
@@ -1,20 +0,0 @@
import { Hono } from 'hono';
import { urlFor, safeRel } from '../files.js';
import { buildSite } from '../hugo.js';
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
const preview = new Hono();
preview.post('/', async (c) => {
const { path: rel } = await c.req.json();
try {
const safe = safeRel(rel);
const build = await buildSite({ dest: 'preview', drafts: true });
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
} catch (e) {
return c.json({ error: String(e.message || e) }, 500);
}
});
export default preview;
-56
View File
@@ -1,56 +0,0 @@
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;
-23
View File
@@ -1,23 +0,0 @@
import { Hono } from 'hono';
import { urlFor, safeRel } from '../files.js';
import { buildSite, gitCommit } from '../hugo.js';
import { syncLibrary } from '../dialog-store.js';
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
const publish = new Hono();
publish.post('/', async (c) => {
const { path: rel } = await c.req.json();
try {
const safe = safeRel(rel);
const build = await buildSite({ dest: 'public', drafts: false });
// Neue/aktualisierte Library-Beiträge sofort als Dialog-Threads spiegeln.
await syncLibrary({ force: true }).catch(() => {});
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
} catch (e) {
return c.json({ error: String(e.message || e) }, 500);
}
});
export default publish;
-47
View File
@@ -1,47 +0,0 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { listEntries } from '../files.js';
import { requireAdmin, roleOf } from '../auth.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.
const content = { beitraege: 0, entwuerfe: 0, library: 0, seiten: 0, rubriken: 0 };
try {
for (const e of await listEntries()) {
if (e.kind === 'beitrag') { content.beitraege++; if (e.draft) content.entwuerfe++; }
else if (e.kind === 'biblio') content.library++;
else if (e.kind === 'rubrik') content.rubriken++;
else content.seiten++;
}
} catch { /* Filesystem nicht lesbar → 0 */ }
// Nutzer nach Rolle.
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 */ }
// Dialog-Zähler (effizient: head + count, keine Zeilen laden).
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 c.json({ content, users, dialog: { forums, threads, comments } });
});
export default stats;
-83
View File
@@ -1,83 +0,0 @@
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;
-62
View File
@@ -1,62 +0,0 @@
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;
-21
View File
@@ -1,21 +0,0 @@
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);
-6
View File
@@ -1,6 +0,0 @@
// 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);
}
-68
View File
@@ -1,68 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// Env vor dem Import setzen: supabase.js bricht ohne URL/Key ab, ADMIN_EMAILS
// und JWT_SECRET werden beim Modul-Load gelesen.
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'dummy';
process.env.JWT_SECRET = 'test-secret';
process.env.ADMIN_EMAILS = 'boss@x.ch';
const { roleOf, requireAuth } = await import('../src/auth.js');
const { sign } = await import('hono/jwt');
test('roleOf: Admin aus ADMIN_EMAILS', () => {
assert.equal(roleOf({ email: 'boss@x.ch' }), 'admin');
assert.equal(roleOf({ email: 'BOSS@X.CH' }), 'admin');
});
test('roleOf: Rolle aus app_metadata', () => {
assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'admin' } }), 'admin');
assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'editor' } }), 'editor');
assert.equal(roleOf({ email: 'a@x.ch' }), 'user');
});
// Minimaler Hono-Kontext-Stub.
function fakeCtx(authHeader) {
const store = {};
return {
req: { header: (h) => (h === 'Authorization' ? authHeader : undefined) },
set: (k, v) => { store[k] = v; },
get: (k) => store[k],
json: (body, status = 200) => ({ __status: status, body }),
};
}
test('requireAuth: gültiges Token wird lokal verifiziert', async () => {
const token = await sign(
{ sub: 'u1', email: 'A@x.ch', app_metadata: { role: 'editor' }, exp: Math.floor(Date.now() / 1000) + 60 },
'test-secret', 'HS256');
let passed = false;
const c = fakeCtx('Bearer ' + token);
await requireAuth(c, async () => { passed = true; });
assert.equal(passed, true);
assert.equal(c.get('email'), 'a@x.ch'); // kleingeschrieben
assert.equal(c.get('role'), 'editor');
assert.equal(c.get('canModerate'), true);
assert.equal(c.get('isAdmin'), false);
});
test('requireAuth: fehlendes Token → 401', async () => {
const c = fakeCtx('');
const r = await requireAuth(c, async () => { throw new Error('darf nicht laufen'); });
assert.equal(r.__status, 401);
});
test('requireAuth: kaputtes/falsch signiertes Token → 401', async () => {
const bad = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) + 60 }, 'falsches-secret', 'HS256');
for (const t of ['Bearer garbage', 'Bearer ' + bad]) {
const r = await requireAuth(fakeCtx(t), async () => { throw new Error('darf nicht laufen'); });
assert.equal(r.__status, 401);
}
});
test('requireAuth: abgelaufenes Token → 401', async () => {
const expired = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) - 10 }, 'test-secret', 'HS256');
const r = await requireAuth(fakeCtx('Bearer ' + expired), async () => { throw new Error('darf nicht laufen'); });
assert.equal(r.__status, 401);
});
-46
View File
@@ -1,46 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { coalesce } = await import('../src/coalesce.js');
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
test('coalesce: nie mehr als ein Lauf gleichzeitig pro Key', async () => {
let active = 0, maxActive = 0, runs = 0;
const fn = async () => { active++; maxActive = Math.max(maxActive, active); await tick(10); runs++; active--; return runs; };
// 5 gleichzeitige Aufrufe.
await Promise.all(Array.from({ length: 5 }, () => coalesce('k1', fn)));
assert.equal(maxActive, 1, 'parallele Läufe');
// Erster Lauf bedient den ersten Aufruf; die 4 während des Laufs eingetroffenen
// teilen sich GENAU EINEN nachgelagerten Lauf → insgesamt 2.
assert.equal(runs, 2);
});
test('coalesce: Wartende teilen sich das Ergebnis des nachgelagerten Laufs', async () => {
let n = 0;
const fn = async () => { await tick(10); return ++n; };
const first = coalesce('k2', fn); // startet sofort → Ergebnis 1
await tick(2); // sicherstellen, dass er läuft
const a = coalesce('k2', fn); // wartet → nachgelagerter Lauf
const b = coalesce('k2', fn); // wartet → selber Lauf wie a
assert.equal(await first, 1);
const [ra, rb] = await Promise.all([a, b]);
assert.equal(ra, 2);
assert.equal(rb, 2); // a und b teilen sich Lauf 2
});
test('coalesce: Fehler wird an die Wartenden propagiert, Key bleibt nutzbar', async () => {
let fail = true;
const fn = async () => { await tick(5); if (fail) throw new Error('boom'); return 'ok'; };
await assert.rejects(() => coalesce('k3', fn), /boom/);
fail = false;
assert.equal(await coalesce('k3', fn), 'ok'); // danach wieder verwendbar
});
test('coalesce: verschiedene Keys laufen unabhängig', async () => {
const fn = async () => { await tick(5); return 'done'; };
const [x, y] = await Promise.all([coalesce('kA', fn), coalesce('kB', fn)]);
assert.equal(x, 'done');
assert.equal(y, 'done');
});
-41
View File
@@ -1,41 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { safeRel, normAuthors, hasAccess, urlFor } = await import('../src/files.js');
test('safeRel: gültiger relativer .md-Pfad bleibt erhalten', () => {
assert.equal(safeRel('library/software/stack.md'), 'library/software/stack.md');
assert.equal(safeRel('a/./b.md'), 'a/b.md');
});
test('safeRel: Path-Traversal wird abgelehnt', () => {
assert.throws(() => safeRel('../etc/passwd.md'));
assert.throws(() => safeRel('a/../../b.md'));
assert.throws(() => safeRel('/absolut.md'));
});
test('safeRel: nur .md erlaubt, leer/falsch wirft', () => {
assert.throws(() => safeRel('note.txt'));
assert.throws(() => safeRel(''));
assert.throws(() => safeRel(null));
});
test('normAuthors: String/Array/Leer normalisieren', () => {
assert.deepEqual(normAuthors('a@x.ch'), ['a@x.ch']);
assert.deepEqual(normAuthors(['a@x.ch', 'b@y.ch']), ['a@x.ch', 'b@y.ch']);
assert.deepEqual(normAuthors(null), []);
assert.deepEqual(normAuthors([]), []);
});
test('hasAccess: case-insensitive Mitgliedschaft', () => {
assert.equal(hasAccess(['Karim@x.ch'], 'karim@x.ch'), true);
assert.equal(hasAccess(['a@x.ch'], 'b@y.ch'), false);
assert.equal(hasAccess([], 'a@x.ch'), false);
});
test('urlFor: Hugo-URLs aus relativem Pfad', () => {
assert.equal(urlFor('_index.md'), '/');
assert.equal(urlFor('manifest.md'), '/manifest/');
assert.equal(urlFor('library/software/stack.md'), '/library/software/stack/');
assert.equal(urlFor('software/_index.md'), '/software/');
});
-44
View File
@@ -1,44 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { rateLimit } = await import('../src/ratelimit.js');
function fakeCtx() {
const headers = {};
return {
req: { header: () => undefined },
header: (k, v) => { headers[k] = v; },
json: (body, status = 200) => ({ __status: status, body }),
_headers: headers,
};
}
test('rateLimit: blockt nach Überschreiten mit 429 + Retry-After', async () => {
const mw = rateLimit({ max: 2, windowMs: 10_000, keyFn: () => 'fix' });
let calls = 0;
const run = async () => { const c = fakeCtx(); const r = await mw(c, async () => { calls++; }); return { c, r }; };
assert.equal((await run()).r, undefined); // 1 → durch (next, kein Return)
assert.equal((await run()).r, undefined); // 2 → durch
const { c, r } = await run(); // 3 → blockiert
assert.equal(r.__status, 429);
assert.ok(c._headers['Retry-After']);
assert.equal(calls, 2); // next nur zweimal aufgerufen
});
test('rateLimit: getrennte Schlüssel zählen getrennt', async () => {
let key = 'a';
const mw = rateLimit({ max: 1, windowMs: 10_000, keyFn: () => key });
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // a:1 ok
assert.equal((await mw(fakeCtx(), async () => {})).__status, 429); // a:2 blockiert
key = 'b';
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // b:1 ok
});
test('rateLimit: Fenster läuft ab → wieder frei', async () => {
const mw = rateLimit({ max: 1, windowMs: 30, keyFn: () => 'win' });
assert.equal((await mw(fakeCtx(), async () => {})), undefined);
assert.equal((await mw(fakeCtx(), async () => {})).__status, 429);
await new Promise((r) => setTimeout(r, 40));
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // Fenster neu
});
+9 -1
View File
@@ -146,7 +146,10 @@ services:
# ════════════════════════════════════════════════════════════════════════
cms:
build:
context: .
# openbureau consumes openbureau-core, vendored via git subtree at cms/core.
# Build context = core's root so core's own Dockerfile picks up core/admin +
# core/api (the generic engine), not this site's files.
context: ./core
dockerfile: api/Dockerfile
args:
# Browser-seitig (Admin-SPA, zur Build-Zeit): öffentliche Supabase-URL.
@@ -169,6 +172,11 @@ services:
JWT_SECRET: ${JWT_SECRET}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
SITE_DIR: /site
# Tells core which content model + plugins this site has (schema-driven engine).
# Lives in the mounted repo (/site = repo root). DATABASE_URL is intentionally
# unset: the stack's `migrate` service owns the schema (db/schema.sql incl. the
# dialog tables), so core's plugin migration runner stays a no-op here.
CMS_CONFIG: /site/cms/openbureau.config.js
PORT: 3000
GIT_PUBLISH: ${GIT_PUBLISH:-false}
GIT_REMOTE: ${GIT_REMOTE:-origin}
+75
View File
@@ -0,0 +1,75 @@
// openbureau site config — what this site's content model + plugins are.
// Consumed by openbureau-core (vendored at cms/core) via CMS_CONFIG. Reproduces
// the previously hard-coded content model 1:1 (proven by core's collections test).
export default {
site: 'openbureau',
auth: 'supabase', // GoTrue/Supabase login (the stack provides it); core default
admins: ['karim@gabrielevarano.ch'],
plugins: ['dialog'], // comment/forum subsystem (library ↔ threads sync)
collections: [
{
kind: 'beitrag', label: 'Beiträge', order: 0,
path: 'archiv/:section/:slug',
sections: ['buerofuehrung', 'software', 'theorie'],
statKey: 'beitraege',
draftStatKey: 'entwuerfe', // Beitrag-Entwürfe als eigener Zähler (Dashboard)
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'section', type: 'select', options: ['buerofuehrung', 'software', 'theorie'] },
{ name: 'slug', type: 'slug' },
{ name: 'date', type: 'date' },
{ name: 'weight', type: 'number' },
{ name: 'color', type: 'string' },
{ name: 'layout', type: 'select', options: ['text'], default: 'text' },
{ name: 'tags', type: 'list' },
{ name: 'summary', type: 'text' },
{ name: 'cover_image', type: 'image' },
{ name: 'authors', type: 'list' },
{ name: 'toc', type: 'bool' },
{ name: 'draft', type: 'bool', default: true },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'biblio', label: 'Library', order: 1,
path: 'library/:slug',
statKey: 'library',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'slug', type: 'slug' },
{ name: 'date', type: 'date' },
{ name: 'tags', type: 'list' },
{ name: 'summary', type: 'text' },
{ name: 'cover_image', type: 'image' },
{ name: 'external', type: 'string' },
{ name: 'group', type: 'string' },
{ name: 'authors', type: 'list' },
{ name: 'draft', type: 'bool', default: true },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'rubrik', label: 'Rubriken', order: 3, index: true,
statKey: 'rubriken',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'color', type: 'string' },
{ name: 'layout', type: 'string' },
{ name: 'weight', type: 'number' },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'seite', label: 'Seiten', order: 2, fallback: true,
statKey: 'seiten',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'layout', type: 'string' },
{ name: 'toc', type: 'bool' },
{ name: 'draft', type: 'bool', default: true },
{ name: 'body', type: 'markdown' },
],
},
],
};