From bd4b470877937ba8f7e5479db37a37d390f37378 Mon Sep 17 00:00:00 2001 From: karim Date: Sun, 31 May 2026 12:22:06 +0200 Subject: [PATCH] cms: Rollen + Kollaboration (Admin sieht alles, Autoren nur eigene/geteilte) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADMIN_EMAILS (.env) = Admins, sehen/bearbeiten alles - Autor:innen sehen nur Einträge mit ihrer Mail unter `authors:`; Ersteller wird beim Anlegen automatisch Autor - Kollaboration: Feld „Autor:innen" im Editor → mehrere Mails = gemeinsamer Zugriff - API erzwingt Zugriff bei list/read/save (403 ohne Recht) - ADMIN_EMAILS in compose + LXC-Script (fragt Admin-Mail ab) Co-Authored-By: Claude Opus 4.8 --- cms/.env.example | 5 ++++ cms/README.md | 12 ++++++++ cms/admin/src/App.jsx | 8 ++++- cms/api/src/auth.js | 10 +++++-- cms/api/src/files.js | 14 +++++++++ cms/api/src/routes/content.js | 45 ++++++++++++++++++++-------- cms/docker-compose.yml | 1 + cms/proxmox/create-openbureau-lxc.sh | 7 ++++- 8 files changed, 86 insertions(+), 16 deletions(-) diff --git a/cms/.env.example b/cms/.env.example index 87fcd83..30f6505 100644 --- a/cms/.env.example +++ b/cms/.env.example @@ -15,6 +15,11 @@ SITE_URL=http://localhost:8080 # Öffentliche Supabase-Adresse (Browser/Admin erreichen Kong hierüber). API_EXTERNAL_URL=http://localhost:8000 +# ═══ Rechte: wer ist Admin (sieht/bearbeitet ALLE Beiträge)? ═══ +# Komma-getrennte E-Mails. Alle anderen sehen nur Einträge, in denen ihre Mail +# unter `authors` steht. +ADMIN_EMAILS=karim@gabrielevarano.ch + # ═══ Optional: Ports ═══ APP_PORT=8080 # CMS: Site + /admin + /_preview + /api KONG_HTTP_PORT=8000 # Supabase-API-Gateway diff --git a/cms/README.md b/cms/README.md index e5c91c0..a2a0caa 100644 --- a/cms/README.md +++ b/cms/README.md @@ -39,6 +39,18 @@ Supabase wird **nur noch für den Login** (GoTrue) gebraucht — keine Posts in DB. Drafts liegen als `draft: true` in der Datei; der Live-Build lässt sie aus, der Preview-Build (`--buildDrafts`) zeigt sie. +## Rechte & Kollaboration + +- **Admin** (E-Mails in `ADMIN_EMAILS`) sieht und bearbeitet **alle** Einträge. +- **Autor:innen** sehen nur Einträge, in denen ihre Mail unter `authors:` steht. + Beim Anlegen wird der Ersteller automatisch eingetragen. +- **Kollaboration**: im Editor weitere E-Mails ins Feld „Autor:innen" → beide + haben Zugriff auf denselben Beitrag. +- Bestehende Beiträge/Seiten/Rubriken **ohne** `authors:` sind nur für Admins + sichtbar; ein Admin kann Autor:innen zuweisen, um sie freizugeben. +- Hinweis: `authors:` landet im Frontmatter (öffentliches Repo) — also E-Mails, + die du dort einträgst, sind im Repo sichtbar. + ## Setup ### Schnellweg: Proxmox-LXC diff --git a/cms/admin/src/App.jsx b/cms/admin/src/App.jsx index 60d8dd4..62e1922 100644 --- a/cms/admin/src/App.jsx +++ b/cms/admin/src/App.jsx @@ -28,7 +28,7 @@ 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: '', toc: false, draft: true, body: '', + cover_image: '', external: '', authors: '', toc: false, draft: true, body: '', }; export default function App() { @@ -264,6 +264,9 @@ function Editor({ initial, onSaved, onMsg }) { +
setF((p) => ({ ...p, body }))} @@ -376,6 +379,7 @@ function fromRead(r) { 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 || ''), toc: !!fm.toc, draft: !!fm.draft, body: r.body || '', }; } @@ -391,6 +395,8 @@ function buildFrontmatter(f) { 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.toc) fm.toc = true; if (f.draft) fm.draft = true; return fm; diff --git a/cms/api/src/auth.js b/cms/api/src/auth.js index 7633e45..909dbb2 100644 --- a/cms/api/src/auth.js +++ b/cms/api/src/auth.js @@ -1,7 +1,10 @@ import { supabase } from './supabase.js'; -// Verifiziert den Supabase-Access-Token aus dem Authorization-Header gegen den -// Supabase-Auth-Server. Schützt alle /api/* ausser /api/health. +// Admins aus der .env (ADMIN_EMAILS=a@x,b@y). Admins sehen/bearbeiten alles. +const ADMINS = (process.env.ADMIN_EMAILS || '') + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); + +// Verifiziert den Supabase-Access-Token und legt user/email/isAdmin im Kontext ab. export async function requireAuth(c, next) { const header = c.req.header('Authorization') || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; @@ -10,6 +13,9 @@ export async function requireAuth(c, next) { const { data, error } = await supabase.auth.getUser(token); if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401); + const email = (data.user.email || '').toLowerCase(); c.set('user', data.user); + c.set('email', email); + c.set('isAdmin', ADMINS.includes(email)); await next(); } diff --git a/cms/api/src/files.js b/cms/api/src/files.js index 1b24d48..4635fff 100644 --- a/cms/api/src/files.js +++ b/cms/api/src/files.js @@ -40,6 +40,19 @@ function classify(rel) { return { kind: 'seite', section: null }; } +// authors-Frontmatter zu Array normalisieren (String oder Array erlaubt). +export function normAuthors(a) { + if (Array.isArray(a)) return a.map(String).filter(Boolean); + 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$/, ''); @@ -63,6 +76,7 @@ export async function listEntries() { layout: data.layout || null, draft: !!data.draft, date: data.date ? String(data.date).slice(0, 10) : null, + authors: normAuthors(data.authors), url: urlFor(rel), }); } diff --git a/cms/api/src/routes/content.js b/cms/api/src/routes/content.js index 75b9b9c..9337604 100644 --- a/cms/api/src/routes/content.js +++ b/cms/api/src/routes/content.js @@ -1,28 +1,49 @@ import { Hono } from 'hono'; -import { listEntries, readEntry, writeEntry, entryExists } from '../files.js'; +import { listEntries, readEntry, writeEntry, entryExists, hasAccess, normAuthors } from '../files.js'; -// Dateibasiert: liest/schreibt die echten .md unter content/. +// Dateibasiert + Rechte: Admin sieht/bearbeitet alles, Autor:innen nur Einträge, +// in denen ihre Mail unter `authors` steht. const content = new Hono(); -// Liste aller Einträge (Beiträge, Seiten, Rubriken). content.get('/', async (c) => { - try { return c.json(await listEntries()); } - catch (e) { return c.json({ error: String(e.message || e) }, 500); } + 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); } }); -// Einen Eintrag lesen: /api/content/entry?path=library/software/stack.md content.get('/entry', async (c) => { - try { return c.json(await readEntry(c.req.query('path'))); } - catch (e) { return c.json({ error: String(e.message || e) }, 400); } + 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); } }); -// Anlegen oder überschreiben. 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 created = !(await entryExists(rel)); - const saved = await writeEntry(rel, frontmatter, body); - return c.json({ ok: true, path: saved, created }); + 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); } }); diff --git a/cms/docker-compose.yml b/cms/docker-compose.yml index acb178e..a4786a6 100644 --- a/cms/docker-compose.yml +++ b/cms/docker-compose.yml @@ -130,6 +130,7 @@ services: # Server-seitig: intern über Kong, mit Service-Key. SUPABASE_URL: http://kong:8000 SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + ADMIN_EMAILS: ${ADMIN_EMAILS:-} SITE_DIR: /site PORT: 3000 GIT_PUBLISH: ${GIT_PUBLISH:-false} diff --git a/cms/proxmox/create-openbureau-lxc.sh b/cms/proxmox/create-openbureau-lxc.sh index 381d9db..1749c7e 100755 --- a/cms/proxmox/create-openbureau-lxc.sh +++ b/cms/proxmox/create-openbureau-lxc.sh @@ -42,6 +42,9 @@ GIT_TOKEN="${GIT_TOKEN:-}" REPO_HOST="git.kgva.ch/karim/OPENBUREAU.git" APP_DIR="/opt/openbureau" +# Admin (sieht/bearbeitet ALLE Beiträge). Wird auch als erster Login-User vorgeschlagen. +ADMIN_EMAIL="${ADMIN_EMAIL:-karim@gabrielevarano.ch}" + # Stack nach dem Setup direkt bauen + starten? COMPOSE_UP="true" ################################################################## @@ -57,6 +60,7 @@ if [ -t 0 ]; then read -rp " Netzwerk-Bridge [${BRIDGE}]: " _x; BRIDGE="${_x:-$BRIDGE}" read -rp " IP (dhcp | x.x.x.x/24) [${IP}]: " _x; IP="${_x:-$IP}" [ "$IP" != "dhcp" ] && { read -rp " Gateway: " GATEWAY; } + read -rp " Admin-E-Mail [${ADMIN_EMAIL}]: " _x; ADMIN_EMAIL="${_x:-$ADMIN_EMAIL}" fi # --- 1. Template sicherstellen ------------------------------------------- @@ -142,6 +146,7 @@ pct exec "$CTID" -- bash -euo pipefail -c " HOSTIP=\$(hostname -I | awk '{print \$1}') sed -i \"s|^SITE_URL=.*|SITE_URL=http://\${HOSTIP}:8080|\" .env sed -i \"s|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://\${HOSTIP}:8000|\" .env + sed -i \"s|^ADMIN_EMAILS=.*|ADMIN_EMAILS=${ADMIN_EMAIL}|\" .env echo 'OK: .env generiert.' fi @@ -169,7 +174,7 @@ Login-User anlegen (im Container, nach dem Start): -H "apikey: \$SERVICE_ROLE_KEY" \\ -H "Authorization: Bearer \$SERVICE_ROLE_KEY" \\ -H "Content-Type: application/json" \\ - -d '{"email":"karim@gabrielevarano.ch","password":"DEIN-PASSWORT","email_confirm":true}' + -d '{"email":"${ADMIN_EMAIL}","password":"DEIN-PASSWORT","email_confirm":true}' Hinweise: • :8000 ist das Supabase-API-Gateway (Kong), keine Web-Oberfläche.