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,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 });
|
||||
}
|
||||
Reference in New Issue
Block a user