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:
@@ -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=
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user