server.js 32 KB

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