server.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  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. // Load environment variables
  10. dotenv.config();
  11. import {
  12. authenticateUser,
  13. getUserByUsername,
  14. changeUserPassword,
  15. } from "./auth.js";
  16. import {
  17. getAllThemes,
  18. getActiveTheme,
  19. setActiveTheme,
  20. createCustomTheme,
  21. updateCustomTheme,
  22. deleteCustomTheme,
  23. exportTheme,
  24. } from "./themes.js";
  25. const __filename = fileURLToPath(import.meta.url);
  26. const __dirname = path.dirname(__filename);
  27. const app = express();
  28. app.set("trust proxy", 1); // Trust the first proxy (Caddy) so secure cookies work behind HTTPS termination
  29. const PORT = process.env.PORT || 3001;
  30. // Paths
  31. const POSTS_DIR = path.resolve(
  32. __dirname,
  33. process.env.POSTS_DIR || "../public/posts",
  34. );
  35. const INDEX_FILE = path.join(POSTS_DIR, "index.json");
  36. // Middleware
  37. app.use(
  38. cors({
  39. origin: [
  40. "http://localhost:5173",
  41. "https://goonblog.thevakhovske.eu.org",
  42. ],
  43. credentials: true, // Enable cookies
  44. }),
  45. );
  46. app.use(express.json());
  47. app.use(express.urlencoded({ extended: true }));
  48. // Session configuration
  49. app.use(
  50. session({
  51. secret:
  52. process.env.SESSION_SECRET ||
  53. "gooneral-wheelchair-secret-key-change-in-production",
  54. resave: false,
  55. saveUninitialized: false,
  56. name: "gooneral-session",
  57. cookie: {
  58. secure: true, // HTTPS required
  59. httpOnly: true,
  60. maxAge: 24 * 60 * 60 * 1000, // 24 hours
  61. sameSite: "lax", // Changed from 'strict' to 'lax'
  62. },
  63. }),
  64. );
  65. // Ensure posts directory exists
  66. await fs.ensureDir(POSTS_DIR);
  67. // Authentication middleware
  68. function requireAuth(req, res, next) {
  69. if (req.session && req.session.user && req.session.user.role === "admin") {
  70. return next();
  71. }
  72. return res.status(401).json({ error: "Authentication required" });
  73. }
  74. // Check if user is authenticated
  75. function isAuthenticated(req, res, next) {
  76. req.isAuthenticated = !!(req.session && req.session.user);
  77. req.user = req.session?.user || null;
  78. next();
  79. }
  80. // Helper function to generate index.json
  81. async function generateIndex() {
  82. try {
  83. const files = await fs.readdir(POSTS_DIR);
  84. const mdFiles = files.filter((f) => f.endsWith(".md"));
  85. await fs.writeJSON(INDEX_FILE, mdFiles, { spaces: 2 });
  86. console.log(`Index updated: ${mdFiles.length} posts`);
  87. return mdFiles;
  88. } catch (error) {
  89. console.error("Error generating index:", error);
  90. throw error;
  91. }
  92. }
  93. // Helper function to parse post metadata
  94. function parsePostMetadata(content) {
  95. const titleMatch = content.match(/title:\s*(.*)/);
  96. const descMatch = content.match(/desc:\s*(.*)/);
  97. const tagsMatch = content.match(/tags:\s*(.*)/);
  98. return {
  99. title: titleMatch ? titleMatch[1].trim() : "Untitled",
  100. description: descMatch ? descMatch[1].trim() : "",
  101. tags: tagsMatch ? tagsMatch[1].split(",").map((tag) => tag.trim()) : [],
  102. };
  103. }
  104. // Helper function to generate filename from title
  105. function generateFilename(title) {
  106. // Create date-based filename similar to existing pattern
  107. const date = new Date();
  108. const dateStr = date.toISOString().slice(0, 10).replace(/-/g, "");
  109. const slug = title
  110. .toLowerCase()
  111. .replace(/[^a-z0-9]+/g, "-")
  112. .replace(/^-|-$/g, "")
  113. .slice(0, 30);
  114. return slug ? `${dateStr}-${slug}.md` : `${dateStr}.md`;
  115. }
  116. // Authentication Routes
  117. // POST /api/auth/login - Login
  118. app.post("/api/auth/login", async (req, res) => {
  119. try {
  120. const { username, password } = req.body;
  121. if (!username || !password) {
  122. return res
  123. .status(400)
  124. .json({ error: "Username and password are required" });
  125. }
  126. const user = await authenticateUser(username, password);
  127. if (!user) {
  128. return res
  129. .status(401)
  130. .json({ error: "Invalid username or password" });
  131. }
  132. // Store user in session
  133. req.session.user = user;
  134. console.log("Login successful - Session ID:", req.sessionID);
  135. console.log("Login successful - Stored user:", req.session.user);
  136. // Manually save the session to ensure it's persisted
  137. req.session.save((err) => {
  138. if (err) {
  139. console.error("Session save error:", err);
  140. return res
  141. .status(500)
  142. .json({ error: "Failed to save session" });
  143. }
  144. console.log("Session saved successfully");
  145. res.json({
  146. success: true,
  147. user: {
  148. username: user.username,
  149. role: user.role,
  150. },
  151. });
  152. });
  153. } catch (error) {
  154. console.error("Login error:", error);
  155. res.status(500).json({ error: "Login failed" });
  156. }
  157. });
  158. // POST /api/auth/logout - Logout
  159. app.post("/api/auth/logout", (req, res) => {
  160. req.session.destroy((err) => {
  161. if (err) {
  162. return res.status(500).json({ error: "Logout failed" });
  163. }
  164. res.clearCookie("gooneral-session"); // Use the same name as configured
  165. res.json({ success: true, message: "Logged out successfully" });
  166. });
  167. });
  168. // GET /api/auth/me - Get current user
  169. app.get("/api/auth/me", isAuthenticated, (req, res) => {
  170. console.log("Auth check - Session ID:", req.sessionID);
  171. console.log("Auth check - Session user:", req.session?.user);
  172. console.log("Auth check - Is authenticated:", req.isAuthenticated);
  173. if (req.isAuthenticated) {
  174. res.json({
  175. user: {
  176. username: req.user.username,
  177. role: req.user.role,
  178. },
  179. });
  180. } else {
  181. res.json({ user: null });
  182. }
  183. });
  184. // POST /api/auth/change-password - Change password
  185. app.post("/api/auth/change-password", requireAuth, async (req, res) => {
  186. try {
  187. const { currentPassword, newPassword } = req.body;
  188. if (!currentPassword || !newPassword) {
  189. return res.status(400).json({
  190. error: "Current password and new password are required",
  191. });
  192. }
  193. if (newPassword.length < 6) {
  194. return res.status(400).json({
  195. error: "New password must be at least 6 characters long",
  196. });
  197. }
  198. const result = await changeUserPassword(
  199. req.user.username,
  200. currentPassword,
  201. newPassword,
  202. );
  203. if (result.success) {
  204. res.json({ success: true, message: result.message });
  205. } else {
  206. res.status(400).json({ error: result.message });
  207. }
  208. } catch (error) {
  209. console.error("Change password error:", error);
  210. res.status(500).json({ error: "Failed to change password" });
  211. }
  212. });
  213. // API Routes
  214. // GET /api/posts - Get all posts with metadata
  215. app.get("/api/posts", async (req, res) => {
  216. try {
  217. const files = await generateIndex();
  218. const posts = [];
  219. for (const filename of files) {
  220. const filePath = path.join(POSTS_DIR, filename);
  221. const content = await fs.readFile(filePath, "utf8");
  222. const metadata = parsePostMetadata(content);
  223. const slug = filename.replace(".md", "");
  224. posts.push({
  225. slug,
  226. filename,
  227. ...metadata,
  228. createdAt: (await fs.stat(filePath)).birthtime,
  229. updatedAt: (await fs.stat(filePath)).mtime,
  230. });
  231. }
  232. // Sort by creation date, newest first
  233. posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  234. res.json(posts);
  235. } catch (error) {
  236. console.error("Error fetching posts:", error);
  237. res.status(500).json({ error: "Failed to fetch posts" });
  238. }
  239. });
  240. // GET /api/posts/:slug - Get specific post
  241. app.get("/api/posts/:slug", async (req, res) => {
  242. try {
  243. const { slug } = req.params;
  244. const filename = `${slug}.md`;
  245. const filePath = path.join(POSTS_DIR, filename);
  246. if (!(await fs.pathExists(filePath))) {
  247. return res.status(404).json({ error: "Post not found" });
  248. }
  249. const content = await fs.readFile(filePath, "utf8");
  250. const metadata = parsePostMetadata(content);
  251. const stats = await fs.stat(filePath);
  252. res.json({
  253. slug,
  254. filename,
  255. ...metadata,
  256. content,
  257. createdAt: stats.birthtime,
  258. updatedAt: stats.mtime,
  259. });
  260. } catch (error) {
  261. console.error("Error fetching post:", error);
  262. res.status(500).json({ error: "Failed to fetch post" });
  263. }
  264. });
  265. // POST /api/posts - Create new post
  266. app.post("/api/posts", requireAuth, async (req, res) => {
  267. try {
  268. const { title, description, content, tags } = req.body;
  269. if (!title || !content) {
  270. return res
  271. .status(400)
  272. .json({ error: "Title and content are required" });
  273. }
  274. // Generate filename
  275. const filename = generateFilename(title);
  276. const filePath = path.join(POSTS_DIR, filename);
  277. // Check if file already exists
  278. if (await fs.pathExists(filePath)) {
  279. return res
  280. .status(409)
  281. .json({ error: "Post with similar title already exists" });
  282. }
  283. // Format the post content
  284. let postContent = "";
  285. postContent += `title: ${title}\n`;
  286. if (description) postContent += `desc: ${description}\n`;
  287. if (tags && tags.length > 0)
  288. postContent += `tags: ${tags.join(", ")}\n`;
  289. postContent += "\n" + content;
  290. // Write the file
  291. await fs.writeFile(filePath, postContent, "utf8");
  292. // Update index
  293. await generateIndex();
  294. const slug = filename.replace(".md", "");
  295. const stats = await fs.stat(filePath);
  296. res.status(201).json({
  297. slug,
  298. filename,
  299. title,
  300. description: description || "",
  301. tags: tags || [],
  302. content: postContent,
  303. createdAt: stats.birthtime,
  304. updatedAt: stats.mtime,
  305. });
  306. } catch (error) {
  307. console.error("Error creating post:", error);
  308. res.status(500).json({ error: "Failed to create post" });
  309. }
  310. });
  311. // PUT /api/posts/:slug - Update existing post
  312. app.put("/api/posts/:slug", requireAuth, async (req, res) => {
  313. try {
  314. const { slug } = req.params;
  315. const { title, description, content, tags } = req.body;
  316. const oldFilename = `${slug}.md`;
  317. const oldFilePath = path.join(POSTS_DIR, oldFilename);
  318. if (!(await fs.pathExists(oldFilePath))) {
  319. return res.status(404).json({ error: "Post not found" });
  320. }
  321. if (!title || !content) {
  322. return res
  323. .status(400)
  324. .json({ error: "Title and content are required" });
  325. }
  326. // Generate new filename if title changed
  327. const newFilename = generateFilename(title);
  328. const newFilePath = path.join(POSTS_DIR, newFilename);
  329. // Format the post content
  330. let postContent = "";
  331. postContent += `title: ${title}\n`;
  332. if (description) postContent += `desc: ${description}\n`;
  333. if (tags && tags.length > 0)
  334. postContent += `tags: ${tags.join(", ")}\n`;
  335. postContent += "\n" + content;
  336. // Write to new file
  337. await fs.writeFile(newFilePath, postContent, "utf8");
  338. // If filename changed, remove old file
  339. if (oldFilename !== newFilename) {
  340. await fs.remove(oldFilePath);
  341. }
  342. // Update index
  343. await generateIndex();
  344. const newSlug = newFilename.replace(".md", "");
  345. const stats = await fs.stat(newFilePath);
  346. res.json({
  347. slug: newSlug,
  348. filename: newFilename,
  349. title,
  350. description: description || "",
  351. tags: tags || [],
  352. content: postContent,
  353. createdAt: stats.birthtime,
  354. updatedAt: stats.mtime,
  355. });
  356. } catch (error) {
  357. console.error("Error updating post:", error);
  358. res.status(500).json({ error: "Failed to update post" });
  359. }
  360. });
  361. // DELETE /api/posts/:slug - Delete post
  362. app.delete("/api/posts/:slug", requireAuth, async (req, res) => {
  363. try {
  364. const { slug } = req.params;
  365. const filename = `${slug}.md`;
  366. const filePath = path.join(POSTS_DIR, filename);
  367. if (!(await fs.pathExists(filePath))) {
  368. return res.status(404).json({ error: "Post not found" });
  369. }
  370. await fs.remove(filePath);
  371. await generateIndex();
  372. res.json({ message: "Post deleted successfully" });
  373. } catch (error) {
  374. console.error("Error deleting post:", error);
  375. res.status(500).json({ error: "Failed to delete post" });
  376. }
  377. });
  378. // Theme API Routes
  379. // GET /api/themes - Get all themes
  380. app.get("/api/themes", async (req, res) => {
  381. try {
  382. const themesData = await getAllThemes();
  383. res.json(themesData);
  384. } catch (error) {
  385. console.error("Error fetching themes:", error);
  386. res.status(500).json({ error: "Failed to fetch themes" });
  387. }
  388. });
  389. // GET /api/themes/active - Get active theme
  390. app.get("/api/themes/active", async (req, res) => {
  391. try {
  392. const activeTheme = await getActiveTheme();
  393. res.json(activeTheme);
  394. } catch (error) {
  395. console.error("Error fetching active theme:", error);
  396. res.status(500).json({ error: "Failed to fetch active theme" });
  397. }
  398. });
  399. // PUT /api/themes/active - Set active theme
  400. app.put("/api/themes/active", requireAuth, async (req, res) => {
  401. try {
  402. const { themeId } = req.body;
  403. if (!themeId) {
  404. return res.status(400).json({ error: "Theme ID is required" });
  405. }
  406. const activeTheme = await setActiveTheme(themeId);
  407. res.json({ success: true, theme: activeTheme });
  408. } catch (error) {
  409. console.error("Error setting active theme:", error);
  410. res.status(400).json({ error: error.message });
  411. }
  412. });
  413. // POST /api/themes - Create custom theme
  414. app.post("/api/themes", requireAuth, async (req, res) => {
  415. try {
  416. const themeData = req.body;
  417. const newTheme = await createCustomTheme(themeData);
  418. res.status(201).json(newTheme);
  419. } catch (error) {
  420. console.error("Error creating theme:", error);
  421. res.status(400).json({ error: error.message });
  422. }
  423. });
  424. // PUT /api/themes/:themeId - Update custom theme
  425. app.put("/api/themes/:themeId", requireAuth, async (req, res) => {
  426. try {
  427. const { themeId } = req.params;
  428. const themeData = req.body;
  429. const updatedTheme = await updateCustomTheme(themeId, themeData);
  430. res.json(updatedTheme);
  431. } catch (error) {
  432. console.error("Error updating theme:", error);
  433. res.status(400).json({ error: error.message });
  434. }
  435. });
  436. // DELETE /api/themes/:themeId - Delete custom theme
  437. app.delete("/api/themes/:themeId", requireAuth, async (req, res) => {
  438. try {
  439. const { themeId } = req.params;
  440. await deleteCustomTheme(themeId);
  441. res.json({ success: true, message: "Theme deleted successfully" });
  442. } catch (error) {
  443. console.error("Error deleting theme:", error);
  444. res.status(400).json({ error: error.message });
  445. }
  446. });
  447. // GET /api/themes/:themeId/export - Export theme
  448. app.get("/api/themes/:themeId/export", requireAuth, async (req, res) => {
  449. try {
  450. const { themeId } = req.params;
  451. const themeData = await exportTheme(themeId);
  452. res.setHeader("Content-Type", "application/json");
  453. res.setHeader(
  454. "Content-Disposition",
  455. `attachment; filename="theme-${themeId}.json"`,
  456. );
  457. res.json(themeData);
  458. } catch (error) {
  459. console.error("Error exporting theme:", error);
  460. res.status(400).json({ error: error.message });
  461. }
  462. });
  463. // Health check endpoint
  464. app.get("/api/health", (req, res) => {
  465. res.json({ status: "OK", timestamp: new Date().toISOString() });
  466. });
  467. // Generate initial index on startup
  468. await generateIndex();
  469. app.listen(PORT, () => {
  470. console.log(`🚀 Backend server running on http://localhost:${PORT}`);
  471. console.log(`📁 Posts directory: ${POSTS_DIR}`);
  472. });