fix: handle broken thumbnails and incomplete uploads
Mirror to GitHub / mirror (push) Successful in 21s
Test and Lint / backend-test (push) Successful in 1m4s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 3s
Mirror to GitHub / mirror (push) Successful in 21s
Test and Lint / backend-test (push) Successful in 1m4s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 3s
- Implement automatic thumbnail regeneration for corrupted files - Add validation middleware to check uploaded files before processing - Improve error handling for "Premature end of input file" errors - Delete and regenerate thumbnails when serving fails - Add file integrity checks during upload process - Better error reporting for failed uploads This fixes the VipsJpeg errors and ensures reliable thumbnail generation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
This commit is contained in:
@@ -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
|
||||||
|
};
|
||||||
@@ -4,9 +4,10 @@ const path = require('path');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { generateThumbnail } = require('../services/imageProcessor');
|
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
|
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
@@ -106,7 +107,7 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
}, validateUploadContent, async (req, res) => {
|
}, validateUploadContent, validateUploadedFiles, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
const { category_id } = req.body;
|
const { category_id } = req.body;
|
||||||
@@ -197,8 +198,14 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
|
|||||||
file.filename = newFilename;
|
file.filename = newFilename;
|
||||||
file.path = newPath;
|
file.path = newPath;
|
||||||
|
|
||||||
// Generate thumbnail with new filename
|
// Generate thumbnail with new filename (with better error handling)
|
||||||
const thumbnailPath = await generateThumbnail(file.path);
|
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
|
// Calculate relative paths
|
||||||
const storagePath = getStoragePath();
|
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 }
|
{ 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
|
// Prepare response
|
||||||
|
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
|
||||||
const response = {
|
const response = {
|
||||||
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
||||||
photos: uploadedPhotos,
|
photos: uploadedPhotos,
|
||||||
totalFiles: req.files.length,
|
totalFiles: totalAttempted,
|
||||||
successCount: uploadedPhotos.length,
|
successCount: uploadedPhotos.length,
|
||||||
failureCount: errors.length
|
failureCount: totalInvalidFiles.length
|
||||||
};
|
};
|
||||||
|
|
||||||
// Include error details if any files failed
|
// Include error details if any files failed
|
||||||
if (errors.length > 0) {
|
if (totalInvalidFiles.length > 0) {
|
||||||
response.errors = errors;
|
response.errors = totalInvalidFiles;
|
||||||
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
|
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
@@ -613,26 +624,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
|||||||
.where({ id: photoId, event_id: eventId })
|
.where({ id: photoId, event_id: eventId })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!photo || !photo.thumbnail_path) {
|
if (!photo) {
|
||||||
console.error(`Thumbnail not found for photo ${photoId}, event ${eventId}`);
|
console.error(`Photo not found: ${photoId}, event ${eventId}`);
|
||||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
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 storagePath = getStoragePath();
|
||||||
const filePath = path.join(storagePath, photo.thumbnail_path);
|
const filePath = path.join(storagePath, thumbnailPath);
|
||||||
|
|
||||||
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
|
// 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('Cache-Control', 'private, max-age=3600');
|
||||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
// Configure sharp for better memory management with large batches
|
// Configure sharp for better memory management with large batches
|
||||||
sharp.cache(false); // Disable cache to prevent memory buildup
|
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 getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
||||||
|
|
||||||
async function generateThumbnail(imagePath) {
|
async function generateThumbnail(imagePath, options = {}) {
|
||||||
const filename = path.basename(imagePath);
|
const filename = path.basename(imagePath);
|
||||||
const thumbnailFilename = `thumb_${filename}`;
|
const thumbnailFilename = `thumb_${filename}`;
|
||||||
const thumbnailDir = getThumbnailPath();
|
const thumbnailDir = getThumbnailPath();
|
||||||
@@ -19,11 +20,29 @@ async function generateThumbnail(imagePath) {
|
|||||||
// Ensure thumbnail directory exists
|
// Ensure thumbnail directory exists
|
||||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
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 {
|
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, {
|
await sharp(imagePath, {
|
||||||
limitInputPixels: 268402689, // ~16k x 16k max
|
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, {
|
.resize(THUMBNAIL_WIDTH, null, {
|
||||||
withoutEnlargement: true,
|
withoutEnlargement: true,
|
||||||
@@ -36,12 +55,80 @@ async function generateThumbnail(imagePath) {
|
|||||||
})
|
})
|
||||||
.toFile(thumbnailPath);
|
.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);
|
return path.relative(getStoragePath(), thumbnailPath);
|
||||||
} catch (error) {
|
} 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 if thumbnail generation fails, don't fail the whole upload
|
||||||
return null;
|
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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user