diff --git a/server/index.js b/server/index.js index 2718aee3..71b9949a 100644 --- a/server/index.js +++ b/server/index.js @@ -27,6 +27,7 @@ import { treffRouter } from "./workspace-treff.js"; 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 { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; import { callRouter } from "./workspace-calls.js"; @@ -254,6 +255,7 @@ app.use(entwicklungRouter); /* Die Rechtetafel -- ansehen und umstellen (11.09.2026). */ app.use(rechteRouter); app.use(erklaerungRouter); +app.use(aufbewahrungRouter); app.use(reportRouter); app.use(scoutRouter); app.use(callRouter); @@ -269,6 +271,7 @@ app.use(contentRouter); workspace-sicherung.js, dort steht auch, warum sie NICHT als eigener Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */ sicherungStarten(); +aufbewahrungStarten(); /* Wiederkehrende Termine einmal auffuellen. Kein Takt, kein Dienst -- siehe workspace-serien.js: Der Nachfueller legt nur an, was fehlt, und diff --git a/server/pruef-aufbewahrung.mjs b/server/pruef-aufbewahrung.mjs new file mode 100644 index 00000000..048ad591 --- /dev/null +++ b/server/pruef-aufbewahrung.mjs @@ -0,0 +1,383 @@ +/* ===================================================================== + Das Löschkonzept (15.09.2026) + + Stufe 8 aus dem Plan: „Löschkonzept je Datenart · IP-Adressen im + Protokoll nach 30 Tagen weg." + + --------------------------------------------------------------------- + BEI ETWAS, DAS LÖSCHT, IST DIE WICHTIGERE HÄLFTE: WAS ES NICHT LÖSCHT + + Eine Aufräumfunktion, die zu viel mitnimmt, fällt erst auf, wenn + jemand etwas sucht, das es nicht mehr gibt — und dann ist es weg. + Deshalb steht zu jeder Löschung hier eine Gegenprobe daneben: + + IP nach 30 Tagen weg → und die von gestern bleibt + IP weg → und die ZEILE bleibt (nur das Feld) + abgelaufene Sitzung weg → und die gültige bleibt + abgelaufene Sitzung weg → und ich bin danach noch angemeldet + + Die letzte ist die unbequemste und die wichtigste: Ein Aufräumlauf, + der die eigene Anmeldung mitnimmt, wirft im Betrieb alle hinaus. + + Aufrufen mit: node server/pruef-aufbewahrung.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(4410, "pruef-aufbewahrung"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-aufb-")); +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-aufbewahrung"); +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-aufbewahrung.js"); + +const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); +const vorTagen = (n) => new Date(Date.now() - n * 86400000).toISOString(); +const inTagen = (n) => new Date(Date.now() + n * 86400000).toISOString(); + +{ + const d = oeffne(); + const jetzt = new Date().toISOString(); + 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; + }; + anlegen("Filipe", "admin", "CODE-DOGI-0001"); + anlegen("Rieke", "hand", "CODE-HAND-0001"); + d.close(); +} + +function roh(pfad, host, kopf = {}, koerper = null, methode = null) { + if (methode === "DELETE") koerper = null; + return new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, + method: methode || (koerper ? "POST" : "GET"), + headers: { Host: host, ...(koerper ? { "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); + if (koerper) 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 roh("/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 kHand = await anmelden("hand", "CODE-HAND-0001", CREW); +ok(!!kDogi && !!kHand, "DogFather und die rechte Hand sind angemeldet"); + +/* ======================================================================= + 1. DIE TAFEL IST VOLLSTÄNDIG + ======================================================================= */ +melde(""); +melde("=== 1. Die Tafel ==="); +{ + const f = modul.FRISTEN; + ok(f.length >= 5, `${f.length} Datenarten im Löschkonzept`); + ok(new Set(f.map((x) => x.schluessel)).size === f.length, "kein Schlüssel doppelt"); + + const unvollstaendig = f.filter((x) => + !x.was || !x.frist || !x.grundlage || !x.wirkung || !x.art); + ok(unvollstaendig.length === 0, + unvollstaendig.length ? `UNVOLLSTÄNDIG: ${unvollstaendig.map((x) => x.schluessel).join(", ")}` + : "jede Datenart nennt Frist, Rechtsgrundlage und Wirkung"); + + const arten = new Set(f.map((x) => x.art)); + ok([...arten].every((a) => ["raeumen", "fremd", "bleibt"].includes(a)), + `nur die drei bekannten Arten (${[...arten].join(", ")})`); + /* Ein Loeschkonzept braucht beides: was geht und was bleibt. Stuende + hier nur „raeumen", waere es eine Aufraeumliste, kein Konzept. */ + ok(arten.has("raeumen") && arten.has("bleibt"), + "es gibt sowohl Gelöschtes als auch begründet Aufgehobenes"); + + ok(f.every((x) => typeof x.offen === "function"), + "jede Zeile kann sagen, wie viele Datensätze sie betrifft"); + ok(f.filter((x) => x.art === "raeumen").every((x) => typeof x.raeumen === "function"), + "und jede, die räumt, kann es auch"); + ok(f.filter((x) => x.art !== "raeumen").every((x) => typeof x.raeumen !== "function"), + "was nicht räumen soll, kann es auch nicht — „bleibt“ ist keine Absichtserklärung"); +} + +/* ======================================================================= + 2. IP-ADRESSEN NACH 30 TAGEN — UND DIE VON GESTERN BLEIBT + ======================================================================= */ +melde(""); +melde("=== 2. IP-Adressen im Protokoll ==="); +{ + const d = oeffne(); + const rein = d.prepare( + "INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)"); + rein.run(vorTagen(modul.IP_TAGE + 10), null, null, "test_alt", "ALT", "203.0.113.7"); + rein.run(vorTagen(modul.IP_TAGE - 1), null, null, "test_jung", "JUNG", "203.0.113.8"); + rein.run(vorTagen(modul.IP_TAGE + 10), null, null, "test_ohne", "OHNE", null); + d.close(); + + const vorher = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); + const zeile = (vorher.fristen || []).find((z) => z.schluessel === "protokoll_ip"); + ok(zeile && zeile.anzahl >= 1, `${zeile?.anzahl} IP(s) warten auf das Löschen`); + + const lauf = await roh("/workspace/api/aufbewahrung/jetzt", HAUS, { cookie: kDogi }, {}); + ok(lauf.code === 200, `der Lauf geht durch (${lauf.code})`); + const getan = json(lauf).getan || []; + ok(getan.some((g) => g.schluessel === "protokoll_ip" && g.anzahl >= 1), + `und meldet, was er getan hat (${JSON.stringify(getan)})`); + + const d2 = oeffne(); + const alt = d2.prepare("SELECT ip FROM protokoll WHERE aktion = 'test_alt'").get(); + const jung = d2.prepare("SELECT ip FROM protokoll WHERE aktion = 'test_jung'").get(); + const zeilen = d2.prepare("SELECT COUNT(*) AS n FROM protokoll WHERE aktion LIKE 'test_%'").get().n; + d2.close(); + + ok(alt && alt.ip === null, "die IP von vor 40 Tagen ist weg"); + /* GEGENPROBE 1 -- ohne sie beweist die Zeile darüber nichts: Eine + Funktion, die ALLE IPs leert, bestünde sie genauso. */ + ok(jung && jung.ip === "203.0.113.8", + `die von vor ${modul.IP_TAGE - 1} Tagen steht noch da (${jung?.ip})`); + /* GEGENPROBE 2: Geleert wird das FELD, nicht die Zeile. Das Protokoll + ist die Sicherung gegen stille Änderungen -- ein Aufräumlauf, der + Einträge entfernt, hebelt genau die aus. */ + ok(zeilen === 3, `alle ${zeilen} Protokollzeilen stehen noch (nur das Feld wurde geleert)`); + + const nachher = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); + const zeile2 = (nachher.fristen || []).find((z) => z.schluessel === "protokoll_ip"); + ok(zeile2 && zeile2.anzahl === 0, `danach wartet keine mehr (${zeile2?.anzahl})`); +} + +/* ======================================================================= + 3. ABGELAUFENE ANMELDUNGEN — UND MEINE EIGENE BLEIBT + ======================================================================= */ +melde(""); +melde("=== 3. Abgelaufene Anmeldungen ==="); +{ + const d = oeffne(); + const vorher = d.prepare("SELECT COUNT(*) AS n FROM sitzungen").get().n; + d.prepare("INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser)" + + " VALUES (?,?,?,?,?,?)") + .run("test-abgelaufen", 1, vorTagen(3), vorTagen(1), "203.0.113.9", "Testbrowser"); + d.prepare("INSERT INTO sitzungen (token_hash, person_id, erstellt, gueltig_bis, ip, browser)" + + " VALUES (?,?,?,?,?,?)") + .run("test-gueltig", 1, new Date().toISOString(), inTagen(1), "203.0.113.10", "Testbrowser"); + d.close(); + ok(vorher >= 2, `${vorher} echte Anmeldungen vorhanden (meine eigene ist dabei)`); + + const lauf = json(await roh("/workspace/api/aufbewahrung/jetzt", HAUS, { cookie: kDogi }, {})); + ok((lauf.getan || []).some((g) => g.schluessel === "sitzungen_abgelaufen" && g.anzahl === 1), + `genau eine abgelaufene Anmeldung geräumt (${JSON.stringify(lauf.getan)})`); + + const d2 = oeffne(); + const weg = d2.prepare("SELECT 1 FROM sitzungen WHERE token_hash = 'test-abgelaufen'").get(); + const da = d2.prepare("SELECT 1 FROM sitzungen WHERE token_hash = 'test-gueltig'").get(); + const uebrig = d2.prepare("SELECT COUNT(*) AS n FROM sitzungen").get().n; + d2.close(); + ok(!weg, "die abgelaufene ist weg"); + ok(!!da, "die gültige steht noch da"); + ok(uebrig === vorher + 1, `und alle echten auch (${uebrig} statt ${vorher + 1})`); + + /* DIE UNBEQUEMSTE GEGENPROBE: Bin ich danach noch angemeldet? Ein + Aufräumlauf, der die eigene Sitzung mitnimmt, wirft im Betrieb + alle hinaus -- und zwar genau einmal täglich. */ + const ich = await roh("/workspace/api/ich", HAUS, { cookie: kDogi }); + ok(ich.code === 200, `ich bin nach dem Lauf noch angemeldet (${ich.code})`); + const sie = await roh("/workspace/api/ich", CREW, { cookie: kHand }); + ok(sie.code === 200, `die rechte Hand auch (${sie.code})`); +} + +/* ======================================================================= + 4. WER DIE TAFEL SEHEN DARF + ======================================================================= */ +melde(""); +melde("=== 4. Wer sie sieht ==="); +{ + const hand = await roh("/workspace/api/aufbewahrung", CREW, { cookie: kHand }); + ok(hand.code === 404, `die rechte Hand bekommt 404 statt 403 (${hand.code})`); + ok(!/Rechtsgrundlage|Art\. 6/.test(hand.text), "und kein Wort aus der Tafel"); + + const ohne = await roh("/workspace/api/aufbewahrung", HAUS, {}); + ok(ohne.code === 401, `ohne Anmeldung 401 (${ohne.code})`); + + const laufFremd = await roh("/workspace/api/aufbewahrung/jetzt", CREW, { cookie: kHand }, {}); + ok(laufFremd.code === 404, `und niemand sonst stößt den Lauf an (${laufFremd.code})`); + + /* GEGENPROBE: DogFather kann beides -- sonst wäre „404“ oben auch + dann grün, wenn es den Weg gar nicht gibt. */ + const chef = await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi }); + ok(chef.code === 200, `DogFather schon (${chef.code})`); +} + +/* ======================================================================= + 5. DER LAUF HINTERLÄSST EINE SPUR + ======================================================================= */ +melde(""); +melde("=== 5. Nachvollziehbar ==="); +{ + const d = oeffne(); + const spur = d.prepare( + "SELECT COUNT(*) AS n FROM protokoll WHERE aktion LIKE 'aufbewahrung%'").get().n; + d.close(); + ok(spur >= 1, `${spur} Protokolleinträge über das Aufräumen selbst`); + + const tafel = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); + ok(tafel.letzte && tafel.letzte.zeitpunkt, + `der letzte Lauf steht in der Tafel (${tafel.letzte?.zeitpunkt?.slice(0, 16)})`); + ok(Array.isArray(tafel.letzte?.fehler) && tafel.letzte.fehler.length === 0, + "und er lief ohne Fehler"); + + /* EIN ZWEITER LAUF DARF NICHTS MEHR FINDEN -- und muss das auch sagen + dürfen. Eine Funktion, die jedes Mal „aufgeräumt" meldet, ohne dass + etwas da war, wäre nicht zu unterscheiden von einer, die nichts tut. */ + const zweiter = json(await roh("/workspace/api/aufbewahrung/jetzt", HAUS, { cookie: kDogi }, {})); + ok((zweiter.getan || []).length === 0, + `der zweite Lauf findet nichts mehr (${JSON.stringify(zweiter.getan)})`); +} + +/* ======================================================================= + 6. DRITTER AUSGANG: WAS, WENN EINE TABELLE FEHLT? + ======================================================================= */ +melde(""); +melde("=== 6. Konnte nicht nachsehen ==="); +{ + /* Eine Zählung, die scheitert, muss `null` liefern -- nicht 0. „Null + Datensätze betroffen" und „konnte nicht nachsehen" sind zwei + verschiedene Auskünfte, und die zweite darf nicht wie die erste + aussehen. */ + const d = oeffne(); + const erfunden = { + offen: (dd) => { + try { return dd.prepare("SELECT COUNT(*) AS n FROM gibt_es_nicht").get()?.n ?? null; } + catch { return null; } + }, + }; + const wert = erfunden.offen(d); + d.close(); + ok(wert === null, `eine Zählung über eine fehlende Tabelle ergibt null, nicht 0 (${wert})`); + + const tafel = json(await roh("/workspace/api/aufbewahrung", HAUS, { cookie: kDogi })); + const zahlen = (tafel.fristen || []).map((z) => z.anzahl); + ok(zahlen.length === modul.FRISTEN.length && zahlen.every((z) => z !== null), + `alle ${zahlen.length} Zeilen konnten wirklich zählen (${zahlen.join(", ")})`); +} + +/* ======================================================================= + 7. UND STEHT ES AUCH AUF DEM BILDSCHIRM? + + Ein Loeschkonzept, das nur in einer Schnittstelle antwortet, ist ein + Dokument in einer Schublade. Geprueft wird deshalb die Seite selbst -- + und der Knopf: Er muss eine Zahl wirklich fallen lassen. + ======================================================================= */ +melde(""); +melde("=== 7. Auf der Seite ==="); +{ + const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await chromium.launch(); + const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + + await seite.goto(`http://127.0.0.1:${PORT}/workspace/`, { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 20000 }); + + /* Etwas zu raeumen anlegen, DAMIT DER KNOPF ETWAS ZU TUN HAT. + Ohne das waere „die Zahl faellt" trivial wahr: sie stand schon + auf null. */ + { + const d = oeffne(); + d.prepare( + "INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)") + .run(vorTagen(modul.IP_TAGE + 5), null, null, "test_bildschirm", "X", "203.0.113.11"); + d.close(); + } + + await seite.goto(`http://127.0.0.1:${PORT}/workspace/automation.html`, { waitUntil: "networkidle" }); + await seite.waitForSelector(".aufb", { timeout: 8000 }).catch(() => {}); + + const stand = await seite.evaluate(() => { + const zeilen = [...document.querySelectorAll(".aufb")]; + return { + anzahl: zeilen.length, + arten: [...new Set(zeilen.map((z) => z.dataset.art))].sort(), + schilder: [...new Set(zeilen.map((z) => z.querySelector(".aufb__art")?.textContent.trim()))], + offen: zeilen.filter((z) => z.querySelector('.aufb__zahl[data-offen="ja"]')).length, + text: document.getElementById("aufbewahrung")?.innerText || "", + }; + }); + ok(stand.anzahl === modul.FRISTEN.length, + `alle ${stand.anzahl} Datenarten stehen auf der Seite`); + /* Die Sorte darf nicht NUR an der Farbe haengen -- wer Farben + schlecht sieht, muss dasselbe lesen koennen. */ + ok(stand.schilder.some((s) => /gelöscht/i.test(s || "")) && stand.schilder.some((s) => /bleibt/i.test(s || "")), + `die Sorte steht als Wort daneben, nicht nur als Farbe (${stand.schilder.join(" / ")})`); + ok(stand.offen >= 1, `${stand.offen} Zeile(n) melden offene Datensätze`); + ok(/Art\. 6|Art\. 17/.test(stand.text), "die Rechtsgrundlage steht dabei"); + + /* DER KNOPF MUSS WIRKEN -- gemessen an der Zahl, nicht am Gefuehl. */ + await seite.click("#aufb-jetzt"); + await seite.waitForFunction( + () => !document.querySelector('.aufb__zahl[data-offen="ja"]'), + null, { timeout: 8000 }).catch(() => {}); + const nachher = await seite.evaluate(() => + document.querySelectorAll('.aufb__zahl[data-offen="ja"]').length); + ok(nachher === 0, `nach dem Knopf wartet nichts mehr (${nachher})`); + + const d3 = oeffne(); + const weg = d3.prepare("SELECT ip FROM protokoll WHERE aktion = 'test_bildschirm'").get(); + d3.close(); + ok(weg && weg.ip === null, "und die IP ist in der Datenbank wirklich weg"); + + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); + await browser.close(); +} +/* ------------------------------------------------------------------- */ +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/pruef-erklaerung.mjs b/server/pruef-erklaerung.mjs index 46a1bc45..b89bbc7b 100644 --- a/server/pruef-erklaerung.mjs +++ b/server/pruef-erklaerung.mjs @@ -173,6 +173,37 @@ melde("=== 2. Kurz und knapp ==="); : "keine Klickanleitungen — erklärt wird der Zweck, nicht der Weg"); ok(/klicke/i.test("dann klicke oben rechts"), "und der Sucher findet so eine auch wirklich"); + /* JEDE ZEILE SAGT ETWAS EIGENES. + + Gemessen wird der Anteil der langen Woerter, die in BEIDEN Zeilen + vorkommen. Ein paar gemeinsame sind unvermeidlich und richtig -- + „Zeitraum" steht in „Wofuer" und in „Du tust", weil es um den + Zeitraum geht. Ueber 40 Prozent ist es keine zweite Auskunft mehr, + sondern derselbe Satz in anderer Reihenfolge. */ + const lang = (s) => new Set((s.toLowerCase().match(/[a-zäöüß]{5,}/g) || [])); + const echo = alle.map(([seite, e]) => { + const a = lang(e.wofuer), b = lang(e.tun); + if (!a.size) return null; + let gleich = 0; + for (const x of a) if (b.has(x)) gleich++; + return { seite, anteil: gleich / a.size, gleich, gesamt: a.size }; + }).filter(Boolean); + ok(echo.length === alle.length, `${echo.length} Paare „Wofür“/„Du tust“ verglichen`); + const doppelt = echo.filter((x) => x.anteil >= 0.4); + ok(doppelt.length === 0, + doppelt.length + ? "SPRECHEN EINANDER NACH: " + doppelt.map((x) => + `${x.seite} (${Math.round(x.anteil * 100)} %, ${x.gleich}/${x.gesamt})`).join(", ") + : `keine Seite sagt zweimal dasselbe (schlechtester Wert: ${Math.round(Math.max(...echo.map((x) => x.anteil)) * 100)} %)`); + /* GEGENPROBE zum Messverfahren selbst -- sonst waere „keine Seite" + auch dann gruen, wenn der Vergleich gar nichts findet. */ + { + const a = lang("Alle Aufgaben des Teams mit Frist und Rückmeldungen"); + const b = lang("Alle Aufgaben des Teams mit Frist und Rückmeldungen ansehen"); + let g = 0; for (const x of a) if (b.has(x)) g++; + ok(g / a.size >= 0.9, `und zwei fast gleiche Sätze erkennt es als solche (${g}/${a.size})`); + } + /* HAUSREGEL: echte Umlaute in allem, was ein Mensch liest. */ const ascii = alle.filter(([, e]) => /\b(fuer|koenn|muess|ueber|waer|moecht|oeffn|schliess)/i.test( diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js new file mode 100644 index 00000000..872b44b2 --- /dev/null +++ b/server/workspace-aufbewahrung.js @@ -0,0 +1,263 @@ +/* ===================================================================== + 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(); + +/** 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: "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 + ===================================================================== */ + +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. */ +aufbewahrungRouter.post("/workspace/api/aufbewahrung/jetzt", 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" }); + } +}); diff --git a/server/workspace-erklaerung.js b/server/workspace-erklaerung.js index e9d9811a..1300e9dd 100644 --- a/server/workspace-erklaerung.js +++ b/server/workspace-erklaerung.js @@ -101,7 +101,7 @@ export const ERKLAERUNGEN = { }, "report.html": { wofuer: "Je Creator und Zeitraum: was lief, was nicht, was als Nächstes kommt.", - tun: "Den Zeitraum durchgehen und drei Dinge festhalten: was lief, was nicht, was als Nächstes kommt.", + tun: "Zeitraum wählen und den Rückblick schreiben, solange die Zahlen dazu danebenstehen.", }, "leistung.html": { wofuer: "Livestunden, Zuschauer und Einnahmen je Tag – aus Backstage oder von Hand.", @@ -113,7 +113,7 @@ export const ERKLAERUNGEN = { }, "startcheck.html": { wofuer: "Ein Fragebogen je neuem Creator und die Arbeitspakete, die daraus entstehen.", - tun: "Die Fragen durchgehen; daraus entstehen automatisch die ersten Arbeitspakete.", + tun: "Gemeinsam mit ihr ausfüllen, nicht über sie – die Antworten sind der Anfang eurer Zusammenarbeit.", }, "profil.html": { wofuer: "Zahlen, Aufgaben, Dateien, Termine und Verlauf zu einer Creatorin an einer Stelle.", @@ -160,7 +160,7 @@ export const ERKLAERUNGEN = { }, "treff-regeln.html": { wofuer: "Die Regeln des Treffs und was passiert, wenn sie jemand nicht einhält.", - tun: "Einmal lesen. Was hier steht, gilt auch – und du siehst, was passiert, wenn es jemand nicht einhält.", + tun: "Einmal lesen. Danach weißt du, was hier gilt und an wen du dich wendest, wenn etwas schiefläuft.", }, "treff-moderation.html": { wofuer: "Offene Meldungen, erledigte Fälle und jede Maßnahme mit Begründung.", diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 86a7dae9..b00b81ce 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=202609142005", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609150036", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609142005", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609150036", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609142005", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609150036", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609142005", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609150036", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/automation.css b/workspace/assets/css/automation.css index 40256266..9551ea15 100644 --- a/workspace/assets/css/automation.css +++ b/workspace/assets/css/automation.css @@ -435,3 +435,51 @@ .ki-kasten { background: Canvas; box-shadow: none; border: 1px solid CanvasText; } .ki-kasten::after { display: none; } } + +/* ===================================================================== + DAS LOESCHKONZEPT (15.09.2026) + + Eine Zeile je Datenart. Die Kante links sagt die Sorte: geloescht, + anderswo geloescht, bleibt. Farbe ist dabei NICHT die einzige + Auskunft -- daneben steht das Wort. Wer Farben schlecht sieht, liest + dasselbe. + ===================================================================== */ + +.aufb { + margin: 0 0 10px; padding: 13px 16px; + background: color-mix(in srgb, var(--tinte) 94%, #ffffff 6%); + border: 1px solid var(--rand); + border-left: 3px solid var(--rand-hell); +} +.aufb[data-art="raeumen"] { border-left-color: color-mix(in srgb, #cc9451 70%, transparent); } +.aufb[data-art="fremd"] { border-left-color: color-mix(in srgb, #8ec9ff 55%, transparent); } +.aufb[data-art="bleibt"] { border-left-color: color-mix(in srgb, #668e6e 65%, transparent); } + +.aufb__kopf { display: flex; flex-wrap: wrap; align-items: baseline; gap: 9px; } +.aufb__was { margin: 0; font-size: .95rem; font-weight: 640; color: var(--text); } +.aufb__art { + font-size: .72rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; + color: var(--text-still); +} +.aufb__frist { + margin-left: auto; font-size: .8rem; color: var(--text-leise); + font-variant-numeric: tabular-nums; +} +.aufb__zeile { + margin: 7px 0 0; max-width: 76ch; + font-size: .83rem; line-height: 1.6; color: var(--text-leise); +} +.aufb__grund { + margin: 6px 0 0; font-size: .76rem; color: var(--text-still); font-style: italic; +} +.aufb__zahl { + margin: 9px 0 0; font-size: .8rem; font-weight: 640; color: var(--text-leise); + font-variant-numeric: tabular-nums; +} +.aufb__zahl[data-offen="ja"] { color: #d8a76a; } +.aufb__zahl[data-unklar="ja"] { color: var(--text-still); font-style: italic; font-weight: 500; } +.aufb__letzte { margin: 14px 0 0; font-size: .8rem; color: var(--text-still); } + +@media (max-width: 560px) { + .aufb__frist { margin-left: 0; width: 100%; } +} diff --git a/workspace/assets/js/automation.js b/workspace/assets/js/automation.js index 1ce418b0..c79ee5d6 100644 --- a/workspace/assets/js/automation.js +++ b/workspace/assets/js/automation.js @@ -364,6 +364,98 @@ ich = await a.json(); } catch { return; } window.werZeigen?.(ich); + /* Ueber window und mit ?. -- die Funktion steht weiter unten in + derselben Datei. Als blanker Name haenge die Seite an der + Reihenfolge des Einlesens; so nicht. */ + window.aufbewahrungLaden?.(); await Promise.all([kiZustand(), zustand(), hinweise(), verlauf()]); })(); })(); + +/* ===================================================================== + DAS LOESCHKONZEPT (15.09.2026) + + Drei Sorten Zeile, und die Zahl daneben bedeutet je nach Sorte etwas + anderes -- deshalb wird sie auch anders beschriftet: + + raeumen "0 warten" was noch zu tun ist + fremd "0 warten" dasselbe, nur raeumt eine andere Stelle + bleibt "13 gespeichert" was absichtlich daliegt + + Eine Zahl ohne Beschriftung waere hier die schlechteste Loesung: + "13" unter einer Loeschregel liest sich wie ein Rueckstand. + ===================================================================== */ +(() => { + const $ = (id) => document.getElementById(id); + const el = (t, k, text) => { + const n = document.createElement(t); + if (k) n.className = k; + if (text !== undefined) n.textContent = text; + return n; + }; + + const SCHILD = { raeumen: 'wird gelöscht', fremd: 'wird anderswo gelöscht', bleibt: 'bleibt' }; + + function zeichne(daten) { + const ziel = $('aufbewahrung'); + if (!ziel) return; + ziel.textContent = ''; + + for (const f of daten.fristen || []) { + const z = el('article', 'aufb'); + z.dataset.art = f.art; + + const kopf = el('div', 'aufb__kopf'); + kopf.append(el('h3', 'aufb__was', f.was)); + kopf.append(el('span', 'aufb__art', SCHILD[f.art] || f.art)); + kopf.append(el('span', 'aufb__frist', f.frist)); + z.append(kopf); + + if (f.zweck && f.zweck !== '—') z.append(el('p', 'aufb__zeile', 'Wofür: ' + f.zweck)); + z.append(el('p', 'aufb__zeile', f.wirkung)); + z.append(el('p', 'aufb__grund', f.grundlage)); + + /* DIE ZAHL. `null` heisst "konnte nicht nachsehen" und wird auch + so geschrieben -- nicht als 0. */ + const n = el('p', 'aufb__zahl'); + if (f.anzahl === null || f.anzahl === undefined) { + n.textContent = 'konnte nicht nachsehen'; + n.dataset.unklar = 'ja'; + } else if (f.art === 'bleibt') { + n.textContent = `${f.anzahl} gespeichert`; + } else { + n.textContent = f.anzahl === 0 ? 'nichts offen' : `${f.anzahl} warten auf das Löschen`; + if (f.anzahl > 0) n.dataset.offen = 'ja'; + } + z.append(n); + ziel.append(z); + } + + if (daten.letzte?.zeitpunkt) { + const d = new Date(daten.letzte.zeitpunkt); + const getan = (daten.letzte.getan || []).reduce((s, g) => s + g.anzahl, 0); + ziel.append(el('p', 'aufb__letzte', + `Zuletzt gelaufen: ${d.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })}` + + (getan ? ` – ${getan} Datensätze geräumt.` : ' – nichts zu tun.'))); + } + } + + window.aufbewahrungLaden = async function () { + if (!$('aufbewahrung')) return; + try { + const a = await fetch('/workspace/api/aufbewahrung', { credentials: 'same-origin' }); + if (!a.ok) { $('aufbewahrung').textContent = ''; return; } + zeichne(await a.json()); + } catch { /* die Seite ist auch ohne vollstaendig */ } + }; + + $('aufb-jetzt')?.addEventListener('click', async (e) => { + const k = e.currentTarget; + k.disabled = true; + try { + await fetch('/workspace/api/aufbewahrung/jetzt', + { method: 'POST', credentials: 'same-origin' }); + await window.aufbewahrungLaden(); + } finally { k.disabled = false; } + }); +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index cdc7de30..7e538250 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 cff2aa10..9563b56a 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. --> - - - - - - - + + + + + + + - + @@ -128,6 +128,27 @@
+ +
+
+

Was gelöscht wird – und was bleibt

+ +
+

+ Personenbezogene Daten werden nicht aufgehoben, weil Platz da ist, sondern + solange es einen Grund gibt. Läuft täglich von selbst. +

+

Wird geladen …

+
+
@@ -147,11 +168,11 @@ - - - - - - + + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index dba126e6..130e3dfa 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 0decddd7..a21629d6 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 62f4575e..31436556 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 09fa8591..abf78dd4 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 26832013..8b03343e 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 66afa666..e2e9843d 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=202609142005", + "src": "/assets/img/app-symbole/crew-192.png?v=202609150036", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609142005", + "src": "/assets/img/app-symbole/crew-512.png?v=202609150036", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609142005", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609150036", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609142005", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609150036", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 8c6547af..ee84aea7 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 fe7336c3..293a0039 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 378d3396..b7c0232e 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 5cf32411..8f6a0b2a 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 245d99a2..93b08117 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 c5f35d54..62eeb7b6 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 91a5a991..99cd7960 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 390305cc..847eeb0c 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 4a50e137..cb836f7f 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 f035a427..aeee9ad6 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 ee16cf1f..55ff4e7a 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 da7df9d4..6d05ded0 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 7834509a..0ae8901c 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. --> - - - - - - - + + + + + + + - + @@ -126,11 +126,11 @@ - - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index d1185f43..590b9dea 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 cf9cf391..199c7021 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 f50bee6e..8f5baf39 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 26674164..26a1e38e 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 7af400bb..5235d568 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -190,10 +190,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/uebersicht.html b/workspace/uebersicht.html index 9bfc15a8..fb46fbac 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 5e93975b..21909d89 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 @@ - - - - - - + + + + + +