Compare commits
12 Commits
51bf175450
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b37edb6bda | |||
| 6aedf21bf6 | |||
| fed003ae31 | |||
| 685592362a | |||
| 2f639c068b | |||
| f518eb7a13 | |||
| 3d902a0bbe | |||
| 42110059c7 | |||
| ecf7710b8e | |||
| ab78c84296 | |||
| 80eca1bc7c | |||
| 2bf27f01be |
@@ -1,8 +0,0 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
// Öffentliche Browser-Werte (zur Build-Zeit von Vite eingesetzt). Der anon-Key
|
|
||||||
// ist per Design öffentlich; die echte Autorität liegt server-seitig.
|
|
||||||
const url = import.meta.env.VITE_SUPABASE_URL;
|
|
||||||
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
export const supabase = createClient(url, anonKey);
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { createClient } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
const url = process.env.SUPABASE_URL;
|
|
||||||
const key = process.env.SUPABASE_SERVICE_KEY;
|
|
||||||
|
|
||||||
if (!url || !key) {
|
|
||||||
console.error('FEHLT: SUPABASE_URL und/oder SUPABASE_SERVICE_KEY in .env');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
|
|
||||||
|
|
||||||
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
|
|
||||||
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
|
|
||||||
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
|
|
||||||
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
|
|
||||||
export const supabase = createClient(url, key, opts);
|
|
||||||
|
|
||||||
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
|
|
||||||
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
|
|
||||||
export const supabaseAuth = createClient(url, key, opts);
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# build output
|
||||||
|
admin/dist/
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# openbureau-core — handover
|
||||||
|
|
||||||
|
## For a fresh instance — START HERE
|
||||||
|
- **Repo (local):** cloned at `/home/karim/openbureau-core`. Remote
|
||||||
|
`git.openbureau.ch/karim/openbureau-core` is **private**; auth via an HTTPS token
|
||||||
|
in `~/.git-credentials` (the SSH key `id_ed25519_openbureau` is currently rejected
|
||||||
|
by gitea/proxmox — server seems rebuilt; fix the key or keep using the token).
|
||||||
|
- **Where the work is:** branch **`stufe-2-5-scaffold`** (pushed, NOT merged). `main`
|
||||||
|
is still the pre-stage-2 foundation. Open a PR or merge when happy.
|
||||||
|
- **Done:** stages 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.
|
||||||
|
- **Status:** stages 1–9 DONE; openbureau + kgva BOTH LIVE on core. openbureau on dev CT134 (schema editor + supabase); kgva LIVE at karimgabrielevarano.xyz via core (Variant A: CMS serves the site; CT130 rebuilt in-place — Docker + kgva-cms on :8081, nginx :80 proxies to it and keeps /media, static timer disabled, Caddy untouched). CAVEAT: kgva GIT_PUBLISH=false → edits on CT130 disk, not git-backed (enable GIT_PUBLISH + creds to fix). Rollback for kgva: restore nginx .static.bak + re-enable kgva-deploy.timer + stop kgva-cms. Original stage-5 note kept below for history.
|
||||||
|
- **Immediate next action (historic):** stage 5 + 5.5 **DONE** on the branch (commits `c4230a4`
|
||||||
|
cutover, `5e06337` DDL, `a93d11a` migration runner). Plugin-manager singleton
|
||||||
|
(`api/src/plugins.js`) wired into `index.js`/`stats.js`/`publish.js`, hard-coded
|
||||||
|
dialog removed, source moved under `api/src/plugins/dialog/`; DDL captured into
|
||||||
|
`plugins/dialog/migrations/001_dialog.sql` (idempotent); `api/src/migrate.js`
|
||||||
|
applies migrations once via `schema_migrations` at boot. **53 tests green** + a real
|
||||||
|
boot. **Live-verified on the dev stack** (CT 134): a 2nd cms instance from
|
||||||
|
`cms-cms:latest` (my `src` mounted over `/app/src`, real Supabase via `kong:8000`,
|
||||||
|
empty SITE_DIR → no writes) returned **byte-identical** `/api/forums` & `/api/recent`
|
||||||
|
vs the running container, `/api/content` 401 on both; the runner's tracking SQL was
|
||||||
|
proven on a throwaway DB. **NOT deployed** (the running stack still runs old code).
|
||||||
|
NEXT (stage 8): deploy core to openbureau — needs `DATABASE_URL` set for the cms
|
||||||
|
service (e.g. `postgres://postgres:$POSTGRES_PASSWORD@db:5432/postgres`) so the
|
||||||
|
runner can apply migrations, then full login/edit/publish verify before cutting the
|
||||||
|
live container over. Then stages 6 (admin `/api/schema`), 7 (local auth), 9 (kgva).
|
||||||
|
Dev stack: Proxmox CT 134 `openbureau-dev`, repo `/opt/openbureau`, compose
|
||||||
|
`cms/docker-compose.yml`, containers openbureau-{cms,auth,kong,rest,db}, dev URL
|
||||||
|
dev.openbureau.ch. **Do NOT push a blind refactor to prod.**
|
||||||
|
- **Env gotcha:** editing a file that's open in Karim's VSCode makes the Edit/Write
|
||||||
|
tool hang/"interrupt" — write such files via Bash (`cat > f <<'EOF' … EOF`) instead.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Extract a generic, **schema-driven** Hugo CMS engine ("openbureau-core") from the
|
||||||
|
openbureau site's bundled CMS, so that **both** openbureau and
|
||||||
|
karimgabrielevarano.xyz (kgva) consume it as a dependency. Decision: own repo
|
||||||
|
(this one), and migrate openbureau to depend on it.
|
||||||
|
|
||||||
|
## State (done this session)
|
||||||
|
- Repo `karim/openbureau-core` created on Gitea, foundation pushed.
|
||||||
|
- `api/` = engine copied verbatim from `karim/OPENBUREAU` → `cms/api` (Hono/Node).
|
||||||
|
- `admin/` = React/Vite SPA copied verbatim from `OPENBUREAU/cms/admin`.
|
||||||
|
- `api/src/config.js` = NEW loader: reads `CMS_CONFIG` → a site config module.
|
||||||
|
- `examples/openbureau.config.js`, `examples/kgva.config.js` = target schemas.
|
||||||
|
- `README.md` = config API + plugin model + the migration checklist.
|
||||||
|
- Nothing in the live openbureau CMS was changed yet.
|
||||||
|
|
||||||
|
## Engine is ~80% generic. openbureau-specific seams to make config-driven:
|
||||||
|
1. [DONE] `api/src/files.js` `classify(rel)` + the `order` map — now derived from
|
||||||
|
`config.collections` via `api/src/collections.js` (classify/buildPath/compareEntries);
|
||||||
|
`buildPath` exposed there too.
|
||||||
|
2. [DONE] `api/src/routes/stats.js` — now per-collection (`statKey`/`draftStatKey`),
|
||||||
|
dialog counts gated by `hasPlugin('dialog')`.
|
||||||
|
3. `api/src/index.js` — always mounts dialog routes + runs `syncLibrary` on boot.
|
||||||
|
→ only when `config.plugins` includes `dialog`.
|
||||||
|
4. `api/src/routes/publish.js` — calls `syncLibrary`. → gate by plugin.
|
||||||
|
5. `admin/src/App.jsx` (714 lines) — hard-codes `SECTIONS`, `KIND_LABEL`, the field
|
||||||
|
set, type dropdown, `buildPath`. → render sidebar groups + editor fields from the
|
||||||
|
schema (add `GET /api/schema` returning `config.collections`).
|
||||||
|
6. dialog subsystem (`dialog-store.js`, `routes/dialog.js`, `routes/comments.js`
|
||||||
|
+ Supabase tables forums/threads/comments + library↔thread sync) = the `dialog`
|
||||||
|
**plugin** (openbureau on, kgva off). First gate by config, later move to
|
||||||
|
`api/src/plugins/dialog/`.
|
||||||
|
|
||||||
|
## CRITICAL
|
||||||
|
api and admin share a data contract (stats keys, field shapes). Generalising the
|
||||||
|
**backend alone breaks the live openbureau admin** — they must change together and
|
||||||
|
be tested on the live Supabase/Hugo stack. Do NOT push a blind half-refactor to
|
||||||
|
production openbureau.
|
||||||
|
|
||||||
|
## Plugin system (decided architecture)
|
||||||
|
Everything beyond the generic engine is a **plugin**; the engine gets a small
|
||||||
|
**plugin manager**. A plugin = `api/src/plugins/<name>/index.js` exporting a
|
||||||
|
manifest `{ name, routes:[{path,app,public?,admin?}], onBoot, onPublish, onPreview,
|
||||||
|
migrations:[…], stats, admin:{…} }`. The manager reads `config.plugins`, imports
|
||||||
|
each, mounts routes in the right auth tier (public before `requireAuth`, rest
|
||||||
|
after, `admin:true` behind `requireAdmin`), registers boot/publish/preview/stats
|
||||||
|
hooks, runs migrations; the admin SPA loads UI from `admin/src/plugins/<name>`.
|
||||||
|
openbureau's extras become plugins (first: `dialog`); kgva enables none.
|
||||||
|
(Full spec in README → "Plugins".)
|
||||||
|
|
||||||
|
## Migration stages (also README checklist)
|
||||||
|
1. [done] repo + engine + config loader + example configs.
|
||||||
|
2. [done] files.js classify/order/buildPath ← config.collections
|
||||||
|
(new `api/src/collections.js` = pure classify/buildPath/compareEntries/contentStats;
|
||||||
|
files.js uses it, loads config lazily in listEntries; `api/test/collections.test.js`
|
||||||
|
proves 1:1 vs openbureau.config.js). All api tests green.
|
||||||
|
3. [done] stats.js ← collections: `content` via `statKey` + `draftStatKey`
|
||||||
|
(openbureau beitrag got `draftStatKey: 'entwuerfe'`), dialog counts gated by
|
||||||
|
`hasPlugin('dialog')` (pluginless site does zero DB reads here).
|
||||||
|
4. [built, not wired] **plugin manager** — `api/src/plugin-manager.js`
|
||||||
|
(`loadPlugins(names)` imports `plugins/<name>/index.js`; `createManager(manifests)`
|
||||||
|
= pure wiring: `mountPublic`/`mountPrivate(+requireAdmin)`, `runBoot/runPublish/
|
||||||
|
runPreview`, `collectStats` (merged under plugin name), `migrations()` (resolves
|
||||||
|
file URLs; execution deferred to the live DB)). Unit-tested in
|
||||||
|
`api/test/plugin-manager.test.js` (tiers/hooks/stats/migrations). TODO: wire into
|
||||||
|
index.js (stage 5), and admin loads plugin UI from `admin/src/plugins/<name>`.
|
||||||
|
5. [DONE — commits c4230a4 (cutover) + 5e06337 (DDL), live e2e verified] extract openbureau extras
|
||||||
|
into the **dialog** plugin. DONE: `api/src/plugins/dialog/index.js` = manifest
|
||||||
|
wrapping the existing modules unchanged (public reads + widget login(rate-limited)
|
||||||
|
+ authed writes + self-guarding mod/adminForums sub-apps; onBoot/onPublish =
|
||||||
|
syncLibrary; stats = forum/thread/comment counts). CUTOVER DONE: shared manager
|
||||||
|
singleton `api/src/plugins.js` wired into index.js (mountPublic before requireAuth,
|
||||||
|
mountPrivate(+requireAdmin) after, manager.runBoot at serve()), publish.js
|
||||||
|
(manager.runPublish) and stats.js (manager.collectStats, `{content,users,dialog}`
|
||||||
|
contract preserved); hard-coded dialog REMOVED from all three; dialog source
|
||||||
|
physically moved to `plugins/dialog/{dialog-store,dialog,comments}.js` (imports
|
||||||
|
fixed). Tested: `api/test/{dialog-plugin,cutover}.test.js`, 48 green + real boot.
|
||||||
|
DDL CAPTURED (commit 5e06337): `migrations/001_dialog.sql` from the live dev DB,
|
||||||
|
idempotent, validated on a throwaway DB; manifest `migrations: ['001_dialog.sql']`.
|
||||||
|
LIVE E2E VERIFIED (dev CT 134): 2nd cms instance from cms-cms:latest with my src
|
||||||
|
mounted, real Supabase via kong:8000 — /api/forums & /api/recent byte-identical to
|
||||||
|
the running container, /api/content 401 both, no writes. Migration runner = stage
|
||||||
|
5.5 (commit a93d11a, see new item below). Deploy is stage 8.
|
||||||
|
NB: dialog-store.js still filters `e.kind === 'beitrag'` — openbureau-specific,
|
||||||
|
already moved with the plugin.
|
||||||
|
5.5 [done, commit a93d11a] migration runner — `api/src/migrate.js` `runMigrations`
|
||||||
|
tracks applied migrations in `public.schema_migrations` and applies each declared
|
||||||
|
file once, in its own transaction, at boot (wired into index.js before runBoot).
|
||||||
|
Lean: no declared migrations → no DB connection, no pg import (kgva stays DB-less).
|
||||||
|
Executor is injectable (unit-tested without a DB); default uses a lazily-imported
|
||||||
|
`pg` against `DATABASE_URL`, warning-and-skipping if unset. Needs `DATABASE_URL`
|
||||||
|
wired into the cms service env for openbureau (stage 8). Tested: migrate.test.js +
|
||||||
|
tracking SQL proven on the live dev Postgres (throwaway DB).
|
||||||
|
6. [DONE, commits f709b5d + f8f4131 + 59b9e20] schema-driven admin. `GET /api/schema`
|
||||||
|
+ `GET /api/system` + a System panel; AND the editor now renders sidebar groups +
|
||||||
|
fields from the site's collections (admin/src/fields.jsx: per-type controls
|
||||||
|
string/text/slug/number/date/bool/select/list/image/color/markdown; form↔frontmatter
|
||||||
|
+ path building). One admin serves any site. Unknown frontmatter is PRESERVED on
|
||||||
|
save (__raw) — no data loss. Verified working via kgva (stage 9). NOTE: openbureau
|
||||||
|
is NOT yet redeployed with the schema editor (its running image keeps the bespoke
|
||||||
|
one); when it is, its config field types (color→swatch, layout opts) are already
|
||||||
|
tuned. Visual review of the openbureau editor by Karim still recommended before
|
||||||
|
redeploying it.
|
||||||
|
7. [DONE, commit 86f9f57] **auth provider** (`config.auth` = `supabase` | `local`).
|
||||||
|
`api/src/auth-local.js` = file-based users (bcryptjs) + self-signed HS256 JWTs of
|
||||||
|
the SAME claim shape; `routes/auth.js` POST /api/auth/login (mounted only when
|
||||||
|
auth:local); supabase.js made null-safe (fail-fast moved to index.js); auth/stats/
|
||||||
|
users.js branch local↔GoTrue; admin supabase.js gains a local-auth SHIM (same
|
||||||
|
interface) when VITE_SUPABASE_URL is absent → App/api untouched, supabase path
|
||||||
|
byte-identical. examples/kgva.config.js → auth:'local'. Verified: 59 tests +
|
||||||
|
DB-less boot smoke (no Supabase: local login → /api/me admin → stats-from-file)
|
||||||
|
+ openbureau redeployed and confirmed unregressed (auth=supabase).
|
||||||
|
8. [DONE, OB main 3d902a0] openbureau consumes core. Mechanism: **git subtree** at
|
||||||
|
`OPENBUREAU/cms/core` (fits the pull-based deploy; update via
|
||||||
|
`git subtree pull --prefix=cms/core <core> main --squash`). docker-compose builds
|
||||||
|
the cms image from `./core`; `cms/openbureau.config.js` (CMS_CONFIG) carries the
|
||||||
|
content model; old vendored cms/api+cms/admin removed. DATABASE_URL left unset (the
|
||||||
|
stack's migrate service owns db/schema.sql incl. dialog). Deployed to dev CT 134
|
||||||
|
and FULL write-verified: login/list/edit/preview/publish/dialog all identical, no
|
||||||
|
git divergence (GIT_PUBLISH=false). Rollback image `cms-cms:rollback` kept.
|
||||||
|
9. [DONE — LIVE on core (Variant A)] kgva consumes core. In
|
||||||
|
`karim/kgva` (git.kgva.ch, main 51a94b8): `cms/core` = core subtree, `cms/kgva.config.js`
|
||||||
|
(project/page/section, auth:'local', no plugins), `docker-compose.yml` builds the core
|
||||||
|
image (Hugo 0.163.3, no VITE_SUPABASE_URL → local-auth admin), `cms/seed-admin.mjs`,
|
||||||
|
updated deploy/provision-lxc.sh + ADMIN.md. VERIFIED DB-less end-to-end on real content
|
||||||
|
(staged on CT134): local login → schema editor → 42 entries (de/en) → stats →
|
||||||
|
create/preview/cleanup; image galleries preserved. The LIVE site is UNCHANGED — CT130
|
||||||
|
still deploys statically (timer → `hugo` → nginx; hugo ignores cms/); the CMS is dormant.
|
||||||
|
PENDING (deliberate, his call — repoints a live domain): provision a kgva-core container
|
||||||
|
(deploy/provision-lxc.sh, free CTID + temp IP) and decide topology — CMS serves the site
|
||||||
|
(Caddy → it) OR CMS edits + GIT_PUBLISH=true commits to git and the CT130 static timer
|
||||||
|
keeps serving. Then Caddy cutover per deploy/README. openbureau also not yet redeployed
|
||||||
|
with the schema editor (stage 6 note).
|
||||||
|
|
||||||
|
## How to run / test
|
||||||
|
openbureau CMS stack: `OPENBUREAU/cms/docker-compose.yml` (Node api + Hugo +
|
||||||
|
Supabase: postgres/kong/gotrue). Env in `cms/.env` (see `.env.example`):
|
||||||
|
SUPABASE_URL/SERVICE_KEY, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY (derive via
|
||||||
|
`scripts/generate-keys.mjs`), ADMIN_EMAILS, SITE_URL, GIT_*. For core add
|
||||||
|
`CMS_CONFIG` (path to the site config) and `SITE_DIR` (site repo mount, default
|
||||||
|
`/site`). Admin at `/admin`, preview `/_preview`, publish builds `public/`.
|
||||||
|
|
||||||
|
## Infra access (NO secrets in this file)
|
||||||
|
- Proxmox node `192.168.1.2`, user `root`. Password: **ask the user** (provided ad
|
||||||
|
hoc, not stored). No SSH key installed — use SSH_ASKPASS, or install a key first.
|
||||||
|
- Gitea = container 120 (`pct exec 120 …`), ROOT_URL `git.openbureau.ch`, sqlite at
|
||||||
|
`/var/lib/gitea/data/gitea.db`. Make a token:
|
||||||
|
`pct exec 120 -- su gitea -s /bin/bash -c "/usr/local/bin/gitea admin user
|
||||||
|
generate-access-token --username karim --scopes all --token-name X --raw
|
||||||
|
--config /etc/gitea/app.ini"`. Keep token `kgva-deploy`. Delete tokens via sqlite
|
||||||
|
(`DELETE FROM access_token WHERE name LIKE '…'`) since the API needs the account
|
||||||
|
password.
|
||||||
|
- Push without local git auth: tar → scp to node → `pct push 120` into the gitea
|
||||||
|
container → `git push http://karim:<token>@127.0.0.1:3000/karim/<repo>.git`.
|
||||||
|
- kgva site deploy: container 130 (kgva-website). nginx serves
|
||||||
|
`/var/www/karimgabrielevarano.xyz` (= ZFS `tank/kgva-website`, owned uid 100000).
|
||||||
|
systemd timer runs `/opt/kgva-deploy.sh` every 60s: pull `karim/kgva` (token
|
||||||
|
`kgva-deploy`) → `hugo` build → copy into webroot. So a push to `karim/kgva` is
|
||||||
|
live in ~1 min. Self-hosted video at `/var/www/media` (nginx `/media/` alias,
|
||||||
|
outside the build). Public TLS for the domain terminates upstream → 192.168.1.130:80.
|
||||||
|
|
||||||
|
## Watch out
|
||||||
|
- Gitea does NOT send CORS on its OAuth token endpoint → a browser git-CMS
|
||||||
|
(Decap-style) can't auth without a same-origin proxy. openbureau-core uses its own
|
||||||
|
Supabase auth, so this doesn't affect it; it's why kgva edits go via git push.
|
||||||
|
- Hugo versions: openbureau CMS bundles 0.161.1; the kgva site needs 0.163.3
|
||||||
|
features. Mind the version a core instance builds with.
|
||||||
@@ -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)
|
||||||
@@ -3,33 +3,10 @@ import ToastEditor from '@toast-ui/editor';
|
|||||||
import '@toast-ui/editor/dist/toastui-editor.css';
|
import '@toast-ui/editor/dist/toastui-editor.css';
|
||||||
import { supabase } from './supabase.js';
|
import { supabase } from './supabase.js';
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
|
import {
|
||||||
// OPENBUREAU-Palette (Hex aus assets/css/custom.css) — für Dropdown + Punkte.
|
Field, blankForm, formFromFrontmatter, frontmatterFromForm, targetPath,
|
||||||
const COLORS = [
|
isShort, isBody, hexOf, DEFAULT_PALETTE,
|
||||||
['', 'keine', 'transparent'],
|
} from './fields.jsx';
|
||||||
['ajisai', 'Ajisai · Hortensie', '#A39EC4'],
|
|
||||||
['sakura', 'Sakura · Kirschblüte', '#C49EC4'],
|
|
||||||
['suna', 'Suna · Sand', '#C4C19E'],
|
|
||||||
['ichigo', 'Ichigo · Erdbeere', '#C49EA0'],
|
|
||||||
['yuyake', 'Yuyake · Sonnenuntergang', '#CEB188'],
|
|
||||||
['sora', 'Sora · Himmel', '#9EC3C4'],
|
|
||||||
['kusa', 'Kusa · Gras', '#9EC49F'],
|
|
||||||
['kori', 'Kori · Eis', '#A5B4CB'],
|
|
||||||
['amagumo', 'Amagumo · Regenwolke', '#4C4C4C'],
|
|
||||||
['yuki', 'Yuki · Schnee', '#F0F0F0'],
|
|
||||||
];
|
|
||||||
const hexOf = (name) => (COLORS.find((c) => c[0] === name) || [])[2] || 'transparent';
|
|
||||||
|
|
||||||
const LAYOUTS = ['', 'text', 'image', 'icon'];
|
|
||||||
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
|
|
||||||
const KIND_LABEL = { beitrag: 'Beiträge', biblio: 'Library', seite: 'Seiten', rubrik: 'Rubriken' };
|
|
||||||
|
|
||||||
const EMPTY = {
|
|
||||||
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
|
|
||||||
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
|
|
||||||
color: '', layout: 'text', tags: '', summary: '', description: '',
|
|
||||||
cover_image: '', external: '', authors: '', group: '', toc: false, draft: true, body: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [session, setSession] = useState(null);
|
const [session, setSession] = useState(null);
|
||||||
@@ -69,6 +46,9 @@ function Login() {
|
|||||||
|
|
||||||
function Dashboard({ email }) {
|
function Dashboard({ email }) {
|
||||||
const [entries, setEntries] = useState([]);
|
const [entries, setEntries] = useState([]);
|
||||||
|
const [collections, setCollections] = useState(null);
|
||||||
|
const [plugins, setPlugins] = useState([]);
|
||||||
|
const [site, setSite] = useState('');
|
||||||
const [current, setCurrent] = useState(null);
|
const [current, setCurrent] = useState(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [view, setView] = useState('content');
|
const [view, setView] = useState('content');
|
||||||
@@ -79,31 +59,57 @@ function Dashboard({ email }) {
|
|||||||
try { setEntries(await api.list()); }
|
try { setEntries(await api.list()); }
|
||||||
catch (e) { setMsg({ type: 'err', text: e.message }); }
|
catch (e) { setMsg({ type: 'err', text: e.message }); }
|
||||||
}
|
}
|
||||||
useEffect(() => { refresh(); api.getMe().then(setMe).catch(() => {}); }, []);
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
api.getMe().then(setMe).catch(() => {});
|
||||||
|
api.schema().then((sc) => {
|
||||||
|
setCollections((sc.collections || []).slice().sort((a, b) => (a.order ?? 99) - (b.order ?? 99)));
|
||||||
|
setPlugins(sc.plugins || []); setSite(sc.site || '');
|
||||||
|
}).catch(() => setCollections([]));
|
||||||
|
}, []);
|
||||||
useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }, [msg]);
|
useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }, [msg]);
|
||||||
|
|
||||||
|
const cols = collections || [];
|
||||||
|
const hasDialog = plugins.includes('dialog');
|
||||||
|
const collectionOf = (kind) => cols.find((c) => c.kind === kind) || cols.find((c) => c.fallback) || cols[0] || null;
|
||||||
|
|
||||||
async function open(entry) {
|
async function open(entry) {
|
||||||
try { setCurrent(fromRead(await api.read(entry.path))); }
|
const col = collectionOf(entry.kind);
|
||||||
catch (err) { setMsg({ type: 'err', text: err.message }); }
|
try {
|
||||||
|
const r = await api.read(entry.path);
|
||||||
|
const bodyField = (col?.fields || []).find(isBody);
|
||||||
|
setCurrent({
|
||||||
|
isNew: false, kind: entry.kind, path: entry.path,
|
||||||
|
...formFromFrontmatter(r.frontmatter || {}, col || { fields: [] }),
|
||||||
|
...(bodyField ? { [bodyField.name]: r.body || '' } : {}),
|
||||||
|
});
|
||||||
|
} catch (err) { setMsg({ type: 'err', text: err.message }); }
|
||||||
|
}
|
||||||
|
function openNew() {
|
||||||
|
const col = cols[0];
|
||||||
|
if (col) setCurrent({ isNew: true, kind: col.kind, path: '', ...blankForm(col) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
const filtered = q ? entries.filter((e) => (e.title || '').toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
||||||
const groups = { beitrag: [], biblio: [], seite: [], rubrik: [] };
|
const fallbackKind = (cols.find((c) => c.fallback) || cols[0] || {}).kind;
|
||||||
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
|
const groups = {};
|
||||||
|
for (const c of cols) groups[c.kind] = [];
|
||||||
|
for (const e of filtered) { const k = groups[e.kind] ? e.kind : fallbackKind; if (groups[k]) groups[k].push(e); }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<span className="logo">OPENBUREAU</span>
|
<span className="logo">{(site || 'cms').toUpperCase()}</span>
|
||||||
<span className="logo-sub">Redaktion</span>
|
<span className="logo-sub">Redaktion</span>
|
||||||
<nav className="nav">
|
<nav className="nav">
|
||||||
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
|
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
|
||||||
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
||||||
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
||||||
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
{me?.canModerate && hasDialog && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||||||
{me?.isAdmin && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
|
{me?.isAdmin && hasDialog && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
|
||||||
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
|
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
|
||||||
|
{me?.isAdmin && <button className={view === 'system' ? 'active' : ''} onClick={() => setView('system')}>System</button>}
|
||||||
</nav>
|
</nav>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="who">{email}</span>
|
<span className="who">{email}</span>
|
||||||
@@ -121,18 +127,20 @@ function Dashboard({ email }) {
|
|||||||
<Forums onMsg={setMsg} />
|
<Forums onMsg={setMsg} />
|
||||||
) : view === 'moderation' ? (
|
) : view === 'moderation' ? (
|
||||||
<Moderation onMsg={setMsg} />
|
<Moderation onMsg={setMsg} />
|
||||||
|
) : view === 'system' ? (
|
||||||
|
<System onMsg={setMsg} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<aside>
|
<aside>
|
||||||
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Beitrag</button>
|
<button className="new" onClick={openNew} disabled={!cols.length}>+ Neu</button>
|
||||||
<div className="search"><span>⌕</span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
|
<div className="search"><span>⌕</span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
|
||||||
{['beitrag', 'biblio', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
|
{cols.map((c) => groups[c.kind]?.length > 0 && (
|
||||||
<div className="group" key={kind}>
|
<div className="group" key={c.kind}>
|
||||||
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
|
<div className="group-title">{c.label} <span>{groups[c.kind].length}</span></div>
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
{groups[kind].map((e) => (
|
{groups[c.kind].map((e) => (
|
||||||
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(e)}>
|
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(e)}>
|
||||||
<span className="dot" style={{ background: e.color ? hexOf(e.color) : 'var(--line)' }} />
|
<span className="dot" style={{ background: e.color ? hexOf(DEFAULT_PALETTE, e.color) : 'var(--line)' }} />
|
||||||
<span className="t">
|
<span className="t">
|
||||||
<span className="t-title">{e.title}</span>
|
<span className="t-title">{e.title}</span>
|
||||||
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
|
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
|
||||||
@@ -146,9 +154,9 @@ function Dashboard({ email }) {
|
|||||||
</aside>
|
</aside>
|
||||||
<main>
|
<main>
|
||||||
{current
|
{current
|
||||||
? <Editor key={current.path || 'new'} initial={current}
|
? <Editor key={(current.path || 'new') + ':' + current.kind} initial={current} collections={cols}
|
||||||
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
|
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
|
||||||
: <div className="empty"><p>Wähle links einen Eintrag — oder leg einen neuen Beitrag an.</p></div>}
|
: <div className="empty"><p>Wähle links einen Eintrag — oder leg einen neuen an.</p></div>}
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -159,7 +167,7 @@ function Dashboard({ email }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Editor({ initial, onSaved, onMsg }) {
|
function Editor({ initial, collections, onSaved, onMsg }) {
|
||||||
const [f, setF] = useState(initial);
|
const [f, setF] = useState(initial);
|
||||||
const [previewUrl, setPreviewUrl] = useState(null);
|
const [previewUrl, setPreviewUrl] = useState(null);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
@@ -167,17 +175,23 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const editorRef = useRef(null);
|
const editorRef = useRef(null);
|
||||||
const dragging = useRef(false);
|
const dragging = useRef(false);
|
||||||
const coverIn = useRef(null);
|
|
||||||
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
|
|
||||||
const isWiki = f.type === 'biblio' || (f.path || '').startsWith('library/');
|
|
||||||
|
|
||||||
async function pickCover(ev) {
|
const cols = collections || [];
|
||||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
const collection = cols.find((c) => c.kind === f.kind) || cols[0] || { fields: [] };
|
||||||
if (!file) return;
|
const fields = collection.fields || [];
|
||||||
setBusy(true);
|
const titleField = fields.find((x) => x.name === 'title') || fields.find((x) => x.type === 'string' && x.required) || null;
|
||||||
try { const { url } = await api.upload(file); setF((p) => ({ ...p, cover_image: url })); }
|
const bodyField = fields.find(isBody);
|
||||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
const draftField = fields.find((x) => x.type === 'bool' && x.name === 'draft');
|
||||||
finally { setBusy(false); }
|
const shortFields = fields.filter((x) => x !== titleField && !isBody(x) && isShort(x));
|
||||||
|
const longFields = fields.filter((x) => x !== titleField && !isBody(x) && !isShort(x));
|
||||||
|
const isDraft = draftField ? !!f[draftField.name] : false;
|
||||||
|
const hasSlugField = fields.some((x) => x.name === 'slug');
|
||||||
|
|
||||||
|
const setField = (name, val) => setF((p) => ({ ...p, [name]: val }));
|
||||||
|
const upload = async (file) => (await api.upload(file)).url;
|
||||||
|
function changeKind(kind) {
|
||||||
|
const col = cols.find((c) => c.kind === kind);
|
||||||
|
if (col) setF({ isNew: true, kind, path: '', ...blankForm(col) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ziehbarer Trenner Editor ↔ Vorschau.
|
// Ziehbarer Trenner Editor ↔ Vorschau.
|
||||||
@@ -194,25 +208,22 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
}, []);
|
}, []);
|
||||||
function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }
|
function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }
|
||||||
|
|
||||||
function currentPath(data = f) {
|
|
||||||
if (!data.isNew) return data.path;
|
|
||||||
const slug = (data.slug || '').trim();
|
|
||||||
if (!slug) return '';
|
|
||||||
if (data.type === 'beitrag') return `archiv/${data.section}/${slug}.md`;
|
|
||||||
if (data.type === 'biblio') return `library/${slug}.md`;
|
|
||||||
return `${slug}.md`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// overrides erlauben z.B. { draft: false } beim Publizieren.
|
|
||||||
async function save(overrides = {}) {
|
async function save(overrides = {}) {
|
||||||
const data = { ...f, ...overrides };
|
const data = { ...f, ...overrides };
|
||||||
const path = currentPath(data);
|
const path = data.isNew ? targetPath(data, collection) : data.path;
|
||||||
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug angeben.' }); return null; }
|
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug (und ggf. Pflichtsegmente wie Rubrik) angeben.' }); return null; }
|
||||||
if (!data.title.trim()) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
|
if (titleField && !((data[titleField.name] || '').trim())) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.save(path, buildFrontmatter(data), data.body);
|
const fm = frontmatterFromForm(data, collection);
|
||||||
const loaded = fromRead(await api.read(path));
|
const body = bodyField ? (data[bodyField.name] || '') : '';
|
||||||
|
await api.save(path, fm, body);
|
||||||
|
const r = await api.read(path);
|
||||||
|
const loaded = {
|
||||||
|
isNew: false, kind: data.kind, path,
|
||||||
|
...formFromFrontmatter(r.frontmatter || {}, collection),
|
||||||
|
...(bodyField ? { [bodyField.name]: r.body || '' } : {}),
|
||||||
|
};
|
||||||
onSaved(loaded); setF(loaded);
|
onSaved(loaded); setF(loaded);
|
||||||
return path;
|
return path;
|
||||||
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
|
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
|
||||||
@@ -226,8 +237,8 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
}
|
}
|
||||||
async function publish() {
|
async function publish() {
|
||||||
if (!confirm('Live publizieren? Der Beitrag wird aus „Entwurf“ genommen.')) return;
|
if (!confirm('Live publizieren?')) return;
|
||||||
const path = await save({ draft: false }); // Publizieren = nicht mehr Entwurf
|
const path = await save(draftField ? { [draftField.name]: false } : {});
|
||||||
if (!path) return;
|
if (!path) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
|
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
|
||||||
@@ -241,7 +252,7 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
<div className="editor-head">
|
<div className="editor-head">
|
||||||
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
|
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{f.draft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>}
|
{draftField && (isDraft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>)}
|
||||||
<button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
|
<button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
|
||||||
{showPreview ? 'Vorschau ⤫' : 'Vorschau ⤢'}
|
{showPreview ? 'Vorschau ⤫' : 'Vorschau ⤢'}
|
||||||
</button>
|
</button>
|
||||||
@@ -254,61 +265,37 @@ function Editor({ initial, onSaved, onMsg }) {
|
|||||||
{f.isNew && (
|
{f.isNew && (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="sm">Typ
|
<label className="sm">Typ
|
||||||
<select value={f.type} onChange={set('type')}>
|
<select value={f.kind} onChange={(e) => changeKind(e.target.value)}>
|
||||||
<option value="beitrag">Beitrag</option>
|
{cols.map((c) => <option key={c.kind} value={c.kind}>{c.label}</option>)}
|
||||||
<option value="biblio">Library-Seite</option>
|
|
||||||
<option value="seite">Seite</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{f.type === 'beitrag' && (
|
{!hasSlugField && (
|
||||||
<label className="sm">Rubrik
|
<label className="sm">Dateiname
|
||||||
<select value={f.section} onChange={set('section')}>{SECTIONS.map((s) => <option key={s}>{s}</option>)}</select>
|
<input value={f.slug || ''} onChange={(e) => setField('slug', e.target.value)} placeholder="z. B. impressum" />
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
|
{titleField && (
|
||||||
|
<label className="big">{titleField.label || 'Titel'}
|
||||||
|
<input value={f[titleField.name] || ''} onChange={(e) => setField(titleField.name, e.target.value)} placeholder="Titel" />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shortFields.length > 0 && (
|
||||||
<div className="meta">
|
<div className="meta">
|
||||||
{isWiki && <label className="sm">Gruppe<input value={f.group} onChange={set('group')} placeholder="z. B. Begriffe" /></label>}
|
{shortFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
|
||||||
<label className="sm">Datum<input type="date" value={f.date} onChange={set('date')} /></label>
|
|
||||||
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
|
|
||||||
<label className="sm">Farbe
|
|
||||||
<div className="colorpick">
|
|
||||||
<span className="swatch" style={{ background: hexOf(f.color) }} />
|
|
||||||
<select value={f.color} onChange={set('color')}>{COLORS.map(([v, label]) => <option key={v} value={v}>{label}</option>)}</select>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label className="sm">Layout
|
|
||||||
<select value={f.layout} onChange={set('layout')}>{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(automatisch)'}</option>)}</select>
|
|
||||||
</label>
|
|
||||||
<label>Tags<input value={f.tags} onChange={set('tags')} placeholder="komma, getrennt" /></label>
|
|
||||||
<label className="check"><input type="checkbox" checked={f.toc} onChange={set('toc')} /> Inhaltsverz.</label>
|
|
||||||
<label className="check"><input type="checkbox" checked={f.draft} onChange={set('draft')} /> Entwurf</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label>
|
{longFields.map((fld) => <Field key={fld.name} field={fld} value={f[fld.name]} onChange={setField} onUpload={upload} busy={busy} />)}
|
||||||
<div className="row">
|
|
||||||
<label>Cover-Bild
|
|
||||||
<div className="cover-row">
|
|
||||||
<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" />
|
|
||||||
<button type="button" onClick={() => coverIn.current?.click()} disabled={busy}>Hochladen</button>
|
|
||||||
<input ref={coverIn} type="file" accept="image/*" hidden onChange={pickCover} />
|
|
||||||
{f.cover_image && <span className="cover-thumb" style={{ backgroundImage: `url(${f.cover_image})` }} />}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label>Externer Link<input value={f.external} onChange={set('external')} placeholder="https://…" /></label>
|
|
||||||
</div>
|
|
||||||
<label>Autor:innen (E-Mails, Komma — für gemeinsamen Zugriff)
|
|
||||||
<input value={f.authors} onChange={set('authors')} placeholder="du@…, kollege@…" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
|
{bodyField && (
|
||||||
<div className="rich">
|
<div className="rich">
|
||||||
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))}
|
<RichEditor value={f[bodyField.name] || ''} onChange={(body) => setField(bodyField.name, body)} onUpload={upload} />
|
||||||
onUpload={async (file) => (await api.upload(file)).url} />
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -402,6 +389,31 @@ function Profile({ onMsg }) {
|
|||||||
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></label>
|
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></label>
|
||||||
<div className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
|
<div className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
|
||||||
</div>
|
</div>
|
||||||
|
<PasswordChange onMsg={onMsg} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordChange({ onMsg }) {
|
||||||
|
const [cur, setCur] = useState('');
|
||||||
|
const [nxt, setNxt] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (nxt.length < 6) { onMsg({ type: 'err', text: 'Neues Passwort zu kurz (min. 6 Zeichen).' }); return; }
|
||||||
|
setBusy(true);
|
||||||
|
try { await api.changePassword(cur, nxt); onMsg({ type: 'ok', text: 'Passwort geändert.' }); setCur(''); setNxt(''); }
|
||||||
|
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="profile-card">
|
||||||
|
<h2>Passwort ändern</h2>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<label>Aktuelles Passwort<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></label>
|
||||||
|
<label>Neues Passwort<input type="password" value={nxt} onChange={(e) => setNxt(e.target.value)} autoComplete="new-password" /></label>
|
||||||
|
<div className="actions"><button className="primary" disabled={busy}>Passwort setzen</button></div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -413,38 +425,80 @@ function Overview({ onMsg, go }) {
|
|||||||
if (!s) return <div className="empty">…</div>;
|
if (!s) return <div className="empty">…</div>;
|
||||||
const Card = ({ label, value, hint, to }) => (
|
const Card = ({ label, value, hint, to }) => (
|
||||||
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
|
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
|
||||||
<span className="stat-value">{value}</span>
|
<span className="stat-value">{value ?? 0}</span>
|
||||||
<span className="stat-label">{label}</span>
|
<span className="stat-label">{label}</span>
|
||||||
<span className="stat-hint">{hint || ' '}</span>
|
<span className="stat-hint">{hint || ' '}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
const content = s.content || {};
|
||||||
|
const d = s.dialog || { forums: 0, threads: 0, comments: 0 };
|
||||||
|
const hasDialog = (d.forums + d.threads + d.comments) > 0;
|
||||||
return (
|
return (
|
||||||
<div className="overview">
|
<div className="overview">
|
||||||
<h2>Übersicht</h2>
|
<h2>Übersicht</h2>
|
||||||
<div className="stat-grid">
|
<div className="stat-grid">
|
||||||
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" />
|
{Object.entries(content).map(([k, v]) => <Card key={k} label={k} value={v} to="content" />)}
|
||||||
<Card label="Library-Seiten" value={s.content.library} to="content" />
|
<Card label="Autor:innen" value={s.users?.total} hint={`${s.users?.admin || 0} Admin · ${s.users?.editor || 0} Red.`} to="users" />
|
||||||
<Card label="Seiten" value={s.content.seiten} />
|
{hasDialog && <Card label="Foren" value={d.forums} to="forums" />}
|
||||||
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" />
|
{hasDialog && <Card label="Threads" value={d.threads} to="moderation" />}
|
||||||
<Card label="Foren" value={s.dialog.forums} to="forums" />
|
{hasDialog && <Card label="Wortmeldungen" value={d.comments} to="moderation" />}
|
||||||
<Card label="Threads" value={s.dialog.threads} to="moderation" />
|
|
||||||
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overview-actions">
|
<div className="overview-actions">
|
||||||
<h3>Schnellzugriff</h3>
|
<h3>Schnellzugriff</h3>
|
||||||
<div className="quick">
|
<div className="quick">
|
||||||
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
|
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
|
||||||
<button onClick={() => go('forums')}>Foren verwalten</button>
|
{hasDialog && <button onClick={() => go('forums')}>Foren verwalten</button>}
|
||||||
<button onClick={() => go('users')}>Autor:innen & Rollen</button>
|
<button onClick={() => go('users')}>Autor:innen & Rollen</button>
|
||||||
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website ↗</a>
|
<a className="quick-link" href="/" target="_blank" rel="noreferrer">Website ↗</a>
|
||||||
<a className="quick-link" href="/dialog/" target="_blank" rel="noreferrer">Dialog ↗</a>
|
|
||||||
<a className="quick-link" href="/library/" target="_blank" rel="noreferrer">Library ↗</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── System (nur Admin): core-Version, Update-Check, Admins, Plugins, Modell ──
|
||||||
|
function System({ onMsg }) {
|
||||||
|
const [s, setS] = useState(null);
|
||||||
|
const [upd, setUpd] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
api.system().then(setS).catch((e) => onMsg({ type: 'err', text: e.message }));
|
||||||
|
api.systemUpdate().then(setUpd).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
if (!s) return <div className="empty">…</div>;
|
||||||
|
const badge = !upd ? null
|
||||||
|
: upd.available ? <span className="status draft">core v{upd.vendored} bereit — Rebuild nötig</span>
|
||||||
|
: upd.vendored ? <span className="status live">aktuell</span>
|
||||||
|
: <span className="muted">(kein Subtree)</span>;
|
||||||
|
return (
|
||||||
|
<div className="profile">
|
||||||
|
<div className="profile-card wide">
|
||||||
|
<h2>System</h2>
|
||||||
|
<div className="sysgrid">
|
||||||
|
<div><span className="muted">Core</span><b>{s.core.name} <span className="ver">v{s.core.version}</span></b> {badge}</div>
|
||||||
|
<div><span className="muted">Site</span><b>{s.site || '—'}</b></div>
|
||||||
|
<div><span className="muted">Auth</span><b>{s.auth}</b></div>
|
||||||
|
<div><span className="muted">Hugo</span><b>{s.hugo}</b></div>
|
||||||
|
</div>
|
||||||
|
<h3>Admins <span className="count-pill">{(s.admins || []).length}</span></h3>
|
||||||
|
<ul className="syslist">{(s.admins || []).map((a) => (
|
||||||
|
<li key={a}><b>{a}</b><span className="muted">aus Config (fix)</span></li>
|
||||||
|
))}</ul>
|
||||||
|
<h3>Plugins <span className="count-pill">{s.plugins.length}</span></h3>
|
||||||
|
{s.plugins.length
|
||||||
|
? <ul className="syslist">{s.plugins.map((p) => (
|
||||||
|
<li key={p.name}><b>{p.name}</b><span className="muted">{p.routes} Routen · {p.migrations} Migration(en)</span></li>
|
||||||
|
))}</ul>
|
||||||
|
: <p className="muted">Keine Plugins aktiv.</p>}
|
||||||
|
<h3>Content-Modell <span className="count-pill">{s.collections.length}</span></h3>
|
||||||
|
<ul className="syslist">{s.collections.map((c) => (
|
||||||
|
<li key={c.kind}><b>{c.label}</b><span className="muted">{c.kind} · {c.fields} Felder{c.statKey ? ` · ${c.statKey}` : ''}</span></li>
|
||||||
|
))}</ul>
|
||||||
|
<p className="muted who-mail">openbureau-core fährt diese Site. Updates werden serverseitig eingespielt (Update-Skript).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
||||||
function Users({ onMsg, currentEmail }) {
|
function Users({ onMsg, currentEmail }) {
|
||||||
const [list, setList] = useState(null);
|
const [list, setList] = useState(null);
|
||||||
@@ -676,39 +730,3 @@ function slugify(s) {
|
|||||||
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mapping Datei-Lesart → Formular ────────────────────────────────────────
|
|
||||||
function fromRead(r) {
|
|
||||||
const fm = r.frontmatter || {};
|
|
||||||
const p = r.path || '';
|
|
||||||
const type = p.startsWith('archiv/') ? 'beitrag' : p.startsWith('library/') ? 'biblio' : 'seite';
|
|
||||||
return {
|
|
||||||
isNew: false, path: r.path, type, section: '', slug: '',
|
|
||||||
title: fm.title || '', date: fm.date ? String(fm.date).slice(0, 10) : '',
|
|
||||||
weight: fm.weight ?? '', color: fm.color || '', layout: fm.layout || '',
|
|
||||||
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
|
|
||||||
summary: fm.summary || '', description: fm.description || '',
|
|
||||||
cover_image: fm.cover_image || '', external: fm.external || '',
|
|
||||||
authors: Array.isArray(fm.authors) ? fm.authors.join(', ') : (fm.authors || ''),
|
|
||||||
group: fm.group || '', toc: !!fm.toc, draft: !!fm.draft, body: r.body || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function buildFrontmatter(f) {
|
|
||||||
const fm = { title: f.title };
|
|
||||||
if (f.date) fm.date = f.date;
|
|
||||||
if (f.weight !== '' && f.weight != null) fm.weight = Number(f.weight);
|
|
||||||
const tags = f.tags ? f.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
|
||||||
if (tags.length) fm.tags = tags;
|
|
||||||
if (f.summary) fm.summary = f.summary;
|
|
||||||
if (f.description) fm.description = f.description;
|
|
||||||
if (f.cover_image) fm.cover_image = f.cover_image;
|
|
||||||
if (f.layout) fm.layout = f.layout;
|
|
||||||
if (f.external) fm.external = f.external;
|
|
||||||
if (f.color) fm.color = f.color;
|
|
||||||
const authors = f.authors ? f.authors.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
|
||||||
if (authors.length) fm.authors = authors;
|
|
||||||
if (f.group) fm.group = f.group;
|
|
||||||
if (f.toc) fm.toc = true;
|
|
||||||
if (f.draft) fm.draft = true;
|
|
||||||
return fm;
|
|
||||||
}
|
|
||||||
@@ -45,6 +45,10 @@ export const api = {
|
|||||||
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
||||||
getMe: () => call('/me'),
|
getMe: () => call('/me'),
|
||||||
stats: () => call('/stats'),
|
stats: () => call('/stats'),
|
||||||
|
system: () => call('/system'),
|
||||||
|
systemUpdate: () => call('/system/update'),
|
||||||
|
schema: () => call('/schema'),
|
||||||
|
changePassword: (current, next) => call('/account/password', { method: 'POST', body: JSON.stringify({ current, next }) }),
|
||||||
listUsers: () => call('/users'),
|
listUsers: () => call('/users'),
|
||||||
createUser: (email, password, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }),
|
createUser: (email, password, role) => call('/users', { method: 'POST', body: JSON.stringify({ email, password, role }) }),
|
||||||
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
setPassword: (id, password) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ password }) }),
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// Schema-driven form fields. The editor renders a collection's `fields` (from
|
||||||
|
// /api/schema) generically, so the SAME admin works for any site's content model
|
||||||
|
// (openbureau, kgva, …). Field types → controls; markdown ('body') is handled by
|
||||||
|
// the rich editor in App.jsx, not here.
|
||||||
|
//
|
||||||
|
// A field: { name, type, required?, options?, default?, hint?, palette? }
|
||||||
|
// Types: string | text | slug | number | date | bool | select | list | image |
|
||||||
|
// color | markdown (markdown rendered separately).
|
||||||
|
|
||||||
|
// openbureau's named colour palette — used for type:'color' when no per-field
|
||||||
|
// palette is given. Harmless elsewhere (sites without colour fields never see it).
|
||||||
|
export const DEFAULT_PALETTE = [
|
||||||
|
['', 'keine', 'transparent'],
|
||||||
|
['ajisai', 'Ajisai · Hortensie', '#A39EC4'], ['sakura', 'Sakura · Kirschblüte', '#C49EC4'],
|
||||||
|
['suna', 'Suna · Sand', '#C4C19E'], ['ichigo', 'Ichigo · Erdbeere', '#C49EA0'],
|
||||||
|
['yuyake', 'Yuyake · Sonnenuntergang', '#CEB188'], ['sora', 'Sora · Himmel', '#9EC3C4'],
|
||||||
|
['kusa', 'Kusa · Gras', '#9EC49F'], ['kori', 'Kori · Eis', '#A5B4CB'],
|
||||||
|
['amagumo', 'Amagumo · Regenwolke', '#4C4C4C'], ['yuki', 'Yuki · Schnee', '#F0F0F0'],
|
||||||
|
];
|
||||||
|
export const hexOf = (palette, name) => (palette.find((c) => c[0] === name) || [])[2] || 'transparent';
|
||||||
|
|
||||||
|
const SHORT = new Set(['string', 'slug', 'number', 'date', 'bool', 'select', 'color']);
|
||||||
|
export const isShort = (f) => SHORT.has(f.type);
|
||||||
|
export const isBody = (f) => f.type === 'markdown';
|
||||||
|
|
||||||
|
// Empty form for a new entry of `collection`.
|
||||||
|
export function blankForm(collection) {
|
||||||
|
const f = { __raw: {} };
|
||||||
|
for (const fld of collection.fields || []) {
|
||||||
|
if (isBody(fld)) { f[fld.name] = fld.default ?? ''; continue; }
|
||||||
|
if (fld.type === 'bool') f[fld.name] = fld.default ?? false;
|
||||||
|
else if (fld.type === 'list') f[fld.name] = '';
|
||||||
|
else f[fld.name] = fld.default ?? '';
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frontmatter (from disk) → form values, per field type. The full original
|
||||||
|
// frontmatter is stashed under __raw so unknown keys survive a save (no data loss).
|
||||||
|
export function formFromFrontmatter(fm, collection) {
|
||||||
|
const f = { __raw: fm || {} };
|
||||||
|
for (const fld of collection.fields || []) {
|
||||||
|
const v = fm[fld.name];
|
||||||
|
if (fld.type === 'bool') f[fld.name] = !!v;
|
||||||
|
else if (fld.type === 'list') f[fld.name] = Array.isArray(v) ? v.join(', ') : (v || '');
|
||||||
|
else if (fld.type === 'date') f[fld.name] = v ? String(v).slice(0, 10) : '';
|
||||||
|
else if (fld.type === 'number') f[fld.name] = v ?? '';
|
||||||
|
else f[fld.name] = v ?? '';
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form values → frontmatter. Starts from any preserved __raw (so fields not in the
|
||||||
|
// schema, e.g. image galleries, are kept), then sets/clears the schema fields.
|
||||||
|
export function frontmatterFromForm(form, collection) {
|
||||||
|
const fm = { ...(form.__raw || {}) };
|
||||||
|
const setOrDrop = (k, v) => { if (v === '' || v == null || (Array.isArray(v) && !v.length)) delete fm[k]; else fm[k] = v; };
|
||||||
|
for (const fld of collection.fields || []) {
|
||||||
|
const v = form[fld.name];
|
||||||
|
if (fld.type === 'bool') { if (v) fm[fld.name] = true; else delete fm[fld.name]; continue; }
|
||||||
|
if (fld.type === 'number') { setOrDrop(fld.name, v === '' || v == null ? '' : Number(v)); continue; }
|
||||||
|
if (fld.type === 'list') { setOrDrop(fld.name, (v || '').split(',').map((x) => x.trim()).filter(Boolean)); continue; }
|
||||||
|
setOrDrop(fld.name, v);
|
||||||
|
}
|
||||||
|
return fm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the target path from collection.path ( e.g. 'archiv/:section/:slug' ),
|
||||||
|
// falling back to '<slug>.md' (or '<id>.md' when there is no slug field).
|
||||||
|
export function targetPath(form, collection) {
|
||||||
|
const tmpl = collection.path;
|
||||||
|
const slug = (form.slug || '').trim();
|
||||||
|
if (tmpl) {
|
||||||
|
const rel = tmpl.replace(/:([a-z_]+)/gi, (_, k) => (form[k] || '').toString().trim());
|
||||||
|
if (rel.includes('//') || rel.endsWith('/') || /:/.test(rel)) return ''; // missing segment
|
||||||
|
return rel + '.md';
|
||||||
|
}
|
||||||
|
if (slug) return `${slug}.md`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// One field control (everything except markdown/body).
|
||||||
|
export function Field({ field, value, onChange, onUpload, busy }) {
|
||||||
|
const set = (val) => onChange(field.name, val);
|
||||||
|
const label = field.label || field.name;
|
||||||
|
const palette = field.palette || DEFAULT_PALETTE;
|
||||||
|
|
||||||
|
if (field.type === 'bool') {
|
||||||
|
return <label className="check"><input type="checkbox" checked={!!value} onChange={(e) => set(e.target.checked)} /> {label}</label>;
|
||||||
|
}
|
||||||
|
if (field.type === 'select') {
|
||||||
|
return (
|
||||||
|
<label className="sm">{label}
|
||||||
|
<select value={value || ''} onChange={(e) => set(e.target.value)}>
|
||||||
|
{!field.required && <option value="">(—)</option>}
|
||||||
|
{(field.options || []).map((o) => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.type === 'color') {
|
||||||
|
return (
|
||||||
|
<label className="sm">{label}
|
||||||
|
<div className="colorpick">
|
||||||
|
<span className="swatch" style={{ background: hexOf(palette, value) }} />
|
||||||
|
<select value={value || ''} onChange={(e) => set(e.target.value)}>
|
||||||
|
{palette.map(([v, lbl]) => <option key={v} value={v}>{lbl}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.type === 'image') {
|
||||||
|
return (
|
||||||
|
<label>{label}
|
||||||
|
<div className="cover-row">
|
||||||
|
<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder="/img/…" />
|
||||||
|
<ImageUpload onUpload={onUpload} onDone={set} busy={busy} />
|
||||||
|
{value && <span className="cover-thumb" style={{ backgroundImage: `url(${value})` }} />}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (field.type === 'text') {
|
||||||
|
return <label>{label}<textarea value={value || ''} rows={2} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
|
||||||
|
}
|
||||||
|
if (field.type === 'number') {
|
||||||
|
return <label className="xs">{label}<input type="number" value={value ?? ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
|
||||||
|
}
|
||||||
|
if (field.type === 'date') {
|
||||||
|
return <label className="sm">{label}<input type="date" value={value || ''} onChange={(e) => set(e.target.value)} /></label>;
|
||||||
|
}
|
||||||
|
if (field.type === 'list') {
|
||||||
|
return <label>{label}<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || 'komma, getrennt'} /></label>;
|
||||||
|
}
|
||||||
|
// string, slug, and any unknown type → text input
|
||||||
|
const cls = field.type === 'slug' ? 'sm' : '';
|
||||||
|
return <label className={cls}>{label}<input value={value || ''} onChange={(e) => set(e.target.value)} placeholder={field.hint || ''} /></label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { useRef } from 'react';
|
||||||
|
function ImageUpload({ onUpload, onDone, busy }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => ref.current?.click()} disabled={busy}>Hochladen</button>
|
||||||
|
<input ref={ref} type="file" accept="image/*" hidden onChange={async (ev) => {
|
||||||
|
const file = ev.target.files?.[0]; ev.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
try { onDone(await onUpload(file)); } catch { /* ignore */ }
|
||||||
|
}} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -219,3 +219,13 @@ label.big input { font-family: var(--serif); font-weight: 600; }
|
|||||||
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
.toast { position: fixed; bottom: 20px; right: 20px; padding: 11px 18px; border-radius: 11px; color: #fff; cursor: pointer; box-shadow: 0 10px 30px -12px rgba(0,0,0,.4); font-size: 13.5px; max-width: 380px; z-index: 50; }
|
||||||
.toast.ok { background: var(--ok); }
|
.toast.ok { background: var(--ok); }
|
||||||
.toast.err { background: var(--accent); }
|
.toast.err { background: var(--accent); }
|
||||||
|
|
||||||
|
/* System-Panel */
|
||||||
|
.sysgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: .75rem; margin: .5rem 0 1rem; }
|
||||||
|
.sysgrid > div { display: flex; flex-direction: column; gap: .15rem; }
|
||||||
|
.sysgrid .muted { font-size: .8rem; }
|
||||||
|
.sysgrid b { font-size: 1.05rem; }
|
||||||
|
.sysgrid .ver { opacity: .6; font-weight: 400; }
|
||||||
|
.syslist { list-style: none; padding: 0; margin: .25rem 0 1rem; }
|
||||||
|
.syslist li { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding: .4rem 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.syslist li .muted { font-size: .85rem; }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
// Öffentliche Browser-Werte (zur Build-Zeit von Vite eingesetzt). Der anon-Key
|
||||||
|
// ist per Design öffentlich; die echte Autorität liegt server-seitig.
|
||||||
|
const url = import.meta.env.VITE_SUPABASE_URL;
|
||||||
|
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
// Mit Supabase-URL: echter Client (openbureau, unverändert). Ohne (DB-loser core,
|
||||||
|
// auth: 'local', z. B. kgva): ein Shim mit DERSELBEN auth-Schnittstelle, die App/
|
||||||
|
// api nutzen — getSession / onAuthStateChange / signInWithPassword / signOut —
|
||||||
|
// gegen /api/auth/login, Token in localStorage. So bleibt der Supabase-Pfad
|
||||||
|
// völlig unangetastet, und der lokale Pfad braucht keine Änderung an App/api.
|
||||||
|
function localAuthClient() {
|
||||||
|
const KEY = 'cms_local_session';
|
||||||
|
const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || 'null'); } catch { return null; } };
|
||||||
|
let listeners = [];
|
||||||
|
const emit = (s) => listeners.forEach((cb) => { try { cb(s ? 'SIGNED_IN' : 'SIGNED_OUT', s); } catch { /* ignore */ } });
|
||||||
|
return {
|
||||||
|
auth: {
|
||||||
|
async getSession() { return { data: { session: read() } }; },
|
||||||
|
onAuthStateChange(cb) {
|
||||||
|
listeners.push(cb);
|
||||||
|
return { data: { subscription: { unsubscribe() { listeners = listeners.filter((l) => l !== cb); } } } };
|
||||||
|
},
|
||||||
|
async signInWithPassword({ email, password }) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) return { error: { message: j.error || `HTTP ${r.status}` } };
|
||||||
|
const session = { access_token: j.access_token, user: { id: j.user.id, email: j.user.email } };
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(session));
|
||||||
|
emit(session);
|
||||||
|
return { data: { session }, error: null };
|
||||||
|
} catch (e) { return { error: { message: String(e.message || e) } }; }
|
||||||
|
},
|
||||||
|
async signOut() { localStorage.removeItem(KEY); emit(null); return { error: null }; },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabase = url ? createClient(url, anonKey) : localAuthClient();
|
||||||
+156
-2
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "openbureau-cms-api",
|
"name": "openbureau-cms-api",
|
||||||
"version": "0.1.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "openbureau-cms-api",
|
"name": "openbureau-cms-api",
|
||||||
"version": "0.1.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@supabase/supabase-js": "^2.47.10",
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"hono": "^4.6.14",
|
"hono": "^4.6.14",
|
||||||
"marked": "^14.1.4",
|
"marked": "^14.1.4",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"sharp": "^0.33.5"
|
"sharp": "^0.33.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -528,6 +530,12 @@
|
|||||||
"sprintf-js": "~1.0.2"
|
"sprintf-js": "~1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "2.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||||
|
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/color": {
|
"node_modules/color": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
@@ -685,6 +693,134 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||||
|
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.14.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.15.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||||
|
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/section-matter": {
|
"node_modules/section-matter": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||||
@@ -758,6 +894,15 @@
|
|||||||
"is-arrayish": "^0.3.1"
|
"is-arrayish": "^0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
@@ -778,6 +923,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openbureau-cms-api",
|
"name": "openbureau-cms-api",
|
||||||
"version": "0.1.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
|
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
|
||||||
@@ -12,9 +12,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@supabase/supabase-js": "^2.47.10",
|
"@supabase/supabase-js": "^2.47.10",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"hono": "^4.6.14",
|
"hono": "^4.6.14",
|
||||||
"marked": "^14.1.4",
|
"marked": "^14.1.4",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"sharp": "^0.33.5"
|
"sharp": "^0.33.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Local auth provider — file-based users (bcrypt) + self-signed JWTs of the SAME
|
||||||
|
// claim shape as Supabase/GoTrue (sub / email / app_metadata.role / exp), so the
|
||||||
|
// rest of the engine (auth.js verifyToken, roleOf) treats local and supabase
|
||||||
|
// tokens identically. Lets a core instance run DB-less (no Supabase/Postgres):
|
||||||
|
// Node + Hugo + nginx. Activated by config.auth === 'local'.
|
||||||
|
//
|
||||||
|
// Users live in a JSON file (USERS_FILE, default <SITE_DIR>/cms-users.json):
|
||||||
|
// [{ id, email, password: <bcrypt hash>, role, created_at, last_sign_in_at }]
|
||||||
|
import path from 'node:path';
|
||||||
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { sign } from 'hono/jwt';
|
||||||
|
|
||||||
|
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||||
|
const USERS_FILE = process.env.USERS_FILE || path.join(SITE_DIR, 'cms-users.json');
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || '';
|
||||||
|
const ROLES = ['user', 'editor', 'admin'];
|
||||||
|
|
||||||
|
// bcryptjs is pure-JS (no native build) and lazily imported — a supabase-auth
|
||||||
|
// instance never loads it.
|
||||||
|
async function bcrypt() { return (await import('bcryptjs')).default; }
|
||||||
|
|
||||||
|
export async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(await readFile(USERS_FILE, 'utf8'));
|
||||||
|
return Array.isArray(j) ? j : (j.users || []);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
async function persist(users) {
|
||||||
|
await mkdir(path.dirname(USERS_FILE), { recursive: true });
|
||||||
|
await writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hashPassword(pw) { return (await bcrypt()).hash(pw, 10); }
|
||||||
|
export async function verifyPassword(pw, hash) {
|
||||||
|
try { return await (await bcrypt()).compare(pw, hash); } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a GoTrue-shaped HS256 access token (verified by auth.js with JWT_SECRET).
|
||||||
|
export async function mintToken(user, ttl = 3600) {
|
||||||
|
if (!JWT_SECRET) throw new Error('JWT_SECRET fehlt — für auth: local nötig');
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return sign({
|
||||||
|
sub: user.id, email: user.email, role: 'authenticated',
|
||||||
|
app_metadata: { role: user.role || 'user' }, iat: now, exp: now + ttl,
|
||||||
|
}, JWT_SECRET, 'HS256');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email, password) {
|
||||||
|
const users = await loadUsers();
|
||||||
|
const u = users.find((x) => (x.email || '').toLowerCase() === (email || '').toLowerCase());
|
||||||
|
if (!u || !u.password) return null;
|
||||||
|
if (!(await verifyPassword(password, u.password))) return null;
|
||||||
|
u.last_sign_in_at = new Date().toISOString();
|
||||||
|
await persist(users);
|
||||||
|
return {
|
||||||
|
access_token: await mintToken(u),
|
||||||
|
token_type: 'bearer',
|
||||||
|
user: { id: u.id, email: u.email, role: u.role || 'user' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin CRUD (mirrors routes/users.js shapes) ──────────────────────────────
|
||||||
|
export async function listUsers(adminEmails = []) {
|
||||||
|
const users = await loadUsers();
|
||||||
|
return users.map((u) => {
|
||||||
|
const fixedAdmin = adminEmails.includes((u.email || '').toLowerCase());
|
||||||
|
const role = u.role || 'user';
|
||||||
|
return {
|
||||||
|
id: u.id, email: u.email, created_at: u.created_at || null,
|
||||||
|
last_sign_in_at: u.last_sign_in_at || null,
|
||||||
|
role: fixedAdmin ? 'admin' : role, isAdmin: fixedAdmin || role === 'admin', fixedAdmin,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function countByRole(adminEmails = []) {
|
||||||
|
const list = await listUsers(adminEmails);
|
||||||
|
const out = { total: list.length, admin: 0, editor: 0, user: 0 };
|
||||||
|
for (const u of list) out[u.role] = (out[u.role] || 0) + 1;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
export async function createUser({ email, password, role }) {
|
||||||
|
if (!email || !password) throw new Error('E-Mail und Passwort nötig');
|
||||||
|
if (role && !ROLES.includes(role)) throw new Error('Unbekannte Rolle');
|
||||||
|
const users = await loadUsers();
|
||||||
|
if (users.some((u) => (u.email || '').toLowerCase() === email.toLowerCase())) {
|
||||||
|
throw new Error('E-Mail existiert bereits');
|
||||||
|
}
|
||||||
|
const u = {
|
||||||
|
id: randomUUID(), email, password: await hashPassword(password),
|
||||||
|
role: role || 'user', created_at: new Date().toISOString(), last_sign_in_at: null,
|
||||||
|
};
|
||||||
|
users.push(u); await persist(users);
|
||||||
|
return u.id;
|
||||||
|
}
|
||||||
|
export async function updateUser(id, { password, role }) {
|
||||||
|
const users = await loadUsers();
|
||||||
|
const u = users.find((x) => x.id === id);
|
||||||
|
if (!u) throw new Error('Nutzer:in nicht gefunden');
|
||||||
|
if (password) u.password = await hashPassword(password);
|
||||||
|
if (role) { if (!ROLES.includes(role)) throw new Error('Unbekannte Rolle'); u.role = role; }
|
||||||
|
await persist(users);
|
||||||
|
}
|
||||||
|
export async function deleteUser(id) {
|
||||||
|
const users = await loadUsers();
|
||||||
|
const next = users.filter((u) => u.id !== id);
|
||||||
|
if (next.length === users.length) throw new Error('Nutzer:in nicht gefunden');
|
||||||
|
await persist(next);
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ async function verifyToken(token) {
|
|||||||
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
|
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
|
// Fallback (kein JWT_SECRET): Remote-Prüfung gegen GoTrue — nur wenn ein
|
||||||
|
// Supabase-Auth-Client existiert (DB-loser Betrieb hat keinen).
|
||||||
|
if (!supabaseAuth) return null;
|
||||||
const { data, error } = await supabaseAuth.auth.getUser(token);
|
const { data, error } = await supabaseAuth.auth.getUser(token);
|
||||||
if (error || !data?.user) return null;
|
if (error || !data?.user) return null;
|
||||||
return data.user;
|
return data.user;
|
||||||
@@ -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,29 @@
|
|||||||
|
// Per-site CMS configuration loader.
|
||||||
|
//
|
||||||
|
// A site tells the core what it manages through one module, referenced by the
|
||||||
|
// CMS_CONFIG env var (e.g. CMS_CONFIG=/site/cms.config.js). The module exports
|
||||||
|
// `default` with { collections, plugins, admins }. Loaded once at startup; the
|
||||||
|
// engine (files/stats/routes) derives its behaviour from it instead of having
|
||||||
|
// openbureau-specific content types hard-coded.
|
||||||
|
//
|
||||||
|
// See ../../README.md for the schema, and ../../examples/*.config.js.
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
const p = process.env.CMS_CONFIG;
|
||||||
|
if (!p) {
|
||||||
|
console.error('FEHLT: CMS_CONFIG (Pfad zur Site-Config, z. B. /site/cms.config.js)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod = await import(pathToFileURL(p).href);
|
||||||
|
export const config = mod.default || mod;
|
||||||
|
config.collections ||= [];
|
||||||
|
config.plugins ||= [];
|
||||||
|
config.admins ||= [];
|
||||||
|
config.auth ||= 'supabase'; // 'supabase' (GoTrue) | 'local' (file users, DB-less)
|
||||||
|
|
||||||
|
export const hasPlugin = (name) => config.plugins.includes(name);
|
||||||
|
|
||||||
|
// Collection lookup helpers (used by the generalised files.js / stats.js).
|
||||||
|
export const collectionOf = (kind) => config.collections.find((c) => c.kind === kind) || null;
|
||||||
|
export const fallbackCollection = () => config.collections.find((c) => c.fallback) || null;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
|
import { classify, compareEntries } from './collections.js';
|
||||||
|
|
||||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||||
const CONTENT = path.join(SITE_DIR, 'content');
|
const CONTENT = path.join(SITE_DIR, 'content');
|
||||||
@@ -26,24 +27,6 @@ async function walk(dir) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beitrag (archiv/<section>/<slug>.md) | Library-Seite (library/<slug>.md)
|
|
||||||
// | Rubrik (_index.md) | Seite (sonst).
|
|
||||||
function classify(rel) {
|
|
||||||
const base = path.basename(rel);
|
|
||||||
const parts = rel.split('/');
|
|
||||||
if (base === '_index.md') {
|
|
||||||
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
|
|
||||||
return { kind: 'rubrik', section };
|
|
||||||
}
|
|
||||||
if (parts[0] === 'archiv' && parts.length === 3) {
|
|
||||||
return { kind: 'beitrag', section: parts[1] };
|
|
||||||
}
|
|
||||||
if (parts[0] === 'library') {
|
|
||||||
return { kind: 'biblio', section: 'library' };
|
|
||||||
}
|
|
||||||
return { kind: 'seite', section: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt).
|
// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt).
|
||||||
export function normAuthors(a) {
|
export function normAuthors(a) {
|
||||||
if (Array.isArray(a)) return a.map(String).filter(Boolean);
|
if (Array.isArray(a)) return a.map(String).filter(Boolean);
|
||||||
@@ -66,6 +49,9 @@ export function urlFor(rel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listEntries() {
|
export async function listEntries() {
|
||||||
|
// Lazy: only listing needs the site config, so importing files.js for its pure
|
||||||
|
// helpers (safeRel/urlFor/…) never triggers the CMS_CONFIG load.
|
||||||
|
const { config } = await import('./config.js');
|
||||||
const files = await walk(CONTENT);
|
const files = await walk(CONTENT);
|
||||||
const items = [];
|
const items = [];
|
||||||
for (const full of files) {
|
for (const full of files) {
|
||||||
@@ -77,7 +63,7 @@ export async function listEntries() {
|
|||||||
items.push({
|
items.push({
|
||||||
path: rel,
|
path: rel,
|
||||||
title: data.title || rel,
|
title: data.title || rel,
|
||||||
...classify(rel),
|
...classify(rel, config.collections),
|
||||||
color: data.color || null,
|
color: data.color || null,
|
||||||
layout: data.layout || null,
|
layout: data.layout || null,
|
||||||
draft: !!data.draft,
|
draft: !!data.draft,
|
||||||
@@ -86,12 +72,8 @@ export async function listEntries() {
|
|||||||
url: urlFor(rel),
|
url: urlFor(rel),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Beiträge zuerst, dann Library, Seiten, Rubriken; je nach Datum/Titel.
|
// Reihenfolge aus der Config (collection.order), dann Datum, dann Titel.
|
||||||
const order = { beitrag: 0, biblio: 1, seite: 2, rubrik: 3 };
|
items.sort(compareEntries(config.collections));
|
||||||
items.sort((a, b) =>
|
|
||||||
(order[a.kind] - order[b.kind]) ||
|
|
||||||
(b.date || '').localeCompare(a.date || '') ||
|
|
||||||
a.title.localeCompare(b.title));
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -12,13 +12,22 @@ import upload from './routes/upload.js';
|
|||||||
import profile from './routes/profile.js';
|
import profile from './routes/profile.js';
|
||||||
import users from './routes/users.js';
|
import users from './routes/users.js';
|
||||||
import stats from './routes/stats.js';
|
import stats from './routes/stats.js';
|
||||||
import { listComments, createComment, deleteComment, login } from './routes/comments.js';
|
import system from './routes/system.js';
|
||||||
|
import account from './routes/account.js';
|
||||||
import history from './routes/history.js';
|
import history from './routes/history.js';
|
||||||
import {
|
import authRoute from './routes/auth.js';
|
||||||
listForums, showForum, recent, threadInfo, newThread, mod, adminForums,
|
import { requireAuth, requireAdmin } from './auth.js';
|
||||||
} from './routes/dialog.js';
|
import { config } from './config.js';
|
||||||
import { requireAuth } from './auth.js';
|
import { supabase } from './supabase.js';
|
||||||
import { syncLibrary } from './dialog-store.js';
|
import { manager } from './plugins.js';
|
||||||
|
import { runMigrations } from './migrate.js';
|
||||||
|
|
||||||
|
// Fail-fast: auth:'supabase' ohne erreichbaren Service-Client ist ein Fehlstart.
|
||||||
|
// (auth:'local' braucht keinen — DB-loser Betrieb.)
|
||||||
|
if (config.auth === 'supabase' && !supabase) {
|
||||||
|
console.error('FEHLT: SUPABASE_URL/SERVICE_KEY für auth: supabase');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||||
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
||||||
@@ -61,17 +70,16 @@ app.use('/api/*', (c, next) =>
|
|||||||
c.req.path.startsWith('/api/upload') ? next() : jsonBodyLimit(c, next));
|
c.req.path.startsWith('/api/upload') ? next() : jsonBodyLimit(c, next));
|
||||||
|
|
||||||
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
|
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
|
||||||
// Öffentlich (ohne Login): Dialog lesen, Übersicht, Login fürs Dialog-Widget.
|
// Öffentliche Plugin-Routen (ohne Login) — je nach config.plugins, z. B. Dialog
|
||||||
app.get('/api/comments', listComments);
|
// lesen + Widget-Login. Jede Route bringt ihre eigenen Limits mit (dialog drosselt
|
||||||
app.get('/api/forums', listForums);
|
// den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
|
||||||
app.get('/api/forums/:slug', showForum);
|
manager.mountPublic(app);
|
||||||
app.get('/api/recent', recent);
|
// Lokaler Login (nur auth: 'local') — ersetzt GoTrue, öffentlich + gedrosselt.
|
||||||
app.get('/api/thread', threadInfo);
|
// Supabase-Sites loggen direkt gegen GoTrue ein und brauchen das nicht.
|
||||||
|
if (config.auth === 'local') app.route('/api/auth', authRoute);
|
||||||
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
|
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
|
||||||
app.route('/api/history', history);
|
app.route('/api/history', history);
|
||||||
// Login gegen Brute-Force drosseln: max. 10 Versuche/IP pro 5 Minuten.
|
// Alles weitere unter /api/* braucht ein gültiges Token (Supabase oder lokal).
|
||||||
app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login);
|
|
||||||
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
|
||||||
app.use('/api/*', requireAuth);
|
app.use('/api/*', requireAuth);
|
||||||
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
|
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
|
||||||
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
|
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
|
||||||
@@ -81,11 +89,10 @@ const mutateLimit = rateLimit({
|
|||||||
});
|
});
|
||||||
app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next)));
|
app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next)));
|
||||||
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
|
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
|
||||||
app.post('/api/comments', createComment);
|
// Authentifizierte Plugin-Routen (nach requireAuth). admin:true-Routen laufen
|
||||||
app.delete('/api/comments/:id', deleteComment);
|
// hinter requireAdmin; self-guarding Sub-Apps (dialog mod/adminForums) regeln den
|
||||||
app.post('/api/threads', newThread);
|
// Zugriff selbst und brauchen das Flag nicht.
|
||||||
app.route('/api/mod', mod);
|
manager.mountPrivate(app, '/api', requireAdmin);
|
||||||
app.route('/api/admin/forums', adminForums);
|
|
||||||
app.route('/api/content', content);
|
app.route('/api/content', content);
|
||||||
app.route('/api/preview', preview);
|
app.route('/api/preview', preview);
|
||||||
app.route('/api/publish', publish);
|
app.route('/api/publish', publish);
|
||||||
@@ -93,6 +100,16 @@ app.route('/api/upload', upload);
|
|||||||
app.route('/api/profile', profile);
|
app.route('/api/profile', profile);
|
||||||
app.route('/api/users', users);
|
app.route('/api/users', users);
|
||||||
app.route('/api/stats', stats);
|
app.route('/api/stats', stats);
|
||||||
|
app.route('/api/system', system);
|
||||||
|
app.route('/api/account', account);
|
||||||
|
// Content-Modell der Site fürs Admin (schema-getriebenes Rendern). Jede:r
|
||||||
|
// eingeloggte Nutzer:in darf es lesen — der Editor braucht es zum Aufbauen.
|
||||||
|
app.get('/api/schema', (c) => c.json({
|
||||||
|
site: config.site || null,
|
||||||
|
auth: config.auth || 'supabase',
|
||||||
|
plugins: config.plugins,
|
||||||
|
collections: config.collections,
|
||||||
|
}));
|
||||||
|
|
||||||
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
||||||
app.get('/admin', (c) => c.redirect('/admin/'));
|
app.get('/admin', (c) => c.redirect('/admin/'));
|
||||||
@@ -120,8 +137,12 @@ app.use('/images/*', serveStatic({ root: `${SITE_DIR}/static` }));
|
|||||||
// --- Live-Site (gebaut nach public/) ---
|
// --- Live-Site (gebaut nach public/) ---
|
||||||
app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
|
app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: PORT }, (info) => {
|
serve({ fetch: app.fetch, port: PORT }, async (info) => {
|
||||||
console.log(`OPENBUREAU CMS läuft auf :${info.port} — Site + API + /_preview`);
|
console.log(`CMS läuft auf :${info.port} — Site + API + /_preview`);
|
||||||
// Library-Beiträge als Threads in „Beiträge" spiegeln (nicht blockierend).
|
// Plugin-Migrationen zuerst anwenden (run-once, getrackt) — die Boot-Hooks
|
||||||
syncLibrary().catch((e) => console.error('syncLibrary:', e?.message || e));
|
// unten brauchen die Tabellen schon. Ohne deklarierte Migrationen ein No-op.
|
||||||
|
await runMigrations(manager, { databaseUrl: process.env.DATABASE_URL })
|
||||||
|
.catch((e) => console.error('migrate:', e?.message || e));
|
||||||
|
// Plugin-Boot-Hooks anstoßen (dialog z. B. spiegelt Library→Threads), nicht blockierend.
|
||||||
|
manager.runBoot({}).catch((e) => console.error('plugin onBoot:', e?.message || e));
|
||||||
});
|
});
|
||||||
@@ -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 { supabase, supabaseAuth } from '../../supabase.js';
|
||||||
import { roleOf } from '../auth.js';
|
import { roleOf } from '../../auth.js';
|
||||||
import { profileFor, threadLocked } from '../dialog-store.js';
|
import { profileFor, threadLocked } from './dialog-store.js';
|
||||||
import { serverError } from '../util.js';
|
import { serverError } from '../../util.js';
|
||||||
|
|
||||||
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
|
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { supabase } from './supabase.js';
|
import { supabase } from '../../supabase.js';
|
||||||
import { listEntries } from './files.js';
|
import { listEntries } from '../../files.js';
|
||||||
|
|
||||||
// Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen).
|
// Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen).
|
||||||
// Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS).
|
// Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS).
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { supabase } from '../supabase.js';
|
import { supabase } from '../../supabase.js';
|
||||||
import { requireAdmin, requireModerator } from '../auth.js';
|
import { requireAdmin, requireModerator } from '../../auth.js';
|
||||||
import { serverError } from '../util.js';
|
import { serverError } from '../../util.js';
|
||||||
import {
|
import {
|
||||||
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
|
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
|
||||||
} from '../dialog-store.js';
|
} from './dialog-store.js';
|
||||||
|
|
||||||
// Fehlt die Tabelle (Migration noch nicht eingespielt), nicht mit einem rohen
|
// Fehlt die Tabelle (Migration noch nicht eingespielt), nicht mit einem rohen
|
||||||
// SQL-Fehler antworten — leer zurückgeben und server-seitig laut loggen.
|
// SQL-Fehler antworten — leer zurückgeben und server-seitig laut loggen.
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { supabase, supabaseAuth } from '../supabase.js';
|
||||||
|
import * as local from '../auth-local.js';
|
||||||
|
|
||||||
|
// Self-service for the logged-in user (after requireAuth). Currently: change own
|
||||||
|
// password — local provider edits the user file; supabase verifies the current
|
||||||
|
// password then updates via the admin API.
|
||||||
|
const account = new Hono();
|
||||||
|
|
||||||
|
account.post('/password', async (c) => {
|
||||||
|
let body = {};
|
||||||
|
try { body = await c.req.json(); } catch { /* 400 below */ }
|
||||||
|
const { current, next } = body;
|
||||||
|
if (!next || String(next).length < 6) return c.json({ error: 'Neues Passwort zu kurz (min. 6 Zeichen).' }, 400);
|
||||||
|
const email = (c.get('email') || '').toLowerCase();
|
||||||
|
const userId = c.get('user')?.id;
|
||||||
|
|
||||||
|
if (config.auth === 'local') {
|
||||||
|
const u = (await local.loadUsers()).find((x) => (x.email || '').toLowerCase() === email);
|
||||||
|
if (!u || !(await local.verifyPassword(current || '', u.password))) {
|
||||||
|
return c.json({ error: 'Aktuelles Passwort ist falsch.' }, 403);
|
||||||
|
}
|
||||||
|
await local.updateUser(u.id, { password: next });
|
||||||
|
return c.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// supabase: verify current via the dedicated auth client, then update via admin.
|
||||||
|
if (!supabaseAuth || !supabase) return c.json({ error: 'Auth nicht verfügbar' }, 500);
|
||||||
|
const { error: e1 } = await supabaseAuth.auth.signInWithPassword({ email, password: current || '' });
|
||||||
|
await supabaseAuth.auth.signOut().catch(() => {}); // Session sofort wieder lösen
|
||||||
|
if (e1) return c.json({ error: 'Aktuelles Passwort ist falsch.' }, 403);
|
||||||
|
const { error: e2 } = await supabase.auth.admin.updateUserById(userId, { password: next });
|
||||||
|
if (e2) return c.json({ error: e2.message }, 400);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default account;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { rateLimit } from '../ratelimit.js';
|
||||||
|
import { login } from '../auth-local.js';
|
||||||
|
|
||||||
|
// Lokaler Login (auth: 'local') — ersetzt GoTrue. Liefert ein GoTrue-förmiges
|
||||||
|
// Token, das requireAuth genauso prüft wie ein Supabase-Token. Nur gemountet,
|
||||||
|
// wenn config.auth === 'local' (siehe index.js); Brute-Force gedrosselt.
|
||||||
|
const auth = new Hono();
|
||||||
|
|
||||||
|
auth.post('/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), async (c) => {
|
||||||
|
let body = {};
|
||||||
|
try { body = await c.req.json(); } catch { /* leerer/kaputter Body → 400 unten */ }
|
||||||
|
const { email, password } = body;
|
||||||
|
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||||
|
const res = await login(email, password);
|
||||||
|
if (!res) return c.json({ error: 'Login fehlgeschlagen' }, 401);
|
||||||
|
return c.json(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default auth;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { urlFor, safeRel } from '../files.js';
|
import { urlFor, safeRel } from '../files.js';
|
||||||
import { buildSite, gitCommit } from '../hugo.js';
|
import { buildSite, gitCommit } from '../hugo.js';
|
||||||
import { syncLibrary } from '../dialog-store.js';
|
import { manager } from '../plugins.js';
|
||||||
|
|
||||||
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
||||||
const publish = new Hono();
|
const publish = new Hono();
|
||||||
@@ -11,8 +11,9 @@ publish.post('/', async (c) => {
|
|||||||
try {
|
try {
|
||||||
const safe = safeRel(rel);
|
const safe = safeRel(rel);
|
||||||
const build = await buildSite({ dest: 'public', drafts: false });
|
const build = await buildSite({ dest: 'public', drafts: false });
|
||||||
// Neue/aktualisierte Library-Beiträge sofort als Dialog-Threads spiegeln.
|
// Plugin-Publish-Hooks anstoßen (dialog spiegelt neue Library-Beiträge sofort
|
||||||
await syncLibrary({ force: true }).catch(() => {});
|
// als Threads). Fehler dürfen das Publish nicht kippen — Hooks fangen selbst ab.
|
||||||
|
await manager.runPublish({}, { path: safe });
|
||||||
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
||||||
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { supabase } from '../supabase.js';
|
||||||
|
import { listEntries } from '../files.js';
|
||||||
|
import { requireAdmin, roleOf } from '../auth.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { contentStats } from '../collections.js';
|
||||||
|
import { manager } from '../plugins.js';
|
||||||
|
import { countByRole } from '../auth-local.js';
|
||||||
|
|
||||||
|
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '')
|
||||||
|
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||||
|
|
||||||
|
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
|
||||||
|
const stats = new Hono();
|
||||||
|
stats.use('*', requireAdmin);
|
||||||
|
|
||||||
|
stats.get('/', async (c) => {
|
||||||
|
// Inhalte aus dem Dateisystem zählen — Zähler pro collection.statKey.
|
||||||
|
let content = {};
|
||||||
|
try { content = contentStats(await listEntries(), config.collections); }
|
||||||
|
catch { /* Filesystem nicht lesbar → leer */ }
|
||||||
|
|
||||||
|
// Nutzer nach Rolle: lokal aus der User-Datei, sonst aus GoTrue.
|
||||||
|
let users = { total: 0, admin: 0, editor: 0, user: 0 };
|
||||||
|
if (config.auth === 'local') {
|
||||||
|
try { users = await countByRole(ADMIN_EMAILS); } catch { /* User-Datei fehlt → 0 */ }
|
||||||
|
} else if (supabase) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabase.auth.admin.listUsers();
|
||||||
|
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
||||||
|
} catch { /* GoTrue nicht erreichbar */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
|
||||||
|
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
|
||||||
|
const pluginStats = await manager.collectStats({});
|
||||||
|
// Dialog bleibt als eigenes Feld im Vertrag (Admin erwartet { forums, threads,
|
||||||
|
// comments }); fehlt das Plugin, sind es Nullen.
|
||||||
|
const dialog = pluginStats.dialog || { forums: 0, threads: 0, comments: 0 };
|
||||||
|
|
||||||
|
return c.json({ content, users, dialog });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default stats;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { manager } from '../plugins.js';
|
||||||
|
import { requireAdmin } from '../auth.js';
|
||||||
|
|
||||||
|
const execFileP = promisify(execFile);
|
||||||
|
|
||||||
|
let _version = null;
|
||||||
|
async function coreVersion() {
|
||||||
|
if (_version) return _version;
|
||||||
|
try { _version = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')).version || '0.0.0'; }
|
||||||
|
catch { _version = '0.0.0'; }
|
||||||
|
return _version;
|
||||||
|
}
|
||||||
|
let _hugo = null;
|
||||||
|
async function hugoVersion() {
|
||||||
|
if (_hugo) return _hugo;
|
||||||
|
try { const { stdout } = await execFileP('hugo', ['version']); _hugo = (stdout.match(/v[0-9][0-9.]*[^\s]*/) || ['?'])[0]; }
|
||||||
|
catch { _hugo = '?'; }
|
||||||
|
return _hugo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// System-/Diagnose-Info fürs Admin-Panel. Reiner Builder (testbar); die Route hängt
|
||||||
|
// nur die zur Laufzeit ermittelten Versionen dran.
|
||||||
|
export function systemInfo({ version, hugo }) {
|
||||||
|
return {
|
||||||
|
core: { name: 'openbureau-core', version },
|
||||||
|
site: config.site || null,
|
||||||
|
auth: config.auth || 'supabase',
|
||||||
|
admins: config.admins || [],
|
||||||
|
plugins: manager.plugins.map((p) => ({
|
||||||
|
name: p.name, routes: (p.routes || []).length, migrations: (p.migrations || []).length,
|
||||||
|
})),
|
||||||
|
collections: config.collections.map((c) => ({
|
||||||
|
kind: c.kind, label: c.label, statKey: c.statKey || null, fields: (c.fields || []).length,
|
||||||
|
})),
|
||||||
|
hugo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const system = new Hono();
|
||||||
|
system.use('*', requireAdmin);
|
||||||
|
|
||||||
|
system.get('/', async (c) => c.json(systemInfo({ version: await coreVersion(), hugo: await hugoVersion() })));
|
||||||
|
|
||||||
|
// Update-Check (token-frei): laufende Core-Version (Image, /app/package.json) vs. die
|
||||||
|
// im Repo VENDORTE Version (SITE_DIR/cms/core/api/package.json). Ist die vendorte neuer,
|
||||||
|
// wurde core aktualisiert aber noch nicht neu gebaut → "Rebuild nötig" (Update-Skript).
|
||||||
|
system.get('/update', async (c) => {
|
||||||
|
const running = await coreVersion();
|
||||||
|
const siteDir = process.env.SITE_DIR || '/site';
|
||||||
|
const vendorPath = path.join(siteDir, process.env.CORE_VENDOR_PATH || 'cms/core', 'api/package.json');
|
||||||
|
let vendored = null;
|
||||||
|
try { vendored = JSON.parse(await readFile(vendorPath, 'utf8')).version || null; }
|
||||||
|
catch { /* nicht als Subtree eingebunden → kein Vergleich möglich */ }
|
||||||
|
return c.json({ running, vendored, available: !!(vendored && vendored !== running) });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default system;
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { supabase } from '../supabase.js';
|
import { supabase } from '../supabase.js';
|
||||||
import { requireAdmin, roleOf } from '../auth.js';
|
import { requireAdmin, roleOf } from '../auth.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import * as local from '../auth-local.js';
|
||||||
|
|
||||||
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
// Autoren-/Nutzerverwaltung. Mit auth:'local' gegen die User-Datei (DB-los),
|
||||||
|
// sonst über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
||||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||||
|
const isLocal = () => config.auth === 'local';
|
||||||
|
|
||||||
const users = new Hono();
|
const users = new Hono();
|
||||||
users.use('*', requireAdmin);
|
users.use('*', requireAdmin);
|
||||||
|
|
||||||
users.get('/', async (c) => {
|
users.get('/', async (c) => {
|
||||||
|
if (isLocal()) {
|
||||||
|
try { return c.json(await local.listUsers(ADMINS)); }
|
||||||
|
catch (e) { return c.json({ error: String(e.message || e) }, 500); }
|
||||||
|
}
|
||||||
const { data, error } = await supabase.auth.admin.listUsers();
|
const { data, error } = await supabase.auth.admin.listUsers();
|
||||||
if (error) return c.json({ error: error.message }, 500);
|
if (error) return c.json({ error: error.message }, 500);
|
||||||
const list = (data?.users || []).map((u) => {
|
const list = (data?.users || []).map((u) => {
|
||||||
@@ -32,6 +40,10 @@ users.post('/', async (c) => {
|
|||||||
const { email, password, role } = await c.req.json();
|
const { email, password, role } = await c.req.json();
|
||||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||||
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||||
|
if (isLocal()) {
|
||||||
|
try { return c.json({ ok: true, id: await local.createUser({ email, password, role }) }); }
|
||||||
|
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||||
|
}
|
||||||
const payload = { email, password, email_confirm: true };
|
const payload = { email, password, email_confirm: true };
|
||||||
if (role && role !== 'user') payload.app_metadata = { role };
|
if (role && role !== 'user') payload.app_metadata = { role };
|
||||||
const { data, error } = await supabase.auth.admin.createUser(payload);
|
const { data, error } = await supabase.auth.admin.createUser(payload);
|
||||||
@@ -41,19 +53,25 @@ users.post('/', async (c) => {
|
|||||||
|
|
||||||
users.put('/:id', async (c) => {
|
users.put('/:id', async (c) => {
|
||||||
const { password, role } = await c.req.json();
|
const { password, role } = await c.req.json();
|
||||||
|
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||||
|
if (!password && !role) return c.json({ error: 'Nichts zu ändern' }, 400);
|
||||||
|
if (isLocal()) {
|
||||||
|
try { await local.updateUser(c.req.param('id'), { password, role }); return c.json({ ok: true }); }
|
||||||
|
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||||
|
}
|
||||||
const patch = {};
|
const patch = {};
|
||||||
if (password) patch.password = password;
|
if (password) patch.password = password;
|
||||||
if (role) {
|
if (role) patch.app_metadata = { role };
|
||||||
if (!['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
|
||||||
patch.app_metadata = { role };
|
|
||||||
}
|
|
||||||
if (!Object.keys(patch).length) return c.json({ error: 'Nichts zu ändern' }, 400);
|
|
||||||
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
|
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
|
||||||
if (error) return c.json({ error: error.message }, 400);
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
users.delete('/:id', async (c) => {
|
users.delete('/:id', async (c) => {
|
||||||
|
if (isLocal()) {
|
||||||
|
try { await local.deleteUser(c.req.param('id')); return c.json({ ok: true }); }
|
||||||
|
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||||
|
}
|
||||||
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
|
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
|
||||||
if (error) return c.json({ error: error.message }, 400);
|
if (error) return c.json({ error: error.message }, 400);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const url = process.env.SUPABASE_URL;
|
||||||
|
const key = process.env.SUPABASE_SERVICE_KEY;
|
||||||
|
|
||||||
|
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
|
||||||
|
|
||||||
|
// Mit Keys: echte Clients. Ohne (DB-loser core, auth:'local'): Clients bleiben
|
||||||
|
// null; die Aufrufer (auth/stats/users) sind null-sicher und nutzen den lokalen
|
||||||
|
// Provider. DB-Plugins (dialog) laufen ohnehin nur mit Supabase. Den Fail-Fast
|
||||||
|
// für auth:'supabase' macht index.js (das kennt die config) — supabase.js bleibt
|
||||||
|
// bewusst config-frei, damit Unit-Tests es ohne CMS_CONFIG importieren können.
|
||||||
|
let _supabase = null;
|
||||||
|
let _supabaseAuth = null;
|
||||||
|
if (url && key) {
|
||||||
|
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
|
||||||
|
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
|
||||||
|
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
|
||||||
|
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
|
||||||
|
_supabase = createClient(url, key, opts);
|
||||||
|
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
|
||||||
|
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
|
||||||
|
_supabaseAuth = createClient(url, key, opts);
|
||||||
|
} else {
|
||||||
|
console.warn('supabase.js: kein SUPABASE_URL/SERVICE_KEY — DB-loser Betrieb (auth: local erwartet).');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabase = _supabase;
|
||||||
|
export const supabaseAuth = _supabaseAuth;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { rm, readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
// Isolated users file + a known secret BEFORE importing the module (it reads env
|
||||||
|
// at import time). No Supabase needed — this is the DB-less path.
|
||||||
|
const USERS = join(tmpdir(), `cms-users-${process.pid}.json`);
|
||||||
|
process.env.USERS_FILE = USERS;
|
||||||
|
process.env.JWT_SECRET ||= 'test-secret-xyz';
|
||||||
|
|
||||||
|
const local = await import('../src/auth-local.js');
|
||||||
|
const { verify } = await import('hono/jwt');
|
||||||
|
|
||||||
|
test.after(async () => { await rm(USERS, { force: true }); });
|
||||||
|
|
||||||
|
test('create → list → login mints a GoTrue-shaped, verifiable token', async () => {
|
||||||
|
const id = await local.createUser({ email: 'Karim@Example.com', password: 'hunter2', role: 'admin' });
|
||||||
|
assert.ok(id);
|
||||||
|
const list = await local.listUsers(['someone@else.com']);
|
||||||
|
assert.equal(list.length, 1);
|
||||||
|
assert.equal(list[0].email, 'Karim@Example.com');
|
||||||
|
assert.equal(list[0].role, 'admin');
|
||||||
|
|
||||||
|
// wrong password rejected, right password accepted (case-insensitive email)
|
||||||
|
assert.equal(await local.login('karim@example.com', 'nope'), null);
|
||||||
|
const res = await local.login('karim@example.com', 'hunter2');
|
||||||
|
assert.ok(res?.access_token);
|
||||||
|
assert.equal(res.user.email, 'Karim@Example.com');
|
||||||
|
|
||||||
|
const claims = await verify(res.access_token, process.env.JWT_SECRET, 'HS256');
|
||||||
|
assert.equal(claims.sub, id);
|
||||||
|
assert.equal(claims.email, 'Karim@Example.com');
|
||||||
|
assert.equal(claims.app_metadata.role, 'admin'); // same shape auth.js/roleOf read
|
||||||
|
assert.ok(claims.exp > Math.floor(Date.now() / 1000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passwords are bcrypt-hashed at rest (never plaintext)', async () => {
|
||||||
|
const raw = JSON.parse(await readFile(USERS, 'utf8'));
|
||||||
|
assert.match(raw[0].password, /^\$2[aby]\$/); // bcrypt hash prefix
|
||||||
|
assert.notEqual(raw[0].password, 'hunter2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('countByRole reflects roles; fixed-admin email forced to admin', async () => {
|
||||||
|
await local.createUser({ email: 'red@x.com', password: 'pw', role: 'editor' });
|
||||||
|
const counts = await local.countByRole([]);
|
||||||
|
assert.equal(counts.total, 2);
|
||||||
|
assert.equal(counts.admin, 1);
|
||||||
|
assert.equal(counts.editor, 1);
|
||||||
|
// an env-admin email overrides the stored role in the listing
|
||||||
|
const forced = await local.listUsers(['red@x.com']);
|
||||||
|
assert.equal(forced.find((u) => u.email === 'red@x.com').role, 'admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update + delete', async () => {
|
||||||
|
const id = await local.createUser({ email: 'tmp@x.com', password: 'pw' });
|
||||||
|
await local.updateUser(id, { role: 'editor' });
|
||||||
|
assert.equal((await local.listUsers([])).find((u) => u.id === id).role, 'editor');
|
||||||
|
await local.deleteUser(id);
|
||||||
|
assert.equal((await local.listUsers([])).some((u) => u.id === id), false);
|
||||||
|
await assert.rejects(() => local.deleteUser('nope'), /nicht gefunden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate email rejected', async () => {
|
||||||
|
await assert.rejects(() => local.createUser({ email: 'red@x.com', password: 'pw' }), /existiert bereits/);
|
||||||
|
});
|
||||||
@@ -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,31 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
process.env.CMS_CONFIG ||= fileURLToPath(new URL('../../examples/openbureau.config.js', import.meta.url));
|
||||||
|
process.env.SUPABASE_URL ||= 'http://localhost';
|
||||||
|
process.env.SUPABASE_SERVICE_KEY ||= 'x';
|
||||||
|
process.env.JWT_SECRET ||= 'x';
|
||||||
|
process.env.SITE_DIR ||= '/tmp/nosite';
|
||||||
|
|
||||||
|
const { systemInfo } = await import('../src/routes/system.js');
|
||||||
|
|
||||||
|
test('systemInfo reflects core version, plugins and content model', () => {
|
||||||
|
const info = systemInfo({ version: '0.6.0', hugo: '0.161.1+extended' });
|
||||||
|
assert.equal(info.core.name, 'openbureau-core');
|
||||||
|
assert.equal(info.core.version, '0.6.0');
|
||||||
|
assert.equal(info.site, 'openbureau');
|
||||||
|
assert.equal(info.auth, 'supabase'); // openbureau.config.js sets auth: 'supabase'
|
||||||
|
assert.equal(info.hugo, '0.161.1+extended');
|
||||||
|
assert.deepEqual(info.admins, ['karim@gabrielevarano.ch']);
|
||||||
|
// dialog plugin is active with its routes + the one migration
|
||||||
|
const dialog = info.plugins.find((p) => p.name === 'dialog');
|
||||||
|
assert.ok(dialog, 'dialog plugin listed');
|
||||||
|
assert.ok(dialog.routes > 0);
|
||||||
|
assert.equal(dialog.migrations, 1);
|
||||||
|
// content model surfaced from the config collections
|
||||||
|
assert.deepEqual(info.collections.map((c) => c.kind).sort(), ['beitrag', 'biblio', 'rubrik', 'seite']);
|
||||||
|
const beitrag = info.collections.find((c) => c.kind === 'beitrag');
|
||||||
|
assert.equal(beitrag.label, 'Beiträge');
|
||||||
|
assert.ok(beitrag.fields > 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// karimgabrielevarano.xyz — second consumer of openbureau-core.
|
||||||
|
// No dialog plugin; a portfolio-shaped content model.
|
||||||
|
export default {
|
||||||
|
site: 'kgva',
|
||||||
|
auth: 'local', // DB-less: file-based users + self-signed JWTs (no Supabase/Postgres)
|
||||||
|
admins: ['karim@gabrielevarano.ch'],
|
||||||
|
plugins: [],
|
||||||
|
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
kind: 'project', label: 'Portfolio', order: 0,
|
||||||
|
path: 'portfolio/:slug',
|
||||||
|
statKey: 'projects',
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string', required: true },
|
||||||
|
{ name: 'slug', type: 'slug' },
|
||||||
|
{ name: 'date', type: 'date' },
|
||||||
|
{ name: 'draft', type: 'bool', default: true },
|
||||||
|
{ name: 'thumbnail', type: 'image' },
|
||||||
|
{ name: 'studies', type: 'string', hint: '/studies/hslu/ba/semester_05' },
|
||||||
|
{ name: 'schools', type: 'list' },
|
||||||
|
{ name: 'degrees', type: 'list' },
|
||||||
|
{ name: 'semesters', type: 'list' },
|
||||||
|
{ name: 'video', type: 'string', hint: '/media/x.mp4' },
|
||||||
|
{ name: 'images', type: 'list', of: { src: 'image', name: 'string' } },
|
||||||
|
{ name: 'body', type: 'markdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'page', label: 'Pages', order: 1, fallback: true,
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string', required: true },
|
||||||
|
{ name: 'description', type: 'string' },
|
||||||
|
{ name: 'layout', type: 'string' },
|
||||||
|
{ name: 'body', type: 'markdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'section', label: 'Sections', order: 2, index: true,
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string' },
|
||||||
|
{ name: 'description', type: 'string' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// openbureau site — reproduces the current hard-coded content model 1:1.
|
||||||
|
// Once the engine is config-driven, openbureau consumes openbureau-core with
|
||||||
|
// exactly this config and behaves as today.
|
||||||
|
export default {
|
||||||
|
site: 'openbureau',
|
||||||
|
admins: ['karim@gabrielevarano.ch'],
|
||||||
|
plugins: ['dialog'], // comment/forum subsystem (library ↔ threads sync)
|
||||||
|
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
kind: 'beitrag', label: 'Beiträge', order: 0,
|
||||||
|
path: 'archiv/:section/:slug',
|
||||||
|
sections: ['buerofuehrung', 'software', 'theorie'],
|
||||||
|
statKey: 'beitraege',
|
||||||
|
draftStatKey: 'entwuerfe', // Beitrag-Entwürfe als eigener Zähler (Dashboard)
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string', required: true },
|
||||||
|
{ name: 'section', type: 'select', options: ['buerofuehrung', 'software', 'theorie'] },
|
||||||
|
{ name: 'slug', type: 'slug' },
|
||||||
|
{ name: 'date', type: 'date' },
|
||||||
|
{ name: 'weight', type: 'number' },
|
||||||
|
{ name: 'color', type: 'color' },
|
||||||
|
{ name: 'layout', type: 'select', options: ['text', 'image', 'icon'], default: 'text' },
|
||||||
|
{ name: 'tags', type: 'list' },
|
||||||
|
{ name: 'summary', type: 'text' },
|
||||||
|
{ name: 'cover_image', type: 'image' },
|
||||||
|
{ name: 'authors', type: 'list' },
|
||||||
|
{ name: 'toc', type: 'bool' },
|
||||||
|
{ name: 'draft', type: 'bool', default: true },
|
||||||
|
{ name: 'body', type: 'markdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'biblio', label: 'Library', order: 1,
|
||||||
|
path: 'library/:slug',
|
||||||
|
statKey: 'library',
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string', required: true },
|
||||||
|
{ name: 'slug', type: 'slug' },
|
||||||
|
{ name: 'date', type: 'date' },
|
||||||
|
{ name: 'tags', type: 'list' },
|
||||||
|
{ name: 'summary', type: 'text' },
|
||||||
|
{ name: 'cover_image', type: 'image' },
|
||||||
|
{ name: 'external', type: 'string' },
|
||||||
|
{ name: 'group', type: 'string' },
|
||||||
|
{ name: 'authors', type: 'list' },
|
||||||
|
{ name: 'draft', type: 'bool', default: true },
|
||||||
|
{ name: 'body', type: 'markdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'rubrik', label: 'Rubriken', order: 3, index: true,
|
||||||
|
statKey: 'rubriken',
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string', required: true },
|
||||||
|
{ name: 'color', type: 'color' },
|
||||||
|
{ name: 'layout', type: 'string' },
|
||||||
|
{ name: 'weight', type: 'number' },
|
||||||
|
{ name: 'body', type: 'markdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'seite', label: 'Seiten', order: 2, fallback: true,
|
||||||
|
statKey: 'seiten',
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string', required: true },
|
||||||
|
{ name: 'layout', type: 'string' },
|
||||||
|
{ name: 'toc', type: 'bool' },
|
||||||
|
{ name: 'draft', type: 'bool', default: true },
|
||||||
|
{ name: 'body', type: 'markdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -146,7 +146,10 @@ services:
|
|||||||
# ════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════
|
||||||
cms:
|
cms:
|
||||||
build:
|
build:
|
||||||
context: .
|
# openbureau consumes openbureau-core, vendored via git subtree at cms/core.
|
||||||
|
# Build context = core's root so core's own Dockerfile picks up core/admin +
|
||||||
|
# core/api (the generic engine), not this site's files.
|
||||||
|
context: ./core
|
||||||
dockerfile: api/Dockerfile
|
dockerfile: api/Dockerfile
|
||||||
args:
|
args:
|
||||||
# Browser-seitig (Admin-SPA, zur Build-Zeit): öffentliche Supabase-URL.
|
# Browser-seitig (Admin-SPA, zur Build-Zeit): öffentliche Supabase-URL.
|
||||||
@@ -169,6 +172,11 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
SITE_DIR: /site
|
SITE_DIR: /site
|
||||||
|
# Tells core which content model + plugins this site has (schema-driven engine).
|
||||||
|
# Lives in the mounted repo (/site = repo root). DATABASE_URL is intentionally
|
||||||
|
# unset: the stack's `migrate` service owns the schema (db/schema.sql incl. the
|
||||||
|
# dialog tables), so core's plugin migration runner stays a no-op here.
|
||||||
|
CMS_CONFIG: /site/cms/openbureau.config.js
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
GIT_PUBLISH: ${GIT_PUBLISH:-false}
|
GIT_PUBLISH: ${GIT_PUBLISH:-false}
|
||||||
GIT_REMOTE: ${GIT_REMOTE:-origin}
|
GIT_REMOTE: ${GIT_REMOTE:-origin}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// openbureau site config — what this site's content model + plugins are.
|
||||||
|
// Consumed by openbureau-core (vendored at cms/core) via CMS_CONFIG. Reproduces
|
||||||
|
// the previously hard-coded content model 1:1 (proven by core's collections test).
|
||||||
|
export default {
|
||||||
|
site: 'openbureau',
|
||||||
|
auth: 'supabase', // GoTrue/Supabase login (the stack provides it); core default
|
||||||
|
admins: ['karim@gabrielevarano.ch'],
|
||||||
|
plugins: ['dialog'], // comment/forum subsystem (library ↔ threads sync)
|
||||||
|
|
||||||
|
collections: [
|
||||||
|
{
|
||||||
|
kind: 'beitrag', label: 'Beiträge', order: 0,
|
||||||
|
path: 'archiv/:section/:slug',
|
||||||
|
sections: ['buerofuehrung', 'software', 'theorie'],
|
||||||
|
statKey: 'beitraege',
|
||||||
|
draftStatKey: 'entwuerfe', // Beitrag-Entwürfe als eigener Zähler (Dashboard)
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string', required: true },
|
||||||
|
{ name: 'section', type: 'select', options: ['buerofuehrung', 'software', 'theorie'] },
|
||||||
|
{ name: 'slug', type: 'slug' },
|
||||||
|
{ name: 'date', type: 'date' },
|
||||||
|
{ name: 'weight', type: 'number' },
|
||||||
|
{ name: 'color', type: 'color' },
|
||||||
|
{ name: 'layout', type: 'select', options: ['text', 'image', 'icon'], default: 'text' },
|
||||||
|
{ name: 'tags', type: 'list' },
|
||||||
|
{ name: 'summary', type: 'text' },
|
||||||
|
{ name: 'cover_image', type: 'image' },
|
||||||
|
{ name: 'authors', type: 'list' },
|
||||||
|
{ name: 'toc', type: 'bool' },
|
||||||
|
{ name: 'draft', type: 'bool', default: true },
|
||||||
|
{ name: 'body', type: 'markdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'biblio', label: 'Library', order: 1,
|
||||||
|
path: 'library/:slug',
|
||||||
|
statKey: 'library',
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string', required: true },
|
||||||
|
{ name: 'slug', type: 'slug' },
|
||||||
|
{ name: 'date', type: 'date' },
|
||||||
|
{ name: 'tags', type: 'list' },
|
||||||
|
{ name: 'summary', type: 'text' },
|
||||||
|
{ name: 'cover_image', type: 'image' },
|
||||||
|
{ name: 'external', type: 'string' },
|
||||||
|
{ name: 'group', type: 'string' },
|
||||||
|
{ name: 'authors', type: 'list' },
|
||||||
|
{ name: 'draft', type: 'bool', default: true },
|
||||||
|
{ name: 'body', type: 'markdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'rubrik', label: 'Rubriken', order: 3, index: true,
|
||||||
|
statKey: 'rubriken',
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string', required: true },
|
||||||
|
{ name: 'color', type: 'color' },
|
||||||
|
{ name: 'layout', type: 'string' },
|
||||||
|
{ name: 'weight', type: 'number' },
|
||||||
|
{ name: 'body', type: 'markdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'seite', label: 'Seiten', order: 2, fallback: true,
|
||||||
|
statKey: 'seiten',
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', type: 'string', required: true },
|
||||||
|
{ name: 'layout', type: 'string' },
|
||||||
|
{ name: 'toc', type: 'bool' },
|
||||||
|
{ name: 'draft', type: 'bool', default: true },
|
||||||
|
{ name: 'body', type: 'markdown' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user