/* ===================================================================== workspace.js — Anmeldung und Sitzungen für den Creator Workspace (/workspace). Eigenständiger Router, nach dem Muster von gate.js und webdesign-gate.js. BEWUSST OHNE NEUE ABHÄNGIGKEITEN. Node 24 bringt `node:sqlite` mit, das Hashen und die Zufallswerte kommen aus `node:crypto`. Damit gibt es nichts zu kompilieren, nichts zu aktualisieren und keine fremde Lieferkette in einem Bereich, in dem es um Zugangsdaten geht. WICHTIG — dieses Modul darf die Website niemals mitreißen. Es läuft im selben Prozess wie dogfather-universe.com. Deshalb: * beim Laden wird KEINE Datenbank geöffnet (siehe `db()` weiter unten), * jede Route fängt ihre Fehler selbst ab, * schlägt die Datenbank fehl, antwortet nur /workspace/api/* mit 503 — die restliche Seite merkt davon nichts. Sicherheitsentscheidungen und ihre Gründe stehen jeweils direkt an der betreffenden Stelle. ===================================================================== */ import express from "express"; import { randomBytes, scryptSync, timingSafeEqual, createHash, } from "node:crypto"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { mkdirSync } from "node:fs"; import { createRequire } from "node:module"; const __dirname = dirname(fileURLToPath(import.meta.url)); /* `node:sqlite` wird bewusst NICHT oben importiert, sondern erst in db() nachgeladen. Ein fehlgeschlagener Import an dieser Stelle würde den gesamten Website-Prozess beim Start abbrechen -- also auch dogfather-universe.com. So bleibt der Schaden auf /workspace/api/ beschränkt. (Vorhanden ab Node 22; auf dem Server läuft Node 24.) */ const require = createRequire(pathToFileURL(__dirname + "/")); /* Die Datenbank liegt bewusst AUSSERHALB des Repo-Ordners. Zwei Gründe: express.static liefert den Repo-Ordner aus (eine .db darin wäre über das Netz erreichbar), und ein `git pull` darf Nutzdaten nie anfassen. */ const DB_PFAD = process.env.WORKSPACE_DB || join(__dirname, "..", "..", "workspace-daten", "workspace.db"); const COOKIE = "dfw_sitzung"; const SITZUNG_STUNDEN = 12; const VERSUCHE_MAX = 8; // pro IP const VERSUCHE_FENSTER_MIN = 10; const ROLLEN = new Set(["admin", "creator", "scout"]); /* scrypt-Parameter. N=2^15 braucht auf diesem Server rund 150 ms — spürbar genug, um Rateversuche auszubremsen, und unauffällig für einen echten Anmeldevorgang. Die Werte werden je Datensatz mitgespeichert, damit sie später erhöht werden können, ohne alte Codes ungültig zu machen. */ const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64 }; let _db = null; let _dbFehler = null; export function db() { if (_db) return _db; if (_dbFehler) throw _dbFehler; try { const { DatabaseSync } = require("node:sqlite"); mkdirSync(dirname(DB_PFAD), { recursive: true }); const d = new DatabaseSync(DB_PFAD); d.exec(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS personen ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, rolle TEXT NOT NULL CHECK (rolle IN ('admin','creator','scout')), code_hash TEXT NOT NULL, code_salt TEXT NOT NULL, code_n INTEGER NOT NULL, aktiv INTEGER NOT NULL DEFAULT 1, erstellt TEXT NOT NULL, letzter_login TEXT ); CREATE TABLE IF NOT EXISTS sitzungen ( token_hash TEXT PRIMARY KEY, person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, erstellt TEXT NOT NULL, gueltig_bis TEXT NOT NULL, ip TEXT, browser TEXT ); /* Audit-Log. Im Konzept ausdrücklich gefordert ("Login-Historie, kritische Aktionen protokollieren"). Nur anhängen, nie ändern. */ CREATE TABLE IF NOT EXISTS protokoll ( id INTEGER PRIMARY KEY AUTOINCREMENT, zeitpunkt TEXT NOT NULL, person_id INTEGER, rolle TEXT, aktion TEXT NOT NULL, detail TEXT, ip TEXT ); /* Aufgaben. creator_id sagt, ZU WEM die Aufgabe gehört (wessen Bereich), verantwortlich_id, WER sie erledigt. Beides getrennt, weil im Konzept auch Aufgaben vorkommen, die das Management für einen Creator anlegt. */ CREATE TABLE IF NOT EXISTS aufgaben ( id INTEGER PRIMARY KEY AUTOINCREMENT, titel TEXT NOT NULL, beschreibung TEXT, status TEXT NOT NULL DEFAULT 'offen' CHECK (status IN ('offen','arbeit','review','erledigt')), prioritaet TEXT NOT NULL DEFAULT 'mittel' CHECK (prioritaet IN ('hoch','mittel','niedrig')), creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, frist TEXT, erstellt TEXT NOT NULL, erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, geaendert TEXT, erledigt_am TEXT ); CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status); CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id); /* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je Creator, entsteht erst beim ersten Speichern. admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an das Management ausgeliefert -- siehe workspace-profil.js. */ CREATE TABLE IF NOT EXISTS profile ( person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, handles TEXT, nische TEXT, live_zeiten TEXT, technik TEXT, ziel_live TEXT, ziel_content TEXT, ziel_community TEXT, ziel_technik TEXT, plan_start TEXT, plan_prio1 TEXT, plan_prio2 TEXT, plan_prio3 TEXT, naechster_review TEXT, admin_notiz TEXT, geaendert TEXT, geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS versuche ( ip TEXT NOT NULL, zeitpunkt TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_versuche_ip ON versuche (ip, zeitpunkt); CREATE INDEX IF NOT EXISTS idx_sitzungen_gueltig ON sitzungen (gueltig_bis); `); _db = d; return d; } catch (fehler) { _dbFehler = fehler; console.error("[workspace] Datenbank nicht verfügbar:", fehler?.message); throw fehler; } } /* ---------- Hilfsmittel ---------------------------------------------- */ const jetzt = () => new Date().toISOString(); /* Die echte Besucher-IP. Warum das nötig ist: Die Kette lautet Besucher -> Cloudflare -> Caddy -> Express. `trust proxy` steht auf 1, Express nimmt daher den letzten Eintrag aus X-Forwarded-For -- und das ist die Adresse von Cloudflare, nicht die des Besuchers. Folge: Im Protokoll stand bei jedem dieselbe IP, und viel schlimmer -- ALLE Nutzer teilten sich einen einzigen Sperr-Zähler. Ein paar Tippfehler von drei Leuten hätten den Zugang für alle gesperrt. Cloudflare setzt die echte Adresse in CF-Connecting-IP und überschreibt den Kopf bei jeder Anfrage, ein Besucher kann ihn also nicht fälschen. Einschränkung: Wer die Adresse des Servers kennt und ihn unter Umgehung von Cloudflare direkt anspricht, könnte den Kopf frei setzen. Deshalb wird auf die Peer-Adresse zurückgefallen, sobald der Kopf fehlt -- und beide Werte landen im Protokoll, damit so etwas auffällt. */ export function echteIp(req) { const cf = req.get("cf-connecting-ip"); if (cf && cf.length <= 45) return cf.trim(); return req.ip || "?"; } function hashe(code, salt, N) { return scryptSync(code, salt, SCRYPT.keylen, { N, r: SCRYPT.r, p: SCRYPT.p, maxmem: 256 * 1024 * 1024 }).toString("hex"); } /* Vergleich in konstanter Zeit. Ein normaler ===-Vergleich bricht beim ersten abweichenden Zeichen ab; aus den Laufzeitunterschieden lässt sich ein Geheimnis Zeichen für Zeichen erraten. */ function gleich(a, b) { const x = Buffer.from(String(a), "utf8"); const y = Buffer.from(String(b), "utf8"); if (x.length !== y.length) return false; return timingSafeEqual(x, y); } const tokenHash = (t) => createHash("sha256").update(t).digest("hex"); export function protokolliere(aktion, { personId = null, rolle = null, detail = null, ip = null } = {}) { try { db().prepare( "INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)" ).run(jetzt(), personId, rolle, aktion, detail, ip); } catch { /* Ein fehlgeschlagener Protokolleintrag darf nichts blockieren. */ } } function zuVieleVersuche(ip) { const grenze = new Date(Date.now() - VERSUCHE_FENSTER_MIN * 60_000).toISOString(); db().prepare("DELETE FROM versuche WHERE zeitpunkt < ?").run(grenze); const { anzahl } = db() .prepare("SELECT COUNT(*) AS anzahl FROM versuche WHERE ip = ? AND zeitpunkt >= ?") .get(ip, grenze); return anzahl >= VERSUCHE_MAX; } /* ---------- Sitzung ---------------------------------------------------- */ export function sitzungLesen(req) { const token = req.cookies?.[COOKIE]; if (!token) return null; try { const reihe = db().prepare(` SELECT s.gueltig_bis, p.id, p.name, p.rolle, p.aktiv FROM sitzungen s JOIN personen p ON p.id = s.person_id WHERE s.token_hash = ?`).get(tokenHash(token)); if (!reihe || !reihe.aktiv) return null; if (reihe.gueltig_bis < jetzt()) { db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token)); return null; } return { id: reihe.id, name: reihe.name, rolle: reihe.rolle }; } catch { return null; } } function sitzungSetzen(res, person, req) { const token = randomBytes(32).toString("hex"); const bis = new Date(Date.now() + SITZUNG_STUNDEN * 3600_000).toISOString(); db().prepare( "INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser) VALUES (?,?,?,?,?,?)" ).run(tokenHash(token), person.id, jetzt(), bis, echteIp(req), (req.get("user-agent") || "").slice(0, 200)); res.cookie(COOKIE, token, { httpOnly: true, // kein Zugriff aus JavaScript -> kein Diebstahl per XSS /* Live immer true: Hinter Caddy ist req.secure dank `trust proxy` das Ergebnis von X-Forwarded-Proto. Nur beim lokalen Test über http fällt es weg -- sonst wäre der Ablauf gar nicht prüfbar, weil Browser und curl ein Secure-Cookie über http verwerfen. */ secure: req.secure, sameSite: "lax", // schützt vor fremden Formularen (CSRF) path: "/workspace", // gilt nur hier, nicht auf der ganzen Domain maxAge: SITZUNG_STUNDEN * 3600_000, }); } /* ---------- Router ----------------------------------------------------- */ export const workspaceRouter = express.Router(); /* Schutz der angemeldeten Seiten. Serverseitig, nicht nur im Browser -- sonst könnte man die Seite einfach direkt aufrufen. */ /* Pfad -> erlaubte Rollen. `null` heisst: jede angemeldete Rolle. Die Rolle wird hier mitgeprueft und nicht nur in der Schnittstelle. Sonst bekommt z. B. ein Creator die Verwaltungsseite zwar ausgeliefert (HTTP 200) und sieht ihr Geruest, auch wenn sie danach leer bleibt und das Skript ihn wegschickt. Sichtbar sein soll sie gar nicht. */ const GESCHUETZT = { "/workspace/start.html": null, "/workspace/aufgaben.html": null, "/workspace/personen.html": ["admin"], "/workspace/profil.html": ["admin", "creator"], }; workspaceRouter.use((req, res, next) => { if (!Object.hasOwn(GESCHUETZT, req.path)) return next(); const person = sitzungLesen(req); if (!person) return res.redirect(302, "/workspace/"); const erlaubt = GESCHUETZT[req.path]; if (erlaubt && !erlaubt.includes(person.rolle)) { return res.redirect(302, "/workspace/start.html"); } return next(); }); workspaceRouter.post("/workspace/api/anmelden", (req, res) => { const ip = echteIp(req); try { if (zuVieleVersuche(ip)) { protokolliere("anmeldung_gesperrt", { ip }); return res.status(429).json({ fehler: "zu_viele_versuche" }); } const rolle = String(req.body?.rolle || ""); const code = String(req.body?.code || ""); if (!ROLLEN.has(rolle) || code.length < 4 || code.length > 200) { db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt()); return res.status(401).json({ fehler: "ungueltig" }); } /* Alle aktiven Personen dieser Rolle durchgehen. Es gibt bewusst KEIN Benutzerfeld: Der Code allein identifiziert die Person. Bei der erwarteten Größenordnung (eine Handvoll Personen je Rolle) ist das unkritisch; ab etwa 50 Codes je Rolle sollte hier ein Präfix im Code die Vorauswahl übernehmen, sonst wird die Anmeldung spürbar langsam. */ const kandidaten = db() .prepare("SELECT id, name, rolle, code_hash, code_salt, code_n FROM personen WHERE rolle = ? AND aktiv = 1") .all(rolle); let gefunden = null; for (const k of kandidaten) { if (gleich(hashe(code, k.code_salt, k.code_n), k.code_hash)) { gefunden = k; break; } } if (!gefunden) { db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt()); protokolliere("anmeldung_fehlgeschlagen", { rolle, ip }); /* Bewusst dieselbe Antwort wie bei falscher Rolle: Wer raten will, soll nicht erfahren, ob wenigstens die Rolle gestimmt hat. */ return res.status(401).json({ fehler: "ungueltig" }); } db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip); db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), gefunden.id); sitzungSetzen(res, gefunden, req); protokolliere("anmeldung", { personId: gefunden.id, rolle, ip, detail: gefunden.name }); return res.json({ weiter: "/workspace/start.html", name: gefunden.name, rolle }); } catch (fehler) { console.error("[workspace] Anmeldung fehlgeschlagen:", fehler?.message); return res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); workspaceRouter.post("/workspace/api/abmelden", (req, res) => { try { const token = req.cookies?.[COOKIE]; if (token) { const person = sitzungLesen(req); db().prepare("DELETE FROM sitzungen WHERE token_hash = ?").run(tokenHash(token)); if (person) protokolliere("abmeldung", { personId: person.id, rolle: person.rolle, ip: echteIp(req) }); } } catch { /* Abmelden darf nie scheitern. */ } res.clearCookie(COOKIE, { path: "/workspace" }); res.json({ ok: true }); }); workspaceRouter.get("/workspace/api/ich", (req, res) => { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); /* Die eigene Nummer gehört mit dazu: Ohne sie kann die Oberfläche nicht erkennen, welcher Eintrag der eigene ist (etwa "das bin ich" in der Personenliste), und das eigene Profil liesse sich gar nicht aufrufen. Ein Geheimnis ist sie nicht -- sie beschreibt nur den Angemeldeten. */ res.json({ id: person.id, name: person.name, rolle: person.rolle }); }); /* ---------- Verwaltung (nur über die Kommandozeile) -------------------- Wird von workspace-code.js benutzt. Bewusst nicht über das Netz erreichbar: Codes werden auf dem Server erzeugt, einmal angezeigt und nie gespeichert -- weder in Git noch in einer Notiz. */ /* Alphabet ohne 0/O und 1/I/l: Diese Codes werden abgetippt und weitergegeben, Verwechslungen kosten sonst unnötig Nerven. */ const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; export function codeErzeugen(gruppen = 4, laenge = 4) { const roh = randomBytes(gruppen * laenge); let aus = ""; for (let i = 0; i < gruppen * laenge; i++) { if (i && i % laenge === 0) aus += "-"; aus += ALPHABET[roh[i] % ALPHABET.length]; } return aus; } /* `akteur` ist WER die Aktion ausloest -- nicht, wen sie betrifft. Das muss getrennt bleiben: Stand im Protokoll die neu angelegte Person als person_id, las sich der Eintrag so, als haette sie sich selbst angelegt. Wer betroffen ist, steht im Text. Ohne Akteur (Kommandozeile) bleibt das Feld leer. */ export function personAnlegen(name, rolle, akteur = null) { if (!ROLLEN.has(rolle)) throw new Error(`Unbekannte Rolle: ${rolle}`); const code = codeErzeugen(); const salt = randomBytes(16).toString("hex"); const hash = hashe(code, salt, SCRYPT.N); const { lastInsertRowid } = db().prepare( "INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)" ).run(name, rolle, hash, salt, SCRYPT.N, jetzt()); protokolliere("person_angelegt", { personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null, detail: `${name} (${rolle})`, }); return { id: Number(lastInsertRowid), name, rolle, code }; } export function codeNeu(id, akteur = null) { const person = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(id); if (!person) throw new Error(`Keine Person mit Nummer ${id}`); const code = codeErzeugen(); const salt = randomBytes(16).toString("hex"); db().prepare("UPDATE personen SET code_hash = ?, code_salt = ?, code_n = ? WHERE id = ?") .run(hashe(code, salt, SCRYPT.N), salt, SCRYPT.N, id); /* Alle offenen Sitzungen beenden -- ein neuer Code soll den alten Zugang wirklich beenden, nicht nur die nächste Anmeldung betreffen. */ db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id); protokolliere("code_erneuert", { personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null, detail: `für ${person.name}`, }); return { ...person, code }; } export function personSperren(id, aktiv = 0, akteur = null) { const person = db().prepare("SELECT name FROM personen WHERE id = ?").get(id); db().prepare("UPDATE personen SET aktiv = ? WHERE id = ?").run(aktiv ? 1 : 0, id); if (!aktiv) db().prepare("DELETE FROM sitzungen WHERE person_id = ?").run(id); protokolliere(aktiv ? "person_entsperrt" : "person_gesperrt", { personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null, detail: person?.name ?? `#${id}`, }); } export function personenListe() { return db().prepare(` SELECT p.id, p.name, p.rolle, p.aktiv, p.erstellt, p.letzter_login, (SELECT COUNT(*) FROM sitzungen s WHERE s.person_id = p.id) AS sitzungen FROM personen p ORDER BY p.rolle, p.name`).all(); } export function protokollLesen(anzahl = 20) { return db().prepare( "SELECT zeitpunkt, rolle, aktion, detail, ip FROM protokoll ORDER BY id DESC LIMIT ?" ).all(anzahl); }