server.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import express from 'express';
  2. import cors from 'cors';
  3. import session from 'express-session';
  4. import fs from 'fs-extra';
  5. import path from 'path';
  6. import { fileURLToPath } from 'url';
  7. import { v4 as uuidv4 } from 'uuid';
  8. import { authenticateUser, getUserByUsername, changeUserPassword } from './auth.js';
  9. const __filename = fileURLToPath(import.meta.url);
  10. const __dirname = path.dirname(__filename);
  11. const app = express();
  12. const PORT = process.env.PORT || 3001;
  13. // Paths
  14. const POSTS_DIR = path.join(__dirname, '../public/posts');
  15. const INDEX_FILE = path.join(POSTS_DIR, 'index.json');
  16. // Middleware
  17. app.use(cors({
  18. origin: 'http://localhost:5173', // Frontend URL
  19. credentials: true // Enable cookies
  20. }));
  21. app.use(express.json());
  22. app.use(express.urlencoded({ extended: true }));
  23. // Session configuration
  24. app.use(session({
  25. secret: process.env.SESSION_SECRET || 'gooneral-wheelchair-secret-key-change-in-production',
  26. resave: false,
  27. saveUninitialized: false,
  28. cookie: {
  29. secure: false, // Set to true in production with HTTPS
  30. httpOnly: true,
  31. maxAge: 24 * 60 * 60 * 1000 // 24 hours
  32. }
  33. }));
  34. // Ensure posts directory exists
  35. await fs.ensureDir(POSTS_DIR);
  36. // Authentication middleware
  37. function requireAuth(req, res, next) {
  38. if (req.session && req.session.user && req.session.user.role === 'admin') {
  39. return next();
  40. }
  41. return res.status(401).json({ error: 'Authentication required' });
  42. }
  43. // Check if user is authenticated
  44. function isAuthenticated(req, res, next) {
  45. req.isAuthenticated = !!(req.session && req.session.user);
  46. req.user = req.session?.user || null;
  47. next();
  48. }
  49. // Helper function to generate index.json
  50. async function generateIndex() {
  51. try {
  52. const files = await fs.readdir(POSTS_DIR);
  53. const mdFiles = files.filter(f => f.endsWith('.md'));
  54. await fs.writeJSON(INDEX_FILE, mdFiles, { spaces: 2 });
  55. console.log(`Index updated: ${mdFiles.length} posts`);
  56. return mdFiles;
  57. } catch (error) {
  58. console.error('Error generating index:', error);
  59. throw error;
  60. }
  61. }
  62. // Helper function to parse post metadata
  63. function parsePostMetadata(content) {
  64. const titleMatch = content.match(/title:\s*(.*)/);
  65. const descMatch = content.match(/desc:\s*(.*)/);
  66. const tagsMatch = content.match(/tags:\s*(.*)/);
  67. return {
  68. title: titleMatch ? titleMatch[1].trim() : 'Untitled',
  69. description: descMatch ? descMatch[1].trim() : '',
  70. tags: tagsMatch ? tagsMatch[1].split(',').map(tag => tag.trim()) : []
  71. };
  72. }
  73. // Helper function to generate filename from title
  74. function generateFilename(title) {
  75. // Create date-based filename similar to existing pattern
  76. const date = new Date();
  77. const dateStr = date.toISOString().slice(0, 10).replace(/-/g, '');
  78. const slug = title.toLowerCase()
  79. .replace(/[^a-z0-9]+/g, '-')
  80. .replace(/^-|-$/g, '')
  81. .slice(0, 30);
  82. return slug ? `${dateStr}-${slug}.md` : `${dateStr}.md`;
  83. }
  84. // Authentication Routes
  85. // POST /api/auth/login - Login
  86. app.post('/api/auth/login', async (req, res) => {
  87. try {
  88. const { username, password } = req.body;
  89. if (!username || !password) {
  90. return res.status(400).json({ error: 'Username and password are required' });
  91. }
  92. const user = await authenticateUser(username, password);
  93. if (!user) {
  94. return res.status(401).json({ error: 'Invalid username or password' });
  95. }
  96. // Store user in session
  97. req.session.user = user;
  98. res.json({
  99. success: true,
  100. user: {
  101. username: user.username,
  102. role: user.role
  103. }
  104. });
  105. } catch (error) {
  106. console.error('Login error:', error);
  107. res.status(500).json({ error: 'Login failed' });
  108. }
  109. });
  110. // POST /api/auth/logout - Logout
  111. app.post('/api/auth/logout', (req, res) => {
  112. req.session.destroy((err) => {
  113. if (err) {
  114. return res.status(500).json({ error: 'Logout failed' });
  115. }
  116. res.clearCookie('connect.sid');
  117. res.json({ success: true, message: 'Logged out successfully' });
  118. });
  119. });
  120. // GET /api/auth/me - Get current user
  121. app.get('/api/auth/me', isAuthenticated, (req, res) => {
  122. if (req.isAuthenticated) {
  123. res.json({
  124. user: {
  125. username: req.user.username,
  126. role: req.user.role
  127. }
  128. });
  129. } else {
  130. res.json({ user: null });
  131. }
  132. });
  133. // POST /api/auth/change-password - Change password
  134. app.post('/api/auth/change-password', requireAuth, async (req, res) => {
  135. try {
  136. const { currentPassword, newPassword } = req.body;
  137. if (!currentPassword || !newPassword) {
  138. return res.status(400).json({ error: 'Current password and new password are required' });
  139. }
  140. if (newPassword.length < 6) {
  141. return res.status(400).json({ error: 'New password must be at least 6 characters long' });
  142. }
  143. const result = await changeUserPassword(req.user.username, currentPassword, newPassword);
  144. if (result.success) {
  145. res.json({ success: true, message: result.message });
  146. } else {
  147. res.status(400).json({ error: result.message });
  148. }
  149. } catch (error) {
  150. console.error('Change password error:', error);
  151. res.status(500).json({ error: 'Failed to change password' });
  152. }
  153. });
  154. // API Routes
  155. // GET /api/posts - Get all posts with metadata
  156. app.get('/api/posts', async (req, res) => {
  157. try {
  158. const files = await generateIndex();
  159. const posts = [];
  160. for (const filename of files) {
  161. const filePath = path.join(POSTS_DIR, filename);
  162. const content = await fs.readFile(filePath, 'utf8');
  163. const metadata = parsePostMetadata(content);
  164. const slug = filename.replace('.md', '');
  165. posts.push({
  166. slug,
  167. filename,
  168. ...metadata,
  169. content,
  170. createdAt: (await fs.stat(filePath)).birthtime,
  171. updatedAt: (await fs.stat(filePath)).mtime
  172. });
  173. }
  174. // Sort by creation date, newest first
  175. posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  176. res.json(posts);
  177. } catch (error) {
  178. console.error('Error fetching posts:', error);
  179. res.status(500).json({ error: 'Failed to fetch posts' });
  180. }
  181. });
  182. // GET /api/posts/:slug - Get specific post
  183. app.get('/api/posts/:slug', async (req, res) => {
  184. try {
  185. const { slug } = req.params;
  186. const filename = `${slug}.md`;
  187. const filePath = path.join(POSTS_DIR, filename);
  188. if (!(await fs.pathExists(filePath))) {
  189. return res.status(404).json({ error: 'Post not found' });
  190. }
  191. const content = await fs.readFile(filePath, 'utf8');
  192. const metadata = parsePostMetadata(content);
  193. const stats = await fs.stat(filePath);
  194. res.json({
  195. slug,
  196. filename,
  197. ...metadata,
  198. content,
  199. createdAt: stats.birthtime,
  200. updatedAt: stats.mtime
  201. });
  202. } catch (error) {
  203. console.error('Error fetching post:', error);
  204. res.status(500).json({ error: 'Failed to fetch post' });
  205. }
  206. });
  207. // POST /api/posts - Create new post
  208. app.post('/api/posts', requireAuth, async (req, res) => {
  209. try {
  210. const { title, description, content, tags } = req.body;
  211. if (!title || !content) {
  212. return res.status(400).json({ error: 'Title and content are required' });
  213. }
  214. // Generate filename
  215. const filename = generateFilename(title);
  216. const filePath = path.join(POSTS_DIR, filename);
  217. // Check if file already exists
  218. if (await fs.pathExists(filePath)) {
  219. return res.status(409).json({ error: 'Post with similar title already exists' });
  220. }
  221. // Format the post content
  222. let postContent = '';
  223. postContent += `title: ${title}\n`;
  224. if (description) postContent += `desc: ${description}\n`;
  225. if (tags && tags.length > 0) postContent += `tags: ${tags.join(', ')}\n`;
  226. postContent += '\n' + content;
  227. // Write the file
  228. await fs.writeFile(filePath, postContent, 'utf8');
  229. // Update index
  230. await generateIndex();
  231. const slug = filename.replace('.md', '');
  232. const stats = await fs.stat(filePath);
  233. res.status(201).json({
  234. slug,
  235. filename,
  236. title,
  237. description: description || '',
  238. tags: tags || [],
  239. content: postContent,
  240. createdAt: stats.birthtime,
  241. updatedAt: stats.mtime
  242. });
  243. } catch (error) {
  244. console.error('Error creating post:', error);
  245. res.status(500).json({ error: 'Failed to create post' });
  246. }
  247. });
  248. // PUT /api/posts/:slug - Update existing post
  249. app.put('/api/posts/:slug', requireAuth, async (req, res) => {
  250. try {
  251. const { slug } = req.params;
  252. const { title, description, content, tags } = req.body;
  253. const oldFilename = `${slug}.md`;
  254. const oldFilePath = path.join(POSTS_DIR, oldFilename);
  255. if (!(await fs.pathExists(oldFilePath))) {
  256. return res.status(404).json({ error: 'Post not found' });
  257. }
  258. if (!title || !content) {
  259. return res.status(400).json({ error: 'Title and content are required' });
  260. }
  261. // Generate new filename if title changed
  262. const newFilename = generateFilename(title);
  263. const newFilePath = path.join(POSTS_DIR, newFilename);
  264. // Format the post content
  265. let postContent = '';
  266. postContent += `title: ${title}\n`;
  267. if (description) postContent += `desc: ${description}\n`;
  268. if (tags && tags.length > 0) postContent += `tags: ${tags.join(', ')}\n`;
  269. postContent += '\n' + content;
  270. // Write to new file
  271. await fs.writeFile(newFilePath, postContent, 'utf8');
  272. // If filename changed, remove old file
  273. if (oldFilename !== newFilename) {
  274. await fs.remove(oldFilePath);
  275. }
  276. // Update index
  277. await generateIndex();
  278. const newSlug = newFilename.replace('.md', '');
  279. const stats = await fs.stat(newFilePath);
  280. res.json({
  281. slug: newSlug,
  282. filename: newFilename,
  283. title,
  284. description: description || '',
  285. tags: tags || [],
  286. content: postContent,
  287. createdAt: stats.birthtime,
  288. updatedAt: stats.mtime
  289. });
  290. } catch (error) {
  291. console.error('Error updating post:', error);
  292. res.status(500).json({ error: 'Failed to update post' });
  293. }
  294. });
  295. // DELETE /api/posts/:slug - Delete post
  296. app.delete('/api/posts/:slug', requireAuth, async (req, res) => {
  297. try {
  298. const { slug } = req.params;
  299. const filename = `${slug}.md`;
  300. const filePath = path.join(POSTS_DIR, filename);
  301. if (!(await fs.pathExists(filePath))) {
  302. return res.status(404).json({ error: 'Post not found' });
  303. }
  304. await fs.remove(filePath);
  305. await generateIndex();
  306. res.json({ message: 'Post deleted successfully' });
  307. } catch (error) {
  308. console.error('Error deleting post:', error);
  309. res.status(500).json({ error: 'Failed to delete post' });
  310. }
  311. });
  312. // Health check endpoint
  313. app.get('/api/health', (req, res) => {
  314. res.json({ status: 'OK', timestamp: new Date().toISOString() });
  315. });
  316. // Generate initial index on startup
  317. await generateIndex();
  318. app.listen(PORT, () => {
  319. console.log(`🚀 Backend server running on http://localhost:${PORT}`);
  320. console.log(`📁 Posts directory: ${POSTS_DIR}`);
  321. });