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], bmp: [0x42, 0x4d], tiff_le: [0x49, 0x49, 0x2a, 0x00], tiff_be: [0x4d, 0x4d, 0x00, 0x2a], ico: [0x00, 0x00, 0x01, 0x00], avif: [0x00, 0x00, 0x00], // AVIF starts with ftyp box heic: [0x00, 0x00, 0x00], // HEIC starts with ftyp box }; // Validate file content by checking magic bytes const validateFileContent = async (filePath, mimetype) => { try { const buffer = Buffer.alloc(12); const fd = await fs.open(filePath, "r"); await fd.read(buffer, 0, 12, 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 ); } // Check BMP if (mimetype === "image/bmp") { return buffer[0] === 0x42 && buffer[1] === 0x4d; } // Check TIFF (both little-endian and big-endian) if (mimetype === "image/tiff") { return ( (buffer[0] === 0x49 && buffer[1] === 0x49 && buffer[2] === 0x2a && buffer[3] === 0x00) || (buffer[0] === 0x4d && buffer[1] === 0x4d && buffer[2] === 0x00 && buffer[3] === 0x2a) ); } // Check ICO if ( mimetype === "image/x-icon" || mimetype === "image/vnd.microsoft.icon" || mimetype === "image/ico" ) { return ( buffer[0] === 0x00 && buffer[1] === 0x00 && buffer[2] === 0x01 && buffer[3] === 0x00 ); } // Check SVG (text-based, starts with < or whitespace then <) if (mimetype === "image/svg+xml") { const text = buffer.toString("utf8").trim(); return text.startsWith("<") || text.startsWith(" { 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; // SECURITY: Use parameterized queries for all conditions let queryText; const params = []; if (folderId === undefined) { queryText = `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 ORDER BY created_at DESC`; } else if (folderId === "null" || folderId === "") { queryText = `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 WHERE folder_id IS NULL ORDER BY created_at DESC`; } else { // SECURITY: Validate folder_id is a valid integer const parsedFolderId = parseInt(folderId, 10); if (isNaN(parsedFolderId) || parsedFolderId < 0) { return res.status(400).json({ success: false, error: "Invalid folder ID", }); } queryText = `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 WHERE folder_id = $1 ORDER BY created_at DESC`; params.push(parsedFolderId); } const result = await pool.query(queryText, 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"); // SECURITY: Sanitize filename - remove any path traversal attempts const sanitizedFilename = path .basename(filename) .replace(/[^a-zA-Z0-9._-]/g, ""); if (!sanitizedFilename || sanitizedFilename !== filename) { logger.warn("Path traversal attempt detected", { filename, ip: req.ip }); return res.status(403).json({ success: false, error: "Invalid filename", }); } const filePath = path.join(uploadDir, sanitizedFilename); const resolvedPath = path.resolve(filePath); const resolvedUploadDir = path.resolve(uploadDir); // SECURITY: Double-check path is within uploads directory after resolution if (!resolvedPath.startsWith(resolvedUploadDir + path.sep)) { logger.warn("Path traversal attempt blocked", { filename, resolvedPath, ip: req.ip, }); 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, }); } }); // Rename a file router.patch("/uploads/:id/rename", requireAuth, async (req, res) => { try { const fileId = parseInt(req.params.id); const { newName } = req.body; if (!newName || newName.trim() === "") { return res.status(400).json({ success: false, error: "New name is required", }); } // Get current file info const fileResult = await pool.query( "SELECT filename, original_name FROM uploads WHERE id = $1", [fileId], ); if (fileResult.rows.length === 0) { return res.status(404).json({ success: false, error: "File not found", }); } const currentFile = fileResult.rows[0]; const ext = path.extname(currentFile.filename); // Sanitize new name and keep extension const sanitizedName = newName .trim() .replace(/[^a-z0-9\s\-_]/gi, "-") .toLowerCase() .substring(0, 100); const newFilename = sanitizedName + "-" + Date.now() + ext; const uploadDir = path.join(__dirname, "..", "..", "website", "uploads"); const oldPath = path.join(uploadDir, currentFile.filename); const newPath = path.join(uploadDir, newFilename); // Rename physical file try { await fs.rename(oldPath, newPath); } catch (fileError) { logger.error("Error renaming physical file:", fileError); return res.status(500).json({ success: false, error: "Failed to rename file on disk", }); } // Update database const result = await pool.query( `UPDATE uploads SET filename = $1, original_name = $2, file_path = $3, updated_at = NOW() WHERE id = $4 RETURNING id, filename, original_name, file_path`, [newFilename, newName.trim() + ext, `/uploads/${newFilename}`, fileId], ); res.json({ success: true, message: "File renamed successfully", file: { id: result.rows[0].id, filename: result.rows[0].filename, originalName: result.rows[0].original_name, path: result.rows[0].file_path, }, }); } catch (error) { logger.error("Error renaming file:", error); res.status(500).json({ success: false, error: error.message, }); } }); // Rename a folder router.patch("/folders/:id/rename", requireAuth, async (req, res) => { try { const folderId = parseInt(req.params.id); const { newName } = req.body; if (!newName || newName.trim() === "") { return res.status(400).json({ success: false, error: "New name is required", }); } // Get current folder info const folderResult = await pool.query( "SELECT id, name, parent_id, path FROM media_folders WHERE id = $1", [folderId], ); if (folderResult.rows.length === 0) { return res.status(404).json({ success: false, error: "Folder not found", }); } const currentFolder = folderResult.rows[0]; const sanitizedName = newName.trim().replace(/[^a-zA-Z0-9\s\-_]/g, ""); // Build new path const oldPath = currentFolder.path; const pathParts = oldPath.split("/"); pathParts[pathParts.length - 1] = sanitizedName; const newPath = pathParts.join("/"); // Check for duplicate name in same parent const duplicateCheck = await pool.query( `SELECT id FROM media_folders WHERE name = $1 AND parent_id IS NOT DISTINCT FROM $2 AND id != $3`, [sanitizedName, currentFolder.parent_id, folderId], ); if (duplicateCheck.rows.length > 0) { return res.status(400).json({ success: false, error: "A folder with this name already exists in this location", }); } // Update folder and all subfolders paths await pool.query( `UPDATE media_folders SET name = $1, path = $2, updated_at = NOW() WHERE id = $3`, [sanitizedName, newPath, folderId], ); // Update subfolders paths await pool.query( `UPDATE media_folders SET path = REPLACE(path, $1, $2), updated_at = NOW() WHERE path LIKE $3 AND id != $4`, [oldPath, newPath, oldPath + "/%", folderId], ); res.json({ success: true, message: "Folder renamed successfully", folder: { id: folderId, name: sanitizedName, path: newPath, }, }); } catch (error) { logger.error("Error renaming folder:", error); res.status(500).json({ success: false, error: error.message, }); } }); module.exports = router;