Files
dogfather-universe/server-internal/index.js
T
DogFatherGitandClaude Opus 5 bda3198c76 Neue Funktion: Supporter-Abstimmungen (Verwaltung + DogiCrew-Bereich)
Naechste "Ausbaustufe" aus Dogfather_VanVan_Supporter_Abo.odt Abschnitt 20
(siehe Supporter-Abo-System.md), auf Nutzerwunsch "perfektioniere meine
Verwaltungsseite": Dogi/VanVan koennen in verwaltung.html eine Frage mit
2-6 Antwortoptionen auf Deutsch erstellen, automatische Uebersetzung beim
Speichern (gleiches Muster wie "Event des Jahres"). Jede aktive DogiCrew-
Person sieht die Abstimmung in ihrem Supporter-Bereich, stimmt genau einmal
ab (UNIQUE-Constraint in der DB, nicht nur Anwendungslogik), sieht danach
die Live-Ergebnisse. Admin-Seite zeigt Ergebnisbalken live, kann schliessen/
wiedereroeffnen/loeschen.

- Neue Migration 0005_supporter_polls.sql (supporter_polls,
  supporter_poll_votes), neue Berechtigung POLLS_MANAGE.
- server-internal/routes/polls.js: 30 End-to-End-Tests gegen eine
  Fake-DB bestanden (better-sqlite3 laesst sich lokal nicht kompilieren).
- verwaltung.html: neue "Abstimmungen"-Kiste im bestehenden
  vw-overview-box-Stil (violett/pink Ergebnisbalken).
- supporter.html: neue "Aktuelle Abstimmung"-Karte im bestehenden
  Gold-Look, Optionen -> Stimme -> Ergebnisbalken, alle 5 Sprachen.

Backend-Teil (server-internal/, cloudflare-worker/migrations/) noch ohne
Deploy-Zugriff -- Dogi muss ihn manuell auf dogiintern ausrollen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 16:07:51 +02:00

284 lines
16 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 multer from "multer";
import { mkdirSync } from "node:fs";
import { extname } from "node:path";
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";
import { getEventsPublic, saveEvent, uploadEventImage } from "./routes/events.js";
import {
createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll,
} from "./routes/polls.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
/* Bild-Uploads für admin-gepflegte Inhalte (aktuell: "Event des Jahres",
20.08.2026). Liegt bewusst NEBEN der Datenbank unter /var/lib/... statt im
Git-Repo-Ordner — Uploads sollen einen `git pull`/Deploy unbeschadet
überstehen und nicht versehentlich mit eingecheckt werden. */
const UPLOADS_BASIS_DIR = process.env.UPLOADS_DIR || "/var/lib/dogfather-internal/uploads";
const EVENTS_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/events`;
mkdirSync(EVENTS_UPLOADS_DIR, { recursive: true });
const ERLAUBTE_BILD_TYPEN = new Set(["image/jpeg", "image/png", "image/webp"]);
const bildUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, EVENTS_UPLOADS_DIR),
// Zufälliger Dateiname (crypto.randomUUID) statt des Originalnamens —
// verhindert Pfad-/Namenskollisionen und Path-Traversal-Versuche über
// einen manipulierten Dateinamen komplett, ganz ohne Validierung nötig.
filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}${extname(file.originalname).toLowerCase()}`),
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
});
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());
// Bild-Uploads oeffentlich ausliefern (index.html zeigt "Event des Jahres"-Bilder
// JEDER Besucherin/JEDEM Besucher, ohne Login) — nur lesend, Schreiben bleibt
// ausschliesslich ueber /admin/events/upload-image mit EVENTS_MANAGE-Berechtigung.
app.use("/uploads", express.static(UPLOADS_BASIS_DIR));
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.get("/events-of-year", wrap(getEventsPublic));
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));
// Startseite: "Event des Jahres" (20.08.2026)
app.post("/admin/events/save", requireTeamSession, withSession(saveEvent));
app.post("/admin/events/upload-image", requireTeamSession, bildUpload.single("image"), withSession(uploadEventImage));
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));
// Supporter-Abstimmungen (20.08.2026)
app.post("/admin/polls/list", requireTeamSession, withSession(listPolls));
app.post("/admin/polls/create", requireTeamSession, withSession(createPoll));
app.post("/admin/polls/close", requireTeamSession, withSession(closePoll));
app.post("/admin/polls/reopen", requireTeamSession, withSession(reopenPoll));
app.post("/admin/polls/delete", requireTeamSession, withSession(deletePoll));
/* ---------- Ö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.post("/supporter/polls/active", requireSupporterAuth, withSupporter(getActivePollPublic));
app.post("/supporter/polls/vote", requireSupporterAuth, withSupporter(voteOnPoll));
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);
}
if (err instanceof multer.MulterError) {
const nachricht = err.code === "LIMIT_FILE_SIZE" ? "Das Bild ist zu groß (maximal 5 MB)." : "Bild-Upload fehlgeschlagen.";
return json(res, { ok: false, error: nachricht }, 400);
}
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}`);
});