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).
|
# Öffentliche Supabase-Adresse (Browser/Admin erreichen Kong hierüber).
|
||||||
API_EXTERNAL_URL=http://localhost:8000
|
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 ═══
|
# ═══ Optional: Ports ═══
|
||||||
APP_PORT=8080 # CMS: Site + /admin + /_preview + /api
|
APP_PORT=8080 # CMS: Site + /admin + /_preview + /api
|
||||||
KONG_HTTP_PORT=8000 # Supabase-API-Gateway
|
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,
|
DB. Drafts liegen als `draft: true` in der Datei; der Live-Build lässt sie aus,
|
||||||
der Preview-Build (`--buildDrafts`) zeigt sie.
|
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
|
## Setup
|
||||||
|
|
||||||
### Schnellweg: Proxmox-LXC
|
### Schnellweg: Proxmox-LXC
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const EMPTY = {
|
|||||||
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
|
isNew: true, path: '', type: 'beitrag', section: 'software', slug: '',
|
||||||
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
|
title: '', date: new Date().toISOString().slice(0, 10), weight: '',
|
||||||
color: '', layout: 'text', tags: '', summary: '', description: '',
|
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() {
|
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>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>
|
<label>Externer Link<input value={f.external} onChange={set('external')} placeholder="https://…" /></label>
|
||||||
</div>
|
</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">
|
<div className="rich">
|
||||||
<RichEditor value={f.body} onChange={(body) => setF((p) => ({ ...p, body }))}
|
<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(', ') : '',
|
tags: Array.isArray(fm.tags) ? fm.tags.join(', ') : '',
|
||||||
summary: fm.summary || '', description: fm.description || '',
|
summary: fm.summary || '', description: fm.description || '',
|
||||||
cover_image: fm.cover_image || '', external: fm.external || '',
|
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 || '',
|
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.layout) fm.layout = f.layout;
|
||||||
if (f.external) fm.external = f.external;
|
if (f.external) fm.external = f.external;
|
||||||
if (f.color) fm.color = f.color;
|
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.toc) fm.toc = true;
|
||||||
if (f.draft) fm.draft = true;
|
if (f.draft) fm.draft = true;
|
||||||
return fm;
|
return fm;
|
||||||
|
|||||||
+8
-2
@@ -1,7 +1,10 @@
|
|||||||
import { supabase } from './supabase.js';
|
import { supabase } from './supabase.js';
|
||||||
|
|
||||||
// Verifiziert den Supabase-Access-Token aus dem Authorization-Header gegen den
|
// Admins aus der .env (ADMIN_EMAILS=a@x,b@y). Admins sehen/bearbeiten alles.
|
||||||
// Supabase-Auth-Server. Schützt alle /api/* ausser /api/health.
|
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) {
|
export async function requireAuth(c, next) {
|
||||||
const header = c.req.header('Authorization') || '';
|
const header = c.req.header('Authorization') || '';
|
||||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
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);
|
const { data, error } = await supabase.auth.getUser(token);
|
||||||
if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401);
|
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('user', data.user);
|
||||||
|
c.set('email', email);
|
||||||
|
c.set('isAdmin', ADMINS.includes(email));
|
||||||
await next();
|
await next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ function classify(rel) {
|
|||||||
return { kind: 'seite', section: null };
|
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.
|
// Hugo-URL aus dem relativen Pfad.
|
||||||
export function urlFor(rel) {
|
export function urlFor(rel) {
|
||||||
let p = rel.replace(/\.md$/, '');
|
let p = rel.replace(/\.md$/, '');
|
||||||
@@ -63,6 +76,7 @@ export async function listEntries() {
|
|||||||
layout: data.layout || null,
|
layout: data.layout || null,
|
||||||
draft: !!data.draft,
|
draft: !!data.draft,
|
||||||
date: data.date ? String(data.date).slice(0, 10) : null,
|
date: data.date ? String(data.date).slice(0, 10) : null,
|
||||||
|
authors: normAuthors(data.authors),
|
||||||
url: urlFor(rel),
|
url: urlFor(rel),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,49 @@
|
|||||||
import { Hono } from 'hono';
|
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();
|
const content = new Hono();
|
||||||
|
|
||||||
// Liste aller Einträge (Beiträge, Seiten, Rubriken).
|
|
||||||
content.get('/', async (c) => {
|
content.get('/', async (c) => {
|
||||||
try { return c.json(await listEntries()); }
|
const email = c.get('email'); const isAdmin = c.get('isAdmin');
|
||||||
catch (e) { return c.json({ error: String(e.message || e) }, 500); }
|
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) => {
|
content.get('/entry', async (c) => {
|
||||||
try { return c.json(await readEntry(c.req.query('path'))); }
|
const email = c.get('email'); const isAdmin = c.get('isAdmin');
|
||||||
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
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) => {
|
content.put('/entry', async (c) => {
|
||||||
|
const email = c.get('email'); const isAdmin = c.get('isAdmin');
|
||||||
const { path: rel, frontmatter, body } = await c.req.json();
|
const { path: rel, frontmatter, body } = await c.req.json();
|
||||||
try {
|
try {
|
||||||
const created = !(await entryExists(rel));
|
const exists = await entryExists(rel);
|
||||||
const saved = await writeEntry(rel, frontmatter, body);
|
if (exists && !isAdmin) {
|
||||||
return c.json({ ok: true, path: saved, created });
|
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); }
|
} catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ services:
|
|||||||
# Server-seitig: intern über Kong, mit Service-Key.
|
# Server-seitig: intern über Kong, mit Service-Key.
|
||||||
SUPABASE_URL: http://kong:8000
|
SUPABASE_URL: http://kong:8000
|
||||||
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
SITE_DIR: /site
|
SITE_DIR: /site
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
GIT_PUBLISH: ${GIT_PUBLISH:-false}
|
GIT_PUBLISH: ${GIT_PUBLISH:-false}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ GIT_TOKEN="${GIT_TOKEN:-}"
|
|||||||
REPO_HOST="git.kgva.ch/karim/OPENBUREAU.git"
|
REPO_HOST="git.kgva.ch/karim/OPENBUREAU.git"
|
||||||
APP_DIR="/opt/openbureau"
|
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?
|
# Stack nach dem Setup direkt bauen + starten?
|
||||||
COMPOSE_UP="true"
|
COMPOSE_UP="true"
|
||||||
##################################################################
|
##################################################################
|
||||||
@@ -57,6 +60,7 @@ if [ -t 0 ]; then
|
|||||||
read -rp " Netzwerk-Bridge [${BRIDGE}]: " _x; BRIDGE="${_x:-$BRIDGE}"
|
read -rp " Netzwerk-Bridge [${BRIDGE}]: " _x; BRIDGE="${_x:-$BRIDGE}"
|
||||||
read -rp " IP (dhcp | x.x.x.x/24) [${IP}]: " _x; IP="${_x:-$IP}"
|
read -rp " IP (dhcp | x.x.x.x/24) [${IP}]: " _x; IP="${_x:-$IP}"
|
||||||
[ "$IP" != "dhcp" ] && { read -rp " Gateway: " GATEWAY; }
|
[ "$IP" != "dhcp" ] && { read -rp " Gateway: " GATEWAY; }
|
||||||
|
read -rp " Admin-E-Mail [${ADMIN_EMAIL}]: " _x; ADMIN_EMAIL="${_x:-$ADMIN_EMAIL}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 1. Template sicherstellen -------------------------------------------
|
# --- 1. Template sicherstellen -------------------------------------------
|
||||||
@@ -142,6 +146,7 @@ pct exec "$CTID" -- bash -euo pipefail -c "
|
|||||||
HOSTIP=\$(hostname -I | awk '{print \$1}')
|
HOSTIP=\$(hostname -I | awk '{print \$1}')
|
||||||
sed -i \"s|^SITE_URL=.*|SITE_URL=http://\${HOSTIP}:8080|\" .env
|
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|^API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://\${HOSTIP}:8000|\" .env
|
||||||
|
sed -i \"s|^ADMIN_EMAILS=.*|ADMIN_EMAILS=${ADMIN_EMAIL}|\" .env
|
||||||
echo 'OK: .env generiert.'
|
echo 'OK: .env generiert.'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -169,7 +174,7 @@ Login-User anlegen (im Container, nach dem Start):
|
|||||||
-H "apikey: \$SERVICE_ROLE_KEY" \\
|
-H "apikey: \$SERVICE_ROLE_KEY" \\
|
||||||
-H "Authorization: Bearer \$SERVICE_ROLE_KEY" \\
|
-H "Authorization: Bearer \$SERVICE_ROLE_KEY" \\
|
||||||
-H "Content-Type: application/json" \\
|
-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:
|
Hinweise:
|
||||||
• :8000 ist das Supabase-API-Gateway (Kong), keine Web-Oberfläche.
|
• :8000 ist das Supabase-API-Gateway (Kong), keine Web-Oberfläche.
|
||||||
|
|||||||
Reference in New Issue
Block a user