webstack-author/server.ts

518 lines
20 KiB
TypeScript
Raw 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 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<void> = 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<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;
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<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 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) => ({
"&": "&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 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(/</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 type="application/ld+json">${structuredData}</script>`,
].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(`<?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));
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);
});