server.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  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.status(400).json({ error: 'Username and password are required' });
  123. }
  124. const user = await authenticateUser(username, password);
  125. if (!user) {
  126. return res.status(401).json({ error: 'Invalid username or password' });
  127. }
  128. // Store user in session
  129. req.session.user = user;
  130. console.log('Login successful - Session ID:', req.sessionID);
  131. console.log('Login successful - Stored user:', req.session.user);
  132. // Manually save the session to ensure it's persisted
  133. req.session.save((err) => {
  134. if (err) {
  135. console.error('Session save error:', err);
  136. return res.status(500).json({ error: 'Failed to save session' });
  137. }
  138. console.log('Session saved successfully');
  139. res.json({
  140. success: true,
  141. user: {
  142. username: user.username,
  143. role: user.role
  144. }
  145. });
  146. });
  147. } catch (error) {
  148. console.error('Login error:', error);
  149. res.status(500).json({ error: 'Login failed' });
  150. }
  151. });
  152. // POST /api/auth/logout - Logout
  153. app.post('/api/auth/logout', (req, res) => {
  154. req.session.destroy((err) => {
  155. if (err) {
  156. return res.status(500).json({ error: 'Logout failed' });
  157. }
  158. res.clearCookie('gooneral-session'); // Use the same name as configured
  159. res.json({ success: true, message: 'Logged out successfully' });
  160. });
  161. });
  162. // GET /api/auth/me - Get current user
  163. app.get('/api/auth/me', isAuthenticated, (req, res) => {
  164. console.log('Auth check - Session ID:', req.sessionID);
  165. console.log('Auth check - Session user:', req.session?.user);
  166. console.log('Auth check - Is authenticated:', req.isAuthenticated);
  167. if (req.isAuthenticated) {
  168. res.json({
  169. user: {
  170. username: req.user.username,
  171. role: req.user.role
  172. }
  173. });
  174. } else {
  175. res.json({ user: null });
  176. }
  177. });
  178. // POST /api/auth/change-password - Change password
  179. app.post("/api/auth/change-password", requireAuth, async (req, res) => {
  180. try {
  181. const { currentPassword, newPassword } = req.body;
  182. if (!currentPassword || !newPassword) {
  183. return res.status(400).json({
  184. error: "Current password and new password are required",
  185. });
  186. }
  187. if (newPassword.length < 6) {
  188. return res.status(400).json({
  189. error: "New password must be at least 6 characters long",
  190. });
  191. }
  192. const result = await changeUserPassword(
  193. req.user.username,
  194. currentPassword,
  195. newPassword,
  196. );
  197. if (result.success) {
  198. res.json({ success: true, message: result.message });
  199. } else {
  200. res.status(400).json({ error: result.message });
  201. }
  202. } catch (error) {
  203. console.error("Change password error:", error);
  204. res.status(500).json({ error: "Failed to change password" });
  205. }
  206. });
  207. // API Routes
  208. // GET /api/posts - Get all posts with metadata
  209. app.get("/api/posts", async (req, res) => {
  210. try {
  211. const files = await generateIndex();
  212. const posts = [];
  213. for (const filename of files) {
  214. const filePath = path.join(POSTS_DIR, filename);
  215. const content = await fs.readFile(filePath, "utf8");
  216. const metadata = parsePostMetadata(content);
  217. const slug = filename.replace(".md", "");
  218. posts.push({
  219. slug,
  220. filename,
  221. ...metadata,
  222. content,
  223. createdAt: (await fs.stat(filePath)).birthtime,
  224. updatedAt: (await fs.stat(filePath)).mtime,
  225. });
  226. }
  227. // Sort by creation date, newest first
  228. posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  229. res.json(posts);
  230. } catch (error) {
  231. console.error("Error fetching posts:", error);
  232. res.status(500).json({ error: "Failed to fetch posts" });
  233. }
  234. });
  235. // GET /api/posts/:slug - Get specific post
  236. app.get("/api/posts/:slug", async (req, res) => {
  237. try {
  238. const { slug } = req.params;
  239. const filename = `${slug}.md`;
  240. const filePath = path.join(POSTS_DIR, filename);
  241. if (!(await fs.pathExists(filePath))) {
  242. return res.status(404).json({ error: "Post not found" });
  243. }
  244. const content = await fs.readFile(filePath, "utf8");
  245. const metadata = parsePostMetadata(content);
  246. const stats = await fs.stat(filePath);
  247. res.json({
  248. slug,
  249. filename,
  250. ...metadata,
  251. content,
  252. createdAt: stats.birthtime,
  253. updatedAt: stats.mtime,
  254. });
  255. } catch (error) {
  256. console.error("Error fetching post:", error);
  257. res.status(500).json({ error: "Failed to fetch post" });
  258. }
  259. });
  260. // POST /api/posts - Create new post
  261. app.post("/api/posts", requireAuth, async (req, res) => {
  262. try {
  263. const { title, description, content, tags } = req.body;
  264. if (!title || !content) {
  265. return res
  266. .status(400)
  267. .json({ error: "Title and content are required" });
  268. }
  269. // Generate filename
  270. const filename = generateFilename(title);
  271. const filePath = path.join(POSTS_DIR, filename);
  272. // Check if file already exists
  273. if (await fs.pathExists(filePath)) {
  274. return res
  275. .status(409)
  276. .json({ error: "Post with similar title already exists" });
  277. }
  278. // Format the post content
  279. let postContent = "";
  280. postContent += `title: ${title}\n`;
  281. if (description) postContent += `desc: ${description}\n`;
  282. if (tags && tags.length > 0)
  283. postContent += `tags: ${tags.join(", ")}\n`;
  284. postContent += "\n" + content;
  285. // Write the file
  286. await fs.writeFile(filePath, postContent, "utf8");
  287. // Update index
  288. await generateIndex();
  289. const slug = filename.replace(".md", "");
  290. const stats = await fs.stat(filePath);
  291. res.status(201).json({
  292. slug,
  293. filename,
  294. title,
  295. description: description || "",
  296. tags: tags || [],
  297. content: postContent,
  298. createdAt: stats.birthtime,
  299. updatedAt: stats.mtime,
  300. });
  301. } catch (error) {
  302. console.error("Error creating post:", error);
  303. res.status(500).json({ error: "Failed to create post" });
  304. }
  305. });
  306. // PUT /api/posts/:slug - Update existing post
  307. app.put("/api/posts/:slug", requireAuth, async (req, res) => {
  308. try {
  309. const { slug } = req.params;
  310. const { title, description, content, tags } = req.body;
  311. const oldFilename = `${slug}.md`;
  312. const oldFilePath = path.join(POSTS_DIR, oldFilename);
  313. if (!(await fs.pathExists(oldFilePath))) {
  314. return res.status(404).json({ error: "Post not found" });
  315. }
  316. if (!title || !content) {
  317. return res
  318. .status(400)
  319. .json({ error: "Title and content are required" });
  320. }
  321. // Generate new filename if title changed
  322. const newFilename = generateFilename(title);
  323. const newFilePath = path.join(POSTS_DIR, newFilename);
  324. // Format the post content
  325. let postContent = "";
  326. postContent += `title: ${title}\n`;
  327. if (description) postContent += `desc: ${description}\n`;
  328. if (tags && tags.length > 0)
  329. postContent += `tags: ${tags.join(", ")}\n`;
  330. postContent += "\n" + content;
  331. // Write to new file
  332. await fs.writeFile(newFilePath, postContent, "utf8");
  333. // If filename changed, remove old file
  334. if (oldFilename !== newFilename) {
  335. await fs.remove(oldFilePath);
  336. }
  337. // Update index
  338. await generateIndex();
  339. const newSlug = newFilename.replace(".md", "");
  340. const stats = await fs.stat(newFilePath);
  341. res.json({
  342. slug: newSlug,
  343. filename: newFilename,
  344. title,
  345. description: description || "",
  346. tags: tags || [],
  347. content: postContent,
  348. createdAt: stats.birthtime,
  349. updatedAt: stats.mtime,
  350. });
  351. } catch (error) {
  352. console.error("Error updating post:", error);
  353. res.status(500).json({ error: "Failed to update post" });
  354. }
  355. });
  356. // DELETE /api/posts/:slug - Delete post
  357. app.delete("/api/posts/:slug", requireAuth, async (req, res) => {
  358. try {
  359. const { slug } = req.params;
  360. const filename = `${slug}.md`;
  361. const filePath = path.join(POSTS_DIR, filename);
  362. if (!(await fs.pathExists(filePath))) {
  363. return res.status(404).json({ error: "Post not found" });
  364. }
  365. await fs.remove(filePath);
  366. await generateIndex();
  367. res.json({ message: "Post deleted successfully" });
  368. } catch (error) {
  369. console.error("Error deleting post:", error);
  370. res.status(500).json({ error: "Failed to delete post" });
  371. }
  372. });
  373. // Theme API Routes
  374. // GET /api/themes - Get all themes
  375. app.get("/api/themes", async (req, res) => {
  376. try {
  377. const themesData = await getAllThemes();
  378. res.json(themesData);
  379. } catch (error) {
  380. console.error("Error fetching themes:", error);
  381. res.status(500).json({ error: "Failed to fetch themes" });
  382. }
  383. });
  384. // GET /api/themes/active - Get active theme
  385. app.get("/api/themes/active", async (req, res) => {
  386. try {
  387. const activeTheme = await getActiveTheme();
  388. res.json(activeTheme);
  389. } catch (error) {
  390. console.error("Error fetching active theme:", error);
  391. res.status(500).json({ error: "Failed to fetch active theme" });
  392. }
  393. });
  394. // PUT /api/themes/active - Set active theme
  395. app.put("/api/themes/active", requireAuth, async (req, res) => {
  396. try {
  397. const { themeId } = req.body;
  398. if (!themeId) {
  399. return res.status(400).json({ error: "Theme ID is required" });
  400. }
  401. const activeTheme = await setActiveTheme(themeId);
  402. res.json({ success: true, theme: activeTheme });
  403. } catch (error) {
  404. console.error("Error setting active theme:", error);
  405. res.status(400).json({ error: error.message });
  406. }
  407. });
  408. // POST /api/themes - Create custom theme
  409. app.post("/api/themes", requireAuth, async (req, res) => {
  410. try {
  411. const themeData = req.body;
  412. const newTheme = await createCustomTheme(themeData);
  413. res.status(201).json(newTheme);
  414. } catch (error) {
  415. console.error("Error creating theme:", error);
  416. res.status(400).json({ error: error.message });
  417. }
  418. });
  419. // PUT /api/themes/:themeId - Update custom theme
  420. app.put("/api/themes/:themeId", requireAuth, async (req, res) => {
  421. try {
  422. const { themeId } = req.params;
  423. const themeData = req.body;
  424. const updatedTheme = await updateCustomTheme(themeId, themeData);
  425. res.json(updatedTheme);
  426. } catch (error) {
  427. console.error("Error updating theme:", error);
  428. res.status(400).json({ error: error.message });
  429. }
  430. });
  431. // DELETE /api/themes/:themeId - Delete custom theme
  432. app.delete("/api/themes/:themeId", requireAuth, async (req, res) => {
  433. try {
  434. const { themeId } = req.params;
  435. await deleteCustomTheme(themeId);
  436. res.json({ success: true, message: "Theme deleted successfully" });
  437. } catch (error) {
  438. console.error("Error deleting theme:", error);
  439. res.status(400).json({ error: error.message });
  440. }
  441. });
  442. // GET /api/themes/:themeId/export - Export theme
  443. app.get("/api/themes/:themeId/export", requireAuth, async (req, res) => {
  444. try {
  445. const { themeId } = req.params;
  446. const themeData = await exportTheme(themeId);
  447. res.setHeader("Content-Type", "application/json");
  448. res.setHeader(
  449. "Content-Disposition",
  450. `attachment; filename="theme-${themeId}.json"`,
  451. );
  452. res.json(themeData);
  453. } catch (error) {
  454. console.error("Error exporting theme:", error);
  455. res.status(400).json({ error: error.message });
  456. }
  457. });
  458. // Health check endpoint
  459. app.get("/api/health", (req, res) => {
  460. res.json({ status: "OK", timestamp: new Date().toISOString() });
  461. });
  462. // Generate initial index on startup
  463. await generateIndex();
  464. app.listen(PORT, () => {
  465. console.log(`🚀 Backend server running on http://localhost:${PORT}`);
  466. console.log(`📁 Posts directory: ${POSTS_DIR}`);
  467. });