Postfach/Team-Verwaltung/Supporter-Abo als Node.js/Express-Server portiert

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.
This commit is contained in:
2026-08-05 18:57:19 +02:00
parent e331965bf1
commit e87aca3019
26 changed files with 2962 additions and 0 deletions
+47
View File
@@ -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 <[email protected]>
# ---- 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=
+51
View File
@@ -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}`);
}
}
+193
View File
@@ -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}`);
});
+16
View File
@@ -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()
);
}
+142
View File
@@ -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);
}
+62
View File
@@ -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();
}
+68
View File
@@ -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);
}
}
+45
View File
@@ -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],
};
}
+10
View File
@@ -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);
}
+133
View File
@@ -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 };
}
+142
View File
@@ -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";
}
+74
View File
@@ -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);
}
+86
View File
@@ -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);
}
+57
View File
@@ -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),
})),
};
}
+74
View File
@@ -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) => `
<div style="font-family:Arial,sans-serif;background:#0b0d10;color:#f3f5f7;padding:32px;">
<div style="max-width:480px;margin:0 auto;background:#171b21;border-radius:14px;padding:28px;border:1px solid #262c35;">
<h1 style="color:#8fd9ea;font-size:20px;margin:0 0 16px;">${title}</h1>
${bodyHtml}
<p style="color:#9aa4b2;font-size:12px;margin-top:24px;">DogiCrew 🩵</p>
</div>
</div>`;
export async function sendVerifyEmailCode({ to, name, code }) {
return send({
to,
subject: "Bestätige deine E-Mail-Adresse — DogiCrew 🩵",
html: WRAP(
`Willkommen, ${name}! 🩵`,
`<p style="line-height:1.6;">Bestätige deine E-Mail-Adresse mit diesem Code:</p>
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig.</p>`
),
});
}
export async function sendLoginCode({ to, name, code }) {
return send({
to,
subject: "Dein Login-Code — Dogfather Supporter-Bereich",
html: WRAP(
`Hallo ${name} 👋`,
`<p style="line-height:1.6;">Dein Login-Code für den Supporter-Bereich:</p>
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig. Hast du das nicht angefordert, ignoriere diese E-Mail.</p>`
),
});
}
export async function sendMilestoneUnlocked({ to, name, premiumLabel }) {
return send({
to,
subject: `Neuer Meilenstein freigeschaltet — ${premiumLabel} 🎉`,
html: WRAP(
`Glückwunsch, ${name}! 🩵`,
`<p style="line-height:1.6;">Du hast einen neuen Meilenstein erreicht und kannst jetzt deine Prämie einlösen:</p>
<p style="font-size:20px;font-weight:800;color:#fff;">${premiumLabel}</p>
<p style="color:#9aa4b2;font-size:13px;">Logge dich in deinen Supporter-Bereich ein, um die Prämie einzulösen.</p>`
),
});
}
+60
View File
@@ -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);
}
}
+71
View File
@@ -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 = `<script id="${id}"`;
const start = html.indexOf(marker);
if (start === -1) return null;
const open = html.indexOf(">", start);
if (open === -1) return null;
const end = html.indexOf("</script>", 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,
};
}
+19
View File
@@ -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"
}
}
+228
View File
@@ -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 });
}
+20
View File
@@ -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 });
}
+104
View File
@@ -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 });
}
+93
View File
@@ -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 });
}
+224
View File
@@ -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 });
}
+309
View File
@@ -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 });
}
+379
View File
@@ -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 });
}
+255
View File
@@ -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 });
}