diff --git a/server/pruef-anhaenge.mjs b/server/pruef-anhaenge.mjs new file mode 100644 index 00000000..4f60cd50 --- /dev/null +++ b/server/pruef-anhaenge.mjs @@ -0,0 +1,348 @@ +/* ===================================================================== + Anhänge an Aufgaben (15.09.2026) + + Stufe 9: „Anhänge an Aufgaben". Eine Aufgabe „Vertrag prüfen" ohne + den Vertrag daneben ist eine Aufforderung zum Suchen. + + --------------------------------------------------------------------- + KEIN ZWEITER HOCHLADEWEG — UND GENAU DAS WIRD GEPRÜFT + + Dateien leben in der Dateiablage, mit ihrer Sichtbarkeit, ihren + Freigaben, ihren Fassungen und ihrem Protokoll. Die Aufgabe bekommt + keine eigene Ablage, sondern die Datei bekommt eine Spalte. + + Die drei Fragen, auf die es ankommt: + + 1. Landet die Datei wirklich an der Aufgabe — und in der Ablage? + 2. Lässt sich eine Datei an eine FREMDE Aufgabe hängen? (nein) + 3. Was passiert mit der Datei, wenn die Aufgabe gelöscht wird? + + Die zweite ist der Riegel: Wäre der Kopf `x-aufgabe` ungeprüft, wäre + er ein Weg, die Existenz fremder Aufgaben zu erfahren — man müsste + nur Zahlen durchprobieren. + + Die dritte ist die, die wehtun würde: `CASCADE` hätte beim Löschen + einer Aufgabe den Vertrag mitgenommen. + + Aufrufen mit: node server/pruef-anhaenge.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; +import { portMussFreiSein } from "./helfer-port.mjs"; + +const PORT = await portMussFreiSein(4416, "pruef-anhaenge"); + +const ordner = mkdtempSync(join(tmpdir(), "ws-anh-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +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-anhaenge"); +await new Promise((r) => setTimeout(r, 700)); + +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 HAUS = "workspace.dogfather-universe.com"; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); + +const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +let idDogi, idLuna, idMax, idBen, aufgabeLuna, aufgabeMax, aufgabeHaus; +{ + const d = oeffne(); + 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); + const 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); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; + }; + idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); + idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); + idMax = anlegen("Max", "creator", "CODE-CREA-0002"); + idBen = anlegen("Ben", "scout", "CODE-SCOU-0001"); + /* Ben betreut Luna, aber nicht Max -- das ist die Trennlinie, an der + der Riegel gemessen wird. */ + d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)") + .run(idLuna, idBen, jetzt); + + const auf = d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, creator_id, erstellt, erstellt_von) + VALUES (?,'offen','mittel',?,?,?)`); + auf.run("Vertrag pruefen (Luna)", idLuna, jetzt, idDogi); + aufgabeLuna = d.prepare("SELECT last_insert_rowid() AS id").get().id; + auf.run("Vertrag pruefen (Max)", idMax, jetzt, idDogi); + aufgabeMax = d.prepare("SELECT last_insert_rowid() AS id").get().id; + auf.run("Etwas fuers Haus", null, jetzt, idDogi); + aufgabeHaus = d.prepare("SELECT last_insert_rowid() AS id").get().id; + d.close(); +} + +function ruf(pfad, methode, kopf = {}, koerper = null) { + return new Promise((fertig, schief) => { + const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode, + headers: { Host: HAUS, ...kopf } }, (antwort) => { + let text = ""; + antwort.on("data", (s) => { text += s; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers })); + }); + a.on("error", schief); + if (koerper) a.write(koerper); + a.end(); + }); +} +const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; +async function anmelden(rolle, code) { + const r = await ruf("/workspace/api/anmelden", "POST", + { "Content-Type": "application/json" }, JSON.stringify({ rolle, code })); + return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); +} +const kDogi = await anmelden("admin", "CODE-DOGI-0001"); +const kBen = await anmelden("scout", "CODE-SCOU-0001"); +ok(!!kDogi && !!kBen, "DogFather und ein Scout sind angemeldet"); + +/** Laedt hoch -- an eine Aufgabe oder frei. */ +const hochladen = (name, inhalt, keks, aufgabeId) => + ruf("/workspace/api/dateien", "POST", { + cookie: keks, + "content-type": "text/plain", + "x-dateiname": encodeURIComponent(name), + ...(aufgabeId ? { "x-aufgabe": String(aufgabeId) } : {}), + "content-length": Buffer.byteLength(inhalt), + }, inhalt); + +const anhaenge = async (aufgabeId, keks) => + json(await ruf(`/workspace/api/aufgaben/${aufgabeId}/dateien`, "GET", { cookie: keks })).dateien || []; + +/* ======================================================================= + 1. DIE DATEI LANDET AN DER AUFGABE — UND IN DER ABLAGE + ======================================================================= */ +melde(""); +melde("=== 1. Angehängt ==="); +{ + const a = await hochladen("vertrag.pdf", "Inhalt eins", kDogi, aufgabeLuna); + ok(a.code === 201, `die Datei geht durch (${a.code}) ${a.code === 201 ? "" : a.text.slice(0, 120)}`); + ok(json(a).aufgabe_id === aufgabeLuna, + `und haengt an der Aufgabe (${json(a).aufgabe_id})`); + + const liste = await anhaenge(aufgabeLuna, kDogi); + ok(liste.length === 1, `die Aufgabe kennt ${liste.length} Anhang`); + ok(liste[0].name_original === "vertrag.pdf", `mit Namen (${liste[0].name_original})`); + + /* SIE IST KEINE ZWEITE ABLAGE: Dieselbe Datei steht auch in der + Dateiliste, mit allem, was dort dazugehoert. */ + const alle = json(await ruf("/workspace/api/dateien", "GET", { cookie: kDogi })).dateien || []; + const drin = alle.find((x) => x.id === json(a).id); + ok(!!drin, "und sie steht ganz normal in der Dateiablage"); + ok(drin.status === "entwurf", `mit dem gewohnten Stand (${drin.status})`); + + /* DER CREATOR-BEZUG FOLGT DER AUFGABE -- sonst laege die Datei fuer + die Sichtbarkeit woanders als die Aufgabe, an der sie haengt. */ + ok(drin.creator_id === idLuna, + `und dem Creator-Bezug der Aufgabe (${drin.creator_id === idLuna ? "Luna" : drin.creator_id})`); + + /* GEGENPROBE: Eine Datei OHNE Aufgabe haengt an keiner. */ + const frei = await hochladen("lose.txt", "ohne Aufgabe", kDogi, null); + ok(json(frei).aufgabe_id === null, "eine Datei ohne Aufgabe bleibt ohne"); + const nachher = await anhaenge(aufgabeLuna, kDogi); + ok(nachher.length === 1, `und taucht nicht an der Aufgabe auf (${nachher.length})`); +} + +/* ======================================================================= + 2. DER RIEGEL: KEINE FREMDE AUFGABE + + Waere `x-aufgabe` ungeprueft, waere der Kopf ein Weg, die Existenz + fremder Aufgaben zu erfahren -- man muesste nur Zahlen durchprobieren. + ======================================================================= */ +melde(""); +melde("=== 2. Keine fremde Aufgabe ==="); +{ + /* Ben betreut Luna -- an ihre Aufgabe darf er. */ + const eigen = await hochladen("bens-datei.txt", "darf", kBen, aufgabeLuna); + ok(eigen.code === 201, `der Scout darf an die Aufgabe seiner Creatorin (${eigen.code})`); + + /* An die von Max nicht. */ + const fremd = await hochladen("heimlich.txt", "darf nicht", kBen, aufgabeMax); + ok(fremd.code === 404, `an die eines fremden Creators nicht (${fremd.code})`); + ok(json(fremd).fehler === "aufgabe_nicht_gefunden", + `und die Absage verraet nichts ueber sie (${json(fremd).fehler})`); + + /* UND ES IST AUCH WIRKLICH NICHTS ENTSTANDEN -- eine Absage, nach der + die Datei trotzdem in der Ablage liegt, waere die schlechteste. */ + const d = oeffne(); + const gibt = d.prepare("SELECT COUNT(*) AS n FROM dateien WHERE name_original = 'heimlich.txt'").get().n; + d.close(); + ok(gibt === 0, `es liegt auch keine lose Datei herum (${gibt})`); + + /* Eine erfundene Nummer ergibt dieselbe Antwort wie eine fremde -- + sonst waere der Unterschied die Auskunft. */ + const erfunden = await hochladen("nummer.txt", "x", kBen, 999999); + ok(erfunden.code === 404, `eine erfundene Nummer ebenso (${erfunden.code})`); + ok(json(erfunden).fehler === json(fremd).fehler, + "mit wortgleicher Absage — der Unterschied waere die Auskunft"); + + /* Eine Aufgabe ohne Creator-Bezug gehoert dem Haus. */ + const haus = await hochladen("fuers-haus.txt", "x", kBen, aufgabeHaus); + ok(haus.code === 201, `eine Aufgabe ohne Creator-Bezug steht allen offen (${haus.code})`); +} + +/* ======================================================================= + 3. WER DIE AUFGABE NICHT SEHEN DARF, SIEHT AUCH DIE ANHÄNGE NICHT + ======================================================================= */ +melde(""); +melde("=== 3. Die Liste der Anhänge ==="); +{ + await hochladen("max-vertrag.pdf", "geheim", kDogi, aufgabeMax); + + const beiDogi = await anhaenge(aufgabeMax, kDogi); + ok(beiDogi.length >= 1, `DogFather sieht ${beiDogi.length} Anhang an Max' Aufgabe`); + + const beiBen = await anhaenge(aufgabeMax, kBen); + ok(beiBen.length === 0, `der Scout sieht dort nichts (${beiBen.length})`); + + /* GEGENPROBE -- ohne sie beweist die Zeile darueber nichts: An der + Aufgabe SEINER Creatorin sieht er sehr wohl etwas. */ + const seine = await anhaenge(aufgabeLuna, kBen); + ok(seine.length >= 1, `an der Aufgabe seiner Creatorin schon (${seine.length})`); + + const ohne = await ruf(`/workspace/api/aufgaben/${aufgabeLuna}/dateien`, "GET"); + ok(ohne.code === 401, `ohne Anmeldung gar nichts (${ohne.code})`); +} + +/* ======================================================================= + 4. WIRD DIE AUFGABE GELÖSCHT, BLEIBT DIE DATEI + + Die Frage, die wehtun wuerde: CASCADE haette den Vertrag mitgenommen. + ======================================================================= */ +melde(""); +melde("=== 4. Wenn die Aufgabe verschwindet ==="); +{ + const a = await hochladen("bleibt.pdf", "wichtig", kDogi, aufgabeHaus); + const dateiId = json(a).id; + + const d = oeffne(); + d.prepare("PRAGMA foreign_keys = ON").run(); + d.prepare("DELETE FROM aufgaben WHERE id = ?").run(aufgabeHaus); + const uebrig = d.prepare("SELECT id, aufgabe_id, name_original FROM dateien WHERE id = ?").get(dateiId); + d.close(); + + ok(!!uebrig, "die Datei gibt es noch, obwohl die Aufgabe weg ist"); + ok(uebrig.aufgabe_id === null, + `und der Bezug steht auf null statt ins Leere (${uebrig.aufgabe_id})`); + ok(uebrig.name_original === "bleibt.pdf", "mit unverändertem Namen"); + + const alle = json(await ruf("/workspace/api/dateien", "GET", { cookie: kDogi })).dateien || []; + ok(alle.some((x) => x.id === dateiId), "sie steht wieder ganz normal in der Ablage"); +} + +/* ======================================================================= + 5. FASSUNGEN GELTEN AUCH AM ANHANG + + Der eigentliche Nutzen: Wer an einer Aufgabe die alte Fassung + herunterlaedt, merkt es sonst nicht. + ======================================================================= */ +melde(""); +melde("=== 5. Fassungen am Anhang ==="); +{ + const eins = await hochladen("konzept.pdf", "eins", kDogi, aufgabeLuna); + const zwei = await hochladen("konzept.pdf", "zwei", kDogi, aufgabeLuna); + ok(json(zwei).ersetzt_id === json(eins).id, "die zweite Fassung wird auch am Anhang erkannt"); + + const liste = await anhaenge(aufgabeLuna, kDogi); + const alt = liste.find((x) => x.id === json(eins).id); + const neu = liste.find((x) => x.id === json(zwei).id); + ok(alt?.ersetzt_durch === neu?.id, "die alte weiss, dass sie ersetzt wurde"); + ok(neu?.fassung === 2, `die neue ist Fassung ${neu?.fassung}`); + ok(neu?.ersetzt_durch === null, "und wird von niemandem ersetzt"); +} + +/* ======================================================================= + 6. IM BROWSER + ======================================================================= */ +melde(""); +melde("=== 6. Im Browser ==="); +{ + const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await chromium.launch(); + const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const seite = await kontext.newPage(); + const konsole = []; + seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + + await seite.goto(`http://127.0.0.1:${PORT}/workspace/`, { waitUntil: "domcontentloaded" }); + await seite.click('.rolle[data-rolle="admin"]'); + await seite.fill("#code", "CODE-DOGI-0001"); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 20000 }); + await seite.goto(`http://127.0.0.1:${PORT}/workspace/aufgaben.html`, { waitUntil: "networkidle" }); + await seite.waitForTimeout(900); + + /* Den Dialog der Aufgabe mit den Anhaengen oeffnen. */ + const geoeffnet = await seite.evaluate((titel) => { + const karten = [...document.querySelectorAll(".aufgabe, .karte, article")]; + const k = karten.find((x) => x.textContent.includes(titel)); + const stift = k?.querySelector("button"); + if (!stift) return false; + stift.click(); + return true; + }, "Vertrag pruefen (Luna)"); + ok(geoeffnet === true, "der Bearbeiten-Dialog lässt sich öffnen"); + await seite.waitForTimeout(900); + + const stand = await seite.evaluate(() => ({ + abschnitt: !!document.getElementById("b-anhaenge"), + dateien: document.querySelectorAll(".anhaenge__datei").length, + knopf: !!document.getElementById("b-datei"), + alt: document.querySelectorAll(".anhaenge__alt").length, + ziele: [...document.querySelectorAll(".anhaenge__datei")].map((a) => a.getAttribute("href")), + })); + ok(stand.abschnitt === true, "der Abschnitt „Anhänge“ steht im Dialog"); + ok(stand.dateien >= 3, `${stand.dateien} Anhänge werden gezeigt`); + ok(stand.knopf === true, "und es gibt einen Weg, eine Datei anzuhängen"); + ok(stand.alt >= 1, `${stand.alt} davon sind als „nicht mehr aktuell“ gekennzeichnet`); + /* DER VERWEIS MUSS AUF DEN ECHTEN WEG ZEIGEN. Beim ersten Anlauf + stand dort /dateien/:id -- den es gar nicht gibt. */ + ok(stand.ziele.every((h) => /\/workspace\/api\/dateien\/\d+\/inhalt$/.test(h || "")), + `jeder Verweis zeigt auf den Herunterlade-Weg (${stand.ziele[0]})`); + + /* Und er trifft auch wirklich etwas -- gemessen, nicht angenommen. */ + const antwort = await seite.evaluate(async (h) => { + const a = await fetch(h, { credentials: "same-origin" }); + return a.status; + }, stand.ziele[0]); + ok(antwort === 200, `und liefert die Datei aus (${antwort})`); + + ok(konsole.length === 0, + konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); + 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-dateien.js b/server/workspace-dateien.js index 3d07443a..16225552 100644 --- a/server/workspace-dateien.js +++ b/server/workspace-dateien.js @@ -208,7 +208,7 @@ export function freigabenSetzen(dateiId, ids, person) { const SPALTEN = ` d.id, d.name_original, d.groesse, d.typ, d.status, d.notiz, d.creator_id, d.hochgeladen_von, d.erstellt, d.geaendert, - d.creator_extern, d.ersetzt_id, + d.creator_extern, d.ersetzt_id, d.aufgabe_id, ${externSql("pc.name", "d.creator_extern")} AS creator_name, ph.name AS hochgeladen_name`; @@ -293,6 +293,50 @@ dateienRouter.delete("/workspace/api/dateien/:id(\\d+)/fassung", (req, res) => { } }); +/** Darf dieser Mensch diese Aufgabe sehen? + * + * BEWUSST EINFACH GEHALTEN und nicht aus workspace-aufgaben.js + * importiert: Dort haengt die Sichtbarkeit an einer laengeren Abfrage + * mit Betreuung und Sicht. Hier wird nur gefragt, ob die Datei an eine + * Aufgabe darf, und dafuer gilt dieselbe Schranke wie fuer die Datei + * selbst -- der Creator-Bezug. Wer den Creator sehen darf, darf die + * Aufgabe daran haengen. + * + * Waere hier mehr erlaubt als bei der Datei, entstuende genau das + * Leck, das die Spalte verhindern soll. */ +function aufgabeSichtbar(aufgabe, person) { + if (!aufgabe) return false; + /* Eine Aufgabe ohne Creator-Bezug gehoert dem Haus -- sie ist fuer + alle sichtbar, die ueberhaupt Dateien sehen duerfen. */ + if (!aufgabe.creator_id) return true; + /* `darfCreator` ist genau diese Frage, und sie steht an einer Stelle + im Haus. Eine eigene Herleitung hier waere eine zweite Meinung + darueber -- und die zweite ist immer die, die mehr durchlaesst. + (Erster Anlauf: eine handgebaute Abfrage plus sichtbareCreatorIds, + das gar nicht importiert war.) */ + return darfCreator(person, aufgabe.creator_id); +} + +/* ---------- Anhaenge einer Aufgabe -------------------------------------- + Eigener Weg statt eines Filters auf der grossen Liste: Die Aufgabe + fragt nach IHREN Dateien, und die Antwort ist kurz genug, um sie beim + Oeffnen des Dialogs zu holen. */ +dateienRouter.get("/workspace/api/aufgaben/:id(\\d+)/dateien", (req, res) => { + try { + const id = Number(req.params.id); + const { wo, werte } = sichtbar(req.sicht || req.person); + const liste = db().prepare(` + SELECT ${SPALTEN} ${VERBUND} + WHERE ${wo} AND d.aufgabe_id = ? + ORDER BY d.erstellt DESC`).all(...werte, id); + fassungenSetzen(liste); + res.json({ dateien: liste }); + } catch (fehler) { + console.error("[dateien] Anhaenge:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /* ---------- Liste ------------------------------------------------------- */ dateienRouter.get("/workspace/api/dateien", (req, res) => { @@ -411,6 +455,33 @@ dateienRouter.post("/workspace/api/dateien", const dateiname = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${endung}`; writeFileSync(join(ORDNER, dateiname), req.body, { flag: "wx" }); + /* ---- GEHOERT DIE DATEI ZU EINER AUFGABE? (15.09.2026) --------- + + Die Nummer kommt im Kopf, wie der Dateiname auch. Geprueft wird + sie gegen die Aufgaben, die dieser Mensch ueberhaupt sehen + darf -- sonst waere der Kopf ein Weg, eine Datei an eine fremde + Aufgabe zu haengen und damit ihre Existenz zu verraten. + + EINE UNBEKANNTE NUMMER IST EIN FEHLER, kein stilles Ignorieren: + Wer eine Datei an eine Aufgabe haengen wollte und sie liegt + danach lose in der Ablage, sucht sie spaeter an der Aufgabe. */ + let aufgabeId = null; + const rohAufgabe = req.get("x-aufgabe"); + if (rohAufgabe) { + const n = Number(rohAufgabe); + const gefunden = Number.isInteger(n) && n > 0 + ? db().prepare(`SELECT a.id, a.creator_id FROM aufgaben a WHERE a.id = ?`).get(n) + : null; + if (!gefunden || !aufgabeSichtbar(gefunden, req.sicht || req.person)) { + return res.status(404).json({ fehler: "aufgabe_nicht_gefunden" }); + } + aufgabeId = gefunden.id; + /* Der Creator-Bezug folgt der Aufgabe, wenn keiner mitkam -- + sonst laege die Datei fuer die Sichtbarkeit woanders als die + Aufgabe, an der sie haengt. */ + if (creatorId === null && gefunden.creator_id) creatorId = gefunden.creator_id; + } + /* ---- IST DAS EINE NEUE FASSUNG? (15.09.2026) ------------------ AUTOMATISCH VERKNUEPFT, NICHT GEFRAGT -- und das ist eine @@ -436,11 +507,11 @@ dateienRouter.post("/workspace/api/dateien", const { lastInsertRowid } = db().prepare(` INSERT INTO dateien (name_original, name_datei, groesse, typ, status, notiz, - creator_id, hochgeladen_von, erstellt, ersetzt_id) - VALUES (?,?,?,?,'entwurf',?,?,?,?,?)`).run( + creator_id, hochgeladen_von, erstellt, ersetzt_id, aufgabe_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); + notiz, creatorId, req.person.id, jetzt(), ersetztId, aufgabeId); /* Ausdrueckliche Freigaben. Der Kopf traegt eine Liste von Ids -- geprueft wird jede einzelne gegen die erlaubte Auswahl. */ @@ -456,7 +527,9 @@ dateienRouter.post("/workspace/api/dateien", detail: `#${lastInsertRowid} ${name} (${Math.round(req.body.length / 1024)} KB)` + (anzahl ? `, freigegeben fuer ${anzahl}` : "").slice(0, 40), }); - res.status(201).json({ id: Number(lastInsertRowid), ersetzt_id: ersetztId }); + res.status(201).json({ + id: Number(lastInsertRowid), ersetzt_id: ersetztId, aufgabe_id: aufgabeId, + }); } catch (fehler) { if (fehler?.type === "entity.too.large") { return res.status(413).json({ fehler: "Datei ist zu groß (max. 25 MB)." }); diff --git a/server/workspace.js b/server/workspace.js index cccb8d07..06c2d900 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -2080,6 +2080,23 @@ function umstellungen(d) { ON DELETE SET NULL: Wird die alte Fassung geloescht, bleibt die neue -- sie verliert nur ihre Vorgeschichte. */ ["dateien", "ersetzt_id", "INTEGER REFERENCES dateien(id) ON DELETE SET NULL"], + + /* ANHAENGE AN AUFGABEN (15.09.2026, Stufe 9). + + KEIN ZWEITER HOCHLADEWEG. Dateien leben in der Dateiablage -- mit + ihrer Sichtbarkeit, ihren Freigaben, ihren Fassungen und ihrem + Protokoll. Ein eigener Weg an der Aufgabe waere eine zweite + Ablage mit einer zweiten Rechtelogik, und die zweite ist immer + die, die etwas durchlaesst. + + Deshalb nur eine Spalte: Die Datei WEISS, zu welcher Aufgabe sie + gehoert. Alles andere bleibt, wo es ist. + + ON DELETE SET NULL, NICHT CASCADE -- und das ist der wichtigste + Teil dieser Zeile: Wer eine Aufgabe loescht, will die Aufgabe + loeschen, nicht den Vertrag, der daran hing. Die Datei verliert + nur ihren Bezug und steht danach wieder in der Ablage. */ + ["dateien", "aufgabe_id", "INTEGER REFERENCES aufgaben(id) ON DELETE SET NULL"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 90c317de..62514ca8 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=202609151258", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609151311", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609151258", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609151311", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151258", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609151311", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151258", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609151311", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css index 97ef7ea1..01f870db 100644 --- a/workspace/assets/css/aufgaben.css +++ b/workspace/assets/css/aufgaben.css @@ -995,3 +995,46 @@ @media (forced-colors: active) { .karte__wichtig { border: 1px solid CanvasText; } } + +/* ===================================================================== + ANHAENGE IM AUFGABEN-DIALOG (15.09.2026) + + Eine Datei je Zeile, anklickbar, mit Groesse dahinter. "nicht mehr + aktuell" darf auffallen -- wer hier die alte Fassung herunterlaedt, + merkt es sonst nicht. + ===================================================================== */ + +.anhaenge__liste { display: grid; gap: 5px; margin: 4px 0 8px; } +.anhaenge__datei { + display: flex; align-items: center; flex-wrap: wrap; gap: 9px; + padding: 8px 11px; + text-decoration: none; + color: var(--text-leise); + background: rgba(0, 0, 0, .22); + border: 1px solid var(--rand); + border-radius: var(--radius); +} +.anhaenge__datei:hover { color: var(--text); border-color: var(--rand-hell); } +.anhaenge__name { font-size: .86rem; font-weight: 600; } +.anhaenge__groesse { + font-size: .76rem; color: var(--text-still); margin-left: auto; + font-variant-numeric: tabular-nums; +} +.anhaenge__fassung { font-size: .76rem; color: var(--text-still); } +.anhaenge__alt { + font-size: .76rem; font-weight: 640; + padding: 1px 8px; border-radius: 999px; + color: #d8a76a; + border: 1px solid color-mix(in srgb, #cc9451 45%, transparent); +} +.anhaenge__leer { font-size: .82rem; color: var(--text-still); } +.anhaenge__zufuegen { display: inline-block; cursor: pointer; } +.anhaenge__knopf { + display: inline-block; + font-size: .8rem; font-weight: 600; + padding: 7px 14px; border-radius: 999px; + color: var(--text-leise); + border: 1px solid var(--rand); +} +.anhaenge__zufuegen:hover .anhaenge__knopf { color: var(--text); border-color: var(--rand-hell); } +.anhaenge__hinweis { margin: 6px 0 0; font-size: .78rem; color: var(--text-still); } diff --git a/workspace/assets/js/aufgaben.js b/workspace/assets/js/aufgaben.js index 8934c4ae..b68b1443 100644 --- a/workspace/assets/js/aufgaben.js +++ b/workspace/assets/js/aufgaben.js @@ -843,6 +843,7 @@ } kategorieFeldZeigen('b'); if ($('b-kategorie')) $('b-kategorie').value = a.kategorie || ''; + window.anhaengeLaden?.(a.id); dialog.showModal(); $('b-titel').focus(); } @@ -1384,3 +1385,106 @@ Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerwe } } })(); + +/* ===================================================================== + ANHAENGE AN EINER AUFGABE (15.09.2026, Stufe 9) + + KEIN ZWEITER HOCHLADEWEG. Die Datei geht an denselben Weg wie in der + Dateiablage (/workspace/api/dateien) und bekommt nur einen Kopf mehr: + die Nummer der Aufgabe. Sichtbarkeit, Freigaben, Fassungen und + Protokoll bleiben damit genau dort, wo sie schon richtig sind. + + WAS BEIM LOESCHEN DER AUFGABE PASSIERT, steht in der Datenbank + (ON DELETE SET NULL) und nicht hier: Die Datei bleibt, sie verliert + nur ihren Bezug. Wer eine Aufgabe loescht, will die Aufgabe loeschen. + ===================================================================== */ +(() => { + const $ = (id) => document.getElementById(id); + const el = (t, k, text) => { + const n = document.createElement(t); + if (k) n.className = k; + if (text !== undefined) n.textContent = text; + return n; + }; + const feld = $('b-datei'); + const liste = $('b-anhaenge'); + const stand = $('b-anhang-stand'); + if (!feld || !liste) return; + + let fuerAufgabe = null; + + const groesse = (n) => (n >= 1048576 + ? (n / 1048576).toFixed(1) + ' MB' + : Math.max(1, Math.round(n / 1024)) + ' KB'); + + const sagen = (t) => { if (stand) stand.textContent = t || ''; }; + + window.anhaengeLaden = async function (aufgabeId) { + fuerAufgabe = aufgabeId; + liste.textContent = ''; + sagen(''); + try { + const a = await fetch('/workspace/api/aufgaben/' + aufgabeId + '/dateien', + { credentials: 'same-origin' }); + if (!a.ok) return; + const d = await a.json(); + if (!d.dateien?.length) { + liste.append(el('span', 'anhaenge__leer', 'Noch nichts angehängt.')); + return; + } + for (const x of d.dateien) { + const v = el('a', 'anhaenge__datei'); + /* Der Herunterlade-Weg heisst /inhalt -- /dateien/:id allein + gibt es nicht. Beim ersten Anlauf stand hier der Verweis ins + Leere; gefunden hat es der Blick in die Routen, nicht der + Klick. */ + v.href = '/workspace/api/dateien/' + x.id + '/inhalt'; + v.append(el('span', 'anhaenge__name', x.name_original)); + v.append(el('span', 'anhaenge__groesse', groesse(x.groesse))); + /* Eine ueberholte Fassung ALS SOLCHE kennzeichnen -- sonst laedt + jemand hier die alte herunter und merkt es nicht. Genau der + Fall, fuer den es die Fassungen gibt. */ + if (x.ersetzt_durch) { + const w = el('span', 'anhaenge__alt', 'nicht mehr aktuell'); + w.title = 'Es gibt eine neuere Fassung dieser Datei.'; + v.append(w); + } else if (x.fassung > 1) { + v.append(el('span', 'anhaenge__fassung', 'Fassung ' + x.fassung)); + } + liste.append(v); + } + } catch { /* der Dialog ist auch ohne Anhaenge benutzbar */ } + }; + + feld.addEventListener('change', async () => { + const f = feld.files?.[0]; + if (!f || !fuerAufgabe) return; + sagen('Wird hochgeladen …'); + try { + const a = await fetch('/workspace/api/dateien', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': f.type || 'application/octet-stream', + 'x-dateiname': encodeURIComponent(f.name), + 'x-aufgabe': String(fuerAufgabe), + }, + body: f, + }); + if (!a.ok) { + const j = await a.json().catch(() => ({})); + sagen(j.fehler === 'zu_gross' + ? 'Die Datei ist zu groß.' + : (j.fehler || 'Das hat nicht geklappt.')); + return; + } + sagen('Angehängt.'); + await window.anhaengeLaden(fuerAufgabe); + } catch { + sagen('Keine Verbindung.'); + } finally { + /* Zuruecksetzen, sonst loest dieselbe Datei kein zweites Mal aus. */ + feld.value = ''; + } + }); +})(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 740117aa..e891bc86 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. --> - - - - - - + + + + + + - + @@ -213,6 +213,21 @@ + +
+ Anhänge +
+ +

+
+
@@ -252,10 +267,10 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 6f9619f3..09e3e66e 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/bereich.html b/workspace/bereich.html index dd632bcc..5c774313 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. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index f70b2db4..1a788d78 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 9a4e3b08..6e091b52 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 454826b9..37280f3e 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 37dd581d..292e3e3b 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 e30ac789..94708365 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=202609151258", + "src": "/assets/img/app-symbole/crew-192.png?v=202609151311", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609151258", + "src": "/assets/img/app-symbole/crew-512.png?v=202609151311", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609151258", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609151311", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609151258", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609151311", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index f0832333..b46af05a 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 914b092e..947d8229 100644 --- a/workspace/entwicklung.html +++ b/workspace/entwicklung.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -94,9 +94,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index 05537978..7e4e3b9e 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 f5a91bc3..06331d0a 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 3ab81c51..e8043dbc 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. --> - - - - - - - + + + + + + + - + @@ -423,10 +423,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index a9a0155d..223c711b 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 0fe7781d..fc79716f 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 8934bf16..4040461b 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 2b556cf3..5d358850 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 db54c227..24f303ad 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 a9848d90..4653c6e4 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 69f6b674..43b6dec0 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 5cdf8d44..b9604215 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 6cbbc921..829bed46 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 ae086854..7824cef9 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 977ae3a2..ab3d03be 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 ce707e7d..b9ade640 100644 --- a/workspace/treff-moderation.html +++ b/workspace/treff-moderation.html @@ -7,14 +7,14 @@ - - - - - - - - + + + + + + + + @@ -110,9 +110,9 @@ - - - - + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html index 1afb12cd..c2e1bcc1 100644 --- a/workspace/treff-regeln.html +++ b/workspace/treff-regeln.html @@ -7,18 +7,18 @@ - - - - - - - + + + + + + + - + @@ -210,10 +210,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 749f12de..9e5e67d7 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 570dc2b4..630ef205 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 @@ - - - - - + + + + +