/* ===================================================================== DOGFATHER UNIVERSE — Interner Bereich Worker Setzt "Geschuetzter_Zugang_Rollen_Benutzerverwaltung.pdf" um: Session-basierte Anmeldung, verschlüsselte (reversible) Code-Speicherung NUR für den Owner einsehbar, vollständig konfigurierbare Rollen mit granularen Rechten, individuelle Personen-Ausnahmen, Aktivitätsprotokoll, Login-Lockout nach Fehlversuchen. Architektur: src/lib/crypto.js Hashing, AES-GCM (Code-Verschlüsselung), IDs src/lib/auth.js Login-Prüfung, Sessions, Rate-Limiting src/lib/permissions.js Vollständiger Permission-Katalog src/lib/audit.js Aktivitätsprotokoll schreiben src/lib/http.js CORS/JSON-Helfer src/routes/*.js Endpoint-Handler pro Bereich Bindings (siehe wrangler.toml): D1: DB (Datenbank für Users/Rollen/Sessions/Audit/Bewerbungen) KV: BEWERBUNGEN (nur noch für den Live-Status-Schalter genutzt) Secret: POSTFACH_CODE (Owner-Code, mit `wrangler secret put` gesetzt) Secret: ENCRYPTION_KEY (AES-256-Schlüssel, base64, für Code-Verschlüsselung) ===================================================================== */ import { json, corsHeaders } from "./lib/http.js"; import { requireSession } from "./lib/auth.js"; import { login, logout, setOwnerCode } from "./routes/auth.js"; import { listRoles, createRole, updateRole, deleteRole, } from "./routes/roles.js"; import { listUsers, prepareUser, finalizeUser, setUserCode, revealUserCode, lockUser, unlockUser, deleteUser, setUserRole, setUserPermissions, endUserSessions, updateUserMeta, } from "./routes/users.js"; import { submitApplication, listApplications, changeApplicationStatus, takeOverApplication, assignApplication, archiveApplication, restoreApplication, deleteApplication, markAnswered, listNotes, addNote, deleteNote, listDrafts, saveDraft, } from "./routes/applications.js"; import { listAuditLog } from "./routes/audit.js"; import { checkTikTokLive } from "./lib/tiktok-live.js"; import { logAction } from "./lib/audit.js"; import { requireSupporterSession } from "./lib/supporter-auth.js"; import { registerSupporter, verifySupporterEmail, requestSupporterLogin, verifySupporterLogin, logoutSupporter, googleLoginSupporter, getPublicConfig, getSupporterMe, updateSupporterProfile, requestSupporterEmailChange, confirmSupporterEmailChange, redeemPremium, deleteSupporterAccount, } from "./routes/supporter.js"; import { startPaypalSubscription, confirmPaypalSubscription, cancelPaypalSubscription, handlePaypalWebhook, } from "./routes/supporter-paypal.js"; import { listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview, } from "./routes/supporter-admin.js"; /* ===================================================================== LIVE-STATUS Der grüne Punkt auf der Startseite läuft automatisch: ein Cron-Auftrag fragt jede Minute bei TikTok nach, ob @dogfather0804 gerade sendet, und schreibt das Ergebnis in die Tabelle app_settings. Zwei Schlüssel: live_auto – letzter ERFOLGREICH ermittelter Stand (wird bei einem fehlgeschlagenen Abruf bewusst NICHT überschrieben, damit ein TikTok-Aussetzer den Punkt nicht fälschlich auf offline zieht) live_override – manueller Vorrang aus dem Postfach, mit Ablaufzeit. Solange er gilt, schlägt er die Automatik. ===================================================================== */ const DEFAULT_OVERRIDE_MINUTES = 180; async function readSetting(env, key) { const row = await env.DB.prepare(`SELECT value FROM app_settings WHERE key = ?`) .bind(key) .first() .catch(() => null); if (!row || !row.value) return null; try { return JSON.parse(row.value); } catch { return null; } } async function writeSetting(env, key, value) { await env.DB.prepare( `INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` ) .bind(key, JSON.stringify(value), new Date().toISOString()) .run(); } /** Ermittelt den Stand, der nach außen gilt (Vorrang: manuell vor Automatik). */ async function resolveLiveStatus(env) { const auto = (await readSetting(env, "live_auto")) || {}; const override = await readSetting(env, "live_override"); const overrideActive = override && override.until && new Date(override.until).getTime() > Date.now(); if (overrideActive) { return { live: !!override.live, source: "manuell", updatedAt: override.setAt || null, overrideUntil: override.until, autoLive: auto.live ?? null, autoCheckedAt: auto.checkedAt || null, autoHealthy: auto.ok !== false, }; } return { live: !!auto.live, source: "automatisch", updatedAt: auto.changedAt || auto.checkedAt || null, title: auto.title || null, autoLive: auto.live ?? null, autoCheckedAt: auto.checkedAt || null, autoHealthy: auto.ok !== false, }; } async function handleLiveStatusGet(env) { const state = await resolveLiveStatus(env); return json({ ok: true, ...state }); } /** * Manueller Eingriff aus dem Postfach. * { live: true|false, minutes?: number } → Vorrang setzen * { auto: true } → Vorrang aufheben, Automatik übernimmt */ async function handleLiveStatusSet(request, env, session) { if (!session?.isOwner) return json({ ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 403); const body = await request.json().catch(() => ({})); if (body.auto === true) { await env.DB.prepare(`DELETE FROM app_settings WHERE key = 'live_override'`).run(); await logAction(env, "owner", "live.override_cleared", null, null); return json({ ok: true, ...(await resolveLiveStatus(env)) }); } const minutes = Number.isFinite(body.minutes) ? Math.min(Math.max(body.minutes, 5), 1440) : DEFAULT_OVERRIDE_MINUTES; const now = new Date(); await writeSetting(env, "live_override", { live: !!body.live, setAt: now.toISOString(), until: new Date(now.getTime() + minutes * 60_000).toISOString(), }); await logAction(env, "owner", "live.override_set", null, { live: !!body.live, minutes }); return json({ ok: true, ...(await resolveLiveStatus(env)) }); } /** Wird vom Cron-Auftrag aufgerufen — fragt TikTok ab und schreibt das Ergebnis. */ async function refreshLiveFromTikTok(env) { const username = env.TIKTOK_USERNAME || "dogfather0804"; const result = await checkTikTokLive(username); const now = new Date().toISOString(); if (!result.ok) { // Fehlgeschlagen: letzten guten Stand behalten, nur den Fehler vermerken. const previous = (await readSetting(env, "live_auto")) || {}; await writeSetting(env, "live_auto", { ...previous, ok: false, lastError: result.reason, checkedAt: now, }); return { ok: false, reason: result.reason }; } const previous = (await readSetting(env, "live_auto")) || {}; const changed = previous.live !== result.live; await writeSetting(env, "live_auto", { ok: true, live: result.live, title: result.title, roomId: result.roomId, rawStatus: result.rawStatus, checkedAt: now, changedAt: changed ? now : previous.changedAt || now, lastError: null, }); if (changed) { await logAction(env, "system", result.live ? "live.started" : "live.ended", null, { title: result.title, }); } return { ok: true, live: result.live, changed }; } // Routen, die eine gültige Session brauchen. `session` wird injiziert. const AUTHED_ROUTES = { "POST /auth/logout": (req, env) => logout(req, env), "POST /admin/owner/set-code": (req, env, session) => setOwnerCode(req, env, session), "POST /live-status/set": (req, env, session) => handleLiveStatusSet(req, env, session), "POST /admin/applications/list": (req, env, session) => listApplications(req, env, session), "POST /admin/applications/status": (req, env, session) => changeApplicationStatus(req, env, session), "POST /admin/applications/take-over": (req, env, session) => takeOverApplication(req, env, session), "POST /admin/applications/assign": (req, env, session) => assignApplication(req, env, session), "POST /admin/applications/archive": (req, env, session) => archiveApplication(req, env, session), "POST /admin/applications/restore": (req, env, session) => restoreApplication(req, env, session), "POST /admin/applications/delete": (req, env, session) => deleteApplication(req, env, session), "POST /admin/applications/mark-answered": (req, env, session) => markAnswered(req, env, session), "POST /admin/notes/list": (req, env, session) => listNotes(req, env, session), "POST /admin/notes/add": (req, env, session) => addNote(req, env, session), "POST /admin/notes/delete": (req, env, session) => deleteNote(req, env, session), "POST /admin/drafts/list": (req, env, session) => listDrafts(req, env, session), "POST /admin/drafts/save": (req, env, session) => saveDraft(req, env, session), "POST /admin/roles/list": (req, env, session) => listRoles(env, session), "POST /admin/roles/create": (req, env, session) => createRole(req, env, session), "POST /admin/roles/update": (req, env, session) => updateRole(req, env, session), "POST /admin/roles/delete": (req, env, session) => deleteRole(req, env, session), "POST /admin/users/list": (req, env, session) => listUsers(env, session), "POST /admin/users/prepare": (req, env, session) => prepareUser(req, env, session), "POST /admin/users/finalize": (req, env, session) => finalizeUser(req, env, session), "POST /admin/users/set-code": (req, env, session) => setUserCode(req, env, session), "POST /admin/users/reveal-code": (req, env, session) => revealUserCode(req, env, session), "POST /admin/users/lock": (req, env, session) => lockUser(req, env, session), "POST /admin/users/unlock": (req, env, session) => unlockUser(req, env, session), "POST /admin/users/delete": (req, env, session) => deleteUser(req, env, session), "POST /admin/users/set-role": (req, env, session) => setUserRole(req, env, session), "POST /admin/users/set-permissions": (req, env, session) => setUserPermissions(req, env, session), "POST /admin/users/end-sessions": (req, env, session) => endUserSessions(req, env, session), "POST /admin/users/update-meta": (req, env, session) => updateUserMeta(req, env, session), "POST /admin/audit/list": (req, env, session) => listAuditLog(req, env, session), // Supporter-Abo — Administrationsbereich (interne Team-Session + eigene // SUPPORTER_*-Rechte, siehe permissions.js). "POST /admin/overview": (req, env, session) => getAdminOverview(req, env, session), "POST /admin/supporter/list": (req, env, session) => listSupporters(req, env, session), "POST /admin/supporter/detail": (req, env, session) => getSupporterDetail(req, env, session), "POST /admin/supporter/premium/update": (req, env, session) => updatePremium(req, env, session), "POST /admin/supporter/collections/list": (req, env, session) => listCollections(req, env, session), "POST /admin/supporter/collections/upsert": (req, env, session) => upsertCollection(req, env, session), }; // Routen des ÖFFENTLICHEN Supporter-Bereichs, die eine gültige // Supporter-Session brauchen (eigenes, von AUTHED_ROUTES komplett // getrenntes Token-System — siehe lib/supporter-auth.js). `supporter` // (die volle supporter_users-Zeile) wird injiziert statt `session`. const SUPPORTER_AUTHED_ROUTES = { "POST /supporter/logout": (req, env) => logoutSupporter(req, env), "POST /supporter/me": (req, env, supporter) => getSupporterMe(req, env, supporter), "POST /supporter/update-profile": (req, env, supporter) => updateSupporterProfile(req, env, supporter), "POST /supporter/request-email-change": (req, env, supporter) => requestSupporterEmailChange(req, env, supporter), "POST /supporter/confirm-email-change": (req, env, supporter) => confirmSupporterEmailChange(req, env, supporter), "POST /supporter/redeem-premium": (req, env, supporter) => redeemPremium(req, env, supporter), "POST /supporter/delete-account": (req, env, supporter) => deleteSupporterAccount(req, env, supporter), "POST /supporter/paypal/start": (req, env, supporter) => startPaypalSubscription(req, env, supporter), "POST /supporter/paypal/confirm": (req, env, supporter) => confirmPaypalSubscription(req, env, supporter), "POST /supporter/paypal/cancel": (req, env, supporter) => cancelPaypalSubscription(req, env, supporter), }; export default { async fetch(request, env, ctx) { const url = new URL(request.url); const routeKey = `${request.method} ${url.pathname}`; if (request.method === "OPTIONS") { return new Response(null, { headers: corsHeaders() }); } if (url.pathname === "/health" && request.method === "GET") { return json({ ok: true, service: "dogfather-universe-interner-bereich" }); } if (url.pathname === "/submit" && request.method === "POST") { return submitApplication(request, env, ctx); } if (url.pathname === "/live-status" && request.method === "GET") { return handleLiveStatusGet(env); } if (url.pathname === "/auth/login" && request.method === "POST") { return login(request, env); } // Supporter-Abo — öffentliche Routen ohne Session (Registrierung/Login). if (url.pathname === "/supporter/register" && request.method === "POST") return registerSupporter(request, env); if (url.pathname === "/supporter/verify-email" && request.method === "POST") return verifySupporterEmail(request, env); if (url.pathname === "/supporter/login-request" && request.method === "POST") return requestSupporterLogin(request, env); if (url.pathname === "/supporter/verify-login" && request.method === "POST") return verifySupporterLogin(request, env); if (url.pathname === "/supporter/google-login" && request.method === "POST") return googleLoginSupporter(request, env); if (url.pathname === "/supporter/public-config" && request.method === "GET") return getPublicConfig(request, env); // PayPal ruft diese Adresse direkt auf — eigene Signaturprüfung statt Session (siehe paypal.js). if (url.pathname === "/supporter/paypal-webhook" && request.method === "POST") return handlePaypalWebhook(request, env); const supporterHandler = SUPPORTER_AUTHED_ROUTES[routeKey]; if (supporterHandler) { const supporter = await requireSupporterSession(request, env); if (!supporter) return json({ ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut einloggen." }, 401); return supporterHandler(request, env, supporter); } const handler = AUTHED_ROUTES[routeKey]; if (handler) { const session = await requireSession(request, env); if (!session) return json({ ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut anmelden." }, 401); return handler(request, env, session); } return json({ ok: false, error: "Not found." }, 404); }, /* Cron-Auftrag: läuft jede Minute und hält den Live-Punkt aktuell. */ async scheduled(event, env, ctx) { ctx.waitUntil(refreshLiveFromTikTok(env)); }, };