| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039 |
- import dotenv from "dotenv";
- import express from "express";
- import cors from "cors";
- import session from "express-session";
- import fs from "fs-extra";
- import path from "path";
- import { fileURLToPath } from "url";
- import { v4 as uuidv4 } from "uuid";
- import multer from "multer";
- import { GoogleGenerativeAI } from "@google/generative-ai";
- // Load environment variables
- dotenv.config();
- import {
- authenticateUser,
- getUserByUsername,
- changeUserPassword,
- } from "./auth.js";
- import {
- getAllThemes,
- getActiveTheme,
- setActiveTheme,
- createCustomTheme,
- updateCustomTheme,
- deleteCustomTheme,
- exportTheme,
- } from "./themes.js";
- const __filename = fileURLToPath(import.meta.url);
- const __dirname = path.dirname(__filename);
- const app = express();
- app.set("trust proxy", 1); // Trust the first proxy (Caddy) so secure cookies work behind HTTPS termination
- const PORT = process.env.PORT || 3001;
- // Paths
- const POSTS_DIR = path.resolve(
- __dirname,
- process.env.POSTS_DIR || "../public/posts",
- );
- const INDEX_FILE = path.join(POSTS_DIR, "index.json");
- // Multer Storage Configuration
- const storage = multer.diskStorage({
- destination: async function (req, file, cb) {
- // Determine folder based on slug
- let folder = "uploads"; // Default
- if (req.query.slug) {
- // Clean the slug just in case, though it should be safe if generated by us
- const cleanSlug = req.query.slug.replace(/[^a-z0-9-]/gi, "");
- folder = path.join(cleanSlug, "images");
- }
- const uploadPath = path.join(POSTS_DIR, folder);
- try {
- await fs.ensureDir(uploadPath);
- cb(null, uploadPath);
- } catch (err) {
- cb(err);
- }
- },
- filename: function (req, file, cb) {
- const ext = path.extname(file.originalname).toLowerCase();
- const namePart = path
- .basename(file.originalname, ext)
- .replace(/[^a-z0-9]/gi, "-")
- .toLowerCase();
- const timestamp = Date.now();
- const newFilename = `${namePart}_${timestamp}${ext}`;
- cb(null, newFilename);
- },
- });
- const upload = multer({ storage: storage });
- // Middleware
- app.use(
- cors({
- origin: [
- "http://localhost:5173",
- "https://goonblog.thevakhovske.eu.org",
- ],
- credentials: true, // Enable cookies
- }),
- );
- app.use(express.json());
- app.use(express.urlencoded({ extended: true }));
- // Serve static files from POSTS_DIR so images are accessible
- // Access images via /api/media-files/SLUG/images/FILENAME.jpg
- // We use /api/media-files to avoid conflict with /api/posts routes and to align with API_BASE
- app.use("/api/media-files", express.static(POSTS_DIR));
- // Session configuration
- app.use(
- session({
- secret:
- process.env.SESSION_SECRET ||
- "gooneral-wheelchair-secret-key-change-in-production",
- resave: false,
- saveUninitialized: false,
- name: "gooneral-session",
- cookie: {
- secure: true, // HTTPS required
- httpOnly: true,
- maxAge: 24 * 60 * 60 * 1000, // 24 hours
- sameSite: "lax", // Changed from 'strict' to 'lax'
- },
- }),
- );
- // Ensure posts directory exists
- await fs.ensureDir(POSTS_DIR);
- // Authentication middleware
- function requireAuth(req, res, next) {
- if (req.session && req.session.user && req.session.user.role === "admin") {
- return next();
- }
- return res.status(401).json({ error: "Authentication required" });
- }
- // Check if user is authenticated
- function isAuthenticated(req, res, next) {
- req.isAuthenticated = !!(req.session && req.session.user);
- req.user = req.session?.user || null;
- next();
- }
- // Helper function to generate index.json
- async function generateIndex() {
- try {
- const files = await fs.readdir(POSTS_DIR);
- // We only care about root level .md files for the index
- const mdFiles = files.filter((f) => f.endsWith(".md"));
- await fs.writeJSON(INDEX_FILE, mdFiles, { spaces: 2 });
- console.log(`Index updated: ${mdFiles.length} posts`);
- return mdFiles;
- } catch (error) {
- console.error("Error generating index:", error);
- throw error;
- }
- }
- // Helper function to parse post metadata
- function parsePostMetadata(content) {
- const titleMatch = content.match(/title:\s*(.*)/);
- const descMatch = content.match(/desc:\s*(.*)/);
- const tagsMatch = content.match(/tags:\s*(.*)/);
- const hiddenMatch = content.match(/hidden:\s*(true|false)/);
- const pinnedMatch = content.match(/pinned:\s*(true|false)/);
- return {
- title: titleMatch ? titleMatch[1].trim() : "Untitled",
- description: descMatch ? descMatch[1].trim() : "",
- tags: tagsMatch ? tagsMatch[1].split(",").map((tag) => tag.trim()) : [],
- hidden: hiddenMatch ? hiddenMatch[1] === "true" : false,
- pinned: pinnedMatch ? pinnedMatch[1] === "true" : false,
- };
- }
- // Helper function to generate filename from title
- function generateFilename(title, dateOverride = null) {
- // Create date-based filename similar to existing pattern
- // Use override if provided, otherwise current date
- let dateStr;
- if (dateOverride) {
- dateStr = dateOverride;
- } else {
- const date = new Date();
- dateStr = date.toISOString().slice(0, 10).replace(/-/g, "");
- }
- const slug = title
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-|-$/g, "")
- .slice(0, 30);
- return slug ? `${dateStr}-${slug}.md` : `${dateStr}.md`;
- }
- // Helper to generate just the SLUG part (without date/extension if possible, or matches filename structure)
- // For internal consistency, we should try to match how valid posts are named.
- // But if user creates a "temporary" slug from title, we use that.
- function getSlugFromTitle(title) {
- const date = new Date();
- const dateStr = date.toISOString().slice(0, 10).replace(/-/g, "");
- const slugText = title
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "-")
- .replace(/^-|-$/g, "")
- .slice(0, 30);
- return `${dateStr}-${slugText}`;
- }
- // Authentication Routes
- // POST /api/auth/login - Login
- app.post("/api/auth/login", async (req, res) => {
- try {
- const { username, password } = req.body;
- if (!username || !password) {
- return res
- .status(400)
- .json({ error: "Username and password are required" });
- }
- const user = await authenticateUser(username, password);
- if (!user) {
- return res
- .status(401)
- .json({ error: "Invalid username or password" });
- }
- // Store user in session
- req.session.user = user;
- console.log("Login successful - Session ID:", req.sessionID);
- console.log("Login successful - Stored user:", req.session.user);
- // Manually save the session to ensure it's persisted
- req.session.save((err) => {
- if (err) {
- console.error("Session save error:", err);
- return res
- .status(500)
- .json({ error: "Failed to save session" });
- }
- console.log("Session saved successfully");
- res.json({
- success: true,
- user: {
- username: user.username,
- role: user.role,
- },
- });
- });
- } catch (error) {
- console.error("Login error:", error);
- res.status(500).json({ error: "Login failed" });
- }
- });
- // POST /api/auth/logout - Logout
- app.post("/api/auth/logout", (req, res) => {
- req.session.destroy((err) => {
- if (err) {
- return res.status(500).json({ error: "Logout failed" });
- }
- res.clearCookie("gooneral-session"); // Use the same name as configured
- res.json({ success: true, message: "Logged out successfully" });
- });
- });
- // GET /api/auth/me - Get current user
- app.get("/api/auth/me", isAuthenticated, (req, res) => {
- console.log("Auth check - Session ID:", req.sessionID);
- console.log("Auth check - Session user:", req.session?.user);
- console.log("Auth check - Is authenticated:", req.isAuthenticated);
- if (req.isAuthenticated) {
- res.json({
- user: {
- username: req.user.username,
- role: req.user.role,
- },
- });
- } else {
- res.json({ user: null });
- }
- });
- // POST /api/auth/change-password - Change password
- app.post(
- "/api/auth/change-password",
- requireAuth,
- isAuthenticated,
- async (req, res) => {
- try {
- const { currentPassword, newPassword } = req.body;
- console.log("Change password request for user:", req.user.username);
- console.log(
- "Current password received:",
- currentPassword ? "Yes" : "No",
- );
- console.log("New password received:", newPassword ? "Yes" : "No");
- if (!currentPassword || !newPassword) {
- return res.status(400).json({
- error: "Current password and new password are required",
- });
- }
- if (newPassword.length < 6) {
- return res.status(400).json({
- error: "New password must be at least 6 characters long",
- });
- }
- const result = await changeUserPassword(
- req.user.username,
- currentPassword,
- newPassword,
- );
- console.log("Result from changeUserPassword:", result);
- if (result.success) {
- res.json({ success: true, message: result.message });
- } else {
- res.status(400).json({ error: result.message });
- }
- } catch (error) {
- console.error("Change password error:", error);
- res.status(500).json({ error: "Failed to change password" });
- }
- },
- );
- // AI Generation Route
- app.post("/api/ai/generate", requireAuth, async (req, res) => {
- try {
- const { context, prompt } = req.body;
- const apiKey = process.env.GEMINI_API_KEY;
- if (!apiKey) {
- return res
- .status(500)
- .json({ error: "AI API key is not configured." });
- }
- if (!prompt) {
- return res.status(400).json({ error: "A prompt is required." });
- }
- res.setHeader("Content-Type", "text/event-stream");
- res.setHeader("Cache-Control", "no-cache");
- res.setHeader("Connection", "keep-alive");
- const genAI = new GoogleGenerativeAI(apiKey);
- const model = genAI.getGenerativeModel({
- model: "gemini-3-flash-preview",
- });
- const fullPrompt = `${context}\n\n${prompt}`;
- const result = await model.generateContentStream(fullPrompt);
- for await (const chunk of result.stream) {
- const chunkText = chunk.text();
- res.write(`data: ${JSON.stringify({ chunk: chunkText })}\n\n`);
- }
- res.end();
- } catch (error) {
- console.error("AI generation error:", error);
- // We can't send a 500 status here as the headers are already sent.
- // We can write an error event to the stream.
- res.write(
- `data: ${JSON.stringify({ error: "Failed to generate AI content." })}\n\n`,
- );
- res.end();
- }
- });
- // MEDIA ROUTES
- // POST /api/upload - Upload file
- // Query param: slug (optional, for organization)
- app.post("/api/upload", requireAuth, upload.single("file"), (req, res) => {
- if (!req.file) {
- return res.status(400).json({ error: "No file uploaded" });
- }
- const { slug } = req.query;
- let urlPath = "";
- if (slug) {
- // Should match the folder structure logic in storage config
- const cleanSlug = slug.replace(/[^a-z0-9-]/gi, "");
- urlPath = `/media-files/${cleanSlug}/images/${req.file.filename}`;
- } else {
- urlPath = `/media-files/uploads/${req.file.filename}`;
- }
- res.json({
- success: true,
- url: urlPath,
- filename: req.file.filename,
- originalName: req.file.originalname,
- });
- });
- // GET /api/media - List files
- // Query param: slug (optional)
- app.get("/api/media", requireAuth, async (req, res) => {
- try {
- const { slug } = req.query;
- let targetDir = "";
- if (slug) {
- const cleanSlug = slug.replace(/[^a-z0-9-]/gi, "");
- targetDir = path.join(POSTS_DIR, cleanSlug, "images");
- } else {
- // If no slug, maybe list all? Or list 'uploads'?
- // Providing a 'root' param or just listing everything might be expensive.
- // Let's list 'uploads' by default or require a slug properly.
- // For a "Media Manager", we might want to scan ALL folders.
- // For now, let's implement listing a specific slug's images.
- // If we want a global manager, we can return a tree.
- // Simple approach: list all directories in POSTS_DIR that are directories, and then their images.
- // That's complex. Let's just return empty if no slug, or implement logic later for global view.
- // Let's default to "uploads" if no slug, OR if a special flag "all" is present, we scan?
- targetDir = path.join(POSTS_DIR, "uploads");
- }
- if (!(await fs.pathExists(targetDir))) {
- return res.json([]);
- }
- const files = await fs.readdir(targetDir);
- const mediaFiles = [];
- const { type } = req.query; // 'image' or 'csv'
- for (const file of files) {
- const stats = await fs.stat(path.join(targetDir, file));
- if (!stats.isFile()) continue;
- const isImage = /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file);
- const isCsv = /\.csv$/i.test(file);
- // Filter logic
- let shouldInclude = false;
- if (type === "image") {
- shouldInclude = isImage;
- } else if (type === "csv") {
- shouldInclude = isCsv;
- } else {
- shouldInclude = isImage || isCsv;
- }
- if (shouldInclude) {
- const slugPart = slug
- ? slug.replace(/[^a-z0-9-]/gi, "")
- : "uploads";
- const url = slug
- ? `/media-files/${slugPart}/images/${file}`
- : `/media-files/uploads/${file}`;
- mediaFiles.push({
- name: file,
- url: url,
- size: stats.size,
- date: stats.mtime,
- type: isImage ? "image" : isCsv ? "csv" : "file",
- });
- }
- }
- // Sort by newest
- mediaFiles.sort((a, b) => b.date - a.date);
- res.json(mediaFiles);
- } catch (err) {
- console.error("Error listing media:", err);
- res.status(500).json({ error: "Failed to list media" });
- }
- });
- // DELETE /api/media - Delete file
- app.delete("/api/media", requireAuth, async (req, res) => {
- try {
- const { path: relativePath } = req.body; // e.g. "/media-files/slug/images/file.jpg"
- if (!relativePath)
- return res.status(400).json({ error: "Path is required" });
- // Security check: ensure path is within POSTS_DIR
- // relativePath typically starts with /media-files/
- const cleanPath = relativePath.replace(/^\/media-files\//, "");
- const fullPath = path.join(POSTS_DIR, cleanPath);
- // Prevent directory traversal
- if (!fullPath.startsWith(POSTS_DIR)) {
- return res.status(403).json({ error: "Invalid path" });
- }
- if (await fs.pathExists(fullPath)) {
- await fs.remove(fullPath);
- res.json({ success: true });
- } else {
- res.status(404).json({ error: "File not found" });
- }
- } catch (err) {
- console.error("Delete media error:", err);
- res.status(500).json({ error: "Failed to delete media" });
- }
- });
- // API Routes continue...
- // GET /api/posts - Get all posts with metadata
- app.get("/api/posts", async (req, res) => {
- try {
- const files = await generateIndex();
- const posts = [];
- for (const filename of files) {
- const filePath = path.join(POSTS_DIR, filename);
- const content = await fs.readFile(filePath, "utf8");
- const metadata = parsePostMetadata(content);
- const slug = filename.replace(".md", "");
- posts.push({
- slug,
- filename,
- ...metadata,
- createdAt: (await fs.stat(filePath)).birthtime,
- updatedAt: (await fs.stat(filePath)).mtime,
- });
- }
- // Sort by pinned (true first), then by creation date (newest first)
- posts.sort((a, b) => {
- if (a.pinned && !b.pinned) return -1;
- if (!a.pinned && b.pinned) return 1;
- return new Date(b.createdAt) - new Date(a.createdAt);
- });
- // Filter out hidden posts for non-admins
- const isAdmin =
- req.session &&
- req.session.user &&
- req.session.user.role === "admin";
- const visiblePosts = posts.filter((post) => isAdmin || !post.hidden);
- res.json(visiblePosts);
- } catch (error) {
- console.error("Error fetching posts:", error);
- res.status(500).json({ error: "Failed to fetch posts" });
- }
- });
- // GET /api/posts/:slug - Get specific post
- app.get("/api/posts/:slug", async (req, res) => {
- try {
- const { slug } = req.params;
- const filename = `${slug}.md`;
- const filePath = path.join(POSTS_DIR, filename);
- if (!(await fs.pathExists(filePath))) {
- return res.status(404).json({ error: "Post not found" });
- }
- const content = await fs.readFile(filePath, "utf8");
- const metadata = parsePostMetadata(content);
- const stats = await fs.stat(filePath);
- // Access control for hidden posts
- const isAdmin =
- req.session &&
- req.session.user &&
- req.session.user.role === "admin";
- if (metadata.hidden && !isAdmin) {
- // Return 404 to hide existence, or 403 if we want to be explicit.
- // 404 is safer for "hidden" content.
- return res.status(404).json({ error: "Post not found" });
- }
- res.json({
- slug,
- filename,
- ...metadata,
- content,
- createdAt: stats.birthtime,
- updatedAt: stats.mtime,
- });
- } catch (error) {
- console.error("Error fetching post:", error);
- res.status(500).json({ error: "Failed to fetch post" });
- }
- });
- // POST /api/posts - Create new post
- app.post("/api/posts", requireAuth, async (req, res) => {
- try {
- const { title, description, content, tags, hidden, pinned } = req.body;
- if (!title || !content) {
- return res
- .status(400)
- .json({ error: "Title and content are required" });
- }
- // Generate filename
- const filename = generateFilename(title);
- const filePath = path.join(POSTS_DIR, filename);
- // Check if file already exists
- if (await fs.pathExists(filePath)) {
- return res
- .status(409)
- .json({ error: "Post with similar title already exists" });
- }
- // Format the post content
- let postContent = "";
- postContent += `title: ${title}\n`;
- if (description) postContent += `desc: ${description}\n`;
- if (tags && tags.length > 0)
- postContent += `tags: ${tags.join(", ")}\n`;
- if (hidden) postContent += `hidden: true\n`;
- if (pinned) postContent += `pinned: true\n`;
- postContent += "\n" + content;
- // Write the file
- await fs.writeFile(filePath, postContent, "utf8");
- // Update index
- await generateIndex();
- const slug = filename.replace(".md", "");
- const stats = await fs.stat(filePath);
- res.status(201).json({
- slug,
- filename,
- title,
- description: description || "",
- tags: tags || [],
- content: postContent,
- createdAt: stats.birthtime,
- updatedAt: stats.mtime,
- });
- } catch (error) {
- console.error("Error creating post:", error);
- res.status(500).json({ error: "Failed to create post" });
- }
- });
- // PUT /api/posts/:slug - Update existing post
- app.put("/api/posts/:slug", requireAuth, async (req, res) => {
- try {
- const { slug } = req.params;
- const { title, description, content, tags, hidden, pinned } = req.body;
- const oldFilename = `${slug}.md`;
- const oldFilePath = path.join(POSTS_DIR, oldFilename);
- if (!(await fs.pathExists(oldFilePath))) {
- return res.status(404).json({ error: "Post not found" });
- }
- if (!title || !content) {
- return res
- .status(400)
- .json({ error: "Title and content are required" });
- }
- // Extract date from existing slug/filename to preserve it
- // Filename format: YYYYMMDD-slug.md
- // We assume the first 8 chars are the date if they are digits
- const dateMatch = slug.match(/^(\d{8})-/);
- const originalDate = dateMatch ? dateMatch[1] : null;
- // Generate new filename if title changed, but PRESERVE original date
- const newFilename = generateFilename(title, originalDate);
- const newFilePath = path.join(POSTS_DIR, newFilename);
- // Format the post content
- let postContent = "";
- postContent += `title: ${title}\n`;
- if (description) postContent += `desc: ${description}\n`;
- if (tags && tags.length > 0)
- postContent += `tags: ${tags.join(", ")}\n`;
- if (hidden) postContent += `hidden: true\n`;
- if (pinned) postContent += `pinned: true\n`;
- postContent += "\n" + content;
- // Write to new file
- await fs.writeFile(newFilePath, postContent, "utf8");
- // If filename changed, remove old file
- if (oldFilename !== newFilename) {
- await fs.remove(oldFilePath);
- // IMPORTANT: If we rename the post, should we verify if images folder usage needs update?
- // Currently images are stored in /slug/images/.
- // If the slug (filename) depends on the title, changing title changes slug.
- // We should rename the image folder too!
- const oldSlug = slug;
- const newSlug = newFilename.replace(".md", "");
- const oldImgDir = path.join(POSTS_DIR, oldSlug, "images");
- const newImgDir = path.join(POSTS_DIR, newSlug, "images");
- if (await fs.pathExists(oldImgDir)) {
- await fs.move(oldImgDir, newImgDir, { overwrite: true });
- }
- }
- // Update index
- await generateIndex();
- const newSlug = newFilename.replace(".md", "");
- const stats = await fs.stat(newFilePath);
- res.json({
- slug: newSlug,
- filename: newFilename,
- title,
- description: description || "",
- tags: tags || [],
- content: postContent,
- createdAt: stats.birthtime,
- updatedAt: stats.mtime,
- });
- } catch (error) {
- console.error("Error updating post:", error);
- res.status(500).json({ error: "Failed to update post" });
- }
- });
- // DELETE /api/posts/:slug - Delete post
- app.delete("/api/posts/:slug", requireAuth, async (req, res) => {
- try {
- const { slug } = req.params;
- const filename = `${slug}.md`;
- const filePath = path.join(POSTS_DIR, filename);
- if (!(await fs.pathExists(filePath))) {
- return res.status(404).json({ error: "Post not found" });
- }
- await fs.remove(filePath);
- // Removing associated images folder
- const imgDir = path.join(POSTS_DIR, slug, "images");
- if (await fs.pathExists(imgDir)) {
- await fs.remove(imgDir);
- }
- // Also remove parent folder if it was created just for this?
- // Structure is POSTS_DIR/slug/images.
- // We should remove POSTS_DIR/slug
- const slugDir = path.join(POSTS_DIR, slug);
- if (await fs.pathExists(slugDir)) {
- await fs.remove(slugDir);
- }
- await generateIndex();
- res.json({ message: "Post deleted successfully" });
- } catch (error) {
- console.error("Error deleting post:", error);
- res.status(500).json({ error: "Failed to delete post" });
- }
- });
- // Theme API Routes
- // GET /api/themes - Get all themes
- app.get("/api/themes", async (req, res) => {
- try {
- const themesData = await getAllThemes();
- res.json(themesData);
- } catch (error) {
- console.error("Error fetching themes:", error);
- res.status(500).json({ error: "Failed to fetch themes" });
- }
- });
- // GET /api/themes/active - Get active theme
- app.get("/api/themes/active", async (req, res) => {
- try {
- const activeTheme = await getActiveTheme();
- res.json(activeTheme);
- } catch (error) {
- console.error("Error fetching active theme:", error);
- res.status(500).json({ error: "Failed to fetch active theme" });
- }
- });
- // PUT /api/themes/active - Set active theme
- app.put("/api/themes/active", requireAuth, async (req, res) => {
- try {
- const { themeId } = req.body;
- if (!themeId) {
- return res.status(400).json({ error: "Theme ID is required" });
- }
- const activeTheme = await setActiveTheme(themeId);
- res.json({ success: true, theme: activeTheme });
- } catch (error) {
- console.error("Error setting active theme:", error);
- res.status(400).json({ error: error.message });
- }
- });
- import { getConfig, updateConfig } from "./config.js";
- // GET /api/config - Get app configuration
- app.get("/api/config", async (req, res) => {
- try {
- const config = await getConfig();
- res.json(config);
- } catch (error) {
- console.error("Error getting config:", error);
- res.status(500).json({ error: "Failed to get config" });
- }
- });
- // PUT /api/config - Update app configuration
- app.put("/api/config", requireAuth, async (req, res) => {
- try {
- const updates = req.body;
- // Whitelist allowed config keys to prevent abuse
- const allowedKeys = ["postWidth", "activeTheme"];
- const filteredUpdates = Object.keys(updates)
- .filter((key) => allowedKeys.includes(key))
- .reduce((obj, key) => {
- obj[key] = updates[key];
- return obj;
- }, {});
- const newConfig = await updateConfig(filteredUpdates);
- res.json({ success: true, config: newConfig });
- } catch (error) {
- console.error("Error updating config:", error);
- res.status(500).json({ error: "Failed to update config" });
- }
- });
- // POST /api/themes - Create custom theme
- app.post("/api/themes", requireAuth, async (req, res) => {
- try {
- const themeData = req.body;
- const newTheme = await createCustomTheme(themeData);
- res.status(201).json(newTheme);
- } catch (error) {
- console.error("Error creating theme:", error);
- res.status(400).json({ error: error.message });
- }
- });
- // PUT /api/themes/:themeId - Update custom theme
- app.put("/api/themes/:themeId", requireAuth, async (req, res) => {
- try {
- const { themeId } = req.params;
- const themeData = req.body;
- const updatedTheme = await updateCustomTheme(themeId, themeData);
- res.json(updatedTheme);
- } catch (error) {
- console.error("Error updating theme:", error);
- res.status(400).json({ error: error.message });
- }
- });
- // DELETE /api/themes/:themeId - Delete custom theme
- app.delete("/api/themes/:themeId", requireAuth, async (req, res) => {
- try {
- const { themeId } = req.params;
- await deleteCustomTheme(themeId);
- res.json({ success: true, message: "Theme deleted successfully" });
- } catch (error) {
- console.error("Error deleting theme:", error);
- res.status(400).json({ error: error.message });
- }
- });
- // GET /api/themes/:themeId/export - Export theme
- app.get("/api/themes/:themeId/export", requireAuth, async (req, res) => {
- try {
- const { themeId } = req.params;
- const themeData = await exportTheme(themeId);
- res.setHeader("Content-Type", "application/json");
- res.setHeader(
- "Content-Disposition",
- `attachment; filename="theme-${themeId}.json"`,
- );
- res.json(themeData);
- } catch (error) {
- console.error("Error exporting theme:", error);
- res.status(400).json({ error: error.message });
- }
- });
- // Health check endpoint
- app.get("/api/health", (req, res) => {
- res.json({ status: "OK", timestamp: new Date().toISOString() });
- });
- // SERVE FRONTEND (SSR-lite for Meta Tags)
- const DIST_DIR = path.join(__dirname, "../dist");
- const INDEX_HTML = path.join(DIST_DIR, "index.html");
- // Serve static assets with aggressive caching
- // Vite assets are hashed (e.g., index.h4124j.js), so they are immutable.
- app.use(
- "/assets",
- express.static(path.join(DIST_DIR, "assets"), {
- maxAge: "1y", // 1 year
- immutable: true,
- setHeaders: (res, path) => {
- res.setHeader(
- "Cache-Control",
- "public, max-age=31536000, immutable",
- );
- },
- }),
- );
- // Serve other static files (favicon, robots, etc.) with default caching
- app.use(express.static(DIST_DIR, { index: false }));
- // Helper to inject meta tags
- const injectMetaTags = async (html, metadata, url) => {
- let injected = html;
- // Default values
- const title = metadata.title || "Gooneral Wheelchair";
- const description = metadata.description || "A blog about stuff.";
- const image =
- metadata.image || "https://goonblog.thevakhovske.eu.org/og-image.jpg"; // Fallback image
- // Replace Title
- injected = injected.replace(
- /<title>.*<\/title>/,
- `<title>${title}</title>`,
- );
- // Meta Tags to Inject
- const metaTags = `
- <meta property="og:title" content="${title}" />
- <meta property="og:description" content="${description}" />
- <meta property="og:image" content="${image}" />
- <meta property="og:url" content="${url}" />
- <meta property="og:type" content="article" />
- <meta name="twitter:card" content="summary_large_image" />
- <meta name="twitter:title" content="${title}" />
- <meta name="twitter:description" content="${description}" />
- <meta name="twitter:image" content="${image}" />
- `;
- // Inject before </head>
- return injected.replace("</head>", `${metaTags}</head>`);
- };
- // Handle Post Routes for SSR
- app.get("/posts/:slug", async (req, res) => {
- try {
- const { slug } = req.params;
- const filename = `${slug}.md`;
- const filePath = path.join(POSTS_DIR, filename);
- // Read index.html template
- let html = await fs.readFile(INDEX_HTML, "utf8");
- if (await fs.pathExists(filePath)) {
- const content = await fs.readFile(filePath, "utf8");
- const metadata = parsePostMetadata(content);
- // Try to find the first image in the content for the OG image
- const imageMatch = content.match(/!\[.*?\]\((.*?)\)/);
- let imageUrl = null;
- if (imageMatch) {
- imageUrl = imageMatch[1];
- // Ensure absolute URL
- if (imageUrl.startsWith("/")) {
- imageUrl = `${req.protocol}://${req.get("host")}${imageUrl}`;
- }
- }
- // Check access (Hidden posts)
- // If hidden and not admin, serve standard index.html (SPA will handle 404/auth check client-side)
- // OR we can pretend it doesn't exist to bots.
- const isAdmin =
- req.session &&
- req.session.user &&
- req.session.user.role === "admin";
- if (metadata.hidden && !isAdmin) {
- // Determine behavior:
- // If we send raw index.html, client app loads and shows "Not Found" or "Login".
- // We'll just send raw index.html without generic meta tags? Or default tags?
- // Let's send default tags to avoid leaking info.
- res.send(html);
- return;
- }
- const pageMetadata = {
- title: metadata.title,
- description: metadata.description,
- image: imageUrl,
- };
- const finalHtml = await injectMetaTags(
- html,
- pageMetadata,
- `${req.protocol}://${req.get("host")}${req.originalUrl}`,
- );
- res.send(finalHtml);
- } else {
- // Post not found - serve SPA to handle 404
- res.send(html);
- }
- } catch (err) {
- console.error("SSR Error:", err);
- // Fallback to serving static file if something fails
- res.sendFile(INDEX_HTML);
- }
- });
- // Catch-all route for SPA
- app.get("*", async (req, res) => {
- // For other routes (e.g. /, /admin, /login), serve index.html
- // We can also inject default meta tags here if we want.
- try {
- if (await fs.pathExists(INDEX_HTML)) {
- res.sendFile(INDEX_HTML);
- } else {
- res.status(404).send("Frontend build not found");
- }
- } catch (err) {
- res.status(500).send("Server Error");
- }
- });
- // Generate initial index on startup
- await generateIndex();
- app.listen(PORT, () => {
- console.log(`🚀 Backend server running on http://localhost:${PORT}`);
- console.log(`📁 Posts directory: ${POSTS_DIR}`);
- });
|