Workspace: Aufgabenbrett und Dashboard-Zahlen
Erster echter Arbeitsbereich aus dem Konzept. Kanban mit offen / in Arbeit / Review / erledigt, dazu Prioritaet, Frist, Verantwortlicher und Zuordnung zu einem Creator-Bereich. Kern ist die Datentrennung, und die sitzt AUSSCHLIESSLICH im Server -- in jeder einzelnen Abfrage, an einer Stelle definiert (sichtbar()): admin sieht alles creator sieht seinen Bereich und was ihm zugewiesen ist scout sieht nur, was ihm zugewiesen ist Geprueft mit vier Testkonten: - Chef sieht 4, Luna 2, Mika 1, Sam 1 Aufgaben - Luna auf Mikas Aufgabe: 404 (nicht 403 -- sonst liesse sich durch Ausprobieren herausfinden, welche Nummern es gibt) - Luna legt Aufgabe mit creator_id=Mika an: wird still auf ihren eigenen Bereich umgebogen, Mika sieht sie nicht - Anfrage mit fremdem Origin: 403 - ohne Anmeldung: 401, ungueltiger Status/leerer Titel: 400 - Scout bekommt in der Personenliste nur sich selbst - Dashboard-Zahlen je Rolle korrekt eingegrenzt Weitere Punkte: - Texte werden im Browser nur ueber textContent gesetzt, nie innerHTML -- ein Aufgabentitel darf keine Auszeichnung einschleusen - ueberfaellig = Frist vorbei UND nicht erledigt; die Kachel faerbt sich nur, wenn wirklich etwas ansteht - erledigt_am wird gesetzt bzw. wieder geleert, wenn eine Aufgabe zurueckgeholt wird - Erledigtes verschwindet nicht, wie im Konzept gefordert
This commit is contained in:
@@ -14,6 +14,7 @@ import { gateMiddleware } from "./gate.js";
|
||||
import { webdesignGate } from "./webdesign-gate.js";
|
||||
import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js";
|
||||
import { workspaceRouter } from "./workspace.js";
|
||||
import { aufgabenRouter } from "./workspace-aufgaben.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SITE_DIR = join(__dirname, "..");
|
||||
@@ -68,6 +69,7 @@ app.use(webdesignGate);
|
||||
Es öffnet beim Laden keine Datenbank, und jede Route fängt ihre Fehler
|
||||
selbst ab (Begründung im Kopf von workspace.js). */
|
||||
app.use(workspaceRouter);
|
||||
app.use(aufgabenRouter);
|
||||
|
||||
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
|
||||
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/* =====================================================================
|
||||
workspace-aufgaben.js — Aufgaben und Dashboard-Zahlen für /workspace.
|
||||
|
||||
Kernpunkt dieses Moduls ist die Datentrennung aus dem Konzept:
|
||||
"Creator sehen ihren Bereich. Scouts sehen nur ihre Pipeline."
|
||||
Sie wird AUSSCHLIESSLICH serverseitig durchgesetzt -- in jeder Abfrage,
|
||||
nicht im Browser. Ein manipulierter Aufruf bekommt dadurch keine
|
||||
fremden Daten, egal was er behauptet.
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js";
|
||||
|
||||
export const aufgabenRouter = express.Router();
|
||||
|
||||
const STATUS = ["offen", "arbeit", "review", "erledigt"];
|
||||
const PRIORITAETEN = ["hoch", "mittel", "niedrig"];
|
||||
const TITEL_MAX = 160;
|
||||
const TEXT_MAX = 4000;
|
||||
|
||||
const jetzt = () => new Date().toISOString();
|
||||
|
||||
/* ---------- Schranke ---------------------------------------------------- */
|
||||
|
||||
function angemeldet(req, res, next) {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
req.person = person;
|
||||
next();
|
||||
}
|
||||
|
||||
/* Schutz gegen Anfragen von fremden Seiten. SameSite=lax verhindert das
|
||||
meiste schon, aber nur solange sich der Browser daran hält. Bei allem,
|
||||
was Daten verändert, wird zusätzlich geprüft, dass die Anfrage von
|
||||
dieser Domain kommt. Fehlt der Kopf ganz (z. B. bei curl), ist es kein
|
||||
Browser-Angriff über eine fremde Seite -- dann zählt allein das Cookie. */
|
||||
function gleicheHerkunft(req, res, next) {
|
||||
const herkunft = req.get("origin");
|
||||
if (!herkunft) return next();
|
||||
let erlaubt;
|
||||
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
||||
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
||||
next();
|
||||
}
|
||||
|
||||
aufgabenRouter.use("/workspace/api", angemeldet);
|
||||
|
||||
/* ---------- Sichtbarkeit ------------------------------------------------ */
|
||||
|
||||
/* Liefert WHERE-Bedingung und Werte, passend zur Rolle. An genau einer
|
||||
Stelle definiert, damit keine Abfrage sie versehentlich vergisst. */
|
||||
function sichtbar(person) {
|
||||
switch (person.rolle) {
|
||||
case "admin":
|
||||
return { wo: "1=1", werte: [] };
|
||||
case "creator":
|
||||
return { wo: "(a.creator_id = ? OR a.verantwortlich_id = ?)", werte: [person.id, person.id] };
|
||||
case "scout":
|
||||
return { wo: "a.verantwortlich_id = ?", werte: [person.id] };
|
||||
default:
|
||||
return { wo: "0=1", werte: [] }; // unbekannte Rolle sieht nichts
|
||||
}
|
||||
}
|
||||
|
||||
function darfAendern(person, aufgabe) {
|
||||
if (person.rolle === "admin") return true;
|
||||
return aufgabe.creator_id === person.id || aufgabe.verantwortlich_id === person.id;
|
||||
}
|
||||
|
||||
const SPALTEN = `
|
||||
a.id, a.titel, a.beschreibung, a.status, a.prioritaet, a.frist,
|
||||
a.creator_id, a.verantwortlich_id, a.erstellt, a.geaendert, a.erledigt_am,
|
||||
pc.name AS creator_name, pv.name AS verantwortlich_name`;
|
||||
|
||||
const VERBUND = `
|
||||
FROM aufgaben a
|
||||
LEFT JOIN personen pc ON pc.id = a.creator_id
|
||||
LEFT JOIN personen pv ON pv.id = a.verantwortlich_id`;
|
||||
|
||||
/* ---------- Lesen ------------------------------------------------------- */
|
||||
|
||||
aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => {
|
||||
try {
|
||||
const { wo, werte } = sichtbar(req.person);
|
||||
const reihen = db().prepare(`
|
||||
SELECT ${SPALTEN} ${VERBUND}
|
||||
WHERE ${wo}
|
||||
ORDER BY
|
||||
CASE a.prioritaet WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END,
|
||||
CASE WHEN a.frist IS NULL THEN 1 ELSE 0 END, a.frist,
|
||||
a.id DESC`).all(...werte);
|
||||
res.json({ aufgaben: reihen });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Aufgaben lesen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
aufgabenRouter.get("/workspace/api/uebersicht", (req, res) => {
|
||||
try {
|
||||
const { wo, werte } = sichtbar(req.person);
|
||||
const zaehle = (zusatz, extra = []) => db()
|
||||
.prepare(`SELECT COUNT(*) AS n ${VERBUND} WHERE ${wo}${zusatz}`)
|
||||
.get(...werte, ...extra).n;
|
||||
|
||||
const heute = jetzt().slice(0, 10);
|
||||
res.json({
|
||||
offen: zaehle(" AND a.status = 'offen'"),
|
||||
arbeit: zaehle(" AND a.status = 'arbeit'"),
|
||||
review: zaehle(" AND a.status = 'review'"),
|
||||
erledigt: zaehle(" AND a.status = 'erledigt'"),
|
||||
/* Überfällig = Frist vorbei und noch nicht erledigt. Genau die Zahl,
|
||||
die das Konzept auf dem Dashboard sehen will. */
|
||||
ueberfaellig: zaehle(" AND a.status <> 'erledigt' AND a.frist IS NOT NULL AND a.frist < ?", [heute]),
|
||||
heute: zaehle(" AND a.status <> 'erledigt' AND a.frist = ?", [heute]),
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Übersicht:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* Für die Zuweisung: wen darf ich überhaupt eintragen? Scouts bekommen
|
||||
die Liste bewusst nicht -- sie sollen keine fremden Namen sehen. */
|
||||
aufgabenRouter.get("/workspace/api/personen", (req, res) => {
|
||||
try {
|
||||
if (req.person.rolle === "admin") {
|
||||
return res.json({
|
||||
personen: db().prepare(
|
||||
"SELECT id, name, rolle FROM personen WHERE aktiv = 1 ORDER BY rolle, name").all(),
|
||||
});
|
||||
}
|
||||
res.json({ personen: [{ id: req.person.id, name: req.person.name, rolle: req.person.rolle }] });
|
||||
} catch {
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Anlegen und Ändern ------------------------------------------ */
|
||||
|
||||
function pruefeFelder(körper, { neu }) {
|
||||
const fehler = [];
|
||||
const aus = {};
|
||||
|
||||
if (neu || körper.titel !== undefined) {
|
||||
const titel = String(körper.titel ?? "").trim();
|
||||
if (titel.length < 2) fehler.push("Titel fehlt.");
|
||||
else if (titel.length > TITEL_MAX) fehler.push(`Titel ist länger als ${TITEL_MAX} Zeichen.`);
|
||||
else aus.titel = titel;
|
||||
}
|
||||
if (körper.beschreibung !== undefined) {
|
||||
const t = String(körper.beschreibung ?? "").trim();
|
||||
if (t.length > TEXT_MAX) fehler.push("Beschreibung ist zu lang.");
|
||||
else aus.beschreibung = t || null;
|
||||
}
|
||||
if (körper.status !== undefined) {
|
||||
if (!STATUS.includes(körper.status)) fehler.push("Unbekannter Status.");
|
||||
else aus.status = körper.status;
|
||||
}
|
||||
if (körper.prioritaet !== undefined) {
|
||||
if (!PRIORITAETEN.includes(körper.prioritaet)) fehler.push("Unbekannte Priorität.");
|
||||
else aus.prioritaet = körper.prioritaet;
|
||||
}
|
||||
if (körper.frist !== undefined) {
|
||||
const f = String(körper.frist ?? "").trim();
|
||||
if (!f) aus.frist = null;
|
||||
else if (!/^\d{4}-\d{2}-\d{2}$/.test(f) || Number.isNaN(Date.parse(f))) fehler.push("Frist ist kein gültiges Datum.");
|
||||
else aus.frist = f;
|
||||
}
|
||||
for (const feld of ["creator_id", "verantwortlich_id"]) {
|
||||
if (körper[feld] === undefined) continue;
|
||||
const wert = körper[feld];
|
||||
if (wert === null || wert === "") { aus[feld] = null; continue; }
|
||||
const zahl = Number(wert);
|
||||
if (!Number.isInteger(zahl) || zahl < 1) fehler.push("Ungültige Zuordnung.");
|
||||
else aus[feld] = zahl;
|
||||
}
|
||||
return { aus, fehler };
|
||||
}
|
||||
|
||||
aufgabenRouter.post("/workspace/api/aufgaben", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
const { aus, fehler } = pruefeFelder(req.body || {}, { neu: true });
|
||||
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
|
||||
|
||||
/* Wer nicht Management ist, darf ausschließlich für sich selbst
|
||||
anlegen -- egal, was im Aufruf steht. */
|
||||
if (req.person.rolle !== "admin") {
|
||||
aus.creator_id = req.person.rolle === "creator" ? req.person.id : null;
|
||||
aus.verantwortlich_id = req.person.id;
|
||||
}
|
||||
|
||||
for (const feld of ["creator_id", "verantwortlich_id"]) {
|
||||
if (aus[feld] && !db().prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(aus[feld])) {
|
||||
return res.status(400).json({ fehler: "Zugeordnete Person gibt es nicht." });
|
||||
}
|
||||
}
|
||||
|
||||
const { lastInsertRowid } = db().prepare(`
|
||||
INSERT INTO aufgaben
|
||||
(titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id,
|
||||
frist, erstellt, erstellt_von)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`).run(
|
||||
aus.titel, aus.beschreibung ?? null, aus.status ?? "offen",
|
||||
aus.prioritaet ?? "mittel", aus.creator_id ?? null, aus.verantwortlich_id ?? null,
|
||||
aus.frist ?? null, jetzt(), req.person.id);
|
||||
|
||||
protokolliere("aufgabe_angelegt", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${lastInsertRowid} ${aus.titel}`.slice(0, 120),
|
||||
});
|
||||
res.status(201).json({ id: Number(lastInsertRowid) });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Aufgabe anlegen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
aufgabenRouter.patch("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
/* Erst mit der Sichtbarkeitsregel holen: Was jemand nicht sehen darf,
|
||||
existiert für ihn auch nicht -- deshalb 404 und nicht 403. Sonst
|
||||
liesse sich durch Ausprobieren herausfinden, welche Nummern es gibt. */
|
||||
const { wo, werte } = sichtbar(req.person);
|
||||
const aufgabe = db().prepare(
|
||||
`SELECT a.* ${VERBUND} WHERE ${wo} AND a.id = ?`).get(...werte, id);
|
||||
if (!aufgabe) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!darfAendern(req.person, aufgabe)) return res.status(403).json({ fehler: "nicht_erlaubt" });
|
||||
|
||||
const { aus, fehler } = pruefeFelder(req.body || {}, { neu: false });
|
||||
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
|
||||
|
||||
/* Zuordnungen darf nur das Management verschieben. */
|
||||
if (req.person.rolle !== "admin") { delete aus.creator_id; delete aus.verantwortlich_id; }
|
||||
|
||||
const felder = Object.keys(aus);
|
||||
if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" });
|
||||
|
||||
const setz = felder.map((f) => `${f} = ?`);
|
||||
const daten = felder.map((f) => aus[f]);
|
||||
setz.push("geaendert = ?"); daten.push(jetzt());
|
||||
|
||||
if (aus.status === "erledigt" && aufgabe.status !== "erledigt") {
|
||||
setz.push("erledigt_am = ?"); daten.push(jetzt());
|
||||
} else if (aus.status && aus.status !== "erledigt") {
|
||||
setz.push("erledigt_am = NULL");
|
||||
}
|
||||
|
||||
db().prepare(`UPDATE aufgaben SET ${setz.join(", ")} WHERE id = ?`).run(...daten, id);
|
||||
|
||||
protokolliere("aufgabe_geaendert", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${id} ${felder.join(",")}`.slice(0, 120),
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Aufgabe ändern:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
+27
-4
@@ -58,7 +58,7 @@ const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64 };
|
||||
let _db = null;
|
||||
let _dbFehler = null;
|
||||
|
||||
function db() {
|
||||
export function db() {
|
||||
if (_db) return _db;
|
||||
if (_dbFehler) throw _dbFehler;
|
||||
try {
|
||||
@@ -102,6 +102,29 @@ function db() {
|
||||
ip TEXT
|
||||
);
|
||||
|
||||
/* Aufgaben. creator_id sagt, ZU WEM die Aufgabe gehört (wessen
|
||||
Bereich), verantwortlich_id, WER sie erledigt. Beides getrennt,
|
||||
weil im Konzept auch Aufgaben vorkommen, die das Management für
|
||||
einen Creator anlegt. */
|
||||
CREATE TABLE IF NOT EXISTS aufgaben (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
titel TEXT NOT NULL,
|
||||
beschreibung TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'offen'
|
||||
CHECK (status IN ('offen','arbeit','review','erledigt')),
|
||||
prioritaet TEXT NOT NULL DEFAULT 'mittel'
|
||||
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
|
||||
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
frist TEXT,
|
||||
erstellt TEXT NOT NULL,
|
||||
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
||||
geaendert TEXT,
|
||||
erledigt_am TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS versuche (
|
||||
ip TEXT NOT NULL,
|
||||
zeitpunkt TEXT NOT NULL
|
||||
@@ -138,7 +161,7 @@ const jetzt = () => new Date().toISOString();
|
||||
von Cloudflare direkt anspricht, könnte den Kopf frei setzen. Deshalb
|
||||
wird auf die Peer-Adresse zurückgefallen, sobald der Kopf fehlt -- und
|
||||
beide Werte landen im Protokoll, damit so etwas auffällt. */
|
||||
function echteIp(req) {
|
||||
export function echteIp(req) {
|
||||
const cf = req.get("cf-connecting-ip");
|
||||
if (cf && cf.length <= 45) return cf.trim();
|
||||
return req.ip || "?";
|
||||
@@ -161,7 +184,7 @@ function gleich(a, b) {
|
||||
|
||||
const tokenHash = (t) => createHash("sha256").update(t).digest("hex");
|
||||
|
||||
function protokolliere(aktion, { personId = null, rolle = null, detail = null, ip = null } = {}) {
|
||||
export function protokolliere(aktion, { personId = null, rolle = null, detail = null, ip = null } = {}) {
|
||||
try {
|
||||
db().prepare(
|
||||
"INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)"
|
||||
@@ -224,7 +247,7 @@ export const workspaceRouter = express.Router();
|
||||
|
||||
/* Schutz der angemeldeten Seiten. Serverseitig, nicht nur im Browser --
|
||||
sonst könnte man die Seite einfach direkt aufrufen. */
|
||||
const GESCHUETZT = ["/workspace/start.html"];
|
||||
const GESCHUETZT = ["/workspace/start.html", "/workspace/aufgaben.html"];
|
||||
|
||||
workspaceRouter.use((req, res, next) => {
|
||||
if (!GESCHUETZT.includes(req.path)) return next();
|
||||
|
||||
Reference in New Issue
Block a user