From e87aca30197237166b524848252e825021970814 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Wed, 5 Aug 2026 18:57:19 +0200 Subject: [PATCH] Postfach/Team-Verwaltung/Supporter-Abo als Node.js/Express-Server portiert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vollständiger 1:1-Port des cloudflare-worker/ (dogfather-universe-postfach) auf server-internal/: Bewerbungs-Postfach mit Rollen/Rechten, Team- Zugangsverwaltung (verschlüsselte Owner-only-Codes), DogiCrew-Supporter-Abo (PayPal Subscriptions, Google-Login, Resend-E-Mail, 30-Monats-Prämienzyklus) und automatische TikTok-Live-Erkennung. D1 -> better-sqlite3, Cloudflare- Cron -> node-cron. Läuft ohne gesetzte Secrets weiter (PayPal/Google/Resend/ Ticketanizer/Discord bleiben "nicht konfiguriert" statt kaputt), bis die echten Zugangsdaten eingetragen werden. --- server-internal/.env.example | 47 +++ server-internal/db.js | 51 +++ server-internal/index.js | 193 +++++++++++ server-internal/lib/audit.js | 16 + server-internal/lib/auth.js | 142 ++++++++ server-internal/lib/crypto.js | 62 ++++ server-internal/lib/discord-webhooks.js | 68 ++++ server-internal/lib/google-auth.js | 45 +++ server-internal/lib/http.js | 10 + server-internal/lib/live-status.js | 133 ++++++++ server-internal/lib/paypal.js | 142 ++++++++ server-internal/lib/permissions.js | 74 ++++ server-internal/lib/supporter-auth.js | 86 +++++ server-internal/lib/supporter-cycle.js | 57 ++++ server-internal/lib/supporter-mail.js | 74 ++++ server-internal/lib/ticketanizer.js | 60 ++++ server-internal/lib/tiktok-live.js | 71 ++++ server-internal/package.json | 19 ++ server-internal/routes/applications.js | 228 +++++++++++++ server-internal/routes/audit.js | 20 ++ server-internal/routes/auth.js | 104 ++++++ server-internal/routes/roles.js | 93 +++++ server-internal/routes/supporter-admin.js | 224 ++++++++++++ server-internal/routes/supporter-paypal.js | 309 +++++++++++++++++ server-internal/routes/supporter.js | 379 +++++++++++++++++++++ server-internal/routes/users.js | 255 ++++++++++++++ 26 files changed, 2962 insertions(+) create mode 100644 server-internal/.env.example create mode 100644 server-internal/db.js create mode 100644 server-internal/index.js create mode 100644 server-internal/lib/audit.js create mode 100644 server-internal/lib/auth.js create mode 100644 server-internal/lib/crypto.js create mode 100644 server-internal/lib/discord-webhooks.js create mode 100644 server-internal/lib/google-auth.js create mode 100644 server-internal/lib/http.js create mode 100644 server-internal/lib/live-status.js create mode 100644 server-internal/lib/paypal.js create mode 100644 server-internal/lib/permissions.js create mode 100644 server-internal/lib/supporter-auth.js create mode 100644 server-internal/lib/supporter-cycle.js create mode 100644 server-internal/lib/supporter-mail.js create mode 100644 server-internal/lib/ticketanizer.js create mode 100644 server-internal/lib/tiktok-live.js create mode 100644 server-internal/package.json create mode 100644 server-internal/routes/applications.js create mode 100644 server-internal/routes/audit.js create mode 100644 server-internal/routes/auth.js create mode 100644 server-internal/routes/roles.js create mode 100644 server-internal/routes/supporter-admin.js create mode 100644 server-internal/routes/supporter-paypal.js create mode 100644 server-internal/routes/supporter.js create mode 100644 server-internal/routes/users.js diff --git a/server-internal/.env.example b/server-internal/.env.example new file mode 100644 index 0000000..eb1389b --- /dev/null +++ b/server-internal/.env.example @@ -0,0 +1,47 @@ +# Vorlage — echte Werte kommen in eine eigene ".env"-Datei (nie committen, +# nie in den Vault, nie in den Chat). 1:1 die gleichen Werte, die vorher per +# `wrangler secret put` bzw. in wrangler.toml [vars] gesetzt waren. + +PORT=4200 +DB_PATH=/var/lib/dogfather-internal/dogfather-internal.db + +# ---- Owner-Zugang (Postfach/Team-Bereich) ---- +# Erst-Login-Code des Hauptadministrators (Dogi). Nach dem ersten Login kann +# er über "Code ändern" durch einen in der DB gehashten Code ersetzt werden +# (siehe routes/auth.js setOwnerCode) — POSTFACH_CODE bleibt dann als reiner +# Fallback bestehen, wird aber nicht mehr benutzt, solange owner_code_hash +# in app_settings gesetzt ist. +POSTFACH_CODE= + +# AES-256-Schlüssel (base64, 32 Bytes) für die reversible Team-Code- +# Verschlüsselung. Erzeugen z.B. mit: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +ENCRYPTION_KEY= + +# ---- TikTok-Live-Erkennung ---- +TIKTOK_USERNAME=dogfather0804 + +# ---- Bewerbungen: Ticketanizer ---- +TICKETANIZER_API_KEY= + +# ---- Bewerbungen: Discord-Benachrichtigungen (3 verschiedene Kanäle) ---- +DISCORD_WEBHOOK_MODI= +DISCORD_WEBHOOK_KOOPERATION= +DISCORD_WEBHOOK_SCOUT_MANAGER= + +# ---- Supporter-Abo: E-Mail (Resend) ---- +RESEND_API_KEY= +RESEND_FROM=Dogfather Team + +# ---- Supporter-Abo: Google-Login ---- +# Öffentliche Client-ID (kein Geheimnis), aber bewusst nur per .env gesetzt, +# nicht hart im Frontend-Code, damit sie zusammen mit den anderen Werten an +# einer Stelle verwaltet wird. +GOOGLE_CLIENT_ID= + +# ---- Supporter-Abo: PayPal Subscriptions ---- +# "sandbox" zum Testen, "live" für echte Zahlungen. +PAYPAL_ENV=sandbox +PAYPAL_CLIENT_ID= +PAYPAL_CLIENT_SECRET= +PAYPAL_PLAN_ID= +PAYPAL_WEBHOOK_ID= diff --git a/server-internal/db.js b/server-internal/db.js new file mode 100644 index 0000000..52e4e58 --- /dev/null +++ b/server-internal/db.js @@ -0,0 +1,51 @@ +/* ===================================================================== + db.js — SQLite-Datenbank (better-sqlite3), Ersatz für Cloudflare D1. + Führt beim ersten Start alle vier Migrationen aus ../cloudflare-worker/migrations/ + in der ursprünglichen Reihenfolge aus (0001 Basis-Schema, 0002 app_settings, + 0003 Supporter-Abo, 0004 TikTok/Discord-Spalten) — anders als bei VanVans Shop + ist hier NIE alles in einer konsolidierten schema.sql zusammengefasst worden, + deshalb müssen wir hier tatsächlich alle vier der Reihe nach anwenden. + ===================================================================== */ + +import Database from "better-sqlite3"; +import { readFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DB_PATH = process.env.DB_PATH || "/var/lib/dogfather-internal/dogfather-internal.db"; +const MIGRATIONS_DIR = join(__dirname, "..", "cloudflare-worker", "migrations"); + +mkdirSync(dirname(DB_PATH), { recursive: true }); + +export const db = new Database(DB_PATH); +db.pragma("journal_mode = WAL"); +db.pragma("foreign_keys = ON"); + +const MIGRATION_FILES = [ + "0001_init.sql", + "0002_app_settings.sql", + "0003_supporter_abo.sql", + "0004_supporter_oauth_providers.sql", +]; + +function alreadyApplied(name) { + const row = db.prepare(`SELECT 1 FROM _migrations WHERE name = ?`).get(name); + return !!row; +} + +export function initDb() { + db.exec(`CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`); + for (const file of MIGRATION_FILES) { + if (alreadyApplied(file)) continue; + const path = join(MIGRATIONS_DIR, file); + if (!existsSync(path)) { + console.warn(`⚠️ Migration ${file} nicht gefunden unter ${path}, übersprungen.`); + continue; + } + const sql = readFileSync(path, "utf-8"); + db.exec(sql); + db.prepare(`INSERT INTO _migrations (name, applied_at) VALUES (?, ?)`).run(file, new Date().toISOString()); + console.log(`Migration angewendet: ${file}`); + } +} diff --git a/server-internal/index.js b/server-internal/index.js new file mode 100644 index 0000000..a77b453 --- /dev/null +++ b/server-internal/index.js @@ -0,0 +1,193 @@ +/* ===================================================================== + DOGFATHER UNIVERSE — Interner Bereich (Postfach, Team-Verwaltung, + DogiCrew-Supporter-Abo). Node.js/Express-Server als 1:1-Ersatz für den + Cloudflare Worker "dogfather-universe-postfach" + (siehe ../cloudflare-worker/src/worker.js für das Original). + + Architektur (unverändert zum Original): + db.js SQLite-Datenbank (better-sqlite3), Migrationen + lib/crypto.js Hashing, AES-GCM (Code-Verschlüsselung), IDs + lib/auth.js Login-Prüfung, Sessions, Rate-Limiting (Team) + lib/supporter-auth.js Login/Sessions für den öffentlichen Bereich + lib/permissions.js Vollständiger Permission-Katalog + lib/audit.js Aktivitätsprotokoll schreiben + lib/http.js JSON-Response-Helfer + lib/live-status.js TikTok-Live-Erkennung (ersetzt Cloudflare-Cron) + routes/*.js Endpoint-Handler pro Bereich + + Unterschiede zum Original (rein technisch, gleiches Verhalten): + - D1 (async) → better-sqlite3 (synchron) + - KV BEWERBUNGEN wurde nie für mehr als den Live-Status-Schalter + genutzt, der jetzt komplett in app_settings (SQLite) lebt. + - ctx.waitUntil() → einfaches "nicht abwarten" (Node-Prozess läuft + durchgehend weiter, kein Cold-Start-Risiko wie bei Workers). + - scheduled()-Cron → node-cron, gleicher Rhythmus ("* * * * *"). + ===================================================================== */ +import "dotenv/config"; +import express from "express"; +import cors from "cors"; +import cron from "node-cron"; + +import { initDb } from "./db.js"; +import { json } from "./lib/http.js"; +import { requireSession } from "./lib/auth.js"; +import { requireSupporterSession } from "./lib/supporter-auth.js"; +import { handleLiveStatusGet, handleLiveStatusSet, refreshLiveFromTikTok } from "./lib/live-status.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 { + 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"; + +initDb(); + +const PORT = Number(process.env.PORT || 4200); +const app = express(); +app.set("trust proxy", 1); + +app.use(cors()); +app.use( + express.json({ + limit: "2mb", + // Roh-Body wird zusätzlich als String aufbewahrt — die PayPal-Webhook- + // Signaturprüfung braucht den unveränderten Original-Text (siehe + // routes/supporter-paypal.js), nicht das nachträglich neu serialisierte + // req.body. + verify: (req, res, buf) => { + req.rawBody = buf.toString("utf8"); + }, + }) +); + +/* ---------- Middlewares: interne Team-Session bzw. Supporter-Session ---------- */ + +async function requireTeamSession(req, res, next) { + const session = await requireSession(req); + if (!session) return json(res, { ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut anmelden." }, 401); + req.session = session; + next(); +} + +async function requireSupporterAuth(req, res, next) { + const supporter = await requireSupporterSession(req); + if (!supporter) return json(res, { ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut einloggen." }, 401); + req.supporter = supporter; + next(); +} + +/** Reicht die injizierte session/supporter als drittes Argument an den Handler weiter (wie im Cloudflare-Original). */ +const withSession = (handler) => (req, res) => handler(req, res, req.session); +const withSupporter = (handler) => (req, res) => handler(req, res, req.supporter); + +/* ---------- Öffentliche Routen (keine Session) ---------- */ + +app.get("/health", (req, res) => json(res, { ok: true, service: "dogfather-universe-interner-bereich" })); +app.post("/submit", submitApplication); +app.get("/live-status", handleLiveStatusGet); +app.post("/auth/login", login); + +app.post("/supporter/register", registerSupporter); +app.post("/supporter/verify-email", verifySupporterEmail); +app.post("/supporter/login-request", requestSupporterLogin); +app.post("/supporter/verify-login", verifySupporterLogin); +app.post("/supporter/google-login", googleLoginSupporter); +app.get("/supporter/public-config", getPublicConfig); +// PayPal ruft diese Adresse direkt auf — eigene Signaturprüfung statt Session (siehe lib/paypal.js). +app.post("/supporter/paypal-webhook", handlePaypalWebhook); + +/* ---------- Team-Bereich (interne Session, requireTeamSession) ---------- */ + +app.post("/auth/logout", requireTeamSession, logout); +app.post("/admin/owner/set-code", requireTeamSession, withSession(setOwnerCode)); +app.post("/live-status/set", requireTeamSession, withSession(handleLiveStatusSet)); + +app.post("/admin/applications/list", requireTeamSession, withSession(listApplications)); +app.post("/admin/applications/status", requireTeamSession, withSession(changeApplicationStatus)); +app.post("/admin/applications/take-over", requireTeamSession, withSession(takeOverApplication)); +app.post("/admin/applications/assign", requireTeamSession, withSession(assignApplication)); +app.post("/admin/applications/archive", requireTeamSession, withSession(archiveApplication)); +app.post("/admin/applications/restore", requireTeamSession, withSession(restoreApplication)); +app.post("/admin/applications/delete", requireTeamSession, withSession(deleteApplication)); +app.post("/admin/applications/mark-answered", requireTeamSession, withSession(markAnswered)); + +app.post("/admin/notes/list", requireTeamSession, withSession(listNotes)); +app.post("/admin/notes/add", requireTeamSession, withSession(addNote)); +app.post("/admin/notes/delete", requireTeamSession, withSession(deleteNote)); + +app.post("/admin/drafts/list", requireTeamSession, withSession(listDrafts)); +app.post("/admin/drafts/save", requireTeamSession, withSession(saveDraft)); + +app.post("/admin/roles/list", requireTeamSession, withSession(listRoles)); +app.post("/admin/roles/create", requireTeamSession, withSession(createRole)); +app.post("/admin/roles/update", requireTeamSession, withSession(updateRole)); +app.post("/admin/roles/delete", requireTeamSession, withSession(deleteRole)); + +app.post("/admin/users/list", requireTeamSession, withSession(listUsers)); +app.post("/admin/users/prepare", requireTeamSession, withSession(prepareUser)); +app.post("/admin/users/finalize", requireTeamSession, withSession(finalizeUser)); +app.post("/admin/users/set-code", requireTeamSession, withSession(setUserCode)); +app.post("/admin/users/reveal-code", requireTeamSession, withSession(revealUserCode)); +app.post("/admin/users/lock", requireTeamSession, withSession(lockUser)); +app.post("/admin/users/unlock", requireTeamSession, withSession(unlockUser)); +app.post("/admin/users/delete", requireTeamSession, withSession(deleteUser)); +app.post("/admin/users/set-role", requireTeamSession, withSession(setUserRole)); +app.post("/admin/users/set-permissions", requireTeamSession, withSession(setUserPermissions)); +app.post("/admin/users/end-sessions", requireTeamSession, withSession(endUserSessions)); +app.post("/admin/users/update-meta", requireTeamSession, withSession(updateUserMeta)); + +app.post("/admin/audit/list", requireTeamSession, withSession(listAuditLog)); + +// Supporter-Abo — Administrationsbereich (interne Team-Session + eigene +// SUPPORTER_*-Rechte, siehe lib/permissions.js). +app.post("/admin/overview", requireTeamSession, withSession(getAdminOverview)); +app.post("/admin/supporter/list", requireTeamSession, withSession(listSupporters)); +app.post("/admin/supporter/detail", requireTeamSession, withSession(getSupporterDetail)); +app.post("/admin/supporter/premium/update", requireTeamSession, withSession(updatePremium)); +app.post("/admin/supporter/collections/list", requireTeamSession, withSession(listCollections)); +app.post("/admin/supporter/collections/upsert", requireTeamSession, withSession(upsertCollection)); + +/* ---------- Öffentlicher Supporter-Bereich (eigene Session, requireSupporterAuth) ---------- */ + +app.post("/supporter/logout", requireSupporterAuth, logoutSupporter); +app.post("/supporter/me", requireSupporterAuth, withSupporter(getSupporterMe)); +app.post("/supporter/update-profile", requireSupporterAuth, withSupporter(updateSupporterProfile)); +app.post("/supporter/request-email-change", requireSupporterAuth, withSupporter(requestSupporterEmailChange)); +app.post("/supporter/confirm-email-change", requireSupporterAuth, withSupporter(confirmSupporterEmailChange)); +app.post("/supporter/redeem-premium", requireSupporterAuth, withSupporter(redeemPremium)); +app.post("/supporter/delete-account", requireSupporterAuth, withSupporter(deleteSupporterAccount)); +app.post("/supporter/paypal/start", requireSupporterAuth, withSupporter(startPaypalSubscription)); +app.post("/supporter/paypal/confirm", requireSupporterAuth, withSupporter(confirmPaypalSubscription)); +app.post("/supporter/paypal/cancel", requireSupporterAuth, withSupporter(cancelPaypalSubscription)); + +app.use((req, res) => json(res, { ok: false, error: "Not found." }, 404)); + +/* ---------- Cron: läuft jede Minute, hält den Live-Punkt aktuell (ersetzt Cloudflares scheduled()) ---------- */ +cron.schedule("* * * * *", () => { + refreshLiveFromTikTok().catch((err) => console.error("[live-status] Fehler beim Aktualisieren:", err?.message || err)); +}); + +app.listen(PORT, "127.0.0.1", () => { + console.log(`DOGFATHER UNIVERSE — Interner Bereich läuft auf http://127.0.0.1:${PORT}`); +}); diff --git a/server-internal/lib/audit.js b/server-internal/lib/audit.js new file mode 100644 index 0000000..dbf0793 --- /dev/null +++ b/server-internal/lib/audit.js @@ -0,0 +1,16 @@ +/* ===================================================================== + audit.js — Aktivitätsprotokoll. 1:1 portiert, D1 → better-sqlite3. + WICHTIG: Codes dürfen hier NIEMALS im Klartext auftauchen. + ===================================================================== */ +import { db } from "../db.js"; +import { nowIso } from "./crypto.js"; + +export function logAction(actor, action, target, detail) { + db.prepare(`INSERT INTO audit_log (actor, action, target, detail, created_at) VALUES (?, ?, ?, ?, ?)`).run( + actor, + action, + target || null, + detail ? JSON.stringify(detail) : null, + nowIso() + ); +} diff --git a/server-internal/lib/auth.js b/server-internal/lib/auth.js new file mode 100644 index 0000000..35c3237 --- /dev/null +++ b/server-internal/lib/auth.js @@ -0,0 +1,142 @@ +/* ===================================================================== + auth.js — Login, Sessions, Rate-Limiting/Lockout für den internen + Team-Bereich. 1:1 portiert aus cloudflare-worker/src/lib/auth.js, + D1 (async) → better-sqlite3 (synchron). + ===================================================================== */ +import { db } from "../db.js"; +import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js"; +import { logAction } from "./audit.js"; + +const MAX_ATTEMPTS = 5; +const LOCKOUT_MINUTES = 15; +const INACTIVITY_MS = 24 * 60 * 60 * 1000; +const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +export function clientKey(req) { + return req.headers["cf-connecting-ip"] || req.ip || "unknown"; +} + +function timingSafeEqual(a, b) { + if (a.length !== b.length) return false; + let mismatch = 0; + for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + return mismatch === 0; +} + +export function isLockedOut(key) { + const row = db.prepare(`SELECT locked_until FROM login_attempts WHERE key = ?`).get(key); + if (!row || !row.locked_until) return false; + return new Date(row.locked_until) > new Date(); +} + +export function recordFailedAttempt(key) { + const row = db.prepare(`SELECT count FROM login_attempts WHERE key = ?`).get(key); + const count = (row?.count || 0) + 1; + let lockedUntil = null; + if (count >= MAX_ATTEMPTS) { + lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString(); + } + db.prepare( + `INSERT INTO login_attempts (key, count, locked_until, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET count = ?, locked_until = ?, updated_at = ?` + ).run(key, count, lockedUntil, nowIso(), count, lockedUntil, nowIso()); + return { count, lockedUntil }; +} + +export function clearAttempts(key) { + db.prepare(`DELETE FROM login_attempts WHERE key = ?`).run(key); +} + +/** Prüft einen eingegebenen Code gegen Owner-Secret/-Hash und Team-Datenbank. */ +export async function verifyCode(code) { + const hash = await sha256Hex(code); + + const ownerRow = db.prepare(`SELECT value FROM app_settings WHERE key = 'owner_code_hash'`).get(); + + if (ownerRow && ownerRow.value) { + if (timingSafeEqual(hash, ownerRow.value)) return { ok: true, isOwner: true }; + } else if (process.env.POSTFACH_CODE && timingSafeEqual(code, process.env.POSTFACH_CODE)) { + return { ok: true, isOwner: true }; + } + + const user = db + .prepare(`SELECT id, name, username, role_id, status, expires_at FROM users WHERE code_hash = ?`) + .get(hash); + if (!user) return { ok: false }; + if (user.status !== "active") return { ok: false, reason: "locked" }; + if (user.expires_at && new Date(user.expires_at) < new Date()) return { ok: false, reason: "expired" }; + return { ok: true, isOwner: false, user }; +} + +export async function createSession({ isOwner, userId }) { + const token = generateSessionToken(); + const tokenHash = await sha256Hex(token); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString(); + db.prepare( + `INSERT INTO sessions (token_hash, user_id, is_owner, created_at, last_active_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)` + ).run(tokenHash, isOwner ? "owner" : userId, isOwner ? 1 : 0, now.toISOString(), now.toISOString(), expiresAt); + return token; +} + +export async function resolveSession(token) { + if (!token) return null; + const tokenHash = await sha256Hex(token); + const session = db.prepare(`SELECT * FROM sessions WHERE token_hash = ?`).get(tokenHash); + if (!session) return null; + + const now = Date.now(); + if (new Date(session.expires_at).getTime() < now) { + db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash); + return null; + } + if (now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) { + db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash); + return null; + } + + db.prepare(`UPDATE sessions SET last_active_at = ? WHERE token_hash = ?`).run(new Date().toISOString(), tokenHash); + + if (session.is_owner) { + return { isOwner: true, actor: "owner" }; + } + + const user = db + .prepare( + `SELECT u.id, u.name, u.username, u.role_id, u.status, r.name as role_name, r.permissions + FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?` + ) + .get(session.user_id); + if (!user || user.status !== "active") return null; + + const overridesRows = db.prepare(`SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?`).all(user.id); + const overrides = {}; + for (const r of overridesRows) overrides[r.permission] = !!r.allowed; + + return { + isOwner: false, + actor: user.username, + userId: user.id, + name: user.name, + roleId: user.role_id, + roleName: user.role_name, + permissions: user.permissions ? JSON.parse(user.permissions) : [], + overrides, + }; +} + +export async function endSession(token) { + if (!token) return; + const tokenHash = await sha256Hex(token); + db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash); +} + +export function endAllSessionsForUser(userId) { + db.prepare(`DELETE FROM sessions WHERE user_id = ?`).run(userId); +} + +export async function requireSession(req) { + const auth = req.headers["authorization"] || ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : null; + return resolveSession(token); +} diff --git a/server-internal/lib/crypto.js b/server-internal/lib/crypto.js new file mode 100644 index 0000000..dcf5c29 --- /dev/null +++ b/server-internal/lib/crypto.js @@ -0,0 +1,62 @@ +/* ===================================================================== + crypto.js — Verschlüsselung & Hashing für das Zugangssystem. 1:1 portiert + aus cloudflare-worker/src/lib/crypto.js (Web Crypto API, in Node 20+ + global verfügbar wie im Cloudflare Worker — keine Änderung nötig). + ===================================================================== */ + +const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne I,O,0,1 + +export function generateFriendlyCode() { + const bytes = crypto.getRandomValues(new Uint8Array(12)); + let raw = ""; + for (const b of bytes) raw += CODE_ALPHABET[b % CODE_ALPHABET.length]; + return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`; +} + +export function generateSessionToken() { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +export function generateId() { + return crypto.randomUUID(); +} + +export async function sha256Hex(text) { + const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function getAesKey() { + const raw = Uint8Array.from(atob(process.env.ENCRYPTION_KEY), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]); +} + +function bufToBase64(buf) { + return btoa(String.fromCharCode(...new Uint8Array(buf))); +} +function base64ToBuf(b64) { + return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); +} + +/** Verschlüsselt einen Klartext-Code reversibel. Rückgabe: "iv.ciphertext" (base64). */ +export async function encryptCode(plaintext) { + const key = await getAesKey(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext)); + return `${bufToBase64(iv)}.${bufToBase64(ciphertext)}`; +} + +/** Entschlüsselt einen mit encryptCode() erzeugten Wert. */ +export async function decryptCode(encoded) { + const [ivB64, ctB64] = encoded.split("."); + const key = await getAesKey(); + const iv = base64ToBuf(ivB64); + const ciphertext = base64ToBuf(ctB64); + const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); + return new TextDecoder().decode(plainBuf); +} + +export function nowIso() { + return new Date().toISOString(); +} diff --git a/server-internal/lib/discord-webhooks.js b/server-internal/lib/discord-webhooks.js new file mode 100644 index 0000000..980e608 --- /dev/null +++ b/server-internal/lib/discord-webhooks.js @@ -0,0 +1,68 @@ +/* ===================================================================== + lib/discord-webhooks.js — Bewerbungen als Discord-Nachricht posten. + 1:1 portiert aus cloudflare-worker/src/lib/discord-webhooks.js + (reines fetch(), env.X → process.env.X). + ===================================================================== */ + +const WEBHOOK_ENV_KEYS = { + modi: "DISCORD_WEBHOOK_MODI", + kooperation: "DISCORD_WEBHOOK_KOOPERATION", + scout: "DISCORD_WEBHOOK_SCOUT_MANAGER", + creator: "DISCORD_WEBHOOK_SCOUT_MANAGER", +}; + +const TYPE_LABELS = { + modi: "🩵 Neue Bewerbung — Modi (Team Dogi)", + kooperation: "🤝 Neue Kooperationsanfrage", + scout: "🔎 Neue Bewerbung — Scout (Spicy Media)", + creator: "🎬 Neue Bewerbung — Creator / Manager-Bewerbung (Spicy Media)", +}; + +const TYPE_COLORS = { modi: 0x8fd9ea, kooperation: 0x8b5cf6, scout: 0xd94a5c, creator: 0xd94a5c }; + +const FIELD_LABELS = { + name: "Name", email: "E-Mail", kontakt: "Kontakt (Discord/TikTok)", erfahrung: "Erfahrung", + verfuegbarkeit: "Verfügbarkeit", warum: "Warum möchtest du dabei sein?", platform: "Plattform", + content: "Content-Art", firma: "Firma / Projekt", idee: "Idee / Anliegen", +}; + +function feldWert(wert) { + const text = String(wert ?? "").trim(); + if (!text) return "_(leer)_"; + return text.length > 1000 ? text.slice(0, 1000) + "…" : text; +} + +export async function bewerbungAlsDiscordNachrichtPosten(type, data) { + const envKey = WEBHOOK_ENV_KEYS[type]; + const webhookUrl = envKey && process.env[envKey]; + if (!webhookUrl) { + console.error(`[discord-webhook] Keine Webhook-URL für "${type}" hinterlegt (${envKey}) — übersprungen.`); + return; + } + + const fields = Object.entries(data || {}) + .filter(([, v]) => String(v ?? "").trim() !== "") + .map(([key, value]) => ({ name: FIELD_LABELS[key] || key, value: feldWert(value), inline: String(value).length < 40 })); + + const embed = { + title: TYPE_LABELS[type] || "Neue Bewerbung", + color: TYPE_COLORS[type] ?? 0x8fd9ea, + fields: fields.length ? fields : [{ name: "Hinweis", value: "Keine Angaben übermittelt." }], + footer: { text: "DOGFATHER UNIVERSE — automatisch von der Homepage" }, + timestamp: new Date().toISOString(), + }; + + try { + const antwort = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ embeds: [embed] }), + }); + if (!antwort.ok) { + const antwortText = await antwort.text().catch(() => ""); + console.error(`[discord-webhook] Fehler beim Posten (${type}): HTTP ${antwort.status} — ${antwortText}`); + } + } catch (fehler) { + console.error(`[discord-webhook] Ausnahme beim Posten (${type}):`, fehler && fehler.message); + } +} diff --git a/server-internal/lib/google-auth.js b/server-internal/lib/google-auth.js new file mode 100644 index 0000000..10bbef2 --- /dev/null +++ b/server-internal/lib/google-auth.js @@ -0,0 +1,45 @@ +/* ===================================================================== + lib/google-auth.js — Verifiziert ein Google-ID-Token ("Mit Google + anmelden") serverseitig, für den öffentlichen Supporter-Login. + 1:1 portiert aus cloudflare-worker/src/lib/google-auth.js, env.X → process.env.X. + + Braucht GOOGLE_CLIENT_ID (öffentlich, KEIN Geheimnis). Ohne gesetzten + Wert bleibt "Mit Google anmelden" ausgeblendet (siehe getPublicConfig + in routes/supporter.js) statt kaputt zu sein. + + Verifikation läuft über Googles offiziellen tokeninfo-Endpunkt + (https://oauth2.googleapis.com/tokeninfo) — Google prüft dort Signatur, + Ablaufzeit und Aussteller selbst und gibt die Nutzdaten zurück. + ===================================================================== */ + +export class GoogleAuthNotConfiguredError extends Error { + constructor() { + super("Google-Login ist noch nicht eingerichtet (GOOGLE_CLIENT_ID fehlt)."); + this.name = "GoogleAuthNotConfiguredError"; + this.code = "GOOGLE_AUTH_NOT_CONFIGURED"; + } +} + +/** + * Prüft ein von Google Identity Services im Browser ausgestelltes ID-Token. + * Gibt bei Erfolg die bestätigten Nutzerdaten zurück (E-Mail ist von Google + * selbst bereits verifiziert — braucht deshalb KEINEN zusätzlichen + * E-Mail-Bestätigungscode mehr, siehe routes/supporter.js). + */ +export async function verifyGoogleIdToken(credential) { + if (!process.env.GOOGLE_CLIENT_ID) throw new GoogleAuthNotConfiguredError(); + if (!credential) return { ok: false, reason: "Kein Token übergeben." }; + + const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`); + if (!res.ok) return { ok: false, reason: "Google-Token konnte nicht geprüft werden." }; + const data = await res.json(); + + if (data.aud !== process.env.GOOGLE_CLIENT_ID) return { ok: false, reason: "Token ist für eine andere App ausgestellt." }; + if (!data.email || data.email_verified !== "true") return { ok: false, reason: "E-Mail-Adresse ist bei Google nicht bestätigt." }; + + return { + ok: true, + email: String(data.email).toLowerCase(), + name: data.name || data.given_name || data.email.split("@")[0], + }; +} diff --git a/server-internal/lib/http.js b/server-internal/lib/http.js new file mode 100644 index 0000000..b017743 --- /dev/null +++ b/server-internal/lib/http.js @@ -0,0 +1,10 @@ +/* ===================================================================== + lib/http.js — gemeinsamer JSON-Response-Helfer für Express. + 1:1 dem Sinn nach portiert aus cloudflare-worker/src/lib/http.js + (dort baute json() eine Response mit CORS-Headern; CORS läuft hier + stattdessen zentral über die "cors"-Middleware in index.js, siehe dort). + ===================================================================== */ + +export function json(res, data, status = 200) { + return res.status(status).json(data); +} diff --git a/server-internal/lib/live-status.js b/server-internal/lib/live-status.js new file mode 100644 index 0000000..4677688 --- /dev/null +++ b/server-internal/lib/live-status.js @@ -0,0 +1,133 @@ +/* ===================================================================== + lib/live-status.js — Automatische TikTok-Live-Erkennung für den grünen + Punkt auf der Startseite. 1:1 portiert aus dem Live-Status-Teil von + cloudflare-worker/src/worker.js, D1 → better-sqlite3. + + Ein Cron-Auftrag (node-cron in index.js, ersetzt Cloudflares + `scheduled()`) 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. + ===================================================================== */ +import { db } from "../db.js"; +import { json } from "./http.js"; +import { logAction } from "./audit.js"; +import { checkTikTokLive } from "./tiktok-live.js"; + +const DEFAULT_OVERRIDE_MINUTES = 180; + +function readSetting(key) { + const row = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(key); + if (!row || !row.value) return null; + try { + return JSON.parse(row.value); + } catch { + return null; + } +} + +function writeSetting(key, value) { + 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` + ).run(key, JSON.stringify(value), new Date().toISOString()); +} + +/** Ermittelt den Stand, der nach außen gilt (Vorrang: manuell vor Automatik). */ +function resolveLiveStatus() { + const auto = readSetting("live_auto") || {}; + const override = readSetting("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, + }; +} + +export async function handleLiveStatusGet(req, res) { + return json(res, { ok: true, ...resolveLiveStatus() }); +} + +/** + * Manueller Eingriff aus dem Postfach. + * { live: true|false, minutes?: number } → Vorrang setzen + * { auto: true } → Vorrang aufheben, Automatik übernimmt + */ +export async function handleLiveStatusSet(req, res, session) { + if (!session?.isOwner) return json(res, { ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 403); + const body = req.body || {}; + + if (body.auto === true) { + db.prepare(`DELETE FROM app_settings WHERE key = 'live_override'`).run(); + logAction("owner", "live.override_cleared", null, null); + return json(res, { ok: true, ...resolveLiveStatus() }); + } + + const minutes = Number.isFinite(body.minutes) ? Math.min(Math.max(body.minutes, 5), 1440) : DEFAULT_OVERRIDE_MINUTES; + const now = new Date(); + writeSetting("live_override", { + live: !!body.live, + setAt: now.toISOString(), + until: new Date(now.getTime() + minutes * 60_000).toISOString(), + }); + logAction("owner", "live.override_set", null, { live: !!body.live, minutes }); + return json(res, { ok: true, ...resolveLiveStatus() }); +} + +/** Wird vom Cron-Auftrag aufgerufen (index.js) — fragt TikTok ab und schreibt das Ergebnis. */ +export async function refreshLiveFromTikTok() { + const username = process.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 = readSetting("live_auto") || {}; + writeSetting("live_auto", { ...previous, ok: false, lastError: result.reason, checkedAt: now }); + return { ok: false, reason: result.reason }; + } + + const previous = readSetting("live_auto") || {}; + const changed = previous.live !== result.live; + + writeSetting("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) { + logAction("system", result.live ? "live.started" : "live.ended", null, { title: result.title }); + } + return { ok: true, live: result.live, changed }; +} diff --git a/server-internal/lib/paypal.js b/server-internal/lib/paypal.js new file mode 100644 index 0000000..b20a336 --- /dev/null +++ b/server-internal/lib/paypal.js @@ -0,0 +1,142 @@ +/* ===================================================================== + lib/paypal.js — PayPal Subscriptions API Anbindung (Supporter-Abo). + 1:1 portiert aus cloudflare-worker/src/lib/paypal.js, env.X → process.env.X. + + WICHTIG — offener Punkt (wie im Original): vollständig fertig + implementiert (OAuth2, Subscription erstellen, Subscription abfragen, + Webhook-Signatur prüfen), kann aber erst wirklich Geld verarbeiten, + sobald folgende echten Werte in der .env gesetzt sind: + PAYPAL_CLIENT_ID + PAYPAL_CLIENT_SECRET + PAYPAL_PLAN_ID (aus dem PayPal-Business-Konto) + PAYPAL_WEBHOOK_ID + PAYPAL_ENV = "sandbox" | "live" (unkritisch, kein Secret) + Diese Werte kann NUR der Website-Betreiber selbst erzeugen (PayPal- + Business-Konto + Developer-App sind an seine eigene Identität/Bank + gebunden) — deshalb "fail closed" mit sprechendem Fehler statt eines + Absturzes, solange sie fehlen. NIEMALS in .env.example oder Vault! + ===================================================================== */ + +export class PayPalNotConfiguredError extends Error { + constructor() { + super("PayPal ist noch nicht eingerichtet (PAYPAL_CLIENT_ID/SECRET/PLAN_ID fehlen)."); + this.name = "PayPalNotConfiguredError"; + this.code = "PAYPAL_NOT_CONFIGURED"; + } +} + +function baseUrl() { + return process.env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"; +} + +function assertConfigured() { + if (!process.env.PAYPAL_CLIENT_ID || !process.env.PAYPAL_CLIENT_SECRET || !process.env.PAYPAL_PLAN_ID) { + throw new PayPalNotConfiguredError(); + } +} + +let cachedToken = null; // { value, expiresAt } — pro Serverprozess, spart Requests + +async function getAccessToken() { + assertConfigured(); + if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.value; + + const res = await fetch(`${baseUrl()}/v1/oauth2/token`, { + method: "POST", + headers: { + Authorization: `Basic ${btoa(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`)}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "grant_type=client_credentials", + }); + if (!res.ok) throw new Error(`PayPal-OAuth fehlgeschlagen (${res.status}): ${await res.text().catch(() => "")}`); + const data = await res.json(); + cachedToken = { value: data.access_token, expiresAt: Date.now() + (data.expires_in || 3600) * 1000 }; + return cachedToken.value; +} + +async function paypalFetch(path, options = {}) { + const token = await getAccessToken(); + const res = await fetch(`${baseUrl()}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }); + const text = await res.text(); + const data = text ? JSON.parse(text) : null; + if (!res.ok) { + const err = new Error(`PayPal-Anfrage fehlgeschlagen (${res.status}): ${text.slice(0, 400)}`); + err.paypalStatus = res.status; + err.paypalBody = data; + throw err; + } + return data; +} + +/** + * Erstellt ein PayPal-Abonnement für einen Supporter und liefert den + * "approve"-Link zurück, zu dem der Nutzer weitergeleitet werden muss, um + * das Abo direkt bei PayPal zu bestätigen. custom_id trägt unsere interne + * supporter_id, damit der Webhook später weiß, wem die Zahlung gehört. + */ +export async function createSubscription({ supporterId, returnUrl, cancelUrl }) { + const data = await paypalFetch("/v1/billing/subscriptions", { + method: "POST", + headers: { "PayPal-Request-Id": `sub-${supporterId}-${Date.now()}` }, + body: JSON.stringify({ + plan_id: process.env.PAYPAL_PLAN_ID, + custom_id: supporterId, + application_context: { + brand_name: "Dogfather", + locale: "de-DE", + shipping_preference: "NO_SHIPPING", + user_action: "SUBSCRIBE_NOW", + return_url: returnUrl, + cancel_url: cancelUrl, + }, + }), + }); + const approveLink = (data.links || []).find((l) => l.rel === "approve"); + return { paypalSubscriptionId: data.id, approveUrl: approveLink?.href || null, raw: data }; +} + +export async function getSubscription(paypalSubscriptionId) { + return paypalFetch(`/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}`); +} + +/** Kündigung — Zugang bleibt bis Periodenende bestehen. */ +export async function cancelSubscription(paypalSubscriptionId, reason) { + await paypalFetch(`/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}/cancel`, { + method: "POST", + body: JSON.stringify({ reason: reason || "Kündigung durch Supporter" }), + }); + return true; +} + +/** + * Prüft die Echtheit eines eingehenden Webhooks über PayPals offizielle + * Verify-Signature-Funktion. `req` ist ein Express-Request (Header über + * req.headers Objekt, nicht .get()/.headers.get() wie bei Cloudflare). + */ +export async function verifyWebhookSignature(req, rawBody) { + assertConfigured(); + if (!process.env.PAYPAL_WEBHOOK_ID) throw new PayPalNotConfiguredError(); + + const h = req.headers; + const verification = await paypalFetch("/v1/notifications/verify-webhook-signature", { + method: "POST", + body: JSON.stringify({ + auth_algo: h["paypal-auth-algo"], + cert_url: h["paypal-cert-url"], + transmission_id: h["paypal-transmission-id"], + transmission_sig: h["paypal-transmission-sig"], + transmission_time: h["paypal-transmission-time"], + webhook_id: process.env.PAYPAL_WEBHOOK_ID, + webhook_event: JSON.parse(rawBody), + }), + }); + return verification.verification_status === "SUCCESS"; +} diff --git a/server-internal/lib/permissions.js b/server-internal/lib/permissions.js new file mode 100644 index 0000000..68bb761 --- /dev/null +++ b/server-internal/lib/permissions.js @@ -0,0 +1,74 @@ +/* ===================================================================== + permissions.js — Vollständiger Permission-Katalog. 1:1 portiert aus + cloudflare-worker/src/lib/permissions.js (keine Datenbank-Abhängigkeit). + ===================================================================== */ + +export const PERMISSIONS = { + APPLICATIONS_VIEW_CREATOR: "Creator-Bewerbungen ansehen", + APPLICATIONS_VIEW_MANAGER_SCOUT: "Manager-/Scout-Bewerbungen ansehen", + APPLICATIONS_VIEW_ARCHIVED: "Archivierte Bewerbungen ansehen", + APPLICATIONS_OPEN_FULL: "Vollständige Bewerbungen öffnen", + + APPLICATIONS_TAKE_OVER: "Bewerbungen übernehmen", + APPLICATIONS_ASSIGN: "Bewerbungen anderen Personen zuweisen", + APPLICATIONS_CHANGE_STATUS: "Status verändern", + APPLICATIONS_ARCHIVE: "Bewerbungen archivieren", + APPLICATIONS_RESTORE: "Bewerbungen wiederherstellen", + APPLICATIONS_DELETE: "Bewerbungen löschen", + + DRAFTS_CREATE: "Antwortentwürfe erstellen", + DRAFTS_EDIT_OWN: "Eigene Entwürfe bearbeiten", + DRAFTS_EDIT_OTHERS: "Entwürfe anderer Personen bearbeiten", + REPLIES_COPY: "Fertige Antworten kopieren", + EMAILS_COPY: "E-Mail-Adressen kopieren", + APPLICATIONS_MARK_ANSWERED: "Bewerbungen als beantwortet markieren", + + NOTES_READ: "Notizen lesen", + NOTES_WRITE: "Notizen schreiben", + NOTES_EDIT_OWN: "Eigene Notizen bearbeiten", + NOTES_EDIT_OTHERS: "Fremde Notizen bearbeiten", + NOTES_DELETE: "Notizen löschen", + + TEAM_VIEW: "Andere Teammitglieder ansehen", + TEAM_PREPARE_USER: "Neue Benutzer vorbereiten", + TEAM_LOCK_USER: "Benutzer sperren", + TEAM_ASSIGN_ROLE: "Rollen vergeben", + TEAM_CREATE_ROLE: "Rollen erstellen", + TEAM_EDIT_PERMISSIONS: "Allgemeine Berechtigungen verändern", + + SETTINGS_EDIT_FORMS: "Bewerbungsformulare bearbeiten", + SETTINGS_EDIT_STATUS_TYPES: "Statusarten verändern", + SETTINGS_VIEW_STATS: "Statistiken ansehen", + SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen", + SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten", + + SUPPORTER_VIEW: "Supporter-Abonnenten ansehen", + SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten", + SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten", +}; + +export const PERMISSION_GROUPS = [ + { label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] }, + { label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] }, + { label: "Antworten", keys: ["DRAFTS_CREATE", "DRAFTS_EDIT_OWN", "DRAFTS_EDIT_OTHERS", "REPLIES_COPY", "EMAILS_COPY", "APPLICATIONS_MARK_ANSWERED"] }, + { label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] }, + { label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] }, + { label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] }, + { label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] }, +]; + +export const OWNER_ONLY_ACTIONS = [ + "CODE_VIEW", "CODE_COPY", "CODE_CREATE", "CODE_EDIT", "CODE_RESET", + "EDIT_OWNER_ACCOUNT", "DELETE_OR_LIMIT_OWNER_ROLE", "PROMOTE_TO_OWNER", +]; + +export function hasPermission(rolePermissions, overrides, key) { + if (overrides && Object.prototype.hasOwnProperty.call(overrides, key)) { + return !!overrides[key]; + } + return Array.isArray(rolePermissions) && rolePermissions.includes(key); +} + +export function isValidPermissionKey(key) { + return Object.prototype.hasOwnProperty.call(PERMISSIONS, key); +} diff --git a/server-internal/lib/supporter-auth.js b/server-internal/lib/supporter-auth.js new file mode 100644 index 0000000..8ef57bb --- /dev/null +++ b/server-internal/lib/supporter-auth.js @@ -0,0 +1,86 @@ +/* ===================================================================== + lib/supporter-auth.js — Registrierung, E-Mail-Code-Login, Sessions für + den öffentlichen Supporter-Bereich. 1:1 portiert, D1 → better-sqlite3. + ===================================================================== */ +import { db } from "../db.js"; +import { sha256Hex, generateSessionToken, generateId, nowIso } from "./crypto.js"; + +const CODE_TTL_MS = 15 * 60 * 1000; +const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; +const INACTIVITY_MS = 14 * 24 * 60 * 60 * 1000; + +export function normalizeTikTokUsername(input) { + if (!input) return null; + return String(input).trim().replace(/^@+/, "").toLowerCase() || null; +} + +export function normalizeEmail(input) { + return String(input || "").trim().toLowerCase(); +} + +function generateNumericCode() { + const n = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000; + return String(n).padStart(6, "0"); +} + +export async function issueAuthCode(supporterId, purpose) { + const code = generateNumericCode(); + const codeHash = await sha256Hex(code); + db.prepare( + `INSERT INTO supporter_auth_codes (id, supporter_id, purpose, code_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)` + ).run(generateId(), supporterId, purpose, codeHash, new Date(Date.now() + CODE_TTL_MS).toISOString(), nowIso()); + return code; +} + +export async function verifyAuthCode(supporterId, purpose, code) { + const codeHash = await sha256Hex(String(code || "").trim()); + const row = db + .prepare( + `SELECT id, expires_at, used_at FROM supporter_auth_codes + WHERE supporter_id = ? AND purpose = ? AND code_hash = ? ORDER BY created_at DESC LIMIT 1` + ) + .get(supporterId, purpose, codeHash); + if (!row) return false; + if (row.used_at) return false; + if (new Date(row.expires_at).getTime() < Date.now()) return false; + db.prepare(`UPDATE supporter_auth_codes SET used_at = ? WHERE id = ?`).run(nowIso(), row.id); + return true; +} + +export async function createSupporterSession(supporterId) { + const token = generateSessionToken(); + const tokenHash = await sha256Hex(token); + const now = new Date(); + db.prepare( + `INSERT INTO supporter_sessions (token_hash, supporter_id, created_at, last_active_at, expires_at) VALUES (?, ?, ?, ?, ?)` + ).run(tokenHash, supporterId, now.toISOString(), now.toISOString(), new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString()); + return token; +} + +export async function resolveSupporterSession(token) { + if (!token) return null; + const tokenHash = await sha256Hex(token); + const session = db.prepare(`SELECT * FROM supporter_sessions WHERE token_hash = ?`).get(tokenHash); + if (!session) return null; + + const now = Date.now(); + if (new Date(session.expires_at).getTime() < now || now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) { + db.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).run(tokenHash); + return null; + } + db.prepare(`UPDATE supporter_sessions SET last_active_at = ? WHERE token_hash = ?`).run(nowIso(), tokenHash); + + const supporter = db.prepare(`SELECT * FROM supporter_users WHERE id = ? AND status = 'active'`).get(session.supporter_id); + return supporter || null; +} + +export async function endSupporterSession(token) { + if (!token) return; + db.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).run(await sha256Hex(token)); +} + +export async function requireSupporterSession(req) { + const auth = req.headers["authorization"] || ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : null; + return resolveSupporterSession(token); +} diff --git a/server-internal/lib/supporter-cycle.js b/server-internal/lib/supporter-cycle.js new file mode 100644 index 0000000..5d0fab8 --- /dev/null +++ b/server-internal/lib/supporter-cycle.js @@ -0,0 +1,57 @@ +/* ===================================================================== + lib/supporter-cycle.js — Reine Rechenlogik für den 30-Monats-Prämienzyklus. + 1:1 portiert, unverändert (keine D1-/Netzwerkabhängigkeit). + ===================================================================== */ + +export const CYCLE_LENGTH = 30; + +export const MILESTONES = [ + { month: 6, type: "teddy_klein", labelDe: "Kleiner Hasen-Teddy" }, + { month: 12, type: "teddy_mittel", labelDe: "Mittelgroßer Hasen-Teddy" }, + { month: 18, type: "teddy_gross", labelDe: "Großer Hasen-Teddy" }, + { month: 24, type: "tasse_karte", labelDe: "Tasse & Autogrammkarte" }, + { month: 30, type: "hoodie", labelDe: "Exklusiver Hoodie" }, +]; + +export function computeProgress(totalPaidMonths) { + const total = Math.max(0, Math.floor(totalPaidMonths || 0)); + if (total === 0) return { totalPaidMonths: 0, completedCycles: 0, cycleNumber: 1, currentCycleMonth: 0 }; + const completedCycles = Math.floor((total - 1) / CYCLE_LENGTH); + const currentCycleMonth = total - completedCycles * CYCLE_LENGTH; + return { totalPaidMonths: total, completedCycles, cycleNumber: completedCycles + 1, currentCycleMonth }; +} + +export function reachedMilestonesInCurrentCycle(currentCycleMonth) { + return MILESTONES.filter((m) => currentCycleMonth >= m.month); +} + +export function nextMilestone(currentCycleMonth) { + return MILESTONES.find((m) => currentCycleMonth < m.month) || null; +} + +export function allUnlockedMilestones(totalPaidMonths) { + const { completedCycles, currentCycleMonth } = computeProgress(totalPaidMonths); + const unlocked = []; + for (let c = 1; c <= completedCycles; c++) { + for (const m of MILESTONES) unlocked.push({ cycleNumber: c, ...m }); + } + const cycleNumber = completedCycles + 1; + for (const m of reachedMilestonesInCurrentCycle(currentCycleMonth)) { + unlocked.push({ cycleNumber, ...m }); + } + return unlocked; +} + +export function timelineForCurrentCycle(totalPaidMonths) { + const { currentCycleMonth, cycleNumber } = computeProgress(totalPaidMonths); + return { + cycleNumber, + currentCycleMonth, + milestones: MILESTONES.map((m) => ({ + ...m, + reached: currentCycleMonth >= m.month, + isNext: nextMilestone(currentCycleMonth)?.month === m.month, + monthsRemaining: Math.max(0, m.month - currentCycleMonth), + })), + }; +} diff --git a/server-internal/lib/supporter-mail.js b/server-internal/lib/supporter-mail.js new file mode 100644 index 0000000..d3c4314 --- /dev/null +++ b/server-internal/lib/supporter-mail.js @@ -0,0 +1,74 @@ +/* ===================================================================== + lib/supporter-mail.js — Transaktions-E-Mails für den Supporter-Bereich + (Resend-Adapter). 1:1 portiert, env.X → process.env.X. + ===================================================================== */ + +export class EmailNotConfiguredError extends Error { + constructor() { + super("E-Mail-Versand ist noch nicht eingerichtet (RESEND_API_KEY fehlt)."); + this.name = "EmailNotConfiguredError"; + this.code = "EMAIL_NOT_CONFIGURED"; + } +} + +async function send({ to, subject, html }) { + if (!process.env.RESEND_API_KEY || !process.env.RESEND_FROM) throw new EmailNotConfiguredError(); + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ from: process.env.RESEND_FROM, to: [to], subject, html }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`E-Mail-Versand fehlgeschlagen (${res.status}): ${text.slice(0, 300)}`); + } + return true; +} + +const WRAP = (title, bodyHtml) => ` +
+
+

${title}

+ ${bodyHtml} +

DogiCrew 🩵

+
+
`; + +export async function sendVerifyEmailCode({ to, name, code }) { + return send({ + to, + subject: "Bestätige deine E-Mail-Adresse — DogiCrew 🩵", + html: WRAP( + `Willkommen, ${name}! 🩵`, + `

Bestätige deine E-Mail-Adresse mit diesem Code:

+

${code}

+

Der Code ist 15 Minuten gültig.

` + ), + }); +} + +export async function sendLoginCode({ to, name, code }) { + return send({ + to, + subject: "Dein Login-Code — Dogfather Supporter-Bereich", + html: WRAP( + `Hallo ${name} 👋`, + `

Dein Login-Code für den Supporter-Bereich:

+

${code}

+

Der Code ist 15 Minuten gültig. Hast du das nicht angefordert, ignoriere diese E-Mail.

` + ), + }); +} + +export async function sendMilestoneUnlocked({ to, name, premiumLabel }) { + return send({ + to, + subject: `Neuer Meilenstein freigeschaltet — ${premiumLabel} 🎉`, + html: WRAP( + `Glückwunsch, ${name}! 🩵`, + `

Du hast einen neuen Meilenstein erreicht und kannst jetzt deine Prämie einlösen:

+

${premiumLabel}

+

Logge dich in deinen Supporter-Bereich ein, um die Prämie einzulösen.

` + ), + }); +} diff --git a/server-internal/lib/ticketanizer.js b/server-internal/lib/ticketanizer.js new file mode 100644 index 0000000..f7e6f5c --- /dev/null +++ b/server-internal/lib/ticketanizer.js @@ -0,0 +1,60 @@ +/* ===================================================================== + lib/ticketanizer.js — Bewerbungen als Ticket bei Ticketanizer eröffnen. + 1:1 portiert aus cloudflare-worker/src/lib/ticketanizer.js. + ===================================================================== */ + +const TICKETANIZER_BASIS_URL = "https://api.ticketanizer.com"; + +const PANEL_IDS = { modi: 4, kooperation: 5, scout: 6, creator: 7 }; + +const FIELD_LABELS = { + name: "Name", email: "E-Mail", kontakt: "Kontakt (Discord/TikTok)", erfahrung: "Erfahrung", + verfuegbarkeit: "Verfügbarkeit", warum: "Warum möchtest du dabei sein?", platform: "Plattform", + content: "Content-Art", firma: "Firma / Projekt", idee: "Idee / Anliegen", +}; + +function ersteNachrichtAufbauen(type, data) { + const zeilen = Object.entries(data || {}) + .filter(([, v]) => String(v ?? "").trim() !== "") + .map(([key, value]) => `**${FIELD_LABELS[key] || key}:** ${String(value).trim()}`); + if (!zeilen.length) zeilen.push("_Keine weiteren Angaben übermittelt._"); + const text = zeilen.join("\n"); + return text.length > 3500 ? text.slice(0, 3500) + "…" : text; +} + +const warten = (ms) => new Promise((fertig) => setTimeout(fertig, ms)); +const MAX_VERSUCHE = 3; +const STANDARD_WARTEZEITEN_MS = [2000, 5000]; + +async function ticketAnfrageSenden(apiKey, panelId, visitorName, firstMessage) { + for (let versuch = 1; versuch <= MAX_VERSUCHE; versuch++) { + const antwort = await fetch(`${TICKETANIZER_BASIS_URL}/v1/tickets`, { + method: "POST", + headers: { "X-API-Key": apiKey, "Content-Type": "application/json" }, + body: JSON.stringify({ panel_id: panelId, visitor_name: visitorName, first_message: firstMessage }), + }); + if (antwort.status !== 429 || versuch === MAX_VERSUCHE) return antwort; + const retryAfterHeader = Number(antwort.headers.get("Retry-After")); + const wartezeit = Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader * 1000 : STANDARD_WARTEZEITEN_MS[versuch - 1] || 5000; + await warten(wartezeit); + } +} + +export async function bewerbungAlsTicketAnlegen(type, data) { + const apiKey = process.env.TICKETANIZER_API_KEY; + const panelId = PANEL_IDS[type]; + if (!apiKey || !panelId) return; + + const visitorName = String(data?.name || data?.firma || "Unbekannt").trim().slice(0, 80) || "Unbekannt"; + const firstMessage = ersteNachrichtAufbauen(type, data); + + try { + const antwort = await ticketAnfrageSenden(apiKey, panelId, visitorName, firstMessage); + if (!antwort.ok) { + const antwortText = await antwort.text().catch(() => ""); + console.error(`[ticketanizer] Fehler beim Ticket-Anlegen (${type}): HTTP ${antwort.status} — ${antwortText}`); + } + } catch (fehler) { + console.error(`[ticketanizer] Ausnahme beim Ticket-Anlegen (${type}):`, fehler && fehler.message); + } +} diff --git a/server-internal/lib/tiktok-live.js b/server-internal/lib/tiktok-live.js new file mode 100644 index 0000000..91fef27 --- /dev/null +++ b/server-internal/lib/tiktok-live.js @@ -0,0 +1,71 @@ +/* ===================================================================== + lib/tiktok-live.js — Automatische Live-Erkennung für TikTok. 1:1 portiert + aus cloudflare-worker/src/lib/tiktok-live.js (das "cf"-Fetch-Sonderfeld + von Cloudflare Workers entfällt, hat in Node keine Entsprechung/Wirkung). + ===================================================================== */ + +const LIVE_STATUS_RUNNING = 2; + +const BROWSER_HEADERS = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "de-DE,de;q=0.9,en;q=0.8", + "Cache-Control": "no-cache", + Pragma: "no-cache", +}; + +function extractScriptJson(html, id) { + const marker = `", open); + if (end === -1) return null; + try { + return JSON.parse(html.slice(open + 1, end)); + } catch { + return null; + } +} + +export async function checkTikTokLive(username) { + const handle = String(username || "").replace(/^@/, "").trim(); + if (!handle) return { ok: false, reason: "kein-benutzername" }; + + let res; + try { + res = await fetch(`https://www.tiktok.com/@${encodeURIComponent(handle)}/live`, { + headers: BROWSER_HEADERS, + redirect: "follow", + }); + } catch (err) { + return { ok: false, reason: `netzwerkfehler: ${String(err).slice(0, 120)}` }; + } + + if (!res.ok) return { ok: false, reason: `http-${res.status}` }; + + const html = await res.text(); + if (html.length < 5000) return { ok: false, reason: "seite-zu-klein-vermutlich-blockiert" }; + + const sigi = extractScriptJson(html, "SIGI_STATE"); + let info = sigi?.LiveRoom?.liveRoomUserInfo; + + if (!info) { + const universal = extractScriptJson(html, "__UNIVERSAL_DATA_FOR_REHYDRATION__"); + info = universal?.__DEFAULT_SCOPE__?.["webapp.live-detail"]?.liveRoomUserInfo; + } + if (!info) return { ok: false, reason: "struktur-nicht-gefunden" }; + + const status = info.liveRoom?.status; + if (typeof status !== "number") return { ok: false, reason: "kein-status-feld" }; + + return { + ok: true, + live: status === LIVE_STATUS_RUNNING, + rawStatus: status, + title: info.liveRoom?.title || null, + roomId: info.user?.roomId ? String(info.user.roomId) : null, + followers: info.stats?.followerCount ?? null, + }; +} diff --git a/server-internal/package.json b/server-internal/package.json new file mode 100644 index 0000000..f6080b7 --- /dev/null +++ b/server-internal/package.json @@ -0,0 +1,19 @@ +{ + "name": "dogfather-universe-internal-server", + "type": "module", + "version": "1.0.0", + "description": "Node.js/Express-Server als Ersatz für den 'dogfather-universe-postfach' Cloudflare Worker: Bewerbungs-Postfach, Team-Zugangsverwaltung mit Rollen/Rechten, und das DogiCrew-Supporter-Abo (PayPal). 1:1 portiert aus cloudflare-worker/.", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "better-sqlite3": "^11.8.1", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "node-cron": "^3.0.3" + } +} diff --git a/server-internal/routes/applications.js b/server-internal/routes/applications.js new file mode 100644 index 0000000..239fc21 --- /dev/null +++ b/server-internal/routes/applications.js @@ -0,0 +1,228 @@ +/* ===================================================================== + routes/applications.js — Bewerbungen (Postfach), mit vollem Rollen-/ + Rechte-Modell statt einem pauschalen Owner/Helfer-Zugriff. Status, + Zuweisung, Notizen und Antwortentwürfe pro Bewerbung. 1:1 portiert aus + cloudflare-worker/src/routes/applications.js. + ===================================================================== */ +import { db } from "../db.js"; +import { generateId, nowIso } from "../lib/crypto.js"; +import { hasPermission } from "../lib/permissions.js"; +import { logAction } from "../lib/audit.js"; +import { json } from "../lib/http.js"; +import { bewerbungAlsTicketAnlegen } from "../lib/ticketanizer.js"; +import { bewerbungAlsDiscordNachrichtPosten } from "../lib/discord-webhooks.js"; + +const ALLOWED_TYPES = ["creator", "scout", "kooperation", "modi"]; + +function can(session, key) { + if (!session) return false; + if (session.isOwner) return true; + return hasPermission(session.permissions, session.overrides, key); +} + +function viewPermissionForType(type) { + // Der Rechte-Katalog kommt fest aus dem Anforderungsdokument und kennt nur + // zwei Bewerbungs-Sichtbereiche (Creator / "Rest"). Scout, Kooperation und + // Modi laufen deshalb alle über APPLICATIONS_VIEW_MANAGER_SCOUT — es wird + // bewusst KEIN neuer Permission-Key erfunden, der nicht im Dokument steht. + return type === "creator" ? "APPLICATIONS_VIEW_CREATOR" : "APPLICATIONS_VIEW_MANAGER_SCOUT"; +} + +export async function submitApplication(req, res) { + const body = req.body; + const { type, data } = body || {}; + if (!ALLOWED_TYPES.includes(type) || typeof data !== "object" || !data) { + return json(res, { ok: false, error: "Ungültige Bewerbungsdaten." }, 400); + } + const values = Object.values(data).map((v) => String(v || "").trim()); + if (values.every((v) => v === "")) return json(res, { ok: false, error: "Leere Bewerbung." }, 400); + + const id = generateId(); + db.prepare(`INSERT INTO applications (id, type, data, status, archived, created_at) VALUES (?, ?, ?, 'neu', 0, ?)`).run( + id, + type, + JSON.stringify(data), + nowIso() + ); + + // Zusätzlich (rein informativ, blockiert die Bewerbung selbst nie): + // 1) echtes Ticket bei Ticketanizer im passenden Bereich anlegen + // (Web-Inbox zum Bearbeiten/Antworten) + // 2) direkte, garantiert sichtbare Discord-Nachricht in den passenden + // Kanal posten (Ticketanizer erzeugt bei API-Tickets keinen echten + // Discord-Kanal, siehe discord-webhooks.js) + // Läuft im Hintergrund weiter (kein ctx.waitUntil-Äquivalent in Express + // nötig — der Node-Prozess bleibt am Leben), blockiert aber die Antwort + // an die bewerbende Person nicht. + Promise.all([bewerbungAlsTicketAnlegen(type, data), bewerbungAlsDiscordNachrichtPosten(type, data)]).catch(() => {}); + + return json(res, { ok: true }); +} + +export async function listApplications(req, res, session) { + if (!session) return json(res, { ok: false, error: "Nicht angemeldet." }, 401); + const body = req.body || {}; + const includeArchived = !!body.includeArchived; + + const allowedTypes = ALLOWED_TYPES.filter((t) => session.isOwner || can(session, viewPermissionForType(t))); + if (allowedTypes.length === 0) return json(res, { ok: true, applications: [] }); + if (includeArchived && !session.isOwner && !can(session, "APPLICATIONS_VIEW_ARCHIVED")) { + return json(res, { ok: false, error: "Keine Berechtigung für archivierte Bewerbungen." }, 403); + } + + const placeholders = allowedTypes.map(() => "?").join(","); + const archivedClause = includeArchived ? "" : "AND archived = 0"; + const rows = db + .prepare(`SELECT * FROM applications WHERE type IN (${placeholders}) ${archivedClause} ORDER BY created_at DESC LIMIT 300`) + .all(...allowedTypes); + + const applications = rows.map((r) => ({ + id: r.id, + type: r.type, + data: session.isOwner || can(session, "APPLICATIONS_OPEN_FULL") ? JSON.parse(r.data) : null, + status: r.status, + assignedTo: r.assigned_to, + archived: !!r.archived, + createdAt: r.created_at, + })); + + return json(res, { ok: true, applications }); +} + +export async function changeApplicationStatus(req, res, session) { + if (!can(session, "APPLICATIONS_CHANGE_STATUS")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + const status = String(body.status || "").slice(0, 40); + db.prepare(`UPDATE applications SET status = ? WHERE id = ?`).run(status, id); + logAction(session.actor, "application.status", id, { status }); + return json(res, { ok: true }); +} + +export async function takeOverApplication(req, res, session) { + if (!can(session, "APPLICATIONS_TAKE_OVER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + const who = session.isOwner ? "owner" : session.userId; + db.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).run(who, id); + logAction(session.actor, "application.take_over", id, null); + return json(res, { ok: true }); +} + +export async function assignApplication(req, res, session) { + if (!can(session, "APPLICATIONS_ASSIGN")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + const assignedTo = body.assignedTo ? String(body.assignedTo) : null; + db.prepare(`UPDATE applications SET assigned_to = ? WHERE id = ?`).run(assignedTo, id); + logAction(session.actor, "application.assign", id, { assignedTo }); + return json(res, { ok: true }); +} + +export async function archiveApplication(req, res, session) { + if (!can(session, "APPLICATIONS_ARCHIVE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + db.prepare(`UPDATE applications SET archived = 1 WHERE id = ?`).run(id); + logAction(session.actor, "application.archive", id, null); + return json(res, { ok: true }); +} + +export async function restoreApplication(req, res, session) { + if (!can(session, "APPLICATIONS_RESTORE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + db.prepare(`UPDATE applications SET archived = 0 WHERE id = ?`).run(id); + logAction(session.actor, "application.restore", id, null); + return json(res, { ok: true }); +} + +export async function deleteApplication(req, res, session) { + if (!can(session, "APPLICATIONS_DELETE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + db.prepare(`DELETE FROM application_notes WHERE application_id = ?`).run(id); + db.prepare(`DELETE FROM application_drafts WHERE application_id = ?`).run(id); + db.prepare(`DELETE FROM applications WHERE id = ?`).run(id); + logAction(session.actor, "application.delete", id, null); + return json(res, { ok: true }); +} + +export async function markAnswered(req, res, session) { + if (!can(session, "APPLICATIONS_MARK_ANSWERED")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + db.prepare(`UPDATE applications SET status = 'beantwortet' WHERE id = ?`).run(id); + logAction(session.actor, "application.mark_answered", id, null); + return json(res, { ok: true }); +} + +/* ---- Notizen ---- */ +export async function listNotes(req, res, session) { + if (!can(session, "NOTES_READ")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const rows = db + .prepare(`SELECT * FROM application_notes WHERE application_id = ? ORDER BY created_at ASC`) + .all(String(body.applicationId || "")); + return json(res, { ok: true, notes: rows }); +} + +export async function addNote(req, res, session) { + if (!can(session, "NOTES_WRITE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const text = String(body.text || "").slice(0, 2000); + if (!text.trim()) return json(res, { ok: false, error: "Leere Notiz." }, 400); + db.prepare(`INSERT INTO application_notes (application_id, author, text, created_at) VALUES (?, ?, ?, ?)`).run( + String(body.applicationId || ""), + session.actor, + text, + nowIso() + ); + logAction(session.actor, "note.add", body.applicationId, null); + return json(res, { ok: true }); +} + +export async function deleteNote(req, res, session) { + if (!can(session, "NOTES_DELETE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + db.prepare(`DELETE FROM application_notes WHERE id = ?`).run(Number(body.id)); + logAction(session.actor, "note.delete", String(body.id), null); + return json(res, { ok: true }); +} + +/* ---- Antwortentwürfe ---- */ +export async function listDrafts(req, res, session) { + const body = req.body || {}; + const rows = db + .prepare(`SELECT * FROM application_drafts WHERE application_id = ? ORDER BY updated_at DESC`) + .all(String(body.applicationId || "")); + return json(res, { ok: true, drafts: rows }); +} + +export async function saveDraft(req, res, session) { + const body = req.body || {}; + const content = String(body.content || "").slice(0, 5000); + + if (body.id) { + const existing = db.prepare(`SELECT * FROM application_drafts WHERE id = ?`).get(Number(body.id)); + if (!existing) return json(res, { ok: false, error: "Entwurf nicht gefunden." }, 404); + const isOwnDraft = existing.author === session.actor; + if (!can(session, isOwnDraft ? "DRAFTS_EDIT_OWN" : "DRAFTS_EDIT_OTHERS")) { + return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + } + db.prepare(`UPDATE application_drafts SET content = ?, updated_at = ? WHERE id = ?`).run(content, nowIso(), Number(body.id)); + logAction(session.actor, "draft.update", String(body.id), null); + return json(res, { ok: true }); + } + + if (!can(session, "DRAFTS_CREATE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + db.prepare(`INSERT INTO application_drafts (application_id, author, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`).run( + String(body.applicationId || ""), + session.actor, + content, + nowIso(), + nowIso() + ); + logAction(session.actor, "draft.create", body.applicationId, null); + return json(res, { ok: true }); +} diff --git a/server-internal/routes/audit.js b/server-internal/routes/audit.js new file mode 100644 index 0000000..7dc7b14 --- /dev/null +++ b/server-internal/routes/audit.js @@ -0,0 +1,20 @@ +/* ===================================================================== + routes/audit.js — Aktivitätsprotokoll ansehen. 1:1 portiert aus + cloudflare-worker/src/routes/audit.js. + ===================================================================== */ +import { db } from "../db.js"; +import { hasPermission } from "../lib/permissions.js"; +import { json } from "../lib/http.js"; + +export async function listAuditLog(req, res, session) { + if (!session) return json(res, { ok: false, error: "Nicht angemeldet." }, 401); + if (!session.isOwner && !hasPermission(session.permissions, session.overrides, "SETTINGS_VIEW_ACTIVITY_LOG")) { + return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + } + const body = req.body || {}; + const limit = Math.min(Number(body.limit) || 100, 500); + const rows = db + .prepare(`SELECT id, actor, action, target, detail, created_at FROM audit_log ORDER BY created_at DESC LIMIT ?`) + .all(limit); + return json(res, { ok: true, entries: rows }); +} diff --git a/server-internal/routes/auth.js b/server-internal/routes/auth.js new file mode 100644 index 0000000..83d4131 --- /dev/null +++ b/server-internal/routes/auth.js @@ -0,0 +1,104 @@ +/* ===================================================================== + routes/auth.js — Login / Logout (interner Team-Bereich). + 1:1 portiert aus cloudflare-worker/src/routes/auth.js. + ===================================================================== */ +import { db } from "../db.js"; +import { verifyCode, createSession, endSession, isLockedOut, recordFailedAttempt, clearAttempts, clientKey } from "../lib/auth.js"; +import { logAction } from "../lib/audit.js"; +import { nowIso, sha256Hex } from "../lib/crypto.js"; +import { json } from "../lib/http.js"; + +/** + * Der Hauptadministrator ändert seinen eigenen Zugangscode. + * Nur mit gültiger Owner-Session UND erneuter Eingabe des aktuellen Codes. + * Gespeichert wird ausschließlich der SHA-256-Hash — nie der Klartext. + */ +export async function setOwnerCode(req, res, session) { + if (!session || !session.isOwner) { + return json(res, { ok: false, error: "Das darf nur der Hauptadministrator." }, 403); + } + + const body = req.body || {}; + const currentCode = String(body.currentCode || ""); + const newCode = String(body.newCode || ""); + + const check = await verifyCode(currentCode); + if (!check.ok || !check.isOwner) { + logAction("owner", "owner.code_change_failed", null, null); + return json(res, { ok: false, error: "Der aktuelle Code stimmt nicht." }, 403); + } + + if (newCode.length < 10) { + return json(res, { ok: false, error: "Der neue Code muss mindestens 10 Zeichen haben." }, 400); + } + if (newCode === currentCode) { + return json(res, { ok: false, error: "Der neue Code muss sich vom alten unterscheiden." }, 400); + } + + const hash = await sha256Hex(newCode); + db.prepare( + `INSERT INTO app_settings (key, value, updated_at) VALUES ('owner_code_hash', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` + ).run(hash, nowIso()); + + logAction("owner", "owner.code_changed", null, null); + return json(res, { ok: true }); +} + +export async function login(req, res) { + const body = req.body || {}; + const code = String(body.code || ""); + const key = `login:${clientKey(req)}`; + + if (isLockedOut(key)) { + return json(res, { ok: false, error: "Zu viele Fehlversuche. Bitte in ein paar Minuten erneut versuchen." }, 429); + } + + const result = await verifyCode(code); + if (!result.ok) { + const { count } = recordFailedAttempt(key); + logAction("anonym", "login.failed", null, { attempt: count }); + if (result.reason === "locked") return json(res, { ok: false, error: "Dieser Zugang ist gesperrt." }, 403); + if (result.reason === "expired") return json(res, { ok: false, error: "Dieser Zugang ist abgelaufen." }, 403); + return json(res, { ok: false, error: "Falscher Code." }, 401); + } + + clearAttempts(key); + + if (result.isOwner) { + const token = await createSession({ isOwner: true }); + logAction("owner", "login.success", null, null); + return json(res, { ok: true, token, isOwner: true, name: "Dogi", roleName: "Hauptadministrator" }); + } + + const token = await createSession({ isOwner: false, userId: result.user.id }); + db.prepare(`UPDATE users SET last_login_at = ? WHERE id = ?`).run(nowIso(), result.user.id); + + const roleRow = result.user.role_id + ? db.prepare(`SELECT name, permissions FROM roles WHERE id = ?`).get(result.user.role_id) + : null; + const overridesRows = db.prepare(`SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?`).all(result.user.id); + const overrides = {}; + for (const r of overridesRows) overrides[r.permission] = !!r.allowed; + const permissions = roleRow ? JSON.parse(roleRow.permissions || "[]") : []; + + logAction(result.user.username, "login.success", null, null); + + return json(res, { + ok: true, + token, + isOwner: false, + name: result.user.name, + username: result.user.username, + roleName: roleRow?.name || "Ohne Rolle", + permissions, + overrides, + }); +} + +export async function logout(req, res) { + const auth = req.headers["authorization"] || ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : null; + await endSession(token); + return json(res, { ok: true }); +} diff --git a/server-internal/routes/roles.js b/server-internal/routes/roles.js new file mode 100644 index 0000000..fed3c08 --- /dev/null +++ b/server-internal/routes/roles.js @@ -0,0 +1,93 @@ +/* ===================================================================== + routes/roles.js — Rollen erstellen/umbenennen/verwalten. 1:1 portiert + aus cloudflare-worker/src/routes/roles.js. + + Die Owner-Rolle existiert absichtlich NICHT in dieser Tabelle — sie ist + fest an POSTFACH_CODE gebunden und kann hier weder erstellt, verändert + noch gelöscht werden (siehe Anforderungsdokument: "Meine persönliche + Hauptadministrator-Rolle bleibt davon ausgeschlossen"). + ===================================================================== */ +import { db } from "../db.js"; +import { generateId, nowIso } from "../lib/crypto.js"; +import { isValidPermissionKey, hasPermission } from "../lib/permissions.js"; +import { logAction } from "../lib/audit.js"; +import { json } from "../lib/http.js"; + +function canManageRoles(session) { + if (!session) return false; + if (session.isOwner) return true; + return ( + hasPermission(session.permissions, session.overrides, "TEAM_CREATE_ROLE") || + hasPermission(session.permissions, session.overrides, "TEAM_EDIT_PERMISSIONS") + ); +} + +function canViewRoles(session) { + if (!session) return false; + if (session.isOwner) return true; + return ( + canManageRoles(session) || + hasPermission(session.permissions, session.overrides, "TEAM_ASSIGN_ROLE") || + hasPermission(session.permissions, session.overrides, "TEAM_VIEW") + ); +} + +function sanitizePermissions(list) { + if (!Array.isArray(list)) return []; + return [...new Set(list.filter((k) => typeof k === "string" && isValidPermissionKey(k)))]; +} + +export async function listRoles(req, res, session) { + if (!canViewRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const rows = db.prepare(`SELECT * FROM roles ORDER BY created_at ASC`).all(); + const roles = rows.map((r) => ({ ...r, permissions: JSON.parse(r.permissions || "[]") })); + return json(res, { ok: true, roles }); +} + +export async function createRole(req, res, session) { + if (!canManageRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const name = String(body.name || "").trim().slice(0, 60); + if (!name) return json(res, { ok: false, error: "Name fehlt." }, 400); + const permissions = sanitizePermissions(body.permissions); + const id = generateId(); + + db.prepare(`INSERT INTO roles (id, name, permissions, created_at) VALUES (?, ?, ?, ?)`).run(id, name, JSON.stringify(permissions), nowIso()); + + logAction(session.actor, "role.create", id, { name, permissions }); + return json(res, { ok: true, role: { id, name, permissions } }); +} + +export async function updateRole(req, res, session) { + if (!canManageRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + if (!id) return json(res, { ok: false, error: "id fehlt." }, 400); + + const existing = db.prepare(`SELECT * FROM roles WHERE id = ?`).get(id); + if (!existing) return json(res, { ok: false, error: "Rolle nicht gefunden." }, 404); + + const name = body.name !== undefined ? String(body.name).trim().slice(0, 60) || existing.name : existing.name; + const permissions = body.permissions !== undefined ? sanitizePermissions(body.permissions) : JSON.parse(existing.permissions || "[]"); + + db.prepare(`UPDATE roles SET name = ?, permissions = ? WHERE id = ?`).run(name, JSON.stringify(permissions), id); + + logAction(session.actor, "role.update", id, { name, permissions }); + return json(res, { ok: true }); +} + +export async function deleteRole(req, res, session) { + if (!canManageRoles(session)) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + if (!id) return json(res, { ok: false, error: "id fehlt." }, 400); + + const inUse = db.prepare(`SELECT COUNT(*) as n FROM users WHERE role_id = ?`).get(id); + if (inUse && inUse.n > 0) { + return json(res, { ok: false, error: "Rolle ist noch Personen zugewiesen — erst umziehen." }, 400); + } + + db.prepare(`DELETE FROM roles WHERE id = ?`).run(id); + logAction(session.actor, "role.delete", id, null); + return json(res, { ok: true }); +} diff --git a/server-internal/routes/supporter-admin.js b/server-internal/routes/supporter-admin.js new file mode 100644 index 0000000..9e24fb7 --- /dev/null +++ b/server-internal/routes/supporter-admin.js @@ -0,0 +1,224 @@ +/* ===================================================================== + routes/supporter-admin.js — Administrationsbereich für das Supporter- + Abo (Dogfather_VanVan_Supporter_Abo.odt Abschnitt 27). 1:1 portiert aus + cloudflare-worker/src/routes/supporter-admin.js. + + Nutzt die BESTEHENDE interne Team-Session (auth.js/requireSession), + NICHT die öffentliche Supporter-Session — nur berechtigte + Administrator:innen (Owner oder Rolle mit SUPPORTER_*-Rechten) dürfen + hierauf zugreifen. + ===================================================================== */ +import { db } from "../db.js"; +import { generateId, nowIso } from "../lib/crypto.js"; +import { hasPermission } from "../lib/permissions.js"; +import { json } from "../lib/http.js"; +import { logAction } from "../lib/audit.js"; +import { computeProgress, timelineForCurrentCycle } from "../lib/supporter-cycle.js"; + +function can(session, key) { + if (!session) return false; + if (session.isOwner) return true; + return hasPermission(session.permissions, session.overrides, key); +} + +function forbidden(res) { + return json(res, { ok: false, error: "Keine Berechtigung." }, 403); +} + +/** Abonnentenübersicht mit Filtern (Abschnitt 27). */ +export async function listSupporters(req, res, session) { + if (!can(session, "SUPPORTER_VIEW")) return forbidden(res); + const body = req.body || {}; + const filter = body.filter || "alle"; + + const rows = db + .prepare( + `SELECT u.id, u.display_name, u.tiktok_username, u.email, u.email_verified, u.created_at, + s.id as sub_id, s.paypal_subscription_id, s.status as sub_status, s.started_at, + s.next_payment_at, s.paid_period_end_at, s.cancelled_at, s.total_paid_months + FROM supporter_users u + LEFT JOIN supporter_subscriptions s ON s.supporter_id = u.id + WHERE u.status = 'active' + ORDER BY u.created_at DESC` + ) + .all(); + + let list = rows; + const FILTER_MAP = { + aktiv: (r) => r.sub_status === "active", + gekuendigt: (r) => r.sub_status === "cancelled", + zahlung_fehlgeschlagen: (r) => r.sub_status === "suspended", + monat_6: (r) => r.total_paid_months >= 6, + monat_12: (r) => r.total_paid_months >= 12, + monat_18: (r) => r.total_paid_months >= 18, + monat_24: (r) => r.total_paid_months >= 24, + monat_30: (r) => r.total_paid_months >= 30, + }; + if (FILTER_MAP[filter]) list = list.filter(FILTER_MAP[filter]); + + // Fortschritt (Zyklus/Meilenstein) rein rechnerisch aus total_paid_months + // ergänzen (Nutzer-Wunsch 04.08.2026: "wo die Leute mit den Abos dran + // sind ... alles einsehen können") — dieselbe Logik wie im Supporter- + // Bereich selbst (lib/supporter-cycle.js), damit nichts auseinanderläuft. + list = list.map((r) => ({ ...r, progress: computeProgress(r.total_paid_months || 0) })); + + return json(res, { ok: true, supporters: list }); +} + +/** + * Übersicht über ALLE Supporter hinweg (Nutzer-Wunsch 04.08.2026: "ein + * festes Fenster, wo man drauf klicken muss um zu sehen wer was hat, wie + * weit ist" — statt sich erst durch jeden einzelnen Supporter klicken zu + * müssen). Liefert die komplette Prämien-Warteschlange (für den Versand) + * und die letzten Zahlungen über alle Supporter, jeweils mit Name/E-Mail + * dazu — bewusst EIN kombinierter Aufruf statt vieler Einzelabfragen. + */ +export async function getAdminOverview(req, res, session) { + if (!can(session, "SUPPORTER_VIEW")) return forbidden(res); + + const premiums = db + .prepare( + `SELECT p.*, c.name as collection_name, u.display_name, u.email, u.tiktok_username + FROM supporter_premiums p + JOIN supporter_collections c ON c.id = p.collection_id + JOIN supporter_users u ON u.id = p.supporter_id + ORDER BY + CASE p.redeem_status + WHEN 'wird_geprueft' THEN 0 + WHEN 'wird_vorbereitet' THEN 1 + WHEN 'versandbereit' THEN 2 + WHEN 'problem' THEN 3 + WHEN 'ersatz_wird_vorbereitet' THEN 4 + WHEN 'verfuegbar' THEN 5 + WHEN 'versendet' THEN 6 + WHEN 'zugestellt' THEN 7 + WHEN 'erhalten' THEN 8 + ELSE 9 + END, + p.unlocked_at DESC` + ) + .all(); + + const payments = db + .prepare( + `SELECT pay.*, u.display_name, u.email + FROM supporter_payments pay + JOIN supporter_users u ON u.id = pay.supporter_id + ORDER BY pay.paid_at DESC LIMIT 100` + ) + .all(); + + return json(res, { ok: true, premiums, payments }); +} + +/** Details + Prämien eines einzelnen Supporters. */ +export async function getSupporterDetail(req, res, session) { + if (!can(session, "SUPPORTER_VIEW")) return forbidden(res); + const body = req.body || {}; + const supporter = db.prepare(`SELECT * FROM supporter_users WHERE id = ?`).get(body.supporterId); + if (!supporter) return json(res, { ok: false, error: "Nicht gefunden." }, 404); + + const sub = db + .prepare(`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? ORDER BY created_at DESC LIMIT 1`) + .get(supporter.id); + const premiums = db + .prepare( + `SELECT p.*, c.name as collection_name FROM supporter_premiums p + JOIN supporter_collections c ON c.id = p.collection_id + WHERE p.supporter_id = ? ORDER BY p.cycle_number, p.milestone_month` + ) + .all(supporter.id); + const payments = db.prepare(`SELECT * FROM supporter_payments WHERE supporter_id = ? ORDER BY paid_at DESC LIMIT 50`).all(supporter.id); + + const totalPaidMonths = sub?.total_paid_months || 0; + const progress = computeProgress(totalPaidMonths); + const timeline = timelineForCurrentCycle(totalPaidMonths); + + return json(res, { + ok: true, supporter, subscription: sub, premiums, payments, + progress, timeline, + }); +} + +/** Prämie/Versand manuell bearbeiten (Abschnitt 27 "Prämienverwaltung"). */ +export async function updatePremium(req, res, session) { + if (!can(session, "SUPPORTER_MANAGE_PREMIUMS")) return forbidden(res); + const body = req.body || {}; + const premium = db.prepare(`SELECT * FROM supporter_premiums WHERE id = ?`).get(body.premiumId); + if (!premium) return json(res, { ok: false, error: "Prämie nicht gefunden." }, 404); + + const ALLOWED_STATUS = [ + "verfuegbar", "eingeloest", "wird_geprueft", "wird_vorbereitet", + "versandbereit", "versendet", "zugestellt", "erhalten", "problem", "ersatz_wird_vorbereitet", + ]; + const nextStatus = ALLOWED_STATUS.includes(body.redeemStatus) ? body.redeemStatus : premium.redeem_status; + const trackingNumber = body.trackingNumber !== undefined ? body.trackingNumber : premium.tracking_number; + const shippedAt = nextStatus === "versendet" && !premium.shipped_at ? nowIso() : premium.shipped_at; + const deliveredAt = nextStatus === "zugestellt" && !premium.delivered_at ? nowIso() : premium.delivered_at; + + db.prepare( + `UPDATE supporter_premiums SET redeem_status = ?, tracking_number = ?, shipped_at = ?, delivered_at = ?, admin_note = ? WHERE id = ?` + ).run(nextStatus, trackingNumber || null, shippedAt, deliveredAt, body.adminNote ?? premium.admin_note, premium.id); + + logAction(session.actor, "supporter.premium_updated", premium.id, { + from: premium.redeem_status, to: nextStatus, + }); + return json(res, { ok: true }); +} + +/** Kollektionen anlegen/bearbeiten (Abschnitt 27 "Kollektionen verwalten"). */ +export async function listCollections(req, res, session) { + if (!can(session, "SUPPORTER_VIEW")) return forbidden(res); + const rows = db.prepare(`SELECT * FROM supporter_collections ORDER BY collection_number ASC`).all(); + return json(res, { ok: true, collections: rows }); +} + +export async function upsertCollection(req, res, session) { + if (!can(session, "SUPPORTER_MANAGE_COLLECTIONS")) return forbidden(res); + const body = req.body || {}; + const num = Number(body.collectionNumber); + if (!Number.isFinite(num) || num < 1) return json(res, { ok: false, error: "Ungültige Kollektionsnummer." }, 400); + + const existing = db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).get(num); + const id = existing?.id || generateId(); + const fields = { + name: body.name || `Kollektion ${String(num).padStart(2, "0")}`, + theme: body.theme || null, + description: body.description || null, + cover_image: body.coverImage || null, + small_teddy_design: body.smallTeddyDesign || null, + medium_teddy_design: body.mediumTeddyDesign || null, + large_teddy_design: body.largeTeddyDesign || null, + mug_design: body.mugDesign || null, + card_design: body.cardDesign || null, + hoodie_design: body.hoodieDesign || null, + available_hoodie_sizes: JSON.stringify(body.availableHoodieSizes || ["S", "M", "L", "XL", "XXL"]), + status: body.status === "published" ? "published" : "draft", + }; + + if (existing) { + db.prepare( + `UPDATE supporter_collections SET name=?, theme=?, description=?, cover_image=?, small_teddy_design=?, + medium_teddy_design=?, large_teddy_design=?, mug_design=?, card_design=?, hoodie_design=?, + available_hoodie_sizes=?, status=? WHERE id=?` + ).run( + fields.name, fields.theme, fields.description, fields.cover_image, fields.small_teddy_design, + fields.medium_teddy_design, fields.large_teddy_design, fields.mug_design, fields.card_design, + fields.hoodie_design, fields.available_hoodie_sizes, fields.status, id + ); + } else { + db.prepare( + `INSERT INTO supporter_collections + (id, collection_number, name, theme, description, cover_image, small_teddy_design, medium_teddy_design, + large_teddy_design, mug_design, card_design, hoodie_design, available_hoodie_sizes, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, num, fields.name, fields.theme, fields.description, fields.cover_image, fields.small_teddy_design, + fields.medium_teddy_design, fields.large_teddy_design, fields.mug_design, fields.card_design, + fields.hoodie_design, fields.available_hoodie_sizes, fields.status, nowIso() + ); + } + + logAction(session.actor, "supporter.collection_saved", id, { collectionNumber: num }); + return json(res, { ok: true, id }); +} diff --git a/server-internal/routes/supporter-paypal.js b/server-internal/routes/supporter-paypal.js new file mode 100644 index 0000000..60c2348 --- /dev/null +++ b/server-internal/routes/supporter-paypal.js @@ -0,0 +1,309 @@ +/* ===================================================================== + routes/supporter-paypal.js — PayPal-Abo erstellen + Webhook-Verarbeitung + (Dogfather_VanVan_Supporter_Abo.odt Abschnitt 6-8). 1:1 portiert aus + cloudflare-worker/src/routes/supporter-paypal.js. + + WICHTIG: `req.rawBody` (der unveränderte Roh-Text des Request-Bodys, + von index.js im express.json()-verify-Callback gesetzt) wird für die + PayPal-Signaturprüfung gebraucht — req.body ist bereits geparst und + könnte beim Neuzusammenbau minimal vom Original abweichen (Reihenfolge/ + Whitespace), was die Signatur ungültig machen würde. + ===================================================================== */ +import { db } from "../db.js"; +import { generateId, nowIso } from "../lib/crypto.js"; +import { json } from "../lib/http.js"; +import { logAction } from "../lib/audit.js"; +import { createSubscription, verifyWebhookSignature, cancelSubscription, PayPalNotConfiguredError } from "../lib/paypal.js"; +import { allUnlockedMilestones } from "../lib/supporter-cycle.js"; +import { sendMilestoneUnlocked } from "../lib/supporter-mail.js"; + +const REDEEM_LABELS = { + teddy_klein: "Kleiner Hasen-Teddy", + teddy_mittel: "Mittelgroßer Hasen-Teddy", + teddy_gross: "Großer Hasen-Teddy", + tasse_karte: "Tasse & Autogrammkarte", + hoodie: "Exklusiver Hoodie", +}; + +function paypalNotConfiguredResponse(res) { + return json( + res, + { + ok: false, + code: "PAYPAL_NOT_CONFIGURED", + error: + "PayPal ist auf dieser Website noch nicht eingerichtet. Der Websitebetreiber muss zuerst " + + "PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_PLAN_ID und PAYPAL_WEBHOOK_ID setzen " + + "(siehe cloudflare-worker/README.md, Abschnitt 'Supporter-Abo Setup').", + }, + 503 + ); +} + +/** Startet ein PayPal-Abonnement für den eingeloggten Supporter (Abschnitt 6, Schritt 6). */ +export async function startPaypalSubscription(req, res, supporter) { + const body = req.body || {}; + const origin = body.origin || "https://dogfather-universe.com"; + + try { + const { paypalSubscriptionId, approveUrl } = await createSubscription({ + supporterId: supporter.id, + returnUrl: `${origin}/supporter.html?paypal=success`, + cancelUrl: `${origin}/abonnieren.html?paypal=cancelled`, + }); + if (!approveUrl) return json(res, { ok: false, error: "PayPal hat keinen Bestätigungslink geliefert." }, 502); + + const subId = generateId(); + db.prepare( + `INSERT INTO supporter_subscriptions + (id, supporter_id, paypal_subscription_id, paypal_plan_id, status, total_paid_months, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)` + ).run(subId, supporter.id, paypalSubscriptionId, process.env.PAYPAL_PLAN_ID, nowIso(), nowIso()); + + logAction("system", "supporter.subscription_started", supporter.id, { paypalSubscriptionId }); + return json(res, { ok: true, approveUrl }); + } catch (err) { + if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res); + return json(res, { ok: false, error: "PayPal-Abo konnte nicht erstellt werden." }, 502); + } +} + +/** + * Gegenstück zu den PayPal Smart-Payment-Buttons im Frontend: dort erzeugt + * das PayPal-JS-SDK die Subscription direkt im Browser (zeigt dabei + * automatisch alle für die Käuferin/den Käufer verfügbaren Zahlarten an — + * PayPal-Guthaben, Bankkonto, Kreditkarte als Gast, je nach Land/Konto), + * damit landet das Geld direkt auf Dogis PayPal-Konto. Der Browser + * übergibt uns danach nur noch die fertige `subscriptionID`, die wir hier + * mit dem Supporter-Konto verknüpfen. Der tatsächliche Aktivierungsstatus + * kommt weiterhin ausschließlich über den geprüften Webhook (Abschnitt 6: + * "Eine reine Weiterleitung... darf nicht ausreichen, um das Abo als aktiv + * zu markieren"). + */ +export async function confirmPaypalSubscription(req, res, supporter) { + const body = req.body || {}; + const paypalSubscriptionId = String(body.subscriptionId || "").trim(); + if (!paypalSubscriptionId) return json(res, { ok: false, error: "Keine PayPal-Subscription-ID übergeben." }, 400); + + // Rechts-Update 04.08.2026: Nachweis der Widerrufsrecht-Zustimmung + // (sofortiger Leistungsbeginn bei digitalen Inhalten, § 356 Abs. 5 BGB / + // entspr. Regelung, siehe agb.html Ziffer 6). Die Checkbox im Frontend + // (abonnieren.html) blockiert das Laden der PayPal-Buttons bereits + // clientseitig — diese serverseitige Prüfung verhindert zusätzlich eine + // Umgehung per direktem API-Aufruf ohne echte Zustimmung. + const widerrufConsentAt = String(body.widerrufConsentAt || "").trim(); + const consentDatum = widerrufConsentAt ? new Date(widerrufConsentAt) : null; + const consentGueltig = consentDatum && !Number.isNaN(consentDatum.getTime()) && consentDatum.getTime() <= Date.now(); + if (!consentGueltig) { + return json(res, { ok: false, error: "Bitte bestätige zuerst die Checkbox zum sofortigen Leistungsbeginn." }, 400); + } + + const existing = db.prepare(`SELECT id FROM supporter_subscriptions WHERE paypal_subscription_id = ?`).get(paypalSubscriptionId); + if (existing) return json(res, { ok: true }); // schon verknüpft (z.B. doppelter Klick) — kein Fehler + + const subId = generateId(); + db.prepare( + `INSERT INTO supporter_subscriptions + (id, supporter_id, paypal_subscription_id, paypal_plan_id, status, total_paid_months, created_at, updated_at) + VALUES (?, ?, ?, ?, 'pending', 0, ?, ?)` + ).run(subId, supporter.id, paypalSubscriptionId, process.env.PAYPAL_PLAN_ID, nowIso(), nowIso()); + + logAction("system", "supporter.subscription_confirmed_client_side", supporter.id, { + paypalSubscriptionId, + widerrufConsentAt: consentDatum.toISOString(), + }); + return json(res, { ok: true }); +} + +export async function cancelPaypalSubscription(req, res, supporter) { + const sub = db + .prepare( + `SELECT * FROM supporter_subscriptions WHERE supporter_id = ? AND status IN ('active','pending','suspended') ORDER BY created_at DESC LIMIT 1` + ) + .get(supporter.id); + if (!sub) return json(res, { ok: false, error: "Kein aktives Abonnement gefunden." }, 404); + + try { + if (sub.paypal_subscription_id) await cancelSubscription(sub.paypal_subscription_id); + } catch (err) { + if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res); + return json(res, { ok: false, error: "Kündigung bei PayPal fehlgeschlagen." }, 502); + } + + db.prepare(`UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?, updated_at = ? WHERE id = ?`).run( + nowIso(), + nowIso(), + sub.id + ); + logAction("system", "supporter.subscription_cancelled", supporter.id, null); + return json(res, { ok: true }); +} + +/* ---------- Webhook (öffentlich erreichbar, aber signaturgeprüft) ---------- */ + +function findSubscriptionByPaypalId(paypalSubscriptionId) { + if (!paypalSubscriptionId) return null; + return db.prepare(`SELECT * FROM supporter_subscriptions WHERE paypal_subscription_id = ?`).get(paypalSubscriptionId); +} + +function alreadyProcessed(eventId) { + if (!eventId) return false; + const row = db.prepare(`SELECT id FROM supporter_payments WHERE paypal_event_id = ?`).get(eventId); + return !!row; +} + +/** Erfolgreiche Zahlung: Monat anrechnen, ggf. neue Prämien freischalten (Abschnitt 8, 11-13). */ +async function handleSuccessfulPayment(sub, event, resource) { + const isSandbox = process.env.PAYPAL_ENV !== "live"; + const eventId = event.id; + const paymentId = generateId(); + + db.prepare( + `INSERT INTO supporter_payments + (id, supporter_id, subscription_id, paypal_transaction_id, paypal_event_id, amount, currency, + payment_status, is_sandbox, counted_for_progress, paid_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'completed', ?, ?, ?, ?)` + ).run( + paymentId, sub.supporter_id, sub.id, resource.id || null, eventId, + resource.amount?.total || resource.amount?.value || null, + resource.amount?.currency || "EUR", + isSandbox ? 1 : 0, isSandbox ? 0 : 1, nowIso(), nowIso() + ); + + if (isSandbox) return; // Testzahlungen zählen NIE als echter Monat (Abschnitt 8/34). + + const oldTotal = sub.total_paid_months; + const newTotal = oldTotal + 1; + db.prepare(`UPDATE supporter_subscriptions SET total_paid_months = ?, updated_at = ? WHERE id = ?`).run(newTotal, nowIso(), sub.id); + + const before = new Set(allUnlockedMilestones(oldTotal).map((m) => `${m.cycleNumber}:${m.month}`)); + const after = allUnlockedMilestones(newTotal); + const newlyUnlocked = after.filter((m) => !before.has(`${m.cycleNumber}:${m.month}`)); + + for (const m of newlyUnlocked) { + // Kollektion für diesen Zyklus ermitteln (per collection_number = cycleNumber), + // fällt auf Kollektion 1 zurück, falls eine spätere Kollektion im Admin- + // Bereich noch nicht angelegt wurde — damit niemand ins Leere freigeschaltet wird. + const collection = + db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = ?`).get(m.cycleNumber) || + db.prepare(`SELECT id FROM supporter_collections WHERE collection_number = 1`).get(); + if (!collection) continue; + + db.prepare( + `INSERT OR IGNORE INTO supporter_premiums + (id, supporter_id, collection_id, cycle_number, milestone_month, premium_type, unlocked_at, redeem_status) + VALUES (?, ?, ?, ?, ?, ?, ?, 'verfuegbar')` + ).run(generateId(), sub.supporter_id, collection.id, m.cycleNumber, m.month, m.type, nowIso()); + + const supporterRow = db.prepare(`SELECT display_name, email FROM supporter_users WHERE id = ?`).get(sub.supporter_id); + if (supporterRow) { + await sendMilestoneUnlocked({ + to: supporterRow.email, + name: supporterRow.display_name, + premiumLabel: REDEEM_LABELS[m.type] || m.type, + }).catch(() => {}); // E-Mail-Fehler dürfen die Webhook-Verarbeitung nie blockieren. + } + logAction("system", "supporter.milestone_unlocked", sub.supporter_id, { cycle: m.cycleNumber, month: m.month, type: m.type }); + } +} + +function handleReversedPayment(sub, event, resource) { + const eventId = event.id; + const paymentId = generateId(); + db.prepare( + `INSERT INTO supporter_payments + (id, supporter_id, subscription_id, paypal_transaction_id, paypal_event_id, amount, currency, + payment_status, is_sandbox, counted_for_progress, paid_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)` + ).run( + paymentId, sub.supporter_id, sub.id, resource.id || null, eventId, + resource.amount?.total || resource.amount?.value || null, resource.amount?.currency || "EUR", + event.event_type.includes("REFUND") ? "refunded" : "reversed", + process.env.PAYPAL_ENV !== "live" ? 1 : 0, nowIso(), nowIso() + ); + + // Zurückgebuchte/zurückerstattete Zahlung: betroffenen Monat zur manuellen + // Prüfung markieren statt automatisch zu löschen (Abschnitt 24 — "muss + // manuell durch einen Administrator geprüft werden"). + logAction("admin", "supporter.payment_flagged_for_review", sub.supporter_id, { + eventType: event.event_type, + transactionId: resource.id, + }); +} + +export async function handlePaypalWebhook(req, res) { + const rawBody = req.rawBody || JSON.stringify(req.body || {}); + let event; + try { + event = JSON.parse(rawBody); + } catch { + return json(res, { ok: false, error: "Ungültiger Webhook-Body." }, 400); + } + + let verified = false; + try { + verified = await verifyWebhookSignature(req, rawBody); + } catch (err) { + if (err instanceof PayPalNotConfiguredError) return paypalNotConfiguredResponse(res); + return json(res, { ok: false, error: "Webhook-Signaturprüfung fehlgeschlagen." }, 400); + } + if (!verified) return json(res, { ok: false, error: "Ungültige Webhook-Signatur." }, 400); + + if (alreadyProcessed(event.id)) return json(res, { ok: true, duplicate: true }); + + const resource = event.resource || {}; + const type = event.event_type; + + try { + if (type === "BILLING.SUBSCRIPTION.ACTIVATED") { + const sub = findSubscriptionByPaypalId(resource.id); + if (sub) { + db.prepare( + `UPDATE supporter_subscriptions SET status = 'active', started_at = COALESCE(started_at, ?), + next_payment_at = ?, updated_at = ? WHERE id = ?` + ).run(nowIso(), resource.billing_info?.next_billing_time || null, nowIso(), sub.id); + } + } else if (type === "BILLING.SUBSCRIPTION.UPDATED") { + const sub = findSubscriptionByPaypalId(resource.id); + if (sub) { + db.prepare(`UPDATE supporter_subscriptions SET next_payment_at = ?, updated_at = ? WHERE id = ?`).run( + resource.billing_info?.next_billing_time || sub.next_payment_at, + nowIso(), + sub.id + ); + } + } else if (type === "BILLING.SUBSCRIPTION.CANCELLED") { + const sub = findSubscriptionByPaypalId(resource.id); + if (sub) { + db.prepare( + `UPDATE supporter_subscriptions SET status = 'cancelled', cancelled_at = ?, + paid_period_end_at = COALESCE(paid_period_end_at, next_payment_at), updated_at = ? WHERE id = ?` + ).run(nowIso(), nowIso(), sub.id); + } + } else if (type === "BILLING.SUBSCRIPTION.SUSPENDED") { + const sub = findSubscriptionByPaypalId(resource.id); + if (sub) db.prepare(`UPDATE supporter_subscriptions SET status = 'suspended', updated_at = ? WHERE id = ?`).run(nowIso(), sub.id); + } else if (type === "BILLING.SUBSCRIPTION.EXPIRED") { + const sub = findSubscriptionByPaypalId(resource.id); + if (sub) db.prepare(`UPDATE supporter_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`).run(nowIso(), sub.id); + } else if (type === "PAYMENT.SALE.COMPLETED") { + const sub = findSubscriptionByPaypalId(resource.billing_agreement_id); + if (sub) await handleSuccessfulPayment(sub, event, resource); + } else if (type === "PAYMENT.SALE.DENIED") { + const sub = findSubscriptionByPaypalId(resource.billing_agreement_id); + if (sub) logAction("system", "supporter.payment_failed", sub.supporter_id, { transactionId: resource.id }); + } else if (type === "PAYMENT.SALE.REFUNDED" || type === "PAYMENT.SALE.REVERSED") { + const sub = findSubscriptionByPaypalId(resource.billing_agreement_id); + if (sub) handleReversedPayment(sub, event, resource); + } + // Unbekannte/nicht relevante Ereignistypen werden bewusst stillschweigend bestätigt + // (PayPal wiederholt Zustellversuche sonst unnötig). + } catch (err) { + // Bewusst 200 statt 500 an PayPal zurückgeben nur, wenn der Fehler NICHT + // die Zahlungsverarbeitung selbst betrifft — hier lassen wir echte + // Fehler durchschlagen, damit PayPal es später erneut zustellt. + return json(res, { ok: false, error: "Webhook-Verarbeitung fehlgeschlagen." }, 500); + } + + return json(res, { ok: true }); +} diff --git a/server-internal/routes/supporter.js b/server-internal/routes/supporter.js new file mode 100644 index 0000000..adfc6f5 --- /dev/null +++ b/server-internal/routes/supporter.js @@ -0,0 +1,379 @@ +/* ===================================================================== + routes/supporter.js — Öffentlicher Supporter-Bereich: Registrierung, + E-Mail-Bestätigung, Magic-Link-Login, persönliches Dashboard, Prämien- + Einlösung, Aboverwaltung. 1:1 portiert aus + cloudflare-worker/src/routes/supporter.js. + + Setzt Dogfather_VanVan_Supporter_Abo.odt Abschnitt 5, 6, 9-18, 21-25 um. + ===================================================================== */ +import { db } from "../db.js"; +import { generateId, nowIso } from "../lib/crypto.js"; +import { json } from "../lib/http.js"; +import { logAction } from "../lib/audit.js"; +import { + normalizeEmail, normalizeTikTokUsername, issueAuthCode, verifyAuthCode, + createSupporterSession, endSupporterSession, +} from "../lib/supporter-auth.js"; +import { EmailNotConfiguredError, sendVerifyEmailCode, sendLoginCode } from "../lib/supporter-mail.js"; +import { computeProgress, timelineForCurrentCycle } from "../lib/supporter-cycle.js"; +import { verifyGoogleIdToken, GoogleAuthNotConfiguredError } from "../lib/google-auth.js"; + +function bad(res, msg, status = 400) { + return json(res, { ok: false, error: msg }, status); +} + +function emailErrorResponse(res, err) { + if (err instanceof EmailNotConfiguredError || err.code === "EMAIL_NOT_CONFIGURED") { + return json( + res, + { + ok: false, + error: + "Der E-Mail-Versand ist auf dieser Website noch nicht eingerichtet (RESEND_API_KEY fehlt). " + + "Bitte zuerst in der .env konfigurieren, siehe cloudflare-worker/README.md.", + code: "EMAIL_NOT_CONFIGURED", + }, + 503 + ); + } + return json(res, { ok: false, error: "E-Mail konnte nicht verschickt werden." }, 502); +} + +/* ---------- Registrierung & Login (öffentlich, keine Session) ---------- */ + +export async function registerSupporter(req, res) { + const body = req.body || {}; + const displayName = String(body.displayName || "").trim().slice(0, 60); + const tiktokUsername = normalizeTikTokUsername(body.tiktokUsername); + const email = normalizeEmail(body.email); + + if (!displayName) return bad(res, "Bitte gib einen Namen oder Anzeigenamen an."); + if (!email || !email.includes("@")) return bad(res, "Bitte gib eine gültige E-Mail-Adresse an."); + + const existing = db.prepare(`SELECT id, email_verified FROM supporter_users WHERE email = ?`).get(email); + + let supporterId; + if (existing) { + if (existing.email_verified) { + return bad(res, "Für diese E-Mail-Adresse existiert bereits ein Supporter-Konto. Bitte einloggen.", 409); + } + supporterId = existing.id; + db.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`).run(displayName, tiktokUsername, supporterId); + } else { + supporterId = generateId(); + db.prepare( + `INSERT INTO supporter_users (id, display_name, tiktok_username, email, email_verified, created_at) + VALUES (?, ?, ?, ?, 0, ?)` + ).run(supporterId, displayName, tiktokUsername, email, nowIso()); + logAction("system", "supporter.registered", supporterId, { email }); + } + + const code = await issueAuthCode(supporterId, "verify_email"); + try { + await sendVerifyEmailCode({ to: email, name: displayName, code }); + } catch (err) { + return emailErrorResponse(res, err); + } + return json(res, { ok: true, needsVerification: true }); +} + +export async function verifySupporterEmail(req, res) { + const body = req.body || {}; + const email = normalizeEmail(body.email); + const code = String(body.code || "").trim(); + if (!email || !code) return bad(res, "E-Mail und Code werden benötigt."); + + const supporter = db.prepare(`SELECT id, display_name FROM supporter_users WHERE email = ?`).get(email); + if (!supporter) return bad(res, "Kein Supporter-Konto mit dieser E-Mail-Adresse gefunden.", 404); + + const valid = await verifyAuthCode(supporter.id, "verify_email", code); + if (!valid) return bad(res, "Der Code ist ungültig oder abgelaufen.", 401); + + db.prepare(`UPDATE supporter_users SET email_verified = 1, last_login_at = ? WHERE id = ?`).run(nowIso(), supporter.id); + logAction("system", "supporter.email_verified", supporter.id, null); + + const token = await createSupporterSession(supporter.id); + return json(res, { ok: true, token }); +} + +export async function requestSupporterLogin(req, res) { + const body = req.body || {}; + const email = normalizeEmail(body.email); + if (!email) return bad(res, "Bitte gib deine E-Mail-Adresse an."); + + const supporter = db + .prepare(`SELECT id, display_name, email_verified FROM supporter_users WHERE email = ? AND status = 'active'`) + .get(email); + // Bewusst IMMER ok:true nach außen (keine Auskunft, ob ein Konto existiert). + if (!supporter || !supporter.email_verified) return json(res, { ok: true }); + + const code = await issueAuthCode(supporter.id, "login"); + try { + await sendLoginCode({ to: email, name: supporter.display_name, code }); + } catch (err) { + return emailErrorResponse(res, err); + } + return json(res, { ok: true }); +} + +export async function verifySupporterLogin(req, res) { + const body = req.body || {}; + const email = normalizeEmail(body.email); + const code = String(body.code || "").trim(); + if (!email || !code) return bad(res, "E-Mail und Code werden benötigt."); + + const supporter = db.prepare(`SELECT id FROM supporter_users WHERE email = ? AND status = 'active'`).get(email); + if (!supporter) return bad(res, "Kein Supporter-Konto gefunden.", 404); + + const valid = await verifyAuthCode(supporter.id, "login", code); + if (!valid) return bad(res, "Der Code ist ungültig oder abgelaufen.", 401); + + db.prepare(`UPDATE supporter_users SET last_login_at = ? WHERE id = ?`).run(nowIso(), supporter.id); + const token = await createSupporterSession(supporter.id); + return json(res, { ok: true, token }); +} + +/** + * "Mit Google anmelden" — deckt Registrierung UND Login gleichzeitig ab: + * Google hat die E-Mail-Adresse bereits selbst bestätigt, deshalb entfällt + * hier der sonst nötige Bestätigungscode. Existiert schon ein Konto mit + * dieser E-Mail (egal ob per Code oder vorher schon per Google angelegt), + * wird es einfach eingeloggt statt doppelt erzeugt. + */ +export async function googleLoginSupporter(req, res) { + const body = req.body || {}; + let result; + try { + result = await verifyGoogleIdToken(body.credential); + } catch (err) { + if (err instanceof GoogleAuthNotConfiguredError) { + return json( + res, + { + ok: false, + code: "GOOGLE_AUTH_NOT_CONFIGURED", + error: "Google-Login ist auf dieser Website noch nicht eingerichtet.", + }, + 503 + ); + } + return json(res, { ok: false, error: "Google-Anmeldung fehlgeschlagen." }, 502); + } + if (!result.ok) return bad(res, result.reason || "Google-Anmeldung fehlgeschlagen.", 401); + + let supporter = db.prepare(`SELECT id FROM supporter_users WHERE email = ? AND status = 'active'`).get(result.email); + + if (!supporter) { + const id = generateId(); + db.prepare( + `INSERT INTO supporter_users (id, display_name, email, email_verified, created_at, last_login_at) + VALUES (?, ?, ?, 1, ?, ?)` + ).run(id, result.name, result.email, nowIso(), nowIso()); + supporter = { id }; + logAction("system", "supporter.registered_via_google", supporter.id, { email: result.email }); + } else { + db.prepare(`UPDATE supporter_users SET last_login_at = ? WHERE id = ?`).run(nowIso(), supporter.id); + } + + const token = await createSupporterSession(supporter.id); + return json(res, { ok: true, token }); +} + +/** + * Öffentliche, NICHT geheime Konfiguration fürs Frontend (Client-IDs sind + * per Design öffentlich — nur die zugehörigen Secrets sind es nicht). + * Fehlt ein Wert, bleibt der jeweilige Button im Frontend einfach + * ausgeblendet statt kaputt zu sein. + */ +export async function getPublicConfig(req, res) { + return json(res, { + ok: true, + googleClientId: process.env.GOOGLE_CLIENT_ID || null, + paypalClientId: process.env.PAYPAL_CLIENT_ID || null, + paypalPlanId: process.env.PAYPAL_PLAN_ID || null, + }); +} + +export async function logoutSupporter(req, res) { + const auth = req.headers["authorization"] || ""; + const token = auth.startsWith("Bearer ") ? auth.slice(7) : null; + await endSupporterSession(token); + return json(res, { ok: true }); +} + +/* ---------- Geschützter Supporter-Bereich (Session erforderlich) ---------- */ + +const REDEEM_LABELS = { + teddy_klein: "Kleiner Hasen-Teddy", + teddy_mittel: "Mittelgroßer Hasen-Teddy", + teddy_gross: "Großer Hasen-Teddy", + tasse_karte: "Tasse & Autogrammkarte", + hoodie: "Exklusiver Hoodie", +}; + +/** Baut die vollständige Dashboard-Antwort (Fenster 1+2+Archiv, Abschnitt 10/15). */ +function buildDashboard(supporter) { + const sub = db + .prepare(`SELECT * FROM supporter_subscriptions WHERE supporter_id = ? ORDER BY created_at DESC LIMIT 1`) + .get(supporter.id); + + const totalPaidMonths = sub?.total_paid_months || 0; + const progress = computeProgress(totalPaidMonths); + const timeline = timelineForCurrentCycle(totalPaidMonths); + + const premiumsRows = db + .prepare( + `SELECT p.*, c.name as collection_name, c.collection_number + FROM supporter_premiums p JOIN supporter_collections c ON c.id = p.collection_id + WHERE p.supporter_id = ? ORDER BY p.cycle_number ASC, p.milestone_month ASC` + ) + .all(supporter.id); + const premiums = premiumsRows.map((p) => ({ ...p, label: REDEEM_LABELS[p.premium_type] || p.premium_type })); + + // Archiv: nach Kollektion gruppiert (Abschnitt 15 — abgeschlossene Kollektionen bleiben sichtbar). + const byCollection = new Map(); + for (const p of premiums) { + const key = p.collection_number; + if (!byCollection.has(key)) byCollection.set(key, { collectionNumber: key, collectionName: p.collection_name, premiums: [] }); + byCollection.get(key).premiums.push(p); + } + + // Zahlungshistorie (Nutzer-Wunsch 04.08.2026: "die Kisten sollen beim + // Klicken viel mehr Infos zeigen") — für die Detailansicht "Nächste + // Zahlung" im Frontend. sub kann null sein (noch nie ein Abo gehabt). + let payments = []; + if (sub) { + payments = db + .prepare( + `SELECT amount, currency, payment_status, paid_at, is_sandbox + FROM supporter_payments WHERE supporter_id = ? ORDER BY paid_at DESC LIMIT 36` + ) + .all(supporter.id); + } + + return { + profile: { + displayName: supporter.display_name, + tiktokUsername: supporter.tiktok_username, + email: supporter.email, + hasShippingAddress: !!(supporter.ship_street && supporter.ship_zip), + }, + subscription: sub + ? { + status: sub.status, + startedAt: sub.started_at, + nextPaymentAt: sub.next_payment_at, + paidPeriodEndAt: sub.paid_period_end_at, + cancelledAt: sub.cancelled_at, + monthlyPrice: "4,99 €", + totalPaidMonths, + } + : { status: "none", totalPaidMonths: 0, monthlyPrice: "4,99 €" }, + progress, + timeline, + premiums, + archive: [...byCollection.values()].sort((a, b) => a.collectionNumber - b.collectionNumber), + payments, + }; +} + +export async function getSupporterMe(req, res, supporter) { + return json(res, { ok: true, ...buildDashboard(supporter) }); +} + +export async function updateSupporterProfile(req, res, supporter) { + const body = req.body || {}; + const displayName = body.displayName !== undefined ? String(body.displayName).trim().slice(0, 60) : supporter.display_name; + const tiktokUsername = + body.tiktokUsername !== undefined ? normalizeTikTokUsername(body.tiktokUsername) : supporter.tiktok_username; + if (!displayName) return bad(res, "Der Name darf nicht leer sein."); + + db.prepare(`UPDATE supporter_users SET display_name = ?, tiktok_username = ? WHERE id = ?`).run(displayName, tiktokUsername, supporter.id); + logAction("system", "supporter.profile_updated", supporter.id, null); + return json(res, { ok: true }); +} + +export async function requestSupporterEmailChange(req, res, supporter) { + const body = req.body || {}; + const newEmail = normalizeEmail(body.newEmail); + if (!newEmail || !newEmail.includes("@")) return bad(res, "Bitte gib eine gültige neue E-Mail-Adresse an."); + + const clash = db.prepare(`SELECT id FROM supporter_users WHERE email = ? AND id != ?`).get(newEmail, supporter.id); + if (clash) return bad(res, "Diese E-Mail-Adresse wird bereits verwendet.", 409); + + db.prepare(`UPDATE supporter_users SET pending_email = ? WHERE id = ?`).run(newEmail, supporter.id); + const code = await issueAuthCode(supporter.id, "change_email"); + try { + await sendVerifyEmailCode({ to: newEmail, name: supporter.display_name, code }); + } catch (err) { + return emailErrorResponse(res, err); + } + return json(res, { ok: true }); +} + +export async function confirmSupporterEmailChange(req, res, supporter) { + const body = req.body || {}; + const code = String(body.code || "").trim(); + if (!supporter.pending_email) return bad(res, "Es liegt keine E-Mail-Änderung vor."); + const valid = await verifyAuthCode(supporter.id, "change_email", code); + if (!valid) return bad(res, "Der Code ist ungültig oder abgelaufen.", 401); + + db.prepare(`UPDATE supporter_users SET email = pending_email, pending_email = NULL WHERE id = ?`).run(supporter.id); + logAction("system", "supporter.email_changed", supporter.id, null); + return json(res, { ok: true }); +} + +/** Prämie einlösen (Abschnitt 17) — Versandadresse erst hier, nicht bei der Registrierung. */ +export async function redeemPremium(req, res, supporter) { + const body = req.body || {}; + const premium = db.prepare(`SELECT * FROM supporter_premiums WHERE id = ? AND supporter_id = ?`).get(body.premiumId, supporter.id); + if (!premium) return bad(res, "Prämie nicht gefunden.", 404); + if (premium.redeem_status !== "verfuegbar") return bad(res, "Diese Prämie wurde bereits eingelöst oder ist gesperrt.", 409); + + const useSaved = !!body.useSavedAddress && supporter.ship_street; + const addr = useSaved + ? { + name: supporter.ship_name, street: supporter.ship_street, zip: supporter.ship_zip, + city: supporter.ship_city, country: supporter.ship_country, + } + : { + name: String(body.shipName || "").trim(), + street: String(body.shipStreet || "").trim(), + zip: String(body.shipZip || "").trim(), + city: String(body.shipCity || "").trim(), + country: String(body.shipCountry || "").trim(), + }; + if (!addr.name || !addr.street || !addr.zip || !addr.city || !addr.country) { + return bad(res, "Bitte fülle alle Adressfelder aus."); + } + + if (premium.premium_type === "hoodie" && !body.hoodieSize) { + return bad(res, "Bitte wähle eine Hoodie-Größe."); + } + + db.prepare( + `UPDATE supporter_premiums SET redeem_status = 'eingeloest', redeemed_at = ?, + ship_name = ?, ship_street = ?, ship_zip = ?, ship_city = ?, ship_country = ?, + hoodie_size = ?, hoodie_variant = ? + WHERE id = ?` + ).run(nowIso(), addr.name, addr.street, addr.zip, addr.city, addr.country, body.hoodieSize || null, body.hoodieVariant || null, premium.id); + + if (body.saveAddress) { + db.prepare( + `UPDATE supporter_users SET ship_name=?, ship_street=?, ship_zip=?, ship_city=?, ship_country=?, ship_save_address=1 WHERE id=?` + ).run(addr.name, addr.street, addr.zip, addr.city, addr.country, supporter.id); + } + + logAction("system", "supporter.premium_redeemed", supporter.id, { premiumId: premium.id, type: premium.premium_type }); + return json(res, { ok: true }); +} + +export async function deleteSupporterAccount(req, res, supporter) { + // Datenschutz (Abschnitt 30): Konto löschbar. Vertrags-/Zahlungsdaten + // bleiben aus gesetzlichen Aufbewahrungspflichten bestehen (nur an den + // Nutzerdatensatz selbst wird nicht mehr referenziert) — deshalb hier + // bewusst "soft delete" statt hartem DELETE der Zahlungshistorie. + db.prepare(`UPDATE supporter_users SET status = 'deleted', email = email || '.geloescht.' || id WHERE id = ?`).run(supporter.id); + logAction("system", "supporter.account_deleted", supporter.id, null); + return json(res, { ok: true }); +} diff --git a/server-internal/routes/users.js b/server-internal/routes/users.js new file mode 100644 index 0000000..29bdb12 --- /dev/null +++ b/server-internal/routes/users.js @@ -0,0 +1,255 @@ +/* ===================================================================== + routes/users.js — Zugänge und Teammitglieder. 1:1 portiert aus + cloudflare-worker/src/routes/users.js. + + Zentrale Regel aus dem Anforderungsdokument, überall durchgesetzt: + AUSSCHLIESSLICH der Owner darf Codes sehen, erstellen, ändern, + kopieren oder zurücksetzen. Keine Rolle und keine individuelle + Berechtigung kann das jemals freischalten — die entsprechenden + Funktionen prüfen `session.isOwner` direkt, nie eine Permission. + ===================================================================== */ +import { db } from "../db.js"; +import { generateId, generateFriendlyCode, sha256Hex, encryptCode, decryptCode, nowIso } from "../lib/crypto.js"; +import { hasPermission, isValidPermissionKey } from "../lib/permissions.js"; +import { endAllSessionsForUser, verifyCode } from "../lib/auth.js"; +import { logAction } from "../lib/audit.js"; +import { json } from "../lib/http.js"; + +function can(session, key) { + if (!session) return false; + if (session.isOwner) return true; + return hasPermission(session.permissions, session.overrides, key); +} + +function loadUser(id) { + return db + .prepare(`SELECT u.*, r.name as role_name FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?`) + .get(id); +} + +function publicUser(u) { + return { + id: u.id, + name: u.name, + username: u.username, + email: u.email, + roleId: u.role_id, + roleName: u.role_name, + status: u.status, + note: u.note, + expiresAt: u.expires_at, + createdAt: u.created_at, + createdBy: u.created_by, + lastLoginAt: u.last_login_at, + // Codes erscheinen hier bewusst NIE — auch nicht verschlüsselt/gehasht. + }; +} + +export async function listUsers(req, res, session) { + if (!can(session, "TEAM_VIEW") && !session?.isOwner) { + return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + } + const rows = db + .prepare(`SELECT u.*, r.name as role_name FROM users u LEFT JOIN roles r ON r.id = u.role_id ORDER BY u.created_at DESC`) + .all(); + return json(res, { ok: true, users: rows.map(publicUser) }); +} + +/** Nicht-Owner mit TEAM_PREPARE_USER können einen Entwurf anlegen — noch ohne Code, inaktiv. */ +export async function prepareUser(req, res, session) { + if (!can(session, "TEAM_PREPARE_USER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const name = String(body.name || "").trim().slice(0, 80); + const username = String(body.username || "").trim().toLowerCase().slice(0, 40); + if (!name || !username) return json(res, { ok: false, error: "Name und Benutzername sind Pflicht." }, 400); + + const id = generateId(); + try { + db.prepare( + `INSERT INTO users (id, name, username, email, role_id, status, note, created_at, created_by) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)` + ).run(id, name, username, String(body.email || "").trim() || null, body.roleId || null, String(body.note || "").slice(0, 500) || null, nowIso(), session.actor); + } catch { + return json(res, { ok: false, error: "Benutzername bereits vergeben." }, 400); + } + + logAction(session.actor, "user.prepare", id, { username }); + return json(res, { ok: true, id }); +} + +/** NUR Owner: legt einen Zugang final an (neu oder aus einem vorbereiteten Entwurf) inkl. Code. */ +export async function finalizeUser(req, res, session) { + if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf Zugänge anlegen/aktivieren." }, 403); + const body = req.body || {}; + + let id = body.pendingUserId ? String(body.pendingUserId) : null; + const name = String(body.name || "").trim().slice(0, 80); + const username = String(body.username || "").trim().toLowerCase().slice(0, 40); + const email = String(body.email || "").trim() || null; + const roleId = body.roleId || null; + const note = String(body.note || "").slice(0, 500) || null; + + if (!id && (!name || !username)) { + return json(res, { ok: false, error: "Name und Benutzername sind Pflicht." }, 400); + } + + const code = body.code ? String(body.code).trim().slice(0, 40) : generateFriendlyCode(); + const codeHash = await sha256Hex(code); + const codeEnc = await encryptCode(code); + + let expiresAt = null; + if (body.expiresAt) expiresAt = new Date(body.expiresAt).toISOString(); + else if (body.expiresInDays) expiresAt = new Date(Date.now() + Number(body.expiresInDays) * 86400000).toISOString(); + + if (id) { + const existing = loadUser(id); + if (!existing) return json(res, { ok: false, error: "Vorbereiteter Benutzer nicht gefunden." }, 404); + db.prepare( + `UPDATE users SET name = ?, username = ?, email = ?, role_id = ?, code_hash = ?, code_enc = ?, + status = 'active', note = ?, expires_at = ? WHERE id = ?` + ).run(name || existing.name, username || existing.username, email, roleId, codeHash, codeEnc, note, expiresAt, id); + } else { + id = generateId(); + try { + db.prepare( + `INSERT INTO users (id, name, username, email, role_id, code_hash, code_enc, status, note, expires_at, created_at, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)` + ).run(id, name, username, email, roleId, codeHash, codeEnc, note, expiresAt, nowIso(), session.actor); + } catch { + return json(res, { ok: false, error: "Benutzername bereits vergeben." }, 400); + } + } + + logAction(session.actor, "user.finalize", id, { username: username || undefined }); + // Der Klartext-Code wird JETZT einmalig zurückgegeben — danach nie wieder abrufbar, + // außer über den geschützten "Code anzeigen"-Reveal-Flow (siehe revealUserCode()). + return json(res, { ok: true, id, code }); +} + +/** NUR Owner: neuen Code für bestehenden Benutzer setzen (manuell oder automatisch). */ +export async function setUserCode(req, res, session) { + if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf Codes ändern." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + const user = loadUser(id); + if (!user) return json(res, { ok: false, error: "Benutzer nicht gefunden." }, 404); + + const code = body.code ? String(body.code).trim().slice(0, 40) : generateFriendlyCode(); + const codeHash = await sha256Hex(code); + const codeEnc = await encryptCode(code); + + db.prepare(`UPDATE users SET code_hash = ?, code_enc = ? WHERE id = ?`).run(codeHash, codeEnc, id); + endAllSessionsForUser(id); // alter Code darf auf keinem Gerät mehr gültig bleiben + + logAction(session.actor, "user.code_reset", id, null); + return json(res, { ok: true, code }); +} + +/** NUR Owner, mit erneuter Re-Auth (ownerCode): zeigt einen bestehenden Code im Klartext. */ +export async function revealUserCode(req, res, session) { + if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf Codes ansehen." }, 403); + const body = req.body || {}; + + const reAuth = await verifyCode(String(body.reAuthCode || "")); + if (!reAuth.ok || !reAuth.isOwner) { + return json(res, { ok: false, error: "Re-Authentifizierung fehlgeschlagen." }, 401); + } + + const id = String(body.id || ""); + const user = loadUser(id); + if (!user || !user.code_enc) return json(res, { ok: false, error: "Kein Code hinterlegt." }, 404); + + const code = await decryptCode(user.code_enc); + logAction(session.actor, "user.code_reveal", id, null); + return json(res, { ok: true, code }); +} + +export async function lockUser(req, res, session) { + if (!can(session, "TEAM_LOCK_USER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + db.prepare(`UPDATE users SET status = 'locked' WHERE id = ?`).run(id); + endAllSessionsForUser(id); + logAction(session.actor, "user.lock", id, null); + return json(res, { ok: true }); +} + +export async function unlockUser(req, res, session) { + if (!can(session, "TEAM_LOCK_USER")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + const user = loadUser(id); + if (!user) return json(res, { ok: false, error: "Benutzer nicht gefunden." }, 404); + if (user.expires_at && new Date(user.expires_at) < new Date()) { + return json(res, { ok: false, error: "Zugang ist abgelaufen — bitte zuerst Gültigkeit verlängern." }, 400); + } + db.prepare(`UPDATE users SET status = 'active' WHERE id = ?`).run(id); + logAction(session.actor, "user.unlock", id, null); + return json(res, { ok: true }); +} + +/** NUR Owner: Zugang vollständig löschen. */ +export async function deleteUser(req, res, session) { + if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf Zugänge löschen." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + db.prepare(`DELETE FROM user_permission_overrides WHERE user_id = ?`).run(id); + db.prepare(`DELETE FROM sessions WHERE user_id = ?`).run(id); + db.prepare(`DELETE FROM users WHERE id = ?`).run(id); + logAction(session.actor, "user.delete", id, null); + return json(res, { ok: true }); +} + +export async function setUserRole(req, res, session) { + if (!can(session, "TEAM_ASSIGN_ROLE")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + const roleId = body.roleId || null; + db.prepare(`UPDATE users SET role_id = ? WHERE id = ?`).run(roleId, id); + logAction(session.actor, "user.set_role", id, { roleId }); + return json(res, { ok: true }); +} + +export async function setUserPermissions(req, res, session) { + if (!can(session, "TEAM_EDIT_PERMISSIONS")) return json(res, { ok: false, error: "Keine Berechtigung." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + const overrides = body.overrides && typeof body.overrides === "object" ? body.overrides : {}; + + db.prepare(`DELETE FROM user_permission_overrides WHERE user_id = ?`).run(id); + for (const [key, allowed] of Object.entries(overrides)) { + if (!isValidPermissionKey(key)) continue; // Owner-only-Aktionen können hier nie landen + db.prepare(`INSERT INTO user_permission_overrides (user_id, permission, allowed) VALUES (?, ?, ?)`).run(id, key, allowed ? 1 : 0); + } + logAction(session.actor, "user.set_permissions", id, { overrides }); + return json(res, { ok: true }); +} + +/** NUR Owner: alle Sitzungen einer Person sofort beenden ("alle Geräte abmelden"). */ +export async function endUserSessions(req, res, session) { + if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf Sitzungen beenden." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + endAllSessionsForUser(id); + logAction(session.actor, "user.end_sessions", id, null); + return json(res, { ok: true }); +} + +export async function updateUserMeta(req, res, session) { + if (!session?.isOwner) return json(res, { ok: false, error: "Nur der Hauptadministrator darf diese Angaben ändern." }, 403); + const body = req.body || {}; + const id = String(body.id || ""); + const user = loadUser(id); + if (!user) return json(res, { ok: false, error: "Benutzer nicht gefunden." }, 404); + + const name = body.name !== undefined ? String(body.name).trim().slice(0, 80) || user.name : user.name; + const email = body.email !== undefined ? String(body.email).trim() || null : user.email; + const note = body.note !== undefined ? String(body.note).slice(0, 500) || null : user.note; + let expiresAt = user.expires_at; + if (body.expiresAt !== undefined) expiresAt = body.expiresAt ? new Date(body.expiresAt).toISOString() : null; + if (body.expiresInDays) expiresAt = new Date(Date.now() + Number(body.expiresInDays) * 86400000).toISOString(); + + db.prepare(`UPDATE users SET name = ?, email = ?, note = ?, expires_at = ? WHERE id = ?`).run(name, email, note, expiresAt, id); + logAction(session.actor, "user.update_meta", id, null); + return json(res, { ok: true }); +}