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"; // 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"); // 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 })); // 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); 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*(.*)/); return { title: titleMatch ? titleMatch[1].trim() : "Untitled", description: descMatch ? descMatch[1].trim() : "", tags: tagsMatch ? tagsMatch[1].split(",").map((tag) => tag.trim()) : [], }; } // 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`; } // 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" }); } }); // API Routes // 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, content, 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)); res.json(posts); } 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); 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 } = 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`; 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 } = 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`; 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); } // 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); 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() }); }); // 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}`); });