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"; // 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) { // Keep original filename but ensure uniqueness if needed? // For now, simple filename is better for markdown readability. // We'll replace spaces with dashes. const cleanName = file.originalname.replace(/[^a-z0-9.]/gi, "-").toLowerCase(); cb(null, cleanName); // Overwrites if exists, which might be desired behavior for updating images }, }); 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)/); 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, }; } // Helper function to generate filename from title function generateFilename(title) { // Create date-based filename similar to existing pattern const date = new Date(); const 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, async (req, res) => { try { const { currentPassword, newPassword } = req.body; 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, ); 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" }); } }); // 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 = []; for (const file of files) { const stats = await fs.stat(path.join(targetDir, file)); if (stats.isFile() && /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file)) { const slugPart = slug ? slug.replace(/[^a-z0-9-]/gi, "") : "uploads"; // If slug was passed, url is /media-files/SLUG/images/FILE // If no slug (using uploads), url is /media-files/uploads/FILE const url = slug ? `/media-files/${slugPart}/images/${file}` : `/media-files/uploads/${file}`; mediaFiles.push({ name: file, url: url, size: stats.size, date: stats.mtime, }); } } // 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 creation date, newest first posts.sort((a, b) => 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 } = 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`; 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 } = 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" }); } // Generate new filename if title changed const newFilename = generateFilename(title); 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`; 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 }); } }); // 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(/