Files
dogfather-universe/server-internal/routes/webdesign-anfragen.js
T
DogFatherGitandClaude Opus 5 f435d89a16 Benachrichtigung bei neuer Anfrage: Push aufs Handy
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]>
2026-08-26 10:50:33 +02:00

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 });
}