diff --git a/.gitignore b/.gitignore index 8b52976..6d2c4a1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ node_modules/ .env .env.local hugo.local.yaml + +# DB-Backups (Dialog-Daten-Dumps) +backups/ diff --git a/cms/README.md b/cms/README.md index 90a504d..fd18b2f 100644 --- a/cms/README.md +++ b/cms/README.md @@ -104,6 +104,24 @@ docker compose exec -T db psql -U supabase_admin -d postgres < db/seed-demo.sql Wieder entfernen: DELETE-Block am Ende der Datei (auskommentiert). +### Backup der Dialog-Daten + +⚠️ Foren, Threads und Wortmeldungen liegen **nur in Postgres** — anders als +`content/*.md` (in Git) sind sie sonst nirgends gesichert. Das Proxmox-Script +richtet ein **tägliches** Backup ein (`/etc/cron.d/openbureau-backup`, 3:15 Uhr). +Manuell/sonst: + +```bash +bash scripts/backup-db.sh # → backups/openbureau-.sql.gz (rotiert, 14 Stk.) +``` + +Wiederherstellen: + +```bash +gunzip -c backups/openbureau-.sql.gz \ + | docker compose exec -T db psql -U supabase_admin -d postgres +``` + ## Sicherheit / Härtung Eingebaute Schutzmaßnahmen (Stand: Härtungs-Pass): diff --git a/cms/api/src/auth.js b/cms/api/src/auth.js index e51d056..3d0af75 100644 --- a/cms/api/src/auth.js +++ b/cms/api/src/auth.js @@ -1,5 +1,27 @@ +import { verify } from 'hono/jwt'; import { supabaseAuth } from './supabase.js'; +// Supabase-Tokens sind HS256-signiert. Mit dem JWT_SECRET verifizieren wir sie +// lokal (Signatur + Ablauf) — das spart pro Request den Roundtrip zu GoTrue. +// Ohne JWT_SECRET (z.B. Alt-Deploy) fällt requireAuth auf die Remote-Prüfung +// zurück. Tokens sind kurzlebig (1h) und Self-Signup ist aus → kein +// Sperr-Check nötig. +const JWT_SECRET = process.env.JWT_SECRET || ''; + +// Liefert ein User-Objekt {id,email,app_metadata} oder null. +async function verifyToken(token) { + if (JWT_SECRET) { + try { + const p = await verify(token, JWT_SECRET, 'HS256'); + if (!p?.sub) return null; + return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} }; + } catch { return null; } + } + const { data, error } = await supabaseAuth.auth.getUser(token); + if (error || !data?.user) return null; + return data.user; +} + // Rollen-Hierarchie: admin > editor (Redakteur) > user. // - admin: alles (Foren verwalten, moderieren, Nutzer/Rollen, Inhalte) // - editor: moderieren (Wortmeldungen ausblenden/löschen, Threads sperren) @@ -24,12 +46,12 @@ export async function requireAuth(c, next) { const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401); - const { data, error } = await supabaseAuth.auth.getUser(token); - if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401); + const user = await verifyToken(token); + if (!user) return c.json({ error: 'Ungültiges Token' }, 401); - const email = (data.user.email || '').toLowerCase(); - const role = roleOf(data.user); - c.set('user', data.user); + const email = (user.email || '').toLowerCase(); + const role = roleOf(user); + c.set('user', user); c.set('email', email); c.set('role', role); c.set('isAdmin', role === 'admin'); diff --git a/cms/api/src/dialog-store.js b/cms/api/src/dialog-store.js index 6ab11e5..c128ff6 100644 --- a/cms/api/src/dialog-store.js +++ b/cms/api/src/dialog-store.js @@ -34,15 +34,11 @@ export async function syncLibrary() { } // Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }. +// Aggregiert in Postgres (View comment_stats) statt alle Zeilen zu laden. async function commentStats() { - const { data } = await supabase.from('comments').select('thread,created_at,deleted'); + const { data } = await supabase.from('comment_stats').select('thread,count,last'); const map = {}; - for (const r of data || []) { - if (r.deleted) continue; - const t = map[r.thread] || (map[r.thread] = { count: 0, last: r.created_at }); - t.count += 1; - if (r.created_at > t.last) t.last = r.created_at; - } + for (const r of data || []) map[r.thread] = { count: r.count, last: r.last }; return map; } diff --git a/cms/api/src/index.js b/cms/api/src/index.js index d570265..c6477b8 100644 --- a/cms/api/src/index.js +++ b/cms/api/src/index.js @@ -42,6 +42,15 @@ app.use('/images/*', secureHeaders({ xContentTypeOptions: 'nosniff', })); +// Statische Assets cachen: Hugo fingerprintet CSS/JS, Uploads haben stabile, +// eindeutige Namen. HTML bleibt ungecacht (Antwort ohne Header → immer frisch). +app.use('*', async (c, next) => { + await next(); + if (c.req.method === 'GET' && /\.(css|js|mjs|woff2?|ttf|otf|eot|svg|png|jpe?g|webp|avif|gif|ico)$/i.test(c.req.path)) { + c.header('Cache-Control', 'public, max-age=604800'); // 1 Woche + } +}); + // --- API --- // Globales Limit gegen aufgeblähte JSON-Bodies (DoS / DB-Bloat). Der Upload-Pfad // ist ausgenommen — der bringt sein eigenes, größeres Bild-Limit mit. @@ -60,6 +69,13 @@ app.get('/api/thread', threadInfo); app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login); // Alles weitere unter /api/* braucht ein gültiges Supabase-Token. app.use('/api/*', requireAuth); +// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token): +// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei. +const mutateLimit = rateLimit({ + max: 60, windowMs: 60_000, + keyFn: (c) => 'u:' + (c.get('user')?.id || c.req.header('x-forwarded-for') || 'anon'), +}); +app.use('/api/*', (c, next) => (c.req.method === 'GET' ? next() : mutateLimit(c, next))); app.get('/api/me', (c) => c.json({ email: c.get('email'), role: c.get('role'), isAdmin: c.get('isAdmin'), canModerate: c.get('canModerate') })); app.post('/api/comments', createComment); app.delete('/api/comments/:id', deleteComment); diff --git a/cms/db/schema.sql b/cms/db/schema.sql index a549993..764fcbd 100644 --- a/cms/db/schema.sql +++ b/cms/db/schema.sql @@ -52,6 +52,15 @@ create index if not exists comments_thread_idx on public.comments (thread, creat alter table public.comments enable row level security; grant all on public.comments to anon, authenticated, service_role; +-- Aggregat je Thread (Anzahl + letzte Aktivität). Spart der API den Full-Table- +-- Scan + JS-Aggregation bei jedem Forum-Aufruf; Postgres zählt direkt. +create or replace view public.comment_stats as + select thread, count(*)::int as count, max(created_at) as last + from public.comments + where not deleted + group by thread; +grant select on public.comment_stats to service_role; + -- ── Foren / Subforen ──────────────────────────────────────────────────── -- Kategorien, in denen Threads leben. Admin-verwaltet. `kind=library` ist die -- Sonder-Kategorie, in der die Library-Beiträge automatisch als Threads landen. diff --git a/cms/docker-compose.yml b/cms/docker-compose.yml index 34b1ee3..398aea6 100644 --- a/cms/docker-compose.yml +++ b/cms/docker-compose.yml @@ -161,6 +161,8 @@ services: # Server-seitig: intern über Kong, mit Service-Key. SUPABASE_URL: http://kong:8000 SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} + # Für lokale JWT-Verifikation (kein GoTrue-Roundtrip pro Request). + JWT_SECRET: ${JWT_SECRET} ADMIN_EMAILS: ${ADMIN_EMAILS:-} SITE_DIR: /site PORT: 3000 diff --git a/cms/proxmox/create-openbureau-lxc.sh b/cms/proxmox/create-openbureau-lxc.sh index 0ed691f..0bc928b 100755 --- a/cms/proxmox/create-openbureau-lxc.sh +++ b/cms/proxmox/create-openbureau-lxc.sh @@ -163,6 +163,10 @@ pct exec "$CTID" -- bash -euo pipefail -c " echo '→ Baue + starte Stack (dauert beim ersten Mal ein paar Minuten)…' docker compose up -d --build fi + + # Tägliches DB-Backup (3:15 Uhr) — Dialog-Daten liegen NUR in Postgres. + printf 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\n15 3 * * * root cd ${APP_DIR}/cms && bash scripts/backup-db.sh >> /var/log/openbureau-backup.log 2>&1\n' > /etc/cron.d/openbureau-backup + echo 'OK: tägliches DB-Backup eingerichtet (/etc/cron.d/openbureau-backup).' " # --- 5. Abschluss -------------------------------------------------------- diff --git a/cms/scripts/backup-db.sh b/cms/scripts/backup-db.sh new file mode 100755 index 0000000..1340234 --- /dev/null +++ b/cms/scripts/backup-db.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# OPENBUREAU — Backup der Postgres-DB. +# +# WICHTIG: Foren, Threads und Wortmeldungen (Dialog) leben NUR in Postgres — +# anders als content/*.md sind sie NICHT in Git. Ohne Backup sind sie beim +# Verlust des Volumes weg. Dieses Skript dumpt die ganze DB komprimiert weg. +# +# Auf dem Host/LXC im cms/-Verzeichnis ausführen (oder per Cron, siehe README): +# bash scripts/backup-db.sh +# +# Wiederherstellen: +# gunzip -c backups/openbureau-.sql.gz \ +# | docker compose exec -T db psql -U supabase_admin -d postgres +set -euo pipefail + +# Ins cms/-Verzeichnis (eine Ebene über scripts/). +cd "$(dirname "$0")/.." + +DIR="${BACKUP_DIR:-./backups}" +KEEP="${BACKUP_KEEP:-14}" # wie viele Dumps behalten +mkdir -p "$DIR" + +TS="$(date +%Y%m%d-%H%M%S)" +OUT="$DIR/openbureau-$TS.sql.gz" + +docker compose exec -T db pg_dump -U supabase_admin -d postgres | gzip > "$OUT" +echo "✓ Backup: $OUT ($(du -h "$OUT" | cut -f1))" + +# Rotation: nur die letzten $KEEP Dumps behalten. +ls -1t "$DIR"/openbureau-*.sql.gz 2>/dev/null | tail -n +$((KEEP + 1)) | xargs -r rm -f