Files
dogfather-universe/server-internal/index.js
T
DogFatherGitandClaude Opus 5 399260b805 "Stimmen" verwandelt: Fans reichen selbst Testimonials ein
Nutzer-Wunsch 20.08.2026: "eine richtig geile kachel ... wo die leute mit
namen und tiktok namen mir eine nachricht schreiben können die ich in die
verwaltung kriege, und dann kann ich die ausgewählten über die
verwaltungsseite auf die website hinzufügen ... das wird mega persoenlich
zu den fans, bitte wirklich krass geil speziell."

- Neue, augenschonend gestaltete Einreich-Kachel auf stimmen.html: Name,
  TikTok-Name (optional), Nachricht -- landet NIE automatisch oeffentlich,
  sondern immer erst als Entwurf in der Verwaltung.
  Warmer Babyblau/Rosé-Farbverlauf, wandernder Lichtschein, pulsierendes
  Herz -- ersetzt die 3 ewigen "Hier steht bald..."-Platzhalterkarten.
- Neue Verwaltungs-Sektion "💬 Stimmen verwalten": Warteschlange (offene
  zuerst), Freigeben/Ablehnen/Löschen pro Eintrag.
- Freigegebene Stimmen erscheinen automatisch in einer neuen, spezielleren
  Zitat-Kartenoptik (großes Anführungszeichen, Name + TikTok-Chip) --
  Abschnitt bleibt komplett unsichtbar, solange keine einzige freigegeben
  wurde.
- Backend: neue Tabelle `testimonials` (Migration 0006), routes/
  testimonials.js (oeffentliches Einreichen + Lesen freigegebener,
  admin-Warteschlange + Status/Loeschen mit neuer TESTIMONIALS_MANAGE-
  Berechtigung). 18 automatisierte Tests gegen eine Fake-DB bestanden.
- Alles per Playwright visuell durchgespielt: leerer Zustand, Einreichen
  -> Erfolgsmeldung, freigegebene Stimmen-Anzeige, Verwaltungs-Warteschlange
  mit allen Aktionen.

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 23:37:31 +02:00

301 lines
17 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, getEventsAdmin, saveEvent, uploadEventImage } from "./routes/events.js";
import { getSpecialEventPublic, getSpecialEventAdmin, saveSpecialEvent } from "./routes/special-event.js";
import {
submitTestimonial, getApprovedTestimonialsPublic, getTestimonialsAdmin, setTestimonialStatus, deleteTestimonial,
} from "./routes/testimonials.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.get("/special-event", wrap(getSpecialEventPublic));
app.post("/testimonials/submit", wrap(submitTestimonial));
app.get("/testimonials", wrap(getApprovedTestimonialsPublic));
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/list", requireTeamSession, withSession(getEventsAdmin));
app.post("/admin/events/save", requireTeamSession, withSession(saveEvent));
app.post("/admin/events/upload-image", requireTeamSession, bildUpload.single("image"), withSession(uploadEventImage));
// Spezielles Live-Event mit Countdown auf streamplan.html (20.08.2026)
app.post("/admin/special-event", requireTeamSession, withSession(getSpecialEventAdmin));
app.post("/admin/special-event/save", requireTeamSession, withSession(saveSpecialEvent));
// "Stimmen"-Warteschlange (20.08.2026)
app.post("/admin/testimonials/list", requireTeamSession, withSession(getTestimonialsAdmin));
app.post("/admin/testimonials/status", requireTeamSession, withSession(setTestimonialStatus));
app.post("/admin/testimonials/delete", requireTeamSession, withSession(deleteTestimonial));
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}`);
});