server.js 34 KB

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