diff --git a/routes/media.js b/routes/media.js index f69d0b1..1ecd0b2 100644 --- a/routes/media.js +++ b/routes/media.js @@ -2,10 +2,22 @@ const express = require('express'); const router = express.Router(); const multer = require('multer'); const path = require('path'); +const fsNative = require('fs'); const fs = require('fs').promises; + const Media = require('../models/Media'); const adminAuth = require('../middleware/adminAuth'); +// ======================= +// Config (LOCAL + PROD) +// ======================= +const UPLOAD_DIR = process.env.UPLOAD_DIR + ? path.resolve(process.env.UPLOAD_DIR) + : path.resolve(__dirname, '..', 'uploads'); // local fallback + +const PUBLIC_BASE = process.env.UPLOAD_PUBLIC_URL || '/uploads'; +const MEDIA_ROOT = process.env.MEDIA_SUBDIR || 'images'; + // Ensure upload directories exist const ensureUploadDir = async (dir) => { try { @@ -15,24 +27,39 @@ const ensureUploadDir = async (dir) => { } }; +const sanitizeFolder = (folder) => { + let f = String(folder || 'general').trim(); + // allow a-z0-9 _ - and slashes for nested folders + f = f.replace(/[^a-z0-9/_-]/gi, ''); + // prevent traversal + while (f.includes('..')) f = f.replace(/\.\./g, ''); + f = f.replace(/^\/+|\/+$/g, ''); + return f || 'general'; +}; + // Configure multer storage const storage = multer.diskStorage({ destination: async (req, file, cb) => { - const folder = req.body.folder || 'general'; - const uploadPath = path.join(__dirname, '../../client/public/images', folder); - await ensureUploadDir(uploadPath); - cb(null, uploadPath); + try { + const folderSafe = sanitizeFolder(req.body.folder); + const uploadPath = path.join(UPLOAD_DIR, MEDIA_ROOT, folderSafe); + await ensureUploadDir(uploadPath); + cb(null, uploadPath); + } catch (e) { + cb(e); + } }, filename: (req, file, cb) => { - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const ext = path.extname(file.originalname); - const basename = path.basename(file.originalname, ext) + const basename = path + .basename(file.originalname, ext) .toLowerCase() .replace(/[^a-z0-9]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); - cb(null, basename + '-' + uniqueSuffix + ext); - } + cb(null, (basename || 'file') + '-' + uniqueSuffix + ext.toLowerCase()); + }, }); // File filter @@ -46,7 +73,7 @@ const fileFilter = (req, file, cb) => { 'image/svg+xml', 'video/mp4', 'video/quicktime', - 'application/pdf' + 'application/pdf', ]; if (allowedMimeTypes.includes(file.mimetype)) { @@ -60,8 +87,8 @@ const upload = multer({ storage, fileFilter, limits: { - fileSize: 10 * 1024 * 1024 // 10MB limit - } + fileSize: 10 * 1024 * 1024, // 10MB limit + }, }); // @route POST /api/media/upload @@ -72,11 +99,12 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => { if (!req.file) { return res.status(400).json({ success: false, - message: 'No file uploaded' + message: 'No file uploaded', }); } - const { folder = 'general', alt, caption, description, tags } = req.body; + const folderSafe = sanitizeFolder(req.body.folder); + const { alt, caption, description, tags } = req.body; // Determine media type let mediaType = 'other'; @@ -88,20 +116,22 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => { mediaType = 'document'; } + const url = `${PUBLIC_BASE}/${MEDIA_ROOT}/${folderSafe}/${req.file.filename}`; + // Create media record const media = new Media({ filename: req.file.filename, originalName: req.file.originalname, - url: `/images/${folder}/${req.file.filename}`, + url, mimeType: req.file.mimetype, size: req.file.size, type: mediaType, - folder, + folder: folderSafe, alt, caption, description, - tags: tags ? tags.split(',').map(t => t.trim()) : [], - uploadedBy: req.admin.id + tags: tags ? tags.split(',').map((t) => t.trim()) : [], + uploadedBy: req.admin.id, }); await media.save(); @@ -109,13 +139,13 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => { res.status(201).json({ success: true, message: 'File uploaded successfully', - data: { media } + data: { media }, }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, - message: error.message || 'Error uploading file' + message: error.message || 'Error uploading file', }); } }); @@ -128,11 +158,11 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req if (!req.files || req.files.length === 0) { return res.status(400).json({ success: false, - message: 'No files uploaded' + message: 'No files uploaded', }); } - const { folder = 'general' } = req.body; + const folderSafe = sanitizeFolder(req.body.folder); const mediaRecords = []; for (const file of req.files) { @@ -145,15 +175,17 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req mediaType = 'document'; } + const url = `${PUBLIC_BASE}/${MEDIA_ROOT}/${folderSafe}/${file.filename}`; + const media = new Media({ filename: file.filename, originalName: file.originalname, - url: `/images/${folder}/${file.filename}`, + url, mimeType: file.mimetype, size: file.size, type: mediaType, - folder, - uploadedBy: req.admin.id + folder: folderSafe, + uploadedBy: req.admin.id, }); await media.save(); @@ -163,13 +195,13 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req res.status(201).json({ success: true, message: `${mediaRecords.length} files uploaded successfully`, - data: { media: mediaRecords } + data: { media: mediaRecords }, }); } catch (error) { console.error('Multiple upload error:', error); res.status(500).json({ success: false, - message: error.message || 'Error uploading files' + message: error.message || 'Error uploading files', }); } }); @@ -190,7 +222,7 @@ router.get('/', adminAuth, async (req, res) => { .populate('uploadedBy', 'firstName lastName') .sort({ createdAt: -1 }) .skip(skip) - .limit(parseInt(limit)); + .limit(parseInt(limit, 10)); const total = await Media.countDocuments(query); @@ -199,18 +231,18 @@ router.get('/', adminAuth, async (req, res) => { data: { media, pagination: { - page: parseInt(page), - limit: parseInt(limit), + page: parseInt(page, 10), + limit: parseInt(limit, 10), total, - pages: Math.ceil(total / limit) - } - } + pages: Math.ceil(total / limit), + }, + }, }); } catch (error) { console.error('Get media error:', error); res.status(500).json({ success: false, - message: 'Error fetching media' + message: 'Error fetching media', }); } }); @@ -225,21 +257,21 @@ router.get('/search', adminAuth, async (req, res) => { if (!q) { return res.status(400).json({ success: false, - message: 'Search query is required' + message: 'Search query is required', }); } - const media = await Media.search(q, { folder, type, limit: parseInt(limit) }); + const media = await Media.search(q, { folder, type, limit: parseInt(limit, 10) }); res.json({ success: true, - data: { media } + data: { media }, }); } catch (error) { console.error('Search media error:', error); res.status(500).json({ success: false, - message: 'Error searching media' + message: 'Error searching media', }); } }); @@ -253,39 +285,42 @@ router.put('/:id', adminAuth, async (req, res) => { const { alt, caption, description, tags, folder } = req.body; const updateFields = {}; - if (alt) updateFields.alt = alt; - if (caption) updateFields.caption = caption; - if (description) updateFields.description = description; - if (tags) updateFields.tags = tags; - if (folder) updateFields.folder = folder; + if (alt !== undefined) updateFields.alt = alt; + if (caption !== undefined) updateFields.caption = caption; + if (description !== undefined) updateFields.description = description; + if (tags !== undefined) updateFields.tags = tags; + if (folder !== undefined) updateFields.folder = sanitizeFolder(folder); - const media = await Media.findByIdAndUpdate( - id, - { $set: updateFields }, - { new: true, runValidators: true } - ); + const media = await Media.findByIdAndUpdate(id, { $set: updateFields }, { new: true, runValidators: true }); if (!media) { return res.status(404).json({ success: false, - message: 'Media not found' + message: 'Media not found', }); } res.json({ success: true, message: 'Media updated successfully', - data: { media } + data: { media }, }); } catch (error) { console.error('Update media error:', error); res.status(500).json({ success: false, - message: 'Error updating media' + message: 'Error updating media', }); } }); +const safeResolveInside = (base, rel) => { + const baseAbs = path.resolve(base); + const targetAbs = path.resolve(baseAbs, rel); + if (!targetAbs.startsWith(baseAbs + path.sep)) return null; + return targetAbs; +}; + // @route DELETE /api/media/:id // @desc Delete media file // @access Private (Admin) @@ -298,30 +333,42 @@ router.delete('/:id', adminAuth, async (req, res) => { if (!media) { return res.status(404).json({ success: false, - message: 'Media not found' + message: 'Media not found', }); } - // Delete file from filesystem - const filePath = path.join(__dirname, '../../client/public', media.url); + // Delete file from filesystem (only if inside UPLOAD_DIR) try { - await fs.unlink(filePath); + const url = String(media.url || ''); + let rel = null; + + if (url.startsWith(PUBLIC_BASE + '/')) { + rel = url.slice((PUBLIC_BASE + '/').length); + } else if (url.startsWith('/uploads/')) { + rel = url.slice('/uploads/'.length); + } + + if (rel) { + const filePath = safeResolveInside(UPLOAD_DIR, rel); + if (filePath && fsNative.existsSync(filePath)) { + await fs.unlink(filePath); + } + } } catch (err) { console.error('Error deleting file:', err); } - // Delete media record await Media.findByIdAndDelete(id); res.json({ success: true, - message: 'Media deleted successfully' + message: 'Media deleted successfully', }); } catch (error) { console.error('Delete media error:', error); res.status(500).json({ success: false, - message: 'Error deleting media' + message: 'Error deleting media', }); } }); @@ -335,16 +382,15 @@ router.get('/folders/list', adminAuth, async (req, res) => { res.json({ success: true, - data: { folders } + data: { folders }, }); } catch (error) { console.error('Get folders error:', error); res.status(500).json({ success: false, - message: 'Error fetching folders' + message: 'Error fetching folders', }); } }); -module.exports = router; - +module.exports = router; \ No newline at end of file diff --git a/routes/upload.js b/routes/upload.js index 095e807..a7658d2 100644 --- a/routes/upload.js +++ b/routes/upload.js @@ -6,29 +6,42 @@ const fs = require('fs'); const sharp = require('sharp'); const adminAuth = require('../middleware/adminAuth'); +// ======================= +// Config (LOCAL + PROD) +// ======================= +const UPLOAD_DIR = process.env.UPLOAD_DIR + ? path.resolve(process.env.UPLOAD_DIR) + : path.resolve(__dirname, '..', 'uploads'); // local fallback + +const PUBLIC_BASE = process.env.UPLOAD_PUBLIC_URL || '/uploads'; + // Ensure uploads directory exists -const uploadsDir = path.join(__dirname, '../../client/public/uploads'); -if (!fs.existsSync(uploadsDir)) { - fs.mkdirSync(uploadsDir, { recursive: true }); +if (!fs.existsSync(UPLOAD_DIR)) { + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); } // Configure multer for file upload const storage = multer.diskStorage({ destination: (req, file, cb) => { - cb(null, uploadsDir); + cb(null, UPLOAD_DIR); }, filename: (req, file, cb) => { - // Generate unique filename - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - const ext = path.extname(file.originalname); - const name = path.basename(file.originalname, ext).replace(/[^a-z0-9]/gi, '-').toLowerCase(); - cb(null, `${name}-${uniqueSuffix}${ext}`); - } + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; + const name = path + .basename(file.originalname, ext) + .replace(/[^a-z0-9]/gi, '-') + .toLowerCase() + .replace(/-+/g, '-') + .replace(/(^-|-$)/g, ''); + + cb(null, `${name || 'image'}-${uniqueSuffix}${ext}`); + }, }); const fileFilter = (req, file, cb) => { // Accept images only - if (file.mimetype.startsWith('image/')) { + if (file.mimetype && file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only image files are allowed'), false); @@ -36,13 +49,40 @@ const fileFilter = (req, file, cb) => { }; const upload = multer({ - storage: storage, - fileFilter: fileFilter, + storage, + fileFilter, limits: { - fileSize: 10 * 1024 * 1024 // 10MB limit - } + fileSize: 10 * 1024 * 1024, // 10MB limit + }, }); +const optimizeImageInPlace = async (filePath, ext) => { + const lowerExt = (ext || '').toLowerCase(); + + // GIF: skip optimization (sharp may drop animation) + if (lowerExt === '.gif') return; + + const tmpPath = filePath + '.tmp'; + + const pipeline = sharp(filePath) + .rotate() + .resize(1920, 1080, { + fit: 'inside', + withoutEnlargement: true, + }); + + if (lowerExt === '.png') { + await pipeline.png({ compressionLevel: 9 }).toFile(tmpPath); + } else if (lowerExt === '.webp') { + await pipeline.webp({ quality: 85 }).toFile(tmpPath); + } else { + // jpg/jpeg + anything else + await pipeline.jpeg({ quality: 85 }).toFile(tmpPath); + } + + fs.renameSync(tmpPath, filePath); +}; + // @route POST /api/upload // @desc Upload single or multiple images // @access Private (Admin) @@ -51,47 +91,34 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => { if (!req.files || req.files.length === 0) { return res.status(400).json({ success: false, - message: 'No files uploaded' + message: 'No files uploaded', }); } - // Process uploaded files const uploadedFiles = await Promise.all( req.files.map(async (file) => { try { - // Optimize image with sharp - const optimizedPath = path.join(uploadsDir, `optimized-${file.filename}`); - - await sharp(file.path) - .resize(1920, 1080, { - fit: 'inside', - withoutEnlargement: true - }) - .jpeg({ quality: 85 }) - .toFile(optimizedPath); - - // Replace original with optimized - fs.unlinkSync(file.path); - fs.renameSync(optimizedPath, file.path); + const ext = path.extname(file.filename); + await optimizeImageInPlace(file.path, ext); return { filename: file.filename, originalName: file.originalname, - url: `/uploads/${file.filename}`, - size: file.size, + url: `${PUBLIC_BASE}/${file.filename}`, + size: fs.statSync(file.path).size, mimeType: file.mimetype, - uploadedAt: new Date() + uploadedAt: new Date(), }; } catch (error) { console.error('Error processing image:', error); return { filename: file.filename, originalName: file.originalname, - url: `/uploads/${file.filename}`, + url: `${PUBLIC_BASE}/${file.filename}`, size: file.size, mimeType: file.mimetype, uploadedAt: new Date(), - error: 'Optimization failed' + error: 'Optimization failed', }; } }) @@ -100,13 +127,13 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => { res.json({ success: true, message: 'Files uploaded successfully', - data: { files: uploadedFiles } + data: { files: uploadedFiles }, }); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, - message: error.message || 'Error uploading files' + message: error.message || 'Error uploading files', }); } }); @@ -116,32 +143,32 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => { // @access Private (Admin) router.get('/list', adminAuth, async (req, res) => { try { - const files = fs.readdirSync(uploadsDir); - + const files = fs.readdirSync(UPLOAD_DIR); + const fileList = files - .filter(file => !file.startsWith('.')) // Ignore hidden files - .map(filename => { - const filePath = path.join(uploadsDir, filename); + .filter((file) => !file.startsWith('.')) + .map((filename) => { + const filePath = path.join(UPLOAD_DIR, filename); const stats = fs.statSync(filePath); - + return { filename, - url: `/uploads/${filename}`, + url: `${PUBLIC_BASE}/${filename}`, size: stats.size, - uploadedAt: stats.mtime + uploadedAt: stats.mtime, }; }) - .sort((a, b) => b.uploadedAt - a.uploadedAt); // Sort by newest first + .sort((a, b) => b.uploadedAt - a.uploadedAt); res.json({ success: true, - data: { files: fileList, total: fileList.length } + data: { files: fileList, total: fileList.length }, }); } catch (error) { console.error('List files error:', error); res.status(500).json({ success: false, - message: 'Error listing files' + message: 'Error listing files', }); } }); @@ -152,20 +179,21 @@ router.get('/list', adminAuth, async (req, res) => { router.delete('/:filename', adminAuth, async (req, res) => { try { const { filename } = req.params; - const filePath = path.join(uploadsDir, filename); - // Security check: ensure filename doesn't contain path traversal + // Security check if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { return res.status(400).json({ success: false, - message: 'Invalid filename' + message: 'Invalid filename', }); } + const filePath = path.join(UPLOAD_DIR, filename); + if (!fs.existsSync(filePath)) { return res.status(404).json({ success: false, - message: 'File not found' + message: 'File not found', }); } @@ -173,16 +201,15 @@ router.delete('/:filename', adminAuth, async (req, res) => { res.json({ success: true, - message: 'File deleted successfully' + message: 'File deleted successfully', }); } catch (error) { console.error('Delete file error:', error); res.status(500).json({ success: false, - message: 'Error deleting file' + message: 'Error deleting file', }); } }); -module.exports = router; - +module.exports = router; \ No newline at end of file