webstack-author/server.ts
2026-07-26 22:12:41 +02:00

286 lines
10 KiB
TypeScript

import express from "express";
import path from "path";
import fs from "fs/promises";
import { fileURLToPath } from "url";
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 currentFilename = typeof import.meta !== "undefined" && import.meta.url
? fileURLToPath(import.meta.url)
: (typeof __filename !== "undefined" ? __filename : "");
const currentDirname = typeof import.meta !== "undefined" && import.meta.url
? path.dirname(currentFilename)
: (typeof __dirname !== "undefined" ? __dirname : "");
const app = express();
const PORT = 3000;
// Path to durable local database file
const DATA_DIR = path.join(process.cwd(), "data");
const DATA_FILE = path.join(DATA_DIR, "database.json");
// Establish initial database of authors
async function initDatabase(): Promise<AuthorData> {
try {
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");
const parsed = JSON.parse(existingData) as AuthorData;
let modified = false;
// Ensure all dynamic static text and design properties exist for all profiles
const profileKeys: ("scifi" | "erotica" | "clara" | "renee")[] = ["scifi", "erotica", "clara", "renee"];
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",
"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;
}
if (modified) {
await fs.writeFile(DATA_FILE, JSON.stringify(parsed, null, 2), "utf-8");
}
return parsed;
} catch {
// If file doesn't exist, seed it with default data
await fs.writeFile(DATA_FILE, JSON.stringify(defaultAuthorData, null, 2), "utf-8");
return defaultAuthorData;
}
} catch (err) {
console.error("Database storage initialization failed, using default data in-memory:", err);
return defaultAuthorData;
}
}
// In-memory runtime data cache, synced to active storage file
let dbCache: AuthorData;
// Initialize on boot
initDatabase().then((data) => {
dbCache = data;
});
// 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." });
}
};
// --- API ROUTES ---
// 1. Get entire public author setup
app.get("/api/author-data", (req, res) => {
res.json(dbCache || defaultAuthorData);
});
// 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 {
dbCache[profileKey] = profileData;
await fs.writeFile(DATA_FILE, JSON.stringify(dbCache, null, 2), "utf-8");
res.json({ success: true, 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." });
}
});
// 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(process.cwd(), "data", "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");
app.use(express.static(distPath));
app._router.get("*", (req: express.Request, res: express.Response) => {
res.sendFile(path.join(distPath, "index.html"));
});
}
app.listen(PORT, "0.0.0.0", () => {
console.log(`Server running on http://0.0.0.0:${PORT}`);
});
}
startServer();