webstack-author/server.ts
2026-08-15 09:33:12 +02:00

882 lines
35 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express from "express";
import path from "path";
import fs from "fs/promises";
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";
dotenv.config();
const app = express();
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
// Path to durable local database file
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(process.cwd(), "data"));
const DATA_FILE = path.join(DATA_DIR, "database.json");
const BACKUP_DIR = path.join(DATA_DIR, "backups");
const CURRENT_SCHEMA_VERSION = 2;
const LEGACY_DEFAULT_IMAGE_URLS = new Set([
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&q=80&w=300",
"https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1518709268805-4e9042af9f23?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=300",
"https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=300",
"https://images.unsplash.com/photo-1501504905252-473c47e087f8?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1448375240586-882707db888b?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=300",
"https://images.unsplash.com/photo-1512820790803-83ca734da794?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?auto=format&fit=crop&q=80&w=600",
"https://images.unsplash.com/photo-1543002588-bfa74002ed7e?auto=format&fit=crop&q=80&w=400",
]);
let writeQueue: Promise<void> = Promise.resolve();
let serverReady = false;
const SESSION_COOKIE = "author_session";
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000;
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const LOGIN_MAX_FAILURES = 5;
const GEMINI_WINDOW_MS = 60 * 60 * 1000;
const GEMINI_MAX_REQUESTS = 20;
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
const UPLOAD_MAX_REQUESTS = 30;
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
const SAFE_UPLOAD_NAME = /^(?!\.)[a-zA-Z0-9._-]+\.(?:jpe?g|png|webp|gif|avif)$/i;
interface SessionRecord {
expiresAt: number;
}
interface RateRecord {
count: number;
resetAt: number;
}
const sessions = new Map<string, SessionRecord>();
const loginFailures = new Map<string, RateRecord>();
const geminiRequests = new Map<string, RateRecord>();
const uploadRequests = new Map<string, RateRecord>();
setInterval(() => {
const now = Date.now();
for (const [key, value] of sessions) if (value.expiresAt <= now) sessions.delete(key);
for (const [key, value] of loginFailures) if (value.resetAt <= now) loginFailures.delete(key);
for (const [key, value] of geminiRequests) if (value.resetAt <= now) geminiRequests.delete(key);
for (const [key, value] of uploadRequests) if (value.resetAt <= now) uploadRequests.delete(key);
}, 60 * 60 * 1000).unref();
const profileKeys = ["scifi", "erotica", "clara", "renee"] as const;
type ProfileKey = typeof profileKeys[number];
const publicThemes: Record<ProfileKey, PortfolioTheme> = {
scifi: "cosmic",
erotica: "velvet",
clara: "botanical",
renee: "romance",
};
function isAuthorData(value: unknown): value is AuthorData {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
return profileKeys.every((key) => {
const profile = candidate[key] as Partial<AuthorProfile> | undefined;
return !!profile && typeof profile.name === "string" &&
typeof profile.bio === "string" && Array.isArray(profile.books) &&
Array.isArray(profile.projects);
});
}
async function writeFileAtomically(data: AuthorData): Promise<void> {
const temporaryFile = `${DATA_FILE}.${process.pid}.${Date.now()}.tmp`;
try {
await fs.writeFile(temporaryFile, JSON.stringify(data, null, 2), "utf-8");
await fs.rename(temporaryFile, DATA_FILE);
} finally {
await fs.unlink(temporaryFile).catch(() => undefined);
}
}
function updateDatabase(update: (current: AuthorData) => AuthorData): Promise<AuthorData> {
let savedData: AuthorData;
const operation = writeQueue.then(async () => {
const nextData = update(dbCache);
await writeFileAtomically(nextData);
dbCache = nextData;
savedData = nextData;
});
writeQueue = operation.catch(() => undefined);
return operation.then(() => savedData!);
}
async function createMigrationBackup(rawData: string): Promise<void> {
await fs.mkdir(BACKUP_DIR, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
await fs.writeFile(path.join(BACKUP_DIR, `database-before-migration-${timestamp}.json`), rawData, "utf-8");
}
// Establish initial database of authors
async function initDatabase(): Promise<AuthorData> {
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.mkdir(path.join(DATA_DIR, "uploads"), { recursive: true });
try {
const existingData = await fs.readFile(DATA_FILE, "utf-8");
if (!existingData.trim()) {
const initialData: AuthorData = {
...defaultAuthorData,
schemaVersion: CURRENT_SCHEMA_VERSION,
revision: 0,
};
await createMigrationBackup(existingData);
await writeFileAtomically(initialData);
return initialData;
}
const rawParsed = JSON.parse(existingData) as unknown;
if (!rawParsed || typeof rawParsed !== "object" || Array.isArray(rawParsed)) {
throw new Error("database.json besitzt nicht die erwartete Grundstruktur.");
}
// Legacy files may not contain all newer profiles yet; migrations below add them.
const parsed = rawParsed as AuthorData;
const loadedSchemaVersion = parsed.schemaVersion ?? 0;
let modified = false;
// Ensure all dynamic static text and design properties exist for all profiles
for (const pKey of profileKeys) {
if (!parsed[pKey]) {
parsed[pKey] = defaultAuthorData[pKey];
modified = true;
} else {
const defaults = defaultAuthorData[pKey];
const keysToEnsure: (keyof AuthorProfile)[] = [
"customSectionTitle", "customSectionContent",
"customDomain", "customPath",
"bioTag1Label", "bioTag1Value",
"bioTag2Label", "bioTag2Value",
"bioTag3Label", "bioTag3Value",
"badgeText", "aboutTitle", "spotlightTitle", "spotlightSubtitle",
"projectsTitle", "projectsSubtitle", "projectExcerptTitle", "projectExcerptBadge", "projectProgressLabel",
"booksTitle", "booksSubtitle", "footerText",
"accentColor", "secondaryColor", "backgroundColor", "cardBgColor",
"fontFamily", "heroBannerUrl"
];
for (const k of keysToEnsure) {
if (parsed[pKey][k] === undefined) {
(parsed[pKey] as any)[k] = defaults[k];
modified = true;
}
}
}
}
// Migration check: update erotica default name from M. S. Velvet / Marc Velvet to Annie Slone if unchanged
if (parsed.erotica && (parsed.erotica.name === "M. S. Velvet" || parsed.erotica.name === "Marc Velvet")) {
parsed.erotica.name = "Annie Slone";
parsed.erotica.heroTitle = "ANNIE SLONE";
if (parsed.erotica.bio && parsed.erotica.bio.includes("M. S. Velvet")) {
parsed.erotica.bio = parsed.erotica.bio.replace(/M\. S\. Velvet/g, "Annie Slone");
}
modified = true;
}
// Ensure legalDocuments exists
if (!parsed.legalDocuments || !Array.isArray(parsed.legalDocuments)) {
parsed.legalDocuments = defaultAuthorData.legalDocuments || [];
modified = true;
}
// Version 2 removes only the exact historical demo images. User-uploaded
// and other explicitly configured image URLs remain untouched.
if (loadedSchemaVersion < 2) {
for (const key of profileKeys) {
const profile = parsed[key];
if (LEGACY_DEFAULT_IMAGE_URLS.has(profile.avatarUrl)) profile.avatarUrl = "";
for (const book of profile.books) {
if (LEGACY_DEFAULT_IMAGE_URLS.has(book.coverUrl)) book.coverUrl = "";
}
}
modified = true;
}
if (parsed.schemaVersion !== CURRENT_SCHEMA_VERSION) {
parsed.schemaVersion = CURRENT_SCHEMA_VERSION;
modified = true;
}
if (parsed.revision === undefined) {
parsed.revision = 0;
modified = true;
}
if (!isAuthorData(parsed)) {
throw new Error("database.json ist auch nach der Migration nicht vollständig gültig.");
}
if (modified) {
await createMigrationBackup(existingData);
await writeFileAtomically(parsed);
}
return parsed;
} catch (error: any) {
if (error?.code !== "ENOENT") throw error;
const initialData: AuthorData = {
...defaultAuthorData,
schemaVersion: CURRENT_SCHEMA_VERSION,
revision: 0,
};
await writeFileAtomically(initialData);
return initialData;
}
}
// In-memory runtime data cache, synced to active storage file
let dbCache: AuthorData;
// Configure middleware
if (process.env.TRUST_PROXY === "1") app.set("trust proxy", 1);
app.disable("x-powered-by");
app.use((_req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
if (process.env.NODE_ENV === "production") {
const scriptNonce = randomBytes(18).toString("base64url");
res.locals.scriptNonce = scriptNonce;
res.setHeader("Content-Security-Policy", [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
`script-src 'self' 'nonce-${scriptNonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-src https://open.spotify.com",
].join("; "));
}
next();
});
app.use(express.json({ limit: "12mb" }));
// Initialize Google GenAI if API key exists
const getGeminiClient = () => {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) return null;
return new GoogleGenAI({
apiKey,
httpOptions: {
headers: {
"User-Agent": "aistudio-build",
},
},
});
};
function getAdminPassword(): string {
return process.env.ADMIN_PASSWORD || "dev-only-autor2026";
}
function getSessionSecret(): string {
return process.env.SESSION_SECRET || "dev-only-session-secret-change-me";
}
function validateProductionSecrets(): void {
if (process.env.NODE_ENV !== "production") return;
const missing = ["ADMIN_PASSWORD", "SESSION_SECRET"].filter((name) => !process.env[name]?.trim());
if (missing.length > 0) throw new Error(`Fehlende Produktionskonfiguration: ${missing.join(", ")}`);
if ((process.env.ADMIN_PASSWORD?.length || 0) < 12) throw new Error("ADMIN_PASSWORD muss in Produktion mindestens 12 Zeichen lang sein.");
if ((process.env.SESSION_SECRET?.length || 0) < 32) throw new Error("SESSION_SECRET muss in Produktion mindestens 32 Zeichen lang sein.");
}
function safeEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
function parseCookies(req: express.Request): Record<string, string> {
return Object.fromEntries((req.headers.cookie || "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
const separator = part.indexOf("=");
if (separator < 0) return [part, ""];
return [part.slice(0, separator), decodeURIComponent(part.slice(separator + 1))];
}));
}
function sessionSignature(sessionId: string): string {
return createHmac("sha256", getSessionSecret()).update(sessionId).digest("base64url");
}
function sessionCookieValue(sessionId: string): string {
return `${sessionId}.${sessionSignature(sessionId)}`;
}
function readSessionId(req: express.Request): string | null {
const value = parseCookies(req)[SESSION_COOKIE];
if (!value) return null;
const separator = value.lastIndexOf(".");
if (separator < 1) return null;
const sessionId = value.slice(0, separator);
const signature = value.slice(separator + 1);
if (!safeEqual(signature, sessionSignature(sessionId))) return null;
const record = sessions.get(sessionId);
if (!record || record.expiresAt <= Date.now()) {
sessions.delete(sessionId);
return null;
}
return sessionId;
}
function setSessionCookie(res: express.Response, sessionId: string): void {
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
res.append("Set-Cookie", `${SESSION_COOKIE}=${encodeURIComponent(sessionCookieValue(sessionId))}; Path=/api/admin; Max-Age=${SESSION_DURATION_MS / 1000}; HttpOnly; SameSite=Strict${secure}`);
}
function clearSessionCookie(res: express.Response): void {
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
res.append("Set-Cookie", `${SESSION_COOKIE}=; Path=/api/admin; Max-Age=0; HttpOnly; SameSite=Strict${secure}`);
}
function verifySession(req: express.Request, res: express.Response, next: express.NextFunction): void {
const sessionId = readSessionId(req);
if (!sessionId) {
clearSessionCookie(res);
res.status(401).json({ error: "Keine gültige Admin-Sitzung vorhanden." });
return;
}
res.locals.sessionId = sessionId;
next();
}
function verifySameOrigin(req: express.Request, res: express.Response, next: express.NextFunction): void {
const origin = req.get("origin");
if (!origin) {
if (process.env.NODE_ENV === "production") {
res.status(403).json({ error: "Fehlender Origin-Header." });
return;
}
next();
return;
}
try {
if (new URL(origin).host !== req.get("host")) throw new Error("origin mismatch");
next();
} catch {
res.status(403).json({ error: "Anfrage von einer fremden Herkunft abgelehnt." });
}
}
function rateRecord(map: Map<string, RateRecord>, key: string, windowMs: number): RateRecord {
const existing = map.get(key);
if (!existing || existing.resetAt <= Date.now()) {
const fresh = { count: 0, resetAt: Date.now() + windowMs };
map.set(key, fresh);
return fresh;
}
return existing;
}
interface DetectedImageType {
extension: "jpg" | "png" | "webp" | "gif" | "avif";
mimeType: string;
}
function detectImageType(buffer: Buffer): DetectedImageType | null {
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return { extension: "jpg", mimeType: "image/jpeg" };
}
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
return { extension: "png", mimeType: "image/png" };
}
if (buffer.length >= 12 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
return { extension: "webp", mimeType: "image/webp" };
}
if (buffer.length >= 6 && ["GIF87a", "GIF89a"].includes(buffer.toString("ascii", 0, 6))) {
return { extension: "gif", mimeType: "image/gif" };
}
if (buffer.length >= 12 && buffer.toString("ascii", 4, 8) === "ftyp" && ["avif", "avis"].includes(buffer.toString("ascii", 8, 12))) {
return { extension: "avif", mimeType: "image/avif" };
}
return null;
}
function cleanDomain(value: string): string {
return value.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0];
}
function matchesConfiguredDomain(hostname: string, configuredDomain: string): boolean {
const domain = cleanDomain(configuredDomain);
const host = cleanDomain(hostname);
return !!domain && (host === domain || hostname.toLowerCase() === `www.${domain}`);
}
function profileForRequest(hostname: string, pathname: string): ProfileKey {
const standardPaths: Record<ProfileKey, string[]> = {
erotica: ["/sensual-moments", "/annie-slone", "/marc-velvet"],
clara: ["/clara-finch", "/clara"],
renee: ["/renee-heart", "/renee"],
scifi: ["/sci-fi", "/daniel-hesse"],
};
const standardDomains: Record<ProfileKey, string[]> = {
erotica: ["annieslone.de", "marcvelvet.de"],
clara: ["clarafinch.de", "clara-finch.de"],
renee: ["reneeheart.de", "renee-heart.de"],
scifi: ["hesse-sf.de", "danielhesse.de"],
};
for (const key of profileKeys) {
const configuredDomains = (dbCache[key].customDomain || "").split(",").map(cleanDomain).filter(Boolean);
for (const domain of [...configuredDomains, ...standardDomains[key]]) {
if (matchesConfiguredDomain(hostname, domain)) return key;
}
}
// Path previews are a local development convenience only. In production the
// hostname is the security boundary, so a path can never reveal another site.
if (process.env.NODE_ENV !== "production") {
for (const key of profileKeys) {
const configuredPaths = (dbCache[key].customPath || "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
for (const value of [...configuredPaths, ...standardPaths[key]]) {
const prefix = value.startsWith("/") ? value : `/${value}`;
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return key;
}
}
}
return "scifi";
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;",
})[character]!);
}
function absoluteUrl(value: string | undefined, origin: string): string | undefined {
if (!value) return undefined;
try {
return new URL(value, origin).toString();
} catch {
return undefined;
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function isolateLegalContent(content: string, activeKey: ProfileKey): string {
let isolated = content;
for (const key of profileKeys) {
if (key === activeKey) continue;
const profile = dbCache[key];
const identifiers = [
profile.name,
profile.heroTitle,
...(profile.customDomain || "").split(",").map((domain) => cleanDomain(domain)),
...(profile.customPath || "").split(",").map((pathValue) => pathValue.trim()),
...profile.books.map((book) => book.title),
...profile.projects.map((project) => project.title),
].filter((identifier) => identifier.length >= 4);
for (const identifier of identifiers) {
isolated = isolated.replace(new RegExp(escapeRegExp(identifier), "gi"), "Autorinnen- oder Autorenprofil");
}
}
return isolated;
}
function normalizeCustomSectionLinks(value: unknown): CustomSectionLink[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > 3) return null;
const links: CustomSectionLink[] = [];
for (const candidate of value) {
if (!candidate || typeof candidate !== "object") return null;
const { label, url } = candidate as Record<string, unknown>;
if (typeof label !== "string" || typeof url !== "string" || label.length > 80 || url.length > 2048) return null;
const trimmedLabel = label.trim();
const trimmedUrl = url.trim();
if (!trimmedLabel && !trimmedUrl) continue;
if (!trimmedLabel || !trimmedUrl) continue;
try {
const parsedUrl = new URL(trimmedUrl);
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") return null;
links.push({ label: trimmedLabel, url: parsedUrl.toString() });
} catch {
return null;
}
}
return links;
}
function seoMeta(req: express.Request, scriptNonce?: string): string {
const key = profileForRequest(req.hostname, req.path);
const profile = dbCache[key];
const forwardedProtocol = req.header("x-forwarded-proto")?.split(",")[0].trim();
const protocol = forwardedProtocol === "https" ? "https" : req.protocol;
const origin = `${protocol}://${req.get("host")}`;
const configuredDomain = cleanDomain(profile.customDomain?.split(",")[0] || "");
const canonical = configuredDomain ? `https://${configuredDomain}` : origin;
const title = profile.seoTitle?.trim() || `${profile.name} – ${profile.heroSubtitle || "Autor"}`;
const description = (profile.seoDescription?.trim() || profile.bio).replace(/\s+/g, " ").slice(0, 160);
const image = absoluteUrl(profile.socialImageUrl || profile.heroBannerUrl || profile.avatarUrl, origin);
const robots = profile.noIndex || req.path.startsWith("/admin") ? "noindex, nofollow, noarchive" : "index, follow";
const structuredData = JSON.stringify({
"@context": "https://schema.org",
"@type": "Person",
name: profile.name,
url: canonical,
image,
jobTitle: "Autor",
knowsAbout: profile.books.flatMap((book) => book.genres || []),
}).replace(/</g, "\\u003c");
return [
`<title>${escapeHtml(title)}</title>`,
`<meta name="description" content="${escapeHtml(description)}" />`,
`<meta name="robots" content="${robots}" />`,
`<link rel="canonical" href="${escapeHtml(canonical)}" />`,
`<meta property="og:type" content="profile" />`,
`<meta property="og:locale" content="de_DE" />`,
`<meta property="og:title" content="${escapeHtml(title)}" />`,
`<meta property="og:description" content="${escapeHtml(description)}" />`,
`<meta property="og:url" content="${escapeHtml(canonical)}" />`,
image ? `<meta property="og:image" content="${escapeHtml(image)}" />` : "",
`<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}" />`,
`<script${scriptNonce ? ` nonce="${scriptNonce}"` : ""} type="application/ld+json">${structuredData}</script>`,
].filter(Boolean).join("\n ");
}
// --- API ROUTES ---
// Return only the public data belonging to the requested hostname.
app.get("/api/author-data", (req, res) => {
const key = profileForRequest(req.hostname, req.path);
const { customDomain: _customDomain, customPath: _customPath, ...publicProfile } = dbCache[key];
publicProfile.customSectionLinks = normalizeCustomSectionLinks(publicProfile.customSectionLinks) || [];
const legalDocuments = (dbCache.legalDocuments || [])
.filter((document) => document.assignedProfiles.includes(key))
.map((document) => ({
...document,
content: isolateLegalContent(document.content, key),
assignedProfiles: [],
}));
const response: PublicAuthorData = {
profile: publicProfile,
legalDocuments,
theme: publicThemes[key],
};
res.json(response);
});
app.get("/api/admin/author-data", verifySession, (_req, res) => {
res.json(dbCache);
});
app.get("/health/live", (_req, res) => {
res.json({ status: "ok" });
});
app.get("/health/ready", (_req, res) => {
if (!serverReady || !dbCache) {
res.status(503).json({ status: "not_ready" });
return;
}
res.json({ status: "ok", revision: dbCache.revision ?? 0 });
});
// 2. Manage short-lived admin sessions
app.get("/api/admin/session", verifySession, (_req, res) => {
res.json({ success: true });
});
app.post("/api/admin/login", verifySameOrigin, (req, res) => {
const rate = rateRecord(loginFailures, req.ip || "unknown", LOGIN_WINDOW_MS);
if (rate.count >= LOGIN_MAX_FAILURES) {
res.setHeader("Retry-After", Math.ceil((rate.resetAt - Date.now()) / 1000));
res.status(429).json({ error: "Zu viele fehlgeschlagene Anmeldeversuche. Bitte später erneut versuchen." });
return;
}
const { password } = req.body;
if (!password || typeof password !== "string") {
res.status(400).json({ error: "Passwort ist erforderlich." });
return;
}
if (!safeEqual(password, getAdminPassword())) {
rate.count += 1;
res.status(401).json({ error: "Ungültiges Passwort. Bitte versuchen Sie es erneut." });
return;
}
loginFailures.delete(req.ip || "unknown");
const sessionId = randomBytes(32).toString("base64url");
sessions.set(sessionId, { expiresAt: Date.now() + SESSION_DURATION_MS });
setSessionCookie(res, sessionId);
res.json({ success: true, expiresInSeconds: SESSION_DURATION_MS / 1000 });
});
app.post("/api/admin/logout", verifySameOrigin, (req, res) => {
const sessionId = readSessionId(req);
if (sessionId) sessions.delete(sessionId);
clearSessionCookie(res);
res.json({ success: true });
});
// 3. Save modified profile configurations (About, Projects, Books)
app.post("/api/admin/save-profile", verifySession, verifySameOrigin, async (req, res) => {
const { profileKey, profileData } = 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." });
return;
}
const customSectionLinks = normalizeCustomSectionLinks(profileData.customSectionLinks);
if (customSectionLinks === null) {
res.status(400).json({ error: "Die optionalen Buttons enthalten eine ungültige URL oder überschreiten das Limit von drei Einträgen." });
return;
}
const sanitizedProfileData = { ...profileData, customSectionLinks };
const nextData = await updateDatabase((current) => ({
...current,
[profileKey]: sanitizedProfileData,
revision: (current.revision ?? 0) + 1,
}));
res.json({ success: true, revision: nextData.revision, message: "Profil erfolgreich gespeichert." });
} catch (err: any) {
console.error("Failed to write to database.json:", err);
res.status(500).json({ error: "Fehler beim persistenten Speichern der Formulardaten." });
}
});
// 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." });
return;
}
try {
const nextData = await updateDatabase((current) => ({
...current,
legalDocuments,
revision: (current.revision ?? 0) + 1,
}));
res.json({ success: true, revision: nextData.revision, message: "Rechtliche Dokumente erfolgreich gespeichert." });
} catch (err: any) {
console.error("Failed to write legal documents to database.json:", err);
res.status(500).json({ error: "Fehler beim Speichern der rechtlichen Dokumente." });
}
});
// 4. Creative AI Blurb Assistant for book blurb updates
app.post("/api/admin/generate-blurb", verifySession, verifySameOrigin, async (req, res) => {
const sessionId = res.locals.sessionId as string;
const rate = rateRecord(geminiRequests, sessionId, GEMINI_WINDOW_MS);
if (rate.count >= GEMINI_MAX_REQUESTS) {
res.setHeader("Retry-After", Math.ceil((rate.resetAt - Date.now()) / 1000));
res.status(429).json({ error: "Das stündliche Limit des Schreibassistenten ist erreicht." });
return;
}
rate.count += 1;
const { title, genre, ideas, tone } = req.body;
const ai = getGeminiClient();
if (!ai) {
res.status(503).json({
error: "Mit dem integrierten KI-Schreibassistenten konnte keine Verbindung hergestellt werden. Bitte stellen Sie sicher, dass GEMINI_API_KEY konfiguriert ist."
});
return;
}
try {
const prompt = `Du bist ein professioneller literarischer Marketing-Experte und Buch-Co-Autor.
Schreibe eine fesselnde, hochkarätige Synopsis (Buchrückenblurb) auf Deutsch für folgendes Buch:
- Buchtitel: "${title}"
- Genre/Kategorie: "${genre}"
- Eingebundene Ideen/Motive: "${ideas}"
- Gewünschte Tonalität/Stilrichtung: "${tone}"
Die Synopsis soll neugierig machen, stark die visuelle Stimmung rüberbringen und frei von Klischees sein. Teile den Text in zwei bis max. drei Absätze auf, evtl. eingeleitet durch einen kurzen, fetten Einzeiler, der Aufmerksamkeit catched (z.B. ein prägnanter Satz). Antworte NUR mit dem generierten Blurb-Text in Deutsch.`;
const response = await ai.models.generateContent({
model: "gemini-3.5-flash",
contents: prompt,
});
const generatedText = response.text || "Fehler beim Generieren der Synopsis.";
res.json({ success: true, text: generatedText });
} catch (error: any) {
console.error("Gemini-Fehler aufgetreten:", error);
res.status(500).json({ error: "KI-Generierungsfehler: " + error.message });
}
});
// 5. Upload a file via base64
app.post("/api/admin/upload-file", 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.setHeader("Retry-After", Math.ceil((rate.resetAt - Date.now()) / 1000));
res.status(429).json({ error: "Das stündliche Upload-Limit ist erreicht." });
return;
}
rate.count += 1;
const { fileName, base64Data } = req.body;
if (typeof fileName !== "string" || typeof base64Data !== "string") {
res.status(400).json({ error: "Dateiname und Base64-Daten sind erforderlich." });
return;
}
try {
const match = base64Data.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([a-zA-Z0-9+/]+={0,2})$/);
if (!match) {
res.status(400).json({ error: "Das Uploadformat ist ungültig." });
return;
}
const [, declaredMimeType, encodedData] = match;
const estimatedBytes = Math.floor(encodedData.length * 3 / 4);
if (estimatedBytes > MAX_UPLOAD_BYTES) {
res.status(413).json({ error: "Das Bild darf maximal 8 MB groß sein." });
return;
}
const buffer = Buffer.from(encodedData, "base64");
if (buffer.length === 0 || buffer.length > MAX_UPLOAD_BYTES) {
res.status(413).json({ error: "Das Bild ist leer oder überschreitet 8 MB." });
return;
}
const detectedType = detectImageType(buffer);
if (!detectedType) {
res.status(415).json({ error: "Erlaubt sind ausschließlich echte JPEG-, PNG-, WebP-, GIF- oder AVIF-Bilder." });
return;
}
const normalizedDeclaredType = declaredMimeType === "image/jpg" ? "image/jpeg" : declaredMimeType;
const suppliedExtension = path.extname(path.basename(fileName)).slice(1).toLowerCase();
const normalizedExtension = ["jpeg", "jfif"].includes(suppliedExtension) ? "jpg" : suppliedExtension;
if (normalizedDeclaredType !== detectedType.mimeType || normalizedExtension !== detectedType.extension) {
res.status(415).json({ error: "Dateiendung, MIME-Typ und tatsächlicher Bildinhalt stimmen nicht überein." });
return;
}
const generatedName = `${Date.now().toString(36)}-${randomBytes(16).toString("hex")}.${detectedType.extension}`;
const uploadPath = path.join(DATA_DIR, "uploads", generatedName);
await fs.writeFile(uploadPath, buffer, { flag: "wx" });
res.status(201).json({ success: true, url: `/uploads/${generatedName}`, mimeType: detectedType.mimeType, size: buffer.length });
} catch (err: any) {
console.error("File upload failed:", err);
res.status(500).json({ error: "Fehler beim Speichern der Datei auf dem Server." });
}
});
// 6. List uploaded files
app.get("/api/admin/list-uploads", verifySession, async (req, res) => {
try {
const uploadsDir = path.join(DATA_DIR, "uploads");
await fs.mkdir(uploadsDir, { recursive: true });
const files = await fs.readdir(uploadsDir);
const fileList = files
.filter(file => SAFE_UPLOAD_NAME.test(file))
.map(file => ({
name: file,
url: `/uploads/${file}`
}));
res.json({ success: true, files: fileList });
} catch (err: any) {
console.error("Failed to list uploads:", err);
res.status(500).json({ error: "Fehler beim Auflisten der hochgeladenen Dateien." });
}
});
// Configure Vite middleware or static serve
async function startServer() {
// Serve the dynamic uploads directory statically
app.use("/uploads", (req, res, next) => {
const requestedName = path.basename(req.path);
if (!SAFE_UPLOAD_NAME.test(requestedName)) {
res.status(404).end();
return;
}
next();
});
app.use("/uploads", express.static(path.join(DATA_DIR, "uploads"), {
dotfiles: "deny",
fallthrough: false,
immutable: true,
maxAge: "1y",
setHeaders: (res) => {
res.setHeader("Content-Disposition", "inline");
res.setHeader("X-Content-Type-Options", "nosniff");
},
}));
if (process.env.NODE_ENV !== "production") {
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
server: { middlewareMode: true },
appType: "spa",
});
app.use(vite.middlewares);
} else {
const distPath = path.join(process.cwd(), "dist");
const indexTemplate = await fs.readFile(path.join(distPath, "index.html"), "utf-8");
app.get("/robots.txt", (req, res) => {
const key = profileForRequest(req.hostname, req.path);
const domain = cleanDomain(dbCache[key].customDomain?.split(",")[0] || req.get("host") || "");
res.type("text/plain").send(`User-agent: *\nAllow: /\nDisallow: /admin\n\nSitemap: https://${domain}/sitemap.xml\n`);
});
app.get("/sitemap.xml", (req, res) => {
const key = profileForRequest(req.hostname, req.path);
const profile = dbCache[key];
const domain = cleanDomain(profile.customDomain?.split(",")[0] || req.get("host") || "");
const location = `https://${domain}/`;
res.type("application/xml").send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>${escapeHtml(location)}</loc></url></urlset>`);
});
app.use(express.static(distPath, { index: false }));
app.get("*", (req: express.Request, res: express.Response) => {
const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", seoMeta(req, res.locals.scriptNonce));
if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
res.type("html").send(html);
});
}
const server = app.listen(PORT, "0.0.0.0", () => {
serverReady = true;
console.log(`Server running on http://0.0.0.0:${PORT}`);
});
const shutdown = (signal: string) => {
serverReady = false;
console.log(`${signal} received, shutting down gracefully.`);
server.close(() => {
writeQueue.finally(() => process.exit(0));
});
setTimeout(() => process.exit(1), 10_000).unref();
};
process.once("SIGTERM", () => shutdown("SIGTERM"));
process.once("SIGINT", () => shutdown("SIGINT"));
}
async function main() {
validateProductionSecrets();
dbCache = await initDatabase();
await startServer();
}
main().catch((error) => {
console.error("Server startup failed; existing data was not overwritten:", error);
process.exit(1);
});