refactor: harden admin editing workflows

This commit is contained in:
Daniel Heße 2026-08-15 09:49:03 +02:00
parent 18639de047
commit 2f58d6cb67
8 changed files with 419 additions and 247 deletions

View file

@ -115,10 +115,12 @@ Das Admin-Passwort wird nur beim Login übertragen und weder als Bearer-Token zu
- Sitzungsdauer: 24 Stunden - Sitzungsdauer: 24 Stunden
- Cookie: `HttpOnly`, `SameSite=Strict`, in Produktion zusätzlich `Secure` - Cookie: `HttpOnly`, `SameSite=Strict`, in Produktion zusätzlich `Secure`
- Sitzungen liegen nur im Arbeitsspeicher und enden spätestens bei einem Server-/Container-Neustart - 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 - Schreibende Admin-Anfragen werden auf gleiche Herkunft geprüft
- Fehlgeschlagene Logins werden pro Client-IP begrenzt - Fehlgeschlagene Logins werden pro Client-IP begrenzt
- Uploads und Gemini-Aufrufe besitzen zusätzliche Sitzungslimits - Uploads und Gemini-Aufrufe besitzen zusätzliche Sitzungslimits
- Sicherheitsheader und eine Content Security Policy werden serverseitig gesetzt - 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. 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, - erzeugt zufällige, nicht überschreibbare Dateinamen,
- begrenzt Uploadversuche pro Admin-Sitzung und Stunde, - begrenzt Uploadversuche pro Admin-Sitzung und Stunde,
- liefert Uploads mit `nosniff` und langfristigen Cache-Headern aus. - 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. 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, - Origin-Schutz und Login-Limit,
- exaktes Domain-Routing und öffentliche Profilisolation, - exaktes Domain-Routing und öffentliche Profilisolation,
- Schutz des vollständigen Admin-Datenendpunkts, - Schutz des vollständigen Admin-Datenendpunkts,
- Revisionskonflikte und serverseitige Inhaltsvalidierung,
- gültige und manipulierte Bild-Uploads, - gültige und manipulierte Bild-Uploads,
- Löschung unbenutzter sowie Schutz referenzierter Uploads,
- wesentliche Sicherheitsheader. - wesentliche Sicherheitsheader.
## Hinweise zur Aktualisierung ## Hinweise zur Aktualisierung

144
server.ts
View file

@ -54,6 +54,8 @@ interface RateRecord {
resetAt: number; resetAt: number;
} }
class RevisionConflictError extends Error {}
const sessions = new Map<string, SessionRecord>(); const sessions = new Map<string, SessionRecord>();
const loginFailures = new Map<string, RateRecord>(); const loginFailures = new Map<string, RateRecord>();
const geminiRequests = new Map<string, RateRecord>(); const geminiRequests = new Map<string, RateRecord>();
@ -510,6 +512,77 @@ function normalizeCustomSectionLinks(value: unknown): CustomSectionLink[] | null
return links; 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 { 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];
@ -626,15 +699,20 @@ app.post("/api/admin/logout", verifySameOrigin, (req, res) => {
// 3. Save modified profile configurations (About, Projects, Books) // 3. Save modified profile configurations (About, Projects, Books)
app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => { 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") { if (profileKey !== "scifi" && profileKey !== "erotica" && profileKey !== "clara" && profileKey !== "renee") {
res.status(400).json({ error: "Ungültiger Profilschlüssel." }); res.status(400).json({ error: "Ungültiger Profilschlüssel." });
return; return;
} }
try { try {
if (!profileData || typeof profileData !== "object" || !Array.isArray(profileData.books) || !Array.isArray(profileData.projects)) { if (hasRevisionConflict(expectedRevision)) {
res.status(400).json({ error: "Profildaten besitzen nicht die erwartete Struktur." }); 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; return;
} }
const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks); const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks);
@ -643,13 +721,16 @@ app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req,
return; return;
} }
const sanitizedProfileData = { ...profileData, customSectionLinks }; const sanitizedProfileData = { ...profileData, customSectionLinks };
const nextData = await updateDatabase((current) => ({ const nextData = await updateDatabase((current) => {
...current, if (expectedRevision !== (current.revision ?? 0)) throw new RevisionConflictError();
[profileKey]: sanitizedProfileData, return { ...current, [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." });
} catch (err: any) { } 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); console.error("Failed to write to database.json:", err);
res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." }); 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) // 3b. Save legal documents (Impressum & Datenschutzerklärung)
app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => { app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => {
const { legalDocuments } = req.body; const { legalDocuments, expectedRevision } = req.body;
if (!Array.isArray(legalDocuments)) { if (hasRevisionConflict(expectedRevision)) {
res.status(400).json({ error: "legalDocuments muss ein Array sein." }); 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; return;
} }
try { try {
const nextData = await updateDatabase((current) => ({ const nextData = await updateDatabase((current) => {
...current, if (expectedRevision !== (current.revision ?? 0)) throw new RevisionConflictError();
legalDocuments, return { ...current, legalDocuments, revision: (current.revision ?? 0) + 1 };
revision: (current.revision ?? 0) + 1, });
}));
res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." }); res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." });
} catch (err: any) { } 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); console.error("Failed to write legal documents to database.json:", err);
res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." }); 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 // Configure Vite middleware or static serve
async function startServer() { async function startServer() {
// Serve the dynamic uploads directory statically // Serve the dynamic uploads directory statically

41
src/api/adminApi.ts Normal file
View file

@ -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<T>(url: string, init?: RequestInit): Promise<T> {
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<AuthorData>("/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" }),
};

View file

@ -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<HTMLDivElement>(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<HTMLElement>(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<HTMLElement>(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 (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<button type="button" className="fixed inset-0 bg-slate-950/80 backdrop-blur-md" onClick={onClose} aria-label="Dialog schließen" />
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div
ref={dialogRef}
tabIndex={-1}
className={`relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full ${maxWidthClass} p-6 md:p-8 space-y-6`}
>
{children}
</div>
</div>
</div>
);
}

View file

@ -9,6 +9,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import ImagePicker from "./ImagePicker"; import ImagePicker from "./ImagePicker";
import AdminLogin from "./admin/AdminLogin"; import AdminLogin from "./admin/AdminLogin";
import { adminApi, AdminApiError } from "../api/adminApi";
interface AdminPanelProps { interface AdminPanelProps {
onLogout: () => void; onLogout: () => void;
@ -19,6 +20,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<{ message: string; tone: "success" | "error" } | null>(null);
// Author Data State // Author Data State
const [authorData, setAuthorData] = useState<AuthorData | null>(null); const [authorData, setAuthorData] = useState<AuthorData | null>(null);
@ -57,11 +59,9 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const restoreSession = async () => { const restoreSession = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await fetch("/api/admin/session"); await adminApi.session();
if (response.ok) { setIsLoggedIn(true);
setIsLoggedIn(true); await fetchAuthorData();
await fetchAuthorData();
}
} catch (err) { } catch (err) {
console.error("Admin session check failed:", err); console.error("Admin session check failed:", err);
} finally { } finally {
@ -71,11 +71,20 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
restoreSession(); 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 () => { const fetchAuthorData = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/admin/author-data"); const data = await adminApi.loadData();
const data = await res.json();
setAuthorData(data); setAuthorData(data);
} catch (err) { } catch (err) {
console.error("Error fetching author data:", err); console.error("Error fetching author data:", err);
@ -89,21 +98,12 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
setError(""); setError("");
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/admin/login", { await adminApi.login(password);
method: "POST", setPassword("");
headers: { "Content-Type": "application/json" }, setIsLoggedIn(true);
body: JSON.stringify({ password }), await fetchAuthorData();
});
const data = await res.json();
if (res.ok && data.success) {
setPassword("");
setIsLoggedIn(true);
await fetchAuthorData();
} else {
setError(data.error || "Ungültiges Passwort.");
}
} catch (err) { } catch (err) {
setError("Verbindung zum Server fehlgeschlagen."); setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -111,7 +111,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const handleLogoutLocal = async () => { const handleLogoutLocal = async () => {
try { try {
await fetch("/api/admin/logout", { method: "POST" }); await adminApi.logout();
} catch (err) { } catch (err) {
console.error("Admin logout failed:", err); console.error("Admin logout failed:", err);
} }
@ -120,6 +120,28 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
onLogout(); 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 // Profile save handler
const handleSaveProfileInfo = async (profileKey: "scifi" | "erotica" | "clara" | "renee") => { const handleSaveProfileInfo = async (profileKey: "scifi" | "erotica" | "clara" | "renee") => {
if (!authorData) return; if (!authorData) return;
@ -127,26 +149,10 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/admin/save-profile", { await persistProfile(profileKey, authorData[profileKey]);
method: "POST", showNotice("Profilinformationen erfolgreich gespeichert!", "success");
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.");
}
} catch (err) { } catch (err) {
setError("Fehler beim Senden der Profildaten."); setError(err instanceof AdminApiError ? err.message : "Fehler beim Senden der Profildaten.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -198,27 +204,14 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
} }
const updatedProfile = { ...profile, projects: updatedProjects }; const updatedProfile = { ...profile, projects: updatedProjects };
const nextData = { ...authorData, [activeProfile]: updatedProfile };
// Save to server // Save to server
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/admin/save-profile", { await persistProfile(activeProfile, updatedProfile);
method: "POST", setEditingProjectId(null);
headers: { setProjectForm({});
"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.");
}
} catch (err) { } catch (err) {
alert("Netzwerkfehler."); showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -229,24 +222,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const updatedProjects = profile.projects.filter(p => p.id !== projId); const updatedProjects = profile.projects.filter(p => p.id !== projId);
const updatedProfile = { ...profile, projects: updatedProjects }; const updatedProfile = { ...profile, projects: updatedProjects };
const nextData = { ...authorData, [activeProfile]: updatedProfile };
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/admin/save-profile", { await persistProfile(activeProfile, updatedProfile);
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.");
}
} catch (err) { } catch (err) {
alert("Netzwerkfehler."); showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -292,26 +272,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
} }
const updatedProfile = { ...profile, books: updatedBooks }; const updatedProfile = { ...profile, books: updatedBooks };
const nextData = { ...authorData, [activeProfile]: updatedProfile };
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/admin/save-profile", { await persistProfile(activeProfile, updatedProfile);
method: "POST", setEditingBookId(null);
headers: { setBookForm({});
"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.");
}
} catch (err) { } catch (err) {
alert("Netzwerkfehler."); showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -322,24 +289,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const updatedBooks = profile.books.filter(b => b.id !== bookId); const updatedBooks = profile.books.filter(b => b.id !== bookId);
const updatedProfile = { ...profile, books: updatedBooks }; const updatedProfile = { ...profile, books: updatedBooks };
const nextData = { ...authorData, [activeProfile]: updatedProfile };
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/admin/save-profile", { await persistProfile(activeProfile, updatedProfile);
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.");
}
} catch (err) { } catch (err) {
alert("Netzwerkfehler."); showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -348,36 +302,22 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
// --- GEMINI SYNOPSIS GENERATOR --- // --- GEMINI SYNOPSIS GENERATOR ---
const handleGenerateAISynopsis = async () => { const handleGenerateAISynopsis = async () => {
if (!aiTitle.trim()) { 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; return;
} }
setAiLoading(true); setAiLoading(true);
setAiResult(""); setAiResult("");
try { try {
const res = await fetch("/api/admin/generate-blurb", { const data = await adminApi.generateBlurb({
method: "POST", title: aiTitle,
headers: { genre: aiGenre || (activeProfile === "scifi" ? "Science-Fiction" : "Literatur"),
"Content-Type": "application/json", ideas: aiIdeas,
}, tone: aiTone,
body: JSON.stringify({
title: aiTitle,
genre: aiGenre || activeProfile === "scifi" ? "Science-Fiction" : "Sinnliche Literatur",
ideas: aiIdeas,
tone: aiTone
}),
}); });
const data = await res.json(); setAiResult(data.text);
if (res.ok && data.text) { setBookForm({ ...bookForm, description: data.text });
setAiResult(data.text); } catch (err) {
setBookForm({ showNotice(err instanceof AdminApiError ? err.message : "Fehler bei der Verbindung zur KI-Schnittstelle.");
...bookForm,
description: data.text
});
} else {
alert(data.error || "Generierung fehlgeschlagen.");
}
} catch {
alert("Fehler bei der Verbindung zur KI-Schnittstelle.");
} finally { } finally {
setAiLoading(false); setAiLoading(false);
} }
@ -418,22 +358,11 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/admin/save-legal", { await persistLegalDocuments(updatedDocs);
method: "POST", setEditingLegalId(null);
headers: { setLegalForm({});
"Content-Type": "application/json", } catch (err) {
}, showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler beim Speichern.");
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.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -444,24 +373,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
const updatedDocs = (authorData.legalDocuments || []).filter(d => d.id !== docId); const updatedDocs = (authorData.legalDocuments || []).filter(d => d.id !== docId);
setLoading(true); setLoading(true);
try { try {
const res = await fetch("/api/admin/save-legal", { await persistLegalDocuments(updatedDocs);
method: "POST", if (editingLegalId === docId) {
headers: { setEditingLegalId(null);
"Content-Type": "application/json", setLegalForm({});
},
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.");
} }
} catch { } catch (err) {
alert("Netzwerkfehler beim Löschen."); showNotice(err instanceof AdminApiError ? err.message : "Netzwerkfehler beim Löschen.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -510,6 +428,15 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) {
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-200"> <div className="min-h-screen bg-slate-950 text-slate-200">
{notice && (
<div
role="status"
aria-live="polite"
className={`fixed right-4 top-4 z-[70] max-w-sm rounded-xl border px-4 py-3 text-sm shadow-2xl ${notice.tone === "success" ? "border-emerald-700 bg-emerald-950 text-emerald-100" : "border-rose-800 bg-rose-950 text-rose-100"}`}
>
{notice.message}
</div>
)}
{/* Top Navbar */} {/* Top Navbar */}
<nav className="bg-slate-900/80 border-b border-slate-800 sticky top-0 z-30 backdrop-blur-md"> <nav className="bg-slate-900/80 border-b border-slate-800 sticky top-0 z-30 backdrop-blur-md">

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, FolderOpen, AlertCircle } from "lucide-react"; import { Upload, Image as ImageIcon, Link as LinkIcon, X, Check, Loader2, FolderOpen, AlertCircle, Trash2 } from "lucide-react";
import { adminApi, AdminApiError } from "../api/adminApi";
interface ImagePickerProps { interface ImagePickerProps {
value: string; value: string;
@ -39,18 +40,10 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
setLoadingFiles(true); setLoadingFiles(true);
setError(""); setError("");
try { try {
const response = await fetch("/api/admin/list-uploads"); const data = await adminApi.listUploads();
if (!response.ok) { setServerFiles(data.files || []);
throw new Error("Fehler beim Laden der Serverdateien"); } catch (err) {
} setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen.");
const data = await response.json();
if (data.success) {
setServerFiles(data.files || []);
} else {
setError(data.error || "Laden der Dateien fehlgeschlagen.");
}
} catch (err: any) {
setError("Verbindung zum Server fehlgeschlagen.");
console.error(err); console.error(err);
} finally { } finally {
setLoadingFiles(false); setLoadingFiles(false);
@ -78,31 +71,11 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
reader.onloadend = async () => { reader.onloadend = async () => {
const base64Data = reader.result as string; const base64Data = reader.result as string;
try { try {
const response = await fetch("/api/admin/upload-file", { const data = await adminApi.upload(file.name, base64Data);
method: "POST", onChange(data.url);
headers: { setIsOpen(false);
"Content-Type": "application/json", } catch (err) {
}, setError(err instanceof AdminApiError ? err.message : "Upload fehlgeschlagen.");
body: JSON.stringify({
fileName: file.name,
base64Data: base64Data,
}),
});
if (!response.ok) {
const responseData = await response.json().catch(() => null);
throw new Error(responseData?.error || "Serverfehler beim Upload.");
}
const data = await response.json();
if (data.success) {
onChange(data.url);
setIsOpen(false);
} else {
setError(data.error || "Upload fehlgeschlagen.");
}
} catch (err: any) {
setError("Upload-Fehler: " + err.message);
} finally { } finally {
setUploading(false); setUploading(false);
} }
@ -156,6 +129,18 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
setIsOpen(false); setIsOpen(false);
}; };
const handleDeleteServerFile = async (file: ServerFile) => {
if (!confirm(`Bild „${file.name}“ wirklich dauerhaft löschen?`)) return;
setError("");
try {
await adminApi.deleteUpload(file.name);
setServerFiles((files) => files.filter((candidate) => candidate.name !== file.name));
if (value === file.url) onChange("");
} catch (err) {
setError(err instanceof AdminApiError ? err.message : "Die Datei konnte nicht gelöscht werden.");
}
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -330,22 +315,22 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
{serverFiles.map((file) => { {serverFiles.map((file) => {
const isSelected = value === file.url; const isSelected = value === file.url;
return ( return (
<button <div
key={file.name} key={file.name}
type="button"
onClick={() => handleSelectServerFile(file.url)}
className={`group relative aspect-square rounded-xl overflow-hidden border bg-slate-950 text-left transition-all ${ className={`group relative aspect-square rounded-xl overflow-hidden border bg-slate-950 text-left transition-all ${
isSelected isSelected
? "border-indigo-500 ring-2 ring-indigo-500/20" ? "border-indigo-500 ring-2 ring-indigo-500/20"
: "border-slate-800 hover:border-slate-700" : "border-slate-800 hover:border-slate-700"
}`} }`}
> >
<button type="button" onClick={() => handleSelectServerFile(file.url)} className="absolute inset-0 z-0 w-full h-full">
<img <img
src={file.url} src={file.url}
alt={file.name} alt={file.name}
className="w-full h-full object-cover transition-transform group-hover:scale-105" className="w-full h-full object-cover transition-transform group-hover:scale-105"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
</button>
{/* Overlay filename on hover */} {/* Overlay filename on hover */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-2 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent p-2 flex flex-col justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-[9px] text-slate-200 truncate font-mono">{file.name}</p> <p className="text-[9px] text-slate-200 truncate font-mono">{file.name}</p>
@ -356,7 +341,16 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
</div> </div>
)} )}
</button> <button
type="button"
onClick={() => handleDeleteServerFile(file)}
className="absolute top-1.5 left-1.5 z-20 rounded-md bg-black/75 p-1.5 text-slate-300 opacity-0 transition-opacity hover:bg-rose-900 hover:text-white group-hover:opacity-100 focus:opacity-100"
aria-label={`${file.name} löschen`}
title="Bild löschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
); );
})} })}
</div> </div>

View file

@ -6,6 +6,7 @@ import SpotlightSection from "./SpotlightSection";
import LegalModal from "./LegalModal"; import LegalModal from "./LegalModal";
import ProjectDetailMedia from "./ProjectDetailMedia"; import ProjectDetailMedia from "./ProjectDetailMedia";
import SpotifyConsentPlayer from "./SpotifyConsentPlayer"; import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
import AccessibleModal from "./AccessibleModal";
interface PortfolioPageProps { interface PortfolioPageProps {
data: AuthorProfile; data: AuthorProfile;
@ -358,15 +359,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
{/* Book details modular interface modal */} {/* Book details modular interface modal */}
{selectedBook && ( {selectedBook && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true"> <AccessibleModal titleId="book-detail-title" onClose={() => setSelectedBook(null)}>
{/* Overlay background */}
<div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
onClick={() => setSelectedBook(null)}
/>
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-4xl p-6 md:p-8 space-y-6">
{/* Close Button */} {/* Close Button */}
<button <button
@ -415,7 +408,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
))} ))}
</div> </div>
<h3 className="text-3xl font-extrabold text-white"> <h3 id="book-detail-title" className="text-3xl font-extrabold text-white">
{selectedBook.title} {selectedBook.title}
</h3> </h3>
@ -441,22 +434,12 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</div> </div>
</div> </AccessibleModal>
</div>
</div>
)} )}
{/* Project details modular modal */} {/* Project details modular modal */}
{selectedProject && ( {selectedProject && (
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true"> <AccessibleModal titleId="project-detail-title" onClose={() => setSelectedProject(null)} maxWidthClass="sm:max-w-5xl">
{/* Overlay background */}
<div
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md transition-opacity"
onClick={() => setSelectedProject(null)}
/>
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="relative transform overflow-hidden rounded-2xl bg-slate-900 border border-slate-800 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-5xl p-6 md:p-8 space-y-6">
{/* Close Button */} {/* Close Button */}
<button <button
@ -476,7 +459,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</span> </span>
</div> </div>
<h3 className="text-3xl font-extrabold text-white"> <h3 id="project-detail-title" className="text-3xl font-extrabold text-white">
{selectedProject.title} {selectedProject.title}
</h3> </h3>
@ -514,9 +497,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
</div> </div>
</div> </div>
</div> </AccessibleModal>
</div>
</div>
)} )}
{/* Styled Footer */} {/* Styled Footer */}

View file

@ -125,9 +125,17 @@ test("production hardening and public routing", async (t) => {
response = await fetch(`${baseUrl}/api/admin/save-profile`, { response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json", origin: baseUrl, cookie }, 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); 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`, { response = await fetch(`${baseUrl}/api/admin/save-profile`, {
method: "POST", method: "POST",
@ -135,6 +143,7 @@ test("production hardening and public routing", async (t) => {
body: JSON.stringify({ body: JSON.stringify({
profileKey: "erotica", profileKey: "erotica",
profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] }, profileData: { ...adminData.erotica, customSectionLinks: [{ label: "Unsicher", url: "javascript:alert(1)" }] },
expectedRevision: firstSave.revision,
}), }),
}); });
assert.equal(response.status, 400); 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.equal(response.headers.get("x-content-type-options"), "nosniff");
assert.match(response.headers.get("cache-control") || "", /immutable/); 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 } }); response = await fetch(`${baseUrl}/api/admin/logout`, { method: "POST", headers: { origin: baseUrl, cookie } });
assert.equal(response.status, 200); assert.equal(response.status, 200);
response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } }); response = await fetch(`${baseUrl}/api/admin/session`, { headers: { cookie } });