import express from "express"; import path from "path"; import fs from "fs/promises"; import dotenv from "dotenv"; import { GoogleGenAI } from "@google/genai"; import { defaultAuthorData } from "./src/defaultData.js"; import { AuthorData, AuthorProfile } from "./src/types.js"; dotenv.config(); const app = express(); const PORT = 3000; // 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 = 1; let writeQueue: Promise = Promise.resolve(); let serverReady = false; const profileKeys = ["scifi", "erotica", "clara", "renee"] as const; type ProfileKey = typeof profileKeys[number]; function isAuthorData(value: unknown): value is AuthorData { if (!value || typeof value !== "object") return false; const candidate = value as Record; return profileKeys.every((key) => { const profile = candidate[key] as Partial | 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 { 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 { 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 { 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 { 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; 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; } if (parsed.schemaVersion === undefined) { 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 app.use(express.json({ limit: "10mb" })); // 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", }, }, }); }; // Admin authentication password helper // In production or self-hosted, they set ADMIN_PASSWORD in environment or docker-compose. // Default fallback is "autor2026" const getAdminPassword = () => { return process.env.ADMIN_PASSWORD || "autor2026"; }; // Authorization verification middleware const verifyToken = (req: express.Request, res: express.Response, next: express.NextFunction) => { const authHeader = req.headers.authorization; if (!authHeader) { res.status(401).json({ error: "Kein Autorisierungs-Token bereitgestellt." }); return; } const token = authHeader.replace("Bearer ", ""); // To keep session simple, secure, and self-hosted, our auth token is just the password itself or adminPassword if (token === getAdminPassword()) { next(); } else { res.status(403).json({ error: "Ungültiges Passwort oder Sitzungstoken." }); } }; function cleanDomain(value: string): string { return value.toLowerCase().trim().replace(/^https?:\/\//, "").replace(/^www\./, "").split("/")[0]; } function profileForRequest(hostname: string, pathname: string): ProfileKey { const normalizedHost = cleanDomain(hostname); const standardPaths: Record = { erotica: ["/sensual-moments", "/annie-slone", "/marc-velvet"], clara: ["/clara-finch", "/clara"], renee: ["/renee-heart", "/renee"], scifi: ["/sci-fi", "/daniel-hesse"], }; const standardDomains: Record = { 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 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; } } for (const key of profileKeys) { const configuredDomains = (dbCache[key].customDomain || "").split(",").map(cleanDomain).filter(Boolean); for (const domain of [...configuredDomains, ...standardDomains[key]]) { if (normalizedHost === domain || normalizedHost.endsWith(`.${domain}`)) 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 seoMeta(req: express.Request): 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(/${escapeHtml(title)}`, ``, ``, ``, ``, ``, ``, ``, ``, image ? `` : "", ``, ``, ].filter(Boolean).join("\n "); } // --- API ROUTES --- // 1. Get entire public author setup app.get("/api/author-data", (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 Admin Login app.post("/api/admin/login", (req, res) => { const { password } = req.body; if (!password) { res.status(400).json({ error: "Passwort ist erforderlich." }); return; } if (password === getAdminPassword()) { // Return the token which client stores in localStorage res.json({ success: true, token: getAdminPassword() }); } else { res.status(401).json({ error: "Ungültiges Passwort. Bitte versuchen Sie es erneut." }); } }); // 3. Save modified profile configurations (About, Projects, Books) app.post("/api/admin/save-profile", verifyToken, 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 nextData = await updateDatabase((current) => ({ ...current, [profileKey]: profileData, 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", verifyToken, 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", verifyToken, async (req, res) => { 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", verifyToken, async (req, res) => { const { fileName, base64Data } = req.body; if (!fileName || !base64Data) { res.status(400).json({ error: "Dateiname und Base64-Daten sind erforderlich." }); return; } try { // Sanitize filename to prevent directory traversal const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9.\-_]/g, "_"); const uploadPath = path.join(DATA_DIR, "uploads", safeName); // Strip base64 metadata prefix if present (e.g., "data:image/jpeg;base64,") const base64Clean = base64Data.replace(/^data:image\/\w+;base64,/, ""); const buffer = Buffer.from(base64Clean, "base64"); await fs.writeFile(uploadPath, buffer); res.json({ success: true, url: `/uploads/${safeName}` }); } 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", verifyToken, 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 => !file.startsWith(".")) // skip hidden files .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", express.static(path.join(DATA_DIR, "uploads"))); 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(`\n${escapeHtml(location)}`); }); app.use(express.static(distPath, { index: false })); app.get("*", (req: express.Request, res: express.Response) => { const html = indexTemplate.replace("\n Autoren-Portfolio", seoMeta(req)); 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() { dbCache = await initDatabase(); await startServer(); } main().catch((error) => { console.error("Server startup failed; existing data was not overwritten:", error); process.exit(1); });