server.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766
  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. // Keep original filename but ensure uniqueness if needed?
  57. // For now, simple filename is better for markdown readability.
  58. // We'll replace spaces with dashes.
  59. const cleanName = file.originalname.replace(/[^a-z0-9.]/gi, "-").toLowerCase();
  60. cb(null, cleanName); // Overwrites if exists, which might be desired behavior for updating images
  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. return {
  133. title: titleMatch ? titleMatch[1].trim() : "Untitled",
  134. description: descMatch ? descMatch[1].trim() : "",
  135. tags: tagsMatch ? tagsMatch[1].split(",").map((tag) => tag.trim()) : [],
  136. hidden: hiddenMatch ? hiddenMatch[1] === "true" : false,
  137. };
  138. }
  139. // Helper function to generate filename from title
  140. function generateFilename(title) {
  141. // Create date-based filename similar to existing pattern
  142. const date = new Date();
  143. const dateStr = date.toISOString().slice(0, 10).replace(/-/g, "");
  144. const slug = title
  145. .toLowerCase()
  146. .replace(/[^a-z0-9]+/g, "-")
  147. .replace(/^-|-$/g, "")
  148. .slice(0, 30);
  149. return slug ? `${dateStr}-${slug}.md` : `${dateStr}.md`;
  150. }
  151. // Helper to generate just the SLUG part (without date/extension if possible, or matches filename structure)
  152. // For internal consistency, we should try to match how valid posts are named.
  153. // But if user creates a "temporary" slug from title, we use that.
  154. function getSlugFromTitle(title) {
  155. const date = new Date();
  156. const dateStr = date.toISOString().slice(0, 10).replace(/-/g, "");
  157. const slugText = title
  158. .toLowerCase()
  159. .replace(/[^a-z0-9]+/g, "-")
  160. .replace(/^-|-$/g, "")
  161. .slice(0, 30);
  162. return `${dateStr}-${slugText}`;
  163. }
  164. // Authentication Routes
  165. // POST /api/auth/login - Login
  166. app.post("/api/auth/login", async (req, res) => {
  167. try {
  168. const { username, password } = req.body;
  169. if (!username || !password) {
  170. return res
  171. .status(400)
  172. .json({ error: "Username and password are required" });
  173. }
  174. const user = await authenticateUser(username, password);
  175. if (!user) {
  176. return res
  177. .status(401)
  178. .json({ error: "Invalid username or password" });
  179. }
  180. // Store user in session
  181. req.session.user = user;
  182. console.log("Login successful - Session ID:", req.sessionID);
  183. console.log("Login successful - Stored user:", req.session.user);
  184. // Manually save the session to ensure it's persisted
  185. req.session.save((err) => {
  186. if (err) {
  187. console.error("Session save error:", err);
  188. return res
  189. .status(500)
  190. .json({ error: "Failed to save session" });
  191. }
  192. console.log("Session saved successfully");
  193. res.json({
  194. success: true,
  195. user: {
  196. username: user.username,
  197. role: user.role,
  198. },
  199. });
  200. });
  201. } catch (error) {
  202. console.error("Login error:", error);
  203. res.status(500).json({ error: "Login failed" });
  204. }
  205. });
  206. // POST /api/auth/logout - Logout
  207. app.post("/api/auth/logout", (req, res) => {
  208. req.session.destroy((err) => {
  209. if (err) {
  210. return res.status(500).json({ error: "Logout failed" });
  211. }
  212. res.clearCookie("gooneral-session"); // Use the same name as configured
  213. res.json({ success: true, message: "Logged out successfully" });
  214. });
  215. });
  216. // GET /api/auth/me - Get current user
  217. app.get("/api/auth/me", isAuthenticated, (req, res) => {
  218. console.log("Auth check - Session ID:", req.sessionID);
  219. console.log("Auth check - Session user:", req.session?.user);
  220. console.log("Auth check - Is authenticated:", req.isAuthenticated);
  221. if (req.isAuthenticated) {
  222. res.json({
  223. user: {
  224. username: req.user.username,
  225. role: req.user.role,
  226. },
  227. });
  228. } else {
  229. res.json({ user: null });
  230. }
  231. });
  232. // POST /api/auth/change-password - Change password
  233. app.post("/api/auth/change-password", requireAuth, async (req, res) => {
  234. try {
  235. const { currentPassword, newPassword } = req.body;
  236. if (!currentPassword || !newPassword) {
  237. return res.status(400).json({
  238. error: "Current password and new password are required",
  239. });
  240. }
  241. if (newPassword.length < 6) {
  242. return res.status(400).json({
  243. error: "New password must be at least 6 characters long",
  244. });
  245. }
  246. const result = await changeUserPassword(
  247. req.user.username,
  248. currentPassword,
  249. newPassword,
  250. );
  251. if (result.success) {
  252. res.json({ success: true, message: result.message });
  253. } else {
  254. res.status(400).json({ error: result.message });
  255. }
  256. } catch (error) {
  257. console.error("Change password error:", error);
  258. res.status(500).json({ error: "Failed to change password" });
  259. }
  260. });
  261. // MEDIA ROUTES
  262. // POST /api/upload - Upload file
  263. // Query param: slug (optional, for organization)
  264. app.post("/api/upload", requireAuth, upload.single("file"), (req, res) => {
  265. if (!req.file) {
  266. return res.status(400).json({ error: "No file uploaded" });
  267. }
  268. const { slug } = req.query;
  269. let urlPath = "";
  270. if (slug) {
  271. // Should match the folder structure logic in storage config
  272. const cleanSlug = slug.replace(/[^a-z0-9-]/gi, "");
  273. urlPath = `/media-files/${cleanSlug}/images/${req.file.filename}`;
  274. } else {
  275. urlPath = `/media-files/uploads/${req.file.filename}`;
  276. }
  277. res.json({
  278. success: true,
  279. url: urlPath,
  280. filename: req.file.filename,
  281. originalName: req.file.originalname,
  282. });
  283. });
  284. // GET /api/media - List files
  285. // Query param: slug (optional)
  286. app.get("/api/media", requireAuth, async (req, res) => {
  287. try {
  288. const { slug } = req.query;
  289. let targetDir = "";
  290. if (slug) {
  291. const cleanSlug = slug.replace(/[^a-z0-9-]/gi, "");
  292. targetDir = path.join(POSTS_DIR, cleanSlug, "images");
  293. } else {
  294. // If no slug, maybe list all? Or list 'uploads'?
  295. // Providing a 'root' param or just listing everything might be expensive.
  296. // Let's list 'uploads' by default or require a slug properly.
  297. // For a "Media Manager", we might want to scan ALL folders.
  298. // For now, let's implement listing a specific slug's images.
  299. // If we want a global manager, we can return a tree.
  300. // Simple approach: list all directories in POSTS_DIR that are directories, and then their images.
  301. // That's complex. Let's just return empty if no slug, or implement logic later for global view.
  302. // Let's default to "uploads" if no slug, OR if a special flag "all" is present, we scan?
  303. targetDir = path.join(POSTS_DIR, "uploads");
  304. }
  305. if (!(await fs.pathExists(targetDir))) {
  306. return res.json([]);
  307. }
  308. const files = await fs.readdir(targetDir);
  309. const mediaFiles = [];
  310. for (const file of files) {
  311. const stats = await fs.stat(path.join(targetDir, file));
  312. if (stats.isFile() && /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file)) {
  313. const slugPart = slug ? slug.replace(/[^a-z0-9-]/gi, "") : "uploads";
  314. // If slug was passed, url is /media-files/SLUG/images/FILE
  315. // If no slug (using uploads), url is /media-files/uploads/FILE
  316. const url = slug
  317. ? `/media-files/${slugPart}/images/${file}`
  318. : `/media-files/uploads/${file}`;
  319. mediaFiles.push({
  320. name: file,
  321. url: url,
  322. size: stats.size,
  323. date: stats.mtime,
  324. });
  325. }
  326. }
  327. // Sort by newest
  328. mediaFiles.sort((a, b) => b.date - a.date);
  329. res.json(mediaFiles);
  330. } catch (err) {
  331. console.error("Error listing media:", err);
  332. res.status(500).json({ error: "Failed to list media" });
  333. }
  334. });
  335. // DELETE /api/media - Delete file
  336. app.delete("/api/media", requireAuth, async (req, res) => {
  337. try {
  338. const { path: relativePath } = req.body; // e.g. "/media-files/slug/images/file.jpg"
  339. if (!relativePath) return res.status(400).json({ error: "Path is required" });
  340. // Security check: ensure path is within POSTS_DIR
  341. // relativePath typically starts with /media-files/
  342. const cleanPath = relativePath.replace(/^\/media-files\//, "");
  343. const fullPath = path.join(POSTS_DIR, cleanPath);
  344. // Prevent directory traversal
  345. if (!fullPath.startsWith(POSTS_DIR)) {
  346. return res.status(403).json({ error: "Invalid path" });
  347. }
  348. if (await fs.pathExists(fullPath)) {
  349. await fs.remove(fullPath);
  350. res.json({ success: true });
  351. } else {
  352. res.status(404).json({ error: "File not found" });
  353. }
  354. } catch (err) {
  355. console.error("Delete media error:", err);
  356. res.status(500).json({ error: "Failed to delete media" });
  357. }
  358. });
  359. // API Routes continue...
  360. // GET /api/posts - Get all posts with metadata
  361. app.get("/api/posts", async (req, res) => {
  362. try {
  363. const files = await generateIndex();
  364. const posts = [];
  365. for (const filename of files) {
  366. const filePath = path.join(POSTS_DIR, filename);
  367. const content = await fs.readFile(filePath, "utf8");
  368. const metadata = parsePostMetadata(content);
  369. const slug = filename.replace(".md", "");
  370. posts.push({
  371. slug,
  372. filename,
  373. ...metadata,
  374. createdAt: (await fs.stat(filePath)).birthtime,
  375. updatedAt: (await fs.stat(filePath)).mtime,
  376. });
  377. }
  378. // Sort by creation date, newest first
  379. posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  380. // Filter out hidden posts for non-admins
  381. const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
  382. const visiblePosts = posts.filter(post => isAdmin || !post.hidden);
  383. res.json(visiblePosts);
  384. } catch (error) {
  385. console.error("Error fetching posts:", error);
  386. res.status(500).json({ error: "Failed to fetch posts" });
  387. }
  388. });
  389. // GET /api/posts/:slug - Get specific post
  390. app.get("/api/posts/:slug", async (req, res) => {
  391. try {
  392. const { slug } = req.params;
  393. const filename = `${slug}.md`;
  394. const filePath = path.join(POSTS_DIR, filename);
  395. if (!(await fs.pathExists(filePath))) {
  396. return res.status(404).json({ error: "Post not found" });
  397. }
  398. const content = await fs.readFile(filePath, "utf8");
  399. const metadata = parsePostMetadata(content);
  400. const stats = await fs.stat(filePath);
  401. // Access control for hidden posts
  402. const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
  403. if (metadata.hidden && !isAdmin) {
  404. // Return 404 to hide existence, or 403 if we want to be explicit.
  405. // 404 is safer for "hidden" content.
  406. return res.status(404).json({ error: "Post not found" });
  407. }
  408. res.json({
  409. slug,
  410. filename,
  411. ...metadata,
  412. content,
  413. createdAt: stats.birthtime,
  414. updatedAt: stats.mtime,
  415. });
  416. } catch (error) {
  417. console.error("Error fetching post:", error);
  418. res.status(500).json({ error: "Failed to fetch post" });
  419. }
  420. });
  421. // POST /api/posts - Create new post
  422. app.post("/api/posts", requireAuth, async (req, res) => {
  423. try {
  424. const { title, description, content, tags, hidden } = req.body;
  425. if (!title || !content) {
  426. return res
  427. .status(400)
  428. .json({ error: "Title and content are required" });
  429. }
  430. // Generate filename
  431. const filename = generateFilename(title);
  432. const filePath = path.join(POSTS_DIR, filename);
  433. // Check if file already exists
  434. if (await fs.pathExists(filePath)) {
  435. return res
  436. .status(409)
  437. .json({ error: "Post with similar title already exists" });
  438. }
  439. // Format the post content
  440. let postContent = "";
  441. postContent += `title: ${title}\n`;
  442. if (description) postContent += `desc: ${description}\n`;
  443. if (tags && tags.length > 0)
  444. postContent += `tags: ${tags.join(", ")}\n`;
  445. if (hidden) postContent += `hidden: true\n`;
  446. postContent += "\n" + content;
  447. // Write the file
  448. await fs.writeFile(filePath, postContent, "utf8");
  449. // Update index
  450. await generateIndex();
  451. const slug = filename.replace(".md", "");
  452. const stats = await fs.stat(filePath);
  453. res.status(201).json({
  454. slug,
  455. filename,
  456. title,
  457. description: description || "",
  458. tags: tags || [],
  459. content: postContent,
  460. createdAt: stats.birthtime,
  461. updatedAt: stats.mtime,
  462. });
  463. } catch (error) {
  464. console.error("Error creating post:", error);
  465. res.status(500).json({ error: "Failed to create post" });
  466. }
  467. });
  468. // PUT /api/posts/:slug - Update existing post
  469. app.put("/api/posts/:slug", requireAuth, async (req, res) => {
  470. try {
  471. const { slug } = req.params;
  472. const { title, description, content, tags, hidden } = req.body;
  473. const oldFilename = `${slug}.md`;
  474. const oldFilePath = path.join(POSTS_DIR, oldFilename);
  475. if (!(await fs.pathExists(oldFilePath))) {
  476. return res.status(404).json({ error: "Post not found" });
  477. }
  478. if (!title || !content) {
  479. return res
  480. .status(400)
  481. .json({ error: "Title and content are required" });
  482. }
  483. // Generate new filename if title changed
  484. const newFilename = generateFilename(title);
  485. const newFilePath = path.join(POSTS_DIR, newFilename);
  486. // Format the post content
  487. let postContent = "";
  488. postContent += `title: ${title}\n`;
  489. if (description) postContent += `desc: ${description}\n`;
  490. if (tags && tags.length > 0)
  491. postContent += `tags: ${tags.join(", ")}\n`;
  492. if (hidden) postContent += `hidden: true\n`;
  493. postContent += "\n" + content;
  494. // Write to new file
  495. await fs.writeFile(newFilePath, postContent, "utf8");
  496. // If filename changed, remove old file
  497. if (oldFilename !== newFilename) {
  498. await fs.remove(oldFilePath);
  499. // IMPORTANT: If we rename the post, should we verify if images folder usage needs update?
  500. // Currently images are stored in /slug/images/.
  501. // If the slug (filename) depends on the title, changing title changes slug.
  502. // We should rename the image folder too!
  503. const oldSlug = slug;
  504. const newSlug = newFilename.replace(".md", "");
  505. const oldImgDir = path.join(POSTS_DIR, oldSlug, "images");
  506. const newImgDir = path.join(POSTS_DIR, newSlug, "images");
  507. if (await fs.pathExists(oldImgDir)) {
  508. await fs.move(oldImgDir, newImgDir, { overwrite: true });
  509. }
  510. }
  511. // Update index
  512. await generateIndex();
  513. const newSlug = newFilename.replace(".md", "");
  514. const stats = await fs.stat(newFilePath);
  515. res.json({
  516. slug: newSlug,
  517. filename: newFilename,
  518. title,
  519. description: description || "",
  520. tags: tags || [],
  521. content: postContent,
  522. createdAt: stats.birthtime,
  523. updatedAt: stats.mtime,
  524. });
  525. } catch (error) {
  526. console.error("Error updating post:", error);
  527. res.status(500).json({ error: "Failed to update post" });
  528. }
  529. });
  530. // DELETE /api/posts/:slug - Delete post
  531. app.delete("/api/posts/:slug", requireAuth, async (req, res) => {
  532. try {
  533. const { slug } = req.params;
  534. const filename = `${slug}.md`;
  535. const filePath = path.join(POSTS_DIR, filename);
  536. if (!(await fs.pathExists(filePath))) {
  537. return res.status(404).json({ error: "Post not found" });
  538. }
  539. await fs.remove(filePath);
  540. // Removing associated images folder
  541. const imgDir = path.join(POSTS_DIR, slug, "images");
  542. if (await fs.pathExists(imgDir)) {
  543. await fs.remove(imgDir);
  544. }
  545. // Also remove parent folder if it was created just for this?
  546. // Structure is POSTS_DIR/slug/images.
  547. // We should remove POSTS_DIR/slug
  548. const slugDir = path.join(POSTS_DIR, slug);
  549. if (await fs.pathExists(slugDir)) {
  550. await fs.remove(slugDir);
  551. }
  552. await generateIndex();
  553. res.json({ message: "Post deleted successfully" });
  554. } catch (error) {
  555. console.error("Error deleting post:", error);
  556. res.status(500).json({ error: "Failed to delete post" });
  557. }
  558. });
  559. // Theme API Routes
  560. // GET /api/themes - Get all themes
  561. app.get("/api/themes", async (req, res) => {
  562. try {
  563. const themesData = await getAllThemes();
  564. res.json(themesData);
  565. } catch (error) {
  566. console.error("Error fetching themes:", error);
  567. res.status(500).json({ error: "Failed to fetch themes" });
  568. }
  569. });
  570. // GET /api/themes/active - Get active theme
  571. app.get("/api/themes/active", async (req, res) => {
  572. try {
  573. const activeTheme = await getActiveTheme();
  574. res.json(activeTheme);
  575. } catch (error) {
  576. console.error("Error fetching active theme:", error);
  577. res.status(500).json({ error: "Failed to fetch active theme" });
  578. }
  579. });
  580. // PUT /api/themes/active - Set active theme
  581. app.put("/api/themes/active", requireAuth, async (req, res) => {
  582. try {
  583. const { themeId } = req.body;
  584. if (!themeId) {
  585. return res.status(400).json({ error: "Theme ID is required" });
  586. }
  587. const activeTheme = await setActiveTheme(themeId);
  588. res.json({ success: true, theme: activeTheme });
  589. } catch (error) {
  590. console.error("Error setting active theme:", error);
  591. res.status(400).json({ error: error.message });
  592. }
  593. });
  594. // POST /api/themes - Create custom theme
  595. app.post("/api/themes", requireAuth, async (req, res) => {
  596. try {
  597. const themeData = req.body;
  598. const newTheme = await createCustomTheme(themeData);
  599. res.status(201).json(newTheme);
  600. } catch (error) {
  601. console.error("Error creating theme:", error);
  602. res.status(400).json({ error: error.message });
  603. }
  604. });
  605. // PUT /api/themes/:themeId - Update custom theme
  606. app.put("/api/themes/:themeId", requireAuth, async (req, res) => {
  607. try {
  608. const { themeId } = req.params;
  609. const themeData = req.body;
  610. const updatedTheme = await updateCustomTheme(themeId, themeData);
  611. res.json(updatedTheme);
  612. } catch (error) {
  613. console.error("Error updating theme:", error);
  614. res.status(400).json({ error: error.message });
  615. }
  616. });
  617. // DELETE /api/themes/:themeId - Delete custom theme
  618. app.delete("/api/themes/:themeId", requireAuth, async (req, res) => {
  619. try {
  620. const { themeId } = req.params;
  621. await deleteCustomTheme(themeId);
  622. res.json({ success: true, message: "Theme deleted successfully" });
  623. } catch (error) {
  624. console.error("Error deleting theme:", error);
  625. res.status(400).json({ error: error.message });
  626. }
  627. });
  628. // GET /api/themes/:themeId/export - Export theme
  629. app.get("/api/themes/:themeId/export", requireAuth, async (req, res) => {
  630. try {
  631. const { themeId } = req.params;
  632. const themeData = await exportTheme(themeId);
  633. res.setHeader("Content-Type", "application/json");
  634. res.setHeader(
  635. "Content-Disposition",
  636. `attachment; filename="theme-${themeId}.json"`,
  637. );
  638. res.json(themeData);
  639. } catch (error) {
  640. console.error("Error exporting theme:", error);
  641. res.status(400).json({ error: error.message });
  642. }
  643. });
  644. // Health check endpoint
  645. app.get("/api/health", (req, res) => {
  646. res.json({ status: "OK", timestamp: new Date().toISOString() });
  647. });
  648. // Generate initial index on startup
  649. await generateIndex();
  650. app.listen(PORT, () => {
  651. console.log(`🚀 Backend server running on http://localhost:${PORT}`);
  652. console.log(`📁 Posts directory: ${POSTS_DIR}`);
  653. });