perf/ops: Auth-Latenz, Zähl-View, DB-Backup, Schreib-Limit, Asset-Cache

- auth: Supabase-JWT lokal verifizieren (hono/jwt, HS256) statt GoTrue-
  Roundtrip pro Request; JWT_SECRET in cms-env, Remote-Fallback wenn ungesetzt
- dialog: comment_stats-View (group by thread) ersetzt Full-Table-Scan +
  JS-Aggregation bei jedem Forum-Aufruf
- ops: scripts/backup-db.sh (pg_dump, rotiert) + täglicher Cron im Proxmox-
  Script — Dialog-Daten liegen nur in Postgres, nicht in Git
- security: Rate-Limit auf Schreib-Endpunkte (/api non-GET, 60/min je Nutzer)
- perf: Cache-Control (1 Woche) auf statische Assets, HTML bleibt frisch

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 23:01:12 +02:00
parent d0b5c6f670
commit 8404165f5c
9 changed files with 112 additions and 12 deletions
+18
View File
@@ -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-<TS>.sql.gz (rotiert, 14 Stk.)
```
Wiederherstellen:
```bash
gunzip -c backups/openbureau-<TS>.sql.gz \
| docker compose exec -T db psql -U supabase_admin -d postgres
```
## Sicherheit / Härtung
Eingebaute Schutzmaßnahmen (Stand: Härtungs-Pass):
+27 -5
View File
@@ -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');
+3 -7
View File
@@ -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;
}
+16
View File
@@ -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);
+9
View File
@@ -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.
+2
View File
@@ -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
+4
View File
@@ -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 --------------------------------------------------------
+30
View File
@@ -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-<TS>.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