cms: Rollen + Kollaboration (Admin sieht alles, Autoren nur eigene/geteilte)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 12:22:06 +02:00
parent 35c2a122ae
commit bd4b470877
8 changed files with 86 additions and 16 deletions
+5
View File
@@ -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
+12
View File
@@ -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
+7 -1
View File
@@ -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 }) {
<label>Cover-Bild<input value={f.cover_image} onChange={set('cover_image')} placeholder="/images/…jpg" /></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 }))}
@@ -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;
+8 -2
View File
@@ -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();
}
+14
View File
@@ -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),
});
}
+33 -12
View File
@@ -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); }
});
+1
View File
@@ -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}
+6 -1
View File
@@ -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.