Merge commit '80eca1bc7c1174f9c44a8df9af4ddc8ab855291b' as 'cms/core'
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user