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