webstack-author/server.ts
2026-07-20 21:46:53 +02:00

232 lines
8.1 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 } 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;
// Auto-heal database: make sure all profiles from defaultAuthorData exist
let modified = false;
if (!parsed.scifi) {
parsed.scifi = defaultAuthorData.scifi;
modified = true;
}
if (!parsed.erotica) {
parsed.erotica = defaultAuthorData.erotica;
modified = true;
}
if (!parsed.clara) {
parsed.clara = defaultAuthorData.clara;
modified = true;
}
// Ensure customSection fields exist for all profiles
if (parsed.scifi && parsed.scifi.customSectionTitle === undefined) {
parsed.scifi.customSectionTitle = defaultAuthorData.scifi.customSectionTitle;
parsed.scifi.customSectionContent = defaultAuthorData.scifi.customSectionContent;
modified = true;
}
if (parsed.erotica && parsed.erotica.customSectionTitle === undefined) {
parsed.erotica.customSectionTitle = defaultAuthorData.erotica.customSectionTitle;
parsed.erotica.customSectionContent = defaultAuthorData.erotica.customSectionContent;
modified = true;
}
if (parsed.clara && parsed.clara.customSectionTitle === undefined) {
parsed.clara.customSectionTitle = defaultAuthorData.clara.customSectionTitle;
parsed.clara.customSectionContent = defaultAuthorData.clara.customSectionContent;
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") {
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 });
}
});
// 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();