Webdesign-Bereich: Fundament, oeffentliche Seiten, Zugangsschutz, PayPal

Umsetzung des "Website Masterplan" (16 Seiten) unter /webdesign.

Zugangsschutz mit EIGENER Schranke (server/webdesign-gate.js) statt gate.js:
gate.js laesst seit dem oeffentlichen Start am 21.08.2026 jeden durch, weil
die Pruefung auf SITE_PUBLIC_LAUNCH_AT als allererste Zeile steht. Haette man
/webdesign dahintergehaengt, waere der ausdruecklich nicht-oeffentliche Bereich
inklusive Preisen und spaeteren Kundendaten ab der ersten Sekunde fuer jeden
lesbar gewesen. Eigenes Sitzungs-Cookie, bereich="webdesign" im Token, damit
ein gueltiges Universe-Cookie hier NICHT gilt. 37/37 Tests.

Sieben oeffentliche Seiten in fuenf Sprachen (de, de-CH mit echtem Dialekt, en,
fr, pt). Preise, Zeitrahmen, Paketnamen und die 30-%-Regel stehen an genau
EINER Stelle in wd-core.js -- der Masterplan verlangt "ueberall
widerspruchsfrei", und vier Kopien laufen bei der ersten Preisaenderung
auseinander.

Als App installierbar auf Handy und PC. Der Service Worker speichert bewusst
KEINE HTML-Seite zwischen: nach dem Abmelden wuerden sonst geschuetzte Seiten
weiter ausgeliefert, ohne dass der Server je gefragt wird. 18/18 Tests.

Handy-Abnahme ueber alle Seiten in fuenf Breiten (320-1440) und fuenf Sprachen:
40/40. Der Test fand 35 echte Fehler (Touch-Ziele unter 44px), behoben im
Designsystem statt einzeln pro Seite.

PayPal (Wunsch 22.08.2026 "sofort auf meinem paypal"): Orders API mit
intent=CAPTURE, also sofortiger Einzug statt blosser Reservierung. Gebuehr und
Nettobetrag getrennt gespeichert. Betraege durchgehend als Ganzzahl in Cent.
Gefaelschte Webhooks werden abgewiesen. Fail closed solange Zugangsdaten
fehlen. 28/28 Tests gegen einen nachgebauten PayPal-Server.

Datenbank: 15 Tabellen mit Praefix wd_, fachlich vollstaendig vom Universe
getrennt.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-22 18:06:52 +02:00
co-authored by Claude Opus 5
parent 2010b5ec33
commit 4b3ec450d5
50 changed files with 8264 additions and 0 deletions
+40
View File
@@ -45,3 +45,43 @@ PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=
PAYPAL_PLAN_ID=
PAYPAL_WEBHOOK_ID=
# ---------------------------------------------------------------------------
# PayPal fuer den Webdesign-Bereich (/webdesign), 22.08.2026
# Wunsch: "die gelder sollen auch sofort auf meinem paypal landen" -- deshalb
# wird mit intent=CAPTURE sofort eingezogen, nicht nur reserviert.
#
# Diese Werte kann NUR Filipe selbst erzeugen (PayPal-Business-Konto +
# Developer-App haengen an seiner Identitaet und Bankverbindung).
# Solange sie fehlen, ist die Zahlung sauber GESPERRT statt halb zu
# funktionieren -- eine Zahlung, die "irgendwie durchgeht" ohne dass Geld
# fliesst, waere der schlimmste denkbare Fehler.
# NIEMALS echte Werte hier eintragen, nur in der echten .env!
# ---------------------------------------------------------------------------
# sandbox = Testmodus ohne echtes Geld (Voreinstellung), live = Echtbetrieb
PAYPAL_ENV=sandbox
# Aus dem PayPal-Entwicklerbereich, App vom Typ "Merchant".
# Achtung: Sandbox und Live haben UNTERSCHIEDLICHE Zugangsdaten.
PAYPAL_CLIENT_ID=
PAYPAL_CLIENT_SECRET=
# Webhook fuer den Webdesign-Bereich. Bewusst eine EIGENE Webhook-Kennung,
# getrennt vom Supporter-Abo (PAYPAL_WEBHOOK_ID): sonst laesst sich der eine
# Bereich nicht abschalten, ohne den anderen mit zu treffen.
# Ziel-Adresse beim Anlegen in PayPal:
# https://postfach.dogfather-universe.com/webdesign/paypal-webhook
PAYPAL_WD_WEBHOOK_ID=
# Abo-Plaene fuer die laufende Betreuung (ab 49 EUR/Monat). Ein Plan je Paket,
# weil PayPal den Betrag am Plan festmacht, nicht am einzelnen Abo.
# Nur noetig, wenn Betreuungs-Abos ueber die Seite abgeschlossen werden sollen;
# Anzahlung und Restbetrag funktionieren auch ohne diese Werte.
PAYPAL_WD_PLAN_BASIS=
PAYPAL_WD_PLAN_PLUS=
PAYPAL_WD_PLAN_PREMIUM=
# Zusatz fuer die IP-Hashes im Webdesign-Bereich. Beliebige lange
# Zufallszeichenkette. Wird sie geaendert, passen alte Hashes nicht mehr --
# das ist unkritisch, sie dienen nur der Missbrauchserkennung.
WD_IP_SALT=
+9
View File
@@ -34,6 +34,15 @@ const MIGRATION_FILES = [
"0008_team_members_rich.sql",
"0009_supporter_access_code.sql",
"0010_testimonials_avatar.sql",
// Webdesign-Bereich (22.08.2026): Anfragen, Kunden, Projekte, Dateien,
// Nachrichten, Änderungswünsche, pflegbare Inhalte, Änderungsverlauf.
// Alle Tabellen tragen das Präfix "wd_" und sind fachlich vollständig
// vom Universe getrennt — Begründung im Kopf der Migration.
"0011_webdesign.sql",
// Zahlungen im Webdesign-Bereich (22.08.2026): PayPal-Einmalzahlungen
// fuer Anzahlung/Restbetrag/Zusatzangebote, PayPal-Abo fuer die
// monatliche Betreuung, Webhook-Merkliste gegen Doppelverbuchung.
"0012_webdesign_zahlungen.sql",
];
function alreadyApplied(name) {
+19
View File
@@ -78,6 +78,14 @@ 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";
initDb();
@@ -208,6 +216,17 @@ 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));
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));
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.
+16
View File
@@ -52,6 +52,17 @@ export const PERMISSIONS = {
TEAM_MANAGE: "Team-Mitglieder verwalten (Modis/Scouts/Creator/Manager)",
};
/* Webdesign-Rechte (22.08.2026) — an den Katalog oben angehängt, damit die
bestehende Rechteverwaltung sie ohne Änderung mit anzeigt. */
Object.assign(PERMISSIONS, {
WD_ANFRAGEN_VIEW: "Webdesign: Projektanfragen ansehen",
WD_ANFRAGEN_MANAGE: "Webdesign: Anfragen bearbeiten, Status ändern, archivieren",
WD_KUNDEN_MANAGE: "Webdesign: Kunden anlegen, freischalten, sperren",
WD_PROJEKTE_MANAGE: "Webdesign: Projekte, Termine und Freigaben verwalten",
WD_ZAHLUNGEN_VIEW: "Webdesign: Zahlungen und Beträge einsehen",
WD_INHALTE_EDIT: "Webdesign: Texte und Preise der Seite bearbeiten",
});
export const PERMISSION_GROUPS = [
{ label: "Bewerbungsbereiche", keys: ["APPLICATIONS_VIEW_CREATOR", "APPLICATIONS_VIEW_MANAGER_SCOUT", "APPLICATIONS_VIEW_ARCHIVED", "APPLICATIONS_OPEN_FULL"] },
{ label: "Bearbeitung", keys: ["APPLICATIONS_TAKE_OVER", "APPLICATIONS_ASSIGN", "APPLICATIONS_CHANGE_STATUS", "APPLICATIONS_ARCHIVE", "APPLICATIONS_RESTORE", "APPLICATIONS_DELETE"] },
@@ -63,6 +74,11 @@ export const PERMISSION_GROUPS = [
{ label: "Startseite", keys: ["EVENTS_MANAGE"] },
{ label: "Stimmen", keys: ["TESTIMONIALS_MANAGE"] },
{ label: "Team", keys: ["TEAM_MANAGE"] },
/* Webdesign-Bereich (22.08.2026). Bewusst EIGENE Rechte statt der
bestehenden Bewerbungs-Rechte: eine Person, die Creator-Bewerbungen
lesen darf, soll damit nicht automatisch Kundendaten, Preise und
Zahlungen einsehen können. Das sind zwei getrennte Geschäfte. */
{ label: "Webdesign", keys: ["WD_ANFRAGEN_VIEW", "WD_ANFRAGEN_MANAGE", "WD_KUNDEN_MANAGE", "WD_PROJEKTE_MANAGE", "WD_ZAHLUNGEN_VIEW", "WD_INHALTE_EDIT"] },
];
export const OWNER_ONLY_ACTIONS = [
+177
View File
@@ -0,0 +1,177 @@
/* =====================================================================
webdesign-helfer.js — geteilte Bausteine für den Webdesign-Bereich.
Bewusst klein gehalten und ohne Abhängigkeit zu den Universe-Modulen,
damit eine Änderung dort diesen Bereich nicht mitreißt.
===================================================================== */
import { randomUUID, createHash, timingSafeEqual } from "node:crypto";
import { db } from "../db.js";
export const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
export const PAKETE = ["onepager", "website", "shop", "verwaltung", "betreuung", "unklar"];
export const ANFRAGE_STATUS = [
"neu", "pruefung", "rueckfrage", "angebot", "angenommen", "abgeschlossen", "abgelehnt",
];
export const PROJEKT_STATUS = [
"briefing", "angebot", "design", "entwicklung", "tests", "abnahme", "uebergeben", "pausiert", "abgebrochen",
];
export function jetzt() {
return new Date().toISOString();
}
export function neueId() {
return randomUUID();
}
/* Fortlaufende, gut aussprechbare Nummer: "A-2608-0001".
Der Zähler läuft pro Präfix, damit die Nummer im Gespräch kurz bleibt
("die A-2608-7") statt eine 36-stellige Kennung vorlesen zu müssen. */
export function naechsteNummer(tabelle, praefix) {
const d = new Date();
const monat = String(d.getUTCMonth() + 1).padStart(2, "0");
const jahr = String(d.getUTCFullYear()).slice(2);
const stamm = `${praefix}-${jahr}${monat}-`;
const zeile = db
.prepare(`SELECT nummer FROM ${tabelle} WHERE nummer LIKE ? ORDER BY nummer DESC LIMIT 1`)
.get(stamm + "%");
const letzte = zeile ? parseInt(String(zeile.nummer).slice(stamm.length), 10) : 0;
return stamm + String((Number.isFinite(letzte) ? letzte : 0) + 1).padStart(4, "0");
}
/* Zeichenketten aus Formularen einheitlich säubern:
- Rand-Leerzeichen weg
- Länge begrenzt (sonst kann jemand mit einem 10-MB-Feld die Datenbank
aufblähen; die Begrenzung im Body-Parser allein reicht nicht, weil
ein einzelnes Feld auch innerhalb eines gültigen Bodys riesig sein kann)
- Steuerzeichen entfernen, die in Logs und E-Mails Unfug anrichten */
export function text(wert, maxLaenge = 2000) {
if (wert == null) return "";
return String(wert)
/* Steuerzeichen raus, aber Zeilenumbruch und Tabulator behalten --
die braucht ein mehrzeiliges Textfeld.
Geschrieben ueber Unicode-Eigenschaften statt ueber einen Bereich
aus echten Steuerzeichen: solche Zeichen im Quelltext ueberleben
weder Copy-and-paste noch jeden Editor zuverlaessig, und ein
stillschweigend kaputter Filter faellt erst auf, wenn er gebraucht
wird. \P{Cc} bedeutet "kein Steuerzeichen", die Verneinung der
Klasse trifft also genau die Steuerzeichen ausser \n und \t. */
.replace(/[^\P{Cc}\n\t]/gu, "")
.trim()
.slice(0, maxLaenge);
}
/* Sehr bewusst KEINE komplizierte E-Mail-Regex. Die einzige zuverlässige
Prüfung ist der Bestätigungslink; alles andere lehnt entweder gültige
Adressen ab (z.B. neue Endungen, Umlaut-Domains) oder lässt Unsinn durch.
Geprüft wird deshalb nur das strukturell Notwendige. */
export function istEmail(wert) {
const s = text(wert, 254).toLowerCase();
if (!s || s.length < 6) return false;
const teile = s.split("@");
if (teile.length !== 2) return false;
const [lokal, domain] = teile;
if (!lokal || !domain) return false;
if (!domain.includes(".")) return false;
if (/\s/.test(s)) return false;
return true;
}
export function emailNormal(wert) {
return text(wert, 254).toLowerCase();
}
/* IP nur als Hash speichern (Datensparsamkeit). Der Hash reicht, um
wiederholte Absendungen derselben Quelle zu erkennen, lässt sich aber
nicht ohne Weiteres zurückrechnen. Der feste Zusatz verhindert, dass
jemand mit einer fertigen Regenbogentabelle alle IPv4-Adressen durchprobiert. */
export function ipHash(ip) {
if (!ip) return null;
const zusatz = process.env.WD_IP_SALT || "dogfather-webdesign";
return createHash("sha256").update(zusatz + "|" + String(ip)).digest("hex").slice(0, 32);
}
export function tokenHash(token) {
return createHash("sha256").update(String(token)).digest("hex");
}
export function gleichZeitsicher(a, b) {
const pa = Buffer.from(String(a ?? ""), "utf8");
const pb = Buffer.from(String(b ?? ""), "utf8");
if (pa.length !== pb.length) return false; // timingSafeEqual wirft sonst
return timingSafeEqual(pa, pb);
}
/* Änderungsverlauf schreiben (Masterplan S.13). Absichtlich als eigene
Funktion und nicht inline: so ist garantiert, dass jeder Eintrag
dieselben Felder hat und keiner vergessen wird. */
export function verlauf(bereich, objektId, aktion, wer, details) {
db.prepare(
`INSERT INTO wd_verlauf (id, bereich, objekt_id, aktion, wer, details, erstellt_am)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(
neueId(),
bereich,
objektId || null,
aktion,
wer || "system",
details ? JSON.stringify(details) : null,
jetzt()
);
}
/* Einfache Mengenbegrenzung, in der Datenbank statt im Arbeitsspeicher —
dieser Dienst kann neu starten, ohne dass ein Absender dadurch seine
Sperre verliert. */
export function zuVieleVersuche(schluessel, maxAnzahl, sperreMinuten) {
const zeile = db.prepare(`SELECT * FROM wd_login_versuche WHERE schluessel = ?`).get(schluessel);
if (!zeile) return false;
if (zeile.gesperrt_bis && zeile.gesperrt_bis > jetzt()) return true;
return false;
}
export function versuchZaehlen(schluessel, maxAnzahl = 5, sperreMinuten = 15) {
const zeile = db.prepare(`SELECT * FROM wd_login_versuche WHERE schluessel = ?`).get(schluessel);
const anzahl = (zeile?.anzahl || 0) + 1;
const gesperrtBis = anzahl >= maxAnzahl
? new Date(Date.now() + sperreMinuten * 60000).toISOString()
: null;
db.prepare(
`INSERT INTO wd_login_versuche (schluessel, anzahl, gesperrt_bis, zuletzt)
VALUES (?, ?, ?, ?)
ON CONFLICT(schluessel) DO UPDATE SET anzahl = excluded.anzahl,
gesperrt_bis = excluded.gesperrt_bis, zuletzt = excluded.zuletzt`
).run(schluessel, anzahl, gesperrtBis, jetzt());
return { anzahl, gesperrtBis };
}
export function versucheZuruecksetzen(schluessel) {
db.prepare(`DELETE FROM wd_login_versuche WHERE schluessel = ?`).run(schluessel);
}
/* Die echte Besucher-IP. Gleiche Begründung und gleiche Abhängigkeit wie
in server/gate.js: Cloudflare setzt diesen Header und die Firewall lässt
nur Cloudflare an die Ports 80/443. Ändert sich das, muss diese Stelle
zurückgebaut werden. */
export function besucherIp(req) {
const cf = req.headers["cf-connecting-ip"];
if (typeof cf === "string" && cf.trim() && cf.length <= 45) return cf.trim();
return req.ip || req.socket?.remoteAddress || null;
}
/* Sprache aus der Anfrage übernehmen, damit die Antwort-E-Mail in
derselben Sprache kommt, in der gefragt wurde. */
export function spracheOderDe(wert) {
const s = text(wert, 10);
return SPRACHEN.includes(s) ? s : "de";
}
/* Betrag in Cent -> lesbarer Text. Nie mit Gleitkomma rechnen. */
export function centZuText(cent, waehrung = "EUR") {
if (cent == null) return null;
const ganz = Math.trunc(cent / 100);
const rest = String(Math.abs(cent % 100)).padStart(2, "0");
return `${ganz},${rest} ${waehrung === "EUR" ? "€" : waehrung}`;
}
+381
View File
@@ -0,0 +1,381 @@
/* =====================================================================
webdesign-paypal.js — PayPal für den Webdesign-Bereich.
ZWEI VERSCHIEDENE PAYPAL-WELTEN, BEWUSST GETRENNT
1) Orders API (v2) — Einmalzahlungen: 30 % Anzahlung, Restbetrag,
angenommene Zusatzangebote. Beträge unterscheiden sich bei jedem
Projekt, deshalb geht das NICHT über Abo-Pläne.
2) Subscriptions API (v1) — die monatliche Betreuung. Fester Betrag,
wiederkehrend, braucht einen Plan im PayPal-Konto.
lib/paypal.js (Supporter-Abo) kann nur Nummer 2 und ist an den dortigen
Plan gebunden. Diese Datei ist deshalb eine eigene, damit eine Änderung
am Supporter-Abo keine Kundenzahlungen kaputt macht — und umgekehrt.
⚠️ WANN LANDET DAS GELD AUF DEM KONTO?
Bei PayPal gibt es zwei Wege: "authorize" (Betrag nur reservieren, bis
zu 29 Tage später einziehen) und "capture" (sofort einziehen). Hier wird
ausdrücklich CAPTURE benutzt — Wunsch vom 22.08.2026: "die gelder sollen
auch sofort auf meinem paypal landen". Mit capture ist das Geld
unmittelbar nach der Bestätigung des Kunden auf dem Business-Konto,
ohne Treuhand und ohne Wartezeit.
⚠️ FAIL CLOSED
Solange die Zugangsdaten fehlen, wirft jede Funktion einen klaren
Fehler, statt so zu tun, als sei etwas bezahlt worden. Eine Zahlung,
die "irgendwie durchgeht", obwohl kein Geld geflossen ist, wäre der
schlimmste denkbare Fehler in diesem Modul.
===================================================================== */
import { randomUUID } from "node:crypto";
export class PayPalNichtEingerichtet extends Error {
constructor(fehlend) {
super(
"PayPal ist für den Webdesign-Bereich noch nicht eingerichtet. Fehlende Einstellungen: " +
(fehlend || []).join(", ")
);
this.name = "PayPalNichtEingerichtet";
this.code = "PAYPAL_NICHT_EINGERICHTET";
this.fehlend = fehlend || [];
}
}
/* Testmodus zuerst. Wer vergisst, PAYPAL_ENV auf "live" zu stellen, zahlt
in der Spielwiese — ärgerlich, aber harmlos. Andersherum (versehentlich
live) würde echtes Geld bewegt. Die ungefährliche Richtung gehört in die
Voreinstellung. */
function basis() {
return process.env.PAYPAL_ENV === "live"
? "https://api-m.paypal.com"
: "https://api-m.sandbox.paypal.com";
}
export function istLive() {
return process.env.PAYPAL_ENV === "live";
}
/* Prüft, ob alles Nötige da ist. `fuer` unterscheidet die beiden Welten:
Einmalzahlungen brauchen KEINEN Plan, das Abo schon. Ohne diese
Unterscheidung wäre die Anzahlung blockiert, nur weil noch kein
Betreuungsplan angelegt wurde. */
export function fehlendeEinstellungen(fuer = "einmal") {
const fehlt = [];
if (!process.env.PAYPAL_CLIENT_ID) fehlt.push("PAYPAL_CLIENT_ID");
if (!process.env.PAYPAL_CLIENT_SECRET) fehlt.push("PAYPAL_CLIENT_SECRET");
if (fuer === "abo" && !process.env.PAYPAL_WD_PLAN_BASIS) fehlt.push("PAYPAL_WD_PLAN_BASIS");
return fehlt;
}
export function istEingerichtet(fuer = "einmal") {
return fehlendeEinstellungen(fuer).length === 0;
}
function pruefeEingerichtet(fuer = "einmal") {
const fehlt = fehlendeEinstellungen(fuer);
if (fehlt.length) throw new PayPalNichtEingerichtet(fehlt);
}
/* Zugangstoken zwischenspeichern. PayPal begrenzt die Anzahl der
Token-Anfragen; ohne Zwischenspeicher holt jede Zahlung ein neues. */
let token = null; // { wert, laeuftAb }
async function zugangstoken() {
pruefeEingerichtet("einmal");
if (token && token.laeuftAb > Date.now() + 60_000) return token.wert;
const anmeldung = Buffer.from(
`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`
).toString("base64");
const antwort = await fetch(`${basis()}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${anmeldung}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
});
if (!antwort.ok) {
const txt = await antwort.text().catch(() => "");
throw new Error(`PayPal-Anmeldung fehlgeschlagen (${antwort.status}): ${txt.slice(0, 300)}`);
}
const daten = await antwort.json();
token = {
wert: daten.access_token,
laeuftAb: Date.now() + (daten.expires_in || 3600) * 1000,
};
return token.wert;
}
/* Für Tests: Zwischenspeicher leeren, damit ein Wechsel der Zugangsdaten
sofort wirkt statt erst nach einer Stunde. */
export function tokenVergessen() {
token = null;
}
async function ruf(pfad, optionen = {}) {
const t = await zugangstoken();
const antwort = await fetch(`${basis()}${pfad}`, {
...optionen,
headers: {
Authorization: `Bearer ${t}`,
"Content-Type": "application/json",
...(optionen.headers || {}),
},
});
const txt = await antwort.text();
const daten = txt ? JSON.parse(txt) : null;
if (!antwort.ok) {
const fehler = new Error(`PayPal-Anfrage fehlgeschlagen (${antwort.status}): ${txt.slice(0, 400)}`);
fehler.paypalStatus = antwort.status;
fehler.paypalBody = daten;
throw fehler;
}
return daten;
}
/* Cent -> PayPal-Format. PayPal will einen String mit genau zwei
Nachkommastellen ("300.00"). Bewusst aus der Ganzzahl gebaut statt über
toFixed() auf einer Kommazahl: (betrag/100).toFixed(2) rundet bei
krummen Beträgen unter Umständen falsch. */
export function centZuPaypal(cent) {
if (!Number.isInteger(cent) || cent < 0) {
throw new Error("Betrag muss eine nicht-negative Ganzzahl in Cent sein, war: " + cent);
}
const ganz = Math.floor(cent / 100);
const rest = String(cent % 100).padStart(2, "0");
return `${ganz}.${rest}`;
}
/* Rückweg: PayPal liefert "300.00" -> 30000 Cent. Über Zeichenketten
gerechnet, damit nirgends eine Gleitkommazahl entsteht. */
export function paypalZuCent(wert) {
const s = String(wert ?? "0").trim();
if (!/^\d+(\.\d{1,2})?$/.test(s)) return null;
const [ganz, rest = ""] = s.split(".");
return parseInt(ganz, 10) * 100 + parseInt((rest + "00").slice(0, 2), 10);
}
/* ---------------------------------------------------------------------
1) EINMALZAHLUNG — Bestellung anlegen
Der Kunde wird danach zum "approve"-Link geschickt und bestätigt dort
bei PayPal. Erst der anschließende Einzug (siehe unten) bewegt Geld.
--------------------------------------------------------------------- */
export async function bestellungAnlegen({
zahlungId,
nummer,
betragCent,
waehrung = "EUR",
zweck,
rueckkehrUrl,
abbruchUrl,
sprache = "de",
}) {
pruefeEingerichtet("einmal");
const sprachKarte = { de: "de-DE", "de-CH": "de-DE", en: "en-GB", fr: "fr-FR", pt: "pt-PT" };
const daten = await ruf("/v2/checkout/orders", {
method: "POST",
headers: {
/* Schützt gegen doppelte Bestellungen, wenn dieselbe Anfrage wegen
eines Verbindungsabbruchs zweimal ankommt. PayPal liefert dann
dieselbe Bestellung zurück, statt eine zweite anzulegen. */
"PayPal-Request-Id": `wd-order-${zahlungId}`,
},
body: JSON.stringify({
intent: "CAPTURE", // sofort einziehen, siehe Kopf der Datei
purchase_units: [
{
/* Unsere eigene Kennung. Kommt im Webhook zurück und ist der
einzige verlässliche Weg, eine PayPal-Meldung der richtigen
Zahlung zuzuordnen — die Bestellnummer allein reicht nicht,
weil sie bei manchen Ereignissen fehlt. */
custom_id: zahlungId,
invoice_id: nummer,
description: String(zweck || "Dogfather Webdesign").slice(0, 127),
amount: {
currency_code: waehrung,
value: centZuPaypal(betragCent),
},
},
],
payment_source: {
paypal: {
experience_context: {
brand_name: "Dogfather Webdesign",
locale: sprachKarte[sprache] || "de-DE",
// Keine Lieferadresse abfragen — es gibt nichts zu versenden.
shipping_preference: "NO_SHIPPING",
// Knopf heißt "Jetzt bezahlen" statt "Weiter": der Kunde soll
// wissen, dass mit dem Klick Geld fließt.
user_action: "PAY_NOW",
return_url: rueckkehrUrl,
cancel_url: abbruchUrl,
},
},
},
}),
});
const freigabe = (daten.links || []).find((l) => l.rel === "payer-action" || l.rel === "approve");
return {
orderId: daten.id,
freigabeUrl: freigabe ? freigabe.href : null,
status: daten.status,
};
}
/* ---------------------------------------------------------------------
Einzug — HIER fließt das Geld auf Dogfathers Konto.
--------------------------------------------------------------------- */
export async function bestellungEinziehen(orderId) {
pruefeEingerichtet("einmal");
const daten = await ruf(`/v2/checkout/orders/${encodeURIComponent(orderId)}/capture`, {
method: "POST",
headers: { "PayPal-Request-Id": `wd-capture-${orderId}` },
body: "{}",
});
const einheit = (daten.purchase_units || [])[0] || {};
const einzug = ((einheit.payments || {}).captures || [])[0] || {};
const aufschluesselung = (einzug.seller_receivable_breakdown || {});
return {
status: daten.status, // COMPLETED wenn alles gut ging
captureId: einzug.id || null,
captureStatus: einzug.status || null,
bruttoCent: paypalZuCent(einzug.amount?.value),
/* net_amount ist das, was tatsächlich auf dem Konto ankommt.
paypal_fee ist die Gebühr. Beides getrennt festhalten — die Gebühr
ist Aufwand und gehört in die Buchhaltung, nicht stillschweigend
vom Rechnungsbetrag abgezogen. */
nettoCent: paypalZuCent(aufschluesselung.net_amount?.value),
gebuehrCent: paypalZuCent(aufschluesselung.paypal_fee?.value),
zahlerEmail: daten.payer?.email_address || null,
rohdaten: daten,
};
}
export async function bestellungAbfragen(orderId) {
pruefeEingerichtet("einmal");
return ruf(`/v2/checkout/orders/${encodeURIComponent(orderId)}`);
}
/* ---------------------------------------------------------------------
2) BETREUUNGS-ABO
--------------------------------------------------------------------- */
/* Welcher Plan zu welchem Paket gehört. Ein eigener Plan je Paket, weil
PayPal den Betrag am Plan festmacht — nicht am einzelnen Abo. */
export function planFuerPaket(paket) {
const karte = {
basis: process.env.PAYPAL_WD_PLAN_BASIS,
plus: process.env.PAYPAL_WD_PLAN_PLUS,
premium: process.env.PAYPAL_WD_PLAN_PREMIUM,
};
return karte[paket] || karte.basis || null;
}
export async function aboAnlegen({ aboId, paket, rueckkehrUrl, abbruchUrl, sprache = "de" }) {
pruefeEingerichtet("abo");
const planId = planFuerPaket(paket);
if (!planId) throw new PayPalNichtEingerichtet([`PAYPAL_WD_PLAN_${String(paket).toUpperCase()}`]);
const sprachKarte = { de: "de-DE", "de-CH": "de-DE", en: "en-GB", fr: "fr-FR", pt: "pt-PT" };
const daten = await ruf("/v1/billing/subscriptions", {
method: "POST",
headers: { "PayPal-Request-Id": `wd-abo-${aboId}` },
body: JSON.stringify({
plan_id: planId,
custom_id: aboId,
application_context: {
brand_name: "Dogfather Webdesign",
locale: sprachKarte[sprache] || "de-DE",
shipping_preference: "NO_SHIPPING",
user_action: "SUBSCRIBE_NOW",
return_url: rueckkehrUrl,
cancel_url: abbruchUrl,
},
}),
});
const freigabe = (daten.links || []).find((l) => l.rel === "approve");
return {
subscriptionId: daten.id,
freigabeUrl: freigabe ? freigabe.href : null,
status: daten.status,
};
}
export async function aboAbfragen(subscriptionId) {
pruefeEingerichtet("abo");
return ruf(`/v1/billing/subscriptions/${encodeURIComponent(subscriptionId)}`);
}
/* Kündigung. Der Zugang bleibt bis zum Ende der bezahlten Periode —
der Kunde hat den Monat schließlich bezahlt. */
export async function aboKuendigen(subscriptionId, grund) {
pruefeEingerichtet("abo");
await ruf(`/v1/billing/subscriptions/${encodeURIComponent(subscriptionId)}/cancel`, {
method: "POST",
body: JSON.stringify({ reason: String(grund || "Kündigung durch Kunde").slice(0, 127) }),
});
return true;
}
/* ---------------------------------------------------------------------
3) WEBHOOK-PRÜFUNG
Warum das nicht optional ist: Der Webhook-Endpunkt ist öffentlich
erreichbar. Ohne Signaturprüfung könnte jeder eine erfundene Meldung
"Zahlung eingegangen" schicken und damit ein Projekt als bezahlt
markieren lassen. Die Prüfung ist die einzige Stelle, die eine echte
PayPal-Meldung von einer erfundenen unterscheidet.
--------------------------------------------------------------------- */
export async function webhookEchtheitPruefen(kopfzeilen, rohkoerper) {
pruefeEingerichtet("einmal");
if (!process.env.PAYPAL_WD_WEBHOOK_ID) {
throw new PayPalNichtEingerichtet(["PAYPAL_WD_WEBHOOK_ID"]);
}
const pruefung = await ruf("/v1/notifications/verify-webhook-signature", {
method: "POST",
body: JSON.stringify({
auth_algo: kopfzeilen["paypal-auth-algo"],
cert_url: kopfzeilen["paypal-cert-url"],
transmission_id: kopfzeilen["paypal-transmission-id"],
transmission_sig: kopfzeilen["paypal-transmission-sig"],
transmission_time: kopfzeilen["paypal-transmission-time"],
webhook_id: process.env.PAYPAL_WD_WEBHOOK_ID,
webhook_event: JSON.parse(rohkoerper),
}),
});
return pruefung.verification_status === "SUCCESS";
}
/* Ereignisse, auf die wir reagieren. Alles andere wird protokolliert und
ignoriert — PayPal schickt Dutzende Arten, und auf unbekannte zu
reagieren ist gefährlicher als sie liegen zu lassen. */
export const WEBHOOK_EREIGNISSE = {
"CHECKOUT.ORDER.APPROVED": "bestellung_freigegeben",
"PAYMENT.CAPTURE.COMPLETED": "zahlung_eingegangen",
"PAYMENT.CAPTURE.DENIED": "zahlung_abgelehnt",
"PAYMENT.CAPTURE.REFUNDED": "zahlung_erstattet",
"BILLING.SUBSCRIPTION.ACTIVATED": "abo_aktiv",
"BILLING.SUBSCRIPTION.CANCELLED": "abo_gekuendigt",
"BILLING.SUBSCRIPTION.SUSPENDED": "abo_pausiert",
"BILLING.SUBSCRIPTION.PAYMENT.FAILED": "abo_zahlung_fehlgeschlagen",
"PAYMENT.SALE.COMPLETED": "abo_zahlung_eingegangen",
};
/* Kennung für ein Ereignis. Fällt PayPals eigene id aus, wird eine eigene
erzeugt — dann greift der Doppel-Schutz zwar nicht, aber die Meldung
geht wenigstens nicht verloren. */
export function ereignisId(ereignis) {
return ereignis?.id || `ohne-id-${randomUUID()}`;
}
@@ -0,0 +1,313 @@
/* =====================================================================
routes/webdesign-anfragen.js — Projektanfragen (Masterplan S.10)
Zwei Welten in einer Datei:
- EIN öffentlicher Endpunkt (Formular absenden). Der ist von außen
erreichbar und deshalb der einzige, der wirklich gehärtet sein muss.
- Mehrere Verwaltungs-Endpunkte, die eine Team-Anmeldung verlangen.
Absichtliche Entscheidung: Das Formular erzeugt NIE einen Kunden und NIE
eine Preiszusage (Masterplan S.10: "Das Formular zeigt Richtwerte,
erstellt aber keinen automatisch verbindlichen Vertrag"). Es legt genau
eine Anfrage an, mehr nicht.
===================================================================== */
import { db } from "../db.js";
import { json } from "../lib/http.js";
import { requireSession } from "../lib/auth.js";
import { hasPermission } from "../lib/permissions.js";
import {
jetzt, neueId, naechsteNummer, text, istEmail, emailNormal,
ipHash, besucherIp, spracheOderDe, verlauf,
PAKETE, ANFRAGE_STATUS,
} from "../lib/webdesign-helfer.js";
/* ---------------------------------------------------------------------
Rechteprüfung. Der Eigentümer darf immer alles — genau wie im
restlichen internen Bereich.
--------------------------------------------------------------------- */
async function darf(req, recht) {
const sitzung = await requireSession(req);
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
if (sitzung.isOwner) return { erlaubt: true, sitzung };
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
}
/* ---------------------------------------------------------------------
Missbrauchsschutz für den öffentlichen Endpunkt.
Masterplan S.10 verlangt "Spam- und Missbrauchsschutz ohne unnötige
Hürden" — also ausdrücklich KEIN Captcha. Stattdessen drei Maßnahmen,
die für echte Menschen unsichtbar sind:
1. Honigtopf: ein Feld, das im Browser versteckt ist. Menschen füllen es
nie aus, viele automatische Skripte schon.
2. Mindest-Ausfüllzeit: das Formular schickt mit, wann es geöffnet
wurde. Wer in unter drei Sekunden durch ein mehrstufiges Formular
kommt, hat es nicht gelesen.
3. Mengenbegrenzung pro Absender-IP.
Wichtig: Die ersten beiden sind höflich — sie melden Erfolg, speichern
aber nichts. Ein Skript, dem man sagt "erkannt", probiert es anders.
Eines, das "gespeichert" liest, hört auf.
--------------------------------------------------------------------- */
const ANFRAGE_MAX_PRO_STUNDE = 5;
const MINDEST_SEKUNDEN = 3;
function zuVieleAnfragen(hash) {
if (!hash) return false;
const grenze = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const zeile = db
.prepare(`SELECT COUNT(*) AS anzahl FROM wd_anfragen WHERE ip_hash = ? AND erstellt_am > ?`)
.get(hash, grenze);
return (zeile?.anzahl || 0) >= ANFRAGE_MAX_PRO_STUNDE;
}
/* ---------------------------------------------------------------------
ÖFFENTLICH: Anfrage absenden
--------------------------------------------------------------------- */
export async function anfrageAbsenden(req, res) {
const koerper = req.body || {};
// --- 1. Honigtopf ---
if (text(koerper.website, 200)) {
// Absichtlich "ok" melden, siehe Begründung oben.
return json(res, { ok: true, nummer: null, hinweis: "angenommen" });
}
// --- 2. Ausfüllzeit ---
const geoeffnet = Number(koerper.geoeffnetAm);
if (Number.isFinite(geoeffnet) && geoeffnet > 0) {
const sekunden = (Date.now() - geoeffnet) / 1000;
if (sekunden < MINDEST_SEKUNDEN) {
return json(res, { ok: true, nummer: null, hinweis: "angenommen" });
}
}
// --- 3. Mengenbegrenzung ---
const hash = ipHash(besucherIp(req));
if (zuVieleAnfragen(hash)) {
return json(res, {
ok: false,
error: "Es sind schon mehrere Anfragen von dir eingegangen. Bitte melde dich direkt per E-Mail, dann klären wir das persönlich.",
}, 429);
}
// --- Pflichtangaben (Masterplan S.10) ---
const name = text(koerper.name, 120);
const email = emailNormal(koerper.email);
const paket = text(koerper.paket, 30);
const datenschutz = koerper.datenschutz === true || koerper.datenschutz === "ja";
const fehler = {};
if (!name) fehler.name = "Bitte gib deinen Namen an.";
if (!email) fehler.email = "Bitte gib deine E-Mail-Adresse an.";
else if (!istEmail(email)) fehler.email = "Diese E-Mail-Adresse sieht nicht gültig aus.";
if (!paket || !PAKETE.includes(paket)) fehler.paket = "Bitte wähle aus, worum es ungefähr geht.";
if (!text(koerper.ziel, 4000)) fehler.ziel = "Bitte beschreib kurz, was die Seite erreichen soll.";
if (!datenschutz) fehler.datenschutz = "Ohne diese Zustimmung dürfen wir deine Angaben nicht speichern.";
if (Object.keys(fehler).length) {
/* Alle Fehler auf einmal zurückgeben, nicht einen nach dem anderen —
sonst schickt jemand das Formular fünfmal ab und findet jedes Mal
einen neuen Fehler. */
return json(res, { ok: false, error: "Bitte prüfe die markierten Felder.", felder: fehler }, 400);
}
const id = neueId();
const nummer = naechsteNummer("wd_anfragen", "A");
const zeit = jetzt();
/* Funktionen und kategoriespezifische Antworten als JSON. Begrenzt
gespeichert, damit niemand über ein Array die Datenbank vollschreibt. */
const funktionen = Array.isArray(koerper.funktionen)
? koerper.funktionen.slice(0, 40).map((f) => text(f, 80)).filter(Boolean)
: [];
let details = {};
if (koerper.details && typeof koerper.details === "object" && !Array.isArray(koerper.details)) {
for (const [k, v] of Object.entries(koerper.details).slice(0, 40)) {
details[text(k, 60)] = text(typeof v === "boolean" ? (v ? "ja" : "nein") : v, 1000);
}
}
db.prepare(
`INSERT INTO wd_anfragen (
id, nummer, name, email, telefon, kontaktweg,
paket, ziel, zielgruppe, wirkung, seiten, funktionen, details,
hat_texte, hat_bilder, hat_logo, hat_domain,
budget, termin, bemerkung,
status, sprache, erstellt_am, ip_hash
) VALUES (?,?,?,?,?,?, ?,?,?,?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?,?)`
).run(
id, nummer, name, email,
text(koerper.telefon, 60) || null,
["email", "telefon"].includes(text(koerper.kontaktweg, 20)) ? text(koerper.kontaktweg, 20) : "email",
paket,
text(koerper.ziel, 4000),
text(koerper.zielgruppe, 1000) || null,
text(koerper.wirkung, 1000) || null,
text(koerper.seiten, 2000) || null,
JSON.stringify(funktionen),
JSON.stringify(details),
text(koerper.hatTexte, 20) || null,
text(koerper.hatBilder, 20) || null,
text(koerper.hatLogo, 20) || null,
text(koerper.hatDomain, 20) || null,
text(koerper.budget, 60) || null,
text(koerper.termin, 60) || null,
text(koerper.bemerkung, 4000) || null,
"neu",
spracheOderDe(koerper.sprache),
zeit,
hash
);
verlauf("anfrage", id, "angelegt", "system", { nummer, paket });
/* Benachrichtigung an Dogfather. Läuft bewusst NACH dem Speichern und
ohne await auf den Erfolg: die Anfrage ist bereits sicher in der
Datenbank, und ein Ausfall des Mail-Versands darf den Kunden nicht
eine Fehlermeldung sehen lassen für etwas, das geklappt hat. */
benachrichtige(nummer, name, email, paket).catch((e) =>
console.error("[webdesign] Benachrichtigung fehlgeschlagen:", e?.message || e)
);
return json(res, {
ok: true,
nummer,
/* Die Zusammenfassung kommt vom Server zurück, damit der Kunde
schwarz auf weiß sieht, was tatsächlich gespeichert wurde — nicht
das, was sein Browser glaubt abgeschickt zu haben. */
zusammenfassung: { nummer, name, email, paket, eingegangen: zeit },
});
}
/* Discord-Benachrichtigung über den bereits vorhandenen Webhook-Weg.
Bewusst OHNE Kundendaten im Text: die Meldung sagt nur, dass etwas da
ist. Wer die Anfrage lesen will, meldet sich in der Verwaltung an —
ein Discord-Kanal ist kein sicherer Ort für Kontaktdaten. */
async function benachrichtige(nummer, name, email, paket) {
const url = process.env.DISCORD_WEBHOOK_WEBDESIGN;
if (!url) return;
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: `🖥️ Neue Webdesign-Anfrage **${nummer}** (${paket}). Details in der Verwaltung.`,
}),
});
}
/* ---------------------------------------------------------------------
VERWALTUNG
--------------------------------------------------------------------- */
export async function anfragenListe(req, res) {
const p = await darf(req, "WD_ANFRAGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const status = text(req.query.status, 30);
const paket = text(req.query.paket, 30);
const archiviert = req.query.archiviert === "1" ? 1 : 0;
const suche = text(req.query.suche, 100);
const bedingungen = ["archiviert = ?"];
const werte = [archiviert];
if (status && ANFRAGE_STATUS.includes(status)) { bedingungen.push("status = ?"); werte.push(status); }
if (paket && PAKETE.includes(paket)) { bedingungen.push("paket = ?"); werte.push(paket); }
if (suche) {
bedingungen.push("(name LIKE ? OR email LIKE ? OR nummer LIKE ?)");
werte.push(`%${suche}%`, `%${suche}%`, `%${suche}%`);
}
const zeilen = db
.prepare(
`SELECT id, nummer, name, email, paket, status, budget, termin, sprache, erstellt_am, kunde_id
FROM wd_anfragen WHERE ${bedingungen.join(" AND ")}
ORDER BY erstellt_am DESC LIMIT 300`
)
.all(...werte);
/* Zähler je Status für die Filterleiste — in einer Abfrage statt sieben. */
const zaehler = {};
for (const z of db.prepare(`SELECT status, COUNT(*) AS n FROM wd_anfragen WHERE archiviert = 0 GROUP BY status`).all()) {
zaehler[z.status] = z.n;
}
return json(res, { ok: true, anfragen: zeilen, zaehler });
}
export async function anfrageDetail(req, res) {
const p = await darf(req, "WD_ANFRAGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`).get(text(req.params.id, 64));
if (!zeile) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
/* JSON-Felder aufgelöst zurückgeben, damit die Oberfläche nichts
nachparsen muss (und ein kaputter Eintrag dort keinen Absturz auslöst). */
const sicherParsen = (s, standard) => { try { return JSON.parse(s); } catch { return standard; } };
zeile.funktionen = sicherParsen(zeile.funktionen, []);
zeile.details = sicherParsen(zeile.details, {});
const notizen = db
.prepare(`SELECT * FROM wd_anfrage_notizen WHERE anfrage_id = ? ORDER BY erstellt_am ASC`)
.all(zeile.id);
return json(res, { ok: true, anfrage: zeile, notizen });
}
export async function anfrageStatus(req, res) {
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const id = text(req.params.id, 64);
const neu = text(req.body?.status, 30);
if (!ANFRAGE_STATUS.includes(neu)) {
return json(res, { ok: false, error: "Unbekannter Status." }, 400);
}
const alt = db.prepare(`SELECT status FROM wd_anfragen WHERE id = ?`).get(id);
if (!alt) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
db.prepare(`UPDATE wd_anfragen SET status = ?, aktualisiert_am = ? WHERE id = ?`).run(neu, jetzt(), id);
verlauf("anfrage", id, "status_geaendert", p.sitzung.actor, { von: alt.status, nach: neu });
return json(res, { ok: true, status: neu });
}
export async function anfrageArchivieren(req, res) {
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const id = text(req.params.id, 64);
/* Archivieren statt löschen (Masterplan S.13). Der Datensatz bleibt
vollständig erhalten und lässt sich jederzeit zurückholen. */
const zurueck = req.body?.zurueck === true;
const treffer = db
.prepare(`UPDATE wd_anfragen SET archiviert = ?, aktualisiert_am = ? WHERE id = ?`)
.run(zurueck ? 0 : 1, jetzt(), id);
if (!treffer.changes) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
verlauf("anfrage", id, zurueck ? "wiederhergestellt" : "archiviert", p.sitzung.actor, null);
return json(res, { ok: true, archiviert: !zurueck });
}
export async function anfrageNotiz(req, res) {
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const anfrageId = text(req.params.id, 64);
const inhalt = text(req.body?.text, 4000);
if (!inhalt) return json(res, { ok: false, error: "Die Notiz ist leer." }, 400);
const da = db.prepare(`SELECT 1 FROM wd_anfragen WHERE id = ?`).get(anfrageId);
if (!da) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
const id = neueId();
db.prepare(
`INSERT INTO wd_anfrage_notizen (id, anfrage_id, autor, text, intern, erstellt_am)
VALUES (?,?,?,?,?,?)`
).run(id, anfrageId, p.sitzung.actor, inhalt, 1, jetzt());
verlauf("anfrage", anfrageId, "notiz_ergaenzt", p.sitzung.actor, null);
return json(res, { ok: true, id });
}
+193
View File
@@ -0,0 +1,193 @@
/* =====================================================================
test-webdesign-anfragen.mjs — Selbsttest des Anfrage-Endpunkts.
Läuft gegen eine Wegwerf-Datenbank im Arbeitsspeicher. Prüft vor allem
die Fälle, die man beim Klicken NICHT sieht: Spamschutz, Pflichtfelder,
Längenbegrenzung, und dass die Verwaltungs-Endpunkte ohne Anmeldung
wirklich dichtmachen.
Aufruf (aus server-internal/): node test-webdesign-anfragen.mjs
===================================================================== */
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/* Eigene Datenbankdatei in einem Temporärordner: db.js liest DB_PATH beim
Import. Eine echte Datei statt ":memory:", weil better-sqlite3 mit WAL
im Arbeitsspeicher nicht arbeitet. */
process.env.DB_PATH = join(mkdtempSync(join(tmpdir(), "wd-test-")), "test.db");
const { initDb, db } = await import("./db.js");
initDb();
const { anfrageAbsenden, anfragenListe, anfrageDetail, anfrageStatus, anfrageArchivieren } =
await import("./routes/webdesign-anfragen.js");
let ok = 0, schlecht = 0;
function pruefe(name, bedingung, zusatz = "") {
if (bedingung) { ok++; console.log(" ok " + name); }
else { schlecht++; console.log(" FEHLT " + name + " " + zusatz); }
}
/* Minimaler Ersatz für Express-Request/Response. */
function antwortAttrappe() {
const a = { code: 200, daten: null };
a.status = (c) => { a.code = c; return a; };
a.json = (d) => { a.daten = d; return a; };
return a;
}
function anfrageAttrappe(koerper, { ip = "203.0.113.5", auth = null, params = {}, query = {} } = {}) {
return {
body: koerper,
params,
query,
headers: {
"cf-connecting-ip": ip,
...(auth ? { authorization: "Bearer " + auth } : {}),
},
socket: { remoteAddress: ip },
};
}
const gueltig = () => ({
name: "Testfirma Meier",
email: "[email protected]",
paket: "website",
ziel: "Wir wollen endlich online gefunden werden und Anfragen bekommen.",
datenschutz: true,
// Formular vor über einer Minute geöffnet -> Zeitprüfung ist zufrieden
geoeffnetAm: Date.now() - 60000,
});
console.log("\n=== Anfrageformular ===\n");
/* ---------- 1. Gültige Anfrage ---------- */
console.log("1. Gueltige Anfrage");
{
const res = antwortAttrappe();
await anfrageAbsenden(anfrageAttrappe(gueltig()), res);
pruefe("wird angenommen", res.daten?.ok === true, JSON.stringify(res.daten));
pruefe("Nummer im Format A-JJMM-0001", /^A-\d{4}-\d{4}$/.test(res.daten?.nummer || ""), res.daten?.nummer);
pruefe("Zusammenfassung kommt zurueck", !!res.daten?.zusammenfassung?.eingegangen);
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res.daten.nummer);
pruefe("liegt in der Datenbank", !!zeile);
pruefe("Status startet auf 'neu'", zeile?.status === "neu");
pruefe("E-Mail wird kleingeschrieben gespeichert", zeile?.email === "[email protected]");
pruefe("IP wird nur als Hash gespeichert",
!!zeile?.ip_hash && !String(zeile.ip_hash).includes("203.0.113"),
"-> die echte IP darf nirgends stehen");
pruefe("Aenderungsverlauf wurde geschrieben",
!!db.prepare(`SELECT 1 FROM wd_verlauf WHERE bereich='anfrage' AND objekt_id=?`).get(zeile.id));
}
/* ---------- 2. Pflichtfelder ---------- */
console.log("\n2. Pflichtfelder");
{
const faelle = [
["ohne Name", { name: "" }, "name"],
["ohne E-Mail", { email: "" }, "email"],
["unsinnige E-Mail", { email: "keine-email" }, "email"],
["ohne Paket", { paket: "" }, "paket"],
["unbekanntes Paket", { paket: "raumschiff" }, "paket"],
["ohne Ziel", { ziel: "" }, "ziel"],
["ohne Datenschutz-Zustimmung", { datenschutz: false }, "datenschutz"],
];
for (const [name, ueberschreibung, feld] of faelle) {
const res = antwortAttrappe();
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), ...ueberschreibung }, { ip: "203.0.113.6" }), res);
pruefe(name + " -> abgelehnt", res.code === 400 && !!res.daten?.felder?.[feld],
JSON.stringify(res.daten?.felder));
}
const res = antwortAttrappe();
await anfrageAbsenden(anfrageAttrappe({ name: "", email: "", paket: "", ziel: "", datenschutz: false }, { ip: "203.0.113.7" }), res);
pruefe("alle Fehler kommen auf einmal", Object.keys(res.daten?.felder || {}).length >= 5,
"-> sonst schickt man das Formular fuenfmal ab");
}
/* ---------- 3. Spamschutz ---------- */
console.log("\n3. Spamschutz");
{
const vorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n;
const res1 = antwortAttrappe();
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), website: "http://spam.example" }, { ip: "203.0.113.8" }), res1);
pruefe("Honigtopf: meldet Erfolg", res1.daten?.ok === true);
pruefe("Honigtopf: speichert aber NICHTS",
db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n === vorher,
"-> absichtlich hoeflich, damit das Skript nicht umbaut");
const res2 = antwortAttrappe();
await anfrageAbsenden(anfrageAttrappe({ ...gueltig(), geoeffnetAm: Date.now() - 500 }, { ip: "203.0.113.9" }), res2);
pruefe("zu schnell ausgefuellt: speichert nichts",
db.prepare(`SELECT COUNT(*) AS n FROM wd_anfragen`).get().n === vorher);
// Mengenbegrenzung: dieselbe IP mehrfach
let letzte = null;
for (let i = 0; i < 7; i++) {
letzte = antwortAttrappe();
await anfrageAbsenden(anfrageAttrappe(gueltig(), { ip: "203.0.113.99" }), letzte);
}
pruefe("nach fuenf Anfragen pro Stunde wird gebremst", letzte.code === 429, "war " + letzte.code);
const andere = antwortAttrappe();
await anfrageAbsenden(anfrageAttrappe(gueltig(), { ip: "198.51.100.1" }), andere);
pruefe("andere Absender sind NICHT mitgesperrt", andere.daten?.ok === true);
}
/* ---------- 4. Laengen und Sonderzeichen ---------- */
console.log("\n4. Laengen und Sonderzeichen");
{
const res = antwortAttrappe();
await anfrageAbsenden(anfrageAttrappe({
...gueltig(),
name: "N".repeat(5000),
ziel: "Z".repeat(50000),
funktionen: Array.from({ length: 500 }, (_, i) => "funktion-" + i),
details: Object.fromEntries(Array.from({ length: 500 }, (_, i) => ["k" + i, "v".repeat(9000)])),
}, { ip: "198.51.100.2" }), res);
pruefe("ueberlange Eingaben werden angenommen und gekuerzt", res.daten?.ok === true);
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res.daten.nummer);
pruefe("Name auf 120 Zeichen begrenzt", zeile.name.length === 120, String(zeile.name.length));
pruefe("Ziel auf 4000 Zeichen begrenzt", zeile.ziel.length === 4000, String(zeile.ziel.length));
pruefe("Funktionsliste auf 40 Eintraege begrenzt", JSON.parse(zeile.funktionen).length === 40);
pruefe("Detailfelder auf 40 begrenzt", Object.keys(JSON.parse(zeile.details)).length === 40);
const res2 = antwortAttrappe();
await anfrageAbsenden(anfrageAttrappe({
...gueltig(),
name: "Bösewicht <script>alert(1)</script>",
ziel: "Zeile1\nZeile2\tmit Tabulator",
}, { ip: "198.51.100.3" }), res2);
const z2 = db.prepare(`SELECT * FROM wd_anfragen WHERE nummer = ?`).get(res2.daten.nummer);
pruefe("HTML wird als Text gespeichert, nicht ausgefuehrt", z2.name.includes("<script>"),
"-> Entschaerfung passiert bei der Anzeige, nicht beim Speichern");
pruefe("Zeilenumbruch und Tabulator bleiben erhalten",
z2.ziel.includes("\n") && z2.ziel.includes("\t"));
}
/* ---------- 5. Verwaltung ist ohne Anmeldung dicht ---------- */
console.log("\n5. Verwaltung ohne Anmeldung");
{
for (const [name, fn, req] of [
["Liste", anfragenListe, anfrageAttrappe(null, { query: {} })],
["Detail", anfrageDetail, anfrageAttrappe(null, { params: { id: "x" } })],
["Status aendern", anfrageStatus, anfrageAttrappe({ status: "angebot" }, { params: { id: "x" } })],
["Archivieren", anfrageArchivieren, anfrageAttrappe({}, { params: { id: "x" } })],
]) {
const res = antwortAttrappe();
await fn(req, res);
pruefe(name + " -> 401 ohne Anmeldung", res.code === 401, "war " + res.code);
pruefe(name + " -> keine Daten im Fehlerfall", !res.daten?.anfragen && !res.daten?.anfrage);
}
const res = antwortAttrappe();
await anfragenListe(anfrageAttrappe(null, { auth: "voellig-erfundenes-token" }), res);
pruefe("erfundenes Token wird abgelehnt", res.code === 401);
}
console.log(`\n=== ${ok} bestanden, ${schlecht} gescheitert ===\n`);
process.exit(schlecht === 0 ? 0 : 1);
+237
View File
@@ -0,0 +1,237 @@
/* =====================================================================
test-webdesign-paypal.mjs — Selbsttest der PayPal-Anbindung.
Läuft OHNE echte PayPal-Zugangsdaten: ein nachgebauter PayPal-Server
antwortet auf 127.0.0.1 statt api-m.paypal.com. Dadurch lässt sich
alles prüfen, was schiefgehen kann, ohne dass Geld bewegt wird und ohne
dass Filipe vorher ein Konto einrichten muss.
Aufruf: node test-webdesign-paypal.mjs
===================================================================== */
/* Bewusst OHNE Fremdpakete (kein express): server-internal braucht
better-sqlite3, das auf Windows einen C++-Compiler zum Uebersetzen
verlangt. Ein Test, der sich nur mit vollstaendig eingerichteter
Entwicklungsumgebung ausfuehren laesst, wird nicht ausgefuehrt --
und damit ist er wertlos. node:http reicht hier voellig. */
import { createServer } from "node:http";
let bestanden = 0, gescheitert = 0;
function pruefe(name, bedingung, zusatz = "") {
if (bedingung) { bestanden++; console.log(" ok " + name); }
else { gescheitert++; console.log(" FEHLT " + name + " " + zusatz); }
}
console.log("\n=== PayPal-Anbindung Webdesign ===\n");
/* ---------- 1. Rechnen mit Geld ---------- */
console.log("1. Betraege");
const { centZuPaypal, paypalZuCent } = await import("./lib/webdesign-paypal.js");
{
const faelle = [
[30000, "300.00"], [50000, "500.00"], [100000, "1000.00"],
[4900, "49.00"], [1, "0.01"], [99, "0.99"], [100, "1.00"],
[123456, "1234.56"], [0, "0.00"],
];
let alle = true;
for (const [cent, erwartet] of faelle) {
if (centZuPaypal(cent) !== erwartet) { alle = false; console.log(` ${cent} -> ${centZuPaypal(cent)}, erwartet ${erwartet}`); }
}
pruefe("Cent -> PayPal-Format", alle);
let rueck = true;
for (const [cent, txt] of faelle) if (paypalZuCent(txt) !== cent) { rueck = false; console.log(` ${txt} -> ${paypalZuCent(txt)}, erwartet ${cent}`); }
pruefe("PayPal-Format -> Cent", rueck);
/* Der eigentliche Grund fuer die Ganzzahl-Rechnung: mit Gleitkomma
entstehen genau hier falsche Betraege. */
let rundung = true;
for (let c = 0; c <= 200000; c += 7) {
if (paypalZuCent(centZuPaypal(c)) !== c) { rundung = false; console.log(" Rundungsfehler bei " + c); break; }
}
pruefe("Hin- und Rueckrechnung ueber 28.000 Betraege verlustfrei", rundung);
let wirft = false;
try { centZuPaypal(30.5); } catch { wirft = true; }
pruefe("Kommazahl wird abgelehnt", wirft);
wirft = false;
try { centZuPaypal(-100); } catch { wirft = true; }
pruefe("negativer Betrag wird abgelehnt", wirft);
pruefe("unsinniges PayPal-Format ergibt null", paypalZuCent("abc") === null && paypalZuCent("1.234") === null);
}
/* ---------- 2. Fail closed ---------- */
console.log("\n2. Ohne Zugangsdaten wird gesperrt");
{
const gemerkt = { ...process.env };
delete process.env.PAYPAL_CLIENT_ID;
delete process.env.PAYPAL_CLIENT_SECRET;
const m = await import("./lib/webdesign-paypal.js");
pruefe("istEingerichtet meldet false", m.istEingerichtet() === false);
pruefe("fehlende Werte werden benannt",
m.fehlendeEinstellungen().includes("PAYPAL_CLIENT_ID"));
let fehler = null;
try { await m.bestellungAnlegen({ zahlungId: "x", nummer: "Z-1", betragCent: 100 }); }
catch (e) { fehler = e; }
pruefe("Bestellung anlegen wirft statt still zu scheitern", fehler?.code === "PAYPAL_NICHT_EINGERICHTET");
pruefe("Fehlermeldung nennt die fehlende Einstellung", /PAYPAL_CLIENT_ID/.test(fehler?.message || ""));
let fehler2 = null;
try { await m.bestellungEinziehen("ORDER-1"); } catch (e) { fehler2 = e; }
pruefe("Einzug wirft ebenfalls", fehler2?.code === "PAYPAL_NICHT_EINGERICHTET");
Object.assign(process.env, gemerkt);
}
/* ---------- 3. Gegen einen nachgebauten PayPal-Server ---------- */
console.log("\n3. Vollstaendiger Ablauf gegen einen nachgebauten PayPal-Server");
{
const gesehen = { requestIds: [], intents: [], betraege: [] };
/* Nachgebauter PayPal-Server. Antwortet nur auf die Pfade, die wir
tatsaechlich aufrufen -- alles andere gibt 404, damit ein Tippfehler
im Pfad sofort auffaellt statt still ins Leere zu laufen. */
const server = createServer((req, res) => {
let koerper = "";
req.on("data", (stueck) => { koerper += stueck; });
req.on("end", () => {
const antworte = (code, daten) => {
res.writeHead(code, { "Content-Type": "application/json" });
res.end(JSON.stringify(daten));
};
/* Nur JSON auspacken. Der Token-Aufruf schickt bewusst
form-urlencoded ("grant_type=client_credentials") -- ein
bedingungsloses JSON.parse ist hier genau der Fehler, den der
echte PayPal-Server auch machen wuerde. */
const istJson = (req.headers["content-type"] || "").includes("json");
let body = {};
if (istJson && koerper) {
try { body = JSON.parse(koerper); }
catch { return antworte(400, { error: "kein gueltiges JSON" }); }
}
if (req.url === "/v1/oauth2/token") {
const auth = req.headers.authorization || "";
if (!auth.startsWith("Basic ")) return antworte(401, { error: "kein Basic" });
return antworte(200, { access_token: "test-token", expires_in: 3600 });
}
if (req.url === "/v2/checkout/orders" && req.method === "POST") {
gesehen.requestIds.push(req.headers["paypal-request-id"]);
gesehen.intents.push(body.intent);
gesehen.betraege.push(body.purchase_units[0].amount.value);
return antworte(201, {
id: "ORDER-TEST-1",
status: "PAYER_ACTION_REQUIRED",
links: [
{ rel: "self", href: "x" },
{ rel: "payer-action", href: "https://sandbox.paypal.com/checkoutnow?token=ORDER-TEST-1" },
],
});
}
if (/^\/v2\/checkout\/orders\/[^/]+\/capture$/.test(req.url)) {
return antworte(200, {
id: "ORDER-TEST-1",
status: "COMPLETED",
payer: { email_address: "[email protected]" },
purchase_units: [{
payments: {
captures: [{
id: "CAPTURE-1",
status: "COMPLETED",
amount: { currency_code: "EUR", value: "300.00" },
seller_receivable_breakdown: {
gross_amount: { value: "300.00" },
paypal_fee: { value: "10.79" },
net_amount: { value: "289.21" },
},
}],
},
}],
});
}
if (req.url === "/v1/notifications/verify-webhook-signature") {
const echt = body.transmission_sig === "echte-signatur";
return antworte(200, { verification_status: echt ? "SUCCESS" : "FAILURE" });
}
antworte(404, { error: "unbekannter Pfad: " + req.url });
});
});
server.listen(0, "127.0.0.1");
await new Promise((r) => server.once("listening", r));
const port = server.address().port;
/* Den nachgebauten Server unterschieben: fetch so umbiegen, dass
Aufrufe an api-m.sandbox.paypal.com lokal landen. Sauberer als eine
Sonderbehandlung im Produktivcode -- der bleibt dadurch unveraendert. */
const echtesFetch = globalThis.fetch;
globalThis.fetch = (url, opt) => {
const u = String(url).replace(/^https:\/\/api-m\.(sandbox\.)?paypal\.com/, `http://127.0.0.1:${port}`);
return echtesFetch(u, opt);
};
process.env.PAYPAL_CLIENT_ID = "test-id";
process.env.PAYPAL_CLIENT_SECRET = "test-secret";
process.env.PAYPAL_WD_WEBHOOK_ID = "WH-TEST";
delete process.env.PAYPAL_ENV; // Testmodus
const m = await import("./lib/webdesign-paypal.js");
m.tokenVergessen();
pruefe("Testmodus ist die Voreinstellung (nicht live)", m.istLive() === false);
pruefe("mit Zugangsdaten gilt es als eingerichtet", m.istEingerichtet() === true);
const bestellung = await m.bestellungAnlegen({
zahlungId: "zahlung-abc",
nummer: "Z-2608-0001",
betragCent: 30000,
zweck: "30 % Anzahlung Projekt P-2608-0001",
rueckkehrUrl: "https://dogfather-universe.com/webdesign/portal.html?zahlung=ok",
abbruchUrl: "https://dogfather-universe.com/webdesign/portal.html?zahlung=ab",
});
pruefe("Bestellnummer kommt zurueck", bestellung.orderId === "ORDER-TEST-1");
pruefe("Freigabe-Link kommt zurueck", /checkoutnow/.test(bestellung.freigabeUrl || ""));
pruefe("Betrag korrekt uebermittelt (300.00)", gesehen.betraege[0] === "300.00");
pruefe("intent ist CAPTURE, nicht AUTHORIZE", gesehen.intents[0] === "CAPTURE",
"-> nur so landet das Geld sofort auf dem Konto");
pruefe("Doppel-Schutz-Kennung wird mitgeschickt",
(gesehen.requestIds[0] || "").includes("zahlung-abc"));
const einzug = await m.bestellungEinziehen("ORDER-TEST-1");
pruefe("Einzug meldet COMPLETED", einzug.status === "COMPLETED");
pruefe("Einzugs-Kennung wird gelesen", einzug.captureId === "CAPTURE-1");
pruefe("Bruttobetrag 300,00 EUR", einzug.bruttoCent === 30000);
pruefe("Gebuehr getrennt erfasst (10,79 EUR)", einzug.gebuehrCent === 1079);
pruefe("Nettobetrag getrennt erfasst (289,21 EUR)", einzug.nettoCent === 28921);
pruefe("Brutto = Netto + Gebuehr geht auf", einzug.bruttoCent === einzug.nettoCent + einzug.gebuehrCent);
const echt = await m.webhookEchtheitPruefen(
{ "paypal-transmission-sig": "echte-signatur", "paypal-auth-algo": "SHA256withRSA" },
JSON.stringify({ id: "EV-1", event_type: "PAYMENT.CAPTURE.COMPLETED" })
);
pruefe("echter Webhook wird akzeptiert", echt === true);
const gefaelscht = await m.webhookEchtheitPruefen(
{ "paypal-transmission-sig": "gefaelscht" },
JSON.stringify({ id: "EV-2", event_type: "PAYMENT.CAPTURE.COMPLETED" })
);
pruefe("gefaelschter Webhook wird abgelehnt", gefaelscht === false,
"-> sonst koennte jeder eine Zahlung vortaeuschen");
pruefe("bekannte Ereignisse sind zugeordnet",
m.WEBHOOK_EREIGNISSE["PAYMENT.CAPTURE.COMPLETED"] === "zahlung_eingegangen");
pruefe("unbekanntes Ereignis wird nicht zugeordnet",
m.WEBHOOK_EREIGNISSE["IRGENDWAS.ANDERES"] === undefined);
globalThis.fetch = echtesFetch;
server.close();
}
console.log(`\n=== ${bestanden} bestanden, ${gescheitert} gescheitert ===\n`);
process.exit(gescheitert === 0 ? 0 : 1);