Filipe, 22.09.2026: "ersetze die zentrale durch, Die IrrenAnstalt.
genau so geschrieben bitte. und alles was treff heißt oder wo treff
steht soll durch Rudel ersetzt werden bitte."
Die Schreibweise "IrrenAnstalt" mit grossem A in der Mitte ist so
gewollt. Das steht als Hinweis daneben, damit sie beim naechsten Mal
niemand "korrigiert".
WAS UMBENANNT WURDE: alles, was jemand LIEST -- Kachelnamen,
Ueberschriften, Markenzeilen, Saetze, Meldungen, Aufgabenvorlagen.
116 Zeilen in 42 Dateien.
WAS BLEIBT: Adressen (treff-regeln.html), Bezeichner (TREFF_ROLLEN),
Datenbankwerte (bereich = 'treff'), Abfrageteile (b=treff). Eine
Adresse umzubenennen bricht jedes Lesezeichen, und ein Datenbankwert
umzuschreiben waere eine Umstellung ohne Gegenwert. Dieselbe
Entscheidung wie heute frueh bei Dogi-Media, wo material.html auch
material.html geblieben ist.
DREI GRAMMATIKFEHLER IM EIGENEN ENTWURF, alle vor dem Ausliefern
gefunden -- ein blindes Ersetzen reicht hier nicht:
1. "der Treff" ist maennlich, "das Rudel" saechlich. Ohne Tabelle
waere ueberall "Der Rudel" gestanden. (Und "Treffer" waere zu
"Rudeler" geworden, "Treffen" zu "Rudelen" -- deshalb greift die
Regel nur bei Wortgrenze und nie vor einem Kleinbuchstaben.)
2. BINDESTRICH-ZUSAMMENSETZUNGEN. In "der Treff-Chat" gehoert der
Artikel zu "Chat", nicht zu "Treff". Der erste Durchlauf machte
daraus "das Rudel-Chat", "das Rudel-Kacheln" und "ein Rudel-Raum".
Jetzt greift die Artikelregel nur, wenn das Wort allein steht.
3. GESCHUETZTE LEERZEICHEN. In den Markenzeilen steht
`Der Treff`, damit die zwei Woerter nicht umbrechen. Das
Muster hat daran vorbeigegriffen: "Der Rudel", auf fuenf
Seiten. Jetzt wird das Trennzeichen mitgefasst und unveraendert
wieder eingesetzt.
Gefunden wurden alle drei, weil jede einzelne der 116 Zeilen vor dem
Schreiben als ALT/NEU ausgegeben und gelesen wurde -- und danach
gezielt nach falschen Artikeln gesucht ("den Rudel", "der Rudel",
"einen Rudel"). Uebrig blieben sechs Treffer, und die sind alle
richtig: "einen Rudel-Raum", "der Rudel-Chat", "den Rudel-Regeln" --
Zusammensetzungen, bei denen der Artikel zum letzten Wort gehoert.
UND ZWEI PRUEFUNGEN, die die Umbenennung nicht mitbekommen haetten:
`/Treff/.test(...)` und `/Treff/i.test(...)`. Ein regulaerer Ausdruck
ist keine Zeichenkette -- sie haetten ab sofort nach einem Wort
gesucht, das die Seite nicht mehr sagt, und waeren still rot geworden,
ohne dass etwas kaputt ist.
Geprueft, alle gruen: pruef-treff, pruef-treffchat,
pruef-deutsche-texte, pruef-uebernahme, pruef-crew-adresse,
pruef-willkommen, pruef-wege-nach-draussen, pruef-vorlagen,
pruef-start-ansicht, pruef-struktur.
Co-Authored-By: Claude Opus 5 <[email protected]>
413 lines
19 KiB
JavaScript
413 lines
19 KiB
JavaScript
/* =====================================================================
|
|
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,
|
|
sichtbarePersonenIds,
|
|
} 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) {
|
|
/* NICHT mehr istLeitung (03.09.2026): Eine Managerin bekam damit die
|
|
Steckbriefe ALLER -- Bild, Kanaele, "ueber mich" -- auch von
|
|
Creators, die ihr nie zugeteilt waren. Jetzt gilt fuer sie dieselbe
|
|
Zuteilungsregel wie fuer einen Scout, nur mit der Kette ueber ihre
|
|
Scouts dazu (sichtbarePersonenIds). null bleibt "alle" und
|
|
bekommt allein DogFather. */
|
|
const erlaubt = sichtbarePersonenIds(person);
|
|
if (erlaubt === null) return null; // null = alle
|
|
const eigene = new Set(erlaubt);
|
|
eigene.add(person.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). */
|
|
/* WER GEHOERT IN WELCHEN ABSCHNITT (19.09.2026).
|
|
|
|
DIE ZUORDNUNG STEHT HIER UND NICHT IM BROWSER, aus zwei Gruenden.
|
|
|
|
Der erste ist ein Fehler, den sie behebt: In assets/js/steckbrief.js
|
|
stand eine Liste aus drei Gruppen -- Management, Scouting, Creator.
|
|
Wer eine andere Rolle hatte, passte in keine und verschwand
|
|
LAUTLOS aus der Uebersicht. Betroffen waren ausgerechnet die
|
|
rechte Hand und die Modis: Ein Modi, der die Seite oeffnete, sah
|
|
seine eigenen Leute nicht.
|
|
|
|
Der zweite ist die Hausregel: Rollennamen der Community gehoeren
|
|
nicht in eine Datei, die jeder Besucher herunterladen kann. Eine
|
|
Liste ['modi','hand','gast'] im Browser waere genau das.
|
|
|
|
OHNE ABSCHNITT GIBT ES EINEN SAMMELPLATZ. Kommt morgen eine Rolle
|
|
dazu und wird hier vergessen, steht sie unter „Weitere" -- sichtbar
|
|
und mit einem schiefen Namen. Das ist besser als lautlos weg. */
|
|
const ABSCHNITTE = {
|
|
admin: { name: "Leitung", text: "Wer freigibt und entscheidet." },
|
|
manager: { name: "Leitung", text: "Wer freigibt und entscheidet." },
|
|
hand: { name: "Team Dogi", text: "Wer den Laden mit am Laufen hält." },
|
|
modi: { name: "Moderation", text: "Wer im Rudel und im Stream nach dem Rechten sieht." },
|
|
scout: { name: "Scouting", text: "Sie finden neue Creator und begleiten den Anfang." },
|
|
creator: { name: "Creator", text: "Die Leute, um die es geht." },
|
|
gast: { name: "Community", text: "Die Leute, die zuschauen und mitmachen." },
|
|
spicy: { name: "Leitung", text: "Wer freigibt und entscheidet." },
|
|
};
|
|
const ABSCHNITT_SONST = { name: "Weitere", text: "" };
|
|
|
|
/** Welcher Abschnitt gehoert zu dieser Rolle?
|
|
*
|
|
* Ausgeleitet, damit die Pruefung die GEGENPROBE machen kann: Eine
|
|
* Rolle, die hier fehlt, muss sichtbar unter „Weitere" landen und darf
|
|
* nicht verschwinden. Ohne diesen Zugang waere „niemand faellt heraus"
|
|
* eine Behauptung ueber die fuenf Rollen, die es heute gibt. */
|
|
export function abschnittFuer(rolle) {
|
|
return ABSCHNITTE[rolle] || ABSCHNITT_SONST;
|
|
}
|
|
|
|
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) });
|
|
}
|
|
const abschnitt = ABSCHNITTE[z.rolle] || ABSCHNITT_SONST;
|
|
return {
|
|
id: z.id, name: z.name, rolle: z.rolle,
|
|
rolle_name: ROLLEN_NAME[z.rolle] ?? z.rolle,
|
|
abschnitt: abschnitt.name,
|
|
abschnitt_text: abschnitt.text,
|
|
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();
|
|
}
|
|
});
|