Anhaenge an Aufgaben -- ohne eine zweite Dateiablage

Stufe 9 aus dem Plan. Eine Aufgabe "Vertrag pruefen" ohne den Vertrag
daneben ist eine Aufforderung zum Suchen.

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 gewesen,
und die zweite ist immer die, die etwas durchlaesst.

Deshalb nur eine Spalte: Die Datei WEISS, zu welcher Aufgabe sie
gehoert. Hochgeladen wird ueber denselben Weg wie immer, mit einem Kopf
mehr. Alles andere bleibt, wo es schon richtig ist -- die Anhaenge
tragen deshalb auch ihre Fassungsnummer und die Warnung "nicht mehr
aktuell" mit, ohne dass dafuer eine Zeile geschrieben werden musste.

DER RIEGEL

Waere `x-aufgabe` ungeprueft, waere der Kopf ein Weg, die Existenz
fremder Aufgaben zu erfahren -- man muesste nur Zahlen durchprobieren.
Geprueft wird deshalb gegen dieselbe Schranke wie fuer die Datei
selbst: `darfCreator`, die eine Stelle im Haus, an der diese Frage
beantwortet wird. Eine eigene Herleitung hier waere eine zweite Meinung
darueber gewesen. (Erster Anlauf: genau so eine Herleitung, mit einer
Funktion, die gar nicht importiert war.)

Gemessen: Ein Scout darf an die Aufgabe SEINER Creatorin, an die eines
fremden Creators nicht -- mit wortgleicher Absage wie bei einer
erfundenen Nummer. Der Unterschied waere sonst die Auskunft. Und es
bleibt auch keine lose Datei liegen: Die Pruefung findet vor dem ersten
Byte statt.

ON DELETE SET NULL, NICHT CASCADE

Der wichtigste Teil der Spalte: 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. Gemessen.

EIN EIGENER FEHLER, GEFUNDEN BEIM MESSEN

Der Verweis am Anhang zeigte auf /workspace/api/dateien/:id -- den es
gar nicht gibt, der Weg heisst /inhalt. Die Pruefung ruft ihn jetzt
wirklich auf und erwartet 200, statt nur die Adresse zu vergleichen.

PRUEFUNGEN: pruef-anhaenge neu mit 37, davon 8 im Browser.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-15 13:12:41 +02:00
co-authored by Claude Opus 5
parent 478a2895b5
commit 1386534051
34 changed files with 952 additions and 352 deletions
+78 -5
View File
@@ -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)." });