Files
dogfather-universe/cloudflare-worker/src/worker.js
T

323 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
DOGFATHER UNIVERSE — Interner Bereich Worker
Setzt "Geschuetzter_Zugang_Rollen_Benutzerverwaltung.pdf" um:
Session-basierte Anmeldung, verschlüsselte (reversible) Code-Speicherung
NUR für den Owner einsehbar, vollständig konfigurierbare Rollen mit
granularen Rechten, individuelle Personen-Ausnahmen, Aktivitätsprotokoll,
Login-Lockout nach Fehlversuchen.
Architektur:
src/lib/crypto.js Hashing, AES-GCM (Code-Verschlüsselung), IDs
src/lib/auth.js Login-Prüfung, Sessions, Rate-Limiting
src/lib/permissions.js Vollständiger Permission-Katalog
src/lib/audit.js Aktivitätsprotokoll schreiben
src/lib/http.js CORS/JSON-Helfer
src/routes/*.js Endpoint-Handler pro Bereich
Bindings (siehe wrangler.toml):
D1: DB (Datenbank für Users/Rollen/Sessions/Audit/Bewerbungen)
KV: BEWERBUNGEN (nur noch für den Live-Status-Schalter genutzt)
Secret: POSTFACH_CODE (Owner-Code, mit `wrangler secret put` gesetzt)
Secret: ENCRYPTION_KEY (AES-256-Schlüssel, base64, für Code-Verschlüsselung)
===================================================================== */
import { json, corsHeaders } from "./lib/http.js";
import { requireSession } from "./lib/auth.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 { checkTikTokLive } from "./lib/tiktok-live.js";
import { logAction } from "./lib/audit.js";
import { requireSupporterSession } from "./lib/supporter-auth.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";
/* =====================================================================
LIVE-STATUS
Der grüne Punkt auf der Startseite läuft automatisch: ein Cron-Auftrag
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.
===================================================================== */
const DEFAULT_OVERRIDE_MINUTES = 180;
async function readSetting(env, key) {
const row = await env.DB.prepare(`SELECT value FROM app_settings WHERE key = ?`)
.bind(key)
.first()
.catch(() => null);
if (!row || !row.value) return null;
try {
return JSON.parse(row.value);
} catch {
return null;
}
}
async function writeSetting(env, key, value) {
await env.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`
)
.bind(key, JSON.stringify(value), new Date().toISOString())
.run();
}
/** Ermittelt den Stand, der nach außen gilt (Vorrang: manuell vor Automatik). */
async function resolveLiveStatus(env) {
const auto = (await readSetting(env, "live_auto")) || {};
const override = await readSetting(env, "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,
};
}
async function handleLiveStatusGet(env) {
const state = await resolveLiveStatus(env);
return json({ ok: true, ...state });
}
/**
* Manueller Eingriff aus dem Postfach.
* { live: true|false, minutes?: number } → Vorrang setzen
* { auto: true } → Vorrang aufheben, Automatik übernimmt
*/
async function handleLiveStatusSet(request, env, session) {
if (!session?.isOwner) return json({ ok: false, error: "Nur Dogi darf den Live-Status ändern." }, 403);
const body = await request.json().catch(() => ({}));
if (body.auto === true) {
await env.DB.prepare(`DELETE FROM app_settings WHERE key = 'live_override'`).run();
await logAction(env, "owner", "live.override_cleared", null, null);
return json({ ok: true, ...(await resolveLiveStatus(env)) });
}
const minutes = Number.isFinite(body.minutes) ? Math.min(Math.max(body.minutes, 5), 1440) : DEFAULT_OVERRIDE_MINUTES;
const now = new Date();
await writeSetting(env, "live_override", {
live: !!body.live,
setAt: now.toISOString(),
until: new Date(now.getTime() + minutes * 60_000).toISOString(),
});
await logAction(env, "owner", "live.override_set", null, { live: !!body.live, minutes });
return json({ ok: true, ...(await resolveLiveStatus(env)) });
}
/** Wird vom Cron-Auftrag aufgerufen — fragt TikTok ab und schreibt das Ergebnis. */
async function refreshLiveFromTikTok(env) {
const username = 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 = (await readSetting(env, "live_auto")) || {};
await writeSetting(env, "live_auto", {
...previous,
ok: false,
lastError: result.reason,
checkedAt: now,
});
return { ok: false, reason: result.reason };
}
const previous = (await readSetting(env, "live_auto")) || {};
const changed = previous.live !== result.live;
await writeSetting(env, "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) {
await logAction(env, "system", result.live ? "live.started" : "live.ended", null, {
title: result.title,
});
}
return { ok: true, live: result.live, changed };
}
// Routen, die eine gültige Session brauchen. `session` wird injiziert.
const AUTHED_ROUTES = {
"POST /auth/logout": (req, env) => logout(req, env),
"POST /admin/owner/set-code": (req, env, session) => setOwnerCode(req, env, session),
"POST /live-status/set": (req, env, session) => handleLiveStatusSet(req, env, session),
"POST /admin/applications/list": (req, env, session) => listApplications(req, env, session),
"POST /admin/applications/status": (req, env, session) => changeApplicationStatus(req, env, session),
"POST /admin/applications/take-over": (req, env, session) => takeOverApplication(req, env, session),
"POST /admin/applications/assign": (req, env, session) => assignApplication(req, env, session),
"POST /admin/applications/archive": (req, env, session) => archiveApplication(req, env, session),
"POST /admin/applications/restore": (req, env, session) => restoreApplication(req, env, session),
"POST /admin/applications/delete": (req, env, session) => deleteApplication(req, env, session),
"POST /admin/applications/mark-answered": (req, env, session) => markAnswered(req, env, session),
"POST /admin/notes/list": (req, env, session) => listNotes(req, env, session),
"POST /admin/notes/add": (req, env, session) => addNote(req, env, session),
"POST /admin/notes/delete": (req, env, session) => deleteNote(req, env, session),
"POST /admin/drafts/list": (req, env, session) => listDrafts(req, env, session),
"POST /admin/drafts/save": (req, env, session) => saveDraft(req, env, session),
"POST /admin/roles/list": (req, env, session) => listRoles(env, session),
"POST /admin/roles/create": (req, env, session) => createRole(req, env, session),
"POST /admin/roles/update": (req, env, session) => updateRole(req, env, session),
"POST /admin/roles/delete": (req, env, session) => deleteRole(req, env, session),
"POST /admin/users/list": (req, env, session) => listUsers(env, session),
"POST /admin/users/prepare": (req, env, session) => prepareUser(req, env, session),
"POST /admin/users/finalize": (req, env, session) => finalizeUser(req, env, session),
"POST /admin/users/set-code": (req, env, session) => setUserCode(req, env, session),
"POST /admin/users/reveal-code": (req, env, session) => revealUserCode(req, env, session),
"POST /admin/users/lock": (req, env, session) => lockUser(req, env, session),
"POST /admin/users/unlock": (req, env, session) => unlockUser(req, env, session),
"POST /admin/users/delete": (req, env, session) => deleteUser(req, env, session),
"POST /admin/users/set-role": (req, env, session) => setUserRole(req, env, session),
"POST /admin/users/set-permissions": (req, env, session) => setUserPermissions(req, env, session),
"POST /admin/users/end-sessions": (req, env, session) => endUserSessions(req, env, session),
"POST /admin/users/update-meta": (req, env, session) => updateUserMeta(req, env, session),
"POST /admin/audit/list": (req, env, session) => listAuditLog(req, env, session),
// Supporter-Abo — Administrationsbereich (interne Team-Session + eigene
// SUPPORTER_*-Rechte, siehe permissions.js).
"POST /admin/overview": (req, env, session) => getAdminOverview(req, env, session),
"POST /admin/supporter/list": (req, env, session) => listSupporters(req, env, session),
"POST /admin/supporter/detail": (req, env, session) => getSupporterDetail(req, env, session),
"POST /admin/supporter/premium/update": (req, env, session) => updatePremium(req, env, session),
"POST /admin/supporter/collections/list": (req, env, session) => listCollections(req, env, session),
"POST /admin/supporter/collections/upsert": (req, env, session) => upsertCollection(req, env, session),
};
// Routen des ÖFFENTLICHEN Supporter-Bereichs, die eine gültige
// Supporter-Session brauchen (eigenes, von AUTHED_ROUTES komplett
// getrenntes Token-System — siehe lib/supporter-auth.js). `supporter`
// (die volle supporter_users-Zeile) wird injiziert statt `session`.
const SUPPORTER_AUTHED_ROUTES = {
"POST /supporter/logout": (req, env) => logoutSupporter(req, env),
"POST /supporter/me": (req, env, supporter) => getSupporterMe(req, env, supporter),
"POST /supporter/update-profile": (req, env, supporter) => updateSupporterProfile(req, env, supporter),
"POST /supporter/request-email-change": (req, env, supporter) => requestSupporterEmailChange(req, env, supporter),
"POST /supporter/confirm-email-change": (req, env, supporter) => confirmSupporterEmailChange(req, env, supporter),
"POST /supporter/redeem-premium": (req, env, supporter) => redeemPremium(req, env, supporter),
"POST /supporter/delete-account": (req, env, supporter) => deleteSupporterAccount(req, env, supporter),
"POST /supporter/paypal/start": (req, env, supporter) => startPaypalSubscription(req, env, supporter),
"POST /supporter/paypal/confirm": (req, env, supporter) => confirmPaypalSubscription(req, env, supporter),
"POST /supporter/paypal/cancel": (req, env, supporter) => cancelPaypalSubscription(req, env, supporter),
};
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const routeKey = `${request.method} ${url.pathname}`;
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders() });
}
if (url.pathname === "/health" && request.method === "GET") {
return json({ ok: true, service: "dogfather-universe-interner-bereich" });
}
if (url.pathname === "/submit" && request.method === "POST") {
return submitApplication(request, env, ctx);
}
if (url.pathname === "/live-status" && request.method === "GET") {
return handleLiveStatusGet(env);
}
if (url.pathname === "/auth/login" && request.method === "POST") {
return login(request, env);
}
// Supporter-Abo — öffentliche Routen ohne Session (Registrierung/Login).
if (url.pathname === "/supporter/register" && request.method === "POST") return registerSupporter(request, env);
if (url.pathname === "/supporter/verify-email" && request.method === "POST") return verifySupporterEmail(request, env);
if (url.pathname === "/supporter/login-request" && request.method === "POST") return requestSupporterLogin(request, env);
if (url.pathname === "/supporter/verify-login" && request.method === "POST") return verifySupporterLogin(request, env);
if (url.pathname === "/supporter/google-login" && request.method === "POST") return googleLoginSupporter(request, env);
if (url.pathname === "/supporter/public-config" && request.method === "GET") return getPublicConfig(request, env);
// PayPal ruft diese Adresse direkt auf — eigene Signaturprüfung statt Session (siehe paypal.js).
if (url.pathname === "/supporter/paypal-webhook" && request.method === "POST") return handlePaypalWebhook(request, env);
const supporterHandler = SUPPORTER_AUTHED_ROUTES[routeKey];
if (supporterHandler) {
const supporter = await requireSupporterSession(request, env);
if (!supporter) return json({ ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut einloggen." }, 401);
return supporterHandler(request, env, supporter);
}
const handler = AUTHED_ROUTES[routeKey];
if (handler) {
const session = await requireSession(request, env);
if (!session) return json({ ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut anmelden." }, 401);
return handler(request, env, session);
}
return json({ ok: false, error: "Not found." }, 404);
},
/* Cron-Auftrag: läuft jede Minute und hält den Live-Punkt aktuell. */
async scheduled(event, env, ctx) {
ctx.waitUntil(refreshLiveFromTikTok(env));
},
};