Compare commits

...

79 Commits

Author SHA1 Message Date
karim b37edb6bda Merge commit '6aedf21bf6dc495fdda89abadbd383fb3ea9e15e' 2026-06-30 19:52:57 +02:00
karim 6aedf21bf6 Squashed 'cms/core/' changes from 8f4068d..9676bb0
9676bb0 core: update-check is token-free (running vs vendored cms/core version)
59fb691 core: System panel fix + settings (update-check, self-service password, admins)
67bcb6a docs: HANDOVER — kgva LIVE on core (Variant A, in-place CT130); both sites on core

git-subtree-dir: cms/core
git-subtree-split: 9676bb0264a7caf254ccbc461bc803a6b3583127
2026-06-30 19:52:57 +02:00
karim fed003ae31 cms: adopt the schema-driven editor (core update) + tune config field types
git subtree pull of openbureau-core main brings the schema-driven admin editor
(renders sidebar + fields from /api/schema, unknown frontmatter preserved on save).
openbureau.config.js color→type:'color' (swatch) and layout options text/image/icon
so the editor reproduces the previous look.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:36:37 +02:00
karim 685592362a Squashed 'cms/core/' changes from ac7538f..8f4068d
8f4068d docs: HANDOVER — stage 6 (schema editor) + 9 (kgva on core) done; live cutovers pending
59b9e20 core: schema editor preserves unknown frontmatter (no data loss on save)
f8f4131 core: stage 6 finish — schema-driven editor (admin renders from /api/schema)
dd45602 docs: HANDOVER — stages 6 (partial)/7/8 done, openbureau on core; next = kgva

git-subtree-dir: cms/core
git-subtree-split: 8f4068d109d70d33b6619e039aff56ea3962802f
2026-06-30 18:36:26 +02:00
karim 2f639c068b Merge commit '685592362aa6f53e981f9b4801d9a2e8ca318605' 2026-06-30 18:36:26 +02:00
karim f518eb7a13 Squashed 'cms/core/' changes from f709b5d..ac7538f
ac7538f chore: gitignore admin/dist build output
86f9f57 core: stage 7 — auth provider (config.auth: supabase | local), DB-less core

git-subtree-dir: cms/core
git-subtree-split: ac7538fa0c2c883e29fe67c8d8c15c5f40fa2230
2026-06-30 01:24:44 +02:00
karim 3d902a0bbe Merge commit 'f518eb7a1394ee29b825e551e6557dd71cd95a28' 2026-06-30 01:24:44 +02:00
karim 42110059c7 Squashed 'cms/core/' changes from 2f52ec7..f709b5d
f709b5d core: stage 6 (part) — /api/schema + /api/system + admin System panel

git-subtree-dir: cms/core
git-subtree-split: f709b5dc8ded7f138b21ac0aad4dd0855cb75aba
2026-06-30 01:06:09 +02:00
karim ecf7710b8e Merge commit '42110059c7a42b317fd1391e3cd539a555f19226' 2026-06-30 01:06:09 +02:00
karim ab78c84296 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>
2026-06-30 00:14:36 +02:00
karim 80eca1bc7c Squashed 'cms/core/' content from commit 2f52ec7
git-subtree-dir: cms/core
git-subtree-split: 2f52ec7536ee87b4f097d169e1cb0f6a85aebe84
2026-06-30 00:09:44 +02:00
karim 2bf27f01be Merge commit '80eca1bc7c1174f9c44a8df9af4ddc8ab855291b' as 'cms/core' 2026-06-30 00:09:44 +02:00
karim 51bf175450 content: Archiv + Library Texte entschleimen
Kürzer, sachlicher, kein LLM-Pathos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 03:16:25 +02:00
karim d6c5b2edb4 ui: Abstände halbiert, aktive Pills weiss
.atlas gap spacing-lg→md, margin-top spacing-md→sm;
.collection-title margin-bottom spacing-md→sm;
.lib-filter margin-bottom spacing-md→sm.
Archiv-Toggle aktiv: color white (wie Library-Pills).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 02:16:11 +02:00
karim 42f2823ff0 ui(archiv+library): Section-Header, mehr Spalten, Kategorie-Pills
Archiv-Unterseiten (Büroführung etc.): .collection-Wrapper mit
--palette-kusa, .collection-title statt .section-header — gleiche
Optik wie Archiv/Library-Übersichten. Artikel-Grid: auto-fill
minmax(220px) → 3+ Spalten. Datum unter Titel (card-layout).

Archiv-Toggle-Pills: font-family-display (wie Dialog-Pill), margin
von spacing-md → 0.5em.

Library-Übersicht: A-Z-Index → Kategorie-Pills (Gruppe ausgeschrieben,
aktiv = ichigo), Atlas als 2-Spalten-Grid (.atlas--grid2), Suche
kombiniert mit Gruppe-Filter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 02:08:02 +02:00
karim 1709dc093c feature(library): Wiki-Links, Backlinks, Suche + A-Z-Index
single.html: [[Begriff]]-Auflösung zu internen Links (.wikilink),
fehlende Seiten als .wikilink-missing, „Siehe auch" (Gruppe+Tags)
und „Erwähnt in" (Backlinks), Eintrags-Fuss mit Gruppe + bearbeiten.

list.html: Suchfeld + A-Z-Index mit JS-Filter (Umlaute normalisiert),
data-title auf <li> für clientseitiges Filtern.

custom.css: .wikilink, .wikilink-missing, .entry-links, .entry-links-label,
.lib-filter, .lib-search, .lib-az — alle via --section-color (ichigo/rose).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 01:40:00 +02:00
karim 46ee91279e feature(archiv): Umschalter Kategorie ↔ Jahr
Übersicht /archiv umschaltbar: nach Kategorie (Jahr hinter dem Beitrag, je
Rubrik die letzten 10 + 'alle →') oder nach Jahr (Kategorie hinter dem Beitrag,
alle). Clientseitig, merkt die Wahl in localStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:16:30 +02:00
karim 827a04cfe8 ui: farbige Linie noch etwas höher (0.08em→0.03em)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:57:27 +02:00
karim 98955bc097 ui: farbige Linie noch näher an Titel/Kategorien (0.15em→0.08em)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 13:23:16 +02:00
karim 886c090a8a ui: farbige Linie näher an Titel + Kategorien (padding-bottom 0.375em→0.15em)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:50:00 +02:00
karim f8c82ad4a2 ui: Übersichten wie Artikelseiten geboxt (Lesespalte), Oberabstand wie .single; Archiv grün / Library rot (Fuji)
- .collection wrappt Titel + Inhalt in die Lesespalte (≈48.5rem, zentriert);
  margin-top spacing-sm = identischer Oberabstand wie Artikel.
- Titel-Unterstrich + Kategorie-Linien in der Sektionsfarbe: Archiv kusa (grün),
  Library ichigo (rot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:10:13 +02:00
karim 04bb79bcfa ui: Übersichts-Titel auf Inhaltsbreite begrenzt (rem statt ch); Header-Abstände wieder etwas kleiner
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:49:31 +02:00
karim 30fc688622 ui: Übersichts-Titel volle Breite + linksbündig (Linie über ganze Spalte)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:44:49 +02:00
karim 234ed52fa8 ui: Übersichts-Titel als Artikel-Heading mit Unterstrich; Header-Abstände grösser
- Archiv/Library-Titel: .collection-title (Serif, links, 3px-Linie darunter wie
  die Kategorien) statt zentriertem section-header.
- Masthead: padding 0.48rem/2.4px → 0.9rem/0.5rem, Logo↔Menü 0.12→0.45em.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:38:56 +02:00
karim fc422c78d0 ui: Archiv/Library mit Titel oben, Archiv-Atlas zeigt 10 + 'alle →'; Logo +10%, Masthead-Abstände +20%
- Archiv-Root + Library-Übersicht bekommen einen section-title ganz oben.
- Archiv-Atlas: letzte 10 statt 6 pro Kategorie, 'alle in … →' am Block-Ende.
- Subsektionen rendern ihren Intro (.Content) nach dem Header.
- Wordmark clamp 140-200 → 154-220 (+10%); Header-Padding 0.4rem/2px → 0.48rem/2.4px,
  Logo↔Menü 0.1→0.12em (+20%).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:29:42 +02:00
karim 6e4bb06f5f content: Library-Texte an einspaltiges Layout anpassen (kein Sidebar-Filter mehr)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:58:22 +02:00
karim d5f35bb9f8 ui: Headings kleiner + Library als Archiv-Zwilling (Sidebar weg)
- section-title 2.6→1.9rem max, 800→700; single-header h1 3→2.2rem max, 800→700;
  single-summary 1.4→1.2rem. Ruhiger, redaktioneller, site-weit.
- Library raus aus der zweispaltigen Sonderform: Übersicht = .atlas (gruppiert,
  wie Archiv-Root), Eintrag = .single (wie Essay) mit Quellen + Fuss
  (Gruppe · weitere Einträge · bearbeiten). library-nav-Partial entfernt.
- Voll CI-konform: Archiv und Library teilen jetzt dasselbe Gerüst.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:47:53 +02:00
karim ef024921ab library: Quellen-Fussnoten funktionieren (Beispiel auf 'Typus')
Library-Einzelseiten rendern ihren Text in .single-content → die Goldmark-
Fussnoten [^x] bekommen automatisch denselben 'Quellen'-Block wie die Essays.
Typus mit zwei echten Quellen versehen (Quatremère, Moneo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:33:22 +02:00
karim 42de32888c content: Kontakt/Impressum/Datenschutz auf einer Seite (Impressum)
datenschutz.md in impressum.md gemerged (Kontakt · Verantwortlich · Datenschutz),
Alias /datenschutz/ → /impressum/ (kein toter Link). Footer: ein 'Impressum'-Link
statt 'Kontakt / Impressum' + 'Datenschutz'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:24:51 +02:00
karim fbf8ee1ebf ui: Menü schlanker — Manifest + Code in den Footer
Hauptmenü: JOURNAL · LIBRARY · ARCHIV · DIALOG. Manifest und Code (Repo)
wandern in die Footer-Linkleiste zu Colophon/Impressum/Datenschutz/RSS/Spenden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:25:29 +02:00
karim 790141bafe ia: Umbenennung — Library→Archiv, Wiki→Library (URLs, Content, Code)
Neue Informationsarchitektur:
- ARCHIV (/archiv) = die fertigen Texte (vormals Library): Essays mit Byline,
  Quellen/Zitieren, Dialog, Versionsverlauf. Section "archiv".
- LIBRARY (/library) = das verlinkte Werkstattwissen (vormals Wiki): zwei-
  spaltig mit Gruppen-Navigation + Filter. Section "library".

Umgesetzt:
- content/ + layouts/ verschoben (git mv), Menü (ARCHIV+LIBRARY, kein WIKI),
  Startseiten-Journal zieht jetzt Section "archiv", Querverweise umgeschrieben
  (/library→/archiv, /wiki→/library).
- CMS: files.js klassifiziert archiv/<sec>→beitrag, library/→biblio;
  stats.js + Admin (Typ "Library-Seite", KIND_LABEL, Pfade) nachgezogen.
- single.html: Byline/Provenance/Dialog an Section "archiv" gebunden.
- Beide Header zentriert (section-header) — einheitlicher Look.
- Interne Dialog-Werte (thread.kind='library', Forum "Beiträge") unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:13:50 +02:00
karim 54f03270d0 ui(wiki): vollbreiter Header wie Library (gleiche max-width), Spalten darunter
Wiki-Header (section-header) spannt jetzt die volle Spalte (grid-column 1/-1)
und die .wiki-Box hat dieselbe max-width/Zentrierung wie der normale Inhalt —
so fluchten Library- und Wiki-Header. Seitenleiste + Inhalt darunter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:30:44 +02:00
karim b4fdc8c200 fix(wiki): Header in die Inhaltsspalte → Titel fluchtet mit dem Text
Header war volle (schmalere, zentrierte) Inhaltsbreite, die zweispaltige Fläche
darunter aber breiter → Titel versetzt. Header jetzt in .wiki-page, Oberkante
mit der Seitenleiste gleichgezogen (margin-top:0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:25:22 +02:00
karim 0f80378a7c ui(wiki): Header wie die übrigen Sektionen (section-header), kein doppeltes 'Wiki'
- Übersicht: nur ein section-title (Rubrik 'Wiki' über Titel 'Wiki' entfernt)
- Einzelseite: Rubrik = Gruppe (z.B. Begriffe), Titel = Seitentitel
- Titel über die volle Breite (8px-Akzentlinie wie Library), Spalten darunter
- ungenutzte .wiki-rubric/.wiki-head CSS entfernt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:50:12 +02:00
karim 3dd8d5edd4 fix(wiki): Fuss als div statt footer — globales footer{} färbte ihn schwarz
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:30:54 +02:00
karim 0f574bf8a7 ui: Software-Rubrik als kuratierte Landing (Werkzeuge vs. Texte)
/library/software trennt jetzt 'Werkzeuge' (Beiträge mit externem Link wie
DOSSIER/RAPPORT, als Karten mit ↗ und Farbakzent) von 'Texte & Anleitungen'
(chronologisch). Andere Rubriken bleiben unverändert. dist/ ignoriert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:11:07 +02:00
karim 9c9b7e03bd admin: Übersicht-Dashboard, aufgewertete Nutzerverwaltung, Wiki-Autoren
- Neue /api/stats (Admin, read-only): Inhalte/Nutzer/Dialog-Kennzahlen
- Übersicht-View als Admin-Dashboard: Stat-Karten (klickbar) + Schnellzugriff
- Nutzerverwaltung: Avatar-Initiale, angelegt/zuletzt-aktiv, Rolle beim Anlegen,
  Inline-Passwort (statt prompt), Filter, Rollen-Badge; API liefert last_sign_in_at
- Wiki im Editor anlegbar: Typ 'Wiki-Seite' + Gruppe-Feld → content/wiki/<slug>.md;
  files.js klassifiziert wiki als eigene 'kind' (eigene Sidebar-Gruppe)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:09:10 +02:00
karim d9ba2f7bbe feature: Wiki — verlinktes Werkstattwissen mit Gruppen-Navigation
Neue Hugo-Sektion /wiki als 'effektives Wiki' im KISS-Sinn:
- Zweispaltiges Layout: gruppierte Seitenleiste (nach Frontmatter 'group')
  mit Live-Filter + Inhalt mit TOC und 'zuletzt bearbeitet'/bearbeiten-Link
- Übersichtsseite gruppiert alle Einträge; WIKI im Hauptmenü
- Seiten ohne group landen unter 'Allgemein' (robust)
- Start-Inhalte: Meta-Seite (wie es funktioniert), Typus (→ Bibliothek),
  Dateiablage/Benennung; Archetype setzt group/summary
- Bewusst dateibasiert: jede Seite verlinkt zur Bearbeitung ins Repo

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:56:35 +02:00
karim 37fdc9019c dialog: UX-Pass — Lade-Skelette, Avatar-Farben, Links, Cmd+Enter
- Dezente Lade-Skelette (Shimmer) in Übersicht/Forum/Thread statt leerem Pop-in
- Deterministische, ruhige Avatar-Farbe aus dem Namen (statt einheitlichem Grau)
- URLs in Wortmeldungen klickbar (sicher, ohne innerHTML)
- ⌘/Ctrl+Enter sendet; Textarea wächst mit dem Inhalt; Hinweis im Composer
- Enter im Thread-Titel springt ins Textfeld
- prefers-reduced-motion respektiert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:49:09 +02:00
karim 1fb4556ac1 ui: Quellen-Block symmetrisch (Abstand oben=unten)
article-actions margin-top spacing-md -> spacing-sm, damit der Abstand
zitieren->Trennlinie gleich gross ist wie obere Trennlinie->Quellen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:44:05 +02:00
karim 9163f5c90d security: public Deploy härten (Reverse-Proxy, GoTrue-Rate-Limit, RLS-Revoke)
Für die öffentlich erreichbare Instanz (dev.openbureau.ch):

1. Reverse-Proxy nur /auth/* durchreichen — /rest, /storage, /realtime raus.
   PostgREST /rest/v1/ gab die komplette DB-Schema-Beschreibung (OpenAPI) preis;
   der Browser nutzt Supabase nur fürs Login, Daten laufen über /api/*.
   (Caddy-Block in create-openbureau-lxc.sh + proxmox/README.md angepasst.)
2. GoTrue GOTRUE_RATE_LIMIT_TOKEN_REFRESH=100 — bremst Brute-Force aufs /token,
   das public direkt gegen GoTrue läuft (nicht übers Node-Rate-Limit).
3. db/schema.sql: revoke all from anon/authenticated auf posts/comments/forums/
   threads; grants nur noch service_role. RLS bleibt so auch bei künftigen
   Policies dicht (Defense-in-Depth statt "RLS ohne Policy").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:11:19 +02:00
karim 50cec7a965 docs: Domain-/Reverse-Proxy-Deploy dokumentieren (SITE_DOMAIN + Caddy)
Hält den dev.openbureau.ch-Deploy reproduzierbar fest: SITE_DOMAIN-Env,
Env-Overrides (local-zfs, statische IP, CTID …) und der Caddy-Block mit
Pfad-Routing (/auth/* + /rest/* -> :8000, Rest -> :8080) samt
Login-User-Anlage. Der reale Caddyfile liegt im separaten dms-stack (VPS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 01:25:46 +02:00
karim f434885c28 proxmox: create-openbureau-lxc env-überschreibbar + domain-fähig
- Alle CONFIG-Werte per Env überschreibbar (ROOTFS_STORAGE, HOSTNAME,
  DISK_GB, RAM_MB, CORES, BRIDGE, IP, GATEWAY) — vorher teils hardcodet,
  was auf ZFS-Hosts (local-zfs statt local-lvm) non-interaktiv scheiterte.
- Neue Variable SITE_DOMAIN: setzt SITE_URL + API_EXTERNAL_URL auf
  https://<domain> (Same-Origin, Pfad-Routing am Reverse-Proxy) statt
  Container-IP. Abschluss zeigt passenden Caddy-Block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 01:06:05 +02:00
karim b67b24a53c proxmox: Selbsthosting-Set (Dialog-Suite + Einzelskripte) + 2 Artikel
Geführter Installer proxmox/install.sh: erst Vorhaben wählen
(Komplettes Büro / 365+Synology ersetzen / nur Website / einzeln),
dann werden die nötigen LXCs der Reihe nach gebaut. Jeder Dienst ist
auch als eigenständiges, einzeln curlbares Skript verfügbar:

- proxmox/nextcloud-lxc.sh    Nextcloud AIO (ersetzt 365/Synology)
- proxmox/empty-lxc.sh        leerer Docker-LXC als Geruest
- proxmox/git-compose-lxc.sh  beliebiges Git-Repo (RAPPORT/DOSSIER)
- (OPENBUREAU: bestehendes cms/proxmox/create-openbureau-lxc.sh)

Gemeinsames Muster: unprivilegierter Debian-12-LXC mit nesting+keyctl,
Docker via get.docker.com, Dienst als Container/Compose. proxmox/README.md
dokumentiert beide Wege.

Dazu zwei Bibliotheksbeitraege (Hochparterre-Ton):
- server-im-eigenen-haus.md  — das Warum/Ziel
- proxmox-schritt-fuer-schritt.md — die Anleitung mit curl-Befehlen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 01:20:43 +02:00
karim b72f744963 content: Datenschutz-Seite + Footer-Link
Deutsche Datenschutzerklärung an die tatsächliche Praxis angepasst (self-hosted,
keine Tracker/Analytics, Server-Logs nur technisch, Dialog-Konto mit E-Mail/Name,
revDSG-Rechte, EDÖB). Footer: Datenschutz neben Kontakt/Impressum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 05:11:10 +02:00
karim fcbe91c0da ui: Uhr-Icon explizit terracotta (Akzent), weiß beim Hover
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 04:03:07 +02:00
karim 1196f9adf6 ui: Versions-Banner in normaler Schrift (Inter/sans) statt Mono
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 03:18:13 +02:00
karim e62b4c3704 ui: zitieren als Link (↗), Versionen mit Uhr-Icon, interne Zitierweise als Option
- zitieren: kein Pill mehr — Link in der Pill-Schrift (Display) mit ↗ am Ende
- Versionen: Pill behält, davor ein monochromes Uhr-SVG (currentColor)
- Zitier-Formate: „intern" (OPENBUREAU-Hausformat inkl. Version) als dritte
  Option und Default, neben APA und DIN

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 03:11:39 +02:00
karim fce6c9eabc ui: zitieren+Versionen als Pills (wie Dialog), Quellen kompakter
- zitieren + Versionen jetzt Akzent-Outline-Pills wie Dialog (statt Links);
  „kopieren" in der Quellenangabe bleibt ein Link
- Quellen/Fußnoten: feste, vom Theme vererbte Zeilenhöhe (~26px) mit
  unitless line-height:1.5 überschrieben + kleinere Abstände → deutlich
  kompakter. Aktionsreihe-Abstände reduziert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:54:16 +02:00
karim 6aa88a07a6 ui: Artikel-Fuß neu — zitieren als Link, Tags neben Dialog, Versionen-Liste
- zitieren: schlichter Link direkt unter den Quellen (statt Pill); klappt eine
  dezente, gesamthaft kopierbare Quellenangabe auf — wahlweise APA oder DIN
- Tags raus von unter den Quellen → in die Aktionsreihe, ganz rechts neben Dialog
- „Versionen" als Link eine Zeile darunter; Liste in voller Inhaltsbreite und
  normaler Schrift/Größe (statt mono-Box). Auswahl öffnet Diff/Fassung oben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:19:27 +02:00
karim f2aef5c89a feature: Versionsverlauf + Diff (rot/grün) komplett auf der Seite
- API: /api/history/diff liefert den Unified-Diff einer Fassung (git show)
- „Version vom <Datum>"-Pill (statt Marke oben + Git-Links) öffnet den Verlauf
  direkt auf openbureau: Liste der Fassungen → Diff rot/grün wie auf GitHub,
  Toggle „ganze Fassung anzeigen", Rücksprung. Keine externen Git-Links mehr.
- Pills neu: Dialog (Akzent-Outline, wie zuvor) + Version/Zitieren im Tag-Look
- CSS: Tag-Pills, Diff-Styling (d-add/d-del/d-hunk), alte Badge-Styles raus

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:06:43 +02:00
karim 0cc90ac295 ui: Dialog als Pill in die Provenance-Reihe, sticht durch Symbol+Akzent hervor
Dialog-Link in .provenance verschoben: → Dialog · Version · Verlauf · Zitieren
in einer Reihe, gleiche Pill-Form. Dialog (prov-dialog) hebt sich durch Pfeil
+ Akzentfarbe (Rahmen/Text, fett) ab, Hover füllt Akzent. Zähler-Script mit ins
Partial gezogen; alte .dialog-link-Klasse/-CSS entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:52:45 +02:00
karim 22c9b9ff61 ui: Provenance als Pills + Byline nur bei Library-Beiträgen
- Version/Verlauf/Zitieren als dezente Outline-Pills (wie Versions-Marke),
  „·"-Trenner entfernt
- Byline (Autor) + „Aktualisiert am" nur noch bei Library-Beiträgen; Seiten
  wie Manifest/Kontakt/Spenden/Colophon zeigen sie nicht mehr

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:42:28 +02:00
karim 0ce2c73004 feature: alte Versionen direkt auf openbureau anzeigen
- API (öffentlich): /api/history listet Git-Versionen eines Beitrags,
  /api/history/version rendert eine alte Fassung (marked + Fußnoten-Support),
  on-demand via git im CMS-Container — kein Vorbauen. Pfad/rev validiert.
- Versions-Marke neben dem Kopf jedes Library-Beitrags (zeigt bewusst die
  Fassung); öffnet den Verlauf, Auswahl ersetzt den Text + Rücksprung-Banner.
- CSS für Badge/Panel/Banner; marked als Dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:38:48 +02:00
karim c6f5beaa7b ui: Zitieren zeigt Quellenangabe sichtbar + Copy-Fallback für HTTP
Vorher: stiller clipboard.writeText → tut auf HTTP/LAN nichts (Clipboard-API
nur in sicherem Kontext). Jetzt: Klick klappt ein Panel mit der lesbaren
Quellenangabe auf (user-select:all), „Kopieren" nutzt clipboard-API mit
execCommand-Fallback; schlägt das fehl, wird der Text markiert (manuelles
Strg/⌘+C).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:22:55 +02:00
karim a4ca05c88f ui: Fußnoten-Verweise kleiner + dezent unterstrichen
sup auf 0.62em verkleinert; .footnote-ref bekommt feine Unterstreichung
(0.5px, 0.2em Offset) statt gar keiner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:14:54 +02:00
karim 656da26347 content: 3 Musterbeiträge (Fußnoten/Quellen) + Forum-Seed-Wortmeldungen
- content/library/**/muster-*.md: Typus und Modell, Im Offenen arbeiten,
  Die Werkzeugkette — mit echten Fußnoten/Quellen, demonstrieren auch
  Provenance/Zitieren und Dialog am Artikel
- seed-demo.sql: Wortmeldungen auf den Musterbeiträgen (thread = Beitrags-URL)
  + DELETE-Hinweis für die d-Kommentare

Test-/Demo-Inhalt — vor dem späteren Full-Wipe leicht entfernbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:09:26 +02:00
karim c3c8c9639f feature: lebende Dokumente — Fußnoten/Quellen + Provenance/Zitieren
- Fußnoten (Goldmark [^1], schon nativ) gestylt: ruhiges Schriftbild,
  automatische „Quellen"-Überschrift statt <hr>
- provenance.html-Partial bei Library-Beiträgen: Version → Commit, Verlauf
  (Gitea), „Zitieren"-Knopf (kopiert Quellenangabe in die Zwischenablage)
- repoURL-Param (git.openbureau.ch) für die Provenance-Links

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:56:28 +02:00
karim 9f9071d23f content: Kontakt/Impressum-Seite + Footer-Link
- content/impressum.md: Kontakt (E-Mail/Dialog) + Impressum (Karim Varano,
  Fluhmühlerain 1, 6015 Luzern, Privatperson)
- footer: „Kontakt"-Mailto → /impressum/ („Kontakt / Impressum")

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:26:37 +02:00
karim 272d30357f ops: update.sh — Update im LXC in einem Befehl
Kapselt git pull + Deploy-Config + Neustart, damit die Migrationsschritte
nicht mehr per Hand nötig sind:
- kong.yml vor dem Pull auf die Vorlage zurücksetzen (kein Konflikt), danach
  CORS-Origin aus SITE_URL rendern
- chown -R 1000:1000 (non-root-Container darf schreiben)
- git safe.directory für root auf dem uid-1000-Repo
- docker compose up -d --build + kong reload + Healthcheck

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:47:35 +02:00
karim f97999c3c0 perf/test: Build-Coalescing teilen, syncLibrary drosseln, API-Tests
- coalesce.js: generisches Serialisieren+Koaleszieren je Key; buildSite() in
  hugo.js nutzt es → Publish/Preview/Profil starten nie überlappende Hugo-
  Prozesse, schnelle Folge-Aufrufe lösen nur einen Trailing-Build aus
- dialog-store: syncLibrary() gedrosselt (60s-TTL) statt bei jedem Forum-Read
  Filesystem-Walk + Upsert; Publish forciert Sync (force:true)
- test/: node:test-Suite (19 Tests) für safeRel/normAuthors/urlFor/hasAccess,
  roleOf + lokale JWT-Verifikation, Rate-Limiter, Coalescing; npm test

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:14:36 +02:00
karim 8404165f5c perf/ops: Auth-Latenz, Zähl-View, DB-Backup, Schreib-Limit, Asset-Cache
- auth: Supabase-JWT lokal verifizieren (hono/jwt, HS256) statt GoTrue-
  Roundtrip pro Request; JWT_SECRET in cms-env, Remote-Fallback wenn ungesetzt
- dialog: comment_stats-View (group by thread) ersetzt Full-Table-Scan +
  JS-Aggregation bei jedem Forum-Aufruf
- ops: scripts/backup-db.sh (pg_dump, rotiert) + täglicher Cron im Proxmox-
  Script — Dialog-Daten liegen nur in Postgres, nicht in Git
- security: Rate-Limit auf Schreib-Endpunkte (/api non-GET, 60/min je Nutzer)
- perf: Cache-Control (1 Woche) auf statische Assets, HTML bleibt frisch

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:01:12 +02:00
karim d0b5c6f670 dialog: optionaler Forum-Demo-Seed (db/seed-demo.sql)
Beispiel-Threads + Wortmeldungen für die Forum-Kategorien, bewusst getrennt
von der Migration (Produktion startet leer). Idempotent über feste UUIDs +
ON CONFLICT DO NOTHING; manuell einspielbar, DELETE-Block zum Entfernen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:18:27 +02:00
karim 2650913050 security: Härtung der CMS-API + Deployment
App-Level:
- Security-Header (secureHeaders) global; /images/* mit strikter CSP+sandbox
  → bösartiges SVG kann kein JS im Origin ausführen
- Body-Limit 256 KB auf /api/*; Login-Rate-Limit (10/5min) gegen Brute-Force
- Upload: 8-MB-Limit + Format-Verifikation (sharp-Metadaten, SVG/GIF-Signatur)
- Comment-Längenlimit (10k) gegen DB-Bloat
- DB-Fehler nicht mehr roh ausliefern (serverError-Helper)
- Profil-PUT koalesziert Hugo-Builds (kein Build-Sturm)

Infra:
- Container läuft non-root (USER node, uid 1000) + Proxmox-Repo-chown
- Ports binden per Default auf 127.0.0.1 (BIND_ADDR-Escape-Hatch)
- Kong-CORS auf SITE_URL beschränkt statt "*"
- README: Härtungs- + Migrationshinweise

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:05:57 +02:00
karim 6d20be036a dialog: Position/Rolle + Breadcrumb-Nav + nüchterne Wortmeldungen, Footer voll-breit
- comments: author_role (Position bei OPENBUREAU) aus authors.json gespeichert/ausgeliefert
- schema: comments.author_role hinzugefügt
- dialog.js: Breadcrumb (Dialoge › Forum), volles Datum/Uhrzeit, Box→Trennlinien-Layout
- css: Footer voll-breit (Flex statt Grid), Balken zwischen Header/main/Footer entfernt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:37:50 +02:00
karim 7b25f644a2 ui: kurzer Inhalt vertikal zentriert (Rahmen-Layout)
body:not(.is-home) main als Flex-Spalte mit justify-content: safe center —
kurze Seiten zeigen den Inhalt mittig zwischen Header und Footer (gleiche
Lücke oben/unten), langer Inhalt scrollt normal von oben (safe → start).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:40:47 +02:00
karim 4f4eccd475 ui: Footer kompakt + Balken entfernt (Breadcrumb-Linie, Header/Inhalt-Abstand)
- Footer auf allen Seiten so flach wie auf Journal (padding 0.55rem, row-gap 0.2rem)
- Breadcrumb ohne Trennlinie und ohne großen Abstand (war der „Balken" über dem Footer)
- Artikel-Top-Abstände reduziert (Inhalt sitzt enger unter dem Header)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:35:16 +02:00
karim af587af851 ui: App-Rahmen-Layout auf allen Seiten (Header/Footer fix, main scrollt intern)
Statt Seiten-Scroll jetzt überall der Journal-„Rahmen": body height:100dvh +
overflow:hidden, Header oben / Footer unten fix, main scrollt INTERN mit
verstecktem Scrollbalken. Dadurch:
- keine Seiten-Scrollbar → Header/Footer reichen schwarz bis an den rechten Rand
  (kein weißer scrollbar-gutter-Streifen mehr)
- Footer immer unten, Inhalt im Rahmen dazwischen
- nichts hat sichtbare Scrollbalken

Inhalt 72ch-zentriert in der vollbreiten Scroll-Fläche; Hero-Bild füllt die
volle Breite (kein 100vw-Hack mehr); Breadcrumb wandert in main (scrollt mit).
Mobil: normaler Seiten-Scroll (kein 100dvh-Rahmen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 05:36:45 +02:00
karim 309d12f8a2 ui: Sticky-Footer auf allen Seiten — Body von Grid auf Flex-Spalte
Nicht-Home-Seiten nutzten ein Grid-Sticky-Layout, das den Footer durch die
zusätzliche Breadcrumb-Zeile (impliziter Grid-Row) nicht zuverlässig unten
hielt. Body jetzt durchgehend Flex-Spalte wie Home: min-height:100dvh,
main flex:1 → Footer klebt unten. Inhaltsspalte (72ch + 1.75rem Gutter) per
max-width/margin-inline zentriert, Header/Footer volle Breite (eigenes inneres
Raster zentriert deren Inhalt), Full-Bleed-Hero rechnet sich weiter korrekt aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 05:21:12 +02:00
karim f82214719a ui: Header/Footer/Journal/Dialog überarbeitet + Logo
Header/Masthead:
- neuer Wortmark logo.svg; Home als Full-Height-Flex (zwei eigenständig
  scrollbare Journal-Spalten, Footer klebt unten)
- kompakte Masthead; scrollbar-gutter:stable auf html → kein weißer Streifen
  links und kein Logo/Menü-Versatz zwischen den Seiten

Journal-Home:
- zwei Spalten, eckige Karten in natürlicher Höhe; Bild als Block (Inline-
  Whitespace-Lücke behoben)

Footer:
- Lizenzen AGPL-3.0 / CC BY-SA verlinken auf /lizenz/ (eigene Seite);
  „Hosted in Lucerne" (Link zur Autor-URL); keine Underlines; flush unten;
  kein Open-Source-Fließtext mehr, kein Name im Copyright

Dialog:
- schlichte Foren-Auflistung statt Karten; kompaktes Login; kleinerer
  „→ Dialog"-Button (nur noch auf Library-Artikeln); einheitliche Fontgrößen;
  weniger Abstand zum Header, Trennlinien entfernt

Config: CODE-Menü → git.openbureau.ch; params.author.url

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 04:47:36 +02:00
karim f146fc7cff content: Lizenz- und Spenden-Seiten
- /lizenz/agpl-3.0/ + /lizenz/cc-by-sa-4.0/ mit den offiziellen Volltexten
  (Code unter AGPL-3.0, Inhalte unter CC BY-SA 4.0) + /lizenz/-Übersicht
- /spenden/ mit LOGO-OB und Hinweis, wofür Spenden verwendet würden

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 04:46:28 +02:00
karim 822266be6c header: Wortmark auf finales LOGO-OB.svg (Pfade) umgestellt
- LOGO-OB.svg: Honk-Schriftzug in Kurven umgewandelt (10 <path>, keine
  Font-Referenz) → rendert korrekt als Hintergrundbild, hellgrau auf der
  schwarzen Masthead. Hintere Rechtecke füllen die Buchstaben-Punzen hellgrau.
- ungenutzte logo-ob2.svg (lebender Text) entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:39:01 +02:00
karim 58ede9005d ui: Journal als Masonry (unabhängige Spalten) + Wortmark auf eigenes SVG
- Journal-Startseite: .journal-list von Grid auf Multi-Column umgestellt —
  linke/rechte Spalte packen unabhängig (keine gemeinsame Zeilenhöhe mehr),
  break-inside:avoid hält Karten zusammen. Mobil: eine Spalte.
- Wortmark: eigenes logo-ob2.svg als Hintergrund (Honk-Webfont wieder raus).
  Hinweis: SVG enthält noch lebenden Honk-Text — für korrektes Rendern als
  Hintergrundbild müssen die Buchstaben in Kurven umgewandelt werden.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:33:21 +02:00
karim c2dd9d3ffb ui: Journal-Karten kompakt (kein Höhen-Stretch) + Wortmark „openbureau" in Honk
Journal-Startseite:
- Karten-Kontext (.journal-list) nimmt die alten Vollflächen-Panel-Regeln
  zurück: kompakte 2-Spalten-Karten statt min-height:70vh + Hero-Padding.
- Cover-Bild als 16/9-Block OBEN statt absolutem Overlay; Text darunter,
  linksbündig, dunkle Schrift (kein Weiß-auf-Bild mehr).
- align-items:start → keine Zeilen-Stretchung; ein höherer Eintrag zieht den
  Nachbarn nicht mehr mit.

Header:
- Wortmark ist jetzt sichtbarer Kleintext „openbureau" in Honk
  (expressive COLRv1-Color-Font), self-gehostet als static/fonts/honk-latin.woff2
  + @font-face. Ersetzt das bisherige logo.svg-Hintergrundbild.

Beides nur per API/Build geprüft, nicht visuell (kein Headless-Browser).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:03:20 +02:00
karim 70a6798404 cms: Schema-Migration bei jedem up nachziehen (forums/threads in Bestands-DB)
Problem: Init-Scripts (docker-entrypoint-initdb.d) laufen nur beim allerersten
DB-Start. Neue Tabellen aus schema.sql (forums/threads) landeten daher nicht in
einer bereits initialisierten Produktiv-DB → "relation public.forums does not
exist" auf der Dialog-Seite.

- docker-compose.yml: neuer einmaliger `migrate`-Service spielt das idempotente
  schema.sql als supabase_admin (Superuser, keine Owner-Konflikte) bei jedem
  `up` ein und lädt den PostgREST-Cache neu; cms wartet via
  service_completed_successfully darauf.
- routes/dialog.js: fehlende Tabelle führt nicht mehr zu rohem SQL-Fehler —
  leere Liste + server-seitiges Log statt 500 auf der Seite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:50:07 +02:00
karim bd85570259 dialog: Diskussionsplattform mit Foren, Rollen & Moderation + RLS-Fix
Auth/RLS-Fix (Schreiben gab 400):
- supabase.js: eigener supabaseAuth-Client für Login/Token-Check, damit
  signInWithPassword den Service-Daten-Client nicht prozessweit aufs
  User-Token umstellt (sonst lief insert als role=authenticated → RLS-Block).

Rollen (admin > editor > user):
- auth.js: roleOf() aus app_metadata.role + ADMIN_EMAILS, requireModerator.
- users.js: Rolle anzeigen/setzen über GoTrue app_metadata; .env-Admins fix.

Datenmodell (schema.sql):
- forums (Kategorien) + threads; Seed Allgemein/Projekte/Technik/Off-Topic
  und Sonder-Kategorie Beiträge. Library-Beiträge werden als Threads
  gespiegelt (dialog-store.syncLibrary).

API (routes/dialog.js, dialog-store.js):
- öffentlich: /api/forums, /api/forums/:slug, /api/recent, /api/thread
- eingeloggt: POST /api/threads (Thread starten, nur in Foren)
- Moderation: /api/mod/* (sperren/ausblenden), Admin: /api/admin/forums CRUD
- comments: Lock-Prüfung beim Schreiben, Moderation darf jede löschen.

Frontend:
- static/dialog.js: Router (Übersicht-Split-View | Forum | Thread),
  neuer Thread, Mod-Leiste, subtiles Login (dezente Zeile statt Formular).
- Admin-UI: Tabs Foren + Moderation, Rollen-Dropdown bei Autor:innen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:09:19 +02:00
karim 2749451107 cms/Dockerfile: TARGETARCH von BuildKit setzen lassen (kein fester amd64-Default)
Behebt arm64-Builds (z.B. Apple Silicon): vorher wurde immer amd64-Hugo
gezogen und scheiterte unter QEMU. amd64-LXC bleibt unverändert (auto).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:50:23 +02:00
karim 70341e71b1 journal: 2-Spalten-Karten-Raster statt Republik-Einspalter
- volle Breite (aus 72ch ausgebrochen), 2 Spalten, durchgehend 10px Abstände
  (zwischen Karten + zum Rand), mobil 1-spaltig
- Einträge als Karten (Hintergrund, Radius, Padding), linksbündig statt zentriert
- Cover-Bilder im 16:9-Format mit runden Ecken
- Snapshot des vorherigen Standes: Tag snapshot-journal-1col

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:43:36 +02:00
karim cc9d0a6855 footer: schwarz bis ganz unten (Body-Bottom-Padding raus, min-height:100vh) + Forum→Dialog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:39:56 +02:00
125 changed files with 8392 additions and 1309 deletions
+5
View File
@@ -11,6 +11,8 @@ hugo_stats.json
# Node (CMS) # Node (CMS)
node_modules/ node_modules/
# Admin-SPA Build-Output (wird im Container gebaut)
cms/admin/dist/
# Editors # Editors
.vscode/ .vscode/
@@ -22,3 +24,6 @@ node_modules/
.env .env
.env.local .env.local
hugo.local.yaml hugo.local.yaml
# DB-Backups (Dialog-Daten-Dumps)
backups/
+6
View File
@@ -0,0 +1,6 @@
---
title: "{{ replace .File.ContentBaseName `-` ` ` | title }}"
group: "Allgemein"
summary: ""
toc: false
---
+733 -155
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -20,7 +20,13 @@ API_EXTERNAL_URL=http://localhost:8000
# unter `authors` steht. # unter `authors` steht.
ADMIN_EMAILS=karim@gabrielevarano.ch ADMIN_EMAILS=karim@gabrielevarano.ch
# ═══ Optional: Ports ═══ # ═══ Optional: Ports & Binding ═══
# Auf welcher Host-Adresse lauschen die veröffentlichten Ports?
# 127.0.0.1 (Standard) = nur lokal / hinter Reverse-Proxy mit TLS (empfohlen).
# 0.0.0.0 = direkt im LAN erreichbar (ohne Proxy).
# Bei 127.0.0.1 muss SITE_URL/API_EXTERNAL_URL über den Proxy laufen, sonst
# erreicht der Browser :8000/:8080 nicht.
BIND_ADDR=127.0.0.1
APP_PORT=8080 # CMS: Site + /admin + /_preview + /api APP_PORT=8080 # CMS: Site + /admin + /_preview + /api
KONG_HTTP_PORT=8000 # Supabase-API-Gateway KONG_HTTP_PORT=8000 # Supabase-API-Gateway
KONG_HTTPS_PORT=8443 KONG_HTTPS_PORT=8443
+98 -2
View File
@@ -66,14 +66,37 @@ bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/cms/proxm
Fragt interaktiv nur Storage/Bridge/IP ab (Enter = Default). Kein Token nötig. Fragt interaktiv nur Storage/Bridge/IP ab (Enter = Default). Kein Token nötig.
`GIT_TOKEN` nur setzen, wenn das CMS per `GIT_PUBLISH` nach Gitea zurückschreiben soll. `GIT_TOKEN` nur setzen, wenn das CMS per `GIT_PUBLISH` nach Gitea zurückschreiben soll.
Alle CONFIG-Werte sind auch per **Umgebungsvariable** überschreibbar (für
non-interaktiv/SSH) — `ROOTFS_STORAGE` (z.B. `local-zfs`), `HOSTNAME`, `CTID`,
`IP`, `GATEWAY`, `DISK_GB`, `RAM_MB`, `CORES`, `BRIDGE`. Mit **`SITE_DOMAIN`**
wird der Stack direkt für eine öffentliche HTTPS-Domain hinter einem
Reverse-Proxy konfiguriert (Same-Origin, Pfad-Routing der Supabase-API).
Beispiel + Caddy-Block: siehe [`proxmox/README.md`](../proxmox/README.md).
### Updaten (bestehender LXC)
Nicht `git pull` von Hand — das vergisst CORS-Origin (kong.yml), Dateirechte
(non-root) und den Neustart. Stattdessen im Container:
```bash
bash /opt/openbureau/cms/update.sh
```
Das macht: `git pull` → CORS-Origin aus `SITE_URL` in `kong.yml` rendern →
`chown -R 1000:1000``docker compose up -d --build` + kong neu laden →
Healthcheck. (Beim allerersten Mal das Skript per einmaligem `git pull` holen.)
### Manuell (oder im Container) ### Manuell (oder im Container)
1. `cp .env.example .env` 1. `cp .env.example .env`
2. `POSTGRES_PASSWORD` + `JWT_SECRET` setzen: je `openssl rand -hex 32` 2. `POSTGRES_PASSWORD` + `JWT_SECRET` setzen: je `openssl rand -hex 32`
3. Keys ableiten: `node scripts/generate-keys.mjs``ANON_KEY` + `SERVICE_ROLE_KEY` in `.env` 3. Keys ableiten: `node scripts/generate-keys.mjs``ANON_KEY` + `SERVICE_ROLE_KEY` in `.env`
4. `SITE_URL` + `API_EXTERNAL_URL` auf die LAN-/Domain-Adresse setzen 4. `SITE_URL` + `API_EXTERNAL_URL` auf die LAN-/Domain-Adresse setzen
5. `docker compose up -d --build` (Erststart: DB bootet + Schema/Migrations) 5. `kong.yml`: Platzhalter `__CORS_ORIGIN__` durch `SITE_URL` (Browser-Origin) ersetzen
6. Login-User anlegen (Self-Signup ist aus): 6. `BIND_ADDR` in `.env`: `127.0.0.1` hinter Reverse-Proxy (Standard), `0.0.0.0` für LAN-Direktzugriff
7. Repo dem Container-User (uid 1000) übereignen: `chown -R 1000:1000 <repo-root>`
8. `docker compose up -d --build` (Erststart: DB bootet + Schema/Migrations)
9. Login-User anlegen (Self-Signup ist aus):
``` ```
source .env source .env
curl -X POST "$API_EXTERNAL_URL/auth/v1/admin/users" \ curl -X POST "$API_EXTERNAL_URL/auth/v1/admin/users" \
@@ -89,6 +112,79 @@ Dann: Admin `…:8080/admin/` · Live `…:8080/` · Preview `…:8080/_preview/
`cd admin && npm install && npm run dev` (Vite-Devserver, proxyt `/api` + `cd admin && npm install && npm run dev` (Vite-Devserver, proxyt `/api` +
`/_preview` an den laufenden Container auf :8080). `/_preview` an den laufenden Container auf :8080).
Tests der API (ohne DB/Container, reine Logik): `cd api && npm test`
(`node --test` — Pfad-Sicherheit, Rollen/Auth, Rate-Limit, Build-Coalescing).
### Demo-Inhalt fürs Forum (optional)
`db/seed-demo.sql` füllt die Forum-Kategorien mit ein paar Beispiel-Threads und
-Wortmeldungen — bewusst **getrennt** von der Migration, damit die Produktion
leer startet. Bei Bedarf manuell einspielen (idempotent, mehrfach lauffähig):
```bash
docker compose exec -T db psql -U supabase_admin -d postgres < db/seed-demo.sql
```
Wieder entfernen: DELETE-Block am Ende der Datei (auskommentiert).
### Backup der Dialog-Daten
⚠️ Foren, Threads und Wortmeldungen liegen **nur in Postgres** — anders als
`content/*.md` (in Git) sind sie sonst nirgends gesichert. Das Proxmox-Script
richtet ein **tägliches** Backup ein (`/etc/cron.d/openbureau-backup`, 3:15 Uhr).
Manuell/sonst:
```bash
bash scripts/backup-db.sh # → backups/openbureau-<TS>.sql.gz (rotiert, 14 Stk.)
```
Wiederherstellen:
```bash
gunzip -c backups/openbureau-<TS>.sql.gz \
| docker compose exec -T db psql -U supabase_admin -d postgres
```
## Sicherheit / Härtung
Eingebaute Schutzmaßnahmen (Stand: Härtungs-Pass):
- **Sicherheits-Header** auf allen Antworten (X-Frame-Options, nosniff,
Referrer-Policy, HSTS); Uploads unter `/images/*` mit strikter CSP +
`sandbox` → ein bösartiges SVG kann kein JavaScript im Origin ausführen.
- **Rate-Limit** auf `/api/auth/login` (10 Versuche/IP pro 5 Min) gegen Brute-Force.
- **Body-Limit** 256 KB auf JSON-`/api/*`, Bild-Upload max. 8 MB mit
Format-Verifikation (sharp-Metadaten bzw. SVG/GIF-Signatur).
- **Comment-Limits** (Body ≤ 10 000 Zeichen) gegen DB-Bloat.
- **Kein Info-Leak**: rohe DB-Fehler werden serverseitig geloggt, nach außen
nur generische Meldungen.
- **Non-root**: der CMS-Container läuft als `node` (uid 1000).
- **Port-Binding** über `BIND_ADDR` (Standard `127.0.0.1`), DB nur auf localhost.
- **CORS** am Kong-Gateway auf die eigene `SITE_URL`-Origin beschränkt (kein `*`).
- **Reverse-Proxy nur `/auth/*`**: bei einem Domain-Deploy gehört nur das Login
(GoTrue) public — `/rest`, `/storage`, `/realtime` nicht durchreichen (PostgREST
`/rest/v1/` würde sonst die DB-Schema-Beschreibung preisgeben). Siehe Caddy-Block
in [`../proxmox/README.md`](../proxmox/README.md).
- **Login-Rate-Limit** an GoTrue (`GOTRUE_RATE_LIMIT_TOKEN_REFRESH`), weil das
öffentliche Login direkt aufs `/token` geht (nicht übers Node-Limit).
- **Keine Tabellenrechte für `anon`/`authenticated`** (`revoke` in `db/schema.sql`):
RLS bleibt so auch bei künftigen Policies dicht; nur `service_role` (Node) liest.
### Migration eines bestehenden Containers
Bei `git pull` auf einer schon laufenden Instanz greifen drei Änderungen, die
sonst einen frischen Deploy voraussetzen — **vor** dem nächsten
`docker compose up -d --build` von Hand nachziehen:
1. **Non-root:** `chown -R 1000:1000 <repo-root>` — sonst kann Hugo `public/`
nicht mehr bauen (Permission denied).
2. **CORS:** `kong.yml` enthält jetzt `__CORS_ORIGIN__`; auf einem bereits
initialisierten Container ersetzt das Proxmox-Script den Platzhalter nicht.
Manuell auf die `SITE_URL` setzen, sonst werden alle Browser-API-Calls
(inkl. Login) per CORS geblockt.
3. **BIND_ADDR:** Key in `.env` ergänzen. Default `127.0.0.1` ist hinter einem
TLS-Proxy korrekt; für LAN-Direktzugriff `0.0.0.0` setzen.
## API ## API
Alle `/api/*` (ausser `/health`) verlangen `Authorization: Bearer <supabase-token>`. Alle `/api/*` (ausser `/health`) verlangen `Authorization: Bearer <supabase-token>`.
-482
View File
@@ -1,482 +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', 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: '', 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: [], 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">
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</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 === 'profile' ? (
<Profile onMsg={setMsg} />
) : view === 'users' ? (
<Users onMsg={setMsg} currentEmail={me?.email} />
) : (
<>
<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', '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 });
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 '';
return data.type === 'beitrag' ? `library/${data.section}/${slug}.md` : `${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="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">
<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>
);
}
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
function Users({ onMsg, currentEmail }) {
const [list, setList] = useState(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
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); onMsg({ type: 'ok', text: 'Autor:in angelegt.' }); setEmail(''); setPassword(''); refresh(); }
catch (err) { onMsg({ type: 'err', text: err.message }); }
finally { setBusy(false); }
}
async function remove(u) {
if (!confirm(`${u.email} löschen?`)) return;
try { await api.deleteUser(u.id); refresh(); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
async function reset(u) {
const pw = prompt(`Neues Passwort für ${u.email}:`);
if (!pw) return;
try { await api.setPassword(u.id, pw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
}
if (!list) return <div className="empty"></div>;
return (
<div className="profile">
<div className="profile-card">
<h2>Autor:innen</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 />
<button className="primary" disabled={busy}>Anlegen</button>
</form>
<ul className="userlist">
{list.map((u) => (
<li key={u.id}>
<span className="t">{u.email}{u.isAdmin && <span className="status live">Admin</span>}</span>
<button onClick={() => reset(u)}>Passwort</button>
{u.email !== currentEmail && <button onClick={() => remove(u)}>Löschen</button>}
</li>
))}
</ul>
<p className="muted who-mail">Admin-Rechte werden über <code>ADMIN_EMAILS</code> in der .env vergeben (nicht hier).</p>
</div>
</div>
);
}
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
function fromRead(r) {
const fm = r.frontmatter || {};
return {
isNew: false, path: r.path, type: 'beitrag', 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 || ''),
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.toc) fm.toc = true;
if (f.draft) fm.draft = true;
return fm;
}
-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);
-27
View File
@@ -1,27 +0,0 @@
import { supabase } from './supabase.js';
// Admins aus der .env (ADMIN_EMAILS=a@x,b@y). Admins sehen/bearbeiten alles.
const ADMINS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
// Verifiziert den Supabase-Access-Token und legt user/email/isAdmin 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 { data, error } = await supabase.auth.getUser(token);
if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401);
const email = (data.user.email || '').toLowerCase();
c.set('user', data.user);
c.set('email', email);
c.set('isAdmin', ADMINS.includes(email));
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();
}
-66
View File
@@ -1,66 +0,0 @@
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
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 { listComments, listThreads, createComment, deleteComment, login } from './routes/comments.js';
import { requireAuth } from './auth.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();
// --- API ---
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/threads', listThreads);
app.post('/api/auth/login', login);
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
app.use('/api/*', requireAuth);
app.get('/api/me', (c) => c.json({ email: c.get('email'), isAdmin: c.get('isAdmin') }));
app.post('/api/comments', createComment);
app.delete('/api/comments/:id', deleteComment);
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);
// --- 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`);
});
-54
View File
@@ -1,54 +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).
const SITE_DIR = process.env.SITE_DIR || '/site';
const PASSTHROUGH = new Set(['.svg', '.gif']);
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);
const dir = path.join(SITE_DIR, 'static', 'images');
await mkdir(dir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
const ext = path.extname(file.name || '').toLowerCase();
const base = `${safeBase(file.name)}-${uid()}`;
let outName, outBuf;
if (PASSTHROUGH.has(ext)) {
outName = `${base}${ext}`;
outBuf = buffer;
} else {
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;
-46
View File
@@ -1,46 +0,0 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { requireAdmin } 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) => ({
id: u.id,
email: u.email,
created_at: u.created_at,
isAdmin: ADMINS.includes((u.email || '').toLowerCase()),
}));
return c.json(list);
});
users.post('/', async (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 supabase.auth.admin.createUser({ email, password, email_confirm: true });
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 } = await c.req.json();
if (!password) return c.json({ error: 'Passwort nötig' }, 400);
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), { password });
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;
-14
View File
@@ -1,14 +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);
}
// Service-Role-Key: server-seitig, umgeht RLS. Niemals ins Frontend geben.
export const supabase = createClient(url, key, {
auth: { persistSession: false, autoRefreshToken: false },
});
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.env
.env.*
*.log
.DS_Store
# build output
admin/dist/
+202
View File
@@ -0,0 +1,202 @@
# openbureau-core — handover
## For a fresh instance — START HERE
- **Repo (local):** cloned at `/home/karim/openbureau-core`. Remote
`git.openbureau.ch/karim/openbureau-core` is **private**; auth via an HTTPS token
in `~/.git-credentials` (the SSH key `id_ed25519_openbureau` is currently rejected
by gitea/proxmox — server seems rebuilt; fix the key or keep using the token).
- **Where the work is:** branch **`stufe-2-5-scaffold`** (pushed, NOT merged). `main`
is still the pre-stage-2 foundation. Open a PR or merge when happy.
- **Done:** stages 14 complete, stage 5 *scaffolded*. See "Migration stages" below
(markers `[done]`/`[scaffold]`). New code: `api/src/collections.js`,
`api/src/plugin-manager.js`, `api/src/plugins/dialog/index.js` + their tests.
- **Run tests:** `cd api && npm install && node --test`**44 green**. `node_modules`
is git-ignored; the fresh clone needs `npm install` once.
- **Status:** stages 19 DONE; openbureau + kgva BOTH LIVE on core. openbureau on dev CT134 (schema editor + supabase); kgva LIVE at karimgabrielevarano.xyz via core (Variant A: CMS serves the site; CT130 rebuilt in-place — Docker + kgva-cms on :8081, nginx :80 proxies to it and keeps /media, static timer disabled, Caddy untouched). CAVEAT: kgva GIT_PUBLISH=false → edits on CT130 disk, not git-backed (enable GIT_PUBLISH + creds to fix). Rollback for kgva: restore nginx .static.bak + re-enable kgva-deploy.timer + stop kgva-cms. Original stage-5 note kept below for history.
- **Immediate next action (historic):** stage 5 + 5.5 **DONE** on the branch (commits `c4230a4`
cutover, `5e06337` DDL, `a93d11a` migration runner). Plugin-manager singleton
(`api/src/plugins.js`) wired into `index.js`/`stats.js`/`publish.js`, hard-coded
dialog removed, source moved under `api/src/plugins/dialog/`; DDL captured into
`plugins/dialog/migrations/001_dialog.sql` (idempotent); `api/src/migrate.js`
applies migrations once via `schema_migrations` at boot. **53 tests green** + a real
boot. **Live-verified on the dev stack** (CT 134): a 2nd cms instance from
`cms-cms:latest` (my `src` mounted over `/app/src`, real Supabase via `kong:8000`,
empty SITE_DIR → no writes) returned **byte-identical** `/api/forums` & `/api/recent`
vs the running container, `/api/content` 401 on both; the runner's tracking SQL was
proven on a throwaway DB. **NOT deployed** (the running stack still runs old code).
NEXT (stage 8): deploy core to openbureau — needs `DATABASE_URL` set for the cms
service (e.g. `postgres://postgres:$POSTGRES_PASSWORD@db:5432/postgres`) so the
runner can apply migrations, then full login/edit/publish verify before cutting the
live container over. Then stages 6 (admin `/api/schema`), 7 (local auth), 9 (kgva).
Dev stack: Proxmox CT 134 `openbureau-dev`, repo `/opt/openbureau`, compose
`cms/docker-compose.yml`, containers openbureau-{cms,auth,kong,rest,db}, dev URL
dev.openbureau.ch. **Do NOT push a blind refactor to prod.**
- **Env gotcha:** editing a file that's open in Karim's VSCode makes the Edit/Write
tool hang/"interrupt" — write such files via Bash (`cat > f <<'EOF' … EOF`) instead.
## Goal
Extract a generic, **schema-driven** Hugo CMS engine ("openbureau-core") from the
openbureau site's bundled CMS, so that **both** openbureau and
karimgabrielevarano.xyz (kgva) consume it as a dependency. Decision: own repo
(this one), and migrate openbureau to depend on it.
## State (done this session)
- Repo `karim/openbureau-core` created on Gitea, foundation pushed.
- `api/` = engine copied verbatim from `karim/OPENBUREAU``cms/api` (Hono/Node).
- `admin/` = React/Vite SPA copied verbatim from `OPENBUREAU/cms/admin`.
- `api/src/config.js` = NEW loader: reads `CMS_CONFIG` → a site config module.
- `examples/openbureau.config.js`, `examples/kgva.config.js` = target schemas.
- `README.md` = config API + plugin model + the migration checklist.
- Nothing in the live openbureau CMS was changed yet.
## Engine is ~80% generic. openbureau-specific seams to make config-driven:
1. [DONE] `api/src/files.js` `classify(rel)` + the `order` map — now derived from
`config.collections` via `api/src/collections.js` (classify/buildPath/compareEntries);
`buildPath` exposed there too.
2. [DONE] `api/src/routes/stats.js` — now per-collection (`statKey`/`draftStatKey`),
dialog counts gated by `hasPlugin('dialog')`.
3. `api/src/index.js` — always mounts dialog routes + runs `syncLibrary` on boot.
→ only when `config.plugins` includes `dialog`.
4. `api/src/routes/publish.js` — calls `syncLibrary`. → gate by plugin.
5. `admin/src/App.jsx` (714 lines) — hard-codes `SECTIONS`, `KIND_LABEL`, the field
set, type dropdown, `buildPath`. → render sidebar groups + editor fields from the
schema (add `GET /api/schema` returning `config.collections`).
6. dialog subsystem (`dialog-store.js`, `routes/dialog.js`, `routes/comments.js`
+ Supabase tables forums/threads/comments + library↔thread sync) = the `dialog`
**plugin** (openbureau on, kgva off). First gate by config, later move to
`api/src/plugins/dialog/`.
## CRITICAL
api and admin share a data contract (stats keys, field shapes). Generalising the
**backend alone breaks the live openbureau admin** — they must change together and
be tested on the live Supabase/Hugo stack. Do NOT push a blind half-refactor to
production openbureau.
## Plugin system (decided architecture)
Everything beyond the generic engine is a **plugin**; the engine gets a small
**plugin manager**. A plugin = `api/src/plugins/<name>/index.js` exporting a
manifest `{ name, routes:[{path,app,public?,admin?}], onBoot, onPublish, onPreview,
migrations:[…], stats, admin:{…} }`. The manager reads `config.plugins`, imports
each, mounts routes in the right auth tier (public before `requireAuth`, rest
after, `admin:true` behind `requireAdmin`), registers boot/publish/preview/stats
hooks, runs migrations; the admin SPA loads UI from `admin/src/plugins/<name>`.
openbureau's extras become plugins (first: `dialog`); kgva enables none.
(Full spec in README → "Plugins".)
## Migration stages (also README checklist)
1. [done] repo + engine + config loader + example configs.
2. [done] files.js classify/order/buildPath ← config.collections
(new `api/src/collections.js` = pure classify/buildPath/compareEntries/contentStats;
files.js uses it, loads config lazily in listEntries; `api/test/collections.test.js`
proves 1:1 vs openbureau.config.js). All api tests green.
3. [done] stats.js ← collections: `content` via `statKey` + `draftStatKey`
(openbureau beitrag got `draftStatKey: 'entwuerfe'`), dialog counts gated by
`hasPlugin('dialog')` (pluginless site does zero DB reads here).
4. [built, not wired] **plugin manager**`api/src/plugin-manager.js`
(`loadPlugins(names)` imports `plugins/<name>/index.js`; `createManager(manifests)`
= pure wiring: `mountPublic`/`mountPrivate(+requireAdmin)`, `runBoot/runPublish/
runPreview`, `collectStats` (merged under plugin name), `migrations()` (resolves
file URLs; execution deferred to the live DB)). Unit-tested in
`api/test/plugin-manager.test.js` (tiers/hooks/stats/migrations). TODO: wire into
index.js (stage 5), and admin loads plugin UI from `admin/src/plugins/<name>`.
5. [DONE — commits c4230a4 (cutover) + 5e06337 (DDL), live e2e verified] extract openbureau extras
into the **dialog** plugin. DONE: `api/src/plugins/dialog/index.js` = manifest
wrapping the existing modules unchanged (public reads + widget login(rate-limited)
+ authed writes + self-guarding mod/adminForums sub-apps; onBoot/onPublish =
syncLibrary; stats = forum/thread/comment counts). CUTOVER DONE: shared manager
singleton `api/src/plugins.js` wired into index.js (mountPublic before requireAuth,
mountPrivate(+requireAdmin) after, manager.runBoot at serve()), publish.js
(manager.runPublish) and stats.js (manager.collectStats, `{content,users,dialog}`
contract preserved); hard-coded dialog REMOVED from all three; dialog source
physically moved to `plugins/dialog/{dialog-store,dialog,comments}.js` (imports
fixed). Tested: `api/test/{dialog-plugin,cutover}.test.js`, 48 green + real boot.
DDL CAPTURED (commit 5e06337): `migrations/001_dialog.sql` from the live dev DB,
idempotent, validated on a throwaway DB; manifest `migrations: ['001_dialog.sql']`.
LIVE E2E VERIFIED (dev CT 134): 2nd cms instance from cms-cms:latest with my src
mounted, real Supabase via kong:8000 — /api/forums & /api/recent byte-identical to
the running container, /api/content 401 both, no writes. Migration runner = stage
5.5 (commit a93d11a, see new item below). Deploy is stage 8.
NB: dialog-store.js still filters `e.kind === 'beitrag'` — openbureau-specific,
already moved with the plugin.
5.5 [done, commit a93d11a] migration runner — `api/src/migrate.js` `runMigrations`
tracks applied migrations in `public.schema_migrations` and applies each declared
file once, in its own transaction, at boot (wired into index.js before runBoot).
Lean: no declared migrations → no DB connection, no pg import (kgva stays DB-less).
Executor is injectable (unit-tested without a DB); default uses a lazily-imported
`pg` against `DATABASE_URL`, warning-and-skipping if unset. Needs `DATABASE_URL`
wired into the cms service env for openbureau (stage 8). Tested: migrate.test.js +
tracking SQL proven on the live dev Postgres (throwaway DB).
6. [DONE, commits f709b5d + f8f4131 + 59b9e20] schema-driven admin. `GET /api/schema`
+ `GET /api/system` + a System panel; AND the editor now renders sidebar groups +
fields from the site's collections (admin/src/fields.jsx: per-type controls
string/text/slug/number/date/bool/select/list/image/color/markdown; form↔frontmatter
+ path building). One admin serves any site. Unknown frontmatter is PRESERVED on
save (__raw) — no data loss. Verified working via kgva (stage 9). NOTE: openbureau
is NOT yet redeployed with the schema editor (its running image keeps the bespoke
one); when it is, its config field types (color→swatch, layout opts) are already
tuned. Visual review of the openbureau editor by Karim still recommended before
redeploying it.
7. [DONE, commit 86f9f57] **auth provider** (`config.auth` = `supabase` | `local`).
`api/src/auth-local.js` = file-based users (bcryptjs) + self-signed HS256 JWTs of
the SAME claim shape; `routes/auth.js` POST /api/auth/login (mounted only when
auth:local); supabase.js made null-safe (fail-fast moved to index.js); auth/stats/
users.js branch local↔GoTrue; admin supabase.js gains a local-auth SHIM (same
interface) when VITE_SUPABASE_URL is absent → App/api untouched, supabase path
byte-identical. examples/kgva.config.js → auth:'local'. Verified: 59 tests +
DB-less boot smoke (no Supabase: local login → /api/me admin → stats-from-file)
+ openbureau redeployed and confirmed unregressed (auth=supabase).
8. [DONE, OB main 3d902a0] openbureau consumes core. Mechanism: **git subtree** at
`OPENBUREAU/cms/core` (fits the pull-based deploy; update via
`git subtree pull --prefix=cms/core <core> main --squash`). docker-compose builds
the cms image from `./core`; `cms/openbureau.config.js` (CMS_CONFIG) carries the
content model; old vendored cms/api+cms/admin removed. DATABASE_URL left unset (the
stack's migrate service owns db/schema.sql incl. dialog). Deployed to dev CT 134
and FULL write-verified: login/list/edit/preview/publish/dialog all identical, no
git divergence (GIT_PUBLISH=false). Rollback image `cms-cms:rollback` kept.
9. [DONE — LIVE on core (Variant A)] kgva consumes core. In
`karim/kgva` (git.kgva.ch, main 51a94b8): `cms/core` = core subtree, `cms/kgva.config.js`
(project/page/section, auth:'local', no plugins), `docker-compose.yml` builds the core
image (Hugo 0.163.3, no VITE_SUPABASE_URL → local-auth admin), `cms/seed-admin.mjs`,
updated deploy/provision-lxc.sh + ADMIN.md. VERIFIED DB-less end-to-end on real content
(staged on CT134): local login → schema editor → 42 entries (de/en) → stats →
create/preview/cleanup; image galleries preserved. The LIVE site is UNCHANGED — CT130
still deploys statically (timer → `hugo` → nginx; hugo ignores cms/); the CMS is dormant.
PENDING (deliberate, his call — repoints a live domain): provision a kgva-core container
(deploy/provision-lxc.sh, free CTID + temp IP) and decide topology — CMS serves the site
(Caddy → it) OR CMS edits + GIT_PUBLISH=true commits to git and the CT130 static timer
keeps serving. Then Caddy cutover per deploy/README. openbureau also not yet redeployed
with the schema editor (stage 6 note).
## How to run / test
openbureau CMS stack: `OPENBUREAU/cms/docker-compose.yml` (Node api + Hugo +
Supabase: postgres/kong/gotrue). Env in `cms/.env` (see `.env.example`):
SUPABASE_URL/SERVICE_KEY, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY (derive via
`scripts/generate-keys.mjs`), ADMIN_EMAILS, SITE_URL, GIT_*. For core add
`CMS_CONFIG` (path to the site config) and `SITE_DIR` (site repo mount, default
`/site`). Admin at `/admin`, preview `/_preview`, publish builds `public/`.
## Infra access (NO secrets in this file)
- Proxmox node `192.168.1.2`, user `root`. Password: **ask the user** (provided ad
hoc, not stored). No SSH key installed — use SSH_ASKPASS, or install a key first.
- Gitea = container 120 (`pct exec 120 …`), ROOT_URL `git.openbureau.ch`, sqlite at
`/var/lib/gitea/data/gitea.db`. Make a token:
`pct exec 120 -- su gitea -s /bin/bash -c "/usr/local/bin/gitea admin user
generate-access-token --username karim --scopes all --token-name X --raw
--config /etc/gitea/app.ini"`. Keep token `kgva-deploy`. Delete tokens via sqlite
(`DELETE FROM access_token WHERE name LIKE '…'`) since the API needs the account
password.
- Push without local git auth: tar → scp to node → `pct push 120` into the gitea
container → `git push http://karim:<token>@127.0.0.1:3000/karim/<repo>.git`.
- kgva site deploy: container 130 (kgva-website). nginx serves
`/var/www/karimgabrielevarano.xyz` (= ZFS `tank/kgva-website`, owned uid 100000).
systemd timer runs `/opt/kgva-deploy.sh` every 60s: pull `karim/kgva` (token
`kgva-deploy`) → `hugo` build → copy into webroot. So a push to `karim/kgva` is
live in ~1 min. Self-hosted video at `/var/www/media` (nginx `/media/` alias,
outside the build). Public TLS for the domain terminates upstream → 192.168.1.130:80.
## Watch out
- Gitea does NOT send CORS on its OAuth token endpoint → a browser git-CMS
(Decap-style) can't auth without a same-origin proxy. openbureau-core uses its own
Supabase auth, so this doesn't affect it; it's why kgva edits go via git push.
- Hugo versions: openbureau CMS bundles 0.161.1; the kgva site needs 0.163.3
features. Mind the version a core instance builds with.
+149
View File
@@ -0,0 +1,149 @@
# openbureau-core
A generic, schema-driven Hugo CMS engine, extracted from the openbureau site.
A site provides one config (collections + plugins); the core gives it an admin,
content CRUD over `content/**/*.md` (gray-matter), real Hugo preview/publish,
Supabase auth, uploads and git backup. openbureau and karimgabrielevarano.xyz
both consume it.
## How a site uses it
1. Point `CMS_CONFIG` at a config module (e.g. `/site/cms.config.js`).
2. Mount the site repo at `SITE_DIR` (default `/site`). Run the container.
```
SITE_DIR=/site
CMS_CONFIG=/site/cms.config.js
SUPABASE_URL=… SUPABASE_SERVICE_KEY=… JWT_SECRET=… ADMIN_EMAILS=…
```
## Config API
```js
export default {
site: 'name',
admins: ['you@example.com'], // bootstrap admins (env ADMIN_EMAILS still wins)
auth: 'supabase', // 'supabase' (GoTrue) | 'local' (file JWTs, no DB)
plugins: ['dialog'], // optional features (see Plugins)
collections: [
{
kind: 'project', // internal id
label: 'Portfolio', // shown in the admin
order: 0, // sort order in the list
path: 'portfolio/:slug', // file pattern under content/ (:params captured)
// index: true, // matches _index.md (a section)
// fallback: true, // matches anything not matched above
// sections: [...], // fixed choices for a :section param
statKey: 'projects', // key in the stats response
fields: [ // editor form, in order
{ name: 'title', type: 'string', required: true },
{ name: 'images', type: 'list', of: { src: 'image', name: 'string' } },
{ name: 'body', type: 'markdown' },
],
},
],
};
```
`path` patterns: literal segments must match; `:param` segments are captured
(the last one is the slug). `index: true``_index.md`. `fallback: true`
everything else. The engine derives content classification, the `buildPath`, the
list ordering, the stats counters and the admin editor from this.
Field `type`s: `string · text · markdown · slug · date · number · bool · select
(options) · image · list · list(of:{…})`.
## Plugins
Everything beyond the generic engine is a **plugin**. A site opts into plugins via
`plugins: [...]` in its config; core's plugin manager loads them and wires them in.
The engine stays lean; openbureau = core + its plugins + its config.
A plugin is a module at `api/src/plugins/<name>/index.js` exporting a manifest:
```js
export default {
name: 'dialog',
routes: [ // mounted under /api
{ path: '/forums', app: forums, public: true }, // skips requireAuth
{ path: '/comments', app: comments }, // auth required
{ path: '/admin/forums', app: adminForums, admin: true }, // admins only
],
onBoot: async (ctx) => {}, // run once at startup (e.g. syncLibrary)
onPublish: async (ctx, { path }) => {}, // hook after a publish build
onPreview: async (ctx, { path }) => {}, // hook after a preview build
migrations: ['001_forums.sql'], // applied to Postgres/Supabase on boot
stats: async (ctx) => ({ forums, threads, comments }), // merged into /api/stats
admin: { /* UI panels the admin SPA mounts for this plugin */ },
};
```
The **plugin manager** (core): reads `config.plugins`, imports each module, mounts
its routes in the right auth tier (public → before `requireAuth`, the rest after,
`admin: true` behind `requireAdmin`), registers its boot/publish/preview/stats
hooks, and runs its migrations. The admin SPA loads UI from
`admin/src/plugins/<name>` for enabled plugins only.
First plugin: **`dialog`** — the comment/forum subsystem (Supabase
`forums/threads/comments`, library↔thread sync, forum admin UI). openbureau enables
it; kgva doesn't, so none of its routes, hooks, tables or stats load there.
## Auth providers
Auth is a config seam (`auth: 'supabase' | 'local'`, default `supabase`) so a site
can run **without a database**. The engine already verifies tokens locally — HS256
against `JWT_SECRET`, no GoTrue roundtrip ([api/src/auth.js]) — so a provider only
has to *issue* tokens and *store users*.
- **`supabase`** (today): GoTrue logs the user in and is the user store; the engine
verifies the JWT. Needed when there are multiple editors or the `dialog` plugin
(its forums/threads/comments live in Postgres). This is the heavy path.
- **`local`**: file-based users (bcrypt hashes) + self-signed HS256 JWTs of the
**same claim shape** (`sub`, `email`, `app_metadata.role`, `exp`), so `requireAuth`
and roles are unchanged. No GoTrue, no Postgres, no PostgREST. A dialog-less site
(e.g. kgva, single admin) then runs as just **Node + Hugo + nginx** (~80 MB vs the
~12 GB Supabase suite). Trade-off: you own password hashing; you lose GoTrue's
password-reset / rate-limiting — fine for a single-admin CMS.
What a pluginless core needs from Supabase is *only* the auth half anyway: content
is Markdown on disk, uploads are local FS (`static/images`, via sharp), and
PostgREST/tables are a `dialog`-plugin need. `local` drops that half too.
## Architecture
- `api/` — Node engine (Hono): `auth` (Supabase JWT), `files` (gray-matter
CRUD), `hugo` (coalesced build / preview / publish + git), `routes/*`,
`ratelimit`, `config` (this loader).
- `admin/` — React/Vite SPA; renders list + editor from the config schema.
- `examples/``openbureau.config.js`, `kgva.config.js` (the two consumers).
## Status / migration stages
This repo currently holds the **engine as extracted** plus the config API and the
two target configs. Generalisation is staged because the api and admin share a
data contract and openbureau is in production — they must change together and be
tested on the live Supabase/Hugo stack, not flipped blind.
- [x] Repo, engine, config loader, example configs (openbureau + kgva)
- [x] `files.js` classify / order / buildPath driven by `collections`
(`api/src/collections.js` + tests; behaviour 1:1 vs openbureau.config.js)
- [x] `stats.js` counters driven by `collections` (`statKey` / `draftStatKey`),
dialog counts gated by `hasPlugin('dialog')`
- [~] **plugin manager** built + unit-tested (`api/src/plugin-manager.js`,
`api/test/plugin-manager.test.js`): loads `config.plugins`, mounts routes by
auth tier, runs boot/publish/preview hooks, merges stats, surfaces migrations.
NOT yet wired into `index.js` (lands with stage 5, needs the live stack);
admin plugin-UI loading still TODO
- [~] extract openbureau's extras into plugins — first **`dialog`**: manifest
scaffold at `api/src/plugins/dialog/index.js` wraps the existing dialog
modules (routes by tier, `syncLibrary` on boot/publish, forum counters as
`stats`), loaded + structurally tested (`api/test/dialog-plugin.test.js`).
TODO (live-tested): wire it into `index.js`, capture the Supabase DDL into
`migrations/001_dialog.sql`, move the dialog source under `plugins/dialog/`
- [ ] `index.js` / `publish.js`: no hard-coded dialog — everything via the manager
- [ ] admin: editor + sidebar groups rendered from the config schema
- [ ] **auth provider** (`supabase` | `local`): `local` = file users + self-signed
HS256 JWTs (same claim shape), no DB — lets a dialog-less core run Supabase-free
- [ ] cut openbureau over to consume core (= core + `dialog` plugin + its config),
behaviour identical — tested on the live stack
- [ ] onboard kgva as the second consumer (`auth: 'local'`, no plugins, DB-less)
+732
View File
@@ -0,0 +1,732 @@
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';
import {
Field, blankForm, formFromFrontmatter, frontmatterFromForm, targetPath,
isShort, isBody, hexOf, DEFAULT_PALETTE,
} from './fields.jsx';
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 [collections, setCollections] = useState(null);
const [plugins, setPlugins] = useState([]);
const [site, setSite] = 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(() => {});
api.schema().then((sc) => {
setCollections((sc.collections || []).slice().sort((a, b) => (a.order ?? 99) - (b.order ?? 99)));
setPlugins(sc.plugins || []); setSite(sc.site || '');
}).catch(() => setCollections([]));
}, []);
useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }, [msg]);
const cols = collections || [];
const hasDialog = plugins.includes('dialog');
const collectionOf = (kind) => cols.find((c) => c.kind === kind) || cols.find((c) => c.fallback) || cols[0] || null;
async function open(entry) {
const col = collectionOf(entry.kind);
try {
const r = await api.read(entry.path);
const bodyField = (col?.fields || []).find(isBody);
setCurrent({
isNew: false, kind: entry.kind, path: entry.path,
...formFromFrontmatter(r.frontmatter || {}, col || { fields: [] }),
...(bodyField ? { [bodyField.name]: r.body || '' } : {}),
});
} catch (err) { setMsg({ type: 'err', text: err.message }); }
}
function openNew() {
const col = cols[0];
if (col) setCurrent({ isNew: true, kind: col.kind, path: '', ...blankForm(col) });
}
const q = query.trim().toLowerCase();
const filtered = q ? entries.filter((e) => (e.title || '').toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
const fallbackKind = (cols.find((c) => c.fallback) || cols[0] || {}).kind;
const groups = {};
for (const c of cols) groups[c.kind] = [];
for (const e of filtered) { const k = groups[e.kind] ? e.kind : fallbackKind; if (groups[k]) groups[k].push(e); }
return (
<div className="app">
<header className="topbar">
<span className="logo">{(site || 'cms').toUpperCase()}</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 && hasDialog && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
{me?.isAdmin && hasDialog && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
{me?.isAdmin && <button className={view === 'system' ? 'active' : ''} onClick={() => setView('system')}>System</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} />
) : view === 'system' ? (
<System onMsg={setMsg} />
) : (
<>
<aside>
<button className="new" onClick={openNew} disabled={!cols.length}> Neu</button>
<div className="search"><span></span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
{cols.map((c) => groups[c.kind]?.length > 0 && (
<div className="group" key={c.kind}>
<div className="group-title">{c.label} <span>{groups[c.kind].length}</span></div>
<ul className="list">
{groups[c.kind].map((e) => (
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(e)}>
<span className="dot" style={{ background: e.color ? hexOf(DEFAULT_PALETTE, 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') + ':' + current.kind} initial={current} collections={cols}
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
: <div className="empty"><p>Wähle links einen Eintrag oder leg einen neuen an.</p></div>}
</main>
</>
)}
</div>
{msg && <div className={`toast ${msg.type}`} onClick={() => setMsg(null)}>{msg.text}</div>}
</div>
);
}
function Editor({ initial, collections, 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 cols = collections || [];
const collection = cols.find((c) => c.kind === f.kind) || cols[0] || { fields: [] };
const fields = collection.fields || [];
const titleField = fields.find((x) => x.name === 'title') || fields.find((x) => x.type === 'string' && x.required) || null;
const bodyField = fields.find(isBody);
const draftField = fields.find((x) => x.type === 'bool' && x.name === 'draft');
const shortFields = fields.filter((x) => x !== titleField && !isBody(x) && isShort(x));
const longFields = fields.filter((x) => x !== titleField && !isBody(x) && !isShort(x));
const isDraft = draftField ? !!f[draftField.name] : false;
const hasSlugField = fields.some((x) => x.name === 'slug');
const setField = (name, val) => setF((p) => ({ ...p, [name]: val }));
const upload = async (file) => (await api.upload(file)).url;
function changeKind(kind) {
const col = cols.find((c) => c.kind === kind);
if (col) setF({ isNew: true, kind, path: '', ...blankForm(col) });
}
// 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(); }
async function save(overrides = {}) {
const data = { ...f, ...overrides };
const path = data.isNew ? targetPath(data, collection) : data.path;
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug (und ggf. Pflichtsegmente wie Rubrik) angeben.' }); return null; }
if (titleField && !((data[titleField.name] || '').trim())) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
setBusy(true);
try {
const fm = frontmatterFromForm(data, collection);
const body = bodyField ? (data[bodyField.name] || '') : '';
await api.save(path, fm, body);
const r = await api.read(path);
const loaded = {
isNew: false, kind: data.kind, path,
...formFromFrontmatter(r.frontmatter || {}, collection),
...(bodyField ? { [bodyField.name]: r.body || '' } : {}),
};
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?')) return;
const path = await save(draftField ? { [draftField.name]: false } : {});
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" />
{draftField && (isDraft ? <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.kind} onChange={(e) => changeKind(e.target.value)}>
{cols.map((c) => <option key={c.kind} value={c.kind}>{c.label}</option>)}
</select>
</label>
{!hasSlugField && (
<label className="sm">Dateiname
<input value={f.slug || ''} onChange={(e) => setField('slug', e.target.value)} placeholder="z. B. impressum" />
</label>
)}
</div>
)}
{titleField && (
<label className="big">{titleField.label || 'Titel'}
<input value={f[titleField.name] || ''} onChange={(e) => setField(titleField.name, e.target.value)} placeholder="Titel" />
</label>
)}
{shortFields.length > 0 && (
<div className="meta">
{shortFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
</div>
)}
{longFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
{bodyField && (
<div className="rich">
<RichEditor value={f[bodyField.name] || ''} onChange={(body) => setField(bodyField.name, body)} onUpload={upload} />
</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>
<PasswordChange onMsg={onMsg} />
</div>
);
}
function PasswordChange({ onMsg }) {
const [cur, setCur] = useState('');
const [nxt, setNxt] = useState('');
const [busy, setBusy] = useState(false);
async function submit(e) {
e.preventDefault();
if (nxt.length < 6) { onMsg({ type: 'err', text: 'Neues Passwort zu kurz (min. 6 Zeichen).' }); return; }
setBusy(true);
try { await api.changePassword(cur, nxt); onMsg({ type: 'ok', text: 'Passwort geändert.' }); setCur(''); setNxt(''); }
catch (e) { onMsg({ type: 'err', text: e.message }); }
finally { setBusy(false); }
}
return (
<div className="profile-card">
<h2>Passwort ändern</h2>
<form onSubmit={submit}>
<label>Aktuelles Passwort<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></label>
<label>Neues Passwort<input type="password" value={nxt} onChange={(e) => setNxt(e.target.value)} autoComplete="new-password" /></label>
<div className="actions"><button className="primary" disabled={busy}>Passwort setzen</button></div>
</form>
</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 ?? 0}</span>
<span className="stat-label">{label}</span>
<span className="stat-hint">{hint || ' '}</span>
</button>
);
const content = s.content || {};
const d = s.dialog || { forums: 0, threads: 0, comments: 0 };
const hasDialog = (d.forums + d.threads + d.comments) > 0;
return (
<div className="overview">
<h2>Übersicht</h2>
<div className="stat-grid">
{Object.entries(content).map(([k, v]) => <Card key={k} label={k} value={v} to="content" />)}
<Card label="Autor:innen" value={s.users?.total} hint={`${s.users?.admin || 0} Admin · ${s.users?.editor || 0} Red.`} to="users" />
{hasDialog && <Card label="Foren" value={d.forums} to="forums" />}
{hasDialog && <Card label="Threads" value={d.threads} to="moderation" />}
{hasDialog && <Card label="Wortmeldungen" value={d.comments} to="moderation" />}
</div>
<div className="overview-actions">
<h3>Schnellzugriff</h3>
<div className="quick">
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
{hasDialog && <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>
</div>
</div>
</div>
);
}
// ── System (nur Admin): core-Version, Update-Check, Admins, Plugins, Modell ──
function System({ onMsg }) {
const [s, setS] = useState(null);
const [upd, setUpd] = useState(null);
useEffect(() => {
api.system().then(setS).catch((e) => onMsg({ type: 'err', text: e.message }));
api.systemUpdate().then(setUpd).catch(() => {});
}, []);
if (!s) return <div className="empty"></div>;
const badge = !upd ? null
: upd.available ? <span className="status draft">core v{upd.vendored} bereit Rebuild nötig</span>
: upd.vendored ? <span className="status live">aktuell</span>
: <span className="muted">(kein Subtree)</span>;
return (
<div className="profile">
<div className="profile-card wide">
<h2>System</h2>
<div className="sysgrid">
<div><span className="muted">Core</span><b>{s.core.name} <span className="ver">v{s.core.version}</span></b> {badge}</div>
<div><span className="muted">Site</span><b>{s.site || '—'}</b></div>
<div><span className="muted">Auth</span><b>{s.auth}</b></div>
<div><span className="muted">Hugo</span><b>{s.hugo}</b></div>
</div>
<h3>Admins <span className="count-pill">{(s.admins || []).length}</span></h3>
<ul className="syslist">{(s.admins || []).map((a) => (
<li key={a}><b>{a}</b><span className="muted">aus Config (fix)</span></li>
))}</ul>
<h3>Plugins <span className="count-pill">{s.plugins.length}</span></h3>
{s.plugins.length
? <ul className="syslist">{s.plugins.map((p) => (
<li key={p.name}><b>{p.name}</b><span className="muted">{p.routes} Routen · {p.migrations} Migration(en)</span></li>
))}</ul>
: <p className="muted">Keine Plugins aktiv.</p>}
<h3>Content-Modell <span className="count-pill">{s.collections.length}</span></h3>
<ul className="syslist">{s.collections.map((c) => (
<li key={c.kind}><b>{c.label}</b><span className="muted">{c.kind} · {c.fields} Felder{c.statKey ? ` · ${c.statKey}` : ''}</span></li>
))}</ul>
<p className="muted who-mail">openbureau-core fährt diese Site. Updates werden serverseitig eingespielt (Update-Skript).</p>
</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, '');
}
@@ -44,8 +44,26 @@ export const api = {
getProfile: () => call('/profile'), getProfile: () => call('/profile'),
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }), saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
getMe: () => call('/me'), getMe: () => call('/me'),
stats: () => call('/stats'),
system: () => call('/system'),
systemUpdate: () => call('/system/update'),
schema: () => call('/schema'),
changePassword: (current, next) => call('/account/password', { method: 'POST', body: JSON.stringify({ current, next }) }),
listUsers: () => call('/users'), listUsers: () => call('/users'),
createUser: (email, password) => call('/users', { method: 'POST', body: JSON.stringify({ email, password }) }), 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 }) }), 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' }), 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' }),
}; };
+154
View File
@@ -0,0 +1,154 @@
// Schema-driven form fields. The editor renders a collection's `fields` (from
// /api/schema) generically, so the SAME admin works for any site's content model
// (openbureau, kgva, …). Field types → controls; markdown ('body') is handled by
// the rich editor in App.jsx, not here.
//
// A field: { name, type, required?, options?, default?, hint?, palette? }
// Types: string | text | slug | number | date | bool | select | list | image |
// color | markdown (markdown rendered separately).
// openbureau's named colour palette — used for type:'color' when no per-field
// palette is given. Harmless elsewhere (sites without colour fields never see it).
export const DEFAULT_PALETTE = [
['', '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'],
];
export const hexOf = (palette, name) => (palette.find((c) => c[0] === name) || [])[2] || 'transparent';
const SHORT = new Set(['string', 'slug', 'number', 'date', 'bool', 'select', 'color']);
export const isShort = (f) => SHORT.has(f.type);
export const isBody = (f) => f.type === 'markdown';
// Empty form for a new entry of `collection`.
export function blankForm(collection) {
const f = { __raw: {} };
for (const fld of collection.fields || []) {
if (isBody(fld)) { f[fld.name] = fld.default ?? ''; continue; }
if (fld.type === 'bool') f[fld.name] = fld.default ?? false;
else if (fld.type === 'list') f[fld.name] = '';
else f[fld.name] = fld.default ?? '';
}
return f;
}
// Frontmatter (from disk) → form values, per field type. The full original
// frontmatter is stashed under __raw so unknown keys survive a save (no data loss).
export function formFromFrontmatter(fm, collection) {
const f = { __raw: fm || {} };
for (const fld of collection.fields || []) {
const v = fm[fld.name];
if (fld.type === 'bool') f[fld.name] = !!v;
else if (fld.type === 'list') f[fld.name] = Array.isArray(v) ? v.join(', ') : (v || '');
else if (fld.type === 'date') f[fld.name] = v ? String(v).slice(0, 10) : '';
else if (fld.type === 'number') f[fld.name] = v ?? '';
else f[fld.name] = v ?? '';
}
return f;
}
// Form values → frontmatter. Starts from any preserved __raw (so fields not in the
// schema, e.g. image galleries, are kept), then sets/clears the schema fields.
export function frontmatterFromForm(form, collection) {
const fm = { ...(form.__raw || {}) };
const setOrDrop = (k, v) => { if (v === '' || v == null || (Array.isArray(v) && !v.length)) delete fm[k]; else fm[k] = v; };
for (const fld of collection.fields || []) {
const v = form[fld.name];
if (fld.type === 'bool') { if (v) fm[fld.name] = true; else delete fm[fld.name]; continue; }
if (fld.type === 'number') { setOrDrop(fld.name, v === '' || v == null ? '' : Number(v)); continue; }
if (fld.type === 'list') { setOrDrop(fld.name, (v || '').split(',').map((x) => x.trim()).filter(Boolean)); continue; }
setOrDrop(fld.name, v);
}
return fm;
}
// Build the target path from collection.path ( e.g. 'archiv/:section/:slug' ),
// falling back to '<slug>.md' (or '<id>.md' when there is no slug field).
export function targetPath(form, collection) {
const tmpl = collection.path;
const slug = (form.slug || '').trim();
if (tmpl) {
const rel = tmpl.replace(/:([a-z_]+)/gi, (_, k) => (form[k] || '').toString().trim());
if (rel.includes('//') || rel.endsWith('/') || /:/.test(rel)) return ''; // missing segment
return rel + '.md';
}
if (slug) return `${slug}.md`;
return '';
}
// One field control (everything except markdown/body).
export function Field({ field, value, onChange, onUpload, busy }) {
const set = (val) => onChange(field.name, val);
const label = field.label || field.name;
const palette = field.palette || DEFAULT_PALETTE;
if (field.type === 'bool') {
return <label className="check"><input type="checkbox" checked={!!value} onChange={(e) => set(e.target.checked)} /> {label}</label>;
}
if (field.type === 'select') {
return (
<label className="sm">{label}
<select value={value || ''} onChange={(e) => set(e.target.value)}>
{!field.required && <option value="">()</option>}
{(field.options || []).map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</label>
);
}
if (field.type === 'color') {
return (
<label className="sm">{label}
<div className="colorpick">
<span className="swatch" style={{ background: hexOf(palette, value) }} />
<select value={value || ''} onChange={(e) => set(e.target.value)}>
{palette.map(([v, lbl]) => <option key={v} value={v}>{lbl}</option>)}
</select>
</div>
</label>
);
}
if (field.type === 'image') {
return (
<label>{label}
<div className="cover-row">
<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder="/img/…" />
<ImageUpload onUpload={onUpload} onDone={set} busy={busy} />
{value && <span className="cover-thumb" style={{ backgroundImage: `url(${value})` }} />}
</div>
</label>
);
}
if (field.type === 'text') {
return <label>{label}<textarea value={value || ''} rows={2} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
}
if (field.type === 'number') {
return <label className="xs">{label}<input type="number" value={value ?? ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
}
if (field.type === 'date') {
return <label className="sm">{label}<input type="date" value={value || ''} onChange={(e) => set(e.target.value)} /></label>;
}
if (field.type === 'list') {
return <label>{label}<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || 'komma, getrennt'} /></label>;
}
// string, slug, and any unknown type → text input
const cls = field.type === 'slug' ? 'sm' : '';
return <label className={cls}>{label}<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
}
import { useRef } from 'react';
function ImageUpload({ onUpload, onDone, busy }) {
const ref = useRef(null);
return (
<>
<button type="button" onClick={() => ref.current?.click()} disabled={busy}>Hochladen</button>
<input ref={ref} type="file" accept="image/*" hidden onChange={async (ev) => {
const file = ev.target.files?.[0]; ev.target.value = '';
if (!file) return;
try { onDone(await onUpload(file)); } catch { /* ignore */ }
}} />
</>
);
}
@@ -158,7 +158,74 @@ label.big input { font-family: var(--serif); font-weight: 600; }
.profile-card textarea { font-family: var(--serif); font-size: 15px; line-height: 1.6; resize: vertical; } .profile-card textarea { font-family: var(--serif); font-size: 15px; line-height: 1.6; resize: vertical; }
.profile-card .actions { display: flex; } .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 ── */
.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 { 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.ok { background: var(--ok); }
.toast.err { background: var(--accent); } .toast.err { background: var(--accent); }
/* System-Panel */
.sysgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; margin: .5rem 0 1rem; }
.sysgrid > div { display: flex; flex-direction: column; gap: .15rem; }
.sysgrid .muted { font-size: .8rem; }
.sysgrid b { font-size: 1.05rem; }
.sysgrid .ver { opacity: .6; font-weight: 400; }
.syslist { list-style: none; padding: 0; margin: .25rem 0 1rem; }
.syslist li { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: .4rem 0; border-bottom: 1px solid var(--line); }
.syslist li .muted { font-size: .85rem; }
+44
View File
@@ -0,0 +1,44 @@
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;
// Mit Supabase-URL: echter Client (openbureau, unverändert). Ohne (DB-loser core,
// auth: 'local', z. B. kgva): ein Shim mit DERSELBEN auth-Schnittstelle, die App/
// api nutzen — getSession / onAuthStateChange / signInWithPassword / signOut —
// gegen /api/auth/login, Token in localStorage. So bleibt der Supabase-Pfad
// völlig unangetastet, und der lokale Pfad braucht keine Änderung an App/api.
function localAuthClient() {
const KEY = 'cms_local_session';
const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
let listeners = [];
const emit = (s) => listeners.forEach((cb) => { try { cb(s ? 'SIGNED_IN' : 'SIGNED_OUT', s); } catch { /* ignore */ } });
return {
auth: {
async getSession() { return { data: { session: read() } }; },
onAuthStateChange(cb) {
listeners.push(cb);
return { data: { subscription: { unsubscribe() { listeners = listeners.filter((l) => l !== cb); } } } };
},
async signInWithPassword({ email, password }) {
try {
const r = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) return { error: { message: j.error || `HTTP ${r.status}` } };
const session = { access_token: j.access_token, user: { id: j.user.id, email: j.user.email } };
localStorage.setItem(KEY, JSON.stringify(session));
emit(session);
return { data: { session }, error: null };
} catch (e) { return { error: { message: String(e.message || e) } }; }
},
async signOut() { localStorage.removeItem(KEY); emit(null); return { error: null }; },
},
};
}
export const supabase = url ? createClient(url, anonKey) : localAuthClient();
+10 -1
View File
@@ -16,7 +16,9 @@ RUN npm run build
# Debian-slim statt Alpine: Hugo "extended" ist glibc-gelinkt. # Debian-slim statt Alpine: Hugo "extended" ist glibc-gelinkt.
FROM node:24-bookworm-slim FROM node:24-bookworm-slim
ARG HUGO_VERSION=0.161.1 ARG HUGO_VERSION=0.161.1
ARG TARGETARCH=amd64 # 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 \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git curl \ && apt-get install -y --no-install-recommends ca-certificates git curl \
@@ -38,5 +40,12 @@ COPY --from=admin /admin/dist ./admin-dist
ENV NODE_ENV=production ENV NODE_ENV=production
ENV ADMIN_DIR=/app/admin-dist 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 EXPOSE 3000
CMD ["sh", "/app/entrypoint.sh"] CMD ["sh", "/app/entrypoint.sh"]
+169 -2
View File
@@ -1,17 +1,20 @@
{ {
"name": "openbureau-cms-api", "name": "openbureau-cms-api",
"version": "0.1.0", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "openbureau-cms-api", "name": "openbureau-cms-api",
"version": "0.1.0", "version": "0.6.0",
"dependencies": { "dependencies": {
"@hono/node-server": "^1.13.7", "@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10", "@supabase/supabase-js": "^2.47.10",
"bcryptjs": "^2.4.3",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hono": "^4.6.14", "hono": "^4.6.14",
"marked": "^14.1.4",
"pg": "^8.22.0",
"sharp": "^0.33.5" "sharp": "^0.33.5"
} }
}, },
@@ -527,6 +530,12 @@
"sprintf-js": "~1.0.2" "sprintf-js": "~1.0.2"
} }
}, },
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -672,6 +681,146 @@
"node": ">=0.10.0" "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/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/section-matter": { "node_modules/section-matter": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
@@ -745,6 +894,15 @@
"is-arrayish": "^0.3.1" "is-arrayish": "^0.3.1"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": { "node_modules/sprintf-js": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -765,6 +923,15 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
} }
} }
} }
@@ -1,18 +1,22 @@
{ {
"name": "openbureau-cms-api", "name": "openbureau-cms-api",
"version": "0.1.0", "version": "0.7.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.", "description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "node --watch src/index.js" "dev": "node --watch src/index.js",
"test": "node --test"
}, },
"dependencies": { "dependencies": {
"@hono/node-server": "^1.13.7", "@hono/node-server": "^1.13.7",
"@supabase/supabase-js": "^2.47.10", "@supabase/supabase-js": "^2.47.10",
"bcryptjs": "^2.4.3",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hono": "^4.6.14", "hono": "^4.6.14",
"marked": "^14.1.4",
"pg": "^8.22.0",
"sharp": "^0.33.5" "sharp": "^0.33.5"
} }
} }
+109
View File
@@ -0,0 +1,109 @@
// Local auth provider — file-based users (bcrypt) + self-signed JWTs of the SAME
// claim shape as Supabase/GoTrue (sub / email / app_metadata.role / exp), so the
// rest of the engine (auth.js verifyToken, roleOf) treats local and supabase
// tokens identically. Lets a core instance run DB-less (no Supabase/Postgres):
// Node + Hugo + nginx. Activated by config.auth === 'local'.
//
// Users live in a JSON file (USERS_FILE, default <SITE_DIR>/cms-users.json):
// [{ id, email, password: <bcrypt hash>, role, created_at, last_sign_in_at }]
import path from 'node:path';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { sign } from 'hono/jwt';
const SITE_DIR = process.env.SITE_DIR || '/site';
const USERS_FILE = process.env.USERS_FILE || path.join(SITE_DIR, 'cms-users.json');
const JWT_SECRET = process.env.JWT_SECRET || '';
const ROLES = ['user', 'editor', 'admin'];
// bcryptjs is pure-JS (no native build) and lazily imported — a supabase-auth
// instance never loads it.
async function bcrypt() { return (await import('bcryptjs')).default; }
export async function loadUsers() {
try {
const j = JSON.parse(await readFile(USERS_FILE, 'utf8'));
return Array.isArray(j) ? j : (j.users || []);
} catch { return []; }
}
async function persist(users) {
await mkdir(path.dirname(USERS_FILE), { recursive: true });
await writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf8');
}
export async function hashPassword(pw) { return (await bcrypt()).hash(pw, 10); }
export async function verifyPassword(pw, hash) {
try { return await (await bcrypt()).compare(pw, hash); } catch { return false; }
}
// Mint a GoTrue-shaped HS256 access token (verified by auth.js with JWT_SECRET).
export async function mintToken(user, ttl = 3600) {
if (!JWT_SECRET) throw new Error('JWT_SECRET fehlt — für auth: local nötig');
const now = Math.floor(Date.now() / 1000);
return sign({
sub: user.id, email: user.email, role: 'authenticated',
app_metadata: { role: user.role || 'user' }, iat: now, exp: now + ttl,
}, JWT_SECRET, 'HS256');
}
export async function login(email, password) {
const users = await loadUsers();
const u = users.find((x) => (x.email || '').toLowerCase() === (email || '').toLowerCase());
if (!u || !u.password) return null;
if (!(await verifyPassword(password, u.password))) return null;
u.last_sign_in_at = new Date().toISOString();
await persist(users);
return {
access_token: await mintToken(u),
token_type: 'bearer',
user: { id: u.id, email: u.email, role: u.role || 'user' },
};
}
// ── Admin CRUD (mirrors routes/users.js shapes) ──────────────────────────────
export async function listUsers(adminEmails = []) {
const users = await loadUsers();
return users.map((u) => {
const fixedAdmin = adminEmails.includes((u.email || '').toLowerCase());
const role = u.role || 'user';
return {
id: u.id, email: u.email, created_at: u.created_at || null,
last_sign_in_at: u.last_sign_in_at || null,
role: fixedAdmin ? 'admin' : role, isAdmin: fixedAdmin || role === 'admin', fixedAdmin,
};
});
}
export async function countByRole(adminEmails = []) {
const list = await listUsers(adminEmails);
const out = { total: list.length, admin: 0, editor: 0, user: 0 };
for (const u of list) out[u.role] = (out[u.role] || 0) + 1;
return out;
}
export async function createUser({ email, password, role }) {
if (!email || !password) throw new Error('E-Mail und Passwort nötig');
if (role && !ROLES.includes(role)) throw new Error('Unbekannte Rolle');
const users = await loadUsers();
if (users.some((u) => (u.email || '').toLowerCase() === email.toLowerCase())) {
throw new Error('E-Mail existiert bereits');
}
const u = {
id: randomUUID(), email, password: await hashPassword(password),
role: role || 'user', created_at: new Date().toISOString(), last_sign_in_at: null,
};
users.push(u); await persist(users);
return u.id;
}
export async function updateUser(id, { password, role }) {
const users = await loadUsers();
const u = users.find((x) => x.id === id);
if (!u) throw new Error('Nutzer:in nicht gefunden');
if (password) u.password = await hashPassword(password);
if (role) { if (!ROLES.includes(role)) throw new Error('Unbekannte Rolle'); u.role = role; }
await persist(users);
}
export async function deleteUser(id) {
const users = await loadUsers();
const next = users.filter((u) => u.id !== id);
if (next.length === users.length) throw new Error('Nutzer:in nicht gefunden');
await persist(next);
}
+75
View File
@@ -0,0 +1,75 @@
import { verify } from 'hono/jwt';
import { supabaseAuth } from './supabase.js';
// Supabase-Tokens sind HS256-signiert. Mit dem JWT_SECRET verifizieren wir sie
// lokal (Signatur + Ablauf) — das spart pro Request den Roundtrip zu GoTrue.
// Ohne JWT_SECRET (z.B. Alt-Deploy) fällt requireAuth auf die Remote-Prüfung
// zurück. Tokens sind kurzlebig (1h) und Self-Signup ist aus → kein
// Sperr-Check nötig.
const JWT_SECRET = process.env.JWT_SECRET || '';
// Liefert ein User-Objekt {id,email,app_metadata} oder null.
async function verifyToken(token) {
if (JWT_SECRET) {
try {
const p = await verify(token, JWT_SECRET, 'HS256');
if (!p?.sub) return null;
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
} catch { return null; }
}
// Fallback (kein JWT_SECRET): Remote-Prüfung gegen GoTrue — nur wenn ein
// Supabase-Auth-Client existiert (DB-loser Betrieb hat keinen).
if (!supabaseAuth) return null;
const { data, error } = await supabaseAuth.auth.getUser(token);
if (error || !data?.user) return null;
return data.user;
}
// Rollen-Hierarchie: admin > editor (Redakteur) > user.
// - admin: alles (Foren verwalten, moderieren, Nutzer/Rollen, Inhalte)
// - editor: moderieren (Wortmeldungen ausblenden/löschen, Threads sperren)
// - user: im Forum mitschreiben
// Admins aus der .env (ADMIN_EMAILS=a@x,b@y) sind immer Admin (Bootstrap, damit
// man sich nicht aussperrt). Zusätzlich kann eine Rolle in app_metadata.role
// liegen (im Admin-UI vergeben).
const ADMINS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
export function roleOf(user) {
const email = (user?.email || '').toLowerCase();
const meta = (user?.app_metadata?.role || '').toLowerCase();
if (ADMINS.includes(email) || meta === 'admin') return 'admin';
if (meta === 'editor') return 'editor';
return 'user';
}
// Verifiziert den Supabase-Access-Token und legt user/email/role im Kontext ab.
export async function requireAuth(c, next) {
const header = c.req.header('Authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
const user = await verifyToken(token);
if (!user) return c.json({ error: 'Ungültiges Token' }, 401);
const email = (user.email || '').toLowerCase();
const role = roleOf(user);
c.set('user', user);
c.set('email', email);
c.set('role', role);
c.set('isAdmin', role === 'admin');
c.set('canModerate', role === 'admin' || role === 'editor');
await next();
}
// Nur Admins (nach requireAuth einsetzen).
export async function requireAdmin(c, next) {
if (!c.get('isAdmin')) return c.json({ error: 'Nur für Admins' }, 403);
await next();
}
// Admins + Redakteure — fürs Moderieren (nach requireAuth einsetzen).
export async function requireModerator(c, next) {
if (!c.get('canModerate')) return c.json({ error: 'Nur für Moderation' }, 403);
await next();
}
+39
View File
@@ -0,0 +1,39 @@
// Serialisiert asynchrone Aufgaben je `key` und koalesziert Wartende:
// - Es läuft nie mehr als eine Aufgabe pro Key gleichzeitig.
// - Kommen während eines Laufs weitere Aufrufe rein, wird GENAU EIN weiterer
// Durchlauf nachgelagert (egal wie viele warten) — sie teilen sich dessen
// Ergebnis. So sehen alle den jüngsten Stand, ohne einen Lauf-Sturm.
//
// Einsatz: teure, idempotente Vorgänge wie der Hugo-Build (siehe hugo.js).
const state = new Map();
export function coalesce(key, fn) {
let s = state.get(key);
if (!s) { s = { running: false, rerun: false, fn, waiters: [] }; state.set(key, s); }
s.fn = fn; // jüngste Variante gewinnt für den nächsten Lauf
return new Promise((resolve, reject) => {
s.waiters.push({ resolve, reject });
if (!s.running) drain(key);
else s.rerun = true;
});
}
async function drain(key) {
const s = state.get(key);
s.running = true;
try {
do {
s.rerun = false;
const waiters = s.waiters;
s.waiters = [];
try {
const r = await s.fn();
waiters.forEach((w) => w.resolve(r));
} catch (e) {
waiters.forEach((w) => w.reject(e));
}
} while (s.rerun);
} finally {
s.running = false;
}
}
+119
View File
@@ -0,0 +1,119 @@
// Derive content classification, list ordering and path building from a site's
// `config.collections` (see ../../README.md). Pure functions — no config import,
// no filesystem — so they unit-test against any collections array directly.
//
// Fed `examples/openbureau.config.js` these reproduce the engine's original
// hard-coded archiv/library/rubrik/seite behaviour 1:1.
// Parse a `path` pattern ('archiv/:section/:slug') into segments.
function parsePattern(pattern) {
return pattern.split('/').map((seg) =>
seg.startsWith(':') ? { param: seg.slice(1) } : { literal: seg });
}
// Match a content rel-path's segments (no .md) against a `path` pattern.
// Returns captured params, or null. Strict: the segment count must equal the
// pattern's, so 'archiv/:section/:slug' matches exactly three deep — the same
// rule the original classify() enforced for archiv.
function matchPattern(segs, pattern) {
const pat = parsePattern(pattern);
if (segs.length !== pat.length) return null;
const params = {};
for (let i = 0; i < pat.length; i++) {
if (pat[i].literal !== undefined) {
if (pat[i].literal !== segs[i]) return null;
} else {
if (!segs[i]) return null;
params[pat[i].param] = segs[i];
}
}
return params;
}
// First literal segment of a pattern (e.g. 'library' from 'library/:slug').
function rootSegment(pattern) {
const first = pattern.split('/')[0];
return first.startsWith(':') ? null : first;
}
// { kind, section } for a content file, derived from collections.
// Priority: index (_index.md) → path patterns → fallback — mirrors the original
// hard-coded classify() when fed openbureau.config.js.
export function classify(rel, collections) {
const parts = rel.split('/');
const base = parts[parts.length - 1];
const segs = rel.replace(/\.md$/, '').split('/');
// _index.md → the index collection; section = parent dir (or 'home' at root).
if (base === '_index.md') {
const idx = collections.find((c) => c.index);
if (idx) {
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
return { kind: idx.kind, section };
}
}
// path-pattern collections (skip index/fallback).
for (const c of collections) {
if (!c.path || c.index || c.fallback) continue;
const params = matchPattern(segs, c.path);
if (params) {
const section = params.section ?? rootSegment(c.path);
return { kind: c.kind, section: section ?? null };
}
}
// fallback collection (everything not otherwise matched).
const fb = collections.find((c) => c.fallback);
return { kind: fb ? fb.kind : null, section: null };
}
// Sort comparator for listEntries: collection `order`, then date desc, then title.
export function compareEntries(collections) {
const order = {};
collections.forEach((c, i) => { order[c.kind] = c.order ?? 100 + i; });
return (a, b) =>
((order[a.kind] ?? 999) - (order[b.kind] ?? 999)) ||
(b.date || '').localeCompare(a.date || '') ||
a.title.localeCompare(b.title);
}
// Build a content rel-path for a NEW entry of `kind` from form `data`.
// Index collection → '<slug>/_index.md'; a collection without a `path` (fallback)
// → '<slug>.md'; otherwise substitute :params in the collection's `path`
// (archiv/:section/:slug → archiv/<section>/<slug>.md). Returns '' if a required
// segment is missing.
export function buildPath(kind, data, collections) {
const c = collections.find((x) => x.kind === kind);
const slug = String(data.slug || '').trim();
if (c?.index) return slug ? `${slug}/_index.md` : '';
if (!c?.path) return slug ? `${slug}.md` : '';
let ok = true;
const rel = c.path.split('/').map((seg) => {
if (!seg.startsWith(':')) return seg;
const v = String(data[seg.slice(1)] || '').trim();
if (!v) ok = false;
return v;
}).join('/');
return ok && rel ? `${rel}.md` : '';
}
// Build the stats `content` object from classified entries + collections:
// one counter per collection.statKey, plus a drafts counter for any collection
// that declares `draftStatKey`. Mirrors the original hard-coded stats block
// (beitraege/entwuerfe/library/seiten/rubriken) when fed openbureau.config.js.
export function contentStats(entries, collections) {
const content = {};
for (const c of collections) {
if (c.statKey) content[c.statKey] ??= 0;
if (c.draftStatKey) content[c.draftStatKey] ??= 0;
}
const byKind = Object.fromEntries(collections.map((c) => [c.kind, c]));
for (const e of entries) {
const c = byKind[e.kind];
if (!c) continue;
if (c.statKey) content[c.statKey]++;
if (c.draftStatKey && e.draft) content[c.draftStatKey]++;
}
return content;
}
+29
View File
@@ -0,0 +1,29 @@
// Per-site CMS configuration loader.
//
// A site tells the core what it manages through one module, referenced by the
// CMS_CONFIG env var (e.g. CMS_CONFIG=/site/cms.config.js). The module exports
// `default` with { collections, plugins, admins }. Loaded once at startup; the
// engine (files/stats/routes) derives its behaviour from it instead of having
// openbureau-specific content types hard-coded.
//
// See ../../README.md for the schema, and ../../examples/*.config.js.
import { pathToFileURL } from 'node:url';
const p = process.env.CMS_CONFIG;
if (!p) {
console.error('FEHLT: CMS_CONFIG (Pfad zur Site-Config, z. B. /site/cms.config.js)');
process.exit(1);
}
const mod = await import(pathToFileURL(p).href);
export const config = mod.default || mod;
config.collections ||= [];
config.plugins ||= [];
config.admins ||= [];
config.auth ||= 'supabase'; // 'supabase' (GoTrue) | 'local' (file users, DB-less)
export const hasPlugin = (name) => config.plugins.includes(name);
// Collection lookup helpers (used by the generalised files.js / stats.js).
export const collectionOf = (kind) => config.collections.find((c) => c.kind === kind) || null;
export const fallbackCollection = () => config.collections.find((c) => c.fallback) || null;
@@ -1,6 +1,7 @@
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { classify, compareEntries } from './collections.js';
const SITE_DIR = process.env.SITE_DIR || '/site'; const SITE_DIR = process.env.SITE_DIR || '/site';
const CONTENT = path.join(SITE_DIR, 'content'); const CONTENT = path.join(SITE_DIR, 'content');
@@ -26,20 +27,6 @@ async function walk(dir) {
return out; return out;
} }
// Beitrag (library/<section>/<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] === 'library' && parts.length === 3) {
return { kind: 'beitrag', section: parts[1] };
}
return { kind: 'seite', section: null };
}
// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt). // authors-Frontmatter zu Array normalisieren (String oder Array erlaubt).
export function normAuthors(a) { export function normAuthors(a) {
if (Array.isArray(a)) return a.map(String).filter(Boolean); if (Array.isArray(a)) return a.map(String).filter(Boolean);
@@ -62,6 +49,9 @@ export function urlFor(rel) {
} }
export async function listEntries() { export async function listEntries() {
// Lazy: only listing needs the site config, so importing files.js for its pure
// helpers (safeRel/urlFor/…) never triggers the CMS_CONFIG load.
const { config } = await import('./config.js');
const files = await walk(CONTENT); const files = await walk(CONTENT);
const items = []; const items = [];
for (const full of files) { for (const full of files) {
@@ -73,7 +63,7 @@ export async function listEntries() {
items.push({ items.push({
path: rel, path: rel,
title: data.title || rel, title: data.title || rel,
...classify(rel), ...classify(rel, config.collections),
color: data.color || null, color: data.color || null,
layout: data.layout || null, layout: data.layout || null,
draft: !!data.draft, draft: !!data.draft,
@@ -82,12 +72,8 @@ export async function listEntries() {
url: urlFor(rel), url: urlFor(rel),
}); });
} }
// Beiträge zuerst, dann Seiten, dann Rubriken; je nach Datum/Titel. // Reihenfolge aus der Config (collection.order), dann Datum, dann Titel.
const order = { beitrag: 0, seite: 1, rubrik: 2 }; items.sort(compareEntries(config.collections));
items.sort((a, b) =>
(order[a.kind] - order[b.kind]) ||
(b.date || '').localeCompare(a.date || '') ||
a.title.localeCompare(b.title));
return items; return items;
} }
@@ -1,5 +1,6 @@
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { coalesce } from './coalesce.js';
const execFileP = promisify(execFile); const execFileP = promisify(execFile);
const SITE_DIR = process.env.SITE_DIR || '/site'; const SITE_DIR = process.env.SITE_DIR || '/site';
@@ -16,6 +17,13 @@ export async function hugoBuild({ dest, drafts = false } = {}) {
return { stdout, stderr }; 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 — // Optionaler Git-Backup beim Publish (GIT_PUBLISH=true). Schlägt nie hart fehl —
// das Publish soll an einem Git-Problem nicht scheitern. // das Publish soll an einem Git-Problem nicht scheitern.
export async function gitCommit(message) { export async function gitCommit(message) {
+148
View File
@@ -0,0 +1,148 @@
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
import { Hono } from 'hono';
import { secureHeaders } from 'hono/secure-headers';
import { bodyLimit } from 'hono/body-limit';
import { rateLimit } from './ratelimit.js';
import content from './routes/content.js';
import preview from './routes/preview.js';
import publish from './routes/publish.js';
import upload from './routes/upload.js';
import profile from './routes/profile.js';
import users from './routes/users.js';
import stats from './routes/stats.js';
import system from './routes/system.js';
import account from './routes/account.js';
import history from './routes/history.js';
import authRoute from './routes/auth.js';
import { requireAuth, requireAdmin } from './auth.js';
import { config } from './config.js';
import { supabase } from './supabase.js';
import { manager } from './plugins.js';
import { runMigrations } from './migrate.js';
// Fail-fast: auth:'supabase' ohne erreichbaren Service-Client ist ein Fehlstart.
// (auth:'local' braucht keinen — DB-loser Betrieb.)
if (config.auth === 'supabase' && !supabase) {
console.error('FEHLT: SUPABASE_URL/SERVICE_KEY für auth: supabase');
process.exit(1);
}
const SITE_DIR = process.env.SITE_DIR || '/site';
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
const PORT = Number(process.env.PORT || 3000);
const app = new Hono();
// --- Sicherheits-Header (auf allem) ---
// CSP bewusst zurückhaltend: Site + Admin-SPA + Dialog-Widget laufen same-origin.
app.use('*', secureHeaders({
xFrameOptions: 'SAMEORIGIN',
xContentTypeOptions: 'nosniff',
referrerPolicy: 'strict-origin-when-cross-origin',
crossOriginOpenerPolicy: 'same-origin',
// HSTS nur sinnvoll hinter TLS-Proxy; schadet via HTTP nicht (Browser ignoriert).
strictTransportSecurity: 'max-age=31536000; includeSubDomains',
}));
// Hochgeladene Bilder strikt isolieren: ein bösartiges SVG kann so kein
// JavaScript im Origin ausführen (sandbox + keine Skript-Quellen).
app.use('/images/*', secureHeaders({
contentSecurityPolicy: { defaultSrc: ["'none'"], imgSrc: ["'self'"], styleSrc: ["'unsafe-inline'"], sandbox: [] },
xContentTypeOptions: 'nosniff',
}));
// Statische Assets cachen: Hugo fingerprintet CSS/JS, Uploads haben stabile,
// eindeutige Namen. HTML bleibt ungecacht (Antwort ohne Header → immer frisch).
app.use('*', async (c, next) => {
await next();
if (c.req.method === 'GET' && /\.(css|js|mjs|woff2?|ttf|otf|eot|svg|png|jpe?g|webp|avif|gif|ico)$/i.test(c.req.path)) {
c.header('Cache-Control', 'public, max-age=604800'); // 1 Woche
}
});
// --- API ---
// Globales Limit gegen aufgeblähte JSON-Bodies (DoS / DB-Bloat). Der Upload-Pfad
// ist ausgenommen — der bringt sein eigenes, größeres Bild-Limit mit.
const jsonBodyLimit = bodyLimit({ maxSize: 256 * 1024, onError: (c) => c.json({ error: 'Anfrage zu groß' }, 413) });
app.use('/api/*', (c, next) =>
c.req.path.startsWith('/api/upload') ? next() : jsonBodyLimit(c, next));
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
// Öffentliche Plugin-Routen (ohne Login) — je nach config.plugins, z. B. Dialog
// lesen + Widget-Login. Jede Route bringt ihre eigenen Limits mit (dialog drosselt
// den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
manager.mountPublic(app);
// Lokaler Login (nur auth: 'local') — ersetzt GoTrue, öffentlich + gedrosselt.
// Supabase-Sites loggen direkt gegen GoTrue ein und brauchen das nicht.
if (config.auth === 'local') app.route('/api/auth', authRoute);
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
app.route('/api/history', history);
// Alles weitere unter /api/* braucht ein gültiges Token (Supabase oder lokal).
app.use('/api/*', requireAuth);
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
const mutateLimit = rateLimit({
max: 60, windowMs: 60_000,
keyFn: (c) => 'u:' + (c.get('user')?.id || c.req.header('x-forwarded-for') || 'anon'),
});
app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next)));
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
// Authentifizierte Plugin-Routen (nach requireAuth). admin:true-Routen laufen
// hinter requireAdmin; self-guarding Sub-Apps (dialog mod/adminForums) regeln den
// Zugriff selbst und brauchen das Flag nicht.
manager.mountPrivate(app, '/api', requireAdmin);
app.route('/api/content', content);
app.route('/api/preview', preview);
app.route('/api/publish', publish);
app.route('/api/upload', upload);
app.route('/api/profile', profile);
app.route('/api/users', users);
app.route('/api/stats', stats);
app.route('/api/system', system);
app.route('/api/account', account);
// Content-Modell der Site fürs Admin (schema-getriebenes Rendern). Jede:r
// eingeloggte Nutzer:in darf es lesen — der Editor braucht es zum Aufbauen.
app.get('/api/schema', (c) => c.json({
site: config.site || null,
auth: config.auth || 'supabase',
plugins: config.plugins,
collections: config.collections,
}));
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
app.get('/admin', (c) => c.redirect('/admin/'));
app.use(
'/admin/*',
serveStatic({
root: ADMIN_DIR,
rewriteRequestPath: (p) => p.replace(/^\/admin/, '') || '/',
}),
);
// --- Vorschau (gebaut nach preview/ mit --buildDrafts) ---
app.use(
'/_preview/*',
serveStatic({
root: `${SITE_DIR}/preview`,
rewriteRequestPath: (p) => p.replace(/^\/_preview/, ''),
}),
);
// Hochgeladene Bilder direkt aus static/ servieren — sofort sichtbar
// (Vorschau, Cover, Profilbild), ohne auf den nächsten Hugo-Build zu warten.
app.use('/images/*', serveStatic({ root: `${SITE_DIR}/static` }));
// --- Live-Site (gebaut nach public/) ---
app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
serve({ fetch: app.fetch, port: PORT }, async (info) => {
console.log(`CMS läuft auf :${info.port} — Site + API + /_preview`);
// Plugin-Migrationen zuerst anwenden (run-once, getrackt) — die Boot-Hooks
// unten brauchen die Tabellen schon. Ohne deklarierte Migrationen ein No-op.
await runMigrations(manager, { databaseUrl: process.env.DATABASE_URL })
.catch((e) => console.error('migrate:', e?.message || e));
// Plugin-Boot-Hooks anstoßen (dialog z. B. spiegelt Library→Threads), nicht blockierend.
manager.runBoot({}).catch((e) => console.error('plugin onBoot:', e?.message || e));
});
+75
View File
@@ -0,0 +1,75 @@
// Migration runner — applies plugin-declared migrations once each, tracked in
// public.schema_migrations. The plugin manager only *surfaces* migrations()
// (file URLs); this is what actually runs them, at boot.
//
// Design for a lean / DB-optional core:
// * No declared migrations (e.g. kgva: no plugins) → returns immediately, never
// opens a DB connection and never imports `pg`.
// * `exec` is injectable — an async (text, params?) => rows function — so the
// logic unit-tests without a real database. Omit it and the runner connects
// via `pg` to `databaseUrl` (lazy import; clear error if `pg` is missing).
// * Each migration runs once (id = "<plugin>/<file>") inside its own
// transaction; the file SQL is itself idempotent (see 001_dialog.sql), so a
// pre-existing DB (openbureau) is baselined cleanly on first run.
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
export async function runMigrations(manager, { databaseUrl, exec, log = console } = {}) {
const migs = manager.migrations();
if (!migs.length) return { applied: [], skipped: [], pending: false };
let close = async () => {};
if (!exec) {
if (!databaseUrl) {
log.warn?.(`migrate: ${migs.length} Migration(en) deklariert, aber DATABASE_URL fehlt — übersprungen.`);
return { applied: [], skipped: migs.map((m) => `${m.plugin}/${m.file}`), pending: true };
}
({ exec, close } = await pgExecutor(databaseUrl));
}
try {
await exec(`CREATE TABLE IF NOT EXISTS public.schema_migrations (
id text PRIMARY KEY,
plugin text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now()
)`);
const applied = [], skipped = [];
for (const m of migs) {
const id = `${m.plugin}/${m.file}`;
const done = await exec('SELECT 1 FROM public.schema_migrations WHERE id = $1', [id]);
if (done?.length) { skipped.push(id); continue; }
try {
const sql = await readFile(fileURLToPath(m.url), 'utf8');
await exec('BEGIN');
try {
await exec(sql);
await exec('INSERT INTO public.schema_migrations (id, plugin) VALUES ($1, $2)', [id, m.plugin]);
await exec('COMMIT');
} catch (e) {
await exec('ROLLBACK');
throw e;
}
} catch (e) {
throw new Error(`Migration ${id} fehlgeschlagen: ${e.message}`);
}
applied.push(id);
log.log?.(`migrate: angewandt ${id}`);
}
return { applied, skipped, pending: false };
} finally {
await close();
}
}
// Default executor: one pg client over databaseUrl. `pg` is imported lazily so a
// DB-less instance never pulls it in at runtime.
async function pgExecutor(databaseUrl) {
let pg;
try { pg = (await import('pg')).default; }
catch { throw new Error("migrate: Paket 'pg' fehlt (npm i pg) — für Plugin-Migrationen nötig."); }
const client = new pg.Client({ connectionString: databaseUrl });
await client.connect();
const exec = async (text, params) => (await client.query(text, params)).rows;
return { exec, close: () => client.end() };
}
+95
View File
@@ -0,0 +1,95 @@
// Plugin manager: everything beyond the generic engine is a plugin (see README →
// "Plugins"). A plugin is `api/src/plugins/<name>/index.js` exporting a manifest:
// { name, routes:[…], onBoot, onPublish, onPreview, migrations:[…], stats, admin:{…} }
//
// A route entry is either a mounted sub-app or a single handler:
// { path, app, public?, admin? } // app = Hono instance
// { method, path, handler, public?, admin?, use?:[mw] } // method = get|post|put|delete
// `public: true` → mounted before requireAuth; `admin: true` → guarded with the
// supplied requireAdmin; `use` adds per-route middleware (e.g. a rate limit).
//
// The manager loads the manifests named in config.plugins and wires them in:
// mounts routes by auth tier, runs lifecycle hooks, merges stats, surfaces
// migrations. Pure wiring — plugins import what they need (supabase, files, …).
const PLUGINS_DIR = new URL('./plugins/', import.meta.url);
// Import the manifests for `names` (config.plugins) and return a manager.
export async function loadPlugins(names = [], dir = PLUGINS_DIR) {
const manifests = [];
for (const name of names) {
const url = new URL(`${name}/index.js`, dir);
const mod = await import(url.href);
const manifest = mod.default || mod;
if (!manifest?.name) throw new Error(`Plugin "${name}": ungültiges Manifest (name fehlt)`);
manifest.__dir = new URL(`${name}/`, dir);
manifests.push(manifest);
}
return createManager(manifests);
}
// Mount one route entry (sub-app or handler) at `full`, behind `guards` (+ its
// own `use` middleware).
function mountRoute(app, full, r, guards) {
const mws = [...guards, ...(r.use || [])];
if (r.app) {
for (const mw of mws) { app.use(full, mw); app.use(full + '/*', mw); }
app.route(full, r.app);
} else if (r.method && r.handler) {
app[r.method](full, ...mws, r.handler);
} else {
throw new Error(`Plugin-Route ${full}: braucht entweder app oder method+handler`);
}
}
// Build a manager over already-loaded manifests (kept separate from import so it
// unit-tests without touching the filesystem).
export function createManager(manifests) {
const plugins = (manifests || []).filter(Boolean);
const routesWhere = (pred) =>
plugins.flatMap((p) => (p.routes || []).filter(pred).map((r) => ({ plugin: p.name, ...r })));
return {
plugins,
names: () => plugins.map((p) => p.name),
has: (name) => plugins.some((p) => p.name === name),
// Public routes (no auth) — mount BEFORE `requireAuth` in the pipeline.
mountPublic(app, prefix = '/api') {
for (const r of routesWhere((r) => r.public)) mountRoute(app, prefix + r.path, r, []);
},
// Authenticated routes — mount AFTER `requireAuth`. `admin: true` routes are
// guarded with the supplied requireAdmin (sub-apps that self-guard can omit
// the flag, as dialog's mod/adminForums do).
mountPrivate(app, prefix = '/api', requireAdmin) {
for (const r of routesWhere((r) => !r.public)) {
const guards = r.admin && requireAdmin ? [requireAdmin] : [];
mountRoute(app, prefix + r.path, r, guards);
}
},
// Lifecycle hooks (awaited in plugin order).
async runBoot(ctx) { for (const p of plugins) if (p.onBoot) await p.onBoot(ctx); },
async runPublish(ctx, payload) { for (const p of plugins) if (p.onPublish) await p.onPublish(ctx, payload); },
async runPreview(ctx, payload) { for (const p of plugins) if (p.onPreview) await p.onPreview(ctx, payload); },
// Merge each plugin's stats() under its own name: { dialog: { forums, … } }.
async collectStats(ctx) {
const out = {};
for (const p of plugins) if (p.stats) out[p.name] = await p.stats(ctx);
return out;
},
// Declared migrations resolved to file URLs; EXECUTION is the caller's job
// (needs the live Postgres — wired in stage 5/boot, not here).
migrations() {
return plugins.flatMap((p) =>
(p.migrations || []).map((file) => ({
plugin: p.name,
file,
url: p.__dir ? new URL(`migrations/${file}`, p.__dir).href : null,
})));
},
};
}
+9
View File
@@ -0,0 +1,9 @@
// The plugin manager for this instance, loaded once from config.plugins.
//
// Shared singleton: index.js wires its routes/hooks, publish.js fires onPublish,
// stats.js merges collectStats. A pluginless site (config.plugins = []) gets an
// empty manager — every call is a no-op, zero DB reads.
import { loadPlugins } from './plugin-manager.js';
import { config } from './config.js';
export const manager = await loadPlugins(config.plugins);
@@ -1,20 +1,13 @@
import { readFile } from 'node:fs/promises'; import { supabase, supabaseAuth } from '../../supabase.js';
import path from 'node:path'; import { roleOf } from '../../auth.js';
import { supabase } from '../supabase.js'; import { profileFor, threadLocked } from './dialog-store.js';
import { listEntries } from '../files.js'; import { serverError } from '../../util.js';
// Dialog: flache Wortmeldungen pro Thread (= Beitrags-Pfad), optionaler Bezug. // Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
const SITE_DIR = process.env.SITE_DIR || '/site';
// Anzeigename + Avatar aus dem Profil (data/authors.json), Fallback = Mail-Teil. const COLS = 'id,thread,parent_id,author_name,author_avatar,author_role,body,created_at,deleted';
async function profileFor(email) { const MAX_BODY = 10_000; // Zeichen je Wortmeldung
try { const MAX_THREAD = 512; // Thread-Key-Länge
const all = JSON.parse(await readFile(path.join(SITE_DIR, 'data', 'authors.json'), 'utf8'));
return all[email] || null;
} catch { return null; }
}
const COLS = 'id,thread,parent_id,author_name,author_avatar,body,created_at,deleted';
// ÖFFENTLICH: Wortmeldungen eines Threads lesen. // ÖFFENTLICH: Wortmeldungen eines Threads lesen.
export async function listComments(c) { export async function listComments(c) {
@@ -22,36 +15,20 @@ export async function listComments(c) {
if (!thread) return c.json({ error: 'thread fehlt' }, 400); if (!thread) return c.json({ error: 'thread fehlt' }, 400);
const { data, error } = await supabase const { data, error } = await supabase
.from('comments').select(COLS).eq('thread', thread).order('created_at', { ascending: true }); .from('comments').select(COLS).eq('thread', thread).order('created_at', { ascending: true });
if (error) return c.json({ error: error.message }, 500); if (error) return serverError(c, 'listComments', error);
const out = (data || []).map((r) => (r.deleted ? { ...r, body: '[gelöscht]', author_avatar: null } : r)); const out = (data || []).map((r) => (r.deleted ? { ...r, body: '[gelöscht]', author_avatar: null } : r));
return c.json(out); return c.json(out);
} }
// ÖFFENTLICH: Übersicht aller begonnenen Dialoge (Threads mit Wortmeldungen).
export async function listThreads(c) {
const { data, error } = await supabase.from('comments').select('thread,created_at,deleted');
if (error) return c.json({ error: error.message }, 500);
const map = {};
for (const r of data || []) {
if (r.deleted) continue;
const t = map[r.thread] || (map[r.thread] = { thread: r.thread, count: 0, last: r.created_at });
t.count += 1;
if (r.created_at > t.last) t.last = r.created_at;
}
let titles = {};
try { (await listEntries()).forEach((e) => { titles[e.url] = e.title; }); } catch { /* egal */ }
const out = Object.values(map)
.map((t) => ({ ...t, title: titles[t.thread] || t.thread }))
.sort((a, b) => (b.last || '').localeCompare(a.last || ''));
return c.json(out);
}
// EINGELOGGT: Wortmeldung schreiben. // EINGELOGGT: Wortmeldung schreiben.
export async function createComment(c) { export async function createComment(c) {
const user = c.get('user'); const user = c.get('user');
const email = c.get('email'); const email = c.get('email');
const { thread, body, parent_id } = await c.req.json(); 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 (!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 prof = await profileFor(email);
const row = { const row = {
@@ -60,23 +37,24 @@ export async function createComment(c) {
user_id: user.id, user_id: user.id,
author_name: prof?.name || email.split('@')[0], author_name: prof?.name || email.split('@')[0],
author_avatar: prof?.avatar || null, author_avatar: prof?.avatar || null,
author_role: prof?.title || null, // „Position bei OPENBUREAU" (aus data/authors.json)
body: body.trim(), body: body.trim(),
}; };
const { data, error } = await supabase.from('comments').insert(row).select(COLS).single(); const { data, error } = await supabase.from('comments').insert(row).select(COLS).single();
if (error) return c.json({ error: error.message }, 400); if (error) return serverError(c, 'createComment', error, 400);
return c.json(data, 201); return c.json(data, 201);
} }
// EINGELOGGT: eigene Wortmeldung (oder als Admin jede) löschen. // EINGELOGGT: eigene Wortmeldung löschen; Moderation (Admin/Redakteur) jede.
export async function deleteComment(c) { export async function deleteComment(c) {
const user = c.get('user'); const user = c.get('user');
const isAdmin = c.get('isAdmin'); const canModerate = c.get('canModerate');
const id = c.req.param('id'); const id = c.req.param('id');
const { data: row, error: e1 } = await supabase.from('comments').select('user_id').eq('id', id).single(); 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 (e1 || !row) return c.json({ error: 'Nicht gefunden' }, 404);
if (!isAdmin && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403); 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); const { error } = await supabase.from('comments').update({ deleted: true }).eq('id', id);
if (error) return c.json({ error: error.message }, 400); if (error) return serverError(c, 'deleteComment', error, 400);
return c.json({ ok: true }); return c.json({ ok: true });
} }
@@ -84,12 +62,13 @@ export async function deleteComment(c) {
export async function login(c) { export async function login(c) {
const { email, password } = await c.req.json(); const { email, password } = await c.req.json();
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400); if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
const { data, error } = await supabase.auth.signInWithPassword({ email, password }); const { data, error } = await supabaseAuth.auth.signInWithPassword({ email, password });
if (error) return c.json({ error: error.message }, 401); if (error) return c.json({ error: error.message }, 401);
const prof = await profileFor((data.user.email || '').toLowerCase()); const prof = await profileFor((data.user.email || '').toLowerCase());
return c.json({ return c.json({
access_token: data.session.access_token, access_token: data.session.access_token,
email: data.user.email, email: data.user.email,
name: prof?.name || (data.user.email || '').split('@')[0], name: prof?.name || (data.user.email || '').split('@')[0],
role: roleOf(data.user),
}); });
} }
@@ -0,0 +1,182 @@
import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { supabase } from '../../supabase.js';
import { listEntries } from '../../files.js';
// Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen).
// Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS).
const SITE_DIR = process.env.SITE_DIR || '/site';
export const LIBRARY_SLUG = 'beitraege';
export async function profileFor(email) {
try {
const all = JSON.parse(await readFile(path.join(SITE_DIR, 'data', 'authors.json'), 'utf8'));
return all[email] || null;
} catch { return null; }
}
// Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man
// auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key).
//
// Gedrosselt: Reads rufen das bei jedem Forum-Aufruf, aber der eigentliche
// Sync (DB + Filesystem-Walk + Upsert) läuft höchstens alle SYNC_TTL ms.
// `force: true` (z.B. nach Publish) überspringt die Drosselung.
const SYNC_TTL = 60_000;
let lastSync = 0;
export async function syncLibrary({ force = false } = {}) {
if (!force && Date.now() - lastSync < SYNC_TTL) return;
lastSync = Date.now();
const { data: forum } = await supabase
.from('forums').select('id').eq('slug', LIBRARY_SLUG).single();
if (!forum) return;
let entries = [];
try { entries = (await listEntries()).filter((e) => e.kind === 'beitrag'); } catch { return; }
if (!entries.length) return;
const rows = entries.map((e) => ({
forum_id: forum.id, key: e.url, title: e.title, url: e.url, kind: 'library',
}));
// Nicht title überschreiben? Doch — Titel kann sich ändern. user_id/locked bleiben.
await supabase.from('threads').upsert(rows, { onConflict: 'key', ignoreDuplicates: false });
}
// Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }.
// Aggregiert in Postgres (View comment_stats) statt alle Zeilen zu laden.
async function commentStats() {
const { data } = await supabase.from('comment_stats').select('thread,count,last');
const map = {};
for (const r of data || []) map[r.thread] = { count: r.count, last: r.last };
return map;
}
// Alle Foren mit Thread-/Wortmeldungs-Zahl und letzter Aktivität.
export async function forumsWithCounts() {
await syncLibrary();
const [{ data: forums }, { data: threads }, stats] = await Promise.all([
supabase.from('forums').select('*').order('sort'),
supabase.from('threads').select('id,forum_id,key,deleted'),
commentStats(),
]);
const byForum = {};
for (const t of threads || []) {
if (t.deleted) continue;
const f = byForum[t.forum_id] || (byForum[t.forum_id] = { threads: 0, posts: 0, last: '' });
f.threads += 1;
const s = stats[t.key];
if (s) { f.posts += s.count; if (s.last > f.last) f.last = s.last; }
}
return (forums || []).map((f) => ({
...f,
thread_count: byForum[f.id]?.threads || 0,
post_count: byForum[f.id]?.posts || 0,
last_at: byForum[f.id]?.last || null,
}));
}
// Ein Forum samt seiner Threads (nach letzter Aktivität sortiert).
export async function forumWithThreads(slug) {
const { data: forum } = await supabase.from('forums').select('*').eq('slug', slug).single();
if (!forum) return null;
if (forum.kind === 'library') await syncLibrary();
const [{ data: threads }, stats] = await Promise.all([
supabase.from('threads').select('*').eq('forum_id', forum.id).eq('deleted', false),
commentStats(),
]);
const list = (threads || []).map((t) => ({
key: t.key, title: t.title, url: t.url, kind: t.kind, locked: t.locked,
author_name: t.author_name, created_at: t.created_at,
count: stats[t.key]?.count || 0, last: stats[t.key]?.last || t.created_at,
})).sort((a, b) => (b.last || '').localeCompare(a.last || ''));
return { forum, threads: list };
}
// Letzte Wortmeldungen über alles — für die linke Spalte der Übersicht.
export async function recentComments(limit = 20) {
await syncLibrary();
const [{ data: comments }, { data: threads }, { data: forums }] = await Promise.all([
supabase.from('comments').select('id,thread,author_name,body,created_at')
.eq('deleted', false).order('created_at', { ascending: false }).limit(limit),
supabase.from('threads').select('key,title,url,forum_id,kind'),
supabase.from('forums').select('id,slug,name'),
]);
const tByKey = {}; for (const t of threads || []) tByKey[t.key] = t;
const fById = {}; for (const f of forums || []) fById[f.id] = f;
return (comments || []).map((c) => {
const t = tByKey[c.thread];
const f = t ? fById[t.forum_id] : null;
return {
id: c.id, body: c.body, author_name: c.author_name, created_at: c.created_at,
thread_title: t?.title || c.thread,
thread_url: t ? (t.kind === 'library' ? t.url : '/dialog/?thread=' + encodeURIComponent(c.thread)) : c.thread,
forum_name: f?.name || null, forum_slug: f?.slug || null,
};
});
}
// Moderations-Überblick: letzte Wortmeldungen + alle Threads (zum Sperren/Löschen).
export async function recentForModeration() {
const [comments, { data: threads }, { data: forums }] = await Promise.all([
recentComments(50),
supabase.from('threads').select('key,title,url,kind,forum_id,locked,deleted,author_name,created_at')
.order('created_at', { ascending: false }),
supabase.from('forums').select('id,name,slug'),
]);
const fById = {}; for (const f of forums || []) fById[f.id] = f;
const stats = await commentStats();
const t = (threads || []).map((x) => ({
key: x.key, title: x.title, url: x.url, kind: x.kind, locked: x.locked, deleted: x.deleted,
author_name: x.author_name, created_at: x.created_at,
forum_name: fById[x.forum_id]?.name || null,
count: stats[x.key]?.count || 0,
}));
return { comments, threads: t };
}
// Neuen Thread in einem Forum anlegen (+ erste Wortmeldung). Gibt den Thread zurück.
export async function createThread({ forumId, forumSlug, title, body, user, email }) {
let fid = forumId;
if (!fid && forumSlug) {
const { data: f } = await supabase.from('forums').select('id,kind').eq('slug', forumSlug).single();
if (!f) return { error: 'Forum unbekannt' };
if (f.kind === 'library') return { error: 'In Beiträge entstehen Threads automatisch' };
fid = f.id;
}
if (!fid) return { error: 'Forum nötig' };
if (!title || !title.trim()) return { error: 'Titel nötig' };
if (!body || !body.trim()) return { error: 'Erster Beitrag nötig' };
const prof = await profileFor(email);
const name = prof?.name || email.split('@')[0];
const key = 't/' + randomUUID();
const { data: thread, error: e1 } = await supabase.from('threads').insert({
forum_id: fid, key, title: title.trim(), url: '/dialog/?thread=' + encodeURIComponent(key),
kind: 'forum', author_name: name, user_id: user.id,
}).select('*').single();
if (e1) return { error: e1.message };
const { error: e2 } = await supabase.from('comments').insert({
thread: key, user_id: user.id, author_name: name,
author_avatar: prof?.avatar || null, body: body.trim(),
});
if (e2) return { error: e2.message };
return { thread };
}
// Ist ein Thread gesperrt? (verhindert neue Wortmeldungen)
export async function threadLocked(key) {
const { data } = await supabase.from('threads').select('locked').eq('key', key).single();
return !!data?.locked;
}
// Thread-Metadaten für die Thread-Ansicht (Titel, Forum-Rücklink, Lock-Status).
export async function threadMeta(key) {
const { data: t } = await supabase
.from('threads').select('title,url,kind,locked,forum_id').eq('key', key).single();
if (!t) return null;
let forum = null;
if (t.forum_id) {
const { data: f } = await supabase.from('forums').select('slug,name').eq('id', t.forum_id).single();
forum = f || null;
}
return { title: t.title, url: t.url, kind: t.kind, locked: !!t.locked, forum };
}
+104
View File
@@ -0,0 +1,104 @@
import { Hono } from 'hono';
import { supabase } from '../../supabase.js';
import { requireAdmin, requireModerator } from '../../auth.js';
import { serverError } from '../../util.js';
import {
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
} from './dialog-store.js';
// Fehlt die Tabelle (Migration noch nicht eingespielt), nicht mit einem rohen
// SQL-Fehler antworten — leer zurückgeben und server-seitig laut loggen.
function softFail(c, e, fallback) {
console.error('[dialog]', e?.message || e);
return c.json(fallback);
}
// ── Öffentliche Lese-Handler ─────────────────────────────────────────────
export async function listForums(c) {
try { return c.json(await forumsWithCounts()); }
catch (e) { return softFail(c, e, []); }
}
export async function showForum(c) {
try {
const data = await forumWithThreads(c.req.param('slug'));
if (!data) return c.json({ error: 'Forum nicht gefunden' }, 404);
return c.json(data);
} catch (e) { return softFail(c, e, { forum: null, threads: [] }); }
}
export async function recent(c) {
try { return c.json(await recentComments(Number(c.req.query('limit')) || 20)); }
catch (e) { return softFail(c, e, []); }
}
export async function threadInfo(c) {
const key = c.req.query('key');
if (!key) return c.json({ error: 'key fehlt' }, 400);
const meta = await threadMeta(key);
if (!meta) return c.json({ error: 'Thread nicht gefunden' }, 404);
return c.json(meta);
}
// ── Eingeloggt: neuen Thread starten ─────────────────────────────────────
export async function newThread(c) {
const user = c.get('user');
const email = c.get('email');
const { forum_id, forum_slug, title, body } = await c.req.json();
const res = await createThread({ forumId: forum_id, forumSlug: forum_slug, title, body, user, email });
if (res.error) return c.json({ error: res.error }, 400);
return c.json(res.thread, 201);
}
// ── Moderation (Admin + Redakteur) ───────────────────────────────────────
export const mod = new Hono();
mod.use('*', requireModerator);
// Feed: letzte Wortmeldungen + alle Threads (zum Moderieren/Sperren).
mod.get('/overview', async (c) => c.json(await recentForModeration()));
// Thread sperren/entsperren.
mod.post('/thread-lock', async (c) => {
const { key, locked } = await c.req.json();
if (!key) return c.json({ error: 'key nötig' }, 400);
const { error } = await supabase.from('threads').update({ locked: !!locked }).eq('key', key);
if (error) return serverError(c, 'dialog', error, 400);
return c.json({ ok: true });
});
// Thread ausblenden (löschen).
mod.post('/thread-delete', async (c) => {
const { key } = await c.req.json();
if (!key) return c.json({ error: 'key nötig' }, 400);
const { error } = await supabase.from('threads').update({ deleted: true }).eq('key', key);
if (error) return serverError(c, 'dialog', error, 400);
return c.json({ ok: true });
});
// ── Foren-Verwaltung (nur Admin) ─────────────────────────────────────────
export const adminForums = new Hono();
adminForums.use('*', requireAdmin);
adminForums.get('/', async (c) => {
const { data, error } = await supabase.from('forums').select('*').order('sort');
if (error) return serverError(c, 'dialog', error, 500);
return c.json(data || []);
});
adminForums.post('/', async (c) => {
const { slug, name, description, color, sort } = await c.req.json();
if (!slug || !name) return c.json({ error: 'slug und name nötig' }, 400);
const row = { slug: String(slug).trim(), name: String(name).trim(),
description: description || '', color: color || null, sort: Number(sort) || 0 };
const { data, error } = await supabase.from('forums').insert(row).select('*').single();
if (error) return serverError(c, 'dialog', error, 400);
return c.json(data, 201);
});
adminForums.put('/:id', async (c) => {
const patch = await c.req.json();
const allowed = {};
for (const k of ['name', 'description', 'color', 'sort', 'slug']) if (k in patch) allowed[k] = patch[k];
const { data, error } = await supabase.from('forums').update(allowed).eq('id', c.req.param('id')).select('*').single();
if (error) return serverError(c, 'dialog', error, 400);
return c.json(data);
});
adminForums.delete('/:id', async (c) => {
const id = c.req.param('id');
const { data: f } = await supabase.from('forums').select('kind').eq('id', id).single();
if (f?.kind === 'library') return c.json({ error: 'Beiträge-Kategorie kann nicht gelöscht werden' }, 400);
const { error } = await supabase.from('forums').delete().eq('id', id);
if (error) return serverError(c, 'dialog', error, 400);
return c.json({ ok: true });
});
+74
View File
@@ -0,0 +1,74 @@
// dialog plugin — openbureau's forum/comment subsystem as a core plugin.
// Wraps the existing dialog modules (no logic change) into a plugin manifest:
// public reads + widget login, authenticated writes, self-guarding moderation /
// forum-admin sub-apps, syncLibrary on boot + after publish, and the forum
// counters merged into /api/stats. kgva does not enable this plugin.
//
// NB: this manifest exists; wiring it into index.js (and removing the hard-coded
// dialog there) is the live-tested cutover (stage 5) — see HANDOVER.
import { rateLimit } from '../../ratelimit.js';
import {
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
} from './dialog.js';
import { listComments, createComment, deleteComment, login } from './comments.js';
import { supabase } from '../../supabase.js';
import { syncLibrary } from './dialog-store.js';
export default {
name: 'dialog',
routes: [
// ── public reads (mounted before requireAuth) ──
{ method: 'get', path: '/comments', handler: listComments, public: true },
{ method: 'get', path: '/forums', handler: listForums, public: true },
{ method: 'get', path: '/forums/:slug', handler: showForum, public: true },
{ method: 'get', path: '/recent', handler: recent, public: true },
{ method: 'get', path: '/thread', handler: threadInfo, public: true },
// widget login — brute-force throttled (10 / 5 min per IP), as in index.js
{
method: 'post', path: '/auth/login', handler: login, public: true,
use: [rateLimit({ max: 10, windowMs: 5 * 60_000 })],
},
// ── authenticated writes (mounted after requireAuth) ──
{ method: 'post', path: '/comments', handler: createComment },
{ method: 'delete', path: '/comments/:id', handler: deleteComment },
{ method: 'post', path: '/threads', handler: newThread },
// ── self-guarding sub-apps (requireModerator / requireAdmin inside) ──
{ path: '/mod', app: mod },
{ path: '/admin/forums', app: adminForums },
],
// Mirror library posts as threads — on boot and after every publish build.
onBoot: async () => {
await syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e));
},
onPublish: async () => {
await syncLibrary({ force: true }).catch(() => {});
},
// Forum/thread/comment counters, merged into /api/stats under "dialog".
stats: async () => {
const count = async (table, filter) => {
try {
let q = supabase.from(table).select('*', { count: 'exact', head: true });
if (filter) q = filter(q);
const { count: n } = await q;
return n || 0;
} catch { return 0; }
};
const [forums, threads, comments] = await Promise.all([
count('forums'),
count('threads', (q) => q.eq('deleted', false)),
count('comments', (q) => q.eq('deleted', false)),
]);
return { forums, threads, comments };
},
// Schema (forums/threads/comments + comment_stats view) — captured from the live
// openbureau dev DB into migrations/001_dialog.sql (idempotent; verified to apply
// on a fresh DB and re-apply as a no-op). The manager surfaces it; a migration
// runner applies it on boot for a fresh instance (execution still TODO).
migrations: ['001_dialog.sql'],
};
@@ -0,0 +1,99 @@
-- dialog plugin schema — forums / threads / comments (+ comment_stats view).
--
-- Captured 2026-06-29 from the live openbureau dev DB (CT 134 → openbureau-db,
-- supabase/postgres 15.8) via `pg_dump --schema-only --no-owner --no-privileges`
-- of public.{forums,threads,comments,comment_stats}. Made idempotent (IF NOT
-- EXISTS / OR REPLACE / constraint guards) so it is a no-op against the existing
-- openbureau DB and safe to apply to a fresh instance.
--
-- Notes:
-- * gen_random_uuid() is built into Postgres 13+ — no pgcrypto extension needed.
-- * RLS is enabled with NO policies: every read/write is mediated by the CMS
-- using the Supabase service_role key (which bypasses RLS). The dialog plugin
-- therefore only makes sense with `auth: supabase`; kgva (local auth) omits it.
-- * `posts` exists in the live DB but the dialog code never touches it — NOT a
-- dialog table, intentionally excluded.
-- ── tables ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.forums (
id uuid DEFAULT gen_random_uuid() NOT NULL,
slug text NOT NULL,
name text NOT NULL,
description text DEFAULT ''::text,
color text,
sort integer DEFAULT 0 NOT NULL,
kind text DEFAULT 'forum'::text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.threads (
id uuid DEFAULT gen_random_uuid() NOT NULL,
forum_id uuid,
key text NOT NULL,
title text NOT NULL,
url text,
kind text DEFAULT 'forum'::text NOT NULL,
author_name text,
user_id uuid,
locked boolean DEFAULT false NOT NULL,
deleted boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE IF NOT EXISTS public.comments (
id uuid DEFAULT gen_random_uuid() NOT NULL,
thread text NOT NULL,
parent_id uuid,
user_id uuid,
author_name text,
author_avatar text,
body text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
deleted boolean DEFAULT false NOT NULL,
author_role text
);
-- ── constraints (guarded — Postgres has no ADD CONSTRAINT IF NOT EXISTS) ──────
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'forums_pkey') THEN
ALTER TABLE ONLY public.forums ADD CONSTRAINT forums_pkey PRIMARY KEY (id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'forums_slug_key') THEN
ALTER TABLE ONLY public.forums ADD CONSTRAINT forums_slug_key UNIQUE (slug);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_pkey') THEN
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_pkey PRIMARY KEY (id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_key_key') THEN
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_key_key UNIQUE (key);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'comments_pkey') THEN
ALTER TABLE ONLY public.comments ADD CONSTRAINT comments_pkey PRIMARY KEY (id);
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_forum_id_fkey') THEN
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_forum_id_fkey
FOREIGN KEY (forum_id) REFERENCES public.forums(id) ON DELETE CASCADE;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'comments_parent_id_fkey') THEN
ALTER TABLE ONLY public.comments ADD CONSTRAINT comments_parent_id_fkey
FOREIGN KEY (parent_id) REFERENCES public.comments(id) ON DELETE CASCADE;
END IF;
END $$;
-- ── indexes ───────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS threads_forum_idx ON public.threads USING btree (forum_id);
CREATE INDEX IF NOT EXISTS comments_thread_idx ON public.comments USING btree (thread, created_at);
-- ── view: per-thread comment counts (used by dialog-store) ────────────────────
CREATE OR REPLACE VIEW public.comment_stats AS
SELECT comments.thread,
(count(*))::integer AS count,
max(comments.created_at) AS last
FROM public.comments
WHERE (NOT comments.deleted)
GROUP BY comments.thread;
-- ── row-level security (enabled, no policies — service_role-mediated) ─────────
ALTER TABLE public.forums ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
+34
View File
@@ -0,0 +1,34 @@
// Einfacher In-Memory-Rate-Limiter (ein Container, eine Instanz → genügt).
// Fixed-Window pro Schlüssel (Standard: Client-IP). Bei Überschreitung 429.
// Hinter einem Reverse-Proxy liefert X-Forwarded-For die echte IP.
const buckets = new Map(); // key -> { count, reset }
function clientIp(c) {
const xff = c.req.header('x-forwarded-for');
if (xff) return xff.split(',')[0].trim();
return c.req.header('x-real-ip') || 'unknown';
}
// max Anfragen je windowMs. keyFn erlaubt eigene Schlüssel (z.B. IP+E-Mail).
export function rateLimit({ max = 10, windowMs = 60_000, keyFn = clientIp } = {}) {
return async (c, next) => {
const key = keyFn(c);
const now = Date.now();
let b = buckets.get(key);
if (!b || now > b.reset) { b = { count: 0, reset: now + windowMs }; buckets.set(key, b); }
b.count += 1;
if (b.count > max) {
const retry = Math.ceil((b.reset - now) / 1000);
c.header('Retry-After', String(retry));
return c.json({ error: 'Zu viele Anfragen — bitte später erneut.' }, 429);
}
await next();
};
}
// Speicher sauber halten: abgelaufene Buckets periodisch wegräumen.
setInterval(() => {
const now = Date.now();
for (const [k, b] of buckets) if (now > b.reset) buckets.delete(k);
}, 5 * 60_000).unref?.();
+38
View File
@@ -0,0 +1,38 @@
import { Hono } from 'hono';
import { config } from '../config.js';
import { supabase, supabaseAuth } from '../supabase.js';
import * as local from '../auth-local.js';
// Self-service for the logged-in user (after requireAuth). Currently: change own
// password — local provider edits the user file; supabase verifies the current
// password then updates via the admin API.
const account = new Hono();
account.post('/password', async (c) => {
let body = {};
try { body = await c.req.json(); } catch { /* 400 below */ }
const { current, next } = body;
if (!next || String(next).length < 6) return c.json({ error: 'Neues Passwort zu kurz (min. 6 Zeichen).' }, 400);
const email = (c.get('email') || '').toLowerCase();
const userId = c.get('user')?.id;
if (config.auth === 'local') {
const u = (await local.loadUsers()).find((x) => (x.email || '').toLowerCase() === email);
if (!u || !(await local.verifyPassword(current || '', u.password))) {
return c.json({ error: 'Aktuelles Passwort ist falsch.' }, 403);
}
await local.updateUser(u.id, { password: next });
return c.json({ ok: true });
}
// supabase: verify current via the dedicated auth client, then update via admin.
if (!supabaseAuth || !supabase) return c.json({ error: 'Auth nicht verfügbar' }, 500);
const { error: e1 } = await supabaseAuth.auth.signInWithPassword({ email, password: current || '' });
await supabaseAuth.auth.signOut().catch(() => {}); // Session sofort wieder lösen
if (e1) return c.json({ error: 'Aktuelles Passwort ist falsch.' }, 403);
const { error: e2 } = await supabase.auth.admin.updateUserById(userId, { password: next });
if (e2) return c.json({ error: e2.message }, 400);
return c.json({ ok: true });
});
export default account;
+20
View File
@@ -0,0 +1,20 @@
import { Hono } from 'hono';
import { rateLimit } from '../ratelimit.js';
import { login } from '../auth-local.js';
// Lokaler Login (auth: 'local') — ersetzt GoTrue. Liefert ein GoTrue-förmiges
// Token, das requireAuth genauso prüft wie ein Supabase-Token. Nur gemountet,
// wenn config.auth === 'local' (siehe index.js); Brute-Force gedrosselt.
const auth = new Hono();
auth.post('/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), async (c) => {
let body = {};
try { body = await c.req.json(); } catch { /* leerer/kaputter Body → 400 unten */ }
const { email, password } = body;
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
const res = await login(email, password);
if (!res) return c.json({ error: 'Login fehlgeschlagen' }, 401);
return c.json(res);
});
export default auth;
+83
View File
@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import matter from 'gray-matter';
import { marked } from 'marked';
import { safeRel } from '../files.js';
// ÖFFENTLICH: Versionsverlauf eines Library-Beitrags aus der Git-History.
// Der Container hat das Repo unter /site gemountet + git installiert. Wir
// holen alte Fassungen on-demand (kein Vorbauen) und zeigen sie auf der Site.
const execFileP = promisify(execFile);
const SITE_DIR = process.env.SITE_DIR || '/site';
const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { maxBuffer: 10 * 1024 * 1024 });
const US = '\x1f'; // Feldtrenner (Unit Separator) — kommt in Commit-Daten nicht vor.
const history = new Hono();
// Liste der Versionen: neueste zuerst.
history.get('/', async (c) => {
let rel;
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
try {
const { stdout } = await git(
'log', '--follow', `--format=%H${US}%h${US}%aI${US}%an${US}%s`, '--', `content/${rel}`);
const versions = stdout.trim().split('\n').filter(Boolean).map((line) => {
const [rev, short, date, author, subject] = line.split(US);
return { rev, short, date, author, subject };
});
return c.json(versions);
} catch { return c.json({ error: 'Verlauf nicht verfügbar' }, 500); }
});
// Eine bestimmte Fassung gerendert (HTML), zum Anzeigen auf der Seite.
history.get('/version', async (c) => {
let rel;
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
const rev = c.req.query('rev') || '';
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
try {
const { stdout } = await git('show', `${rev}:content/${rel}`);
const { data, content } = matter(stdout);
return c.json({
rev,
title: data.title || '',
date: data.date ? new Date(data.date).toISOString().slice(0, 10) : null,
html: renderMarkdown(content),
});
} catch { return c.json({ error: 'Version nicht gefunden' }, 404); }
});
// Unified-Diff einer Fassung (was dieser Commit an der Datei geändert hat) —
// fürs rot/grün-Diff auf der Seite. Roh-Diff; das Frontend färbt +/- ein.
history.get('/diff', async (c) => {
let rel;
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
const rev = c.req.query('rev') || '';
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
try {
const { stdout } = await git('show', '--format=', '--no-color', rev, '--', `content/${rel}`);
return c.json({ rev, diff: stdout });
} catch { return c.json({ error: 'Diff nicht verfügbar' }, 404); }
});
// Markdown → HTML. marked kennt Goldmarks Fußnoten ([^id]) nicht — daher
// vorab: Definitionen einsammeln, Verweise zu <sup>-Nummern, „Quellen" anhängen
// (greift dieselbe .footnotes-CSS wie die Live-Seite).
function renderMarkdown(md) {
const defs = {}; const order = [];
md = md.replace(/^\[\^([^\]]+)\]:[ \t]*(.*)$/gm, (_, id, txt) => { defs[id] = txt; return ''; });
md = md.replace(/\[\^([^\]]+)\]/g, (_, id) => {
if (!order.includes(id)) order.push(id);
return `<sup class="footnote-ref">${order.indexOf(id) + 1}</sup>`;
});
let html = marked.parse(md);
if (order.length) {
html += '<div class="footnotes"><ol>'
+ order.map((id) => `<li>${marked.parseInline(defs[id] || '')}</li>`).join('')
+ '</ol></div>';
}
return html;
}
export default history;
@@ -1,6 +1,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { urlFor, safeRel } from '../files.js'; import { urlFor, safeRel } from '../files.js';
import { hugoBuild } from '../hugo.js'; import { buildSite } from '../hugo.js';
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die // Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge). // URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
@@ -10,7 +10,7 @@ preview.post('/', async (c) => {
const { path: rel } = await c.req.json(); const { path: rel } = await c.req.json();
try { try {
const safe = safeRel(rel); const safe = safeRel(rel);
const build = await hugoBuild({ dest: 'preview', drafts: true }); const build = await buildSite({ dest: 'preview', drafts: true });
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout }); return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
} catch (e) { } catch (e) {
return c.json({ error: String(e.message || e) }, 500); return c.json({ error: String(e.message || e) }, 500);
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { hugoBuild } from '../hugo.js'; import { buildSite } from '../hugo.js';
// Profile als Hugo-Data-Datei (data/authors.json) + öffentliche Autor-Seite // Profile als Hugo-Data-Datei (data/authors.json) + öffentliche Autor-Seite
// (content/authors/<slug>.md), gerendert von layouts/authors/single.html. // (content/authors/<slug>.md), gerendert von layouts/authors/single.html.
@@ -46,8 +46,8 @@ profile.put('/', async (c) => {
} }
const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' }); const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' });
await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8'); await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8');
// Live bauen, damit die Seite + Byline-Links sofort funktionieren. // Live bauen (koalesziert), damit die Seite + Byline-Links sofort wirken.
await hugoBuild({ dest: 'public', drafts: false }).catch(() => {}); await buildSite({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e));
} }
return c.json({ ok: true, slug }); return c.json({ ok: true, slug });
@@ -1,6 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { urlFor, safeRel } from '../files.js'; import { urlFor, safeRel } from '../files.js';
import { hugoBuild, gitCommit } from '../hugo.js'; import { buildSite, gitCommit } from '../hugo.js';
import { manager } from '../plugins.js';
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit. // Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
const publish = new Hono(); const publish = new Hono();
@@ -9,7 +10,10 @@ publish.post('/', async (c) => {
const { path: rel } = await c.req.json(); const { path: rel } = await c.req.json();
try { try {
const safe = safeRel(rel); const safe = safeRel(rel);
const build = await hugoBuild({ dest: 'public', drafts: false }); const build = await buildSite({ dest: 'public', drafts: false });
// Plugin-Publish-Hooks anstoßen (dialog spiegelt neue Library-Beiträge sofort
// als Threads). Fehler dürfen das Publish nicht kippen — Hooks fangen selbst ab.
await manager.runPublish({}, { path: safe });
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) })); 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 }); return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
} catch (e) { } catch (e) {
+44
View File
@@ -0,0 +1,44 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { listEntries } from '../files.js';
import { requireAdmin, roleOf } from '../auth.js';
import { config } from '../config.js';
import { contentStats } from '../collections.js';
import { manager } from '../plugins.js';
import { countByRole } from '../auth-local.js';
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
const stats = new Hono();
stats.use('*', requireAdmin);
stats.get('/', async (c) => {
// Inhalte aus dem Dateisystem zählen — Zähler pro collection.statKey.
let content = {};
try { content = contentStats(await listEntries(), config.collections); }
catch { /* Filesystem nicht lesbar → leer */ }
// Nutzer nach Rolle: lokal aus der User-Datei, sonst aus GoTrue.
let users = { total: 0, admin: 0, editor: 0, user: 0 };
if (config.auth === 'local') {
try { users = await countByRole(ADMIN_EMAILS); } catch { /* User-Datei fehlt → 0 */ }
} else if (supabase) {
try {
const { data } = await supabase.auth.admin.listUsers();
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
} catch { /* GoTrue nicht erreichbar */ }
}
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
const pluginStats = await manager.collectStats({});
// Dialog bleibt als eigenes Feld im Vertrag (Admin erwartet { forums, threads,
// comments }); fehlt das Plugin, sind es Nullen.
const dialog = pluginStats.dialog || { forums: 0, threads: 0, comments: 0 };
return c.json({ content, users, dialog });
});
export default stats;
+63
View File
@@ -0,0 +1,63 @@
import { Hono } from 'hono';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { config } from '../config.js';
import { manager } from '../plugins.js';
import { requireAdmin } from '../auth.js';
const execFileP = promisify(execFile);
let _version = null;
async function coreVersion() {
if (_version) return _version;
try { _version = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')).version || '0.0.0'; }
catch { _version = '0.0.0'; }
return _version;
}
let _hugo = null;
async function hugoVersion() {
if (_hugo) return _hugo;
try { const { stdout } = await execFileP('hugo', ['version']); _hugo = (stdout.match(/v[0-9][0-9.]*[^\s]*/) || ['?'])[0]; }
catch { _hugo = '?'; }
return _hugo;
}
// System-/Diagnose-Info fürs Admin-Panel. Reiner Builder (testbar); die Route hängt
// nur die zur Laufzeit ermittelten Versionen dran.
export function systemInfo({ version, hugo }) {
return {
core: { name: 'openbureau-core', version },
site: config.site || null,
auth: config.auth || 'supabase',
admins: config.admins || [],
plugins: manager.plugins.map((p) => ({
name: p.name, routes: (p.routes || []).length, migrations: (p.migrations || []).length,
})),
collections: config.collections.map((c) => ({
kind: c.kind, label: c.label, statKey: c.statKey || null, fields: (c.fields || []).length,
})),
hugo,
};
}
const system = new Hono();
system.use('*', requireAdmin);
system.get('/', async (c) => c.json(systemInfo({ version: await coreVersion(), hugo: await hugoVersion() })));
// Update-Check (token-frei): laufende Core-Version (Image, /app/package.json) vs. die
// im Repo VENDORTE Version (SITE_DIR/cms/core/api/package.json). Ist die vendorte neuer,
// wurde core aktualisiert aber noch nicht neu gebaut → "Rebuild nötig" (Update-Skript).
system.get('/update', async (c) => {
const running = await coreVersion();
const siteDir = process.env.SITE_DIR || '/site';
const vendorPath = path.join(siteDir, process.env.CORE_VENDOR_PATH || 'cms/core', 'api/package.json');
let vendored = null;
try { vendored = JSON.parse(await readFile(vendorPath, 'utf8')).version || null; }
catch { /* nicht als Subtree eingebunden → kein Vergleich möglich */ }
return c.json({ running, vendored, available: !!(vendored && vendored !== running) });
});
export default system;
+83
View File
@@ -0,0 +1,83 @@
import { Hono } from 'hono';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
// Bild-Upload → static/images/. Raster-Bilder werden zu WebP konvertiert
// (kleiner, web-optimiert), auf max. 2000px begrenzt, EXIF-Rotation korrigiert.
// SVG/GIF bleiben unangetastet (Vektor/Animation erhalten).
//
// Sicherheit: hartes Größenlimit (DoS / Decompression-Bombs), Raster wird über
// sharp-Metadaten als echtes Bild verifiziert, SVG nur wenn es wie SVG aussieht.
// Hochgeladene Dateien werden zudem mit strikter CSP (sandbox) ausgeliefert
// (siehe index.js, /images/*) → ein bösartiges SVG kann kein JS im Origin starten.
const SITE_DIR = process.env.SITE_DIR || '/site';
const MAX_UPLOAD = 8 * 1024 * 1024; // 8 MB Rohdatei
const ALLOWED_RASTER = new Set(['jpeg', 'png', 'webp', 'avif', 'tiff']);
const upload = new Hono();
upload.post('/', async (c) => {
const body = await c.req.parseBody();
const file = body['file'];
if (!file || typeof file === 'string') return c.json({ error: 'Keine Datei' }, 400);
if (typeof file.size === 'number' && file.size > MAX_UPLOAD) {
return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413);
}
const buffer = Buffer.from(await file.arrayBuffer());
if (buffer.length > MAX_UPLOAD) return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413);
if (!buffer.length) return c.json({ error: 'Leere Datei' }, 400);
const dir = path.join(SITE_DIR, 'static', 'images');
await mkdir(dir, { recursive: true });
const ext = path.extname(file.name || '').toLowerCase();
const base = `${safeBase(file.name)}-${uid()}`;
let outName, outBuf;
if (ext === '.svg') {
// Muss wie SVG aussehen (Magie/Marker), sonst ablehnen.
const head = buffer.subarray(0, 512).toString('utf8').trimStart().toLowerCase();
if (!head.startsWith('<?xml') && !head.startsWith('<svg')) {
return c.json({ error: 'Keine gültige SVG-Datei' }, 400);
}
outName = `${base}.svg`;
outBuf = buffer;
} else if (ext === '.gif') {
// GIF-Magie prüfen (kann kein Skript ausführen → Passthrough ok).
const sig = buffer.subarray(0, 6).toString('latin1');
if (sig !== 'GIF87a' && sig !== 'GIF89a') return c.json({ error: 'Keine gültige GIF-Datei' }, 400);
outName = `${base}.gif`;
outBuf = buffer;
} else {
// Raster: über sharp als echtes Bild verifizieren, dann zu WebP.
let meta;
try { meta = await sharp(buffer).metadata(); } catch { meta = null; }
if (!meta || !ALLOWED_RASTER.has(meta.format)) {
return c.json({ error: 'Kein unterstütztes Bildformat' }, 400);
}
outName = `${base}.webp`;
outBuf = await sharp(buffer)
.rotate()
.resize({ width: 2000, withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
}
await writeFile(path.join(dir, outName), outBuf);
return c.json({ url: `/images/${outName}` });
});
// Sicherer Basisname ohne Endung.
function safeBase(raw) {
const base = path.basename(String(raw || 'bild')).replace(/\.[^.]+$/, '');
const cleaned = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
return cleaned || 'bild';
}
// Kurze eindeutige Endung, damit gleichnamige Uploads nicht kollidieren.
function uid() {
return Date.now().toString(36).slice(-4) + Math.random().toString(36).slice(2, 5);
}
export default upload;
+80
View File
@@ -0,0 +1,80 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { requireAdmin, roleOf } from '../auth.js';
import { config } from '../config.js';
import * as local from '../auth-local.js';
// Autoren-/Nutzerverwaltung. Mit auth:'local' gegen die User-Datei (DB-los),
// sonst über die GoTrue-Admin-API (Service-Key). Nur Admins.
const ADMINS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const isLocal = () => config.auth === 'local';
const users = new Hono();
users.use('*', requireAdmin);
users.get('/', async (c) => {
if (isLocal()) {
try { return c.json(await local.listUsers(ADMINS)); }
catch (e) { return c.json({ error: String(e.message || e) }, 500); }
}
const { data, error } = await supabase.auth.admin.listUsers();
if (error) return c.json({ error: error.message }, 500);
const list = (data?.users || []).map((u) => {
const role = roleOf(u);
return {
id: u.id,
email: u.email,
created_at: u.created_at,
last_sign_in_at: u.last_sign_in_at || null,
role,
isAdmin: role === 'admin',
// Admins aus der .env lassen sich nicht per UI herabstufen.
fixedAdmin: ADMINS.includes((u.email || '').toLowerCase()),
};
});
return c.json(list);
});
users.post('/', async (c) => {
const { email, password, role } = await c.req.json();
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
if (isLocal()) {
try { return c.json({ ok: true, id: await local.createUser({ email, password, role }) }); }
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
}
const payload = { email, password, email_confirm: true };
if (role && role !== 'user') payload.app_metadata = { role };
const { data, error } = await supabase.auth.admin.createUser(payload);
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true, id: data.user.id });
});
users.put('/:id', async (c) => {
const { password, role } = await c.req.json();
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
if (!password && !role) return c.json({ error: 'Nichts zu ändern' }, 400);
if (isLocal()) {
try { await local.updateUser(c.req.param('id'), { password, role }); return c.json({ ok: true }); }
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
}
const patch = {};
if (password) patch.password = password;
if (role) patch.app_metadata = { role };
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true });
});
users.delete('/:id', async (c) => {
if (isLocal()) {
try { await local.deleteUser(c.req.param('id')); return c.json({ ok: true }); }
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
}
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true });
});
export default users;
+29
View File
@@ -0,0 +1,29 @@
import { createClient } from '@supabase/supabase-js';
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_KEY;
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
// Mit Keys: echte Clients. Ohne (DB-loser core, auth:'local'): Clients bleiben
// null; die Aufrufer (auth/stats/users) sind null-sicher und nutzen den lokalen
// Provider. DB-Plugins (dialog) laufen ohnehin nur mit Supabase. Den Fail-Fast
// für auth:'supabase' macht index.js (das kennt die config) — supabase.js bleibt
// bewusst config-frei, damit Unit-Tests es ohne CMS_CONFIG importieren können.
let _supabase = null;
let _supabaseAuth = null;
if (url && key) {
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
_supabase = createClient(url, key, opts);
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
_supabaseAuth = createClient(url, key, opts);
} else {
console.warn('supabase.js: kein SUPABASE_URL/SERVICE_KEY — DB-loser Betrieb (auth: local erwartet).');
}
export const supabase = _supabase;
export const supabaseAuth = _supabaseAuth;
+6
View File
@@ -0,0 +1,6 @@
// Serverfehler protokollieren, aber dem Client nur eine generische Meldung
// geben — keine DB-/Stack-Interna nach außen (Info-Leak vermeiden).
export function serverError(c, where, err, status = 500) {
console.error(`[${where}]`, err?.message || err);
return c.json({ error: 'Serverfehler' }, status);
}
+67
View File
@@ -0,0 +1,67 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rm, readFile } from 'node:fs/promises';
// Isolated users file + a known secret BEFORE importing the module (it reads env
// at import time). No Supabase needed — this is the DB-less path.
const USERS = join(tmpdir(), `cms-users-${process.pid}.json`);
process.env.USERS_FILE = USERS;
process.env.JWT_SECRET ||= 'test-secret-xyz';
const local = await import('../src/auth-local.js');
const { verify } = await import('hono/jwt');
test.after(async () => { await rm(USERS, { force: true }); });
test('create → list → login mints a GoTrue-shaped, verifiable token', async () => {
const id = await local.createUser({ email: 'Karim@Example.com', password: 'hunter2', role: 'admin' });
assert.ok(id);
const list = await local.listUsers(['someone@else.com']);
assert.equal(list.length, 1);
assert.equal(list[0].email, 'Karim@Example.com');
assert.equal(list[0].role, 'admin');
// wrong password rejected, right password accepted (case-insensitive email)
assert.equal(await local.login('karim@example.com', 'nope'), null);
const res = await local.login('karim@example.com', 'hunter2');
assert.ok(res?.access_token);
assert.equal(res.user.email, 'Karim@Example.com');
const claims = await verify(res.access_token, process.env.JWT_SECRET, 'HS256');
assert.equal(claims.sub, id);
assert.equal(claims.email, 'Karim@Example.com');
assert.equal(claims.app_metadata.role, 'admin'); // same shape auth.js/roleOf read
assert.ok(claims.exp > Math.floor(Date.now() / 1000));
});
test('passwords are bcrypt-hashed at rest (never plaintext)', async () => {
const raw = JSON.parse(await readFile(USERS, 'utf8'));
assert.match(raw[0].password, /^\$2[aby]\$/); // bcrypt hash prefix
assert.notEqual(raw[0].password, 'hunter2');
});
test('countByRole reflects roles; fixed-admin email forced to admin', async () => {
await local.createUser({ email: 'red@x.com', password: 'pw', role: 'editor' });
const counts = await local.countByRole([]);
assert.equal(counts.total, 2);
assert.equal(counts.admin, 1);
assert.equal(counts.editor, 1);
// an env-admin email overrides the stored role in the listing
const forced = await local.listUsers(['red@x.com']);
assert.equal(forced.find((u) => u.email === 'red@x.com').role, 'admin');
});
test('update + delete', async () => {
const id = await local.createUser({ email: 'tmp@x.com', password: 'pw' });
await local.updateUser(id, { role: 'editor' });
assert.equal((await local.listUsers([])).find((u) => u.id === id).role, 'editor');
await local.deleteUser(id);
assert.equal((await local.listUsers([])).some((u) => u.id === id), false);
await assert.rejects(() => local.deleteUser('nope'), /nicht gefunden/);
});
test('duplicate email rejected', async () => {
await assert.rejects(() => local.createUser({ email: 'red@x.com', password: 'pw' }), /existiert bereits/);
});
+68
View File
@@ -0,0 +1,68 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
// Env vor dem Import setzen: supabase.js bricht ohne URL/Key ab, ADMIN_EMAILS
// und JWT_SECRET werden beim Modul-Load gelesen.
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'dummy';
process.env.JWT_SECRET = 'test-secret';
process.env.ADMIN_EMAILS = 'boss@x.ch';
const { roleOf, requireAuth } = await import('../src/auth.js');
const { sign } = await import('hono/jwt');
test('roleOf: Admin aus ADMIN_EMAILS', () => {
assert.equal(roleOf({ email: 'boss@x.ch' }), 'admin');
assert.equal(roleOf({ email: 'BOSS@X.CH' }), 'admin');
});
test('roleOf: Rolle aus app_metadata', () => {
assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'admin' } }), 'admin');
assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'editor' } }), 'editor');
assert.equal(roleOf({ email: 'a@x.ch' }), 'user');
});
// Minimaler Hono-Kontext-Stub.
function fakeCtx(authHeader) {
const store = {};
return {
req: { header: (h) => (h === 'Authorization' ? authHeader : undefined) },
set: (k, v) => { store[k] = v; },
get: (k) => store[k],
json: (body, status = 200) => ({ __status: status, body }),
};
}
test('requireAuth: gültiges Token wird lokal verifiziert', async () => {
const token = await sign(
{ sub: 'u1', email: 'A@x.ch', app_metadata: { role: 'editor' }, exp: Math.floor(Date.now() / 1000) + 60 },
'test-secret', 'HS256');
let passed = false;
const c = fakeCtx('Bearer ' + token);
await requireAuth(c, async () => { passed = true; });
assert.equal(passed, true);
assert.equal(c.get('email'), 'a@x.ch'); // kleingeschrieben
assert.equal(c.get('role'), 'editor');
assert.equal(c.get('canModerate'), true);
assert.equal(c.get('isAdmin'), false);
});
test('requireAuth: fehlendes Token → 401', async () => {
const c = fakeCtx('');
const r = await requireAuth(c, async () => { throw new Error('darf nicht laufen'); });
assert.equal(r.__status, 401);
});
test('requireAuth: kaputtes/falsch signiertes Token → 401', async () => {
const bad = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) + 60 }, 'falsches-secret', 'HS256');
for (const t of ['Bearer garbage', 'Bearer ' + bad]) {
const r = await requireAuth(fakeCtx(t), async () => { throw new Error('darf nicht laufen'); });
assert.equal(r.__status, 401);
}
});
test('requireAuth: abgelaufenes Token → 401', async () => {
const expired = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) - 10 }, 'test-secret', 'HS256');
const r = await requireAuth(fakeCtx('Bearer ' + expired), async () => { throw new Error('darf nicht laufen'); });
assert.equal(r.__status, 401);
});
+46
View File
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { coalesce } = await import('../src/coalesce.js');
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
test('coalesce: nie mehr als ein Lauf gleichzeitig pro Key', async () => {
let active = 0, maxActive = 0, runs = 0;
const fn = async () => { active++; maxActive = Math.max(maxActive, active); await tick(10); runs++; active--; return runs; };
// 5 gleichzeitige Aufrufe.
await Promise.all(Array.from({ length: 5 }, () => coalesce('k1', fn)));
assert.equal(maxActive, 1, 'parallele Läufe');
// Erster Lauf bedient den ersten Aufruf; die 4 während des Laufs eingetroffenen
// teilen sich GENAU EINEN nachgelagerten Lauf → insgesamt 2.
assert.equal(runs, 2);
});
test('coalesce: Wartende teilen sich das Ergebnis des nachgelagerten Laufs', async () => {
let n = 0;
const fn = async () => { await tick(10); return ++n; };
const first = coalesce('k2', fn); // startet sofort → Ergebnis 1
await tick(2); // sicherstellen, dass er läuft
const a = coalesce('k2', fn); // wartet → nachgelagerter Lauf
const b = coalesce('k2', fn); // wartet → selber Lauf wie a
assert.equal(await first, 1);
const [ra, rb] = await Promise.all([a, b]);
assert.equal(ra, 2);
assert.equal(rb, 2); // a und b teilen sich Lauf 2
});
test('coalesce: Fehler wird an die Wartenden propagiert, Key bleibt nutzbar', async () => {
let fail = true;
const fn = async () => { await tick(5); if (fail) throw new Error('boom'); return 'ok'; };
await assert.rejects(() => coalesce('k3', fn), /boom/);
fail = false;
assert.equal(await coalesce('k3', fn), 'ok'); // danach wieder verwendbar
});
test('coalesce: verschiedene Keys laufen unabhängig', async () => {
const fn = async () => { await tick(5); return 'done'; };
const [x, y] = await Promise.all([coalesce('kA', fn), coalesce('kB', fn)]);
assert.equal(x, 'done');
assert.equal(y, 'done');
});
+101
View File
@@ -0,0 +1,101 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, buildPath, compareEntries } from '../src/collections.js';
import openbureau from '../../examples/openbureau.config.js';
import kgva from '../../examples/kgva.config.js';
const ob = openbureau.collections;
const kg = kgva.collections;
// --- openbureau: must reproduce the original hard-coded classify() 1:1 ---
test('classify (openbureau): Beitrag = archiv/<section>/<slug>', () => {
assert.deepEqual(classify('archiv/software/stack.md', ob), { kind: 'beitrag', section: 'software' });
assert.deepEqual(classify('archiv/buerofuehrung/x.md', ob), { kind: 'beitrag', section: 'buerofuehrung' });
});
test('classify (openbureau): Library = library/<slug>, section konstant', () => {
assert.deepEqual(classify('library/stack.md', ob), { kind: 'biblio', section: 'library' });
});
test('classify (openbureau): _index.md = Rubrik, section = Elternordner|home', () => {
assert.deepEqual(classify('_index.md', ob), { kind: 'rubrik', section: 'home' });
assert.deepEqual(classify('software/_index.md', ob), { kind: 'rubrik', section: 'software' });
});
test('classify (openbureau): alles andere = Seite (fallback)', () => {
assert.deepEqual(classify('manifest.md', ob), { kind: 'seite', section: null });
// _index hat Vorrang vor jedem Pfad-Pattern (wie im Original)
assert.deepEqual(classify('library/_index.md', ob), { kind: 'rubrik', section: 'library' });
// archiv strikt drei-tief: tiefer faellt auf Seite (Original: len !== 3)
assert.deepEqual(classify('archiv/software/a/b.md', ob), { kind: 'seite', section: null });
});
test('buildPath (openbureau): aus Pfad-Pattern + Formulardaten', () => {
assert.equal(buildPath('beitrag', { section: 'software', slug: 'neu' }, ob), 'archiv/software/neu.md');
assert.equal(buildPath('biblio', { slug: 'stack' }, ob), 'library/stack.md');
assert.equal(buildPath('seite', { slug: 'impressum' }, ob), 'impressum.md');
assert.equal(buildPath('beitrag', { section: 'software', slug: '' }, ob), '');
});
test('compareEntries (openbureau): Reihenfolge Beitrag<Library<Seite<Rubrik', () => {
const items = [
{ kind: 'rubrik', date: null, title: 'R' },
{ kind: 'seite', date: null, title: 'S' },
{ kind: 'beitrag', date: '2024-01-01', title: 'B' },
{ kind: 'biblio', date: null, title: 'L' },
];
const sorted = [...items].sort(compareEntries(ob)).map((e) => e.kind);
assert.deepEqual(sorted, ['beitrag', 'biblio', 'seite', 'rubrik']);
});
test('compareEntries: gleiche kind -> Datum absteigend, dann Titel', () => {
const items = [
{ kind: 'beitrag', date: '2023-05-01', title: 'B' },
{ kind: 'beitrag', date: '2024-05-01', title: 'A' },
{ kind: 'beitrag', date: '2024-05-01', title: 'C' },
];
const sorted = [...items].sort(compareEntries(ob)).map((e) => e.title);
assert.deepEqual(sorted, ['A', 'C', 'B']);
});
// --- kgva: a different content model, same engine ---
test('classify (kgva): Portfolio / Fallback-Page / Index-Section', () => {
assert.deepEqual(classify('portfolio/projekt.md', kg), { kind: 'project', section: 'portfolio' });
assert.deepEqual(classify('ueber.md', kg), { kind: 'page', section: null });
assert.deepEqual(classify('_index.md', kg), { kind: 'section', section: 'home' });
assert.deepEqual(classify('arbeiten/_index.md', kg), { kind: 'section', section: 'arbeiten' });
});
test('buildPath (kgva)', () => {
assert.equal(buildPath('project', { slug: 'haus' }, kg), 'portfolio/haus.md');
assert.equal(buildPath('page', { slug: 'ueber' }, kg), 'ueber.md');
});
import { contentStats } from '../src/collections.js';
test('contentStats (openbureau): statKey-Zähler + entwuerfe = Beitrag-Entwürfe', () => {
const entries = [
{ kind: 'beitrag', draft: false },
{ kind: 'beitrag', draft: true },
{ kind: 'beitrag', draft: true },
{ kind: 'biblio', draft: true }, // Library-Entwurf zählt NICHT als entwuerfe
{ kind: 'seite', draft: false },
{ kind: 'rubrik', draft: false },
];
assert.deepEqual(contentStats(entries, ob), {
beitraege: 3, entwuerfe: 2, library: 1, rubriken: 1, seiten: 1,
});
});
test('contentStats (kgva): nur deklarierte statKeys, kein entwuerfe', () => {
const entries = [
{ kind: 'project', draft: true },
{ kind: 'project', draft: false },
{ kind: 'page', draft: false }, // page hat keinen statKey
{ kind: 'section', draft: false }, // section hat keinen statKey
];
assert.deepEqual(contentStats(entries, kg), { projects: 2 });
});
+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
// Stage-5 cutover: index.js/stats.js/publish.js no longer hard-code the dialog;
// they go through the shared plugin manager singleton (src/plugins.js), loaded
// from config.plugins. This proves the singleton + the rewired route modules
// resolve (guards the moved-file import paths) — handlers aren't called, so
// dummy supabase/JWT env is enough, as in dialog-plugin.test.js.
process.env.CMS_CONFIG ||= fileURLToPath(new URL('../../examples/openbureau.config.js', import.meta.url));
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'x';
process.env.JWT_SECRET ||= 'x';
process.env.SITE_DIR ||= '/tmp/nosite';
const { manager } = await import('../src/plugins.js');
test('plugins.js: manager loaded from config.plugins (openbureau → dialog)', () => {
assert.deepEqual(manager.names(), ['dialog']);
assert.equal(manager.has('dialog'), true);
});
test('cutover: stats.js still resolves with the manager dependency', async () => {
const stats = (await import('../src/routes/stats.js')).default;
assert.equal(typeof stats.fetch, 'function'); // a Hono app
});
test('cutover: publish.js still resolves with the manager dependency', async () => {
const publish = (await import('../src/routes/publish.js')).default;
assert.equal(typeof publish.fetch, 'function');
});
test('cutover: collectStats exposes dialog under its plugin name', async () => {
// Counts hit a dummy supabase → each count() swallows the error and returns 0,
// but the SHAPE (the stats.js contract) must hold: { dialog: { forums, … } }.
const merged = await manager.collectStats({});
assert.deepEqual(merged.dialog, { forums: 0, threads: 0, comments: 0 });
const dialog = merged.dialog || { forums: 0, threads: 0, comments: 0 };
assert.deepEqual(Object.keys(dialog).sort(), ['comments', 'forums', 'threads']);
});
+53
View File
@@ -0,0 +1,53 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Hono } from 'hono';
// The dialog modules import supabase.js, which exits without these; dummies are
// enough since this test only checks structure/mounting, never calls a handler.
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'x';
process.env.JWT_SECRET ||= 'x';
process.env.SITE_DIR ||= '/tmp/nosite';
const { loadPlugins } = await import('../src/plugin-manager.js');
const m = await loadPlugins(['dialog']);
test('dialog plugin loads with a valid manifest', () => {
assert.deepEqual(m.names(), ['dialog']);
assert.equal(m.has('dialog'), true);
});
test('dialog: route tiers as in index.js', () => {
const routes = m.plugins[0].routes;
const pub = routes.filter((r) => r.public).map((r) => `${r.method || 'route'} ${r.path}`);
const priv = routes.filter((r) => !r.public).map((r) => `${r.method || 'route'} ${r.path}`);
assert.deepEqual(pub, [
'get /comments', 'get /forums', 'get /forums/:slug', 'get /recent', 'get /thread', 'post /auth/login',
]);
assert.deepEqual(priv, [
'post /comments', 'delete /comments/:id', 'post /threads', 'route /mod', 'route /admin/forums',
]);
});
test('dialog: widget login is rate-limited', () => {
const loginRoute = m.plugins[0].routes.find((r) => r.path === '/auth/login');
assert.equal(loginRoute.public, true);
assert.equal(Array.isArray(loginRoute.use) && loginRoute.use.length, 1);
});
test('dialog: lifecycle hooks + stats present', () => {
const p = m.plugins[0];
assert.equal(typeof p.onBoot, 'function');
assert.equal(typeof p.onPublish, 'function');
assert.equal(typeof p.stats, 'function');
});
test('dialog: mounts into the pipeline without throwing', () => {
const app = new Hono();
const requireAdmin = async (c, next) => next();
assert.doesNotThrow(() => {
m.mountPublic(app);
app.use('/api/*', async (c, next) => next());
m.mountPrivate(app, '/api', requireAdmin);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { safeRel, normAuthors, hasAccess, urlFor } = await import('../src/files.js');
test('safeRel: gültiger relativer .md-Pfad bleibt erhalten', () => {
assert.equal(safeRel('library/software/stack.md'), 'library/software/stack.md');
assert.equal(safeRel('a/./b.md'), 'a/b.md');
});
test('safeRel: Path-Traversal wird abgelehnt', () => {
assert.throws(() => safeRel('../etc/passwd.md'));
assert.throws(() => safeRel('a/../../b.md'));
assert.throws(() => safeRel('/absolut.md'));
});
test('safeRel: nur .md erlaubt, leer/falsch wirft', () => {
assert.throws(() => safeRel('note.txt'));
assert.throws(() => safeRel(''));
assert.throws(() => safeRel(null));
});
test('normAuthors: String/Array/Leer normalisieren', () => {
assert.deepEqual(normAuthors('a@x.ch'), ['a@x.ch']);
assert.deepEqual(normAuthors(['a@x.ch', 'b@y.ch']), ['a@x.ch', 'b@y.ch']);
assert.deepEqual(normAuthors(null), []);
assert.deepEqual(normAuthors([]), []);
});
test('hasAccess: case-insensitive Mitgliedschaft', () => {
assert.equal(hasAccess(['Karim@x.ch'], 'karim@x.ch'), true);
assert.equal(hasAccess(['a@x.ch'], 'b@y.ch'), false);
assert.equal(hasAccess([], 'a@x.ch'), false);
});
test('urlFor: Hugo-URLs aus relativem Pfad', () => {
assert.equal(urlFor('_index.md'), '/');
assert.equal(urlFor('manifest.md'), '/manifest/');
assert.equal(urlFor('library/software/stack.md'), '/library/software/stack/');
assert.equal(urlFor('software/_index.md'), '/software/');
});
+77
View File
@@ -0,0 +1,77 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runMigrations } from '../src/migrate.js';
import { createManager } from '../src/plugin-manager.js';
// Dialog modules import supabase.js, which exits without these (loadPlugins for
// the real dialog manifest in the last test touches them transitively).
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'x';
process.env.JWT_SECRET ||= 'x';
process.env.SITE_DIR ||= '/tmp/nosite';
// A fake SQL executor over an in-memory schema_migrations set. Records every
// non-bookkeeping statement so we can assert what actually ran.
function fakeExec(seed = []) {
const applied = new Set(seed);
const ran = [];
const exec = async (text, params) => {
if (/^\s*SELECT 1 FROM public\.schema_migrations/i.test(text)) {
return applied.has(params[0]) ? [{ '?column?': 1 }] : [];
}
if (/^\s*INSERT INTO public\.schema_migrations/i.test(text)) { applied.add(params[0]); return []; }
if (/^\s*(CREATE TABLE IF NOT EXISTS public\.schema_migrations|BEGIN|COMMIT|ROLLBACK)/i.test(text)) return [];
ran.push(text); // the actual migration file SQL
return [];
};
return { exec, ran, applied };
}
test('no declared migrations → no-op, never needs a DB', async () => {
const mgr = createManager([{ name: 'kgva-ish' }]); // no migrations
const res = await runMigrations(mgr, {}); // no exec, no databaseUrl — must not throw
assert.deepEqual(res, { applied: [], skipped: [], pending: false });
});
test('declared migrations but no DATABASE_URL → pending, nothing applied', async () => {
const mgr = createManager([{ name: 'p', migrations: ['001.sql'], __dir: new URL('file:///x/') }]);
const warns = [];
const res = await runMigrations(mgr, { log: { warn: (m) => warns.push(m) } });
assert.equal(res.pending, true);
assert.deepEqual(res.skipped, ['p/001.sql']);
assert.equal(res.applied.length, 0);
assert.match(warns[0], /DATABASE_URL fehlt/);
});
test('fresh apply: real dialog migration runs once and is recorded', async () => {
const { loadPlugins } = await import('../src/plugin-manager.js');
const mgr = await loadPlugins(['dialog']); // real manifest → real 001_dialog.sql url
const fx = fakeExec();
const res = await runMigrations(mgr, { exec: fx.exec, log: {} });
assert.deepEqual(res.applied, ['dialog/001_dialog.sql']);
assert.deepEqual(res.skipped, []);
assert.equal(fx.ran.length, 1);
assert.match(fx.ran[0], /create table if not exists public\.forums/i); // the captured DDL
assert.equal(fx.applied.has('dialog/001_dialog.sql'), true);
});
test('idempotent: already-recorded migration is skipped, file SQL not re-run', async () => {
const { loadPlugins } = await import('../src/plugin-manager.js');
const mgr = await loadPlugins(['dialog']);
const fx = fakeExec(['dialog/001_dialog.sql']); // pretend already applied
const res = await runMigrations(mgr, { exec: fx.exec, log: {} });
assert.deepEqual(res.skipped, ['dialog/001_dialog.sql']);
assert.deepEqual(res.applied, []);
assert.equal(fx.ran.length, 0); // file SQL never executed again
});
test('a failing migration rolls back and surfaces the id', async () => {
const mgr = createManager([{ name: 'boom', migrations: ['x.sql'], __dir: new URL('file:///does-not-exist/') }]);
const calls = [];
const exec = async (text) => {
calls.push(text.trim().split(/\s+/).slice(0, 2).join(' '));
return [];
};
// readFile on the missing url throws before BEGIN; ensure the error names the id.
await assert.rejects(() => runMigrations(mgr, { exec, log: {} }), /boom\/x\.sql/);
});
+113
View File
@@ -0,0 +1,113 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Hono } from 'hono';
import { createManager } from '../src/plugin-manager.js';
// Tiny sub-app that answers GET / with a fixed text.
function mk(text) { const a = new Hono(); a.get('/', (c) => c.text(text)); return a; }
function fixture() {
const state = { booted: false, published: null, previewed: null };
const demo = {
name: 'demo',
routes: [
// sub-app routes
{ path: '/demo-pub', app: mk('pub'), public: true },
{ path: '/demo-priv', app: mk('priv') },
{ path: '/demo-adm', app: mk('adm'), admin: true },
// handler routes (method + handler)
{ method: 'get', path: '/h-pub', handler: (c) => c.text('hpub'), public: true },
{ method: 'post', path: '/h-priv', handler: (c) => c.text('hpriv') },
// per-route middleware (e.g. a rate limit)
{
method: 'post', path: '/h-limited', public: true,
use: [async (c, next) => (c.req.header('x-pass') ? next() : c.json({ e: 'limited' }, 429))],
handler: (c) => c.text('ok'),
},
],
onBoot: async () => { state.booted = true; },
onPublish: async (_ctx, p) => { state.published = p.path; },
onPreview: async (_ctx, p) => { state.previewed = p.path; },
stats: async () => ({ widgets: 3 }),
migrations: ['001_demo.sql'],
};
return { demo, state };
}
// Build the same pipeline as index.js: public → requireAuth → private(+admin).
function buildApp(m) {
const app = new Hono();
m.mountPublic(app);
app.use('/api/*', async (c, next) => (c.req.header('x-auth') ? next() : c.json({ error: 'no auth' }, 401)));
const requireAdmin = async (c, next) => (c.req.header('x-admin') ? next() : c.json({ error: 'no admin' }, 403));
m.mountPrivate(app, '/api', requireAdmin);
return app;
}
test('manager: names / has', () => {
const { demo } = fixture();
const m = createManager([demo]);
assert.deepEqual(m.names(), ['demo']);
assert.equal(m.has('demo'), true);
assert.equal(m.has('nope'), false);
});
test('manager: public routes reachable WITHOUT auth (sub-app + handler)', async () => {
const app = buildApp(createManager([fixture().demo]));
const sub = await app.request('/api/demo-pub');
assert.equal(sub.status, 200); assert.equal(await sub.text(), 'pub');
const h = await app.request('/api/h-pub');
assert.equal(h.status, 200); assert.equal(await h.text(), 'hpub');
});
test('manager: private routes require auth (sub-app + handler)', async () => {
const app = buildApp(createManager([fixture().demo]));
assert.equal((await app.request('/api/demo-priv')).status, 401);
assert.equal((await app.request('/api/h-priv', { method: 'POST' })).status, 401);
const ok = await app.request('/api/h-priv', { method: 'POST', headers: { 'x-auth': '1' } });
assert.equal(ok.status, 200); assert.equal(await ok.text(), 'hpriv');
});
test('manager: admin route guarded by requireAdmin', async () => {
const app = buildApp(createManager([fixture().demo]));
assert.equal((await app.request('/api/demo-adm', { headers: { 'x-auth': '1' } })).status, 403);
const ok = await app.request('/api/demo-adm', { headers: { 'x-auth': '1', 'x-admin': '1' } });
assert.equal(ok.status, 200); assert.equal(await ok.text(), 'adm');
});
test('manager: per-route use middleware runs (rate-limit style)', async () => {
const app = buildApp(createManager([fixture().demo]));
assert.equal((await app.request('/api/h-limited', { method: 'POST' })).status, 429);
const ok = await app.request('/api/h-limited', { method: 'POST', headers: { 'x-pass': '1' } });
assert.equal(ok.status, 200); assert.equal(await ok.text(), 'ok');
});
test('manager: lifecycle hooks run', async () => {
const { demo, state } = fixture();
const m = createManager([demo]);
await m.runBoot({});
await m.runPublish({}, { path: 'a.md' });
await m.runPreview({}, { path: 'b.md' });
assert.equal(state.booted, true);
assert.equal(state.published, 'a.md');
assert.equal(state.previewed, 'b.md');
});
test('manager: stats merged under plugin name', async () => {
const m = createManager([fixture().demo]);
assert.deepEqual(await m.collectStats({}), { demo: { widgets: 3 } });
});
test('manager: declared migrations surfaced', () => {
const m = createManager([fixture().demo]);
assert.deepEqual(m.migrations(), [{ plugin: 'demo', file: '001_demo.sql', url: null }]);
});
test('manager: empty plugin set is a no-op', async () => {
const m = createManager([]);
assert.deepEqual(m.names(), []);
assert.deepEqual(await m.collectStats({}), {});
assert.deepEqual(m.migrations(), []);
const app = buildApp(m);
assert.equal((await app.request('/api/anything', { headers: { 'x-auth': '1' } })).status, 404);
});
+44
View File
@@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
const { rateLimit } = await import('../src/ratelimit.js');
function fakeCtx() {
const headers = {};
return {
req: { header: () => undefined },
header: (k, v) => { headers[k] = v; },
json: (body, status = 200) => ({ __status: status, body }),
_headers: headers,
};
}
test('rateLimit: blockt nach Überschreiten mit 429 + Retry-After', async () => {
const mw = rateLimit({ max: 2, windowMs: 10_000, keyFn: () => 'fix' });
let calls = 0;
const run = async () => { const c = fakeCtx(); const r = await mw(c, async () => { calls++; }); return { c, r }; };
assert.equal((await run()).r, undefined); // 1 → durch (next, kein Return)
assert.equal((await run()).r, undefined); // 2 → durch
const { c, r } = await run(); // 3 → blockiert
assert.equal(r.__status, 429);
assert.ok(c._headers['Retry-After']);
assert.equal(calls, 2); // next nur zweimal aufgerufen
});
test('rateLimit: getrennte Schlüssel zählen getrennt', async () => {
let key = 'a';
const mw = rateLimit({ max: 1, windowMs: 10_000, keyFn: () => key });
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // a:1 ok
assert.equal((await mw(fakeCtx(), async () => {})).__status, 429); // a:2 blockiert
key = 'b';
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // b:1 ok
});
test('rateLimit: Fenster läuft ab → wieder frei', async () => {
const mw = rateLimit({ max: 1, windowMs: 30, keyFn: () => 'win' });
assert.equal((await mw(fakeCtx(), async () => {})), undefined);
assert.equal((await mw(fakeCtx(), async () => {})).__status, 429);
await new Promise((r) => setTimeout(r, 40));
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // Fenster neu
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
process.env.CMS_CONFIG ||= fileURLToPath(new URL('../../examples/openbureau.config.js', import.meta.url));
process.env.SUPABASE_URL ||= 'http://localhost';
process.env.SUPABASE_SERVICE_KEY ||= 'x';
process.env.JWT_SECRET ||= 'x';
process.env.SITE_DIR ||= '/tmp/nosite';
const { systemInfo } = await import('../src/routes/system.js');
test('systemInfo reflects core version, plugins and content model', () => {
const info = systemInfo({ version: '0.6.0', hugo: '0.161.1+extended' });
assert.equal(info.core.name, 'openbureau-core');
assert.equal(info.core.version, '0.6.0');
assert.equal(info.site, 'openbureau');
assert.equal(info.auth, 'supabase'); // openbureau.config.js sets auth: 'supabase'
assert.equal(info.hugo, '0.161.1+extended');
assert.deepEqual(info.admins, ['karim@gabrielevarano.ch']);
// dialog plugin is active with its routes + the one migration
const dialog = info.plugins.find((p) => p.name === 'dialog');
assert.ok(dialog, 'dialog plugin listed');
assert.ok(dialog.routes > 0);
assert.equal(dialog.migrations, 1);
// content model surfaced from the config collections
assert.deepEqual(info.collections.map((c) => c.kind).sort(), ['beitrag', 'biblio', 'rubrik', 'seite']);
const beitrag = info.collections.find((c) => c.kind === 'beitrag');
assert.equal(beitrag.label, 'Beiträge');
assert.ok(beitrag.fields > 0);
});
+46
View File
@@ -0,0 +1,46 @@
// karimgabrielevarano.xyz — second consumer of openbureau-core.
// No dialog plugin; a portfolio-shaped content model.
export default {
site: 'kgva',
auth: 'local', // DB-less: file-based users + self-signed JWTs (no Supabase/Postgres)
admins: ['karim@gabrielevarano.ch'],
plugins: [],
collections: [
{
kind: 'project', label: 'Portfolio', order: 0,
path: 'portfolio/:slug',
statKey: 'projects',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'slug', type: 'slug' },
{ name: 'date', type: 'date' },
{ name: 'draft', type: 'bool', default: true },
{ name: 'thumbnail', type: 'image' },
{ name: 'studies', type: 'string', hint: '/studies/hslu/ba/semester_05' },
{ name: 'schools', type: 'list' },
{ name: 'degrees', type: 'list' },
{ name: 'semesters', type: 'list' },
{ name: 'video', type: 'string', hint: '/media/x.mp4' },
{ name: 'images', type: 'list', of: { src: 'image', name: 'string' } },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'page', label: 'Pages', order: 1, fallback: true,
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'description', type: 'string' },
{ name: 'layout', type: 'string' },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'section', label: 'Sections', order: 2, index: true,
fields: [
{ name: 'title', type: 'string' },
{ name: 'description', type: 'string' },
],
},
],
};
+74
View File
@@ -0,0 +1,74 @@
// openbureau site — reproduces the current hard-coded content model 1:1.
// Once the engine is config-driven, openbureau consumes openbureau-core with
// exactly this config and behaves as today.
export default {
site: 'openbureau',
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: 'color' },
{ name: 'layout', type: 'select', options: ['text', 'image', 'icon'], 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: 'color' },
{ 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' },
],
},
],
};
+69 -1
View File
@@ -31,6 +31,7 @@ create index if not exists posts_section_idx on public.posts (section);
-- RLS aktivieren; die api nutzt den Service-Key (umgeht RLS). Wenn das -- RLS aktivieren; die api nutzt den Service-Key (umgeht RLS). Wenn das
-- Frontend später direkt liest, hier gezielte Policies ergänzen. -- Frontend später direkt liest, hier gezielte Policies ergänzen.
alter table public.posts enable row level security; alter table public.posts enable row level security;
revoke all on public.posts from anon, authenticated;
-- ── Dialog / Diskussionen ─────────────────────────────────────────────── -- ── Dialog / Diskussionen ───────────────────────────────────────────────
-- Thread = Pfad des Beitrags (z.B. /library/software/stack/). Flache Wortmeldungen -- Thread = Pfad des Beitrags (z.B. /library/software/stack/). Flache Wortmeldungen
@@ -46,6 +47,73 @@ create table if not exists public.comments (
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
deleted boolean not null default false deleted boolean not null default false
); );
-- Position/Rolle bei OPENBUREAU (optional, neben dem Namen angezeigt).
alter table public.comments add column if not exists author_role text;
create index if not exists comments_thread_idx on public.comments (thread, created_at); create index if not exists comments_thread_idx on public.comments (thread, created_at);
alter table public.comments enable row level security; alter table public.comments enable row level security;
grant all on public.comments to anon, authenticated, service_role; -- Nur die Node-API (service_role) greift auf die Tabellen zu; der Browser geht
-- ausschliesslich über /api/*. anon/authenticated bekommen KEINE Tabellenrechte,
-- damit das öffentlich erreichbare /rest/v1 auch bei künftigen RLS-Policies dicht
-- bleibt (Defense-in-Depth, nicht nur "RLS ohne Policy").
grant all on public.comments to service_role;
revoke all on public.comments from anon, authenticated;
-- Aggregat je Thread (Anzahl + letzte Aktivität). Spart der API den Full-Table-
-- Scan + JS-Aggregation bei jedem Forum-Aufruf; Postgres zählt direkt.
create or replace view public.comment_stats as
select thread, count(*)::int as count, max(created_at) as last
from public.comments
where not deleted
group by thread;
grant select on public.comment_stats to service_role;
-- ── Foren / Subforen ────────────────────────────────────────────────────
-- Kategorien, in denen Threads leben. Admin-verwaltet. `kind=library` ist die
-- Sonder-Kategorie, in der die Library-Beiträge automatisch als Threads landen.
create table if not exists public.forums (
id uuid primary key default gen_random_uuid(),
slug text not null unique,
name text not null,
description text default '',
color text, -- optionale Akzentfarbe
sort int not null default 0,
kind text not null default 'forum', -- forum | library
created_at timestamptz not null default now()
);
alter table public.forums enable row level security;
grant all on public.forums to service_role;
revoke all on public.forums from anon, authenticated;
-- ── Threads (Diskussionen) ──────────────────────────────────────────────
-- key = stabiler Bezeichner, den comments.thread referenziert:
-- Forum-Thread → 't/<uuid>'
-- Library-Beitrag → Beitragspfad (z.B. /library/software/stack/)
create table if not exists public.threads (
id uuid primary key default gen_random_uuid(),
forum_id uuid references public.forums(id) on delete cascade,
key text not null unique,
title text not null,
url text, -- Ziel-Link (Library: Beitragspfad)
kind text not null default 'forum', -- forum | library
author_name text,
user_id uuid,
locked boolean not null default false,
deleted boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists threads_forum_idx on public.threads (forum_id);
alter table public.threads enable row level security;
grant all on public.threads to service_role;
revoke all on public.threads from anon, authenticated;
-- Seed-Kategorien (idempotent; im Admin umbenenn-/erweiterbar).
insert into public.forums (slug, name, sort, kind) values
('allgemein', 'Allgemein', 10, 'forum'),
('projekte', 'Projekte', 20, 'forum'),
('technik', 'Technik', 30, 'forum'),
('off-topic', 'Off-Topic', 40, 'forum')
on conflict (slug) do nothing;
-- Sonder-Kategorie für die Library-Beiträge.
insert into public.forums (slug, name, sort, kind) values
('beitraege', 'Beiträge', 0, 'library')
on conflict (slug) do nothing;
+142
View File
@@ -0,0 +1,142 @@
-- OPENBUREAU — OPTIONALER Demo-Inhalt fürs Forum (Dialog).
-- ─────────────────────────────────────────────────────────────────────────
-- NICHT Teil der Migration: bewusst getrennt von schema.sql, damit die
-- Produktion sauber bleibt. Nur bei Bedarf manuell einspielen, z.B.:
--
-- docker compose exec -T db \
-- psql -U supabase_admin -d postgres < db/seed-demo.sql
--
-- Idempotent: feste UUIDs + ON CONFLICT DO NOTHING → mehrfaches Einspielen
-- erzeugt keine Duplikate. Demo-Wortmeldungen haben user_id = NULL (keine
-- echten Konten); author_name/author_role sind nur Anzeigetext.
--
-- Wieder entfernen: siehe DELETE-Block ganz unten (auskommentiert).
-- ─────────────────────────────────────────────────────────────────────────
-- ── Threads ───────────────────────────────────────────────────────────────
insert into public.threads (id, forum_id, key, title, url, kind, author_name, created_at) values
('a1111111-1111-1111-1111-111111111101',
(select id from public.forums where slug = 'allgemein'),
't/a1111111-1111-1111-1111-111111111101',
'Willkommen im OPENBUREAU-Dialog',
'/dialog/?thread=t%2Fa1111111-1111-1111-1111-111111111101',
'forum', 'Karim', now() - interval '9 days'),
('a2222222-2222-2222-2222-222222222202',
(select id from public.forums where slug = 'projekte'),
't/a2222222-2222-2222-2222-222222222202',
'Altbausanierung Zürich — Materialwahl Innendämmung',
'/dialog/?thread=t%2Fa2222222-2222-2222-2222-222222222202',
'forum', 'Karim', now() - interval '6 days'),
('a3333333-3333-3333-3333-333333333303',
(select id from public.forums where slug = 'technik'),
't/a3333333-3333-3333-3333-333333333303',
'Hugo-Build-Zeiten bei großen Bildmengen',
'/dialog/?thread=t%2Fa3333333-3333-3333-3333-333333333303',
'forum', 'Mara', now() - interval '3 days'),
('a4444444-4444-4444-4444-444444444404',
(select id from public.forums where slug = 'off-topic'),
't/a4444444-4444-4444-4444-444444444404',
'Welches Architekturbuch hat euch geprägt?',
'/dialog/?thread=t%2Fa4444444-4444-4444-4444-444444444404',
'forum', 'Jonas', now() - interval '2 days')
on conflict (key) do nothing;
-- ── Wortmeldungen ─────────────────────────────────────────────────────────
-- parent_id verweist auf eine andere Wortmeldung (Antwort-Bezug).
insert into public.comments (id, thread, parent_id, author_name, author_role, body, created_at) values
-- Thread 1: Willkommen
('c1111111-0000-0000-0000-000000000001',
't/a1111111-1111-1111-1111-111111111101', null, 'Karim', 'Gründer',
'Hallo zusammen — dieser Bereich ist für den offenen Austausch rund ums Büro: Projekte, Methoden, Werkzeuge. Lest euch ein, schreibt mit.',
now() - interval '9 days'),
('c1111111-0000-0000-0000-000000000002',
't/a1111111-1111-1111-1111-111111111101',
'c1111111-0000-0000-0000-000000000001', 'Mara', 'Projektarchitektin',
'Schön, dass es losgeht. Gibt es eine Empfehlung, wie wir Projektdiskussionen von allgemeinem Plausch trennen?',
now() - interval '8 days'),
('c1111111-0000-0000-0000-000000000003',
't/a1111111-1111-1111-1111-111111111101',
'c1111111-0000-0000-0000-000000000002', 'Karim', 'Gründer',
'Genau dafür gibt es die Kategorie „Projekte". „Off-Topic" ist für alles andere.',
now() - interval '8 days'),
-- Thread 2: Innendämmung
('c2222222-0000-0000-0000-000000000001',
't/a2222222-2222-2222-2222-222222222202', null, 'Karim', 'Gründer',
'Beim Altbau an der Seestrasse steht die Innendämmung an. Kalziumsilikat oder mineralischer Dämmputz? Erfahrungen mit Feuchteverhalten?',
now() - interval '6 days'),
('c2222222-0000-0000-0000-000000000002',
't/a2222222-2222-2222-2222-222222222202',
'c2222222-0000-0000-0000-000000000001', 'Mara', 'Projektarchitektin',
'Kalziumsilikat ist diffusionsoffen und kapillaraktiv — bei den Bestandswänden dort würde ich das vorziehen. Wichtig ist die Detailausbildung an den Holzbalkenköpfen.',
now() - interval '5 days'),
('c2222222-0000-0000-0000-000000000003',
't/a2222222-2222-2222-2222-222222222202',
'c2222222-0000-0000-0000-000000000002', 'Jonas', 'Bauleiter',
'Plus eins für Kalziumsilikat. Ich hänge nächste Woche die hygrothermische Simulation an, dann sehen wir die Tauwasserbilanz.',
now() - interval '4 days'),
-- Thread 3: Hugo-Builds
('c3333333-0000-0000-0000-000000000001',
't/a3333333-3333-3333-3333-333333333303', null, 'Mara', 'Projektarchitektin',
'Seit die Projektgalerien dazugekommen sind, dauert der Build spürbar länger. Hat jemand die Bildverarbeitung schon optimiert?',
now() - interval '3 days'),
('c3333333-0000-0000-0000-000000000002',
't/a3333333-3333-3333-3333-333333333303',
'c3333333-0000-0000-0000-000000000001', 'Karim', 'Gründer',
'Hugo cached die Image-Resizes unter resources/. Solange der Ordner erhalten bleibt, werden nur neue Bilder neu gerechnet — das war bei uns der größte Hebel.',
now() - interval '2 days'),
-- Thread 4: Architekturbuch
('c4444444-0000-0000-0000-000000000001',
't/a4444444-4444-4444-4444-444444444404', null, 'Jonas', 'Bauleiter',
'Bei mir war es „Atmosphären" von Peter Zumthor — schmal, aber prägend. Was hat euch geformt?',
now() - interval '2 days'),
('c4444444-0000-0000-0000-000000000002',
't/a4444444-4444-4444-4444-444444444404',
'c4444444-0000-0000-0000-000000000001', 'Mara', 'Projektarchitektin',
'„Complexity and Contradiction" von Venturi — hat mein Verständnis von Fassaden komplett verschoben.',
now() - interval '1 day')
on conflict (id) do nothing;
-- ── Wortmeldungen auf den Musterbeiträgen (Dialog am Artikel) ─────────────
-- thread = Beitrags-URL (Library-Beiträge werden als Threads gespiegelt).
insert into public.comments (id, thread, parent_id, author_name, author_role, body, created_at) values
('d1111111-0000-0000-0000-000000000001',
'/library/theorie/muster-typologie-fussnoten/', null, 'Mara', 'Projektarchitektin',
'Schöne Verdichtung. Würdest du Léon Krier hier dazustellen, oder ist das eine andere Debatte?',
now() - interval '4 days'),
('d1111111-0000-0000-0000-000000000002',
'/library/theorie/muster-typologie-fussnoten/',
'd1111111-0000-0000-0000-000000000001', 'Karim', 'Gründer',
'Eigene Debatte — Krier zielt auf die Wiederherstellung der Stadt. Hier ging es mir nur um Typus vs. Modell.',
now() - interval '3 days'),
('d2222222-0000-0000-0000-000000000001',
'/library/buerofuehrung/muster-offen-arbeiten/', null, 'Jonas', 'Bauleiter',
'Die Lizenzfrage unterschätzen viele. Gut, das einmal klar getrennt zu sehen.',
now() - interval '2 days'),
('d3333333-0000-0000-0000-000000000001',
'/library/software/muster-werkzeugkette/', null, 'Mara', 'Projektarchitektin',
'Ein Befehl ist Gold wert. Läuft der Build bei euch auch im CMS-Container?',
now() - interval '1 day'),
('d3333333-0000-0000-0000-000000000002',
'/library/software/muster-werkzeugkette/',
'd3333333-0000-0000-0000-000000000001', 'Karim', 'Gründer',
'Genau — der Container hat das Hugo-Binary, Publish baut public/ direkt.',
now() - interval '12 hours')
on conflict (id) do nothing;
-- ── Demo-Inhalt wieder entfernen (bei Bedarf auskommentieren) ──────────────
-- delete from public.comments where id::text like 'c_______-0000-0000-0000-%';
-- delete from public.comments where id::text like 'd_______-0000-0000-0000-%';
-- delete from public.threads where key in (
-- 't/a1111111-1111-1111-1111-111111111101',
-- 't/a2222222-2222-2222-2222-222222222202',
-- 't/a3333333-3333-3333-3333-333333333303',
-- 't/a4444444-4444-4444-4444-444444444404');
-- (Musterbeitrag-Threads verschwinden mit den .md-Dateien automatisch.)
+50 -4
View File
@@ -6,7 +6,10 @@
# 2. JWT_SECRET + POSTGRES_PASSWORD setzen (openssl rand -hex 32) # 2. JWT_SECRET + POSTGRES_PASSWORD setzen (openssl rand -hex 32)
# 3. node scripts/generate-keys.mjs → ANON_KEY + SERVICE_ROLE_KEY in .env # 3. node scripts/generate-keys.mjs → ANON_KEY + SERVICE_ROLE_KEY in .env
# 4. SITE_URL + API_EXTERNAL_URL auf die LAN-/Domain-Adresse setzen # 4. SITE_URL + API_EXTERNAL_URL auf die LAN-/Domain-Adresse setzen
# 5. kong.yml: __CORS_ORIGIN__ durch SITE_URL ersetzen (Browser-Origin)
# 6. BIND_ADDR: 127.0.0.1 hinter Reverse-Proxy, 0.0.0.0 für LAN-Direkt
# #
# (Das Proxmox-Script erledigt 16 automatisch.)
# Dann: docker compose up -d --build # Dann: docker compose up -d --build
# #
# Abweichung von RAPPORT: realtime + storage weggelassen (nutzt das CMS nicht). # Abweichung von RAPPORT: realtime + storage weggelassen (nutzt das CMS nicht).
@@ -41,6 +44,30 @@ services:
retries: 20 retries: 20
start_period: 30s start_period: 30s
# ════════════════════════════════════════════════════════════════════════
# Migrate — spielt das (idempotente) Schema bei jedem `up` nach, damit neue
# Tabellen/Spalten auch in eine BESTEHENDE DB kommen (Init-Scripts laufen nur
# beim allerersten Start). Läuft einmal und beendet sich. supabase_admin =
# Superuser → keine Owner-Konflikte. schema.sql ist idempotent.
# ════════════════════════════════════════════════════════════════════════
migrate:
image: supabase/postgres:15.8.1.020
container_name: openbureau-migrate
restart: "no"
depends_on:
db:
condition: service_healthy
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
volumes:
- ./db/schema.sql:/openbureau-schema.sql:ro
entrypoint: ["bash", "-c"]
command:
- >
psql -h db -U supabase_admin -d postgres -v ON_ERROR_STOP=1 -f /openbureau-schema.sql &&
psql -h db -U supabase_admin -d postgres -c "notify pgrst, 'reload schema';" &&
echo '✓ Schema migriert.'
# ════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════
# GoTrue — Auth (Login für das CMS) # GoTrue — Auth (Login für das CMS)
# ════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════
@@ -62,6 +89,10 @@ services:
# Single-Author: Self-Signup aus. User wird per Admin-API angelegt # Single-Author: Self-Signup aus. User wird per Admin-API angelegt
# (Kommando steht im README / LXC-Output). # (Kommando steht im README / LXC-Output).
GOTRUE_DISABLE_SIGNUP: "true" GOTRUE_DISABLE_SIGNUP: "true"
# Brute-Force-Bremse aufs /token: das öffentliche Login läuft direkt gegen
# GoTrue (nicht über das Node-Rate-Limit), daher hier kappen — max. 100
# Token-Anfragen / 5 Min. Reichlich für einen Autor, bremst Rateversuche.
GOTRUE_RATE_LIMIT_TOKEN_REFRESH: "100"
GOTRUE_JWT_ADMIN_ROLES: service_role GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
@@ -105,15 +136,20 @@ services:
volumes: volumes:
- ./kong.yml:/var/lib/kong/kong.yml:ro - ./kong.yml:/var/lib/kong/kong.yml:ro
ports: ports:
- "${KONG_HTTP_PORT:-8000}:8000" # Standard 127.0.0.1: nur lokal/Reverse-Proxy erreichbar. Für LAN-Direkt-
- "${KONG_HTTPS_PORT:-8443}:8443" # zugriff ohne Proxy BIND_ADDR=0.0.0.0 in .env setzen.
- "${BIND_ADDR:-127.0.0.1}:${KONG_HTTP_PORT:-8000}:8000"
- "${BIND_ADDR:-127.0.0.1}:${KONG_HTTPS_PORT:-8443}:8443"
# ════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════
# CMS — Node-API + Hugo-Binary + Admin-SPA, serviert die Site # CMS — Node-API + Hugo-Binary + Admin-SPA, serviert die Site
# ════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════
cms: cms:
build: 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 dockerfile: api/Dockerfile
args: args:
# Browser-seitig (Admin-SPA, zur Build-Zeit): öffentliche Supabase-URL. # Browser-seitig (Admin-SPA, zur Build-Zeit): öffentliche Supabase-URL.
@@ -124,14 +160,23 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
migrate:
condition: service_completed_successfully
kong: kong:
condition: service_started condition: service_started
environment: environment:
# Server-seitig: intern über Kong, mit Service-Key. # Server-seitig: intern über Kong, mit Service-Key.
SUPABASE_URL: http://kong:8000 SUPABASE_URL: http://kong:8000
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
# Für lokale JWT-Verifikation (kein GoTrue-Roundtrip pro Request).
JWT_SECRET: ${JWT_SECRET}
ADMIN_EMAILS: ${ADMIN_EMAILS:-} ADMIN_EMAILS: ${ADMIN_EMAILS:-}
SITE_DIR: /site 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 PORT: 3000
GIT_PUBLISH: ${GIT_PUBLISH:-false} GIT_PUBLISH: ${GIT_PUBLISH:-false}
GIT_REMOTE: ${GIT_REMOTE:-origin} GIT_REMOTE: ${GIT_REMOTE:-origin}
@@ -142,7 +187,8 @@ services:
# Repo-Root: api schreibt content/ und baut public/ + preview/. # Repo-Root: api schreibt content/ und baut public/ + preview/.
- ..:/site - ..:/site
ports: ports:
- "${APP_PORT:-8080}:3000" # Wie Kong: standardmäßig nur 127.0.0.1 (hinter Reverse-Proxy).
- "${BIND_ADDR:-127.0.0.1}:${APP_PORT:-8080}:3000"
volumes: volumes:
postgres-data: postgres-data:
+20
View File
@@ -15,6 +15,16 @@ services:
- /auth/v1/ - /auth/v1/
plugins: plugins:
- name: cors - name: cors
config:
# Nur die eigene Browser-Origin erlauben (nicht „*"). __CORS_ORIGIN__
# wird beim Provisionieren auf SITE_URL gesetzt (siehe Proxmox-Script);
# bei Domain/HTTPS-Wechsel hier bzw. in .env mitziehen.
origins:
- __CORS_ORIGIN__
methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
headers: [Accept, Authorization, Content-Type, apikey, x-client-info, x-supabase-api-version]
credentials: false
max_age: 3600
- name: rest-v1 - name: rest-v1
url: http://rest:3000/ url: http://rest:3000/
@@ -25,3 +35,13 @@ services:
- /rest/v1/ - /rest/v1/
plugins: plugins:
- name: cors - name: cors
config:
# Nur die eigene Browser-Origin erlauben (nicht „*"). __CORS_ORIGIN__
# wird beim Provisionieren auf SITE_URL gesetzt (siehe Proxmox-Script);
# bei Domain/HTTPS-Wechsel hier bzw. in .env mitziehen.
origins:
- __CORS_ORIGIN__
methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
headers: [Accept, Authorization, Content-Type, apikey, x-client-info, x-supabase-api-version]
credentials: false
max_age: 3600
+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: 'color' },
{ name: 'layout', type: 'select', options: ['text', 'image', 'icon'], 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: 'color' },
{ 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' },
],
},
],
};
+55 -13
View File
@@ -13,23 +13,30 @@
set -euo pipefail set -euo pipefail
############################ CONFIG ############################ ############################ CONFIG ############################
# Alle Werte sind per Umgebungsvariable überschreibbar, z.B.:
# ROOTFS_STORAGE=local-zfs HOSTNAME=openbureau-dev SITE_DOMAIN=dev.openbureau.ch \
# IP=192.168.1.134/24 GATEWAY=192.168.1.1 bash create-openbureau-lxc.sh
CTID="${CTID:-$(pvesh get /cluster/nextid)}" CTID="${CTID:-$(pvesh get /cluster/nextid)}"
HOSTNAME="openbureau" HOSTNAME="${HOSTNAME:-openbureau}"
# Storage # Storage
TEMPLATE_STORAGE="local" TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
ROOTFS_STORAGE="local-lvm" ROOTFS_STORAGE="${ROOTFS_STORAGE:-local-lvm}"
DISK_GB="20" # Supabase + CMS DISK_GB="${DISK_GB:-20}" # Supabase + CMS
# Ressourcen # Ressourcen
RAM_MB="4096" RAM_MB="${RAM_MB:-4096}"
SWAP_MB="1024" SWAP_MB="${SWAP_MB:-1024}"
CORES="2" CORES="${CORES:-2}"
# Netzwerk # Netzwerk
BRIDGE="vmbr0" BRIDGE="${BRIDGE:-vmbr0}"
IP="dhcp" # "dhcp" ODER statisch z.B. "192.168.1.50/24" IP="${IP:-dhcp}" # "dhcp" ODER statisch z.B. "192.168.1.50/24"
GATEWAY="" # nur bei statischer IP GATEWAY="${GATEWAY:-}" # nur bei statischer IP
# Öffentliche Domain hinter einem Reverse-Proxy (Caddy o.ä.) mit Pfad-Routing
# (/auth/* + /rest/* → :8000, Rest → :8080). Leer = LAN-Direktzugriff per IP:Port.
SITE_DOMAIN="${SITE_DOMAIN:-}"
# Zugang # Zugang
SSH_PUBKEY_FILE="${SSH_PUBKEY_FILE:-$HOME/.ssh/id_ed25519.pub}" SSH_PUBKEY_FILE="${SSH_PUBKEY_FILE:-$HOME/.ssh/id_ed25519.pub}"
@@ -142,24 +149,59 @@ pct exec "$CTID" -- bash -euo pipefail -c "
sed -i \"s|^ANON_KEY=.*|ANON_KEY=\${ANON}|\" .env sed -i \"s|^ANON_KEY=.*|ANON_KEY=\${ANON}|\" .env
sed -i \"s|^SERVICE_ROLE_KEY=.*|SERVICE_ROLE_KEY=\${SVC}|\" .env sed -i \"s|^SERVICE_ROLE_KEY=.*|SERVICE_ROLE_KEY=\${SVC}|\" .env
# URLs auf die Container-IP setzen # URLs setzen — bei gesetzter SITE_DOMAIN auf die öffentliche HTTPS-Domain
# (Browser ruft /auth/* + /rest/* same-origin auf, der Proxy routet sie an
# :8000), sonst auf die Container-IP fürs LAN.
HOSTIP=\$(hostname -I | awk '{print \$1}') HOSTIP=\$(hostname -I | awk '{print \$1}')
sed -i \"s|^SITE_URL=.*|SITE_URL=http://\${HOSTIP}:8080|\" .env SITE_DOMAIN='${SITE_DOMAIN}'
sed -i \"s|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://\${HOSTIP}:8000|\" .env if [ -n \"\$SITE_DOMAIN\" ]; then
SITE_URL=\"https://\$SITE_DOMAIN\"; API_URL=\"https://\$SITE_DOMAIN\"
else
SITE_URL=\"http://\${HOSTIP}:8080\"; API_URL=\"http://\${HOSTIP}:8000\"
fi
sed -i \"s|^SITE_URL=.*|SITE_URL=\${SITE_URL}|\" .env
sed -i \"s|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=\${API_URL}|\" .env
sed -i \"s|^ADMIN_EMAILS=.*|ADMIN_EMAILS=${ADMIN_EMAIL}|\" .env sed -i \"s|^ADMIN_EMAILS=.*|ADMIN_EMAILS=${ADMIN_EMAIL}|\" .env
# Auf allen Interfaces lauschen, damit Reverse-Proxy bzw. LAN drankommen.
sed -i \"s|^BIND_ADDR=.*|BIND_ADDR=0.0.0.0|\" .env
# CORS auf die Browser-Origin (= SITE_URL) festnageln statt „*\".
sed -i \"s|__CORS_ORIGIN__|\${SITE_URL}|g\" kong.yml
echo 'OK: .env generiert.' echo 'OK: .env generiert.'
fi fi
# Der CMS-Container läuft als non-root (uid 1000). Das gemountete Repo muss
# ihm gehören, damit Hugo public/ bauen und content/ schreiben kann.
chown -R 1000:1000 '${APP_DIR}'
if [ '${COMPOSE_UP}' = 'true' ]; then if [ '${COMPOSE_UP}' = 'true' ]; then
echo '→ Baue + starte Stack (dauert beim ersten Mal ein paar Minuten)…' echo '→ Baue + starte Stack (dauert beim ersten Mal ein paar Minuten)…'
docker compose up -d --build docker compose up -d --build
fi fi
# Tägliches DB-Backup (3:15 Uhr) — Dialog-Daten liegen NUR in Postgres.
printf 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n15 3 * * * root cd ${APP_DIR}/cms && bash scripts/backup-db.sh >> /var/log/openbureau-backup.log 2>&1\n' > /etc/cron.d/openbureau-backup
echo 'OK: tägliches DB-Backup eingerichtet (/etc/cron.d/openbureau-backup).'
" "
# --- 5. Abschluss -------------------------------------------------------- # --- 5. Abschluss --------------------------------------------------------
IPADDR="$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')" IPADDR="$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')"
say "Fertig. LXC $CTID läuft${IPADDR:+ unter $IPADDR}." say "Fertig. LXC $CTID läuft${IPADDR:+ unter $IPADDR}."
if [ -n "$SITE_DOMAIN" ]; then
cat <<EOF
Öffentlich: https://${SITE_DOMAIN} (sobald der Reverse-Proxy auf ${IPADDR:-<ip>} zeigt)
Caddy-Block: ${SITE_DOMAIN} {
# Nur /auth/* muss public ans Gateway (Browser-Login). Daten
# laufen über /api/* (Node spricht kong intern an). /rest, /storage,
# /realtime NICHT exponieren — unnötige Angriffsfläche.
@auth path /auth/*
reverse_proxy @auth ${IPADDR:-<ip>}:8000
reverse_proxy ${IPADDR:-<ip>}:8080
}
EOF
fi
cat <<EOF cat <<EOF
Admin: http://${IPADDR:-<ip>}:8080/admin/ Admin: http://${IPADDR:-<ip>}:8080/admin/
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# OPENBUREAU — Backup der Postgres-DB.
#
# WICHTIG: Foren, Threads und Wortmeldungen (Dialog) leben NUR in Postgres —
# anders als content/*.md sind sie NICHT in Git. Ohne Backup sind sie beim
# Verlust des Volumes weg. Dieses Skript dumpt die ganze DB komprimiert weg.
#
# Auf dem Host/LXC im cms/-Verzeichnis ausführen (oder per Cron, siehe README):
# bash scripts/backup-db.sh
#
# Wiederherstellen:
# gunzip -c backups/openbureau-<TS>.sql.gz \
# | docker compose exec -T db psql -U supabase_admin -d postgres
set -euo pipefail
# Ins cms/-Verzeichnis (eine Ebene über scripts/).
cd "$(dirname "$0")/.."
DIR="${BACKUP_DIR:-./backups}"
KEEP="${BACKUP_KEEP:-14}" # wie viele Dumps behalten
mkdir -p "$DIR"
TS="$(date +%Y%m%d-%H%M%S)"
OUT="$DIR/openbureau-$TS.sql.gz"
docker compose exec -T db pg_dump -U supabase_admin -d postgres | gzip > "$OUT"
echo "✓ Backup: $OUT ($(du -h "$OUT" | cut -f1))"
# Rotation: nur die letzten $KEEP Dumps behalten.
ls -1t "$DIR"/openbureau-*.sql.gz 2>/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -f
Executable
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# OPENBUREAU — Update im LXC in einem Rutsch.
# Statt `git pull` direkt: holt den Code, rendert die Deploy-Config, setzt die
# Dateirechte für den non-root-Container und startet den Stack neu.
#
# Im Container (als root) ausführen:
# bash /opt/openbureau/cms/update.sh
set -euo pipefail
# Repo-Root = eine Ebene über diesem Skript (cms/..).
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# Git läuft hier als root auf einem Repo, das dem Container-User (uid 1000)
# gehört → ohne das meckert Git über „dubious ownership".
git config --global --add safe.directory "$ROOT" 2>/dev/null || true
echo "→ git pull…"
# kong.yml wird beim Deploy lokal gerendert (CORS-Origin eingesetzt). Vor dem
# Pull auf die versionierte Vorlage (mit __CORS_ORIGIN__) zurücksetzen, sonst
# kollidiert der Pull mit der lokalen Änderung.
git checkout -- cms/kong.yml 2>/dev/null || true
git pull --ff-only
cd "$ROOT/cms"
# CORS-Origin aus SITE_URL (.env) in kong.yml einsetzen — eine Quelle der Wahrheit.
ORIGIN="$(grep -E '^SITE_URL=' .env | head -1 | cut -d= -f2-)"
if [ -n "${ORIGIN:-}" ]; then
sed -i "s|__CORS_ORIGIN__|${ORIGIN}|g" kong.yml
echo "✓ CORS-Origin gesetzt: ${ORIGIN}"
else
echo "WARN: SITE_URL in .env nicht gefunden — kong.yml-Origin bleibt Platzhalter."
fi
# Der CMS-Container läuft als uid 1000 und muss das ganze Repo schreiben können
# (Hugo baut public/, schreibt content/). git pull als root zieht neue Dateien
# als root → hier wieder geradeziehen.
chown -R 1000:1000 "$ROOT"
echo "✓ Dateirechte (uid 1000) gesetzt."
echo "→ docker compose up…"
docker compose up -d --build
# kong liest die declarative config nur beim Start — nach kong.yml-Änderung neu.
docker compose restart kong
echo "✓ Stack neu gestartet."
# Kurzer Healthcheck (localhost im LXC, unabhängig von BIND_ADDR).
PORT="$(grep -E '^APP_PORT=' .env | head -1 | cut -d= -f2-)"; PORT="${PORT:-8080}"
sleep 2
if curl -fsS -I "http://127.0.0.1:${PORT}/" >/dev/null 2>&1; then
echo "✓ Seite antwortet auf :${PORT}."
else
echo "WARN: Seite antwortet (noch) nicht auf :${PORT} — 'docker compose ps' prüfen."
fi
+6
View File
@@ -0,0 +1,6 @@
---
title: "Archiv"
description: "Fertige Texte des Büros, nach Thema geordnet."
---
Fertige Texte, nach Thema geordnet. Das **Journal** auf der Startseite zeigt dieselben Inhalte chronologisch.
@@ -0,0 +1,17 @@
---
title: "Im Offenen arbeiten"
date: 2026-05-30
tags: ["büroführung", "open-source", "muster"]
summary: "Warum ein offenes Büro robuster ist — und wie Lizenzen das absichern. (Musterbeitrag mit Fußnoten.)"
color: sakura
layout: text
---
Offen zu arbeiten heißt nicht, alles zu verschenken. Es heißt, die Grundlagen so zu teilen, dass andere darauf aufbauen können — und dass die Arbeit den Wechsel von Werkzeugen, Mitarbeitenden und Jahren übersteht.
Die rechtliche Absicherung dafür sind Lizenzen. Inhalte auf OPENBUREAU stehen unter CC BY-SA,[^ccbysa] der Code überwiegend unter AGPL oder MIT.[^agpl] Beide sorgen dafür, dass Offenheit weitergegeben wird, statt verloren zu gehen.
Verwandt: Der [Werkzeug-Stack](/archiv/software/stack/) zeigt, womit wir das konkret tun.
[^ccbysa]: Creative Commons, *Attribution-ShareAlike 4.0 International* (CC BY-SA 4.0), <https://creativecommons.org/licenses/by-sa/4.0/>.
[^agpl]: Free Software Foundation, *GNU Affero General Public License v3.0*, 2007.
@@ -14,4 +14,4 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor i
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet — [Werkzeuge](/library/werkzeuge). Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet — [Werkzeuge](/archiv/werkzeuge).
@@ -0,0 +1,21 @@
---
title: "Die Werkzeugkette"
date: 2026-06-01
tags: ["software", "werkzeuge", "muster"]
summary: "Wie DOSSIER, RAPPORT und die Site zusammenspielen. (Musterbeitrag mit Fußnoten und Code.)"
color: yuyake
layout: text
---
Die Werkzeuge des Büros sind keine Inseln, sondern eine Kette: [DOSSIER](/archiv/software/dossier/) hält die Projektdaten, [RAPPORT](/archiv/software/rapport/) erzeugt Berichte daraus, und diese Site veröffentlicht, was öffentlich sein soll.
Der Build ist bewusst banal — ein Befehl:[^hugo]
```sh
hugo --minify --destination public
```
Alles dateibasiert, alles versioniert. Wer den Stand von gestern braucht, fragt Git, nicht ein Backup.[^git]
[^hugo]: Hugo, *The world's fastest framework for building websites*, <https://gohugo.io>.
[^git]: Versionierung ersetzt kein Backup — aber sie macht jede Änderung nachvollziehbar; siehe „Verlauf" unter jedem Beitrag.
@@ -0,0 +1,118 @@
---
title: "Proxmox, Schritt für Schritt"
date: 2026-06-02
tags: ["software", "proxmox", "self-hosting", "anleitung", "lxc"]
summary: "Wie aus einer gebrauchten Kiste die Infrastruktur eines Architekturbüros wird — mit den Skripten, die einen Dienst in Minuten aufstellen."
color: kusa
layout: text
---
Die Kiste aus dem [ersten Teil](/archiv/software/server-im-eigenen-haus/) muss man nicht streicheln können, um sie zu verstehen. Es genügt ein Bild: Proxmox macht aus einem Rechner ein Mehrfamilienhaus. Das Haus ist die Maschine, die Wohnungen sind die Container, und in jeder Wohnung lebt genau ein Dienst — die Website, die Zeiterfassung, der Dateispeicher. Niemand stört den anderen, jeder hat seine eigene Tür, und zieht eine Partei aus, bleiben die übrigen, wo sie sind.
Dieser Text zeigt, wie man das Haus baut und die erste Wohnung bezieht. Er setzt keine Erfahrung mit Servern voraus, nur die Bereitschaft, einen Befehl abzutippen und zu lesen, was er antwortet.
## Das Fundament
Proxmox VE ist im Kern ein Debian-Linux mit einer Weboberfläche und der Fähigkeit, zweierlei Sorten Wohnungen zu vermieten: vollwertige virtuelle Maschinen und — das ist unser Fall — Linux-Container, sogenannte LXC. Ein Container teilt sich den Kern des Wirts und ist deshalb sparsam: Vier Gigabyte Arbeitsspeicher reichen für einen ausgewachsenen Dienst, ein Dutzend davon laufen auf gewöhnlicher Bürohardware.
Installiert wird Proxmox einmalig vom USB-Stick, so wie man ein Betriebssystem installiert. Das ist gut dokumentiert und hier nicht das Thema. Ab dem Moment, in dem die Weboberfläche unter `https://<ip>:8006` erscheint, beginnt der interessante Teil.
## Das Muster: ein Container, ein Dienst, ein Befehl
Wir richten keinen Dienst von Hand ein. Jeder Handgriff, den man zweimal macht, gehört in ein Skript — schon weil man ihn sonst beim Wiederaufsetzen vergisst. Unser Muster, von Dienst zu Dienst gleich, lautet:
1. einen **unprivilegierten** LXC anlegen (er darf weniger, also kann weniger schiefgehen),
2. ihn so einstellen, dass **Docker** darin läuft (`nesting` und `keyctl`),
3. den Dienst als **Docker-Compose-Stack** hineinstellen,
4. alle **Geheimnisse automatisch erzeugen** lassen, nichts von Hand eintippen,
5. ein **Backup** einrichten, bevor überhaupt Daten da sind.
Das ist die ganze Liturgie. Wer sie einmal in ein Skript gegossen hat, stellt den nächsten Dienst hin, indem er das Skript ruft.
## Die erste Wohnung: unser CMS
Diese Website ist das Musterbeispiel. Ein einziger Befehl, abgesetzt auf dem Proxmox-Wirt als `root`, baut den ganzen Container — Docker, das Repository, sämtliche Schlüssel, der laufende Stack:
```bash
bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/cms/proxmox/create-openbureau-lxc.sh)
```
Das Skript fragt nur nach Speicherort, Netzwerkbrücke und IP — Enter übernimmt je den Vorschlag — und ist nach wenigen Minuten fertig. Am Ende nennt es die Adressen: den Editor unter `…:8080/admin/`, die Website unter `…:8080/`.
Spannend ist nicht der Einzeiler, sondern was er tut. Das [vollständige Skript](https://git.kgva.ch/karim/OPENBUREAU/src/branch/main/cms/proxmox/create-openbureau-lxc.sh) liest sich von oben nach unten wie ein Protokoll. Den Container anlegen, mit den zwei Schaltern, die Docker erlauben:
```bash
pct create "$CTID" "$TEMPLATE_REF" \
--hostname openbureau \
--cores 2 --memory 4096 --swap 1024 \
--rootfs "local-lvm:20" \
--net0 "name=eth0,bridge=vmbr0,ip=dhcp" \
--unprivileged 1 \
--features "nesting=1,keyctl=1" \
--onboot 1
```
Dann, im Container, Docker installieren, das Repository ziehen und — der Teil, der einem die durchwachte Nacht erspart — die Geheimnisse erzeugen, statt sie von Hand zu setzen:
```bash
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
git clone https://git.kgva.ch/karim/OPENBUREAU.git /opt/openbureau
cd /opt/openbureau/cms
cp .env.example .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(openssl rand -hex 32)|" .env
sed -i "s|^JWT_SECRET=.*|JWT_SECRET=$(openssl rand -hex 32)|" .env
docker compose up -d --build
```
Und schliesslich, noch bevor der erste Beitrag geschrieben ist, das tägliche Backup — denn das Forum lebt allein in der Datenbank, nicht im Git:
```bash
printf '15 3 * * * root cd /opt/openbureau/cms && bash scripts/backup-db.sh\n' \
> /etc/cron.d/openbureau-backup
```
Kein Schritt davon ist klug; jeder ist nur aufgeschrieben. Das ist der ganze Trick.
## Ein Menü statt Handarbeit
Weil das Muster sich von Dienst zu Dienst wiederholt, haben wir es ein einziges Mal in ein Installationsskript gegossen. Es ruft sich genauso wie das CMS-Skript — ein Einzeiler auf dem Proxmox-Wirt, als `root` —, nur legt es kein bestimmtes Programm fest, sondern fragt, was man haben will:
```bash
bash <(curl -fsSL https://git.kgva.ch/karim/OPENBUREAU/raw/branch/main/proxmox/install.sh)
```
Zuerst fragt es nicht nach Technik, sondern nach dem Vorhaben: ein ganzes Büro einrichten, bloss Office 365 und die Synology ersetzen, nur die öffentliche Website — oder, für jene, die genau wissen, was sie wollen, einzeln auswählen. Aus der Antwort leitet das Skript ab, welche Container es braucht, und baut sie der Reihe nach. Jeder bekommt seine eigene Wohnung; für jede erledigt das Skript dasselbe, was oben Schritt für Schritt stand: Template holen, unprivilegierten Container anlegen, Docker hineinlegen, den Dienst starten.
Wer das Menü überspringen will, hängt den gewünschten Dienst direkt an:
```bash
… install.sh nextcloud # nur Nextcloud
… install.sh empty dateien 200 8192 # leerer Docker-LXC, 200 GB / 8 GB RAM
… install.sh git git.kgva.ch/karim/RAPPORT-SERVER.git rapport
```
Hinter dem Menü steckt keine grosse Maschine, sondern ein Bündel kleiner, eigenständiger Skripte — eines pro Dienst. Die Suite ist nur der Dialog, der sie der Reihe nach aufruft. Wer das Menü gar nicht braucht, lädt das einzelne Skript direkt:
```bash
bash <(curl -fsSL …/proxmox/nextcloud-lxc.sh) # 500 GB / 8 GB RAM
bash <(curl -fsSL …/proxmox/empty-lxc.sh) dateien 200 8192
bash <(curl -fsSL …/proxmox/git-compose-lxc.sh) git.kgva.ch/karim/RAPPORT-SERVER.git rapport
```
Jedes dieser Skripte ist in sich geschlossen und tut, was oben Schritt für Schritt stand: Template holen, unprivilegierten Container anlegen, Docker hineinlegen, den Dienst starten. Genau diese Wiederholbarkeit ist der Sinn der Übung — ein Dienst, den man nicht mit einem Befehl neu aufsetzen kann, ist ein Dienst, vor dem man sich beim nächsten Mal fürchtet.
## Office 365 und die Synology ersetzen: Nextcloud
Der grösste Brocken verdient einen eigenen Blick, weil er am meisten ersetzt. [Nextcloud](https://nextcloud.com) übernimmt in einem Aufwasch, wofür sonst zwei Abos und eine NAS herhalten: die Dateiablage mit Synchronisation auf alle Geräte — das OneDrive- und Synology-Drive-Erbe —, gemeinsame Kalender und Kontakte, dazu über das eingebaute Office das Bearbeiten von Dokumenten und Tabellen im Browser, zu zweit am selben Text.
Im Menü ist es ein Haken, von Hand der Befehl oben. Was dann läuft, ist die offizielle All-in-One-Variante: ein verwalteter Container, der die übrigen selbst aufsetzt. Den Rest erledigt die Weboberfläche unter Port 8080 — sie vergibt das Admin-Passwort, fragt die Domain ab und startet die eigentlichen Dienste. Ohne eigene Domain erreicht man das Ganze vorerst im lokalen Netz; für den Zugriff von aussen kommt später ein Reverse-Proxy davor, dasselbe Prinzip, das auch unser CMS hinter TLS bringt.
Damit ist die Rechnung geschlossen: Mail, Kalender, Kontakte, Dateien, gemeinsame Dokumente — alles, wofür das Büro bisher Monat für Monat pro Kopf bezahlt hat, läuft im Schrank. Und unsere eigenen Werkzeuge, RAPPORT und DOSSIER, ziehen über denselben Git-Eintrag im Menü nach, weil sie demselben Muster folgen wie alles andere.
## Das Backup ist kein Anhang
Ein Satz zum Schluss, der eigentlich an den Anfang gehört. Selbst zu hosten heisst, selbst für die Sicherung geradezustehen. Zweierlei greift bei uns ineinander. Innerhalb jedes Dienstes sichert ein nächtlicher cron-Lauf die Datenbank weg — bei dieser Website das Forum, bei Nextcloud die Metadaten. Und für die Container als Ganzes nimmt der **Proxmox Backup Server** allabendlich einen Schnappschuss, aus dem sich eine ganze Wohnung in Minuten wiederherstellen lässt, sollte sie einmal abbrennen.
Ein Backup, das man nie zurückgespielt hat, ist eine Hoffnung, kein Backup. Darum gehört der erste Wiederherstellungs-Versuch an den Tag, an dem der Dienst aufgesetzt wird — nicht an den Tag, an dem man ihn braucht.
@@ -0,0 +1,36 @@
---
title: "Server im eigenen Haus"
date: 2026-06-02
tags: ["software", "proxmox", "self-hosting", "infrastruktur"]
summary: "Warum unsere Dienste auf einer eigenen Kiste laufen statt bei Microsoft, Google oder Synology — und was das mit Architektur zu tun hat."
color: yuyake
layout: text
---
Im Schrank neben dem Plotter steht jetzt eine Kiste. Kein schönes Gerät, ein ausgemusterter Bürorechner mit zu vielen Lüftern, der leise vor sich hin rauscht. Auf ihm liegt, was sonst über ein halbes Dutzend Abonnements verteilt wäre: die Korrespondenz, die Pläne, die Zeiterfassung, diese Website. Das Büro hat seine Daten nach Hause geholt.
Lange war das anders, und lange fiel es nicht auf. Ein Architekturbüro produziert Daten, bevor es das erste Gebäude produziert — Wettbewerbsbeiträge, Pläne in dreissig Revisionen, Honorarabrechnungen, die Korrespondenz mit Bauherrschaft und Amt. Dieser Bestand wächst still, und ebenso still ist er in die Cloud gewandert. Microsoft 365 für Mail und Dokumente, OneDrive oder die Synology im Keller für die Dateien, ein gemietetes CRM für die Adressen. Jedes Stück für sich vernünftig, zusammen ein Büro, dessen Substanz auf fremden Servern liegt, zu Bedingungen, die ein anderer schreibt.
Das funktioniert tadellos. Es ist bequem. Und es heisst, dass das Gedächtnis des Büros zur Miete wohnt.
## Die Praxis besitzt ihre Werkzeuge
Dass wir das umdrehen, ist keine Prinzipienreiterei, sondern eine Konsequenz aus dem [Manifest](/manifest/): Ein Büro offen zu führen heisst auch, die eigenen Werkzeuge zu besitzen — so wie ein Schreiner seine Hobel besitzt und nicht pro Span bezahlt. Wer die Werkzeuge mietet, mietet am Ende die eigene Arbeitsweise.
Der Hobel ist in diesem Fall die Kiste im Schrank. Darauf läuft Proxmox, eine quelloffene Software, die aus einem gewöhnlichen Rechner viele kleine, sauber getrennte Maschinen macht. In jeder steckt ein Dienst: diese Website samt dem Editor, mit dem dieser Text geschrieben wurde; RAPPORT, unsere Zeiterfassung; DOSSIER, die Projektablage; der Dateispeicher, der die Synology ablöst; Kalender, Kontakte, Mail. Alles offen, alles auf den eigenen Platten. Was vorher Monat für Monat pro Kopf abgebucht wurde, deckt die gebrauchte Hardware in unter einem Jahr.
## Was das mit Architektur zu tun hat
Mehr, als es zunächst scheint. Wo die Daten einer Bauherrschaft liegen, ist keine Geschmacksfrage, sondern eine des Anstands und des Datenschutzes. Eine Maschine im eigenen Haus beantwortet die Frage, wo die anvertrauten Unterlagen sind, mit einem Fingerzeig auf den Schrank — nicht mit einem Verweis auf Rechenzentren in einer anderen Rechtsordnung.
Dazu kommt die schlichte Unkündbarkeit. Verdoppelt ein Anbieter den Preis, streicht eine Funktion oder stellt das Produkt ein, ist das sein gutes Recht; man steht daneben und zahlt. Bei uns gibt es nichts, das gekündigt werden kann. Die Formate sind offen — die Texte dieser Bibliothek etwa sind schlichte Textdateien, lesbar auch dann, wenn unser ganzer Apparat einmal verschwindet.
Und schliesslich behandelt ein offenes Büro seine Infrastruktur wie einen Entwurf: Man versteht sie, ändert sie, dokumentiert sie. Dieser Aufbau ist deshalb kein Betriebsgeheimnis, sondern steht [unter freier Lizenz](/lizenz/) offen. Wer sein Büro ähnlich einrichten will, kopiert unsere Skripte und macht weiter.
## Der Preis der Selbstverständlichkeit
Bleibt die unbequeme Seite, und sie gehört in jeden ehrlichen Text dieser Art: Man wird sein eigener Hauswart. Backups laufen nicht mehr von allein, Aktualisierungen muss jemand einspielen, und fällt der Strom, klingelt kein Support.
Tragbar finden wir das aus zwei Gründen. Der Aufwand ist kleiner, als er klingt, sobald die Handgriffe automatisiert sind — einen neuen Dienst aufzusetzen ist bei uns ein einziger Befehl und kein verlorener Nachmittag. Und die Kontrolle ist den Rest wert: Lieber für ein Backup geradestehen, das man versteht, als sich auf eines verlassen, das man nie gesehen hat.
Wie die Kiste im Schrank konkret eingerichtet ist — die Maschine, die Container, die Befehle, mit denen ein Dienst in Minuten steht — steht im zweiten Teil: [Proxmox, Schritt für Schritt](/archiv/software/proxmox-schritt-fuer-schritt/).
@@ -28,7 +28,7 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor i
## Lorem Ipsum IV ## Lorem Ipsum IV
- **[DOSSIER](/library/software/dossier/)** — lorem ipsum. - **[DOSSIER](/archiv/software/dossier/)** — lorem ipsum.
- **[RAPPORT](/library/software/rapport/)** — dolor sit amet. - **[RAPPORT](/archiv/software/rapport/)** — dolor sit amet.
— sed ut perspiciatis unde omnis iste natus error sit voluptatem. — sed ut perspiciatis unde omnis iste natus error sit voluptatem.
@@ -0,0 +1,18 @@
---
title: "Typus und Modell"
date: 2026-05-28
tags: ["theorie", "typologie", "muster"]
summary: "Eine kurze Unterscheidung — und warum sie fürs Entwerfen praktisch ist. (Musterbeitrag mit Fußnoten.)"
color: kusa
layout: text
---
Quatremère de Quincy trennte im frühen 19. Jahrhundert *Typus* und *Modell*: Das Modell ist die exakte Vorlage zum Kopieren, der Typus dagegen ein Prinzip, das viele verschiedene Werke begründen kann.[^quatremere]
Rafael Moneo griff diese Idee 1978 wieder auf und machte sie für die Praxis brauchbar — der Typus ist kein Käfig, sondern ein Ausgangspunkt, gegen den man entwirft.[^moneo] Aldo Rossi schließlich verband den Typus mit der Stadt: als dauerhaftes Element, das den Wandel überdauert.[^rossi]
Fürs Büro heißt das konkret: Wer den Typus einer Aufgabe versteht, entwirft nicht aus dem Nichts, sondern variiert bewusst. Das spart Zeit und macht Entscheidungen begründbar.
[^quatremere]: Antoine-Chrysostome Quatremère de Quincy, *Encyclopédie méthodique. Architecture*, Bd. 3, Paris 1825, Stichwort „Type".
[^moneo]: Rafael Moneo, „On Typology", in: *Oppositions* 13 (1978), S. 2245.
[^rossi]: Aldo Rossi, *L'architettura della città*, Padua 1966.
+45
View File
@@ -0,0 +1,45 @@
---
title: "Impressum"
toc: false
showreadingtime: false
aliases:
- /datenschutz/
---
## Kontakt
[karim@gabrielevarano.ch](mailto:karim@gabrielevarano.ch)
Oder direkt im [Dialog](/dialog/).
## Verantwortlich
Karim Varano\
Fluhmühlerain 1\
6015 Luzern
Privatperson
## Datenschutz
OPENBUREAU ist selbst-gehostet — ohne Werbung, ohne Tracker, ohne Analyse-Dienste Dritter. So wenig Daten wie möglich zu erheben, ist Teil der Idee.
### Beim Besuch der Seite
Beim Aufruf werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, aufgerufene Seite, Browsertyp) kurzzeitig in Server-Logs erfasst — ausschließlich für Betrieb und Sicherheit. Diese Daten werden nicht mit anderen Quellen zusammengeführt, nicht für Werbung verwendet und nicht an Dritte weitergegeben.
Es kommen **keine** Tracking-Cookies, **keine** Analyse-Werkzeuge (etwa Google Analytics) und **keine** Werbenetzwerke zum Einsatz.
### Dialog (Mitschreiben)
Lesen ist anonym. Wer im [Dialog](/dialog/) mitschreibt, legt ein Konto an: dafür werden eine E-Mail-Adresse und ein angezeigter Name gespeichert. Deine Wortmeldungen erscheinen mit diesem Namen öffentlich. Nach dem Login wird ein Sitzungs-Token lokal in deinem Browser (localStorage) abgelegt — kein serverseitiges Tracking.
Diese Daten liegen auf eigener Infrastruktur in Luzern (self-hosted) und werden nicht an Dritte weitergegeben.
### Deine Rechte
Nach dem Schweizer Datenschutzgesetz (revDSG) hast du das Recht auf Auskunft, Berichtigung und Löschung deiner Daten — eine kurze Mail genügt. Beschwerden kannst du beim Eidgenössischen Datenschutz- und Öffentlichkeitsbeauftragten (EDÖB) einreichen.
### Offenheit
Die Seite ist quelloffen; der Code ist einsehbar. Zu Lizenzen und Technik siehe [Colophon](/colophon/).
+2 -3
View File
@@ -1,7 +1,6 @@
--- ---
title: "Library" title: "Library"
description: "Die Bibliothek von OPENBUREAU — Texte, Notizen, Recherchen." summary: "Notizen zu Abläufen, Begriffen und Werkzeugen des Büros."
--- ---
Die Library ist die Sammlung. Alles, was gelesen und geschrieben wird, lebt hier. Notizen zu Abläufen, Begriffen und Werkzeugen aus dem Büroalltag. Fertige Texte stehen im [Archiv](/archiv/). Ergänzungen im CMS oder direkt im Repository.
Texte werden thematisch organisiert; das **Journal** auf der Startseite zeigt dieselben Inhalte chronologisch.
+25
View File
@@ -0,0 +1,25 @@
---
title: "Dateiablage & Benennung"
group: "Konventionen"
summary: "Wie Projektdateien heissen, damit man sie in fünf Jahren noch findet."
toc: true
---
Eine Konvention ist nur dann eine, wenn sich alle daran halten. Dies ist ein Vorschlag, kein Gesetz — verbessern erwünscht.
## Projektordner
Jedes Projekt liegt unter `Projekte/JJJJ_Nummer_Kurzname/`, z. B. `2026_014_Mehrfamilienhaus-Seeblick/`. Das Jahr vorne sortiert chronologisch, die Nummer ist eindeutig, der Kurzname macht es lesbar.
## Dateinamen
`JJMMTT_Projekt_Inhalt_vNN` — Datum zuerst (sortiert sich selbst), dann was es ist, dann die Version:
- `260604_Seeblick_Grundriss-EG_v03.pdf`
- `260604_Seeblick_Kostenschaetzung_v01.xlsx`
Keine Umlaute, keine Leerzeichen, keine Sonderzeichen — Bindestrich trennt Wörter, Unterstrich trennt Felder.
## Versionen
`vNN` zählt hoch, nichts wird überschrieben. Die jeweils gültige Fassung bekommt keinen Sonderstatus im Namen — das erledigt das Datum. Wer mit Git arbeitet, lässt die Versionsnummer weg und vertraut der Historie.
+14
View File
@@ -0,0 +1,14 @@
---
title: "Typus"
group: "Begriffe"
summary: "Ein Prinzip, das viele Werke begründet — nicht die Vorlage zum Kopieren."
---
Der **Typus** ist nicht das fertige Vorbild, sondern das zugrunde liegende Prinzip einer Bauaufgabe: das, was eine Markthalle zur Markthalle macht, unabhängig von Ort, Material und Epoche. Vom *Modell* unterscheidet er sich darin, dass man ihn nicht kopiert, sondern gegen ihn entwirft.[^quatremere]
Fürs Büro ist der Typus ein Werkzeug der Ökonomie: Wer den Typus einer Aufgabe kennt, beginnt nicht bei null, sondern variiert bewusst — und kann die eigenen Entscheidungen begründen.[^moneo]
Ausführlicher im Archiv: [Typus und Modell](/archiv/theorie/muster-typologie-fussnoten/).
[^quatremere]: Antoine-Chrysostome Quatremère de Quincy, *Encyclopédie méthodique. Architecture*, Bd. 3, Paris 1825, Stichwort „Type".
[^moneo]: Rafael Moneo, „On Typology", in: *Oppositions* 13 (1978), S. 2245.
@@ -0,0 +1,24 @@
---
title: "Wie die Library funktioniert"
group: "Werkstatt"
summary: "Kleine Seiten, klare Titel, viele Verweise."
toc: true
---
Die Library ist kein Lexikon, das jemand fertigstellt, sondern ein gemeinsames Gedächtnis, das beim Arbeiten entsteht. Ein paar Konventionen halten sie übersichtlich.
## Eine Seite, ein Begriff
Lieber viele kleine Seiten als wenige grosse. Eine Seite behandelt einen Begriff, einen Handgriff, eine Entscheidung. Passt etwas nicht mehr auf eine Bildschirmseite, wird es meist zwei Themen sein.
## Verweise
Seiten verweisen mit gewöhnlichen Markdown-Links aufeinander — `[Typus](/library/typus/)` — und gerne auch ins [Archiv](/archiv/), wenn ein Gedanke dort ausführlicher steht. Verlinken ist die eigentliche Arbeit: Eine Notiz, auf die nichts zeigt, findet niemand.
## Gruppen
Das Feld `group` im Frontmatter sortiert eine Seite in einen Bereich der Übersicht — z. B. `group: "Begriffe"`. Seiten ohne Gruppe landen unter „Allgemein". Mehr Struktur braucht es selten.
## Bearbeiten
Jede Seite hat unten einen **bearbeiten**-Link, der direkt ins Repository führt. Wer lieber im Redaktions-Editor arbeitet, legt eine Seite vom Typ *Library* an und füllt Titel, Gruppe und Text.

Some files were not shown because too many files have changed in this diff Show More