diff --git a/server/workspace-dateien.js b/server/workspace-dateien.js index 11831443..3c9d253f 100644 --- a/server/workspace-dateien.js +++ b/server/workspace-dateien.js @@ -29,7 +29,7 @@ import { randomBytes } from "node:crypto"; import { join, extname, basename } from "node:path"; import { mkdirSync, createReadStream, writeFileSync, unlinkSync, statSync } from "node:fs"; import { - db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, + db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, betreuteIds, } from "./workspace.js"; export const dateienRouter = express.Router(); @@ -69,13 +69,71 @@ dateienRouter.use("/workspace/api/dateien", angemeldet); vergisst. */ export function sichtbar(person) { if (person.rolle === "admin") return { wo: "1=1", werte: [] }; + + /* Ausdrueckliche Freigabe -- gilt fuer jede Rolle gleich. Wer in der + Liste steht, sieht die Datei, egal ob Creator oder Scout. */ + const freigabe = "EXISTS (SELECT 1 FROM datei_personen dp WHERE dp.datei_id = d.id AND dp.person_id = ?)"; + if (person.rolle === "creator") { - return { wo: "(d.creator_id = ? OR d.hochgeladen_von = ?)", werte: [person.id, person.id] }; + return { + wo: `(d.creator_id = ? OR d.hochgeladen_von = ? OR ${freigabe})`, + werte: [person.id, person.id, person.id], + }; } const b = betreutWo(person, "d.creator_id"); return b - ? { wo: `(d.hochgeladen_von = ? OR ${b.wo})`, werte: [person.id, ...b.werte] } - : { wo: "d.hochgeladen_von = ?", werte: [person.id] }; + ? { wo: `(d.hochgeladen_von = ? OR ${freigabe} OR ${b.wo})`, + werte: [person.id, person.id, ...b.werte] } + : { wo: `(d.hochgeladen_von = ? OR ${freigabe})`, werte: [person.id, person.id] }; +} + +/* Wem eine Datei freigegeben ist -- fuer die Anzeige. */ +export function freigabenZu(ids) { + if (!ids.length) return new Map(); + const platz = ids.map(() => "?").join(","); + const karte = new Map(); + for (const z of db().prepare(` + SELECT dp.datei_id, p.id, p.name, p.rolle + FROM datei_personen dp JOIN personen p ON p.id = dp.person_id + WHERE dp.datei_id IN (${platz}) + ORDER BY p.rolle, p.name`).all(...ids)) { + if (!karte.has(z.datei_id)) karte.set(z.datei_id, []); + karte.get(z.datei_id).push({ id: z.id, name: z.name, rolle: z.rolle }); + } + return karte; +} + +/* Wen darf diese Person ueberhaupt auswaehlen? DogFather jeden aktiven + Menschen ausser sich selbst, ein Scout nur die Creator, die er + betreut -- sonst koennte er sich ueber eine Freigabe Zugang zu + fremden Bereichen verschaffen. */ +export function moeglicheEmpfaenger(person) { + if (person.rolle === "admin") { + return db().prepare( + "SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND id <> ? ORDER BY rolle, name") + .all(person.id); + } + if (person.rolle === "scout") { + const ids = betreuteIds(person); + if (!ids.length) return []; + return db().prepare( + `SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND id IN (${ids.map(() => "?").join(",")}) + ORDER BY name`).all(...ids); + } + return []; +} + +/* Freigaben setzen. Prueft jede Id gegen die erlaubte Auswahl -- ein + geschickter Aufruf soll keine Person erreichen, die im Formular gar + nicht zur Wahl stand. */ +export function freigabenSetzen(dateiId, ids, person) { + const erlaubt = new Set(moeglicheEmpfaenger(person).map((p) => p.id)); + const sauber = [...new Set(ids.map(Number).filter((n) => Number.isInteger(n) && erlaubt.has(n)))]; + const d = db(); + d.prepare("DELETE FROM datei_personen WHERE datei_id = ?").run(dateiId); + const einf = d.prepare("INSERT OR IGNORE INTO datei_personen (datei_id, person_id) VALUES (?,?)"); + for (const pid of sauber) einf.run(dateiId, pid); + return sauber.length; } const SPALTEN = ` @@ -93,11 +151,16 @@ const VERBUND = ` dateienRouter.get("/workspace/api/dateien", (req, res) => { try { const { wo, werte } = sichtbar(req.person); - res.json({ - dateien: db().prepare(` + const liste = db().prepare(` SELECT ${SPALTEN} ${VERBUND} WHERE ${wo} - ORDER BY d.erstellt DESC`).all(...werte), + ORDER BY d.erstellt DESC`).all(...werte); + const karte = freigabenZu(liste.map((d) => d.id)); + for (const d of liste) d.freigaben = karte.get(d.id) || []; + res.json({ + dateien: liste, + empfaenger: moeglicheEmpfaenger(req.person), + darf_freigeben: req.person.rolle !== "creator", max_bytes: MAX_BYTES, }); } catch (fehler) { @@ -195,9 +258,19 @@ dateienRouter.post("/workspace/api/dateien", (req.get("content-type") || "application/octet-stream").slice(0, 100), notiz, creatorId, req.person.id, jetzt()); + /* Ausdrueckliche Freigaben. Der Kopf traegt eine Liste von Ids -- + geprueft wird jede einzelne gegen die erlaubte Auswahl. */ + let anzahl = 0; + const roh = req.get("x-personen"); + if (roh) { + anzahl = freigabenSetzen(Number(lastInsertRowid), + String(roh).split(",").map((x) => x.trim()).filter(Boolean), req.person); + } + protokolliere("datei_hochgeladen", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), - detail: `#${lastInsertRowid} ${name} (${Math.round(req.body.length / 1024)} KB)`.slice(0, 120), + detail: `#${lastInsertRowid} ${name} (${Math.round(req.body.length / 1024)} KB)` + + (anzahl ? `, freigegeben fuer ${anzahl}` : "").slice(0, 40), }); res.status(201).json({ id: Number(lastInsertRowid) }); } catch (fehler) { @@ -255,6 +328,24 @@ dateienRouter.patch("/workspace/api/dateien/:id", gleicheHerkunft, (req, res) => const datei = db().prepare(`SELECT d.* ${VERBUND} WHERE ${wo} AND d.id = ?`).get(...werte, id); if (!datei) return res.status(404).json({ fehler: "nicht_gefunden" }); + /* Freigaben aendern. Steht vor den Feldern, weil es eine eigene + Tabelle ist und nicht in das UPDATE unten passt. Ein Creator darf + das nie -- er wuerde sich sonst selbst Publikum verschaffen. */ + if (req.body?.freigaben !== undefined) { + if (req.person.rolle === "creator") { + return res.status(403).json({ fehler: "Freigeben darf nur DogFather oder ein Scout." }); + } + if (!Array.isArray(req.body.freigaben)) { + return res.status(400).json({ fehler: "Ungültige Auswahl." }); + } + const n = freigabenSetzen(id, req.body.freigaben, req.person); + protokolliere("datei_freigaben", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} an ${n} Person(en)`.slice(0, 120), + }); + if (Object.keys(req.body).length === 1) return res.json({ ok: true }); + } + const setz = []; const daten = []; diff --git a/server/workspace.js b/server/workspace.js index acf7e1ab..742318c1 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -237,6 +237,20 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id); + /* Wer darf welche Datei sehen. Bisher hatte eine Datei genau + EINEN Bereich (dateien.creator_id) -- damit liess sich eine + Datei nicht zweien geben, ohne sie zweimal hochzuladen. + Diese Tabelle erlaubt beliebig viele Empfaenger, Creator wie + Scouts. creator_id bleibt bestehen: Es sagt weiterhin, zu + wessen BEREICH eine Datei gehoert, waehrend hier steht, WER sie + sehen darf. Zwei verschiedene Fragen. */ + CREATE TABLE IF NOT EXISTS datei_personen ( + datei_id INTEGER NOT NULL REFERENCES dateien(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + PRIMARY KEY (datei_id, person_id) + ); + CREATE INDEX IF NOT EXISTS idx_datei_personen ON datei_personen (person_id); + /* Wissens-Bibliothek: Anleitungen als PDF, hinter dem Login. Jede PDF hat GENAU EINE Hauptkategorie -- so gibt es sie nur einmal. Zusaetzliche Themen laufen ueber Tags, damit dieselbe diff --git a/workspace/assets/css/dateien.css b/workspace/assets/css/dateien.css index 21541f62..edcad35d 100644 --- a/workspace/assets/css/dateien.css +++ b/workspace/assets/css/dateien.css @@ -120,3 +120,64 @@ font-size: .88rem; color: var(--text-leise); line-height: 1.5; } .nur-lesen strong { color: var(--text); } + +/* =================================================================== + Wer darf die Datei sehen (28.08.2026). + + Kleine Schalter statt + verliert man mit einem Fehlklick die ganze Auswahl, und auf dem Handy + ist sie kaum bedienbar. Hier ist jeder Name ein eigener Schalter. */ + + let empfaenger = []; + let dartFreigeben = false; + + function baueWahl() { + const ziel = $('wahl'); + if (!ziel) return; + const vorher = new Set([...ziel.querySelectorAll('.wahl__knopf[data-an="ja"]')] + .map((b) => b.dataset.id)); + ziel.textContent = ''; + if (!empfaenger.length) { + ziel.append(el('p', 'leise', dartFreigeben + ? 'Es gibt noch niemanden, dem du eine Datei geben könntest.' + : '')); + return; + } + for (const p of empfaenger) { + const b = el('button', 'wahl__knopf'); + b.type = 'button'; + b.dataset.id = String(p.id); + b.dataset.rolle = p.rolle; + if (vorher.has(String(p.id))) b.dataset.an = 'ja'; + b.append(el('span', 'wahl__name', p.name)); + b.append(el('span', 'wahl__rolle', ROLLENKURZ[p.rolle] || p.rolle)); + b.addEventListener('click', () => { + if (b.dataset.an === 'ja') delete b.dataset.an; else b.dataset.an = 'ja'; + wahlHinweis(); + }); + ziel.append(b); + } + wahlHinweis(); + } + + function wahlHinweis() { + const n = document.querySelectorAll('.wahl__knopf[data-an="ja"]').length; + const h = $('wahl-hinweis'); + if (!h) return; + h.textContent = n + ? `${n} ${n === 1 ? 'Person' : 'Personen'} ausgewählt – nur sie sehen die Datei.` + : 'Niemand ausgewählt – dann sieht sie nur DogFather.'; + } + + const ROLLENKURZ = { admin: 'DogFather', creator: 'Creator', scout: 'Scout' }; + /* ---------- Hochladen -------------------------------------------------- */ async function schicke(datei) { @@ -55,6 +103,10 @@ 'X-Dateiname': encodeURIComponent(datei.name), }; if (ich.rolle === 'admin' && $('f-creator').value) kopf['X-Creator'] = $('f-creator').value; + /* Wer die Datei sehen darf -- als Liste von Ids im Kopf. */ + const gewaehlt = [...document.querySelectorAll('.wahl__knopf[data-an="ja"]')] + .map((b) => b.dataset.id); + if (gewaehlt.length) kopf['X-Personen'] = gewaehlt.join(','); const a = await hole('/workspace/api/dateien', { method: 'POST', headers: kopf, body: datei }); if (!a.ok) { @@ -62,7 +114,11 @@ melde(d.fehler || 'Hochladen hat nicht geklappt.'); return; } + /* Auswahl nach dem Hochladen loeschen -- sonst bekaeme die + naechste Datei stillschweigend dasselbe Publikum. */ + for (const b of document.querySelectorAll('.wahl__knopf[data-an="ja"]')) delete b.dataset.an; await laden(); + wahlHinweis(); } catch { /* umgeleitet */ } finally { $('fortschritt').hidden = true; $('feld-datei').value = ''; @@ -170,14 +226,77 @@ } if (d.notiz) zeile2.append(el('span', null, '· ' + d.notiz)); k.append(zeile2); + + /* Wer sie sehen darf -- sichtbar fuer alle, damit niemand raten + muss, wer eine Datei schon hat. */ + const frei = el('div', 'datei__freigaben'); + if (d.freigaben?.length) { + frei.append(el('span', 'datei__schild', 'Sichtbar für')); + for (const p of d.freigaben) frei.append(el('span', 'marke-person', p.name)); + } else { + frei.append(el('span', 'datei__schild', 'Sichtbar für')); + frei.append(el('span', 'marke-person marke-person--leer', 'niemanden zugeteilt')); + } + + if (dartFreigeben && empfaenger.length) { + const knopf = el('button', 'knopf-still', 'Ändern'); + knopf.type = 'button'; + knopf.addEventListener('click', () => freigabenBearbeiten(k, d)); + frei.append(knopf); + } + k.append(frei); return k; } + /* Auswahl direkt an der Datei -- ohne Seitenwechsel und ohne Fenster, + das den Rest verdeckt. */ + function freigabenBearbeiten(karte, d) { + if (karte.querySelector('.datei__wahl')) return; + const box = el('div', 'datei__wahl'); + const jetzt = new Set((d.freigaben || []).map((p) => p.id)); + for (const p of empfaenger) { + const b = el('button', 'wahl__knopf'); + b.type = 'button'; + b.dataset.id = String(p.id); + b.dataset.rolle = p.rolle; + if (jetzt.has(p.id)) b.dataset.an = 'ja'; + b.append(el('span', 'wahl__name', p.name)); + b.append(el('span', 'wahl__rolle', ROLLENKURZ[p.rolle] || p.rolle)); + b.addEventListener('click', () => { + if (b.dataset.an === 'ja') delete b.dataset.an; else b.dataset.an = 'ja'; + }); + box.append(b); + } + const knoepfe = el('div', 'datei__knoepfe'); + const speichern = el('button', 'knopf knopf--klein', 'Übernehmen'); + speichern.type = 'button'; + speichern.addEventListener('click', async () => { + speichern.disabled = true; + const ids = [...box.querySelectorAll('.wahl__knopf[data-an="ja"]')].map((b) => Number(b.dataset.id)); + const a = await hole(`/workspace/api/dateien/${d.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ freigaben: ids }), + }); + speichern.disabled = false; + if (!a.ok) { melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; } + await laden(); + }); + const abbrechen = el('button', 'knopf-still', 'Abbrechen'); + abbrechen.type = 'button'; + abbrechen.addEventListener('click', () => { box.remove(); knoepfe.remove(); }); + knoepfe.append(speichern, abbrechen); + karte.append(box, knoepfe); + } + 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(); + empfaenger = daten.empfaenger || []; + dartFreigeben = !!daten.darf_freigeben; + baueWahl(); maxBytes = daten.max_bytes || maxBytes; $('ablage-grenze').textContent = 'bis ' + groesse(maxBytes); @@ -236,6 +355,9 @@ $('f-creator').append(o); } } catch { /* Zuordnung bleibt leer */ } + } else if (ich.rolle === 'scout') { + $('zuordnung').hidden = false; + $('f-creator').closest('.feld').hidden = true; } await laden(); })(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 7ecdd351..0921d593 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -134,7 +134,7 @@ - - + + diff --git a/workspace/bereich.html b/workspace/bereich.html index b018e5d2..4d13b036 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -98,7 +98,7 @@ - - + + diff --git a/workspace/calls.html b/workspace/calls.html index e968ce09..4e55e111 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -49,7 +49,7 @@ - - + + diff --git a/workspace/dateien.html b/workspace/dateien.html index 3d970400..766abea7 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -61,8 +61,17 @@ @@ -79,7 +88,7 @@ - - + + diff --git a/workspace/index.html b/workspace/index.html index e4cb918c..ee953414 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -130,6 +130,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index f746cc82..51b915b8 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -94,7 +94,7 @@ - - + + diff --git a/workspace/personen.html b/workspace/personen.html index 390cf51b..9981a746 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -87,7 +87,7 @@ - - + + diff --git a/workspace/profil.html b/workspace/profil.html index cacc0913..54bea75f 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -127,7 +127,7 @@ - - + + diff --git a/workspace/report.html b/workspace/report.html index 33a4cc68..655afc2d 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -86,7 +86,7 @@ - - + + diff --git a/workspace/scouting.html b/workspace/scouting.html index ff46b7dc..a745ade7 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,7 +124,7 @@ - - + + diff --git a/workspace/start.html b/workspace/start.html index fd0aba72..07e0d387 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -56,7 +56,7 @@

- - + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 3ad09dd2..325aaa70 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -65,7 +65,7 @@ - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index ce38da89..0d78c7d1 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -145,7 +145,7 @@ - - + +