1090 lines
46 KiB
TypeScript
1090 lines
46 KiB
TypeScript
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 DOWNLOAD_DIR = path.join(DATA_DIR, "downloads");
|
||
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 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;
|
||
|
||
interface SessionRecord {
|
||
expiresAt: number;
|
||
}
|
||
|
||
interface RateRecord {
|
||
count: number;
|
||
resetAt: number;
|
||
}
|
||
|
||
class RevisionConflictError extends Error {}
|
||
|
||
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 });
|
||
await fs.mkdir(DOWNLOAD_DIR, { 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: "15mb" }));
|
||
|
||
// 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) => ({
|
||
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'",
|
||
})[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 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.contactEmail && (value.contactEmail.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.contactEmail))) return "Die Kontakt-E-Mail-Adresse ist ungültig.";
|
||
if (!isSafeContentUrl(value.instagramUrl) || !isSafeContentUrl(value.threadsUrl)) return "Mindestens ein Social-Media-Link 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.ebookLink) || !isSafeContentUrl(book.paperbackLink) ||
|
||
(book.samplePdfUrl !== undefined && book.samplePdfUrl !== "" && (typeof book.samplePdfUrl !== "string" || !book.samplePdfUrl.startsWith("/downloads/") || !SAFE_PDF_NAME.test(path.basename(book.samplePdfUrl)))) ||
|
||
!isSafeContentUrl(book.spotifyPlaylistId, true) ||
|
||
(book.seriesName !== undefined && (typeof book.seriesName !== "string" || book.seriesName.length > 300)) ||
|
||
(book.seriesNumber !== undefined && book.seriesNumber !== "" && (!Number.isFinite(Number(book.seriesNumber)) || Number(book.seriesNumber) < 0 || Number(book.seriesNumber) > 999)) ||
|
||
(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 isDownloadReferenced(url: string): boolean {
|
||
return profileKeys.some((key) => dbCache[key].books.some((book) => book.samplePdfUrl === url));
|
||
}
|
||
|
||
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, expectedRevision } = req.body;
|
||
if (profileKey !== "scifi" && profileKey !== "erotica" && profileKey !== "clara" && profileKey !== "renee") {
|
||
res.status(400).json({ error: "Ungültiger Profilschlüssel." });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
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);
|
||
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) => {
|
||
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." });
|
||
}
|
||
});
|
||
|
||
// 3b. Save legal documents (Impressum & Datenschutzerklärung)
|
||
app.post("/api/admin/save-legal", verifySession, verifySameOrigin, async (req, res) => {
|
||
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) => {
|
||
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." });
|
||
}
|
||
});
|
||
|
||
// 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." });
|
||
}
|
||
});
|
||
|
||
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." });
|
||
}
|
||
});
|
||
|
||
app.post("/api/admin/upload-sample", 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 } = req.body;
|
||
if (typeof fileName !== "string" || typeof base64Data !== "string" || path.extname(fileName).toLowerCase() !== ".pdf") {
|
||
res.status(400).json({ error: "Eine PDF-Datei ist erforderlich." });
|
||
return;
|
||
}
|
||
const match = base64Data.match(/^data:application\/pdf;base64,([a-zA-Z0-9+/]+={0,2})$/);
|
||
if (!match) {
|
||
res.status(400).json({ error: "Das PDF-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 Leseprobe darf maximal 10 MB groß sein." });
|
||
return;
|
||
}
|
||
if (buffer.subarray(0, 5).toString("ascii") !== "%PDF-") {
|
||
res.status(415).json({ error: "Die Datei besitzt keinen gültigen PDF-Dateikopf." });
|
||
return;
|
||
}
|
||
try {
|
||
const generatedName = `${Date.now().toString(36)}-${randomBytes(16).toString("hex")}.pdf`;
|
||
await fs.writeFile(path.join(DOWNLOAD_DIR, generatedName), buffer, { flag: "wx" });
|
||
res.status(201).json({ success: true, url: `/downloads/${generatedName}`, size: buffer.length });
|
||
} catch (error) {
|
||
console.error("Sample upload failed:", error);
|
||
res.status(500).json({ error: "Die Leseprobe 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) {
|
||
res.status(400).json({ error: "Ungültiger Dateiname." });
|
||
return;
|
||
}
|
||
const url = `/downloads/${name}`;
|
||
if (isDownloadReferenced(url)) {
|
||
res.status(409).json({ error: "Die Leseprobe wird noch von einem Buch 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 Leseprobe wurde nicht gefunden." });
|
||
return;
|
||
}
|
||
res.status(500).json({ error: "Die Leseprobe konnte nicht gelöscht werden." });
|
||
}
|
||
});
|
||
|
||
// 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");
|
||
},
|
||
}));
|
||
app.use("/downloads", (req, res, next) => {
|
||
const requestedName = path.basename(req.path);
|
||
if (!SAFE_PDF_NAME.test(requestedName)) {
|
||
res.status(404).end();
|
||
return;
|
||
}
|
||
next();
|
||
});
|
||
app.use("/downloads", express.static(DOWNLOAD_DIR, {
|
||
dotfiles: "deny",
|
||
fallthrough: false,
|
||
setHeaders: (res, filePath) => {
|
||
res.setHeader("Content-Type", "application/pdf");
|
||
res.setHeader("Content-Disposition", `attachment; filename="${path.basename(filePath)}"`);
|
||
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 isKnownRoute = req.path === "/" || req.path === "/admin" || req.path.startsWith("/admin/");
|
||
const meta = isKnownRoute
|
||
? seoMeta(req, res.locals.scriptNonce)
|
||
: '<title>Seite nicht gefunden</title>\n <meta name="robots" content="noindex, nofollow, noarchive" />';
|
||
const html = indexTemplate.replace("<!-- SEO_META -->\n <title>Autoren-Portfolio</title>", meta);
|
||
if (req.path.startsWith("/admin")) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
|
||
if (!isKnownRoute) res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive");
|
||
res.status(isKnownRoute ? 200 : 404).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);
|
||
});
|