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:
@@ -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=
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user