server.js 15 KB

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