Schreibt jemand "Welche Daten haben Sie ueber mich?" oder "Bitte loeschen Sie meine Daten", laeuft eine Frist von einem Monat (Art. 12 Abs. 3 DSGVO). Bisher haette man dafuer von Hand durch ein Dutzend Tabellen suchen muessen -- und uebersieht man eine, ist die Auskunft unvollstaendig, ohne dass man es ihr ansieht. ⚠️ LOESCHEN IST NICHT EINFACH LOESCHEN Der naheliegende Weg waere ein "Alles loeschen". Das waere bequem und doppelt falsch: Rechnungen und Buchungsbelege unterliegen einer gesetzlichen Aufbewahrungspflicht -- § 147 Abs. 3 AO nennt acht Jahre fuer Buchungsbelege, zehn fuer Handelsbuecher, gerechnet ab dem Ende des Kalenderjahres (Abs. 4). Wer sie auf Zuruf loescht, verstoesst gegen Steuerrecht, um Datenschutzrecht zu erfuellen. Die DSGVO nimmt diesen Fall selbst aus (Art. 17 Abs. 3 lit. b). Fuer das, was bleiben muss, sieht sie die Einschraenkung der Verarbeitung vor (Art. 18) -- genau so wird es ausgewiesen, samt Datum, ab dem geloescht werden darf. Dasselbe bei Widerrufserklaerungen: Sie sind der Nachweis, DASS und WANN widerrufen wurde. Wer sie loescht, vernichtet seinen eigenen Beleg in genau der Sache, in der es spaeter Streit geben koennte. EIN FEHLER, DEN DER TEST VOR DEM ERSTEN LAUF GEFUNDEN HAT Die Tabelle wd_widerrufe fuehrt die Adresse nicht als "email", sondern als "kontakt". Die erste Fassung suchte nach "email" -- sie haette dort NIE einen Treffer geliefert, und die Auskunft haette trotzdem sauber ausgesehen. Aufgefallen nur, weil die Testdaten gegen das echte Schema angelegt wurden statt gegen die Annahme. Deshalb steht in der Quellenliste jetzt die Spalte, nicht eine Vermutung. WEITERE ENTSCHEIDUNGEN - Die Suche geht ueber die Adresse UND ueber die Kundenkennung. Vieles haengt nicht an der Adresse; ohne diesen Schritt bliebe die halbe Auskunft leer. - Gross- und Kleinschreibung spielt keine Rolle -- sonst bekaeme jemand eine leere Auskunft, weil er seine Adresse anders schreibt. - Die Loeschung verlangt die Adresse ein zweites Mal. Der Unterschied zwischen .org und .com ist ein Buchstabe, die Folge unwiederbringlich. - Sie laeuft in einer Transaktion: Bricht sie ab, bliebe sonst ein halbgeloeschter Bestand, und niemand wuesste, welcher Teil weg ist. - Der Vorgang wird protokolliert, aber OHNE die geloeschten Inhalte -- sie dabei erneut zu speichern waere das Gegenteil des Zwecks. 15 Pruefungen gruen, mit Gegenprobe (fremde Adresse liefert nichts). Die Oberflaeche in der Verwaltung folgt. Deploy braucht Filipe: server-internal liegt unter /home/dogiintern mit Rechten 700. Co-Authored-By: Claude Opus 5 <[email protected]>
578 lines
33 KiB
JavaScript
578 lines
33 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,
|
|
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}`);
|
|
});
|