const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs').promises; const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { generateThumbnail } = require('../services/imageProcessor'); const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { escapeLikePattern } = require('../utils/sqlSecurity'); const router = express.Router(); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); // Configure multer for file uploads const storage = multer.diskStorage({ destination: async (req, file, cb) => { console.log('Multer destination called for file:', file.originalname); const { eventId } = req.params; try { // Get event details const event = await db('events').where({ id: eventId }).first(); if (!event) { console.error('Event not found in multer destination:', eventId); return cb(new Error('Event not found')); } // Store event in request for use in filename generation req.eventData = event; // Create destination path - now just event folder, no type subfolder const destPath = path.join(getStoragePath(), 'events/active', event.slug); console.log('Destination path:', destPath); // Ensure directory exists await fs.mkdir(destPath, { recursive: true }); cb(null, destPath); } catch (error) { console.error('Error in multer destination:', error); cb(error); } }, filename: async (req, file, cb) => { console.log('Multer filename called for file:', file.originalname); try { // Use temporary filename for now, will rename after getting category info const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`; console.log('Temp filename:', tempName); cb(null, tempName); } catch (error) { console.error('Error in multer filename:', error); cb(error); } } }); const { validateFileType } = require('../utils/fileSecurityUtils'); const upload = multer({ storage: storage, limits: { fileSize: 50 * 1024 * 1024, // 50MB limit per file files: 500, // Maximum 500 files // Set a reasonable field size limit to prevent memory issues fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields }, fileFilter: (req, file, cb) => { // Accept images only with proper validation const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { return cb(null, true); } else { cb(new Error('Only JPEG, PNG and WebP images are allowed')); } } }); const { createFileUploadValidator } = require('../utils/fileSecurityUtils'); // Create content validator middleware const validateUploadContent = createFileUploadValidator({ allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], maxFileSize: 50 * 1024 * 1024, validateContent: true }); // Upload photos for an event // Increased limit to 500 files, but recommend chunked uploads for better performance router.post('/:eventId/upload', adminAuth, (req, res, next) => { upload.array('photos', 500)(req, res, (err) => { if (err) { console.error('Multer error:', err); if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' }); } if (err.code === 'LIMIT_FILE_COUNT') { return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' }); } return res.status(400).json({ error: `Upload error: ${err.message}` }); } return res.status(400).json({ error: err.message || 'Upload failed' }); } next(); }); }, validateUploadContent, async (req, res) => { try { const { eventId } = req.params; const { category_id } = req.body; console.log('Upload request received for event:', eventId); console.log('Body:', req.body); console.log('Files:', req.files ? req.files.length : 'none'); console.log('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype }))); console.log('Category ID received:', category_id); // Verify event exists and admin has access const event = await db('events').where({ id: eventId }).first(); if (!event) { console.error('Event not found:', eventId); return res.status(404).json({ error: 'Event not found' }); } if (!req.files || req.files.length === 0) { console.error('No files in request. req.files:', req.files); console.error('Request body keys:', Object.keys(req.body)); return res.status(400).json({ error: 'No files uploaded' }); } // Parse category_id to number if provided const parsedCategoryId = category_id ? parseInt(category_id, 10) : null; // Get category details if provided let category = null; if (parsedCategoryId) { category = await db('photo_categories').where({ id: parsedCategoryId }).first(); if (!category) { return res.status(400).json({ error: 'Invalid category' }); } } const uploadedPhotos = []; const errors = []; // Process files in batches to optimize database operations const BATCH_SIZE = 10; // Process 10 files at a time for database operations for (let i = 0; i < req.files.length; i += BATCH_SIZE) { const batch = req.files.slice(i, i + BATCH_SIZE); // Start a single transaction for the batch const trx = await db.transaction(); try { // Get initial counter for this batch let batchCounter = 1; if (category) { const categoryData = await trx('photo_categories') .where({ id: parsedCategoryId }) .forUpdate() .first(); batchCounter = (categoryData.photo_counter || 0) + 1; } else { const uncategorizedCount = await trx('photos') .where({ event_id: eventId }) .whereNull('category_id') .count('id as count') .first(); batchCounter = (uncategorizedCount.count || 0) + 1; } const batchPhotos = []; for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) { const file = batch[fileIndex]; const counter = batchCounter + fileIndex; try { // Generate new filename const extension = path.extname(file.originalname); const newFilename = generatePhotoFilename( event.event_name, category ? category.name : 'uncategorized', counter, extension ); // Rename the file const oldPath = file.path; const newPath = path.join(path.dirname(oldPath), newFilename); await fs.rename(oldPath, newPath); // Update file object file.filename = newFilename; file.path = newPath; // Generate thumbnail with new filename const thumbnailPath = await generateThumbnail(file.path); // Calculate relative paths const storagePath = getStoragePath(); const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path); const relativeThumbPath = thumbnailPath; // Prepare photo data for batch insert batchPhotos.push({ event_id: eventId, filename: file.filename, path: relativePath, thumbnail_path: relativeThumbPath, category_id: parsedCategoryId || null, type: 'individual', size_bytes: file.size }); } catch (error) { console.error(`Error processing file ${file.originalname}:`, error); errors.push({ filename: file.originalname, error: error.message }); // Delete the file if it was partially processed if (file.path) { try { await fs.unlink(file.path); } catch (e) {} } } } // Batch insert all photos from this batch if (batchPhotos.length > 0) { const insertedIds = await trx('photos').insert(batchPhotos).returning('id'); // Update category counter if needed if (category) { await trx('photo_categories') .where({ id: parsedCategoryId }) .update({ photo_counter: batchCounter + batchPhotos.length - 1 }); } // Add to uploaded photos array batchPhotos.forEach((photo, index) => { uploadedPhotos.push({ id: insertedIds[index]?.id || insertedIds[index], filename: photo.filename, size: photo.size_bytes, category_id: photo.category_id }); }); } // Commit the batch transaction await trx.commit(); } catch (error) { console.error(`Error processing batch starting at index ${i}:`, error); await trx.rollback(); // Try to clean up files from failed batch for (const file of batch) { if (file.path) { try { await fs.unlink(file.path); } catch (e) {} } } } } // Log activity await logActivity('photos_uploaded', { count: uploadedPhotos.length, eventName: event.event_name }, eventId, { type: 'admin', id: req.admin.id, name: req.admin.username } ); // Prepare response const response = { message: `Successfully uploaded ${uploadedPhotos.length} photos`, photos: uploadedPhotos, totalFiles: req.files.length, successCount: uploadedPhotos.length, failureCount: errors.length }; // Include error details if any files failed if (errors.length > 0) { response.errors = errors; response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`; } res.json(response); } catch (error) { console.error('Error uploading photos:', error); res.status(500).json({ error: 'Failed to upload photos' }); } }); // Delete a photo router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => { try { const { eventId, photoId } = req.params; // Get photo details const photo = await db('photos') .where({ id: photoId, event_id: eventId }) .first(); if (!photo) { return res.status(404).json({ error: 'Photo not found' }); } // Delete physical files const storagePath = getStoragePath(); const photoPath = path.join(storagePath, 'events/active', photo.path); try { await fs.unlink(photoPath); } catch (error) { console.error('Error deleting photo file:', error); } // Delete thumbnail if exists if (photo.thumbnail_path) { const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path); try { await fs.unlink(thumbPath); } catch (error) { console.error('Error deleting thumbnail:', error); } } // Remove from database await db('photos').where({ id: photoId }).delete(); // Log activity const event = await db('events').where({ id: eventId }).first(); await logActivity('photo_deleted', { filename: photo.filename, eventName: event.event_name }, eventId, { type: 'admin', id: req.admin.id, name: req.admin.username } ); res.json({ message: 'Photo deleted successfully' }); } catch (error) { console.error('Error deleting photo:', error); res.status(500).json({ error: 'Failed to delete photo' }); } }); // Update a photo (e.g., change category) router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => { try { const { eventId, photoId } = req.params; const { category_id } = req.body; // Verify photo belongs to event const photo = await db('photos') .where({ id: photoId, event_id: eventId }) .first(); if (!photo) { return res.status(404).json({ error: 'Photo not found' }); } // Update photo await db('photos') .where({ id: photoId }) .update({ category_id: category_id || null }); res.json({ message: 'Photo updated successfully' }); } catch (error) { console.error('Error updating photo:', error); res.status(500).json({ error: 'Failed to update photo' }); } }); // Bulk delete photos router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => { try { const { eventId } = req.params; const { photoIds } = req.body; if (!Array.isArray(photoIds) || photoIds.length === 0) { return res.status(400).json({ error: 'Invalid photo IDs' }); } // Get all photos to delete const photos = await db('photos') .whereIn('id', photoIds) .where('event_id', eventId); if (photos.length === 0) { return res.status(404).json({ error: 'No photos found' }); } // Delete physical files const storagePath = getStoragePath(); const event = await db('events').where({ id: eventId }).first(); for (const photo of photos) { // Delete photo file const photoPath = path.join(storagePath, 'events/active', photo.path); try { await fs.unlink(photoPath); } catch (error) { console.error('Error deleting photo file:', error); } // Delete thumbnail if (photo.thumbnail_path) { const thumbPath = path.join(storagePath, photo.thumbnail_path); try { await fs.unlink(thumbPath); } catch (error) { console.error('Error deleting thumbnail:', error); } } } // Delete from database await db('photos') .whereIn('id', photoIds) .where('event_id', eventId) .delete(); // Log activity await logActivity('photos_bulk_deleted', { count: photos.length, eventName: event.event_name }, eventId, { type: 'admin', id: req.admin.id, name: req.admin.username } ); res.json({ message: `${photos.length} photos deleted successfully` }); } catch (error) { console.error('Error bulk deleting photos:', error); res.status(500).json({ error: 'Failed to delete photos' }); } }); // Bulk update photos router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => { try { const { eventId } = req.params; const { photoIds, updates } = req.body; if (!Array.isArray(photoIds) || photoIds.length === 0) { return res.status(400).json({ error: 'Invalid photo IDs' }); } // Verify all photos belong to the event const photoCount = await db('photos') .whereIn('id', photoIds) .where('event_id', eventId) .count('id as count') .first(); if (photoCount.count !== photoIds.length) { return res.status(400).json({ error: 'Some photos do not belong to this event' }); } // Update photos const updateData = {}; if (updates.category_id !== undefined) { updateData.category_id = updates.category_id || null; } await db('photos') .whereIn('id', photoIds) .where('event_id', eventId) .update(updateData); res.json({ message: `${photoIds.length} photos updated successfully` }); } catch (error) { console.error('Error bulk updating photos:', error); res.status(500).json({ error: 'Failed to update photos' }); } }); // Download a photo router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => { try { const { eventId, photoId } = req.params; const photo = await db('photos') .where({ id: photoId, event_id: eventId }) .first(); if (!photo) { return res.status(404).json({ error: 'Photo not found' }); } const storagePath = getStoragePath(); const filePath = path.join(storagePath, 'events/active', photo.path); // Check if file exists try { await fs.access(filePath); } catch (error) { return res.status(404).json({ error: 'Photo file not found' }); } // Send file res.download(filePath, photo.filename); } catch (error) { console.error('Error downloading photo:', error); res.status(500).json({ error: 'Failed to download photo' }); } }); // Get all photos for an event router.get('/:eventId/photos', adminAuth, async (req, res) => { try { const { eventId } = req.params; const { category_id, type, search, sort = 'date', order = 'desc' } = req.query; let query = db('photos') .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') .where({ 'photos.event_id': eventId }) .select( 'photos.*', 'photo_categories.name as category_name', 'photo_categories.slug as category_slug' ); // Filter by category (including uncategorized) if (category_id !== undefined) { if (category_id === '' || category_id === '0') { query = query.whereNull('photos.category_id'); } else { query = query.where({ 'photos.category_id': category_id }); } } // Keep type filter for backwards compatibility if (type) { query = query.where({ 'photos.type': type }); } // Search by filename if (search) { const escapedSearch = escapeLikePattern(search); query = query.where('photos.filename', 'like', `%${escapedSearch}%`); } // Sorting let orderByColumn = 'photos.uploaded_at'; if (sort === 'name') { orderByColumn = 'photos.filename'; } else if (sort === 'size') { orderByColumn = 'photos.size_bytes'; } const photos = await query.orderBy(orderByColumn, order); res.json({ photos: photos.map(photo => ({ id: photo.id, filename: photo.filename, url: `/admin/events/${eventId}/photo/${photo.id}`, thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null, type: photo.type, category_id: photo.category_id, category_name: photo.category_name, category_slug: photo.category_slug, size: photo.size_bytes, uploaded_at: photo.uploaded_at })) }); } catch (error) { console.error('Error fetching photos:', error); res.status(500).json({ error: 'Failed to fetch photos' }); } }); // Serve photo with admin authentication router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => { try { const { eventId, photoId } = req.params; const photo = await db('photos') .where({ id: photoId, event_id: eventId }) .first(); if (!photo) { return res.status(404).json({ error: 'Photo not found' }); } const storagePath = getStoragePath(); const filePath = path.join(storagePath, 'events/active', photo.path); // Check if file exists try { await fs.access(filePath); } catch (error) { return res.status(404).json({ error: 'Photo file not found' }); } // Set appropriate headers res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`); res.setHeader('Cache-Control', 'private, max-age=3600'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); // Send file (sendFile requires absolute path) res.sendFile(path.resolve(filePath)); } catch (error) { console.error('Error serving photo:', error); res.status(500).json({ error: 'Failed to serve photo' }); } }); // Serve thumbnail with admin authentication router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => { try { const { eventId, photoId } = req.params; const photo = await db('photos') .where({ id: photoId, event_id: eventId }) .first(); if (!photo || !photo.thumbnail_path) { console.error(`Thumbnail not found for photo ${photoId}, event ${eventId}`); return res.status(404).json({ error: 'Thumbnail not found' }); } const storagePath = getStoragePath(); const filePath = path.join(storagePath, photo.thumbnail_path); console.log(`Attempting to serve thumbnail: ${filePath}`); // Check if file exists try { await fs.access(filePath); } catch (error) { console.error(`Thumbnail file not found: ${filePath}`, error); return res.status(404).json({ error: 'Thumbnail file not found' }); } // Set appropriate headers res.setHeader('Content-Type', `image/${path.extname(photo.thumbnail_path).slice(1)}`); res.setHeader('Cache-Control', 'private, max-age=3600'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); // Send file (sendFile requires absolute path) res.sendFile(path.resolve(filePath)); } catch (error) { console.error('Error serving thumbnail:', error); console.error('Photo ID:', req.params.photoId); console.error('Event ID:', req.params.eventId); res.status(500).json({ error: 'Failed to serve thumbnail' }); } }); // Debug endpoint to check photo existence router.get('/:eventId/debug', adminAuth, async (req, res) => { try { const { eventId } = req.params; const event = await db('events').where({ id: eventId }).first(); const photoCount = await db('photos').where({ event_id: eventId }).count('id as count').first(); const photos = await db('photos').where({ event_id: eventId }).limit(5); res.json({ event: event || 'Not found', photoCount: photoCount.count, samplePhotos: photos, storagePath: getStoragePath() }); } catch (error) { res.status(500).json({ error: error.message }); } }); module.exports = router;