Compare commits

...

3 Commits

Author SHA1 Message Date
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 2bf27f01be Merge commit '80eca1bc7c1174f9c44a8df9af4ddc8ab855291b' as 'cms/core' 2026-06-30 00:09:44 +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
56 changed files with 1638 additions and 109 deletions
-47
View File
@@ -1,47 +0,0 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { listEntries } from '../files.js';
import { requireAdmin, roleOf } from '../auth.js';
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
const stats = new Hono();
stats.use('*', requireAdmin);
stats.get('/', async (c) => {
// Inhalte aus dem Dateisystem zählen.
const content = { beitraege: 0, entwuerfe: 0, library: 0, seiten: 0, rubriken: 0 };
try {
for (const e of await listEntries()) {
if (e.kind === 'beitrag') { content.beitraege++; if (e.draft) content.entwuerfe++; }
else if (e.kind === 'biblio') content.library++;
else if (e.kind === 'rubrik') content.rubriken++;
else content.seiten++;
}
} catch { /* Filesystem nicht lesbar → 0 */ }
// Nutzer nach Rolle.
const users = { total: 0, admin: 0, editor: 0, user: 0 };
try {
const { data } = await supabase.auth.admin.listUsers();
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
} catch { /* GoTrue nicht erreichbar */ }
// Dialog-Zähler (effizient: head + count, keine Zeilen laden).
const count = async (table, filter) => {
try {
let q = supabase.from(table).select('*', { count: 'exact', head: true });
if (filter) q = filter(q);
const { count: n } = await q;
return n || 0;
} catch { return 0; }
};
const [forums, threads, comments] = await Promise.all([
count('forums'),
count('threads', (q) => q.eq('deleted', false)),
count('comments', (q) => q.eq('deleted', false)),
]);
return c.json({ content, users, dialog: { forums, threads, comments } });
});
export default stats;
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
.env.*
*.log
.DS_Store
+174
View File
@@ -0,0 +1,174 @@
# 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.
- **Immediate next action:** 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. admin: `/api/schema` + App.jsx renders from schema (must reproduce openbureau's
editor 1:1 when fed `openbureau.config.js`).
7. **auth provider** (`config.auth` = `supabase` | `local`). Verify is already local
(HS256/JWT_SECRET in auth.js). Add a `local` provider: file-based users (bcrypt) +
self-signed JWTs of the SAME claim shape (sub/email/app_metadata.role/exp), and a
`/login` route to replace GoTrue; admin login (`admin/src/supabase.js`) calls it
instead of `signInWithPassword`; users.js CRUD edits the user file. Goal: a
dialog-less core runs Node+Hugo+nginx, **no Supabase/Postgres** (~80 MB). openbureau
stays `supabase`. Independent of the cutover — `supabase` is the default no-op.
8. cut openbureau over to consume core (= core + `dialog` plugin + `supabase` auth +
its config) — test login / list / edit / preview / publish / dialog — identical.
9. onboard kgva (`CMS_CONFIG=…/kgva.config.js`, `auth: 'local'`, no plugins) as the
2nd consumer — DB-less.
## 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)
@@ -13,6 +13,7 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hono": "^4.6.14", "hono": "^4.6.14",
"marked": "^14.1.4", "marked": "^14.1.4",
"pg": "^8.22.0",
"sharp": "^0.33.5" "sharp": "^0.33.5"
} }
}, },
@@ -685,6 +686,134 @@
"node": ">= 18" "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",
@@ -758,6 +887,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",
@@ -778,6 +916,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"
}
} }
} }
} }
@@ -15,6 +15,7 @@
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"hono": "^4.6.14", "hono": "^4.6.14",
"marked": "^14.1.4", "marked": "^14.1.4",
"pg": "^8.22.0",
"sharp": "^0.33.5" "sharp": "^0.33.5"
} }
} }
+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;
}
+28
View File
@@ -0,0 +1,28 @@
// Per-site CMS configuration loader.
//
// A site tells the core what it manages through one module, referenced by the
// CMS_CONFIG env var (e.g. CMS_CONFIG=/site/cms.config.js). The module exports
// `default` with { collections, plugins, admins }. Loaded once at startup; the
// engine (files/stats/routes) derives its behaviour from it instead of having
// openbureau-specific content types hard-coded.
//
// See ../../README.md for the schema, and ../../examples/*.config.js.
import { pathToFileURL } from 'node:url';
const p = process.env.CMS_CONFIG;
if (!p) {
console.error('FEHLT: CMS_CONFIG (Pfad zur Site-Config, z. B. /site/cms.config.js)');
process.exit(1);
}
const mod = await import(pathToFileURL(p).href);
export const config = mod.default || mod;
config.collections ||= [];
config.plugins ||= [];
config.admins ||= [];
export const hasPlugin = (name) => config.plugins.includes(name);
// Collection lookup helpers (used by the generalised files.js / stats.js).
export const collectionOf = (kind) => config.collections.find((c) => c.kind === kind) || null;
export const fallbackCollection = () => config.collections.find((c) => c.fallback) || null;
@@ -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,24 +27,6 @@ async function walk(dir) {
return out; return out;
} }
// Beitrag (archiv/<section>/<slug>.md) | Library-Seite (library/<slug>.md)
// | Rubrik (_index.md) | Seite (sonst).
function classify(rel) {
const base = path.basename(rel);
const parts = rel.split('/');
if (base === '_index.md') {
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
return { kind: 'rubrik', section };
}
if (parts[0] === 'archiv' && parts.length === 3) {
return { kind: 'beitrag', section: parts[1] };
}
if (parts[0] === 'library') {
return { kind: 'biblio', section: 'library' };
}
return { kind: 'seite', section: null };
}
// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt). // 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);
@@ -66,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) {
@@ -77,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,
@@ -86,12 +72,8 @@ export async function listEntries() {
url: urlFor(rel), url: urlFor(rel),
}); });
} }
// Beiträge zuerst, dann Library, Seiten, Rubriken; je nach Datum/Titel. // Reihenfolge aus der Config (collection.order), dann Datum, dann Titel.
const order = { beitrag: 0, biblio: 1, seite: 2, rubrik: 3 }; 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;
} }
@@ -12,13 +12,10 @@ import upload from './routes/upload.js';
import profile from './routes/profile.js'; import profile from './routes/profile.js';
import users from './routes/users.js'; import users from './routes/users.js';
import stats from './routes/stats.js'; import stats from './routes/stats.js';
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
import history from './routes/history.js'; import history from './routes/history.js';
import { import { requireAuth, requireAdmin } from './auth.js';
listForums, showForum, recent, threadInfo, newThread, mod, adminForums, import { manager } from './plugins.js';
} from './routes/dialog.js'; import { runMigrations } from './migrate.js';
import { requireAuth } from './auth.js';
import { syncLibrary } from './dialog-store.js';
const SITE_DIR = process.env.SITE_DIR || '/site'; const SITE_DIR = process.env.SITE_DIR || '/site';
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist'; const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
@@ -61,16 +58,12 @@ app.use('/api/*', (c, next) =>
c.req.path.startsWith('/api/upload') ? next() : jsonBodyLimit(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' })); 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. // Öffentliche Plugin-Routen (ohne Login) — je nach config.plugins, z. B. Dialog
app.get('/api/comments', listComments); // lesen + Widget-Login. Jede Route bringt ihre eigenen Limits mit (dialog drosselt
app.get('/api/forums', listForums); // den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
app.get('/api/forums/:slug', showForum); manager.mountPublic(app);
app.get('/api/recent', recent);
app.get('/api/thread', threadInfo);
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar. // Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
app.route('/api/history', history); app.route('/api/history', history);
// Login gegen Brute-Force drosseln: max. 10 Versuche/IP pro 5 Minuten.
app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login);
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token. // Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
app.use('/api/*', requireAuth); app.use('/api/*', requireAuth);
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token): // Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
@@ -81,11 +74,10 @@ const mutateLimit = rateLimit({
}); });
app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next))); app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next)));
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') })); app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
app.post('/api/comments', createComment); // Authentifizierte Plugin-Routen (nach requireAuth). admin:true-Routen laufen
app.delete('/api/comments/:id', deleteComment); // hinter requireAdmin; self-guarding Sub-Apps (dialog mod/adminForums) regeln den
app.post('/api/threads', newThread); // Zugriff selbst und brauchen das Flag nicht.
app.route('/api/mod', mod); manager.mountPrivate(app, '/api', requireAdmin);
app.route('/api/admin/forums', adminForums);
app.route('/api/content', content); app.route('/api/content', content);
app.route('/api/preview', preview); app.route('/api/preview', preview);
app.route('/api/publish', publish); app.route('/api/publish', publish);
@@ -120,8 +112,12 @@ app.use('/images/*', serveStatic({ root: `${SITE_DIR}/static` }));
// --- Live-Site (gebaut nach public/) --- // --- Live-Site (gebaut nach public/) ---
app.use('/*', serveStatic({ root: `${SITE_DIR}/public` })); app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
serve({ fetch: app.fetch, port: PORT }, (info) => { serve({ fetch: app.fetch, port: PORT }, async (info) => {
console.log(`OPENBUREAU CMS läuft auf :${info.port} — Site + API + /_preview`); console.log(`CMS läuft auf :${info.port} — Site + API + /_preview`);
// Library-Beiträge als Threads in „Beiträge" spiegeln (nicht blockierend). // Plugin-Migrationen zuerst anwenden (run-once, getrackt) — die Boot-Hooks
syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e)); // 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,7 +1,7 @@
import { supabase, supabaseAuth } from '../supabase.js'; import { supabase, supabaseAuth } from '../../supabase.js';
import { roleOf } from '../auth.js'; import { roleOf } from '../../auth.js';
import { profileFor, threadLocked } from '../dialog-store.js'; import { profileFor, threadLocked } from './dialog-store.js';
import { serverError } from '../util.js'; import { serverError } from '../../util.js';
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug. // Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
@@ -1,8 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { supabase } from './supabase.js'; import { supabase } from '../../supabase.js';
import { listEntries } from './files.js'; import { listEntries } from '../../files.js';
// Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen). // Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen).
// Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS). // Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS).
@@ -1,10 +1,10 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { supabase } from '../supabase.js'; import { supabase } from '../../supabase.js';
import { requireAdmin, requireModerator } from '../auth.js'; import { requireAdmin, requireModerator } from '../../auth.js';
import { serverError } from '../util.js'; import { serverError } from '../../util.js';
import { import {
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta, forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
} from '../dialog-store.js'; } from './dialog-store.js';
// Fehlt die Tabelle (Migration noch nicht eingespielt), nicht mit einem rohen // Fehlt die Tabelle (Migration noch nicht eingespielt), nicht mit einem rohen
// SQL-Fehler antworten — leer zurückgeben und server-seitig laut loggen. // SQL-Fehler antworten — leer zurückgeben und server-seitig laut loggen.
+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;
@@ -1,7 +1,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { urlFor, safeRel } from '../files.js'; import { urlFor, safeRel } from '../files.js';
import { buildSite, gitCommit } from '../hugo.js'; import { buildSite, gitCommit } from '../hugo.js';
import { syncLibrary } from '../dialog-store.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();
@@ -11,8 +11,9 @@ publish.post('/', async (c) => {
try { try {
const safe = safeRel(rel); const safe = safeRel(rel);
const build = await buildSite({ dest: 'public', drafts: false }); const build = await buildSite({ dest: 'public', drafts: false });
// Neue/aktualisierte Library-Beiträge sofort als Dialog-Threads spiegeln. // Plugin-Publish-Hooks anstoßen (dialog spiegelt neue Library-Beiträge sofort
await syncLibrary({ force: true }).catch(() => {}); // 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) {
+36
View File
@@ -0,0 +1,36 @@
import { Hono } from 'hono';
import { supabase } from '../supabase.js';
import { listEntries } from '../files.js';
import { requireAdmin, roleOf } from '../auth.js';
import { config } from '../config.js';
import { contentStats } from '../collections.js';
import { manager } from '../plugins.js';
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
const stats = new Hono();
stats.use('*', requireAdmin);
stats.get('/', async (c) => {
// Inhalte aus dem Dateisystem zählen — Zähler pro collection.statKey.
let content = {};
try { content = contentStats(await listEntries(), config.collections); }
catch { /* Filesystem nicht lesbar → leer */ }
// Nutzer nach Rolle (GoTrue).
const users = { total: 0, admin: 0, editor: 0, user: 0 };
try {
const { data } = await supabase.auth.admin.listUsers();
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
} catch { /* GoTrue nicht erreichbar */ }
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
const pluginStats = await manager.collectStats({});
// Dialog bleibt als eigenes Feld im Vertrag (Admin erwartet { forums, threads,
// comments }); fehlt das Plugin, sind es Nullen.
const dialog = pluginStats.dialog || { forums: 0, threads: 0, comments: 0 };
return c.json({ content, users, dialog });
});
export default stats;
+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);
});
});
+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);
});
+45
View File
@@ -0,0 +1,45 @@
// karimgabrielevarano.xyz — second consumer of openbureau-core.
// No dialog plugin; a portfolio-shaped content model.
export default {
site: 'kgva',
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: 'string' },
{ name: 'layout', type: 'select', options: ['text'], default: 'text' },
{ name: 'tags', type: 'list' },
{ name: 'summary', type: 'text' },
{ name: 'cover_image', type: 'image' },
{ name: 'authors', type: 'list' },
{ name: 'toc', type: 'bool' },
{ name: 'draft', type: 'bool', default: true },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'biblio', label: 'Library', order: 1,
path: 'library/:slug',
statKey: 'library',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'slug', type: 'slug' },
{ name: 'date', type: 'date' },
{ name: 'tags', type: 'list' },
{ name: 'summary', type: 'text' },
{ name: 'cover_image', type: 'image' },
{ name: 'external', type: 'string' },
{ name: 'group', type: 'string' },
{ name: 'authors', type: 'list' },
{ name: 'draft', type: 'bool', default: true },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'rubrik', label: 'Rubriken', order: 3, index: true,
statKey: 'rubriken',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'color', type: 'string' },
{ name: 'layout', type: 'string' },
{ name: 'weight', type: 'number' },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'seite', label: 'Seiten', order: 2, fallback: true,
statKey: 'seiten',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'layout', type: 'string' },
{ name: 'toc', type: 'bool' },
{ name: 'draft', type: 'bool', default: true },
{ name: 'body', type: 'markdown' },
],
},
],
};
+9 -1
View File
@@ -146,7 +146,10 @@ services:
# ════════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════════
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.
@@ -169,6 +172,11 @@ services:
JWT_SECRET: ${JWT_SECRET} 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}
+75
View File
@@ -0,0 +1,75 @@
// openbureau site config — what this site's content model + plugins are.
// Consumed by openbureau-core (vendored at cms/core) via CMS_CONFIG. Reproduces
// the previously hard-coded content model 1:1 (proven by core's collections test).
export default {
site: 'openbureau',
auth: 'supabase', // GoTrue/Supabase login (the stack provides it); core default
admins: ['karim@gabrielevarano.ch'],
plugins: ['dialog'], // comment/forum subsystem (library ↔ threads sync)
collections: [
{
kind: 'beitrag', label: 'Beiträge', order: 0,
path: 'archiv/:section/:slug',
sections: ['buerofuehrung', 'software', 'theorie'],
statKey: 'beitraege',
draftStatKey: 'entwuerfe', // Beitrag-Entwürfe als eigener Zähler (Dashboard)
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'section', type: 'select', options: ['buerofuehrung', 'software', 'theorie'] },
{ name: 'slug', type: 'slug' },
{ name: 'date', type: 'date' },
{ name: 'weight', type: 'number' },
{ name: 'color', type: 'string' },
{ name: 'layout', type: 'select', options: ['text'], default: 'text' },
{ name: 'tags', type: 'list' },
{ name: 'summary', type: 'text' },
{ name: 'cover_image', type: 'image' },
{ name: 'authors', type: 'list' },
{ name: 'toc', type: 'bool' },
{ name: 'draft', type: 'bool', default: true },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'biblio', label: 'Library', order: 1,
path: 'library/:slug',
statKey: 'library',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'slug', type: 'slug' },
{ name: 'date', type: 'date' },
{ name: 'tags', type: 'list' },
{ name: 'summary', type: 'text' },
{ name: 'cover_image', type: 'image' },
{ name: 'external', type: 'string' },
{ name: 'group', type: 'string' },
{ name: 'authors', type: 'list' },
{ name: 'draft', type: 'bool', default: true },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'rubrik', label: 'Rubriken', order: 3, index: true,
statKey: 'rubriken',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'color', type: 'string' },
{ name: 'layout', type: 'string' },
{ name: 'weight', type: 'number' },
{ name: 'body', type: 'markdown' },
],
},
{
kind: 'seite', label: 'Seiten', order: 2, fallback: true,
statKey: 'seiten',
fields: [
{ name: 'title', type: 'string', required: true },
{ name: 'layout', type: 'string' },
{ name: 'toc', type: 'bool' },
{ name: 'draft', type: 'bool', default: true },
{ name: 'body', type: 'markdown' },
],
},
],
};