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
|
||||||
.env.local
|
.env.local
|
||||||
hugo.local.yaml
|
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).
|
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
|
## Sicherheit / Härtung
|
||||||
|
|
||||||
Eingebaute Schutzmaßnahmen (Stand: Härtungs-Pass):
|
Eingebaute Schutzmaßnahmen (Stand: Härtungs-Pass):
|
||||||
|
|||||||
+27
-5
@@ -1,5 +1,27 @@
|
|||||||
|
import { verify } from 'hono/jwt';
|
||||||
import { supabaseAuth } from './supabase.js';
|
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.
|
// Rollen-Hierarchie: admin > editor (Redakteur) > user.
|
||||||
// - admin: alles (Foren verwalten, moderieren, Nutzer/Rollen, Inhalte)
|
// - admin: alles (Foren verwalten, moderieren, Nutzer/Rollen, Inhalte)
|
||||||
// - editor: moderieren (Wortmeldungen ausblenden/löschen, Threads sperren)
|
// - 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;
|
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||||
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
|
if (!token) return c.json({ error: 'Nicht eingeloggt' }, 401);
|
||||||
|
|
||||||
const { data, error } = await supabaseAuth.auth.getUser(token);
|
const user = await verifyToken(token);
|
||||||
if (error || !data?.user) return c.json({ error: 'Ungültiges Token' }, 401);
|
if (!user) return c.json({ error: 'Ungültiges Token' }, 401);
|
||||||
|
|
||||||
const email = (data.user.email || '').toLowerCase();
|
const email = (user.email || '').toLowerCase();
|
||||||
const role = roleOf(data.user);
|
const role = roleOf(user);
|
||||||
c.set('user', data.user);
|
c.set('user', user);
|
||||||
c.set('email', email);
|
c.set('email', email);
|
||||||
c.set('role', role);
|
c.set('role', role);
|
||||||
c.set('isAdmin', role === 'admin');
|
c.set('isAdmin', role === 'admin');
|
||||||
|
|||||||
@@ -34,15 +34,11 @@ export async function syncLibrary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }.
|
// Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }.
|
||||||
|
// Aggregiert in Postgres (View comment_stats) statt alle Zeilen zu laden.
|
||||||
async function commentStats() {
|
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 = {};
|
const map = {};
|
||||||
for (const r of data || []) {
|
for (const r of data || []) map[r.thread] = { count: r.count, last: r.last };
|
||||||
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;
|
|
||||||
}
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ app.use('/images/*', secureHeaders({
|
|||||||
xContentTypeOptions: 'nosniff',
|
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 ---
|
// --- API ---
|
||||||
// Globales Limit gegen aufgeblähte JSON-Bodies (DoS / DB-Bloat). Der Upload-Pfad
|
// Globales Limit gegen aufgeblähte JSON-Bodies (DoS / DB-Bloat). Der Upload-Pfad
|
||||||
// ist ausgenommen — der bringt sein eigenes, größeres Bild-Limit mit.
|
// 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);
|
app.post('/api/auth/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), login);
|
||||||
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
||||||
app.use('/api/*', requireAuth);
|
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.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.post('/api/comments', createComment);
|
||||||
app.delete('/api/comments/:id', deleteComment);
|
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;
|
alter table public.comments enable row level security;
|
||||||
grant all on public.comments to anon, authenticated, service_role;
|
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 ────────────────────────────────────────────────────
|
-- ── Foren / Subforen ────────────────────────────────────────────────────
|
||||||
-- Kategorien, in denen Threads leben. Admin-verwaltet. `kind=library` ist die
|
-- Kategorien, in denen Threads leben. Admin-verwaltet. `kind=library` ist die
|
||||||
-- Sonder-Kategorie, in der die Library-Beiträge automatisch als Threads landen.
|
-- 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.
|
# 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}
|
||||||
|
# Für lokale JWT-Verifikation (kein GoTrue-Roundtrip pro Request).
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
SITE_DIR: /site
|
SITE_DIR: /site
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ pct exec "$CTID" -- bash -euo pipefail -c "
|
|||||||
echo '→ Baue + starte Stack (dauert beim ersten Mal ein paar Minuten)…'
|
echo '→ Baue + starte Stack (dauert beim ersten Mal ein paar Minuten)…'
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
fi
|
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 --------------------------------------------------------
|
# --- 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