Nutzer-Wunsch 21.08.2026: "überall wo Modis sind oder Scouts oder Manager, oder Creator, will ich dass ich die easy über meine Verwaltungsseite hinzufüge und die automatisch in der Website hinzugefügt werden ... auch mit Fotos und TikTok Link." Backend (server-internal, braucht Filipes manuellen Deploy): - Neue Tabelle team_members (Migration 0007) für alle vier Kategorien gemeinsam -- ergänzt, überschreibt NIE die von Hand gepflegten Einträge in data-modis.js/data-scouts.js/data-creator.js. - routes/team.js: öffentliches Lesen (nur aktive Mitglieder, optional nach Kategorie gefiltert), TEAM_MANAGE-geschütztes Anlegen/Bearbeiten/ Löschen, Foto-Upload (gleiches Muster wie "Event des Jahres"), automatische Übersetzung von Rolle/Bio/Zitat wie bei anderen admin-gepflegten Texten. Slug global eindeutig (mit automatischer Kollisionsauflösung), TikTok-Kurzname wird zu voller URL ergänzt. 27 Tests in isolierter Testumgebung geprüft (kein better-sqlite3 lokal kompilierbar). Frontend: - window.dogiTeamLaden() in main.js: holt admin-gepflegte Mitglieder einer (oder aller) Kategorien und bringt sie in exakt dieselbe Form wie die bestehenden data-*.js-Einträge. - team-scouts.html/team-modis.html/creator.html hängen das Ergebnis einfach an ihre bestehenden Arrays an und rendern erneut -- exakt derselbe Look wie die schon bestehenden Profile (Foto/Avatar-Initiale, Rolle, Zitat, TikTok-Button). creator.html hatte bisher ein "return" bei leerem CREATORS-Array, wodurch admin-Creator NIE geladen worden wären -- gefixt. team-modis.html berücksichtigt Rang/Rang-Bezeichnung für die Pyramide. - Neue Sektion "Weitere Manager" auf manager.html, komplett unsichtbar bis der erste Manager angelegt wird (data-manager.js neu, wie data-creator.js aktuell leer). - profil.html sucht jetzt kategorieübergreifend auch in admin-gepflegten Profilen, inkl. korrektem Theme/Zurück-Link auch für Manager. Verwaltung: neue Sektion "Team verwalten" (TEAM_MANAGE-Berechtigung) -- ein Formular für alle vier Kategorien mit Foto-Sofort-Upload, TikTok-Feld, Bio/Zitat, bedingten Rang-Feldern (nur Modi), Kategorie- Filterleiste und Bearbeiten/Löschen pro Eintrag. Beim Testen mit Playwright einen echten Syntaxfehler gefunden und gefixt (ASCII-Anführungszeichen statt schließendem „" in einem Statustext), der das GESAMTE Verwaltungs-Skript und damit die komplette Seite lahmgelegt hätte. Cache-Busting-Version auf 20260821j erhöht. Co-Authored-By: Claude Sonnet 5 <[email protected]>
323 lines
18 KiB
JavaScript
323 lines
18 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";
|
|
import {
|
|
getTeamPublic, getTeamAdmin, saveTeamMember, deleteTeamMember, uploadTeamPhoto,
|
|
} from "./routes/team.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 });
|
|
// 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)),
|
|
});
|
|
|
|
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.get("/team", wrap(getTeamPublic));
|
|
|
|
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));
|
|
|
|
// 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/delete", requireTeamSession, withSession(deleteTeamMember));
|
|
app.post("/admin/team/upload-photo", requireTeamSession, teamBildUpload.single("image"), withSession(uploadTeamPhoto));
|
|
|
|
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}`);
|
|
});
|