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 @@ - - - + + + @@ -139,9 +139,9 @@ - - - - + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 2a8054d2..b46f47e4 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -132,9 +132,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 0600de8e..85adf912 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -107,9 +107,9 @@ - - - - + + + + diff --git a/workspace/calls.html b/workspace/calls.html index d95b90aa..2d622247 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -54,10 +54,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 62879a45..7502a878 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -158,9 +158,9 @@ - - - - + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index eb6fea95..984ffa5d 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,9 +93,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index 85952639..9e84be68 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 3d7cc4d6..c194b8db 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -147,9 +147,9 @@ - - - - + + + + diff --git a/workspace/personen.html b/workspace/personen.html index b0a9a005..af55a31e 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -121,9 +121,9 @@ - - - - + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 110104fa..04499e72 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -48,6 +48,44 @@ + + +