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 00000000..745a4766 Binary files /dev/null and b/server/workspace-dateien.js differ 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 …

+ +
+ + + + +