diff --git a/server/index.js b/server/index.js
index 75180c91..82c1960b 100644
--- a/server/index.js
+++ b/server/index.js
@@ -30,6 +30,7 @@ import { wissenRouter } from "./workspace-wissen.js";
import { kiRouter } from "./workspace-ki.js";
import { contentRouter } from "./workspace-content.js";
import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js";
+import { steckbriefRouter } from "./workspace-steckbrief.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const SITE_DIR = join(__dirname, "..");
@@ -89,6 +90,12 @@ app.use(workspaceRouter);
aber von einem Skript ohne Sitzung und wuerde dort abgewiesen, bevor
ihr Schluessel ueberhaupt geprueft wird. */
app.use(sicherungRouter);
+/* Ebenfalls VOR aufgabenRouter: Das Profilbild wird von
+ geholt. Der Browser schickt dabei den Sitzungs-Cookie zwar mit, aber
+ die Schranke des Aufgaben-Routers antwortet mit JSON -- ein 401 als
+ JSON in einem
ergibt nur ein kaputtes Bild ohne jeden Hinweis.
+ Der Steckbrief bringt seine eigene Schranke mit. */
+app.use(steckbriefRouter);
app.use(aufgabenRouter);
app.use(personenRouter);
app.use(profilRouter);
diff --git a/server/pruef-steckbrief.mjs b/server/pruef-steckbrief.mjs
new file mode 100644
index 00000000..ebaa9a80
--- /dev/null
+++ b/server/pruef-steckbrief.mjs
@@ -0,0 +1,331 @@
+/* Prueft den eigenen Steckbrief: Bild, Satz ueber sich, Kanaele -- und
+ vor allem die Stellen, an denen Profilbilder typischerweise scheitern.
+
+ Der wichtigste Teil ist der letzte: Ein umbenanntes SVG mit Skript
+ darin MUSS abgelehnt werden. Wuerde es durchkommen und der Browser
+ liefe es aus, koennte es im Namen der Domain handeln -- mit der
+ Sitzung des Betrachters. */
+import { mkdtempSync, rmSync, readdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-sb-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = "4196";
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
+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 }); };
+await import("./index.js");
+await new Promise((r) => setTimeout(r, 700));
+const BASIS = "http://127.0.0.1:4196";
+setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 200_000).unref?.();
+
+let fehler = 0;
+const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+function 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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
+const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001");
+const idFremd = anlegen("Fremd", "creator", "CODE-FREM-0001");
+d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idScout, jetzt);
+d.close();
+
+/* ---------- Anmelden ohne Browser ---------------------------------------- */
+async function anmelden(code, rolle) {
+ const a = await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle, code }) });
+ const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
+ return keks;
+}
+const kDogi = await anmelden("CODE-DOGI-0001", "admin");
+const kScout = await anmelden("CODE-SCOU-0001", "scout");
+const kTili = await anmelden("CODE-ANNA-0001", "creator");
+
+const hole = (pfad, keks, optionen = {}) => fetch(BASIS + pfad, {
+ ...optionen, headers: { Cookie: keks, ...(optionen.headers || {}) } });
+
+/* ---------- Der eigene Steckbrief ---------------------------------------- */
+console.log("\n=== Jeder hat sein eigenes Profil");
+for (const [name, keks, rolle] of [["Filipe", kDogi, "admin"],
+ ["Patrick", kScout, "scout"],
+ ["Tili", kTili, "creator"]]) {
+ const a = await hole("/workspace/api/steckbrief/mein", keks);
+ const { steckbrief, kanaele } = await a.json();
+ ok(a.ok && steckbrief.name === name && steckbrief.rolle === rolle,
+ `${name} (${rolle}) hat ein eigenes Profil`);
+ ok(kanaele?.length === 4, ` und vier Kanäle zur Auswahl (${kanaele?.length})`);
+}
+
+/* ---------- Kanäle eintragen --------------------------------------------- */
+console.log("\n=== Kanäle");
+const patch = (keks, koerper) => hole("/workspace/api/steckbrief/mein", keks, {
+ method: "PATCH", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(koerper) });
+
+{
+ const a = await patch(kTili, { tiktok: "tili.live", ueber_mich: "Ich streame abends." });
+ const { steckbrief } = await a.json();
+ const tt = steckbrief.kanaele.find((k) => k.art === "tiktok");
+ ok(a.ok && tt?.handle === "tili.live", `TikTok-Name gespeichert (${tt?.handle})`);
+ ok(tt?.adresse === "https://www.tiktok.com/@tili.live",
+ `und daraus wird ein Verweis (${tt?.adresse})`);
+ ok(steckbrief.ueber_mich === "Ich streame abends.", "der Satz über sich steht auch");
+}
+
+/* Was Leute wirklich einfuegen: ein @ davor oder gleich die ganze
+ Adresse. Beides muss angenommen und zurueckgefuehrt werden -- eine
+ Fehlermeldung fuer ein vorangestelltes @ waere schlechte Arbeit. */
+for (const [eingabe, erwartet] of [
+ ["@tili.live", "tili.live"],
+ ["https://www.tiktok.com/@tili.live", "tili.live"],
+ ["https://www.tiktok.com/@tili.live?lang=de&is_from_webapp=1", "tili.live"],
+ ["https://www.tiktok.com/@tili.live#etwas", "tili.live"],
+ [" tili.live ", "tili.live"],
+]) {
+ const a = await patch(kTili, { tiktok: eingabe });
+ const antwort = await a.json().catch(() => ({}));
+ const tt = antwort.steckbrief?.kanaele.find((k) => k.art === "tiktok");
+ /* Nicht destrukturieren, ohne den Fehlerfall abzufangen: Beim ersten
+ Lauf schlug genau hier die Pruefung selbst mit einem Absturz fehl,
+ statt "FEHL" zu melden -- und ein abgestuerzter Lauf sagt nichts
+ darueber, was danach noch geprueft worden waere. */
+ ok(a.ok && tt?.handle === erwartet,
+ `"${eingabe.slice(0, 42)}" wird zu "${tt?.handle ?? antwort.fehler}"`);
+}
+
+/* Unsinn muss abgelehnt werden -- sonst steht in der Liste ein Verweis,
+ der ins Leere fuehrt. */
+{
+ const a = await patch(kTili, { tiktok: "hat leerzeichen und ümläute" });
+ ok(a.status === 400, `ein unmöglicher Name wird abgelehnt (${a.status})`);
+ const b = await patch(kTili, { tiktok: "" });
+ const { steckbrief } = await b.json();
+ ok(b.ok && !steckbrief.kanaele.some((k) => k.art === "tiktok"),
+ "und leeren heisst löschen, nicht Fehler");
+}
+
+/* ---------- Wer darf wen sehen? ------------------------------------------ */
+console.log("\n=== Sichtbarkeit");
+{
+ const alle = await (await hole("/workspace/api/steckbrief", kDogi)).json();
+ ok(alle.steckbriefe.length === 4, `DogFather sieht alle vier (${alle.steckbriefe.length})`);
+
+ const scout = await (await hole("/workspace/api/steckbrief", kScout)).json();
+ const namen = scout.steckbriefe.map((s) => s.name).sort();
+ ok(namen.includes("Patrick") && namen.includes("Tili"),
+ `der Scout sieht sich und seinen Creator (${namen.join(", ")})`);
+ ok(!namen.includes("Fremd"), " aber nicht den fremden Creator");
+
+ const tili = await (await hole("/workspace/api/steckbrief", kTili)).json();
+ const tnamen = tili.steckbriefe.map((s) => s.name).sort();
+ ok(tnamen.includes("Tili") && tnamen.includes("Patrick"),
+ `der Creator sieht sich und seinen Betreuer (${tnamen.join(", ")})`);
+ ok(!tnamen.includes("Fremd"), " aber keinen anderen Creator");
+
+ /* 404, nicht 403: Wer eine Person nicht sehen darf, soll nicht
+ erfahren, dass es sie gibt. */
+ const einzeln = await hole(`/workspace/api/steckbrief/${idFremd}`, kTili);
+ ok(einzeln.status === 404, `ein fremdes Profil ist 404, nicht 403 (${einzeln.status})`);
+}
+
+/* Niemand schreibt einem anderen ins Profil -- auch DogFather nicht.
+ Es gibt schlicht keinen Weg dafuer. */
+{
+ const a = await hole(`/workspace/api/steckbrief/${idTili}`, kDogi, {
+ method: "PATCH", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ueber_mich: "fremd geschrieben" }) });
+ ok(a.status === 404 || a.status === 405,
+ `auch DogFather kann fremde Profile nicht beschreiben (${a.status})`);
+}
+
+/* ---------- Das Bild ------------------------------------------------------ */
+console.log("\n=== Profilbild");
+
+/* Ein winziges, echtes PNG (1x1). Von Hand gebaut, damit die Pruefung
+ keine Datei von aussen braucht. */
+const echtesPng = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+ "base64");
+
+const bildHoch = (keks, puffer) => hole("/workspace/api/steckbrief/bild", keks, {
+ method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: puffer });
+
+let bildPfad = null;
+{
+ const a = await bildHoch(kTili, echtesPng);
+ const antwort = await a.json();
+ bildPfad = antwort.bild;
+ ok(a.ok && /^\/workspace\/api\/steckbrief\/bild\/[a-f0-9]{32}\.png$/.test(bildPfad || ""),
+ `ein PNG wird angenommen und bekommt einen Zufallsnamen (${bildPfad})`);
+
+ const bild = await hole(bildPfad, kTili);
+ ok(bild.ok && bild.headers.get("content-type") === "image/png",
+ `und wird als Bild ausgeliefert (${bild.headers.get("content-type")})`);
+ ok(bild.headers.get("x-content-type-options") === "nosniff",
+ "mit nosniff -- der Browser darf den Typ nicht selbst raten");
+ ok(/default-src 'none'/.test(bild.headers.get("content-security-policy") || ""),
+ "und einer eigenen, alles verbietenden Inhaltsregel");
+}
+
+/* DER WICHTIGSTE FALL. Ein SVG mit Skript, als Bild hochgeladen. Es
+ traegt einen harmlosen Content-Type -- der ist eine BEHAUPTUNG des
+ Absenders, keine Tatsache. Geprueft wird die Signatur der Datei. */
+{
+ const boeses = Buffer.from(
+ '', "utf8");
+ const a = await bildHoch(kTili, boeses);
+ ok(a.status === 400, `ein SVG mit Skript wird abgelehnt (${a.status})`);
+
+ /* Auch ein umbenanntes Textstueck mit PNG-Endung kommt nicht durch --
+ der Server sieht ja gar keinen Dateinamen, nur den Inhalt. */
+ const b = await bildHoch(kTili, Buffer.from("GIF89a das ist kein erlaubtes Bild", "utf8"));
+ ok(b.status === 400, `und ein GIF ebenfalls nicht (${b.status})`);
+}
+
+/* Der Weg zum Bild darf nicht verlassen werden koennen. */
+for (const versuch of ["..%2f..%2fworkspace.db", "....//workspace.db", "abc.png", "x".repeat(32) + ".png"]) {
+ const a = await hole("/workspace/api/steckbrief/bild/" + versuch, kTili);
+ ok(a.status === 404, `"${versuch.slice(0, 24)}" führt nirgendwohin (${a.status})`);
+}
+
+/* Ein zweites Bild ersetzt das erste UND raeumt es weg -- sonst
+ sammelten sich bei jedem Wechsel Dateien an, die niemand mehr findet. */
+{
+ const vorher = bildPfad;
+ const a = await bildHoch(kTili, echtesPng);
+ const { bild } = await a.json();
+ ok(bild !== vorher, "ein neues Bild bekommt einen neuen Namen");
+ const alt = await hole(vorher, kTili);
+ ok(alt.status === 404, `und das alte ist wirklich weg (${alt.status})`);
+
+ const dateien = readdirSync(join(ordner, "profilbilder"));
+ ok(dateien.length === 1, `es liegt genau eine Datei auf der Platte (${dateien.length})`);
+}
+
+/* Zu gross: Die Grenze liegt bei 4 MB. */
+{
+ const a = await bildHoch(kTili, Buffer.alloc(5 * 1024 * 1024, 1));
+ ok(a.status === 413 || a.status === 400, `ein 5-MB-Bild wird abgewiesen (${a.status})`);
+}
+
+/* ---------- Das Bild erscheint in der Kopfleiste -------------------------- */
+console.log("\n=== Es kommt oben an");
+{
+ const ich = await (await hole("/workspace/api/ich", kTili)).json();
+ ok(/^\/workspace\/api\/steckbrief\/bild\//.test(ich.bild || ""),
+ `/api/ich liefert das Bild mit (${ich.bild})`);
+
+ const ohne = await (await hole("/workspace/api/ich", kDogi)).json();
+ ok(ohne.bild === null, "und null, solange keines gesetzt ist");
+
+ /* Ohne Anmeldung gibt es gar nichts -- auch kein Bild. */
+ const fremd = await fetch(BASIS + "/workspace/api/steckbrief/mein");
+ ok(fremd.status === 401, `ohne Anmeldung: 401 (${fremd.status})`);
+}
+
+/* ---------- Im Browser ---------------------------------------------------- */
+console.log("\n=== Die Seite");
+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: 1000 } });
+ const konsole = [];
+ const seite = await kontext.newPage();
+ seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
+ seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
+
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await seite.click('.rolle[data-rolle="creator"]');
+ await seite.fill("#code", "CODE-ANNA-0001");
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 15000 });
+ await seite.goto(BASIS + "/workspace/profil.html", { waitUntil: "networkidle" });
+ await seite.waitForTimeout(900);
+
+ const r = await seite.evaluate(() => ({
+ sichtbar: !document.getElementById("steckbrief")?.hidden,
+ name: document.getElementById("sb-name")?.textContent,
+ rolle: document.getElementById("sb-rolle")?.textContent,
+ kanaele: [...document.querySelectorAll("#sb-kanaele .kanal")].map((k) => k.dataset.art),
+ hatBild: !!document.querySelector("#sb-bild img"),
+ bildRund: getComputedStyle(document.getElementById("sb-bild")).borderRadius,
+ obenBild: !!document.querySelector(".wer__bild"),
+ ueber: document.getElementById("sb-ueber")?.value,
+ ueber504: document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ }));
+ console.log(" Kanäle:", r.kanaele.join(", "));
+ ok(r.sichtbar, "der Steckbrief steht auf der Profilseite");
+ ok(r.name === "Tili", `mit dem eigenen Namen (${r.name})`);
+ ok(r.rolle === "Creator", `und der eigenen Rolle (${r.rolle})`);
+ ok(r.kanaele.length === 4, `vier Kanalfelder (${r.kanaele.length})`);
+ ok(r.kanaele[0] === "tiktok", `TikTok steht an erster Stelle (${r.kanaele[0]})`);
+ ok(r.hatBild, "das hochgeladene Bild wird angezeigt");
+ ok(/50%/.test(r.bildRund), `und ist rund (${r.bildRund})`);
+ ok(r.obenBild, "es steht auch oben rechts in der Kopfleiste");
+ ok(r.ueber504 <= 0, `nichts steht über (${r.ueber504}px)`);
+ ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`);
+
+ /* Speichern über die Oberfläche -- nicht nur über die Schnittstelle. */
+ await seite.fill("#sb-tiktok", "@neuer.name");
+ await seite.fill("#sb-ueber", "Neuer Text über mich.");
+ await seite.click("#sb-speichern");
+ await seite.waitForTimeout(800);
+ const n = await seite.evaluate(() => ({
+ tiktok: document.getElementById("sb-tiktok")?.value,
+ auf: document.querySelector('.kanal[data-art="tiktok"] .kanal__auf')?.getAttribute("href"),
+ stand: document.getElementById("sb-stand")?.textContent,
+ }));
+ ok(n.tiktok === "neuer.name", `das @ wird beim Speichern entfernt (${n.tiktok})`);
+ ok(n.auf === "https://www.tiktok.com/@neuer.name", `und der Verweis erscheint (${n.auf})`);
+ ok(/gespeichert/i.test(n.stand || ""), `mit Rückmeldung ("${n.stand}")`);
+
+ await seite.screenshot({ path: "pruef-steckbrief.png", fullPage: false });
+ await kontext.close();
+}
+
+/* Ein Scout hat denselben Steckbrief -- er ist nicht nur fuer Creator. */
+{
+ const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const seite = await kontext.newPage();
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
+ await seite.click('.rolle[data-rolle="scout"]');
+ await seite.fill("#code", "CODE-SCOU-0001");
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 15000 });
+ await seite.goto(BASIS + "/workspace/profil.html", { waitUntil: "networkidle" });
+ await seite.waitForTimeout(800);
+ const r = await seite.evaluate(() => ({
+ sichtbar: !document.getElementById("steckbrief")?.hidden,
+ name: document.getElementById("sb-name")?.textContent,
+ rolle: document.getElementById("sb-rolle")?.textContent,
+ }));
+ ok(r.sichtbar && r.name === "Patrick" && r.rolle === "Scout",
+ `auch ein Scout hat sein Profil (${r.name} / ${r.rolle})`);
+ await kontext.close();
+}
+
+await browser.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung.");
+process.exit(fehler ? 1 : 0);
diff --git a/server/workspace-steckbrief.js b/server/workspace-steckbrief.js
new file mode 100644
index 00000000..5c37c887
--- /dev/null
+++ b/server/workspace-steckbrief.js
@@ -0,0 +1,363 @@
+/* =====================================================================
+ workspace-steckbrief.js — das eigene Profil jeder Person.
+
+ Jeder im Workspace, egal ob Creator, Scout, Manager oder DogFather,
+ hat einen Steckbrief: ein Bild, einen Satz über sich, und die Kanäle,
+ auf denen er unterwegs ist.
+
+ WARUM KEINE ECHTE TIKTOK-ANMELDUNG.
+ „Mit TikTok verbinden" klingt nach OAuth. Das wäre hier aber:
+ * ein TikTok-Entwicklerkonto mit App-Freischaltung,
+ * eine Prüfung durch TikTok, die Wochen dauern kann und widerrufbar
+ ist,
+ * ein Zugriffstoken je Person, das ablaufen kann, erneuert werden
+ muss und — wenn es abhandenkommt — fremden Zugriff auf ein
+ fremdes Konto bedeutet,
+ * eine Abhängigkeit von einer Schnittstelle, die sich ändern kann,
+ ohne dass wir gefragt werden.
+ Dafür bekäme man: Follower-Zahlen und Video-Statistiken.
+
+ Gebraucht wird hier aber etwas anderes: „Wer ist das, und wo finde ich
+ ihn?" Dafür genügt der öffentliche Name. Gespeichert wird deshalb NUR
+ das Handle (ohne @) — kein Token, kein Passwort, nichts, was
+ abhandenkommen kann. Daraus wird ein Verweis auf den Kanal, und in der
+ Personenliste steht, wer wo unterwegs ist.
+
+ Sollen später echte Zahlen dazukommen, ist das ein eigenes Vorhaben
+ mit eigener Entscheidung — der Name hier bleibt dann trotzdem richtig.
+
+ DAS BILD liegt als Datei neben der Datenbank, nicht darin. Bilder in
+ SQLite zu legen bläht jede Sicherung auf, und die läuft jede Nacht.
+ In der Datenbank steht nur der erzeugte Dateiname.
+
+ Sicherheit beim Bild — die Stelle, an der Profilbilder typischerweise
+ scheitern:
+ 1. Der Dateityp wird an der SIGNATUR der Datei geprüft, nicht am
+ Content-Type, den der Browser behauptet. Ein umbenanntes SVG mit
+ Skript darin käme sonst durch und würde im Namen der Domain
+ ausgeführt.
+ 2. Auf der Platte bekommt jede Datei einen Zufallsnamen. Damit kann
+ kein Name Pfade verlassen (../) oder etwas überschreiben.
+ 3. Ausgeliefert wird mit nosniff und einer eigenen, alles
+ verbietenden Inhaltsregel.
+ ===================================================================== */
+
+import express from "express";
+import { randomBytes } from "node:crypto";
+import { join } from "node:path";
+import { mkdirSync, writeFileSync, unlinkSync, existsSync, createReadStream } from "node:fs";
+import {
+ db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER,
+ istLeitung, betreuteIds, ROLLEN_SORTIERUNG, ROLLEN_NAME,
+} from "./workspace.js";
+
+export const steckbriefRouter = express.Router();
+
+const ORDNER = join(DATEN_ORDNER, "profilbilder");
+const MAX_BYTES = 4 * 1024 * 1024; // 4 MB — ein Profilbild braucht nicht mehr
+const UEBER_MAX = 400;
+const NAME_MAX = 40;
+
+/* Die Kanäle. Der Schlüssel ist zugleich der Spaltenname; das Muster
+ begrenzt, was ein Handle sein darf, und die Adresse baut den Verweis.
+ Alles an einer Stelle — sonst steht das Muster in der Prüfung und die
+ Adresse in der Oberfläche, und beim nächsten Kanal wird eines davon
+ vergessen. */
+export const KANAELE = {
+ tiktok: { name: "TikTok", muster: /^[a-zA-Z0-9._]{2,24}$/, adresse: (h) => `https://www.tiktok.com/@${h}` },
+ instagram: { name: "Instagram", muster: /^[a-zA-Z0-9._]{2,30}$/, adresse: (h) => `https://www.instagram.com/${h}/` },
+ youtube: { name: "YouTube", muster: /^[a-zA-Z0-9._-]{3,30}$/, adresse: (h) => `https://www.youtube.com/@${h}` },
+ twitch: { name: "Twitch", muster: /^[a-zA-Z0-9_]{4,25}$/, adresse: (h) => `https://www.twitch.tv/${h}` },
+};
+
+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();
+}
+
+steckbriefRouter.use("/workspace/api/steckbrief", angemeldet);
+
+/* ---------- Wen darf ich sehen? -----------------------------------------
+
+ Sich selbst immer. Die Leitung alle. Ein Scout die Creator, die er
+ betreut. Ein Creator zusätzlich die Menschen, die IHN betreuen -- er
+ soll wissen, wer sein Ansprechpartner ist, sonst steht dort ein Name
+ ohne Gesicht.
+
+ Alles andere ist 404, nicht 403: Wer eine Person nicht sehen darf,
+ soll auch nicht erfahren, dass es sie gibt. */
+function sichtbareIds(person) {
+ const eigene = new Set([person.id]);
+ if (istLeitung(person)) return null; // null = alle
+
+ if (person.rolle === "scout") {
+ for (const id of betreuteIds(person)) eigene.add(id);
+ }
+ if (person.rolle === "creator") {
+ /* Die eigenen Betreuer -- und die Leitung, die ohnehin für alle
+ zuständig ist. */
+ for (const z of db().prepare(
+ "SELECT betreuer_id AS id FROM betreuung WHERE creator_id = ?").all(person.id)) {
+ eigene.add(z.id);
+ }
+ for (const z of db().prepare(
+ "SELECT id FROM personen WHERE rolle IN ('admin','manager') AND aktiv = 1").all()) {
+ eigene.add(z.id);
+ }
+ }
+ return eigene;
+}
+
+const SPALTEN = `p.id, p.name, p.rolle, p.aktiv, p.erstellt, p.letzter_login,
+ p.bild, p.ueber_mich, p.tiktok, p.instagram, p.youtube, p.twitch`;
+
+/** Eine Zeile aus der Datenbank in das, was nach außen geht. Die Kanäle
+ * kommen als fertige Verweise mit -- damit die Oberfläche die Adressen
+ * nicht ein zweites Mal zusammenbauen muss (und dabei anders). */
+function hinaus(z) {
+ const kanaele = [];
+ for (const [schluessel, k] of Object.entries(KANAELE)) {
+ const handle = z[schluessel];
+ if (handle) kanaele.push({ art: schluessel, name: k.name, handle, adresse: k.adresse(handle) });
+ }
+ return {
+ id: z.id, name: z.name, rolle: z.rolle,
+ rolle_name: ROLLEN_NAME[z.rolle] ?? z.rolle,
+ aktiv: !!z.aktiv, erstellt: z.erstellt, letzter_login: z.letzter_login,
+ bild: z.bild ? `/workspace/api/steckbrief/bild/${z.bild}` : null,
+ ueber_mich: z.ueber_mich || null,
+ kanaele,
+ };
+}
+
+/* ---------- Lesen -------------------------------------------------------- */
+
+/** Der eigene Steckbrief. Eigener Weg statt /:id mit der eigenen Nummer:
+ * Die Oberfläche soll ihn holen können, ohne vorher zu wissen, wer sie
+ * ist. */
+steckbriefRouter.get("/workspace/api/steckbrief/mein", (req, res) => {
+ try {
+ const z = db().prepare(`SELECT ${SPALTEN} FROM personen p WHERE p.id = ?`).get(req.person.id);
+ if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
+ res.json({ steckbrief: hinaus(z), kanaele: KANAELE_ANZEIGE });
+ } catch (fehler) {
+ console.error("[workspace] Steckbrief lesen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* Was die Oberfläche über die Kanäle wissen muss, ohne die Muster als
+ regulären Ausdruck zu übertragen. */
+const KANAELE_ANZEIGE = Object.entries(KANAELE).map(([art, k]) => ({ art, name: k.name }));
+
+/** Alle Steckbriefe, die ich sehen darf. Für die Personenliste und das
+ * Dashboard -- dort steht dann ein Bild statt eines Buchstabens. */
+steckbriefRouter.get("/workspace/api/steckbrief", (req, res) => {
+ try {
+ const erlaubt = sichtbareIds(req.person);
+ const alle = db().prepare(
+ `SELECT ${SPALTEN} FROM personen p WHERE p.aktiv = 1
+ ORDER BY ${ROLLEN_SORTIERUNG.replace(/rolle/g, "p.rolle")}, p.name`).all();
+ const meine = erlaubt ? alle.filter((z) => erlaubt.has(z.id)) : alle;
+ res.json({ steckbriefe: meine.map(hinaus) });
+ } catch (fehler) {
+ console.error("[workspace] Steckbriefe lesen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+steckbriefRouter.get("/workspace/api/steckbrief/:id(\\d+)", (req, res) => {
+ try {
+ const erlaubt = sichtbareIds(req.person);
+ const id = Number(req.params.id);
+ if (erlaubt && !erlaubt.has(id)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const z = db().prepare(`SELECT ${SPALTEN} FROM personen p WHERE p.id = ?`).get(id);
+ if (!z) return res.status(404).json({ fehler: "nicht_gefunden" });
+ res.json({ steckbrief: hinaus(z) });
+ } catch (fehler) {
+ console.error("[workspace] Steckbrief lesen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------- Ändern ------------------------------------------------------
+
+ Nur den EIGENEN. Auch die Leitung schreibt niemandem etwas ins Profil:
+ Ein Satz über sich und der eigene Kanalname gehören der Person, nicht
+ ihrem Vorgesetzten. (Löschen einer Person geht weiterhin über die
+ Personenverwaltung -- das ist etwas anderes.) */
+
+steckbriefRouter.patch("/workspace/api/steckbrief/mein", gleicheHerkunft,
+ express.json({ limit: "16kb" }), (req, res) => {
+ try {
+ const setzen = [];
+ const werte = [];
+ const fehler = [];
+
+ if (req.body.ueber_mich !== undefined) {
+ const t = String(req.body.ueber_mich ?? "").trim();
+ if (t.length > UEBER_MAX) fehler.push(`Der Text darf höchstens ${UEBER_MAX} Zeichen haben.`);
+ else { setzen.push("ueber_mich = ?"); werte.push(t || null); }
+ }
+
+ for (const [art, k] of Object.entries(KANAELE)) {
+ if (req.body[art] === undefined) continue;
+ /* Ein führendes @ und eine ganze Adresse sind das, was Leute
+ tatsächlich einfügen -- beides wird angenommen und auf den
+ Namen zurückgeführt, statt es abzulehnen. Eine Fehlermeldung
+ für ein vorangestelltes @ wäre schlechte Arbeit. */
+ let h = String(req.body[art] ?? "").trim();
+ h = h
+ .replace(/^https?:\/\/[^/]+\//i, "") // ganze Adresse -> Rest
+ .replace(/^@/, "") // führendes @
+ .replace(/[?#].*$/, "") // Parameter und Anker
+ .replace(/\/.*$/, ""); // alles ab dem nächsten /
+ /* Die Reihenfolge ist wichtig, und das ? kam erst nach einem
+ Fehlschlag dazu: Was die TikTok-App beim Teilen kopiert, ist
+ "https://www.tiktok.com/@name?lang=de&is_from_webapp=1" -- also
+ genau das, was jemand einfügt. Ohne diese Zeile wurde daraus
+ "name?lang=de", das Muster passte nicht, und der Nutzer bekam
+ "sieht nicht richtig aus" zu lesen, obwohl er den offiziellen
+ Link benutzt hat. */
+ if (!h) { setzen.push(`${art} = ?`); werte.push(null); continue; }
+ if (h.length > NAME_MAX || !k.muster.test(h)) {
+ fehler.push(`Der ${k.name}-Name sieht nicht richtig aus.`);
+ continue;
+ }
+ setzen.push(`${art} = ?`);
+ werte.push(h);
+ }
+
+ if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
+ if (!setzen.length) return res.status(400).json({ fehler: "Nichts zu ändern." });
+
+ werte.push(req.person.id);
+ db().prepare(`UPDATE personen SET ${setzen.join(", ")} WHERE id = ?`).run(...werte);
+ protokolliere("steckbrief_geaendert", req.person, echteIp(req), `${setzen.length} Feld(er)`);
+
+ const z = db().prepare(`SELECT ${SPALTEN} FROM personen p WHERE p.id = ?`).get(req.person.id);
+ res.json({ steckbrief: hinaus(z) });
+ } catch (fehler) {
+ console.error("[workspace] Steckbrief ändern:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* ---------- Das Bild ----------------------------------------------------- */
+
+/** Erkennt den Bildtyp an der SIGNATUR der Datei. Der Content-Type, den
+ * der Browser mitschickt, ist nur eine Behauptung -- ein umbenanntes
+ * SVG mit Skript darin käme darüber durch. */
+function bildart(puffer) {
+ if (puffer.length < 12) return null;
+ if (puffer[0] === 0xff && puffer[1] === 0xd8 && puffer[2] === 0xff) return { typ: "image/jpeg", endung: "jpg" };
+ if (puffer[0] === 0x89 && puffer[1] === 0x50 && puffer[2] === 0x4e && puffer[3] === 0x47) {
+ return { typ: "image/png", endung: "png" };
+ }
+ if (puffer.toString("ascii", 0, 4) === "RIFF" && puffer.toString("ascii", 8, 12) === "WEBP") {
+ return { typ: "image/webp", endung: "webp" };
+ }
+ return null;
+}
+
+/* Ein zu grosses Bild ist kein Serverfehler, sondern eine ganz normale
+ Eingabe -- und der Absender soll erfahren, WAS zu tun ist.
+
+ Ohne diese Stelle wirft express.raw() bei Überschreitung einen
+ PayloadTooLargeError, der bis in die allgemeine Fehlerbehandlung
+ durchläuft: Der Nutzer bekäme "500 Serverfehler" zu lesen und wüsste
+ nicht, dass sein Bild einfach zu gross ist. Aufgefallen ist das erst,
+ als die Prüfung ein 5-MB-Bild geschickt hat -- von Hand probiert
+ hätte das vermutlich nie jemand. */
+function zuGross(fehler, req, res, next) {
+ if (fehler?.type === "entity.too.large") {
+ return res.status(413).json({
+ fehler: `Das Bild ist zu gross. Erlaubt sind ${Math.round(MAX_BYTES / 1024 / 1024)} MB.` });
+ }
+ return next(fehler);
+}
+
+steckbriefRouter.post("/workspace/api/steckbrief/bild", gleicheHerkunft,
+ express.raw({ type: "*/*", limit: MAX_BYTES }), zuGross, (req, res) => {
+ try {
+ const puffer = req.body;
+ if (!puffer || !puffer.length) return res.status(400).json({ fehler: "Keine Datei angekommen." });
+
+ const art = bildart(puffer);
+ if (!art) {
+ return res.status(400).json({
+ fehler: "Das ist kein Bild. Erlaubt sind JPG, PNG und WebP." });
+ }
+
+ mkdirSync(ORDNER, { recursive: true });
+ const datei = `${randomBytes(16).toString("hex")}.${art.endung}`;
+ writeFileSync(join(ORDNER, datei), puffer);
+
+ /* Das alte Bild wegräumen -- sonst sammelt sich bei jedem Wechsel
+ eine weitere Datei an, die niemand mehr findet. */
+ const vorher = db().prepare("SELECT bild FROM personen WHERE id = ?").get(req.person.id)?.bild;
+ db().prepare("UPDATE personen SET bild = ? WHERE id = ?").run(datei, req.person.id);
+ if (vorher && vorher !== datei) {
+ try { unlinkSync(join(ORDNER, vorher)); } catch { /* war schon weg */ }
+ }
+
+ protokolliere("profilbild", req.person, echteIp(req), `${Math.round(puffer.length / 1024)} KB`);
+ res.json({ bild: `/workspace/api/steckbrief/bild/${datei}` });
+ } catch (fehler) {
+ console.error("[workspace] Profilbild:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+steckbriefRouter.delete("/workspace/api/steckbrief/bild", gleicheHerkunft, (req, res) => {
+ try {
+ const vorher = db().prepare("SELECT bild FROM personen WHERE id = ?").get(req.person.id)?.bild;
+ db().prepare("UPDATE personen SET bild = NULL WHERE id = ?").run(req.person.id);
+ if (vorher) { try { unlinkSync(join(ORDNER, vorher)); } catch { /* war schon weg */ } }
+ res.json({ ok: true });
+ } catch (fehler) {
+ console.error("[workspace] Profilbild löschen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/** Ein Bild ausliefern. Der Name muss GENAU dem erzeugten Muster
+ * entsprechen -- damit kann kein Pfad verlassen werden, auch nicht
+ * über kodierte Zeichen. */
+steckbriefRouter.get("/workspace/api/steckbrief/bild/:datei", (req, res) => {
+ try {
+ const datei = String(req.params.datei);
+ if (!/^[a-f0-9]{32}\.(jpg|png|webp)$/.test(datei)) return res.status(404).end();
+ const pfad = join(ORDNER, datei);
+ if (!existsSync(pfad)) return res.status(404).end();
+
+ const typ = datei.endsWith(".jpg") ? "image/jpeg"
+ : datei.endsWith(".png") ? "image/png" : "image/webp";
+ res.setHeader("Content-Type", typ);
+ res.setHeader("X-Content-Type-Options", "nosniff");
+ /* Eigene, alles verbietende Inhaltsregel: Selbst wenn eine Datei
+ jemals durchkäme, die der Browser als etwas anderes liest, darf
+ sie nichts nachladen und nichts ausführen. */
+ res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
+ /* Der Dateiname ist zufällig und ändert sich bei jedem neuen Bild --
+ er darf deshalb lange zwischengespeichert werden. */
+ res.setHeader("Cache-Control", "private, max-age=604800");
+ createReadStream(pfad).pipe(res);
+ } catch (fehler) {
+ console.error("[workspace] Profilbild lesen:", fehler?.message);
+ res.status(404).end();
+ }
+});
diff --git a/server/workspace.js b/server/workspace.js
index 58a1cedd..089aa3e1 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -147,11 +147,38 @@ function umstellungen(d) {
Vier neue Spalten. ALTER TABLE ADD COLUMN laesst SQLite anstandslos
zu; alte Eintraege bleiben leer und stoeren nicht. */
+ /* ---- Der eigene Steckbrief (01.09.2026) ------------------------------
+
+ Jede Person -- Creator, Scout, Manager, DogFather -- bekommt ein
+ eigenes Profil: ein Bild, ein Satz ueber sich, und die Kanaele, auf
+ denen sie unterwegs ist.
+
+ Warum der TikTok-Name als eigene Spalte und nicht als freier Text:
+ Er wird zu einem Verweis, taucht in der Personenliste auf und soll
+ eindeutig sein. Gespeichert wird NUR der oeffentliche Name (das
+ Handle ohne @) -- kein Zugriffstoken, kein Passwort. Eine echte
+ Anmeldung bei TikTok braucht ein Entwicklerkonto mit Freischaltung
+ und laufender Pruefung durch TikTok; das waere eine eigene Baustelle
+ mit Kosten und Abhaengigkeit. Der Verweis leistet das, was hier
+ gebraucht wird: Man kommt mit einem Klick auf den Kanal, und in der
+ Uebersicht steht, wer wo unterwegs ist.
+
+ Das Bild liegt als Datei auf der Platte, in der Datenbank steht nur
+ der Dateiname. Bilder in eine SQLite-Datei zu legen macht jede
+ Sicherung um ein Vielfaches groesser -- und die Sicherung laeuft
+ jede Nacht. */
for (const [tabelle, spalte, typ] of [
["eintraege", "hook", "TEXT"], // die ersten Sekunden
["eintraege", "format", "TEXT"], // Talking Head, Tutorial, Story …
["eintraege", "saeule_id", "INTEGER"],// zu welcher Content-Saeule
["eintraege", "geplant", "TEXT"], // wann es raus soll
+
+ ["personen", "bild", "TEXT"], // Dateiname des Profilbildes
+ ["personen", "ueber_mich", "TEXT"], // ein Satz zur Person
+ ["personen", "tiktok", "TEXT"], // öffentlicher Name, ohne @
+ ["personen", "instagram", "TEXT"],
+ ["personen", "youtube", "TEXT"],
+ ["personen", "twitch", "TEXT"],
]) {
try {
const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name);
@@ -806,9 +833,21 @@ workspaceRouter.get("/workspace/api/ich", (req, res) => {
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. */
+ /* Das Profilbild kommt mit: Es steht in der Kopfleiste JEDER Seite und
+ in der Begrüßung. Es hier mitzuliefern spart auf jeder Seite eine
+ zweite Abfrage -- und verhindert, dass die Plakette erst als
+ Buchstabe erscheint und einen Wimpernschlag später zum Bild
+ umspringt. */
+ let bild = null;
+ try {
+ const z = db().prepare("SELECT bild FROM personen WHERE id = ?").get(person.id);
+ if (z?.bild) bild = `/workspace/api/steckbrief/bild/${z.bild}`;
+ } catch { /* ohne Bild ist die Anmeldung trotzdem gültig */ }
+
res.json({
id: person.id, name: person.name, rolle: person.rolle,
rolle_name: ROLLEN_NAME[person.rolle] ?? person.rolle,
+ bild,
});
});
diff --git a/workspace/assets/css/profil.css b/workspace/assets/css/profil.css
index 2abf7979..eb22847e 100644
--- a/workspace/assets/css/profil.css
+++ b/workspace/assets/css/profil.css
@@ -66,3 +66,105 @@
@media (max-width: 560px) {
.gruppe { padding: 16px 15px 18px; }
}
+
+/* ===================================================================
+ Der eigene Steckbrief
+
+ Steht ganz oben und gehoert JEDEM im Workspace -- nicht nur Creatorn.
+ Deshalb sieht er auch anders aus als die Creator-Akte darunter: eine
+ geschlossene Karte mit dem Bild links, nicht ein weiteres Formularfeld.
+ =================================================================== */
+
+.steckbrief {
+ position: relative; overflow: hidden;
+ margin-bottom: 28px;
+ padding: 22px;
+ border: 1px solid color-mix(in srgb, var(--ton, var(--akzent)) 22%, var(--rand));
+ border-radius: var(--radius);
+ background:
+ radial-gradient(90% 120% at 0% 0%,
+ color-mix(in srgb, var(--ton, var(--akzent)) 12%, transparent), transparent 62%),
+ var(--flaeche);
+}
+.steckbrief__kopf { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
+
+.steckbrief__bildfeld { display: grid; gap: 10px; justify-items: center; flex: none; }
+
+/* Rund, wie die Plakette in der Begruessung: Ein runder Koerper gehoert
+ in dieser Oberflaeche zu einer PERSON, ein eckiger zu einer Sache. */
+.steckbrief__bild {
+ display: grid; place-items: center;
+ width: 128px; height: 128px; border-radius: 50%;
+ overflow: hidden;
+ font-size: 3rem; font-weight: 700; line-height: 1;
+ color: #f2f7fd;
+ border: 1px solid color-mix(in srgb, var(--ton, var(--akzent)) 40%, transparent);
+ background:
+ radial-gradient(120% 120% at 30% 20%,
+ color-mix(in srgb, var(--ton, var(--akzent)) 30%, transparent), transparent 70%),
+ rgba(255, 255, 255, .05);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, .1),
+ 0 14px 34px -16px color-mix(in srgb, var(--ton, var(--akzent)) 70%, transparent);
+}
+.steckbrief__bild img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.steckbrief__bildknoepfe { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
+/* Das Dateifeld ist versteckt; die Beschriftung ist der Knopf. Sie muss
+ deshalb auch AUSSEHEN wie einer und den Zeiger aendern. */
+.steckbrief__bildknoepfe label { cursor: pointer; }
+
+.steckbrief__text { flex: 1 1 320px; min-width: 0; }
+.steckbrief__name {
+ margin: 4px 0 2px;
+ font-size: 1.5rem; letter-spacing: -.02em; color: #f2f7fd;
+}
+.steckbrief__rolle {
+ margin: 0 0 16px;
+ font-size: .78rem; letter-spacing: .12em; text-transform: uppercase;
+ color: color-mix(in srgb, var(--ton, var(--akzent)) 62%, #cfe0f0);
+}
+.steckbrief__schild { margin-top: 18px; }
+.steckbrief textarea { width: 100%; }
+
+/* ---------- Die Kanaele ---------------------------------------------------
+ Gespeichert wird NUR der oeffentliche Name -- kein Login, kein Token
+ (Begruendung im Kopf von server/workspace-steckbrief.js). Das @ steht
+ deshalb fest vor dem Feld und wird nicht mit eingegeben: So ist ohne
+ Erklaerung klar, was hier hingehoert. */
+.steckbrief__kanaele { display: grid; gap: 8px; margin-top: 8px; }
+.kanal {
+ display: grid;
+ grid-template-columns: 92px minmax(0, 1fr) auto;
+ align-items: center; gap: 10px;
+}
+.kanal__name { font-size: .84rem; color: var(--text-leise); }
+.kanal__feld {
+ display: flex; align-items: center;
+ border: 1px solid var(--rand); border-radius: var(--radius-klein);
+ background: rgba(0, 0, 0, .22);
+ overflow: hidden;
+}
+.kanal__feld:focus-within { border-color: color-mix(in srgb, var(--akzent) 50%, transparent); }
+.kanal__at { padding: 0 2px 0 11px; color: var(--text-still); }
+.kanal__feld input {
+ flex: 1 1 auto; min-width: 0;
+ border: 0; background: none; color: var(--text);
+ font: inherit; font-size: .88rem;
+ padding: 9px 11px 9px 3px;
+}
+.kanal__feld input:focus { outline: none; }
+.kanal__auf { font-size: .78rem; color: var(--akzent); white-space: nowrap; }
+
+.steckbrief__fuss {
+ display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
+ margin-top: 18px;
+}
+.steckbrief__stand { font-size: .82rem; color: var(--text-leise); }
+
+@media (max-width: 560px) {
+ .steckbrief { padding: 18px 15px; }
+ .steckbrief__kopf { gap: 16px; }
+ .steckbrief__bild { width: 96px; height: 96px; font-size: 2.2rem; }
+ .kanal { grid-template-columns: 1fr; gap: 4px; }
+ .kanal__auf { justify-self: start; }
+}
diff --git a/workspace/assets/js/kopf.js b/workspace/assets/js/kopf.js
index 903daf26..5a9a491b 100644
--- a/workspace/assets/js/kopf.js
+++ b/workspace/assets/js/kopf.js
@@ -25,10 +25,22 @@
ziel.textContent = '';
ziel.dataset.rolle = ich.rolle || '';
+ /* Das eigene Bild, wenn es eines gibt -- sonst der Anfangsbuchstabe.
+ Der Buchstabe bleibt als Unterlage LIEGEN und wird nicht ersetzt:
+ Laedt das Bild nicht (Verbindung weg, Datei geloescht), steht dort
+ weiterhin etwas Sinnvolles statt eines kaputten Bildsymbols. */
const zeichen = document.createElement('span');
zeichen.className = 'wer__zeichen';
zeichen.setAttribute('aria-hidden', 'true');
zeichen.textContent = (ich.name || '?').trim().charAt(0).toUpperCase();
+ if (ich.bild) {
+ const b = document.createElement('img');
+ b.className = 'wer__bild';
+ b.src = ich.bild;
+ b.alt = '';
+ b.addEventListener('error', () => b.remove());
+ zeichen.append(b);
+ }
const name = document.createElement('span');
name.className = 'wer__name';
diff --git a/workspace/assets/js/profil.js b/workspace/assets/js/profil.js
index f42c5bad..29bfc3e4 100644
--- a/workspace/assets/js/profil.js
+++ b/workspace/assets/js/profil.js
@@ -134,3 +134,195 @@
}
})();
})();
+
+/* ===================================================================
+ Der eigene Steckbrief.
+
+ Läuft unabhängig vom Creator-Formular darüber: Den Steckbrief hat
+ JEDER (auch Scouts, Manager und DogFather), die Creator-Akte nur ein
+ Creator. Deshalb ein eigener, abgeschlossener Block -- fällt einer
+ von beiden aus, steht der andere weiterhin.
+ =================================================================== */
+(() => {
+ 'use strict';
+
+ const $ = (id) => document.getElementById(id);
+ const block = $('steckbrief');
+ if (!block) return;
+
+ let stand = null;
+
+ async function hole(pfad, optionen = {}) {
+ const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
+ if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
+ return a;
+ }
+
+ const melde = (t) => { $('sb-fehler').textContent = t || ''; };
+ const sage = (t) => {
+ $('sb-stand').textContent = t || '';
+ if (t) setTimeout(() => { if ($('sb-stand').textContent === t) $('sb-stand').textContent = ''; }, 4000);
+ };
+
+ function bildZeigen(quelle, name) {
+ const feld = $('sb-bild');
+ feld.textContent = '';
+ if (quelle) {
+ const b = document.createElement('img');
+ b.src = quelle;
+ b.alt = '';
+ /* Fällt das Bild aus, erscheint der Buchstabe -- nicht das kaputte
+ Bildsymbol des Browsers. */
+ b.addEventListener('error', () => { b.remove(); feld.textContent = anfang(name); });
+ feld.append(b);
+ $('sb-bild-weg').hidden = false;
+ } else {
+ feld.textContent = anfang(name);
+ $('sb-bild-weg').hidden = true;
+ }
+ }
+ const anfang = (n) => (n || '?').trim().charAt(0).toUpperCase();
+
+ function kanaeleBauen(kanaele, vorhanden) {
+ const ziel = $('sb-kanaele');
+ ziel.textContent = '';
+ for (const k of kanaele) {
+ const zeile = document.createElement('div');
+ zeile.className = 'kanal';
+ zeile.dataset.art = k.art;
+
+ const schild = document.createElement('label');
+ schild.className = 'kanal__name';
+ schild.setAttribute('for', 'sb-' + k.art);
+ schild.textContent = k.name;
+
+ const feld = document.createElement('div');
+ feld.className = 'kanal__feld';
+ const at = document.createElement('span');
+ at.className = 'kanal__at';
+ at.setAttribute('aria-hidden', 'true');
+ at.textContent = '@';
+ const eingabe = document.createElement('input');
+ eingabe.id = 'sb-' + k.art;
+ eingabe.type = 'text';
+ eingabe.maxLength = 40;
+ eingabe.autocomplete = 'off';
+ eingabe.placeholder = 'dein Name';
+ const da = vorhanden.find((v) => v.art === k.art);
+ eingabe.value = da ? da.handle : '';
+ feld.append(at, eingabe);
+
+ zeile.append(schild, feld);
+
+ /* Ein Verweis erscheint erst, wenn wirklich ein Name da ist -- ein
+ Knopf, der ins Leere führt, ist schlimmer als keiner. */
+ if (da) {
+ const a = document.createElement('a');
+ a.className = 'kanal__auf';
+ a.href = da.adresse;
+ a.target = '_blank';
+ a.rel = 'noopener noreferrer';
+ a.textContent = 'öffnen';
+ zeile.append(a);
+ }
+ ziel.append(zeile);
+ }
+ }
+
+ function zeichne(sb, kanaele) {
+ stand = sb;
+ $('sb-name').textContent = sb.name;
+ $('sb-rolle').textContent = sb.rolle_name;
+ $('sb-rolle').dataset.rolle = sb.rolle;
+ $('sb-ueber').value = sb.ueber_mich || '';
+ bildZeigen(sb.bild, sb.name);
+ kanaeleBauen(kanaele, sb.kanaele);
+ block.hidden = false;
+ }
+
+ /* ---------- Bild hochladen ---------------------------------------------
+ Roh im Rumpf statt als multipart -- dasselbe Verfahren wie in der
+ Dateiablage. Kein Parsen von multipart im Server, keine zusätzliche
+ Abhängigkeit. */
+ $('sb-datei').addEventListener('change', async (e) => {
+ const datei = e.target.files?.[0];
+ if (!datei) return;
+ melde('');
+ if (datei.size > 4 * 1024 * 1024) {
+ melde('Das Bild ist größer als 4 MB. Bitte ein kleineres nehmen.');
+ e.target.value = '';
+ return;
+ }
+ sage('Bild wird hochgeladen …');
+ try {
+ const a = await hole('/workspace/api/steckbrief/bild', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/octet-stream' },
+ body: datei,
+ });
+ const antwort = await a.json().catch(() => ({}));
+ if (!a.ok) { melde(antwort.fehler || 'Das Hochladen ging nicht.'); sage(''); return; }
+ bildZeigen(antwort.bild, stand?.name);
+ sage('Bild gespeichert.');
+ /* Auch oben rechts in der Kopfleiste soll sofort das neue Bild
+ stehen -- sonst müsste man die Seite neu laden, um es zu sehen. */
+ const oben = document.querySelector('.wer__zeichen');
+ if (oben) {
+ oben.querySelector('.wer__bild')?.remove();
+ const b = document.createElement('img');
+ b.className = 'wer__bild';
+ b.src = antwort.bild;
+ b.alt = '';
+ oben.append(b);
+ }
+ } catch { /* umgeleitet */ } finally { e.target.value = ''; }
+ });
+
+ $('sb-bild-weg').addEventListener('click', async () => {
+ if (!confirm('Profilbild entfernen?')) return;
+ try {
+ const a = await hole('/workspace/api/steckbrief/bild', { method: 'DELETE' });
+ if (!a.ok) { melde('Entfernen ging nicht.'); return; }
+ bildZeigen(null, stand?.name);
+ document.querySelector('.wer__bild')?.remove();
+ sage('Bild entfernt.');
+ } catch { /* umgeleitet */ }
+ });
+
+ /* ---------- Speichern ---------------------------------------------------- */
+
+ $('sb-speichern').addEventListener('click', async () => {
+ melde('');
+ const knopf = $('sb-speichern');
+ const koerper = { ueber_mich: $('sb-ueber').value };
+ for (const zeile of document.querySelectorAll('#sb-kanaele .kanal')) {
+ koerper[zeile.dataset.art] = zeile.querySelector('input').value;
+ }
+ knopf.disabled = true;
+ try {
+ const a = await hole('/workspace/api/steckbrief/mein', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(koerper),
+ });
+ const antwort = await a.json().catch(() => ({}));
+ if (!a.ok) { melde(antwort.fehler || 'Speichern ging nicht.'); return; }
+ /* Neu zeichnen, damit die "öffnen"-Verweise zu den frisch
+ eingetragenen Namen sofort erscheinen. */
+ zeichne(antwort.steckbrief, KANAELE);
+ sage('Profil gespeichert.');
+ } catch { /* umgeleitet */ } finally { knopf.disabled = false; }
+ });
+
+ let KANAELE = [];
+
+ (async () => {
+ try {
+ const a = await hole('/workspace/api/steckbrief/mein');
+ if (!a.ok) return;
+ const { steckbrief, kanaele } = await a.json();
+ KANAELE = kanaele;
+ zeichne(steckbrief, kanaele);
+ } catch { /* umgeleitet oder nicht verfügbar -- die Seite bleibt nutzbar */ }
+ })();
+})();
diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html
index b11dbd85..afd61e10 100644
--- a/workspace/aufgaben.html
+++ b/workspace/aufgaben.html
@@ -7,9 +7,9 @@
-
-
-
+
+
+