/* ===================================================================== UNTERSTÜTZEN — die Wege (24.09.2026) Filipe: „jeder soll diese kachel sehen aber nur dogfather soll sie verändern können oder vieles mehr sehen. alle anderen, rechte und linke hand, modis und community sollen supporten können." Warum die Wege in der Datenbank stehen und warum die Zählung keine Personenspalte hat, steht im Kopf von unterstuetzung-tabellen.js. Hier sind die Wege — und die vier Stellen, an denen etwas schiefgehen kann: 1. EINE ADRESSE IST EIN VERSPRECHEN. Auf dieser Seite steht ein Zahlungslink. Wer ihn ändern darf, kann Geld umleiten — deshalb hängt das Schreiben an `istDogFather` und nicht an `istLeitung`. Die rechte Hand führt dieses Team mit, und sie soll trotzdem nicht an diesen Link können. Das ist kein Misstrauen, sondern dieselbe Regel wie bei der Rechtetafel: Genau eine Tür bleibt bei ihm. 2. UND SIE WIRD GEPRÜFT, BEVOR SIE IN DER DATENBANK STEHT. Erlaubt sind `https://` und eine Seite dieses Hauses. `javascript:` wäre ein Skript, das jeder Besucher ausführt, `http://` ein Zahlungsweg ohne Verschlüsselung, `data:` eine ganze Seite im Link. Geprüft wird beim SCHREIBEN: Was erst beim Anzeigen auffällt, steht vorher schon in der Datenbank. 3. DER LESEWEG IST FÜR ALLE UND ZEIGT NICHT ALLES. Unsichtbare Wege und die Zählung gehen nur an DogFather. Beides entscheidet EINE Stelle (`darfAlles`), damit nicht zwei Antworten auf dieselbe Frage entstehen. 4. EIN STRICH IST KEIN PROTOKOLL. Gezählt wird je Weg und Tag, ohne Person. Dass derselbe Mensch nicht zehnmal zählt, merkt sich der Server NUR im Arbeitsspeicher — nach einem Neustart ist es vergessen, und auf der Platte steht es nie. ===================================================================== */ import express from "express"; import { db, protokolliere, echteIp, istDogFather, heuteLokal, } from "./workspace.js"; import { UNTERSTUETZUNG_ARTEN, ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js"; export const unterstuetzungRouter = express.Router(); const TITEL_MAX = 60; const TEXT_MAX = 400; const KNOPF_MAX = 40; const ZIEL_MAX = 500; const WEGE_MAX = 12; /** Wer ändern darf UND wer mehr sieht — dieselbe Frage, eine Antwort. * * Zwei getrennte Prüfungen wären zwei Gelegenheiten, eine davon zu * lockern. Und die beiden gehören ohnehin zusammen: Wer einen Weg * bearbeitet, muss ihn auch sehen, solange er noch unsichtbar ist. */ const darfAlles = (person) => istDogFather(person); /* Gleiche Herkunft — dasselbe Muster wie überall im Haus. Ohne Origin-Kopf (Server zu Server, Prüfungen) geht es durch; mit einem fremden nicht. */ function gleicheHerkunft(req, res, next) { const herkunft = req.get("origin"); if (!herkunft) return next(); try { if (new URL(herkunft).host === req.get("host")) return next(); } catch { /* unlesbar -> abweisen */ } return res.status(403).json({ fehler: "fremde_herkunft" }); } /** Nur DogFather. 404 und nicht 403: Wer nicht ändern darf, muss nicht * erfahren, dass es die Möglichkeit gibt — dasselbe Vorgehen wie bei * der Aufbewahrung und den Kanälen. */ function nurDogFather(req, res, next) { if (!darfAlles(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); next(); } /* ===================================================================== DIE ADRESSE ===================================================================== Drei Formen sind erlaubt, alles andere nicht: "" kein Ziel (ein Weg ohne Knopf) https://… nach draußen, verschlüsselt seite.html[?frage] eine Seite dieses Hauses AUSDRÜCKLICH NICHT: `javascript:` (ein Skript, das jeder Besucher ausführt), `data:` (eine ganze Seite im Link), `http://` (ein Zahlungsweg im Klartext) und alles mit `//` am Anfang (das übernimmt das Schema der Seite und sieht aus wie ein eigener Pfad). Geprüft wird mit `new URL()` und nicht mit einem Muster: Ein selbstgeschriebener Ausdruck übersieht `HtTpS:` oder ein eingeschobenes Leerzeichen, der Parser des Browsers nicht. */ const HAUS_SEITE = /^[a-z0-9][a-z0-9-]{0,40}\.html(\?[A-Za-z0-9=&_%.-]{0,80})?$/; function zielPruefen(roh) { const z = String(roh ?? "").trim(); if (!z) return { ok: true, wert: "" }; if (z.length > ZIEL_MAX) return { ok: false, grund: "Die Adresse ist zu lang." }; if (HAUS_SEITE.test(z)) return { ok: true, wert: z }; let u; try { u = new URL(z); } catch { return { ok: false, grund: "Das ist keine gültige Adresse. Sie muss mit https:// anfangen." }; } if (u.protocol !== "https:") { return { ok: false, grund: "Nur https:// – ein Zahlungsweg ohne Verschlüsselung kommt hier nicht hin." }; } return { ok: true, wert: u.href }; } const text = (roh, max) => String(roh ?? "").replace(/\s+/g, " ").trim().slice(0, max); /* ===================================================================== LESEN — für alle ===================================================================== */ unterstuetzungRouter.get("/workspace/api/unterstuetzung", (req, res) => { try { const alles = darfAlles(req.person); const d = db(); const wege = d.prepare( `SELECT id, schluessel, art, titel, text, knopf, ziel, sichtbar, platz FROM unterstuetzung_wege ${alles ? "" : "WHERE sichtbar = 1"} ORDER BY platz, id`).all(); /* WAS DOGFATHER MEHR SIEHT (und sonst niemand): die unsichtbaren Wege — und wie oft geöffnet wurde. DREI ZEITRÄUME, weil eine einzelne Zahl nichts sagt: „48" kann „läuft gut" oder „lief mal" heißen. Mit 7 und 30 Tagen daneben sieht man den Unterschied. */ let zahlen = null; if (alles) { const tagVor = (n) => { const t = new Date(); t.setHours(12, 0, 0, 0); t.setDate(t.getDate() - n); return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, "0")}` + `-${String(t.getDate()).padStart(2, "0")}`; }; const roh = d.prepare( `SELECT weg_id, SUM(anzahl) AS gesamt, SUM(CASE WHEN tag >= ? THEN anzahl ELSE 0 END) AS tage30, SUM(CASE WHEN tag >= ? THEN anzahl ELSE 0 END) AS tage7 FROM unterstuetzung_striche GROUP BY weg_id`).all(tagVor(29), tagVor(6)); zahlen = Object.fromEntries(roh.map((z) => [z.weg_id, { gesamt: z.gesamt || 0, tage30: z.tage30 || 0, tage7: z.tage7 || 0, }])); } res.json({ wege: wege.map((w) => ({ ...w, sichtbar: !!w.sichtbar })), arten: UNTERSTUETZUNG_ARTEN, darf_aendern: alles, zahlen, }); } catch (fehler) { console.error("[unterstuetzung] Lesen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ===================================================================== EINEN STRICH MACHEN — für alle ===================================================================== Wer einen Weg öffnet, macht einen Strich. Kein Name, keine Nummer, keine Kennung: nur Weg und Tag. DIE SPERRE GEGEN MEHRFACHZÄHLUNG LIEGT IM ARBEITSSPEICHER. Sie müsste sonst eine Kennung je Mensch auf die Platte schreiben — und damit wäre die Zusage dieser Seite gebrochen, für eine Zahl, die nur ungefähr stimmen muss. Nach einem Neustart zählt jeder wieder einmal; das ist der Preis, und er ist richtig herum bezahlt. */ const heuteSchon = new Map(); // "sitzung|weg" -> Tag unterstuetzungRouter.post("/workspace/api/unterstuetzung/:id(\\d+)/strich", gleicheHerkunft, (req, res) => { try { const id = Number(req.params.id); const weg = db().prepare( "SELECT id, sichtbar FROM unterstuetzung_wege WHERE id = ?").get(id); /* Ein unsichtbarer Weg wird nicht gezählt. Sonst stünde eine Zahl an einem Weg, den außer DogFather niemand sehen kann — und die käme dann von ihm selbst. */ if (!weg || (!weg.sichtbar && !darfAlles(req.person))) { return res.status(404).json({ fehler: "nicht_gefunden" }); } const tag = heuteLokal(); const schluessel = `${req.person?.id ?? "?"}|${id}`; if (heuteSchon.get(schluessel) === tag) return res.json({ gezaehlt: false }); heuteSchon.set(schluessel, tag); /* Der Speicher wächst sonst mit jedem Menschen und jedem Weg. Bei mehr als tausend Einträgen fängt er von vorn an — eine Zahl, die nur ungefähr stimmen muss, verträgt das. */ if (heuteSchon.size > 1000) heuteSchon.clear(); db().prepare( `INSERT INTO unterstuetzung_striche (weg_id, tag, anzahl) VALUES (?,?,1) ON CONFLICT(weg_id, tag) DO UPDATE SET anzahl = anzahl + 1`).run(id, tag); res.json({ gezaehlt: true }); } catch (fehler) { console.error("[unterstuetzung] Strich:", fehler?.message); /* Ein Zähler darf den Weg nach draußen nie blockieren: Der Browser öffnet den Link ohnehin, egal was hier steht. */ res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ===================================================================== ÄNDERN — nur DogFather ===================================================================== */ unterstuetzungRouter.put("/workspace/api/unterstuetzung/:id(\\d+)", gleicheHerkunft, nurDogFather, express.json({ limit: "8kb" }), (req, res) => { try { const id = Number(req.params.id); const alt = db().prepare("SELECT * FROM unterstuetzung_wege WHERE id = ?").get(id); if (!alt) return res.status(404).json({ fehler: "nicht_gefunden" }); const titel = text(req.body?.titel ?? alt.titel, TITEL_MAX); if (titel.length < 2) return res.status(400).json({ fehler: "Der Weg braucht einen Namen." }); const art = String(req.body?.art ?? alt.art); if (!UNTERSTUETZUNG_ARTEN[art]) return res.status(400).json({ fehler: "Diese Art gibt es nicht." }); const z = zielPruefen(req.body?.ziel ?? alt.ziel); if (!z.ok) return res.status(400).json({ fehler: z.grund }); const knopf = text(req.body?.knopf ?? alt.knopf, KNOPF_MAX); /* EIN KNOPF OHNE ZIEL IST EIN VERSPRECHEN INS LEERE. Er sieht aus wie ein Weg und tut nichts — und weil nichts passiert, hält man die Seite für kaputt, nicht die Eingabe für unvollständig. */ if (knopf && !z.wert) { return res.status(400).json({ fehler: "Ein Knopf braucht eine Adresse – sonst passiert beim Drücken nichts.", }); } /* UND EIN SICHTBARER WEG MIT ZIEL BRAUCHT EINEN KNOPF, sonst steht die Adresse da und niemand kommt hin. */ const sichtbar = req.body?.sichtbar === undefined ? !!alt.sichtbar : !!req.body.sichtbar; if (sichtbar && z.wert && !knopf) { return res.status(400).json({ fehler: "Zu einer Adresse gehört eine Aufschrift für den Knopf.", }); } db().prepare(`UPDATE unterstuetzung_wege SET titel = ?, text = ?, knopf = ?, ziel = ?, art = ?, sichtbar = ?, platz = ?, geaendert = ?, geaendert_von = ? WHERE id = ?`) .run(titel, text(req.body?.text ?? alt.text, TEXT_MAX), knopf, z.wert, art, sichtbar ? 1 : 0, Number.isInteger(Number(req.body?.platz)) ? Number(req.body.platz) : alt.platz, new Date().toISOString(), req.person.id, id); /* PROTOKOLLIERT WIRD, DASS DIE ADRESSE SICH GEÄNDERT HAT — nicht, wie sie lautet. Ein Zahlungslink gehört nicht in ein Protokoll, das andere lesen können; dass er gewechselt hat, schon. */ protokolliere("unterstuetzung_geaendert", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `${alt.schluessel}${alt.ziel !== z.wert ? " (Adresse gewechselt)" : ""}`, }); res.json({ ok: true }); } catch (fehler) { console.error("[unterstuetzung] Aendern:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); unterstuetzungRouter.post("/workspace/api/unterstuetzung", gleicheHerkunft, nurDogFather, express.json({ limit: "8kb" }), (req, res) => { try { const d = db(); const { n } = d.prepare("SELECT COUNT(*) AS n FROM unterstuetzung_wege").get(); /* EINE OBERGRENZE, und zwar eine niedrige. Zwanzig Wege sind keine Auswahl mehr, sondern eine Wand — und die Seite soll leicht sein. */ if (n >= WEGE_MAX) { return res.status(400).json({ fehler: `Mehr als ${WEGE_MAX} Wege werden unübersichtlich. Räum erst einen weg.`, }); } const titel = text(req.body?.titel, TITEL_MAX); if (titel.length < 2) return res.status(400).json({ fehler: "Der Weg braucht einen Namen." }); const art = String(req.body?.art ?? "frei"); if (!UNTERSTUETZUNG_ARTEN[art]) return res.status(400).json({ fehler: "Diese Art gibt es nicht." }); const z = zielPruefen(req.body?.ziel); if (!z.ok) return res.status(400).json({ fehler: z.grund }); const knopf = text(req.body?.knopf, KNOPF_MAX); if (knopf && !z.wert) { return res.status(400).json({ fehler: "Ein Knopf braucht eine Adresse – sonst passiert beim Drücken nichts.", }); } /* Der Schlüssel wird ABGELEITET und nicht getippt. Er hält die Striche der letzten Monate am Weg fest, auch wenn der Titel sich ändert — und ein „eigen-" davor kann nie mit einem der fünf Startschlüssel zusammenfallen. */ const rein = titel.toLowerCase() .replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss") .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30) || "weg"; let schluessel = "eigen-" + rein; if (d.prepare("SELECT 1 FROM unterstuetzung_wege WHERE schluessel = ?").get(schluessel)) { schluessel += "-" + Date.now().toString(36).slice(-4); } const { platz } = d.prepare( "SELECT COALESCE(MAX(platz), 0) + 10 AS platz FROM unterstuetzung_wege").get(); d.prepare(`INSERT INTO unterstuetzung_wege (schluessel, art, titel, text, knopf, ziel, sichtbar, platz, geaendert, geaendert_von) VALUES (?,?,?,?,?,?,1,?,?,?)`) .run(schluessel, art, titel, text(req.body?.text, TEXT_MAX), knopf, z.wert, platz, new Date().toISOString(), req.person.id); protokolliere("unterstuetzung_angelegt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: schluessel, }); res.json({ id: d.prepare("SELECT last_insert_rowid() AS id").get().id }); } catch (fehler) { console.error("[unterstuetzung] Anlegen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); unterstuetzungRouter.delete("/workspace/api/unterstuetzung/:id(\\d+)", gleicheHerkunft, nurDogFather, (req, res) => { try { const id = Number(req.params.id); const weg = db().prepare( "SELECT schluessel FROM unterstuetzung_wege WHERE id = ?").get(id); if (!weg) return res.status(404).json({ fehler: "nicht_gefunden" }); /* Die Striche gehen mit (ON DELETE CASCADE). Sie ohne ihren Weg stehen zu lassen hieße, eine Zahl aufzubewahren, zu der niemand mehr sagen kann, wozu sie gehört. */ db().prepare("DELETE FROM unterstuetzung_wege WHERE id = ?").run(id); protokolliere("unterstuetzung_entfernt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: weg.schluessel, }); res.json({ ok: true }); } catch (fehler) { console.error("[unterstuetzung] Entfernen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /** Alte Tagesstriche wegräumen — aus dem Rundgang, nicht beim Lesen. * * Hinge es am Öffnen der Seite, bliebe alles liegen, solange niemand * hinsieht. */ export function unterstuetzungAufraeumen() { try { const grenze = new Date(Date.now() - ZAEHLUNG_TAGE * 86400000) .toISOString().slice(0, 10); const r = db().prepare("DELETE FROM unterstuetzung_striche WHERE tag < ?").run(grenze); return r.changes || 0; } catch { return 0; } }