Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab78c84296 | |||
| 2bf27f01be | |||
| 80eca1bc7c |
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,174 @@
|
||||
# openbureau-core — handover
|
||||
|
||||
## For a fresh instance — START HERE
|
||||
- **Repo (local):** cloned at `/home/karim/openbureau-core`. Remote
|
||||
`git.openbureau.ch/karim/openbureau-core` is **private**; auth via an HTTPS token
|
||||
in `~/.git-credentials` (the SSH key `id_ed25519_openbureau` is currently rejected
|
||||
by gitea/proxmox — server seems rebuilt; fix the key or keep using the token).
|
||||
- **Where the work is:** branch **`stufe-2-5-scaffold`** (pushed, NOT merged). `main`
|
||||
is still the pre-stage-2 foundation. Open a PR or merge when happy.
|
||||
- **Done:** stages 1–4 complete, stage 5 *scaffolded*. See "Migration stages" below
|
||||
(markers `[done]`/`[scaffold]`). New code: `api/src/collections.js`,
|
||||
`api/src/plugin-manager.js`, `api/src/plugins/dialog/index.js` + their tests.
|
||||
- **Run tests:** `cd api && npm install && node --test` → **44 green**. `node_modules`
|
||||
is git-ignored; the fresh clone needs `npm install` once.
|
||||
- **Immediate next action:** stage 5 + 5.5 **DONE** on the branch (commits `c4230a4`
|
||||
cutover, `5e06337` DDL, `a93d11a` migration runner). Plugin-manager singleton
|
||||
(`api/src/plugins.js`) wired into `index.js`/`stats.js`/`publish.js`, hard-coded
|
||||
dialog removed, source moved under `api/src/plugins/dialog/`; DDL captured into
|
||||
`plugins/dialog/migrations/001_dialog.sql` (idempotent); `api/src/migrate.js`
|
||||
applies migrations once via `schema_migrations` at boot. **53 tests green** + a real
|
||||
boot. **Live-verified on the dev stack** (CT 134): a 2nd cms instance from
|
||||
`cms-cms:latest` (my `src` mounted over `/app/src`, real Supabase via `kong:8000`,
|
||||
empty SITE_DIR → no writes) returned **byte-identical** `/api/forums` & `/api/recent`
|
||||
vs the running container, `/api/content` 401 on both; the runner's tracking SQL was
|
||||
proven on a throwaway DB. **NOT deployed** (the running stack still runs old code).
|
||||
NEXT (stage 8): deploy core to openbureau — needs `DATABASE_URL` set for the cms
|
||||
service (e.g. `postgres://postgres:$POSTGRES_PASSWORD@db:5432/postgres`) so the
|
||||
runner can apply migrations, then full login/edit/publish verify before cutting the
|
||||
live container over. Then stages 6 (admin `/api/schema`), 7 (local auth), 9 (kgva).
|
||||
Dev stack: Proxmox CT 134 `openbureau-dev`, repo `/opt/openbureau`, compose
|
||||
`cms/docker-compose.yml`, containers openbureau-{cms,auth,kong,rest,db}, dev URL
|
||||
dev.openbureau.ch. **Do NOT push a blind refactor to prod.**
|
||||
- **Env gotcha:** editing a file that's open in Karim's VSCode makes the Edit/Write
|
||||
tool hang/"interrupt" — write such files via Bash (`cat > f <<'EOF' … EOF`) instead.
|
||||
|
||||
## Goal
|
||||
Extract a generic, **schema-driven** Hugo CMS engine ("openbureau-core") from the
|
||||
openbureau site's bundled CMS, so that **both** openbureau and
|
||||
karimgabrielevarano.xyz (kgva) consume it as a dependency. Decision: own repo
|
||||
(this one), and migrate openbureau to depend on it.
|
||||
|
||||
## State (done this session)
|
||||
- Repo `karim/openbureau-core` created on Gitea, foundation pushed.
|
||||
- `api/` = engine copied verbatim from `karim/OPENBUREAU` → `cms/api` (Hono/Node).
|
||||
- `admin/` = React/Vite SPA copied verbatim from `OPENBUREAU/cms/admin`.
|
||||
- `api/src/config.js` = NEW loader: reads `CMS_CONFIG` → a site config module.
|
||||
- `examples/openbureau.config.js`, `examples/kgva.config.js` = target schemas.
|
||||
- `README.md` = config API + plugin model + the migration checklist.
|
||||
- Nothing in the live openbureau CMS was changed yet.
|
||||
|
||||
## Engine is ~80% generic. openbureau-specific seams to make config-driven:
|
||||
1. [DONE] `api/src/files.js` `classify(rel)` + the `order` map — now derived from
|
||||
`config.collections` via `api/src/collections.js` (classify/buildPath/compareEntries);
|
||||
`buildPath` exposed there too.
|
||||
2. [DONE] `api/src/routes/stats.js` — now per-collection (`statKey`/`draftStatKey`),
|
||||
dialog counts gated by `hasPlugin('dialog')`.
|
||||
3. `api/src/index.js` — always mounts dialog routes + runs `syncLibrary` on boot.
|
||||
→ only when `config.plugins` includes `dialog`.
|
||||
4. `api/src/routes/publish.js` — calls `syncLibrary`. → gate by plugin.
|
||||
5. `admin/src/App.jsx` (714 lines) — hard-codes `SECTIONS`, `KIND_LABEL`, the field
|
||||
set, type dropdown, `buildPath`. → render sidebar groups + editor fields from the
|
||||
schema (add `GET /api/schema` returning `config.collections`).
|
||||
6. dialog subsystem (`dialog-store.js`, `routes/dialog.js`, `routes/comments.js`
|
||||
+ Supabase tables forums/threads/comments + library↔thread sync) = the `dialog`
|
||||
**plugin** (openbureau on, kgva off). First gate by config, later move to
|
||||
`api/src/plugins/dialog/`.
|
||||
|
||||
## CRITICAL
|
||||
api and admin share a data contract (stats keys, field shapes). Generalising the
|
||||
**backend alone breaks the live openbureau admin** — they must change together and
|
||||
be tested on the live Supabase/Hugo stack. Do NOT push a blind half-refactor to
|
||||
production openbureau.
|
||||
|
||||
## Plugin system (decided architecture)
|
||||
Everything beyond the generic engine is a **plugin**; the engine gets a small
|
||||
**plugin manager**. A plugin = `api/src/plugins/<name>/index.js` exporting a
|
||||
manifest `{ name, routes:[{path,app,public?,admin?}], onBoot, onPublish, onPreview,
|
||||
migrations:[…], stats, admin:{…} }`. The manager reads `config.plugins`, imports
|
||||
each, mounts routes in the right auth tier (public before `requireAuth`, rest
|
||||
after, `admin:true` behind `requireAdmin`), registers boot/publish/preview/stats
|
||||
hooks, runs migrations; the admin SPA loads UI from `admin/src/plugins/<name>`.
|
||||
openbureau's extras become plugins (first: `dialog`); kgva enables none.
|
||||
(Full spec in README → "Plugins".)
|
||||
|
||||
## Migration stages (also README checklist)
|
||||
1. [done] repo + engine + config loader + example configs.
|
||||
2. [done] files.js classify/order/buildPath ← config.collections
|
||||
(new `api/src/collections.js` = pure classify/buildPath/compareEntries/contentStats;
|
||||
files.js uses it, loads config lazily in listEntries; `api/test/collections.test.js`
|
||||
proves 1:1 vs openbureau.config.js). All api tests green.
|
||||
3. [done] stats.js ← collections: `content` via `statKey` + `draftStatKey`
|
||||
(openbureau beitrag got `draftStatKey: 'entwuerfe'`), dialog counts gated by
|
||||
`hasPlugin('dialog')` (pluginless site does zero DB reads here).
|
||||
4. [built, not wired] **plugin manager** — `api/src/plugin-manager.js`
|
||||
(`loadPlugins(names)` imports `plugins/<name>/index.js`; `createManager(manifests)`
|
||||
= pure wiring: `mountPublic`/`mountPrivate(+requireAdmin)`, `runBoot/runPublish/
|
||||
runPreview`, `collectStats` (merged under plugin name), `migrations()` (resolves
|
||||
file URLs; execution deferred to the live DB)). Unit-tested in
|
||||
`api/test/plugin-manager.test.js` (tiers/hooks/stats/migrations). TODO: wire into
|
||||
index.js (stage 5), and admin loads plugin UI from `admin/src/plugins/<name>`.
|
||||
5. [DONE — commits c4230a4 (cutover) + 5e06337 (DDL), live e2e verified] extract openbureau extras
|
||||
into the **dialog** plugin. DONE: `api/src/plugins/dialog/index.js` = manifest
|
||||
wrapping the existing modules unchanged (public reads + widget login(rate-limited)
|
||||
+ authed writes + self-guarding mod/adminForums sub-apps; onBoot/onPublish =
|
||||
syncLibrary; stats = forum/thread/comment counts). CUTOVER DONE: shared manager
|
||||
singleton `api/src/plugins.js` wired into index.js (mountPublic before requireAuth,
|
||||
mountPrivate(+requireAdmin) after, manager.runBoot at serve()), publish.js
|
||||
(manager.runPublish) and stats.js (manager.collectStats, `{content,users,dialog}`
|
||||
contract preserved); hard-coded dialog REMOVED from all three; dialog source
|
||||
physically moved to `plugins/dialog/{dialog-store,dialog,comments}.js` (imports
|
||||
fixed). Tested: `api/test/{dialog-plugin,cutover}.test.js`, 48 green + real boot.
|
||||
DDL CAPTURED (commit 5e06337): `migrations/001_dialog.sql` from the live dev DB,
|
||||
idempotent, validated on a throwaway DB; manifest `migrations: ['001_dialog.sql']`.
|
||||
LIVE E2E VERIFIED (dev CT 134): 2nd cms instance from cms-cms:latest with my src
|
||||
mounted, real Supabase via kong:8000 — /api/forums & /api/recent byte-identical to
|
||||
the running container, /api/content 401 both, no writes. Migration runner = stage
|
||||
5.5 (commit a93d11a, see new item below). Deploy is stage 8.
|
||||
NB: dialog-store.js still filters `e.kind === 'beitrag'` — openbureau-specific,
|
||||
already moved with the plugin.
|
||||
5.5 [done, commit a93d11a] migration runner — `api/src/migrate.js` `runMigrations`
|
||||
tracks applied migrations in `public.schema_migrations` and applies each declared
|
||||
file once, in its own transaction, at boot (wired into index.js before runBoot).
|
||||
Lean: no declared migrations → no DB connection, no pg import (kgva stays DB-less).
|
||||
Executor is injectable (unit-tested without a DB); default uses a lazily-imported
|
||||
`pg` against `DATABASE_URL`, warning-and-skipping if unset. Needs `DATABASE_URL`
|
||||
wired into the cms service env for openbureau (stage 8). Tested: migrate.test.js +
|
||||
tracking SQL proven on the live dev Postgres (throwaway DB).
|
||||
6. admin: `/api/schema` + App.jsx renders from schema (must reproduce openbureau's
|
||||
editor 1:1 when fed `openbureau.config.js`).
|
||||
7. **auth provider** (`config.auth` = `supabase` | `local`). Verify is already local
|
||||
(HS256/JWT_SECRET in auth.js). Add a `local` provider: file-based users (bcrypt) +
|
||||
self-signed JWTs of the SAME claim shape (sub/email/app_metadata.role/exp), and a
|
||||
`/login` route to replace GoTrue; admin login (`admin/src/supabase.js`) calls it
|
||||
instead of `signInWithPassword`; users.js CRUD edits the user file. Goal: a
|
||||
dialog-less core runs Node+Hugo+nginx, **no Supabase/Postgres** (~80 MB). openbureau
|
||||
stays `supabase`. Independent of the cutover — `supabase` is the default no-op.
|
||||
8. cut openbureau over to consume core (= core + `dialog` plugin + `supabase` auth +
|
||||
its config) — test login / list / edit / preview / publish / dialog — identical.
|
||||
9. onboard kgva (`CMS_CONFIG=…/kgva.config.js`, `auth: 'local'`, no plugins) as the
|
||||
2nd consumer — DB-less.
|
||||
|
||||
## How to run / test
|
||||
openbureau CMS stack: `OPENBUREAU/cms/docker-compose.yml` (Node api + Hugo +
|
||||
Supabase: postgres/kong/gotrue). Env in `cms/.env` (see `.env.example`):
|
||||
SUPABASE_URL/SERVICE_KEY, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY (derive via
|
||||
`scripts/generate-keys.mjs`), ADMIN_EMAILS, SITE_URL, GIT_*. For core add
|
||||
`CMS_CONFIG` (path to the site config) and `SITE_DIR` (site repo mount, default
|
||||
`/site`). Admin at `/admin`, preview `/_preview`, publish builds `public/`.
|
||||
|
||||
## Infra access (NO secrets in this file)
|
||||
- Proxmox node `192.168.1.2`, user `root`. Password: **ask the user** (provided ad
|
||||
hoc, not stored). No SSH key installed — use SSH_ASKPASS, or install a key first.
|
||||
- Gitea = container 120 (`pct exec 120 …`), ROOT_URL `git.openbureau.ch`, sqlite at
|
||||
`/var/lib/gitea/data/gitea.db`. Make a token:
|
||||
`pct exec 120 -- su gitea -s /bin/bash -c "/usr/local/bin/gitea admin user
|
||||
generate-access-token --username karim --scopes all --token-name X --raw
|
||||
--config /etc/gitea/app.ini"`. Keep token `kgva-deploy`. Delete tokens via sqlite
|
||||
(`DELETE FROM access_token WHERE name LIKE '…'`) since the API needs the account
|
||||
password.
|
||||
- Push without local git auth: tar → scp to node → `pct push 120` into the gitea
|
||||
container → `git push http://karim:<token>@127.0.0.1:3000/karim/<repo>.git`.
|
||||
- kgva site deploy: container 130 (kgva-website). nginx serves
|
||||
`/var/www/karimgabrielevarano.xyz` (= ZFS `tank/kgva-website`, owned uid 100000).
|
||||
systemd timer runs `/opt/kgva-deploy.sh` every 60s: pull `karim/kgva` (token
|
||||
`kgva-deploy`) → `hugo` build → copy into webroot. So a push to `karim/kgva` is
|
||||
live in ~1 min. Self-hosted video at `/var/www/media` (nginx `/media/` alias,
|
||||
outside the build). Public TLS for the domain terminates upstream → 192.168.1.130:80.
|
||||
|
||||
## Watch out
|
||||
- Gitea does NOT send CORS on its OAuth token endpoint → a browser git-CMS
|
||||
(Decap-style) can't auth without a same-origin proxy. openbureau-core uses its own
|
||||
Supabase auth, so this doesn't affect it; it's why kgva edits go via git push.
|
||||
- Hugo versions: openbureau CMS bundles 0.161.1; the kgva site needs 0.163.3
|
||||
features. Mind the version a core instance builds with.
|
||||
@@ -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
|
||||
~1–2 GB Supabase suite). Trade-off: you own password hashing; you lose GoTrue's
|
||||
password-reset / rate-limiting — fine for a single-admin CMS.
|
||||
|
||||
What a pluginless core needs from Supabase is *only* the auth half anyway: content
|
||||
is Markdown on disk, uploads are local FS (`static/images`, via sharp), and
|
||||
PostgREST/tables are a `dialog`-plugin need. `local` drops that half too.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `api/` — Node engine (Hono): `auth` (Supabase JWT), `files` (gray-matter
|
||||
CRUD), `hugo` (coalesced build / preview / publish + git), `routes/*`,
|
||||
`ratelimit`, `config` (this loader).
|
||||
- `admin/` — React/Vite SPA; renders list + editor from the config schema.
|
||||
- `examples/` — `openbureau.config.js`, `kgva.config.js` (the two consumers).
|
||||
|
||||
## Status / migration stages
|
||||
|
||||
This repo currently holds the **engine as extracted** plus the config API and the
|
||||
two target configs. Generalisation is staged because the api and admin share a
|
||||
data contract and openbureau is in production — they must change together and be
|
||||
tested on the live Supabase/Hugo stack, not flipped blind.
|
||||
|
||||
- [x] Repo, engine, config loader, example configs (openbureau + kgva)
|
||||
- [x] `files.js` classify / order / buildPath driven by `collections`
|
||||
(`api/src/collections.js` + tests; behaviour 1:1 vs openbureau.config.js)
|
||||
- [x] `stats.js` counters driven by `collections` (`statKey` / `draftStatKey`),
|
||||
dialog counts gated by `hasPlugin('dialog')`
|
||||
- [~] **plugin manager** built + unit-tested (`api/src/plugin-manager.js`,
|
||||
`api/test/plugin-manager.test.js`): loads `config.plugins`, mounts routes by
|
||||
auth tier, runs boot/publish/preview hooks, merges stats, surfaces migrations.
|
||||
NOT yet wired into `index.js` (lands with stage 5, needs the live stack);
|
||||
admin plugin-UI loading still TODO
|
||||
- [~] extract openbureau's extras into plugins — first **`dialog`**: manifest
|
||||
scaffold at `api/src/plugins/dialog/index.js` wraps the existing dialog
|
||||
modules (routes by tier, `syncLibrary` on boot/publish, forum counters as
|
||||
`stats`), loaded + structurally tested (`api/test/dialog-plugin.test.js`).
|
||||
TODO (live-tested): wire it into `index.js`, capture the Supabase DDL into
|
||||
`migrations/001_dialog.sql`, move the dialog source under `plugins/dialog/`
|
||||
- [ ] `index.js` / `publish.js`: no hard-coded dialog — everything via the manager
|
||||
- [ ] admin: editor + sidebar groups rendered from the config schema
|
||||
- [ ] **auth provider** (`supabase` | `local`): `local` = file users + self-signed
|
||||
HS256 JWTs (same claim shape), no DB — lets a dialog-less core run Supabase-free
|
||||
- [ ] cut openbureau over to consume core (= core + `dialog` plugin + its config),
|
||||
behaviour identical — tested on the live stack
|
||||
- [ ] onboard kgva as the second consumer (`auth: 'local'`, no plugins, DB-less)
|
||||
@@ -13,6 +13,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.6.14",
|
||||
"marked": "^14.1.4",
|
||||
"pg": "^8.22.0",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
},
|
||||
@@ -685,6 +686,134 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
@@ -758,6 +887,15 @@
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
@@ -778,6 +916,15 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.6.14",
|
||||
"marked": "^14.1.4",
|
||||
"pg": "^8.22.0",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Per-site CMS configuration loader.
|
||||
//
|
||||
// A site tells the core what it manages through one module, referenced by the
|
||||
// CMS_CONFIG env var (e.g. CMS_CONFIG=/site/cms.config.js). The module exports
|
||||
// `default` with { collections, plugins, admins }. Loaded once at startup; the
|
||||
// engine (files/stats/routes) derives its behaviour from it instead of having
|
||||
// openbureau-specific content types hard-coded.
|
||||
//
|
||||
// See ../../README.md for the schema, and ../../examples/*.config.js.
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const p = process.env.CMS_CONFIG;
|
||||
if (!p) {
|
||||
console.error('FEHLT: CMS_CONFIG (Pfad zur Site-Config, z. B. /site/cms.config.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mod = await import(pathToFileURL(p).href);
|
||||
export const config = mod.default || mod;
|
||||
config.collections ||= [];
|
||||
config.plugins ||= [];
|
||||
config.admins ||= [];
|
||||
|
||||
export const hasPlugin = (name) => config.plugins.includes(name);
|
||||
|
||||
// Collection lookup helpers (used by the generalised files.js / stats.js).
|
||||
export const collectionOf = (kind) => config.collections.find((c) => c.kind === kind) || null;
|
||||
export const fallbackCollection = () => config.collections.find((c) => c.fallback) || null;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
import { classify, compareEntries } from './collections.js';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const CONTENT = path.join(SITE_DIR, 'content');
|
||||
@@ -26,24 +27,6 @@ async function walk(dir) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Beitrag (archiv/<section>/<slug>.md) | Library-Seite (library/<slug>.md)
|
||||
// | Rubrik (_index.md) | Seite (sonst).
|
||||
function classify(rel) {
|
||||
const base = path.basename(rel);
|
||||
const parts = rel.split('/');
|
||||
if (base === '_index.md') {
|
||||
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
|
||||
return { kind: 'rubrik', section };
|
||||
}
|
||||
if (parts[0] === 'archiv' && parts.length === 3) {
|
||||
return { kind: 'beitrag', section: parts[1] };
|
||||
}
|
||||
if (parts[0] === 'library') {
|
||||
return { kind: 'biblio', section: 'library' };
|
||||
}
|
||||
return { kind: 'seite', section: null };
|
||||
}
|
||||
|
||||
// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt).
|
||||
export function normAuthors(a) {
|
||||
if (Array.isArray(a)) return a.map(String).filter(Boolean);
|
||||
@@ -66,6 +49,9 @@ export function urlFor(rel) {
|
||||
}
|
||||
|
||||
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 items = [];
|
||||
for (const full of files) {
|
||||
@@ -77,7 +63,7 @@ export async function listEntries() {
|
||||
items.push({
|
||||
path: rel,
|
||||
title: data.title || rel,
|
||||
...classify(rel),
|
||||
...classify(rel, config.collections),
|
||||
color: data.color || null,
|
||||
layout: data.layout || null,
|
||||
draft: !!data.draft,
|
||||
@@ -86,12 +72,8 @@ export async function listEntries() {
|
||||
url: urlFor(rel),
|
||||
});
|
||||
}
|
||||
// Beiträge zuerst, dann Library, Seiten, Rubriken; je nach Datum/Titel.
|
||||
const order = { beitrag: 0, biblio: 1, seite: 2, rubrik: 3 };
|
||||
items.sort((a, b) =>
|
||||
(order[a.kind] - order[b.kind]) ||
|
||||
(b.date || '').localeCompare(a.date || '') ||
|
||||
a.title.localeCompare(b.title));
|
||||
// Reihenfolge aus der Config (collection.order), dann Datum, dann Titel.
|
||||
items.sort(compareEntries(config.collections));
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -12,13 +12,10 @@ import upload from './routes/upload.js';
|
||||
import profile from './routes/profile.js';
|
||||
import users from './routes/users.js';
|
||||
import stats from './routes/stats.js';
|
||||
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
||||
import history from './routes/history.js';
|
||||
import {
|
||||
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
|
||||
} from './routes/dialog.js';
|
||||
import { requireAuth } from './auth.js';
|
||||
import { syncLibrary } from './dialog-store.js';
|
||||
import { requireAuth, requireAdmin } from './auth.js';
|
||||
import { manager } from './plugins.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
||||
@@ -61,16 +58,12 @@ app.use('/api/*', (c, next) =>
|
||||
c.req.path.startsWith('/api/upload') ? next() : jsonBodyLimit(c, next));
|
||||
|
||||
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
|
||||
// Öffentlich (ohne Login): Dialog lesen, Übersicht, Login fürs Dialog-Widget.
|
||||
app.get('/api/comments', listComments);
|
||||
app.get('/api/forums', listForums);
|
||||
app.get('/api/forums/:slug', showForum);
|
||||
app.get('/api/recent', recent);
|
||||
app.get('/api/thread', threadInfo);
|
||||
// Öffentliche Plugin-Routen (ohne Login) — je nach config.plugins, z. B. Dialog
|
||||
// lesen + Widget-Login. Jede Route bringt ihre eigenen Limits mit (dialog drosselt
|
||||
// den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
|
||||
manager.mountPublic(app);
|
||||
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
|
||||
app.route('/api/history', history);
|
||||
// Login gegen Brute-Force drosseln: max. 10 Versuche/IP pro 5 Minuten.
|
||||
app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login);
|
||||
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
||||
app.use('/api/*', requireAuth);
|
||||
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
|
||||
@@ -81,11 +74,10 @@ const mutateLimit = rateLimit({
|
||||
});
|
||||
app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next)));
|
||||
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
|
||||
app.post('/api/comments', createComment);
|
||||
app.delete('/api/comments/:id', deleteComment);
|
||||
app.post('/api/threads', newThread);
|
||||
app.route('/api/mod', mod);
|
||||
app.route('/api/admin/forums', adminForums);
|
||||
// Authentifizierte Plugin-Routen (nach requireAuth). admin:true-Routen laufen
|
||||
// hinter requireAdmin; self-guarding Sub-Apps (dialog mod/adminForums) regeln den
|
||||
// Zugriff selbst und brauchen das Flag nicht.
|
||||
manager.mountPrivate(app, '/api', requireAdmin);
|
||||
app.route('/api/content', content);
|
||||
app.route('/api/preview', preview);
|
||||
app.route('/api/publish', publish);
|
||||
@@ -120,8 +112,12 @@ app.use('/images/*', serveStatic({ root: `${SITE_DIR}/static` }));
|
||||
// --- Live-Site (gebaut nach public/) ---
|
||||
app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
|
||||
|
||||
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
||||
console.log(`OPENBUREAU CMS läuft auf :${info.port} — Site + API + /_preview`);
|
||||
// Library-Beiträge als Threads in „Beiträge" spiegeln (nicht blockierend).
|
||||
syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e));
|
||||
serve({ fetch: app.fetch, port: PORT }, async (info) => {
|
||||
console.log(`CMS läuft auf :${info.port} — Site + API + /_preview`);
|
||||
// Plugin-Migrationen zuerst anwenden (run-once, getrackt) — die Boot-Hooks
|
||||
// unten brauchen die Tabellen schon. Ohne deklarierte Migrationen ein No-op.
|
||||
await runMigrations(manager, { databaseUrl: process.env.DATABASE_URL })
|
||||
.catch((e) => console.error('migrate:', e?.message || e));
|
||||
// Plugin-Boot-Hooks anstoßen (dialog z. B. spiegelt Library→Threads), nicht blockierend.
|
||||
manager.runBoot({}).catch((e) => console.error('plugin onBoot:', e?.message || e));
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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,
|
||||
})));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 { roleOf } from '../auth.js';
|
||||
import { profileFor, threadLocked } from '../dialog-store.js';
|
||||
import { serverError } from '../util.js';
|
||||
import { supabase, supabaseAuth } from '../../supabase.js';
|
||||
import { roleOf } from '../../auth.js';
|
||||
import { profileFor, threadLocked } from './dialog-store.js';
|
||||
import { serverError } from '../../util.js';
|
||||
|
||||
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { supabase } from './supabase.js';
|
||||
import { listEntries } from './files.js';
|
||||
import { supabase } from '../../supabase.js';
|
||||
import { listEntries } from '../../files.js';
|
||||
|
||||
// Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen).
|
||||
// Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS).
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { requireAdmin, requireModerator } from '../auth.js';
|
||||
import { serverError } from '../util.js';
|
||||
import { supabase } from '../../supabase.js';
|
||||
import { requireAdmin, requireModerator } from '../../auth.js';
|
||||
import { serverError } from '../../util.js';
|
||||
import {
|
||||
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
|
||||
} from '../dialog-store.js';
|
||||
} from './dialog-store.js';
|
||||
|
||||
// Fehlt die Tabelle (Migration noch nicht eingespielt), nicht mit einem rohen
|
||||
// SQL-Fehler antworten — leer zurückgeben und server-seitig laut loggen.
|
||||
@@ -0,0 +1,74 @@
|
||||
// dialog plugin — openbureau's forum/comment subsystem as a core plugin.
|
||||
// Wraps the existing dialog modules (no logic change) into a plugin manifest:
|
||||
// public reads + widget login, authenticated writes, self-guarding moderation /
|
||||
// forum-admin sub-apps, syncLibrary on boot + after publish, and the forum
|
||||
// counters merged into /api/stats. kgva does not enable this plugin.
|
||||
//
|
||||
// NB: this manifest exists; wiring it into index.js (and removing the hard-coded
|
||||
// dialog there) is the live-tested cutover (stage 5) — see HANDOVER.
|
||||
import { rateLimit } from '../../ratelimit.js';
|
||||
import {
|
||||
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
|
||||
} from './dialog.js';
|
||||
import { listComments, createComment, deleteComment, login } from './comments.js';
|
||||
import { supabase } from '../../supabase.js';
|
||||
import { syncLibrary } from './dialog-store.js';
|
||||
|
||||
export default {
|
||||
name: 'dialog',
|
||||
|
||||
routes: [
|
||||
// ── public reads (mounted before requireAuth) ──
|
||||
{ method: 'get', path: '/comments', handler: listComments, public: true },
|
||||
{ method: 'get', path: '/forums', handler: listForums, public: true },
|
||||
{ method: 'get', path: '/forums/:slug', handler: showForum, public: true },
|
||||
{ method: 'get', path: '/recent', handler: recent, public: true },
|
||||
{ method: 'get', path: '/thread', handler: threadInfo, public: true },
|
||||
// widget login — brute-force throttled (10 / 5 min per IP), as in index.js
|
||||
{
|
||||
method: 'post', path: '/auth/login', handler: login, public: true,
|
||||
use: [rateLimit({ max: 10, windowMs: 5 * 60_000 })],
|
||||
},
|
||||
|
||||
// ── authenticated writes (mounted after requireAuth) ──
|
||||
{ method: 'post', path: '/comments', handler: createComment },
|
||||
{ method: 'delete', path: '/comments/:id', handler: deleteComment },
|
||||
{ method: 'post', path: '/threads', handler: newThread },
|
||||
|
||||
// ── self-guarding sub-apps (requireModerator / requireAdmin inside) ──
|
||||
{ path: '/mod', app: mod },
|
||||
{ path: '/admin/forums', app: adminForums },
|
||||
],
|
||||
|
||||
// Mirror library posts as threads — on boot and after every publish build.
|
||||
onBoot: async () => {
|
||||
await syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e));
|
||||
},
|
||||
onPublish: async () => {
|
||||
await syncLibrary({ force: true }).catch(() => {});
|
||||
},
|
||||
|
||||
// Forum/thread/comment counters, merged into /api/stats under "dialog".
|
||||
stats: async () => {
|
||||
const count = async (table, filter) => {
|
||||
try {
|
||||
let q = supabase.from(table).select('*', { count: 'exact', head: true });
|
||||
if (filter) q = filter(q);
|
||||
const { count: n } = await q;
|
||||
return n || 0;
|
||||
} catch { return 0; }
|
||||
};
|
||||
const [forums, threads, comments] = await Promise.all([
|
||||
count('forums'),
|
||||
count('threads', (q) => q.eq('deleted', false)),
|
||||
count('comments', (q) => q.eq('deleted', false)),
|
||||
]);
|
||||
return { forums, threads, comments };
|
||||
},
|
||||
|
||||
// Schema (forums/threads/comments + comment_stats view) — captured from the live
|
||||
// openbureau dev DB into migrations/001_dialog.sql (idempotent; verified to apply
|
||||
// on a fresh DB and re-apply as a no-op). The manager surfaces it; a migration
|
||||
// runner applies it on boot for a fresh instance (execution still TODO).
|
||||
migrations: ['001_dialog.sql'],
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
-- dialog plugin schema — forums / threads / comments (+ comment_stats view).
|
||||
--
|
||||
-- Captured 2026-06-29 from the live openbureau dev DB (CT 134 → openbureau-db,
|
||||
-- supabase/postgres 15.8) via `pg_dump --schema-only --no-owner --no-privileges`
|
||||
-- of public.{forums,threads,comments,comment_stats}. Made idempotent (IF NOT
|
||||
-- EXISTS / OR REPLACE / constraint guards) so it is a no-op against the existing
|
||||
-- openbureau DB and safe to apply to a fresh instance.
|
||||
--
|
||||
-- Notes:
|
||||
-- * gen_random_uuid() is built into Postgres 13+ — no pgcrypto extension needed.
|
||||
-- * RLS is enabled with NO policies: every read/write is mediated by the CMS
|
||||
-- using the Supabase service_role key (which bypasses RLS). The dialog plugin
|
||||
-- therefore only makes sense with `auth: supabase`; kgva (local auth) omits it.
|
||||
-- * `posts` exists in the live DB but the dialog code never touches it — NOT a
|
||||
-- dialog table, intentionally excluded.
|
||||
|
||||
-- ── tables ──────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.forums (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
slug text NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text DEFAULT ''::text,
|
||||
color text,
|
||||
sort integer DEFAULT 0 NOT NULL,
|
||||
kind text DEFAULT 'forum'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.threads (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
forum_id uuid,
|
||||
key text NOT NULL,
|
||||
title text NOT NULL,
|
||||
url text,
|
||||
kind text DEFAULT 'forum'::text NOT NULL,
|
||||
author_name text,
|
||||
user_id uuid,
|
||||
locked boolean DEFAULT false NOT NULL,
|
||||
deleted boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.comments (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
thread text NOT NULL,
|
||||
parent_id uuid,
|
||||
user_id uuid,
|
||||
author_name text,
|
||||
author_avatar text,
|
||||
body text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
deleted boolean DEFAULT false NOT NULL,
|
||||
author_role text
|
||||
);
|
||||
|
||||
-- ── constraints (guarded — Postgres has no ADD CONSTRAINT IF NOT EXISTS) ──────
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'forums_pkey') THEN
|
||||
ALTER TABLE ONLY public.forums ADD CONSTRAINT forums_pkey PRIMARY KEY (id);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'forums_slug_key') THEN
|
||||
ALTER TABLE ONLY public.forums ADD CONSTRAINT forums_slug_key UNIQUE (slug);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_pkey') THEN
|
||||
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_pkey PRIMARY KEY (id);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_key_key') THEN
|
||||
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_key_key UNIQUE (key);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'comments_pkey') THEN
|
||||
ALTER TABLE ONLY public.comments ADD CONSTRAINT comments_pkey PRIMARY KEY (id);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'threads_forum_id_fkey') THEN
|
||||
ALTER TABLE ONLY public.threads ADD CONSTRAINT threads_forum_id_fkey
|
||||
FOREIGN KEY (forum_id) REFERENCES public.forums(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'comments_parent_id_fkey') THEN
|
||||
ALTER TABLE ONLY public.comments ADD CONSTRAINT comments_parent_id_fkey
|
||||
FOREIGN KEY (parent_id) REFERENCES public.comments(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── indexes ───────────────────────────────────────────────────────────────────
|
||||
CREATE INDEX IF NOT EXISTS threads_forum_idx ON public.threads USING btree (forum_id);
|
||||
CREATE INDEX IF NOT EXISTS comments_thread_idx ON public.comments USING btree (thread, created_at);
|
||||
|
||||
-- ── view: per-thread comment counts (used by dialog-store) ────────────────────
|
||||
CREATE OR REPLACE VIEW public.comment_stats AS
|
||||
SELECT comments.thread,
|
||||
(count(*))::integer AS count,
|
||||
max(comments.created_at) AS last
|
||||
FROM public.comments
|
||||
WHERE (NOT comments.deleted)
|
||||
GROUP BY comments.thread;
|
||||
|
||||
-- ── row-level security (enabled, no policies — service_role-mediated) ─────────
|
||||
ALTER TABLE public.forums ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.threads ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { urlFor, safeRel } from '../files.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.
|
||||
const publish = new Hono();
|
||||
@@ -11,8 +11,9 @@ publish.post('/', async (c) => {
|
||||
try {
|
||||
const safe = safeRel(rel);
|
||||
const build = await buildSite({ dest: 'public', drafts: false });
|
||||
// Neue/aktualisierte Library-Beiträge sofort als Dialog-Threads spiegeln.
|
||||
await syncLibrary({ force: true }).catch(() => {});
|
||||
// Plugin-Publish-Hooks anstoßen (dialog spiegelt neue Library-Beiträge sofort
|
||||
// als Threads). Fehler dürfen das Publish nicht kippen — Hooks fangen selbst ab.
|
||||
await manager.runPublish({}, { path: safe });
|
||||
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
||||
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
||||
} catch (e) {
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { listEntries } from '../files.js';
|
||||
import { requireAdmin, roleOf } from '../auth.js';
|
||||
import { config } from '../config.js';
|
||||
import { contentStats } from '../collections.js';
|
||||
import { manager } from '../plugins.js';
|
||||
|
||||
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
|
||||
const stats = new Hono();
|
||||
stats.use('*', requireAdmin);
|
||||
|
||||
stats.get('/', async (c) => {
|
||||
// Inhalte aus dem Dateisystem zählen — Zähler pro collection.statKey.
|
||||
let content = {};
|
||||
try { content = contentStats(await listEntries(), config.collections); }
|
||||
catch { /* Filesystem nicht lesbar → leer */ }
|
||||
|
||||
// Nutzer nach Rolle (GoTrue).
|
||||
const users = { total: 0, admin: 0, editor: 0, user: 0 };
|
||||
try {
|
||||
const { data } = await supabase.auth.admin.listUsers();
|
||||
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
||||
} catch { /* GoTrue nicht erreichbar */ }
|
||||
|
||||
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
|
||||
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
|
||||
const pluginStats = await manager.collectStats({});
|
||||
// Dialog bleibt als eigenes Feld im Vertrag (Admin erwartet { forums, threads,
|
||||
// comments }); fehlt das Plugin, sind es Nullen.
|
||||
const dialog = pluginStats.dialog || { forums: 0, threads: 0, comments: 0 };
|
||||
|
||||
return c.json({ content, users, dialog });
|
||||
});
|
||||
|
||||
export default stats;
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// karimgabrielevarano.xyz — second consumer of openbureau-core.
|
||||
// No dialog plugin; a portfolio-shaped content model.
|
||||
export default {
|
||||
site: 'kgva',
|
||||
admins: ['karim@gabrielevarano.ch'],
|
||||
plugins: [],
|
||||
|
||||
collections: [
|
||||
{
|
||||
kind: 'project', label: 'Portfolio', order: 0,
|
||||
path: 'portfolio/:slug',
|
||||
statKey: 'projects',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'thumbnail', type: 'image' },
|
||||
{ name: 'studies', type: 'string', hint: '/studies/hslu/ba/semester_05' },
|
||||
{ name: 'schools', type: 'list' },
|
||||
{ name: 'degrees', type: 'list' },
|
||||
{ name: 'semesters', type: 'list' },
|
||||
{ name: 'video', type: 'string', hint: '/media/x.mp4' },
|
||||
{ name: 'images', type: 'list', of: { src: 'image', name: 'string' } },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'page', label: 'Pages', order: 1, fallback: true,
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'description', type: 'string' },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'section', label: 'Sections', order: 2, index: true,
|
||||
fields: [
|
||||
{ name: 'title', type: 'string' },
|
||||
{ name: 'description', type: 'string' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
// openbureau site — reproduces the current hard-coded content model 1:1.
|
||||
// Once the engine is config-driven, openbureau consumes openbureau-core with
|
||||
// exactly this config and behaves as today.
|
||||
export default {
|
||||
site: 'openbureau',
|
||||
admins: ['karim@gabrielevarano.ch'],
|
||||
plugins: ['dialog'], // comment/forum subsystem (library ↔ threads sync)
|
||||
|
||||
collections: [
|
||||
{
|
||||
kind: 'beitrag', label: 'Beiträge', order: 0,
|
||||
path: 'archiv/:section/:slug',
|
||||
sections: ['buerofuehrung', 'software', 'theorie'],
|
||||
statKey: 'beitraege',
|
||||
draftStatKey: 'entwuerfe', // Beitrag-Entwürfe als eigener Zähler (Dashboard)
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'section', type: 'select', options: ['buerofuehrung', 'software', 'theorie'] },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'color', type: 'string' },
|
||||
{ name: 'layout', type: 'select', options: ['text'], default: 'text' },
|
||||
{ name: 'tags', type: 'list' },
|
||||
{ name: 'summary', type: 'text' },
|
||||
{ name: 'cover_image', type: 'image' },
|
||||
{ name: 'authors', type: 'list' },
|
||||
{ name: 'toc', type: 'bool' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'biblio', label: 'Library', order: 1,
|
||||
path: 'library/:slug',
|
||||
statKey: 'library',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'tags', type: 'list' },
|
||||
{ name: 'summary', type: 'text' },
|
||||
{ name: 'cover_image', type: 'image' },
|
||||
{ name: 'external', type: 'string' },
|
||||
{ name: 'group', type: 'string' },
|
||||
{ name: 'authors', type: 'list' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'rubrik', label: 'Rubriken', order: 3, index: true,
|
||||
statKey: 'rubriken',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'color', type: 'string' },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'seite', label: 'Seiten', order: 2, fallback: true,
|
||||
statKey: 'seiten',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'toc', type: 'bool' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -146,7 +146,10 @@ services:
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
cms:
|
||||
build:
|
||||
context: .
|
||||
# openbureau consumes openbureau-core, vendored via git subtree at cms/core.
|
||||
# Build context = core's root so core's own Dockerfile picks up core/admin +
|
||||
# core/api (the generic engine), not this site's files.
|
||||
context: ./core
|
||||
dockerfile: api/Dockerfile
|
||||
args:
|
||||
# Browser-seitig (Admin-SPA, zur Build-Zeit): öffentliche Supabase-URL.
|
||||
@@ -169,6 +172,11 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||
SITE_DIR: /site
|
||||
# Tells core which content model + plugins this site has (schema-driven engine).
|
||||
# Lives in the mounted repo (/site = repo root). DATABASE_URL is intentionally
|
||||
# unset: the stack's `migrate` service owns the schema (db/schema.sql incl. the
|
||||
# dialog tables), so core's plugin migration runner stays a no-op here.
|
||||
CMS_CONFIG: /site/cms/openbureau.config.js
|
||||
PORT: 3000
|
||||
GIT_PUBLISH: ${GIT_PUBLISH:-false}
|
||||
GIT_REMOTE: ${GIT_REMOTE:-origin}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// openbureau site config — what this site's content model + plugins are.
|
||||
// Consumed by openbureau-core (vendored at cms/core) via CMS_CONFIG. Reproduces
|
||||
// the previously hard-coded content model 1:1 (proven by core's collections test).
|
||||
export default {
|
||||
site: 'openbureau',
|
||||
auth: 'supabase', // GoTrue/Supabase login (the stack provides it); core default
|
||||
admins: ['karim@gabrielevarano.ch'],
|
||||
plugins: ['dialog'], // comment/forum subsystem (library ↔ threads sync)
|
||||
|
||||
collections: [
|
||||
{
|
||||
kind: 'beitrag', label: 'Beiträge', order: 0,
|
||||
path: 'archiv/:section/:slug',
|
||||
sections: ['buerofuehrung', 'software', 'theorie'],
|
||||
statKey: 'beitraege',
|
||||
draftStatKey: 'entwuerfe', // Beitrag-Entwürfe als eigener Zähler (Dashboard)
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'section', type: 'select', options: ['buerofuehrung', 'software', 'theorie'] },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'color', type: 'string' },
|
||||
{ name: 'layout', type: 'select', options: ['text'], default: 'text' },
|
||||
{ name: 'tags', type: 'list' },
|
||||
{ name: 'summary', type: 'text' },
|
||||
{ name: 'cover_image', type: 'image' },
|
||||
{ name: 'authors', type: 'list' },
|
||||
{ name: 'toc', type: 'bool' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'biblio', label: 'Library', order: 1,
|
||||
path: 'library/:slug',
|
||||
statKey: 'library',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'tags', type: 'list' },
|
||||
{ name: 'summary', type: 'text' },
|
||||
{ name: 'cover_image', type: 'image' },
|
||||
{ name: 'external', type: 'string' },
|
||||
{ name: 'group', type: 'string' },
|
||||
{ name: 'authors', type: 'list' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'rubrik', label: 'Rubriken', order: 3, index: true,
|
||||
statKey: 'rubriken',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'color', type: 'string' },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'seite', label: 'Seiten', order: 2, fallback: true,
|
||||
statKey: 'seiten',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'toc', type: 'bool' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user