diff --git a/routes/media.js b/routes/media.js index 1ecd0b2..11073a9 100644 --- a/routes/media.js +++ b/routes/media.js @@ -2,23 +2,20 @@ 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 +// --- Helpers --- +const getUploadsDir = () => { + const envDir = (process.env.UPLOADS_DIR || '').trim(); + if (envDir) return path.isAbsolute(envDir) ? envDir : path.resolve(process.cwd(), envDir); + return path.resolve(__dirname, '..', 'uploads'); +}; -const PUBLIC_BASE = process.env.UPLOAD_PUBLIC_URL || '/uploads'; -const MEDIA_ROOT = process.env.MEDIA_SUBDIR || 'images'; +const baseDir = getUploadsDir(); +const urlPrefix = (process.env.UPLOADS_URL_PREFIX || '/uploads').replace(/\/$/, ''); -// Ensure upload directories exist const ensureUploadDir = async (dir) => { try { await fs.access(dir); @@ -28,41 +25,43 @@ 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'; + const f = String(folder || 'general').trim().toLowerCase(); + return f.replace(/[^a-z0-9/_-]/g, '').replace(/\/+/g, '/').replace(/^\/|\/$/g, '') || 'general'; +}; + +const resolveSafePath = (root, relative) => { + const safeRel = String(relative || '').replace(/^\/+/, ''); + const full = path.resolve(root, safeRel); + const rootResolved = path.resolve(root); + if (!full.startsWith(rootResolved + path.sep) && full !== rootResolved) { + throw new Error('Invalid path'); + } + return full; }; // Configure multer storage const storage = multer.diskStorage({ - destination: async (req, file, cb) => { - 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); - } + destination: (req, file, cb) => { + const folder = sanitizeFolder(req.body.folder || 'general'); + const uploadPath = path.join(baseDir, folder); + + ensureUploadDir(uploadPath) + .then(() => cb(null, uploadPath)) + .catch((e) => cb(e)); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); - const ext = path.extname(file.originalname); + const ext = path.extname(file.originalname).toLowerCase(); const basename = path .basename(file.originalname, ext) .toLowerCase() .replace(/[^a-z0-9]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); - cb(null, (basename || 'file') + '-' + uniqueSuffix + ext.toLowerCase()); + cb(null, `${basename || 'file'}-${uniqueSuffix}${ext || ''}`); }, }); -// File filter const fileFilter = (req, file, cb) => { const allowedMimeTypes = [ 'image/jpeg', @@ -76,19 +75,14 @@ const fileFilter = (req, file, cb) => { 'application/pdf', ]; - if (allowedMimeTypes.includes(file.mimetype)) { - cb(null, true); - } else { - cb(new Error('Invalid file type. Only images, videos, and PDFs are allowed.'), false); - } + if (allowedMimeTypes.includes(file.mimetype)) return cb(null, true); + cb(new Error('Invalid file type. Only images, videos, and PDFs are allowed.'), false); }; const upload = multer({ storage, fileFilter, - limits: { - fileSize: 10 * 1024 * 1024, // 10MB limit - }, + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB }); // @route POST /api/media/upload @@ -97,40 +91,29 @@ const upload = multer({ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => { try { if (!req.file) { - return res.status(400).json({ - success: false, - message: 'No file uploaded', - }); + return res.status(400).json({ success: false, message: 'No file uploaded' }); } - const folderSafe = sanitizeFolder(req.body.folder); + const folder = sanitizeFolder(req.body.folder || 'general'); const { alt, caption, description, tags } = req.body; - // Determine media type let mediaType = 'other'; - if (req.file.mimetype.startsWith('image/')) { - mediaType = 'image'; - } else if (req.file.mimetype.startsWith('video/')) { - mediaType = 'video'; - } else if (req.file.mimetype === 'application/pdf') { - mediaType = 'document'; - } + if (req.file.mimetype.startsWith('image/')) mediaType = 'image'; + else if (req.file.mimetype.startsWith('video/')) mediaType = 'video'; + else if (req.file.mimetype === 'application/pdf') 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, + url: `${urlPrefix}/${folder}/${req.file.filename}`, mimeType: req.file.mimetype, size: req.file.size, type: mediaType, - folder: folderSafe, + folder, alt, caption, description, - tags: tags ? tags.split(',').map((t) => t.trim()) : [], + tags: tags ? tags.split(',').map((t) => t.trim()).filter(Boolean) : [], uploadedBy: req.admin.id, }); @@ -143,48 +126,34 @@ router.post('/upload', adminAuth, upload.single('file'), async (req, res) => { }); } catch (error) { console.error('Upload error:', error); - res.status(500).json({ - success: false, - message: error.message || 'Error uploading file', - }); + res.status(500).json({ success: false, message: error.message || 'Error uploading file' }); } }); // @route POST /api/media/upload-multiple -// @desc Upload multiple media files -// @access Private (Admin) router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req, res) => { try { if (!req.files || req.files.length === 0) { - return res.status(400).json({ - success: false, - message: 'No files uploaded', - }); + return res.status(400).json({ success: false, message: 'No files uploaded' }); } - const folderSafe = sanitizeFolder(req.body.folder); + const folder = sanitizeFolder(req.body.folder || 'general'); const mediaRecords = []; for (const file of req.files) { let mediaType = 'other'; - if (file.mimetype.startsWith('image/')) { - mediaType = 'image'; - } else if (file.mimetype.startsWith('video/')) { - mediaType = 'video'; - } else if (file.mimetype === 'application/pdf') { - mediaType = 'document'; - } - - const url = `${PUBLIC_BASE}/${MEDIA_ROOT}/${folderSafe}/${file.filename}`; + if (file.mimetype.startsWith('image/')) mediaType = 'image'; + else if (file.mimetype.startsWith('video/')) mediaType = 'video'; + else if (file.mimetype === 'application/pdf') mediaType = 'document'; const media = new Media({ filename: file.filename, originalName: file.originalname, - url, + url: `${urlPrefix}/${folder}/${file.filename}`, mimeType: file.mimetype, size: file.size, type: mediaType, - folder: folderSafe, + folder, uploadedBy: req.admin.id, }); @@ -199,16 +168,11 @@ router.post('/upload-multiple', adminAuth, upload.array('files', 10), async (req }); } catch (error) { console.error('Multiple upload error:', error); - res.status(500).json({ - success: false, - message: error.message || 'Error uploading files', - }); + res.status(500).json({ success: false, message: error.message || 'Error uploading files' }); } }); // @route GET /api/media -// @desc Get all media -// @access Private (Admin) router.get('/', adminAuth, async (req, res) => { try { const { folder, type, page = 1, limit = 50 } = req.query; @@ -240,45 +204,29 @@ router.get('/', adminAuth, async (req, res) => { }); } catch (error) { console.error('Get media error:', error); - res.status(500).json({ - success: false, - message: 'Error fetching media', - }); + res.status(500).json({ success: false, message: 'Error fetching media' }); } }); // @route GET /api/media/search -// @desc Search media -// @access Private (Admin) router.get('/search', adminAuth, async (req, res) => { try { const { q, folder, type, limit = 50 } = req.query; if (!q) { - return res.status(400).json({ - success: false, - message: 'Search query is required', - }); + return res.status(400).json({ success: false, message: 'Search query is required' }); } const media = await Media.search(q, { folder, type, limit: parseInt(limit, 10) }); - res.json({ - success: true, - data: { media }, - }); + res.json({ success: true, data: { media } }); } catch (error) { console.error('Search media error:', error); - res.status(500).json({ - success: false, - message: 'Error searching media', - }); + res.status(500).json({ success: false, message: 'Error searching media' }); } }); // @route PUT /api/media/:id -// @desc Update media metadata -// @access Private (Admin) router.put('/:id', adminAuth, async (req, res) => { try { const { id } = req.params; @@ -293,66 +241,29 @@ router.put('/:id', adminAuth, async (req, res) => { 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', - }); - } + if (!media) return res.status(404).json({ success: false, message: 'Media not found' }); - res.json({ - success: true, - message: 'Media updated successfully', - data: { media }, - }); + res.json({ success: true, message: 'Media updated successfully', data: { media } }); } catch (error) { console.error('Update media error:', error); - res.status(500).json({ - success: false, - message: 'Error updating media', - }); + res.status(500).json({ success: false, 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) router.delete('/:id', adminAuth, async (req, res) => { try { const { id } = req.params; - const media = await Media.findById(id); - if (!media) { - return res.status(404).json({ - success: false, - message: 'Media not found', - }); - } + if (!media) return res.status(404).json({ success: false, message: 'Media not found' }); - // Delete file from filesystem (only if inside UPLOAD_DIR) + // Delete file from filesystem (based on /uploads/.. url) try { - 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); - } + if (media.url && media.url.startsWith(urlPrefix + '/')) { + const relative = media.url.slice(urlPrefix.length + 1); // remove "/uploads/" + const filePath = resolveSafePath(baseDir, relative); + await fs.unlink(filePath); } } catch (err) { console.error('Error deleting file:', err); @@ -360,36 +271,21 @@ router.delete('/:id', adminAuth, async (req, res) => { await Media.findByIdAndDelete(id); - res.json({ - success: true, - message: 'Media deleted successfully', - }); + res.json({ success: true, message: 'Media deleted successfully' }); } catch (error) { console.error('Delete media error:', error); - res.status(500).json({ - success: false, - message: 'Error deleting media', - }); + res.status(500).json({ success: false, message: 'Error deleting media' }); } }); // @route GET /api/media/folders/list -// @desc Get list of all folders -// @access Private (Admin) router.get('/folders/list', adminAuth, async (req, res) => { try { const folders = await Media.distinct('folder'); - - res.json({ - success: true, - data: { folders }, - }); + res.json({ success: true, data: { folders } }); } catch (error) { console.error('Get folders error:', error); - res.status(500).json({ - success: false, - message: 'Error fetching folders', - }); + res.status(500).json({ success: false, message: 'Error fetching folders' }); } }); diff --git a/routes/upload.js b/routes/upload.js index a7658d2..71e73e6 100644 --- a/routes/upload.js +++ b/routes/upload.js @@ -6,82 +6,72 @@ 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 +// --- Helpers --- +const getUploadsDir = () => { + const envDir = (process.env.UPLOADS_DIR || '').trim(); + if (envDir) return path.isAbsolute(envDir) ? envDir : path.resolve(process.cwd(), envDir); + return path.resolve(__dirname, '..', 'uploads'); +}; -const PUBLIC_BASE = process.env.UPLOAD_PUBLIC_URL || '/uploads'; +const uploadsDir = getUploadsDir(); +const urlPrefix = (process.env.UPLOADS_URL_PREFIX || '/uploads').replace(/\/$/, ''); // Ensure uploads directory exists -if (!fs.existsSync(UPLOAD_DIR)) { - fs.mkdirSync(UPLOAD_DIR, { recursive: true }); +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); } // Configure multer for file upload const storage = multer.diskStorage({ - destination: (req, file, cb) => { - cb(null, UPLOAD_DIR); - }, + destination: (req, file, cb) => cb(null, uploadsDir), filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); - const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; + const ext = path.extname(file.originalname).toLowerCase(); const name = path .basename(file.originalname, ext) .replace(/[^a-z0-9]/gi, '-') - .toLowerCase() .replace(/-+/g, '-') - .replace(/(^-|-$)/g, ''); + .replace(/^-|-$/g, '') + .toLowerCase(); - cb(null, `${name || 'image'}-${uniqueSuffix}${ext}`); + cb(null, `${name || 'file'}-${uniqueSuffix}${ext || ''}`); }, }); const fileFilter = (req, file, cb) => { // Accept images only - if (file.mimetype && file.mimetype.startsWith('image/')) { - cb(null, true); - } else { - cb(new Error('Only image files are allowed'), false); - } + if (file.mimetype && file.mimetype.startsWith('image/')) return cb(null, true); + cb(new Error('Only image files are allowed'), false); }; const upload = multer({ storage, fileFilter, - limits: { - fileSize: 10 * 1024 * 1024, // 10MB limit - }, + limits: { fileSize: 10 * 1024 * 1024 }, // 10MB }); -const optimizeImageInPlace = async (filePath, ext) => { - const lowerExt = (ext || '').toLowerCase(); +async function optimizeImageIfPossible(filePath, extLower) { + if (extLower === '.gif') return; - // GIF: skip optimization (sharp may drop animation) - if (lowerExt === '.gif') return; + const tmpPath = `${filePath}.tmp`; - const tmpPath = filePath + '.tmp'; + let pipeline = sharp(filePath).resize(1920, 1080, { + fit: 'inside', + withoutEnlargement: true, + }); - 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); + if (extLower === '.png') { + pipeline = pipeline.png({ quality: 85, compressionLevel: 9 }); + } else if (extLower === '.webp') { + pipeline = pipeline.webp({ quality: 85 }); } else { - // jpg/jpeg + anything else - await pipeline.jpeg({ quality: 85 }).toFile(tmpPath); + pipeline = pipeline.jpeg({ quality: 85 }); } + await pipeline.toFile(tmpPath); + fs.unlinkSync(filePath); fs.renameSync(tmpPath, filePath); -}; +} // @route POST /api/upload // @desc Upload single or multiple images @@ -89,40 +79,35 @@ const optimizeImageInPlace = async (filePath, ext) => { router.post('/', adminAuth, upload.array('images', 10), async (req, res) => { try { if (!req.files || req.files.length === 0) { - return res.status(400).json({ - success: false, - message: 'No files uploaded', - }); + return res.status(400).json({ success: false, message: 'No files uploaded' }); } - const uploadedFiles = await Promise.all( - req.files.map(async (file) => { - try { - const ext = path.extname(file.filename); - await optimizeImageInPlace(file.path, ext); + const uploadedFiles = []; - return { - filename: file.filename, - originalName: file.originalname, - url: `${PUBLIC_BASE}/${file.filename}`, - size: fs.statSync(file.path).size, - mimeType: file.mimetype, - uploadedAt: new Date(), - }; - } catch (error) { - console.error('Error processing image:', error); - return { - filename: file.filename, - originalName: file.originalname, - url: `${PUBLIC_BASE}/${file.filename}`, - size: file.size, - mimeType: file.mimetype, - uploadedAt: new Date(), - error: 'Optimization failed', - }; - } - }) - ); + for (const file of req.files) { + try { + const extLower = path.extname(file.filename).toLowerCase(); + await optimizeImageIfPossible(file.path, extLower); + + uploadedFiles.push({ + filename: file.filename, + originalName: file.originalname, + url: `${urlPrefix}/${file.filename}`, + mimeType: file.mimetype, + uploadedAt: new Date(), + }); + } catch (e) { + console.error('Error processing image:', e); + uploadedFiles.push({ + filename: file.filename, + originalName: file.originalname, + url: `${urlPrefix}/${file.filename}`, + mimeType: file.mimetype, + uploadedAt: new Date(), + error: 'Optimization failed', + }); + } + } res.json({ success: true, @@ -131,10 +116,7 @@ router.post('/', adminAuth, upload.array('images', 10), async (req, res) => { }); } catch (error) { console.error('Upload error:', error); - res.status(500).json({ - success: false, - message: error.message || 'Error uploading files', - }); + res.status(500).json({ success: false, message: error.message || 'Error uploading files' }); } }); @@ -143,33 +125,26 @@ 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(UPLOAD_DIR); + const files = fs.readdirSync(uploadsDir); const fileList = files - .filter((file) => !file.startsWith('.')) + .filter((f) => !f.startsWith('.')) .map((filename) => { - const filePath = path.join(UPLOAD_DIR, filename); + const filePath = path.join(uploadsDir, filename); const stats = fs.statSync(filePath); - return { filename, - url: `${PUBLIC_BASE}/${filename}`, + url: `${urlPrefix}/${filename}`, size: stats.size, uploadedAt: stats.mtime, }; }) .sort((a, b) => b.uploadedAt - a.uploadedAt); - res.json({ - success: true, - data: { files: fileList, total: fileList.length }, - }); + res.json({ success: true, data: { files: fileList, total: fileList.length } }); } catch (error) { console.error('List files error:', error); - res.status(500).json({ - success: false, - message: 'Error listing files', - }); + res.status(500).json({ success: false, message: 'Error listing files' }); } }); @@ -180,35 +155,22 @@ router.delete('/:filename', adminAuth, async (req, res) => { try { const { filename } = req.params; - // Security check + // Security check: prevent path traversal if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { - return res.status(400).json({ - success: false, - message: 'Invalid filename', - }); + return res.status(400).json({ success: false, message: 'Invalid filename' }); } - const filePath = path.join(UPLOAD_DIR, filename); + const filePath = path.join(uploadsDir, filename); if (!fs.existsSync(filePath)) { - return res.status(404).json({ - success: false, - message: 'File not found', - }); + return res.status(404).json({ success: false, message: 'File not found' }); } fs.unlinkSync(filePath); - - res.json({ - success: true, - message: 'File deleted successfully', - }); + res.json({ success: true, message: 'File deleted successfully' }); } catch (error) { console.error('Delete file error:', error); - res.status(500).json({ - success: false, - message: 'Error deleting file', - }); + res.status(500).json({ success: false, message: 'Error deleting file' }); } });