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]>
332 lines
14 KiB
JavaScript
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 });
|
|
}
|