Compare commits

..

12 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
65 changed files with 2496 additions and 314 deletions
-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);
-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;
-21
View File
@@ -1,21 +0,0 @@
import { createClient } from '@supabase/supabase-js';
const url = process.env.SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_KEY;
if (!url || !key) {
console.error('FEHLT: SUPABASE_URL und/oder SUPABASE_SERVICE_KEY in .env');
process.exit(1);
}
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
export const supabase = createClient(url, key, opts);
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
export const supabaseAuth = createClient(url, key, opts);
+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)
@@ -3,33 +3,10 @@ import ToastEditor from '@toast-ui/editor';
import '@toast-ui/editor/dist/toastui-editor.css'; import '@toast-ui/editor/dist/toastui-editor.css';
import { supabase } from './supabase.js'; import { supabase } from './supabase.js';
import { api } from './api.js'; import { api } from './api.js';
import {
// OPENBUREAU-Palette (Hex aus assets/css/custom.css) für Dropdown + Punkte. Field, blankForm, formFromFrontmatter, frontmatterFromForm, targetPath,
const COLORS = [ isShort, isBody, hexOf, DEFAULT_PALETTE,
['', 'keine', 'transparent'], } from './fields.jsx';
['ajisai', 'Ajisai · Hortensie', '#A39EC4'],
['sakura', 'Sakura · Kirschblüte', '#C49EC4'],
['suna', 'Suna · Sand', '#C4C19E'],
['ichigo', 'Ichigo · Erdbeere', '#C49EA0'],
['yuyake', 'Yuyake · Sonnenuntergang', '#CEB188'],
['sora', 'Sora · Himmel', '#9EC3C4'],
['kusa', 'Kusa · Gras', '#9EC49F'],
['kori', 'Kori · Eis', '#A5B4CB'],
['amagumo', 'Amagumo · Regenwolke', '#4C4C4C'],
['yuki', 'Yuki · Schnee', '#F0F0F0'],
];
const hexOf = (name) => (COLORS.find((c) => c[0] === name) || [])[2] || 'transparent';
const LAYOUTS = ['', 'text', 'image', 'icon'];
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
const KIND_LABEL = { beitrag: 'Beiträge', biblio: 'Library', seite: 'Seiten', rubrik: 'Rubriken' };
const EMPTY = {
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
color: '', layout: 'text', tags: '', summary: '', description: '',
cover_image: '', external: '', authors: '', group: '', toc: false, draft: true, body: '',
};
export default function App() { export default function App() {
const [session, setSession] = useState(null); const [session, setSession] = useState(null);
@@ -69,6 +46,9 @@ function Login() {
function Dashboard({ email }) { function Dashboard({ email }) {
const [entries, setEntries] = useState([]); const [entries, setEntries] = useState([]);
const [collections, setCollections] = useState(null);
const [plugins, setPlugins] = useState([]);
const [site, setSite] = useState('');
const [current, setCurrent] = useState(null); const [current, setCurrent] = useState(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [view, setView] = useState('content'); const [view, setView] = useState('content');
@@ -79,31 +59,57 @@ function Dashboard({ email }) {
try { setEntries(await api.list()); } try { setEntries(await api.list()); }
catch (e) { setMsg({ type: 'err', text: e.message }); } catch (e) { setMsg({ type: 'err', text: e.message }); }
} }
useEffect(() => { refresh(); api.getMe().then(setMe).catch(() => {}); }, []); 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]); 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) { async function open(entry) {
try { setCurrent(fromRead(await api.read(entry.path))); } const col = collectionOf(entry.kind);
catch (err) { setMsg({ type: 'err', text: err.message }); } 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 q = query.trim().toLowerCase();
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries; const filtered = q ? entries.filter((e) => (e.title || '').toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
const groups = { beitrag: [], biblio: [], seite: [], rubrik: [] }; const fallbackKind = (cols.find((c) => c.fallback) || cols[0] || {}).kind;
for (const e of filtered) (groups[e.kind] || groups.seite).push(e); 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 ( return (
<div className="app"> <div className="app">
<header className="topbar"> <header className="topbar">
<span className="logo">OPENBUREAU</span> <span className="logo">{(site || 'cms').toUpperCase()}</span>
<span className="logo-sub">Redaktion</span> <span className="logo-sub">Redaktion</span>
<nav className="nav"> <nav className="nav">
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>} {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 === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button> <button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>} {me?.canModerate && hasDialog && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
{me?.isAdmin && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</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 === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
{me?.isAdmin && <button className={view === 'system' ? 'active' : ''} onClick={() => setView('system')}>System</button>}
</nav> </nav>
<span className="spacer" /> <span className="spacer" />
<span className="who">{email}</span> <span className="who">{email}</span>
@@ -121,18 +127,20 @@ function Dashboard({ email }) {
<Forums onMsg={setMsg} /> <Forums onMsg={setMsg} />
) : view === 'moderation' ? ( ) : view === 'moderation' ? (
<Moderation onMsg={setMsg} /> <Moderation onMsg={setMsg} />
) : view === 'system' ? (
<System onMsg={setMsg} />
) : ( ) : (
<> <>
<aside> <aside>
<button className="new" onClick={() => setCurrent({ ...EMPTY })}> Neuer Beitrag</button> <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> <div className="search"><span></span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
{['beitrag', 'biblio', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && ( {cols.map((c) => groups[c.kind]?.length > 0 && (
<div className="group" key={kind}> <div className="group" key={c.kind}>
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div> <div className="group-title">{c.label} <span>{groups[c.kind].length}</span></div>
<ul className="list"> <ul className="list">
{groups[kind].map((e) => ( {groups[c.kind].map((e) => (
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(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="dot" style={{ background: e.color ? hexOf(DEFAULT_PALETTE, e.color) : 'var(--line)' }} />
<span className="t"> <span className="t">
<span className="t-title">{e.title}</span> <span className="t-title">{e.title}</span>
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span> <span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
@@ -146,9 +154,9 @@ function Dashboard({ email }) {
</aside> </aside>
<main> <main>
{current {current
? <Editor key={current.path || 'new'} initial={current} ? <Editor key={(current.path || 'new') + ':' + current.kind} initial={current} collections={cols}
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} /> onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
: <div className="empty"><p>Wähle links einen Eintrag oder leg einen neuen Beitrag an.</p></div>} : <div className="empty"><p>Wähle links einen Eintrag oder leg einen neuen an.</p></div>}
</main> </main>
</> </>
)} )}
@@ -159,7 +167,7 @@ function Dashboard({ email }) {
); );
} }
function Editor({ initial, onSaved, onMsg }) { function Editor({ initial, collections, onSaved, onMsg }) {
const [f, setF] = useState(initial); const [f, setF] = useState(initial);
const [previewUrl, setPreviewUrl] = useState(null); const [previewUrl, setPreviewUrl] = useState(null);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
@@ -167,17 +175,23 @@ function Editor({ initial, onSaved, onMsg }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const editorRef = useRef(null); const editorRef = useRef(null);
const dragging = useRef(false); const dragging = useRef(false);
const coverIn = useRef(null);
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
const isWiki = f.type === 'biblio' || (f.path || '').startsWith('library/');
async function pickCover(ev) { const cols = collections || [];
const file = ev.target.files?.[0]; ev.target.value = ''; const collection = cols.find((c) => c.kind === f.kind) || cols[0] || { fields: [] };
if (!file) return; const fields = collection.fields || [];
setBusy(true); const titleField = fields.find((x) => x.name === 'title') || fields.find((x) => x.type === 'string' && x.required) || null;
try { const { url } = await api.upload(file); setF((p) => ({ ...p, cover_image: url })); } const bodyField = fields.find(isBody);
catch (e) { onMsg({ type: 'err', text: e.message }); } const draftField = fields.find((x) => x.type === 'bool' && x.name === 'draft');
finally { setBusy(false); } 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. // Ziehbarer Trenner Editor Vorschau.
@@ -194,25 +208,22 @@ function Editor({ initial, onSaved, onMsg }) {
}, []); }, []);
function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); } function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }
function currentPath(data = f) {
if (!data.isNew) return data.path;
const slug = (data.slug || '').trim();
if (!slug) return '';
if (data.type === 'beitrag') return `archiv/${data.section}/${slug}.md`;
if (data.type === 'biblio') return `library/${slug}.md`;
return `${slug}.md`;
}
// overrides erlauben z.B. { draft: false } beim Publizieren.
async function save(overrides = {}) { async function save(overrides = {}) {
const data = { ...f, ...overrides }; const data = { ...f, ...overrides };
const path = currentPath(data); const path = data.isNew ? targetPath(data, collection) : data.path;
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug angeben.' }); return null; } if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug (und ggf. Pflichtsegmente wie Rubrik) angeben.' }); return null; }
if (!data.title.trim()) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; } if (titleField && !((data[titleField.name] || '').trim())) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
setBusy(true); setBusy(true);
try { try {
await api.save(path, buildFrontmatter(data), data.body); const fm = frontmatterFromForm(data, collection);
const loaded = fromRead(await api.read(path)); 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); onSaved(loaded); setF(loaded);
return path; return path;
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; } } catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
@@ -226,8 +237,8 @@ function Editor({ initial, onSaved, onMsg }) {
finally { setBusy(false); } finally { setBusy(false); }
} }
async function publish() { async function publish() {
if (!confirm('Live publizieren? Der Beitrag wird aus „Entwurf“ genommen.')) return; if (!confirm('Live publizieren?')) return;
const path = await save({ draft: false }); // Publizieren = nicht mehr Entwurf const path = await save(draftField ? { [draftField.name]: false } : {});
if (!path) return; if (!path) return;
setBusy(true); setBusy(true);
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); } try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
@@ -241,7 +252,7 @@ function Editor({ initial, onSaved, onMsg }) {
<div className="editor-head"> <div className="editor-head">
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div> <div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
<span className="spacer" /> <span className="spacer" />
{f.draft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>} {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"> <button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
{showPreview ? 'Vorschau ' : 'Vorschau ⤢'} {showPreview ? 'Vorschau ' : 'Vorschau ⤢'}
</button> </button>
@@ -254,61 +265,37 @@ function Editor({ initial, onSaved, onMsg }) {
{f.isNew && ( {f.isNew && (
<div className="row"> <div className="row">
<label className="sm">Typ <label className="sm">Typ
<select value={f.type} onChange={set('type')}> <select value={f.kind} onChange={(e) => changeKind(e.target.value)}>
<option value="beitrag">Beitrag</option> {cols.map((c) => <option key={c.kind} value={c.kind}>{c.label}</option>)}
<option value="biblio">Library-Seite</option>
<option value="seite">Seite</option>
</select> </select>
</label> </label>
{f.type === 'beitrag' && ( {!hasSlugField && (
<label className="sm">Rubrik <label className="sm">Dateiname
<select value={f.section} onChange={set('section')}>{SECTIONS.map((s) => <option key={s}>{s}</option>)}</select> <input value={f.slug || ''} onChange={(e) => setField('slug', e.target.value)} placeholder="z. B. impressum" />
</label> </label>
)} )}
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
</div> </div>
)} )}
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label> {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"> <div className="meta">
{isWiki && <label className="sm">Gruppe<input value={f.group} onChange={set('group')} placeholder="z. B. Begriffe" /></label>} {shortFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
<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> </div>
)}
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label> {longFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
<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>
{bodyField && (
<div className="rich"> <div className="rich">
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))} <RichEditor value={f[bodyField.name] || ''} onChange={(body) => setField(bodyField.name, body)} onUpload={upload} />
onUpload={async (file) => (await api.upload(file)).url} />
</div> </div>
)}
</div> </div>
</div> </div>
@@ -402,6 +389,31 @@ function Profile({ onMsg }) {
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></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 className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
</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> </div>
); );
} }
@@ -413,38 +425,80 @@ function Overview({ onMsg, go }) {
if (!s) return <div className="empty"></div>; if (!s) return <div className="empty"></div>;
const Card = ({ label, value, hint, to }) => ( const Card = ({ label, value, hint, to }) => (
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}> <button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
<span className="stat-value">{value}</span> <span className="stat-value">{value ?? 0}</span>
<span className="stat-label">{label}</span> <span className="stat-label">{label}</span>
<span className="stat-hint">{hint || ' '}</span> <span className="stat-hint">{hint || ' '}</span>
</button> </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 ( return (
<div className="overview"> <div className="overview">
<h2>Übersicht</h2> <h2>Übersicht</h2>
<div className="stat-grid"> <div className="stat-grid">
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" /> {Object.entries(content).map(([k, v]) => <Card key={k} label={k} value={v} to="content" />)}
<Card label="Library-Seiten" value={s.content.library} to="content" /> <Card label="Autor:innen" value={s.users?.total} hint={`${s.users?.admin || 0} Admin · ${s.users?.editor || 0} Red.`} to="users" />
<Card label="Seiten" value={s.content.seiten} /> {hasDialog && <Card label="Foren" value={d.forums} to="forums" />}
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" /> {hasDialog && <Card label="Threads" value={d.threads} to="moderation" />}
<Card label="Foren" value={s.dialog.forums} to="forums" /> {hasDialog && <Card label="Wortmeldungen" value={d.comments} to="moderation" />}
<Card label="Threads" value={s.dialog.threads} to="moderation" />
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
</div> </div>
<div className="overview-actions"> <div className="overview-actions">
<h3>Schnellzugriff</h3> <h3>Schnellzugriff</h3>
<div className="quick"> <div className="quick">
<button onClick={() => go('content')}>Inhalte bearbeiten</button> <button onClick={() => go('content')}>Inhalte bearbeiten</button>
<button onClick={() => go('forums')}>Foren verwalten</button> {hasDialog && <button onClick={() => go('forums')}>Foren verwalten</button>}
<button onClick={() => go('users')}>Autor:innen &amp; Rollen</button> <button onClick={() => go('users')}>Autor:innen &amp; Rollen</button>
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website </a> <a className="quick-link" href="/" target="_blank" rel="noreferrer">Website </a>
<a className="quick-link" href="/dialog/" target="_blank" rel="noreferrer">Dialog </a>
<a className="quick-link" href="/library/" target="_blank" rel="noreferrer">Library </a>
</div> </div>
</div> </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) // Autor:innen-Verwaltung (nur Admin)
function Users({ onMsg, currentEmail }) { function Users({ onMsg, currentEmail }) {
const [list, setList] = useState(null); const [list, setList] = useState(null);
@@ -676,39 +730,3 @@ function slugify(s) {
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
} }
// Mapping Datei-Lesart Formular
function fromRead(r) {
const fm = r.frontmatter || {};
const p = r.path || '';
const type = p.startsWith('archiv/') ? 'beitrag' : p.startsWith('library/') ? 'biblio' : 'seite';
return {
isNew: false, path: r.path, type, section: '', slug: '',
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
summary: fm.summary || '', description: fm.description || '',
cover_image: fm.cover_image || '', external: fm.external || '',
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
group: fm.group || '', toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
};
}
function buildFrontmatter(f) {
const fm = { title: f.title };
if (f.date) fm.date = f.date;
if (f.weight !== '' && f.weight != null) fm.weight = Number(f.weight);
const tags = f.tags ? f.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
if (tags.length) fm.tags = tags;
if (f.summary) fm.summary = f.summary;
if (f.description) fm.description = f.description;
if (f.cover_image) fm.cover_image = f.cover_image;
if (f.layout) fm.layout = f.layout;
if (f.external) fm.external = f.external;
if (f.color) fm.color = f.color;
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
if (authors.length) fm.authors = authors;
if (f.group) fm.group = f.group;
if (f.toc) fm.toc = true;
if (f.draft) fm.draft = true;
return fm;
}
@@ -45,6 +45,10 @@ export const api = {
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'), 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, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }), 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 }) }),
+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 */ }
}} />
</>
);
}
@@ -219,3 +219,13 @@ label.big input { font-family: var(--serif); font-weight: 600; }
.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();
+156 -2
View File
@@ -1,18 +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", "marked": "^14.1.4",
"pg": "^8.22.0",
"sharp": "^0.33.5" "sharp": "^0.33.5"
} }
}, },
@@ -528,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",
@@ -685,6 +693,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 +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",
@@ -778,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,6 +1,6 @@
{ {
"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.",
@@ -12,9 +12,11 @@
"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", "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);
}
@@ -17,6 +17,9 @@ async function verifyToken(token) {
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} }; return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
} catch { return null; } } 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); const { data, error } = await supabaseAuth.auth.getUser(token);
if (error || !data?.user) return null; if (error || !data?.user) return null;
return data.user; return data.user;
+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,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,22 @@ 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 system from './routes/system.js';
import account from './routes/account.js';
import history from './routes/history.js'; import history from './routes/history.js';
import { import authRoute from './routes/auth.js';
listForums, showForum, recent, threadInfo, newThread, mod, adminForums, import { requireAuth, requireAdmin } from './auth.js';
} from './routes/dialog.js'; import { config } from './config.js';
import { requireAuth } from './auth.js'; import { supabase } from './supabase.js';
import { syncLibrary } from './dialog-store.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 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,17 +70,16 @@ 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); // Lokaler Login (nur auth: 'local') — ersetzt GoTrue, öffentlich + gedrosselt.
app.get('/api/thread', threadInfo); // 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. // Ö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. // Alles weitere unter /api/* braucht ein gültiges Token (Supabase oder lokal).
app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login);
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
app.use('/api/*', requireAuth); app.use('/api/*', requireAuth);
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token): // Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei. // 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
@@ -81,11 +89,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);
@@ -93,6 +100,16 @@ app.route('/api/upload', upload);
app.route('/api/profile', profile); app.route('/api/profile', profile);
app.route('/api/users', users); app.route('/api/users', users);
app.route('/api/stats', stats); 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) --- // --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
app.get('/admin', (c) => c.redirect('/admin/')); app.get('/admin', (c) => c.redirect('/admin/'));
@@ -120,8 +137,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;
+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;
@@ -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) {
+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;
@@ -1,15 +1,23 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { supabase } from '../supabase.js'; import { supabase } from '../supabase.js';
import { requireAdmin, roleOf } from '../auth.js'; import { requireAdmin, roleOf } from '../auth.js';
import { config } from '../config.js';
import * as local from '../auth-local.js';
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins. // 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 || '') const ADMINS = (process.env.ADMIN_EMAILS || '')
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const isLocal = () => config.auth === 'local';
const users = new Hono(); const users = new Hono();
users.use('*', requireAdmin); users.use('*', requireAdmin);
users.get('/', async (c) => { 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(); const { data, error } = await supabase.auth.admin.listUsers();
if (error) return c.json({ error: error.message }, 500); if (error) return c.json({ error: error.message }, 500);
const list = (data?.users || []).map((u) => { const list = (data?.users || []).map((u) => {
@@ -32,6 +40,10 @@ users.post('/', async (c) => {
const { email, password, role } = await c.req.json(); const { email, password, role } = 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);
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 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 }; const payload = { email, password, email_confirm: true };
if (role && role !== 'user') payload.app_metadata = { role }; if (role && role !== 'user') payload.app_metadata = { role };
const { data, error } = await supabase.auth.admin.createUser(payload); const { data, error } = await supabase.auth.admin.createUser(payload);
@@ -41,19 +53,25 @@ users.post('/', async (c) => {
users.put('/:id', async (c) => { users.put('/:id', async (c) => {
const { password, role } = await c.req.json(); 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 = {}; const patch = {};
if (password) patch.password = password; if (password) patch.password = password;
if (role) { if (role) patch.app_metadata = { role };
if (!['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
patch.app_metadata = { role };
}
if (!Object.keys(patch).length) return c.json({ error: 'Nichts zu ändern' }, 400);
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch); const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
if (error) return c.json({ error: error.message }, 400); if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true }); return c.json({ ok: true });
}); });
users.delete('/:id', async (c) => { 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')); const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
if (error) return c.json({ error: error.message }, 400); if (error) return c.json({ error: error.message }, 400);
return c.json({ ok: true }); return c.json({ ok: true });
+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;
+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/);
});
+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);
});
+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' },
],
},
],
};
+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: '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' },
],
},
],
};