diff --git a/server/pruef-galerie.mjs b/server/pruef-galerie.mjs new file mode 100644 index 00000000..68ff3d13 --- /dev/null +++ b/server/pruef-galerie.mjs @@ -0,0 +1,327 @@ +/* ===================================================================== + STUFE 5: HIGHLIGHTS ALS GALERIE -- UND DER WEG, AUF DEM BILDER + HINAUSGEHEN (17.09.2026) + + Aus dem Plan im Vault. "Eure Clips und Bilder" zeigte Textkarten mit + einem Anhang darunter. Ein Bild als Anhang unter einer Ueberschrift + ist kein Highlight -- dieser Bereich lebt vom Sehen. + + --------------------------------------------------------------------- + ZWEI DINGE, DIE BEIM MESSEN ANDERS WAREN ALS IM PLAN + + 1. "Anhaenge sind schon da" -- sie hingen an AUFGABEN + (`dateien.aufgabe_id`). Ein Eintrag konnte gar kein Bild tragen. + Neue Spalte `eintrag_id`. + + 2. UND DER WICHTIGE: Alles in dieser Ablage geht als DOWNLOAD hinaus + (`application/octet-stream`, `Content-Disposition: attachment`). + Das ist richtig so -- eine hochgeladene HTML- oder SVG-Datei + wuerde im Browser als Seite DIESER Domain laufen, mit Zugriff auf + die Sitzung. Eine Galerie braucht also einen eigenen, engen Weg. + + --------------------------------------------------------------------- + DIE FRAGE, AUF DIE ES ANKOMMT + + "Ist das ein Bild?" wird NICHT ueber `dateien.typ` beantwortet. Diese + Spalte traegt den vom Browser BEHAUPTETEN Typ -- wer hochlaedt, + bestimmt ihn selbst. Eine Galerie, die ihm glaubt, liefert auf Zuruf + alles inline aus. + + Erkannt wird an den ERSTEN BYTES. Die kann niemand behaupten. + + UND SVG IST AUSDRUECKLICH NICHT DABEI: Es ist ein Bildformat UND es + kann Skript enthalten -- genau die Luecke, gegen die die Regel gebaut + wurde. Ein Format, das beides ist, gehoert nicht in die Ausnahme. + + Aufrufen mit: node server/pruef-galerie.mjs + Bild: SCHIRM=1 node server/pruef-galerie.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { portMussFreiSein } from "./helfer-port.mjs"; +import { heuteLokal } from "./helfer-tag.mjs"; + +const PORT = await portMussFreiSein(4487, "pruef-galerie"); +const ordner = mkdtempSync(join(tmpdir(), "ws-gal-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.WORKSPACE_DATEN = join(ordner, "daten"); +process.env.PORT = String(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 }); }; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(180_000, "pruef-galerie"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = `http://127.0.0.1:${PORT}`; + +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 { 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 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); +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", schluessel).update(code).digest("hex"), jetzt); +} +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +anlegen("Marina", "modi", "CODE-MODI-0001"); +d.close(); + +async function anmelden(kachel, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: kachel, code }), + }); + return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); +} +const dogi = await anmelden("admin", "CODE-DOGI-0001"); +const modi = await anmelden("creator", "CODE-MODI-0001"); + +/* ---------- Ein Highlight und die Dateien dazu ----------------------- */ +const a = await fetch(BASIS + "/workspace/api/bereich/highlight", { + method: "POST", + headers: { "Content-Type": "application/json", cookie: dogi, origin: BASIS }, + body: JSON.stringify({ art: "clip", titel: "Casper springt ins Bild", datum: heuteLokal() }), +}); +const eintragId = (await a.json().catch(() => null))?.id || 0; + +/* ECHTE DATEIANFAENGE, keine erfundenen: Ein PNG faengt mit + \x89PNG\r\n\x1a\n an, ein JPEG mit FFD8FF. Wer hier Platzhalter + nimmt, prueft die eigene Erfindung. */ +/* EIN ECHTES, DEKODIERBARES PNG (1x1, blau). Der erste Entwurf hatte + nur den Dateikopf plus Nullen -- die Bytepruefung im Server nahm es + an, der Browser konnte es aber nicht zeichnen. Das Bild verschwand + dann durch den `error`-Handler in bereich.js, und die Pruefung fand + im Raster nichts. + + Das war kein Fehler im Haus, sondern in der Pruefung -- aber ein + nuetzlicher: Er hat nebenbei gezeigt, dass der Handler tut, was er + soll. Ein kaputtes Bild hinterlaesst keinen leeren Rahmen. */ +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64"); +const SVG = Buffer.from( + ''); +const HTML = Buffer.from(""); + +async function hochladen(inhalt, name, typBehauptet, eintrag = eintragId) { + const kopf = { + cookie: dogi, origin: BASIS, + "content-type": typBehauptet, + "x-dateiname": encodeURIComponent(name), + }; + if (eintrag) kopf["x-eintrag"] = String(eintrag); + const r = await fetch(BASIS + "/workspace/api/dateien", { + method: "POST", headers: kopf, body: inhalt, + }); + return { status: r.status, daten: await r.json().catch(() => null) }; +} + +/* ======================================================================= + 1. EIN BILD HAENGT AM BEITRAG + ======================================================================= */ +melde(""); +melde("=== 1. Das Bild am Highlight ==="); +let idPng = 0; +{ + ok(eintragId > 0, `ein Highlight angelegt (#${eintragId})`); + const r = await hochladen(PNG, "casper.png", "image/png"); + ok(r.status === 201, `ein PNG hochgeladen (${r.status} ${r.daten?.fehler || ""})`); + idPng = r.daten?.id || 0; + + const l = await fetch(BASIS + "/workspace/api/bereich/highlight", { headers: { cookie: dogi } }); + const j = await l.json().catch(() => null); + const e = (j?.eintraege || []).find((x) => x.id === eintragId); + ok(Array.isArray(e?.bilder) && e.bilder.length === 1, + `der Beitrag bringt sein Bild mit (${e?.bilder?.length ?? 0})`); + ok(e?.bilder?.[0]?.id === idPng, "und zwar genau das hochgeladene"); +} + +/* ======================================================================= + 2. ES GEHT ALS BILD HINAUS -- UND NUR ALS BILD + ======================================================================= */ +melde(""); +melde("=== 2. Der Bildweg ==="); +{ + const r = await fetch(`${BASIS}/workspace/api/dateien/${idPng}/bild`, { headers: { cookie: dogi } }); + ok(r.status === 200, `das PNG kommt (${r.status})`); + ok(r.headers.get("content-type") === "image/png", + `als image/png (${r.headers.get("content-type")})`); + /* INLINE, sonst zeigt der Browser es nicht an, sondern laedt es. */ + ok(/^inline/.test(r.headers.get("content-disposition") || ""), + `und inline (${r.headers.get("content-disposition")})`); + /* DIE DREI SCHUTZKOEPFE muessen mit. Ohne `nosniff` darf der Browser + den Typ selbst raten -- und dann waere die ganze Pruefung an den + ersten Bytes umsonst. */ + ok(r.headers.get("x-content-type-options") === "nosniff", "mit nosniff"); + ok(/default-src 'none'/.test(r.headers.get("content-security-policy") || ""), + "und mit enger Inhaltsregel"); + + /* GEGENPROBE: Der alte Weg liefert dieselbe Datei weiterhin als + Download aus -- daran hat sich nichts geaendert. */ + const alt = await fetch(`${BASIS}/workspace/api/dateien/${idPng}/inhalt`, { headers: { cookie: dogi } }); + ok(alt.headers.get("content-type") === "application/octet-stream", + `der alte Weg bleibt ein Download (${alt.headers.get("content-type")})`); +} + +/* ======================================================================= + 3. WAS NICHT INLINE HINAUSGEHT -- DER EIGENTLICHE PUNKT + ======================================================================= */ +melde(""); +melde("=== 3. Was kein Bild ist ==="); +{ + /* SVG IST EIN BILDFORMAT UND KANN SKRIPT ENTHALTEN. Genau deshalb + steht es nicht in der Ausnahme. */ + const s = await hochladen(SVG, "hübsch.svg", "image/svg+xml"); + ok(s.status === 201, `ein SVG laesst sich ablegen (${s.status})`); + const sr = await fetch(`${BASIS}/workspace/api/dateien/${s.daten?.id}/bild`, { headers: { cookie: dogi } }); + ok(sr.status === 404, `aber NICHT als Bild ausgeliefert (${sr.status})`); + + /* EINE HTML-DATEI, DIE SICH ALS PNG AUSGIBT. Das ist der Fall, den + ein Blick auf `dateien.typ` durchgelassen haette: Der behauptete + Typ stimmt, der Inhalt nicht. */ + const h = await hochladen(HTML, "bild.png", "image/png"); + ok(h.status === 201, `eine HTML-Datei mit der Behauptung "image/png" wird abgelegt (${h.status})`); + const hr = await fetch(`${BASIS}/workspace/api/dateien/${h.daten?.id}/bild`, { headers: { cookie: dogi } }); + ok(hr.status === 404, + `und trotz der Behauptung NICHT als Bild ausgeliefert (${hr.status})`); + + /* UND UMGEKEHRT: ein echtes PNG, das sich als Text ausgibt, geht + durch. Ohne diese Zeile hiesse "404" nur, dass der Weg immer + ablehnt. */ + const p = await hochladen(PNG, "getarnt.txt", "text/plain"); + const pr = await fetch(`${BASIS}/workspace/api/dateien/${p.daten?.id}/bild`, { headers: { cookie: dogi } }); + ok(pr.status === 200 && pr.headers.get("content-type") === "image/png", + `ein echtes PNG mit falscher Behauptung geht durch (${pr.status})`); +} + +/* ======================================================================= + 4. WER DAS BILD SEHEN DARF + ======================================================================= */ +melde(""); +melde("=== 4. Die Sicht ==="); +{ + /* EIN BILD AM COMMUNITY-BEITRAG FOLGT DEM BEITRAG, nicht der + Dateiablage. Die Ablage-Regel haengt an Creator-Zuordnungen -- ein + Modi hat keine, er saehe sonst nie ein Highlight, obwohl es fuer + ihn gemacht ist. */ + const r = await fetch(`${BASIS}/workspace/api/dateien/${idPng}/bild`, { headers: { cookie: modi } }); + ok(r.status === 200, `ein Modi sieht das Highlight-Bild (${r.status})`); + + /* GEGENPROBE: Eine Datei OHNE Beitrag faellt auf die normale + Ablage-Regel zurueck -- und die gilt fuer ihn nicht. */ + const lose = await hochladen(PNG, "intern.png", "image/png", null); + const lr = await fetch(`${BASIS}/workspace/api/dateien/${lose.daten?.id}/bild`, { headers: { cookie: modi } }); + ok(lr.status === 404, + `eine lose Datei der Ablage dagegen nicht (${lr.status})`); +} + +/* ======================================================================= + 5. IM BROWSER: DIE GALERIE + ======================================================================= */ +melde(""); +melde("=== 5. Was man sieht ==="); +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); +try { + const kontext = await browser.newContext({ viewport: { width: 1100, height: 900 } }); + await kontext.addCookies(dogi.split("; ").map((k) => { + const [name, ...rest] = k.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); }); + await seite.goto(`${BASIS}/workspace/bereich.html?b=highlight`, { waitUntil: "networkidle" }); + await seite.waitForSelector(".bildstreifen__bild", { timeout: 15_000 }); + await seite.waitForTimeout(600); + + const m = await seite.evaluate(() => { + const bilder = [...document.querySelectorAll(".bildstreifen__bild")]; + return { + raster: document.getElementById("liste")?.dataset.galerie || "", + spalten: getComputedStyle(document.getElementById("liste")) + .gridTemplateColumns.split(" ").filter(Boolean).length, + anzahl: bilder.length, + /* WIRKLICH GELADEN, nicht nur im Quelltext: Ein kaputtes Bild hat + naturalWidth 0 -- und sieht im Foto aus wie ein Rahmen. */ + geladen: bilder.filter((b) => b.naturalWidth > 0).length, + alt: bilder.map((b) => b.alt), + /* Anspringbar: Eine Galerie aus nicht erreichbaren Kacheln ist + fuer einen Teil der Zuschauer keine Galerie. */ + verweise: document.querySelectorAll(".bildstreifen__feld[href]").length, + }; + }); + + ok(m.raster === "ja", "die Liste ist ein Raster"); + ok(m.spalten >= 2, `mit mehreren Spalten (${m.spalten})`); + ok(m.anzahl >= 1, `${m.anzahl} Bilder im Raster`); + ok(m.geladen === m.anzahl, `alle wirklich geladen (${m.geladen} von ${m.anzahl})`); + ok(m.alt.every((t) => t && t.length > 5), + `jedes hat eine Beschreibung ("${m.alt[0] || ""}")`); + ok(m.verweise === m.anzahl, `und jedes ist anspringbar (${m.verweise})`); + + /* DER EIGENTLICHE BEWEIS DIESES ABSCHNITTS: Am Beitrag haengen VIER + Dateien -- zwei echte PNGs, ein SVG und eine HTML-Datei, die sich + als PNG ausgibt. Im Raster stehen ZWEI. Die beiden anderen holt + der Browser, bekommt 404 und der `error`-Handler raeumt sie weg: + kein leerer Rahmen, kein kaputtes Symbol. + + Das ist die Stelle, an der die Bytepruefung im Server und die + Oberflaeche zusammenarbeiten -- und die Zahl 2 von 4 ist der + Beleg. */ + const angehaengt = await (await fetch(BASIS + "/workspace/api/bereich/highlight", + { headers: { cookie: dogi } })).json() + .then((j) => (j.eintraege || []).find((x) => x.id === eintragId)?.bilder?.length ?? 0); + ok(angehaengt === 4, `am Beitrag haengen ${angehaengt} Dateien`); + ok(m.anzahl === 2, + `im Raster stehen nur die ${m.anzahl} echten Bilder -- SVG und getarnte HTML nicht`); + + /* DIE KONSOLE: Genau diese beiden 404 sind gewollt. Alles andere + nicht. Ein pauschales "Konsole egal" waere der bequeme Weg und + wuerde jeden echten Fehler mitverstecken. */ + const unerwartet = konsole.filter((z) => !/404/.test(z)); + ok(unerwartet.length === 0, + unerwartet.length ? `Konsole: ${unerwartet[0]}` + : `nur die ${konsole.length} gewollten 404 der Nicht-Bilder in der Konsole`); + + if (process.env.SCHIRM) { + const pfad = fileURLToPath(new URL("pruef-galerie.png", import.meta.url)); + await seite.screenshot({ path: pfad, fullPage: false }); + melde(" -- Bildschirmfoto: server/pruef-galerie.png"); + } + await kontext.close(); +} finally { + 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/workspace-bereiche.js b/server/workspace-bereiche.js index 54c1bab6..e97217ac 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -1132,6 +1132,28 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => { ORDER BY ${reihenfolge}`) .all(...(treffBrett ? [wer] : []), ...regel.werte, bereich); + /* DIE BILDER AN DEN BEITRAEGEN (17.09.2026) -- in EINER Abfrage + fuer die ganze Liste. Vierzig Beitraege einzeln zu fragen waeren + vierzig Anfragen, und die Seite ruckelte sichtbar beim Aufbau; + genau dieselbe Ueberlegung steht schon bei den Rueckmeldungen. + + WAS HIER MITKOMMT, IST NUR DIE NUMMER. Ob dahinter wirklich ein + Bild liegt, entscheidet erst der Bildweg selbst -- und zwar an + den ersten Bytes der Datei, nicht am behaupteten Typ. */ + if (eintraege.length) { + const platz = eintraege.map(() => "?").join(", "); + const bilder = db().prepare( + `SELECT id, eintrag_id, name_original FROM dateien + WHERE eintrag_id IN (${platz}) ORDER BY id`) + .all(...eintraege.map((e) => e.id)); + const je = new Map(); + for (const b of bilder) { + if (!je.has(b.eintrag_id)) je.set(b.eintrag_id, []); + je.get(b.eintrag_id).push({ id: b.id, name: b.name_original }); + } + for (const e of eintraege) e.bilder = je.get(e.id) || []; + } + /* DASS DIES EIN BRETT DES TREFFS IST, SAGT DER SERVER (11.09.2026). Die Oberflaeche braucht die Auskunft, um ihre Knoepfe zu bauen diff --git a/server/workspace-dateien.js b/server/workspace-dateien.js index 16225552..afe91e04 100644 --- a/server/workspace-dateien.js +++ b/server/workspace-dateien.js @@ -27,11 +27,13 @@ import express from "express"; import { randomBytes } from "node:crypto"; import { join, extname, basename } from "node:path"; -import { mkdirSync, createReadStream, writeFileSync, unlinkSync, statSync } from "node:fs"; +import { mkdirSync, createReadStream, writeFileSync, unlinkSync, statSync, + openSync, readSync, closeSync } from "node:fs"; import { db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, siehtAlles, istSpicy, ohneDogFather, ROLLEN_SORTIERUNG, externSql, EXTERN_MAX, sichtbarePersonenIds, ohneTeamDogi, ohneAgentur, siehtModis, + TREFF_BRETTER, TREFF_ROLLEN, } from "./workspace.js"; export const dateienRouter = express.Router(); @@ -504,14 +506,32 @@ dateienRouter.post("/workspace/api/dateien", ORDER BY id DESC`).all(name, creatorId, creatorId); const ersetztId = vorgaenger.length === 1 ? vorgaenger[0].id : null; + /* AN EINEN COMMUNITY-BEITRAG (17.09.2026). Dieselbe Regel wie bei + der Aufgabe eine Ebene hoeher: Eine unbekannte Nummer ist ein + FEHLER, kein stilles Ignorieren -- wer ein Bild an ein Highlight + haengen wollte und es liegt danach lose in der Ablage, sucht es + spaeter am Highlight. */ + let eintragId = null; + const rohEintrag = req.get("x-eintrag"); + if (rohEintrag) { + const n = Number(rohEintrag); + const e = Number.isInteger(n) && n > 0 + ? db().prepare("SELECT id, bereich FROM eintraege WHERE id = ?").get(n) + : null; + if (!e || !TREFF_BRETTER.includes(e.bereich)) { + return res.status(404).json({ fehler: "eintrag_nicht_gefunden" }); + } + eintragId = e.id; + } + const { lastInsertRowid } = db().prepare(` INSERT INTO dateien (name_original, name_datei, groesse, typ, status, notiz, - creator_id, hochgeladen_von, erstellt, ersetzt_id, aufgabe_id) - VALUES (?,?,?,?,'entwurf',?,?,?,?,?,?)`).run( + creator_id, hochgeladen_von, erstellt, ersetzt_id, aufgabe_id, eintrag_id) + VALUES (?,?,?,?,'entwurf',?,?,?,?,?,?,?)`).run( name, dateiname, req.body.length, (req.get("content-type") || "application/octet-stream").slice(0, 100), - notiz, creatorId, req.person.id, jetzt(), ersetztId, aufgabeId); + notiz, creatorId, req.person.id, jetzt(), ersetztId, aufgabeId, eintragId); /* Ausdrueckliche Freigaben. Der Kopf traegt eine Liste von Ids -- geprueft wird jede einzelne gegen die erlaubte Auswahl. */ @@ -541,6 +561,105 @@ dateienRouter.post("/workspace/api/dateien", /* ---------- Herunterladen ------------------------------------------------ */ +/* ===================================================================== + EIN BILD INLINE AUSLIEFERN (17.09.2026) + + Alles in dieser Ablage geht sonst als DOWNLOAD hinaus + (`application/octet-stream`, `Content-Disposition: attachment`), und + das ist richtig so: Eine hochgeladene HTML- oder SVG-Datei wuerde im + Browser als Seite DIESER Domain laufen -- mit Zugriff auf die + Sitzung. + + Eine Galerie braucht Bilder im Bild. Also ein eigener, enger Weg -- + und der beantwortet die Frage "ist das ein Bild?" NICHT so, wie man + es zuerst tut: + + `dateien.typ` ist der vom Browser BEHAUPTETE Typ + (`req.get("content-type")`). Wer hochlaedt, bestimmt ihn selbst. + Eine Galerie, die ihm glaubt, liefert auf Zuruf alles inline aus. + + Erkannt wird deshalb an den ERSTEN BYTES der Datei. Die kann niemand + behaupten; sie stehen da oder nicht. + + UND SVG IST AUSDRUECKLICH NICHT DABEI. Es ist ein Bildformat, und es + kann Skript enthalten -- genau die Luecke, gegen die die Regel oben + gebaut wurde. Ein Format, das beides ist, gehoert nicht in die + Ausnahme. */ +const BILD_KENNUNGEN = [ + { typ: "image/png", pruefe: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 }, + { typ: "image/jpeg", pruefe: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, + { typ: "image/gif", pruefe: (b) => b.slice(0, 6).toString("latin1") === "GIF87a" + || b.slice(0, 6).toString("latin1") === "GIF89a" }, + { typ: "image/webp", pruefe: (b) => b.slice(0, 4).toString("latin1") === "RIFF" + && b.slice(8, 12).toString("latin1") === "WEBP" }, +]; + +/** Welches Bildformat steht wirklich am Dateianfang? `null` heisst: + * kein Bild -- und dann wird nichts inline ausgeliefert. */ +export function bildArtVon(anfang) { + if (!Buffer.isBuffer(anfang) || anfang.length < 12) return null; + for (const k of BILD_KENNUNGEN) { + try { if (k.pruefe(anfang)) return k.typ; } catch { /* naechste */ } + } + return null; +} + +dateienRouter.get("/workspace/api/dateien/:id/bild", (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const datei = db().prepare( + "SELECT id, name_datei, name_original, eintrag_id FROM dateien WHERE id = ?").get(id); + if (!datei) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* WESSEN REGEL GILT? Ein Bild an einem Community-Beitrag folgt dem + BEITRAG, nicht der Dateiablage: Wer das Brett sehen darf, sieht + das Bild. Die Ablage-Regel haengt an Creator-Zuordnungen, und ein + Gast hat keine -- er saehe sonst nie ein Highlight, obwohl es + fuer ihn gemacht ist. Alles ohne Beitrag faellt auf die normale + Regel zurueck. */ + let erlaubt = false; + if (datei.eintrag_id) { + const e = db().prepare("SELECT bereich FROM eintraege WHERE id = ?").get(datei.eintrag_id); + erlaubt = !!e && TREFF_BRETTER.includes(e.bereich) + && TREFF_ROLLEN.has((req.sicht || req.person)?.rolle); + } + if (!erlaubt) { + const { wo, werte } = sichtbar(req.person); + erlaubt = !!db().prepare(`SELECT d.id ${VERBUND} WHERE ${wo} AND d.id = ?`) + .get(...werte, id); + } + if (!erlaubt) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const pfad = join(ORDNER, datei.name_datei); + let anfang; + try { + const f = openSync(pfad, "r"); + anfang = Buffer.alloc(16); + readSync(f, anfang, 0, 16, 0); + closeSync(f); + } catch { return res.status(410).json({ fehler: "Datei fehlt auf der Platte." }); } + + const art = bildArtVon(anfang); + /* KEIN BILD HEISST 404, nicht 415. Eine eigene Antwort waere die + Auskunft "diese Nummer gibt es, sie ist nur kein Bild" -- und die + laesst sich durchzaehlen. */ + if (!art) return res.status(404).json({ fehler: "nicht_gefunden" }); + + res.setHeader("Content-Type", art); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox"); + res.setHeader("Content-Disposition", + `inline; filename*=UTF-8''${encodeURIComponent(datei.name_original)}`); + res.setHeader("Cache-Control", "private, max-age=3600"); + createReadStream(pfad).pipe(res); + } catch (fehler) { + console.error("[workspace] Bild ausliefern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + dateienRouter.get("/workspace/api/dateien/:id/inhalt", (req, res) => { try { const id = Number(req.params.id); diff --git a/server/workspace.js b/server/workspace.js index a599f8aa..fe68dbdf 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -1766,6 +1766,23 @@ function umstellungen(d) { falschen Moment. */ const jetztStempel = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, ""); + /* ---- Ein Bild an einem Community-Beitrag (17.09.2026) ---- + + "Highlights -- eure Clips und Bilder" konnte bis heute gar keine + Bilder tragen: `dateien` hing an `aufgabe_id`, nicht an einem + Eintrag. Der Plan im Vault sagte "Anhaenge sind schon da" -- sie + hingen nur am falschen Ding. */ + try { + const spalten = d.prepare("PRAGMA table_info(dateien)").all().map((s) => s.name); + if (spalten.length && !spalten.includes("eintrag_id")) { + d.exec("ALTER TABLE dateien ADD COLUMN eintrag_id INTEGER REFERENCES eintraege(id) ON DELETE CASCADE"); + d.exec("CREATE INDEX IF NOT EXISTS idx_dateien_eintrag ON dateien (eintrag_id)"); + console.log("[workspace] Spalte 'eintrag_id' an den Dateien ergaenzt."); + } + } catch (fehler) { + console.error("[workspace] Spalte 'eintrag_id':", fehler?.message); + } + /* ---- Die Uhrzeit an einem Termin (17.09.2026) ---- "Was ansteht" bekommt einen Countdown: "Nächster Stream in 3 Std diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 72d25968..75fd453b 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=202609171134", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609171139", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609171134", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609171139", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171134", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609171139", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171134", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609171139", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index cf5a89ce..594012c3 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -845,3 +845,55 @@ background: color-mix(in srgb, var(--akzent) 12%, transparent); cursor: default; } + +/* ===================================================================== + HIGHLIGHTS ALS GALERIE (17.09.2026) + + Aus dem Plan im Vault, Stufe 5. „Eure Clips und Bilder" zeigte + bisher Textkarten mit einem Anhang darunter. **Ein Bild als Anhang + unter einer Überschrift ist kein Highlight** — dieser Bereich lebt + vom Sehen. Der Text wird zur Bildunterschrift, nicht umgekehrt. + ------------------------------------------------------------------- */ + +/* Das Raster. `auto-fill` mit `minmax` statt einer festen Spaltenzahl: + Auf 390 px wird es eine Spalte, auf dem Rechner drei — und zwar + gemessen, nicht gerechnet. Eine feste Zahl wäre an einem der beiden + Enden falsch. */ +#liste[data-galerie="ja"] { + display: grid; gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + align-items: start; +} + +/* Der Bildstreifen auf einer Karte. */ +.bildstreifen { + display: grid; gap: 4px; margin: -4px -4px 12px; + border-radius: var(--radius-klein); overflow: hidden; +} +/* Bei mehreren Bildern zwei Spalten — mehr wird auf einer Karte zu + klein, um noch etwas zu erkennen. */ +.bildstreifen[data-mehrere="ja"] { grid-template-columns: 1fr 1fr; } + +.bildstreifen__feld { + display: block; position: relative; + aspect-ratio: 16 / 9; + background: rgba(255, 255, 255, .03); + border-radius: var(--radius-klein); + overflow: hidden; + /* Ein Bild, das man anfassen kann, muss auch anspringbar sein — der + Fokusring kommt aus start.css und darf nicht abgeschnitten werden. */ + outline-offset: 2px; +} +.bildstreifen__bild { + width: 100%; height: 100%; + object-fit: cover; display: block; + /* Beim Überfahren ganz leicht näher — genug, um zu sagen „das geht + auf", zu wenig, um zu zappeln. */ + transition: transform var(--tempo); +} +.bildstreifen__feld:hover .bildstreifen__bild, +.bildstreifen__feld:focus-visible .bildstreifen__bild { transform: scale(1.02); } +@media (prefers-reduced-motion: reduce) { + .bildstreifen__bild { transition: none; } + .bildstreifen__feld:hover .bildstreifen__bild { transform: none; } +} diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 62d4384b..8a8c12aa 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -326,6 +326,44 @@ } kopf.append(b); } + /* DAS BILD STEHT OBEN, VOR DEM TITEL (17.09.2026) -- aus dem Plan + im Vault, Stufe 5. + + "Highlights -- eure Clips und Bilder" zeigte bisher Textkarten + mit einem Anhang darunter. Ein Bild als Anhang unter einer + Ueberschrift ist kein Highlight; dieser Bereich lebt vom SEHEN. + Der Text wird damit zur Bildunterschrift, nicht umgekehrt. + + ANSPRINGBAR UND MIT NAMEN: Ein Bild ohne `alt` ist fuer einen + Teil der Zuschauer gar nicht da, und eine Galerie aus + nicht-anspringbaren Kacheln ist keine Galerie. Deshalb ein + echter Verweis mit Beschriftung -- das kann die Tastatur, ohne + eine Zeile JavaScript. */ + if (Array.isArray(e.bilder) && e.bilder.length) { + const bg = el('div', 'bildstreifen'); + if (e.bilder.length > 1) bg.dataset.mehrere = 'ja'; + for (const b of e.bilder) { + const a = el('a', 'bildstreifen__feld'); + a.href = `/workspace/api/dateien/${b.id}/bild`; + a.target = '_blank'; + a.rel = 'noopener'; + const bild = el('img', 'bildstreifen__bild'); + bild.src = `/workspace/api/dateien/${b.id}/bild`; + bild.loading = 'lazy'; + bild.decoding = 'async'; + /* Der Titel des Beitrags ist die beste Beschreibung, die es + hier gibt -- ein Dateiname wie "IMG_2831.jpg" sagt niemandem + etwas. */ + bild.alt = e.titel ? `Bild zu „${e.titel}“` : (b.name || 'Bild'); + /* Ist es doch kein Bild (oder die Datei fehlt), verschwindet + der Rahmen, statt ein kaputtes Symbol zu zeigen. */ + bild.addEventListener('error', () => { a.remove(); if (!bg.childNodes.length) bg.remove(); }); + a.append(bild); + bg.append(a); + } + k.append(bg); + } + k.append(kopf); /* DER RANGBALKEN. Eine Wunschliste ist eine Rangfolge, keine Liste @@ -742,6 +780,12 @@ const ziel = $('liste'); ziel.textContent = ''; ziel.setAttribute('aria-busy', 'false'); + /* HIGHLIGHTS SIND EINE GALERIE, keine Spalte aus Karten. Das + Raster steht hier und nicht fest im HTML, weil dieselbe Seite + acht Bretter bedient -- ein Raster in der Datei waere fuer + sieben davon falsch. */ + if (bereich === 'highlight') ziel.dataset.galerie = 'ja'; + else delete ziel.dataset.galerie; const sichtbar = daten.eintraege.filter((e) => { if (!filter) return true; diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 2d09a3fe..d94be48f 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. --> - - - - - - + + + + + + - + @@ -291,10 +291,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 738177e0..387c0d09 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,10 +168,10 @@ - - - - - + + + + + diff --git a/workspace/befinden.html b/workspace/befinden.html index a9f802a6..475c5f27 100644 --- a/workspace/befinden.html +++ b/workspace/befinden.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -105,9 +105,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index b0d8416c..1db333e3 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. --> - - - - - - - + + + + + + + - + @@ -194,14 +194,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index dec74052..b21ee3c9 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,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index b3b59122..84d982e5 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,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 1c7fd5da..53160cd0 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,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 4937aee3..5e3b746b 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -36,15 +36,15 @@ - - - + + + - + @@ -339,6 +339,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 642f34a8..563f8931 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=202609171134", + "src": "/assets/img/app-symbole/crew-192.png?v=202609171139", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609171134", + "src": "/assets/img/app-symbole/crew-512.png?v=202609171139", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609171134", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609171139", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609171134", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609171139", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 09fe3787..e0bd01fb 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,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/entwicklung.html b/workspace/entwicklung.html index 0d6887dc..79de1909 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -102,9 +102,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index 2e50c8ed..077f79dd 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 2e2e52ef..e09f78f5 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,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 26b5a8c7..6e04071f 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. --> - - - - - - - + + + + + + + - + @@ -444,10 +444,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index d3d5038d..5a69ab63 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,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index e2ae0942..0115b82e 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,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html index 7fdfe275..25d33dd0 100644 --- a/workspace/rechte.html +++ b/workspace/rechte.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -134,9 +134,9 @@ 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 ecc7b7f6..978e9b1a 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,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 1555cd16..1ee75bca 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,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 52c3b0f6..61e6245d 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,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 1f2e9d23..a82c1259 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,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index c420b303..5019984d 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,11 +146,11 @@ - - - - - - + + + + + + diff --git a/workspace/talente.html b/workspace/talente.html index 6f3727a0..99919769 100644 --- a/workspace/talente.html +++ b/workspace/talente.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/team.html b/workspace/team.html index 8b94352d..077701ef 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 8f6e902d..b2747cac 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 97e451fe..8529e63f 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -126,9 +126,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index d1d226f6..07922c5d 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -211,10 +211,10 @@ Beim ersten Anlauf fehlte die Zeile auf genau diesen beiden neuen Seiten. Gefunden hat das nicht das Auge, sondern pruef-css-klassen, die seit dem 07.09. jede Seite danach absucht. --> - - - - - + + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 4618bcdd..678173b1 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,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 4924f555..06bfc19c 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,10 +190,10 @@ - - - - - + + + + +