- Login-Sperre war umgehbar: clientKey() vertraute dem Header CF-Connecting-IP. Bei Cloudflare war das sicher (CF ueberschreibt ihn), auf dem eigenen Server nicht: der Ursprungsserver ist auch direkt unter seiner IP erreichbar, dort konnte der Header frei gesetzt und die 5-Versuche-Sperre komplett ausgehebelt werden (nachgewiesen). Jetzt req.ip hinter trust proxy. - Absturzsicherheit: Express 4 faengt Fehler aus async-Handlern nicht ab, eine einzige fehlerhafte Anfrage konnte den ganzen Dienst beenden. wrap() um alle Handler, zentraler Fehler-Handler, unhandledRejection/uncaughtException-Netz. - Sicherheits-Header (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, HSTS) wurden bisher nur ueber die Datei _headers gesetzt, die auf dem eigenen Server wirkungslos ist. Jetzt im Express-Server. - x-powered-by abgeschaltet. - Datenschutzerklaerung/AGB: nannten Cloudflare als Hoster und eine Cloudflare-D1- Datenbank. Jetzt korrekt netcup (Rechenzentrum Nuernberg) als Hoster, Cloudflare als vorgeschaltetes CDN mit Drittlandhinweis.
234 lines
13 KiB
JavaScript
234 lines
13 KiB
JavaScript
/* =====================================================================
|
|
DOGFATHER UNIVERSE — Interner Bereich (Postfach, Team-Verwaltung,
|
|
DogiCrew-Supporter-Abo). Node.js/Express-Server als 1:1-Ersatz für den
|
|
Cloudflare Worker "dogfather-universe-postfach"
|
|
(siehe ../cloudflare-worker/src/worker.js für das Original).
|
|
|
|
Architektur (unverändert zum Original):
|
|
db.js SQLite-Datenbank (better-sqlite3), Migrationen
|
|
lib/crypto.js Hashing, AES-GCM (Code-Verschlüsselung), IDs
|
|
lib/auth.js Login-Prüfung, Sessions, Rate-Limiting (Team)
|
|
lib/supporter-auth.js Login/Sessions für den öffentlichen Bereich
|
|
lib/permissions.js Vollständiger Permission-Katalog
|
|
lib/audit.js Aktivitätsprotokoll schreiben
|
|
lib/http.js JSON-Response-Helfer
|
|
lib/live-status.js TikTok-Live-Erkennung (ersetzt Cloudflare-Cron)
|
|
routes/*.js Endpoint-Handler pro Bereich
|
|
|
|
Unterschiede zum Original (rein technisch, gleiches Verhalten):
|
|
- D1 (async) → better-sqlite3 (synchron)
|
|
- KV BEWERBUNGEN wurde nie für mehr als den Live-Status-Schalter
|
|
genutzt, der jetzt komplett in app_settings (SQLite) lebt.
|
|
- ctx.waitUntil() → einfaches "nicht abwarten" (Node-Prozess läuft
|
|
durchgehend weiter, kein Cold-Start-Risiko wie bei Workers).
|
|
- scheduled()-Cron → node-cron, gleicher Rhythmus ("* * * * *").
|
|
===================================================================== */
|
|
import "dotenv/config";
|
|
import express from "express";
|
|
import cors from "cors";
|
|
import cron from "node-cron";
|
|
|
|
import { initDb } from "./db.js";
|
|
import { json } from "./lib/http.js";
|
|
import { requireSession } from "./lib/auth.js";
|
|
import { requireSupporterSession } from "./lib/supporter-auth.js";
|
|
import { handleLiveStatusGet, handleLiveStatusSet, refreshLiveFromTikTok } from "./lib/live-status.js";
|
|
|
|
import { login, logout, setOwnerCode } from "./routes/auth.js";
|
|
import { listRoles, createRole, updateRole, deleteRole } from "./routes/roles.js";
|
|
import {
|
|
listUsers, prepareUser, finalizeUser, setUserCode, revealUserCode,
|
|
lockUser, unlockUser, deleteUser, setUserRole, setUserPermissions,
|
|
endUserSessions, updateUserMeta,
|
|
} from "./routes/users.js";
|
|
import {
|
|
submitApplication, listApplications, changeApplicationStatus,
|
|
takeOverApplication, assignApplication, archiveApplication,
|
|
restoreApplication, deleteApplication, markAnswered,
|
|
listNotes, addNote, deleteNote, listDrafts, saveDraft,
|
|
} from "./routes/applications.js";
|
|
import { listAuditLog } from "./routes/audit.js";
|
|
import {
|
|
registerSupporter, verifySupporterEmail, requestSupporterLogin, verifySupporterLogin, logoutSupporter,
|
|
googleLoginSupporter, getPublicConfig,
|
|
getSupporterMe, updateSupporterProfile, requestSupporterEmailChange, confirmSupporterEmailChange,
|
|
redeemPremium, deleteSupporterAccount,
|
|
} from "./routes/supporter.js";
|
|
import {
|
|
startPaypalSubscription, confirmPaypalSubscription, cancelPaypalSubscription, handlePaypalWebhook,
|
|
} from "./routes/supporter-paypal.js";
|
|
import {
|
|
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
|
|
} from "./routes/supporter-admin.js";
|
|
|
|
initDb();
|
|
|
|
const PORT = Number(process.env.PORT || 4200);
|
|
const app = express();
|
|
app.set("trust proxy", 1);
|
|
app.disable("x-powered-by"); // verrät sonst unnötig den eingesetzten Technik-Stack
|
|
|
|
app.use((req, res, next) => {
|
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
next();
|
|
});
|
|
|
|
app.use(cors());
|
|
app.use(
|
|
express.json({
|
|
limit: "2mb",
|
|
// Roh-Body wird zusätzlich als String aufbewahrt — die PayPal-Webhook-
|
|
// Signaturprüfung braucht den unveränderten Original-Text (siehe
|
|
// routes/supporter-paypal.js), nicht das nachträglich neu serialisierte
|
|
// req.body.
|
|
verify: (req, res, buf) => {
|
|
req.rawBody = buf.toString("utf8");
|
|
},
|
|
})
|
|
);
|
|
|
|
/* ---------- Absturzsicherheit ----------
|
|
Express 4 fängt Fehler aus async-Handlern NICHT selbst ab: eine abgelehnte Promise wird zu einer
|
|
"unhandled rejection", und Node beendet daraufhin den ganzen Prozess — im Cloudflare Worker war
|
|
ein Fehler dagegen immer auf die eine betroffene Anfrage begrenzt. Ohne die folgende Absicherung
|
|
könnte z.B. eine Anfrage mit fehlender ID (better-sqlite3 wirft bei `undefined` als Parameter)
|
|
den kompletten internen Bereich offline nehmen. `wrap()` leitet jeden Fehler stattdessen an den
|
|
Fehler-Handler am Ende der Datei weiter. */
|
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
|
|
/* ---------- Middlewares: interne Team-Session bzw. Supporter-Session ---------- */
|
|
|
|
const requireTeamSession = wrap(async (req, res, next) => {
|
|
const session = await requireSession(req);
|
|
if (!session) return json(res, { ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut anmelden." }, 401);
|
|
req.session = session;
|
|
next();
|
|
});
|
|
|
|
const requireSupporterAuth = wrap(async (req, res, next) => {
|
|
const supporter = await requireSupporterSession(req);
|
|
if (!supporter) return json(res, { ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut einloggen." }, 401);
|
|
req.supporter = supporter;
|
|
next();
|
|
});
|
|
|
|
/** Reicht die injizierte session/supporter als drittes Argument an den Handler weiter (wie im Cloudflare-Original). */
|
|
const withSession = (handler) => wrap((req, res) => handler(req, res, req.session));
|
|
const withSupporter = (handler) => wrap((req, res) => handler(req, res, req.supporter));
|
|
|
|
/* ---------- Öffentliche Routen (keine Session) ---------- */
|
|
|
|
app.get("/health", (req, res) => json(res, { ok: true, service: "dogfather-universe-interner-bereich" }));
|
|
app.post("/submit", wrap(submitApplication));
|
|
app.get("/live-status", wrap(handleLiveStatusGet));
|
|
app.post("/auth/login", wrap(login));
|
|
|
|
app.post("/supporter/register", wrap(registerSupporter));
|
|
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
|
|
app.post("/supporter/login-request", wrap(requestSupporterLogin));
|
|
app.post("/supporter/verify-login", wrap(verifySupporterLogin));
|
|
app.post("/supporter/google-login", wrap(googleLoginSupporter));
|
|
app.get("/supporter/public-config", wrap(getPublicConfig));
|
|
// PayPal ruft diese Adresse direkt auf — eigene Signaturprüfung statt Session (siehe lib/paypal.js).
|
|
app.post("/supporter/paypal-webhook", wrap(handlePaypalWebhook));
|
|
|
|
/* ---------- Team-Bereich (interne Session, requireTeamSession) ---------- */
|
|
|
|
app.post("/auth/logout", requireTeamSession, wrap(logout));
|
|
app.post("/admin/owner/set-code", requireTeamSession, withSession(setOwnerCode));
|
|
app.post("/live-status/set", requireTeamSession, withSession(handleLiveStatusSet));
|
|
|
|
app.post("/admin/applications/list", requireTeamSession, withSession(listApplications));
|
|
app.post("/admin/applications/status", requireTeamSession, withSession(changeApplicationStatus));
|
|
app.post("/admin/applications/take-over", requireTeamSession, withSession(takeOverApplication));
|
|
app.post("/admin/applications/assign", requireTeamSession, withSession(assignApplication));
|
|
app.post("/admin/applications/archive", requireTeamSession, withSession(archiveApplication));
|
|
app.post("/admin/applications/restore", requireTeamSession, withSession(restoreApplication));
|
|
app.post("/admin/applications/delete", requireTeamSession, withSession(deleteApplication));
|
|
app.post("/admin/applications/mark-answered", requireTeamSession, withSession(markAnswered));
|
|
|
|
app.post("/admin/notes/list", requireTeamSession, withSession(listNotes));
|
|
app.post("/admin/notes/add", requireTeamSession, withSession(addNote));
|
|
app.post("/admin/notes/delete", requireTeamSession, withSession(deleteNote));
|
|
|
|
app.post("/admin/drafts/list", requireTeamSession, withSession(listDrafts));
|
|
app.post("/admin/drafts/save", requireTeamSession, withSession(saveDraft));
|
|
|
|
app.post("/admin/roles/list", requireTeamSession, withSession(listRoles));
|
|
app.post("/admin/roles/create", requireTeamSession, withSession(createRole));
|
|
app.post("/admin/roles/update", requireTeamSession, withSession(updateRole));
|
|
app.post("/admin/roles/delete", requireTeamSession, withSession(deleteRole));
|
|
|
|
app.post("/admin/users/list", requireTeamSession, withSession(listUsers));
|
|
app.post("/admin/users/prepare", requireTeamSession, withSession(prepareUser));
|
|
app.post("/admin/users/finalize", requireTeamSession, withSession(finalizeUser));
|
|
app.post("/admin/users/set-code", requireTeamSession, withSession(setUserCode));
|
|
app.post("/admin/users/reveal-code", requireTeamSession, withSession(revealUserCode));
|
|
app.post("/admin/users/lock", requireTeamSession, withSession(lockUser));
|
|
app.post("/admin/users/unlock", requireTeamSession, withSession(unlockUser));
|
|
app.post("/admin/users/delete", requireTeamSession, withSession(deleteUser));
|
|
app.post("/admin/users/set-role", requireTeamSession, withSession(setUserRole));
|
|
app.post("/admin/users/set-permissions", requireTeamSession, withSession(setUserPermissions));
|
|
app.post("/admin/users/end-sessions", requireTeamSession, withSession(endUserSessions));
|
|
app.post("/admin/users/update-meta", requireTeamSession, withSession(updateUserMeta));
|
|
|
|
app.post("/admin/audit/list", requireTeamSession, withSession(listAuditLog));
|
|
|
|
// Supporter-Abo — Administrationsbereich (interne Team-Session + eigene
|
|
// SUPPORTER_*-Rechte, siehe lib/permissions.js).
|
|
app.post("/admin/overview", requireTeamSession, withSession(getAdminOverview));
|
|
app.post("/admin/supporter/list", requireTeamSession, withSession(listSupporters));
|
|
app.post("/admin/supporter/detail", requireTeamSession, withSession(getSupporterDetail));
|
|
app.post("/admin/supporter/premium/update", requireTeamSession, withSession(updatePremium));
|
|
app.post("/admin/supporter/collections/list", requireTeamSession, withSession(listCollections));
|
|
app.post("/admin/supporter/collections/upsert", requireTeamSession, withSession(upsertCollection));
|
|
|
|
/* ---------- Öffentlicher Supporter-Bereich (eigene Session, requireSupporterAuth) ---------- */
|
|
|
|
app.post("/supporter/logout", requireSupporterAuth, wrap(logoutSupporter));
|
|
app.post("/supporter/me", requireSupporterAuth, withSupporter(getSupporterMe));
|
|
app.post("/supporter/update-profile", requireSupporterAuth, withSupporter(updateSupporterProfile));
|
|
app.post("/supporter/request-email-change", requireSupporterAuth, withSupporter(requestSupporterEmailChange));
|
|
app.post("/supporter/confirm-email-change", requireSupporterAuth, withSupporter(confirmSupporterEmailChange));
|
|
app.post("/supporter/redeem-premium", requireSupporterAuth, withSupporter(redeemPremium));
|
|
app.post("/supporter/delete-account", requireSupporterAuth, withSupporter(deleteSupporterAccount));
|
|
app.post("/supporter/paypal/start", requireSupporterAuth, withSupporter(startPaypalSubscription));
|
|
app.post("/supporter/paypal/confirm", requireSupporterAuth, withSupporter(confirmPaypalSubscription));
|
|
app.post("/supporter/paypal/cancel", requireSupporterAuth, withSupporter(cancelPaypalSubscription));
|
|
|
|
app.use((req, res) => json(res, { ok: false, error: "Not found." }, 404));
|
|
|
|
/* ---------- Fehler-Handler (muss NACH allen Routen stehen) ----------
|
|
Fängt alles ab, was in einem Handler schiefgeht, und antwortet mit sauberem JSON statt einer
|
|
HTML-Fehlerseite mit Stacktrace. Ersetzt gleichzeitig die try/catch-Behandlung für kaputtes
|
|
JSON, die im Cloudflare-Original noch in jeder Route einzeln stand. */
|
|
// eslint-disable-next-line no-unused-vars
|
|
app.use((err, req, res, next) => {
|
|
if (err?.type === "entity.parse.failed") {
|
|
return json(res, { ok: false, error: "Ungültiges JSON." }, 400);
|
|
}
|
|
if (err?.type === "entity.too.large") {
|
|
return json(res, { ok: false, error: "Die Anfrage ist zu groß." }, 413);
|
|
}
|
|
console.error(`[fehler] ${req.method} ${req.originalUrl}:`, err?.stack || err);
|
|
if (res.headersSent) return;
|
|
return json(res, { ok: false, error: "Interner Fehler." }, 500);
|
|
});
|
|
|
|
/* Letztes Netz: Ein unbeaufsichtigter Fehler darf den Dienst nicht beenden. Ohne diese beiden
|
|
Zeilen würde Node den Prozess bei einer unbehandelten Promise-Ablehnung sofort beenden und der
|
|
gesamte interne Bereich (inkl. Zahlungsverarbeitung) wäre bis zum systemd-Neustart offline. */
|
|
process.on("unhandledRejection", (grund) => console.error("[unhandledRejection]", grund));
|
|
process.on("uncaughtException", (fehler) => console.error("[uncaughtException]", fehler?.stack || fehler));
|
|
|
|
/* ---------- Cron: läuft jede Minute, hält den Live-Punkt aktuell (ersetzt Cloudflares scheduled()) ---------- */
|
|
cron.schedule("* * * * *", () => {
|
|
refreshLiveFromTikTok().catch((err) => console.error("[live-status] Fehler beim Aktualisieren:", err?.message || err));
|
|
});
|
|
|
|
app.listen(PORT, "127.0.0.1", () => {
|
|
console.log(`DOGFATHER UNIVERSE — Interner Bereich läuft auf http://127.0.0.1:${PORT}`);
|
|
});
|