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);