Files
dogfather-universe/server/workspace-unterstuetzung.js
T
DogFatherGitandClaude Opus 5 aa2e1a4ae2 Eine Kachel "Unterstuetzen" -- und die Gespraechsliste neu gebaut
=== 1. UNTERSTUETZEN (neue Kachel im Community-Bereich) ===

Filipe: "ich brauch auch noch eine kachel im community bereich. wo mein
paypal und meine amazon liste sein wird. wo die leute alle supporten
koennen auf andere art anstatt nur tiktok. jeder soll diese kachel sehen
aber nur dogfather soll sie veraendern koennen oder vieles mehr sehen."

Neu: unterstuetzen.html + css + js, server/workspace-unterstuetzung.js,
server/unterstuetzung-tabellen.js. Ton 45 (#7567fe) ist gerechnet, das
Kachelzeichen ist ein Herz ueber zwei offenen Haenden.

DIE WEGE STEHEN IN DER DATENBANK, nicht im Quelltext -- ein Recht, das
man nur ueber einen Entwickler ausueben kann, ist keines. DogFather
schreibt Titel, Text, Knopf und Adresse selbst, blendet Wege aus und
nimmt neue dazu.

DER PAYPAL-LINK STEHT LEER UND UNSICHTBAR DA. Filipe schrieb "mein
paypal kennst du ja schon" -- gesucht im ganzen Haus und im Vault,
nirgends gefunden. Eine Zahlungsadresse zu RATEN waere der
gefaehrlichste Fehler dieser Seite: Geld an einen Fremden, und niemand
merkt es. Also steht dort nichts, der Weg ist ausgeblendet, und die
Seite sagt genau EINER Person, dass er fehlt.

DREI ARTEN, UND DIE DRITTE IST DIE WICHTIGSTE: geld, geschenk, frei.
"Kostet nichts" bekommt dieselbe Kartenform und dieselbe Groesse.
Waeren die freien Wege kleiner oder weiter unten, waere die Aussage
"das ist zweite Wahl" -- und die Mehrheit derer, die hier lesen, waere
damit zweite Wahl.

DIE ZAEHLUNG IST ANONYM, UND ZWAR VON DER TABELLE HER. DogFather sieht,
wie oft ein Weg geoeffnet wurde (7/30 Tage/gesamt). Was er NICHT sieht,
ist WER -- weil es in unterstuetzung_striche keine Spalte dafuer gibt.
Geprueft wird das ueber PRAGMA table_info, nicht ueber eine Abfrage:
eine Abfrage liesse sich morgen erweitern, eine fehlende Spalte nicht.

Schreiben haengt an istDogFather, nicht an istLeitung -- die rechte Hand
fuehrt dieses Team mit und kommt trotzdem nicht an diesen Link. Jede
Adresse wird beim SCHREIBEN geprueft (nur https:// oder eine Seite
dieses Hauses); javascript:, data:, http:// und // werden abgewiesen.

Neu: server/pruef-unterstuetzung.mjs -- 70 Pruefungen, alle gruen.
Sie misst ueber die CREW-ADRESSE: Beim ersten Lauf waren vier Rollen
gruen und der Zuschauer rot, und es sah nach einem Rechtefehler aus.
Es war ein Messfehler -- ueber 127.0.0.1 landet man still im
Agenturhaus, und dort gibt es die Rolle "gast" gar nicht.

=== 2. DIE GESPRAECHSLISTE (screen1 + screen2) ===

Filipe: "ich will dass die komplette kachel viel krasser und geiler
aussieht. der hintergrund der kachel soll gleich bleiben."

Der Hintergrund ist unangetastet. Zwei echte Fehler auf seinem Bild:

  - Bei "Das Rudel" stand das "@" rechts oben und die orange "6" eine
    ZEILE TIEFER. Der Knopf ist ein Raster mit DREI Spalten und bekam
    VIER Kinder -- das vierte fiel um. Ausgerechnet die wichtigste
    Auskunft der Liste landete an der unauffaelligsten Stelle.
  - Jedes Gespraech mit Profilbild haengte das Bild ZWEIMAL ein: ein
    Block stand Zeichen fuer Zeichen doppelt da. Zu sehen war nichts,
    gekostet hat es die doppelte Ladelast bei jedem Neuzeichnen.

Und eine tote Regel: `.chat-raum__knopf[data-an="ja"]` beschrieb die
Schiene am offenen Gespraech -- gesetzt wird aber `data-offen` am <li>.
Die Regel griff nie, und daneben stand eine zweite, blassere Fassung
derselben Sache. Jetzt steht alles einmal, und die Schiene ist da.

Dazu: Zeilen als Karten, 44px-Gesichter, ein Zaehler neben "Gespraeche",
Suchfeld als Pille mit Lupe, runder Farbfleck statt Quadrat, und der
leere Raum rechts bekommt eine Mitte statt eines Satzes in der Ecke.

=== 3. VIER FUNDE NEBENBEI ===

  - supportAufraeumen() war exportiert und wurde NIRGENDS gerufen. Die
    Meldungen samt Bildschirmfotos waeren fuer immer liegen geblieben,
    obwohl "90 Tage" dokumentiert ist. Jetzt im Loeschkonzept.
  - aufgaben_zuteilung fehlte ebenfalls im Loeschkonzept (seit 21.09.).
    pruef-aufbewahrung ist damit wieder gruen.
  - pruef-start-ansicht war rot, seit "Aufgaben" am 23.09. die silberne
    Kachel bekam -- die ueberschreibt ihr --ton absichtlich. Die
    Pruefung nimmt sie jetzt aus UND prueft die Ausnahme selbst.
  - pruef-buehne meldete auf entwicklung.html 1,79:1 Kontrast bei "Wen
    gehst du durch?" -- die Ueberschrift lag direkt auf dem Foto. Sie
    steht jetzt auf einer deckenden Flaeche.

OFFEN: pruef-buehne meldet treff-regeln.html mal "0 von 40", mal "3 von
40" -- dieselbe Seite, verschiedene Antworten. Eine Pruefung, die
schwankt, ist schlimmer als eine rote. Nicht in diesem Zug behoben.

Gemessen: pruef-unterstuetzung 70, pruef-start-ansicht, pruef-buehne
(bis auf den Wackler), pruef-workspace-seiten, pruef-aufbewahrung 45,
pruef-rechtetafel 19, pruef-css-klassen, pruef-chatkachel 36,
pruef-chat-ausbau, pruef-entwicklung-kacheln 31 -- gruen.

Neu: server/mess-chat-liste.mjs (zeigt die Liste mit sieben Gespraechen
verschiedener Art, prueft nichts).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-24 14:14:25 +02:00

367 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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; }
}