diff --git a/server/index.js b/server/index.js index 2ef1280c..4e38fd10 100644 --- a/server/index.js +++ b/server/index.js @@ -37,6 +37,7 @@ import { auskunftRouter } from "./workspace-auskunft.js"; import { uebernahmeRouter } from "./workspace-uebernahme.js"; import { befindenRouter } from "./workspace-befinden.js"; import { hilfeRouter } from "./workspace-hilfe.js"; +import { supportRouter } from "./workspace-support.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; import { callRouter } from "./workspace-calls.js"; @@ -309,6 +310,7 @@ app.use(auskunftRouter); app.use(uebernahmeRouter); app.use(befindenRouter); app.use(hilfeRouter); +app.use(supportRouter); app.use(bewerbungRouter); app.use(reportRouter); app.use(scoutRouter); diff --git a/server/pruef-support.mjs b/server/pruef-support.mjs new file mode 100644 index 00000000..c9f9d013 --- /dev/null +++ b/server/pruef-support.mjs @@ -0,0 +1,457 @@ +/* ===================================================================== + DER SUPPORT (24.09.2026) + + Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder + benutzen kann. jeder kann da probleme von der seite melden und + hinweisen. fotos mit schicken mit einem text. … also bei dogfather + und der rechten hand soll die kachel anders gebaut sein weil die + sind die die sich um die probleme kümmern." + + GEPRUEFT WIRD, WAS ER BESTELLT HAT — und drei Dinge, die er nicht + bestellt hat, weil sie sonst niemandem auffallen: + + * JEDE Rolle sieht die Kachel und kommt auf die Seite. Nicht vier + von acht -- genau daran ist heute der Benachrichtigungs-Knopf + gescheitert. + * Eine fremde Meldung bleibt fremd. Wer meldet, sieht seine + eigene; alles andere gehoert der Leitung. + * Eine als Bild getarnte HTML-Datei kommt nicht herein. Das ist + der Standardfehler bei selbstgebauten Uploads und von aussen + nicht zu sehen. + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-sup-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Supportpruefung"); + +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +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 }); }; +await import("./index.js"); +notbremse(240_000, "pruef-support"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = `http://127.0.0.1:${PORT}`; + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const melde = (t) => console.log(t); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const kennSchluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kennSchluessel, jetzt); +function 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", kennSchluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +/* ALLE ACHT ROLLEN. „Die jeder sieht" heisst jede -- und die Liste + steht hier vollstaendig, damit eine fehlende auffaellt. */ +const ROLLEN = [ + ["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"], + ["modi", "Miss"], ["spicy", "Spicy Media"], ["manager", "Patrick"], + ["scout", "Kevin"], ["creator", "Luna"], ["gast", "Community-Mitglied"], +]; +const CODES = {}; +for (const [i, [rolle, name]] of ROLLEN.entries()) { + CODES[rolle] = `CODE-S${String(i).padStart(2, "0")}-000001`; + anlegen(name, rolle, CODES[rolle]); +} +d.close(); + +/* ---------- Anmelden ohne Browser ------------------------------------ + Die Sitzung kommt aus einem echten Anmelde-Aufruf; danach reist sie + als Keks mit. Ein Browser waere hier Aufwand ohne Gewinn -- geprueft + werden Wege, nicht Aussehen. */ +/* DIE COMMUNITY KOMMT NUR AUF EINER ADRESSE HEREIN (gemessen, nicht + geraten): `crew.dogfather-universe.com`. Auf 127.0.0.1 antwortet + die Anmeldung mit 401 -- beim ersten Lauf stand deshalb + "gast: kommt nicht herein", und das sah aus wie ein schwerer + Befund am Support. Es war einer an meiner Messung. + + Versucht wird zuerst ohne Host, dann mit -- so faellt auch auf, + wenn eine Rolle PLOETZLICH einen braucht. */ +const CREW_HOST = "crew.dogfather-universe.com"; + +/* ZWEI DINGE, DIE ICH ERST NACH DREI FEHLLAEUFEN GEMESSEN HABE -- + beide stehen in pruef-treff.mjs, das sie seit dem 15.09. richtig + macht: + + 1. `fetch` VERWIRFT DEN Host-KOPF. Er steht auf der Liste der + verbotenen Kopfzeilen; Node setzt stattdessen die Adresse des + Ziels. Wer eine Hausadresse braucht, muss node:http nehmen. + 2. DIE COMMUNITY BESTAETIGT IHR ALTER (`alter_ok`). Ohne das Feld + antwortet die Anmeldung mit 401 -- und das sah aus wie ein + Zugangsproblem am Support. + + Beim ersten Lauf stand deshalb dreimal rot, und kein einziges Mal + lag es am Support. */ +const { request } = await import("node:http"); +function roh(pfad, host, koerper) { + return new Promise((fertig, schief) => { + const daten = koerper ? JSON.stringify(koerper) : null; + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, + method: daten ? "POST" : "GET", + headers: { + ...(host ? { Host: host } : {}), + Origin: host ? `https://${host}` : BASIS, + ...(daten ? { "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(daten) } : {}), + }, + }, (antwort) => { + let text = ""; + antwort.on("data", (t) => { text += t; }); + antwort.on("end", () => fertig({ + code: antwort.statusCode, kopf: antwort.headers, text, + })); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} + +async function anmeldeVersuch(rolle, host) { + const r = await roh("/workspace/api/anmelden", host, + rolle === "gast" + ? { rolle, code: CODES[rolle], alter_ok: true } + : { rolle, code: CODES[rolle] }); + if (r.code !== 200) return null; + const keks = [].concat(r.kopf["set-cookie"] || []) + .map((z) => z.split(";")[0]).join("; "); + return keks || null; +} +const HOST_FUER = {}; +async function anmelden(rolle) { + const ohne = await anmeldeVersuch(rolle, null); + if (ohne) { HOST_FUER[rolle] = null; return ohne; } + const mit = await anmeldeVersuch(rolle, CREW_HOST); + if (mit) { HOST_FUER[rolle] = CREW_HOST; return mit; } + return null; +} + +/** Ein winziges, gueltiges PNG (1x1, schwarz) -- echte Kopfbytes, + * damit die Erkennung wirklich etwas zu erkennen hat. */ +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk" + + "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"); + +const kekse = {}; +melde("\n=== Alle neun Rollen kommen herein ==="); +for (const [rolle] of ROLLEN) { + kekse[rolle] = await anmelden(rolle); + ok(!!kekse[rolle], `${rolle}: angemeldet`); +} + +/* Fuer Rollen OHNE Hausadresse genuegt fetch -- es ist kuerzer und + kann Buffer als Rumpf. Wer eine Adresse braucht, geht ueber + node:http (siehe oben, `fetch` verwirft den Host-Kopf). */ +async function hol(rolle, pfad, einst = {}) { + if (HOST_FUER[rolle]) { + const r = await rohMitKeks(rolle, pfad, einst); + return r; + } + return fetch(BASIS + pfad, { + ...einst, + headers: { Cookie: kekse[rolle] || "", Origin: BASIS, ...(einst.headers || {}) }, + }); +} + +/** Derselbe Aufruf ueber node:http -- mit Hausadresse und Sitzung. + * Gibt etwas zurueck, das sich wie eine fetch-Antwort benutzen + * laesst: `status`, `ok`, `json()`, `headers.get()`. */ +function rohMitKeks(rolle, pfad, einst = {}) { + return new Promise((fertig, schief) => { + const rumpf = einst.body === undefined ? null + : (Buffer.isBuffer(einst.body) ? einst.body : Buffer.from(String(einst.body))); + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, + method: einst.method || "GET", + headers: { + Host: HOST_FUER[rolle], + Origin: `https://${HOST_FUER[rolle]}`, + Cookie: kekse[rolle] || "", + ...(rumpf ? { "Content-Length": rumpf.length } : {}), + ...(einst.headers || {}), + }, + }, (antwort) => { + const teile = []; + antwort.on("data", (t) => teile.push(t)); + antwort.on("end", () => { + const text = Buffer.concat(teile).toString("utf8"); + fertig({ + status: antwort.statusCode, + ok: antwort.statusCode >= 200 && antwort.statusCode < 300, + headers: { get: (n) => antwort.headers[String(n).toLowerCase()] || null }, + json: async () => JSON.parse(text), + text: async () => text, + }); + }); + }); + a.on("error", schief); + if (rumpf) a.write(rumpf); + a.end(); + }); +} + +/* ===================================================================== + 1. DIE KACHEL SIEHT JEDER + ===================================================================== */ +melde("\n=== Die Kachel steht bei jeder Rolle ==="); +{ + const ohne = []; + for (const [rolle] of ROLLEN) { + /* DIE ROUTE HEISST /api/ich, nicht /api/start. Beim ersten Lauf + stand hier ein erfundener Name, und alle neun Rollen meldeten + "Kachel fehlt (HTTP 404)" -- ein Messfehler, der wie ein + Totalausfall aussah. */ + const a = await hol(rolle, "/workspace/api/ich"); + if (!a.ok) { ohne.push(`${rolle} (HTTP ${a.status})`); continue; } + const r = await a.json(); + /* `bereiche` ist null, wenn die Oberflaeche ihre eigene Liste + nimmt -- dann steht die Kachel in assets/js/bereiche.js und + wird eine Pruefung weiter unten geprueft. */ + if (r.bereiche === null) { ohne.push(`${rolle} (eigene Liste im Browser)`); continue; } + const da = (r.bereiche || []).some((k) => k?.ziel === "support.html"); + if (!da) ohne.push(rolle); + } + /* DIE AGENTUR-ROLLEN bekommen `bereiche: null` und ihre Liste aus + dem Browser. Das ist kein Mangel, aber es muss genannt werden -- + sonst sieht "alle haben sie" gruen aus, obwohl vier gar nicht + geprueft wurden. */ + const ausBrowser = ohne.filter((x) => x.includes("eigene Liste")); + const echtOhne = ohne.filter((x) => !x.includes("eigene Liste")); + ok(echtOhne.length === 0, + "jede Rolle mit Server-Liste hat die Support-Kachel" + + (echtOhne.length ? ` -- FEHLT bei: ${echtOhne.join(", ")}` : "")); + melde(` (${ausBrowser.length} Rollen nehmen ihre Liste aus dem Browser)`); + + /* UND DIE BROWSER-LISTE AUCH. Ohne diese Zeile waere die Haelfte + der Rollen ungeprueft -- und genau dort hat heute schon einmal + etwas gefehlt. */ + const { readFileSync } = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const bereicheJs = readFileSync(fileURLToPath( + new URL("../workspace/assets/js/bereiche.js", import.meta.url)), "utf8"); + ok(/ziel:\s*'support\.html'/.test(bereicheJs), + "auch die Kachelliste im Browser kennt den Support"); +} + +/* ===================================================================== + 2. AUF DIE SEITE DARF JEDER + ===================================================================== */ +melde("\n=== Die Seite ist fuer jede Rolle offen ==="); +{ + const zu = []; + for (const [rolle] of ROLLEN) { + const a = await hol(rolle, "/workspace/support.html", { redirect: "manual" }); + if (a.status !== 200) zu.push(`${rolle}=${a.status}`); + } + ok(zu.length === 0, + "support.html antwortet jeder Rolle mit 200" + + (zu.length ? ` -- nicht bei: ${zu.join(", ")}` : "")); +} + +/* ===================================================================== + 3. MELDEN — mit Text, mit Bild, und was nicht geht + ===================================================================== */ +melde("\n=== Melden ==="); +let meldungMitBild = 0; +{ + /* Ohne Text geht nichts -- sonst stehen leere Zeilen im Eingang. */ + const leer = await hol("creator", "/workspace/api/support", { + method: "POST", headers: { "x-text": encodeURIComponent("hm") }, + }); + ok(leer.status === 400, `ein Wort reicht nicht (${leer.status})`); + + const nurText = await hol("creator", "/workspace/api/support", { + method: "POST", + headers: { "x-text": encodeURIComponent("Der Haken auf der Aufgabenseite geht nicht.") }, + }); + ok(nurText.status === 201, `Text allein genuegt (${nurText.status})`); + + const mitBild = await hol("modi", "/workspace/api/support", { + method: "POST", + headers: { + "x-text": encodeURIComponent("Hier stimmt die Farbe nicht, siehe Bild."), + "x-seite": "/workspace/chat.html", + "Content-Type": "application/octet-stream", + }, + body: PNG, + }); + ok(mitBild.status === 201, `mit Bild geht auch (${mitBild.status})`); + if (mitBild.status === 201) { + const j = await mitBild.json(); + meldungMitBild = j.meldung?.id || 0; + ok(!!j.meldung?.bild, "und die Antwort nennt die Adresse des Bildes"); + } else { ok(false, "kein Bild in der Antwort"); } + + /* DER STANDARDFEHLER BEI UPLOADS: eine HTML-Datei mit Bildnamen. + Sie liefe im Browser als Seite DIESER Domain, mit Zugriff auf die + Sitzung des Empfaengers. */ + const getarnt = await hol("scout", "/workspace/api/support", { + method: "POST", + headers: { + "x-text": encodeURIComponent("Test mit einer getarnten Datei."), + "Content-Type": "image/png", + }, + body: Buffer.from(""), + }); + ok(getarnt.status === 415, + `als Bild getarntes HTML wird abgelehnt (${getarnt.status})`); + + /* Und ein PDF ebenfalls -- dateiErkennen kennt es, der Support + will es nicht. */ + const pdf = await hol("scout", "/workspace/api/support", { + method: "POST", + headers: { + "x-text": encodeURIComponent("Test mit einem PDF im Anhang."), + "Content-Type": "application/pdf", + }, + body: Buffer.concat([Buffer.from("%PDF-1.4\n"), Buffer.alloc(64, 0x20)]), + }); + ok(pdf.status === 415, `ein PDF ist kein Foto (${pdf.status})`); +} + +/* ===================================================================== + 4. WER SIEHT WAS + ===================================================================== */ +melde("\n=== Wer sieht was ==="); +{ + const alsCreator = await (await hol("creator", "/workspace/api/support")).json(); + ok(alsCreator.leitung === false, "ein Creator ist nicht die Leitung"); + ok(alsCreator.meldungen.length === 1, + `und sieht genau seine eigene (${alsCreator.meldungen.length})`); + + const alsModi = await (await hol("modi", "/workspace/api/support")).json(); + ok(alsModi.meldungen.length === 1 && alsModi.meldungen[0].id === meldungMitBild, + "ein Modi sieht seine eigene und nicht die des Creators"); + + for (const rolle of ["admin", "hand"]) { + const r = await (await hol(rolle, "/workspace/api/support")).json(); + ok(r.leitung === true && r.meldungen.length >= 2, + `${rolle} sieht alle (${r.meldungen.length})`); + } + for (const rolle of ["linke", "modi", "spicy", "manager", "scout", "creator", "gast"]) { + const r = await (await hol(rolle, "/workspace/api/support")).json(); + ok(r.leitung !== true, `${rolle} ist NICHT die Leitung`); + } + + /* DAS BILD EINER FREMDEN MELDUNG bleibt fremd. */ + const fremd = await hol("creator", `/workspace/api/support/${meldungMitBild}/bild`); + ok(fremd.status === 404, + `das Bild einer fremden Meldung gibt es fuer andere nicht (${fremd.status})`); + + const eigen = await hol("modi", `/workspace/api/support/${meldungMitBild}/bild`); + ok(eigen.status === 200, `der Melder sieht sein eigenes Bild (${eigen.status})`); + ok(eigen.headers.get("content-type") === "image/png", + `ausgeliefert wird der ERKANNTE Typ (${eigen.headers.get("content-type")})`); + ok((eigen.headers.get("x-content-type-options") || "") === "nosniff", + "mit nosniff"); + ok(/sandbox/.test(eigen.headers.get("content-security-policy") || ""), + "und einer Richtlinie, die jede Ausfuehrung nimmt"); + + const leitungSieht = await hol("hand", `/workspace/api/support/${meldungMitBild}/bild`); + ok(leitungSieht.status === 200, "die Leitung sieht es auch"); +} + +/* ===================================================================== + 5. SICH KUEMMERN + ===================================================================== */ +melde("\n=== Sich kuemmern ==="); +{ + /* Wer nicht Leitung ist, kann nichts aendern -- 404 und nicht 403: + Wer keine Zustaendigkeit hat, soll nicht erfahren, dass es sie + gibt. */ + const versuch = await hol("creator", `/workspace/api/support/${meldungMitBild}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stand: "erledigt", antwort: "einfach mal zu" }), + }); + ok(versuch.status === 404, `ein Creator kann nichts erledigen (${versuch.status})`); + + const dran = await hol("hand", `/workspace/api/support/${meldungMitBild}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stand: "in_arbeit" }), + }); + ok(dran.status === 200, `die rechte Hand uebernimmt (${dran.status})`); + + /* ZUMACHEN OHNE SATZ GEHT NICHT. Der Melder sieht nur diesen Satz. */ + const ohneSatz = await hol("hand", `/workspace/api/support/${meldungMitBild}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stand: "erledigt" }), + }); + ok(ohneSatz.status === 400, `erledigen ohne Satz wird abgelehnt (${ohneSatz.status})`); + + const zu = await hol("hand", `/workspace/api/support/${meldungMitBild}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stand: "erledigt", antwort: "Behoben, die Farbe stimmt wieder." }), + }); + ok(zu.status === 200, `mit Satz geht es (${zu.status})`); + + /* ERLEDIGT IST ERLEDIGT -- auch fuer die Leitung. */ + const nochmal = await hol("admin", `/workspace/api/support/${meldungMitBild}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stand: "in_arbeit", antwort: "doch nicht" }), + }); + ok(nochmal.status === 409, + `eine erledigte Meldung wird nicht wieder geoeffnet (${nochmal.status})`); + + /* Und der Melder sieht die Antwort. */ + const beimMelder = await (await hol("modi", "/workspace/api/support")).json(); + const meine = beimMelder.meldungen.find((m) => m.id === meldungMitBild); + ok(meine?.stand === "erledigt" && /Behoben/.test(meine?.antwort || ""), + "der Melder sieht Stand und Antwort"); +} + +/* ===================================================================== + 6. GEGENPROBEN — kann diese Pruefung ueberhaupt rot werden? + ===================================================================== */ +melde("\n=== Gegenproben (jede MUSS anschlagen) ==="); +{ + /* Eine Meldung, die es nicht gibt. */ + const nichts = await hol("admin", "/workspace/api/support/999999"); + ok(nichts.status === 404, "eine erfundene Nummer gibt es nicht"); + + /* Ohne Anmeldung kommt niemand an die Liste. */ + const ohne = await fetch(BASIS + "/workspace/api/support"); + ok(ohne.status === 401 || ohne.status === 302 || ohne.status === 404, + `ohne Anmeldung keine Liste (${ohne.status})`); + + /* Und ohne Anmeldung auch nicht an ein Bild. */ + const ohneBild = await fetch(BASIS + `/workspace/api/support/${meldungMitBild}/bild`); + ok(ohneBild.status !== 200, `ohne Anmeldung kein Bild (${ohneBild.status})`); +} + +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +console.log(""); +console.log(`${geprueft} Pruefungen, ${fehler} Fehler`); +console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +process.exit(fehler ? 1 : 0); diff --git a/server/rechte.js b/server/rechte.js index ff66ece8..abb65316 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -455,6 +455,23 @@ export const SEITEN = { eigenen Faelle, die Leitung alle. */ "/workspace/hilfe.html": ["gast", "hand", "admin"], + /* DER SUPPORT: WIRKLICH JEDER (24.09.2026). + + Filipe: „da fehlt auch eine support kachel. die jeder sieht. + jeder benutzen kann." + + Das ist der Unterschied zum vertraulichen Meldeweg eine Zeile + darueber: Der gilt nur fuer gast, hand und admin -- Modis, + Creator, Scouts, Manager und die linke Hand hatten gar keinen + Weg, einen kaputten Knopf zu melden. + + ALLE ROLLEN AUSGESCHRIEBEN und nicht „* " oder ein Weglassen: + Diese Tafel ist die Stelle, an der man nachsieht, wer wohin darf. + Eine Abkuerzung hier hiesse, dass der Support in der Uebersicht + „Wer sieht was" als Luecke erscheint. */ + "/workspace/support.html": ["gast", "modi", "creator", "scout", "manager", + "linke", "hand", "spicy", "admin"], + /* WER SIEHT WAS. DogFather und die rechte Hand (Filipe, 11.09.2026: "ich will die kategorie auch auf der team dogi seite fuer die dogfather und rechte hand rollen"). diff --git a/server/support-tabellen.js b/server/support-tabellen.js new file mode 100644 index 00000000..98ac8590 --- /dev/null +++ b/server/support-tabellen.js @@ -0,0 +1,127 @@ +/* ===================================================================== + DER SUPPORT — Tabellen (24.09.2026) + + Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder + benutzen kann. jeder kann da probleme von der seite melden und + hinweisen. fotos mit schicken mit einem text. … es geht drum dass + die leute einfach ein text mit einem foto schicken und dan die + rechte hand und dogfather es bekommen. es sehen in dieser support + kachel. also bei dogfather und der rechten hand soll die kachel + anders gebaut sein weil die sind die die sich um die probleme + kümmern." + + --------------------------------------------------------------------- + WARUM DAS NICHT DER VERTRAULICHE MELDEWEG IST + + Es gibt ihn schon (hilfe.html, hilfe_faelle) und er sieht auf den + ersten Blick gleich aus. Drei Unterschiede machen ihn zu etwas + anderem: + + 1. ER IST NICHT FÜR ALLE. In rechte.js steht + `"/workspace/hilfe.html": ["gast", "hand", "admin"]` — Modis, + Creator, Scouts, Manager und die linke Hand haben ihn nicht. + Das Team selbst hatte damit gar keinen Weg, einen kaputten + Knopf zu melden. + 2. ER KANN KEINE BILDER. Bei „der Knopf oben rechts tut nichts" + ist ein Bildschirmfoto die halbe Antwort. Ein Meldeweg ohne + Bild führt zu drei Rückfragen. + 3. ER IST BEWUSST SCHWER. Strikter Wechsel (melden → warten → + antworten → warten), drei offene Fälle höchstens, ein Betreff. + Das ist richtig, wenn es um einen Vorfall zwischen Menschen + geht. Für „das Datum steht falsch da" ist es zu viel. + + Deshalb eine eigene, sehr kurze Sache: EIN Feld, EIN Bild, fertig. + Kein Betreff, keine Kategorie, keine Dringlichkeitsstufe. Wer ein + Formular mit fünf Feldern sieht, meldet den kleinen Fehler nicht — + und genau die kleinen sind es, die sonst niemand erfährt. + + --------------------------------------------------------------------- + DREI STÄNDE, NICHT FÜNF + + „neu" Niemand hat es angesehen. + „in_arbeit" Jemand kümmert sich — sichtbar auch für den Melder, + damit er weiß, dass es angekommen ist. + „erledigt" Zu. Mit einer Antwort, wenn es etwas zu sagen gab. + + Eine vierte Stufe („abgelehnt") gibt es absichtlich nicht. Ein + Hinweis, den man nicht umsetzt, wird trotzdem erledigt — mit einem + Satz warum. „Abgelehnt" ist eine Tür, die man dem zuschlägt, der + sich die Mühe gemacht hat. + + --------------------------------------------------------------------- + DAS BILD LIEGT AUF DER PLATTE, NICHT IN DER DATENBANK + + Dieselbe Entscheidung wie bei Chat-Anhängen und der Dateiablage: Ein + Bildschirmfoto sind ein bis fünf Megabyte, und eine SQLite-Datei, + die man täglich sichert, soll das nicht tragen. In der Tabelle steht + nur der Dateiname; der Ordner steht in workspace-support.js über + `join(DATEN_ORDNER, …)` — genau dieses Muster liest + tools/wiederherstellung-proben.mjs, um zu prüfen, ob die Sicherung + ihn mitnimmt. + + DIESE DATEI IMPORTIERT NICHTS — derselbe Grund wie bei + hilfe-tabellen.js und treff-tabellen.js: Sie wird aus dem Bauplan in + workspace.js aufgerufen und darf keinen Kreis erzeugen. + ===================================================================== */ + +/** Die drei Stände einer Meldung. + * + * Hier und nicht als Zeichenkette an zwanzig Stellen — ein Tippfehler + * in einem `WHERE stand = 'neu'` findet lautlos nichts. */ +export const SUPPORT_STAND = { + neu: "neu", + inArbeit: "in_arbeit", + erledigt: "erledigt", +}; + +/** Wie lange eine erledigte Meldung aufbewahrt wird. + * + * Neunzig Tage. Lang genug, um ein wiederkehrendes Problem zu + * erkennen („das hatten wir im Juli schon"), kurz genug, dass + * Bildschirmfotos aus dem Arbeitsalltag nicht ewig herumliegen. + * Aufgeräumt wird im Rundgang, nicht beim Lesen — sonst hängt das + * Löschen davon ab, dass jemand die Seite öffnet. */ +export const AUFBEWAHRUNG_TAGE = 90; + +export function supportTabellen(d) { + d.exec(` + CREATE TABLE IF NOT EXISTS support_meldungen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + /* ON DELETE SET NULL und nicht CASCADE: Wird ein Zugang + gelöscht, ist der gemeldete Fehler immer noch da. Die Meldung + verliert ihren Absender, nicht ihren Inhalt. */ + person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + /* Der Name wird MITGESCHRIEBEN, nicht nur verknüpft. Sonst steht + nach einem Rollenwechsel oder einer Löschung „—" neben einer + Meldung, zu der jemand etwas fragen möchte. */ + von_name TEXT, + von_rolle TEXT, + text TEXT NOT NULL, + /* WO ES PASSIERT IST. Kommt nicht aus einem Auswahlfeld, sondern + aus der Adresse, von der aus gemeldet wurde — eine Angabe, die + niemand tippen muss und die trotzdem stimmt. */ + seite TEXT, + /* Was der Browser über sich sagt, gekürzt. Bei „geht bei mir + nicht" ist das die erste Frage, und sie erübrigt sich damit. */ + geraet TEXT, + bild_datei TEXT, + bild_typ TEXT, + bild_breite INTEGER, + bild_hoehe INTEGER, + stand TEXT NOT NULL DEFAULT 'neu' + CHECK (stand IN ('neu','in_arbeit','erledigt')), + antwort TEXT, + erstellt TEXT NOT NULL, + geaendert TEXT NOT NULL, + erledigt_am TEXT, + erledigt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + + /* Die Leitung fragt „was ist offen?", der Melder „was ist meins?". + Zwei Indizes für zwei Fragen. */ + CREATE INDEX IF NOT EXISTS idx_support_stand + ON support_meldungen(stand, id DESC); + CREATE INDEX IF NOT EXISTS idx_support_person + ON support_meldungen(person_id, id DESC); + `); +} diff --git a/server/workspace-chat.js b/server/workspace-chat.js index e3bdb022..5cc04af1 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -557,7 +557,12 @@ function direktRaum(personA, idB, { anlegen = true } = {}) { Laden nicht. Lassen sie sich nicht ablesen, ist das kein Grund zur Ablehnung -- dann steht eben 0, und der Platz wird geschätzt. ===================================================================== */ -function dateiErkennen(b) { +/* EXPORTIERT SEIT DEM 24.09.2026 -- der Support braucht dieselbe + Erkennung fuer seine Bildschirmfotos. Eine zweite Fassung dort waere + die, die beim naechsten Dateiformat vergessen wird, und an dieser + Funktion haengt die ganze Sicherheit der Uploads (siehe der Kopf + darueber). Eine Wahrheit, zwei Benutzer. */ +export function dateiErkennen(b) { if (!Buffer.isBuffer(b) || b.length < 16) return null; const kopf = (von, bis) => b.toString("latin1", von, bis); diff --git a/server/workspace-push.js b/server/workspace-push.js index 018b3e73..c64388d4 100644 --- a/server/workspace-push.js +++ b/server/workspace-push.js @@ -141,6 +141,18 @@ export const ARTEN = [ { schluessel: "bewerbung_antwort", name: "Antwort auf deine eigene Bewerbung", vorgabe: true }, + /* DER SUPPORT (24.09.2026). + + EINE ART FUER BEIDE RICHTUNGEN -- die neue Meldung an die Leitung + und die Antwort an den Melder. Zwei Schluessel waeren zwei + Schalter in den Einstellungen fuer dieselbe Sache, und wer den + einen abschaltet, wundert sich ueber den anderen. + + NICHT DRINGEND: Ein kaputter Knopf weckt niemanden um drei Uhr + nachts; die Ruhezeit gilt. */ + { schluessel: "support", + name: "Support: neue Meldung oder Antwort darauf", vorgabe: true }, + /* ER IST LIVE (22.09.2026). Filipe: "die leute sollen auch automatisch eine benarichtigung diff --git a/server/workspace-support.js b/server/workspace-support.js new file mode 100644 index 00000000..7bb5f537 --- /dev/null +++ b/server/workspace-support.js @@ -0,0 +1,436 @@ +/* ===================================================================== + DER SUPPORT (24.09.2026) + + Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder + benutzen kann. jeder kann da probleme von der seite melden und + hinweisen. fotos mit schicken mit einem text. es soll alles hoch + profissionell gemacht werden aber so einfach wie möglich, will + nichts kompliziertes oder zu viel. kurz und knapp … also bei + dogfather und der rechten hand soll die kachel anders gebaut sein + weil die sind die die sich um die probleme kümmern." + + Die Regeln und ihre Begründung stehen im Kopf von + support-tabellen.js. Hier sind die Wege — und die vier Stellen, an + denen etwas schiefgehen kann: + + 1. EINE MELDUNG DARF NIE BEIM FALSCHEN LANDEN. Jede Leseroute geht + über `meldungFuer()`, das den Datensatz zurückgibt oder `null` — + nie `true`/`false`. So kann keine Route „darf sehen" beantworten + und danach trotzdem den falschen Datensatz laden. Dasselbe + Muster wie im vertraulichen Meldeweg. + + 2. DER TYP EINES BILDES KOMMT AUS DEM INHALT. `dateiErkennen()` + liest die ersten Bytes; Dateiname und Content-Type kommen vom + Absender und sind frei erfunden. Die Funktion wird aus + workspace-chat.js IMPORTIERT und nicht abgeschrieben: Eine + zweite Fassung wäre die, die beim nächsten Dateiformat vergessen + wird — und an ihr hängt die ganze Sicherheit der Uploads. + + 3. DER RUMPF WIRD ERST ANGENOMMEN, WENN DIE BERECHTIGUNG STEHT. + Wer nicht angemeldet ist, kommt gar nicht bis zur Datei. + + 4. ERLEDIGT IST ERLEDIGT. Auch für die Leitung — eine geschlossene + Meldung wird nicht stillschweigend wieder geöffnet und anders + beantwortet. + ===================================================================== */ + +import express from "express"; +import { createReadStream, statSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; +import { db, protokolliere, DATEN_ORDNER, echteIp } from "./workspace.js"; +import { SUPPORT_STAND, AUFBEWAHRUNG_TAGE } from "./support-tabellen.js"; +import { dateiErkennen } from "./workspace-chat.js"; +import { benachrichtige } from "./workspace-push.js"; + +export const supportRouter = express.Router(); + +/** Wer alle Meldungen sieht und sie bearbeitet. + * + * GENAU ZWEI ROLLEN, wie bestellt: DogFather und die rechte Hand. + * Dieselbe Menge wie im vertraulichen Meldeweg — und aus einem + * ähnlichen Grund: Wer sich kümmert, muss alles sehen; wer nur + * meldet, sieht seine eigene Meldung. */ +const LEITUNG = new Set(["admin", "hand"]); +const istLeitung = (person) => LEITUNG.has(person?.rolle); + +const TEXT_MAX = 2000; +const SEITE_MAX = 120; +const GERAET_MAX = 200; +const ANTWORT_MAX = 2000; + +/** Zwölf Megabyte — dieselbe Grenze wie bei Chat-Anhängen. + * Ein Bildschirmfoto vom Handy liegt bei 2 bis 5 MB. */ +const BILD_MAX = 12 * 1024 * 1024; + +/** Wie viele offene Meldungen eine Person gleichzeitig haben darf. + * + * Zehn, und damit deutlich großzügiger als die drei im vertraulichen + * Meldeweg. Dort ist die Grenze ein Schutz vor Überforderung auf + * beiden Seiten; hier wäre sie eine Bremse für genau die Person, die + * sich die Mühe macht, fünf kleine Fehler einzeln zu melden. Sie + * steht nur da, damit ein hängendes Skript nicht tausend Zeilen + * anlegt. */ +const OFFEN_MAX = 10; + +/* Der Ordner steht über join(DATEN_ORDNER, …) — genau dieses Muster + liest tools/wiederherstellung-proben.mjs aus, um zu prüfen, ob die + Sicherung ihn mitnimmt. Eine gepflegte Liste wäre die, die beim + nächsten Ordner vergessen wird. */ +const BILD_ORDNER = join(DATEN_ORDNER, "support-bilder"); + +const jetzt = () => new Date().toISOString(); + +function sauber(s, max) { + return String(s ?? "").replace(/\s+/g, " ").trim().slice(0, max); +} + +/** Mehrzeiliger Text: Absätze bleiben, Leerzeilenwüsten nicht. + * + * Beim Melden schreibt man „Schritt 1 … Schritt 2 …" untereinander. + * Das über `\s+` plattzumachen wie einen Betreff würde genau die + * Struktur zerstören, die den Fehler nachvollziehbar macht. */ +function textSauber(s, max) { + return String(s ?? "") + .replace(/\r\n/g, "\n") + .replace(/[ \t]+/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim() + .slice(0, max); +} + +/** Darf diese Person diese Meldung sehen? + * + * Gibt die Meldung zurück oder `null`. NIE `true`/`false` — dasselbe + * Muster wie `fallFuer()` im vertraulichen Meldeweg, und aus + * demselben Grund. */ +function meldungFuer(person, id) { + const m = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(Number(id)); + if (!m) return null; + if (istLeitung(person)) return m; + return m.person_id === person?.id ? m : null; +} + +/** Wie eine Meldung nach draußen aussieht. + * + * AN EINER STELLE, weil vier Routen sie ausgeben. Vier Stellen wären + * vier Gelegenheiten, ein Feld zu vergessen — oder eines zu viel + * mitzuschicken. + * + * `fuerLeitung` entscheidet nur über die Zusatzangaben, nicht über + * den Inhalt: Wer meldet, sieht seine eigene Meldung vollständig, + * auch den Namen, den er selbst trägt. */ +function nachDraussen(m, fuerLeitung) { + return { + id: m.id, + text: m.text, + stand: m.stand, + antwort: m.antwort || null, + erstellt: m.erstellt, + geaendert: m.geaendert, + erledigt_am: m.erledigt_am || null, + seite: m.seite || null, + /* Das Bild als fertige ADRESSE, nicht als Dateiname — sonst setzt + jede Stelle im Browser denselben Pfad zusammen, und eine davon + setzt ihn irgendwann anders. Dieselbe Entscheidung wie beim + Chat-Anhang. */ + bild: m.bild_datei ? `/workspace/api/support/${m.id}/bild` : null, + bild_breite: m.bild_breite || 0, + bild_hoehe: m.bild_hoehe || 0, + von: m.von_name || null, + von_rolle: m.von_rolle || null, + /* Nur die Leitung braucht zu wissen, mit welchem Gerät gemeldet + wurde. Für den Melder ist es seine eigene Angabe und nur + Rauschen. */ + geraet: fuerLeitung ? (m.geraet || null) : undefined, + }; +} + +/* ===================================================================== + LESEN + ===================================================================== */ + +/** Die eigenen Meldungen — oder für die Leitung alle. + * + * EINE ROUTE FÜR BEIDE, nicht zwei. Zwei Routen hießen zwei Stellen, + * an denen die Sichtbarkeitsregel gepflegt werden muss — und eine + * davon läuft irgendwann auseinander. */ +supportRouter.get("/workspace/api/support", (req, res) => { + try { + const leitung = istLeitung(req.person); + const zeilen = leitung + ? db().prepare(`SELECT * FROM support_meldungen ORDER BY + /* Offene zuerst, und darin die ältesten oben: Was am + längsten wartet, gehört nach vorn. Erledigte danach, das + Neueste zuerst. */ + CASE stand WHEN 'neu' THEN 0 WHEN 'in_arbeit' THEN 1 ELSE 2 END, + CASE WHEN stand = 'erledigt' THEN -id ELSE id END + LIMIT 300`).all() + : db().prepare(`SELECT * FROM support_meldungen WHERE person_id = ? + ORDER BY id DESC LIMIT 100`).all(req.person.id); + + res.json({ + leitung, + meldungen: zeilen.map((m) => nachDraussen(m, leitung)), + /* Die Zahlen stehen in der Antwort und werden nicht im Browser + nachgezählt: Die Liste ist bei 300 abgeschnitten, eine Zählung + darauf wäre ab der 301. Meldung still falsch. */ + offen: leitung + ? db().prepare("SELECT COUNT(*) AS n FROM support_meldungen WHERE stand <> 'erledigt'") + .get().n + : db().prepare(`SELECT COUNT(*) AS n FROM support_meldungen + WHERE person_id = ? AND stand <> 'erledigt'`).get(req.person.id).n, + hoechstens_bytes: BILD_MAX, + text_max: TEXT_MAX, + }); + } catch (fehler) { + console.error("[support] Liste:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Das Bild einer Meldung. + * + * Dieselben Kopfzeilen wie beim Chat-Anhang: der Typ aus der + * ERKENNUNG (nicht der eingeschickte), nosniff, und eine Richtlinie, + * die dem Dokument jede Ausführung nimmt. */ +supportRouter.get("/workspace/api/support/:id(\\d+)/bild", (req, res) => { + try { + const m = meldungFuer(req.person, req.params.id); + if (!m || !m.bild_datei) return res.status(404).json({ fehler: "nicht_gefunden" }); + const pfad = join(BILD_ORDNER, m.bild_datei); + try { statSync(pfad); } catch { + return res.status(410).json({ fehler: "Das Bild fehlt auf der Platte." }); + } + res.setHeader("Content-Type", m.bild_typ || "image/png"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox"); + res.setHeader("Cache-Control", "private, max-age=86400"); + createReadStream(pfad).pipe(res); + } catch (fehler) { + console.error("[support] Bild:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + MELDEN + ===================================================================== */ + +/** Vor express.raw: Darf diese Person überhaupt melden? + * + * Angemeldet sein genügt — „die jeder sieht, jeder benutzen kann". + * Die Prüfung steht trotzdem VOR der Annahme des Rumpfes, sonst + * wanderten zwölf Megabyte durch die Leitung, bevor irgendjemand + * fragt, wer da schickt. */ +function darfMelden(req, res, next) { + if (!req.person?.id) return res.status(401).json({ fehler: "nicht_angemeldet" }); + const offen = db().prepare(`SELECT COUNT(*) AS n FROM support_meldungen + WHERE person_id = ? AND stand <> 'erledigt'`).get(req.person.id).n; + if (offen >= OFFEN_MAX) { + return res.status(429).json({ + fehler: `Du hast schon ${offen} offene Meldungen. Warte kurz ab, ` + + "bis sich jemand darum gekümmert hat.", + }); + } + next(); +} + +/** Eine Meldung abschicken. + * + * EIN WEG FÜR BEIDES — mit Bild und ohne. Der Text reist im Kopf + * (`x-text`, prozentkodiert), das Bild im Rumpf. Zwei Routen zu + * bauen („/support" für Text, "/support/bild" für Bild) wäre die + * naheliegende Aufteilung und hätte einen Zustand dazwischen: eine + * Meldung, die schon existiert, während das Bild noch hochlädt — + * und bei einem Abbruch für immer ohne Bild dasteht. + * + * Ein leerer Rumpf ist zulässig: Dann gibt es eben kein Bild. */ +supportRouter.post("/workspace/api/support", + darfMelden, + express.raw({ type: "*/*", limit: BILD_MAX }), + (req, res) => { + let geschrieben = null; + try { + let text = ""; + try { text = decodeURIComponent(req.get("x-text") || ""); } catch { text = ""; } + text = textSauber(text, TEXT_MAX); + if (text.length < 5) { + return res.status(400).json({ + fehler: "Schreib bitte kurz, was los ist – ein Satz reicht.", + }); + } + + let bildDatei = null, bildTyp = null, bildBreite = 0, bildHoehe = 0; + if (Buffer.isBuffer(req.body) && req.body.length) { + const erkannt = dateiErkennen(req.body); + /* NUR BILDER. dateiErkennen kennt auch PDF und Ton — hier geht + es um Bildschirmfotos, und alles andere wäre eine Tür, die + niemand bestellt hat. */ + if (!erkannt || erkannt.art !== "bild") { + return res.status(415).json({ + fehler: "Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.", + }); + } + mkdirSync(BILD_ORDNER, { recursive: true }); + bildDatei = `${Date.now()}-${randomBytes(8).toString("hex")}${erkannt.endung}`; + writeFileSync(join(BILD_ORDNER, bildDatei), req.body); + geschrieben = join(BILD_ORDNER, bildDatei); + bildTyp = erkannt.typ; + bildBreite = erkannt.breite || 0; + bildHoehe = erkannt.hoehe || 0; + } + + const n = jetzt(); + const info = db().prepare(`INSERT INTO support_meldungen + (person_id, von_name, von_rolle, text, seite, geraet, + bild_datei, bild_typ, bild_breite, bild_hoehe, + stand, erstellt, geaendert) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`) + .run(req.person.id, req.person.name || null, req.person.rolle || null, + text, sauber(req.get("x-seite"), SEITE_MAX) || null, + sauber(req.get("user-agent"), GERAET_MAX) || null, + bildDatei, bildTyp, bildBreite, bildHoehe, + SUPPORT_STAND.neu, n, n); + const id = Number(info.lastInsertRowid); + + protokolliere("support_gemeldet", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${text.slice(0, 80)}`, + }); + + /* DIE LEITUNG ERFÄHRT ES SOFORT. Eine Meldung, die erst auffällt, + wenn jemand die Seite zufällig öffnet, ist keine Meldung, + sondern ein Zettel im Schrank. + + NICHT DRINGEND: Ein kaputter Knopf weckt niemanden um drei Uhr + nachts. Die Ruhezeit gilt (siehe workspace-push.js). */ + for (const p of db().prepare( + `SELECT id FROM personen WHERE aktiv = 1 AND rolle IN ('admin','hand')`).all()) { + benachrichtige(p.id, "support", { + titel: "Neue Support-Meldung", + text: text.slice(0, 120), + ziel: `/workspace/support.html?meldung=${id}`, + }).catch(() => { /* eine ausgefallene Benachrichtigung darf die + Meldung nicht verhindern */ }); + } + + const frisch = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(id); + res.status(201).json({ meldung: nachDraussen(frisch, istLeitung(req.person)) }); + } catch (fehler) { + /* DIE DATEI GEHT MIT, WENN DIE ZEILE NICHT ZUSTANDE KAM. Sonst + sammelt der Ordner Bilder an, zu denen es keine Meldung gibt — + und niemand merkt es, weil nichts darauf zeigt. */ + if (geschrieben) { try { unlinkSync(geschrieben); } catch { /* egal */ } } + console.error("[support] Melden:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +/* ===================================================================== + SICH KÜMMERN — nur die Leitung + ===================================================================== */ + +/** Stand ändern und antworten. + * + * EINE ROUTE FÜR BEIDES, weil es in der Oberfläche eine Handlung ist: + * „erledigt, und zwar deshalb". Zwei Routen hießen zwei Anfragen, und + * bei der zweiten kann das Netz abbrechen — dann steht die Meldung + * auf „erledigt" ohne den Satz, der sie erklärt. */ +supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, res) => { + try { + if (!istLeitung(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + const m = db().prepare("SELECT * FROM support_meldungen WHERE id = ?") + .get(Number(req.params.id)); + if (!m) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* ERLEDIGT IST ERLEDIGT. Eine geschlossene Meldung wird nicht + stillschweigend wieder geöffnet und anders beantwortet — sonst + ist „zugemacht" eine Meinung und keine Tatsache. Dieselbe Regel + wie im vertraulichen Meldeweg. */ + if (m.stand === SUPPORT_STAND.erledigt) { + return res.status(409).json({ + fehler: "Diese Meldung ist erledigt. Für etwas Neues bitte eine neue.", + }); + } + + const wunsch = String(req.body?.stand || ""); + if (!Object.values(SUPPORT_STAND).includes(wunsch)) { + return res.status(400).json({ fehler: "ungueltiger_stand" }); + } + const antwort = textSauber(req.body?.antwort, ANTWORT_MAX); + /* WER ZUMACHT, SAGT WARUM. Nicht als Schikane: Der Melder sieht + nur, dass seine Sache zu ist — ohne einen Satz weiß er nicht, ob + sie behoben wurde oder ob niemand Lust hatte. */ + if (wunsch === SUPPORT_STAND.erledigt && antwort.length < 3) { + return res.status(400).json({ + fehler: "Schreib kurz dazu, was daraus geworden ist – der Melder sieht nur das.", + }); + } + + const n = jetzt(); + db().prepare(`UPDATE support_meldungen + SET stand = ?, antwort = ?, geaendert = ?, + erledigt_am = ?, erledigt_von = ? + WHERE id = ?`) + .run(wunsch, antwort || m.antwort || null, n, + wunsch === SUPPORT_STAND.erledigt ? n : null, + wunsch === SUPPORT_STAND.erledigt ? req.person.id : null, + m.id); + + protokolliere("support_bearbeitet", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${m.id} -> ${wunsch}`, + }); + + /* DER MELDER ERFÄHRT ES. Wer etwas meldet und nie wieder davon + hört, meldet beim nächsten Mal nichts mehr. */ + if (m.person_id && m.person_id !== req.person.id) { + benachrichtige(m.person_id, "support", { + titel: wunsch === SUPPORT_STAND.erledigt + ? "Deine Meldung ist erledigt" : "Jemand kümmert sich", + text: antwort ? antwort.slice(0, 120) : m.text.slice(0, 120), + ziel: `/workspace/support.html?meldung=${m.id}`, + }).catch(() => { /* siehe oben */ }); + } + + const frisch = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(m.id); + res.json({ meldung: nachDraussen(frisch, true) }); + } catch (fehler) { + console.error("[support] Bearbeiten:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + AUFRÄUMEN + ===================================================================== */ + +/** Erledigte Meldungen nach der Aufbewahrungsfrist entfernen. + * + * Wird aus dem Rundgang aufgerufen, nicht beim Lesen — sonst hängt + * das Aufräumen davon ab, dass jemand die Seite öffnet, und auf einem + * ruhigen System wächst der Bilderordner still weiter. + * + * DIE DATEI GEHT MIT DER ZEILE. Ein Bild ohne Meldung zeigt auf + * nichts und niemand findet es je wieder. */ +export function supportAufraeumen() { + try { + const grenze = new Date(Date.now() - AUFBEWAHRUNG_TAGE * 86400000).toISOString(); + const alt = db().prepare(`SELECT id, bild_datei FROM support_meldungen + WHERE stand = 'erledigt' AND erledigt_am IS NOT NULL AND erledigt_am < ?`).all(grenze); + if (!alt.length) return 0; + for (const m of alt) { + if (m.bild_datei) { + try { unlinkSync(join(BILD_ORDNER, m.bild_datei)); } catch { /* schon weg */ } + } + db().prepare("DELETE FROM support_meldungen WHERE id = ?").run(m.id); + } + console.log(`[support] ${alt.length} erledigte Meldungen aelter als ` + + `${AUFBEWAHRUNG_TAGE} Tage entfernt.`); + return alt.length; + } catch (fehler) { + console.error("[support] Aufraeumen:", fehler?.message); + return 0; + } +} diff --git a/server/workspace-willkommen.js b/server/workspace-willkommen.js index 8b061c73..b8e59dd0 100644 --- a/server/workspace-willkommen.js +++ b/server/workspace-willkommen.js @@ -140,6 +140,10 @@ const LANG = { "hilfe.html": "Wenn etwas vorgefallen ist, das niemand mitlesen soll. Nur DogFather " + "und die rechte Hand sehen diese Meldungen.", + "support.html": + "Etwas an der Seite funktioniert nicht oder sieht falsch aus? Schreib " + + "einen Satz dazu und häng ein Bildschirmfoto an — DogFather und die " + + "rechte Hand bekommen es sofort und melden sich, wenn es erledigt ist.", "treff-regeln.html": "Wie wir hier miteinander umgehen. Kurz, und es lohnt sich einmal.", "unsere-seiten.html": diff --git a/server/workspace.js b/server/workspace.js index 121707c4..a66f6b94 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -33,6 +33,7 @@ import { darfSeite, OHNE_ANMELDUNG, seitenFuer } from "./rechte.js"; Begruendung im Kopf von treff-tabellen.js. */ import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.js"; import { hilfeTabellen } from "./hilfe-tabellen.js"; +import { supportTabellen } from "./support-tabellen.js"; import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN, TEAM_DOGI_ROLLEN, CREW_ADRESSE } from "./crew-adresse.js"; /* Weitergereicht, damit die Fachmodule sie wie alles andere aus @@ -1569,6 +1570,31 @@ export const TREFF_ROLLEN = new Set(["gast", "modi", "hand", "linke", "admin"]); SIE STEHT VOR "FUER DICH": Ein Problem ist dringender als der eigene Steckbrief. Die Reihenfolge innerhalb der Gruppe "Fuer dich" ergibt sich aus der Liste, die Sortierung in start.js fasst beide ans Ende. */ +/* DER SUPPORT (24.09.2026). + + Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder + benutzen kann. jeder kann da probleme von der seite melden und + hinweisen. fotos mit schicken mit einem text." + + SIE STEHT NICHT IN EINER ROLLENLISTE, sondern wird in + `bereicheFuer()` an JEDE angehaengt -- siehe dort. Sie in fuenf + Listen einzutragen waere fuenf Gelegenheiten, sie bei der sechsten + Rolle zu vergessen; und genau so ist heute der + Benachrichtigungs-Knopf auf 14 Seiten verschwunden. + + TON 44: gezaehlt in start.css, wo die Farben wirklich stehen (bis + 43 vergeben). Danach laeuft `node tools/kachel-farben.mjs + --schreiben`, das alle Toene neu gegeneinander rechnet. + + ZEICHEN "schutz" wie beim vertraulichen Meldeweg -- beides sind + Wege, auf denen man sich meldet, wenn etwas nicht stimmt. Ein + eigenes Zeichen zu erfinden hiesse, zwei Dinge zu unterscheiden, + die sich fuer den Benutzer gleich anfuehlen. */ +const SUPPORT_KACHEL = { + name: "Support", unter: "Etwas kaputt? Schreib es mit Bild", + zeichen: "schutz", ton: 44, ziel: "support.html", szene: "studio", +}; + const HILFE_KACHEL = { gruppe: "Für dich", gruppeUnter: "Deine eigene Seite", name: "Vertraulich melden", unter: "Nur DogFather und die rechte Hand", @@ -1858,7 +1884,28 @@ const STECKBRIEF_KACHEL_TREFF = { /* Die Community: der Treff und ihre eigene Seite. Sonst nichts. */ const TREFF_MIT_EIGENEM = [...TREFF_BEREICHE, HILFE_KACHEL, STECKBRIEF_KACHEL_TREFF]; +/* DIE SUPPORT-KACHEL BEKOMMT JEDER -- an genau einer Stelle + angehaengt (24.09.2026). + + `bereicheFuer` gibt je nach Rolle eine von fuenf Listen zurueck, + dazu `null` ("nimm die Liste aus dem Browser") und `[]` ("nichts"). + Die Kachel in jede Liste einzutragen waeren fuenf Aenderungen und + fuenf kuenftige Gelegenheiten, eine zu vergessen. Stattdessen + haengt diese Huelle sie an das an, was herauskommt. + + `null` UND `[]` BLEIBEN UNANGETASTET, und das ist Absicht: + `null` heisst "der Browser hat die Liste" -- dort steht sie in + assets/js/bereiche.js und wird dort ergaenzt. `[]` heisst "diese + Rolle bekommt gar nichts"; ihr eine einzelne Kachel zu geben waere + die Ausnahme, die spaeter niemand mehr erklaeren kann. */ export function bereicheFuer(person) { + const liste = bereicheRoh(person); + if (liste === null || !Array.isArray(liste) || !liste.length) return liste; + return liste.some((k) => k?.ziel === "support.html") + ? liste : [...liste, SUPPORT_KACHEL]; +} + +function bereicheRoh(person) { if (person?.rolle === "modi") return MODI_MIT_TREFF; /* Die linke Hand vor der allgemeinen Hand-Regel -- sonst faenge `istHand()` sie ab, und der Filter darunter liefe nie. */ @@ -5261,6 +5308,7 @@ export function db() { /* Der vertrauliche Meldeweg (19.09.2026). Haengt an personen und kommt deshalb hierher, nicht in den Bauplan darueber. */ hilfeTabellen(d); + supportTabellen(d); umstellungen(d); _db = d; return d; diff --git a/tools/kachel-farbe-einzeln.mjs b/tools/kachel-farbe-einzeln.mjs index 410d3176..fdff0bdd 100644 --- a/tools/kachel-farbe-einzeln.mjs +++ b/tools/kachel-farbe-einzeln.mjs @@ -234,7 +234,36 @@ for (const ziel of nummern) { const alt = Math.min(...andere.map((t) => abstand(vorher.lab, t.lab))); const neu = beste(andere); - if (neu.abstand <= alt) { + /* EIN UNGUELTIGER IST-WERT IST KEINE MESSLATTE (24.09.2026). + + Gefunden beim Eintragen von Ton 44 -- und es war GENAU DERSELBE + FEHLER wie am 22.09., nur eine Ebene hoeher. + + Damals: Die Suche lief bis Buntheit 0,04 herunter, die Pruefung + verlangt 0,12. Behoben, indem beide dieselbe Zahl lesen. + + Heute: Ich hatte fuer Ton 44 von Hand ein zu blasses #bcdbff + eingetragen (Buntheit 0,060). Dieses Werkzeug las es als + Ausgangswert, verglich den besten NEUEN Vorschlag dagegen -- und + meldete "bleibt #bcdbff, nichts Besseres gefunden". Der Abstand + war tatsaechlich groesser; nur war der Ist-Wert gar nicht + zulaessig. Ein Werkzeug, das einen regelwidrigen Zustand + verteidigt, weil er zufaellig gut misst, haelt genau den Fehler + fest, den es beheben soll. + + Geprueft werden deshalb ZUERST die Regeln, dann der Abstand. */ + const istBuntheit = Math.hypot(vorher.lab[1], vorher.lab[2]); + const istGueltig = istBuntheit >= MINDEST_BUNTHEIT + && kontrast(vorher.hex) >= MINDEST_KONTRAST; + if (!istGueltig) { + console.log(` +Ton ${ziel} (${vorher.wofuer}): ${vorher.hex} haelt die Regeln ` + + `nicht ein (Buntheit ${istBuntheit.toFixed(3)}, ` + + `Kontrast ${kontrast(vorher.hex).toFixed(2)}:1) -- er wird ersetzt, ` + + "egal wie gut sein Abstand ist."); + } + + if (istGueltig && neu.abstand <= alt) { console.log(`\nTon ${ziel} (${vorher.wofuer}): bleibt ${vorher.hex} — ` + `nichts Besseres gefunden (${alt.toFixed(4)}).`); continue; diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index bae2b423..5e1643d0 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +