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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user