|
@@ -6,6 +6,7 @@ import fs from "fs-extra";
|
|
|
import path from "path";
|
|
import path from "path";
|
|
|
import { fileURLToPath } from "url";
|
|
import { fileURLToPath } from "url";
|
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
|
|
|
+import multer from "multer";
|
|
|
|
|
|
|
|
// Load environment variables
|
|
// Load environment variables
|
|
|
dotenv.config();
|
|
dotenv.config();
|
|
@@ -38,6 +39,37 @@ const POSTS_DIR = path.resolve(
|
|
|
);
|
|
);
|
|
|
const INDEX_FILE = path.join(POSTS_DIR, "index.json");
|
|
const INDEX_FILE = path.join(POSTS_DIR, "index.json");
|
|
|
|
|
|
|
|
|
|
+// Multer Storage Configuration
|
|
|
|
|
+const storage = multer.diskStorage({
|
|
|
|
|
+ destination: async function (req, file, cb) {
|
|
|
|
|
+ // Determine folder based on slug
|
|
|
|
|
+ let folder = "uploads"; // Default
|
|
|
|
|
+ if (req.query.slug) {
|
|
|
|
|
+ // Clean the slug just in case, though it should be safe if generated by us
|
|
|
|
|
+ const cleanSlug = req.query.slug.replace(/[^a-z0-9-]/gi, "");
|
|
|
|
|
+ folder = path.join(cleanSlug, "images");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const uploadPath = path.join(POSTS_DIR, folder);
|
|
|
|
|
+ try {
|
|
|
|
|
+ await fs.ensureDir(uploadPath);
|
|
|
|
|
+ cb(null, uploadPath);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ cb(err);
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ filename: function (req, file, cb) {
|
|
|
|
|
+ // Keep original filename but ensure uniqueness if needed?
|
|
|
|
|
+ // For now, simple filename is better for markdown readability.
|
|
|
|
|
+ // We'll replace spaces with dashes.
|
|
|
|
|
+ const cleanName = file.originalname.replace(/[^a-z0-9.]/gi, "-").toLowerCase();
|
|
|
|
|
+ cb(null, cleanName); // Overwrites if exists, which might be desired behavior for updating images
|
|
|
|
|
+ },
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+const upload = multer({ storage: storage });
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
// Middleware
|
|
// Middleware
|
|
|
app.use(
|
|
app.use(
|
|
|
cors({
|
|
cors({
|
|
@@ -51,6 +83,11 @@ app.use(
|
|
|
app.use(express.json());
|
|
app.use(express.json());
|
|
|
app.use(express.urlencoded({ extended: true }));
|
|
app.use(express.urlencoded({ extended: true }));
|
|
|
|
|
|
|
|
|
|
+// Serve static files from POSTS_DIR so images are accessible
|
|
|
|
|
+// Access images via /api/media-files/SLUG/images/FILENAME.jpg
|
|
|
|
|
+// We use /api/media-files to avoid conflict with /api/posts routes and to align with API_BASE
|
|
|
|
|
+app.use("/api/media-files", express.static(POSTS_DIR));
|
|
|
|
|
+
|
|
|
// Session configuration
|
|
// Session configuration
|
|
|
app.use(
|
|
app.use(
|
|
|
session({
|
|
session({
|
|
@@ -91,6 +128,7 @@ function isAuthenticated(req, res, next) {
|
|
|
async function generateIndex() {
|
|
async function generateIndex() {
|
|
|
try {
|
|
try {
|
|
|
const files = await fs.readdir(POSTS_DIR);
|
|
const files = await fs.readdir(POSTS_DIR);
|
|
|
|
|
+ // We only care about root level .md files for the index
|
|
|
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
|
await fs.writeJSON(INDEX_FILE, mdFiles, { spaces: 2 });
|
|
await fs.writeJSON(INDEX_FILE, mdFiles, { spaces: 2 });
|
|
|
console.log(`Index updated: ${mdFiles.length} posts`);
|
|
console.log(`Index updated: ${mdFiles.length} posts`);
|
|
@@ -106,11 +144,13 @@ function parsePostMetadata(content) {
|
|
|
const titleMatch = content.match(/title:\s*(.*)/);
|
|
const titleMatch = content.match(/title:\s*(.*)/);
|
|
|
const descMatch = content.match(/desc:\s*(.*)/);
|
|
const descMatch = content.match(/desc:\s*(.*)/);
|
|
|
const tagsMatch = content.match(/tags:\s*(.*)/);
|
|
const tagsMatch = content.match(/tags:\s*(.*)/);
|
|
|
|
|
+ const hiddenMatch = content.match(/hidden:\s*(true|false)/);
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
title: titleMatch ? titleMatch[1].trim() : "Untitled",
|
|
title: titleMatch ? titleMatch[1].trim() : "Untitled",
|
|
|
description: descMatch ? descMatch[1].trim() : "",
|
|
description: descMatch ? descMatch[1].trim() : "",
|
|
|
tags: tagsMatch ? tagsMatch[1].split(",").map((tag) => tag.trim()) : [],
|
|
tags: tagsMatch ? tagsMatch[1].split(",").map((tag) => tag.trim()) : [],
|
|
|
|
|
+ hidden: hiddenMatch ? hiddenMatch[1] === "true" : false,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -128,6 +168,21 @@ function generateFilename(title) {
|
|
|
return slug ? `${dateStr}-${slug}.md` : `${dateStr}.md`;
|
|
return slug ? `${dateStr}-${slug}.md` : `${dateStr}.md`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Helper to generate just the SLUG part (without date/extension if possible, or matches filename structure)
|
|
|
|
|
+// For internal consistency, we should try to match how valid posts are named.
|
|
|
|
|
+// But if user creates a "temporary" slug from title, we use that.
|
|
|
|
|
+function getSlugFromTitle(title) {
|
|
|
|
|
+ const date = new Date();
|
|
|
|
|
+ const dateStr = date.toISOString().slice(0, 10).replace(/-/g, "");
|
|
|
|
|
+ const slugText = title
|
|
|
|
|
+ .toLowerCase()
|
|
|
|
|
+ .replace(/[^a-z0-9]+/g, "-")
|
|
|
|
|
+ .replace(/^-|-$/g, "")
|
|
|
|
|
+ .slice(0, 30);
|
|
|
|
|
+ return `${dateStr}-${slugText}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
// Authentication Routes
|
|
// Authentication Routes
|
|
|
|
|
|
|
|
// POST /api/auth/login - Login
|
|
// POST /api/auth/login - Login
|
|
@@ -240,7 +295,127 @@ app.post("/api/auth/change-password", requireAuth, async (req, res) => {
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
-// API Routes
|
|
|
|
|
|
|
+// MEDIA ROUTES
|
|
|
|
|
+
|
|
|
|
|
+// POST /api/upload - Upload file
|
|
|
|
|
+// Query param: slug (optional, for organization)
|
|
|
|
|
+app.post("/api/upload", requireAuth, upload.single("file"), (req, res) => {
|
|
|
|
|
+ if (!req.file) {
|
|
|
|
|
+ return res.status(400).json({ error: "No file uploaded" });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const { slug } = req.query;
|
|
|
|
|
+ let urlPath = "";
|
|
|
|
|
+
|
|
|
|
|
+ if (slug) {
|
|
|
|
|
+ // Should match the folder structure logic in storage config
|
|
|
|
|
+ const cleanSlug = slug.replace(/[^a-z0-9-]/gi, "");
|
|
|
|
|
+ urlPath = `/media-files/${cleanSlug}/images/${req.file.filename}`;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ urlPath = `/media-files/uploads/${req.file.filename}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ res.json({
|
|
|
|
|
+ success: true,
|
|
|
|
|
+ url: urlPath,
|
|
|
|
|
+ filename: req.file.filename,
|
|
|
|
|
+ originalName: req.file.originalname,
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// GET /api/media - List files
|
|
|
|
|
+// Query param: slug (optional)
|
|
|
|
|
+app.get("/api/media", requireAuth, async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { slug } = req.query;
|
|
|
|
|
+ let targetDir = "";
|
|
|
|
|
+
|
|
|
|
|
+ if (slug) {
|
|
|
|
|
+ const cleanSlug = slug.replace(/[^a-z0-9-]/gi, "");
|
|
|
|
|
+ targetDir = path.join(POSTS_DIR, cleanSlug, "images");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // If no slug, maybe list all? Or list 'uploads'?
|
|
|
|
|
+ // Providing a 'root' param or just listing everything might be expensive.
|
|
|
|
|
+ // Let's list 'uploads' by default or require a slug properly.
|
|
|
|
|
+ // For a "Media Manager", we might want to scan ALL folders.
|
|
|
|
|
+
|
|
|
|
|
+ // For now, let's implement listing a specific slug's images.
|
|
|
|
|
+ // If we want a global manager, we can return a tree.
|
|
|
|
|
+
|
|
|
|
|
+ // Simple approach: list all directories in POSTS_DIR that are directories, and then their images.
|
|
|
|
|
+ // That's complex. Let's just return empty if no slug, or implement logic later for global view.
|
|
|
|
|
+
|
|
|
|
|
+ // Let's default to "uploads" if no slug, OR if a special flag "all" is present, we scan?
|
|
|
|
|
+ targetDir = path.join(POSTS_DIR, "uploads");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!(await fs.pathExists(targetDir))) {
|
|
|
|
|
+ return res.json([]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const files = await fs.readdir(targetDir);
|
|
|
|
|
+ const mediaFiles = [];
|
|
|
|
|
+
|
|
|
|
|
+ for (const file of files) {
|
|
|
|
|
+ const stats = await fs.stat(path.join(targetDir, file));
|
|
|
|
|
+ if (stats.isFile() && /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(file)) {
|
|
|
|
|
+ const slugPart = slug ? slug.replace(/[^a-z0-9-]/gi, "") : "uploads";
|
|
|
|
|
+ // If slug was passed, url is /media-files/SLUG/images/FILE
|
|
|
|
|
+ // If no slug (using uploads), url is /media-files/uploads/FILE
|
|
|
|
|
+
|
|
|
|
|
+ const url = slug
|
|
|
|
|
+ ? `/media-files/${slugPart}/images/${file}`
|
|
|
|
|
+ : `/media-files/uploads/${file}`;
|
|
|
|
|
+
|
|
|
|
|
+ mediaFiles.push({
|
|
|
|
|
+ name: file,
|
|
|
|
|
+ url: url,
|
|
|
|
|
+ size: stats.size,
|
|
|
|
|
+ date: stats.mtime,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Sort by newest
|
|
|
|
|
+ mediaFiles.sort((a, b) => b.date - a.date);
|
|
|
|
|
+
|
|
|
|
|
+ res.json(mediaFiles);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error("Error listing media:", err);
|
|
|
|
|
+ res.status(500).json({ error: "Failed to list media" });
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// DELETE /api/media - Delete file
|
|
|
|
|
+app.delete("/api/media", requireAuth, async (req, res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { path: relativePath } = req.body; // e.g. "/media-files/slug/images/file.jpg"
|
|
|
|
|
+ if (!relativePath) return res.status(400).json({ error: "Path is required" });
|
|
|
|
|
+
|
|
|
|
|
+ // Security check: ensure path is within POSTS_DIR
|
|
|
|
|
+ // relativePath typically starts with /media-files/
|
|
|
|
|
+ const cleanPath = relativePath.replace(/^\/media-files\//, "");
|
|
|
|
|
+ const fullPath = path.join(POSTS_DIR, cleanPath);
|
|
|
|
|
+
|
|
|
|
|
+ // Prevent directory traversal
|
|
|
|
|
+ if (!fullPath.startsWith(POSTS_DIR)) {
|
|
|
|
|
+ return res.status(403).json({ error: "Invalid path" });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (await fs.pathExists(fullPath)) {
|
|
|
|
|
+ await fs.remove(fullPath);
|
|
|
|
|
+ res.json({ success: true });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ res.status(404).json({ error: "File not found" });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error("Delete media error:", err);
|
|
|
|
|
+ res.status(500).json({ error: "Failed to delete media" });
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// API Routes continue...
|
|
|
|
|
|
|
|
// GET /api/posts - Get all posts with metadata
|
|
// GET /api/posts - Get all posts with metadata
|
|
|
app.get("/api/posts", async (req, res) => {
|
|
app.get("/api/posts", async (req, res) => {
|
|
@@ -266,7 +441,11 @@ app.get("/api/posts", async (req, res) => {
|
|
|
// Sort by creation date, newest first
|
|
// Sort by creation date, newest first
|
|
|
posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
posts.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
|
|
|
|
|
|
- res.json(posts);
|
|
|
|
|
|
|
+ // Filter out hidden posts for non-admins
|
|
|
|
|
+ const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
|
|
|
|
|
+ const visiblePosts = posts.filter(post => isAdmin || !post.hidden);
|
|
|
|
|
+
|
|
|
|
|
+ res.json(visiblePosts);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
console.error("Error fetching posts:", error);
|
|
console.error("Error fetching posts:", error);
|
|
|
res.status(500).json({ error: "Failed to fetch posts" });
|
|
res.status(500).json({ error: "Failed to fetch posts" });
|
|
@@ -288,6 +467,14 @@ app.get("/api/posts/:slug", async (req, res) => {
|
|
|
const metadata = parsePostMetadata(content);
|
|
const metadata = parsePostMetadata(content);
|
|
|
const stats = await fs.stat(filePath);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
|
|
|
|
|
|
+ // Access control for hidden posts
|
|
|
|
|
+ const isAdmin = req.session && req.session.user && req.session.user.role === "admin";
|
|
|
|
|
+ if (metadata.hidden && !isAdmin) {
|
|
|
|
|
+ // Return 404 to hide existence, or 403 if we want to be explicit.
|
|
|
|
|
+ // 404 is safer for "hidden" content.
|
|
|
|
|
+ return res.status(404).json({ error: "Post not found" });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
res.json({
|
|
res.json({
|
|
|
slug,
|
|
slug,
|
|
|
filename,
|
|
filename,
|
|
@@ -305,7 +492,7 @@ app.get("/api/posts/:slug", async (req, res) => {
|
|
|
// POST /api/posts - Create new post
|
|
// POST /api/posts - Create new post
|
|
|
app.post("/api/posts", requireAuth, async (req, res) => {
|
|
app.post("/api/posts", requireAuth, async (req, res) => {
|
|
|
try {
|
|
try {
|
|
|
- const { title, description, content, tags } = req.body;
|
|
|
|
|
|
|
+ const { title, description, content, tags, hidden } = req.body;
|
|
|
|
|
|
|
|
if (!title || !content) {
|
|
if (!title || !content) {
|
|
|
return res
|
|
return res
|
|
@@ -330,6 +517,7 @@ app.post("/api/posts", requireAuth, async (req, res) => {
|
|
|
if (description) postContent += `desc: ${description}\n`;
|
|
if (description) postContent += `desc: ${description}\n`;
|
|
|
if (tags && tags.length > 0)
|
|
if (tags && tags.length > 0)
|
|
|
postContent += `tags: ${tags.join(", ")}\n`;
|
|
postContent += `tags: ${tags.join(", ")}\n`;
|
|
|
|
|
+ if (hidden) postContent += `hidden: true\n`;
|
|
|
postContent += "\n" + content;
|
|
postContent += "\n" + content;
|
|
|
|
|
|
|
|
// Write the file
|
|
// Write the file
|
|
@@ -361,7 +549,7 @@ app.post("/api/posts", requireAuth, async (req, res) => {
|
|
|
app.put("/api/posts/:slug", requireAuth, async (req, res) => {
|
|
app.put("/api/posts/:slug", requireAuth, async (req, res) => {
|
|
|
try {
|
|
try {
|
|
|
const { slug } = req.params;
|
|
const { slug } = req.params;
|
|
|
- const { title, description, content, tags } = req.body;
|
|
|
|
|
|
|
+ const { title, description, content, tags, hidden } = req.body;
|
|
|
|
|
|
|
|
const oldFilename = `${slug}.md`;
|
|
const oldFilename = `${slug}.md`;
|
|
|
const oldFilePath = path.join(POSTS_DIR, oldFilename);
|
|
const oldFilePath = path.join(POSTS_DIR, oldFilename);
|
|
@@ -386,6 +574,7 @@ app.put("/api/posts/:slug", requireAuth, async (req, res) => {
|
|
|
if (description) postContent += `desc: ${description}\n`;
|
|
if (description) postContent += `desc: ${description}\n`;
|
|
|
if (tags && tags.length > 0)
|
|
if (tags && tags.length > 0)
|
|
|
postContent += `tags: ${tags.join(", ")}\n`;
|
|
postContent += `tags: ${tags.join(", ")}\n`;
|
|
|
|
|
+ if (hidden) postContent += `hidden: true\n`;
|
|
|
postContent += "\n" + content;
|
|
postContent += "\n" + content;
|
|
|
|
|
|
|
|
// Write to new file
|
|
// Write to new file
|
|
@@ -394,6 +583,20 @@ app.put("/api/posts/:slug", requireAuth, async (req, res) => {
|
|
|
// If filename changed, remove old file
|
|
// If filename changed, remove old file
|
|
|
if (oldFilename !== newFilename) {
|
|
if (oldFilename !== newFilename) {
|
|
|
await fs.remove(oldFilePath);
|
|
await fs.remove(oldFilePath);
|
|
|
|
|
+ // IMPORTANT: If we rename the post, should we verify if images folder usage needs update?
|
|
|
|
|
+ // Currently images are stored in /slug/images/.
|
|
|
|
|
+ // If the slug (filename) depends on the title, changing title changes slug.
|
|
|
|
|
+ // We should rename the image folder too!
|
|
|
|
|
+
|
|
|
|
|
+ const oldSlug = slug;
|
|
|
|
|
+ const newSlug = newFilename.replace(".md", "");
|
|
|
|
|
+
|
|
|
|
|
+ const oldImgDir = path.join(POSTS_DIR, oldSlug, "images");
|
|
|
|
|
+ const newImgDir = path.join(POSTS_DIR, newSlug, "images");
|
|
|
|
|
+
|
|
|
|
|
+ if (await fs.pathExists(oldImgDir)) {
|
|
|
|
|
+ await fs.move(oldImgDir, newImgDir, { overwrite: true });
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Update index
|
|
// Update index
|
|
@@ -430,6 +633,20 @@ app.delete("/api/posts/:slug", requireAuth, async (req, res) => {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
await fs.remove(filePath);
|
|
await fs.remove(filePath);
|
|
|
|
|
+
|
|
|
|
|
+ // Removing associated images folder
|
|
|
|
|
+ const imgDir = path.join(POSTS_DIR, slug, "images");
|
|
|
|
|
+ if (await fs.pathExists(imgDir)) {
|
|
|
|
|
+ await fs.remove(imgDir);
|
|
|
|
|
+ }
|
|
|
|
|
+ // Also remove parent folder if it was created just for this?
|
|
|
|
|
+ // Structure is POSTS_DIR/slug/images.
|
|
|
|
|
+ // We should remove POSTS_DIR/slug
|
|
|
|
|
+ const slugDir = path.join(POSTS_DIR, slug);
|
|
|
|
|
+ if (await fs.pathExists(slugDir)) {
|
|
|
|
|
+ await fs.remove(slugDir);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
await generateIndex();
|
|
await generateIndex();
|
|
|
|
|
|
|
|
res.json({ message: "Post deleted successfully" });
|
|
res.json({ message: "Post deleted successfully" });
|
|
@@ -440,7 +657,6 @@ app.delete("/api/posts/:slug", requireAuth, async (req, res) => {
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// Theme API Routes
|
|
// Theme API Routes
|
|
|
-
|
|
|
|
|
// GET /api/themes - Get all themes
|
|
// GET /api/themes - Get all themes
|
|
|
app.get("/api/themes", async (req, res) => {
|
|
app.get("/api/themes", async (req, res) => {
|
|
|
try {
|
|
try {
|