Merge commit '80eca1bc7c1174f9c44a8df9af4ddc8ab855291b' as 'cms/core'
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,174 @@
|
||||
# openbureau-core — handover
|
||||
|
||||
## For a fresh instance — START HERE
|
||||
- **Repo (local):** cloned at `/home/karim/openbureau-core`. Remote
|
||||
`git.openbureau.ch/karim/openbureau-core` is **private**; auth via an HTTPS token
|
||||
in `~/.git-credentials` (the SSH key `id_ed25519_openbureau` is currently rejected
|
||||
by gitea/proxmox — server seems rebuilt; fix the key or keep using the token).
|
||||
- **Where the work is:** branch **`stufe-2-5-scaffold`** (pushed, NOT merged). `main`
|
||||
is still the pre-stage-2 foundation. Open a PR or merge when happy.
|
||||
- **Done:** stages 1–4 complete, stage 5 *scaffolded*. See "Migration stages" below
|
||||
(markers `[done]`/`[scaffold]`). New code: `api/src/collections.js`,
|
||||
`api/src/plugin-manager.js`, `api/src/plugins/dialog/index.js` + their tests.
|
||||
- **Run tests:** `cd api && npm install && node --test` → **44 green**. `node_modules`
|
||||
is git-ignored; the fresh clone needs `npm install` once.
|
||||
- **Immediate next action:** stage 5 + 5.5 **DONE** on the branch (commits `c4230a4`
|
||||
cutover, `5e06337` DDL, `a93d11a` migration runner). Plugin-manager singleton
|
||||
(`api/src/plugins.js`) wired into `index.js`/`stats.js`/`publish.js`, hard-coded
|
||||
dialog removed, source moved under `api/src/plugins/dialog/`; DDL captured into
|
||||
`plugins/dialog/migrations/001_dialog.sql` (idempotent); `api/src/migrate.js`
|
||||
applies migrations once via `schema_migrations` at boot. **53 tests green** + a real
|
||||
boot. **Live-verified on the dev stack** (CT 134): a 2nd cms instance from
|
||||
`cms-cms:latest` (my `src` mounted over `/app/src`, real Supabase via `kong:8000`,
|
||||
empty SITE_DIR → no writes) returned **byte-identical** `/api/forums` & `/api/recent`
|
||||
vs the running container, `/api/content` 401 on both; the runner's tracking SQL was
|
||||
proven on a throwaway DB. **NOT deployed** (the running stack still runs old code).
|
||||
NEXT (stage 8): deploy core to openbureau — needs `DATABASE_URL` set for the cms
|
||||
service (e.g. `postgres://postgres:$POSTGRES_PASSWORD@db:5432/postgres`) so the
|
||||
runner can apply migrations, then full login/edit/publish verify before cutting the
|
||||
live container over. Then stages 6 (admin `/api/schema`), 7 (local auth), 9 (kgva).
|
||||
Dev stack: Proxmox CT 134 `openbureau-dev`, repo `/opt/openbureau`, compose
|
||||
`cms/docker-compose.yml`, containers openbureau-{cms,auth,kong,rest,db}, dev URL
|
||||
dev.openbureau.ch. **Do NOT push a blind refactor to prod.**
|
||||
- **Env gotcha:** editing a file that's open in Karim's VSCode makes the Edit/Write
|
||||
tool hang/"interrupt" — write such files via Bash (`cat > f <<'EOF' … EOF`) instead.
|
||||
|
||||
## Goal
|
||||
Extract a generic, **schema-driven** Hugo CMS engine ("openbureau-core") from the
|
||||
openbureau site's bundled CMS, so that **both** openbureau and
|
||||
karimgabrielevarano.xyz (kgva) consume it as a dependency. Decision: own repo
|
||||
(this one), and migrate openbureau to depend on it.
|
||||
|
||||
## State (done this session)
|
||||
- Repo `karim/openbureau-core` created on Gitea, foundation pushed.
|
||||
- `api/` = engine copied verbatim from `karim/OPENBUREAU` → `cms/api` (Hono/Node).
|
||||
- `admin/` = React/Vite SPA copied verbatim from `OPENBUREAU/cms/admin`.
|
||||
- `api/src/config.js` = NEW loader: reads `CMS_CONFIG` → a site config module.
|
||||
- `examples/openbureau.config.js`, `examples/kgva.config.js` = target schemas.
|
||||
- `README.md` = config API + plugin model + the migration checklist.
|
||||
- Nothing in the live openbureau CMS was changed yet.
|
||||
|
||||
## Engine is ~80% generic. openbureau-specific seams to make config-driven:
|
||||
1. [DONE] `api/src/files.js` `classify(rel)` + the `order` map — now derived from
|
||||
`config.collections` via `api/src/collections.js` (classify/buildPath/compareEntries);
|
||||
`buildPath` exposed there too.
|
||||
2. [DONE] `api/src/routes/stats.js` — now per-collection (`statKey`/`draftStatKey`),
|
||||
dialog counts gated by `hasPlugin('dialog')`.
|
||||
3. `api/src/index.js` — always mounts dialog routes + runs `syncLibrary` on boot.
|
||||
→ only when `config.plugins` includes `dialog`.
|
||||
4. `api/src/routes/publish.js` — calls `syncLibrary`. → gate by plugin.
|
||||
5. `admin/src/App.jsx` (714 lines) — hard-codes `SECTIONS`, `KIND_LABEL`, the field
|
||||
set, type dropdown, `buildPath`. → render sidebar groups + editor fields from the
|
||||
schema (add `GET /api/schema` returning `config.collections`).
|
||||
6. dialog subsystem (`dialog-store.js`, `routes/dialog.js`, `routes/comments.js`
|
||||
+ Supabase tables forums/threads/comments + library↔thread sync) = the `dialog`
|
||||
**plugin** (openbureau on, kgva off). First gate by config, later move to
|
||||
`api/src/plugins/dialog/`.
|
||||
|
||||
## CRITICAL
|
||||
api and admin share a data contract (stats keys, field shapes). Generalising the
|
||||
**backend alone breaks the live openbureau admin** — they must change together and
|
||||
be tested on the live Supabase/Hugo stack. Do NOT push a blind half-refactor to
|
||||
production openbureau.
|
||||
|
||||
## Plugin system (decided architecture)
|
||||
Everything beyond the generic engine is a **plugin**; the engine gets a small
|
||||
**plugin manager**. A plugin = `api/src/plugins/<name>/index.js` exporting a
|
||||
manifest `{ name, routes:[{path,app,public?,admin?}], onBoot, onPublish, onPreview,
|
||||
migrations:[…], stats, admin:{…} }`. The manager reads `config.plugins`, imports
|
||||
each, mounts routes in the right auth tier (public before `requireAuth`, rest
|
||||
after, `admin:true` behind `requireAdmin`), registers boot/publish/preview/stats
|
||||
hooks, runs migrations; the admin SPA loads UI from `admin/src/plugins/<name>`.
|
||||
openbureau's extras become plugins (first: `dialog`); kgva enables none.
|
||||
(Full spec in README → "Plugins".)
|
||||
|
||||
## Migration stages (also README checklist)
|
||||
1. [done] repo + engine + config loader + example configs.
|
||||
2. [done] files.js classify/order/buildPath ← config.collections
|
||||
(new `api/src/collections.js` = pure classify/buildPath/compareEntries/contentStats;
|
||||
files.js uses it, loads config lazily in listEntries; `api/test/collections.test.js`
|
||||
proves 1:1 vs openbureau.config.js). All api tests green.
|
||||
3. [done] stats.js ← collections: `content` via `statKey` + `draftStatKey`
|
||||
(openbureau beitrag got `draftStatKey: 'entwuerfe'`), dialog counts gated by
|
||||
`hasPlugin('dialog')` (pluginless site does zero DB reads here).
|
||||
4. [built, not wired] **plugin manager** — `api/src/plugin-manager.js`
|
||||
(`loadPlugins(names)` imports `plugins/<name>/index.js`; `createManager(manifests)`
|
||||
= pure wiring: `mountPublic`/`mountPrivate(+requireAdmin)`, `runBoot/runPublish/
|
||||
runPreview`, `collectStats` (merged under plugin name), `migrations()` (resolves
|
||||
file URLs; execution deferred to the live DB)). Unit-tested in
|
||||
`api/test/plugin-manager.test.js` (tiers/hooks/stats/migrations). TODO: wire into
|
||||
index.js (stage 5), and admin loads plugin UI from `admin/src/plugins/<name>`.
|
||||
5. [DONE — commits c4230a4 (cutover) + 5e06337 (DDL), live e2e verified] extract openbureau extras
|
||||
into the **dialog** plugin. DONE: `api/src/plugins/dialog/index.js` = manifest
|
||||
wrapping the existing modules unchanged (public reads + widget login(rate-limited)
|
||||
+ authed writes + self-guarding mod/adminForums sub-apps; onBoot/onPublish =
|
||||
syncLibrary; stats = forum/thread/comment counts). CUTOVER DONE: shared manager
|
||||
singleton `api/src/plugins.js` wired into index.js (mountPublic before requireAuth,
|
||||
mountPrivate(+requireAdmin) after, manager.runBoot at serve()), publish.js
|
||||
(manager.runPublish) and stats.js (manager.collectStats, `{content,users,dialog}`
|
||||
contract preserved); hard-coded dialog REMOVED from all three; dialog source
|
||||
physically moved to `plugins/dialog/{dialog-store,dialog,comments}.js` (imports
|
||||
fixed). Tested: `api/test/{dialog-plugin,cutover}.test.js`, 48 green + real boot.
|
||||
DDL CAPTURED (commit 5e06337): `migrations/001_dialog.sql` from the live dev DB,
|
||||
idempotent, validated on a throwaway DB; manifest `migrations: ['001_dialog.sql']`.
|
||||
LIVE E2E VERIFIED (dev CT 134): 2nd cms instance from cms-cms:latest with my src
|
||||
mounted, real Supabase via kong:8000 — /api/forums & /api/recent byte-identical to
|
||||
the running container, /api/content 401 both, no writes. Migration runner = stage
|
||||
5.5 (commit a93d11a, see new item below). Deploy is stage 8.
|
||||
NB: dialog-store.js still filters `e.kind === 'beitrag'` — openbureau-specific,
|
||||
already moved with the plugin.
|
||||
5.5 [done, commit a93d11a] migration runner — `api/src/migrate.js` `runMigrations`
|
||||
tracks applied migrations in `public.schema_migrations` and applies each declared
|
||||
file once, in its own transaction, at boot (wired into index.js before runBoot).
|
||||
Lean: no declared migrations → no DB connection, no pg import (kgva stays DB-less).
|
||||
Executor is injectable (unit-tested without a DB); default uses a lazily-imported
|
||||
`pg` against `DATABASE_URL`, warning-and-skipping if unset. Needs `DATABASE_URL`
|
||||
wired into the cms service env for openbureau (stage 8). Tested: migrate.test.js +
|
||||
tracking SQL proven on the live dev Postgres (throwaway DB).
|
||||
6. admin: `/api/schema` + App.jsx renders from schema (must reproduce openbureau's
|
||||
editor 1:1 when fed `openbureau.config.js`).
|
||||
7. **auth provider** (`config.auth` = `supabase` | `local`). Verify is already local
|
||||
(HS256/JWT_SECRET in auth.js). Add a `local` provider: file-based users (bcrypt) +
|
||||
self-signed JWTs of the SAME claim shape (sub/email/app_metadata.role/exp), and a
|
||||
`/login` route to replace GoTrue; admin login (`admin/src/supabase.js`) calls it
|
||||
instead of `signInWithPassword`; users.js CRUD edits the user file. Goal: a
|
||||
dialog-less core runs Node+Hugo+nginx, **no Supabase/Postgres** (~80 MB). openbureau
|
||||
stays `supabase`. Independent of the cutover — `supabase` is the default no-op.
|
||||
8. cut openbureau over to consume core (= core + `dialog` plugin + `supabase` auth +
|
||||
its config) — test login / list / edit / preview / publish / dialog — identical.
|
||||
9. onboard kgva (`CMS_CONFIG=…/kgva.config.js`, `auth: 'local'`, no plugins) as the
|
||||
2nd consumer — DB-less.
|
||||
|
||||
## How to run / test
|
||||
openbureau CMS stack: `OPENBUREAU/cms/docker-compose.yml` (Node api + Hugo +
|
||||
Supabase: postgres/kong/gotrue). Env in `cms/.env` (see `.env.example`):
|
||||
SUPABASE_URL/SERVICE_KEY, JWT_SECRET, ANON_KEY, SERVICE_ROLE_KEY (derive via
|
||||
`scripts/generate-keys.mjs`), ADMIN_EMAILS, SITE_URL, GIT_*. For core add
|
||||
`CMS_CONFIG` (path to the site config) and `SITE_DIR` (site repo mount, default
|
||||
`/site`). Admin at `/admin`, preview `/_preview`, publish builds `public/`.
|
||||
|
||||
## Infra access (NO secrets in this file)
|
||||
- Proxmox node `192.168.1.2`, user `root`. Password: **ask the user** (provided ad
|
||||
hoc, not stored). No SSH key installed — use SSH_ASKPASS, or install a key first.
|
||||
- Gitea = container 120 (`pct exec 120 …`), ROOT_URL `git.openbureau.ch`, sqlite at
|
||||
`/var/lib/gitea/data/gitea.db`. Make a token:
|
||||
`pct exec 120 -- su gitea -s /bin/bash -c "/usr/local/bin/gitea admin user
|
||||
generate-access-token --username karim --scopes all --token-name X --raw
|
||||
--config /etc/gitea/app.ini"`. Keep token `kgva-deploy`. Delete tokens via sqlite
|
||||
(`DELETE FROM access_token WHERE name LIKE '…'`) since the API needs the account
|
||||
password.
|
||||
- Push without local git auth: tar → scp to node → `pct push 120` into the gitea
|
||||
container → `git push http://karim:<token>@127.0.0.1:3000/karim/<repo>.git`.
|
||||
- kgva site deploy: container 130 (kgva-website). nginx serves
|
||||
`/var/www/karimgabrielevarano.xyz` (= ZFS `tank/kgva-website`, owned uid 100000).
|
||||
systemd timer runs `/opt/kgva-deploy.sh` every 60s: pull `karim/kgva` (token
|
||||
`kgva-deploy`) → `hugo` build → copy into webroot. So a push to `karim/kgva` is
|
||||
live in ~1 min. Self-hosted video at `/var/www/media` (nginx `/media/` alias,
|
||||
outside the build). Public TLS for the domain terminates upstream → 192.168.1.130:80.
|
||||
|
||||
## Watch out
|
||||
- Gitea does NOT send CORS on its OAuth token endpoint → a browser git-CMS
|
||||
(Decap-style) can't auth without a same-origin proxy. openbureau-core uses its own
|
||||
Supabase auth, so this doesn't affect it; it's why kgva edits go via git push.
|
||||
- Hugo versions: openbureau CMS bundles 0.161.1; the kgva site needs 0.163.3
|
||||
features. Mind the version a core instance builds with.
|
||||
@@ -0,0 +1,149 @@
|
||||
# openbureau-core
|
||||
|
||||
A generic, schema-driven Hugo CMS engine, extracted from the openbureau site.
|
||||
A site provides one config (collections + plugins); the core gives it an admin,
|
||||
content CRUD over `content/**/*.md` (gray-matter), real Hugo preview/publish,
|
||||
Supabase auth, uploads and git backup. openbureau and karimgabrielevarano.xyz
|
||||
both consume it.
|
||||
|
||||
## How a site uses it
|
||||
|
||||
1. Point `CMS_CONFIG` at a config module (e.g. `/site/cms.config.js`).
|
||||
2. Mount the site repo at `SITE_DIR` (default `/site`). Run the container.
|
||||
|
||||
```
|
||||
SITE_DIR=/site
|
||||
CMS_CONFIG=/site/cms.config.js
|
||||
SUPABASE_URL=… SUPABASE_SERVICE_KEY=… JWT_SECRET=… ADMIN_EMAILS=…
|
||||
```
|
||||
|
||||
## Config API
|
||||
|
||||
```js
|
||||
export default {
|
||||
site: 'name',
|
||||
admins: ['you@example.com'], // bootstrap admins (env ADMIN_EMAILS still wins)
|
||||
auth: 'supabase', // 'supabase' (GoTrue) | 'local' (file JWTs, no DB)
|
||||
plugins: ['dialog'], // optional features (see Plugins)
|
||||
collections: [
|
||||
{
|
||||
kind: 'project', // internal id
|
||||
label: 'Portfolio', // shown in the admin
|
||||
order: 0, // sort order in the list
|
||||
path: 'portfolio/:slug', // file pattern under content/ (:params captured)
|
||||
// index: true, // matches _index.md (a section)
|
||||
// fallback: true, // matches anything not matched above
|
||||
// sections: [...], // fixed choices for a :section param
|
||||
statKey: 'projects', // key in the stats response
|
||||
fields: [ // editor form, in order
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'images', type: 'list', of: { src: 'image', name: 'string' } },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
`path` patterns: literal segments must match; `:param` segments are captured
|
||||
(the last one is the slug). `index: true` → `_index.md`. `fallback: true` →
|
||||
everything else. The engine derives content classification, the `buildPath`, the
|
||||
list ordering, the stats counters and the admin editor from this.
|
||||
|
||||
Field `type`s: `string · text · markdown · slug · date · number · bool · select
|
||||
(options) · image · list · list(of:{…})`.
|
||||
|
||||
## Plugins
|
||||
|
||||
Everything beyond the generic engine is a **plugin**. A site opts into plugins via
|
||||
`plugins: [...]` in its config; core's plugin manager loads them and wires them in.
|
||||
The engine stays lean; openbureau = core + its plugins + its config.
|
||||
|
||||
A plugin is a module at `api/src/plugins/<name>/index.js` exporting a manifest:
|
||||
|
||||
```js
|
||||
export default {
|
||||
name: 'dialog',
|
||||
routes: [ // mounted under /api
|
||||
{ path: '/forums', app: forums, public: true }, // skips requireAuth
|
||||
{ path: '/comments', app: comments }, // auth required
|
||||
{ path: '/admin/forums', app: adminForums, admin: true }, // admins only
|
||||
],
|
||||
onBoot: async (ctx) => {}, // run once at startup (e.g. syncLibrary)
|
||||
onPublish: async (ctx, { path }) => {}, // hook after a publish build
|
||||
onPreview: async (ctx, { path }) => {}, // hook after a preview build
|
||||
migrations: ['001_forums.sql'], // applied to Postgres/Supabase on boot
|
||||
stats: async (ctx) => ({ forums, threads, comments }), // merged into /api/stats
|
||||
admin: { /* UI panels the admin SPA mounts for this plugin */ },
|
||||
};
|
||||
```
|
||||
|
||||
The **plugin manager** (core): reads `config.plugins`, imports each module, mounts
|
||||
its routes in the right auth tier (public → before `requireAuth`, the rest after,
|
||||
`admin: true` behind `requireAdmin`), registers its boot/publish/preview/stats
|
||||
hooks, and runs its migrations. The admin SPA loads UI from
|
||||
`admin/src/plugins/<name>` for enabled plugins only.
|
||||
|
||||
First plugin: **`dialog`** — the comment/forum subsystem (Supabase
|
||||
`forums/threads/comments`, library↔thread sync, forum admin UI). openbureau enables
|
||||
it; kgva doesn't, so none of its routes, hooks, tables or stats load there.
|
||||
|
||||
## Auth providers
|
||||
|
||||
Auth is a config seam (`auth: 'supabase' | 'local'`, default `supabase`) so a site
|
||||
can run **without a database**. The engine already verifies tokens locally — HS256
|
||||
against `JWT_SECRET`, no GoTrue roundtrip ([api/src/auth.js]) — so a provider only
|
||||
has to *issue* tokens and *store users*.
|
||||
|
||||
- **`supabase`** (today): GoTrue logs the user in and is the user store; the engine
|
||||
verifies the JWT. Needed when there are multiple editors or the `dialog` plugin
|
||||
(its forums/threads/comments live in Postgres). This is the heavy path.
|
||||
- **`local`**: file-based users (bcrypt hashes) + self-signed HS256 JWTs of the
|
||||
**same claim shape** (`sub`, `email`, `app_metadata.role`, `exp`), so `requireAuth`
|
||||
and roles are unchanged. No GoTrue, no Postgres, no PostgREST. A dialog-less site
|
||||
(e.g. kgva, single admin) then runs as just **Node + Hugo + nginx** (~80 MB vs the
|
||||
~1–2 GB Supabase suite). Trade-off: you own password hashing; you lose GoTrue's
|
||||
password-reset / rate-limiting — fine for a single-admin CMS.
|
||||
|
||||
What a pluginless core needs from Supabase is *only* the auth half anyway: content
|
||||
is Markdown on disk, uploads are local FS (`static/images`, via sharp), and
|
||||
PostgREST/tables are a `dialog`-plugin need. `local` drops that half too.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `api/` — Node engine (Hono): `auth` (Supabase JWT), `files` (gray-matter
|
||||
CRUD), `hugo` (coalesced build / preview / publish + git), `routes/*`,
|
||||
`ratelimit`, `config` (this loader).
|
||||
- `admin/` — React/Vite SPA; renders list + editor from the config schema.
|
||||
- `examples/` — `openbureau.config.js`, `kgva.config.js` (the two consumers).
|
||||
|
||||
## Status / migration stages
|
||||
|
||||
This repo currently holds the **engine as extracted** plus the config API and the
|
||||
two target configs. Generalisation is staged because the api and admin share a
|
||||
data contract and openbureau is in production — they must change together and be
|
||||
tested on the live Supabase/Hugo stack, not flipped blind.
|
||||
|
||||
- [x] Repo, engine, config loader, example configs (openbureau + kgva)
|
||||
- [x] `files.js` classify / order / buildPath driven by `collections`
|
||||
(`api/src/collections.js` + tests; behaviour 1:1 vs openbureau.config.js)
|
||||
- [x] `stats.js` counters driven by `collections` (`statKey` / `draftStatKey`),
|
||||
dialog counts gated by `hasPlugin('dialog')`
|
||||
- [~] **plugin manager** built + unit-tested (`api/src/plugin-manager.js`,
|
||||
`api/test/plugin-manager.test.js`): loads `config.plugins`, mounts routes by
|
||||
auth tier, runs boot/publish/preview hooks, merges stats, surfaces migrations.
|
||||
NOT yet wired into `index.js` (lands with stage 5, needs the live stack);
|
||||
admin plugin-UI loading still TODO
|
||||
- [~] extract openbureau's extras into plugins — first **`dialog`**: manifest
|
||||
scaffold at `api/src/plugins/dialog/index.js` wraps the existing dialog
|
||||
modules (routes by tier, `syncLibrary` on boot/publish, forum counters as
|
||||
`stats`), loaded + structurally tested (`api/test/dialog-plugin.test.js`).
|
||||
TODO (live-tested): wire it into `index.js`, capture the Supabase DDL into
|
||||
`migrations/001_dialog.sql`, move the dialog source under `plugins/dialog/`
|
||||
- [ ] `index.js` / `publish.js`: no hard-coded dialog — everything via the manager
|
||||
- [ ] admin: editor + sidebar groups rendered from the config schema
|
||||
- [ ] **auth provider** (`supabase` | `local`): `local` = file users + self-signed
|
||||
HS256 JWTs (same claim shape), no DB — lets a dialog-less core run Supabase-free
|
||||
- [ ] cut openbureau over to consume core (= core + `dialog` plugin + its config),
|
||||
behaviour identical — tested on the live stack
|
||||
- [ ] onboard kgva as the second consumer (`auth: 'local'`, no plugins, DB-less)
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OPENBUREAU — CMS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2060
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "openbureau-cms-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"@toast-ui/editor": "^3.2.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import ToastEditor from '@toast-ui/editor';
|
||||
import '@toast-ui/editor/dist/toastui-editor.css';
|
||||
import { supabase } from './supabase.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
// OPENBUREAU-Palette (Hex aus assets/css/custom.css) — für Dropdown + Punkte.
|
||||
const COLORS = [
|
||||
['', 'keine', 'transparent'],
|
||||
['ajisai', 'Ajisai · Hortensie', '#A39EC4'],
|
||||
['sakura', 'Sakura · Kirschblüte', '#C49EC4'],
|
||||
['suna', 'Suna · Sand', '#C4C19E'],
|
||||
['ichigo', 'Ichigo · Erdbeere', '#C49EA0'],
|
||||
['yuyake', 'Yuyake · Sonnenuntergang', '#CEB188'],
|
||||
['sora', 'Sora · Himmel', '#9EC3C4'],
|
||||
['kusa', 'Kusa · Gras', '#9EC49F'],
|
||||
['kori', 'Kori · Eis', '#A5B4CB'],
|
||||
['amagumo', 'Amagumo · Regenwolke', '#4C4C4C'],
|
||||
['yuki', 'Yuki · Schnee', '#F0F0F0'],
|
||||
];
|
||||
const hexOf = (name) => (COLORS.find((c) => c[0] === name) || [])[2] || 'transparent';
|
||||
|
||||
const LAYOUTS = ['', 'text', 'image', 'icon'];
|
||||
const SECTIONS = ['buerofuehrung', 'software', 'theorie'];
|
||||
const KIND_LABEL = { beitrag: 'Beiträge', 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() {
|
||||
const [session, setSession] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => { setSession(data.session); setLoading(false); });
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s));
|
||||
return () => sub.subscription.unsubscribe();
|
||||
}, []);
|
||||
if (loading) return <div className="center muted">…</div>;
|
||||
if (!session) return <Login />;
|
||||
return <Dashboard email={session.user.email} />;
|
||||
}
|
||||
|
||||
function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState(null);
|
||||
async function submit(e) {
|
||||
e.preventDefault(); setErr(null);
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) setErr(error.message);
|
||||
}
|
||||
return (
|
||||
<div className="center">
|
||||
<form className="login" onSubmit={submit}>
|
||||
<div className="login-brand">OPENBUREAU</div>
|
||||
<div className="login-sub">Redaktion</div>
|
||||
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} autoFocus />
|
||||
<input type="password" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<button type="submit">Anmelden</button>
|
||||
{err && <p className="err">{err}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard({ email }) {
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [current, setCurrent] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [view, setView] = useState('content');
|
||||
const [me, setMe] = useState(null);
|
||||
const [msg, setMsg] = useState(null);
|
||||
|
||||
async function refresh() {
|
||||
try { setEntries(await api.list()); }
|
||||
catch (e) { setMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
useEffect(() => { refresh(); api.getMe().then(setMe).catch(() => {}); }, []);
|
||||
useEffect(() => { if (!msg) return; const t = setTimeout(() => setMsg(null), 4000); return () => clearTimeout(t); }, [msg]);
|
||||
|
||||
async function open(entry) {
|
||||
try { setCurrent(fromRead(await api.read(entry.path))); }
|
||||
catch (err) { setMsg({ type: 'err', text: err.message }); }
|
||||
}
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q ? entries.filter((e) => e.title.toLowerCase().includes(q) || (e.section || '').includes(q)) : entries;
|
||||
const groups = { beitrag: [], biblio: [], seite: [], rubrik: [] };
|
||||
for (const e of filtered) (groups[e.kind] || groups.seite).push(e);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<span className="logo">OPENBUREAU</span>
|
||||
<span className="logo-sub">Redaktion</span>
|
||||
<nav className="nav">
|
||||
{me?.isAdmin && <button className={view === 'overview' ? 'active' : ''} onClick={() => setView('overview')}>Übersicht</button>}
|
||||
<button className={view === 'content' ? 'active' : ''} onClick={() => setView('content')}>Inhalte</button>
|
||||
<button className={view === 'profile' ? 'active' : ''} onClick={() => setView('profile')}>Profil</button>
|
||||
{me?.canModerate && <button className={view === 'moderation' ? 'active' : ''} onClick={() => setView('moderation')}>Moderation</button>}
|
||||
{me?.isAdmin && <button className={view === 'forums' ? 'active' : ''} onClick={() => setView('forums')}>Foren</button>}
|
||||
{me?.isAdmin && <button className={view === 'users' ? 'active' : ''} onClick={() => setView('users')}>Autor:innen</button>}
|
||||
</nav>
|
||||
<span className="spacer" />
|
||||
<span className="who">{email}</span>
|
||||
<button className="ghost" onClick={() => supabase.auth.signOut()}>Abmelden</button>
|
||||
</header>
|
||||
|
||||
<div className="body">
|
||||
{view === 'overview' ? (
|
||||
<Overview onMsg={setMsg} go={setView} />
|
||||
) : view === 'profile' ? (
|
||||
<Profile onMsg={setMsg} />
|
||||
) : view === 'users' ? (
|
||||
<Users onMsg={setMsg} currentEmail={me?.email} />
|
||||
) : view === 'forums' ? (
|
||||
<Forums onMsg={setMsg} />
|
||||
) : view === 'moderation' ? (
|
||||
<Moderation onMsg={setMsg} />
|
||||
) : (
|
||||
<>
|
||||
<aside>
|
||||
<button className="new" onClick={() => setCurrent({ ...EMPTY })}>+ Neuer Beitrag</button>
|
||||
<div className="search"><span>⌕</span><input placeholder="Suchen…" value={query} onChange={(e) => setQuery(e.target.value)} /></div>
|
||||
{['beitrag', 'biblio', 'seite', 'rubrik'].map((kind) => groups[kind].length > 0 && (
|
||||
<div className="group" key={kind}>
|
||||
<div className="group-title">{KIND_LABEL[kind]} <span>{groups[kind].length}</span></div>
|
||||
<ul className="list">
|
||||
{groups[kind].map((e) => (
|
||||
<li key={e.path} className={current?.path === e.path ? 'active' : ''} onClick={() => open(e)}>
|
||||
<span className="dot" style={{ background: e.color ? hexOf(e.color) : 'var(--line)' }} />
|
||||
<span className="t">
|
||||
<span className="t-title">{e.title}</span>
|
||||
<span className="t-meta">{[e.section, e.date].filter(Boolean).join(' · ')}</span>
|
||||
</span>
|
||||
{e.draft && <span className="draft-tag">Entwurf</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
<main>
|
||||
{current
|
||||
? <Editor key={current.path || 'new'} initial={current}
|
||||
onSaved={(loaded) => { setCurrent(loaded); refresh(); }} onMsg={setMsg} />
|
||||
: <div className="empty"><p>Wähle links einen Eintrag — oder leg einen neuen Beitrag an.</p></div>}
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && <div className={`toast ${msg.type}`} onClick={() => setMsg(null)}>{msg.text}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Editor({ initial, onSaved, onMsg }) {
|
||||
const [f, setF] = useState(initial);
|
||||
const [previewUrl, setPreviewUrl] = useState(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [pw, setPw] = useState(44);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const editorRef = useRef(null);
|
||||
const dragging = useRef(false);
|
||||
const coverIn = useRef(null);
|
||||
const set = (k) => (e) => setF({ ...f, [k]: e.target.type === 'checkbox' ? e.target.checked : e.target.value });
|
||||
const isWiki = f.type === 'biblio' || (f.path || '').startsWith('library/');
|
||||
|
||||
async function pickCover(ev) {
|
||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try { const { url } = await api.upload(file); setF((p) => ({ ...p, cover_image: url })); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
// Ziehbarer Trenner Editor ↔ Vorschau.
|
||||
useEffect(() => {
|
||||
function move(e) {
|
||||
if (!dragging.current || !editorRef.current) return;
|
||||
const r = editorRef.current.getBoundingClientRect();
|
||||
setPw(Math.min(70, Math.max(25, ((r.right - e.clientX) / r.width) * 100)));
|
||||
}
|
||||
function up() { dragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
|
||||
}, []);
|
||||
function startDrag(e) { dragging.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }
|
||||
|
||||
function currentPath(data = f) {
|
||||
if (!data.isNew) return data.path;
|
||||
const slug = (data.slug || '').trim();
|
||||
if (!slug) return '';
|
||||
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 = {}) {
|
||||
const data = { ...f, ...overrides };
|
||||
const path = currentPath(data);
|
||||
if (!path) { onMsg({ type: 'err', text: 'Bitte einen Slug angeben.' }); return null; }
|
||||
if (!data.title.trim()) { onMsg({ type: 'err', text: 'Titel fehlt.' }); return null; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.save(path, buildFrontmatter(data), data.body);
|
||||
const loaded = fromRead(await api.read(path));
|
||||
onSaved(loaded); setF(loaded);
|
||||
return path;
|
||||
} catch (e) { onMsg({ type: 'err', text: e.message }); return null; }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function preview() {
|
||||
const path = await save(); if (!path) return;
|
||||
setShowPreview(true); setBusy(true);
|
||||
try { const res = await api.preview(path); setPreviewUrl(`${res.url}?t=${Date.now()}`); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function publish() {
|
||||
if (!confirm('Live publizieren? Der Beitrag wird aus „Entwurf“ genommen.')) return;
|
||||
const path = await save({ draft: false }); // Publizieren = nicht mehr Entwurf
|
||||
if (!path) return;
|
||||
setBusy(true);
|
||||
try { const res = await api.publish(path); onMsg({ type: 'ok', text: `Live: ${res.url}` }); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="editor" ref={editorRef}>
|
||||
<div className="editor-main">
|
||||
<div className="editor-head">
|
||||
<div className="crumb">{f.isNew ? 'Neuer Eintrag' : f.path}</div>
|
||||
<span className="spacer" />
|
||||
{f.draft ? <span className="status draft">Entwurf</span> : <span className="status live">Veröffentlicht</span>}
|
||||
<button className="toggle" onClick={() => setShowPreview((v) => !v)} title="Vorschau ein/aus">
|
||||
{showPreview ? 'Vorschau ⤫' : 'Vorschau ⤢'}
|
||||
</button>
|
||||
<button onClick={() => save().then((p) => p && onMsg({ type: 'ok', text: 'Gespeichert.' }))} disabled={busy}>Speichern</button>
|
||||
<button onClick={preview} disabled={busy}>Vorschau</button>
|
||||
<button className="primary" onClick={publish} disabled={busy}>Publizieren</button>
|
||||
</div>
|
||||
|
||||
<div className="fields">
|
||||
{f.isNew && (
|
||||
<div className="row">
|
||||
<label className="sm">Typ
|
||||
<select value={f.type} onChange={set('type')}>
|
||||
<option value="beitrag">Beitrag</option>
|
||||
<option value="biblio">Library-Seite</option>
|
||||
<option value="seite">Seite</option>
|
||||
</select>
|
||||
</label>
|
||||
{f.type === 'beitrag' && (
|
||||
<label className="sm">Rubrik
|
||||
<select value={f.section} onChange={set('section')}>{SECTIONS.map((s) => <option key={s}>{s}</option>)}</select>
|
||||
</label>
|
||||
)}
|
||||
<label>Slug<input value={f.slug} onChange={set('slug')} placeholder="z.B. neuer-beitrag" /></label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="big">Titel<input value={f.title} onChange={set('title')} placeholder="Titel des Beitrags" /></label>
|
||||
|
||||
<div className="meta">
|
||||
{isWiki && <label className="sm">Gruppe<input value={f.group} onChange={set('group')} placeholder="z. B. Begriffe" /></label>}
|
||||
<label className="sm">Datum<input type="date" value={f.date} onChange={set('date')} /></label>
|
||||
<label className="xs">Reihenfolge<input type="number" value={f.weight} onChange={set('weight')} placeholder="weight" /></label>
|
||||
<label className="sm">Farbe
|
||||
<div className="colorpick">
|
||||
<span className="swatch" style={{ background: hexOf(f.color) }} />
|
||||
<select value={f.color} onChange={set('color')}>{COLORS.map(([v, label]) => <option key={v} value={v}>{label}</option>)}</select>
|
||||
</div>
|
||||
</label>
|
||||
<label className="sm">Layout
|
||||
<select value={f.layout} onChange={set('layout')}>{LAYOUTS.map((l) => <option key={l} value={l}>{l || '(automatisch)'}</option>)}</select>
|
||||
</label>
|
||||
<label>Tags<input value={f.tags} onChange={set('tags')} placeholder="komma, getrennt" /></label>
|
||||
<label className="check"><input type="checkbox" checked={f.toc} onChange={set('toc')} /> Inhaltsverz.</label>
|
||||
<label className="check"><input type="checkbox" checked={f.draft} onChange={set('draft')} /> Entwurf</label>
|
||||
</div>
|
||||
|
||||
<label>Kurztext (summary)<input value={f.summary} onChange={set('summary')} /></label>
|
||||
<div className="row">
|
||||
<label>Cover-Bild
|
||||
<div className="cover-row">
|
||||
<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" />
|
||||
<button type="button" onClick={() => coverIn.current?.click()} disabled={busy}>Hochladen</button>
|
||||
<input ref={coverIn} type="file" accept="image/*" hidden onChange={pickCover} />
|
||||
{f.cover_image && <span className="cover-thumb" style={{ backgroundImage: `url(${f.cover_image})` }} />}
|
||||
</div>
|
||||
</label>
|
||||
<label>Externer Link<input value={f.external} onChange={set('external')} placeholder="https://…" /></label>
|
||||
</div>
|
||||
<label>Autor:innen (E-Mails, Komma — für gemeinsamen Zugriff)
|
||||
<input value={f.authors} onChange={set('authors')} placeholder="du@…, kollege@…" />
|
||||
</label>
|
||||
|
||||
<div className="rich">
|
||||
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))}
|
||||
onUpload={async (file) => (await api.upload(file)).url} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showPreview && <div className="splitter" onMouseDown={startDrag} />}
|
||||
{showPreview && (
|
||||
<div className="preview" style={{ width: pw + '%' }}>
|
||||
{previewUrl
|
||||
? <iframe title="Vorschau" src={previewUrl} />
|
||||
: <div className="empty small"><p>Auf „Vorschau“ klicken — die Seite erscheint hier in deinem echten Theme.</p></div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── WYSIWYG-Editor (Toast UI, vanilla) — Formatierung live, speichert Markdown ──
|
||||
function RichEditor({ value, onChange, onUpload }) {
|
||||
const el = useRef(null);
|
||||
const inst = useRef(null);
|
||||
// value/onChange/onUpload in Refs, damit der Editor nur EINMAL erzeugt wird.
|
||||
const cb = useRef({ onChange, onUpload });
|
||||
cb.current = { onChange, onUpload };
|
||||
|
||||
useEffect(() => {
|
||||
inst.current = new ToastEditor({
|
||||
el: el.current,
|
||||
initialValue: value || '',
|
||||
initialEditType: 'wysiwyg',
|
||||
previewStyle: 'tab',
|
||||
height: '100%',
|
||||
usageStatistics: false,
|
||||
autofocus: false,
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol'],
|
||||
['link', 'image'],
|
||||
['code', 'codeblock'],
|
||||
],
|
||||
hooks: {
|
||||
addImageBlobHook: async (blob, done) => {
|
||||
try { done(await cb.current.onUpload(blob), blob.name || 'bild'); }
|
||||
catch { /* Upload fehlgeschlagen */ }
|
||||
},
|
||||
},
|
||||
events: { change: () => cb.current.onChange(inst.current.getMarkdown()) },
|
||||
});
|
||||
return () => { inst.current?.destroy(); inst.current = null; };
|
||||
}, []);
|
||||
|
||||
return <div ref={el} className="rich-host" />;
|
||||
}
|
||||
|
||||
// ── Profil ──────────────────────────────────────────────────────────────────
|
||||
function Profile({ onMsg }) {
|
||||
const [p, setP] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const fileIn = useRef(null);
|
||||
useEffect(() => { api.getProfile().then(setP).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
|
||||
if (!p) return <div className="empty">…</div>;
|
||||
const set = (k) => (e) => setP({ ...p, [k]: e.target.value });
|
||||
|
||||
async function pickAvatar(ev) {
|
||||
const file = ev.target.files?.[0]; ev.target.value = '';
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try { const { url } = await api.upload(file); setP((x) => ({ ...x, avatar: url })); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try { await api.saveProfile({ name: p.name, bio: p.bio, avatar: p.avatar }); onMsg({ type: 'ok', text: 'Profil gespeichert.' }); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile">
|
||||
<div className="profile-card">
|
||||
<h2>Profil</h2>
|
||||
<div className="avatar-row">
|
||||
<div className="avatar" style={{ backgroundImage: p.avatar ? `url(${p.avatar})` : 'none' }}>{!p.avatar && '🙂'}</div>
|
||||
<div>
|
||||
<button onClick={() => fileIn.current?.click()} disabled={busy}>Profilbild wählen</button>
|
||||
<input ref={fileIn} type="file" accept="image/*" hidden onChange={pickAvatar} />
|
||||
<p className="muted who-mail">{p.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label>Name<input value={p.name} onChange={set('name')} placeholder="Dein Name" /></label>
|
||||
<label>Über mich<textarea value={p.bio} onChange={set('bio')} rows={5} placeholder="Kurzer Text über dich…" /></label>
|
||||
<div className="actions"><button className="primary" onClick={save} disabled={busy}>Speichern</button></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Übersicht / Dashboard (nur Admin) ───────────────────────────────────────
|
||||
function Overview({ onMsg, go }) {
|
||||
const [s, setS] = useState(null);
|
||||
useEffect(() => { api.stats().then(setS).catch((e) => onMsg({ type: 'err', text: e.message })); }, []);
|
||||
if (!s) return <div className="empty">…</div>;
|
||||
const Card = ({ label, value, hint, to }) => (
|
||||
<button className="stat-card" onClick={to ? () => go(to) : undefined} disabled={!to}>
|
||||
<span className="stat-value">{value}</span>
|
||||
<span className="stat-label">{label}</span>
|
||||
<span className="stat-hint">{hint || ' '}</span>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className="overview">
|
||||
<h2>Übersicht</h2>
|
||||
<div className="stat-grid">
|
||||
<Card label="Beiträge" value={s.content.beitraege} hint={`${s.content.entwuerfe} Entwürfe`} to="content" />
|
||||
<Card label="Library-Seiten" value={s.content.library} to="content" />
|
||||
<Card label="Seiten" value={s.content.seiten} />
|
||||
<Card label="Autor:innen" value={s.users.total} hint={`${s.users.admin} Admin · ${s.users.editor} Red.`} to="users" />
|
||||
<Card label="Foren" value={s.dialog.forums} to="forums" />
|
||||
<Card label="Threads" value={s.dialog.threads} to="moderation" />
|
||||
<Card label="Wortmeldungen" value={s.dialog.comments} to="moderation" />
|
||||
</div>
|
||||
<div className="overview-actions">
|
||||
<h3>Schnellzugriff</h3>
|
||||
<div className="quick">
|
||||
<button onClick={() => go('content')}>Inhalte bearbeiten</button>
|
||||
<button onClick={() => go('forums')}>Foren verwalten</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="/dialog/" target="_blank" rel="noreferrer">Dialog ↗</a>
|
||||
<a className="quick-link" href="/library/" target="_blank" rel="noreferrer">Library ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Autor:innen-Verwaltung (nur Admin) ──────────────────────────────────────
|
||||
function Users({ onMsg, currentEmail }) {
|
||||
const [list, setList] = useState(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState('user');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [pwFor, setPwFor] = useState(null);
|
||||
const [newPw, setNewPw] = useState('');
|
||||
|
||||
async function refresh() {
|
||||
try { setList(await api.listUsers()); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
async function create(e) {
|
||||
e.preventDefault(); setBusy(true);
|
||||
try {
|
||||
await api.createUser(email, password, role);
|
||||
onMsg({ type: 'ok', text: 'Autor:in angelegt.' });
|
||||
setEmail(''); setPassword(''); setRole('user'); refresh();
|
||||
} catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function remove(u) {
|
||||
if (!confirm(`${u.email} wirklich löschen?`)) return;
|
||||
try { await api.deleteUser(u.id); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
async function savePw(u) {
|
||||
if (!newPw || newPw.length < 6) { onMsg({ type: 'err', text: 'Passwort zu kurz (min. 6 Zeichen).' }); return; }
|
||||
try { await api.setPassword(u.id, newPw); onMsg({ type: 'ok', text: 'Passwort gesetzt.' }); setPwFor(null); setNewPw(''); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
async function changeRole(u, r) {
|
||||
try { await api.setRole(u.id, r); onMsg({ type: 'ok', text: `Rolle: ${ROLE_LABEL[r]}` }); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
|
||||
if (!list) return <div className="empty">…</div>;
|
||||
const filtered = q ? list.filter((u) => u.email.toLowerCase().includes(q.toLowerCase())) : list;
|
||||
const RoleSelect = ({ u }) => (
|
||||
<select className="role-select" value={u.role} onChange={(e) => changeRole(u, e.target.value)}>
|
||||
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
|
||||
</select>
|
||||
);
|
||||
return (
|
||||
<div className="profile">
|
||||
<div className="profile-card wide">
|
||||
<h2>Autor:innen & Rollen <span className="count-pill">{list.length}</span></h2>
|
||||
<form className="userform" onSubmit={create}>
|
||||
<input type="email" placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
<input type="text" placeholder="Passwort" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
<select className="role-select" value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
<option value="user">User</option><option value="editor">Redakteur</option><option value="admin">Admin</option>
|
||||
</select>
|
||||
<button className="primary" disabled={busy}>Anlegen</button>
|
||||
</form>
|
||||
{list.length > 6 && <input className="userfilter" placeholder="filtern…" value={q} onChange={(e) => setQ(e.target.value)} />}
|
||||
<ul className="userlist">
|
||||
{filtered.map((u) => (
|
||||
<li key={u.id}>
|
||||
<span className="uavatar" style={avatarStyle(u.email)}>{(u.email || '?').slice(0, 1).toUpperCase()}</span>
|
||||
<span className="t ucol">
|
||||
<span className="uemail">{u.email}{u.email === currentEmail && <span className="you"> · du</span>}</span>
|
||||
<span className="umeta">
|
||||
angelegt {fmtDate(u.created_at)}
|
||||
{u.last_sign_in_at ? ` · zuletzt aktiv ${fmtDate(u.last_sign_in_at)}` : ' · nie angemeldet'}
|
||||
</span>
|
||||
</span>
|
||||
{u.fixedAdmin ? <span className="rolebadge admin">Admin · .env</span> : <RoleSelect u={u} />}
|
||||
{pwFor === u.id ? (
|
||||
<span className="pwinline">
|
||||
<input type="text" placeholder="neues Passwort" value={newPw} autoFocus
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') savePw(u); if (e.key === 'Escape') { setPwFor(null); setNewPw(''); } }} />
|
||||
<button onClick={() => savePw(u)}>OK</button>
|
||||
<button onClick={() => { setPwFor(null); setNewPw(''); }}>✕</button>
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => { setPwFor(u.id); setNewPw(''); }}>Passwort</button>
|
||||
)}
|
||||
{u.email !== currentEmail && !u.fixedAdmin && <button onClick={() => remove(u)}>Löschen</button>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="muted who-mail"><b>User</b> schreiben im Forum · <b>Redakteur</b> moderiert · <b>Admin</b> verwaltet alles. Admins aus <code>ADMIN_EMAILS</code> sind fix.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ROLE_LABEL = { user: 'User', editor: 'Redakteur', admin: 'Admin' };
|
||||
function fmtDate(ts) { if (!ts) return '—'; try { return new Date(ts).toLocaleDateString('de-CH'); } catch { return '—'; } }
|
||||
function uHashHue(s) { let h = 0; for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return Math.abs(h) % 360; }
|
||||
function avatarStyle(s) { const h = uHashHue(s); return { background: `hsl(${h} 36% 82%)`, color: `hsl(${h} 30% 28%)` }; }
|
||||
|
||||
// ── Foren-Verwaltung (nur Admin) ────────────────────────────────────────────
|
||||
function Forums({ onMsg }) {
|
||||
const [list, setList] = useState(null);
|
||||
const [draft, setDraft] = useState({ slug: '', name: '', sort: 50 });
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
try { setList(await api.listForumsAdmin()); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
async function create(e) {
|
||||
e.preventDefault();
|
||||
if (!draft.slug || !draft.name) return;
|
||||
setBusy(true);
|
||||
try { await api.createForum(draft); onMsg({ type: 'ok', text: 'Kategorie angelegt.' }); setDraft({ slug: '', name: '', sort: 50 }); refresh(); }
|
||||
catch (err) { onMsg({ type: 'err', text: err.message }); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function save(f, patch) {
|
||||
try { await api.updateForum(f.id, patch); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
async function remove(f) {
|
||||
if (!confirm(`Kategorie „${f.name}“ löschen? Threads darin verschwinden.`)) return;
|
||||
try { await api.deleteForum(f.id); onMsg({ type: 'ok', text: 'Gelöscht.' }); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
|
||||
if (!list) return <div className="empty">…</div>;
|
||||
return (
|
||||
<div className="profile">
|
||||
<div className="profile-card wide">
|
||||
<h2>Foren / Kategorien</h2>
|
||||
<form className="userform" onSubmit={create}>
|
||||
<input placeholder="Name (z. B. Wettbewerbe)" value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value, slug: draft.slug || slugify(e.target.value) })} required />
|
||||
<input placeholder="slug" value={draft.slug} onChange={(e) => setDraft({ ...draft, slug: slugify(e.target.value) })} required />
|
||||
<input type="number" placeholder="Sort" style={{ width: '5em' }} value={draft.sort} onChange={(e) => setDraft({ ...draft, sort: e.target.value })} />
|
||||
<button className="primary" disabled={busy}>Anlegen</button>
|
||||
</form>
|
||||
<ul className="forumlist">
|
||||
{list.map((f) => (
|
||||
<li key={f.id} className={f.kind === 'library' ? 'is-library' : ''}>
|
||||
<span className="fsort">{f.sort}</span>
|
||||
<input className="fname" defaultValue={f.name} onBlur={(e) => e.target.value !== f.name && save(f, { name: e.target.value })} />
|
||||
<input className="fcolor" type="color" value={/^#[0-9a-fA-F]{6}$/.test(f.color || '') ? f.color : '#cccccc'} onChange={(e) => save(f, { color: e.target.value })} title="Akzentfarbe" />
|
||||
<span className="fslug">/{f.slug}</span>
|
||||
{f.kind === 'library'
|
||||
? <span className="status">Library (auto)</span>
|
||||
: <button onClick={() => remove(f)}>Löschen</button>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="muted who-mail">„Beiträge“ ist die automatische Library-Kategorie und kann nicht gelöscht werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Moderation (Admin + Redakteur) ──────────────────────────────────────────
|
||||
function Moderation({ onMsg }) {
|
||||
const [data, setData] = useState(null);
|
||||
|
||||
async function refresh() {
|
||||
try { setData(await api.modOverview()); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
async function delComment(c) {
|
||||
if (!confirm('Wortmeldung löschen?')) return;
|
||||
try { await api.deleteComment(c.id); onMsg({ type: 'ok', text: 'Gelöscht.' }); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
async function toggleLock(t) {
|
||||
try { await api.lockThread(t.key, !t.locked); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
async function delThread(t) {
|
||||
if (!confirm(`Thread „${t.title}“ ausblenden?`)) return;
|
||||
try { await api.deleteThread(t.key); onMsg({ type: 'ok', text: 'Ausgeblendet.' }); refresh(); }
|
||||
catch (e) { onMsg({ type: 'err', text: e.message }); }
|
||||
}
|
||||
|
||||
if (!data) return <div className="empty">…</div>;
|
||||
return (
|
||||
<div className="moderation">
|
||||
<div className="mod-col">
|
||||
<h2>Letzte Wortmeldungen</h2>
|
||||
<ul className="modlist">
|
||||
{data.comments.map((c) => (
|
||||
<li key={c.id}>
|
||||
<div className="mod-head"><b>{c.author_name}</b>
|
||||
<span className="muted"> · {c.forum_name || '—'} · {c.thread_title}</span></div>
|
||||
<div className="mod-body">{c.body}</div>
|
||||
<div className="mod-actions">
|
||||
<a href={c.thread_url} target="_blank" rel="noreferrer">öffnen</a>
|
||||
<button onClick={() => delComment(c)}>Löschen</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{!data.comments.length && <li className="muted">Noch keine Wortmeldungen.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mod-col">
|
||||
<h2>Threads</h2>
|
||||
<ul className="modlist">
|
||||
{data.threads.map((t) => (
|
||||
<li key={t.key} className={t.deleted ? 'gone' : ''}>
|
||||
<div className="mod-head"><b>{t.title}</b>
|
||||
<span className="muted"> · {t.forum_name} · {t.count}</span>
|
||||
{t.locked && <span className="status">gesperrt</span>}
|
||||
{t.deleted && <span className="status">ausgeblendet</span>}</div>
|
||||
<div className="mod-actions">
|
||||
<a href={t.url} target="_blank" rel="noreferrer">öffnen</a>
|
||||
{t.kind !== 'library' && <button onClick={() => toggleLock(t)}>{t.locked ? 'Entsperren' : 'Sperren'}</button>}
|
||||
{!t.deleted && <button onClick={() => delThread(t)}>Ausblenden</button>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function slugify(s) {
|
||||
return (s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { supabase } from './supabase.js';
|
||||
|
||||
// Ruft die CMS-API (gleiche Origin) mit dem aktuellen Supabase-Token auf.
|
||||
async function call(path, options = {}) {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const token = data?.session?.access_token;
|
||||
const res = await fetch(`/api${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
return json;
|
||||
}
|
||||
|
||||
// Datei-Upload (multipart): Browser setzt den Header selbst.
|
||||
async function uploadFile(file) {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const token = data?.session?.access_token;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
||||
return json;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
list: () => call('/content'),
|
||||
read: (path) => call(`/content/entry?path=${encodeURIComponent(path)}`),
|
||||
save: (path, frontmatter, body) =>
|
||||
call('/content/entry', { method: 'PUT', body: JSON.stringify({ path, frontmatter, body }) }),
|
||||
preview: (path) => call('/preview', { method: 'POST', body: JSON.stringify({ path }) }),
|
||||
publish: (path) => call('/publish', { method: 'POST', body: JSON.stringify({ path }) }),
|
||||
upload: uploadFile,
|
||||
getProfile: () => call('/profile'),
|
||||
saveProfile: (p) => call('/profile', { method: 'PUT', body: JSON.stringify(p) }),
|
||||
getMe: () => call('/me'),
|
||||
stats: () => call('/stats'),
|
||||
listUsers: () => call('/users'),
|
||||
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 }) }),
|
||||
setRole: (id, role) => call(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ role }) }),
|
||||
deleteUser: (id) => call(`/users/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Foren-Verwaltung (Admin)
|
||||
listForumsAdmin: () => call('/admin/forums'),
|
||||
createForum: (f) => call('/admin/forums', { method: 'POST', body: JSON.stringify(f) }),
|
||||
updateForum: (id, f) => call(`/admin/forums/${id}`, { method: 'PUT', body: JSON.stringify(f) }),
|
||||
deleteForum: (id) => call(`/admin/forums/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Moderation (Admin + Redakteur)
|
||||
modOverview: () => call('/mod/overview'),
|
||||
lockThread: (key, locked) => call('/mod/thread-lock', { method: 'POST', body: JSON.stringify({ key, locked }) }),
|
||||
deleteThread: (key) => call('/mod/thread-delete', { method: 'POST', body: JSON.stringify({ key }) }),
|
||||
deleteComment: (id) => call(`/comments/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,221 @@
|
||||
@import url('https://fonts.bunny.net/css?family=newsreader:400,500,600,700|inter:400,500,600|space-grotesk:500,700|ibm-plex-mono:400,500');
|
||||
|
||||
:root {
|
||||
--serif: 'Newsreader', Georgia, serif;
|
||||
--sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--display: 'Space Grotesk', 'Inter', sans-serif;
|
||||
--mono: 'IBM Plex Mono', ui-monospace, monospace;
|
||||
|
||||
--bg: hsl(35 14% 96%);
|
||||
--panel: #fffdf9;
|
||||
--panel-2: hsl(35 14% 93%);
|
||||
--line: hsl(35 14% 86%);
|
||||
--text: hsl(25 18% 12%);
|
||||
--muted: hsl(25 8% 42%);
|
||||
|
||||
--accent: #b54a2c;
|
||||
--accent-soft: #d97a5a;
|
||||
--dark: #191919;
|
||||
--dark-text: #f0f0f0;
|
||||
--dark-muted: #a9a9a9;
|
||||
--ok: #5d7d4b;
|
||||
--amber: #b8902f;
|
||||
|
||||
--radius: 11px;
|
||||
--pill: 22px;
|
||||
--shadow: 0 10px 34px -22px rgba(40,20,10,.5);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; }
|
||||
body { margin: 0; font-family: var(--sans); font-size: 14.5px; color: var(--text); background: var(--bg); }
|
||||
button, input, select, textarea { font-family: inherit; font-size: inherit; color: var(--text); }
|
||||
.muted { color: var(--muted); }
|
||||
.center { display: grid; place-items: center; height: 100%; }
|
||||
|
||||
/* ── Login ── */
|
||||
.login { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 36px 32px; width: 320px; display: flex; flex-direction: column; gap: 12px; box-shadow: var(--shadow); }
|
||||
.login-brand { font-family: var(--display); font-weight: 700; letter-spacing: .14em; font-size: 20px; }
|
||||
.login-sub { font-family: var(--serif); font-style: italic; color: var(--muted); margin-bottom: 10px; }
|
||||
.err { color: var(--accent); margin: 4px 0 0; font-size: 13px; }
|
||||
|
||||
/* ── Inputs / Buttons (Pill) ── */
|
||||
input, select, textarea { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; padding: 9px 11px; width: 100%; }
|
||||
/* Einheitliche, kompakte Höhe für einzeilige Felder (Dropdowns = Textfelder) */
|
||||
.fields input, .fields select { height: 32px; padding: 0 10px; font-size: 14px; }
|
||||
.fields label.big input { height: 46px; padding: 0 13px; font-size: 21px; }
|
||||
.login input, .profile-card input, .userform input { height: 36px; }
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent-soft); box-shadow: 0 0 0 3px rgba(181,74,44,.12); }
|
||||
button { background: var(--panel); border: 1px solid var(--line); border-radius: var(--pill); padding: 8px 16px; cursor: pointer; font-weight: 500; transition: .12s; white-space: nowrap; }
|
||||
button:hover { border-color: var(--accent-soft); }
|
||||
button:disabled { opacity: .5; cursor: default; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
button.primary:hover { background: #a23f23; }
|
||||
button.ghost { background: transparent; border-color: transparent; color: var(--dark-muted); }
|
||||
button.ghost:hover { color: #fff; border-color: var(--dark-muted); }
|
||||
|
||||
/* ── Topbar (schwarz wie Site-Masthead) ── */
|
||||
.app { display: flex; flex-direction: column; height: 100%; }
|
||||
.topbar { display: flex; align-items: center; gap: 12px; padding: 0 18px; height: 54px; background: var(--dark); color: var(--dark-text); flex: none; }
|
||||
.topbar .logo { font-family: var(--display); font-weight: 700; letter-spacing: .14em; }
|
||||
.topbar .logo-sub { font-family: var(--serif); font-style: italic; color: var(--dark-muted); font-size: 13px; }
|
||||
.topbar .spacer { flex: 1; }
|
||||
.topbar .who { color: var(--dark-muted); font-size: 13px; }
|
||||
.nav { display: flex; gap: 4px; margin-left: 16px; }
|
||||
.nav button { background: transparent; border: none; color: var(--dark-muted); padding: 6px 15px; border-radius: var(--pill); }
|
||||
.nav button:hover { color: #fff; }
|
||||
.nav button.active { background: rgba(255,255,255,.12); color: #fff; }
|
||||
|
||||
.body { display: flex; flex: 1; min-height: 0; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
aside { width: 290px; flex: none; border-right: 1px solid var(--line); background: var(--panel-2); padding: 14px; overflow: auto; }
|
||||
.new { width: 100%; margin-bottom: 12px; background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
||||
.new:hover { background: #a23f23; }
|
||||
.search { display: flex; align-items: center; gap: 7px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--pill); padding: 0 13px; margin-bottom: 16px; }
|
||||
.search span { color: var(--muted); font-size: 17px; }
|
||||
.search input { border: none; background: transparent; padding: 9px 0; }
|
||||
.search input:focus { box-shadow: none; }
|
||||
.group { margin-bottom: 18px; }
|
||||
.group-title { display: flex; align-items: center; gap: 7px; font-family: var(--display); font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 6px 8px; }
|
||||
.group-title span { background: var(--line); color: var(--muted); border-radius: 20px; padding: 1px 7px; font-size: 10px; letter-spacing: 0; }
|
||||
.list { list-style: none; margin: 0; padding: 0; }
|
||||
.list li { display: flex; align-items: center; gap: 10px; padding: 9px; border-radius: 10px; cursor: pointer; }
|
||||
.list li:hover { background: var(--panel); }
|
||||
.list li.active { background: var(--panel); box-shadow: inset 3px 0 0 var(--accent), var(--shadow); }
|
||||
.list .dot { width: 10px; height: 10px; border-radius: 50%; flex: none; border: 1px solid rgba(0,0,0,.12); }
|
||||
.list .t { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||
.list .t-title { font-family: var(--serif); font-size: 15.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.list .t-meta { font-size: 11px; color: var(--muted); }
|
||||
.draft-tag { font-size: 10px; color: var(--amber); border: 1px solid var(--amber); border-radius: 20px; padding: 1px 7px; flex: none; }
|
||||
|
||||
main { flex: 1; min-width: 0; }
|
||||
.empty { display: grid; place-items: center; height: 100%; color: var(--muted); font-family: var(--serif); font-style: italic; padding: 24px; text-align: center; }
|
||||
.empty.small { font-size: 14px; }
|
||||
|
||||
/* ── Editor ── */
|
||||
.editor { display: flex; height: 100%; }
|
||||
.editor-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.editor-head { display: flex; align-items: center; gap: 9px; padding: 11px 22px; border-bottom: 1px solid var(--line); background: var(--panel); flex: none; }
|
||||
.editor-head .crumb { font-family: var(--mono); font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.editor-head .spacer { flex: 1; }
|
||||
.editor-head .toggle { background: transparent; border-color: transparent; color: var(--muted); }
|
||||
.editor-head .toggle:hover { color: var(--text); border-color: var(--line); }
|
||||
.status { font-size: 11px; border-radius: var(--pill); padding: 3px 11px; font-weight: 600; }
|
||||
.status.draft { color: var(--amber); background: rgba(184,144,47,.12); }
|
||||
.status.live { color: var(--ok); background: rgba(93,125,75,.14); }
|
||||
|
||||
/* Metadaten kompakt oben, Schreibfeld groß darunter */
|
||||
.fields { flex: 1; min-height: 0; padding: 16px 22px; overflow: auto; display: flex; flex-direction: column; gap: 10px; }
|
||||
.row { display: flex; gap: 12px; align-items: flex-end; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 9px 12px; align-items: flex-end; }
|
||||
label { display: flex; flex-direction: column; gap: 3px; font-size: 11.5px; color: var(--muted); flex: 1; }
|
||||
.meta label { flex: 0 0 auto; }
|
||||
.meta label.sm { width: 160px; } .meta label.xs { width: 100px; } .meta label:not(.sm):not(.xs):not(.check) { flex: 1; min-width: 140px; }
|
||||
label.check { flex-direction: row; align-items: center; gap: 7px; white-space: nowrap; padding-bottom: 7px; }
|
||||
label.check input { width: auto; height: auto; }
|
||||
label.big input { font-family: var(--serif); font-weight: 600; }
|
||||
.colorpick { display: flex; align-items: center; gap: 8px; }
|
||||
.colorpick .swatch { width: 32px; height: 32px; border-radius: 7px; border: 1px solid rgba(0,0,0,.15); flex: none; }
|
||||
.colorpick select { flex: 1; }
|
||||
|
||||
/* Cover-Upload */
|
||||
.cover-row { display: flex; align-items: center; gap: 8px; }
|
||||
.cover-row input { flex: 1; }
|
||||
.cover-row button { height: 32px; flex: none; padding: 0 14px; }
|
||||
.cover-thumb { width: 32px; height: 32px; border-radius: 7px; border: 1px solid var(--line); background: center/cover no-repeat; flex: none; }
|
||||
|
||||
/* Autor:innen-Verwaltung */
|
||||
.userform { display: flex; gap: 8px; }
|
||||
.userform input { flex: 1; }
|
||||
.userlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.userlist li { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.userlist .t { flex: 1; display: flex; align-items: center; gap: 9px; font-family: var(--serif); }
|
||||
.userlist button { padding: 5px 12px; font-size: 13px; }
|
||||
.userlist .status { padding: 2px 9px; }
|
||||
|
||||
/* WYSIWYG-Editor füllt den meisten Platz */
|
||||
.rich { flex: 1; min-height: 460px; display: flex; flex-direction: column; }
|
||||
.rich-host { flex: 1; min-height: 0; }
|
||||
.rich .toastui-editor-defaultUI { height: 100%; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); font-family: var(--sans); }
|
||||
.toastui-editor-contents { font-family: var(--serif); font-size: 16px; }
|
||||
.toastui-editor-defaultUI-toolbar { background: var(--panel-2); }
|
||||
.toastui-editor-toolbar { border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
|
||||
|
||||
/* ── Ziehbarer Trenner + Vorschau ── */
|
||||
.splitter { width: 7px; flex: none; cursor: col-resize; background: var(--line); }
|
||||
.splitter:hover { background: var(--accent-soft); }
|
||||
.preview { flex: none; background: #fff; }
|
||||
.preview iframe { width: 100%; height: 100%; border: 0; }
|
||||
|
||||
/* ── Profil ── */
|
||||
.profile { width: 100%; overflow: auto; display: flex; justify-content: center; padding: 44px 20px; }
|
||||
.profile-card { width: 100%; max-width: 560px; height: max-content; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 28px 30px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.profile-card h2 { font-family: var(--serif); margin: 0 0 4px; font-weight: 600; }
|
||||
.avatar-row { display: flex; align-items: center; gap: 18px; }
|
||||
.avatar { width: 92px; height: 92px; border-radius: 50%; background: var(--panel-2) center/cover no-repeat; border: 1px solid var(--line); display: grid; place-items: center; font-size: 34px; flex: none; }
|
||||
.who-mail { font-size: 12px; margin: 9px 0 0; }
|
||||
.profile-card textarea { font-family: var(--serif); font-size: 15px; line-height: 1.6; resize: vertical; }
|
||||
.profile-card .actions { display: flex; }
|
||||
|
||||
.profile-card.wide { max-width: 760px; }
|
||||
.role-select { width: auto; height: 32px; padding: 4px 10px; font-size: 13px; }
|
||||
|
||||
/* ── Foren-Verwaltung ── */
|
||||
.forumlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.forumlist li { display: flex; align-items: center; gap: 10px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 10px; }
|
||||
.forumlist li.is-library { background: var(--panel-2); }
|
||||
.forumlist .fsort { width: 2.2em; text-align: center; color: var(--muted); font-size: 12px; flex: none; }
|
||||
.forumlist .fname { flex: 1; height: 32px; }
|
||||
.forumlist .fcolor { width: 34px; height: 32px; padding: 2px; flex: none; }
|
||||
.forumlist .fslug { color: var(--muted); font-size: 12px; font-family: var(--mono, monospace); flex: none; }
|
||||
.forumlist button { padding: 5px 12px; font-size: 13px; }
|
||||
|
||||
/* ── Moderation (zweispaltig) ── */
|
||||
.moderation { width: 100%; overflow: auto; display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 30px 24px; align-content: start; }
|
||||
.mod-col h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 12px; }
|
||||
.modlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.modlist li { padding: 10px 13px; border: 1px solid var(--line); border-radius: 11px; background: var(--panel); }
|
||||
.modlist li.gone { opacity: .5; }
|
||||
.mod-head { font-size: 13.5px; }
|
||||
.mod-head .status { margin-left: 6px; padding: 1px 8px; background: rgba(184,144,47,.14); color: var(--amber); }
|
||||
.mod-body { font-family: var(--serif); font-size: 14.5px; margin: 6px 0; color: var(--text); }
|
||||
.mod-actions { display: flex; align-items: center; gap: 12px; font-size: 13px; }
|
||||
.mod-actions a { color: var(--muted); }
|
||||
.mod-actions button { padding: 4px 11px; font-size: 12.5px; }
|
||||
|
||||
/* ── Übersicht / Dashboard ── */
|
||||
.overview { width: 100%; overflow: auto; padding: 30px 28px; }
|
||||
.overview h2 { font-family: var(--serif); font-weight: 600; margin: 0 0 18px; }
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
|
||||
.stat-card { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); }
|
||||
.stat-card:not(:disabled):hover { border-color: var(--accent-soft); transform: translateY(-1px); }
|
||||
.stat-card:disabled { opacity: 1; cursor: default; }
|
||||
.stat-value { font-family: var(--display); font-weight: 700; font-size: 30px; line-height: 1; color: var(--accent); }
|
||||
.stat-label { font-family: var(--serif); font-size: 15px; margin-top: 6px; }
|
||||
.stat-hint { font-size: 11.5px; color: var(--muted); min-height: 1em; }
|
||||
.overview-actions { margin-top: 30px; }
|
||||
.overview-actions h3 { font-family: var(--display); font-size: 12px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0 0 10px; }
|
||||
.quick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.quick-link { display: inline-flex; align-items: center; padding: 8px 16px; border: 1px solid var(--line); border-radius: var(--pill); text-decoration: none; color: var(--muted); }
|
||||
.quick-link:hover { border-color: var(--accent-soft); color: var(--text); }
|
||||
|
||||
/* ── Nutzerliste (aufgewertet) ── */
|
||||
.count-pill { font-family: var(--sans); font-size: 12px; font-weight: 500; color: var(--muted); background: var(--panel-2); border-radius: 20px; padding: 2px 9px; vertical-align: middle; margin-left: 6px; }
|
||||
.userfilter { margin: 4px 0 2px; height: 34px; }
|
||||
.userlist .uavatar { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 600; font-size: 13px; flex: none; }
|
||||
.userlist .ucol { flex-direction: column; align-items: flex-start; gap: 1px; min-width: 0; }
|
||||
.uemail { font-family: var(--serif); font-size: 14.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
||||
.uemail .you { color: var(--accent); font-family: var(--sans); font-size: 12px; }
|
||||
.umeta { font-size: 11.5px; color: var(--muted); }
|
||||
.rolebadge { font-size: 11px; border-radius: var(--pill); padding: 3px 10px; font-weight: 600; flex: none; }
|
||||
.rolebadge.admin { color: var(--accent); background: rgba(181,74,44,.12); }
|
||||
.pwinline { display: flex; align-items: center; gap: 5px; flex: none; }
|
||||
.pwinline input { width: 150px; height: 30px; }
|
||||
.pwinline button { padding: 4px 10px; font-size: 12.5px; }
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast { 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.err { background: var(--accent); }
|
||||
@@ -0,0 +1,8 @@
|
||||
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);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// base /admin/ — die SPA wird vom CMS-Container unter /admin serviert.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/admin/',
|
||||
server: {
|
||||
// Dev: API + /_preview vom laufenden Container durchreichen.
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/_preview': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
# --- Stage 1: Admin-SPA bauen ---
|
||||
# (Build-Context ist cms/, siehe docker-compose.yml)
|
||||
FROM node:24-bookworm-slim AS admin
|
||||
WORKDIR /admin
|
||||
COPY admin/package.json admin/package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY admin/ ./
|
||||
# Öffentliche Browser-Werte, zur Build-Zeit eingesetzt.
|
||||
ARG VITE_SUPABASE_URL
|
||||
ARG VITE_SUPABASE_ANON_KEY
|
||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: API + Hugo + serviert Site/Admin ---
|
||||
# Debian-slim statt Alpine: Hugo "extended" ist glibc-gelinkt.
|
||||
FROM node:24-bookworm-slim
|
||||
ARG HUGO_VERSION=0.161.1
|
||||
# Von BuildKit automatisch auf die Ziel-Arch gesetzt (amd64 auf dem LXC,
|
||||
# arm64 z.B. auf Apple-Silicon) — kein fester Default, sonst falsche Binary.
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates git curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& case "${TARGETARCH}" in \
|
||||
arm64) HUGO_ARCH=linux-arm64 ;; \
|
||||
*) HUGO_ARCH=linux-amd64 ;; \
|
||||
esac \
|
||||
&& curl -sSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_${HUGO_ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin hugo \
|
||||
&& hugo version
|
||||
|
||||
WORKDIR /app
|
||||
COPY api/package.json api/package-lock.json* ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
COPY api/src ./src
|
||||
COPY api/entrypoint.sh ./entrypoint.sh
|
||||
COPY --from=admin /admin/dist ./admin-dist
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ADMIN_DIR=/app/admin-dist
|
||||
|
||||
# Als non-root laufen (das node-Image bringt den User `node`, uid/gid 1000 mit).
|
||||
# /app gehört dem Build (root, read-only zur Laufzeit — reicht zum Servieren).
|
||||
# Das gemountete Repo unter /site muss uid 1000 gehören (siehe Proxmox-Script:
|
||||
# chown -R 1000:1000), damit Hugo dort public/ bauen und content/ schreiben kann.
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["sh", "/app/entrypoint.sh"]
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Beim Container-Start die Hugo-Site einmal bauen, damit die Live-Seite (/)
|
||||
# sofort steht — auch vor dem ersten Publish. public/ ist git-ignored und
|
||||
# existiert im frischen Clone nicht; ohne diesen Build gäbe es 404 auf /.
|
||||
set -e
|
||||
|
||||
SITE_DIR="${SITE_DIR:-/site}"
|
||||
|
||||
echo "→ Initialer Hugo-Build ($SITE_DIR → public/)…"
|
||||
if hugo --source "$SITE_DIR" --destination "$SITE_DIR/public" --cleanDestinationDir; then
|
||||
echo "✓ Live-Seite gebaut."
|
||||
else
|
||||
echo "WARN: Hugo-Build fehlgeschlagen — Live-Seite bleibt leer bis zum ersten Publish."
|
||||
fi
|
||||
|
||||
exec node src/index.js
|
||||
Generated
+930
@@ -0,0 +1,930 @@
|
||||
{
|
||||
"name": "openbureau-cms-api",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openbureau-cms-api",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.6.14",
|
||||
"marked": "^14.1.4",
|
||||
"pg": "^8.22.0",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
||||
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
||||
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
||||
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
||||
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
||||
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
||||
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
||||
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.106.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz",
|
||||
"integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.106.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz",
|
||||
"integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/phoenix": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
|
||||
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.106.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz",
|
||||
"integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.106.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz",
|
||||
"integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/phoenix": "^0.4.2",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.106.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz",
|
||||
"integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.106.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz",
|
||||
"integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.106.2",
|
||||
"@supabase/functions-js": "2.106.2",
|
||||
"@supabase/postgrest-js": "2.106.2",
|
||||
"@supabase/realtime-js": "2.106.2",
|
||||
"@supabase/storage-js": "2.106.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
"color-string": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.23",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "14.1.4",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.1.4.tgz",
|
||||
"integrity": "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
|
||||
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.33.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"color": "^4.2.3",
|
||||
"detect-libc": "^2.0.3",
|
||||
"semver": "^7.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.33.5",
|
||||
"@img/sharp-darwin-x64": "0.33.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.0.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.0.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.0.5",
|
||||
"@img/sharp-libvips-linux-arm64": "1.0.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.0.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.0.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
|
||||
"@img/sharp-linux-arm": "0.33.5",
|
||||
"@img/sharp-linux-arm64": "0.33.5",
|
||||
"@img/sharp-linux-s390x": "0.33.5",
|
||||
"@img/sharp-linux-x64": "0.33.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.33.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||
"@img/sharp-wasm32": "0.33.5",
|
||||
"@img/sharp-win32-ia32": "0.33.5",
|
||||
"@img/sharp-win32-x64": "0.33.5"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "openbureau-cms-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Headless CMS backend für OPENBUREAU — schreibt Supabase-Posts in Hugo-content/, baut und serviert die Site.",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.6.14",
|
||||
"marked": "^14.1.4",
|
||||
"pg": "^8.22.0",
|
||||
"sharp": "^0.33.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { verify } from 'hono/jwt';
|
||||
import { supabaseAuth } from './supabase.js';
|
||||
|
||||
// Supabase-Tokens sind HS256-signiert. Mit dem JWT_SECRET verifizieren wir sie
|
||||
// lokal (Signatur + Ablauf) — das spart pro Request den Roundtrip zu GoTrue.
|
||||
// Ohne JWT_SECRET (z.B. Alt-Deploy) fällt requireAuth auf die Remote-Prüfung
|
||||
// zurück. Tokens sind kurzlebig (1h) und Self-Signup ist aus → kein
|
||||
// Sperr-Check nötig.
|
||||
const JWT_SECRET = process.env.JWT_SECRET || '';
|
||||
|
||||
// Liefert ein User-Objekt {id,email,app_metadata} oder null.
|
||||
async function verifyToken(token) {
|
||||
if (JWT_SECRET) {
|
||||
try {
|
||||
const p = await verify(token, JWT_SECRET, 'HS256');
|
||||
if (!p?.sub) return null;
|
||||
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
|
||||
} catch { return null; }
|
||||
}
|
||||
const { data, error } = await supabaseAuth.auth.getUser(token);
|
||||
if (error || !data?.user) return null;
|
||||
return data.user;
|
||||
}
|
||||
|
||||
// Rollen-Hierarchie: admin > editor (Redakteur) > user.
|
||||
// - admin: alles (Foren verwalten, moderieren, Nutzer/Rollen, Inhalte)
|
||||
// - editor: moderieren (Wortmeldungen ausblenden/löschen, Threads sperren)
|
||||
// - user: im Forum mitschreiben
|
||||
// Admins aus der .env (ADMIN_EMAILS=a@x,b@y) sind immer Admin (Bootstrap, damit
|
||||
// man sich nicht aussperrt). Zusätzlich kann eine Rolle in app_metadata.role
|
||||
// liegen (im Admin-UI vergeben).
|
||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
export function roleOf(user) {
|
||||
const email = (user?.email || '').toLowerCase();
|
||||
const meta = (user?.app_metadata?.role || '').toLowerCase();
|
||||
if (ADMINS.includes(email) || meta === 'admin') return 'admin';
|
||||
if (meta === 'editor') return 'editor';
|
||||
return 'user';
|
||||
}
|
||||
|
||||
// Verifiziert den Supabase-Access-Token und legt user/email/role im Kontext ab.
|
||||
export async function requireAuth(c, next) {
|
||||
const header = c.req.header('Authorization') || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
|
||||
|
||||
const user = await verifyToken(token);
|
||||
if (!user) return c.json({ error: 'Ungültiges Token' }, 401);
|
||||
|
||||
const email = (user.email || '').toLowerCase();
|
||||
const role = roleOf(user);
|
||||
c.set('user', user);
|
||||
c.set('email', email);
|
||||
c.set('role', role);
|
||||
c.set('isAdmin', role === 'admin');
|
||||
c.set('canModerate', role === 'admin' || role === 'editor');
|
||||
await next();
|
||||
}
|
||||
|
||||
// Nur Admins (nach requireAuth einsetzen).
|
||||
export async function requireAdmin(c, next) {
|
||||
if (!c.get('isAdmin')) return c.json({ error: 'Nur für Admins' }, 403);
|
||||
await next();
|
||||
}
|
||||
|
||||
// Admins + Redakteure — fürs Moderieren (nach requireAuth einsetzen).
|
||||
export async function requireModerator(c, next) {
|
||||
if (!c.get('canModerate')) return c.json({ error: 'Nur für Moderation' }, 403);
|
||||
await next();
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Serialisiert asynchrone Aufgaben je `key` und koalesziert Wartende:
|
||||
// - Es läuft nie mehr als eine Aufgabe pro Key gleichzeitig.
|
||||
// - Kommen während eines Laufs weitere Aufrufe rein, wird GENAU EIN weiterer
|
||||
// Durchlauf nachgelagert (egal wie viele warten) — sie teilen sich dessen
|
||||
// Ergebnis. So sehen alle den jüngsten Stand, ohne einen Lauf-Sturm.
|
||||
//
|
||||
// Einsatz: teure, idempotente Vorgänge wie der Hugo-Build (siehe hugo.js).
|
||||
const state = new Map();
|
||||
|
||||
export function coalesce(key, fn) {
|
||||
let s = state.get(key);
|
||||
if (!s) { s = { running: false, rerun: false, fn, waiters: [] }; state.set(key, s); }
|
||||
s.fn = fn; // jüngste Variante gewinnt für den nächsten Lauf
|
||||
return new Promise((resolve, reject) => {
|
||||
s.waiters.push({ resolve, reject });
|
||||
if (!s.running) drain(key);
|
||||
else s.rerun = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function drain(key) {
|
||||
const s = state.get(key);
|
||||
s.running = true;
|
||||
try {
|
||||
do {
|
||||
s.rerun = false;
|
||||
const waiters = s.waiters;
|
||||
s.waiters = [];
|
||||
try {
|
||||
const r = await s.fn();
|
||||
waiters.forEach((w) => w.resolve(r));
|
||||
} catch (e) {
|
||||
waiters.forEach((w) => w.reject(e));
|
||||
}
|
||||
} while (s.rerun);
|
||||
} finally {
|
||||
s.running = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Derive content classification, list ordering and path building from a site's
|
||||
// `config.collections` (see ../../README.md). Pure functions — no config import,
|
||||
// no filesystem — so they unit-test against any collections array directly.
|
||||
//
|
||||
// Fed `examples/openbureau.config.js` these reproduce the engine's original
|
||||
// hard-coded archiv/library/rubrik/seite behaviour 1:1.
|
||||
|
||||
// Parse a `path` pattern ('archiv/:section/:slug') into segments.
|
||||
function parsePattern(pattern) {
|
||||
return pattern.split('/').map((seg) =>
|
||||
seg.startsWith(':') ? { param: seg.slice(1) } : { literal: seg });
|
||||
}
|
||||
|
||||
// Match a content rel-path's segments (no .md) against a `path` pattern.
|
||||
// Returns captured params, or null. Strict: the segment count must equal the
|
||||
// pattern's, so 'archiv/:section/:slug' matches exactly three deep — the same
|
||||
// rule the original classify() enforced for archiv.
|
||||
function matchPattern(segs, pattern) {
|
||||
const pat = parsePattern(pattern);
|
||||
if (segs.length !== pat.length) return null;
|
||||
const params = {};
|
||||
for (let i = 0; i < pat.length; i++) {
|
||||
if (pat[i].literal !== undefined) {
|
||||
if (pat[i].literal !== segs[i]) return null;
|
||||
} else {
|
||||
if (!segs[i]) return null;
|
||||
params[pat[i].param] = segs[i];
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// First literal segment of a pattern (e.g. 'library' from 'library/:slug').
|
||||
function rootSegment(pattern) {
|
||||
const first = pattern.split('/')[0];
|
||||
return first.startsWith(':') ? null : first;
|
||||
}
|
||||
|
||||
// { kind, section } for a content file, derived from collections.
|
||||
// Priority: index (_index.md) → path patterns → fallback — mirrors the original
|
||||
// hard-coded classify() when fed openbureau.config.js.
|
||||
export function classify(rel, collections) {
|
||||
const parts = rel.split('/');
|
||||
const base = parts[parts.length - 1];
|
||||
const segs = rel.replace(/\.md$/, '').split('/');
|
||||
|
||||
// _index.md → the index collection; section = parent dir (or 'home' at root).
|
||||
if (base === '_index.md') {
|
||||
const idx = collections.find((c) => c.index);
|
||||
if (idx) {
|
||||
const section = parts.length >= 2 ? parts[parts.length - 2] : 'home';
|
||||
return { kind: idx.kind, section };
|
||||
}
|
||||
}
|
||||
|
||||
// path-pattern collections (skip index/fallback).
|
||||
for (const c of collections) {
|
||||
if (!c.path || c.index || c.fallback) continue;
|
||||
const params = matchPattern(segs, c.path);
|
||||
if (params) {
|
||||
const section = params.section ?? rootSegment(c.path);
|
||||
return { kind: c.kind, section: section ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
// fallback collection (everything not otherwise matched).
|
||||
const fb = collections.find((c) => c.fallback);
|
||||
return { kind: fb ? fb.kind : null, section: null };
|
||||
}
|
||||
|
||||
// Sort comparator for listEntries: collection `order`, then date desc, then title.
|
||||
export function compareEntries(collections) {
|
||||
const order = {};
|
||||
collections.forEach((c, i) => { order[c.kind] = c.order ?? 100 + i; });
|
||||
return (a, b) =>
|
||||
((order[a.kind] ?? 999) - (order[b.kind] ?? 999)) ||
|
||||
(b.date || '').localeCompare(a.date || '') ||
|
||||
a.title.localeCompare(b.title);
|
||||
}
|
||||
|
||||
// Build a content rel-path for a NEW entry of `kind` from form `data`.
|
||||
// Index collection → '<slug>/_index.md'; a collection without a `path` (fallback)
|
||||
// → '<slug>.md'; otherwise substitute :params in the collection's `path`
|
||||
// (archiv/:section/:slug → archiv/<section>/<slug>.md). Returns '' if a required
|
||||
// segment is missing.
|
||||
export function buildPath(kind, data, collections) {
|
||||
const c = collections.find((x) => x.kind === kind);
|
||||
const slug = String(data.slug || '').trim();
|
||||
if (c?.index) return slug ? `${slug}/_index.md` : '';
|
||||
if (!c?.path) return slug ? `${slug}.md` : '';
|
||||
let ok = true;
|
||||
const rel = c.path.split('/').map((seg) => {
|
||||
if (!seg.startsWith(':')) return seg;
|
||||
const v = String(data[seg.slice(1)] || '').trim();
|
||||
if (!v) ok = false;
|
||||
return v;
|
||||
}).join('/');
|
||||
return ok && rel ? `${rel}.md` : '';
|
||||
}
|
||||
|
||||
// Build the stats `content` object from classified entries + collections:
|
||||
// one counter per collection.statKey, plus a drafts counter for any collection
|
||||
// that declares `draftStatKey`. Mirrors the original hard-coded stats block
|
||||
// (beitraege/entwuerfe/library/seiten/rubriken) when fed openbureau.config.js.
|
||||
export function contentStats(entries, collections) {
|
||||
const content = {};
|
||||
for (const c of collections) {
|
||||
if (c.statKey) content[c.statKey] ??= 0;
|
||||
if (c.draftStatKey) content[c.draftStatKey] ??= 0;
|
||||
}
|
||||
const byKind = Object.fromEntries(collections.map((c) => [c.kind, c]));
|
||||
for (const e of entries) {
|
||||
const c = byKind[e.kind];
|
||||
if (!c) continue;
|
||||
if (c.statKey) content[c.statKey]++;
|
||||
if (c.draftStatKey && e.draft) content[c.draftStatKey]++;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Per-site CMS configuration loader.
|
||||
//
|
||||
// A site tells the core what it manages through one module, referenced by the
|
||||
// CMS_CONFIG env var (e.g. CMS_CONFIG=/site/cms.config.js). The module exports
|
||||
// `default` with { collections, plugins, admins }. Loaded once at startup; the
|
||||
// engine (files/stats/routes) derives its behaviour from it instead of having
|
||||
// openbureau-specific content types hard-coded.
|
||||
//
|
||||
// See ../../README.md for the schema, and ../../examples/*.config.js.
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const p = process.env.CMS_CONFIG;
|
||||
if (!p) {
|
||||
console.error('FEHLT: CMS_CONFIG (Pfad zur Site-Config, z. B. /site/cms.config.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mod = await import(pathToFileURL(p).href);
|
||||
export const config = mod.default || mod;
|
||||
config.collections ||= [];
|
||||
config.plugins ||= [];
|
||||
config.admins ||= [];
|
||||
|
||||
export const hasPlugin = (name) => config.plugins.includes(name);
|
||||
|
||||
// Collection lookup helpers (used by the generalised files.js / stats.js).
|
||||
export const collectionOf = (kind) => config.collections.find((c) => c.kind === kind) || null;
|
||||
export const fallbackCollection = () => config.collections.find((c) => c.fallback) || null;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
import { classify, compareEntries } from './collections.js';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const CONTENT = path.join(SITE_DIR, 'content');
|
||||
|
||||
// Pfad-Sicherheit: relativer Pfad innerhalb content/, nur .md.
|
||||
export function safeRel(rel) {
|
||||
if (!rel || typeof rel !== 'string') throw new Error('Pfad fehlt');
|
||||
const norm = path.normalize(rel).split(path.sep).join('/');
|
||||
if (norm.startsWith('..') || norm.startsWith('/') || norm.includes('../')) {
|
||||
throw new Error('Ungültiger Pfad');
|
||||
}
|
||||
if (!norm.endsWith('.md')) throw new Error('Nur .md erlaubt');
|
||||
return norm;
|
||||
}
|
||||
|
||||
async function walk(dir) {
|
||||
const out = [];
|
||||
for (const e of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...(await walk(full)));
|
||||
else if (e.name.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt).
|
||||
export function normAuthors(a) {
|
||||
if (Array.isArray(a)) return a.map(String).filter(Boolean);
|
||||
if (a) return [String(a)];
|
||||
return [];
|
||||
}
|
||||
|
||||
// Hat diese E-Mail Zugriff (steht sie in der authors-Liste)?
|
||||
export function hasAccess(authors, email) {
|
||||
const e = (email || '').toLowerCase();
|
||||
return normAuthors(authors).some((a) => a.toLowerCase() === e);
|
||||
}
|
||||
|
||||
// Hugo-URL aus dem relativen Pfad.
|
||||
export function urlFor(rel) {
|
||||
let p = rel.replace(/\.md$/, '');
|
||||
if (p === '_index') return '/';
|
||||
p = p.replace(/\/_index$/, '');
|
||||
return '/' + p + '/';
|
||||
}
|
||||
|
||||
export async function listEntries() {
|
||||
// Lazy: only listing needs the site config, so importing files.js for its pure
|
||||
// helpers (safeRel/urlFor/…) never triggers the CMS_CONFIG load.
|
||||
const { config } = await import('./config.js');
|
||||
const files = await walk(CONTENT);
|
||||
const items = [];
|
||||
for (const full of files) {
|
||||
const rel = path.relative(CONTENT, full).split(path.sep).join('/');
|
||||
// Autor-Seiten werden über „Profil" verwaltet, nicht im Inhalts-Editor.
|
||||
if (rel === 'authors' || rel.startsWith('authors/')) continue;
|
||||
let data = {};
|
||||
try { data = matter(await readFile(full, 'utf8')).data || {}; } catch {}
|
||||
items.push({
|
||||
path: rel,
|
||||
title: data.title || rel,
|
||||
...classify(rel, config.collections),
|
||||
color: data.color || null,
|
||||
layout: data.layout || null,
|
||||
draft: !!data.draft,
|
||||
date: data.date ? String(data.date).slice(0, 10) : null,
|
||||
authors: normAuthors(data.authors),
|
||||
url: urlFor(rel),
|
||||
});
|
||||
}
|
||||
// Reihenfolge aus der Config (collection.order), dann Datum, dann Titel.
|
||||
items.sort(compareEntries(config.collections));
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function readEntry(rel) {
|
||||
rel = safeRel(rel);
|
||||
const { data, content } = matter(await readFile(path.join(CONTENT, rel), 'utf8'));
|
||||
return { path: rel, url: urlFor(rel), frontmatter: data || {}, body: content || '' };
|
||||
}
|
||||
|
||||
export async function entryExists(rel) {
|
||||
try { await stat(path.join(CONTENT, safeRel(rel))); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
export async function writeEntry(rel, frontmatter = {}, body = '') {
|
||||
rel = safeRel(rel);
|
||||
const full = path.join(CONTENT, rel);
|
||||
await mkdir(path.dirname(full), { recursive: true });
|
||||
|
||||
// Leere Werte rauswerfen, damit das Frontmatter sauber bleibt.
|
||||
const fm = {};
|
||||
for (const [k, v] of Object.entries(frontmatter)) {
|
||||
if (v === '' || v === null || v === undefined) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
fm[k] = v;
|
||||
}
|
||||
await writeFile(full, matter.stringify(body || '', fm), 'utf8');
|
||||
return rel;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { coalesce } from './coalesce.js';
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
|
||||
// Baut die Site. dest ist relativ zum Repo-Root (z.B. "public" oder "preview").
|
||||
// drafts:true => --buildDrafts (für die Vorschau).
|
||||
export async function hugoBuild({ dest, drafts = false } = {}) {
|
||||
const args = ['--source', SITE_DIR, '--destination', dest, '--cleanDestinationDir'];
|
||||
if (drafts) args.push('--buildDrafts');
|
||||
const { stdout, stderr } = await execFileP('hugo', args, {
|
||||
cwd: SITE_DIR,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return { stdout, stderr };
|
||||
}
|
||||
|
||||
// Koaleszierter Build je Ziel: nie zwei `hugo`-Prozesse für dasselbe dest
|
||||
// parallel; schnelle Folge-Aufrufe lösen nur einen nachgelagerten Build aus.
|
||||
// Publish (public), Preview (preview) und Profil teilen sich diesen Weg.
|
||||
export function buildSite({ dest, drafts = false } = {}) {
|
||||
return coalesce(`build:${dest}:${drafts ? 'd' : 'p'}`, () => hugoBuild({ dest, drafts }));
|
||||
}
|
||||
|
||||
// Optionaler Git-Backup beim Publish (GIT_PUBLISH=true). Schlägt nie hart fehl —
|
||||
// das Publish soll an einem Git-Problem nicht scheitern.
|
||||
export async function gitCommit(message) {
|
||||
if (process.env.GIT_PUBLISH !== 'true') return { skipped: true };
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || 'OPENBUREAU CMS',
|
||||
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || 'cms@openbureau.ch',
|
||||
GIT_COMMITTER_NAME: process.env.GIT_AUTHOR_NAME || 'OPENBUREAU CMS',
|
||||
GIT_COMMITTER_EMAIL: process.env.GIT_AUTHOR_EMAIL || 'cms@openbureau.ch',
|
||||
};
|
||||
const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { env });
|
||||
|
||||
await git('add', 'content');
|
||||
// Nichts zu committen? Dann ruhig raus.
|
||||
const status = await git('status', '--porcelain', 'content');
|
||||
if (!status.stdout.trim()) return { nothing: true };
|
||||
|
||||
await git('commit', '-m', message);
|
||||
await git('push', process.env.GIT_REMOTE || 'origin', process.env.GIT_BRANCH || 'main');
|
||||
return { committed: true };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { Hono } from 'hono';
|
||||
import { secureHeaders } from 'hono/secure-headers';
|
||||
import { bodyLimit } from 'hono/body-limit';
|
||||
|
||||
import { rateLimit } from './ratelimit.js';
|
||||
import content from './routes/content.js';
|
||||
import preview from './routes/preview.js';
|
||||
import publish from './routes/publish.js';
|
||||
import upload from './routes/upload.js';
|
||||
import profile from './routes/profile.js';
|
||||
import users from './routes/users.js';
|
||||
import stats from './routes/stats.js';
|
||||
import history from './routes/history.js';
|
||||
import { requireAuth, requireAdmin } from './auth.js';
|
||||
import { manager } from './plugins.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// --- Sicherheits-Header (auf allem) ---
|
||||
// CSP bewusst zurückhaltend: Site + Admin-SPA + Dialog-Widget laufen same-origin.
|
||||
app.use('*', secureHeaders({
|
||||
xFrameOptions: 'SAMEORIGIN',
|
||||
xContentTypeOptions: 'nosniff',
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
crossOriginOpenerPolicy: 'same-origin',
|
||||
// HSTS nur sinnvoll hinter TLS-Proxy; schadet via HTTP nicht (Browser ignoriert).
|
||||
strictTransportSecurity: 'max-age=31536000; includeSubDomains',
|
||||
}));
|
||||
|
||||
// Hochgeladene Bilder strikt isolieren: ein bösartiges SVG kann so kein
|
||||
// JavaScript im Origin ausführen (sandbox + keine Skript-Quellen).
|
||||
app.use('/images/*', secureHeaders({
|
||||
contentSecurityPolicy: { defaultSrc: ["'none'"], imgSrc: ["'self'"], styleSrc: ["'unsafe-inline'"], sandbox: [] },
|
||||
xContentTypeOptions: 'nosniff',
|
||||
}));
|
||||
|
||||
// Statische Assets cachen: Hugo fingerprintet CSS/JS, Uploads haben stabile,
|
||||
// eindeutige Namen. HTML bleibt ungecacht (Antwort ohne Header → immer frisch).
|
||||
app.use('*', async (c, next) => {
|
||||
await next();
|
||||
if (c.req.method === 'GET' && /\.(css|js|mjs|woff2?|ttf|otf|eot|svg|png|jpe?g|webp|avif|gif|ico)$/i.test(c.req.path)) {
|
||||
c.header('Cache-Control', 'public, max-age=604800'); // 1 Woche
|
||||
}
|
||||
});
|
||||
|
||||
// --- API ---
|
||||
// Globales Limit gegen aufgeblähte JSON-Bodies (DoS / DB-Bloat). Der Upload-Pfad
|
||||
// ist ausgenommen — der bringt sein eigenes, größeres Bild-Limit mit.
|
||||
const jsonBodyLimit = bodyLimit({ maxSize: 256 * 1024, onError: (c) => c.json({ error: 'Anfrage zu groß' }, 413) });
|
||||
app.use('/api/*', (c, next) =>
|
||||
c.req.path.startsWith('/api/upload') ? next() : jsonBodyLimit(c, next));
|
||||
|
||||
app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
|
||||
// Öffentliche Plugin-Routen (ohne Login) — je nach config.plugins, z. B. Dialog
|
||||
// lesen + Widget-Login. Jede Route bringt ihre eigenen Limits mit (dialog drosselt
|
||||
// den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
|
||||
manager.mountPublic(app);
|
||||
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
|
||||
app.route('/api/history', history);
|
||||
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
||||
app.use('/api/*', requireAuth);
|
||||
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
|
||||
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
|
||||
const mutateLimit = rateLimit({
|
||||
max: 60, windowMs: 60_000,
|
||||
keyFn: (c) => 'u:' + (c.get('user')?.id || c.req.header('x-forwarded-for') || 'anon'),
|
||||
});
|
||||
app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next)));
|
||||
app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') }));
|
||||
// Authentifizierte Plugin-Routen (nach requireAuth). admin:true-Routen laufen
|
||||
// hinter requireAdmin; self-guarding Sub-Apps (dialog mod/adminForums) regeln den
|
||||
// Zugriff selbst und brauchen das Flag nicht.
|
||||
manager.mountPrivate(app, '/api', requireAdmin);
|
||||
app.route('/api/content', content);
|
||||
app.route('/api/preview', preview);
|
||||
app.route('/api/publish', publish);
|
||||
app.route('/api/upload', upload);
|
||||
app.route('/api/profile', profile);
|
||||
app.route('/api/users', users);
|
||||
app.route('/api/stats', stats);
|
||||
|
||||
// --- Admin-SPA (im Container mitgebaut, unter /admin serviert) ---
|
||||
app.get('/admin', (c) => c.redirect('/admin/'));
|
||||
app.use(
|
||||
'/admin/*',
|
||||
serveStatic({
|
||||
root: ADMIN_DIR,
|
||||
rewriteRequestPath: (p) => p.replace(/^\/admin/, '') || '/',
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Vorschau (gebaut nach preview/ mit --buildDrafts) ---
|
||||
app.use(
|
||||
'/_preview/*',
|
||||
serveStatic({
|
||||
root: `${SITE_DIR}/preview`,
|
||||
rewriteRequestPath: (p) => p.replace(/^\/_preview/, ''),
|
||||
}),
|
||||
);
|
||||
|
||||
// Hochgeladene Bilder direkt aus static/ servieren — sofort sichtbar
|
||||
// (Vorschau, Cover, Profilbild), ohne auf den nächsten Hugo-Build zu warten.
|
||||
app.use('/images/*', serveStatic({ root: `${SITE_DIR}/static` }));
|
||||
|
||||
// --- Live-Site (gebaut nach public/) ---
|
||||
app.use('/*', serveStatic({ root: `${SITE_DIR}/public` }));
|
||||
|
||||
serve({ fetch: app.fetch, port: PORT }, async (info) => {
|
||||
console.log(`CMS läuft auf :${info.port} — Site + API + /_preview`);
|
||||
// Plugin-Migrationen zuerst anwenden (run-once, getrackt) — die Boot-Hooks
|
||||
// unten brauchen die Tabellen schon. Ohne deklarierte Migrationen ein No-op.
|
||||
await runMigrations(manager, { databaseUrl: process.env.DATABASE_URL })
|
||||
.catch((e) => console.error('migrate:', e?.message || e));
|
||||
// Plugin-Boot-Hooks anstoßen (dialog z. B. spiegelt Library→Threads), nicht blockierend.
|
||||
manager.runBoot({}).catch((e) => console.error('plugin onBoot:', e?.message || e));
|
||||
});
|
||||
@@ -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);
|
||||
@@ -0,0 +1,74 @@
|
||||
import { supabase, supabaseAuth } from '../../supabase.js';
|
||||
import { roleOf } from '../../auth.js';
|
||||
import { profileFor, threadLocked } from './dialog-store.js';
|
||||
import { serverError } from '../../util.js';
|
||||
|
||||
// Dialog: flache Wortmeldungen pro Thread (= Thread-Key), optionaler Bezug.
|
||||
|
||||
const COLS = 'id,thread,parent_id,author_name,author_avatar,author_role,body,created_at,deleted';
|
||||
const MAX_BODY = 10_000; // Zeichen je Wortmeldung
|
||||
const MAX_THREAD = 512; // Thread-Key-Länge
|
||||
|
||||
// ÖFFENTLICH: Wortmeldungen eines Threads lesen.
|
||||
export async function listComments(c) {
|
||||
const thread = c.req.query('thread');
|
||||
if (!thread) return c.json({ error: 'thread fehlt' }, 400);
|
||||
const { data, error } = await supabase
|
||||
.from('comments').select(COLS).eq('thread', thread).order('created_at', { ascending: true });
|
||||
if (error) return serverError(c, 'listComments', error);
|
||||
const out = (data || []).map((r) => (r.deleted ? { ...r, body: '[gelöscht]', author_avatar: null } : r));
|
||||
return c.json(out);
|
||||
}
|
||||
|
||||
// EINGELOGGT: Wortmeldung schreiben.
|
||||
export async function createComment(c) {
|
||||
const user = c.get('user');
|
||||
const email = c.get('email');
|
||||
const { thread, body, parent_id } = await c.req.json();
|
||||
if (!thread || !body || !body.trim()) return c.json({ error: 'thread und Text nötig' }, 400);
|
||||
if (typeof thread !== 'string' || thread.length > MAX_THREAD) return c.json({ error: 'Ungültiger Thread' }, 400);
|
||||
if (typeof body !== 'string' || body.length > MAX_BODY) return c.json({ error: `Text zu lang (max. ${MAX_BODY} Zeichen)` }, 400);
|
||||
if (await threadLocked(thread)) return c.json({ error: 'Thread ist gesperrt' }, 403);
|
||||
|
||||
const prof = await profileFor(email);
|
||||
const row = {
|
||||
thread,
|
||||
parent_id: parent_id || null,
|
||||
user_id: user.id,
|
||||
author_name: prof?.name || email.split('@')[0],
|
||||
author_avatar: prof?.avatar || null,
|
||||
author_role: prof?.title || null, // „Position bei OPENBUREAU" (aus data/authors.json)
|
||||
body: body.trim(),
|
||||
};
|
||||
const { data, error } = await supabase.from('comments').insert(row).select(COLS).single();
|
||||
if (error) return serverError(c, 'createComment', error, 400);
|
||||
return c.json(data, 201);
|
||||
}
|
||||
|
||||
// EINGELOGGT: eigene Wortmeldung löschen; Moderation (Admin/Redakteur) jede.
|
||||
export async function deleteComment(c) {
|
||||
const user = c.get('user');
|
||||
const canModerate = c.get('canModerate');
|
||||
const id = c.req.param('id');
|
||||
const { data: row, error: e1 } = await supabase.from('comments').select('user_id').eq('id', id).single();
|
||||
if (e1 || !row) return c.json({ error: 'Nicht gefunden' }, 404);
|
||||
if (!canModerate && row.user_id !== user.id) return c.json({ error: 'Kein Recht' }, 403);
|
||||
const { error } = await supabase.from('comments').update({ deleted: true }).eq('id', id);
|
||||
if (error) return serverError(c, 'deleteComment', error, 400);
|
||||
return c.json({ ok: true });
|
||||
}
|
||||
|
||||
// ÖFFENTLICH: Login fürs Dialog-Widget — gibt das User-Token zurück.
|
||||
export async function login(c) {
|
||||
const { email, password } = await c.req.json();
|
||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||
const { data, error } = await supabaseAuth.auth.signInWithPassword({ email, password });
|
||||
if (error) return c.json({ error: error.message }, 401);
|
||||
const prof = await profileFor((data.user.email || '').toLowerCase());
|
||||
return c.json({
|
||||
access_token: data.session.access_token,
|
||||
email: data.user.email,
|
||||
name: prof?.name || (data.user.email || '').split('@')[0],
|
||||
role: roleOf(data.user),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { supabase } from '../../supabase.js';
|
||||
import { listEntries } from '../../files.js';
|
||||
|
||||
// Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen).
|
||||
// Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS).
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
export const LIBRARY_SLUG = 'beitraege';
|
||||
|
||||
export async function profileFor(email) {
|
||||
try {
|
||||
const all = JSON.parse(await readFile(path.join(SITE_DIR, 'data', 'authors.json'), 'utf8'));
|
||||
return all[email] || null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man
|
||||
// auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key).
|
||||
//
|
||||
// Gedrosselt: Reads rufen das bei jedem Forum-Aufruf, aber der eigentliche
|
||||
// Sync (DB + Filesystem-Walk + Upsert) läuft höchstens alle SYNC_TTL ms.
|
||||
// `force: true` (z.B. nach Publish) überspringt die Drosselung.
|
||||
const SYNC_TTL = 60_000;
|
||||
let lastSync = 0;
|
||||
export async function syncLibrary({ force = false } = {}) {
|
||||
if (!force && Date.now() - lastSync < SYNC_TTL) return;
|
||||
lastSync = Date.now();
|
||||
const { data: forum } = await supabase
|
||||
.from('forums').select('id').eq('slug', LIBRARY_SLUG).single();
|
||||
if (!forum) return;
|
||||
let entries = [];
|
||||
try { entries = (await listEntries()).filter((e) => e.kind === 'beitrag'); } catch { return; }
|
||||
if (!entries.length) return;
|
||||
const rows = entries.map((e) => ({
|
||||
forum_id: forum.id, key: e.url, title: e.title, url: e.url, kind: 'library',
|
||||
}));
|
||||
// Nicht title überschreiben? Doch — Titel kann sich ändern. user_id/locked bleiben.
|
||||
await supabase.from('threads').upsert(rows, { onConflict: 'key', ignoreDuplicates: false });
|
||||
}
|
||||
|
||||
// Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }.
|
||||
// Aggregiert in Postgres (View comment_stats) statt alle Zeilen zu laden.
|
||||
async function commentStats() {
|
||||
const { data } = await supabase.from('comment_stats').select('thread,count,last');
|
||||
const map = {};
|
||||
for (const r of data || []) map[r.thread] = { count: r.count, last: r.last };
|
||||
return map;
|
||||
}
|
||||
|
||||
// Alle Foren mit Thread-/Wortmeldungs-Zahl und letzter Aktivität.
|
||||
export async function forumsWithCounts() {
|
||||
await syncLibrary();
|
||||
const [{ data: forums }, { data: threads }, stats] = await Promise.all([
|
||||
supabase.from('forums').select('*').order('sort'),
|
||||
supabase.from('threads').select('id,forum_id,key,deleted'),
|
||||
commentStats(),
|
||||
]);
|
||||
const byForum = {};
|
||||
for (const t of threads || []) {
|
||||
if (t.deleted) continue;
|
||||
const f = byForum[t.forum_id] || (byForum[t.forum_id] = { threads: 0, posts: 0, last: '' });
|
||||
f.threads += 1;
|
||||
const s = stats[t.key];
|
||||
if (s) { f.posts += s.count; if (s.last > f.last) f.last = s.last; }
|
||||
}
|
||||
return (forums || []).map((f) => ({
|
||||
...f,
|
||||
thread_count: byForum[f.id]?.threads || 0,
|
||||
post_count: byForum[f.id]?.posts || 0,
|
||||
last_at: byForum[f.id]?.last || null,
|
||||
}));
|
||||
}
|
||||
|
||||
// Ein Forum samt seiner Threads (nach letzter Aktivität sortiert).
|
||||
export async function forumWithThreads(slug) {
|
||||
const { data: forum } = await supabase.from('forums').select('*').eq('slug', slug).single();
|
||||
if (!forum) return null;
|
||||
if (forum.kind === 'library') await syncLibrary();
|
||||
const [{ data: threads }, stats] = await Promise.all([
|
||||
supabase.from('threads').select('*').eq('forum_id', forum.id).eq('deleted', false),
|
||||
commentStats(),
|
||||
]);
|
||||
const list = (threads || []).map((t) => ({
|
||||
key: t.key, title: t.title, url: t.url, kind: t.kind, locked: t.locked,
|
||||
author_name: t.author_name, created_at: t.created_at,
|
||||
count: stats[t.key]?.count || 0, last: stats[t.key]?.last || t.created_at,
|
||||
})).sort((a, b) => (b.last || '').localeCompare(a.last || ''));
|
||||
return { forum, threads: list };
|
||||
}
|
||||
|
||||
// Letzte Wortmeldungen über alles — für die linke Spalte der Übersicht.
|
||||
export async function recentComments(limit = 20) {
|
||||
await syncLibrary();
|
||||
const [{ data: comments }, { data: threads }, { data: forums }] = await Promise.all([
|
||||
supabase.from('comments').select('id,thread,author_name,body,created_at')
|
||||
.eq('deleted', false).order('created_at', { ascending: false }).limit(limit),
|
||||
supabase.from('threads').select('key,title,url,forum_id,kind'),
|
||||
supabase.from('forums').select('id,slug,name'),
|
||||
]);
|
||||
const tByKey = {}; for (const t of threads || []) tByKey[t.key] = t;
|
||||
const fById = {}; for (const f of forums || []) fById[f.id] = f;
|
||||
return (comments || []).map((c) => {
|
||||
const t = tByKey[c.thread];
|
||||
const f = t ? fById[t.forum_id] : null;
|
||||
return {
|
||||
id: c.id, body: c.body, author_name: c.author_name, created_at: c.created_at,
|
||||
thread_title: t?.title || c.thread,
|
||||
thread_url: t ? (t.kind === 'library' ? t.url : '/dialog/?thread=' + encodeURIComponent(c.thread)) : c.thread,
|
||||
forum_name: f?.name || null, forum_slug: f?.slug || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Moderations-Überblick: letzte Wortmeldungen + alle Threads (zum Sperren/Löschen).
|
||||
export async function recentForModeration() {
|
||||
const [comments, { data: threads }, { data: forums }] = await Promise.all([
|
||||
recentComments(50),
|
||||
supabase.from('threads').select('key,title,url,kind,forum_id,locked,deleted,author_name,created_at')
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase.from('forums').select('id,name,slug'),
|
||||
]);
|
||||
const fById = {}; for (const f of forums || []) fById[f.id] = f;
|
||||
const stats = await commentStats();
|
||||
const t = (threads || []).map((x) => ({
|
||||
key: x.key, title: x.title, url: x.url, kind: x.kind, locked: x.locked, deleted: x.deleted,
|
||||
author_name: x.author_name, created_at: x.created_at,
|
||||
forum_name: fById[x.forum_id]?.name || null,
|
||||
count: stats[x.key]?.count || 0,
|
||||
}));
|
||||
return { comments, threads: t };
|
||||
}
|
||||
|
||||
// Neuen Thread in einem Forum anlegen (+ erste Wortmeldung). Gibt den Thread zurück.
|
||||
export async function createThread({ forumId, forumSlug, title, body, user, email }) {
|
||||
let fid = forumId;
|
||||
if (!fid && forumSlug) {
|
||||
const { data: f } = await supabase.from('forums').select('id,kind').eq('slug', forumSlug).single();
|
||||
if (!f) return { error: 'Forum unbekannt' };
|
||||
if (f.kind === 'library') return { error: 'In Beiträge entstehen Threads automatisch' };
|
||||
fid = f.id;
|
||||
}
|
||||
if (!fid) return { error: 'Forum nötig' };
|
||||
if (!title || !title.trim()) return { error: 'Titel nötig' };
|
||||
if (!body || !body.trim()) return { error: 'Erster Beitrag nötig' };
|
||||
|
||||
const prof = await profileFor(email);
|
||||
const name = prof?.name || email.split('@')[0];
|
||||
const key = 't/' + randomUUID();
|
||||
const { data: thread, error: e1 } = await supabase.from('threads').insert({
|
||||
forum_id: fid, key, title: title.trim(), url: '/dialog/?thread=' + encodeURIComponent(key),
|
||||
kind: 'forum', author_name: name, user_id: user.id,
|
||||
}).select('*').single();
|
||||
if (e1) return { error: e1.message };
|
||||
const { error: e2 } = await supabase.from('comments').insert({
|
||||
thread: key, user_id: user.id, author_name: name,
|
||||
author_avatar: prof?.avatar || null, body: body.trim(),
|
||||
});
|
||||
if (e2) return { error: e2.message };
|
||||
return { thread };
|
||||
}
|
||||
|
||||
// Ist ein Thread gesperrt? (verhindert neue Wortmeldungen)
|
||||
export async function threadLocked(key) {
|
||||
const { data } = await supabase.from('threads').select('locked').eq('key', key).single();
|
||||
return !!data?.locked;
|
||||
}
|
||||
|
||||
// Thread-Metadaten für die Thread-Ansicht (Titel, Forum-Rücklink, Lock-Status).
|
||||
export async function threadMeta(key) {
|
||||
const { data: t } = await supabase
|
||||
.from('threads').select('title,url,kind,locked,forum_id').eq('key', key).single();
|
||||
if (!t) return null;
|
||||
let forum = null;
|
||||
if (t.forum_id) {
|
||||
const { data: f } = await supabase.from('forums').select('slug,name').eq('id', t.forum_id).single();
|
||||
forum = f || null;
|
||||
}
|
||||
return { title: t.title, url: t.url, kind: t.kind, locked: !!t.locked, forum };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../../supabase.js';
|
||||
import { requireAdmin, requireModerator } from '../../auth.js';
|
||||
import { serverError } from '../../util.js';
|
||||
import {
|
||||
forumsWithCounts, forumWithThreads, recentComments, createThread, recentForModeration, threadMeta,
|
||||
} from './dialog-store.js';
|
||||
|
||||
// Fehlt die Tabelle (Migration noch nicht eingespielt), nicht mit einem rohen
|
||||
// SQL-Fehler antworten — leer zurückgeben und server-seitig laut loggen.
|
||||
function softFail(c, e, fallback) {
|
||||
console.error('[dialog]', e?.message || e);
|
||||
return c.json(fallback);
|
||||
}
|
||||
|
||||
// ── Öffentliche Lese-Handler ─────────────────────────────────────────────
|
||||
export async function listForums(c) {
|
||||
try { return c.json(await forumsWithCounts()); }
|
||||
catch (e) { return softFail(c, e, []); }
|
||||
}
|
||||
export async function showForum(c) {
|
||||
try {
|
||||
const data = await forumWithThreads(c.req.param('slug'));
|
||||
if (!data) return c.json({ error: 'Forum nicht gefunden' }, 404);
|
||||
return c.json(data);
|
||||
} catch (e) { return softFail(c, e, { forum: null, threads: [] }); }
|
||||
}
|
||||
export async function recent(c) {
|
||||
try { return c.json(await recentComments(Number(c.req.query('limit')) || 20)); }
|
||||
catch (e) { return softFail(c, e, []); }
|
||||
}
|
||||
export async function threadInfo(c) {
|
||||
const key = c.req.query('key');
|
||||
if (!key) return c.json({ error: 'key fehlt' }, 400);
|
||||
const meta = await threadMeta(key);
|
||||
if (!meta) return c.json({ error: 'Thread nicht gefunden' }, 404);
|
||||
return c.json(meta);
|
||||
}
|
||||
|
||||
// ── Eingeloggt: neuen Thread starten ─────────────────────────────────────
|
||||
export async function newThread(c) {
|
||||
const user = c.get('user');
|
||||
const email = c.get('email');
|
||||
const { forum_id, forum_slug, title, body } = await c.req.json();
|
||||
const res = await createThread({ forumId: forum_id, forumSlug: forum_slug, title, body, user, email });
|
||||
if (res.error) return c.json({ error: res.error }, 400);
|
||||
return c.json(res.thread, 201);
|
||||
}
|
||||
|
||||
// ── Moderation (Admin + Redakteur) ───────────────────────────────────────
|
||||
export const mod = new Hono();
|
||||
mod.use('*', requireModerator);
|
||||
// Feed: letzte Wortmeldungen + alle Threads (zum Moderieren/Sperren).
|
||||
mod.get('/overview', async (c) => c.json(await recentForModeration()));
|
||||
// Thread sperren/entsperren.
|
||||
mod.post('/thread-lock', async (c) => {
|
||||
const { key, locked } = await c.req.json();
|
||||
if (!key) return c.json({ error: 'key nötig' }, 400);
|
||||
const { error } = await supabase.from('threads').update({ locked: !!locked }).eq('key', key);
|
||||
if (error) return serverError(c, 'dialog', error, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
// Thread ausblenden (löschen).
|
||||
mod.post('/thread-delete', async (c) => {
|
||||
const { key } = await c.req.json();
|
||||
if (!key) return c.json({ error: 'key nötig' }, 400);
|
||||
const { error } = await supabase.from('threads').update({ deleted: true }).eq('key', key);
|
||||
if (error) return serverError(c, 'dialog', error, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Foren-Verwaltung (nur Admin) ─────────────────────────────────────────
|
||||
export const adminForums = new Hono();
|
||||
adminForums.use('*', requireAdmin);
|
||||
adminForums.get('/', async (c) => {
|
||||
const { data, error } = await supabase.from('forums').select('*').order('sort');
|
||||
if (error) return serverError(c, 'dialog', error, 500);
|
||||
return c.json(data || []);
|
||||
});
|
||||
adminForums.post('/', async (c) => {
|
||||
const { slug, name, description, color, sort } = await c.req.json();
|
||||
if (!slug || !name) return c.json({ error: 'slug und name nötig' }, 400);
|
||||
const row = { slug: String(slug).trim(), name: String(name).trim(),
|
||||
description: description || '', color: color || null, sort: Number(sort) || 0 };
|
||||
const { data, error } = await supabase.from('forums').insert(row).select('*').single();
|
||||
if (error) return serverError(c, 'dialog', error, 400);
|
||||
return c.json(data, 201);
|
||||
});
|
||||
adminForums.put('/:id', async (c) => {
|
||||
const patch = await c.req.json();
|
||||
const allowed = {};
|
||||
for (const k of ['name', 'description', 'color', 'sort', 'slug']) if (k in patch) allowed[k] = patch[k];
|
||||
const { data, error } = await supabase.from('forums').update(allowed).eq('id', c.req.param('id')).select('*').single();
|
||||
if (error) return serverError(c, 'dialog', error, 400);
|
||||
return c.json(data);
|
||||
});
|
||||
adminForums.delete('/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const { data: f } = await supabase.from('forums').select('kind').eq('id', id).single();
|
||||
if (f?.kind === 'library') return c.json({ error: 'Beiträge-Kategorie kann nicht gelöscht werden' }, 400);
|
||||
const { error } = await supabase.from('forums').delete().eq('id', id);
|
||||
if (error) return serverError(c, 'dialog', error, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -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,34 @@
|
||||
// Einfacher In-Memory-Rate-Limiter (ein Container, eine Instanz → genügt).
|
||||
// Fixed-Window pro Schlüssel (Standard: Client-IP). Bei Überschreitung 429.
|
||||
// Hinter einem Reverse-Proxy liefert X-Forwarded-For die echte IP.
|
||||
|
||||
const buckets = new Map(); // key -> { count, reset }
|
||||
|
||||
function clientIp(c) {
|
||||
const xff = c.req.header('x-forwarded-for');
|
||||
if (xff) return xff.split(',')[0].trim();
|
||||
return c.req.header('x-real-ip') || 'unknown';
|
||||
}
|
||||
|
||||
// max Anfragen je windowMs. keyFn erlaubt eigene Schlüssel (z.B. IP+E-Mail).
|
||||
export function rateLimit({ max = 10, windowMs = 60_000, keyFn = clientIp } = {}) {
|
||||
return async (c, next) => {
|
||||
const key = keyFn(c);
|
||||
const now = Date.now();
|
||||
let b = buckets.get(key);
|
||||
if (!b || now > b.reset) { b = { count: 0, reset: now + windowMs }; buckets.set(key, b); }
|
||||
b.count += 1;
|
||||
if (b.count > max) {
|
||||
const retry = Math.ceil((b.reset - now) / 1000);
|
||||
c.header('Retry-After', String(retry));
|
||||
return c.json({ error: 'Zu viele Anfragen — bitte später erneut.' }, 429);
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
// Speicher sauber halten: abgelaufene Buckets periodisch wegräumen.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, b] of buckets) if (now > b.reset) buckets.delete(k);
|
||||
}, 5 * 60_000).unref?.();
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Hono } from 'hono';
|
||||
import { listEntries, readEntry, writeEntry, entryExists, hasAccess, normAuthors } from '../files.js';
|
||||
|
||||
// Dateibasiert + Rechte: Admin sieht/bearbeitet alles, Autor:innen nur Einträge,
|
||||
// in denen ihre Mail unter `authors` steht.
|
||||
const content = new Hono();
|
||||
|
||||
content.get('/', async (c) => {
|
||||
const email = c.get('email'); const isAdmin = c.get('isAdmin');
|
||||
try {
|
||||
let items = await listEntries();
|
||||
if (!isAdmin) items = items.filter((e) => hasAccess(e.authors, email));
|
||||
return c.json(items);
|
||||
} catch (e) { return c.json({ error: String(e.message || e) }, 500); }
|
||||
});
|
||||
|
||||
content.get('/entry', async (c) => {
|
||||
const email = c.get('email'); const isAdmin = c.get('isAdmin');
|
||||
try {
|
||||
const entry = await readEntry(c.req.query('path'));
|
||||
if (!isAdmin && !hasAccess(entry.frontmatter.authors, email)) {
|
||||
return c.json({ error: 'Kein Zugriff auf diesen Eintrag' }, 403);
|
||||
}
|
||||
return c.json(entry);
|
||||
} catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||
});
|
||||
|
||||
content.put('/entry', async (c) => {
|
||||
const email = c.get('email'); const isAdmin = c.get('isAdmin');
|
||||
const { path: rel, frontmatter, body } = await c.req.json();
|
||||
try {
|
||||
const exists = await entryExists(rel);
|
||||
if (exists && !isAdmin) {
|
||||
const cur = await readEntry(rel);
|
||||
if (!hasAccess(cur.frontmatter.authors, email)) {
|
||||
return c.json({ error: 'Kein Zugriff auf diesen Eintrag' }, 403);
|
||||
}
|
||||
}
|
||||
// authors zusammenführen; Ersteller wird beim Anlegen automatisch Autor.
|
||||
const authors = normAuthors(frontmatter.authors);
|
||||
if (!exists && email && !authors.some((a) => a.toLowerCase() === email)) {
|
||||
authors.unshift(email);
|
||||
}
|
||||
const fm = { ...frontmatter, authors };
|
||||
const saved = await writeEntry(rel, fm, body);
|
||||
return c.json({ ok: true, path: saved, created: !exists });
|
||||
} catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||
});
|
||||
|
||||
export default content;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Hono } from 'hono';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import matter from 'gray-matter';
|
||||
import { marked } from 'marked';
|
||||
import { safeRel } from '../files.js';
|
||||
|
||||
// ÖFFENTLICH: Versionsverlauf eines Library-Beitrags aus der Git-History.
|
||||
// Der Container hat das Repo unter /site gemountet + git installiert. Wir
|
||||
// holen alte Fassungen on-demand (kein Vorbauen) und zeigen sie auf der Site.
|
||||
const execFileP = promisify(execFile);
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const git = (...args) => execFileP('git', ['-C', SITE_DIR, ...args], { maxBuffer: 10 * 1024 * 1024 });
|
||||
const US = '\x1f'; // Feldtrenner (Unit Separator) — kommt in Commit-Daten nicht vor.
|
||||
|
||||
const history = new Hono();
|
||||
|
||||
// Liste der Versionen: neueste zuerst.
|
||||
history.get('/', async (c) => {
|
||||
let rel;
|
||||
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
|
||||
try {
|
||||
const { stdout } = await git(
|
||||
'log', '--follow', `--format=%H${US}%h${US}%aI${US}%an${US}%s`, '--', `content/${rel}`);
|
||||
const versions = stdout.trim().split('\n').filter(Boolean).map((line) => {
|
||||
const [rev, short, date, author, subject] = line.split(US);
|
||||
return { rev, short, date, author, subject };
|
||||
});
|
||||
return c.json(versions);
|
||||
} catch { return c.json({ error: 'Verlauf nicht verfügbar' }, 500); }
|
||||
});
|
||||
|
||||
// Eine bestimmte Fassung gerendert (HTML), zum Anzeigen auf der Seite.
|
||||
history.get('/version', async (c) => {
|
||||
let rel;
|
||||
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
|
||||
const rev = c.req.query('rev') || '';
|
||||
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
|
||||
try {
|
||||
const { stdout } = await git('show', `${rev}:content/${rel}`);
|
||||
const { data, content } = matter(stdout);
|
||||
return c.json({
|
||||
rev,
|
||||
title: data.title || '',
|
||||
date: data.date ? new Date(data.date).toISOString().slice(0, 10) : null,
|
||||
html: renderMarkdown(content),
|
||||
});
|
||||
} catch { return c.json({ error: 'Version nicht gefunden' }, 404); }
|
||||
});
|
||||
|
||||
// Unified-Diff einer Fassung (was dieser Commit an der Datei geändert hat) —
|
||||
// fürs rot/grün-Diff auf der Seite. Roh-Diff; das Frontend färbt +/- ein.
|
||||
history.get('/diff', async (c) => {
|
||||
let rel;
|
||||
try { rel = safeRel(c.req.query('path')); } catch { return c.json({ error: 'Ungültiger Pfad' }, 400); }
|
||||
const rev = c.req.query('rev') || '';
|
||||
if (!/^[0-9a-f]{7,40}$/i.test(rev)) return c.json({ error: 'Ungültige Version' }, 400);
|
||||
try {
|
||||
const { stdout } = await git('show', '--format=', '--no-color', rev, '--', `content/${rel}`);
|
||||
return c.json({ rev, diff: stdout });
|
||||
} catch { return c.json({ error: 'Diff nicht verfügbar' }, 404); }
|
||||
});
|
||||
|
||||
// Markdown → HTML. marked kennt Goldmarks Fußnoten ([^id]) nicht — daher
|
||||
// vorab: Definitionen einsammeln, Verweise zu <sup>-Nummern, „Quellen" anhängen
|
||||
// (greift dieselbe .footnotes-CSS wie die Live-Seite).
|
||||
function renderMarkdown(md) {
|
||||
const defs = {}; const order = [];
|
||||
md = md.replace(/^\[\^([^\]]+)\]:[ \t]*(.*)$/gm, (_, id, txt) => { defs[id] = txt; return ''; });
|
||||
md = md.replace(/\[\^([^\]]+)\]/g, (_, id) => {
|
||||
if (!order.includes(id)) order.push(id);
|
||||
return `<sup class="footnote-ref">${order.indexOf(id) + 1}</sup>`;
|
||||
});
|
||||
let html = marked.parse(md);
|
||||
if (order.length) {
|
||||
html += '<div class="footnotes"><ol>'
|
||||
+ order.map((id) => `<li>${marked.parseInline(defs[id] || '')}</li>`).join('')
|
||||
+ '</ol></div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
export default history;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Hono } from 'hono';
|
||||
import { urlFor, safeRel } from '../files.js';
|
||||
import { buildSite } from '../hugo.js';
|
||||
|
||||
// Echte Hugo-Vorschau: ganze Site mit --buildDrafts nach preview/ bauen und die
|
||||
// URL des Eintrags zurückgeben (so erscheinen auch draft:true-Einträge).
|
||||
const preview = new Hono();
|
||||
|
||||
preview.post('/', async (c) => {
|
||||
const { path: rel } = await c.req.json();
|
||||
try {
|
||||
const safe = safeRel(rel);
|
||||
const build = await buildSite({ dest: 'preview', drafts: true });
|
||||
return c.json({ ok: true, url: `/_preview${urlFor(safe)}`, hugo: build.stdout });
|
||||
} catch (e) {
|
||||
return c.json({ error: String(e.message || e) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default preview;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Hono } from 'hono';
|
||||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import matter from 'gray-matter';
|
||||
import { buildSite } from '../hugo.js';
|
||||
|
||||
// Profile als Hugo-Data-Datei (data/authors.json) + öffentliche Autor-Seite
|
||||
// (content/authors/<slug>.md), gerendert von layouts/authors/single.html.
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const FILE = path.join(SITE_DIR, 'data', 'authors.json');
|
||||
const AUTHORS_DIR = path.join(SITE_DIR, 'content', 'authors');
|
||||
|
||||
async function readAll() {
|
||||
try { return JSON.parse(await readFile(FILE, 'utf8')); } catch { return {}; }
|
||||
}
|
||||
// Muss zu Hugos `urlize` passen (Byline-Link).
|
||||
function slugify(s) {
|
||||
return String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
async function exists(p) { try { await stat(p); return true; } catch { return false; } }
|
||||
|
||||
const profile = new Hono();
|
||||
|
||||
profile.get('/', async (c) => {
|
||||
const email = c.get('user')?.email || 'default';
|
||||
const all = await readAll();
|
||||
return c.json({ email, name: '', bio: '', avatar: '', ...(all[email] || {}) });
|
||||
});
|
||||
|
||||
profile.put('/', async (c) => {
|
||||
const email = c.get('user')?.email || 'default';
|
||||
const { name, bio, avatar } = await c.req.json();
|
||||
const slug = slugify(name);
|
||||
|
||||
const all = await readAll();
|
||||
all[email] = { name: name || '', bio: bio || '', avatar: avatar || '', slug };
|
||||
await mkdir(path.dirname(FILE), { recursive: true });
|
||||
await writeFile(FILE, JSON.stringify(all, null, 2) + '\n', 'utf8');
|
||||
|
||||
// Öffentliche Autor-Seite schreiben (nur mit Name).
|
||||
if (slug) {
|
||||
await mkdir(AUTHORS_DIR, { recursive: true });
|
||||
const idx = path.join(AUTHORS_DIR, '_index.md');
|
||||
if (!(await exists(idx))) {
|
||||
await writeFile(idx, matter.stringify('', { title: 'Autor:innen' }), 'utf8');
|
||||
}
|
||||
const page = matter.stringify(bio || '', { title: name, avatar: avatar || '' });
|
||||
await writeFile(path.join(AUTHORS_DIR, `${slug}.md`), page, 'utf8');
|
||||
// Live bauen (koalesziert), damit die Seite + Byline-Links sofort wirken.
|
||||
await buildSite({ dest: 'public', drafts: false }).catch((e) => console.error('[profile] build:', e?.message || e));
|
||||
}
|
||||
|
||||
return c.json({ ok: true, slug });
|
||||
});
|
||||
|
||||
export default profile;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Hono } from 'hono';
|
||||
import { urlFor, safeRel } from '../files.js';
|
||||
import { buildSite, gitCommit } from '../hugo.js';
|
||||
import { manager } from '../plugins.js';
|
||||
|
||||
// Publizieren: public/ neu bauen (ohne Drafts) → live. Optional git-commit.
|
||||
const publish = new Hono();
|
||||
|
||||
publish.post('/', async (c) => {
|
||||
const { path: rel } = await c.req.json();
|
||||
try {
|
||||
const safe = safeRel(rel);
|
||||
const build = await buildSite({ dest: 'public', drafts: false });
|
||||
// Plugin-Publish-Hooks anstoßen (dialog spiegelt neue Library-Beiträge sofort
|
||||
// als Threads). Fehler dürfen das Publish nicht kippen — Hooks fangen selbst ab.
|
||||
await manager.runPublish({}, { path: safe });
|
||||
const git = await gitCommit(`cms: publish ${safe}`).catch((e) => ({ error: String(e.message || e) }));
|
||||
return c.json({ ok: true, url: urlFor(safe), git, hugo: build.stdout });
|
||||
} catch (e) {
|
||||
return c.json({ error: String(e.message || e) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default publish;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { listEntries } from '../files.js';
|
||||
import { requireAdmin, roleOf } from '../auth.js';
|
||||
import { config } from '../config.js';
|
||||
import { contentStats } from '../collections.js';
|
||||
import { manager } from '../plugins.js';
|
||||
|
||||
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
|
||||
const stats = new Hono();
|
||||
stats.use('*', requireAdmin);
|
||||
|
||||
stats.get('/', async (c) => {
|
||||
// Inhalte aus dem Dateisystem zählen — Zähler pro collection.statKey.
|
||||
let content = {};
|
||||
try { content = contentStats(await listEntries(), config.collections); }
|
||||
catch { /* Filesystem nicht lesbar → leer */ }
|
||||
|
||||
// Nutzer nach Rolle (GoTrue).
|
||||
const users = { total: 0, admin: 0, editor: 0, user: 0 };
|
||||
try {
|
||||
const { data } = await supabase.auth.admin.listUsers();
|
||||
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
||||
} catch { /* GoTrue nicht erreichbar */ }
|
||||
|
||||
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
|
||||
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
|
||||
const pluginStats = await manager.collectStats({});
|
||||
// Dialog bleibt als eigenes Feld im Vertrag (Admin erwartet { forums, threads,
|
||||
// comments }); fehlt das Plugin, sind es Nullen.
|
||||
const dialog = pluginStats.dialog || { forums: 0, threads: 0, comments: 0 };
|
||||
|
||||
return c.json({ content, users, dialog });
|
||||
});
|
||||
|
||||
export default stats;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Hono } from 'hono';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
// Bild-Upload → static/images/. Raster-Bilder werden zu WebP konvertiert
|
||||
// (kleiner, web-optimiert), auf max. 2000px begrenzt, EXIF-Rotation korrigiert.
|
||||
// SVG/GIF bleiben unangetastet (Vektor/Animation erhalten).
|
||||
//
|
||||
// Sicherheit: hartes Größenlimit (DoS / Decompression-Bombs), Raster wird über
|
||||
// sharp-Metadaten als echtes Bild verifiziert, SVG nur wenn es wie SVG aussieht.
|
||||
// Hochgeladene Dateien werden zudem mit strikter CSP (sandbox) ausgeliefert
|
||||
// (siehe index.js, /images/*) → ein bösartiges SVG kann kein JS im Origin starten.
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const MAX_UPLOAD = 8 * 1024 * 1024; // 8 MB Rohdatei
|
||||
const ALLOWED_RASTER = new Set(['jpeg', 'png', 'webp', 'avif', 'tiff']);
|
||||
|
||||
const upload = new Hono();
|
||||
|
||||
upload.post('/', async (c) => {
|
||||
const body = await c.req.parseBody();
|
||||
const file = body['file'];
|
||||
if (!file || typeof file === 'string') return c.json({ error: 'Keine Datei' }, 400);
|
||||
if (typeof file.size === 'number' && file.size > MAX_UPLOAD) {
|
||||
return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
if (buffer.length > MAX_UPLOAD) return c.json({ error: 'Datei zu groß (max. 8 MB)' }, 413);
|
||||
if (!buffer.length) return c.json({ error: 'Leere Datei' }, 400);
|
||||
|
||||
const dir = path.join(SITE_DIR, 'static', 'images');
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const ext = path.extname(file.name || '').toLowerCase();
|
||||
const base = `${safeBase(file.name)}-${uid()}`;
|
||||
|
||||
let outName, outBuf;
|
||||
if (ext === '.svg') {
|
||||
// Muss wie SVG aussehen (Magie/Marker), sonst ablehnen.
|
||||
const head = buffer.subarray(0, 512).toString('utf8').trimStart().toLowerCase();
|
||||
if (!head.startsWith('<?xml') && !head.startsWith('<svg')) {
|
||||
return c.json({ error: 'Keine gültige SVG-Datei' }, 400);
|
||||
}
|
||||
outName = `${base}.svg`;
|
||||
outBuf = buffer;
|
||||
} else if (ext === '.gif') {
|
||||
// GIF-Magie prüfen (kann kein Skript ausführen → Passthrough ok).
|
||||
const sig = buffer.subarray(0, 6).toString('latin1');
|
||||
if (sig !== 'GIF87a' && sig !== 'GIF89a') return c.json({ error: 'Keine gültige GIF-Datei' }, 400);
|
||||
outName = `${base}.gif`;
|
||||
outBuf = buffer;
|
||||
} else {
|
||||
// Raster: über sharp als echtes Bild verifizieren, dann zu WebP.
|
||||
let meta;
|
||||
try { meta = await sharp(buffer).metadata(); } catch { meta = null; }
|
||||
if (!meta || !ALLOWED_RASTER.has(meta.format)) {
|
||||
return c.json({ error: 'Kein unterstütztes Bildformat' }, 400);
|
||||
}
|
||||
outName = `${base}.webp`;
|
||||
outBuf = await sharp(buffer)
|
||||
.rotate()
|
||||
.resize({ width: 2000, withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
await writeFile(path.join(dir, outName), outBuf);
|
||||
return c.json({ url: `/images/${outName}` });
|
||||
});
|
||||
|
||||
// Sicherer Basisname ohne Endung.
|
||||
function safeBase(raw) {
|
||||
const base = path.basename(String(raw || 'bild')).replace(/\.[^.]+$/, '');
|
||||
const cleaned = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return cleaned || 'bild';
|
||||
}
|
||||
// Kurze eindeutige Endung, damit gleichnamige Uploads nicht kollidieren.
|
||||
function uid() {
|
||||
return Date.now().toString(36).slice(-4) + Math.random().toString(36).slice(2, 5);
|
||||
}
|
||||
|
||||
export default upload;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { requireAdmin, roleOf } from '../auth.js';
|
||||
|
||||
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
const users = new Hono();
|
||||
users.use('*', requireAdmin);
|
||||
|
||||
users.get('/', async (c) => {
|
||||
const { data, error } = await supabase.auth.admin.listUsers();
|
||||
if (error) return c.json({ error: error.message }, 500);
|
||||
const list = (data?.users || []).map((u) => {
|
||||
const role = roleOf(u);
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
created_at: u.created_at,
|
||||
last_sign_in_at: u.last_sign_in_at || null,
|
||||
role,
|
||||
isAdmin: role === 'admin',
|
||||
// Admins aus der .env lassen sich nicht per UI herabstufen.
|
||||
fixedAdmin: ADMINS.includes((u.email || '').toLowerCase()),
|
||||
};
|
||||
});
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
users.post('/', async (c) => {
|
||||
const { email, password, role } = await c.req.json();
|
||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||
const payload = { email, password, email_confirm: true };
|
||||
if (role && role !== 'user') payload.app_metadata = { role };
|
||||
const { data, error } = await supabase.auth.admin.createUser(payload);
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true, id: data.user.id });
|
||||
});
|
||||
|
||||
users.put('/:id', async (c) => {
|
||||
const { password, role } = await c.req.json();
|
||||
const patch = {};
|
||||
if (password) patch.password = password;
|
||||
if (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);
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
users.delete('/:id', async (c) => {
|
||||
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default users;
|
||||
@@ -0,0 +1,21 @@
|
||||
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,6 @@
|
||||
// Serverfehler protokollieren, aber dem Client nur eine generische Meldung
|
||||
// geben — keine DB-/Stack-Interna nach außen (Info-Leak vermeiden).
|
||||
export function serverError(c, where, err, status = 500) {
|
||||
console.error(`[${where}]`, err?.message || err);
|
||||
return c.json({ error: 'Serverfehler' }, status);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Env vor dem Import setzen: supabase.js bricht ohne URL/Key ab, ADMIN_EMAILS
|
||||
// und JWT_SECRET werden beim Modul-Load gelesen.
|
||||
process.env.SUPABASE_URL ||= 'http://localhost';
|
||||
process.env.SUPABASE_SERVICE_KEY ||= 'dummy';
|
||||
process.env.JWT_SECRET = 'test-secret';
|
||||
process.env.ADMIN_EMAILS = 'boss@x.ch';
|
||||
|
||||
const { roleOf, requireAuth } = await import('../src/auth.js');
|
||||
const { sign } = await import('hono/jwt');
|
||||
|
||||
test('roleOf: Admin aus ADMIN_EMAILS', () => {
|
||||
assert.equal(roleOf({ email: 'boss@x.ch' }), 'admin');
|
||||
assert.equal(roleOf({ email: 'BOSS@X.CH' }), 'admin');
|
||||
});
|
||||
|
||||
test('roleOf: Rolle aus app_metadata', () => {
|
||||
assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'admin' } }), 'admin');
|
||||
assert.equal(roleOf({ email: 'a@x.ch', app_metadata: { role: 'editor' } }), 'editor');
|
||||
assert.equal(roleOf({ email: 'a@x.ch' }), 'user');
|
||||
});
|
||||
|
||||
// Minimaler Hono-Kontext-Stub.
|
||||
function fakeCtx(authHeader) {
|
||||
const store = {};
|
||||
return {
|
||||
req: { header: (h) => (h === 'Authorization' ? authHeader : undefined) },
|
||||
set: (k, v) => { store[k] = v; },
|
||||
get: (k) => store[k],
|
||||
json: (body, status = 200) => ({ __status: status, body }),
|
||||
};
|
||||
}
|
||||
|
||||
test('requireAuth: gültiges Token wird lokal verifiziert', async () => {
|
||||
const token = await sign(
|
||||
{ sub: 'u1', email: 'A@x.ch', app_metadata: { role: 'editor' }, exp: Math.floor(Date.now() / 1000) + 60 },
|
||||
'test-secret', 'HS256');
|
||||
let passed = false;
|
||||
const c = fakeCtx('Bearer ' + token);
|
||||
await requireAuth(c, async () => { passed = true; });
|
||||
assert.equal(passed, true);
|
||||
assert.equal(c.get('email'), 'a@x.ch'); // kleingeschrieben
|
||||
assert.equal(c.get('role'), 'editor');
|
||||
assert.equal(c.get('canModerate'), true);
|
||||
assert.equal(c.get('isAdmin'), false);
|
||||
});
|
||||
|
||||
test('requireAuth: fehlendes Token → 401', async () => {
|
||||
const c = fakeCtx('');
|
||||
const r = await requireAuth(c, async () => { throw new Error('darf nicht laufen'); });
|
||||
assert.equal(r.__status, 401);
|
||||
});
|
||||
|
||||
test('requireAuth: kaputtes/falsch signiertes Token → 401', async () => {
|
||||
const bad = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) + 60 }, 'falsches-secret', 'HS256');
|
||||
for (const t of ['Bearer garbage', 'Bearer ' + bad]) {
|
||||
const r = await requireAuth(fakeCtx(t), async () => { throw new Error('darf nicht laufen'); });
|
||||
assert.equal(r.__status, 401);
|
||||
}
|
||||
});
|
||||
|
||||
test('requireAuth: abgelaufenes Token → 401', async () => {
|
||||
const expired = await sign({ sub: 'u1', exp: Math.floor(Date.now() / 1000) - 10 }, 'test-secret', 'HS256');
|
||||
const r = await requireAuth(fakeCtx('Bearer ' + expired), async () => { throw new Error('darf nicht laufen'); });
|
||||
assert.equal(r.__status, 401);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { coalesce } = await import('../src/coalesce.js');
|
||||
|
||||
const tick = (ms = 5) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
test('coalesce: nie mehr als ein Lauf gleichzeitig pro Key', async () => {
|
||||
let active = 0, maxActive = 0, runs = 0;
|
||||
const fn = async () => { active++; maxActive = Math.max(maxActive, active); await tick(10); runs++; active--; return runs; };
|
||||
|
||||
// 5 gleichzeitige Aufrufe.
|
||||
await Promise.all(Array.from({ length: 5 }, () => coalesce('k1', fn)));
|
||||
assert.equal(maxActive, 1, 'parallele Läufe');
|
||||
// Erster Lauf bedient den ersten Aufruf; die 4 während des Laufs eingetroffenen
|
||||
// teilen sich GENAU EINEN nachgelagerten Lauf → insgesamt 2.
|
||||
assert.equal(runs, 2);
|
||||
});
|
||||
|
||||
test('coalesce: Wartende teilen sich das Ergebnis des nachgelagerten Laufs', async () => {
|
||||
let n = 0;
|
||||
const fn = async () => { await tick(10); return ++n; };
|
||||
const first = coalesce('k2', fn); // startet sofort → Ergebnis 1
|
||||
await tick(2); // sicherstellen, dass er läuft
|
||||
const a = coalesce('k2', fn); // wartet → nachgelagerter Lauf
|
||||
const b = coalesce('k2', fn); // wartet → selber Lauf wie a
|
||||
assert.equal(await first, 1);
|
||||
const [ra, rb] = await Promise.all([a, b]);
|
||||
assert.equal(ra, 2);
|
||||
assert.equal(rb, 2); // a und b teilen sich Lauf 2
|
||||
});
|
||||
|
||||
test('coalesce: Fehler wird an die Wartenden propagiert, Key bleibt nutzbar', async () => {
|
||||
let fail = true;
|
||||
const fn = async () => { await tick(5); if (fail) throw new Error('boom'); return 'ok'; };
|
||||
await assert.rejects(() => coalesce('k3', fn), /boom/);
|
||||
fail = false;
|
||||
assert.equal(await coalesce('k3', fn), 'ok'); // danach wieder verwendbar
|
||||
});
|
||||
|
||||
test('coalesce: verschiedene Keys laufen unabhängig', async () => {
|
||||
const fn = async () => { await tick(5); return 'done'; };
|
||||
const [x, y] = await Promise.all([coalesce('kA', fn), coalesce('kB', fn)]);
|
||||
assert.equal(x, 'done');
|
||||
assert.equal(y, 'done');
|
||||
});
|
||||
@@ -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,41 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { safeRel, normAuthors, hasAccess, urlFor } = await import('../src/files.js');
|
||||
|
||||
test('safeRel: gültiger relativer .md-Pfad bleibt erhalten', () => {
|
||||
assert.equal(safeRel('library/software/stack.md'), 'library/software/stack.md');
|
||||
assert.equal(safeRel('a/./b.md'), 'a/b.md');
|
||||
});
|
||||
|
||||
test('safeRel: Path-Traversal wird abgelehnt', () => {
|
||||
assert.throws(() => safeRel('../etc/passwd.md'));
|
||||
assert.throws(() => safeRel('a/../../b.md'));
|
||||
assert.throws(() => safeRel('/absolut.md'));
|
||||
});
|
||||
|
||||
test('safeRel: nur .md erlaubt, leer/falsch wirft', () => {
|
||||
assert.throws(() => safeRel('note.txt'));
|
||||
assert.throws(() => safeRel(''));
|
||||
assert.throws(() => safeRel(null));
|
||||
});
|
||||
|
||||
test('normAuthors: String/Array/Leer normalisieren', () => {
|
||||
assert.deepEqual(normAuthors('a@x.ch'), ['a@x.ch']);
|
||||
assert.deepEqual(normAuthors(['a@x.ch', 'b@y.ch']), ['a@x.ch', 'b@y.ch']);
|
||||
assert.deepEqual(normAuthors(null), []);
|
||||
assert.deepEqual(normAuthors([]), []);
|
||||
});
|
||||
|
||||
test('hasAccess: case-insensitive Mitgliedschaft', () => {
|
||||
assert.equal(hasAccess(['Karim@x.ch'], 'karim@x.ch'), true);
|
||||
assert.equal(hasAccess(['a@x.ch'], 'b@y.ch'), false);
|
||||
assert.equal(hasAccess([], 'a@x.ch'), false);
|
||||
});
|
||||
|
||||
test('urlFor: Hugo-URLs aus relativem Pfad', () => {
|
||||
assert.equal(urlFor('_index.md'), '/');
|
||||
assert.equal(urlFor('manifest.md'), '/manifest/');
|
||||
assert.equal(urlFor('library/software/stack.md'), '/library/software/stack/');
|
||||
assert.equal(urlFor('software/_index.md'), '/software/');
|
||||
});
|
||||
@@ -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,44 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { rateLimit } = await import('../src/ratelimit.js');
|
||||
|
||||
function fakeCtx() {
|
||||
const headers = {};
|
||||
return {
|
||||
req: { header: () => undefined },
|
||||
header: (k, v) => { headers[k] = v; },
|
||||
json: (body, status = 200) => ({ __status: status, body }),
|
||||
_headers: headers,
|
||||
};
|
||||
}
|
||||
|
||||
test('rateLimit: blockt nach Überschreiten mit 429 + Retry-After', async () => {
|
||||
const mw = rateLimit({ max: 2, windowMs: 10_000, keyFn: () => 'fix' });
|
||||
let calls = 0;
|
||||
const run = async () => { const c = fakeCtx(); const r = await mw(c, async () => { calls++; }); return { c, r }; };
|
||||
|
||||
assert.equal((await run()).r, undefined); // 1 → durch (next, kein Return)
|
||||
assert.equal((await run()).r, undefined); // 2 → durch
|
||||
const { c, r } = await run(); // 3 → blockiert
|
||||
assert.equal(r.__status, 429);
|
||||
assert.ok(c._headers['Retry-After']);
|
||||
assert.equal(calls, 2); // next nur zweimal aufgerufen
|
||||
});
|
||||
|
||||
test('rateLimit: getrennte Schlüssel zählen getrennt', async () => {
|
||||
let key = 'a';
|
||||
const mw = rateLimit({ max: 1, windowMs: 10_000, keyFn: () => key });
|
||||
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // a:1 ok
|
||||
assert.equal((await mw(fakeCtx(), async () => {})).__status, 429); // a:2 blockiert
|
||||
key = 'b';
|
||||
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // b:1 ok
|
||||
});
|
||||
|
||||
test('rateLimit: Fenster läuft ab → wieder frei', async () => {
|
||||
const mw = rateLimit({ max: 1, windowMs: 30, keyFn: () => 'win' });
|
||||
assert.equal((await mw(fakeCtx(), async () => {})), undefined);
|
||||
assert.equal((await mw(fakeCtx(), async () => {})).__status, 429);
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
assert.equal((await mw(fakeCtx(), async () => {})), undefined); // Fenster neu
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// karimgabrielevarano.xyz — second consumer of openbureau-core.
|
||||
// No dialog plugin; a portfolio-shaped content model.
|
||||
export default {
|
||||
site: 'kgva',
|
||||
admins: ['karim@gabrielevarano.ch'],
|
||||
plugins: [],
|
||||
|
||||
collections: [
|
||||
{
|
||||
kind: 'project', label: 'Portfolio', order: 0,
|
||||
path: 'portfolio/:slug',
|
||||
statKey: 'projects',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'thumbnail', type: 'image' },
|
||||
{ name: 'studies', type: 'string', hint: '/studies/hslu/ba/semester_05' },
|
||||
{ name: 'schools', type: 'list' },
|
||||
{ name: 'degrees', type: 'list' },
|
||||
{ name: 'semesters', type: 'list' },
|
||||
{ name: 'video', type: 'string', hint: '/media/x.mp4' },
|
||||
{ name: 'images', type: 'list', of: { src: 'image', name: 'string' } },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'page', label: 'Pages', order: 1, fallback: true,
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'description', type: 'string' },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'section', label: 'Sections', order: 2, index: true,
|
||||
fields: [
|
||||
{ name: 'title', type: 'string' },
|
||||
{ name: 'description', type: 'string' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
// openbureau site — reproduces the current hard-coded content model 1:1.
|
||||
// Once the engine is config-driven, openbureau consumes openbureau-core with
|
||||
// exactly this config and behaves as today.
|
||||
export default {
|
||||
site: 'openbureau',
|
||||
admins: ['karim@gabrielevarano.ch'],
|
||||
plugins: ['dialog'], // comment/forum subsystem (library ↔ threads sync)
|
||||
|
||||
collections: [
|
||||
{
|
||||
kind: 'beitrag', label: 'Beiträge', order: 0,
|
||||
path: 'archiv/:section/:slug',
|
||||
sections: ['buerofuehrung', 'software', 'theorie'],
|
||||
statKey: 'beitraege',
|
||||
draftStatKey: 'entwuerfe', // Beitrag-Entwürfe als eigener Zähler (Dashboard)
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'section', type: 'select', options: ['buerofuehrung', 'software', 'theorie'] },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'color', type: 'string' },
|
||||
{ name: 'layout', type: 'select', options: ['text'], default: 'text' },
|
||||
{ name: 'tags', type: 'list' },
|
||||
{ name: 'summary', type: 'text' },
|
||||
{ name: 'cover_image', type: 'image' },
|
||||
{ name: 'authors', type: 'list' },
|
||||
{ name: 'toc', type: 'bool' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'biblio', label: 'Library', order: 1,
|
||||
path: 'library/:slug',
|
||||
statKey: 'library',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'slug', type: 'slug' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'tags', type: 'list' },
|
||||
{ name: 'summary', type: 'text' },
|
||||
{ name: 'cover_image', type: 'image' },
|
||||
{ name: 'external', type: 'string' },
|
||||
{ name: 'group', type: 'string' },
|
||||
{ name: 'authors', type: 'list' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'rubrik', label: 'Rubriken', order: 3, index: true,
|
||||
statKey: 'rubriken',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'color', type: 'string' },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'seite', label: 'Seiten', order: 2, fallback: true,
|
||||
statKey: 'seiten',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string', required: true },
|
||||
{ name: 'layout', type: 'string' },
|
||||
{ name: 'toc', type: 'bool' },
|
||||
{ name: 'draft', type: 'bool', default: true },
|
||||
{ name: 'body', type: 'markdown' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user