BEFUND ZUERST, DENN ER WAR ANDERS ALS ERWARTET
Es gab sehr wohl eine Benachrichtigung -- ueber einen Discord-Webhook,
sauber gebaut, bewusst ohne Kundendaten im Text. Sie begann aber mit:
const url = process.env.DISCORD_WEBHOOK_WEBDESIGN;
if (!url) return;
Und diese Variable ist in .env.example nirgends aufgefuehrt. Eine
Variable, die niemand kennt, wird nicht gesetzt; dann kehrte die
Funktion wortlos zurueck. Kein Fehler, keine Protokollzeile. Es gab
eine Benachrichtigung, die nur im Quelltext existierte -- und niemand
konnte das bemerken, weil "funktioniert" und "kaputt" identisch
aussehen, solange nichts passiert.
WAS JETZT DA IST
Push aufs Handy, ohne Fremdpaket. Der interne Dienst liegt unter
/home/dogiintern mit Rechten 700 -- dort ist kein npm erreichbar, jede
Abhaengigkeit haette dauerhafte Handarbeit bedeutet. Node bringt P-256,
HKDF und AES-128-GCM selbst mit.
Die Schluessel erzeugt der Server beim ersten Start selbst und legt den
privaten Teil verschluesselt in der Datenbank ab (derselbe Weg wie die
PayPal-Zugangsdaten). Damit gibt es keinen Einrichtungsschritt, der
vergessen werden kann.
GEPRUEFT
Gegen RFC 8291 statt gegen ein Bauchgefuehl: alle fuenf Zwischenwerte
aus Anhang A stimmen (ECDH-Geheimnis, PRK_key, IKM, CEK, NONCE), und
die fertige Nachricht ist Byte fuer Byte die aus Abschnitt 5 der Norm.
Bei Kryptographie erzeugt ein Ableitungsfehler keinen Absturz, sondern
Bytes, die genauso zufaellig aussehen wie richtige.
Dazu ein Kettentest mit einem echten Empfaenger, der entschluesselt:
Kopfzeilen, Inhalt, keine Kundendaten in der Meldung, 410 loescht das
Geraet, 500 loescht es NICHT (sonst kostet eine einzelne Stoerung die
Anmeldung). 50 Pruefungen, alle gruen.
DREI ENTSCHEIDUNGEN
1. In der Meldung stehen nur Nummer und Paket. Sie erscheint auf einem
Sperrbildschirm, den auch jemand sieht, der zufaellig danebensteht.
2. Ist der Schluessel unlesbar, wird KEIN neuer erzeugt. Das waere der
bequeme Weg und der schlimmste: Ein neuer oeffentlicher Schluessel
macht schlagartig jede Anmeldung wertlos, ohne dass jemand erfaehrt,
warum nichts mehr ankommt.
3. Die Verwaltung zeigt den Zustand an und hat einen Testknopf. Genau
das fehlte dem Discord-Weg. Bei verweigerter Erlaubnis erscheint
kein Knopf, der nichts bewirkt, sondern der Weg ueber die
Browsereinstellungen.
Das stille "if (!url) return;" ist ersetzt: Jeder Weg wird einzeln
protokolliert -- auch und gerade, wenn er uebersprungen wird.
Co-Authored-By: Claude Opus 5 <[email protected]>
374 lines
16 KiB
JavaScript
374 lines
16 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";
|
|
import { meldeAllen } from "../lib/webdesign-push.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 },
|
|
});
|
|
}
|
|
|
|
/* Benachrichtigung über alle eingerichteten Wege.
|
|
Bewusst OHNE Kundendaten im Text: die Meldung sagt nur, dass etwas da
|
|
ist. Wer die Anfrage lesen will, meldet sich in der Verwaltung an —
|
|
weder ein Discord-Kanal noch ein Sperrbildschirm ist ein sicherer Ort
|
|
für Kontaktdaten.
|
|
|
|
⚠️ HIER STAND EINMAL EIN STILLES `if (!url) return;` (behoben
|
|
26.08.2026)
|
|
|
|
Der Discord-Weg las eine Umgebungsvariable, die in .env.example gar
|
|
nicht aufgeführt war — also mit hoher Wahrscheinlichkeit nie gesetzt
|
|
wurde. Fehlte sie, kehrte die Funktion wortlos zurück: kein Fehler,
|
|
keine Protokollzeile, nichts. Es gab damit eine Benachrichtigung, die
|
|
nur im Quelltext existierte, und niemand konnte das bemerken.
|
|
|
|
Das ist die unangenehmste Sorte Fehler: nicht einer, der etwas
|
|
kaputtmacht, sondern einer, der NICHTS tut und dabei aussieht wie
|
|
Erfolg. Deshalb wird jetzt jeder Weg einzeln protokolliert — auch und
|
|
gerade, wenn er übersprungen wird. */
|
|
async function benachrichtige(nummer, name, email, paket) {
|
|
const wege = [];
|
|
|
|
/* Weg 1: Push aufs Handy. */
|
|
try {
|
|
const r = await meldeAllen({
|
|
titel: "Neue Anfrage",
|
|
text: `${nummer} (${paket})`,
|
|
url: "/webdesign/verwaltung.html?bereich=anfragen",
|
|
art: "anfrage",
|
|
});
|
|
wege.push(r.gesendet ? `push: ${r.gesendet} Gerät(e)` : `push: nichts (${r.grund || "keins erreichbar"})`);
|
|
} catch (e) {
|
|
wege.push(`push: FEHLER ${e?.message || e}`);
|
|
}
|
|
|
|
/* Weg 2: Discord, falls eingerichtet. */
|
|
const url = process.env.DISCORD_WEBHOOK_WEBDESIGN;
|
|
if (!url) {
|
|
wege.push("discord: nicht eingerichtet");
|
|
} else {
|
|
try {
|
|
const a = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
content: `🖥️ Neue Webdesign-Anfrage **${nummer}** (${paket}). Details in der Verwaltung.`,
|
|
}),
|
|
});
|
|
wege.push(a.ok ? "discord: ok" : `discord: HTTP ${a.status}`);
|
|
} catch (e) {
|
|
wege.push(`discord: FEHLER ${e?.message || e}`);
|
|
}
|
|
}
|
|
|
|
console.log(`[webdesign] Anfrage ${nummer} — Benachrichtigung: ${wege.join(", ")}`);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
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 });
|
|
}
|