/* ===================================================================== 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}`; }