From 18639de047689909a3a3319ce35c8e8faaf424e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20He=C3=9Fe?= Date: Sat, 15 Aug 2026 09:33:12 +0200 Subject: [PATCH 1/2] feat: add optional custom section links --- README.md | 9 ++++++- server.ts | 35 +++++++++++++++++++++++++-- src/components/AdminPanel.tsx | 41 ++++++++++++++++++++++++++++++++ src/components/PortfolioPage.tsx | 36 +++++++++++++++++++++++++++- src/types.ts | 6 +++++ tests/server.test.mjs | 26 ++++++++++++++++++++ 6 files changed, 149 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d2ea015..48baea7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Exp ## Funktionsumfang - Mehrere unabhängig konfigurierte Autorenprofile mit eigenen Domains und Themes -- Biografie, Schlagworte, individuelle Texte und optionale Zusatzsektion +- Biografie, Schlagworte, individuelle Texte und optionale Zusatzsektion mit bis zu drei CTA-Buttons - Bücherregal mit Detailansicht, Cover, Kauflink und optionaler Spotify-Playlist - Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist - Lokaler Bild-Upload über den Adminbereich @@ -27,6 +27,7 @@ Im Produktionsmodus gilt der Hostname als Trennlinie: - Pfadbasierte Profilvorschauen stehen nur in der lokalen Entwicklungsumgebung zur Verfügung. - Vollständige Profildaten sind ausschließlich über einen authentifizierten Admin-Endpunkt verfügbar. - Gemeinsam gepflegte Rechtstexte werden bei der öffentlichen Ausgabe von strukturiert bekannten Kennungen anderer Profile bereinigt. Die gespeicherten Originaltexte bleiben unverändert. +- Querverlinkungen entstehen nur durch ausdrücklich gepflegte Buttons oder Inhalte des jeweiligen Profils; es gibt keine automatische Verbindung zu anderen Profilen. Der Reverse Proxy sollte zusätzlich nur die tatsächlich verwendeten Domains an den Container weiterleiten. @@ -149,6 +150,12 @@ Der Server: Die Bilder können für Avatare, Banner, Buchcover und aktuelle Projekte verwendet werden. Leere Bildfelder erzeugen keinen Request zu einem externen Standardbild. +## Optionale Buttons im Zusatzabschnitt + +Im zusätzlichen Textabschnitt eines Profils können bis zu drei CTA-Buttons gepflegt werden. Jeder Button benötigt eine Beschriftung und eine vollständige `http://`- oder `https://`-Adresse. Unvollständige Einträge werden nicht angezeigt, andere URL-Schemata werden serverseitig abgelehnt. + +Die Buttons öffnen das Ziel in einem neuen Tab und werden automatisch mit den Akzentfarben des Profils gestaltet. Auf kleinen Bildschirmen stehen sie untereinander, auf größeren Bildschirmen nebeneinander. Damit können ausgewählte Pseudonyme bewusst miteinander verknüpft werden, ohne dass daraus eine automatische Verlinkung zu weiteren Profilen entsteht. + ## Spotify und externe Dienste Spotify-Playlists werden sowohl in Buch- als auch in Projektdetails nach dem Zwei-Klick-Prinzip eingebunden. Beim Öffnen eines Details erscheint zunächst nur ein lokaler Platzhalter. Erst nach einem bewussten Klick auf „Spotify-Player laden“ wird das Spotify-`iframe` erzeugt und eine Verbindung zu Spotify hergestellt. diff --git a/server.ts b/server.ts index e232c67..0a31d85 100644 --- a/server.ts +++ b/server.ts @@ -5,7 +5,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "crypto"; import dotenv from "dotenv"; import { GoogleGenAI } from "@google/genai"; import { defaultAuthorData } from "./src/defaultData.js"; -import { AuthorData, AuthorProfile, PortfolioTheme, PublicAuthorData } from "./src/types.js"; +import { AuthorData, AuthorProfile, CustomSectionLink, PortfolioTheme, PublicAuthorData } from "./src/types.js"; dotenv.config(); @@ -486,6 +486,30 @@ function isolateLegalContent(content: string, activeKey: ProfileKey): string { return isolated; } +function normalizeCustomSectionLinks(value: unknown): CustomSectionLink[] | null { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length > 3) return null; + + const links: CustomSectionLink[] = []; + for (const candidate of value) { + if (!candidate || typeof candidate !== "object") return null; + const { label, url } = candidate as Record; + if (typeof label !== "string" || typeof url !== "string" || label.length > 80 || url.length > 2048) return null; + const trimmedLabel = label.trim(); + const trimmedUrl = url.trim(); + if (!trimmedLabel && !trimmedUrl) continue; + if (!trimmedLabel || !trimmedUrl) continue; + try { + const parsedUrl = new URL(trimmedUrl); + if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") return null; + links.push({ label: trimmedLabel, url: parsedUrl.toString() }); + } catch { + return null; + } + } + return links; +} + function seoMeta(req: express.Request, scriptNonce?: string): string { const key = profileForRequest(req.hostname, req.path); const profile = dbCache[key]; @@ -530,6 +554,7 @@ function seoMeta(req: express.Request, scriptNonce?: string): string { app.get("/api/author-data", (req, res) => { const key = profileForRequest(req.hostname, req.path); const { customDomain: _customDomain, customPath: _customPath, ...publicProfile } = dbCache[key]; + publicProfile.customSectionLinks = normalizeCustomSectionLinks(publicProfile.customSectionLinks) || []; const legalDocuments = (dbCache.legalDocuments || []) .filter((document) => document.assignedProfiles.includes(key)) .map((document) => ({ @@ -612,9 +637,15 @@ app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." }); return; } + const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks); + if (customSectionLinks === null) { + res.status(400).json({ error: "Die optionalen Buttons enthalten eine ungültige URL oder überschreiten das Limit von drei Einträgen." }); + return; + } + const sanitizedProfileData = { ...profileData, customSectionLinks }; const nextData = await updateDatabase((current) => ({ ...current, - [profileKey]: profileData, + [profileKey]: sanitizedProfileData, revision: (current.revision ?? 0) + 1, })); res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." }); diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index ba4ea4b..78bfe4e 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -165,6 +165,16 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { }); }; + const updateCustomSectionLink = (index: number, field: "label" | "url", value: string) => { + if (!profile) return; + const links = Array.from({ length: 3 }, (_, linkIndex) => ({ + label: profile.customSectionLinks?.[linkIndex]?.label || "", + url: profile.customSectionLinks?.[linkIndex]?.url || "", + })); + links[index] = { ...links[index], [field]: value }; + updateProfileField("customSectionLinks", links); + }; + // --- PROJECT ACTIONS --- const handleSaveProject = async () => { if (!authorData || !profile) return; @@ -866,6 +876,37 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white outline-none focus:border-indigo-500 text-sm font-sans" /> +
+
+ +

Ein Button erscheint nur, wenn Beschriftung und eine vollständige HTTP(S)-Adresse eingetragen sind. Damit freigegebene Ziele werden öffentlich verlinkt.

+
+ {[0, 1, 2].map((index) => ( +
+
+ + updateCustomSectionLink(index, "label", event.target.value)} + placeholder="z.B. Zur anderen Autorin" + className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-white outline-none focus:border-indigo-500 text-sm" + /> +
+
+ + updateCustomSectionLink(index, "url", event.target.value)} + placeholder="https://www.beispiel.de" + className="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-white outline-none focus:border-indigo-500 text-sm font-mono" + /> +
+
+ ))} +
diff --git a/src/components/PortfolioPage.tsx b/src/components/PortfolioPage.tsx index 90b366b..f561441 100644 --- a/src/components/PortfolioPage.tsx +++ b/src/components/PortfolioPage.tsx @@ -13,6 +13,15 @@ interface PortfolioPageProps { theme: PortfolioTheme; } +function safeExternalUrl(value: string): string | null { + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null; + } catch { + return null; + } +} + const themeDefaults: Record = { cosmic: { accent: "#06b6d4", secondary: "#a855f7", background: "#020617", card: "#0f172a", badge: "Geschichten & Welten", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" }, velvet: { accent: "#f97316", secondary: "#f59e0b", background: "#0a0a0a", card: "#171717", badge: "Literatur & Leidenschaft", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" }, @@ -35,6 +44,10 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port const cardBgColor = data.cardBgColor || defaults.card; const fontClass = data.fontFamily === "serif" ? "font-serif" : data.fontFamily === "mono" ? "font-mono" : "font-sans"; + const customSectionLinks = (data.customSectionLinks || []).slice(0, 3).flatMap((link) => { + const url = safeExternalUrl(link.url); + return link.label.trim() && url ? [{ label: link.label.trim(), url }] : []; + }); return (
{/* Section 4: Custom Highlight Section if present */} - {(data.customSectionTitle || data.customSectionContent) && ( + {(data.customSectionTitle || data.customSectionContent || customSectionLinks.length > 0) && (
CUSTOM_INFO_MODULE @@ -317,6 +330,27 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
)} + {customSectionLinks.length > 0 && ( +
+ {customSectionLinks.map((link) => ( + + {link.label} + + ))} +
+ )}
)} diff --git a/src/types.ts b/src/types.ts index dcdf3ad..076fdb2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,11 @@ export interface Book { spotlightBadge?: string; // Optional custom badge text (e.g. "Neuerscheinung", "Vorbestellbar") } +export interface CustomSectionLink { + label: string; + url: string; +} + export interface AuthorProfile { name: string; bio: string; @@ -32,6 +37,7 @@ export interface AuthorProfile { books: Book[]; customSectionTitle?: string; customSectionContent?: string; + customSectionLinks?: CustomSectionLink[]; // Domain & Path Routing customDomain?: string; // e.g., "annieslone.de, annie-slone.de" diff --git a/tests/server.test.mjs b/tests/server.test.mjs index d5e7101..590442b 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -116,6 +116,29 @@ test("production hardening and public routing", async (t) => { response = await fetch(`${baseUrl}/api/admin/author-data`); assert.equal(response.status, 401); + response = await fetch(`${baseUrl}/api/admin/author-data`, { headers: { cookie } }); + assert.equal(response.status, 200); + const adminData = await response.json(); + adminData.erotica.customSectionLinks = [ + { label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" }, + ]; + response = await fetch(`${baseUrl}/api/admin/save-profile`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseUrl, cookie }, + body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica }), + }); + assert.equal(response.status, 200); + + response = await fetch(`${baseUrl}/api/admin/save-profile`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseUrl, cookie }, + body: JSON.stringify({ + profileKey: "erotica", + profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] }, + }), + }); + assert.equal(response.status, 400); + const publicResponse = await getWithHost(`${baseUrl}/api/author-data?path=/clara`, "annieslone.de"); assert.equal(publicResponse.status, 200); const publicData = JSON.parse(publicResponse.body); @@ -124,6 +147,9 @@ test("production hardening and public routing", async (t) => { assert.equal(publicData.theme, "velvet"); assert.equal(publicData.profile.customDomain, undefined); assert.equal(publicData.profile.customPath, undefined); + assert.deepEqual(publicData.profile.customSectionLinks, [ + { label: "Zum befreundeten Pseudonym", url: "https://example-pseudonym.test/" }, + ]); assert.equal(publicResponse.body.includes("Clara Finch"), false); assert.equal(publicResponse.body.includes("Renee Heart"), false); assert.equal(publicResponse.body.includes("Daniel Hesse"), false); -- 2.45.2 From 2f58d6cb67f9f1f3a8cc60271103c9b170d10592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20He=C3=9Fe?= Date: Sat, 15 Aug 2026 09:49:03 +0200 Subject: [PATCH 2/2] refactor: harden admin editing workflows --- README.md | 5 + server.ts | 144 +++++++++++++++-- src/api/adminApi.ts | 41 +++++ src/components/AccessibleModal.tsx | 72 +++++++++ src/components/AdminPanel.tsx | 251 ++++++++++------------------- src/components/ImagePicker.tsx | 78 +++++---- src/components/PortfolioPage.tsx | 33 +--- tests/server.test.mjs | 42 ++++- 8 files changed, 419 insertions(+), 247 deletions(-) create mode 100644 src/api/adminApi.ts create mode 100644 src/components/AccessibleModal.tsx diff --git a/README.md b/README.md index 48baea7..432f782 100644 --- a/README.md +++ b/README.md @@ -115,10 +115,12 @@ Das Admin-Passwort wird nur beim Login übertragen und weder als Bearer-Token zu - Sitzungsdauer: 24 Stunden - Cookie: `HttpOnly`, `SameSite=Strict`, in Produktion zusätzlich `Secure` - Sitzungen liegen nur im Arbeitsspeicher und enden spätestens bei einem Server-/Container-Neustart +- Abgelaufene Sitzungen führen im Adminbereich kontrolliert zurück zur Anmeldung - Schreibende Admin-Anfragen werden auf gleiche Herkunft geprüft - Fehlgeschlagene Logins werden pro Client-IP begrenzt - Uploads und Gemini-Aufrufe besitzen zusätzliche Sitzungslimits - Sicherheitsheader und eine Content Security Policy werden serverseitig gesetzt +- Profil- und Rechtstextänderungen verwenden eine Revision; parallele Änderungen werden mit einem Konflikthinweis abgelehnt statt überschrieben Ein vergessenes Admin-Passwort wird über die Betriebsumgebung geändert; es gibt keine öffentliche Passwort-zurücksetzen-Funktion. @@ -147,6 +149,7 @@ Der Server: - erzeugt zufällige, nicht überschreibbare Dateinamen, - begrenzt Uploadversuche pro Admin-Sitzung und Stunde, - liefert Uploads mit `nosniff` und langfristigen Cache-Headern aus. +- erlaubt das Löschen unbenutzter Bilder in der Server-Mediathek, schützt aber Bilder, die noch in einem Profil referenziert werden. Die Bilder können für Avatare, Banner, Buchcover und aktuelle Projekte verwendet werden. Leere Bildfelder erzeugen keinen Request zu einem externen Standardbild. @@ -193,7 +196,9 @@ Die Integrationstests verwenden ein temporäres Datenverzeichnis und einen kurzl - Origin-Schutz und Login-Limit, - exaktes Domain-Routing und öffentliche Profilisolation, - Schutz des vollständigen Admin-Datenendpunkts, +- Revisionskonflikte und serverseitige Inhaltsvalidierung, - gültige und manipulierte Bild-Uploads, +- Löschung unbenutzter sowie Schutz referenzierter Uploads, - wesentliche Sicherheitsheader. ## Hinweise zur Aktualisierung diff --git a/server.ts b/server.ts index 0a31d85..b6f30e7 100644 --- a/server.ts +++ b/server.ts @@ -54,6 +54,8 @@ interface RateRecord { resetAt: number; } +class RevisionConflictError extends Error {} + const sessions = new Map(); const loginFailures = new Map(); const geminiRequests = new Map(); @@ -510,6 +512,77 @@ function normalizeCustomSectionLinks(value: unknown): CustomSectionLink[] | null return links; } +function isSafeContentUrl(value: unknown, allowSpotifyId = false): boolean { + if (value === undefined || value === "") return true; + if (typeof value !== "string" || value.length > 2048) return false; + if (allowSpotifyId && /^[a-zA-Z0-9]+$/.test(value)) return true; + if (value.startsWith("/uploads/")) return SAFE_UPLOAD_NAME.test(path.basename(value)); + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +} + +function validateProfile(profile: unknown): string | null { + if (!profile || typeof profile !== "object") return "Profildaten fehlen."; + const value = profile as AuthorProfile; + if (!Array.isArray(value.books) || value.books.length > 500) return "Die Bücherliste ist ungültig oder zu groß."; + if (!Array.isArray(value.projects) || value.projects.length > 200) return "Die Projektliste ist ungültig oder zu groß."; + const requiredStrings: Array<[unknown, string, number]> = [ + [value.name, "Name", 200], [value.bio, "Biografie", 50_000], + [value.heroTitle, "Hero-Titel", 500], [value.heroSubtitle, "Hero-Untertitel", 500], + ]; + for (const [field, label, maxLength] of requiredStrings) { + if (typeof field !== "string" || field.length > maxLength) return `${label} ist ungültig oder zu lang.`; + } + if (!isSafeContentUrl(value.avatarUrl) || !isSafeContentUrl(value.heroBannerUrl) || !isSafeContentUrl(value.socialImageUrl)) { + return "Mindestens eine Bild-URL ist ungültig."; + } + if (value.fontFamily && !["sans", "serif", "mono"].includes(value.fontFamily)) return "Die Schriftart ist ungültig."; + for (const project of value.projects) { + if (!project || typeof project.id !== "string" || typeof project.title !== "string" || project.title.length > 500 || + typeof project.description !== "string" || project.description.length > 100_000 || + !Number.isFinite(project.progress) || project.progress < 0 || project.progress > 100 || + !isSafeContentUrl(project.imageUrl) || !isSafeContentUrl(project.spotifyPlaylistId, true)) { + return "Mindestens ein Projekt enthält ungültige Werte."; + } + } + for (const book of value.books) { + if (!book || typeof book.id !== "string" || typeof book.title !== "string" || book.title.length > 500 || + typeof book.description !== "string" || book.description.length > 100_000 || + !isSafeContentUrl(book.coverUrl) || !isSafeContentUrl(book.buyLink) || !isSafeContentUrl(book.spotifyPlaylistId, true) || + (book.genres !== undefined && (!Array.isArray(book.genres) || book.genres.length > 20 || book.genres.some((genre) => typeof genre !== "string" || genre.length > 100)))) { + return "Mindestens ein Buch enthält ungültige Werte."; + } + } + return null; +} + +function validateLegalDocuments(value: unknown): value is AuthorData["legalDocuments"] { + return Array.isArray(value) && value.length <= 100 && value.every((document) => + document && typeof document.id === "string" && document.id.length <= 200 && + typeof document.title === "string" && document.title.length <= 500 && + ["impressum", "privacy", "custom"].includes(document.type) && + typeof document.content === "string" && document.content.length <= 200_000 && + Array.isArray(document.assignedProfiles) && document.assignedProfiles.length <= profileKeys.length && + document.assignedProfiles.every((key: unknown) => profileKeys.includes(key as ProfileKey)) + ); +} + +function hasRevisionConflict(expectedRevision: unknown): boolean { + return !Number.isInteger(expectedRevision) || expectedRevision !== (dbCache.revision ?? 0); +} + +function isUploadReferenced(url: string): boolean { + return profileKeys.some((key) => { + const profile = dbCache[key]; + return profile.avatarUrl === url || profile.heroBannerUrl === url || profile.socialImageUrl === url || + profile.books.some((book) => book.coverUrl === url) || profile.projects.some((project) => project.imageUrl === url); + }); +} + function seoMeta(req: express.Request, scriptNonce?: string): string { const key = profileForRequest(req.hostname, req.path); const profile = dbCache[key]; @@ -626,15 +699,20 @@ app.post("/api/admin/logout", verifySameOrigin, (req, res) => { // 3. Save modified profile configurations (About, Projects, Books) app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => { - const { profileKey, profileData } = req.body; + const { profileKey, profileData, expectedRevision } = req.body; if (profileKey !== "scifi" && profileKey !== "erotica" && profileKey !== "clara" && profileKey !== "renee") { res.status(400).json({ error: "Ungültiger Profilschlüssel." }); return; } try { - if (!profileData || typeof profileData !== "object" || !Array.isArray(profileData.books) || !Array.isArray(profileData.projects)) { - res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." }); + if (hasRevisionConflict(expectedRevision)) { + res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 }); + return; + } + const validationError = validateProfile(profileData); + if (validationError) { + res.status(400).json({ error: validationError }); return; } const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks); @@ -643,13 +721,16 @@ app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, return; } const sanitizedProfileData = { ...profileData, customSectionLinks }; - const nextData = await updateDatabase((current) => ({ - ...current, - [profileKey]: sanitizedProfileData, - revision: (current.revision ?? 0) + 1, - })); + const nextData = await updateDatabase((current) => { + if (expectedRevision !== (current.revision ?? 0)) throw new RevisionConflictError(); + return { ...current, [profileKey]: sanitizedProfileData, revision: (current.revision ?? 0) + 1 }; + }); res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." }); } catch (err: any) { + if (err instanceof RevisionConflictError) { + res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 }); + return; + } console.error("Failed to write to database.json:", err); res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." }); } @@ -657,20 +738,27 @@ app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, // 3b. Save legal documents (Impressum & Datenschutzerklärung) app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => { - const { legalDocuments } = req.body; - if (!Array.isArray(legalDocuments)) { - res.status(400).json({ error: "legalDocuments muss ein Array sein." }); + const { legalDocuments, expectedRevision } = req.body; + if (hasRevisionConflict(expectedRevision)) { + res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 }); + return; + } + if (!validateLegalDocuments(legalDocuments)) { + res.status(400).json({ error: "Die rechtlichen Dokumente besitzen nicht die erwartete Struktur oder überschreiten zulässige Längen." }); return; } try { - const nextData = await updateDatabase((current) => ({ - ...current, - legalDocuments, - revision: (current.revision ?? 0) + 1, - })); + const nextData = await updateDatabase((current) => { + if (expectedRevision !== (current.revision ?? 0)) throw new RevisionConflictError(); + return { ...current, legalDocuments, revision: (current.revision ?? 0) + 1 }; + }); res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." }); } catch (err: any) { + if (err instanceof RevisionConflictError) { + res.status(409).json({ error: "Die Daten wurden zwischenzeitlich geändert. Bitte neu laden und die Änderung erneut vornehmen.", revision: dbCache.revision ?? 0 }); + return; + } console.error("Failed to write legal documents to database.json:", err); res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." }); } @@ -798,6 +886,30 @@ app.get("/api/admin/list-uploads", verifySession, async (req, res) => { } }); +app.delete("/api/admin/uploads/:name", verifySession, verifySameOrigin, async (req, res) => { + const name = req.params.name; + if (!SAFE_UPLOAD_NAME.test(name) || path.basename(name) !== name) { + res.status(400).json({ error: "Ungültiger Dateiname." }); + return; + } + const url = `/uploads/${name}`; + if (isUploadReferenced(url)) { + res.status(409).json({ error: "Das Bild wird noch in mindestens einem Profil verwendet und kann nicht gelöscht werden." }); + return; + } + try { + await fs.unlink(path.join(DATA_DIR, "uploads", name)); + res.json({ success: true }); + } catch (error: any) { + if (error?.code === "ENOENT") { + res.status(404).json({ error: "Die Datei wurde nicht gefunden." }); + return; + } + console.error("Failed to delete upload:", error); + res.status(500).json({ error: "Die Datei konnte nicht gelöscht werden." }); + } +}); + // Configure Vite middleware or static serve async function startServer() { // Serve the dynamic uploads directory statically diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts new file mode 100644 index 0000000..097d243 --- /dev/null +++ b/src/api/adminApi.ts @@ -0,0 +1,41 @@ +import type { AuthorData, AuthorProfile, LegalDocument } from "../types"; + +export class AdminApiError extends Error { + constructor(message: string, public readonly status: number) { + super(message); + } +} + +async function request(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + const payload = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) { + if (response.status === 401 && url !== "/api/admin/login") window.dispatchEvent(new Event("admin-session-expired")); + throw new AdminApiError(payload.error || `Anfrage fehlgeschlagen (HTTP ${response.status}).`, response.status); + } + return payload as T; +} + +const jsonPost = (body: unknown): RequestInit => ({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); + +export const adminApi = { + session: () => request<{ success: true }>("/api/admin/session"), + login: (password: string) => request<{ success: true }>("/api/admin/login", jsonPost({ password })), + logout: () => request<{ success: true }>("/api/admin/logout", { method: "POST" }), + loadData: () => request("/api/admin/author-data"), + saveProfile: (profileKey: "scifi" | "erotica" | "clara" | "renee", profileData: AuthorProfile, expectedRevision: number) => + request<{ success: true; revision: number }>("/api/admin/save-profile", jsonPost({ profileKey, profileData, expectedRevision })), + saveLegal: (legalDocuments: LegalDocument[], expectedRevision: number) => + request<{ success: true; revision: number }>("/api/admin/save-legal", jsonPost({ legalDocuments, expectedRevision })), + generateBlurb: (input: { title: string; genre: string; ideas: string; tone: string }) => + request<{ success: true; text: string }>("/api/admin/generate-blurb", jsonPost(input)), + listUploads: () => request<{ success: true; files: Array<{ name: string; url: string }> }>("/api/admin/list-uploads"), + upload: (fileName: string, base64Data: string) => + request<{ success: true; url: string }>("/api/admin/upload-file", jsonPost({ fileName, base64Data })), + deleteUpload: (name: string) => + request<{ success: true }>(`/api/admin/uploads/${encodeURIComponent(name)}`, { method: "DELETE" }), +}; diff --git a/src/components/AccessibleModal.tsx b/src/components/AccessibleModal.tsx new file mode 100644 index 0000000..57c3e47 --- /dev/null +++ b/src/components/AccessibleModal.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, type ReactNode } from "react"; + +interface AccessibleModalProps { + titleId: string; + onClose: () => void; + children: ReactNode; + maxWidthClass?: string; +} + +const focusableSelector = [ + "a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", + "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])", +].join(","); + +export default function AccessibleModal({ titleId, onClose, children, maxWidthClass = "sm:max-w-4xl" }: AccessibleModalProps) { + const dialogRef = useRef(null); + + useEffect(() => { + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const dialog = dialogRef.current; + const firstFocusable = dialog?.querySelector(focusableSelector); + (firstFocusable || dialog)?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== "Tab" || !dialog) return; + const focusable = Array.from(dialog.querySelectorAll(focusableSelector)); + if (focusable.length === 0) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = previousOverflow; + previousFocus?.focus(); + }; + }, [onClose]); + + return ( +
+
+ ); +} diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index 78bfe4e..b1e4340 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -9,6 +9,7 @@ import { } from "lucide-react"; import ImagePicker from "./ImagePicker"; import AdminLogin from "./admin/AdminLogin"; +import { adminApi, AdminApiError } from "../api/adminApi"; interface AdminPanelProps { onLogout: () => void; @@ -19,6 +20,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { const [isLoggedIn, setIsLoggedIn] = useState(false); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [notice, setNotice] = useState<{ message: string; tone: "success" | "error" } | null>(null); // Author Data State const [authorData, setAuthorData] = useState(null); @@ -57,11 +59,9 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { const restoreSession = async () => { setLoading(true); try { - const response = await fetch("/api/admin/session"); - if (response.ok) { - setIsLoggedIn(true); - await fetchAuthorData(); - } + await adminApi.session(); + setIsLoggedIn(true); + await fetchAuthorData(); } catch (err) { console.error("Admin session check failed:", err); } finally { @@ -71,11 +71,20 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { restoreSession(); }, []); + useEffect(() => { + const handleExpiredSession = () => { + setIsLoggedIn(false); + setAuthorData(null); + setError("Die Admin-Sitzung ist abgelaufen. Bitte erneut anmelden."); + }; + window.addEventListener("admin-session-expired", handleExpiredSession); + return () => window.removeEventListener("admin-session-expired", handleExpiredSession); + }, []); + const fetchAuthorData = async () => { setLoading(true); try { - const res = await fetch("/api/admin/author-data"); - const data = await res.json(); + const data = await adminApi.loadData(); setAuthorData(data); } catch (err) { console.error("Error fetching author data:", err); @@ -89,21 +98,12 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { setError(""); setLoading(true); try { - const res = await fetch("/api/admin/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), - }); - const data = await res.json(); - if (res.ok && data.success) { - setPassword(""); - setIsLoggedIn(true); - await fetchAuthorData(); - } else { - setError(data.error || "Ungültiges Passwort."); - } + await adminApi.login(password); + setPassword(""); + setIsLoggedIn(true); + await fetchAuthorData(); } catch (err) { - setError("Verbindung zum Server fehlgeschlagen."); + setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen."); } finally { setLoading(false); } @@ -111,7 +111,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { const handleLogoutLocal = async () => { try { - await fetch("/api/admin/logout", { method: "POST" }); + await adminApi.logout(); } catch (err) { console.error("Admin logout failed:", err); } @@ -120,6 +120,28 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { onLogout(); }; + const persistProfile = async (profileKey: "scifi" | "erotica" | "clara" | "renee", profileData: AuthorProfile) => { + if (!authorData) throw new Error("Keine Profildaten geladen."); + const result = await adminApi.saveProfile(profileKey, profileData, authorData.revision ?? 0); + setAuthorData((current) => current ? { ...current, [profileKey]: profileData, revision: result.revision } : current); + return result; + }; + + const persistLegalDocuments = async (legalDocuments: LegalDocument[]) => { + if (!authorData) throw new Error("Keine Profildaten geladen."); + const result = await adminApi.saveLegal(legalDocuments, authorData.revision ?? 0); + setAuthorData((current) => current ? { ...current, legalDocuments, revision: result.revision } : current); + return result; + }; + + const showNotice = (message: string, tone: "success" | "error" = "error") => setNotice({ message, tone }); + + useEffect(() => { + if (!notice) return; + const timeout = window.setTimeout(() => setNotice(null), 5000); + return () => window.clearTimeout(timeout); + }, [notice]); + // Profile save handler const handleSaveProfileInfo = async (profileKey: "scifi" | "erotica" | "clara" | "renee") => { if (!authorData) return; @@ -127,26 +149,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { setLoading(true); try { - const res = await fetch("/api/admin/save-profile", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - profileKey, - profileData: authorData[profileKey] - }), - }); - - const resData = await res.json(); - if (res.ok && resData.success) { - alert("Profilinformationen erfolgreich gespeichert!"); - fetchAuthorData(); - } else { - setError(resData.error || "Profile konnte nicht gespeichert werden."); - } + await persistProfile(profileKey, authorData[profileKey]); + showNotice("Profilinformationen erfolgreich gespeichert!", "success"); } catch (err) { - setError("Fehler beim Senden der Profildaten."); + setError(err instanceof AdminApiError ? err.message : "Fehler beim Senden der Profildaten."); } finally { setLoading(false); } @@ -198,27 +204,14 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { } const updatedProfile = { ...profile, projects: updatedProjects }; - const nextData = { ...authorData, [activeProfile]: updatedProfile }; - // Save to server setLoading(true); try { - const res = await fetch("/api/admin/save-profile", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }), - }); - if (res.ok) { - setAuthorData(nextData); - setEditingProjectId(null); - setProjectForm({}); - } else { - alert("Fehler beim Speichern des Projekts."); - } + await persistProfile(activeProfile, updatedProfile); + setEditingProjectId(null); + setProjectForm({}); } catch (err) { - alert("Netzwerkfehler."); + showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler."); } finally { setLoading(false); } @@ -229,24 +222,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { const updatedProjects = profile.projects.filter(p => p.id !== projId); const updatedProfile = { ...profile, projects: updatedProjects }; - const nextData = { ...authorData, [activeProfile]: updatedProfile }; - setLoading(true); try { - const res = await fetch("/api/admin/save-profile", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }), - }); - if (res.ok) { - setAuthorData(nextData); - } else { - alert("Fehler beim Löschen."); - } + await persistProfile(activeProfile, updatedProfile); } catch (err) { - alert("Netzwerkfehler."); + showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler."); } finally { setLoading(false); } @@ -292,26 +272,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { } const updatedProfile = { ...profile, books: updatedBooks }; - const nextData = { ...authorData, [activeProfile]: updatedProfile }; - setLoading(true); try { - const res = await fetch("/api/admin/save-profile", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }), - }); - if (res.ok) { - setAuthorData(nextData); - setEditingBookId(null); - setBookForm({}); - } else { - alert("Fehler beim Speichern des Buches."); - } + await persistProfile(activeProfile, updatedProfile); + setEditingBookId(null); + setBookForm({}); } catch (err) { - alert("Netzwerkfehler."); + showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler."); } finally { setLoading(false); } @@ -322,24 +289,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { const updatedBooks = profile.books.filter(b => b.id !== bookId); const updatedProfile = { ...profile, books: updatedBooks }; - const nextData = { ...authorData, [activeProfile]: updatedProfile }; - setLoading(true); try { - const res = await fetch("/api/admin/save-profile", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ profileKey: activeProfile, profileData: updatedProfile }), - }); - if (res.ok) { - setAuthorData(nextData); - } else { - alert("Fehler beim Löschen des Buches."); - } + await persistProfile(activeProfile, updatedProfile); } catch (err) { - alert("Netzwerkfehler."); + showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler."); } finally { setLoading(false); } @@ -348,36 +302,22 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { // --- GEMINI SYNOPSIS GENERATOR --- const handleGenerateAISynopsis = async () => { if (!aiTitle.trim()) { - alert("Bitte geben Sie einen Buchtitel für den KI-Assistenten an."); + showNotice("Bitte geben Sie einen Buchtitel für den KI-Assistenten an."); return; } setAiLoading(true); setAiResult(""); try { - const res = await fetch("/api/admin/generate-blurb", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - title: aiTitle, - genre: aiGenre || activeProfile === "scifi" ? "Science-Fiction" : "Sinnliche Literatur", - ideas: aiIdeas, - tone: aiTone - }), + const data = await adminApi.generateBlurb({ + title: aiTitle, + genre: aiGenre || (activeProfile === "scifi" ? "Science-Fiction" : "Literatur"), + ideas: aiIdeas, + tone: aiTone, }); - const data = await res.json(); - if (res.ok && data.text) { - setAiResult(data.text); - setBookForm({ - ...bookForm, - description: data.text - }); - } else { - alert(data.error || "Generierung fehlgeschlagen."); - } - } catch { - alert("Fehler bei der Verbindung zur KI-Schnittstelle."); + setAiResult(data.text); + setBookForm({ ...bookForm, description: data.text }); + } catch (err) { + showNotice(err instanceof AdminApiError ? err.message : "Fehler bei der Verbindung zur KI-Schnittstelle."); } finally { setAiLoading(false); } @@ -418,22 +358,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { setLoading(true); try { - const res = await fetch("/api/admin/save-legal", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ legalDocuments: updatedDocs }) - }); - if (res.ok) { - setAuthorData({ ...authorData, legalDocuments: updatedDocs }); - setEditingLegalId(null); - setLegalForm({}); - } else { - alert("Fehler beim Speichern des Dokuments."); - } - } catch { - alert("Netzwerkfehler beim Speichern."); + await persistLegalDocuments(updatedDocs); + setEditingLegalId(null); + setLegalForm({}); + } catch (err) { + showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler beim Speichern."); } finally { setLoading(false); } @@ -444,24 +373,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { const updatedDocs = (authorData.legalDocuments || []).filter(d => d.id !== docId); setLoading(true); try { - const res = await fetch("/api/admin/save-legal", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ legalDocuments: updatedDocs }) - }); - if (res.ok) { - setAuthorData({ ...authorData, legalDocuments: updatedDocs }); - if (editingLegalId === docId) { - setEditingLegalId(null); - setLegalForm({}); - } - } else { - alert("Fehler beim Löschen des Dokuments."); + await persistLegalDocuments(updatedDocs); + if (editingLegalId === docId) { + setEditingLegalId(null); + setLegalForm({}); } - } catch { - alert("Netzwerkfehler beim Löschen."); + } catch (err) { + showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler beim Löschen."); } finally { setLoading(false); } @@ -510,6 +428,15 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { return (
+ {notice && ( +
+ {notice.message} +
+ )} {/* Top Navbar */}
-
+ )} {/* Styled Footer */} diff --git a/tests/server.test.mjs b/tests/server.test.mjs index 590442b..acb4028 100644 --- a/tests/server.test.mjs +++ b/tests/server.test.mjs @@ -125,9 +125,17 @@ test("production hardening and public routing", async (t) => { response = await fetch(`${baseUrl}/api/admin/save-profile`, { method: "POST", headers: { "content-type": "application/json", origin: baseUrl, cookie }, - body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica }), + body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: adminData.revision }), }); assert.equal(response.status, 200); + const firstSave = await response.json(); + + response = await fetch(`${baseUrl}/api/admin/save-profile`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseUrl, cookie }, + body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: adminData.revision }), + }); + assert.equal(response.status, 409); response = await fetch(`${baseUrl}/api/admin/save-profile`, { method: "POST", @@ -135,6 +143,7 @@ test("production hardening and public routing", async (t) => { body: JSON.stringify({ profileKey: "erotica", profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] }, + expectedRevision: firstSave.revision, }), }); assert.equal(response.status, 400); @@ -184,6 +193,37 @@ test("production hardening and public routing", async (t) => { assert.equal(response.headers.get("x-content-type-options"), "nosniff"); assert.match(response.headers.get("cache-control") || "", /immutable/); + adminData.erotica.avatarUrl = uploaded.url; + response = await fetch(`${baseUrl}/api/admin/save-profile`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseUrl, cookie }, + body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: firstSave.revision }), + }); + assert.equal(response.status, 200); + const referencedSave = await response.json(); + + response = await fetch(`${baseUrl}/api/admin/uploads/${path.basename(uploaded.url)}`, { + method: "DELETE", + headers: { origin: baseUrl, cookie }, + }); + assert.equal(response.status, 409); + + adminData.erotica.avatarUrl = ""; + response = await fetch(`${baseUrl}/api/admin/save-profile`, { + method: "POST", + headers: { "content-type": "application/json", origin: baseUrl, cookie }, + body: JSON.stringify({ profileKey: "erotica", profileData: adminData.erotica, expectedRevision: referencedSave.revision }), + }); + assert.equal(response.status, 200); + + response = await fetch(`${baseUrl}/api/admin/uploads/${path.basename(uploaded.url)}`, { + method: "DELETE", + headers: { origin: baseUrl, cookie }, + }); + assert.equal(response.status, 200); + response = await fetch(`${baseUrl}${uploaded.url}`); + assert.equal(response.status, 404); + response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } }); assert.equal(response.status, 200); response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } }); -- 2.45.2