/* ===================================================================== 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, loginSupporterMitCode, setSupporterAccessCode, 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, uploadEigenesTestimonialBild, setTestimonialAvatar, uploadTestimonialAvatar, } from "./routes/testimonials.js"; import { createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll, } from "./routes/polls.js"; import { getTeamPublic, getTeamAdmin, saveTeamMember, importLegacyMember, deleteTeamMember, uploadTeamPhoto, } from "./routes/team.js"; import { getSiteTextsPublic, saveSiteText } from "./routes/site-texts.js"; /* Webdesign-Bereich (22.08.2026). Die Rechteprüfung sitzt bewusst IN den Handlern statt in einer Middleware davor: die Endpunkte brauchen unterschiedliche Rechte (ansehen vs. bearbeiten), und ein einzelnes requireTeamSession davor würde diesen Unterschied verwischen. */ import { anfrageAbsenden, anfragenListe, anfrageDetail, anfrageStatus, anfrageArchivieren, anfrageNotiz, } from "./routes/webdesign-anfragen.js"; /* Push-Benachrichtigung (26.08.2026). Ohne Fremdpaket gebaut, weil der Dienst unter /home/dogiintern mit Rechten 700 liegt -- dort ist kein npm erreichbar. Node bringt P-256, HKDF und AES-GCM selbst mit. */ import { pushStand, pushSchluessel, pushAnmelden, pushAbmelden, pushTest, } from "./routes/webdesign-push.js"; /* Auskunft und Loeschung (DSGVO Art. 15/17, 26.08.2026). Beides verlangt eine Antwort binnen eines Monats -- von Hand durch ein Dutzend Tabellen zu suchen ist dafuer zu langsam und zu unsicher. */ import { dsAuskunft, dsLoeschvorschau, dsLoeschen, } from "./routes/webdesign-datenschutz.js"; /* Kundenportal (Masterplan S.12). Eigene Anmeldung mit eigener Sitzung -- Kunden sind KEINE Teammitglieder und duerfen mit deren Rechtesystem nichts zu tun haben. Die Mandantentrennung sitzt in den Handlern: jede Abfrage filtert auf die kunde_id aus der Sitzung. */ import { portalAnmelden, portalAbmelden, portalEinladungPruefen, portalPasswortSetzen, portalUebersicht, portalAngebotZusagen, portalAngebotAblehnen, portalProjekt, portalAenderungEinreichen, portalAenderungEntscheiden, portalNachricht, portalProfilSpeichern, } from "./routes/webdesign-portal.js"; /* Kunden und Projekte verwalten (Masterplan S.13). Hier entstehen Kundenzugaenge -- es gibt bewusst keine Selbstregistrierung. */ import { kundeAnlegen, kundeEinladungNeu, kundenListe, kundeSchalten, projektAnlegen, projektAendern, aenderungBeziffern, projektNachricht, projekteListe, } from "./routes/webdesign-kunden.js"; /* Aufgabenliste je Projekt ("was ist schon gemacht von dem, was im Plan war") und allgemeines Postfach fuer Nachrichten OHNE Projektbezug. */ import { portalAufgaben, portalAufgabeErledigt, adminAufgaben, adminAufgabeAnlegen, adminProjektAlles, adminAufgabeAendern, adminAufgabeLoeschen, adminVorlageAnwenden, } from "./routes/webdesign-aufgaben.js"; import { portalPostfach, portalPostfachSenden, adminPostfachListe, adminPostfachVerlauf, adminPostfachSenden, adminPostfachZaehler, } from "./routes/webdesign-postfach.js"; /* Elektronische Widerrufsfunktion (§ 356a BGB). Bewusst OHNE Anmeldung erreichbar -- das Gesetz verlangt "leicht zugaenglich", und wer einen Vertrag loswerden will, darf nicht erst an einer Anmeldung scheitern. */ import { widerrufAbsenden, widerrufeListe, widerrufBearbeiten, } from "./routes/webdesign-widerruf.js"; /* Zahlungen im Webdesign-Bereich. Der Webhook ist oeffentlich erreichbar (PayPal hat keine Sitzung) und ausschliesslich durch die Signaturpruefung geschuetzt -- ohne gueltige Signatur wird nichts verarbeitet. */ import { zahlungAnsehen, zahlungStarten, zahlungAbschliessen, paypalWebhook, zahlungAnlegen, zahlungenListe, einstellungenLesen, einstellungenSchreiben, einstellungenPruefen, uebersicht, meldungGelesen, } from "./routes/webdesign-zahlungen.js"; import { annahmeVorschau, anfrageAnnehmen, absageVorschau, anfrageAblehnen, abbruchVorschau, projektAbbrechen, } from "./routes/webdesign-annahme.js"; import { suche } from "./routes/webdesign-suche.js"; import { angebotVorschau, angebotSenden, angebotZurueckziehen } from "./routes/webdesign-angebote.js"; /* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen, damit Filipe sie in seiner Verwaltung eintragen kann statt per SSH. Sie muessen beim Start EINMAL entschluesselt werden, weil die Pruefungen des PayPal-Moduls synchron sind. */ import { geheimnisseLaden } from "./lib/webdesign-geheimnisse.js"; import { vapidSicherstellen } from "./lib/webdesign-push.js"; import { vorlagenSicherstellen } from "./lib/webdesign-vorlagen.js"; initDb(); /* Muss NACH initDb laufen -- die Tabelle entsteht erst dort. Schreibt die Standard-Aufgabenlisten beim allerersten Start in die Datenbank und tut danach nichts mehr. Vorhandene Vorlagen werden nie ueberschrieben, sonst waeren von Hand angepasste nach dem naechsten Neustart weg -- und niemand kaeme auf die Idee, dass der Neustart daran schuld war. */ vorlagenSicherstellen(); /* Ebenfalls nach initDb. Faellt das aus (z.B. weil der ENCRYPTION_KEY fehlt), laeuft der Dienst trotzdem weiter -- er verhaelt sich dann so, als waere in der Verwaltung nichts hinterlegt, und die .env gilt weiter. Ein fehlender Zahlungsanbieter darf nicht die ganze Seite anhalten. */ geheimnisseLaden() .then((n) => { if (n) console.log(`[webdesign] ${n} Einstellungen aus der Datenbank geladen.`); }) .catch((e) => console.error("[webdesign] Einstellungen nicht ladbar:", e?.message)); /* Push-Schluessel bereitstellen. Beim allerersten Start werden sie erzeugt, danach nur noch gelesen -- ein Wechsel wuerde alle angemeldeten Geraete stillschweigend abhaengen. Faellt das aus, laeuft der Dienst weiter: Ohne Benachrichtigung arbeitet die Seite normal, sie meldet sich nur nicht von selbst. Die Verwaltung zeigt den Zustand an, statt ihn zu verschweigen. */ vapidSicherstellen() .then((r) => { if (r.zustand === "neu") console.log("[push] eingerichtet."); }) .catch((e) => console.error("[push] nicht einsatzbereit:", e?.message)); 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 }); // Team-Fotos (21.08.2026, "Verwaltungsseite perfektionieren"-Auftrag) -- // eigener Unterordner, gleiches Muster wie bei den Event-Bildern oben. const TEAM_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/team`; mkdirSync(TEAM_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)), }); const teamBildUpload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => cb(null, TEAM_UPLOADS_DIR), 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)), }); // Profilbilder für die "Stimmen" (21.08.2026) — gleiches Muster wie oben. // Nur aus der Verwaltung heraus erreichbar; das öffentliche Stimmen-Formular // nimmt bewusst keine Dateien entgegen (siehe routes/testimonials.js). const TESTIMONIAL_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/testimonials`; mkdirSync(TESTIMONIAL_UPLOADS_DIR, { recursive: true }); const stimmenBildUpload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => cb(null, TESTIMONIAL_UPLOADS_DIR), 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)); /* Öffentlich, ohne Login: eigenes Profilbild für die eigene Stimme hochladen (21.08.2026). Bewusst mit derselben multer-Härtung wie der Verwaltungs-Upload (nur JPG/PNG/WebP, max. 5 MB, zufälliger Dateiname); zusätzlich bremst die Route selbst Massen-Uploads pro IP aus (siehe uploadEigenesTestimonialBild in routes/testimonials.js). Sichtbar wird ein hochgeladenes Bild erst, wenn die zugehörige Stimme freigegeben wird -- Einreichungen landen immer zuerst in der Warteschlange. */ app.post("/testimonials/upload-avatar", stimmenBildUpload.single("image"), wrap(uploadEigenesTestimonialBild)); app.get("/testimonials", wrap(getApprovedTestimonialsPublic)); app.get("/team", wrap(getTeamPublic)); app.get("/site-texts", wrap(getSiteTextsPublic)); /* ---------- Webdesign-Bereich (22.08.2026) ---------- Genau EIN öffentlicher Endpunkt: das Anfrageformular. Alles Weitere verlangt eine Team-Anmeldung, die in den Handlern selbst geprüft wird (unterschiedliche Rechte je Endpunkt, siehe Import oben). */ app.post("/webdesign/anfrage", wrap(anfrageAbsenden)); /* Zweiter oeffentlicher Endpunkt des Bereichs -- und der einzige, bei dem das so sein MUSS: § 356a BGB verlangt eine leicht zugaengliche Widerrufsfunktion. Eine Anmeldung davor waere ein Rechtsverlust fuer jeden, der sein Passwort vergessen hat. */ app.post("/webdesign/widerruf", wrap(widerrufAbsenden)); /* Dritter oeffentlicher Endpunkt: PayPal ruft ihn direkt auf und kann sich nicht anmelden. Die Echtheit wird ueber die Signatur geprueft. */ app.post("/webdesign/paypal-webhook", wrap(paypalWebhook)); app.get("/webdesign/admin/widerrufe", wrap(widerrufeListe)); app.post("/webdesign/admin/widerrufe/:id", wrap(widerrufBearbeiten)); app.get("/webdesign/admin/anfragen", wrap(anfragenListe)); app.get("/webdesign/admin/anfragen/:id", wrap(anfrageDetail)); app.post("/webdesign/admin/anfragen/:id/status", wrap(anfrageStatus)); app.post("/webdesign/admin/anfragen/:id/archivieren", wrap(anfrageArchivieren)); app.post("/webdesign/admin/anfragen/:id/notiz", wrap(anfrageNotiz)); /* Benachrichtigung aufs Handy (26.08.2026). Der Stand-Endpunkt ist der wichtigste davon: Er beantwortet die Frage "wuerde ich es mitbekommen, wenn jetzt eine Anfrage kaeme?". Genau die war bisher nicht zu beantworten. */ app.get("/webdesign/admin/push/stand", wrap(pushStand)); app.get("/webdesign/admin/push/schluessel", wrap(pushSchluessel)); app.post("/webdesign/admin/push/anmelden", wrap(pushAnmelden)); app.post("/webdesign/admin/push/abmelden", wrap(pushAbmelden)); app.post("/webdesign/admin/push/test", wrap(pushTest)); /* Datenschutz-Auskunft und -Loeschung. Die Reihenfolge ist Absicht: erst sehen, dann Vorschau, dann -- mit doppelter Bestaetigung -- ausfuehren. Eine Loeschung ist endgueltig. */ app.post("/webdesign/admin/datenschutz/auskunft", wrap(dsAuskunft)); app.post("/webdesign/admin/datenschutz/vorschau", wrap(dsLoeschvorschau)); app.post("/webdesign/admin/datenschutz/loeschen", wrap(dsLoeschen)); /* Annehmen und Ablehnen. Die Vorschau-Endpunkte lesen nur und veraendern nichts -- deshalb GET. Sie fuettern den Bestaetigungsdialog, damit der zeigen kann, WAS passieren wird (Termin, Zahl der Arbeitsschritte, ob der Kunde schon existiert), statt nur "wirklich?" zu fragen. */ app.get("/webdesign/admin/anfragen/:id/annahme-vorschau", wrap(annahmeVorschau)); app.post("/webdesign/admin/anfragen/:id/annehmen", wrap(anfrageAnnehmen)); app.get("/webdesign/admin/anfragen/:id/absage-vorschau", wrap(absageVorschau)); app.post("/webdesign/admin/anfragen/:id/ablehnen", wrap(anfrageAblehnen)); /* Angebot: Vorschau (liest nur), Absenden, Zurueckziehen. */ app.get("/webdesign/admin/anfragen/:id/angebot-vorschau", wrap(angebotVorschau)); app.post("/webdesign/admin/anfragen/:id/angebot", wrap(angebotSenden)); app.post("/webdesign/admin/angebote/:id/zurueckziehen", wrap(angebotZurueckziehen)); /* ---------- Kundenportal ---------- Oeffentlich sind nur Anmeldung und das Einloesen eines Einladungslinks. Alles Weitere prueft die Kundensitzung im Handler selbst. */ app.post("/webdesign/portal/anmelden", wrap(portalAnmelden)); app.post("/webdesign/portal/abmelden", wrap(portalAbmelden)); app.get("/webdesign/portal/einladung", wrap(portalEinladungPruefen)); app.post("/webdesign/portal/passwort", wrap(portalPasswortSetzen)); app.get("/webdesign/portal/uebersicht", wrap(portalUebersicht)); app.get("/webdesign/portal/projekt/:id", wrap(portalProjekt)); app.post("/webdesign/portal/angebote/:id/zusagen", wrap(portalAngebotZusagen)); app.post("/webdesign/portal/angebote/:id/ablehnen", wrap(portalAngebotAblehnen)); app.post("/webdesign/portal/aenderung", wrap(portalAenderungEinreichen)); app.post("/webdesign/portal/aenderung/:id/entscheiden", wrap(portalAenderungEntscheiden)); app.post("/webdesign/portal/nachricht", wrap(portalNachricht)); app.post("/webdesign/portal/profil", wrap(portalProfilSpeichern)); /* ---------- Kunden und Projekte (Verwaltung) ---------- */ app.get("/webdesign/admin/kunden", wrap(kundenListe)); app.post("/webdesign/admin/kunden", wrap(kundeAnlegen)); app.post("/webdesign/admin/kunden/:id/einladung", wrap(kundeEinladungNeu)); app.post("/webdesign/admin/kunden/:id/zugang", wrap(kundeSchalten)); app.get("/webdesign/admin/projekte", wrap(projekteListe)); app.post("/webdesign/admin/projekte", wrap(projektAnlegen)); app.post("/webdesign/admin/projekte/:id", wrap(projektAendern)); app.post("/webdesign/admin/aenderungen/:id/beziffern", wrap(aenderungBeziffern)); app.post("/webdesign/admin/projekt-nachricht", wrap(projektNachricht)); /* ---------- Aufgabenliste je Projekt ---------- Kundenseite links, Verwaltung rechts. Der Kunde darf nur Punkte abhaken, die IHM zugewiesen sind -- geprueft im Handler, nicht hier. */ app.get("/webdesign/portal/projekt/:id/aufgaben", wrap(portalAufgaben)); app.post("/webdesign/portal/aufgabe/:id/erledigt", wrap(portalAufgabeErledigt)); app.get("/webdesign/admin/projekte/:id/aufgaben", wrap(adminAufgaben)); /* Das ganze Projekt in EINER Antwort -- Stand, Aufgaben, Aenderungswuensche und Nachrichten. Fuenf Anfragen hintereinander wuerden die Ansicht sichtbar ruckelnd aufbauen. */ app.get("/webdesign/admin/projekte/:id/alles", wrap(adminProjektAlles)); app.post("/webdesign/admin/projekte/:id/aufgaben", wrap(adminAufgabeAnlegen)); app.post("/webdesign/admin/projekte/:id/aufgaben/vorlage", wrap(adminVorlageAnwenden)); app.post("/webdesign/admin/aufgaben/:id", wrap(adminAufgabeAendern)); app.post("/webdesign/admin/aufgaben/:id/loeschen", wrap(adminAufgabeLoeschen)); /* ---------- Postfach (Nachrichten ohne Projektbezug) ---------- */ app.get("/webdesign/portal/postfach", wrap(portalPostfach)); app.post("/webdesign/portal/postfach", wrap(portalPostfachSenden)); app.get("/webdesign/portal/zahlung/:id", wrap(zahlungAnsehen)); app.post("/webdesign/portal/zahlung/:id/start", wrap(zahlungStarten)); app.post("/webdesign/portal/zahlung/:id/abschluss", wrap(zahlungAbschliessen)); app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe)); app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen)); app.get("/webdesign/admin/uebersicht", wrap(uebersicht)); app.get("/webdesign/admin/suche", wrap(suche)); app.post("/webdesign/admin/meldungen/:id/gelesen", wrap(meldungGelesen)); app.get("/webdesign/admin/projekte/:id/abbruch-vorschau", wrap(abbruchVorschau)); app.post("/webdesign/admin/projekte/:id/abbrechen", wrap(projektAbbrechen)); app.get("/webdesign/admin/einstellungen", wrap(einstellungenLesen)); app.post("/webdesign/admin/einstellungen", wrap(einstellungenSchreiben)); app.post("/webdesign/admin/einstellungen/pruefen", wrap(einstellungenPruefen)); app.get("/webdesign/admin/postfach", wrap(adminPostfachListe)); app.get("/webdesign/admin/postfach-zaehler", wrap(adminPostfachZaehler)); app.get("/webdesign/admin/postfach/:kundeId", wrap(adminPostfachVerlauf)); app.post("/webdesign/admin/postfach/:kundeId", wrap(adminPostfachSenden)); app.post("/supporter/register", wrap(registerSupporter)); app.post("/supporter/verify-email", wrap(verifySupporterEmail)); // Normaler Login seit 21.08.2026: E-Mail + selbst gewählter Zugangscode. app.post("/supporter/login-code", wrap(loginSupporterMitCode)); // login-request/verify-login sind jetzt der "Code vergessen?"-Weg. 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)); // Profilbild einer Stimme setzen bzw. eigenes Foto hochladen (21.08.2026). app.post("/admin/testimonials/avatar", requireTeamSession, withSession(setTestimonialAvatar)); app.post("/admin/testimonials/upload-avatar", requireTeamSession, stimmenBildUpload.single("image"), withSession(uploadTestimonialAvatar)); // Team-Mitglieder (Modis/Scouts/Creator/Manager) — 21.08.2026 app.post("/admin/team/list", requireTeamSession, withSession(getTeamAdmin)); app.post("/admin/team/save", requireTeamSession, withSession(saveTeamMember)); app.post("/admin/team/import", requireTeamSession, withSession(importLegacyMember)); app.post("/admin/team/delete", requireTeamSession, withSession(deleteTeamMember)); app.post("/admin/team/upload-photo", requireTeamSession, teamBildUpload.single("image"), withSession(uploadTeamPhoto)); app.post("/admin/site-texts/save", requireTeamSession, withSession(saveSiteText)); 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)); // Neuen Zugangscode setzen — freiwillig im Supporter-Bereich oder als // Abschluss des "Code vergessen?"-Weges (da ist man bereits eingeloggt). app.post("/supporter/set-access-code", requireSupporterAuth, withSupporter(setSupporterAccessCode)); 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}`); });