server.js 32 KB

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