const express = require("express"); const router = express.Router(); const multer = require("multer"); const path = require("path"); const fs = require("fs").promises; const { requireAuth } = require("../middleware/auth"); const { pool } = require("../config/database"); const logger = require("../config/logger"); const { uploadLimiter } = require("../config/rateLimiter"); require("dotenv").config(); // Magic bytes for image file validation const MAGIC_BYTES = { jpeg: [0xff, 0xd8, 0xff], png: [0x89, 0x50, 0x4e, 0x47], gif: [0x47, 0x49, 0x46], webp: [0x52, 0x49, 0x46, 0x46], }; // Validate file content by checking magic bytes const validateFileContent = async (filePath, mimetype) => { try { const buffer = Buffer.alloc(8); const fd = await fs.open(filePath, "r"); await fd.read(buffer, 0, 8, 0); await fd.close(); // Check JPEG if (mimetype === "image/jpeg" || mimetype === "image/jpg") { return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; } // Check PNG if (mimetype === "image/png") { return ( buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 ); } // Check GIF if (mimetype === "image/gif") { return buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46; } // Check WebP if (mimetype === "image/webp") { return ( buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 ); } return false; } catch (error) { logger.error("Magic byte validation error:", error); return false; } }; // Allowed file types const ALLOWED_MIME_TYPES = ( process.env.ALLOWED_FILE_TYPES || "image/jpeg,image/png,image/gif,image/webp" ).split(","); const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE) || 5 * 1024 * 1024; // 5MB default // Configure multer for file uploads const storage = multer.diskStorage({ destination: async function (req, file, cb) { const uploadDir = path.join(__dirname, "..", "..", "website", "uploads"); try { await fs.mkdir(uploadDir, { recursive: true }); cb(null, uploadDir); } catch (error) { logger.error("Error creating upload directory:", error); cb(error); } }, filename: function (req, file, cb) { // Generate unique filename const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9); const ext = path.extname(file.originalname).toLowerCase(); const name = path .basename(file.originalname, ext) .replace(/[^a-z0-9]/gi, "-") .toLowerCase() .substring(0, 50); // Limit filename length cb(null, name + "-" + uniqueSuffix + ext); }, }); const upload = multer({ storage: storage, limits: { fileSize: MAX_FILE_SIZE, files: 10, // Max 10 files per request }, fileFilter: function (req, file, cb) { // Validate MIME type if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { logger.warn("File upload rejected - invalid type", { mimetype: file.mimetype, userId: req.session?.user?.id, }); return cb( new Error( `File type not allowed. Allowed types: ${ALLOWED_MIME_TYPES.join( ", " )}` ), false ); } // Validate file extension const ext = path.extname(file.originalname).toLowerCase(); const allowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; if (!allowedExtensions.includes(ext)) { logger.warn("File upload rejected - invalid extension", { extension: ext, userId: req.session?.user?.id, }); return cb(new Error("Invalid file extension"), false); } cb(null, true); }, }); // Upload multiple files router.post( "/upload", requireAuth, uploadLimiter, upload.array("files", 10), async (req, res, next) => { try { if (!req.files || req.files.length === 0) { return res.status(400).json({ success: false, message: "No files uploaded", }); } const uploadedBy = req.session.user?.id || null; const folderId = req.body.folder_id ? parseInt(req.body.folder_id) : null; const files = []; // Validate file content with magic bytes for (const file of req.files) { const isValid = await validateFileContent(file.path, file.mimetype); if (!isValid) { logger.warn("File upload rejected - magic byte mismatch", { filename: file.filename, mimetype: file.mimetype, userId: uploadedBy, }); // Clean up invalid file await fs .unlink(file.path) .catch((err) => logger.error("Failed to clean up invalid file:", err) ); return res.status(400).json({ success: false, message: `File ${file.originalname} failed security validation`, }); } } // Insert each file into database for (const file of req.files) { try { const result = await pool.query( `INSERT INTO uploads (filename, original_name, file_path, file_size, mime_type, uploaded_by, folder_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) RETURNING id, filename, original_name, file_path, file_size, mime_type, folder_id, created_at`, [ file.filename, file.originalname, `/uploads/${file.filename}`, file.size, file.mimetype, uploadedBy, folderId, ] ); files.push({ id: result.rows[0].id, filename: result.rows[0].filename, originalName: result.rows[0].original_name, size: result.rows[0].file_size, mimetype: result.rows[0].mime_type, path: result.rows[0].file_path, uploadDate: result.rows[0].created_at, folderId: result.rows[0].folder_id, }); logger.info("File uploaded successfully", { fileId: result.rows[0].id, filename: file.filename, userId: uploadedBy, }); } catch (dbError) { logger.error("Database insert failed for file:", { filename: file.filename, error: dbError.message, }); // Clean up this specific file await fs .unlink(file.path) .catch((err) => logger.error("Failed to clean up file:", err)); } } if (files.length === 0) { return res.status(500).json({ success: false, message: "Failed to save uploaded files", }); } res.json({ success: true, message: `${files.length} file(s) uploaded successfully`, files: files, }); } catch (error) { logger.error("Upload error:", error); // Clean up all uploaded files on error if (req.files) { for (const file of req.files) { try { await fs.unlink(file.path); } catch (unlinkError) { logger.error("Error cleaning up file:", unlinkError); } } } next(error); } } ); // Get all uploaded files router.get("/uploads", requireAuth, async (req, res) => { try { const folderId = req.query.folder_id; let query = `SELECT id, filename, original_name, file_path, file_size, mime_type, uploaded_by, folder_id, created_at, updated_at, used_in_type, used_in_id FROM uploads`; const params = []; if (folderId !== undefined) { if (folderId === "null" || folderId === "") { query += ` WHERE folder_id IS NULL`; } else { query += ` WHERE folder_id = $1`; params.push(parseInt(folderId)); } } query += ` ORDER BY created_at DESC`; const result = await pool.query(query, params); const files = result.rows.map((row) => ({ id: row.id, filename: row.filename, originalName: row.original_name, size: row.file_size, mimetype: row.mime_type, path: row.file_path, uploadDate: row.created_at, uploadedBy: row.uploaded_by, folderId: row.folder_id, usedInType: row.used_in_type, usedInId: row.used_in_id, })); res.json({ success: true, files: files, }); } catch (error) { logger.error("Error listing files:", error); res.status(500).json({ success: false, error: error.message, }); } }); // Delete a file router.delete("/uploads/:filename", requireAuth, async (req, res) => { try { const filename = req.params.filename; const uploadDir = path.join(__dirname, "..", "..", "website", "uploads"); const filePath = path.join(uploadDir, filename); // Security check: ensure file is within uploads directory if (!filePath.startsWith(uploadDir)) { return res.status(403).json({ success: false, error: "Invalid file path", }); } // Start transaction: delete from database first const result = await pool.query( "DELETE FROM uploads WHERE filename = $1 RETURNING id", [filename] ); if (result.rowCount === 0) { return res.status(404).json({ success: false, error: "File not found in database", }); } // Then delete physical file try { await fs.unlink(filePath); } catch (fileError) { logger.warn("File already deleted from disk:", filename); // Continue anyway since database record is deleted } res.json({ success: true, message: "File deleted successfully", }); } catch (error) { logger.error("Error deleting file:", error); res.status(500).json({ success: false, error: error.message, }); } }); // Delete file by ID router.delete("/uploads/id/:id", requireAuth, async (req, res) => { try { const fileId = parseInt(req.params.id); // Get file info first const fileResult = await pool.query( "SELECT filename FROM uploads WHERE id = $1", [fileId] ); if (fileResult.rows.length === 0) { return res.status(404).json({ success: false, error: "File not found", }); } const filename = fileResult.rows[0].filename; const uploadDir = path.join(__dirname, "..", "..", "website", "uploads"); const filePath = path.join(uploadDir, filename); // Delete from database await pool.query("DELETE FROM uploads WHERE id = $1", [fileId]); // Delete physical file try { await fs.unlink(filePath); } catch (fileError) { logger.warn("File already deleted from disk:", filename); } res.json({ success: true, message: "File deleted successfully", }); } catch (error) { logger.error("Error deleting file:", error); res.status(500).json({ success: false, error: error.message, }); } }); // ===== FOLDER MANAGEMENT ROUTES ===== // Create a new folder router.post("/folders", requireAuth, async (req, res) => { try { const { name, parent_id } = req.body; if (!name || name.trim() === "") { return res.status(400).json({ success: false, error: "Folder name is required", }); } // Sanitize folder name const sanitizedName = name.trim().replace(/[^a-zA-Z0-9\s\-_]/g, ""); // Build path let path = `/${sanitizedName}`; if (parent_id) { const parentResult = await pool.query( "SELECT path FROM media_folders WHERE id = $1", [parent_id] ); if (parentResult.rows.length === 0) { return res.status(404).json({ success: false, error: "Parent folder not found", }); } path = `${parentResult.rows[0].path}/${sanitizedName}`; } const createdBy = req.session.user?.id || null; const result = await pool.query( `INSERT INTO media_folders (name, parent_id, path, created_by) VALUES ($1, $2, $3, $4) RETURNING id, name, parent_id, path, created_at`, [sanitizedName, parent_id || null, path, createdBy] ); res.json({ success: true, folder: { id: result.rows[0].id, name: result.rows[0].name, parentId: result.rows[0].parent_id, path: result.rows[0].path, createdAt: result.rows[0].created_at, }, }); } catch (error) { if (error.code === "23505") { // Unique constraint violation return res.status(400).json({ success: false, error: "A folder with this name already exists in this location", }); } logger.error("Error creating folder:", error); res.status(500).json({ success: false, error: error.message, }); } }); // Get all folders router.get("/folders", requireAuth, async (req, res) => { try { const result = await pool.query( `SELECT f.id, f.name, f.parent_id, f.path, f.created_at, (SELECT COUNT(*) FROM uploads WHERE folder_id = f.id) as file_count, (SELECT COUNT(*) FROM media_folders WHERE parent_id = f.id) as subfolder_count FROM media_folders f ORDER BY f.path ASC` ); const folders = result.rows.map((row) => ({ id: row.id, name: row.name, parentId: row.parent_id, path: row.path, createdAt: row.created_at, fileCount: parseInt(row.file_count), subfolderCount: parseInt(row.subfolder_count), })); res.json({ success: true, folders: folders, }); } catch (error) { logger.error("Error listing folders:", error); res.status(500).json({ success: false, error: error.message, }); } }); // Delete a folder (and optionally its contents) router.delete("/folders/:id", requireAuth, async (req, res) => { try { const folderId = parseInt(req.params.id); const deleteContents = req.query.delete_contents === "true"; // Check if folder exists const folderResult = await pool.query( "SELECT id, name FROM media_folders WHERE id = $1", [folderId] ); if (folderResult.rows.length === 0) { return res.status(404).json({ success: false, error: "Folder not found", }); } if (deleteContents) { // Delete all files in this folder and subfolders const filesResult = await pool.query( `SELECT u.filename FROM uploads u WHERE u.folder_id = $1 OR u.folder_id IN ( SELECT id FROM media_folders WHERE path LIKE ( SELECT path || '%' FROM media_folders WHERE id = $1 ) )`, [folderId] ); // Delete physical files const uploadDir = path.join(__dirname, "..", "..", "website", "uploads"); for (const row of filesResult.rows) { try { await fs.unlink(path.join(uploadDir, row.filename)); } catch (err) { logger.warn(`Could not delete file: ${row.filename}`, err); } } // Delete folder (cascade will delete subfolders and DB records) await pool.query("DELETE FROM media_folders WHERE id = $1", [folderId]); } else { // Check if folder has contents const contentsCheck = await pool.query( `SELECT (SELECT COUNT(*) FROM uploads WHERE folder_id = $1) as file_count, (SELECT COUNT(*) FROM media_folders WHERE parent_id = $1) as subfolder_count`, [folderId] ); const fileCount = parseInt(contentsCheck.rows[0].file_count); const subfolderCount = parseInt(contentsCheck.rows[0].subfolder_count); if (fileCount > 0 || subfolderCount > 0) { return res.status(400).json({ success: false, error: `Folder contains ${fileCount} file(s) and ${subfolderCount} subfolder(s). Delete contents first or use delete_contents=true`, }); } await pool.query("DELETE FROM media_folders WHERE id = $1", [folderId]); } res.json({ success: true, message: "Folder deleted successfully", }); } catch (error) { logger.error("Error deleting folder:", error); res.status(500).json({ success: false, error: error.message, }); } }); // Move files to a folder router.patch("/uploads/move", requireAuth, async (req, res) => { try { const { file_ids, folder_id } = req.body; if (!Array.isArray(file_ids) || file_ids.length === 0) { return res.status(400).json({ success: false, error: "file_ids array is required", }); } const targetFolderId = folder_id || null; // Verify folder exists if provided if (targetFolderId) { const folderCheck = await pool.query( "SELECT id FROM media_folders WHERE id = $1", [targetFolderId] ); if (folderCheck.rows.length === 0) { return res.status(404).json({ success: false, error: "Target folder not found", }); } } // Move files const result = await pool.query( `UPDATE uploads SET folder_id = $1, updated_at = NOW() WHERE id = ANY($2::int[]) RETURNING id`, [targetFolderId, file_ids] ); res.json({ success: true, message: `${result.rowCount} file(s) moved successfully`, movedCount: result.rowCount, }); } catch (error) { logger.error("Error moving files:", error); res.status(500).json({ success: false, error: error.message, }); } }); // Bulk delete files router.post("/uploads/bulk-delete", requireAuth, async (req, res) => { try { const { file_ids } = req.body; if (!Array.isArray(file_ids) || file_ids.length === 0) { return res.status(400).json({ success: false, error: "file_ids array is required", }); } // Get filenames first const filesResult = await pool.query( "SELECT filename FROM uploads WHERE id = ANY($1::int[])", [file_ids] ); // Delete from database const result = await pool.query( "DELETE FROM uploads WHERE id = ANY($1::int[])", [file_ids] ); // Delete physical files const uploadDir = path.join(__dirname, "..", "..", "website", "uploads"); for (const row of filesResult.rows) { try { await fs.unlink(path.join(uploadDir, row.filename)); } catch (err) { logger.warn(`Could not delete file: ${row.filename}`, err); } } res.json({ success: true, message: `${result.rowCount} file(s) deleted successfully`, deletedCount: result.rowCount, }); } catch (error) { logger.error("Error bulk deleting files:", error); res.status(500).json({ success: false, error: error.message, }); } }); module.exports = router;