const express = require('express'); const router = express.Router(); const multer = require('multer'); const sharp = require('sharp'); const path = require('path'); const fs = require('fs').promises; // Authentication middleware const requireAuth = (req, res, next) => { if (!req.session.user) { return res.status(401).json({ success: false, message: 'Authentication required' }); } next(); }; // Configure multer for file uploads const storage = multer.diskStorage({ destination: async (req, file, cb) => { const uploadPath = path.join(__dirname, '../public/uploads'); try { await fs.mkdir(uploadPath, { recursive: true }); cb(null, uploadPath); } catch (error) { cb(error); } }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); const ext = path.extname(file.originalname); cb(null, file.fieldname + '-' + uniqueSuffix + ext); } }); const upload = multer({ storage: storage, limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 10 * 1024 * 1024, // 10MB files: 10 }, fileFilter: (req, file, cb) => { // Allow images only if (file.mimetype.startsWith('image/')) { cb(null, true); } else { cb(new Error('Only image files are allowed'), false); } } }); // Upload single image router.post('/upload', requireAuth, upload.single('image'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ success: false, message: 'No file uploaded' }); } const originalPath = req.file.path; const filename = req.file.filename; const nameWithoutExt = path.parse(filename).name; // Create optimized versions const sizes = { thumbnail: { width: 300, height: 200 }, medium: { width: 800, height: 600 }, large: { width: 1200, height: 900 } }; const optimizedImages = {}; for (const [sizeName, dimensions] of Object.entries(sizes)) { const outputPath = path.join( path.dirname(originalPath), `${nameWithoutExt}-${sizeName}.webp` ); await sharp(originalPath) .resize(dimensions.width, dimensions.height, { fit: 'inside', withoutEnlargement: true }) .webp({ quality: 85 }) .toFile(outputPath); optimizedImages[sizeName] = `/uploads/${path.basename(outputPath)}`; } // Keep original as well (converted to webp for better compression) const originalWebpPath = path.join( path.dirname(originalPath), `${nameWithoutExt}-original.webp` ); await sharp(originalPath) .webp({ quality: 90 }) .toFile(originalWebpPath); optimizedImages.original = `/uploads/${path.basename(originalWebpPath)}`; // Remove the original uploaded file await fs.unlink(originalPath); res.json({ success: true, message: 'Image uploaded and optimized successfully', images: optimizedImages, metadata: { originalName: req.file.originalname, mimeType: req.file.mimetype, size: req.file.size } }); } catch (error) { console.error('Image upload error:', error); // Clean up files on error if (req.file && req.file.path) { try { await fs.unlink(req.file.path); } catch (unlinkError) { console.error('Error removing file:', unlinkError); } } res.status(500).json({ success: false, message: 'Error uploading image' }); } }); // Upload multiple images router.post('/upload-multiple', requireAuth, 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' }); } const uploadedImages = []; for (const file of req.files) { try { const originalPath = file.path; const filename = file.filename; const nameWithoutExt = path.parse(filename).name; // Create optimized versions const sizes = { thumbnail: { width: 300, height: 200 }, medium: { width: 800, height: 600 }, large: { width: 1200, height: 900 } }; const optimizedImages = {}; for (const [sizeName, dimensions] of Object.entries(sizes)) { const outputPath = path.join( path.dirname(originalPath), `${nameWithoutExt}-${sizeName}.webp` ); await sharp(originalPath) .resize(dimensions.width, dimensions.height, { fit: 'inside', withoutEnlargement: true }) .webp({ quality: 85 }) .toFile(outputPath); optimizedImages[sizeName] = `/uploads/${path.basename(outputPath)}`; } // Original as webp const originalWebpPath = path.join( path.dirname(originalPath), `${nameWithoutExt}-original.webp` ); await sharp(originalPath) .webp({ quality: 90 }) .toFile(originalWebpPath); optimizedImages.original = `/uploads/${path.basename(originalWebpPath)}`; uploadedImages.push({ originalName: file.originalname, images: optimizedImages, metadata: { mimeType: file.mimetype, size: file.size } }); // Remove original file await fs.unlink(originalPath); } catch (fileError) { console.error(`Error processing file ${file.originalname}:`, fileError); // Clean up this file and continue with others try { await fs.unlink(file.path); } catch (unlinkError) { console.error('Error removing file:', unlinkError); } } } res.json({ success: true, message: `${uploadedImages.length} images uploaded and optimized successfully`, images: uploadedImages }); } catch (error) { console.error('Multiple images upload error:', error); // Clean up files on error if (req.files) { for (const file of req.files) { try { await fs.unlink(file.path); } catch (unlinkError) { console.error('Error removing file:', unlinkError); } } } res.status(500).json({ success: false, message: 'Error uploading images' }); } }); // Delete image router.delete('/:filename', requireAuth, async (req, res) => { try { const filename = req.params.filename; const uploadPath = path.join(__dirname, '../public/uploads'); // Security check - ensure filename doesn't contain path traversal if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { return res.status(400).json({ success: false, message: 'Invalid filename' }); } const filePath = path.join(uploadPath, filename); try { await fs.access(filePath); await fs.unlink(filePath); res.json({ success: true, message: 'Image deleted successfully' }); } catch (error) { if (error.code === 'ENOENT') { return res.status(404).json({ success: false, message: 'Image not found' }); } throw error; } } catch (error) { console.error('Image deletion error:', error); res.status(500).json({ success: false, message: 'Error deleting image' }); } }); // List uploaded images with advanced filtering and search router.get('/list', requireAuth, async (req, res) => { try { const uploadPath = path.join(__dirname, '../public/uploads'); // Create uploads directory if it doesn't exist try { await fs.mkdir(uploadPath, { recursive: true }); } catch (mkdirError) { // Directory might already exist, continue } const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 24; const search = req.query.search?.toLowerCase() || ''; const sortBy = req.query.sortBy || 'date'; // date, name, size const sortOrder = req.query.sortOrder || 'desc'; // asc, desc const fileType = req.query.fileType || 'all'; // all, image, video, document let files; try { files = await fs.readdir(uploadPath); } catch (readdirError) { if (readdirError.code === 'ENOENT') { return res.json({ success: true, images: [], pagination: { current: 1, total: 0, limit, totalItems: 0, hasNext: false, hasPrev: false }, filters: { search: '', sortBy: 'date', sortOrder: 'desc', fileType: 'all' } }); } throw readdirError; } // Filter by file type let filteredFiles = files; if (fileType !== 'all') { const typePatterns = { image: /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?)$/i, video: /\.(mp4|webm|avi|mov|mkv|wmv|flv)$/i, document: /\.(pdf|doc|docx|txt|rtf|odt)$/i }; const pattern = typePatterns[fileType]; if (pattern) { filteredFiles = files.filter(file => pattern.test(file)); } } else { // Only show supported media files filteredFiles = files.filter(file => /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?|mp4|webm|avi|mov|mkv|pdf|doc|docx)$/i.test(file) ); } // Apply search filter if (search) { filteredFiles = filteredFiles.filter(file => file.toLowerCase().includes(search) ); } // Get file stats and create file objects const filesWithStats = await Promise.all( filteredFiles.map(async (file) => { try { const filePath = path.join(uploadPath, file); const stats = await fs.stat(filePath); return { file, stats, filePath }; } catch (error) { return null; } }) ); const validFiles = filesWithStats.filter(item => item !== null); // Sort files validFiles.sort((a, b) => { let aValue, bValue; switch (sortBy) { case 'name': aValue = a.file.toLowerCase(); bValue = b.file.toLowerCase(); break; case 'size': aValue = a.stats.size; bValue = b.stats.size; break; case 'date': default: aValue = a.stats.mtime; bValue = b.stats.mtime; break; } if (sortOrder === 'asc') { return aValue > bValue ? 1 : -1; } else { return aValue < bValue ? 1 : -1; } }); const total = validFiles.length; const totalPages = Math.ceil(total / limit); const start = (page - 1) * limit; const end = start + limit; const paginatedFiles = validFiles.slice(start, end); const filesWithDetails = await Promise.all( paginatedFiles.map(async ({ file, stats, filePath }) => { try { const ext = path.extname(file).toLowerCase(); let fileDetails = { filename: file, url: `/uploads/${file}`, size: stats.size, uploadedAt: stats.birthtime || stats.mtime, modifiedAt: stats.mtime, extension: ext, isImage: false, isVideo: false, isDocument: false }; // Determine file type and get additional info if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?)$/i.test(file)) { fileDetails.isImage = true; fileDetails.mimetype = `image/${ext.replace('.', '')}`; // Get image dimensions try { const metadata = await sharp(filePath).metadata(); fileDetails.dimensions = { width: metadata.width, height: metadata.height }; fileDetails.format = metadata.format; } catch (sharpError) { console.warn(`Could not get image metadata for ${file}`); } } else if (/\.(mp4|webm|avi|mov|mkv|wmv|flv)$/i.test(file)) { fileDetails.isVideo = true; fileDetails.mimetype = `video/${ext.replace('.', '')}`; } else if (/\.(pdf|doc|docx|txt|rtf|odt)$/i.test(file)) { fileDetails.isDocument = true; fileDetails.mimetype = `application/${ext.replace('.', '')}`; } // Generate thumbnail for images if (fileDetails.isImage && !file.includes('-thumbnail.')) { const thumbnailPath = path.join(uploadPath, `${path.parse(file).name}-thumbnail.webp`); try { await fs.access(thumbnailPath); fileDetails.thumbnail = `/uploads/${path.basename(thumbnailPath)}`; } catch { // Thumbnail doesn't exist, create it try { await sharp(filePath) .resize(200, 150, { fit: 'cover', withoutEnlargement: false }) .webp({ quality: 80 }) .toFile(thumbnailPath); fileDetails.thumbnail = `/uploads/${path.basename(thumbnailPath)}`; } catch (thumbError) { console.warn(`Could not create thumbnail for ${file}`); } } } return fileDetails; } catch (error) { console.error(`Error getting details for ${file}:`, error); return null; } }) ); const validMedia = filesWithDetails.filter(item => item !== null); // Calculate storage stats const totalSize = validFiles.reduce((sum, file) => sum + file.stats.size, 0); const imageCount = validMedia.filter(f => f.isImage).length; const videoCount = validMedia.filter(f => f.isVideo).length; const documentCount = validMedia.filter(f => f.isDocument).length; res.json({ success: true, files: validMedia, pagination: { current: page, total: totalPages, limit, totalItems: total, hasNext: page < totalPages, hasPrev: page > 1 }, filters: { search, sortBy, sortOrder, fileType }, stats: { totalFiles: total, totalSize, imageCount, videoCount, documentCount, formattedSize: formatFileSize(totalSize) } }); } catch (error) { console.error('List media error:', error); res.status(500).json({ success: false, message: 'Error listing media files' }); } }); // Create folder structure router.post('/folder', requireAuth, async (req, res) => { try { const { folderName } = req.body; if (!folderName || !folderName.trim()) { return res.status(400).json({ success: false, message: 'Folder name is required' }); } // Sanitize folder name const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_]/g, '-'); const folderPath = path.join(__dirname, '../public/uploads', sanitizedName); try { await fs.mkdir(folderPath, { recursive: true }); res.json({ success: true, message: 'Folder created successfully', folderName: sanitizedName, folderPath: `/uploads/${sanitizedName}` }); } catch (error) { if (error.code === 'EEXIST') { return res.status(400).json({ success: false, message: 'Folder already exists' }); } throw error; } } catch (error) { console.error('Create folder error:', error); res.status(500).json({ success: false, message: 'Error creating folder' }); } }); // Get media file info router.get('/info/:filename', requireAuth, async (req, res) => { try { const filename = req.params.filename; // Security check if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { return res.status(400).json({ success: false, message: 'Invalid filename' }); } const filePath = path.join(__dirname, '../public/uploads', filename); try { const stats = await fs.stat(filePath); const ext = path.extname(filename).toLowerCase(); let fileInfo = { filename, url: `/uploads/${filename}`, size: stats.size, formattedSize: formatFileSize(stats.size), uploadedAt: stats.birthtime || stats.mtime, modifiedAt: stats.mtime, extension: ext, mimetype: getMimeType(ext) }; // Get additional info for images if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?)$/i.test(filename)) { try { const metadata = await sharp(filePath).metadata(); fileInfo.dimensions = { width: metadata.width, height: metadata.height }; fileInfo.format = metadata.format; fileInfo.hasAlpha = metadata.hasAlpha; fileInfo.density = metadata.density; } catch (sharpError) { console.warn(`Could not get image metadata for ${filename}`); } } res.json({ success: true, fileInfo }); } catch (error) { if (error.code === 'ENOENT') { return res.status(404).json({ success: false, message: 'File not found' }); } throw error; } } catch (error) { console.error('Get file info error:', error); res.status(500).json({ success: false, message: 'Error getting file information' }); } }); // Resize image router.post('/resize/:filename', requireAuth, async (req, res) => { try { const filename = req.params.filename; const { width, height, quality = 85 } = req.body; if (!width && !height) { return res.status(400).json({ success: false, message: 'Width or height must be specified' }); } // Security check if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) { return res.status(400).json({ success: false, message: 'Invalid filename' }); } const originalPath = path.join(__dirname, '../public/uploads', filename); const nameWithoutExt = path.parse(filename).name; const resizedPath = path.join( path.dirname(originalPath), `${nameWithoutExt}-${width || 'auto'}x${height || 'auto'}.webp` ); try { let sharpInstance = sharp(originalPath); if (width && height) { sharpInstance = sharpInstance.resize(parseInt(width), parseInt(height), { fit: 'cover' }); } else if (width) { sharpInstance = sharpInstance.resize(parseInt(width)); } else { sharpInstance = sharpInstance.resize(null, parseInt(height)); } await sharpInstance .webp({ quality: parseInt(quality) }) .toFile(resizedPath); res.json({ success: true, message: 'Image resized successfully', originalFile: filename, resizedFile: path.basename(resizedPath), resizedUrl: `/uploads/${path.basename(resizedPath)}` }); } catch (error) { if (error.code === 'ENOENT') { return res.status(404).json({ success: false, message: 'Original file not found' }); } throw error; } } catch (error) { console.error('Resize image error:', error); res.status(500).json({ success: false, message: 'Error resizing image' }); } }); // Utility functions function formatFileSize(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } function getMimeType(ext) { const mimeTypes = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.tiff': 'image/tiff', '.tif': 'image/tiff', '.mp4': 'video/mp4', '.webm': 'video/webm', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska', '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }; return mimeTypes[ext.toLowerCase()] || 'application/octet-stream'; } module.exports = router;