From 81b51ab94f0f490335eee191e753e2bf06458a9e Mon Sep 17 00:00:00 2001 From: Dogfather Date: Fri, 28 Aug 2026 01:14:00 +0200 Subject: [PATCH] Workspace: Dateiablage mit Freigabe-Ablauf (Phase 1 vollstaendig) /workspace/dateien.html -- Ablegen per Ziehen oder Auswaehlen, Entwurf -> Review -> Freigabe, Filter je Zustand. OHNE NEUE ABHAENGIGKEIT. Uploads laufen ueblicherweise ueber multer. Hier schickt der Browser die Datei roh im Rumpf, der Name steht im Kopf (URI-kodiert, damit Umlaute heil ankommen). express.raw() bringt den passenden Empfaenger schon mit -- kein Zerlegen von multipart/form-data von Hand, was gerade hier heikel waere. Die drei Punkte, an denen Dateiablagen typischerweise scheitern: 1. Die Dateien liegen AUSSERHALB des Repos (workspace-daten/dateien/). Lagen sie darin, wuerde express.static sie ungeprueft ans Netz geben. 2. Auf der Platte traegt jede Datei einen erzeugten Zufallsnamen, der Originalname steht nur in der Datenbank. Geprueft mit dem Dateinamen "../../etc/passwd": gespeichert wurde "passwd", auf der Platte ein Zufallsname IM Ordner -- nichts ist ausgebrochen. 3. Ausgeliefert wird immer als Download mit neutralem Typ, dazu nosniff und CSP sandbox. Geprueft mit einer hochgeladenen boese.html: kommt als application/octet-stream zurueck, kann also keinen Code im Namen der Domain ausfuehren. Freigabe nach Konzept: Der Zustand "freigegeben" ist eine Abnahme und bleibt dem Management vorbehalten. Geprueft -- Luna kann Entwurf -> Review, aber nicht freigeben (403); eine freigegebene Datei kann sie weder aendern noch loeschen. Sichtbarkeit wie ueberall: Chef 2 Dateien, Luna 1, Mika 0. Zugriff auf eine fremde Datei: 404, nicht 403. Ein Fehler beim Testen gefunden: Bei zu grosser Datei brach express.raw ab, BEVOR der eigene Code lief -- der Fehler landete im allgemeinen Behandler als HTTP 500. Fuer die Nutzerin sah das aus wie ein kaputter Server statt wie "Datei zu gross". Jetzt faengt ein eigener Fehlerbehandler am Ende des Routers das ab und antwortet mit 413 und einer verstaendlichen Meldung. Damit ist Phase 1 aus dem Konzept vollstaendig. --- server/index.js | 2 + server/workspace-dateien.js | Bin 0 -> 13536 bytes server/workspace.js | 27 ++++ workspace/assets/css/dateien.css | 104 ++++++++++++++ workspace/assets/js/dateien.js | 233 +++++++++++++++++++++++++++++++ workspace/assets/js/start.js | 2 +- workspace/dateien.html | 77 ++++++++++ 7 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 server/workspace-dateien.js create mode 100644 workspace/assets/css/dateien.css create mode 100644 workspace/assets/js/dateien.js create mode 100644 workspace/dateien.html diff --git a/server/index.js b/server/index.js index 16decd31..06740e60 100644 --- a/server/index.js +++ b/server/index.js @@ -18,6 +18,7 @@ import { aufgabenRouter } from "./workspace-aufgaben.js"; import { personenRouter } from "./workspace-personen.js"; import { profilRouter } from "./workspace-profil.js"; import { kalenderRouter } from "./workspace-kalender.js"; +import { dateienRouter } from "./workspace-dateien.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -76,6 +77,7 @@ app.use(aufgabenRouter); app.use(personenRouter); app.use(profilRouter); app.use(kalenderRouter); +app.use(dateienRouter); /* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen). SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert diff --git a/server/workspace-dateien.js b/server/workspace-dateien.js new file mode 100644 index 0000000000000000000000000000000000000000..745a476629168ded9a07d359f4b47d9461f536f0 GIT binary patch literal 13536 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 literal 0 HcmV?d00001 diff --git a/server/workspace.js b/server/workspace.js index f78aeb4d..35554b05 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -43,6 +43,11 @@ const require = createRequire(pathToFileURL(__dirname + "/")); const DB_PFAD = process.env.WORKSPACE_DB || join(__dirname, "..", "..", "workspace-daten", "workspace.db"); +/* Ordner fuer hochgeladene Dateien -- neben der Datenbank, also ebenfalls + ausserhalb des Repos. Waeren sie im Repo, wuerde express.static sie + ungeschuetzt ausliefern. */ +export const DATEN_ORDNER = dirname(DB_PFAD); + const COOKIE = "dfw_sitzung"; const SITZUNG_STUNDEN = 12; const VERSUCHE_MAX = 8; // pro IP @@ -171,6 +176,27 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_termine_beginn ON termine (beginn); + /* Dateiablage. Auf der Platte traegt jede Datei einen erzeugten + Zufallsnamen (name_datei), der Originalname steht nur hier. + Dadurch kann ein Dateiname weder Pfade verlassen noch etwas + ueberschreiben. Der Freigabe-Ablauf aus dem Konzept steckt in + status: entwurf -> review -> freigegeben. */ + CREATE TABLE IF NOT EXISTS dateien ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name_original TEXT NOT NULL, + name_datei TEXT NOT NULL UNIQUE, + groesse INTEGER NOT NULL, + typ TEXT, + status TEXT NOT NULL DEFAULT 'entwurf' + CHECK (status IN ('entwurf','review','freigegeben')), + notiz TEXT, + creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + hochgeladen_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + erstellt TEXT NOT NULL, + geaendert TEXT + ); + CREATE INDEX IF NOT EXISTS idx_dateien_creator ON dateien (creator_id); + CREATE TABLE IF NOT EXISTS versuche ( ip TEXT NOT NULL, zeitpunkt TEXT NOT NULL @@ -305,6 +331,7 @@ const GESCHUETZT = { "/workspace/personen.html": ["admin"], "/workspace/profil.html": ["admin", "creator"], "/workspace/kalender.html": null, + "/workspace/dateien.html": null, }; workspaceRouter.use((req, res, next) => { diff --git a/workspace/assets/css/dateien.css b/workspace/assets/css/dateien.css new file mode 100644 index 00000000..9ae2a6b9 --- /dev/null +++ b/workspace/assets/css/dateien.css @@ -0,0 +1,104 @@ +/* =================================================================== + Dateiablage mit Freigabe-Ablauf. + =================================================================== */ + +.ablage { + display: grid; place-items: center; gap: 6px; + padding: 34px 20px; margin-bottom: 16px; + border: 1px dashed var(--rand); + border-radius: var(--radius); + background: rgba(255, 255, 255, .02); + cursor: pointer; text-align: center; + transition: border-color var(--tempo), background var(--tempo); +} +.ablage:hover, .ablage:focus-visible { + border-color: rgba(63, 189, 245, .5); + background: rgba(63, 189, 245, .05); + outline: none; +} +/* Während eine Datei darüber schwebt, deutlich sichtbar -- sonst weiß + niemand, ob das Loslassen an dieser Stelle etwas bewirkt. */ +.ablage[data-bereit="ja"] { + border-color: var(--akzent); + border-style: solid; + background: rgba(63, 189, 245, .10); +} +.ablage__zeichen { + width: 30px; height: 30px; + fill: none; stroke: var(--text-still); + stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; +} +.ablage:hover .ablage__zeichen, .ablage[data-bereit="ja"] .ablage__zeichen { stroke: var(--akzent); } +.ablage__text { margin: 0; font-size: .92rem; color: var(--text-leise); } +.ablage__text strong { color: var(--akzent); } +.ablage__klein { margin: 0; font-size: .78rem; color: var(--text-still); } + +.hochlade-zeile { margin-bottom: 18px; } +.hochlade-zeile select { + min-width: 240px; padding: 9px 12px; + background: rgba(255, 255, 255, .045); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; +} + +.fortschritt { + margin: 0 0 14px; padding: 10px 14px; + background: rgba(63, 189, 245, .08); + border: 1px solid rgba(63, 189, 245, .3); + border-radius: var(--radius-klein); + font-size: .86rem; color: var(--text-leise); +} + +.filter { display: flex; gap: 8px; margin: 0 0 20px; flex-wrap: wrap; } +.filter .schritt[aria-pressed="true"] { + background: rgba(63, 189, 245, .14); + border-color: rgba(63, 189, 245, .55); + color: var(--text); +} + +/* ---------- Liste ------------------------------------------------------- */ + +.datei { + display: grid; gap: 4px 16px; align-items: center; + grid-template-columns: 34px 1fr auto; + padding: 13px 16px; margin-bottom: 9px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); + border-left: 3px solid var(--rand); + border-radius: var(--radius-klein); +} +.datei[data-status="entwurf"] { border-left-color: #6f89a8; } +.datei[data-status="review"] { border-left-color: #e0a33c; } +.datei[data-status="freigegeben"] { border-left-color: #4dab77; + background: rgba(77, 171, 119, .05); } + +.datei__zeichen { + grid-row: span 2; width: 26px; height: 26px; + fill: none; stroke: var(--text-still); + stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; +} +.datei__name { font-weight: 600; font-size: .93rem; word-break: break-word; } +.datei__zeile { + display: flex; flex-wrap: wrap; gap: 8px; + font-size: .78rem; color: var(--text-still); +} +.datei__knoepfe { grid-row: span 2; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } + +.marke-status { + padding: 1px 9px; border-radius: 999px; + font-size: .68rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; +} +.marke-status[data-status="entwurf"] { background: rgba(111, 137, 168, .18); color: #a8bdd4; } +.marke-status[data-status="review"] { background: rgba(224, 163, 60, .16); color: #e6b45c; } +.marke-status[data-status="freigegeben"] { background: rgba(77, 171, 119, .18); color: #79d1a2; } + +.leer-hinweis { + padding: 26px 20px; text-align: center; + color: var(--text-still); font-size: .9rem; + border: 1px dashed var(--rand); border-radius: var(--radius); +} + +@media (max-width: 720px) { + .datei { grid-template-columns: 34px 1fr; } + .datei__knoepfe { grid-row: auto; grid-column: 1 / -1; margin-top: 9px; } +} diff --git a/workspace/assets/js/dateien.js b/workspace/assets/js/dateien.js new file mode 100644 index 00000000..3b0908d1 --- /dev/null +++ b/workspace/assets/js/dateien.js @@ -0,0 +1,233 @@ +/* =================================================================== + Dateiablage. + + Hochgeladen wird der rohe Dateiinhalt, der Name steht im Kopf -- + deshalb braucht der Server kein multipart/form-data zu zerlegen. + Wer was sehen und freigeben darf, entscheidet ausschliesslich er. + =================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + const STATUSNAME = { entwurf: 'Entwurf', review: 'Review', freigegeben: 'Freigegeben' }; + const WEITER = { entwurf: 'review', review: 'freigegeben' }; + const ZURUECK = { review: 'entwurf', freigegeben: 'review' }; + + let ich = null; + let filter = ''; + let maxBytes = 25 * 1024 * 1024; + + const el = (tag, klasse, text) => { + const k = document.createElement(tag); + if (klasse) k.className = klasse; + if (text !== undefined) k.textContent = text; + return k; + }; + const melde = (t) => { $('fehler').textContent = t || ''; }; + + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + return a; + } + + function groesse(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB'; + return (bytes / 1024 / 1024).toFixed(1).replace('.', ',') + ' MB'; + } + const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : '—'); + + /* ---------- Hochladen -------------------------------------------------- */ + + async function schicke(datei) { + melde(''); + if (datei.size > maxBytes) { + melde(`„${datei.name}" ist ${groesse(datei.size)} groß – erlaubt sind ${groesse(maxBytes)}.`); + return; + } + $('fortschritt').hidden = false; + $('fortschritt-text').textContent = `„${datei.name}" wird hochgeladen …`; + try { + const kopf = { + 'Content-Type': datei.type || 'application/octet-stream', + /* Kodiert, damit Umlaute und Leerzeichen im Kopf heil ankommen. */ + 'X-Dateiname': encodeURIComponent(datei.name), + }; + if (ich.rolle === 'admin' && $('f-creator').value) kopf['X-Creator'] = $('f-creator').value; + + const a = await hole('/workspace/api/dateien', { method: 'POST', headers: kopf, body: datei }); + if (!a.ok) { + const d = await a.json().catch(() => ({})); + melde(d.fehler || 'Hochladen hat nicht geklappt.'); + return; + } + await laden(); + } catch { /* umgeleitet */ } finally { + $('fortschritt').hidden = true; + $('feld-datei').value = ''; + } + } + + const ablage = $('ablage'); + ablage.addEventListener('click', () => $('feld-datei').click()); + ablage.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('feld-datei').click(); } + }); + $('feld-datei').addEventListener('change', () => { + for (const d of $('feld-datei').files) schicke(d); + }); + + for (const art of ['dragenter', 'dragover']) { + ablage.addEventListener(art, (e) => { e.preventDefault(); ablage.dataset.bereit = 'ja'; }); + } + for (const art of ['dragleave', 'drop']) { + ablage.addEventListener(art, () => { delete ablage.dataset.bereit; }); + } + ablage.addEventListener('drop', (e) => { + e.preventDefault(); + for (const d of e.dataTransfer.files) schicke(d); + }); + /* Ohne diese beiden öffnet der Browser eine daneben fallengelassene + Datei einfach als Seite -- die Arbeit wäre dann weg. */ + addEventListener('dragover', (e) => e.preventDefault()); + addEventListener('drop', (e) => e.preventDefault()); + + /* ---------- Liste ------------------------------------------------------ */ + + function tuKnopf(text, tu) { + const b = el('button', 'schritt', text); + b.type = 'button'; + b.addEventListener('click', async () => { + b.disabled = true; melde(''); + try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; } + }); + return b; + } + + async function statusSetzen(d, neu) { + const a = await hole(`/workspace/api/dateien/${d.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: neu }), + }); + if (!a.ok) { + melde((await a.json().catch(() => ({}))).fehler || 'Ändern hat nicht geklappt.'); + return; + } + await laden(); + } + + function zeile(d) { + const k = el('div', 'datei'); + k.dataset.status = d.status; + + const zeichen = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + zeichen.setAttribute('viewBox', '0 0 24 24'); + zeichen.setAttribute('class', 'datei__zeichen'); + zeichen.setAttribute('aria-hidden', 'true'); + const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + pfad.setAttribute('d', 'M13.5 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.5L13.5 3Zm0 0V8a.5.5 0 0 0 .5.5h5'); + zeichen.append(pfad); + k.append(zeichen); + + k.append(el('div', 'datei__name', d.name_original)); + + const knoepfe = el('div', 'datei__knoepfe'); + const marke = el('span', 'marke-status', STATUSNAME[d.status] || d.status); + marke.dataset.status = d.status; + knoepfe.append(marke); + + const holen = el('a', 'schritt', 'öffnen'); + holen.href = `/workspace/api/dateien/${d.id}/inhalt`; + knoepfe.append(holen); + + if (WEITER[d.status]) { + const darf = d.status !== 'review' || ich.rolle === 'admin'; + if (darf) knoepfe.append(tuKnopf( + d.status === 'entwurf' ? 'zum Review ▶' : 'freigeben ▶', + () => statusSetzen(d, WEITER[d.status]))); + } + if (ZURUECK[d.status] && (d.status !== 'freigegeben' || ich.rolle === 'admin')) { + knoepfe.append(tuKnopf('◀ zurück', () => statusSetzen(d, ZURUECK[d.status]))); + } + if (ich.rolle === 'admin' || (d.hochgeladen_von === ich.id && d.status !== 'freigegeben')) { + knoepfe.append(tuKnopf('löschen', async () => { + if (!confirm(`„${d.name_original}" wirklich löschen?`)) return; + const a = await hole(`/workspace/api/dateien/${d.id}`, { method: 'DELETE' }); + if (!a.ok) { melde('Löschen hat nicht geklappt.'); return; } + await laden(); + })); + } + k.append(knoepfe); + + const zeile2 = el('div', 'datei__zeile'); + zeile2.append(el('span', null, groesse(d.groesse))); + zeile2.append(el('span', null, '· ' + datum(d.erstellt))); + if (d.hochgeladen_name) zeile2.append(el('span', null, '· von ' + d.hochgeladen_name)); + if (ich.rolle === 'admin' && d.creator_name) { + zeile2.append(el('span', null, '· Bereich ' + d.creator_name)); + } + if (d.notiz) zeile2.append(el('span', null, '· ' + d.notiz)); + k.append(zeile2); + return k; + } + + async function laden() { + try { + const a = await hole('/workspace/api/dateien'); + if (!a.ok) { melde('Dateien konnten nicht geladen werden.'); return; } + const daten = await a.json(); + maxBytes = daten.max_bytes || maxBytes; + $('ablage-grenze').textContent = 'bis ' + groesse(maxBytes); + + const ziel = $('liste'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + const sichtbar = daten.dateien.filter((d) => !filter || d.status === filter); + if (!sichtbar.length) { + ziel.append(el('p', 'leer-hinweis', daten.dateien.length + ? 'In diesem Zustand liegt gerade nichts.' + : 'Noch keine Datei abgelegt.')); + return; + } + for (const d of sichtbar) ziel.append(zeile(d)); + } catch { /* umgeleitet */ } + } + + for (const b of $('filter').querySelectorAll('.schritt')) { + b.addEventListener('click', async () => { + filter = b.dataset.status; + for (const x of $('filter').querySelectorAll('.schritt')) { + x.setAttribute('aria-pressed', String(x === b)); + } + await laden(); + }); + } + + /* ---------- Start ------------------------------------------------------- */ + + (async () => { + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) { location.assign('/workspace/'); return; } + ich = await a.json(); + } catch { return; } + $('wer').textContent = ich.name + ' · ' + ich.rolle; + + if (ich.rolle === 'admin') { + $('zuordnung').hidden = false; + try { + const { personen } = await (await hole('/workspace/api/personen')).json(); + for (const p of personen.filter((p) => p.rolle === 'creator')) { + const o = document.createElement('option'); + o.value = String(p.id); + o.textContent = p.name; + $('f-creator').append(o); + } + } catch { /* Zuordnung bleibt leer */ } + } + await laden(); + })(); +})(); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 6a5191a4..cf540cc7 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -22,7 +22,7 @@ { name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' }, { name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'aufgaben.html' }, { name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'kalender.html' }, - { name: 'Dateien', phase: 1, rollen: ['admin', 'creator'] }, + { name: 'Dateien', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'dateien.html' }, { name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'creator'] }, { name: 'Content-Planung', phase: 2, rollen: ['admin', 'creator'] }, { name: 'Technik', phase: 2, rollen: ['admin', 'creator'] }, diff --git a/workspace/dateien.html b/workspace/dateien.html new file mode 100644 index 00000000..95d7db95 --- /dev/null +++ b/workspace/dateien.html @@ -0,0 +1,77 @@ + + + + + +Dateien · Creator Workspace + + + + + + + + + + + +
+ +

Creator Workspace · Dateien

+
+ … + +
+
+ +
+ +
+
+

Ablage

+

Dateien & Freigaben

+

Entwurf → Review → Freigabe. Die aktuelle Version ist immer klar erkennbar.

+
+
+ + +
+ +

Datei hierher ziehen oder auswählen

+

bis 25 MB

+ +
+ + + + + + +
+ + + + +
+ +

Dateien werden geladen …

+ +
+ + + + +