diff --git a/backend/src/middleware/uploadValidation.js b/backend/src/middleware/uploadValidation.js new file mode 100644 index 0000000..0c2b34a --- /dev/null +++ b/backend/src/middleware/uploadValidation.js @@ -0,0 +1,90 @@ +const fs = require('fs').promises; +const path = require('path'); +const sharp = require('sharp'); +const logger = require('../utils/logger'); + +/** + * Validate uploaded file is complete and not corrupted + */ +async function validateUploadedFile(filePath) { + try { + // Check file exists and has size + const stats = await fs.stat(filePath); + if (stats.size === 0) { + throw new Error('File is empty'); + } + + // For image files, verify they can be read by Sharp + const ext = path.extname(filePath).toLowerCase(); + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + + if (imageExtensions.includes(ext)) { + // Try to read metadata - this will fail if image is corrupted + const metadata = await sharp(filePath).metadata(); + + if (!metadata.width || !metadata.height) { + throw new Error('Invalid image dimensions'); + } + + // Check for reasonable dimensions + if (metadata.width < 10 || metadata.height < 10) { + throw new Error('Image dimensions too small'); + } + + return true; + } + + return true; + } catch (error) { + logger.error(`File validation failed for ${filePath}:`, error.message); + throw error; + } +} + +/** + * Middleware to validate uploaded files after multer processing + */ +async function validateUploadedFiles(req, res, next) { + if (!req.files || req.files.length === 0) { + return next(); + } + + const validFiles = []; + const invalidFiles = []; + + // Validate each file + for (const file of req.files) { + try { + await validateUploadedFile(file.path); + validFiles.push(file); + } catch (error) { + logger.warn(`Removing invalid upload ${file.originalname}: ${error.message}`); + invalidFiles.push({ + filename: file.originalname, + error: error.message + }); + + // Delete the invalid file + try { + await fs.unlink(file.path); + } catch (unlinkErr) { + logger.error(`Failed to delete invalid file ${file.path}:`, unlinkErr.message); + } + } + } + + // Update req.files to only include valid files + req.files = validFiles; + + // Store invalid files info for response + if (invalidFiles.length > 0) { + req.invalidFiles = invalidFiles; + } + + next(); +} + +module.exports = { + validateUploadedFile, + validateUploadedFiles +}; \ No newline at end of file diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index f2c1186..60321bd 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -4,9 +4,10 @@ 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 { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor'); const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { escapeLikePattern } = require('../utils/sqlSecurity'); +const { validateUploadedFiles } = require('../middleware/uploadValidation'); const router = express.Router(); // Get storage path from environment or default @@ -106,7 +107,7 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => { } next(); }); -}, validateUploadContent, async (req, res) => { +}, validateUploadContent, validateUploadedFiles, async (req, res) => { try { const { eventId } = req.params; const { category_id } = req.body; @@ -197,8 +198,14 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => { file.filename = newFilename; file.path = newPath; - // Generate thumbnail with new filename - const thumbnailPath = await generateThumbnail(file.path); + // Generate thumbnail with new filename (with better error handling) + let thumbnailPath = null; + try { + thumbnailPath = await generateThumbnail(file.path); + } catch (thumbError) { + console.error(`Thumbnail generation failed for ${file.filename}:`, thumbError.message); + // Continue without thumbnail rather than failing the whole upload + } // Calculate relative paths const storagePath = getStoragePath(); @@ -269,19 +276,23 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => { { type: 'admin', id: req.admin.id, name: req.admin.username } ); + // Include any files that were invalid from the validation middleware + const totalInvalidFiles = (req.invalidFiles || []).concat(errors); + // Prepare response + const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0); const response = { message: `Successfully uploaded ${uploadedPhotos.length} photos`, photos: uploadedPhotos, - totalFiles: req.files.length, + totalFiles: totalAttempted, successCount: uploadedPhotos.length, - failureCount: errors.length + failureCount: totalInvalidFiles.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.`; + if (totalInvalidFiles.length > 0) { + response.errors = totalInvalidFiles; + response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`; } res.json(response); @@ -613,26 +624,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => { .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' }); + if (!photo) { + console.error(`Photo not found: ${photoId}, event ${eventId}`); + return res.status(404).json({ error: 'Photo not found' }); + } + + // Ensure thumbnail exists and is valid, regenerate if needed + const thumbnailPath = await ensureThumbnail(photo); + + if (!thumbnailPath) { + console.error(`Failed to generate thumbnail for photo ${photoId}`); + return res.status(404).json({ error: 'Thumbnail generation failed' }); } 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' }); - } + const filePath = path.join(storagePath, thumbnailPath); // Set appropriate headers - res.setHeader('Content-Type', `image/${path.extname(photo.thumbnail_path).slice(1)}`); + res.setHeader('Content-Type', 'image/jpeg'); // Thumbnails are always JPEG res.setHeader('Cache-Control', 'private, max-age=3600'); res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index f27d3ec..26f865d 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -1,6 +1,7 @@ const sharp = require('sharp'); const path = require('path'); const fs = require('fs').promises; +const logger = require('../utils/logger'); // Configure sharp for better memory management with large batches sharp.cache(false); // Disable cache to prevent memory buildup @@ -10,7 +11,7 @@ const THUMBNAIL_WIDTH = 300; const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails'); -async function generateThumbnail(imagePath) { +async function generateThumbnail(imagePath, options = {}) { const filename = path.basename(imagePath); const thumbnailFilename = `thumb_${filename}`; const thumbnailDir = getThumbnailPath(); @@ -19,11 +20,29 @@ async function generateThumbnail(imagePath) { // Ensure thumbnail directory exists await fs.mkdir(thumbnailDir, { recursive: true }); + // Check if we need to regenerate (for broken thumbnails) + if (options.regenerate) { + try { + await fs.unlink(thumbnailPath); + logger.info(`Deleted broken thumbnail: ${thumbnailPath}`); + } catch (err) { + // File might not exist, that's okay + } + } + try { - // Generate thumbnail with memory-efficient settings + // First, verify the source image is complete and valid + const metadata = await sharp(imagePath).metadata(); + + if (!metadata.width || !metadata.height) { + throw new Error('Invalid image metadata - file may be incomplete'); + } + + // Generate thumbnail with memory-efficient settings and error handling await sharp(imagePath, { limitInputPixels: 268402689, // ~16k x 16k max - sequentialRead: true // More memory efficient for large images + sequentialRead: true, // More memory efficient for large images + failOnError: false // Don't fail on minor issues }) .resize(THUMBNAIL_WIDTH, null, { withoutEnlargement: true, @@ -36,12 +55,80 @@ async function generateThumbnail(imagePath) { }) .toFile(thumbnailPath); + // Verify the thumbnail was created successfully + const stats = await fs.stat(thumbnailPath); + if (stats.size === 0) { + throw new Error('Generated thumbnail is empty'); + } + return path.relative(getStoragePath(), thumbnailPath); } catch (error) { - console.error(`Failed to generate thumbnail for ${filename}:`, error); + logger.error(`Failed to generate thumbnail for ${filename}:`, error.message); + + // Clean up any partially created file + try { + await fs.unlink(thumbnailPath); + } catch (unlinkErr) { + // Ignore unlink errors + } + // Return null if thumbnail generation fails, don't fail the whole upload return null; } } -module.exports = { generateThumbnail }; +/** + * Check if a thumbnail exists and is valid + */ +async function isThumbnailValid(thumbnailPath) { + try { + const fullPath = path.join(getStoragePath(), thumbnailPath); + const stats = await fs.stat(fullPath); + + // Check if file exists and has content + if (stats.size === 0) { + return false; + } + + // Try to read metadata to ensure it's a valid image + await sharp(fullPath).metadata(); + return true; + } catch (error) { + return false; + } +} + +/** + * Regenerate thumbnail if it's broken or missing + */ +async function ensureThumbnail(photo) { + const storagePath = getStoragePath(); + const originalPath = path.join(storagePath, 'events/active', photo.path); + + // Check if thumbnail exists and is valid + if (photo.thumbnail_path) { + const isValid = await isThumbnailValid(photo.thumbnail_path); + if (isValid) { + return photo.thumbnail_path; + } + logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`); + } + + // Generate new thumbnail + const newThumbnailPath = await generateThumbnail(originalPath, { regenerate: true }); + + if (newThumbnailPath) { + // Update database with new thumbnail path + const { db } = require('../database/db'); + await db('photos') + .where({ id: photo.id }) + .update({ thumbnail_path: newThumbnailPath }); + + logger.info(`Regenerated thumbnail for photo ${photo.id}`); + return newThumbnailPath; + } + + return null; +} + +module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };