server.js 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986
  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. const { type } = req.query; // 'image' or 'csv'
  319. for (const file of files) {
  320. const stats = await fs.stat(path.join(targetDir, file));
  321. if (!stats.isFile()) continue;
  322. const isImage = /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file);
  323. const isCsv = /\.csv$/i.test(file);
  324. // Filter logic
  325. let shouldInclude = false;
  326. if (type === "image") {
  327. shouldInclude = isImage;
  328. } else if (type === "csv") {
  329. shouldInclude = isCsv;
  330. } else {
  331. shouldInclude = isImage || isCsv;
  332. }
  333. if (shouldInclude) {
  334. const slugPart = slug ? slug.replace(/[^a-z0-9-]/gi, "") : "uploads";
  335. const url = slug
  336. ? `/media-files/${slugPart}/images/${file}`
  337. : `/media-files/uploads/${file}`;
  338. mediaFiles.push({
  339. name: file,
  340. url: url,
  341. size: stats.size,
  342. date: stats.mtime,
  343. type: isImage ? "image" : (isCsv ? "csv" : "file")
  344. });
  345. }
  346. }
  347. // Sort by newest
  348. mediaFiles.sort((a, b) => b.date - a.date);
  349. res.json(mediaFiles);
  350. } catch (err) {
  351. console.error("Error listing media:", err);
  352. res.status(500).json({ error: "Failed to list media" });
  353. }
  354. });
  355. // DELETE /api/media - Delete file
  356. app.delete("/api/media", requireAuth, async (req, res) => {
  357. try {
  358. const { path: relativePath } = req.body; // e.g. "/media-files/slug/images/file.jpg"
  359. if (!relativePath) return res.status(400).json({ error: "Path is required" });
  360. // Security check: ensure path is within POSTS_DIR
  361. // relativePath typically starts with /media-files/
  362. const cleanPath = relativePath.replace(/^\/media-files\//, "");
  363. const fullPath = path.join(POSTS_DIR, cleanPath);
  364. // Prevent directory traversal
  365. if (!fullPath.startsWith(POSTS_DIR)) {
  366. return res.status(403).json({ error: "Invalid path" });
  367. }
  368. if (await fs.pathExists(fullPath)) {
  369. await fs.remove(fullPath);
  370. res.json({ success: true });
  371. } else {
  372. res.status(404).json({ error: "File not found" });
  373. }
  374. } catch (err) {
  375. console.error("Delete media error:", err);
  376. res.status(500).json({ error: "Failed to delete media" });
  377. }
  378. });
  379. // API Routes continue...
  380. // GET /api/posts - Get all posts with metadata
  381. app.get("/api/posts", async (req, res) => {
  382. try {
  383. const files = await generateIndex();
  384. const posts = [];
  385. for (const filename of files) {
  386. const filePath = path.join(POSTS_DIR, filename);
  387. const content = await fs.readFile(filePath, "utf8");
  388. const metadata = parsePostMetadata(content);
  389. const slug = filename.replace(".md", "");
  390. posts.push({
  391. slug,
  392. filename,
  393. ...metadata,
  394. createdAt: (await fs.stat(filePath)).birthtime,
  395. updatedAt: (await fs.stat(filePath)).mtime,
  396. });
  397. }
  398. // Sort by pinned (true first), then by creation date (newest first)
  399. posts.sort((a, b) => {
  400. if (a.pinned && !b.pinned) return -1;
  401. if (!a.pinned && b.pinned) return 1;
  402. return new Date(b.createdAt) - new Date(a.createdAt);
  403. });
  404. // Filter out hidden posts for non-admins
  405. const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
  406. const visiblePosts = posts.filter(post => isAdmin || !post.hidden);
  407. res.json(visiblePosts);
  408. } catch (error) {
  409. console.error("Error fetching posts:", error);
  410. res.status(500).json({ error: "Failed to fetch posts" });
  411. }
  412. });
  413. // GET /api/posts/:slug - Get specific post
  414. app.get("/api/posts/:slug", async (req, res) => {
  415. try {
  416. const { slug } = req.params;
  417. const filename = `${slug}.md`;
  418. const filePath = path.join(POSTS_DIR, filename);
  419. if (!(await fs.pathExists(filePath))) {
  420. return res.status(404).json({ error: "Post not found" });
  421. }
  422. const content = await fs.readFile(filePath, "utf8");
  423. const metadata = parsePostMetadata(content);
  424. const stats = await fs.stat(filePath);
  425. // Access control for hidden posts
  426. const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
  427. if (metadata.hidden && !isAdmin) {
  428. // Return 404 to hide existence, or 403 if we want to be explicit.
  429. // 404 is safer for "hidden" content.
  430. return res.status(404).json({ error: "Post not found" });
  431. }
  432. res.json({
  433. slug,
  434. filename,
  435. ...metadata,
  436. content,
  437. createdAt: stats.birthtime,
  438. updatedAt: stats.mtime,
  439. });
  440. } catch (error) {
  441. console.error("Error fetching post:", error);
  442. res.status(500).json({ error: "Failed to fetch post" });
  443. }
  444. });
  445. // POST /api/posts - Create new post
  446. app.post("/api/posts", requireAuth, async (req, res) => {
  447. try {
  448. const { title, description, content, tags, hidden, pinned } = req.body;
  449. if (!title || !content) {
  450. return res
  451. .status(400)
  452. .json({ error: "Title and content are required" });
  453. }
  454. // Generate filename
  455. const filename = generateFilename(title);
  456. const filePath = path.join(POSTS_DIR, filename);
  457. // Check if file already exists
  458. if (await fs.pathExists(filePath)) {
  459. return res
  460. .status(409)
  461. .json({ error: "Post with similar title already exists" });
  462. }
  463. // Format the post content
  464. let postContent = "";
  465. postContent += `title: ${title}\n`;
  466. if (description) postContent += `desc: ${description}\n`;
  467. if (tags && tags.length > 0)
  468. postContent += `tags: ${tags.join(", ")}\n`;
  469. if (hidden) postContent += `hidden: true\n`;
  470. if (pinned) postContent += `pinned: true\n`;
  471. postContent += "\n" + content;
  472. // Write the file
  473. await fs.writeFile(filePath, postContent, "utf8");
  474. // Update index
  475. await generateIndex();
  476. const slug = filename.replace(".md", "");
  477. const stats = await fs.stat(filePath);
  478. res.status(201).json({
  479. slug,
  480. filename,
  481. title,
  482. description: description || "",
  483. tags: tags || [],
  484. content: postContent,
  485. createdAt: stats.birthtime,
  486. updatedAt: stats.mtime,
  487. });
  488. } catch (error) {
  489. console.error("Error creating post:", error);
  490. res.status(500).json({ error: "Failed to create post" });
  491. }
  492. });
  493. // PUT /api/posts/:slug - Update existing post
  494. app.put("/api/posts/:slug", requireAuth, async (req, res) => {
  495. try {
  496. const { slug } = req.params;
  497. const { title, description, content, tags, hidden, pinned } = req.body;
  498. const oldFilename = `${slug}.md`;
  499. const oldFilePath = path.join(POSTS_DIR, oldFilename);
  500. if (!(await fs.pathExists(oldFilePath))) {
  501. return res.status(404).json({ error: "Post not found" });
  502. }
  503. if (!title || !content) {
  504. return res
  505. .status(400)
  506. .json({ error: "Title and content are required" });
  507. }
  508. // Extract date from existing slug/filename to preserve it
  509. // Filename format: YYYYMMDD-slug.md
  510. // We assume the first 8 chars are the date if they are digits
  511. const dateMatch = slug.match(/^(\d{8})-/);
  512. const originalDate = dateMatch ? dateMatch[1] : null;
  513. // Generate new filename if title changed, but PRESERVE original date
  514. const newFilename = generateFilename(title, originalDate);
  515. const newFilePath = path.join(POSTS_DIR, newFilename);
  516. // Format the post content
  517. let postContent = "";
  518. postContent += `title: ${title}\n`;
  519. if (description) postContent += `desc: ${description}\n`;
  520. if (tags && tags.length > 0)
  521. postContent += `tags: ${tags.join(", ")}\n`;
  522. if (hidden) postContent += `hidden: true\n`;
  523. if (pinned) postContent += `pinned: true\n`;
  524. postContent += "\n" + content;
  525. // Write to new file
  526. await fs.writeFile(newFilePath, postContent, "utf8");
  527. // If filename changed, remove old file
  528. if (oldFilename !== newFilename) {
  529. await fs.remove(oldFilePath);
  530. // IMPORTANT: If we rename the post, should we verify if images folder usage needs update?
  531. // Currently images are stored in /slug/images/.
  532. // If the slug (filename) depends on the title, changing title changes slug.
  533. // We should rename the image folder too!
  534. const oldSlug = slug;
  535. const newSlug = newFilename.replace(".md", "");
  536. const oldImgDir = path.join(POSTS_DIR, oldSlug, "images");
  537. const newImgDir = path.join(POSTS_DIR, newSlug, "images");
  538. if (await fs.pathExists(oldImgDir)) {
  539. await fs.move(oldImgDir, newImgDir, { overwrite: true });
  540. }
  541. }
  542. // Update index
  543. await generateIndex();
  544. const newSlug = newFilename.replace(".md", "");
  545. const stats = await fs.stat(newFilePath);
  546. res.json({
  547. slug: newSlug,
  548. filename: newFilename,
  549. title,
  550. description: description || "",
  551. tags: tags || [],
  552. content: postContent,
  553. createdAt: stats.birthtime,
  554. updatedAt: stats.mtime,
  555. });
  556. } catch (error) {
  557. console.error("Error updating post:", error);
  558. res.status(500).json({ error: "Failed to update post" });
  559. }
  560. });
  561. // Change password endpoint
  562. app.post('/api/auth/change-password', requireAuth, async (req, res) => {
  563. try {
  564. const { currentPassword, newPassword } = req.body;
  565. if (!currentPassword || !newPassword) {
  566. return res.status(400).json({ error: 'Current and new password are required' });
  567. }
  568. if (newPassword.length < 6) {
  569. return res.status(400).json({ error: 'New password must be at least 6 characters' });
  570. }
  571. const result = await changeUserPassword(req.user.username, currentPassword, newPassword);
  572. if (!result.success) {
  573. return res.status(400).json({ error: result.message });
  574. }
  575. console.log(`Password changed for user: ${req.user.username}`);
  576. res.json({ success: true, message: 'Password changed successfully' });
  577. } catch (err) {
  578. console.error('Change password error:', err);
  579. res.status(500).json({ error: 'Internal server error' });
  580. }
  581. });
  582. // DELETE /api/posts/:slug - Delete post
  583. app.delete("/api/posts/:slug", requireAuth, async (req, res) => {
  584. try {
  585. const { slug } = req.params;
  586. const filename = `${slug}.md`;
  587. const filePath = path.join(POSTS_DIR, filename);
  588. if (!(await fs.pathExists(filePath))) {
  589. return res.status(404).json({ error: "Post not found" });
  590. }
  591. await fs.remove(filePath);
  592. // Removing associated images folder
  593. const imgDir = path.join(POSTS_DIR, slug, "images");
  594. if (await fs.pathExists(imgDir)) {
  595. await fs.remove(imgDir);
  596. }
  597. // Also remove parent folder if it was created just for this?
  598. // Structure is POSTS_DIR/slug/images.
  599. // We should remove POSTS_DIR/slug
  600. const slugDir = path.join(POSTS_DIR, slug);
  601. if (await fs.pathExists(slugDir)) {
  602. await fs.remove(slugDir);
  603. }
  604. await generateIndex();
  605. res.json({ message: "Post deleted successfully" });
  606. } catch (error) {
  607. console.error("Error deleting post:", error);
  608. res.status(500).json({ error: "Failed to delete post" });
  609. }
  610. });
  611. // Theme API Routes
  612. // GET /api/themes - Get all themes
  613. app.get("/api/themes", async (req, res) => {
  614. try {
  615. const themesData = await getAllThemes();
  616. res.json(themesData);
  617. } catch (error) {
  618. console.error("Error fetching themes:", error);
  619. res.status(500).json({ error: "Failed to fetch themes" });
  620. }
  621. });
  622. // GET /api/themes/active - Get active theme
  623. app.get("/api/themes/active", async (req, res) => {
  624. try {
  625. const activeTheme = await getActiveTheme();
  626. res.json(activeTheme);
  627. } catch (error) {
  628. console.error("Error fetching active theme:", error);
  629. res.status(500).json({ error: "Failed to fetch active theme" });
  630. }
  631. });
  632. // PUT /api/themes/active - Set active theme
  633. app.put("/api/themes/active", requireAuth, async (req, res) => {
  634. try {
  635. const { themeId } = req.body;
  636. if (!themeId) {
  637. return res.status(400).json({ error: "Theme ID is required" });
  638. }
  639. const activeTheme = await setActiveTheme(themeId);
  640. res.json({ success: true, theme: activeTheme });
  641. } catch (error) {
  642. console.error("Error setting active theme:", error);
  643. res.status(400).json({ error: error.message });
  644. }
  645. });
  646. import { getConfig, updateConfig } from './config.js';
  647. // GET /api/config - Get app configuration
  648. app.get("/api/config", async (req, res) => {
  649. try {
  650. const config = await getConfig();
  651. res.json(config);
  652. } catch (error) {
  653. console.error("Error getting config:", error);
  654. res.status(500).json({ error: "Failed to get config" });
  655. }
  656. });
  657. // PUT /api/config - Update app configuration
  658. app.put("/api/config", requireAuth, async (req, res) => {
  659. try {
  660. const updates = req.body;
  661. // Whitelist allowed config keys to prevent abuse
  662. const allowedKeys = ['postWidth', 'activeTheme'];
  663. const filteredUpdates = Object.keys(updates)
  664. .filter(key => allowedKeys.includes(key))
  665. .reduce((obj, key) => {
  666. obj[key] = updates[key];
  667. return obj;
  668. }, {});
  669. const newConfig = await updateConfig(filteredUpdates);
  670. res.json({ success: true, config: newConfig });
  671. } catch (error) {
  672. console.error("Error updating config:", error);
  673. res.status(500).json({ error: "Failed to update config" });
  674. }
  675. });
  676. // POST /api/themes - Create custom theme
  677. app.post("/api/themes", requireAuth, async (req, res) => {
  678. try {
  679. const themeData = req.body;
  680. const newTheme = await createCustomTheme(themeData);
  681. res.status(201).json(newTheme);
  682. } catch (error) {
  683. console.error("Error creating theme:", error);
  684. res.status(400).json({ error: error.message });
  685. }
  686. });
  687. // PUT /api/themes/:themeId - Update custom theme
  688. app.put("/api/themes/:themeId", requireAuth, async (req, res) => {
  689. try {
  690. const { themeId } = req.params;
  691. const themeData = req.body;
  692. const updatedTheme = await updateCustomTheme(themeId, themeData);
  693. res.json(updatedTheme);
  694. } catch (error) {
  695. console.error("Error updating theme:", error);
  696. res.status(400).json({ error: error.message });
  697. }
  698. });
  699. // DELETE /api/themes/:themeId - Delete custom theme
  700. app.delete("/api/themes/:themeId", requireAuth, async (req, res) => {
  701. try {
  702. const { themeId } = req.params;
  703. await deleteCustomTheme(themeId);
  704. res.json({ success: true, message: "Theme deleted successfully" });
  705. } catch (error) {
  706. console.error("Error deleting theme:", error);
  707. res.status(400).json({ error: error.message });
  708. }
  709. });
  710. // GET /api/themes/:themeId/export - Export theme
  711. app.get("/api/themes/:themeId/export", requireAuth, async (req, res) => {
  712. try {
  713. const { themeId } = req.params;
  714. const themeData = await exportTheme(themeId);
  715. res.setHeader("Content-Type", "application/json");
  716. res.setHeader(
  717. "Content-Disposition",
  718. `attachment; filename="theme-${themeId}.json"`,
  719. );
  720. res.json(themeData);
  721. } catch (error) {
  722. console.error("Error exporting theme:", error);
  723. res.status(400).json({ error: error.message });
  724. }
  725. });
  726. // Health check endpoint
  727. app.get("/api/health", (req, res) => {
  728. res.json({ status: "OK", timestamp: new Date().toISOString() });
  729. });
  730. // SERVE FRONTEND (SSR-lite for Meta Tags)
  731. const DIST_DIR = path.join(__dirname, "../dist");
  732. const INDEX_HTML = path.join(DIST_DIR, "index.html");
  733. // Serve static assets with aggressive caching
  734. // Vite assets are hashed (e.g., index.h4124j.js), so they are immutable.
  735. app.use(
  736. "/assets",
  737. express.static(path.join(DIST_DIR, "assets"), {
  738. maxAge: "1y", // 1 year
  739. immutable: true,
  740. setHeaders: (res, path) => {
  741. res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
  742. },
  743. }),
  744. );
  745. // Serve other static files (favicon, robots, etc.) with default caching
  746. app.use(express.static(DIST_DIR, { index: false }));
  747. // Helper to inject meta tags
  748. const injectMetaTags = async (html, metadata, url) => {
  749. let injected = html;
  750. // Default values
  751. const title = metadata.title || "Gooneral Wheelchair";
  752. const description = metadata.description || "A blog about stuff.";
  753. const image = metadata.image || "https://goonblog.thevakhovske.eu.org/og-image.jpg"; // Fallback image
  754. // Replace Title
  755. injected = injected.replace(/<title>.*<\/title>/, `<title>${title}</title>`);
  756. // Meta Tags to Inject
  757. const metaTags = `
  758. <meta property="og:title" content="${title}" />
  759. <meta property="og:description" content="${description}" />
  760. <meta property="og:image" content="${image}" />
  761. <meta property="og:url" content="${url}" />
  762. <meta property="og:type" content="article" />
  763. <meta name="twitter:card" content="summary_large_image" />
  764. <meta name="twitter:title" content="${title}" />
  765. <meta name="twitter:description" content="${description}" />
  766. <meta name="twitter:image" content="${image}" />
  767. `;
  768. // Inject before </head>
  769. return injected.replace("</head>", `${metaTags}</head>`);
  770. };
  771. // Handle Post Routes for SSR
  772. app.get("/posts/:slug", async (req, res) => {
  773. try {
  774. const { slug } = req.params;
  775. const filename = `${slug}.md`;
  776. const filePath = path.join(POSTS_DIR, filename);
  777. // Read index.html template
  778. let html = await fs.readFile(INDEX_HTML, "utf8");
  779. if (await fs.pathExists(filePath)) {
  780. const content = await fs.readFile(filePath, "utf8");
  781. const metadata = parsePostMetadata(content);
  782. // Try to find the first image in the content for the OG image
  783. const imageMatch = content.match(/!\[.*?\]\((.*?)\)/);
  784. let imageUrl = null;
  785. if (imageMatch) {
  786. imageUrl = imageMatch[1];
  787. // Ensure absolute URL
  788. if (imageUrl.startsWith("/")) {
  789. imageUrl = `${req.protocol}://${req.get("host")}${imageUrl}`;
  790. }
  791. }
  792. // Check access (Hidden posts)
  793. // If hidden and not admin, serve standard index.html (SPA will handle 404/auth check client-side)
  794. // OR we can pretend it doesn't exist to bots.
  795. const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
  796. if (metadata.hidden && !isAdmin) {
  797. // Determine behavior:
  798. // If we send raw index.html, client app loads and shows "Not Found" or "Login".
  799. // We'll just send raw index.html without generic meta tags? Or default tags?
  800. // Let's send default tags to avoid leaking info.
  801. res.send(html);
  802. return;
  803. }
  804. const pageMetadata = {
  805. title: metadata.title,
  806. description: metadata.description,
  807. image: imageUrl
  808. };
  809. const finalHtml = await injectMetaTags(html, pageMetadata, `${req.protocol}://${req.get("host")}${req.originalUrl}`);
  810. res.send(finalHtml);
  811. } else {
  812. // Post not found - serve SPA to handle 404
  813. res.send(html);
  814. }
  815. } catch (err) {
  816. console.error("SSR Error:", err);
  817. // Fallback to serving static file if something fails
  818. res.sendFile(INDEX_HTML);
  819. }
  820. });
  821. // Catch-all route for SPA
  822. app.get("*", async (req, res) => {
  823. // For other routes (e.g. /, /admin, /login), serve index.html
  824. // We can also inject default meta tags here if we want.
  825. try {
  826. if (await fs.pathExists(INDEX_HTML)) {
  827. res.sendFile(INDEX_HTML);
  828. } else {
  829. res.status(404).send("Frontend build not found");
  830. }
  831. } catch (err) {
  832. res.status(500).send("Server Error");
  833. }
  834. });
  835. // Generate initial index on startup
  836. await generateIndex();
  837. app.listen(PORT, () => {
  838. console.log(`🚀 Backend server running on http://localhost:${PORT}`);
  839. console.log(`📁 Posts directory: ${POSTS_DIR}`);
  840. });