196 lines
8.1 KiB
JavaScript
196 lines
8.1 KiB
JavaScript
/* =====================================================================
|
|
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;
|
|
}
|
|
|
|
/* Rollendes Zeitfenster — löscht den Zähler, wenn seit der letzten
|
|
Zählung genug Zeit vergangen ist.
|
|
|
|
Warum das nötig ist: versuchZaehlen() zählt nur hoch. Bei der
|
|
Anmeldung ist das richtig, dort setzt ein erfolgreicher Login zurück.
|
|
Bei einer Mengenbegrenzung für erlaubte Handlungen gibt es aber
|
|
keinen "Erfolg", der zurücksetzt — der Zähler bliebe für immer auf
|
|
seinem Höchststand, und ab der zwanzigsten Nachricht würde JEDE
|
|
weitere sofort wieder eine Sperre auslösen. Aus einer Bremse gegen
|
|
Endlosschleifen wäre damit ein dauerhaftes Schreibverbot geworden.
|
|
|
|
Vor jeder Prüfung aufrufen, mit demselben Fenster wie die Sperre. */
|
|
export function fensterZuruecksetzen(schluessel, fensterMinuten) {
|
|
const grenze = new Date(Date.now() - fensterMinuten * 60000).toISOString();
|
|
db.prepare(`DELETE FROM wd_login_versuche WHERE schluessel = ? AND zuletzt < ?`)
|
|
.run(schluessel, grenze);
|
|
}
|
|
|
|
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}`;
|
|
}
|