323 lines
15 KiB
JavaScript
323 lines
15 KiB
JavaScript
/* =====================================================================
|
||
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));
|
||
},
|
||
};
|