Codex/admin quality hardening #17
8 changed files with 419 additions and 247 deletions
|
|
@ -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
|
||||
|
|
|
|||
144
server.ts
144
server.ts
|
|
@ -54,6 +54,8 @@ interface RateRecord {
|
|||
resetAt: number;
|
||||
}
|
||||
|
||||
class RevisionConflictError extends Error {}
|
||||
|
||||
const sessions = new Map<string, SessionRecord>();
|
||||
const loginFailures = new Map<string, RateRecord>();
|
||||
const geminiRequests = new Map<string, RateRecord>();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
41
src/api/adminApi.ts
Normal file
41
src/api/adminApi.ts
Normal 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" }),
|
||||
};
|
||||
72
src/components/AccessibleModal.tsx
Normal file
72
src/components/AccessibleModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<AuthorData | null>(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) {
|
||||
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) {
|
||||
await adminApi.login(password);
|
||||
setPassword("");
|
||||
setIsLoggedIn(true);
|
||||
await fetchAuthorData();
|
||||
} else {
|
||||
setError(data.error || "Ungültiges Passwort.");
|
||||
}
|
||||
} 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);
|
||||
await persistProfile(activeProfile, updatedProfile);
|
||||
setEditingProjectId(null);
|
||||
setProjectForm({});
|
||||
} else {
|
||||
alert("Fehler beim Speichern des Projekts.");
|
||||
}
|
||||
} 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);
|
||||
await persistProfile(activeProfile, updatedProfile);
|
||||
setEditingBookId(null);
|
||||
setBookForm({});
|
||||
} else {
|
||||
alert("Fehler beim Speichern des Buches.");
|
||||
}
|
||||
} 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({
|
||||
const data = await adminApi.generateBlurb({
|
||||
title: aiTitle,
|
||||
genre: aiGenre || activeProfile === "scifi" ? "Science-Fiction" : "Sinnliche Literatur",
|
||||
genre: aiGenre || (activeProfile === "scifi" ? "Science-Fiction" : "Literatur"),
|
||||
ideas: aiIdeas,
|
||||
tone: aiTone
|
||||
}),
|
||||
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.");
|
||||
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 });
|
||||
await persistLegalDocuments(updatedDocs);
|
||||
setEditingLegalId(null);
|
||||
setLegalForm({});
|
||||
} else {
|
||||
alert("Fehler beim Speichern des Dokuments.");
|
||||
}
|
||||
} catch {
|
||||
alert("Netzwerkfehler beim Speichern.");
|
||||
} 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 });
|
||||
await persistLegalDocuments(updatedDocs);
|
||||
if (editingLegalId === docId) {
|
||||
setEditingLegalId(null);
|
||||
setLegalForm({});
|
||||
}
|
||||
} else {
|
||||
alert("Fehler beim Löschen des Dokuments.");
|
||||
}
|
||||
} 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 (
|
||||
<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 */}
|
||||
<nav className="bg-slate-900/80 border-b border-slate-800 sticky top-0 z-30 backdrop-blur-md">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 {
|
||||
value: string;
|
||||
|
|
@ -39,18 +40,10 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
|||
setLoadingFiles(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/admin/list-uploads");
|
||||
if (!response.ok) {
|
||||
throw new Error("Fehler beim Laden der Serverdateien");
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const data = await adminApi.listUploads();
|
||||
setServerFiles(data.files || []);
|
||||
} else {
|
||||
setError(data.error || "Laden der Dateien fehlgeschlagen.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError("Verbindung zum Server fehlgeschlagen.");
|
||||
} catch (err) {
|
||||
setError(err instanceof AdminApiError ? err.message : "Verbindung zum Server fehlgeschlagen.");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoadingFiles(false);
|
||||
|
|
@ -78,31 +71,11 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
|||
reader.onloadend = async () => {
|
||||
const base64Data = reader.result as string;
|
||||
try {
|
||||
const response = await fetch("/api/admin/upload-file", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
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) {
|
||||
const data = await adminApi.upload(file.name, base64Data);
|
||||
onChange(data.url);
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
setError(data.error || "Upload fehlgeschlagen.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError("Upload-Fehler: " + err.message);
|
||||
} catch (err) {
|
||||
setError(err instanceof AdminApiError ? err.message : "Upload fehlgeschlagen.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
|
@ -156,6 +129,18 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
|||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -330,22 +315,22 @@ export default function ImagePicker({ value, onChange, label }: ImagePickerProps
|
|||
{serverFiles.map((file) => {
|
||||
const isSelected = value === file.url;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
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 ${
|
||||
isSelected
|
||||
? "border-indigo-500 ring-2 ring-indigo-500/20"
|
||||
: "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
|
||||
src={file.url}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</button>
|
||||
{/* 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">
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import SpotlightSection from "./SpotlightSection";
|
|||
import LegalModal from "./LegalModal";
|
||||
import ProjectDetailMedia from "./ProjectDetailMedia";
|
||||
import SpotifyConsentPlayer from "./SpotifyConsentPlayer";
|
||||
import AccessibleModal from "./AccessibleModal";
|
||||
|
||||
interface PortfolioPageProps {
|
||||
data: AuthorProfile;
|
||||
|
|
@ -358,15 +359,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
|||
|
||||
{/* Book details modular interface modal */}
|
||||
{selectedBook && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
|
||||
{/* 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">
|
||||
<AccessibleModal titleId="book-detail-title" onClose={() => setSelectedBook(null)}>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
|
|
@ -415,7 +408,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
|||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="text-3xl font-extrabold text-white">
|
||||
<h3 id="book-detail-title" className="text-3xl font-extrabold text-white">
|
||||
{selectedBook.title}
|
||||
</h3>
|
||||
|
||||
|
|
@ -441,22 +434,12 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
|||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccessibleModal>
|
||||
)}
|
||||
|
||||
{/* Project details modular modal */}
|
||||
{selectedProject && (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
|
||||
{/* 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">
|
||||
<AccessibleModal titleId="project-detail-title" onClose={() => setSelectedProject(null)} maxWidthClass="sm:max-w-5xl">
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
|
|
@ -476,7 +459,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-3xl font-extrabold text-white">
|
||||
<h3 id="project-detail-title" className="text-3xl font-extrabold text-white">
|
||||
{selectedProject.title}
|
||||
</h3>
|
||||
|
||||
|
|
@ -514,9 +497,7 @@ export default function PortfolioPage({ data, legalDocuments = [], theme }: Port
|
|||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccessibleModal>
|
||||
)}
|
||||
|
||||
{/* Styled Footer */}
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
Loading…
Reference in a new issue