Files
dogfather-universe/server/workspace-aufbewahrung.js
T
DogFatherGitandClaude Opus 5 a0d9dc38e4 Teilen -> Workspace: aus fuenf Schritten wird ein Tipp
SCHRITT 4 AUS DEM VIDEO-PLAN
Filipe postet ein Video auf dem Handy. Bisher: Link kopieren, App
oeffnen, Bereich suchen, Feld finden, einfuegen. Fuenf Schritte, von
denen jeder einzelne der Grund sein kann, es "spaeter" zu machen -- und
spaeter heisst nie.

Jetzt: in TikTok auf "Teilen", DogFather waehlen, uebernehmen. Die neue
Seite workspace/teilen.html ist das Ziel dieses Tipps, eingetragen als
share_target in beiden Manifesten.

DER STOLPERSTEIN, DEN JEDE ANLEITUNG VERSCHWEIGT
Das Teilen-Ziel kennt drei Felder: title, text, url. Man baut es, testet
mit "?url=https://…" und ist fertig.

IM ECHTEN LEBEN KOMMT DER LINK IN `text`. Android-Apps -- TikTok
eingeschlossen -- teilen als reinen Text: "Sieh dir das an!
https://vm.tiktok.com/xyz/" steht dann in `text`, und `url` ist leer.
Wer nur `url` liest, hat eine Seite gebaut, die im Test funktioniert und
auf dem Handy eine leere Maske zeigt. Gesucht wird deshalb in allen drei
Feldern nach einem MUSTER, nicht nach einem Feldnamen -- und ein
Satzzeichen am Ende gehoert nicht zur Adresse.

SIE TRAEGT NICHT VON SELBST EIN. Die Seite kann nicht wissen, ob der
Griff in die Tasche gemeint war. Ein Video, das ungefragt in den
Highlights landet, muss jemand wieder loeschen -- das ist mehr Arbeit
als ein Tipp auf "Uebernehmen". Und wohin ist eine Frage, keine
Annahme: Highlights sind vorausgewaehlt, Treff und Anschlagbrett
stehen daneben.

GET UND NICHT POST. POST braeuchte einen Service Worker, der die
Anfrage abfaengt -- eine Stelle mehr, die kaputtgehen kann, und zwar
genau die unsichtbare: Man tippt auf Teilen und landet auf einer leeren
Seite. Hier wird nur ein Link geteilt, keine Datei.

GESEHEN, NICHT NUR GEZAEHLT
Das Bildschirmfoto auf 390 px zeigte einen Mangel, den keine Zahl
gemeldet haette: Das gewaehlte Ziel hob sich nur ueber Helligkeit ab.
Bei einer echten Wahl ist das zu wenig, und wer Farben schlecht
unterscheidet, sieht gar nichts. Jetzt: farbiger Streifen links,
deutlicherer Grund -- und ein HAKEN, der ohne jede Farbwahrnehmung
funktioniert.

EIN SICHERHEITSFUND NEBENBEI -- UND ER IST DER WICHTIGERE
pruef-alle-wege meldete: Von 126 schreibenden Wegen hatte genau EINER
keine Herkunftspruefung.

    POST /workspace/api/aufbewahrung/jetzt  ->  200

Eine beliebige fremde Seite kann im Browser eines angemeldeten
DogFather ein Formular dorthin abschicken; der Browser gibt das
Sitzungsplaetzchen mit, der Server fuehrt aus. Und ausgerechnet dieser
Weg: aufbewahrungLauf() LOESCHT abgelaufene Daten. Ein Aufruf zur
falschen Zeit loescht wirklich etwas, und im Protokoll sieht es aus,
als haette DogFather es selbst angestossen.

Die anderen 125 Wege hatten den Riegel -- dieser eine wurde beim
Hinzufuegen uebersehen. Genau dafuer geht die Pruefung ALLE Wege durch,
statt einer Liste zu glauben. Behoben, danach: "kein schreibender Weg
nimmt eine fremde Herkunft an".

DIE PRUEFUNG (server/pruef-teilen.mjs, 27 Pruefungen)
Auf 390 px gemessen -- diese Seite wird fast nur dort geoeffnet, eine
Messung auf 1400 px misst etwas, das niemand sieht. Geprueft wird der
Satz mit Link darin, das Satzzeichen am Ende, der Fall ohne Link
(Feld zum Einfuegen statt Fehlermeldung), das Eintragen, die Dublette,
das zweite Brett -- und dass share_target in BEIDEN Manifesten steht.
Ohne den Eintrag erscheint der Workspace gar nicht erst im Teilen-Menue,
und die ganze Seite waere unerreichbar, ohne dass eine der anderen
26 Pruefungen es merkt.

GEGENPROBE: nur `url` lesen statt aller drei Felder -- also genau die
naheliegende Fassung, die im Test funktioniert und auf dem Handy
versagt -> 8 Pruefungen rot.

Stempel 202609171416.

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

330 lines
14 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.
/* =====================================================================
Das Löschkonzept — als Tabelle, die sich selbst durchsetzt (15.09.2026)
Stufe 8 aus [[Creator Workspace – Plan zur Perfektion Stufe 2]]:
„Löschkonzept je Datenart · IP-Adressen im Protokoll nach 30 Tagen
weg. Je mehr Daten, desto teurer das Nachholen."
---------------------------------------------------------------------
WARUM DAS KEIN DOKUMENT IST
Ein Löschkonzept als Notiz ist nach dem ersten neuen Feld falsch, und
niemand merkt es. Diese Datei ist deshalb beides: die Übersicht, was
wie lange aufgehoben wird — UND das Programm, das es tut. Was hier
nicht steht, wird nicht geräumt; was hier steht, wird geräumt.
---------------------------------------------------------------------
WAS GEMESSEN WURDE, BEVOR HIER ETWAS STAND
42 Tabellen durchgesehen, drei halten IP-Adressen:
versuche.ip wird beim Anmelden selbst im Fenster geräumt (ok)
sitzungen.ip wird beim Abmelden geloescht -- ABGELAUFENE
Sitzungen aber nie (Luecke)
protokoll.ip wird nirgends geraeumt, waechst ewig (Luecke)
Zwei echte Luecken also, nicht fuenf vermutete.
---------------------------------------------------------------------
DREI SORTEN EINTRAG, UND DIE DRITTE IST DIE WICHTIGSTE
raeumen Diese Datei loescht selbst.
fremd Eine andere Stelle loescht -- hier wird nur GEZAEHLT.
Bricht die andere Stelle, steht die Zahl trotzdem da.
bleibt Wird absichtlich aufgehoben, mit Begruendung.
Ein Loeschkonzept, das nur Loeschungen auffuehrt, ist ein halbes:
Die begruendete Aufbewahrung ist der Teil, nach dem gefragt wird.
===================================================================== */
import express from "express";
import {
db, sitzungLesen, istDogFather, protokolliere, echteIp,
einstellung, einstellungSetzen,
} from "./workspace.js";
export const aufbewahrungRouter = express.Router();
/** Auf welcher Grundlage die Daten eines Menschen verarbeitet werden.
*
* ABGELEITET, NICHT GESPEICHERT. Ein Modi arbeitet hier — das ist ein
* Vertragsverhaeltnis. Ein Mitglied der Community ist freiwillig da —
* das ist eine Einwilligung. Die Grundlage folgt damit aus der Rolle,
* und was sich ableiten laesst, bekommt keine eigene Spalte: Sonst
* gaebe es zwei Wahrheiten, von denen eine veraltet, sobald jemand
* die Rolle wechselt.
*
* Gebraucht wird sie an zwei Stellen: in der Auskunft (Art. 15 Abs. 1
* lit. a — der Mensch darf den Zweck erfahren) und im Loeschkonzept. */
export function grundlageVon(rolle) {
if (rolle === "gast") {
return {
grundlage: "Art. 6 Abs. 1 lit. a DSGVO – Einwilligung",
warum: "Du bist freiwillig im Treff. Du kannst jederzeit gehen, und deine "
+ "Daten gehen mit.",
};
}
return {
grundlage: "Art. 6 Abs. 1 lit. b DSGVO – zur Durchführung des Vertrags",
warum: "Du arbeitest hier. Ohne diese Daten lässt sich die Zusammenarbeit "
+ "nicht durchführen.",
};
}
/** Wie lange IP-Adressen im Protokoll stehen bleiben.
*
* 30 Tage: lang genug, um einen Missbrauch nachzuvollziehen, den man
* erst spaeter bemerkt -- kurz genug, dass daraus kein Bewegungsbild
* wird. Dieselbe Frist, die Filipe im Plan genannt hat. */
export const IP_TAGE = 30;
const vorTagen = (n) => new Date(Date.now() - n * 86400000).toISOString();
const jetzt = () => new Date().toISOString();
/** Zaehlt, ohne zu aendern. Ein Fehler hier ist kein Befund, sondern
* eine Zahl, die fehlt -- deshalb null statt 0, damit „konnte nicht
* nachsehen" nicht wie „nichts zu tun" aussieht. */
function zaehle(d, sql, ...werte) {
try { return d.prepare(sql).get(...werte)?.n ?? null; } catch { return null; }
}
/* =====================================================================
DIE TAFEL
===================================================================== */
export const FRISTEN = [
{
schluessel: "protokoll_ip",
was: "IP-Adressen im Protokoll",
art: "raeumen",
frist: `${IP_TAGE} Tage`,
zweck: "Einen Missbrauch nachvollziehen können, der erst später auffällt.",
grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse",
wirkung: "Nur die IP wird geleert. Wer wann was getan hat, bleibt stehen – "
+ "das Protokoll ist die Sicherung gegen stille Änderungen.",
offen: (d) => zaehle(d,
"SELECT COUNT(*) AS n FROM protokoll WHERE ip IS NOT NULL AND zeitpunkt < ?", vorTagen(IP_TAGE)),
raeumen: (d) => d.prepare(
"UPDATE protokoll SET ip = NULL WHERE ip IS NOT NULL AND zeitpunkt < ?")
.run(vorTagen(IP_TAGE)).changes,
},
{
schluessel: "sitzungen_abgelaufen",
was: "Abgelaufene Anmeldungen (mit IP und Browser)",
art: "raeumen",
frist: "sofort nach Ablauf",
zweck: "Angemeldet bleiben, solange die Sitzung gilt.",
grundlage: "Art. 6 Abs. 1 lit. b DSGVO – zur Durchführung erforderlich",
wirkung: "Die Zeile verschwindet ganz. Nach Ablauf ist sie zu nichts mehr nütze – "
+ "sie stand bisher nur da und trug eine IP mit sich.",
offen: (d) => zaehle(d,
"SELECT COUNT(*) AS n FROM sitzungen WHERE gueltig_bis < ?", jetzt()),
raeumen: (d) => d.prepare("DELETE FROM sitzungen WHERE gueltig_bis < ?")
.run(jetzt()).changes,
},
{
schluessel: "versuche",
was: "Fehlversuche beim Anmelden (IP)",
art: "fremd",
frist: "10 Minuten",
zweck: "Ausprobieren von Codes bremsen.",
grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse",
wirkung: "Wird beim Anmelden selbst geräumt (workspace.js). Hier steht die Zahl "
+ "trotzdem – bricht die andere Stelle, sieht man es hier.",
offen: (d) => zaehle(d,
"SELECT COUNT(*) AS n FROM versuche WHERE zeitpunkt < ?",
new Date(Date.now() - 60 * 60000).toISOString()),
},
{
schluessel: "protokoll_eintraege",
was: "Die Protokolleinträge selbst (ohne IP)",
art: "bleibt",
frist: "unbefristet",
zweck: "Nachvollziehen, wer wann einen Zugang angelegt, gesperrt oder gelöscht hat.",
grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse an Nachvollziehbarkeit",
wirkung: "Bleibt. Ein Protokoll, das sich selbst löscht, sichert nichts.",
offen: (d) => zaehle(d, "SELECT COUNT(*) AS n FROM protokoll"),
},
{
schluessel: "treff_massnahmen",
was: "Maßnahmen im Treff (Hinweis, Pause, Ausschluss) mit Begründung",
art: "bleibt",
frist: "solange die Person ein Konto hat",
zweck: "Eine Entscheidung über einen Menschen muss begründet nachlesbar sein – "
+ "das schützt beide Seiten.",
grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse",
wirkung: "Bleibt, solange es den Zugang gibt. Wird der Zugang gelöscht, geht die "
+ "Maßnahme mit (ON DELETE CASCADE).",
offen: (d) => zaehle(d, "SELECT COUNT(*) AS n FROM treff_massnahmen"),
},
{
schluessel: "alter_bestaetigt",
was: "Der Zeitpunkt der Altersbestätigung (Community)",
art: "bleibt",
frist: "solange der Zugang besteht",
zweck: "Nachweisen können, dass das Mindestalter bestätigt wurde – "
+ "eine Regel, die man nicht belegen kann, hat man nicht.",
grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse an Nachweisbarkeit",
wirkung: "Gespeichert wird NUR der Zeitpunkt, kein Geburtsdatum. Für die Frage "
+ "„hat bestätigt, und wann?“ reicht das – ein Geburtstag wären mehr Daten "
+ "für dieselbe Auskunft.",
offen: (d) => zaehle(d,
"SELECT COUNT(*) AS n FROM personen WHERE alter_bestaetigt_am IS NOT NULL"),
},
{
schluessel: "personen_geloescht",
was: "Daten einer gelöschten Person",
art: "bleibt",
frist: "gehen mit dem Zugang",
zweck: "—",
grundlage: "Art. 17 DSGVO – Recht auf Löschung",
wirkung: "Wer in „Personen & Zugänge“ gelöscht wird, nimmt Sitzungen, Maßnahmen, "
+ "Entwicklungsstände und Chat-Zugehörigkeit mit – die Datenbank erzwingt das "
+ "über ON DELETE CASCADE, nicht eine Liste, die jemand pflegt.",
offen: (d) => zaehle(d, "SELECT COUNT(*) AS n FROM personen WHERE aktiv = 0"),
},
];
/* =====================================================================
DER LAUF
===================================================================== */
/** Räumt alles, was zu räumen ist.
*
* @returns {{getan: Array, fehler: Array}} was passiert ist
*/
export function aufbewahrungLauf() {
const d = db();
const getan = [];
const fehler = [];
for (const f of FRISTEN) {
if (typeof f.raeumen !== "function") continue;
try {
const anzahl = f.raeumen(d);
if (anzahl) getan.push({ schluessel: f.schluessel, anzahl });
} catch (fe) {
/* EIN FEHLSCHLAG DARF DEN REST NICHT AUFHALTEN -- und er darf
nicht still bleiben. Waere er still, hiesse „aufgeraeumt"
irgendwann „einmal probiert". */
fehler.push({ schluessel: f.schluessel, grund: String(fe?.message || fe).slice(0, 200) });
console.error("[aufbewahrung]", f.schluessel, fe?.message);
}
}
try {
einstellungSetzen("aufbewahrung_letzte",
JSON.stringify({ zeitpunkt: jetzt(), getan, fehler }));
} catch { /* der Lauf zaehlt, nicht sein Vermerk */ }
/* Nur protokollieren, wenn wirklich etwas passiert ist. Ein taeglicher
Eintrag „nichts zu tun" waere genau die Art Rauschen, die das
Protokoll unlesbar macht -- und ausgerechnet dieses Protokoll ist
die Sicherung gegen stille Aenderungen. */
if (getan.length || fehler.length) {
try {
protokolliere("aufbewahrung", {
detail: getan.map((g) => `${g.schluessel}: ${g.anzahl}`).join(", ")
+ (fehler.length ? ` | FEHLER: ${fehler.map((f) => f.schluessel).join(", ")}` : ""),
});
} catch { /* egal */ }
}
return { getan, fehler };
}
/** Einmal am Tag, und einmal kurz nach dem Start.
*
* Nicht im Startmoment: Erst soll die Website antworten. Dieselbe
* Ueberlegung wie bei der Sicherung -- ein Aufraeumlauf im
* Startmoment wuerde die Datenbank nur unnoetig frueh oeffnen und
* einen Fehler dort zum Startfehler machen. */
export function aufbewahrungStarten() {
const ersterLauf = setTimeout(() => { try { aufbewahrungLauf(); } catch { /* egal */ } }, 90_000);
const takt = setInterval(() => { try { aufbewahrungLauf(); } catch { /* egal */ } }, 24 * 3600_000);
ersterLauf.unref?.();
takt.unref?.();
}
/* =====================================================================
DIE TAFEL ZUM ANSEHEN
===================================================================== */
/** Kommt die Anfrage von unserer eigenen Seite?
*
* DERSELBE WORTLAUT WIE IN DEN ANDEREN MODULEN und bewusst keine
* eigene Erfindung: Eine zweite, leicht andere Fassung waere eine
* zweite Wahrheit, und beim naechsten Nachschaerfen wuerde genau die
* eine vergessen.
*
* KEIN `origin` HEISST DURCHLASSEN, und das ist Absicht: Browser
* schicken das Feld bei schreibenden Anfragen immer mit. Fehlt es,
* kommt die Anfrage aus einem Werkzeug (curl, eine Pruefung) -- und
* dort ist die Sitzung ohnehin nur da, wenn jemand sie absichtlich
* mitgibt. Der Angriff, um den es geht, braucht einen Browser. */
function gleicheHerkunft(req, res, next) {
const herkunft = req.get("origin");
if (!herkunft) return next();
let erlaubt;
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
next();
}
function nurDogFather(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
/* 404 und nicht 403: Wer „kein Zugriff" liest, weiss, dass es die
Seite gibt. Dieselbe Antwort wie ueberall im Haus. */
if (!istDogFather(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
req.person = person;
next();
}
aufbewahrungRouter.get("/workspace/api/aufbewahrung", nurDogFather, (req, res) => {
try {
const d = db();
let letzte = null;
try { letzte = JSON.parse(einstellung("aufbewahrung_letzte") || "null"); } catch { letzte = null; }
const zeilen = FRISTEN.map((f) => ({
schluessel: f.schluessel,
was: f.was,
art: f.art,
frist: f.frist,
zweck: f.zweck,
grundlage: f.grundlage,
wirkung: f.wirkung,
/* `offen` heisst bei „raeumen": so viele warten. Bei „bleibt":
so viele liegen da. Zwei verschiedene Bedeutungen unter einem
Namen waeren eine Falle -- deshalb steht `art` daneben und die
Oberflaeche beschriftet die Zahl danach. */
anzahl: f.offen ? f.offen(d) : null,
}));
res.json({ fristen: zeilen, letzte, ip_tage: IP_TAGE });
} catch (fehler) {
console.error("[aufbewahrung] Tafel:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Von Hand anstossen.
*
* Damit es kein schwarzer Kasten ist: Wer sehen will, ob es wirkt,
* drueckt drauf und sieht die Zahlen fallen. */
/* DER RIEGEL STEHT VOR DER RECHTEPRUEFUNG.
Reihenfolge wie im ganzen Haus: erst "kommt das von uns?", dann
"darf der das?". Andersherum waere es nicht falsch, aber die
Abweichung faellt beim Lesen auf und kostet jedes Mal einen
Moment. */
aufbewahrungRouter.post("/workspace/api/aufbewahrung/jetzt", gleicheHerkunft, nurDogFather, (req, res) => {
try {
const ergebnis = aufbewahrungLauf();
protokolliere("aufbewahrung_von_hand", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: ergebnis.getan.map((g) => `${g.schluessel}: ${g.anzahl}`).join(", ") || "nichts zu tun",
});
res.json(ergebnis);
} catch (fehler) {
console.error("[aufbewahrung] Lauf:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});