diff --git a/server/index.js b/server/index.js index 71b9949a..b639a5be 100644 --- a/server/index.js +++ b/server/index.js @@ -28,6 +28,7 @@ import { entwicklungRouter } from "./workspace-entwicklung.js"; import { rechteRouter, tafelLaden } from "./workspace-rechte.js"; import { erklaerungRouter } from "./workspace-erklaerung.js"; import { aufbewahrungRouter, aufbewahrungStarten } from "./workspace-aufbewahrung.js"; +import { auskunftRouter } from "./workspace-auskunft.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; import { callRouter } from "./workspace-calls.js"; @@ -256,6 +257,7 @@ app.use(entwicklungRouter); app.use(rechteRouter); app.use(erklaerungRouter); app.use(aufbewahrungRouter); +app.use(auskunftRouter); app.use(reportRouter); app.use(scoutRouter); app.use(callRouter); diff --git a/server/pruef-auskunft.mjs b/server/pruef-auskunft.mjs new file mode 100644 index 00000000..1b73deab --- /dev/null +++ b/server/pruef-auskunft.mjs @@ -0,0 +1,336 @@ +/* ===================================================================== + Auskunft auf Knopfdruck — Art. 15 DSGVO (15.09.2026) + + --------------------------------------------------------------------- + DIE EINE FRAGE, DIE ZÄHLT: IST SIE VOLLSTÄNDIG? + + Eine Auskunft, die eine Tabelle vergisst, ist schlimmer als keine — + sie ist eine falsche Aussage über die Daten eines Menschen. Deshalb + prüft diese Datei nicht, ob *etwas* zurückkommt, sondern ob **jede** + Stelle gefunden wird, an der etwas liegt: + + 1. Jede Spalte mit Fremdschlüssel auf `personen` ist erfasst – + abgeleitet aus der Datenbank, nicht gegen eine Zahl. + 2. Jede Spalte, die auf `_id` endet und KEINEN Fremdschlüssel hat, + ist eingeordnet: entweder „ist eine Person" oder „ist keine". + Kommt eine fünfte dazu, wird das hier rot — und genau daran + stirbt sonst jede gepflegte Liste. + 3. Daten werden wirklich gefunden: In sechs Tabellen wird etwas + angelegt, und alle sechs müssen in der Auskunft auftauchen. + + --------------------------------------------------------------------- + UND ZWEI, DIE NIEMALS DRINSTEHEN DÜRFEN + + Zugangsgeheimnisse (Hash, Salt, Kennung) — Risiko ohne Nutzen + Andere Menschen — deren Daten gehören ihnen + + Aufrufen mit: node server/pruef-auskunft.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4412, "pruef-auskunft"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-ausk-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(180_000, "pruef-auskunft"); +await new Promise((r) => setTimeout(r, 700)); + +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +let fehler = 0, geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const CREW = "crew.dogfather-universe.com"; +const HAUS = "workspace.dogfather-universe.com"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const modul = await import("./workspace-auskunft.js"); + +const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +let idDogi, idRieke, idGast; +{ + const d = oeffne(); + const schluessel = randomBytes(32).toString("hex"); + d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); + const anlegen = (name, rolle, code) => { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); + idRieke = anlegen("Rieke", "hand", "CODE-HAND-0001"); + idGast = anlegen("Kessi", "gast", "CODE-GAST-0001"); + + /* SPUREN IN SECHS TABELLEN -- eine Auskunft ueber einen Menschen ohne + Daten beweist nichts. Bewusst verteilt: eigener Eintrag, fremder + Eintrag mit mir darin, Chat, Protokoll (ohne Fremdschluessel) und + treff_entfernt.autor_id (ebenfalls ohne). */ + d.prepare("INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von)" + + " VALUES ('treff','hallo','KESSI-EINTRAG','Hallo zusammen',?,?,?)") + .run(jetzt.slice(0, 10), jetzt, idGast); + const raum = (() => { + d.prepare("INSERT INTO chat_raeume (art, name, erstellt, erstellt_von) VALUES ('gruppe','Testraum',?,?)") + .run(jetzt, idDogi); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; + })(); + d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit) VALUES (?,?,?)").run(raum, idGast, jetzt); + d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)") + .run(raum, idGast, "KESSI-NACHRICHT", jetzt); + d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)") + .run(raum, idDogi, "FREMDE-NACHRICHT", jetzt); + d.prepare("INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip)" + + " VALUES (?,?,?,?,?,?)").run(jetzt, idGast, "gast", "anmeldung", "KESSI-PROTOKOLL", "203.0.113.5"); + d.prepare("INSERT INTO treff_massnahmen (person_id, art, grund, von, zeitpunkt)" + + " VALUES (?,?,?,?,?)").run(idGast, "hinweis", "KESSI-MASSNAHME", idDogi, jetzt); + d.close(); +} + +function roh(pfad, host, kopf = {}) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: "GET", + headers: { Host: host, ...kopf } }, (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + a.end(); + }); +} +function post(pfad, host, kopf, koerper) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: "POST", + headers: { Host: host, "Content-Type": "application/json", ...kopf } }, (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + a.write(JSON.stringify(koerper)); + a.end(); + }); +} +const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; +async function anmelden(rolle, code, host) { + const r = await post("/workspace/api/anmelden", host, {}, { rolle, code }); + return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +} +const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); +const kRieke = await anmelden("hand", "CODE-HAND-0001", CREW); +const kGast = await anmelden("gast", "CODE-GAST-0001", CREW); +ok(!!kDogi && !!kRieke && !!kGast, "DogFather, rechte Hand und ein Community-Mitglied sind angemeldet"); + +/* ======================================================================= + 1. DIE QUELLEN SIND ABGELEITET — UND VOLLSTÄNDIG EINGEORDNET + ======================================================================= */ +melde(""); +melde("=== 1. Vollständigkeit der Quellen ==="); +{ + const d = oeffne(); + const spalten = modul.personenSpalten(d); + ok(spalten.length > 30, `${spalten.length} Spalten im Haus zeigen auf einen Menschen`); + ok(spalten.some((s) => s.quelle === "fremdschluessel"), "die meisten über einen Fremdschlüssel"); + ok(spalten.some((s) => s.quelle === "nachgetragen"), "und zwei sind nachgetragen"); + + /* DIE PRUEFUNG, DIE DIE LISTE AM LEBEN HAELT: Jede Spalte auf `_id` + ohne Fremdschluessel muss eingeordnet sein -- als Person oder als + keine. Baut jemand eine fuenfte, wird das hier rot, statt dass sie + still aus der Auskunft faellt. */ + const tabellen = d.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all(); + const undeklariert = []; + for (const { name } of tabellen) { + const fk = new Set(d.prepare(`PRAGMA foreign_key_list(${name})`).all().map((f) => f.from)); + for (const c of d.prepare(`PRAGMA table_info(${name})`).all()) { + if (!/_id$/i.test(c.name) || !/INT/i.test(c.type)) continue; + if (fk.has(c.name)) continue; + undeklariert.push(`${name}.${c.name}`); + } + } + ok(undeklariert.length > 0, `${undeklariert.length} Spalten auf _id ohne Fremdschlüssel`); + + const alsPerson = new Set(modul.ZUSATZ.map((z) => `${z.tabelle}.${z.spalte}`)); + const alsNicht = new Set(modul.KEINE_PERSON); + const uneingeordnet = undeklariert.filter((s) => !alsPerson.has(s) && !alsNicht.has(s)); + ok(uneingeordnet.length === 0, + uneingeordnet.length ? `NICHT EINGEORDNET: ${uneingeordnet.join(", ")}` + : `alle ${undeklariert.length} sind eingeordnet (${alsPerson.size} Person, ${alsNicht.size} nicht)`); + + /* Und keine Karteileiche: Was eingeordnet ist, muss es auch geben. */ + const erfunden = [...alsPerson, ...alsNicht].filter((s) => !undeklariert.includes(s)); + ok(erfunden.length === 0, + erfunden.length ? `EINGEORDNET, GIBT ES ABER NICHT: ${erfunden.join(", ")}` : "und keine Karteileiche dabei"); + + ok(modul.ZUSATZ.every((z) => z.warum && z.warum.length > 20), + "jede nachgetragene Spalte sagt, warum sie keinen Fremdschlüssel hat"); + d.close(); +} + +/* ======================================================================= + 2. DIE DATEN WERDEN WIRKLICH GEFUNDEN + ======================================================================= */ +melde(""); +melde("=== 2. Was über Kessi gespeichert ist ==="); +{ + const a = await roh("/workspace/api/auskunft/mich", CREW, { cookie: kGast }); + ok(a.code === 200, `ein Community-Mitglied bekommt seine Auskunft (${a.code})`); + const d = json(a); + + ok(d.person?.name === "Kessi", `die eigene Zeile steht drin (${d.person?.name})`); + ok(d.durchsucht > 30, `${d.durchsucht} Stellen durchsucht`); + ok(d.gefunden >= 5, `an ${d.gefunden} davon liegt etwas über sie`); + + const namen = (d.tabellen || []).map((t) => t.tabelle); + for (const muss of ["eintraege", "chat_nachrichten", "chat_teilnehmer", + "protokoll", "treff_massnahmen"]) { + ok(namen.includes(muss), `${muss} ist dabei`); + } + /* protokoll und treff_entfernt.autor_id haben KEINEN Fremdschluessel. + Dass protokoll trotzdem auftaucht, beweist, dass die nachgetragene + Liste wirkt -- ohne sie fiele es lautlos heraus. */ + ok(namen.includes("protokoll"), + "auch das Protokoll – und das hat gar keinen Fremdschlüssel"); + + const roher = a.text; + ok(/KESSI-EINTRAG/.test(roher) && /KESSI-NACHRICHT/.test(roher) && /KESSI-MASSNAHME/.test(roher), + "die Inhalte stehen wirklich drin, nicht nur die Tabellennamen"); + + /* GEGENPROBE: Was NICHT ihres ist, ist auch nicht drin. */ + ok(!/FREMDE-NACHRICHT/.test(roher), + "die Nachricht eines anderen aus demselben Raum steht NICHT drin"); + ok(d.nicht_lesbar?.length === 0, "und jede Stelle ließ sich lesen"); +} + +/* ======================================================================= + 3. ZWEI DINGE, DIE NIEMALS DRINSTEHEN + ======================================================================= */ +melde(""); +melde("=== 3. Was nicht drinsteht ==="); +{ + const a = await roh("/workspace/api/auskunft/mich", CREW, { cookie: kGast }); + const roher = a.text; + const d = json(a); + + /* 1. Zugangsgeheimnisse. */ + ok(/Zugangsgeheimnis/.test(roher), "die Geheimnisfelder sind als solche markiert"); + const dd = oeffne(); + const echt = dd.prepare("SELECT code_hash, code_salt, code_kennung FROM personen WHERE id = ?").get(idGast); + dd.close(); + ok(!roher.includes(echt.code_hash), "der echte Hash steht NICHT darin"); + ok(!roher.includes(echt.code_salt), "das Salt auch nicht"); + ok(!roher.includes(echt.code_kennung), "und die Kennung ebenso wenig"); + + /* 2. Andere Menschen. Die Maßnahme wurde von DogFather gesetzt -- + seine Nummer ist sein Datum, nicht ihres. */ + const massnahme = (d.tabellen || []).find((t) => t.tabelle === "treff_massnahmen"); + const zeile = massnahme?.zeilen?.[0]; + ok(zeile && zeile.von === "(eine andere Person)", + `wer die Maßnahme gesetzt hat, steht als „eine andere Person“ da (${zeile?.von})`); + /* GEGENPROBE -- ohne sie beweist die Zeile darueber nichts: IHRE + eigene Nummer bleibt stehen, sonst waere die Auskunft unbrauchbar. */ + ok(zeile && Number(zeile.person_id) === Number(idGast), + `ihre eigene Nummer steht unverändert da (${zeile?.person_id})`); + ok(!new RegExp(`"von":\\s*${idDogi}\\b`).test(roher), + "und nirgends im Text steht die Nummer des anderen in einer Personenspalte"); +} + +/* ======================================================================= + 4. WER WEN FRAGEN DARF + ======================================================================= */ +melde(""); +melde("=== 4. Wer wen fragen darf ==="); +{ + const eigene = await roh("/workspace/api/auskunft/mich", CREW, { cookie: kRieke }); + ok(eigene.code === 200, `die rechte Hand bekommt ihre eigene (${eigene.code})`); + ok(json(eigene).person?.name === "Rieke", "und zwar ihre, nicht die eines anderen"); + + const fremd = await roh(`/workspace/api/auskunft/${idGast}`, CREW, { cookie: kRieke }); + ok(fremd.code === 404, `die eines anderen bekommt sie nicht (${fremd.code})`); + ok(!/KESSI-NACHRICHT/.test(fremd.text), "und kein Wort daraus"); + + const gastFremd = await roh(`/workspace/api/auskunft/${idRieke}`, CREW, { cookie: kGast }); + ok(gastFremd.code === 404, `ein Community-Mitglied erst recht nicht (${gastFremd.code})`); + + /* GEGENPROBE: DogFather kann es -- er ist der Verantwortliche. Ohne + diese Zeile waere „404" oben auch dann gruen, wenn es den Weg gar + nicht gibt. */ + const chef = await roh(`/workspace/api/auskunft/${idGast}`, HAUS, { cookie: kDogi }); + ok(chef.code === 200, `DogFather schon (${chef.code})`); + ok(json(chef).person?.name === "Kessi", "und bekommt die richtige Person"); + + const ohne = await roh("/workspace/api/auskunft/mich", HAUS, {}); + ok(ohne.code === 401, `ohne Anmeldung gar nichts (${ohne.code})`); + + const erfunden = await roh("/workspace/api/auskunft/999999", HAUS, { cookie: kDogi }); + ok(erfunden.code === 404, `eine erfundene Nummer ergibt 404 (${erfunden.code})`); +} + +/* ======================================================================= + 5. JEDE AUSKUNFT HINTERLÄSST EINE SPUR + ======================================================================= */ +melde(""); +melde("=== 5. Nachvollziehbar ==="); +{ + const d = oeffne(); + const selbst = d.prepare("SELECT COUNT(*) AS n FROM protokoll WHERE aktion = 'auskunft_selbst'").get().n; + const fremd = d.prepare("SELECT detail FROM protokoll WHERE aktion = 'auskunft_fremd'").all(); + d.close(); + ok(selbst >= 3, `${selbst} eigene Auskünfte im Protokoll`); + ok(fremd.length >= 1, `${fremd.length} Auskunft über einen anderen Menschen`); + ok(/Kessi/.test(fremd[0]?.detail || ""), + `und dort steht, über wen (${fremd[0]?.detail})`); +} + +/* ======================================================================= + 6. DER KNOPF STEHT AUF DEN SEITEN, DIE DEM MENSCHEN GEHÖREN + ======================================================================= */ +melde(""); +melde("=== 6. Der Knopf ==="); +{ + const { readFileSync } = await import("node:fs"); + for (const seite of ["steckbrief.html", "treff-regeln.html"]) { + const s = readFileSync(new URL("../workspace/" + seite, import.meta.url), "utf8"); + ok(/id="auskunft-knopf"/.test(s), `${seite} hat den Knopf`); + ok(/assets\/js\/auskunft\.js/.test(s), `${seite} lädt das Skript dazu`); + } + /* WARUM AUSGERECHNET DIESE ZWEI: Die Community hat keinen Steckbrief. + Ohne die zweite Stelle haette die Gruppe mit den wenigsten Rechten + auch dieses nicht -- und sie ist die groesste. */ + const rechte = await import("./rechte.js"); + const tafel = rechte.tafelJetzt(); + ok(!(tafel["/workspace/steckbrief.html"] || []).includes("gast"), + "die Community hat keinen Steckbrief"); + ok((tafel["/workspace/treff-regeln.html"] || []).includes("gast"), + "aber die Treff-Regeln – deshalb steht er auch dort"); +} + +/* ------------------------------------------------------------------- */ +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-auskunft.js b/server/workspace-auskunft.js new file mode 100644 index 00000000..4310f8da --- /dev/null +++ b/server/workspace-auskunft.js @@ -0,0 +1,234 @@ +/* ===================================================================== + Auskunft auf Knopfdruck — Art. 15 DSGVO (15.09.2026) + + Zweite Hälfte von Stufe 8. Jeder Mensch hier darf wissen, was über + ihn gespeichert ist: DogFather, die rechte Hand, jeder Modi, jede + Creatorin — und jedes Mitglied der Community. + + --------------------------------------------------------------------- + DIE LISTE DER QUELLEN WIRD ABGELEITET, NICHT GEPFLEGT + + Eine Auskunft, die eine Tabelle vergisst, ist schlimmer als keine: + Sie ist eine *falsche Aussage* über die Daten eines Menschen. Eine + handgeschriebene Liste „wo Personendaten liegen" wäre in dem Moment + falsch, in dem jemand eine Tabelle dazubaut — und im Projekt ist + genau diese Bauart schon dreimal schiefgegangen. + + Deshalb fragt diese Datei die Datenbank selbst: + `PRAGMA foreign_key_list` liefert jede Spalte, die auf `personen` + zeigt. Gemessen am 15.09.2026: 37 von 42 Tabellen. + + --------------------------------------------------------------------- + VIER SPALTEN ZEIGEN AUF ETWAS, OHNE ES ZU SAGEN + + Es gibt genau vier Spalten im ganzen Haus, die auf `_id` enden und + keinen Fremdschlüssel deklarieren. Zwei davon sind Personen, zwei + nicht. Sie stehen unten namentlich da — und `pruef-auskunft` schlägt + an, sobald eine fünfte dazukommt, die niemand eingeordnet hat. Die + Liste ist damit klein genug, um richtig zu sein, und kann nicht + heimlich veralten. + + --------------------------------------------------------------------- + ZWEI DINGE STEHEN NIE DRIN + + 1. ZUGANGSGEHEIMNISSE. Hash, Salt, Kennung, Token — sie sind zwar + „Daten über die Person", aber ihre Herausgabe schafft ein Risiko + und keinen Nutzen. Art. 15 Abs. 4: die Rechte anderer bleiben + unberührt, und das eigene Konto zu schützen ist auch ein Recht. + + 2. ANDERE MENSCHEN. Wer eine Beobachtung über mich gesetzt hat, ist + dessen Datum, nicht meines. Steht in einer Zeile über mich die + Nummer eines anderen, wird daraus „eine andere Person" — eine + Regel für alle Tabellen, keine Liste von Ausnahmen. + ===================================================================== */ + +import express from "express"; +import { db, sitzungLesen, istDogFather, protokolliere, echteIp } from "./workspace.js"; + +export const auskunftRouter = express.Router(); + +/** Höchstens so viele Zeilen je Tabelle. + * + * Nicht aus Bequemlichkeit: Ein Chatverlauf kann zehntausend Zeilen + * haben, und eine Datei, die der Browser nicht mehr oeffnet, ist keine + * Auskunft. Wird gekuerzt, steht es DRIN -- eine stille Kuerzung waere + * wieder eine falsche Aussage. */ +export const ZEILEN_MAX = 500; + +/** Personenverweise ohne deklarierten Fremdschluessel. */ +export const ZUSATZ = [ + { + tabelle: "protokoll", spalte: "person_id", + warum: "Das Protokoll soll eine gelöschte Person überdauern – deshalb kein " + + "Fremdschlüssel, der die Zeile mitnehmen würde.", + }, + { + tabelle: "treff_entfernt", spalte: "autor_id", + warum: "Wer einen entfernten Beitrag geschrieben hat. Ohne Fremdschlüssel, damit " + + "der Beleg bleibt, auch wenn das Konto geht.", + }, +]; + +/** Spalten auf `_id`, die KEINE Person meinen. Stehen hier, damit die + * Pruefung sagen kann: jede undeklarierte Spalte ist eingeordnet. */ +export const KEINE_PERSON = ["eintraege.saeule_id", "treff_entfernt.eintrag_id"]; + +const GEHEIM = /hash|salt|kennung|token|secret|geheim/i; + +/** Jede Spalte im Haus, die auf einen Menschen zeigt. + * + * @returns {Array<{tabelle: string, spalte: string, quelle: string}>} */ +export function personenSpalten(d) { + const raus = []; + const tabellen = d.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") + .all().map((z) => z.name); + + for (const tabelle of tabellen) { + if (tabelle === "personen") continue; // die eigene Zeile kommt getrennt + let fks = []; + try { fks = d.prepare(`PRAGMA foreign_key_list(${tabelle})`).all(); } catch { fks = []; } + for (const f of fks) { + if (f.table === "personen") raus.push({ tabelle, spalte: f.from, quelle: "fremdschluessel" }); + } + } + for (const z of ZUSATZ) { + if (!tabellen.includes(z.tabelle)) continue; + if (raus.some((r) => r.tabelle === z.tabelle && r.spalte === z.spalte)) continue; + raus.push({ tabelle: z.tabelle, spalte: z.spalte, quelle: "nachgetragen" }); + } + return raus; +} + +/** Alle Spalten einer Tabelle, die auf einen Menschen zeigen. */ +function personenSpaltenVon(alle, tabelle) { + return alle.filter((s) => s.tabelle === tabelle).map((s) => s.spalte); +} + +/** Eine Zeile so aufbereiten, dass nichts darin steht, was nicht + * hineingehört. */ +function zeileSaeubern(zeile, personId, personSpalten) { + const raus = {}; + for (const [spalte, wert] of Object.entries(zeile)) { + if (GEHEIM.test(spalte)) { raus[spalte] = "(nicht enthalten – Zugangsgeheimnis)"; continue; } + if (personSpalten.includes(spalte) && wert !== null && Number(wert) !== Number(personId)) { + raus[spalte] = "(eine andere Person)"; + continue; + } + raus[spalte] = wert; + } + return raus; +} + +/** Was über diesen Menschen gespeichert ist. + * + * @param {number} personId + * @returns {object|null} null, wenn es die Person nicht gibt */ +export function auskunft(personId) { + const d = db(); + const person = d.prepare("SELECT * FROM personen WHERE id = ?").get(personId); + if (!person) return null; + + const spalten = personenSpalten(d); + const tabellen = []; + const nichtLesbar = []; + + /* Nach Tabelle buendeln: Eine Zeile, in der ich zweimal vorkomme + (erstellt_von UND creator_id), soll einmal dastehen, nicht zweimal. */ + const nachTabelle = new Map(); + for (const s of spalten) { + if (!nachTabelle.has(s.tabelle)) nachTabelle.set(s.tabelle, []); + nachTabelle.get(s.tabelle).push(s.spalte); + } + + for (const [tabelle, sp] of [...nachTabelle].sort((a, b) => a[0].localeCompare(b[0]))) { + try { + const wo = sp.map((c) => `"${c}" = ?`).join(" OR "); + const werte = sp.map(() => personId); + const anzahl = d.prepare(`SELECT COUNT(*) AS n FROM "${tabelle}" WHERE ${wo}`).get(...werte).n; + if (!anzahl) continue; + const zeilen = d.prepare( + `SELECT * FROM "${tabelle}" WHERE ${wo} LIMIT ${ZEILEN_MAX}`).all(...werte); + tabellen.push({ + tabelle, + gefunden_ueber: sp, + anzahl, + gekuerzt: anzahl > zeilen.length, + zeilen: zeilen.map((z) => zeileSaeubern(z, personId, personenSpaltenVon(spalten, tabelle))), + }); + } catch (f) { + /* DRITTER AUSGANG: Eine Tabelle, die sich nicht lesen laesst, ist + KEIN „nichts gefunden". Sie wird benannt -- sonst waere die + Auskunft still unvollstaendig, und das ist das Einzige, was + eine Auskunft nicht sein darf. */ + nichtLesbar.push({ tabelle, grund: String(f?.message || f).slice(0, 160) }); + } + } + + return { + stand: new Date().toISOString(), + person: zeileSaeubern(person, personId, []), + hinweis: "Zugangsgeheimnisse (Hash, Salt, Kennung) sind absichtlich nicht enthalten. " + + "Wo in einer Zeile eine andere Person steht, ist sie als „eine andere Person“ " + + "eingetragen – ihre Daten gehören ihr.", + durchsucht: nachTabelle.size, + gefunden: tabellen.length, + nicht_lesbar: nichtLesbar, + tabellen, + }; +} + +/* ===================================================================== + DIE WEGE + ===================================================================== */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +/** Über mich selbst — für JEDE Rolle, auch für die Community. + * + * Kein Recht, das man erst beantragen muss: Wer hier ist, kann + * nachsehen. Das ist der ganze Punkt von „auf Knopfdruck". */ +auskunftRouter.get("/workspace/api/auskunft/mich", angemeldet, (req, res) => { + try { + const d = auskunft(req.person.id); + if (!d) return res.status(404).json({ fehler: "nicht_gefunden" }); + protokolliere("auskunft_selbst", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${d.gefunden} von ${d.durchsucht} Stellen`, + }); + res.json(d); + } catch (fehler) { + console.error("[auskunft] selbst:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Über einen anderen Menschen — nur DogFather. + * + * Er ist der Verantwortliche im Sinne der Verordnung; wenn jemand ihn + * ausserhalb des Workspace um Auskunft bittet, muss er sie geben + * koennen. 404 statt 403 fuer alle anderen, wie ueberall im Haus. */ +auskunftRouter.get("/workspace/api/auskunft/:id(\\d+)", angemeldet, (req, res) => { + try { + if (!istDogFather(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + const id = Number(req.params.id); + const d = auskunft(id); + if (!d) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* WER UEBER WEN AUSKUNFT GEZOGEN HAT, GEHOERT INS PROTOKOLL. Eine + vollstaendige Sammlung der Daten eines Menschen ist das + Empfindlichste, was dieses Haus herausgibt. */ + protokolliere("auskunft_fremd", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `über #${id} (${d.person?.name || "?"}), ${d.gefunden} von ${d.durchsucht} Stellen`, + }); + res.json(d); + } catch (fehler) { + console.error("[auskunft] fremd:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index b00b81ce..cdd0b698 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609150036", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609150058", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609150036", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609150058", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609150036", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609150058", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609150036", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609150058", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 5d51dafd..143d9623 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -7934,3 +7934,24 @@ body.start .k-leiste .feldschild::after { .erkl__zeile { grid-template-columns: 1fr; gap: 1px; } .erkl__satz { font-size: .83rem; } } + +/* ===================================================================== + „WAS IST UEBER MICH GESPEICHERT?" (15.09.2026) + + Der Kasten steht auf zwei Seiten, die zu verschiedenen Stilvorlagen + gehoeren -- deshalb hier in start.css, die beide laden. Er soll nicht + um Aufmerksamkeit kaempfen: Wer ihn sucht, findet ihn; wer ihn nicht + braucht, liest darueber hinweg. + ===================================================================== */ + +.meine-daten { margin: 30px 0 34px; } +.meine-daten__kopf { + display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 8px; +} +.meine-daten__titel { + margin: 0; font-size: 1.05rem; font-weight: 640; color: var(--text); +} +.meine-daten__satz { + margin: 0 0 12px; max-width: 74ch; + font-size: .85rem; line-height: 1.6; color: var(--text-still); +} diff --git a/workspace/assets/js/auskunft.js b/workspace/assets/js/auskunft.js new file mode 100644 index 00000000..308fd936 --- /dev/null +++ b/workspace/assets/js/auskunft.js @@ -0,0 +1,54 @@ +/* ==================================================================== + Auskunft auf Knopfdruck — Art. 15 DSGVO (15.09.2026) + + Der Knopf holt die Auskunft und legt sie als Datei ab. Bewusst KEINE + Anzeige auf der Seite: Der Inhalt ist je nach Mensch ein paar Zeilen + oder ein paar tausend, und eine Seite, die sich beim Klick in eine + Datenwüste verwandelt, beantwortet die Frage schlechter als eine + Datei, die man in Ruhe ansehen, aufheben und weitergeben kann. + + Erzeugt wird sie im Browser aus der Antwort – es entsteht also keine + Datei auf dem Server, die dort liegen bliebe. + ==================================================================== */ +(() => { + const knopf = document.getElementById('auskunft-knopf'); + if (!knopf) return; + const stand = document.getElementById('auskunft-stand'); + + const sagen = (t) => { if (stand) { stand.textContent = t; stand.hidden = !t; } }; + + knopf.addEventListener('click', async () => { + knopf.disabled = true; + sagen('Wird zusammengestellt …'); + try { + const a = await fetch('/workspace/api/auskunft/mich', { credentials: 'same-origin' }); + if (!a.ok) { sagen('Das ging gerade nicht. Versuch es später noch einmal.'); return; } + const daten = await a.json(); + + const text = JSON.stringify(daten, null, 2); + const tag = new Date().toISOString().slice(0, 10); + const name = (daten.person?.name || 'meine-daten') + .toLowerCase().replace(/[^a-zäöüß0-9]+/g, '-').replace(/^-|-$/g, ''); + const blob = new Blob([text], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a2 = document.createElement('a'); + a2.href = url; + a2.download = `meine-daten-${name}-${tag}.json`; + document.body.append(a2); + a2.click(); + a2.remove(); + /* Erst freigeben, wenn der Browser den Griff hat. Sofort danach + ist der Verweis in manchen Browsern schon tot. */ + setTimeout(() => URL.revokeObjectURL(url), 30_000); + + sagen(`Datei erstellt: ${daten.gefunden} von ${daten.durchsucht} Stellen enthalten etwas über dich.` + + (daten.nicht_lesbar?.length + ? ` Achtung: ${daten.nicht_lesbar.length} Stelle(n) konnten nicht gelesen werden – sag DogFather Bescheid.` + : '')); + } catch { + sagen('Keine Verbindung. Versuch es später noch einmal.'); + } finally { + knopf.disabled = false; + } + }); +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 7e538250..50d89018 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - + @@ -252,11 +252,11 @@ - - - - - - + + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 9563b56a..d1c33902 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -168,11 +168,11 @@ - - - - - - + + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 130e3dfa..032e6a8f 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,15 +181,15 @@ - - - - - - - - - - + + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index a21629d6..dc105303 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,12 +89,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 31436556..757cd484 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,11 +341,11 @@ - - - - - - + + + + + + diff --git a/workspace/content.html b/workspace/content.html index abf78dd4..423ebe06 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,13 +179,13 @@ - - - - - - - - + + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 8b03343e..80f82ff5 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -319,6 +319,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index e2e9843d..7a76d3e6 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609150036", + "src": "/assets/img/app-symbole/crew-192.png?v=202609150058", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609150036", + "src": "/assets/img/app-symbole/crew-512.png?v=202609150058", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609150036", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609150058", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609150036", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609150058", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index ee84aea7..da140d03 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,11 +108,11 @@ - - - - - - + + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 293a0039..6e429657 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,10 +94,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index b7c0232e..be9a9e76 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 8f6a0b2a..aa3a58d5 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,12 +377,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 93b08117..b76ab5f4 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,11 +423,11 @@ - - - - - - + + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 62eeb7b6..f6f518a7 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -423,11 +423,11 @@ - - - - - - + + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 99cd7960..50ef5565 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,12 +214,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 847eeb0c..2b3c3231 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,10 +134,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - + + + + + diff --git a/workspace/report.html b/workspace/report.html index cb836f7f..f3c2448c 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,12 +106,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index aeee9ad6..20d05be7 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,11 +173,11 @@ - - - - - - + + + + + + diff --git a/workspace/start.html b/workspace/start.html index 55ff4e7a..0fbdd1dc 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,11 +674,11 @@

- - - - - - + + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 6d05ded0..94afc5b5 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,12 +85,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 0ae8901c..ceaa1281 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -124,13 +124,34 @@
+ + +
+
+

Was ist über mich gespeichert?

+ +
+

+ Alles, was dieser Workspace über dich weiß, als Datei zum Mitnehmen. + Zugangsgeheimnisse sind nicht dabei, und wo andere Menschen vorkommen, + stehen sie nicht mit Namen darin – ihre Daten gehören ihnen. +

+ +
+ - - - - - - + + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 590b9dea..e5d80355 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,10 +94,10 @@ - - - - - + + + + + diff --git a/workspace/team.html b/workspace/team.html index 199c7021..746ec582 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,11 +110,11 @@ - - - - - - + + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 8f5baf39..6c53fb49 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - - + + + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html index 26a1e38e..6764eb71 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index 5235d568..3d127be7 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -177,6 +177,26 @@ + + +
+
+

Was ist über mich gespeichert?

+ +
+

+ Alles, was dieser Workspace über dich weiß, als Datei zum Mitnehmen. + Zugangsgeheimnisse sind nicht dabei, und wo andere Menschen vorkommen, + stehen sie nicht mit Namen darin – ihre Daten gehören ihnen. +

+ +
+ - - - - - + + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index fb46fbac..fbb91936 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,12 +149,12 @@ - + - - - - - + + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 21909d89..d8205b25 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,11 +190,11 @@ - - - - - - + + + + + +