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,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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user