Files
dogfather-universe/server-internal/routes/webdesign-anfragen.js
T
DogFatherGitandClaude Opus 5 74091590b8 Verwaltung: nur noch EINMAL den Code eingeben
Rueckmeldung 22.08.2026: "ich hab auf der verwaltungs seite 2 mal dass ich
den zugangscode eingeben muss und vanvan auch, ich will es nur einmal
eingeben muessen." Berechtigt.

Die Zugangswand hat serverseitig laengst geprueft, WER da ist -- Dogfather
oder VanVan, laut Masterplan S.4 beide gleichberechtigte
Volladministratoren. Ein zweites Passwort danach bringt keinen
zusaetzlichen Schutz, es kostet nur jedes Mal Zeit.

Warum es nicht einfach "Cookie mitschicken" ist: Die Verwaltungsdaten
liegen hinter postfach.dogfather-universe.com, einem ANDEREN Rechnernamen.
Das Sitzungs-Cookie gilt dort nicht -- so sind Cookies gebaut, und das ist
gut so.

Loesung: Die Zugangswand stellt unter /webdesign/api-ausweis einen
kurzlebigen, signierten Ausweis aus, den die interne API anerkennt.
  - 15 Minuten gueltig, die Seite holt bei Bedarf still einen neuen
  - traegt bereich="wd-admin", damit ein Sitzungs-Token der Zugangswand
    hier NICHT durchgeht und umgekehrt
  - nur mit gueltiger Zugangssitzung zu bekommen
  - gilt AUSSCHLIESSLICH fuer die /webdesign-Endpunkte. Postfach,
    Bewerbungen, Supporter und Teamverwaltung bleiben unberuehrt

Fehlt WEBDESIGN_API_SECRET auf einer der beiden Seiten, gilt kein Ausweis
und die Verwaltung fragt wie bisher nach dem Team-Code. Ein fehlender
Konfigurationswert darf niemals eine Tuer oeffnen -- nur eine schliessen.
Genau das prueft der letzte Testfall.

Bei einem 401 wird EINMAL still ein neuer Ausweis geholt und die Anfrage
wiederholt, statt jemanden mitten im Arbeiten rauszuwerfen.

10/10 Tests, darunter: gefaelschte Signatur, fremdes Geheimnis,
abgelaufen, falscher Bereich, erfundene Rolle.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 21:36:11 +02:00

332 lines
14 KiB
JavaScript

/* =====================================================================
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 { ausweisPruefen } from "../lib/webdesign-ausweis.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) {
/* Zwei Wege fuehren hinein.
1. Der Ausweis der Zugangswand. Wer dort mit dem persoenlichen Code
durchgekommen ist, ist Dogfather oder VanVan -- laut Masterplan S.4
beide gleichberechtigte Volladministratoren. Ein zweites Passwort
danach bringt keinen zusaetzlichen Schutz und kostet nur Zeit
(Rueckmeldung 22.08.2026: "ich will es nur einmal eingeben muessen").
2. Eine normale Team-Anmeldung mit dem passenden Einzelrecht -- fuer
den Fall, dass spaeter jemand aus dem Team nur Anfragen ansehen,
aber keine Kundendaten bearbeiten koennen soll.
Der Ausweis gilt AUSSCHLIESSLICH hier. Postfach, Bewerbungen,
Supporter und Teamverwaltung bleiben davon unberuehrt. */
const ausweis = ausweisPruefen(req);
if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } };
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 });
}