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

Vollständiger 1:1-Port des cloudflare-worker/ (dogfather-universe-postfach)
auf server-internal/: Bewerbungs-Postfach mit Rollen/Rechten, Team-
Zugangsverwaltung (verschlüsselte Owner-only-Codes), DogiCrew-Supporter-Abo
(PayPal Subscriptions, Google-Login, Resend-E-Mail, 30-Monats-Prämienzyklus)
und automatische TikTok-Live-Erkennung. D1 -> better-sqlite3, Cloudflare-
Cron -> node-cron. Läuft ohne gesetzte Secrets weiter (PayPal/Google/Resend/
Ticketanizer/Discord bleiben "nicht konfiguriert" statt kaputt), bis die
echten Zugangsdaten eingetragen werden.
This commit is contained in:
2026-08-05 18:57:19 +02:00
parent e331965bf1
commit e87aca3019
26 changed files with 2962 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
/* =====================================================================
audit.js — Aktivitätsprotokoll. 1:1 portiert, D1 → better-sqlite3.
WICHTIG: Codes dürfen hier NIEMALS im Klartext auftauchen.
===================================================================== */
import { db } from "../db.js";
import { nowIso } from "./crypto.js";
export function logAction(actor, action, target, detail) {
db.prepare(`INSERT INTO audit_log (actor, action, target, detail, created_at) VALUES (?, ?, ?, ?, ?)`).run(
actor,
action,
target || null,
detail ? JSON.stringify(detail) : null,
nowIso()
);
}
+142
View File
@@ -0,0 +1,142 @@
/* =====================================================================
auth.js — Login, Sessions, Rate-Limiting/Lockout für den internen
Team-Bereich. 1:1 portiert aus cloudflare-worker/src/lib/auth.js,
D1 (async) → better-sqlite3 (synchron).
===================================================================== */
import { db } from "../db.js";
import { sha256Hex, generateSessionToken, nowIso } from "./crypto.js";
import { logAction } from "./audit.js";
const MAX_ATTEMPTS = 5;
const LOCKOUT_MINUTES = 15;
const INACTIVITY_MS = 24 * 60 * 60 * 1000;
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
export function clientKey(req) {
return req.headers["cf-connecting-ip"] || req.ip || "unknown";
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
return mismatch === 0;
}
export function isLockedOut(key) {
const row = db.prepare(`SELECT locked_until FROM login_attempts WHERE key = ?`).get(key);
if (!row || !row.locked_until) return false;
return new Date(row.locked_until) > new Date();
}
export function recordFailedAttempt(key) {
const row = db.prepare(`SELECT count FROM login_attempts WHERE key = ?`).get(key);
const count = (row?.count || 0) + 1;
let lockedUntil = null;
if (count >= MAX_ATTEMPTS) {
lockedUntil = new Date(Date.now() + LOCKOUT_MINUTES * 60 * 1000).toISOString();
}
db.prepare(
`INSERT INTO login_attempts (key, count, locked_until, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET count = ?, locked_until = ?, updated_at = ?`
).run(key, count, lockedUntil, nowIso(), count, lockedUntil, nowIso());
return { count, lockedUntil };
}
export function clearAttempts(key) {
db.prepare(`DELETE FROM login_attempts WHERE key = ?`).run(key);
}
/** Prüft einen eingegebenen Code gegen Owner-Secret/-Hash und Team-Datenbank. */
export async function verifyCode(code) {
const hash = await sha256Hex(code);
const ownerRow = db.prepare(`SELECT value FROM app_settings WHERE key = 'owner_code_hash'`).get();
if (ownerRow && ownerRow.value) {
if (timingSafeEqual(hash, ownerRow.value)) return { ok: true, isOwner: true };
} else if (process.env.POSTFACH_CODE && timingSafeEqual(code, process.env.POSTFACH_CODE)) {
return { ok: true, isOwner: true };
}
const user = db
.prepare(`SELECT id, name, username, role_id, status, expires_at FROM users WHERE code_hash = ?`)
.get(hash);
if (!user) return { ok: false };
if (user.status !== "active") return { ok: false, reason: "locked" };
if (user.expires_at && new Date(user.expires_at) < new Date()) return { ok: false, reason: "expired" };
return { ok: true, isOwner: false, user };
}
export async function createSession({ isOwner, userId }) {
const token = generateSessionToken();
const tokenHash = await sha256Hex(token);
const now = new Date();
const expiresAt = new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString();
db.prepare(
`INSERT INTO sessions (token_hash, user_id, is_owner, created_at, last_active_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)`
).run(tokenHash, isOwner ? "owner" : userId, isOwner ? 1 : 0, now.toISOString(), now.toISOString(), expiresAt);
return token;
}
export async function resolveSession(token) {
if (!token) return null;
const tokenHash = await sha256Hex(token);
const session = db.prepare(`SELECT * FROM sessions WHERE token_hash = ?`).get(tokenHash);
if (!session) return null;
const now = Date.now();
if (new Date(session.expires_at).getTime() < now) {
db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash);
return null;
}
if (now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) {
db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash);
return null;
}
db.prepare(`UPDATE sessions SET last_active_at = ? WHERE token_hash = ?`).run(new Date().toISOString(), tokenHash);
if (session.is_owner) {
return { isOwner: true, actor: "owner" };
}
const user = db
.prepare(
`SELECT u.id, u.name, u.username, u.role_id, u.status, r.name as role_name, r.permissions
FROM users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.id = ?`
)
.get(session.user_id);
if (!user || user.status !== "active") return null;
const overridesRows = db.prepare(`SELECT permission, allowed FROM user_permission_overrides WHERE user_id = ?`).all(user.id);
const overrides = {};
for (const r of overridesRows) overrides[r.permission] = !!r.allowed;
return {
isOwner: false,
actor: user.username,
userId: user.id,
name: user.name,
roleId: user.role_id,
roleName: user.role_name,
permissions: user.permissions ? JSON.parse(user.permissions) : [],
overrides,
};
}
export async function endSession(token) {
if (!token) return;
const tokenHash = await sha256Hex(token);
db.prepare(`DELETE FROM sessions WHERE token_hash = ?`).run(tokenHash);
}
export function endAllSessionsForUser(userId) {
db.prepare(`DELETE FROM sessions WHERE user_id = ?`).run(userId);
}
export async function requireSession(req) {
const auth = req.headers["authorization"] || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
return resolveSession(token);
}
+62
View File
@@ -0,0 +1,62 @@
/* =====================================================================
crypto.js — Verschlüsselung & Hashing für das Zugangssystem. 1:1 portiert
aus cloudflare-worker/src/lib/crypto.js (Web Crypto API, in Node 20+
global verfügbar wie im Cloudflare Worker — keine Änderung nötig).
===================================================================== */
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne I,O,0,1
export function generateFriendlyCode() {
const bytes = crypto.getRandomValues(new Uint8Array(12));
let raw = "";
for (const b of bytes) raw += CODE_ALPHABET[b % CODE_ALPHABET.length];
return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`;
}
export function generateSessionToken() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export function generateId() {
return crypto.randomUUID();
}
export async function sha256Hex(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function getAesKey() {
const raw = Uint8Array.from(atob(process.env.ENCRYPTION_KEY), (c) => c.charCodeAt(0));
return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);
}
function bufToBase64(buf) {
return btoa(String.fromCharCode(...new Uint8Array(buf)));
}
function base64ToBuf(b64) {
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
/** Verschlüsselt einen Klartext-Code reversibel. Rückgabe: "iv.ciphertext" (base64). */
export async function encryptCode(plaintext) {
const key = await getAesKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext));
return `${bufToBase64(iv)}.${bufToBase64(ciphertext)}`;
}
/** Entschlüsselt einen mit encryptCode() erzeugten Wert. */
export async function decryptCode(encoded) {
const [ivB64, ctB64] = encoded.split(".");
const key = await getAesKey();
const iv = base64ToBuf(ivB64);
const ciphertext = base64ToBuf(ctB64);
const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
return new TextDecoder().decode(plainBuf);
}
export function nowIso() {
return new Date().toISOString();
}
+68
View File
@@ -0,0 +1,68 @@
/* =====================================================================
lib/discord-webhooks.js — Bewerbungen als Discord-Nachricht posten.
1:1 portiert aus cloudflare-worker/src/lib/discord-webhooks.js
(reines fetch(), env.X → process.env.X).
===================================================================== */
const WEBHOOK_ENV_KEYS = {
modi: "DISCORD_WEBHOOK_MODI",
kooperation: "DISCORD_WEBHOOK_KOOPERATION",
scout: "DISCORD_WEBHOOK_SCOUT_MANAGER",
creator: "DISCORD_WEBHOOK_SCOUT_MANAGER",
};
const TYPE_LABELS = {
modi: "🩵 Neue Bewerbung — Modi (Team Dogi)",
kooperation: "🤝 Neue Kooperationsanfrage",
scout: "🔎 Neue Bewerbung — Scout (Spicy Media)",
creator: "🎬 Neue Bewerbung — Creator / Manager-Bewerbung (Spicy Media)",
};
const TYPE_COLORS = { modi: 0x8fd9ea, kooperation: 0x8b5cf6, scout: 0xd94a5c, creator: 0xd94a5c };
const FIELD_LABELS = {
name: "Name", email: "E-Mail", kontakt: "Kontakt (Discord/TikTok)", erfahrung: "Erfahrung",
verfuegbarkeit: "Verfügbarkeit", warum: "Warum möchtest du dabei sein?", platform: "Plattform",
content: "Content-Art", firma: "Firma / Projekt", idee: "Idee / Anliegen",
};
function feldWert(wert) {
const text = String(wert ?? "").trim();
if (!text) return "_(leer)_";
return text.length > 1000 ? text.slice(0, 1000) + "…" : text;
}
export async function bewerbungAlsDiscordNachrichtPosten(type, data) {
const envKey = WEBHOOK_ENV_KEYS[type];
const webhookUrl = envKey && process.env[envKey];
if (!webhookUrl) {
console.error(`[discord-webhook] Keine Webhook-URL für "${type}" hinterlegt (${envKey}) — übersprungen.`);
return;
}
const fields = Object.entries(data || {})
.filter(([, v]) => String(v ?? "").trim() !== "")
.map(([key, value]) => ({ name: FIELD_LABELS[key] || key, value: feldWert(value), inline: String(value).length < 40 }));
const embed = {
title: TYPE_LABELS[type] || "Neue Bewerbung",
color: TYPE_COLORS[type] ?? 0x8fd9ea,
fields: fields.length ? fields : [{ name: "Hinweis", value: "Keine Angaben übermittelt." }],
footer: { text: "DOGFATHER UNIVERSE — automatisch von der Homepage" },
timestamp: new Date().toISOString(),
};
try {
const antwort = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ embeds: [embed] }),
});
if (!antwort.ok) {
const antwortText = await antwort.text().catch(() => "");
console.error(`[discord-webhook] Fehler beim Posten (${type}): HTTP ${antwort.status}${antwortText}`);
}
} catch (fehler) {
console.error(`[discord-webhook] Ausnahme beim Posten (${type}):`, fehler && fehler.message);
}
}
+45
View File
@@ -0,0 +1,45 @@
/* =====================================================================
lib/google-auth.js — Verifiziert ein Google-ID-Token ("Mit Google
anmelden") serverseitig, für den öffentlichen Supporter-Login.
1:1 portiert aus cloudflare-worker/src/lib/google-auth.js, env.X → process.env.X.
Braucht GOOGLE_CLIENT_ID (öffentlich, KEIN Geheimnis). Ohne gesetzten
Wert bleibt "Mit Google anmelden" ausgeblendet (siehe getPublicConfig
in routes/supporter.js) statt kaputt zu sein.
Verifikation läuft über Googles offiziellen tokeninfo-Endpunkt
(https://oauth2.googleapis.com/tokeninfo) — Google prüft dort Signatur,
Ablaufzeit und Aussteller selbst und gibt die Nutzdaten zurück.
===================================================================== */
export class GoogleAuthNotConfiguredError extends Error {
constructor() {
super("Google-Login ist noch nicht eingerichtet (GOOGLE_CLIENT_ID fehlt).");
this.name = "GoogleAuthNotConfiguredError";
this.code = "GOOGLE_AUTH_NOT_CONFIGURED";
}
}
/**
* Prüft ein von Google Identity Services im Browser ausgestelltes ID-Token.
* Gibt bei Erfolg die bestätigten Nutzerdaten zurück (E-Mail ist von Google
* selbst bereits verifiziert — braucht deshalb KEINEN zusätzlichen
* E-Mail-Bestätigungscode mehr, siehe routes/supporter.js).
*/
export async function verifyGoogleIdToken(credential) {
if (!process.env.GOOGLE_CLIENT_ID) throw new GoogleAuthNotConfiguredError();
if (!credential) return { ok: false, reason: "Kein Token übergeben." };
const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
if (!res.ok) return { ok: false, reason: "Google-Token konnte nicht geprüft werden." };
const data = await res.json();
if (data.aud !== process.env.GOOGLE_CLIENT_ID) return { ok: false, reason: "Token ist für eine andere App ausgestellt." };
if (!data.email || data.email_verified !== "true") return { ok: false, reason: "E-Mail-Adresse ist bei Google nicht bestätigt." };
return {
ok: true,
email: String(data.email).toLowerCase(),
name: data.name || data.given_name || data.email.split("@")[0],
};
}
+10
View File
@@ -0,0 +1,10 @@
/* =====================================================================
lib/http.js — gemeinsamer JSON-Response-Helfer für Express.
1:1 dem Sinn nach portiert aus cloudflare-worker/src/lib/http.js
(dort baute json() eine Response mit CORS-Headern; CORS läuft hier
stattdessen zentral über die "cors"-Middleware in index.js, siehe dort).
===================================================================== */
export function json(res, data, status = 200) {
return res.status(status).json(data);
}
+133
View File
@@ -0,0 +1,133 @@
/* =====================================================================
lib/live-status.js — Automatische TikTok-Live-Erkennung für den grünen
Punkt auf der Startseite. 1:1 portiert aus dem Live-Status-Teil von
cloudflare-worker/src/worker.js, D1 → better-sqlite3.
Ein Cron-Auftrag (node-cron in index.js, ersetzt Cloudflares
`scheduled()`) fragt jede Minute bei TikTok nach, ob @dogfather0804
gerade sendet, und schreibt das Ergebnis in die Tabelle app_settings.
Zwei Schlüssel:
live_auto letzter ERFOLGREICH ermittelter Stand (wird bei einem
fehlgeschlagenen Abruf bewusst NICHT überschrieben,
damit ein TikTok-Aussetzer den Punkt nicht fälschlich
auf offline zieht)
live_override manueller Vorrang aus dem Postfach, mit Ablaufzeit.
Solange er gilt, schlägt er die Automatik.
===================================================================== */
import { db } from "../db.js";
import { json } from "./http.js";
import { logAction } from "./audit.js";
import { checkTikTokLive } from "./tiktok-live.js";
const DEFAULT_OVERRIDE_MINUTES = 180;
function readSetting(key) {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(key);
if (!row || !row.value) return null;
try {
return JSON.parse(row.value);
} catch {
return null;
}
}
function writeSetting(key, value) {
db.prepare(
`INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
).run(key, JSON.stringify(value), new Date().toISOString());
}
/** Ermittelt den Stand, der nach außen gilt (Vorrang: manuell vor Automatik). */
function resolveLiveStatus() {
const auto = readSetting("live_auto") || {};
const override = readSetting("live_override");
const overrideActive = override && override.until && new Date(override.until).getTime() > Date.now();
if (overrideActive) {
return {
live: !!override.live,
source: "manuell",
updatedAt: override.setAt || null,
overrideUntil: override.until,
autoLive: auto.live ?? null,
autoCheckedAt: auto.checkedAt || null,
autoHealthy: auto.ok !== false,
};
}
return {
live: !!auto.live,
source: "automatisch",
updatedAt: auto.changedAt || auto.checkedAt || null,
title: auto.title || null,
autoLive: auto.live ?? null,
autoCheckedAt: auto.checkedAt || null,
autoHealthy: auto.ok !== false,
};
}
export async function handleLiveStatusGet(req, res) {
return json(res, { ok: true, ...resolveLiveStatus() });
}
/**
* Manueller Eingriff aus dem Postfach.
* { live: true|false, minutes?: number } → Vorrang setzen
* { auto: true } → Vorrang aufheben, Automatik übernimmt
*/
export async function handleLiveStatusSet(req, res, session) {
if (!session?.isOwner) return json(res, { ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 403);
const body = req.body || {};
if (body.auto === true) {
db.prepare(`DELETE FROM app_settings WHERE key = 'live_override'`).run();
logAction("owner", "live.override_cleared", null, null);
return json(res, { ok: true, ...resolveLiveStatus() });
}
const minutes = Number.isFinite(body.minutes) ? Math.min(Math.max(body.minutes, 5), 1440) : DEFAULT_OVERRIDE_MINUTES;
const now = new Date();
writeSetting("live_override", {
live: !!body.live,
setAt: now.toISOString(),
until: new Date(now.getTime() + minutes * 60_000).toISOString(),
});
logAction("owner", "live.override_set", null, { live: !!body.live, minutes });
return json(res, { ok: true, ...resolveLiveStatus() });
}
/** Wird vom Cron-Auftrag aufgerufen (index.js) — fragt TikTok ab und schreibt das Ergebnis. */
export async function refreshLiveFromTikTok() {
const username = process.env.TIKTOK_USERNAME || "dogfather0804";
const result = await checkTikTokLive(username);
const now = new Date().toISOString();
if (!result.ok) {
// Fehlgeschlagen: letzten guten Stand behalten, nur den Fehler vermerken.
const previous = readSetting("live_auto") || {};
writeSetting("live_auto", { ...previous, ok: false, lastError: result.reason, checkedAt: now });
return { ok: false, reason: result.reason };
}
const previous = readSetting("live_auto") || {};
const changed = previous.live !== result.live;
writeSetting("live_auto", {
ok: true,
live: result.live,
title: result.title,
roomId: result.roomId,
rawStatus: result.rawStatus,
checkedAt: now,
changedAt: changed ? now : previous.changedAt || now,
lastError: null,
});
if (changed) {
logAction("system", result.live ? "live.started" : "live.ended", null, { title: result.title });
}
return { ok: true, live: result.live, changed };
}
+142
View File
@@ -0,0 +1,142 @@
/* =====================================================================
lib/paypal.js — PayPal Subscriptions API Anbindung (Supporter-Abo).
1:1 portiert aus cloudflare-worker/src/lib/paypal.js, env.X → process.env.X.
WICHTIG — offener Punkt (wie im Original): vollständig fertig
implementiert (OAuth2, Subscription erstellen, Subscription abfragen,
Webhook-Signatur prüfen), kann aber erst wirklich Geld verarbeiten,
sobald folgende echten Werte in der .env gesetzt sind:
PAYPAL_CLIENT_ID
PAYPAL_CLIENT_SECRET
PAYPAL_PLAN_ID (aus dem PayPal-Business-Konto)
PAYPAL_WEBHOOK_ID
PAYPAL_ENV = "sandbox" | "live" (unkritisch, kein Secret)
Diese Werte kann NUR der Website-Betreiber selbst erzeugen (PayPal-
Business-Konto + Developer-App sind an seine eigene Identität/Bank
gebunden) — deshalb "fail closed" mit sprechendem Fehler statt eines
Absturzes, solange sie fehlen. NIEMALS in .env.example oder Vault!
===================================================================== */
export class PayPalNotConfiguredError extends Error {
constructor() {
super("PayPal ist noch nicht eingerichtet (PAYPAL_CLIENT_ID/SECRET/PLAN_ID fehlen).");
this.name = "PayPalNotConfiguredError";
this.code = "PAYPAL_NOT_CONFIGURED";
}
}
function baseUrl() {
return process.env.PAYPAL_ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com";
}
function assertConfigured() {
if (!process.env.PAYPAL_CLIENT_ID || !process.env.PAYPAL_CLIENT_SECRET || !process.env.PAYPAL_PLAN_ID) {
throw new PayPalNotConfiguredError();
}
}
let cachedToken = null; // { value, expiresAt } — pro Serverprozess, spart Requests
async function getAccessToken() {
assertConfigured();
if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.value;
const res = await fetch(`${baseUrl()}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${btoa(`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`)}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
if (!res.ok) throw new Error(`PayPal-OAuth fehlgeschlagen (${res.status}): ${await res.text().catch(() => "")}`);
const data = await res.json();
cachedToken = { value: data.access_token, expiresAt: Date.now() + (data.expires_in || 3600) * 1000 };
return cachedToken.value;
}
async function paypalFetch(path, options = {}) {
const token = await getAccessToken();
const res = await fetch(`${baseUrl()}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(options.headers || {}),
},
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = new Error(`PayPal-Anfrage fehlgeschlagen (${res.status}): ${text.slice(0, 400)}`);
err.paypalStatus = res.status;
err.paypalBody = data;
throw err;
}
return data;
}
/**
* Erstellt ein PayPal-Abonnement für einen Supporter und liefert den
* "approve"-Link zurück, zu dem der Nutzer weitergeleitet werden muss, um
* das Abo direkt bei PayPal zu bestätigen. custom_id trägt unsere interne
* supporter_id, damit der Webhook später weiß, wem die Zahlung gehört.
*/
export async function createSubscription({ supporterId, returnUrl, cancelUrl }) {
const data = await paypalFetch("/v1/billing/subscriptions", {
method: "POST",
headers: { "PayPal-Request-Id": `sub-${supporterId}-${Date.now()}` },
body: JSON.stringify({
plan_id: process.env.PAYPAL_PLAN_ID,
custom_id: supporterId,
application_context: {
brand_name: "Dogfather",
locale: "de-DE",
shipping_preference: "NO_SHIPPING",
user_action: "SUBSCRIBE_NOW",
return_url: returnUrl,
cancel_url: cancelUrl,
},
}),
});
const approveLink = (data.links || []).find((l) => l.rel === "approve");
return { paypalSubscriptionId: data.id, approveUrl: approveLink?.href || null, raw: data };
}
export async function getSubscription(paypalSubscriptionId) {
return paypalFetch(`/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}`);
}
/** Kündigung — Zugang bleibt bis Periodenende bestehen. */
export async function cancelSubscription(paypalSubscriptionId, reason) {
await paypalFetch(`/v1/billing/subscriptions/${encodeURIComponent(paypalSubscriptionId)}/cancel`, {
method: "POST",
body: JSON.stringify({ reason: reason || "Kündigung durch Supporter" }),
});
return true;
}
/**
* Prüft die Echtheit eines eingehenden Webhooks über PayPals offizielle
* Verify-Signature-Funktion. `req` ist ein Express-Request (Header über
* req.headers Objekt, nicht .get()/.headers.get() wie bei Cloudflare).
*/
export async function verifyWebhookSignature(req, rawBody) {
assertConfigured();
if (!process.env.PAYPAL_WEBHOOK_ID) throw new PayPalNotConfiguredError();
const h = req.headers;
const verification = await paypalFetch("/v1/notifications/verify-webhook-signature", {
method: "POST",
body: JSON.stringify({
auth_algo: h["paypal-auth-algo"],
cert_url: h["paypal-cert-url"],
transmission_id: h["paypal-transmission-id"],
transmission_sig: h["paypal-transmission-sig"],
transmission_time: h["paypal-transmission-time"],
webhook_id: process.env.PAYPAL_WEBHOOK_ID,
webhook_event: JSON.parse(rawBody),
}),
});
return verification.verification_status === "SUCCESS";
}
+74
View File
@@ -0,0 +1,74 @@
/* =====================================================================
permissions.js — Vollständiger Permission-Katalog. 1:1 portiert aus
cloudflare-worker/src/lib/permissions.js (keine Datenbank-Abhängigkeit).
===================================================================== */
export const PERMISSIONS = {
APPLICATIONS_VIEW_CREATOR: "Creator-Bewerbungen ansehen",
APPLICATIONS_VIEW_MANAGER_SCOUT: "Manager-/Scout-Bewerbungen ansehen",
APPLICATIONS_VIEW_ARCHIVED: "Archivierte Bewerbungen ansehen",
APPLICATIONS_OPEN_FULL: "Vollständige Bewerbungen öffnen",
APPLICATIONS_TAKE_OVER: "Bewerbungen übernehmen",
APPLICATIONS_ASSIGN: "Bewerbungen anderen Personen zuweisen",
APPLICATIONS_CHANGE_STATUS: "Status verändern",
APPLICATIONS_ARCHIVE: "Bewerbungen archivieren",
APPLICATIONS_RESTORE: "Bewerbungen wiederherstellen",
APPLICATIONS_DELETE: "Bewerbungen löschen",
DRAFTS_CREATE: "Antwortentwürfe erstellen",
DRAFTS_EDIT_OWN: "Eigene Entwürfe bearbeiten",
DRAFTS_EDIT_OTHERS: "Entwürfe anderer Personen bearbeiten",
REPLIES_COPY: "Fertige Antworten kopieren",
EMAILS_COPY: "E-Mail-Adressen kopieren",
APPLICATIONS_MARK_ANSWERED: "Bewerbungen als beantwortet markieren",
NOTES_READ: "Notizen lesen",
NOTES_WRITE: "Notizen schreiben",
NOTES_EDIT_OWN: "Eigene Notizen bearbeiten",
NOTES_EDIT_OTHERS: "Fremde Notizen bearbeiten",
NOTES_DELETE: "Notizen löschen",
TEAM_VIEW: "Andere Teammitglieder ansehen",
TEAM_PREPARE_USER: "Neue Benutzer vorbereiten",
TEAM_LOCK_USER: "Benutzer sperren",
TEAM_ASSIGN_ROLE: "Rollen vergeben",
TEAM_CREATE_ROLE: "Rollen erstellen",
TEAM_EDIT_PERMISSIONS: "Allgemeine Berechtigungen verändern",
SETTINGS_EDIT_FORMS: "Bewerbungsformulare bearbeiten",
SETTINGS_EDIT_STATUS_TYPES: "Statusarten verändern",
SETTINGS_VIEW_STATS: "Statistiken ansehen",
SETTINGS_VIEW_ACTIVITY_LOG: "Aktivitätsverlauf ansehen",
SETTINGS_EDIT_GENERAL: "Allgemeine Einstellungen bearbeiten",
SUPPORTER_VIEW: "Supporter-Abonnenten ansehen",
SUPPORTER_MANAGE_PREMIUMS: "Supporter-Prämien & Versand verwalten",
SUPPORTER_MANAGE_COLLECTIONS: "Supporter-Kollektionen verwalten",
};
export const PERMISSION_GROUPS = [
{ label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] },
{ label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] },
{ label: "Antworten", keys: ["DRAFTS_CREATE", "DRAFTS_EDIT_OWN", "DRAFTS_EDIT_OTHERS", "REPLIES_COPY", "EMAILS_COPY", "APPLICATIONS_MARK_ANSWERED"] },
{ label: "Interne Notizen", keys: ["NOTES_READ", "NOTES_WRITE", "NOTES_EDIT_OWN", "NOTES_EDIT_OTHERS", "NOTES_DELETE"] },
{ label: "Teamverwaltung", keys: ["TEAM_VIEW", "TEAM_PREPARE_USER", "TEAM_LOCK_USER", "TEAM_ASSIGN_ROLE", "TEAM_CREATE_ROLE", "TEAM_EDIT_PERMISSIONS"] },
{ label: "Einstellungen", keys: ["SETTINGS_EDIT_FORMS", "SETTINGS_EDIT_STATUS_TYPES", "SETTINGS_VIEW_STATS", "SETTINGS_VIEW_ACTIVITY_LOG", "SETTINGS_EDIT_GENERAL"] },
{ label: "DogiCrew 🩵", keys: ["SUPPORTER_VIEW", "SUPPORTER_MANAGE_PREMIUMS", "SUPPORTER_MANAGE_COLLECTIONS"] },
];
export const OWNER_ONLY_ACTIONS = [
"CODE_VIEW", "CODE_COPY", "CODE_CREATE", "CODE_EDIT", "CODE_RESET",
"EDIT_OWNER_ACCOUNT", "DELETE_OR_LIMIT_OWNER_ROLE", "PROMOTE_TO_OWNER",
];
export function hasPermission(rolePermissions, overrides, key) {
if (overrides && Object.prototype.hasOwnProperty.call(overrides, key)) {
return !!overrides[key];
}
return Array.isArray(rolePermissions) && rolePermissions.includes(key);
}
export function isValidPermissionKey(key) {
return Object.prototype.hasOwnProperty.call(PERMISSIONS, key);
}
+86
View File
@@ -0,0 +1,86 @@
/* =====================================================================
lib/supporter-auth.js — Registrierung, E-Mail-Code-Login, Sessions für
den öffentlichen Supporter-Bereich. 1:1 portiert, D1 → better-sqlite3.
===================================================================== */
import { db } from "../db.js";
import { sha256Hex, generateSessionToken, generateId, nowIso } from "./crypto.js";
const CODE_TTL_MS = 15 * 60 * 1000;
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const INACTIVITY_MS = 14 * 24 * 60 * 60 * 1000;
export function normalizeTikTokUsername(input) {
if (!input) return null;
return String(input).trim().replace(/^@+/, "").toLowerCase() || null;
}
export function normalizeEmail(input) {
return String(input || "").trim().toLowerCase();
}
function generateNumericCode() {
const n = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000;
return String(n).padStart(6, "0");
}
export async function issueAuthCode(supporterId, purpose) {
const code = generateNumericCode();
const codeHash = await sha256Hex(code);
db.prepare(
`INSERT INTO supporter_auth_codes (id, supporter_id, purpose, code_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)`
).run(generateId(), supporterId, purpose, codeHash, new Date(Date.now() + CODE_TTL_MS).toISOString(), nowIso());
return code;
}
export async function verifyAuthCode(supporterId, purpose, code) {
const codeHash = await sha256Hex(String(code || "").trim());
const row = db
.prepare(
`SELECT id, expires_at, used_at FROM supporter_auth_codes
WHERE supporter_id = ? AND purpose = ? AND code_hash = ? ORDER BY created_at DESC LIMIT 1`
)
.get(supporterId, purpose, codeHash);
if (!row) return false;
if (row.used_at) return false;
if (new Date(row.expires_at).getTime() < Date.now()) return false;
db.prepare(`UPDATE supporter_auth_codes SET used_at = ? WHERE id = ?`).run(nowIso(), row.id);
return true;
}
export async function createSupporterSession(supporterId) {
const token = generateSessionToken();
const tokenHash = await sha256Hex(token);
const now = new Date();
db.prepare(
`INSERT INTO supporter_sessions (token_hash, supporter_id, created_at, last_active_at, expires_at) VALUES (?, ?, ?, ?, ?)`
).run(tokenHash, supporterId, now.toISOString(), now.toISOString(), new Date(now.getTime() + SESSION_MAX_AGE_MS).toISOString());
return token;
}
export async function resolveSupporterSession(token) {
if (!token) return null;
const tokenHash = await sha256Hex(token);
const session = db.prepare(`SELECT * FROM supporter_sessions WHERE token_hash = ?`).get(tokenHash);
if (!session) return null;
const now = Date.now();
if (new Date(session.expires_at).getTime() < now || now - new Date(session.last_active_at).getTime() > INACTIVITY_MS) {
db.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).run(tokenHash);
return null;
}
db.prepare(`UPDATE supporter_sessions SET last_active_at = ? WHERE token_hash = ?`).run(nowIso(), tokenHash);
const supporter = db.prepare(`SELECT * FROM supporter_users WHERE id = ? AND status = 'active'`).get(session.supporter_id);
return supporter || null;
}
export async function endSupporterSession(token) {
if (!token) return;
db.prepare(`DELETE FROM supporter_sessions WHERE token_hash = ?`).run(await sha256Hex(token));
}
export async function requireSupporterSession(req) {
const auth = req.headers["authorization"] || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
return resolveSupporterSession(token);
}
+57
View File
@@ -0,0 +1,57 @@
/* =====================================================================
lib/supporter-cycle.js — Reine Rechenlogik für den 30-Monats-Prämienzyklus.
1:1 portiert, unverändert (keine D1-/Netzwerkabhängigkeit).
===================================================================== */
export const CYCLE_LENGTH = 30;
export const MILESTONES = [
{ month: 6, type: "teddy_klein", labelDe: "Kleiner Hasen-Teddy" },
{ month: 12, type: "teddy_mittel", labelDe: "Mittelgroßer Hasen-Teddy" },
{ month: 18, type: "teddy_gross", labelDe: "Großer Hasen-Teddy" },
{ month: 24, type: "tasse_karte", labelDe: "Tasse & Autogrammkarte" },
{ month: 30, type: "hoodie", labelDe: "Exklusiver Hoodie" },
];
export function computeProgress(totalPaidMonths) {
const total = Math.max(0, Math.floor(totalPaidMonths || 0));
if (total === 0) return { totalPaidMonths: 0, completedCycles: 0, cycleNumber: 1, currentCycleMonth: 0 };
const completedCycles = Math.floor((total - 1) / CYCLE_LENGTH);
const currentCycleMonth = total - completedCycles * CYCLE_LENGTH;
return { totalPaidMonths: total, completedCycles, cycleNumber: completedCycles + 1, currentCycleMonth };
}
export function reachedMilestonesInCurrentCycle(currentCycleMonth) {
return MILESTONES.filter((m) => currentCycleMonth >= m.month);
}
export function nextMilestone(currentCycleMonth) {
return MILESTONES.find((m) => currentCycleMonth < m.month) || null;
}
export function allUnlockedMilestones(totalPaidMonths) {
const { completedCycles, currentCycleMonth } = computeProgress(totalPaidMonths);
const unlocked = [];
for (let c = 1; c <= completedCycles; c++) {
for (const m of MILESTONES) unlocked.push({ cycleNumber: c, ...m });
}
const cycleNumber = completedCycles + 1;
for (const m of reachedMilestonesInCurrentCycle(currentCycleMonth)) {
unlocked.push({ cycleNumber, ...m });
}
return unlocked;
}
export function timelineForCurrentCycle(totalPaidMonths) {
const { currentCycleMonth, cycleNumber } = computeProgress(totalPaidMonths);
return {
cycleNumber,
currentCycleMonth,
milestones: MILESTONES.map((m) => ({
...m,
reached: currentCycleMonth >= m.month,
isNext: nextMilestone(currentCycleMonth)?.month === m.month,
monthsRemaining: Math.max(0, m.month - currentCycleMonth),
})),
};
}
+74
View File
@@ -0,0 +1,74 @@
/* =====================================================================
lib/supporter-mail.js — Transaktions-E-Mails für den Supporter-Bereich
(Resend-Adapter). 1:1 portiert, env.X → process.env.X.
===================================================================== */
export class EmailNotConfiguredError extends Error {
constructor() {
super("E-Mail-Versand ist noch nicht eingerichtet (RESEND_API_KEY fehlt).");
this.name = "EmailNotConfiguredError";
this.code = "EMAIL_NOT_CONFIGURED";
}
}
async function send({ to, subject, html }) {
if (!process.env.RESEND_API_KEY || !process.env.RESEND_FROM) throw new EmailNotConfiguredError();
const res = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ from: process.env.RESEND_FROM, to: [to], subject, html }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`E-Mail-Versand fehlgeschlagen (${res.status}): ${text.slice(0, 300)}`);
}
return true;
}
const WRAP = (title, bodyHtml) => `
<div style="font-family:Arial,sans-serif;background:#0b0d10;color:#f3f5f7;padding:32px;">
<div style="max-width:480px;margin:0 auto;background:#171b21;border-radius:14px;padding:28px;border:1px solid #262c35;">
<h1 style="color:#8fd9ea;font-size:20px;margin:0 0 16px;">${title}</h1>
${bodyHtml}
<p style="color:#9aa4b2;font-size:12px;margin-top:24px;">DogiCrew 🩵</p>
</div>
</div>`;
export async function sendVerifyEmailCode({ to, name, code }) {
return send({
to,
subject: "Bestätige deine E-Mail-Adresse — DogiCrew 🩵",
html: WRAP(
`Willkommen, ${name}! 🩵`,
`<p style="line-height:1.6;">Bestätige deine E-Mail-Adresse mit diesem Code:</p>
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig.</p>`
),
});
}
export async function sendLoginCode({ to, name, code }) {
return send({
to,
subject: "Dein Login-Code — Dogfather Supporter-Bereich",
html: WRAP(
`Hallo ${name} 👋`,
`<p style="line-height:1.6;">Dein Login-Code für den Supporter-Bereich:</p>
<p style="font-size:32px;font-weight:800;letter-spacing:4px;color:#fff;">${code}</p>
<p style="color:#9aa4b2;font-size:13px;">Der Code ist 15 Minuten gültig. Hast du das nicht angefordert, ignoriere diese E-Mail.</p>`
),
});
}
export async function sendMilestoneUnlocked({ to, name, premiumLabel }) {
return send({
to,
subject: `Neuer Meilenstein freigeschaltet — ${premiumLabel} 🎉`,
html: WRAP(
`Glückwunsch, ${name}! 🩵`,
`<p style="line-height:1.6;">Du hast einen neuen Meilenstein erreicht und kannst jetzt deine Prämie einlösen:</p>
<p style="font-size:20px;font-weight:800;color:#fff;">${premiumLabel}</p>
<p style="color:#9aa4b2;font-size:13px;">Logge dich in deinen Supporter-Bereich ein, um die Prämie einzulösen.</p>`
),
});
}
+60
View File
@@ -0,0 +1,60 @@
/* =====================================================================
lib/ticketanizer.js — Bewerbungen als Ticket bei Ticketanizer eröffnen.
1:1 portiert aus cloudflare-worker/src/lib/ticketanizer.js.
===================================================================== */
const TICKETANIZER_BASIS_URL = "https://api.ticketanizer.com";
const PANEL_IDS = { modi: 4, kooperation: 5, scout: 6, creator: 7 };
const FIELD_LABELS = {
name: "Name", email: "E-Mail", kontakt: "Kontakt (Discord/TikTok)", erfahrung: "Erfahrung",
verfuegbarkeit: "Verfügbarkeit", warum: "Warum möchtest du dabei sein?", platform: "Plattform",
content: "Content-Art", firma: "Firma / Projekt", idee: "Idee / Anliegen",
};
function ersteNachrichtAufbauen(type, data) {
const zeilen = Object.entries(data || {})
.filter(([, v]) => String(v ?? "").trim() !== "")
.map(([key, value]) => `**${FIELD_LABELS[key] || key}:** ${String(value).trim()}`);
if (!zeilen.length) zeilen.push("_Keine weiteren Angaben übermittelt._");
const text = zeilen.join("\n");
return text.length > 3500 ? text.slice(0, 3500) + "…" : text;
}
const warten = (ms) => new Promise((fertig) => setTimeout(fertig, ms));
const MAX_VERSUCHE = 3;
const STANDARD_WARTEZEITEN_MS = [2000, 5000];
async function ticketAnfrageSenden(apiKey, panelId, visitorName, firstMessage) {
for (let versuch = 1; versuch <= MAX_VERSUCHE; versuch++) {
const antwort = await fetch(`${TICKETANIZER_BASIS_URL}/v1/tickets`, {
method: "POST",
headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
body: JSON.stringify({ panel_id: panelId, visitor_name: visitorName, first_message: firstMessage }),
});
if (antwort.status !== 429 || versuch === MAX_VERSUCHE) return antwort;
const retryAfterHeader = Number(antwort.headers.get("Retry-After"));
const wartezeit = Number.isFinite(retryAfterHeader) && retryAfterHeader > 0 ? retryAfterHeader * 1000 : STANDARD_WARTEZEITEN_MS[versuch - 1] || 5000;
await warten(wartezeit);
}
}
export async function bewerbungAlsTicketAnlegen(type, data) {
const apiKey = process.env.TICKETANIZER_API_KEY;
const panelId = PANEL_IDS[type];
if (!apiKey || !panelId) return;
const visitorName = String(data?.name || data?.firma || "Unbekannt").trim().slice(0, 80) || "Unbekannt";
const firstMessage = ersteNachrichtAufbauen(type, data);
try {
const antwort = await ticketAnfrageSenden(apiKey, panelId, visitorName, firstMessage);
if (!antwort.ok) {
const antwortText = await antwort.text().catch(() => "");
console.error(`[ticketanizer] Fehler beim Ticket-Anlegen (${type}): HTTP ${antwort.status}${antwortText}`);
}
} catch (fehler) {
console.error(`[ticketanizer] Ausnahme beim Ticket-Anlegen (${type}):`, fehler && fehler.message);
}
}
+71
View File
@@ -0,0 +1,71 @@
/* =====================================================================
lib/tiktok-live.js — Automatische Live-Erkennung für TikTok. 1:1 portiert
aus cloudflare-worker/src/lib/tiktok-live.js (das "cf"-Fetch-Sonderfeld
von Cloudflare Workers entfällt, hat in Node keine Entsprechung/Wirkung).
===================================================================== */
const LIVE_STATUS_RUNNING = 2;
const BROWSER_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "de-DE,de;q=0.9,en;q=0.8",
"Cache-Control": "no-cache",
Pragma: "no-cache",
};
function extractScriptJson(html, id) {
const marker = `<script id="${id}"`;
const start = html.indexOf(marker);
if (start === -1) return null;
const open = html.indexOf(">", start);
if (open === -1) return null;
const end = html.indexOf("</script>", open);
if (end === -1) return null;
try {
return JSON.parse(html.slice(open + 1, end));
} catch {
return null;
}
}
export async function checkTikTokLive(username) {
const handle = String(username || "").replace(/^@/, "").trim();
if (!handle) return { ok: false, reason: "kein-benutzername" };
let res;
try {
res = await fetch(`https://www.tiktok.com/@${encodeURIComponent(handle)}/live`, {
headers: BROWSER_HEADERS,
redirect: "follow",
});
} catch (err) {
return { ok: false, reason: `netzwerkfehler: ${String(err).slice(0, 120)}` };
}
if (!res.ok) return { ok: false, reason: `http-${res.status}` };
const html = await res.text();
if (html.length < 5000) return { ok: false, reason: "seite-zu-klein-vermutlich-blockiert" };
const sigi = extractScriptJson(html, "SIGI_STATE");
let info = sigi?.LiveRoom?.liveRoomUserInfo;
if (!info) {
const universal = extractScriptJson(html, "__UNIVERSAL_DATA_FOR_REHYDRATION__");
info = universal?.__DEFAULT_SCOPE__?.["webapp.live-detail"]?.liveRoomUserInfo;
}
if (!info) return { ok: false, reason: "struktur-nicht-gefunden" };
const status = info.liveRoom?.status;
if (typeof status !== "number") return { ok: false, reason: "kein-status-feld" };
return {
ok: true,
live: status === LIVE_STATUS_RUNNING,
rawStatus: status,
title: info.liveRoom?.title || null,
roomId: info.user?.roomId ? String(info.user.roomId) : null,
followers: info.stats?.followerCount ?? null,
};
}