diff --git a/server/pruef-alter.mjs b/server/pruef-alter.mjs new file mode 100644 index 00000000..113939e8 --- /dev/null +++ b/server/pruef-alter.mjs @@ -0,0 +1,344 @@ +/* ===================================================================== + Das Mindestalter wird eingelöst, nicht behauptet (15.09.2026) + + Letztes Stück von Stufe 8. Auf der Regelseite des Treffs steht seit + dem 11.09. „ab 18" — und geprüft wurde es nirgends. Eine Regel, die + nur dasteht, ist keine; im Streitfall ist sie sogar schlechter als + keine, weil man sie versprochen hat. + + --------------------------------------------------------------------- + VIER FRAGEN + + 1. Kommt jemand ohne Bestätigung herein? (darf nicht) + 2. Kommt er mit Bestätigung herein? (muss) + 3. Wird er beim ZWEITEN Mal wieder gefragt? (darf nicht) + 4. Wird das Team gefragt? (darf nicht) + + Die dritte ist die, die man vergisst — und sie ist die, an der eine + solche Abfrage im Alltag scheitert: Wer sie jedes Mal sieht, klickt + sie weg, ohne sie zu lesen, und dann bestätigt sie nichts mehr. + + Dazu die unbequemste: Ein richtiger Code mit fehlender Bestätigung + darf NICHT als Fehlversuch zählen — sonst sperrt sich jemand mit dem + richtigen Code nach acht Anläufen selbst aus. + + Aufrufen mit: node server/pruef-alter.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(4413, "pruef-alter"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-alter-")); +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-alter"); +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 { MINDESTALTER } = await import("./treff-tabellen.js"); + +const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +let idKessi, idRieke; +{ + 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; + }; + anlegen("Filipe", "admin", "CODE-DOGI-0001"); + idRieke = anlegen("Rieke", "hand", "CODE-HAND-0001"); + idKessi = anlegen("Kessi", "gast", "CODE-GAST-0001"); + d.close(); +} + +function post(pfad, host, koerper, kopf = {}) { + 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(); + }); +} +function hole(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(); + }); +} +const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; +const keks = (a) => [].concat(a.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +const alterVon = (id) => { + const d = oeffne(); + const w = d.prepare("SELECT alter_bestaetigt_am FROM personen WHERE id = ?").get(id)?.alter_bestaetigt_am; + d.close(); + return w; +}; + +/* ======================================================================= + 1. OHNE BESTÄTIGUNG KOMMT NIEMAND HEREIN + ======================================================================= */ +melde(""); +melde("=== 1. Ohne Bestätigung ==="); +{ + ok(MINDESTALTER === 18, `das Mindestalter steht bei ${MINDESTALTER}`); + ok(alterVon(idKessi) === null, "Kessi hat noch nichts bestätigt"); + + const ohne = await post("/workspace/api/anmelden", CREW, { rolle: "gast", code: "CODE-GAST-0001" }); + ok(ohne.code === 400, `sie kommt nicht herein (${ohne.code})`); + ok(json(ohne).fehler === "alter_offen", + `und erfährt, woran es liegt (${json(ohne).fehler})`); + ok(json(ohne).mindestalter === MINDESTALTER, + `samt der Zahl, gegen die geprüft wird (${json(ohne).mindestalter})`); + ok(!keks(ohne), "keine Sitzung dabei — sie ist wirklich draußen"); + + /* AUSDRÜCKLICH NEIN ist dasselbe wie gar nichts. */ + const nein = await post("/workspace/api/anmelden", CREW, + { rolle: "gast", code: "CODE-GAST-0001", alter_ok: false }); + ok(nein.code === 400 && json(nein).fehler === "alter_offen", + `ein ausdrückliches Nein ebenso (${nein.code})`); + + /* UND KEIN FEHLVERSUCH. Wer den richtigen Code hat, darf sich daran + nicht selbst aussperren -- acht Anlaeufe, und die IP waere gesperrt. */ + const d = oeffne(); + const versuche = d.prepare("SELECT COUNT(*) AS n FROM versuche").get().n; + d.close(); + ok(versuche === 0, `und es zählt nicht als Fehlversuch (${versuche} in der Tabelle)`); +} + +/* ======================================================================= + 2. MIT BESTÄTIGUNG SCHON — UND SIE WIRD FESTGEHALTEN + ======================================================================= */ +melde(""); +melde("=== 2. Mit Bestätigung ==="); +{ + const mit = await post("/workspace/api/anmelden", CREW, + { rolle: "gast", code: "CODE-GAST-0001", alter_ok: true }); + ok(mit.code === 200, `mit Häkchen kommt sie herein (${mit.code})`); + ok(!!keks(mit), "und bekommt eine Sitzung"); + + const wann = alterVon(idKessi); + ok(!!wann, `die Bestätigung steht in der Datenbank (${String(wann).slice(0, 16)})`); + ok(Math.abs(Date.parse(wann) - Date.now()) < 120_000, "mit dem Zeitpunkt von jetzt"); + + const d = oeffne(); + const spur = d.prepare( + "SELECT detail FROM protokoll WHERE aktion = 'alter_bestaetigt' AND person_id = ?").get(idKessi); + d.close(); + ok(!!spur, "und im Protokoll"); + ok(/18/.test(spur?.detail || ""), `dort steht, gegen welche Zahl (${spur?.detail})`); +} + +/* ======================================================================= + 3. BEIM ZWEITEN MAL WIRD NICHT WIEDER GEFRAGT + + Die Frage, die man vergisst -- und an der so eine Abfrage im Alltag + scheitert: Wer sie jedes Mal sieht, klickt sie weg, ohne sie zu + lesen. Dann bestaetigt sie nichts mehr. + ======================================================================= */ +melde(""); +melde("=== 3. Nur einmal ==="); +{ + const vorher = alterVon(idKessi); + const zweite = await post("/workspace/api/anmelden", CREW, + { rolle: "gast", code: "CODE-GAST-0001" }); + ok(zweite.code === 200, `ohne Häkchen, und trotzdem herein (${zweite.code})`); + ok(!!keks(zweite), "mit Sitzung"); + ok(alterVon(idKessi) === vorher, + "und der Zeitpunkt der Bestätigung bleibt der erste — er wird nicht überschrieben"); + + const d = oeffne(); + const spuren = d.prepare( + "SELECT COUNT(*) AS n FROM protokoll WHERE aktion = 'alter_bestaetigt'").get().n; + d.close(); + ok(spuren === 1, `und es steht genau einmal im Protokoll (${spuren})`); +} + +/* ======================================================================= + 4. DAS TEAM WIRD NICHT GEFRAGT + ======================================================================= */ +melde(""); +melde("=== 4. Nur die Community ==="); +{ + const hand = await post("/workspace/api/anmelden", CREW, + { rolle: "hand", code: "CODE-HAND-0001" }); + ok(hand.code === 200, `die rechte Hand kommt ohne Häkchen herein (${hand.code})`); + ok(alterVon(idRieke) === null, + "und es wird nichts über ihr Alter gespeichert — sie wurde nie gefragt"); + + const chef = await post("/workspace/api/anmelden", HAUS, + { rolle: "admin", code: "CODE-DOGI-0001" }); + ok(chef.code === 200, `DogFather ebenso (${chef.code})`); +} + +/* ======================================================================= + 5. DER REGELTEXT UND DIE TÜR NENNEN DIESELBE ZAHL + + Bis heute standen sie an zwei Stellen. Ein Regeltext, der sich selbst + widerspricht, ist rechtlich das Gegenteil dessen, wofuer er da ist. + ======================================================================= */ +melde(""); +melde("=== 5. Eine Zahl, nicht zwei ==="); +{ + const anm = await post("/workspace/api/anmelden", CREW, + { rolle: "gast", code: "CODE-GAST-0001" }); + const regeln = json(await hole("/workspace/api/treff/regeln", CREW, { cookie: keks(anm) })); + ok(regeln.mindestalter === MINDESTALTER, + `der Regeltext nennt ${regeln.mindestalter}`); + + /* Und die Tuer nennt dieselbe -- gemessen an einem frischen Zugang, + nicht behauptet. */ + const d = oeffne(); + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync("CODE-NEU-00000001", salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + const sch = d.prepare("SELECT wert FROM einstellungen WHERE schluessel = 'code_kennung_schluessel'").get().wert; + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES ('Neu','gast',?,?,?,?,1,?)") + .run(hash, salt, 32768, createHmac("sha256", sch).update("CODE-NEU-00000001").digest("hex"), jetzt); + d.close(); + const tuer = await post("/workspace/api/anmelden", CREW, + { rolle: "gast", code: "CODE-NEU-00000001" }); + ok(json(tuer).mindestalter === regeln.mindestalter, + `und die Tür dieselbe (${json(tuer).mindestalter})`); + + /* GEGENPROBE zum Vergleich selbst: Zwei verschiedene Zahlen muessten + auffallen -- sonst waere die Zeile darueber auch bei undefined gruen. */ + ok(regeln.mindestalter !== undefined && regeln.mindestalter !== null, + "und beide Zahlen gibt es wirklich"); +} + +/* ======================================================================= + 6. DAS HÄKCHEN STEHT AN DER WAND — UND NUR DORT + ======================================================================= */ +melde(""); +melde("=== 6. An der Wand ==="); +{ + const { readFileSync } = await import("node:fs"); + const crew = readFileSync(new URL("../workspace/crew-index.html", import.meta.url), "utf8"); + const agentur = readFileSync(new URL("../workspace/index.html", import.meta.url), "utf8"); + ok(/id="alterfrage"/.test(crew), "die Wand von Team Dogi hat die Frage"); + ok(!/id="alterfrage"/.test(agentur), + "die Agenturwand nicht — dort gibt es die Community-Kachel gar nicht"); + ok(/id="alter-zahl">18 { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + + /* Die Crew-Wand haengt am Host-Kopf. Ueber 127.0.0.1 kommt die + Agenturwand -- deshalb die Datei direkt ansehen ist keine Loesung. + Playwright kann den Host-Kopf fuer Navigationen nicht setzen; also + wird hier geprueft, was OHNE Server zu pruefen ist: das Verhalten + der Kachelauswahl auf der ausgelieferten Seite. */ + await seite.goto(`http://127.0.0.1:${PORT}/workspace/crew-index.html`, + { waitUntil: "domcontentloaded" }); + await seite.waitForTimeout(400); + + const start = await seite.evaluate(() => { + const f = document.getElementById("alterfrage"); + return { da: !!f, verborgen: f?.hidden }; + }); + ok(start.da === true, "die Frage steht auf der ausgelieferten Seite"); + ok(start.verborgen === true, "und ist zu Beginn verborgen"); + + await seite.click('.rolle[data-rolle="gast"]'); + await seite.waitForTimeout(200); + const nachGast = await seite.evaluate(() => document.getElementById("alterfrage")?.hidden); + ok(nachGast === false, "nach dem Tippen auf „Community“ erscheint sie"); + + /* GEGENPROBE: bei einer anderen Kachel verschwindet sie wieder -- + sonst waere „erscheint" auch dann gruen, wenn sie immer da ist. */ + await seite.click('.rolle[data-rolle="modi"]'); + await seite.waitForTimeout(200); + const nachModi = await seite.evaluate(() => document.getElementById("alterfrage")?.hidden); + ok(nachModi === true, "bei einer anderen Kachel verschwindet sie wieder"); + + /* Und der Haken faellt dabei zurueck -- wer die Kachel wechselt, soll + nicht mit einer Bestaetigung dastehen, die er fuer etwas anderes + gesetzt hat. */ + await seite.click('.rolle[data-rolle="gast"]'); + await seite.check("#alter-ok"); + await seite.click('.rolle[data-rolle="modi"]'); + await seite.click('.rolle[data-rolle="gast"]'); + const haken = await seite.evaluate(() => document.getElementById("alter-ok")?.checked); + ok(haken === false, "und ein gesetzter Haken fällt beim Wechseln zurück"); + + /* Beruehrziel: 24 px ist das Mindestmass nach WCAG 2.5.8. */ + const ziel = await seite.evaluate(() => { + const z = document.querySelector(".alterfrage__zeile"); + if (!z) return null; + const r = z.getBoundingClientRect(); + return { h: Math.round(r.height), w: Math.round(r.width) }; + }); + ok(ziel && ziel.h >= 40, `die Zeile ist ${ziel?.h} px hoch — ein Daumen trifft sie`); + + 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-auskunft.mjs b/server/pruef-auskunft.mjs index 1b73deab..2d417e97 100644 --- a/server/pruef-auskunft.mjs +++ b/server/pruef-auskunft.mjs @@ -136,7 +136,11 @@ function post(pfad, host, kopf, koerper) { } 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 }); + /* Die Community bestaetigt beim ersten Mal ihr Alter (seit 15.09.2026). + Nur fuer 'gast' -- fuer alle anderen muss der Server das Feld gar + nicht erst sehen. */ + const r = await post("/workspace/api/anmelden", host, {}, + rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }); return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); } const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS); diff --git a/server/pruef-neue-seiten.mjs b/server/pruef-neue-seiten.mjs index 15d665a9..4422bc55 100644 --- a/server/pruef-neue-seiten.mjs +++ b/server/pruef-neue-seiten.mjs @@ -110,7 +110,10 @@ function roh(pfad, kopf = {}, koerper = null, methode = null) { } /* Eine Meldung und eine Maßnahme anlegen -- über die echten Wege. */ -const anm = await roh("/workspace/api/anmelden", {}, { rolle: "gast", code: "CODE-GAST-0001" }); +/* alter_ok: Die Tuer fragt die Community seit dem 15.09.2026 beim + ersten Hereinkommen nach dem Alter. */ +const anm = await roh("/workspace/api/anmelden", {}, + { rolle: "gast", code: "CODE-GAST-0001", alter_ok: true }); const kGast = [].concat(anm.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); const anm2 = await roh("/workspace/api/anmelden", {}, { rolle: "admin", code: "CODE-DOGI-0001" }); const kDogi = [].concat(anm2.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); diff --git a/server/pruef-treff-werkzeuge.mjs b/server/pruef-treff-werkzeuge.mjs index 390b3e49..dad8dca9 100644 --- a/server/pruef-treff-werkzeuge.mjs +++ b/server/pruef-treff-werkzeuge.mjs @@ -146,7 +146,11 @@ function roh(pfad, host, kopf = {}, koerper = null, methode = null) { } 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 }); + /* Die Community bestaetigt beim ersten Mal ihr Alter (seit 15.09.2026). + Nur fuer 'gast' -- fuer alle anderen muss der Server das Feld gar + nicht erst sehen. */ + const r = await roh("/workspace/api/anmelden", host, {}, + rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }); const kekse = [].concat(r.kopf?.["set-cookie"] || []); return kekse.map((c) => c.split(";")[0]).join("; "); } diff --git a/server/pruef-treff.mjs b/server/pruef-treff.mjs index 607c124e..c5eb6a2f 100644 --- a/server/pruef-treff.mjs +++ b/server/pruef-treff.mjs @@ -130,7 +130,11 @@ function roh(pfad, host, kopf = {}, koerper = null, methode = null) { }); } async function anmelden(rolle, code, host) { - const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code }); + /* Die Community bestaetigt beim ersten Mal ihr Alter (seit 15.09.2026). + Nur fuer 'gast' -- fuer alle anderen muss der Server das Feld gar + nicht erst sehen. */ + const r = await roh("/workspace/api/anmelden", host, {}, + rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }); const kekse = [].concat(r.kopf?.["set-cookie"] || []); return { keks: kekse.map((c) => c.split(";")[0]).join("; "), code: r.code }; } diff --git a/server/treff-tabellen.js b/server/treff-tabellen.js index f845f967..a5ac6914 100644 --- a/server/treff-tabellen.js +++ b/server/treff-tabellen.js @@ -152,3 +152,17 @@ export function treffSperreLesen(d, personId) { } return null; } + +/* ===================================================================== + DAS MINDESTALTER (Entscheidung Filipe, 11.09.2026: 18 Jahre) + + Nach DSGVO Art. 8 waere ab 16 erlaubt. Der Treff gehoert aber zu + einem Stream fuer Erwachsene, und dort ist die Frage nicht, was + erlaubt ist, sondern wen man hereinlaesst. + + ES STEHT IN DIESEM BLATT, weil zwei Stellen es brauchen: der + Regeltext (workspace-treff.js) und seit dem 15.09.2026 die Tuer + selbst (workspace.js). Ein Import zwischen den beiden waere ein + Kreis -- deshalb liegt die Zahl dort, wo beide hinsehen duerfen. + ===================================================================== */ +export const MINDESTALTER = 18; diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js index 872b44b2..ff5b05c6 100644 --- a/server/workspace-aufbewahrung.js +++ b/server/workspace-aufbewahrung.js @@ -45,6 +45,32 @@ import { export const aufbewahrungRouter = express.Router(); +/** Auf welcher Grundlage die Daten eines Menschen verarbeitet werden. + * + * ABGELEITET, NICHT GESPEICHERT. Ein Modi arbeitet hier — das ist ein + * Vertragsverhaeltnis. Ein Mitglied der Community ist freiwillig da — + * das ist eine Einwilligung. Die Grundlage folgt damit aus der Rolle, + * und was sich ableiten laesst, bekommt keine eigene Spalte: Sonst + * gaebe es zwei Wahrheiten, von denen eine veraltet, sobald jemand + * die Rolle wechselt. + * + * Gebraucht wird sie an zwei Stellen: in der Auskunft (Art. 15 Abs. 1 + * lit. a — der Mensch darf den Zweck erfahren) und im Loeschkonzept. */ +export function grundlageVon(rolle) { + if (rolle === "gast") { + return { + grundlage: "Art. 6 Abs. 1 lit. a DSGVO – Einwilligung", + warum: "Du bist freiwillig im Treff. Du kannst jederzeit gehen, und deine " + + "Daten gehen mit.", + }; + } + return { + grundlage: "Art. 6 Abs. 1 lit. b DSGVO – zur Durchführung des Vertrags", + warum: "Du arbeitest hier. Ohne diese Daten lässt sich die Zusammenarbeit " + + "nicht durchführen.", + }; +} + /** Wie lange IP-Adressen im Protokoll stehen bleiben. * * 30 Tage: lang genug, um einen Missbrauch nachzuvollziehen, den man @@ -130,6 +156,20 @@ export const FRISTEN = [ + "Maßnahme mit (ON DELETE CASCADE).", offen: (d) => zaehle(d, "SELECT COUNT(*) AS n FROM treff_massnahmen"), }, + { + schluessel: "alter_bestaetigt", + was: "Der Zeitpunkt der Altersbestätigung (Community)", + art: "bleibt", + frist: "solange der Zugang besteht", + zweck: "Nachweisen können, dass das Mindestalter bestätigt wurde – " + + "eine Regel, die man nicht belegen kann, hat man nicht.", + grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse an Nachweisbarkeit", + wirkung: "Gespeichert wird NUR der Zeitpunkt, kein Geburtsdatum. Für die Frage " + + "„hat bestätigt, und wann?“ reicht das – ein Geburtstag wären mehr Daten " + + "für dieselbe Auskunft.", + offen: (d) => zaehle(d, + "SELECT COUNT(*) AS n FROM personen WHERE alter_bestaetigt_am IS NOT NULL"), + }, { schluessel: "personen_geloescht", was: "Daten einer gelöschten Person", diff --git a/server/workspace-auskunft.js b/server/workspace-auskunft.js index 4310f8da..1dec5fce 100644 --- a/server/workspace-auskunft.js +++ b/server/workspace-auskunft.js @@ -44,6 +44,7 @@ import express from "express"; import { db, sitzungLesen, istDogFather, protokolliere, echteIp } from "./workspace.js"; +import { grundlageVon } from "./workspace-aufbewahrung.js"; export const auskunftRouter = express.Router(); @@ -165,8 +166,15 @@ export function auskunft(personId) { } } + /* WARUM WIR DIESE DATEN HABEN, gehoert in dieselbe Datei wie die + Daten -- Art. 15 Abs. 1 lit. a. Eine Auskunft, die nur auflistet, + beantwortet die halbe Frage. */ + const wozu = grundlageVon(person.rolle); + return { stand: new Date().toISOString(), + grundlage: wozu.grundlage, + grundlage_warum: wozu.warum, 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“ " diff --git a/server/workspace-treff.js b/server/workspace-treff.js index 1b30bd00..67981996 100644 --- a/server/workspace-treff.js +++ b/server/workspace-treff.js @@ -406,13 +406,19 @@ treffRouter.get("/workspace/api/treff/lage", (req, res) => { /* ---------- Die Regeln ------------------------------------------------ */ -/* DAS MINDESTALTER (Entscheidung Filipe, 11.09.2026: 18 Jahre). +/* DAS MINDESTALTER steht seit dem 15.09.2026 in treff-tabellen.js -- + dem importfreien Blatt. Grund: Seit die Tuer (workspace.js) es + ebenfalls prueft, brauchen es zwei Dateien, die einander nicht + importieren duerfen. Hier nur weitergereicht, damit der Regeltext + unveraendert dieselbe Zahl bekommt. - In Luxemburg liegt die Grenze der DSGVO Art. 8 bei 16 — es waere also - erlaubt, ab 16 hereinzulassen. Der Treff gehoert aber zu einem Stream - fuer Erwachsene, und dort ist die Frage nicht, was erlaubt ist, - sondern wen man hereinlaesst. */ -export const MINDESTALTER = 18; + EINFUEHREN UND WEITERREICHEN, nicht nur weiterreichen: `export { X } + from "..."` macht den Namen fuer IMPORTEURE sichtbar, legt ihn aber + NICHT in dieser Datei an. Die Regelroute unten benutzt ihn selbst -- + sie lief damit in einen ReferenceError, und zwar erst beim Aufruf. + Gefunden von pruef-alter, nicht vom Auge. */ +import { MINDESTALTER } from "./treff-tabellen.js"; +export { MINDESTALTER }; /* WARUM DER REGELTEXT SEINE ZAHLEN VOM SERVER HOLT. diff --git a/server/workspace.js b/server/workspace.js index 863dbf83..9d66297f 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -31,7 +31,7 @@ import { darfSeite, OHNE_ANMELDUNG, seitenFuer } from "./rechte.js"; /* Ein Modul ohne eigene Importe -- deshalb entsteht hier kein Kreis, obwohl workspace-treff.js seinerseits aus dieser Datei importiert. Begruendung im Kopf von treff-tabellen.js. */ -import { treffTabellen, treffSperreLesen } from "./treff-tabellen.js"; +import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.js"; import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN, TEAM_DOGI_ROLLEN } from "./crew-adresse.js"; /* Weitergereicht, damit die Fachmodule sie wie alles andere aus @@ -2027,6 +2027,26 @@ function umstellungen(d) { bei antwort_auf weiter oben. */ ["chat_nachrichten", "angeheftet_am", "TEXT"], ["chat_nachrichten", "angeheftet_von", "INTEGER"], + + /* DAS BESTAETIGTE ALTER (15.09.2026, Stufe 8). + + Auf der Regelseite des Treffs steht seit dem 11.09. "ab 18" -- + und geprueft wurde es nirgends. Eine Regel, die nur dasteht, ist + keine; im Streitfall ist sie sogar schlechter als keine, weil man + sie versprochen hat. + + NUR EIN ZEITSTEMPEL, KEIN GEBURTSDATUM. Gebraucht wird die + Antwort auf "hat bestaetigt, alt genug zu sein, und wann" -- + nicht der Geburtstag. Ein Geburtsdatum waere mehr Daten fuer + dieselbe Auskunft, und Datenminimierung (Art. 5 Abs. 1 lit. c) + gilt auch fuer das eigene Nachweisbeduerfnis. + + WARUM KEINE SPALTE FUER DIE RECHTSGRUNDLAGE: Sie folgt aus der + Rolle -- ein Modi arbeitet hier (Vertrag), ein Community-Mitglied + ist freiwillig da (Einwilligung). Was sich ableiten laesst, wird + nicht gespeichert; sonst haette man zwei Wahrheiten, von denen + eine veraltet. Siehe grundlageVon() in workspace-aufbewahrung.js. */ + ["personen", "alter_bestaetigt_am", "TEXT"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); @@ -4058,6 +4078,46 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => { return res.status(401).json({ fehler: "ungueltig" }); } + /* ================================================================ + DAS ALTER WIRD AN DER TUER BESTAETIGT (15.09.2026) + + Die Regelseite verspricht "ab 18". Bis heute war das ein Satz. + Jetzt ist es eine Bedingung -- einmal, beim ersten Hereinkommen, + und danach nie wieder. + + WARUM AN DER TUER UND NICHT AUF EINER SEITE DAHINTER: Eine Sperre + auf den Brettern liesse sich durch eine andere Adresse umgehen, + und sie muesste auf jeder kuenftigen Seite mitgedacht werden. Die + Tuer gibt es genau einmal. + + WARUM EIN EIGENER FEHLER UND NICHT "ungueltig": Hier ist der Code + richtig. Wer dieselbe Antwort bekaeme wie bei einem Tippfehler, + wuerde seinen Code neu eintippen -- immer wieder, und es wuerde + nie besser. Das ist nicht dieselbe Lage und darf nicht dieselbe + Antwort sein. + + NUR FUER DIE COMMUNITY: Wer zum Team gehoert, hat einen Zugang + von DogFather persoenlich bekommen -- da ist die Frage vorher + geklaert und eine Kachel an der Tuer nur im Weg. */ + if (AUSSEN_ROLLEN.has(gefunden.rolle)) { + const bestaetigt = db().prepare( + "SELECT alter_bestaetigt_am FROM personen WHERE id = ?").get(gefunden.id)?.alter_bestaetigt_am; + if (!bestaetigt) { + if (req.body?.alter_ok !== true) { + /* KEIN Eintrag in `versuche`: Das war kein Fehlversuch. Wer + hier landet, hat den richtigen Code -- ihn nach acht + Anlaeufen auszusperren waere absurd. */ + return res.status(400).json({ fehler: "alter_offen", mindestalter: MINDESTALTER }); + } + db().prepare("UPDATE personen SET alter_bestaetigt_am = ? WHERE id = ?") + .run(jetzt(), gefunden.id); + protokolliere("alter_bestaetigt", { + personId: gefunden.id, rolle: gefunden.rolle, ip, + detail: `mindestens ${MINDESTALTER}`, + }); + } + } + db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip); db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), gefunden.id); sitzungSetzen(res, gefunden, req); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index cdd0b698..9c07f834 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=202609150058", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609150108", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609150058", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609150108", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609150058", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609150108", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609150058", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609150108", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/gate.css b/workspace/assets/css/gate.css index 936454bc..b179f587 100644 --- a/workspace/assets/css/gate.css +++ b/workspace/assets/css/gate.css @@ -2840,3 +2840,32 @@ body.gate--crew .rollen .feldschild::before { inset 0 0 54px -20px rgba(0, 0, 0, .92); } } + +/* ===================================================================== + DIE ALTERSFRAGE AN DER ZUGANGSWAND (15.09.2026) + + Sie darf nicht wie ein Kleingedrucktes aussehen -- wer sie setzt, + soll sie gelesen haben. Also lesbare Groesse, ein Beruehrziel, das + den Namen verdient, und keine graue Fussnotenfarbe. + ===================================================================== */ + +.alterfrage { margin: 0 0 16px; } +.alterfrage__zeile { + display: flex; align-items: flex-start; gap: 11px; + padding: 11px 13px; + cursor: pointer; + border: 1px solid var(--rand); + border-radius: var(--radius); + background: rgba(0, 0, 0, .22); +} +.alterfrage__zeile:hover { border-color: var(--rand-hell); } +.alterfrage__zeile input { + /* 20 px plus die Polsterung ringsum ergeben ein Ziel, das ein Daumen + trifft -- das Kaestchen allein waere 13 px. */ + width: 20px; height: 20px; margin: 1px 0 0; flex: 0 0 auto; + accent-color: var(--akzent); +} +.alterfrage__text { + font-size: .88rem; line-height: 1.5; color: var(--text-leise); +} +.alterfrage__text strong { color: var(--text); } diff --git a/workspace/assets/js/gate.js b/workspace/assets/js/gate.js index a877e0bd..0b9ed7a9 100644 --- a/workspace/assets/js/gate.js +++ b/workspace/assets/js/gate.js @@ -141,6 +141,12 @@ knopf.querySelector('.knopf__text').textContent = an ? 'Prüfe …' : 'Anmelden'; } + /* Die Altersfrage gibt es nur auf der Wand von Team Dogi. Ueberall + sonst bleiben diese drei schlicht null. */ + const alterFrage = document.getElementById('alterfrage'); + const alterOk = document.getElementById('alter-ok'); + const alterZahl = document.getElementById('alter-zahl'); + /* ---------- Absenden ------------------------------------------------ */ formular.addEventListener('submit', async (e) => { @@ -161,7 +167,13 @@ braucht ihn dort nicht: Er prueft den verborgenen Zugang VOR dem gewohnten Weg, und ein leerer Name faellt danach in dieselbe Antwort wie ein erfundener. */ - body: JSON.stringify({ rolle: gewaehlt || '', code: wert }), + /* `alter_ok` geht nur mit, wenn die Frage ueberhaupt dasteht. + Sonst waere es eine Behauptung ueber jemanden, der sie nie + gesehen hat. */ + body: JSON.stringify({ + rolle: gewaehlt || '', code: wert, + ...(alterFrage && !alterFrage.hidden ? { alter_ok: !!alterOk?.checked } : {}), + }), credentials: 'same-origin', }); @@ -180,6 +192,23 @@ return; } + /* DAS ALTER IST NICHT DERSELBE FALL WIE EIN FALSCHER CODE. + Wer hier landet, hat den richtigen Code -- bekaeme er + "Zugangscode stimmt nicht", tippte er ihn immer wieder neu ein, + und es wuerde nie besser. */ + if (antwort.status === 400) { + const d = await antwort.json().catch(() => ({})); + if (d.fehler === 'alter_offen') { + if (alterFrage) { + alterFrage.hidden = false; + if (d.mindestalter && alterZahl) alterZahl.textContent = String(d.mindestalter); + alterOk?.focus(); + } + melde(`Bitte bestätige noch, dass du mindestens ${d.mindestalter || 18} Jahre alt bist.`); + return; + } + } + // Bewusst keine Auskunft darüber, ob Rolle oder Code falsch war. melde(antwort.status === 429 ? 'Zu viele Versuche. Bitte kurz warten.' @@ -225,3 +254,28 @@ } } })(); + +/* ===================================================================== + DIE ALTERSFRAGE ERSCHEINT MIT DER COMMUNITY-KACHEL (15.09.2026) + + Sie steht in crew-index.html und nirgends sonst -- auf der + Agenturwand gibt es sie gar nicht. Deshalb hier ueberall mit „?.“: + Diese Datei laeuft auf beiden Waenden. + ===================================================================== */ +(() => { + const alterFrage = document.getElementById('alterfrage'); + if (!alterFrage) return; + const alterOk = document.getElementById('alter-ok'); + + /* Auf die Kachelreihe hoeren, nicht auf jede Kachel einzeln: Kommt + morgen eine fuenfte dazu, ist sie automatisch dabei. */ + document.querySelector('.rollen')?.addEventListener('click', (e) => { + const knopf = e.target.closest?.('.rolle'); + if (!knopf) return; + const gast = knopf.dataset.rolle === 'gast'; + alterFrage.hidden = !gast; + /* Wer die Kachel wechselt, soll nicht mit einem Haken dastehen, den + er fuer etwas anderes gesetzt hat. */ + if (!gast && alterOk) alterOk.checked = false; + }); +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 50d89018..9715ad73 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 d1c33902..d387ddb2 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 032e6a8f..309a9656 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 dc105303..7ba7aa8d 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 757cd484..4e5c85f9 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 423ebe06..85cf662b 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 80f82ff5..e14c03ef 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -283,6 +283,26 @@ + + +
@@ -319,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 7a76d3e6..93893812 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=202609150058", + "src": "/assets/img/app-symbole/crew-192.png?v=202609150108", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609150058", + "src": "/assets/img/app-symbole/crew-512.png?v=202609150108", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609150058", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609150108", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609150058", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609150108", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index da140d03..3b91ce86 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 6e429657..ed7539fb 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 be9a9e76..6f86f19b 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 aa3a58d5..9105923a 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 b76ab5f4..2135802b 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 f6f518a7..ef5e2782 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 50ef5565..b98793d0 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 2b3c3231..1161d160 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 f3c2448c..285ca779 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 20d05be7..d74255d2 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 0fbdd1dc..68acd0c6 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 94afc5b5..5428a904 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 ceaa1281..e2149577 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. --> - - - - - - - + + + + + + + - + @@ -146,12 +146,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index e5d80355..f0ac3396 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 746ec582..851277b9 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 6c53fb49..150ea5eb 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 6764eb71..f6c83009 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 3d127be7..695bddfe 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -210,11 +210,11 @@ 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 fbb91936..3e461759 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 d8205b25..4be0ca0e 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 @@ - - - - - - + + + + + +