Workspace: Creator-Profile (Onboarding aus dem Konzept)
/workspace/profil.html -- Stammdaten, Ziele und 90-Tage-Plan, genau nach Seite 5 des Konzepts. Management waehlt oben den Creator aus, ein Creator sieht nur sein eigenes Profil. Sicherheitskern ist das Feld admin_notiz. Das Konzept fordert "private Admin-Notizen separat". Die Notiz wird deshalb nicht im Browser ausgeblendet, sondern gar nicht erst gesendet: Die Spaltenliste der Abfrage haengt an der Rolle (FELDER_OFFEN / FELDER_ADMIN). Dasselbe gilt fuer Plan-Start und Review-Termin. Geprueft: - In der kompletten Rohantwort an den Creator kommt der Inhalt der internen Notiz 0-mal vor - Creator auf fremdes Profil: 404 (lesend wie schreibend) - Scout auf ein Profil: 404, profil.html leitet ihn weg - Creator setzt admin_notiz selbst: wird stillschweigend ignoriert, der Inhalt bleibt unveraendert - Profil einer Nicht-Creator-Person: 404 Dabei ist ein aelterer Fehler aufgefallen: /api/ich lieferte nur Name und Rolle, nicht die eigene Nummer. Dadurch rief die Profilseite eines Creators /api/profil/undefined auf und blieb leer. Derselbe Fehler machte in der Personenverwaltung den Selbstvergleich unwirksam -- beim eigenen Eintrag erschien ein "Sperren"-Knopf, den der Server dann ablehnte. /api/ich liefert jetzt zusaetzlich die id. Im Protokoll landen nur die Feldnamen, nie die Inhalte: Im Profil stehen persoenliche Angaben, die nicht zusaetzlich im Audit-Log auftauchen sollen.
This commit is contained in:
@@ -16,6 +16,7 @@ import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js";
|
||||
import { workspaceRouter } from "./workspace.js";
|
||||
import { aufgabenRouter } from "./workspace-aufgaben.js";
|
||||
import { personenRouter } from "./workspace-personen.js";
|
||||
import { profilRouter } from "./workspace-profil.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SITE_DIR = join(__dirname, "..");
|
||||
@@ -72,6 +73,7 @@ app.use(webdesignGate);
|
||||
app.use(workspaceRouter);
|
||||
app.use(aufgabenRouter);
|
||||
app.use(personenRouter);
|
||||
app.use(profilRouter);
|
||||
|
||||
/* 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,177 @@
|
||||
/* =====================================================================
|
||||
workspace-profil.js — Creator-Profile (Onboarding-Teil des Konzepts):
|
||||
Stammdaten, Ziele, 90-Tage-Plan.
|
||||
|
||||
Sicherheitskern dieses Moduls ist das Feld `admin_notiz`. Das Konzept
|
||||
fordert "private Admin-Notizen separat" -- diese Notiz darf ein Creator
|
||||
unter keinen Umständen sehen, auch nicht als Nebeneffekt einer
|
||||
Sammelabfrage. Deshalb wird sie nicht nur im Browser ausgeblendet,
|
||||
sondern gar nicht erst mitgeschickt: Die Spaltenliste der Abfrage
|
||||
haengt an der Rolle (siehe FELDER_OFFEN / FELDER_ADMIN).
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js";
|
||||
|
||||
export const profilRouter = express.Router();
|
||||
|
||||
/* Felder, die auch der Creator selbst sehen und ändern darf. */
|
||||
const FELDER_OFFEN = [
|
||||
"handles", "nische", "live_zeiten", "technik",
|
||||
"ziel_live", "ziel_content", "ziel_community", "ziel_technik",
|
||||
"plan_prio1", "plan_prio2", "plan_prio3",
|
||||
];
|
||||
/* Nur fürs Management. `admin_notiz` ist der eigentliche Grund für die
|
||||
Trennung, die Termine stehen hier, weil sie zur Steuerung gehören. */
|
||||
const FELDER_ADMIN = ["plan_start", "naechster_review", "admin_notiz"];
|
||||
|
||||
const DATUMSFELDER = new Set(["plan_start", "naechster_review"]);
|
||||
const TEXT_MAX = 2000;
|
||||
|
||||
const jetzt = () => new Date().toISOString();
|
||||
|
||||
function angemeldet(req, res, next) {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
req.person = person;
|
||||
next();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
profilRouter.use("/workspace/api/profil", angemeldet);
|
||||
|
||||
/* Wer darf welches Profil? Scouts gar keins -- im Konzept haben sie mit
|
||||
der Creator-Betreuung nichts zu tun. */
|
||||
function darf(person, zielId) {
|
||||
if (person.rolle === "admin") return "voll";
|
||||
if (person.rolle === "creator" && person.id === zielId) return "eigen";
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------- Lesen ------------------------------------------------------- */
|
||||
|
||||
profilRouter.get("/workspace/api/profil/:id", (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const recht = darf(req.person, id);
|
||||
/* 404 statt 403: Wer kein Recht hat, soll nicht erfahren, ob es die
|
||||
Person überhaupt gibt. */
|
||||
if (!recht) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const person = db().prepare(
|
||||
"SELECT id, name, rolle, aktiv, erstellt, letzter_login FROM personen WHERE id = ?").get(id);
|
||||
if (!person || person.rolle !== "creator") {
|
||||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
}
|
||||
|
||||
const felder = recht === "voll" ? [...FELDER_OFFEN, ...FELDER_ADMIN] : FELDER_OFFEN;
|
||||
const reihe = db().prepare(
|
||||
`SELECT ${felder.join(", ")}, geaendert FROM profile WHERE person_id = ?`).get(id);
|
||||
|
||||
res.json({
|
||||
person,
|
||||
/* Kein Profil angelegt = leeres Objekt, kein Fehler. */
|
||||
profil: reihe || Object.fromEntries(felder.map((f) => [f, null])),
|
||||
darf_alles: recht === "voll",
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Profil lesen:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* Liste aller Creator -- fürs Management, zur Auswahl. */
|
||||
profilRouter.get("/workspace/api/profil", (req, res) => {
|
||||
try {
|
||||
if (req.person.rolle === "creator") {
|
||||
return res.json({ creator: [{ id: req.person.id, name: req.person.name }], eigen: true });
|
||||
}
|
||||
if (req.person.rolle !== "admin") return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
res.json({
|
||||
creator: db().prepare(`
|
||||
SELECT p.id, p.name, p.aktiv, p.letzter_login,
|
||||
(SELECT COUNT(*) FROM aufgaben a
|
||||
WHERE a.creator_id = p.id AND a.status <> 'erledigt') AS offene_aufgaben,
|
||||
(SELECT naechster_review FROM profile f WHERE f.person_id = p.id) AS naechster_review,
|
||||
(SELECT geaendert FROM profile f WHERE f.person_id = p.id) AS profil_geaendert
|
||||
FROM personen p WHERE p.rolle = 'creator'
|
||||
ORDER BY p.aktiv DESC, p.name`).all(),
|
||||
eigen: false,
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Creator-Liste:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Speichern ---------------------------------------------------- */
|
||||
|
||||
profilRouter.put("/workspace/api/profil/:id", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const recht = darf(req.person, id);
|
||||
if (!recht) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const person = db().prepare("SELECT id, rolle FROM personen WHERE id = ?").get(id);
|
||||
if (!person || person.rolle !== "creator") return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
/* Ein Creator kann die Verwaltungsfelder nicht setzen -- sie stehen
|
||||
gar nicht erst in der Liste der zulässigen Felder. Was er trotzdem
|
||||
mitschickt, wird stillschweigend ignoriert. */
|
||||
const erlaubt = recht === "voll" ? [...FELDER_OFFEN, ...FELDER_ADMIN] : FELDER_OFFEN;
|
||||
|
||||
const werte = {};
|
||||
for (const feld of erlaubt) {
|
||||
if (req.body?.[feld] === undefined) continue;
|
||||
let wert = req.body[feld];
|
||||
if (wert === null) { werte[feld] = null; continue; }
|
||||
wert = String(wert).trim();
|
||||
if (!wert) { werte[feld] = null; continue; }
|
||||
if (DATUMSFELDER.has(feld)) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(wert) || Number.isNaN(Date.parse(wert))) {
|
||||
return res.status(400).json({ fehler: `Kein gültiges Datum: ${feld}` });
|
||||
}
|
||||
} else if (wert.length > TEXT_MAX) {
|
||||
return res.status(400).json({ fehler: "Ein Feld ist zu lang." });
|
||||
}
|
||||
werte[feld] = wert;
|
||||
}
|
||||
if (!Object.keys(werte).length) return res.status(400).json({ fehler: "nichts_zu_speichern" });
|
||||
|
||||
const spalten = Object.keys(werte);
|
||||
/* UPSERT: Beim ersten Speichern entsteht die Zeile, danach wird sie
|
||||
aktualisiert -- ohne vorher prüfen zu müssen, ob es sie gibt. */
|
||||
db().prepare(`
|
||||
INSERT INTO profile (person_id, ${spalten.join(", ")}, geaendert, geaendert_von)
|
||||
VALUES (?, ${spalten.map(() => "?").join(", ")}, ?, ?)
|
||||
ON CONFLICT(person_id) DO UPDATE SET
|
||||
${spalten.map((s) => `${s} = excluded.${s}`).join(", ")},
|
||||
geaendert = excluded.geaendert,
|
||||
geaendert_von = excluded.geaendert_von
|
||||
`).run(id, ...spalten.map((s) => werte[s]), jetzt(), req.person.id);
|
||||
|
||||
protokolliere("profil_gespeichert", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
/* Nur die Feldnamen, nie die Inhalte -- im Profil stehen persönliche
|
||||
Angaben, die nicht zusätzlich im Protokoll landen sollen. */
|
||||
detail: `Creator #${id}: ${spalten.join(",")}`.slice(0, 120),
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Profil speichern:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
+30
-1
@@ -125,6 +125,30 @@ export function db() {
|
||||
CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id);
|
||||
|
||||
/* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je
|
||||
Creator, entsteht erst beim ersten Speichern.
|
||||
admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an
|
||||
das Management ausgeliefert -- siehe workspace-profil.js. */
|
||||
CREATE TABLE IF NOT EXISTS profile (
|
||||
person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE,
|
||||
handles TEXT,
|
||||
nische TEXT,
|
||||
live_zeiten TEXT,
|
||||
technik TEXT,
|
||||
ziel_live TEXT,
|
||||
ziel_content TEXT,
|
||||
ziel_community TEXT,
|
||||
ziel_technik TEXT,
|
||||
plan_start TEXT,
|
||||
plan_prio1 TEXT,
|
||||
plan_prio2 TEXT,
|
||||
plan_prio3 TEXT,
|
||||
naechster_review TEXT,
|
||||
admin_notiz TEXT,
|
||||
geaendert TEXT,
|
||||
geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS versuche (
|
||||
ip TEXT NOT NULL,
|
||||
zeitpunkt TEXT NOT NULL
|
||||
@@ -257,6 +281,7 @@ const GESCHUETZT = {
|
||||
"/workspace/start.html": null,
|
||||
"/workspace/aufgaben.html": null,
|
||||
"/workspace/personen.html": ["admin"],
|
||||
"/workspace/profil.html": ["admin", "creator"],
|
||||
};
|
||||
|
||||
workspaceRouter.use((req, res, next) => {
|
||||
@@ -335,7 +360,11 @@ workspaceRouter.post("/workspace/api/abmelden", (req, res) => {
|
||||
workspaceRouter.get("/workspace/api/ich", (req, res) => {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
res.json({ name: person.name, rolle: person.rolle });
|
||||
/* Die eigene Nummer gehört mit dazu: Ohne sie kann die Oberfläche nicht
|
||||
erkennen, welcher Eintrag der eigene ist (etwa "das bin ich" in der
|
||||
Personenliste), und das eigene Profil liesse sich gar nicht aufrufen.
|
||||
Ein Geheimnis ist sie nicht -- sie beschreibt nur den Angemeldeten. */
|
||||
res.json({ id: person.id, name: person.name, rolle: person.rolle });
|
||||
});
|
||||
|
||||
/* ---------- Verwaltung (nur über die Kommandozeile) --------------------
|
||||
|
||||
Reference in New Issue
Block a user