Codex/admin quality hardening #17

Merged
Dada1981 merged 2 commits from codex/admin-quality-hardening into main 2026-08-15 07:59:58 +00:00
6 changed files with 149 additions and 4 deletions
Showing only changes of commit 18639de047 - Show all commits

View file

@ -7,7 +7,7 @@ Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Exp
## Funktionsumfang ## Funktionsumfang
- Mehrere unabhängig konfigurierte Autorenprofile mit eigenen Domains und Themes - 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 - Bücherregal mit Detailansicht, Cover, Kauflink und optionaler Spotify-Playlist
- Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist - Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist
- Lokaler Bild-Upload über den Adminbereich - 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. - Pfadbasierte Profilvorschauen stehen nur in der lokalen Entwicklungsumgebung zur Verfügung.
- Vollständige Profildaten sind ausschließlich über einen authentifizierten Admin-Endpunkt verfügbar. - 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. - 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. 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. 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 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. 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.

View file

@ -5,7 +5,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "crypto";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { GoogleGenAI } from "@google/genai"; import { GoogleGenAI } from "@google/genai";
import { defaultAuthorData } from "./src/defaultData.js"; 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(); dotenv.config();
@ -486,6 +486,30 @@ function isolateLegalContent(content: string, activeKey: ProfileKey): string {
return isolated; 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<string, unknown>;
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 { function seoMeta(req: express.Request, scriptNonce?: string): string {
const key = profileForRequest(req.hostname, req.path); const key = profileForRequest(req.hostname, req.path);
const profile = dbCache[key]; const profile = dbCache[key];
@ -530,6 +554,7 @@ function seoMeta(req: express.Request, scriptNonce?: string): string {
app.get("/api/author-data", (req, res) => { app.get("/api/author-data", (req, res) => {
const key = profileForRequest(req.hostname, req.path); const key = profileForRequest(req.hostname, req.path);
const { customDomain: _customDomain, customPath: _customPath, ...publicProfile } = dbCache[key]; const { customDomain: _customDomain, customPath: _customPath, ...publicProfile } = dbCache[key];
publicProfile.customSectionLinks = normalizeCustomSectionLinks(publicProfile.customSectionLinks) || [];
const legalDocuments = (dbCache.legalDocuments || []) const legalDocuments = (dbCache.legalDocuments || [])
.filter((document) => document.assignedProfiles.includes(key)) .filter((document) => document.assignedProfiles.includes(key))
.map((document) => ({ .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." }); res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." });
return; 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) => ({ const nextData = await updateDatabase((current) => ({
...current, ...current,
[profileKey]: profileData, [profileKey]: sanitizedProfileData,
revision: (current.revision ?? 0) + 1, revision: (current.revision ?? 0) + 1,
})); }));
res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." }); res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." });

View file

@ -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 --- // --- PROJECT ACTIONS ---
const handleSaveProject = async () => { const handleSaveProject = async () => {
if (!authorData || !profile) return; 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" 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"
/> />
</div> </div>
<div className="space-y-3 md:col-span-2 pt-2">
<div>
<label className="text-xs font-mono text-slate-400 uppercase">Optionale Buttons (maximal 3)</label>
<p className="text-[11px] text-slate-500 mt-1">Ein Button erscheint nur, wenn Beschriftung und eine vollständige HTTP(S)-Adresse eingetragen sind. Damit freigegebene Ziele werden öffentlich verlinkt.</p>
</div>
{[0, 1, 2].map((index) => (
<div key={index} className="grid grid-cols-1 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] gap-3 rounded-xl border border-slate-800 bg-slate-950/50 p-3">
<div className="space-y-1">
<label className="text-[10px] font-mono text-slate-500 uppercase">Button {index + 1} – Beschriftung</label>
<input
type="text"
maxLength={80}
value={profile.customSectionLinks?.[index]?.label || ""}
onChange={(event) => 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"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-mono text-slate-500 uppercase">Ziel-URL</label>
<input
type="url"
value={profile.customSectionLinks?.[index]?.url || ""}
onChange={(event) => 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"
/>
</div>
</div>
))}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -13,6 +13,15 @@ interface PortfolioPageProps {
theme: PortfolioTheme; 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<PortfolioTheme, { accent: string; secondary: string; background: string; card: string; badge: string; subtitle: string }> = { const themeDefaults: Record<PortfolioTheme, { accent: string; secondary: string; background: string; card: string; badge: string; subtitle: string }> = {
cosmic: { accent: "#06b6d4", secondary: "#a855f7", background: "#020617", card: "#0f172a", badge: "Geschichten & Welten", subtitle: "AUTORINNEN- UND AUTORENPORTFOLIO" }, 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" }, 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 cardBgColor = data.cardBgColor || defaults.card;
const fontClass = data.fontFamily === "serif" ? "font-serif" : data.fontFamily === "mono" ? "font-mono" : "font-sans"; 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 ( return (
<div <div
@ -288,7 +301,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</section> </section>
{/* Section 4: Custom Highlight Section if present */} {/* Section 4: Custom Highlight Section if present */}
{(data.customSectionTitle || data.customSectionContent) && ( {(data.customSectionTitle || data.customSectionContent || customSectionLinks.length > 0) && (
<section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}> <section className="p-8 rounded-2xl border relative overflow-hidden" style={{ backgroundColor: cardBgColor, borderColor: `${accentColor}30` }}>
<div className="absolute top-0 right-0 p-3 opacity-10 font-mono text-xs uppercase" style={{ color: accentColor }}> <div className="absolute top-0 right-0 p-3 opacity-10 font-mono text-xs uppercase" style={{ color: accentColor }}>
CUSTOM_INFO_MODULE CUSTOM_INFO_MODULE
@ -317,6 +330,27 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</Markdown> </Markdown>
</div> </div>
)} )}
{customSectionLinks.length > 0 && (
<div className="mt-6 flex flex-col sm:flex-row sm:flex-wrap gap-3">
{customSectionLinks.map((link) => (
<a
key={`${link.label}-${link.url}`}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-xl border px-5 py-3 text-sm font-semibold text-white transition-all hover:-translate-y-0.5 hover:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950"
style={{
borderColor: `${accentColor}70`,
background: `linear-gradient(135deg, ${accentColor}35, ${secondaryColor}25)`,
boxShadow: `0 8px 24px ${accentColor}15`,
}}
>
<span>{link.label}</span>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
</a>
))}
</div>
)}
</section> </section>
)} )}

View file

@ -22,6 +22,11 @@ export interface Book {
spotlightBadge?: string; // Optional custom badge text (e.g. "Neuerscheinung", "Vorbestellbar") spotlightBadge?: string; // Optional custom badge text (e.g. "Neuerscheinung", "Vorbestellbar")
} }
export interface CustomSectionLink {
label: string;
url: string;
}
export interface AuthorProfile { export interface AuthorProfile {
name: string; name: string;
bio: string; bio: string;
@ -32,6 +37,7 @@ export interface AuthorProfile {
books: Book[]; books: Book[];
customSectionTitle?: string; customSectionTitle?: string;
customSectionContent?: string; customSectionContent?: string;
customSectionLinks?: CustomSectionLink[];
// Domain & Path Routing // Domain & Path Routing
customDomain?: string; // e.g., "annieslone.de, annie-slone.de" customDomain?: string; // e.g., "annieslone.de, annie-slone.de"

View file

@ -116,6 +116,29 @@ test("production hardening and public routing", async (t) => {
response = await fetch(`${baseUrl}/api/admin/author-data`); response = await fetch(`${baseUrl}/api/admin/author-data`);
assert.equal(response.status, 401); 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"); const publicResponse = await getWithHost(`${baseUrl}/api/author-data?path=/clara`, "annieslone.de");
assert.equal(publicResponse.status, 200); assert.equal(publicResponse.status, 200);
const publicData = JSON.parse(publicResponse.body); 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.theme, "velvet");
assert.equal(publicData.profile.customDomain, undefined); assert.equal(publicData.profile.customDomain, undefined);
assert.equal(publicData.profile.customPath, 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("Clara Finch"), false);
assert.equal(publicResponse.body.includes("Renee Heart"), false); assert.equal(publicResponse.body.includes("Renee Heart"), false);
assert.equal(publicResponse.body.includes("Daniel Hesse"), false); assert.equal(publicResponse.body.includes("Daniel Hesse"), false);