server.js 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902
  1. import dotenv from "dotenv";
  2. import express from "express";
  3. import cors from "cors";
  4. import session from "express-session";
  5. import fs from "fs-extra";
  6. import path from "path";
  7. import { fileURLToPath } from "url";
  8. import { v4 as uuidv4 } from "uuid";
  9. import multer from "multer";
  10. // Load environment variables
  11. dotenv.config();
  12. import {
  13. authenticateUser,
  14. getUserByUsername,
  15. changeUserPassword,
  16. } from "./auth.js";
  17. import {
  18. getAllThemes,
  19. getActiveTheme,
  20. setActiveTheme,
  21. createCustomTheme,
  22. updateCustomTheme,
  23. deleteCustomTheme,
  24. exportTheme,
  25. } from "./themes.js";
  26. const __filename = fileURLToPath(import.meta.url);
  27. const __dirname = path.dirname(__filename);
  28. const app = express();
  29. app.set("trust proxy", 1); // Trust the first proxy (Caddy) so secure cookies work behind HTTPS termination
  30. const PORT = process.env.PORT || 3001;
  31. // Paths
  32. const POSTS_DIR = path.resolve(
  33. __dirname,
  34. process.env.POSTS_DIR || "../public/posts",
  35. );
  36. const INDEX_FILE = path.join(POSTS_DIR, "index.json");
  37. // Multer Storage Configuration
  38. const storage = multer.diskStorage({
  39. destination: async function (req, file, cb) {
  40. // Determine folder based on slug
  41. let folder = "uploads"; // Default
  42. if (req.query.slug) {
  43. // Clean the slug just in case, though it should be safe if generated by us
  44. const cleanSlug = req.query.slug.replace(/[^a-z0-9-]/gi, "");
  45. folder = path.join(cleanSlug, "images");
  46. }
  47. const uploadPath = path.join(POSTS_DIR, folder);
  48. try {
  49. await fs.ensureDir(uploadPath);
  50. cb(null, uploadPath);
  51. } catch (err) {
  52. cb(err);
  53. }
  54. },
  55. filename: function (req, file, cb) {
  56. const ext = path.extname(file.originalname).toLowerCase();
  57. const namePart = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, "-").toLowerCase();
  58. const timestamp = Date.now();
  59. const newFilename = `${namePart}_${timestamp}${ext}`;
  60. cb(null, newFilename);
  61. },
  62. });
  63. const upload = multer({ storage: storage });
  64. // Middleware
  65. app.use(
  66. cors({
  67. origin: [
  68. "http://localhost:5173",
  69. "https://goonblog.thevakhovske.eu.org",
  70. ],
  71. credentials: true, // Enable cookies
  72. }),
  73. );
  74. app.use(express.json());
  75. app.use(express.urlencoded({ extended: true }));
  76. // Serve static files from POSTS_DIR so images are accessible
  77. // Access images via /api/media-files/SLUG/images/FILENAME.jpg
  78. // We use /api/media-files to avoid conflict with /api/posts routes and to align with API_BASE
  79. app.use("/api/media-files", express.static(POSTS_DIR));
  80. // Session configuration
  81. app.use(
  82. session({
  83. secret:
  84. process.env.SESSION_SECRET ||
  85. "gooneral-wheelchair-secret-key-change-in-production",
  86. resave: false,
  87. saveUninitialized: false,
  88. name: "gooneral-session",
  89. cookie: {
  90. secure: true, // HTTPS required
  91. httpOnly: true,
  92. maxAge: 24 * 60 * 60 * 1000, // 24 hours
  93. sameSite: "lax", // Changed from 'strict' to 'lax'
  94. },
  95. }),
  96. );
  97. // Ensure posts directory exists
  98. await fs.ensureDir(POSTS_DIR);
  99. // Authentication middleware
  100. function requireAuth(req, res, next) {
  101. if (req.session && req.session.user && req.session.user.role === "admin") {
  102. return next();
  103. }
  104. return res.status(401).json({ error: "Authentication required" });
  105. }
  106. // Check if user is authenticated
  107. function isAuthenticated(req, res, next) {
  108. req.isAuthenticated = !!(req.session && req.session.user);
  109. req.user = req.session?.user || null;
  110. next();
  111. }
  112. // Helper function to generate index.json
  113. async function generateIndex() {
  114. try {
  115. const files = await fs.readdir(POSTS_DIR);
  116. // We only care about root level .md files for the index
  117. const mdFiles = files.filter((f) => f.endsWith(".md"));
  118. await fs.writeJSON(INDEX_FILE, mdFiles, { spaces: 2 });
  119. console.log(`Index updated: ${mdFiles.length} posts`);
  120. return mdFiles;
  121. } catch (error) {
  122. console.error("Error generating index:", error);
  123. throw error;
  124. }
  125. }
  126. // Helper function to parse post metadata
  127. function parsePostMetadata(content) {
  128. const titleMatch = content.match(/title:\s*(.*)/);
  129. const descMatch = content.match(/desc:\s*(.*)/);
  130. const tagsMatch = content.match(/tags:\s*(.*)/);
  131. const hiddenMatch = content.match(/hidden:\s*(true|false)/);
  132. return {
  133. title: titleMatch ? titleMatch[1].trim() : "Untitled",
  134. description: descMatch ? descMatch[1].trim() : "",
  135. tags: tagsMatch ? tagsMatch[1].split(",").map((tag) => tag.trim()) : [],
  136. hidden: hiddenMatch ? hiddenMatch[1] === "true" : false,
  137. };
  138. }
  139. // Helper function to generate filename from title
  140. function generateFilename(title, dateOverride = null) {
  141. // Create date-based filename similar to existing pattern
  142. // Use override if provided, otherwise current date
  143. let dateStr;
  144. if (dateOverride) {
  145. dateStr = dateOverride;
  146. } else {
  147. const date = new Date();
  148. dateStr = date.toISOString().slice(0, 10).replace(/-/g, "");
  149. }
  150. const slug = title
  151. .toLowerCase()
  152. .replace(/[^a-z0-9]+/g, "-")
  153. .replace(/^-|-$/g, "")
  154. .slice(0, 30);
  155. return slug ? `${dateStr}-${slug}.md` : `${dateStr}.md`;
  156. }
  157. // Helper to generate just the SLUG part (without date/extension if possible, or matches filename structure)
  158. // For internal consistency, we should try to match how valid posts are named.
  159. // But if user creates a "temporary" slug from title, we use that.
  160. function getSlugFromTitle(title) {
  161. const date = new Date();
  162. const dateStr = date.toISOString().slice(0, 10).replace(/-/g, "");
  163. const slugText = title
  164. .toLowerCase()
  165. .replace(/[^a-z0-9]+/g, "-")
  166. .replace(/^-|-$/g, "")
  167. .slice(0, 30);
  168. return `${dateStr}-${slugText}`;
  169. }
  170. // Authentication Routes
  171. // POST /api/auth/login - Login
  172. app.post("/api/auth/login", async (req, res) => {
  173. try {
  174. const { username, password } = req.body;
  175. if (!username || !password) {
  176. return res
  177. .status(400)
  178. .json({ error: "Username and password are required" });
  179. }
  180. const user = await authenticateUser(username, password);
  181. if (!user) {
  182. return res
  183. .status(401)
  184. .json({ error: "Invalid username or password" });
  185. }
  186. // Store user in session
  187. req.session.user = user;
  188. console.log("Login successful - Session ID:", req.sessionID);
  189. console.log("Login successful - Stored user:", req.session.user);
  190. // Manually save the session to ensure it's persisted
  191. req.session.save((err) => {
  192. if (err) {
  193. console.error("Session save error:", err);
  194. return res
  195. .status(500)
  196. .json({ error: "Failed to save session" });
  197. }
  198. console.log("Session saved successfully");
  199. res.json({
  200. success: true,
  201. user: {
  202. username: user.username,
  203. role: user.role,
  204. },
  205. });
  206. });
  207. } catch (error) {
  208. console.error("Login error:", error);
  209. res.status(500).json({ error: "Login failed" });
  210. }
  211. });
  212. // POST /api/auth/logout - Logout
  213. app.post("/api/auth/logout", (req, res) => {
  214. req.session.destroy((err) => {
  215. if (err) {
  216. return res.status(500).json({ error: "Logout failed" });
  217. }
  218. res.clearCookie("gooneral-session"); // Use the same name as configured
  219. res.json({ success: true, message: "Logged out successfully" });
  220. });
  221. });
  222. // GET /api/auth/me - Get current user
  223. app.get("/api/auth/me", isAuthenticated, (req, res) => {
  224. console.log("Auth check - Session ID:", req.sessionID);
  225. console.log("Auth check - Session user:", req.session?.user);
  226. console.log("Auth check - Is authenticated:", req.isAuthenticated);
  227. if (req.isAuthenticated) {
  228. res.json({
  229. user: {
  230. username: req.user.username,
  231. role: req.user.role,
  232. },
  233. });
  234. } else {
  235. res.json({ user: null });
  236. }
  237. });
  238. // POST /api/auth/change-password - Change password
  239. app.post("/api/auth/change-password", requireAuth, async (req, res) => {
  240. try {
  241. const { currentPassword, newPassword } = req.body;
  242. if (!currentPassword || !newPassword) {
  243. return res.status(400).json({
  244. error: "Current password and new password are required",
  245. });
  246. }
  247. if (newPassword.length < 6) {
  248. return res.status(400).json({
  249. error: "New password must be at least 6 characters long",
  250. });
  251. }
  252. const result = await changeUserPassword(
  253. req.user.username,
  254. currentPassword,
  255. newPassword,
  256. );
  257. if (result.success) {
  258. res.json({ success: true, message: result.message });
  259. } else {
  260. res.status(400).json({ error: result.message });
  261. }
  262. } catch (error) {
  263. console.error("Change password error:", error);
  264. res.status(500).json({ error: "Failed to change password" });
  265. }
  266. });
  267. // MEDIA ROUTES
  268. // POST /api/upload - Upload file
  269. // Query param: slug (optional, for organization)
  270. app.post("/api/upload", requireAuth, upload.single("file"), (req, res) => {
  271. if (!req.file) {
  272. return res.status(400).json({ error: "No file uploaded" });
  273. }
  274. const { slug } = req.query;
  275. let urlPath = "";
  276. if (slug) {
  277. // Should match the folder structure logic in storage config
  278. const cleanSlug = slug.replace(/[^a-z0-9-]/gi, "");
  279. urlPath = `/media-files/${cleanSlug}/images/${req.file.filename}`;
  280. } else {
  281. urlPath = `/media-files/uploads/${req.file.filename}`;
  282. }
  283. res.json({
  284. success: true,
  285. url: urlPath,
  286. filename: req.file.filename,
  287. originalName: req.file.originalname,
  288. });
  289. });
  290. // GET /api/media - List files
  291. // Query param: slug (optional)
  292. app.get("/api/media", requireAuth, async (req, res) => {
  293. try {
  294. const { slug } = req.query;
  295. let targetDir = "";
  296. if (slug) {
  297. const cleanSlug = slug.replace(/[^a-z0-9-]/gi, "");
  298. targetDir = path.join(POSTS_DIR, cleanSlug, "images");
  299. } else {
  300. // If no slug, maybe list all? Or list 'uploads'?
  301. // Providing a 'root' param or just listing everything might be expensive.
  302. // Let's list 'uploads' by default or require a slug properly.
  303. // For a "Media Manager", we might want to scan ALL folders.
  304. // For now, let's implement listing a specific slug's images.
  305. // If we want a global manager, we can return a tree.
  306. // Simple approach: list all directories in POSTS_DIR that are directories, and then their images.
  307. // That's complex. Let's just return empty if no slug, or implement logic later for global view.
  308. // Let's default to "uploads" if no slug, OR if a special flag "all" is present, we scan?
  309. targetDir = path.join(POSTS_DIR, "uploads");
  310. }
  311. if (!(await fs.pathExists(targetDir))) {
  312. return res.json([]);
  313. }
  314. const files = await fs.readdir(targetDir);
  315. const mediaFiles = [];
  316. for (const file of files) {
  317. const stats = await fs.stat(path.join(targetDir, file));
  318. if (stats.isFile() && /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file)) {
  319. const slugPart = slug ? slug.replace(/[^a-z0-9-]/gi, "") : "uploads";
  320. // If slug was passed, url is /media-files/SLUG/images/FILE
  321. // If no slug (using uploads), url is /media-files/uploads/FILE
  322. const url = slug
  323. ? `/media-files/${slugPart}/images/${file}`
  324. : `/media-files/uploads/${file}`;
  325. mediaFiles.push({
  326. name: file,
  327. url: url,
  328. size: stats.size,
  329. date: stats.mtime,
  330. });
  331. }
  332. }
  333. // Sort by newest
  334. mediaFiles.sort((a, b) => b.date - a.date);
  335. res.json(mediaFiles);
  336. } catch (err) {
  337. console.error("Error listing media:", err);
  338. res.status(500).json({ error: "Failed to list media" });
  339. }
  340. });
  341. // DELETE /api/media - Delete file
  342. app.delete("/api/media", requireAuth, async (req, res) => {
  343. try {
  344. const { path: relativePath } = req.body; // e.g. "/media-files/slug/images/file.jpg"
  345. if (!relativePath) return res.status(400).json({ error: "Path is required" });
  346. // Security check: ensure path is within POSTS_DIR
  347. // relativePath typically starts with /media-files/
  348. const cleanPath = relativePath.replace(/^\/media-files\//, "");
  349. const fullPath = path.join(POSTS_DIR, cleanPath);
  350. // Prevent directory traversal
  351. if (!fullPath.startsWith(POSTS_DIR)) {
  352. return res.status(403).json({ error: "Invalid path" });
  353. }
  354. if (await fs.pathExists(fullPath)) {
  355. await fs.remove(fullPath);
  356. res.json({ success: true });
  357. } else {
  358. res.status(404).json({ error: "File not found" });
  359. }
  360. } catch (err) {
  361. console.error("Delete media error:", err);
  362. res.status(500).json({ error: "Failed to delete media" });
  363. }
  364. });
  365. // API Routes continue...
  366. // GET /api/posts - Get all posts with metadata
  367. app.get("/api/posts", async (req, res) => {
  368. try {
  369. const files = await generateIndex();
  370. const posts = [];
  371. for (const filename of files) {
  372. const filePath = path.join(POSTS_DIR, filename);
  373. const content = await fs.readFile(filePath, "utf8");
  374. const metadata = parsePostMetadata(content);
  375. const slug = filename.replace(".md", "");
  376. posts.push({
  377. slug,
  378. filename,
  379. ...metadata,
  380. createdAt: (await fs.stat(filePath)).birthtime,
  381. updatedAt: (await fs.stat(filePath)).mtime,
  382. });
  383. }
  384. // Sort by creation date, newest first
  385. posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  386. // Filter out hidden posts for non-admins
  387. const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
  388. const visiblePosts = posts.filter(post => isAdmin || !post.hidden);
  389. res.json(visiblePosts);
  390. } catch (error) {
  391. console.error("Error fetching posts:", error);
  392. res.status(500).json({ error: "Failed to fetch posts" });
  393. }
  394. });
  395. // GET /api/posts/:slug - Get specific post
  396. app.get("/api/posts/:slug", async (req, res) => {
  397. try {
  398. const { slug } = req.params;
  399. const filename = `${slug}.md`;
  400. const filePath = path.join(POSTS_DIR, filename);
  401. if (!(await fs.pathExists(filePath))) {
  402. return res.status(404).json({ error: "Post not found" });
  403. }
  404. const content = await fs.readFile(filePath, "utf8");
  405. const metadata = parsePostMetadata(content);
  406. const stats = await fs.stat(filePath);
  407. // Access control for hidden posts
  408. const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
  409. if (metadata.hidden && !isAdmin) {
  410. // Return 404 to hide existence, or 403 if we want to be explicit.
  411. // 404 is safer for "hidden" content.
  412. return res.status(404).json({ error: "Post not found" });
  413. }
  414. res.json({
  415. slug,
  416. filename,
  417. ...metadata,
  418. content,
  419. createdAt: stats.birthtime,
  420. updatedAt: stats.mtime,
  421. });
  422. } catch (error) {
  423. console.error("Error fetching post:", error);
  424. res.status(500).json({ error: "Failed to fetch post" });
  425. }
  426. });
  427. // POST /api/posts - Create new post
  428. app.post("/api/posts", requireAuth, async (req, res) => {
  429. try {
  430. const { title, description, content, tags, hidden } = req.body;
  431. if (!title || !content) {
  432. return res
  433. .status(400)
  434. .json({ error: "Title and content are required" });
  435. }
  436. // Generate filename
  437. const filename = generateFilename(title);
  438. const filePath = path.join(POSTS_DIR, filename);
  439. // Check if file already exists
  440. if (await fs.pathExists(filePath)) {
  441. return res
  442. .status(409)
  443. .json({ error: "Post with similar title already exists" });
  444. }
  445. // Format the post content
  446. let postContent = "";
  447. postContent += `title: ${title}\n`;
  448. if (description) postContent += `desc: ${description}\n`;
  449. if (tags && tags.length > 0)
  450. postContent += `tags: ${tags.join(", ")}\n`;
  451. if (hidden) postContent += `hidden: true\n`;
  452. postContent += "\n" + content;
  453. // Write the file
  454. await fs.writeFile(filePath, postContent, "utf8");
  455. // Update index
  456. await generateIndex();
  457. const slug = filename.replace(".md", "");
  458. const stats = await fs.stat(filePath);
  459. res.status(201).json({
  460. slug,
  461. filename,
  462. title,
  463. description: description || "",
  464. tags: tags || [],
  465. content: postContent,
  466. createdAt: stats.birthtime,
  467. updatedAt: stats.mtime,
  468. });
  469. } catch (error) {
  470. console.error("Error creating post:", error);
  471. res.status(500).json({ error: "Failed to create post" });
  472. }
  473. });
  474. // PUT /api/posts/:slug - Update existing post
  475. app.put("/api/posts/:slug", requireAuth, async (req, res) => {
  476. try {
  477. const { slug } = req.params;
  478. const { title, description, content, tags, hidden } = req.body;
  479. const oldFilename = `${slug}.md`;
  480. const oldFilePath = path.join(POSTS_DIR, oldFilename);
  481. if (!(await fs.pathExists(oldFilePath))) {
  482. return res.status(404).json({ error: "Post not found" });
  483. }
  484. if (!title || !content) {
  485. return res
  486. .status(400)
  487. .json({ error: "Title and content are required" });
  488. }
  489. // Extract date from existing slug/filename to preserve it
  490. // Filename format: YYYYMMDD-slug.md
  491. // We assume the first 8 chars are the date if they are digits
  492. const dateMatch = slug.match(/^(\d{8})-/);
  493. const originalDate = dateMatch ? dateMatch[1] : null;
  494. // Generate new filename if title changed, but PRESERVE original date
  495. const newFilename = generateFilename(title, originalDate);
  496. const newFilePath = path.join(POSTS_DIR, newFilename);
  497. // Format the post content
  498. let postContent = "";
  499. postContent += `title: ${title}\n`;
  500. if (description) postContent += `desc: ${description}\n`;
  501. if (tags && tags.length > 0)
  502. postContent += `tags: ${tags.join(", ")}\n`;
  503. if (hidden) postContent += `hidden: true\n`;
  504. postContent += "\n" + content;
  505. // Write to new file
  506. await fs.writeFile(newFilePath, postContent, "utf8");
  507. // If filename changed, remove old file
  508. if (oldFilename !== newFilename) {
  509. await fs.remove(oldFilePath);
  510. // IMPORTANT: If we rename the post, should we verify if images folder usage needs update?
  511. // Currently images are stored in /slug/images/.
  512. // If the slug (filename) depends on the title, changing title changes slug.
  513. // We should rename the image folder too!
  514. const oldSlug = slug;
  515. const newSlug = newFilename.replace(".md", "");
  516. const oldImgDir = path.join(POSTS_DIR, oldSlug, "images");
  517. const newImgDir = path.join(POSTS_DIR, newSlug, "images");
  518. if (await fs.pathExists(oldImgDir)) {
  519. await fs.move(oldImgDir, newImgDir, { overwrite: true });
  520. }
  521. }
  522. // Update index
  523. await generateIndex();
  524. const newSlug = newFilename.replace(".md", "");
  525. const stats = await fs.stat(newFilePath);
  526. res.json({
  527. slug: newSlug,
  528. filename: newFilename,
  529. title,
  530. description: description || "",
  531. tags: tags || [],
  532. content: postContent,
  533. createdAt: stats.birthtime,
  534. updatedAt: stats.mtime,
  535. });
  536. } catch (error) {
  537. console.error("Error updating post:", error);
  538. res.status(500).json({ error: "Failed to update post" });
  539. }
  540. });
  541. // DELETE /api/posts/:slug - Delete post
  542. app.delete("/api/posts/:slug", requireAuth, async (req, res) => {
  543. try {
  544. const { slug } = req.params;
  545. const filename = `${slug}.md`;
  546. const filePath = path.join(POSTS_DIR, filename);
  547. if (!(await fs.pathExists(filePath))) {
  548. return res.status(404).json({ error: "Post not found" });
  549. }
  550. await fs.remove(filePath);
  551. // Removing associated images folder
  552. const imgDir = path.join(POSTS_DIR, slug, "images");
  553. if (await fs.pathExists(imgDir)) {
  554. await fs.remove(imgDir);
  555. }
  556. // Also remove parent folder if it was created just for this?
  557. // Structure is POSTS_DIR/slug/images.
  558. // We should remove POSTS_DIR/slug
  559. const slugDir = path.join(POSTS_DIR, slug);
  560. if (await fs.pathExists(slugDir)) {
  561. await fs.remove(slugDir);
  562. }
  563. await generateIndex();
  564. res.json({ message: "Post deleted successfully" });
  565. } catch (error) {
  566. console.error("Error deleting post:", error);
  567. res.status(500).json({ error: "Failed to delete post" });
  568. }
  569. });
  570. // Theme API Routes
  571. // GET /api/themes - Get all themes
  572. app.get("/api/themes", async (req, res) => {
  573. try {
  574. const themesData = await getAllThemes();
  575. res.json(themesData);
  576. } catch (error) {
  577. console.error("Error fetching themes:", error);
  578. res.status(500).json({ error: "Failed to fetch themes" });
  579. }
  580. });
  581. // GET /api/themes/active - Get active theme
  582. app.get("/api/themes/active", async (req, res) => {
  583. try {
  584. const activeTheme = await getActiveTheme();
  585. res.json(activeTheme);
  586. } catch (error) {
  587. console.error("Error fetching active theme:", error);
  588. res.status(500).json({ error: "Failed to fetch active theme" });
  589. }
  590. });
  591. // PUT /api/themes/active - Set active theme
  592. app.put("/api/themes/active", requireAuth, async (req, res) => {
  593. try {
  594. const { themeId } = req.body;
  595. if (!themeId) {
  596. return res.status(400).json({ error: "Theme ID is required" });
  597. }
  598. const activeTheme = await setActiveTheme(themeId);
  599. res.json({ success: true, theme: activeTheme });
  600. } catch (error) {
  601. console.error("Error setting active theme:", error);
  602. res.status(400).json({ error: error.message });
  603. }
  604. });
  605. // POST /api/themes - Create custom theme
  606. app.post("/api/themes", requireAuth, async (req, res) => {
  607. try {
  608. const themeData = req.body;
  609. const newTheme = await createCustomTheme(themeData);
  610. res.status(201).json(newTheme);
  611. } catch (error) {
  612. console.error("Error creating theme:", error);
  613. res.status(400).json({ error: error.message });
  614. }
  615. });
  616. // PUT /api/themes/:themeId - Update custom theme
  617. app.put("/api/themes/:themeId", requireAuth, async (req, res) => {
  618. try {
  619. const { themeId } = req.params;
  620. const themeData = req.body;
  621. const updatedTheme = await updateCustomTheme(themeId, themeData);
  622. res.json(updatedTheme);
  623. } catch (error) {
  624. console.error("Error updating theme:", error);
  625. res.status(400).json({ error: error.message });
  626. }
  627. });
  628. // DELETE /api/themes/:themeId - Delete custom theme
  629. app.delete("/api/themes/:themeId", requireAuth, async (req, res) => {
  630. try {
  631. const { themeId } = req.params;
  632. await deleteCustomTheme(themeId);
  633. res.json({ success: true, message: "Theme deleted successfully" });
  634. } catch (error) {
  635. console.error("Error deleting theme:", error);
  636. res.status(400).json({ error: error.message });
  637. }
  638. });
  639. // GET /api/themes/:themeId/export - Export theme
  640. app.get("/api/themes/:themeId/export", requireAuth, async (req, res) => {
  641. try {
  642. const { themeId } = req.params;
  643. const themeData = await exportTheme(themeId);
  644. res.setHeader("Content-Type", "application/json");
  645. res.setHeader(
  646. "Content-Disposition",
  647. `attachment; filename="theme-${themeId}.json"`,
  648. );
  649. res.json(themeData);
  650. } catch (error) {
  651. console.error("Error exporting theme:", error);
  652. res.status(400).json({ error: error.message });
  653. }
  654. });
  655. // Health check endpoint
  656. app.get("/api/health", (req, res) => {
  657. res.json({ status: "OK", timestamp: new Date().toISOString() });
  658. });
  659. // SERVE FRONTEND (SSR-lite for Meta Tags)
  660. const DIST_DIR = path.join(__dirname, "../dist");
  661. const INDEX_HTML = path.join(DIST_DIR, "index.html");
  662. // Serve static assets with aggressive caching
  663. // Vite assets are hashed (e.g., index.h4124j.js), so they are immutable.
  664. app.use(
  665. "/assets",
  666. express.static(path.join(DIST_DIR, "assets"), {
  667. maxAge: "1y", // 1 year
  668. immutable: true,
  669. setHeaders: (res, path) => {
  670. res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
  671. },
  672. }),
  673. );
  674. // Serve other static files (favicon, robots, etc.) with default caching
  675. app.use(express.static(DIST_DIR, { index: false }));
  676. // Helper to inject meta tags
  677. const injectMetaTags = async (html, metadata, url) => {
  678. let injected = html;
  679. // Default values
  680. const title = metadata.title || "Gooneral Wheelchair";
  681. const description = metadata.description || "A blog about stuff.";
  682. const image = metadata.image || "https://goonblog.thevakhovske.eu.org/og-image.jpg"; // Fallback image
  683. // Replace Title
  684. injected = injected.replace(/<title>.*<\/title>/, `<title>${title}</title>`);
  685. // Meta Tags to Inject
  686. const metaTags = `
  687. <meta property="og:title" content="${title}" />
  688. <meta property="og:description" content="${description}" />
  689. <meta property="og:image" content="${image}" />
  690. <meta property="og:url" content="${url}" />
  691. <meta property="og:type" content="article" />
  692. <meta name="twitter:card" content="summary_large_image" />
  693. <meta name="twitter:title" content="${title}" />
  694. <meta name="twitter:description" content="${description}" />
  695. <meta name="twitter:image" content="${image}" />
  696. `;
  697. // Inject before </head>
  698. return injected.replace("</head>", `${metaTags}</head>`);
  699. };
  700. // Handle Post Routes for SSR
  701. app.get("/posts/:slug", async (req, res) => {
  702. try {
  703. const { slug } = req.params;
  704. const filename = `${slug}.md`;
  705. const filePath = path.join(POSTS_DIR, filename);
  706. // Read index.html template
  707. let html = await fs.readFile(INDEX_HTML, "utf8");
  708. if (await fs.pathExists(filePath)) {
  709. const content = await fs.readFile(filePath, "utf8");
  710. const metadata = parsePostMetadata(content);
  711. // Try to find the first image in the content for the OG image
  712. const imageMatch = content.match(/!\[.*?\]\((.*?)\)/);
  713. let imageUrl = null;
  714. if (imageMatch) {
  715. imageUrl = imageMatch[1];
  716. // Ensure absolute URL
  717. if (imageUrl.startsWith("/")) {
  718. imageUrl = `${req.protocol}://${req.get("host")}${imageUrl}`;
  719. }
  720. }
  721. // Check access (Hidden posts)
  722. // If hidden and not admin, serve standard index.html (SPA will handle 404/auth check client-side)
  723. // OR we can pretend it doesn't exist to bots.
  724. const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
  725. if (metadata.hidden && !isAdmin) {
  726. // Determine behavior:
  727. // If we send raw index.html, client app loads and shows "Not Found" or "Login".
  728. // We'll just send raw index.html without generic meta tags? Or default tags?
  729. // Let's send default tags to avoid leaking info.
  730. res.send(html);
  731. return;
  732. }
  733. const pageMetadata = {
  734. title: metadata.title,
  735. description: metadata.description,
  736. image: imageUrl
  737. };
  738. const finalHtml = await injectMetaTags(html, pageMetadata, `${req.protocol}://${req.get("host")}${req.originalUrl}`);
  739. res.send(finalHtml);
  740. } else {
  741. // Post not found - serve SPA to handle 404
  742. res.send(html);
  743. }
  744. } catch (err) {
  745. console.error("SSR Error:", err);
  746. // Fallback to serving static file if something fails
  747. res.sendFile(INDEX_HTML);
  748. }
  749. });
  750. // Catch-all route for SPA
  751. app.get("*", async (req, res) => {
  752. // For other routes (e.g. /, /admin, /login), serve index.html
  753. // We can also inject default meta tags here if we want.
  754. try {
  755. if (await fs.pathExists(INDEX_HTML)) {
  756. res.sendFile(INDEX_HTML);
  757. } else {
  758. res.status(404).send("Frontend build not found");
  759. }
  760. } catch (err) {
  761. res.status(500).send("Server Error");
  762. }
  763. });
  764. // Generate initial index on startup
  765. await generateIndex();
  766. app.listen(PORT, () => {
  767. console.log(`🚀 Backend server running on http://localhost:${PORT}`);
  768. console.log(`📁 Posts directory: ${POSTS_DIR}`);
  769. });