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:
@@ -22,3 +22,6 @@ node_modules/
|
||||
.env
|
||||
.env.local
|
||||
hugo.local.yaml
|
||||
|
||||
# DB-Backups (Dialog-Daten-Dumps)
|
||||
backups/
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 --------------------------------------------------------
|
||||
|
||||
Executable
+30
@@ -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
|
||||
Reference in New Issue
Block a user