From 85768d76cd48dcc386a9b0cad9650232a4b64839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20He=C3=9Fe?= Date: Tue, 18 Aug 2026 16:07:15 +0200 Subject: [PATCH] feat: add downloadable publication library --- README.md | 10 ++ server.ts | 88 ++++++++++++- src/api/adminApi.ts | 4 + src/components/AdminPanel.tsx | 31 ++++- src/components/PortfolioPage.tsx | 48 ++++++- src/components/admin/DownloadsManager.tsx | 123 ++++++++++++++++++ .../admin/PublicationFilePicker.tsx | 62 +++++++++ src/types.ts | 13 ++ tests/server.test.mjs | 40 ++++++ 9 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 src/components/admin/DownloadsManager.tsx create mode 100644 src/components/admin/PublicationFilePicker.tsx diff --git a/README.md b/README.md index bd6309a..a7fc79e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Technisch besteht die Anwendung aus einem React-/Tailwind-Frontend und einem Exp - Buchreihen mit Serienname und automatisch sortierter Bandnummer - getrennte KDP-Links für E-Book und Taschenbuch - optionaler, lokal gehosteter PDF-Leseproben-Download pro Buch +- optionaler Kurzgeschichten- und Download-Bereich mit PDF und ePUB - Aktuelle Projekte mit Fortschritt, Markdown-Detailtext, Bild und optionaler Spotify-Playlist - Lokaler Bild-Upload über den Adminbereich - Verwaltung von Impressum, Datenschutzerklärung und weiteren Rechtstexten @@ -177,6 +178,14 @@ Bücher können optional einem Seriennamen und einer Bandnummer zugeordnet werde Pro Buch kann im Adminbereich eine PDF-Leseprobe mit maximal 10 MB hochgeladen werden. Der Server prüft Dateiendung, MIME-Uploadformat und PDF-Dateikopf und liefert die Datei als Download mit `nosniff` aus. Ohne hinterlegte Datei erscheint kein Leseproben-Button. PDF-Dateien liegen getrennt von Bildern unter `data/downloads/` und müssen daher in Backups eingeschlossen werden. +## Kurzgeschichten und Downloads + +Jedes Profil kann einen eigenen optionalen Download-Bereich pflegen. Einträge bestehen aus Titel, kurzer Markdown-Beschreibung, optionalem Veröffentlichungsdatum, optionalem Bild sowie einer PDF- und/oder ePUB-Datei. Ein Eintrag ohne verfügbare Datei wird öffentlich nicht angezeigt; ohne Einträge verschwinden der gesamte Abschnitt und sein Navigationslink. + +Öffentlich erscheinen zunächst höchstens drei Karten. Bei weiteren Einträgen können Besucher mit „Alle Kurzgeschichten anzeigen“ die vollständige Liste einblenden und anschließend wieder einklappen. Die Reihenfolge wird im Adminbereich explizit über Hoch-/Runter-Aktionen gepflegt; neu angelegte Downloads stehen zunächst oben. + +PDF und ePUB werden getrennt validiert, lokal unter `data/downloads/` gespeichert und direkt als Download ausgeliefert. Beide Formate sind auf 10 MB begrenzt. Der Server prüft bei PDF den Dateikopf und bei ePUB die ZIP-/ePUB-Struktur. Ersetzte oder gelöschte Dateien werden entfernt, sobald kein veröffentlichter Eintrag mehr auf sie verweist. + ## Teilbare Detailansichten Buch- und Projektmodale besitzen adressierbare URLs über `?book=` beziehungsweise `?project=`. Auf geeigneten Mobilgeräten öffnet „Teilen“ den nativen Teilen-Dialog; andernfalls wird die aktuelle URL in die Zwischenablage kopiert. Die URL enthält nur die ID innerhalb des aktuell aufgerufenen Profils und ermöglicht keinen Zugriff auf andere Profile. @@ -224,6 +233,7 @@ Die Integrationstests verwenden ein temporäres Datenverzeichnis und einen kurzl - Revisionskonflikte und serverseitige Inhaltsvalidierung, - gültige und manipulierte Bild-Uploads, - Löschung unbenutzter sowie Schutz referenzierter Uploads, +- validierte PDF-/ePUB-Uploads und Schutz referenzierter Download-Dateien, - wesentliche Sicherheitsheader. ## Hinweise zur Aktualisierung diff --git a/server.ts b/server.ts index 218e751..2b74b97 100644 --- a/server.ts +++ b/server.ts @@ -5,7 +5,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "crypto"; import dotenv from "dotenv"; import { GoogleGenAI } from "@google/genai"; import { defaultAuthorData } from "./src/defaultData.js"; -import { AuthorData, AuthorProfile, CustomSectionLink, PortfolioTheme, PublicAuthorData } from "./src/types.js"; +import { AuthorData, AuthorProfile, CustomSectionLink, DownloadPublication, PortfolioTheme, PublicAuthorData } from "./src/types.js"; dotenv.config(); @@ -47,6 +47,7 @@ const MAX_UPLOAD_BYTES = 8 * 1024 * 1024; const MAX_PDF_BYTES = 10 * 1024 * 1024; const SAFE_UPLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:jpe?g|png|webp|gif|avif)$/i; const SAFE_PDF_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.pdf$/i; +const SAFE_DOWNLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:pdf|epub)$/i; interface SessionRecord { expiresAt: number; @@ -534,6 +535,7 @@ function validateProfile(profile: unknown): string | null { 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ß."; + if (value.downloads !== undefined && (!Array.isArray(value.downloads) || value.downloads.length > 200)) return "Die Downloadliste 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], @@ -567,6 +569,17 @@ function validateProfile(profile: unknown): string | null { return "Mindestens ein Buch enthält ungültige Werte."; } } + for (const download of value.downloads || []) { + if (!download || typeof download.id !== "string" || download.id.length > 200 || + typeof download.title !== "string" || !download.title.trim() || download.title.length > 500 || + typeof download.description !== "string" || download.description.length > 50_000 || + !isSafeContentUrl(download.imageUrl) || + (download.pdfUrl !== undefined && download.pdfUrl !== "" && (typeof download.pdfUrl !== "string" || !download.pdfUrl.startsWith("/downloads/") || !SAFE_PDF_NAME.test(path.basename(download.pdfUrl)))) || + (download.epubUrl !== undefined && download.epubUrl !== "" && (typeof download.epubUrl !== "string" || !download.epubUrl.startsWith("/downloads/") || !/\.epub$/i.test(path.basename(download.epubUrl)))) || + (download.publishedAt !== undefined && (typeof download.publishedAt !== "string" || download.publishedAt.length > 50))) { + return "Mindestens ein Download enthält ungültige Werte."; + } + } return null; } @@ -594,7 +607,8 @@ function isUploadReferenced(url: string): boolean { } function isDownloadReferenced(url: string): boolean { - return profileKeys.some((key) => dbCache[key].books.some((book) => book.samplePdfUrl === url)); + return profileKeys.some((key) => dbCache[key].books.some((book) => book.samplePdfUrl === url) || + (dbCache[key].downloads || []).some((download: DownloadPublication) => download.pdfUrl === url || download.epubUrl === url)); } function seoMeta(req: express.Request, scriptNonce?: string): string { @@ -961,6 +975,49 @@ app.post("/api/admin/upload-sample", verifySession, verifySameOrigin, async (req } }); +app.post("/api/admin/upload-publication", verifySession, verifySameOrigin, async (req, res) => { + const sessionId = res.locals.sessionId as string; + const rate = rateRecord(uploadRequests, sessionId, UPLOAD_WINDOW_MS); + if (rate.count >= UPLOAD_MAX_REQUESTS) { + res.status(429).json({ error: "Das stündliche Upload-Limit ist erreicht." }); + return; + } + rate.count += 1; + const { fileName, base64Data, format } = req.body; + if (typeof fileName !== "string" || typeof base64Data !== "string" || (format !== "pdf" && format !== "epub")) { + res.status(400).json({ error: "Dateiname, Datei und Format sind erforderlich." }); + return; + } + if (path.extname(fileName).toLowerCase() !== `.${format}`) { + res.status(400).json({ error: "Dateiendung und gewähltes Format stimmen nicht überein." }); + return; + } + const match = base64Data.match(/^data:(?:[^;]+)?;base64,([a-zA-Z0-9+/]+={0,2})$/); + if (!match) { + res.status(400).json({ error: "Das Uploadformat ist ungültig." }); + return; + } + const buffer = Buffer.from(match[1], "base64"); + if (buffer.length === 0 || buffer.length > MAX_PDF_BYTES) { + res.status(413).json({ error: "Die Datei darf maximal 10 MB groß sein." }); + return; + } + const isPdf = buffer.subarray(0, 5).toString("ascii") === "%PDF-"; + const isEpub = buffer.length > 58 && buffer[0] === 0x50 && buffer[1] === 0x4b && buffer.includes(Buffer.from("application/epub+zip")); + if ((format === "pdf" && !isPdf) || (format === "epub" && !isEpub)) { + res.status(415).json({ error: `Die Datei besitzt keine gültige ${format.toUpperCase()}-Struktur.` }); + return; + } + try { + const generatedName = `${Date.now().toString(36)}-${randomBytes(16).toString("hex")}.${format}`; + await fs.writeFile(path.join(DOWNLOAD_DIR, generatedName), buffer, { flag: "wx" }); + res.status(201).json({ success: true, url: `/downloads/${generatedName}`, size: buffer.length, format }); + } catch (error) { + console.error("Publication upload failed:", error); + res.status(500).json({ error: "Die Datei konnte nicht gespeichert werden." }); + } +}); + app.delete("/api/admin/samples/:name", verifySession, verifySameOrigin, async (req, res) => { const name = req.params.name; if (!SAFE_PDF_NAME.test(name) || path.basename(name) !== name) { @@ -984,6 +1041,29 @@ app.delete("/api/admin/samples/:name", verifySession, verifySameOrigin, async (r } }); +app.delete("/api/admin/publications/:name", verifySession, verifySameOrigin, async (req, res) => { + const name = req.params.name; + if (!SAFE_DOWNLOAD_NAME.test(name) || path.basename(name) !== name) { + res.status(400).json({ error: "Ungültiger Dateiname." }); + return; + } + const url = `/downloads/${name}`; + if (isDownloadReferenced(url)) { + res.status(409).json({ error: "Die Datei wird noch in einem veröffentlichten Eintrag verwendet." }); + return; + } + try { + await fs.unlink(path.join(DOWNLOAD_DIR, name)); + res.json({ success: true }); + } catch (error: any) { + if (error?.code === "ENOENT") { + res.status(404).json({ error: "Die Datei wurde nicht gefunden." }); + return; + } + 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 @@ -1007,7 +1087,7 @@ async function startServer() { })); app.use("/downloads", (req, res, next) => { const requestedName = path.basename(req.path); - if (!SAFE_PDF_NAME.test(requestedName)) { + if (!SAFE_DOWNLOAD_NAME.test(requestedName)) { res.status(404).end(); return; } @@ -1017,7 +1097,7 @@ async function startServer() { dotfiles: "deny", fallthrough: false, setHeaders: (res, filePath) => { - res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Type", filePath.toLowerCase().endsWith(".epub") ? "application/epub+zip" : "application/pdf"); res.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`); res.setHeader("X-Content-Type-Options", "nosniff"); }, diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 9575a73..0a07c08 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -42,4 +42,8 @@ export const adminApi = { request<{ success: true; url: string }>("/api/admin/upload-sample", jsonPost({ fileName, base64Data })), deleteSample: (name: string) => request<{ success: true }>(`/api/admin/samples/${encodeURIComponent(name)}`, { method: "DELETE" }), + uploadPublication: (fileName: string, base64Data: string, format: "pdf" | "epub") => + request<{ success: true; url: string }>("/api/admin/upload-publication", jsonPost({ fileName, base64Data, format })), + deletePublication: (name: string) => + request<{ success: true }>(`/api/admin/publications/${encodeURIComponent(name)}`, { method: "DELETE" }), }; diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index cbd85ac..ba91d0c 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -5,11 +5,12 @@ import { Save, Plus, Trash2, Edit2, Check, RefreshCw, Sparkles, BookOpen, Compass, Clipboard, Eye, Music, LogOut, ArrowLeftRight, Palette, Type, Image as ImageIcon, Globe, Tag, - Scale, ShieldCheck, FileText, CheckSquare, Square, Copy, ArrowRight + Scale, ShieldCheck, FileText, CheckSquare, Square, Copy, ArrowRight, Download } from "lucide-react"; import ImagePicker from "./ImagePicker"; import AdminLogin from "./admin/AdminLogin"; import SamplePdfPicker from "./admin/SamplePdfPicker"; +import DownloadsManager from "./admin/DownloadsManager"; import { adminApi, AdminApiError } from "../api/adminApi"; interface AdminPanelProps { @@ -30,7 +31,7 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { const [activeProfile, setActiveProfile] = useState<"scifi" | "erotica" | "clara" | "renee">("scifi"); // Sub-tab selection state ("profile" | "texts" | "design" | "books" | "projects" | "legal") - const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects" | "legal">("profile"); + const [activeTab, setActiveTab] = useState<"profile" | "texts" | "design" | "books" | "projects" | "downloads" | "legal">("profile"); // Editing forms state const [editingBookId, setEditingBookId] = useState(null); @@ -563,6 +564,13 @@ export default function AdminPanel({ onLogout }: AdminPanelProps) { Bücherregal ({profile.books.length}) + + + )} + + )} + {/* Section 4: Custom Highlight Section if present */} {(data.customSectionTitle || data.customSectionContent || customSectionLinks.length > 0) && (
diff --git a/src/components/admin/DownloadsManager.tsx b/src/components/admin/DownloadsManager.tsx new file mode 100644 index 0000000..a8e4715 --- /dev/null +++ b/src/components/admin/DownloadsManager.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; +import { ArrowDown, ArrowUp, Edit2, Plus, Save, Trash2 } from "lucide-react"; +import type { DownloadPublication } from "../../types"; +import { adminApi } from "../../api/adminApi"; +import ImagePicker from "../ImagePicker"; +import PublicationFilePicker from "./PublicationFilePicker"; + +interface DownloadsManagerProps { + downloads: DownloadPublication[]; + title: string; + subtitle: string; + loading: boolean; + onSave: (downloads: DownloadPublication[], title: string, subtitle: string) => Promise; + onError: (message: string) => void; +} + +const emptyForm = (): Partial => ({ title: "", description: "", imageUrl: "", pdfUrl: "", epubUrl: "", publishedAt: "" }); + +async function deleteUnreferencedFiles(urls: Array) { + await Promise.all(urls.filter(Boolean).map(async (url) => { + const name = url!.split("/").pop(); + if (name) await adminApi.deletePublication(name).catch(() => undefined); + })); +} + +export default function DownloadsManager({ downloads, title, subtitle, loading, onSave, onError }: DownloadsManagerProps) { + const [sectionTitle, setSectionTitle] = useState(title); + const [sectionSubtitle, setSectionSubtitle] = useState(subtitle); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState>(emptyForm()); + + useEffect(() => setSectionTitle(title), [title]); + useEffect(() => setSectionSubtitle(subtitle), [subtitle]); + + const persist = async (nextDownloads: DownloadPublication[]) => { + try { + await onSave(nextDownloads, sectionTitle, sectionSubtitle); + } catch (error) { + onError(error instanceof Error ? error.message : "Die Downloads konnten nicht gespeichert werden."); + throw error; + } + }; + + const saveEntry = async () => { + if (!form.title?.trim()) { + onError("Bitte einen Titel für den Download eintragen."); + return; + } + if (!form.pdfUrl && !form.epubUrl) { + onError("Bitte mindestens eine PDF- oder ePUB-Datei hochladen."); + return; + } + const previous = editingId && editingId !== "new" ? downloads.find((item) => item.id === editingId) : undefined; + const entry: DownloadPublication = { + id: editingId === "new" ? `download_${Date.now()}` : editingId!, + title: form.title.trim(), + description: form.description || "", + imageUrl: form.imageUrl || "", + pdfUrl: form.pdfUrl || "", + epubUrl: form.epubUrl || "", + publishedAt: form.publishedAt || "", + }; + const next = editingId === "new" ? [entry, ...downloads] : downloads.map((item) => item.id === editingId ? entry : item); + await persist(next); + await deleteUnreferencedFiles([ + previous?.pdfUrl && previous.pdfUrl !== entry.pdfUrl ? previous.pdfUrl : undefined, + previous?.epubUrl && previous.epubUrl !== entry.epubUrl ? previous.epubUrl : undefined, + ]); + setEditingId(null); + setForm(emptyForm()); + }; + + const remove = async (entry: DownloadPublication) => { + if (!confirm(`„${entry.title}“ wirklich löschen?`)) return; + await persist(downloads.filter((item) => item.id !== entry.id)); + await deleteUnreferencedFiles([entry.pdfUrl, entry.epubUrl]); + }; + + const move = async (index: number, direction: -1 | 1) => { + const target = index + direction; + if (target < 0 || target >= downloads.length) return; + const next = [...downloads]; + [next[index], next[target]] = [next[target], next[index]]; + await persist(next); + }; + + if (editingId) { + return ( +
+

{editingId === "new" ? "Neuen Download anlegen" : "Download bearbeiten"}

+
+
setForm({ ...form, title: event.target.value })} className="w-full rounded-xl border border-slate-800 bg-slate-950 px-3 py-2 text-white" />
+