286 lines
10 KiB
TypeScript
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", "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;
|
|
}
|
|
|
|
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();
|