diff --git a/backend/migrations/core/047_add_video_support.js b/backend/migrations/core/047_add_video_support.js new file mode 100644 index 0000000..a8a92e0 --- /dev/null +++ b/backend/migrations/core/047_add_video_support.js @@ -0,0 +1,109 @@ +const { addColumnIfNotExists } = require('../helpers'); + +/** + * Migration: Add video support to photos table + * - Adds columns for video metadata (media_type, duration, codecs, dimensions) + * - Updates existing photos to have media_type 'image' + */ + +exports.up = async function(knex) { + console.log('Running migration: 042_add_video_support'); + + // Add media_type column (image or video) + await addColumnIfNotExists(knex, 'photos', 'media_type', (table) => { + table.string('media_type').defaultTo('image'); + }); + + // Add mime_type column if not exists + await addColumnIfNotExists(knex, 'photos', 'mime_type', (table) => { + table.string('mime_type'); + }); + + // Add duration column (for videos, in seconds) + await addColumnIfNotExists(knex, 'photos', 'duration', (table) => { + table.integer('duration'); + }); + + // Add video codec information + await addColumnIfNotExists(knex, 'photos', 'video_codec', (table) => { + table.string('video_codec'); + }); + + // Add audio codec information + await addColumnIfNotExists(knex, 'photos', 'audio_codec', (table) => { + table.string('audio_codec'); + }); + + // Add width dimension + await addColumnIfNotExists(knex, 'photos', 'width', (table) => { + table.integer('width'); + }); + + // Add height dimension + await addColumnIfNotExists(knex, 'photos', 'height', (table) => { + table.integer('height'); + }); + + // Update existing photos to have media_type 'image' if not set + const hasMediaType = await knex.schema.hasColumn('photos', 'media_type'); + if (hasMediaType) { + await knex('photos') + .whereNull('media_type') + .orWhere('media_type', '') + .update({ media_type: 'image' }); + console.log('Updated existing photos to have media_type "image"'); + } + + console.log('Migration 042_add_video_support completed'); +}; + +exports.down = async function(knex) { + console.log('Rolling back migration: 042_add_video_support'); + + // Remove video support columns + const hasMediaType = await knex.schema.hasColumn('photos', 'media_type'); + if (hasMediaType) { + await knex.schema.alterTable('photos', (table) => { + table.dropColumn('media_type'); + }); + } + + const hasDuration = await knex.schema.hasColumn('photos', 'duration'); + if (hasDuration) { + await knex.schema.alterTable('photos', (table) => { + table.dropColumn('duration'); + }); + } + + const hasVideoCodec = await knex.schema.hasColumn('photos', 'video_codec'); + if (hasVideoCodec) { + await knex.schema.alterTable('photos', (table) => { + table.dropColumn('video_codec'); + }); + } + + const hasAudioCodec = await knex.schema.hasColumn('photos', 'audio_codec'); + if (hasAudioCodec) { + await knex.schema.alterTable('photos', (table) => { + table.dropColumn('audio_codec'); + }); + } + + const hasWidth = await knex.schema.hasColumn('photos', 'width'); + if (hasWidth) { + await knex.schema.alterTable('photos', (table) => { + table.dropColumn('width'); + }); + } + + const hasHeight = await knex.schema.hasColumn('photos', 'height'); + if (hasHeight) { + await knex.schema.alterTable('photos', (table) => { + table.dropColumn('height'); + }); + } + + // Note: We don't drop mime_type as it may be used by images as well + + console.log('Rollback of 042_add_video_support completed'); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 8820292..5c73c08 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0", + "@ffmpeg-installer/ffmpeg": "^1.1.0", "adm-zip": "^0.5.16", "archiver": "^5.3.1", "axios": "^1.12.2", @@ -22,6 +23,7 @@ "express": "^4.18.2", "express-rate-limit": "^6.7.0", "express-validator": "^7.0.1", + "fluent-ffmpeg": "^2.1.3", "form-data": "^4.0.4", "handlebars": "^4.7.8", "helmet": "^7.0.0", @@ -1621,6 +1623,132 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@ffmpeg-installer/darwin-arm64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz", + "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffmpeg-installer/darwin-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz", + "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "LGPL-2.1", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffmpeg-installer/ffmpeg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz", + "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==", + "license": "LGPL-2.1", + "optionalDependencies": { + "@ffmpeg-installer/darwin-arm64": "4.1.5", + "@ffmpeg-installer/darwin-x64": "4.1.0", + "@ffmpeg-installer/linux-arm": "4.1.3", + "@ffmpeg-installer/linux-arm64": "4.1.4", + "@ffmpeg-installer/linux-ia32": "4.1.0", + "@ffmpeg-installer/linux-x64": "4.1.0", + "@ffmpeg-installer/win32-ia32": "4.1.0", + "@ffmpeg-installer/win32-x64": "4.1.0" + } + }, + "node_modules/@ffmpeg-installer/linux-arm": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz", + "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==", + "cpu": [ + "arm" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz", + "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-ia32": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz", + "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==", + "cpu": [ + "ia32" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz", + "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/win32-ia32": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz", + "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==", + "cpu": [ + "ia32" + ], + "license": "GPLv3", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ffmpeg-installer/win32-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz", + "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==", + "cpu": [ + "x64" + ], + "license": "GPLv3", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -5898,6 +6026,37 @@ "dev": true, "license": "ISC" }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fluent-ffmpeg/node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, + "node_modules/fluent-ffmpeg/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 3c8fca6..4a0d09b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0", + "@ffmpeg-installer/ffmpeg": "^1.1.0", "adm-zip": "^0.5.16", "archiver": "^5.3.1", "axios": "^1.12.2", @@ -26,6 +27,7 @@ "express": "^4.18.2", "express-rate-limit": "^6.7.0", "express-validator": "^7.0.1", + "fluent-ffmpeg": "^2.1.3", "form-data": "^4.0.4", "handlebars": "^4.7.8", "helmet": "^7.0.0", diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 2feaf02..ee26933 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -4,36 +4,16 @@ const path = require('path'); const fs = require('fs').promises; const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); -const { generateThumbnail, ensureThumbnail, generateVideoPlaceholder } = 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 { getMaxFilesPerUpload } = require('../services/uploadSettings'); const router = express.Router(); -const { isVideoMimeType, validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils'); -const mime = require('mime-types'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); -const parseCategoryId = (value) => { - if (value === undefined || value === null) return null; - if (typeof value === 'number' && Number.isInteger(value)) { - return value === 0 ? null : value; - } - if (typeof value === 'string') { - const trimmed = value.trim(); - if (!trimmed || trimmed === 'null') return null; - if (/^\d+$/.test(trimmed)) { - const parsed = parseInt(trimmed, 10); - if (!Number.isNaN(parsed)) { - return parsed === 0 ? null : parsed; - } - } - } - return null; -}; - // Configure multer for file uploads // IMPORTANT: Using synchronous functions to prevent file corruption const storage = multer.diskStorage({ @@ -63,10 +43,12 @@ const storage = multer.diskStorage({ } }); +const { validateFileType } = require('../utils/fileSecurityUtils'); + const upload = multer({ storage: storage, limits: { - fileSize: 50 * 1024 * 1024, // 50MB limit per file + fileSize: 500 * 1024 * 1024, // 500MB limit per file to support videos files: 2000, // Hard safety ceiling; actual limit enforced dynamically // Set a reasonable field size limit to prevent memory issues fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields @@ -75,12 +57,12 @@ const upload = multer({ headerPairs: 2000 // Maximum number of header key-value pairs }, fileFilter: (req, file, cb) => { - // Accept images and common video formats with proper validation + // Accept images and videos with proper validation const allowedMimeTypes = [ 'image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo' ]; - + if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { return cb(null, true); } else { @@ -91,14 +73,15 @@ const upload = multer({ abortOnLimit: true }); +const { createFileUploadValidator } = require('../utils/fileSecurityUtils'); + // Create content validator middleware const validateUploadContent = createFileUploadValidator({ allowedTypes: [ 'image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo' ], - // 10GB per file to accommodate large videos; overall limits enforced elsewhere - maxFileSize: 10 * 1024 * 1024 * 1024, + maxFileSize: 500 * 1024 * 1024, // 500MB to support videos validateContent: true }); @@ -190,14 +173,26 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re } // Parse category_id to number if provided - const numericCategoryId = parseCategoryId(category_id); - - const resolveCategoryName = (type) => { - if (type === 'collage') return 'collages'; - if (type === 'video') return 'videos'; - return 'individual'; - }; - + const parsedCategoryId = category_id ? parseInt(category_id, 10) : null; + + // Determine photo type from category_id parameter (for backwards compatibility) + let photoType = 'individual'; // default + let categoryName = 'individual'; + + if (parsedCategoryId === 1 || category_id === 'collage') { + photoType = 'collage'; + categoryName = 'collages'; + } else if (parsedCategoryId === 2 || category_id === 'individual') { + photoType = 'individual'; + categoryName = 'individual'; + } + + // For backwards compatibility, accept string values + if (category_id === 'collage') { + photoType = 'collage'; + categoryName = 'collages'; + } + // Create final destination directory const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug); await fs.mkdir(finalDestPath, { recursive: true }); @@ -215,49 +210,20 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re const trx = await db.transaction(); try { - const preparedBatch = batch.map((file) => { - const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream'; - const video = isVideoMimeType(resolvedMime, file?.originalname); - let inferredType = video ? 'video' : 'individual'; - - if (!video) { - if (numericCategoryId === 1 || category_id === 'collage') { - inferredType = 'collage'; - } else if (numericCategoryId === 2 || category_id === 'individual') { - inferredType = 'individual'; - } - } - - return { - file, - resolvedMime, - isVideo: video, - photoType: inferredType - }; - }); - - const typesInBatch = Array.from(new Set(preparedBatch.map((item) => item.photoType))); - const typeCounters = {}; - - if (typesInBatch.length > 0) { - const existingCounts = await trx('photos') - .where({ event_id: eventId }) - .whereIn('type', typesInBatch) - .select('type') - .count('id as count') - .groupBy('type'); - - existingCounts.forEach((row) => { - typeCounters[row.type] = parseInt(row.count) || 0; - }); - } + // Get initial counter for this batch based on photo type + const existingCount = await trx('photos') + .where({ event_id: eventId, type: photoType }) + .count('id as count') + .first(); + let batchCounter = (parseInt(existingCount.count) || 0) + 1; const batchPhotos = []; const fileRenameOperations = []; // Store rename operations to do after commit // First pass: prepare data and move files from temp to final location - for (let fileIndex = 0; fileIndex < preparedBatch.length; fileIndex++) { - const { file, resolvedMime, isVideo, photoType } = preparedBatch[fileIndex]; + for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) { + const file = batch[fileIndex]; + const counter = batchCounter + fileIndex; const tempPath = file.path; // Original temp path try { @@ -266,15 +232,12 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re if (tempStats.size === 0) { throw new Error('File is empty - upload may have been interrupted'); } - - typeCounters[photoType] = (typeCounters[photoType] || 0) + 1; - const counter = typeCounters[photoType]; // Generate new filename const extension = path.extname(file.originalname); const newFilename = generatePhotoFilename( event.event_name, - resolveCategoryName(photoType), + categoryName, counter, extension ); @@ -291,10 +254,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re path: relativePath, thumbnail_path: null, // Will generate after successful commit type: photoType, - size_bytes: tempStats.size, // Use actual file size from stat - category_id: numericCategoryId, - source_origin: 'managed', - mime_type: resolvedMime + size_bytes: tempStats.size // Use actual file size from stat }; batchPhotos.push(photoData); @@ -304,8 +264,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re tempPath: tempPath, finalPath: finalPath, filename: newFilename, - photoData: photoData, - isVideo + photoData: photoData }); } catch (error) { console.error(`Error preparing file ${file.originalname}:`, error); @@ -315,7 +274,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re // Insert all photos in this batch if (batchPhotos.length > 0) { - console.log(`Inserting batch of ${batchPhotos.length} files with types: ${typesInBatch.join(', ')}`); + console.log(`Inserting batch of ${batchPhotos.length} photos with type: ${photoType}`); const insertedIds = await trx('photos').insert(batchPhotos).returning('id'); @@ -340,31 +299,27 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re } // Generate thumbnail with final path - let thumbnailPath = null; - try { - thumbnailPath = operation.isVideo - ? await generateVideoPlaceholder(operation.filename) - : await generateThumbnail(operation.finalPath); - - // Update the database with thumbnail path - if (thumbnailPath && insertedIds[idx]) { - const photoId = insertedIds[idx]?.id || insertedIds[idx]; - await db('photos') - .where({ id: photoId }) - .update({ thumbnail_path: thumbnailPath }); - } - } catch (thumbError) { - console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message); - } + let thumbnailPath = null; + try { + thumbnailPath = await generateThumbnail(operation.finalPath); + + // Update the database with thumbnail path + if (thumbnailPath && insertedIds[idx]) { + const photoId = insertedIds[idx]?.id || insertedIds[idx]; + await db('photos') + .where({ id: photoId }) + .update({ thumbnail_path: thumbnailPath }); + } + } catch (thumbError) { + console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message); + } // Add to successful uploads uploadedPhotos.push({ id: insertedIds[idx]?.id || insertedIds[idx], filename: operation.filename, size: operation.photoData.size_bytes, - category_id: operation.photoData.category_id, - type: operation.photoData.type, - mime_type: operation.photoData.mime_type + category_id: operation.photoData.category_id }); } catch (moveError) { console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, moveError); @@ -431,7 +386,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re // Prepare response const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0); const response = { - message: `Successfully uploaded ${uploadedPhotos.length} files`, + message: `Successfully uploaded ${uploadedPhotos.length} photos`, photos: uploadedPhotos, totalFiles: totalAttempted, successCount: uploadedPhotos.length, @@ -441,7 +396,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re // Include error details if any files failed if (totalInvalidFiles.length > 0) { response.errors = totalInvalidFiles; - response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} files. ${totalInvalidFiles.length} failed.`; + response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`; } res.json(response); @@ -458,7 +413,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re } } - res.status(500).json({ error: 'Failed to upload files' }); + res.status(500).json({ error: 'Failed to upload photos' }); } }); @@ -488,7 +443,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => { // Delete thumbnail if exists if (photo.thumbnail_path) { - const thumbPath = path.join(storagePath, photo.thumbnail_path); + const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path); try { // Check if file exists before attempting to delete await fs.access(thumbPath); @@ -535,44 +490,24 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => { } // Prepare update data - const updateData = { - updated_at: new Date() - }; + const updateData = {}; // Handle type-based categories ('individual' or 'collage') // These are string values that map to the photo.type field if (category_id === 'individual' || category_id === 'collage') { updateData.type = category_id; updateData.category_id = null; // Clear legacy category_id - } else if (category_id === null || category_id === undefined) { - // Explicitly clear category - updateData.category_id = null; } else { - // Handle numeric category IDs from photo_categories table - const numericCategoryId = parseInt(category_id, 10); - if (!isNaN(numericCategoryId)) { - updateData.category_id = numericCategoryId; - } else { - updateData.category_id = null; - } + // Handle legacy numeric category IDs + updateData.category_id = category_id || null; } // Update photo - const normalizedCategoryId = parseCategoryId(category_id); - await db('photos') - .where({ id: photoId, event_id: eventId }) + .where({ id: photoId }) .update(updateData); - // Fetch and return updated photo for confirmation - const updatedPhoto = await db('photos') - .where({ id: photoId }) - .first(); - - res.json({ - message: 'Photo updated successfully', - photo: updatedPhoto - }); + res.json({ message: 'Photo updated successfully' }); } catch (error) { console.error('Error updating photo:', error); res.status(500).json({ error: 'Failed to update photo' }); @@ -652,44 +587,33 @@ 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 (parseInt(photoCount.count) !== photoIds.length) { + + if (photoCount.count !== photoIds.length) { return res.status(400).json({ error: 'Some photos do not belong to this event' }); } - + // Prepare update data - const updateData = { - updated_at: new Date() - }; - + const updateData = {}; if (updates.category_id !== undefined) { // Handle type-based categories ('individual' or 'collage') // These are string values that map to the photo.type field if (updates.category_id === 'individual' || updates.category_id === 'collage') { updateData.type = updates.category_id; updateData.category_id = null; // Clear legacy category_id - } else if (updates.category_id === null) { - // Explicitly clear category - updateData.category_id = null; } else { - // Handle numeric category IDs from photo_categories table - const numericCategoryId = parseInt(updates.category_id, 10); - if (!isNaN(numericCategoryId)) { - updateData.category_id = numericCategoryId; - } else { - updateData.category_id = null; - } + // Handle legacy numeric category IDs + updateData.category_id = updates.category_id || null; } } @@ -741,25 +665,17 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => router.get('/:eventId/photos', adminAuth, async (req, res) => { try { const { eventId } = req.params; - const { category_id, type, media_type, search, sort = 'date', order = 'desc' } = req.query; + const { category_id, type, search, sort = 'date', order = 'desc' } = req.query; let query = db('photos') - .leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id') .where({ 'photos.event_id': eventId }) - .select( - 'photos.*', - 'pc.name as category_display_name', - 'pc.slug as category_display_slug' - ); + .select('photos.*'); // Filter by type (individual/collage) - category_id maps to type if (category_id !== undefined) { - if (category_id === '') { - // No filter when empty string is provided - } else if (category_id === '0') { - query = query.whereNull('photos.category_id'); - } else if (/^\d+$/.test(category_id)) { - query = query.where('photos.category_id', parseInt(category_id, 10)); + if (category_id === '' || category_id === '0') { + // For backwards compatibility, empty category means no filter + // Don't filter anything } else if (category_id === 'individual' || category_id === 'collage') { query = query.where({ 'photos.type': category_id }); } @@ -769,20 +685,6 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { if (type) { query = query.where({ 'photos.type': type }); } - - if (media_type === 'video') { - query = query.where((qb) => { - qb.where('photos.type', 'video') - .orWhere('photos.mime_type', 'like', 'video/%'); - }); - } else if (media_type === 'photo') { - query = query.where((qb) => { - qb.whereNot('photos.type', 'video') - .andWhere(function(inner) { - inner.whereNull('photos.mime_type').orWhere('photos.mime_type', 'not like', 'video/%'); - }); - }); - } // Search by filename if (search) { @@ -799,11 +701,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { } const photos = await query.orderBy(orderByColumn, order); - - if (photos.length === 0) { - return res.json({ photos: [] }); - } - + // Get comment counts separately const commentCounts = await db('photo_feedback') .whereIn('photo_id', photos.map(p => p.id)) @@ -820,37 +718,26 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { }); res.json({ - photos: photos.map(photo => { - const mediaType = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video' ? 'video' : 'photo'; - const categoryName = photo.category_display_name - || (photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages'); - const normalizedCategoryId = photo.category_id !== null && photo.category_id !== undefined - ? (Number.isNaN(Number(photo.category_id)) ? photo.category_id : Number(photo.category_id)) - : null; - - return ({ - id: photo.id, - filename: photo.filename, - // Use the correct admin photos router base for serving images - url: `/admin/photos/${eventId}/photo/${photo.id}`, - // Always expose a thumbnail URL; backend will generate on demand if missing - thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`, - type: photo.type, - category_id: normalizedCategoryId, - mime_type: photo.mime_type, - media_type: mediaType, - category_name: categoryName, - category_slug: photo.category_display_slug || photo.type, - size: photo.size_bytes, - uploaded_at: photo.uploaded_at, - // Feedback data - has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), - average_rating: photo.average_rating || 0, - comment_count: commentMap[photo.id] || 0, - like_count: photo.like_count || 0, - favorite_count: photo.favorite_count || 0 - }); - }) + photos: photos.map(photo => ({ + id: photo.id, + filename: photo.filename, + // Use the correct admin photos router base for serving images + url: `/admin/photos/${eventId}/photo/${photo.id}`, + // Always expose a thumbnail URL; backend will generate on demand if missing + thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`, + type: photo.type, + category_id: photo.type, + category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages', + category_slug: photo.type, + size: photo.size_bytes, + uploaded_at: photo.uploaded_at, + // Feedback data + has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), + average_rating: photo.average_rating || 0, + comment_count: commentMap[photo.id] || 0, + like_count: photo.like_count || 0, + favorite_count: photo.favorite_count || 0 + })) }); } catch (error) { console.error('Error fetching photos:', error); @@ -882,10 +769,8 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => { return res.status(404).json({ error: 'Photo file not found' }); } - const mimeType = photo.mime_type || `image/${path.extname(photo.filename).slice(1)}`; - // Set appropriate headers - res.setHeader('Content-Type', mimeType); + 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'); @@ -911,30 +796,8 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => { return res.status(404).json({ error: 'Photo not found' }); } - const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); // Ensure thumbnail exists and is valid, regenerate if needed - let thumbnailPath = photo.thumbnail_path; - const thumbMissing = !thumbnailPath || !(await (async () => { - try { - const fs = require('fs').promises; - await fs.access(path.join(getStoragePath(), thumbnailPath)); - return true; - } catch { - return false; - } - })()); - - if (isVideo) { - if (!thumbnailPath || thumbMissing) { - const regenerated = await generateVideoPlaceholder(photo.filename, { regenerate: true }); - if (regenerated) { - thumbnailPath = regenerated; - await db('photos').where({ id: photo.id }).update({ thumbnail_path: regenerated }); - } - } - } else { - thumbnailPath = await ensureThumbnail(photo); - } + const thumbnailPath = await ensureThumbnail(photo); if (!thumbnailPath) { console.error(`Failed to generate thumbnail for photo ${photoId}`); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 2f9abd3..47035e8 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -10,8 +10,6 @@ const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); const { resolvePhotoFilePath } = require('../services/photoResolver'); const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); -const { ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor'); -const { isVideoMimeType } = require('../utils/fileSecurityUtils'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); @@ -250,17 +248,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { .distinct('type') .orderBy('type', 'asc'); - const resolveCategoryName = (type, mimeType, filename) => { - if (type === 'video' || isVideoMimeType(mimeType, filename)) return 'Videos'; - if (type === 'individual') return 'Individual Photos'; - if (type === 'collage') return 'Collages'; - return type || 'Uncategorized'; - }; - // Convert types to category-like objects const categories = categoryResults.map(result => ({ id: result.type, - name: resolveCategoryName(result.type), + name: result.type === 'individual' ? 'Individual Photos' : 'Collages', slug: result.type, is_global: false })); @@ -301,13 +292,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { }, categories: categories, photos: photos.map(photo => { - const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); - const mediaType = isVideo ? 'video' : 'photo'; - const useJwtUrl = isVideo || (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard'); + const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard'); const photoUrl = useJwtUrl ? `/api/gallery/${req.params.slug}/photo/${photo.id}` : `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`; - const categoryName = resolveCategoryName(photo.type, photo.mime_type, photo.filename); return { id: photo.id, @@ -318,14 +306,12 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`, type: photo.type, category_id: photo.type, - category_name: categoryName, + category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages', category_slug: photo.type, size: photo.size_bytes, uploaded_at: photo.uploaded_at, - media_type: mediaType, - mime_type: photo.mime_type, // Fixed: Use the calculated useJwtUrl variable instead of recalculating - requires_token: !useJwtUrl && !isVideo, + requires_token: !useJwtUrl, // Feedback data has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), average_rating: photo.average_rating || 0, @@ -359,7 +345,6 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => return res.status(404).json({ error: 'Photo not found' }); } - const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); // Update download count await db('photos').where('id', photoId).increment('download_count', 1); @@ -388,7 +373,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => // Get watermark settings const watermarkSettings = await watermarkService.getWatermarkSettings(); - if (watermarkSettings && watermarkSettings.enabled && !isVideo) { + if (watermarkSettings && watermarkSettings.enabled) { // Apply watermark and send const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); @@ -401,9 +386,6 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => res.send(watermarkedBuffer); } else { // Send original file - if (isVideo) { - res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' }); - } res.download(filePath, photo.filename, (downloadError) => { if (downloadError) { logger.error('Error streaming gallery download', { @@ -481,16 +463,14 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { let archiveName; if (hasMultipleTypes) { // Use photo type as folder - const folderName = photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages'; + const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages'; archiveName = path.join(folderName, photo.filename); } else { // No folders, just the filename archiveName = photo.filename; } - const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); - - if (watermarkSettings && watermarkSettings.enabled && !isVideo) { + if (watermarkSettings && watermarkSettings.enabled) { try { const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); archive.append(watermarkedBuffer, { name: archiveName }); @@ -585,9 +565,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) => try { const filePath = resolvePhotoFilePath(req.event, photo); const name = photo.filename || `photo-${photo.id}.jpg`; - const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); - - if (watermarkSettings && watermarkSettings.enabled && !isVideo) { + if (watermarkSettings && watermarkSettings.enabled) { try { const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); archive.append(watermarkedBuffer, { name }); @@ -632,43 +610,41 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) => // View single photo (with watermark if enabled) -router.get('/:slug/photo/:photoId', - verifyGalleryAccess, +router.get('/:slug/photo/:photoId', + verifyGalleryAccess, async (req, res) => { try { const { photoId } = req.params; - const numericPhotoId = parseInt(photoId, 10); - if (!Number.isInteger(numericPhotoId)) { - return res.status(400).json({ error: 'Invalid photo id' }); - } - + const photo = await db('photos') - .where({ id: numericPhotoId, event_id: req.event.id }) + .where({ id: photoId, event_id: req.event.id }) .first(); - - + + if (!photo) { return res.status(404).json({ error: 'Photo not found' }); } - const isVideo = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video'; + // Check if this is a video + const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/')); + // Check protection level - basic and standard protection allow direct JWT access const protectionLevel = req.event.protection_level || 'standard'; - - if (!isVideo && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) { + + if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') { // For enhanced/maximum protection, redirect to secure endpoint - return res.status(302).json({ + return res.status(302).json({ error: 'Secure access required', secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`, photoId: photoId }); } - + // Resolve the absolute file path for this photo, supporting both managed and external reference modes const { resolvePhotoFilePath } = require('../services/photoResolver'); const filePath = resolvePhotoFilePath(req.event, photo); - - + + // Log access - temporarily disabled for debugging // await secureImageService.logImageAccess( // photoId, @@ -676,20 +652,61 @@ router.get('/:slug/photo/:photoId', // req.clientInfo, // 'view_basic' // ); - + + // Handle video streaming with range requests + if (isVideo) { + const fs = require('fs'); + const stat = fs.statSync(filePath); + const fileSize = stat.size; + const range = req.headers.range; + + if (range) { + // Parse range header + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + const chunksize = (end - start) + 1; + const file = fs.createReadStream(filePath, { start, end }); + + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + 'Content-Type': photo.mime_type || 'video/mp4', + 'Cache-Control': 'private, max-age=1800', + 'X-Protection-Level': 'basic' + }); + + file.pipe(res); + } else { + // No range request, send entire file + res.writeHead(200, { + 'Content-Length': fileSize, + 'Content-Type': photo.mime_type || 'video/mp4', + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'private, max-age=1800', + 'X-Protection-Level': 'basic' + }); + + fs.createReadStream(filePath).pipe(res); + } + return; + } + + // Handle images (existing logic) // Get watermark settings const watermarkSettings = await watermarkService.getWatermarkSettings(); - - if (watermarkSettings && watermarkSettings.enabled && !isVideo) { + + if (watermarkSettings && watermarkSettings.enabled) { // Apply watermark and send const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); - + res.set({ 'Content-Type': photo.mime_type || 'image/jpeg', 'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes 'X-Protection-Level': 'basic' }); - + res.send(watermarkedBuffer); } else { // Send original file with basic protection headers @@ -699,9 +716,6 @@ router.get('/:slug/photo/:photoId', }); // Ensure absolute path for res.sendFile const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); - if (isVideo) { - res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' }); - } res.sendFile(absolutePath); } } catch (error) { @@ -722,62 +736,32 @@ router.get('/:slug/thumbnail/:photoId', async (req, res) => { try { const { photoId } = req.params; - const numericPhotoId = parseInt(photoId, 10); - if (!Number.isInteger(numericPhotoId)) { - return res.status(400).json({ error: 'Invalid photo id' }); - } const photo = await db('photos') - .where({ id: numericPhotoId, event_id: req.event.id }) + .where({ id: photoId, event_id: req.event.id }) .first(); - if (!photo) { - return res.status(404).json({ error: 'Thumbnail not found' }); - } - const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); - - let thumbnailPath = photo.thumbnail_path; - let thumbFilePath = thumbnailPath ? path.join(getStoragePath(), thumbnailPath) : null; - - if (isVideo) { - const fs = require('fs').promises; - const missing = !thumbFilePath || !(await (async () => { try { await fs.access(thumbFilePath); return true; } catch { return false; } })()); - if (missing) { - const regenerated = await generateVideoPlaceholder(photo.filename, { regenerate: true }); - if (regenerated) { - thumbnailPath = regenerated; - thumbFilePath = path.join(getStoragePath(), regenerated); - await db('photos').where({ id: photo.id }).update({ thumbnail_path: regenerated }); - } - } - } else { - thumbnailPath = await ensureThumbnail(photo); - thumbFilePath = thumbnailPath ? path.join(getStoragePath(), thumbnailPath) : null; - } - - if (!thumbFilePath) { + if (!photo || !photo.thumbnail_path) { return res.status(404).json({ error: 'Thumbnail not found' }); } + const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); + // Check if file exists const fs = require('fs').promises; try { - await fs.access(thumbFilePath); + await fs.access(thumbPath); } catch (error) { return res.status(404).json({ error: 'Thumbnail file not found' }); } // Log thumbnail access - try { - await secureImageService.logImageAccess( - numericPhotoId, - req.event.id, - req.clientInfo, - 'thumbnail' - ); - } catch (logErr) { - logger.warn('Thumbnail access log failed', { photoId, eventId: req.event.id, error: logErr.message }); - } + await secureImageService.logImageAccess( + photoId, + req.event.id, + req.clientInfo, + 'thumbnail' + ); // Set appropriate headers with enhanced security res.set({ @@ -789,14 +773,8 @@ router.get('/:slug/thumbnail/:photoId', }); // Send file - res.sendFile(path.resolve(thumbFilePath)); + res.sendFile(path.resolve(thumbPath)); } catch (error) { - console.error('Thumbnail route error', { - message: error?.message, - stack: error?.stack, - photoId: req.params.photoId, - eventId: req.event?.id, - }); logger.error('Error serving thumbnail:', { error: error.message, photoId: req.params.photoId, diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js index 75274a7..7571af7 100644 --- a/backend/src/services/photoProcessor.js +++ b/backend/src/services/photoProcessor.js @@ -1,11 +1,9 @@ const path = require('path'); const fs = require('fs').promises; const { db } = require('../database/db'); -const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor'); -const { processUploadedVideo } = require('./videoProcessor'); +const { generateThumbnail } = require('./imageProcessor'); const { generatePhotoFilename } = require('../utils/filenameSanitizer'); -const { isVideoMimeType } = require('../utils/fileSecurityUtils'); -const mime = require('mime-types'); +const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); @@ -73,15 +71,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ const trx = await db.transaction(); try { - const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream'; - const isVideo = isVideoMimeType(resolvedMime, file?.originalname); - // Count existing photos to generate sequence number let counter = 1; - let photoType = isVideo ? 'video' : 'individual'; // default type + let photoType = 'individual'; // default type // If categoryId is provided and matches photo types, use it as type - if (!isVideo && categoryId === 'collage') { + if (categoryId === 'collage') { photoType = 'collage'; } @@ -96,7 +91,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ // Generate new filename const extension = path.extname(file.originalname); - const categoryName = photoType === 'collage' ? 'collages' : (isVideo ? 'videos' : 'individual'); + const categoryName = photoType === 'collage' ? 'collages' : 'individual'; const newFilename = generatePhotoFilename( event.event_name, categoryName, @@ -156,31 +151,34 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ } } - // Generate thumbnail and metadata - let thumbnailPath = null; + // Determine if this is a video or image + const isVideo = isVideoMimeType(file.mimetype); + const mediaType = isVideo ? 'video' : 'image'; + + // Generate thumbnail and extract metadata + let thumbnailPath; let videoMetadata = null; + if (isVideo) { + // Process video: extract metadata and generate thumbnail const thumbnailDir = path.join(getStoragePath(), 'thumbnails'); await fs.mkdir(thumbnailDir, { recursive: true }); const videoThumbnailPath = path.join(thumbnailDir, `thumb_${newFilename.replace(/\.[^.]+$/, '.jpg')}`); - try { - const result = await processUploadedVideo(newPath, videoThumbnailPath); - videoMetadata = result?.metadata || null; - thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath); - } catch (videoErr) { - console.error('Failed to process uploaded video, falling back to placeholder:', videoErr.message); - thumbnailPath = await generateVideoPlaceholder(newFilename); - } + + const result = await processUploadedVideo(newPath, videoThumbnailPath); + videoMetadata = result.metadata; + thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath); } else { + // Process image: generate thumbnail thumbnailPath = await generateThumbnail(newPath); } - + // Calculate relative paths const storagePath = getStoragePath(); const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath); const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root - - // Add to database with uploaded_by field + + // Add to database with uploaded_by field and media metadata let insertResult; const clientName = trx?.client?.config?.client; const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName); @@ -194,10 +192,19 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ size_bytes: file.size, uploaded_by: uploadedBy, source_origin: 'managed', - mime_type: resolvedMime, - media_type: isVideo ? 'video' : 'photo' + media_type: mediaType, + mime_type: file.mimetype }; + // Add video-specific metadata if applicable + if (isVideo && videoMetadata) { + photoData.duration = videoMetadata.duration; + photoData.video_codec = videoMetadata.videoCodec; + photoData.audio_codec = videoMetadata.audioCodec; + photoData.width = videoMetadata.width; + photoData.height = videoMetadata.height; + } + if (supportsReturning) { insertResult = await trx('photos') .insert(photoData) @@ -223,8 +230,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ id: photoId, filename: newFilename, size: file.size, - type: photoType, - media_type: isVideo ? 'video' : 'photo' + type: photoType }); console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`); diff --git a/backend/src/services/videoProcessor.js b/backend/src/services/videoProcessor.js new file mode 100644 index 0000000..fee6e13 --- /dev/null +++ b/backend/src/services/videoProcessor.js @@ -0,0 +1,182 @@ +const ffmpeg = require('fluent-ffmpeg'); +const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path; +const path = require('path'); +const fs = require('fs').promises; +const logger = require('../utils/logger'); + +// Set FFmpeg path +ffmpeg.setFfmpegPath(ffmpegPath); + +/** + * Extract video metadata using FFmpeg + * @param {string} videoPath - Path to the video file + * @returns {Promise} - Video metadata + */ +async function extractVideoMetadata(videoPath) { + return new Promise((resolve, reject) => { + ffmpeg.ffprobe(videoPath, (err, metadata) => { + if (err) { + logger.error('Error extracting video metadata', { error: err.message, videoPath }); + return reject(err); + } + + try { + const videoStream = metadata.streams.find(s => s.codec_type === 'video'); + const audioStream = metadata.streams.find(s => s.codec_type === 'audio'); + + const result = { + duration: Math.floor(metadata.format.duration || 0), + width: videoStream?.width || null, + height: videoStream?.height || null, + videoCodec: videoStream?.codec_name || null, + audioCodec: audioStream?.codec_name || null, + size: metadata.format.size || 0, + bitrate: metadata.format.bit_rate || null, + format: metadata.format.format_name || null + }; + + resolve(result); + } catch (parseErr) { + logger.error('Error parsing video metadata', { error: parseErr.message }); + reject(parseErr); + } + }); + }); +} + +/** + * Generate thumbnail from video + * @param {string} videoPath - Path to the video file + * @param {string} outputPath - Path for the output thumbnail + * @param {Object} options - Thumbnail options + * @returns {Promise} - Path to generated thumbnail + */ +async function generateVideoThumbnail(videoPath, outputPath, options = {}) { + const { + timeOffset = '00:00:01', // Take screenshot at 1 second + size = '300x300', + quality = 2 // 1-31, lower is better quality + } = options; + + return new Promise((resolve, reject) => { + ffmpeg(videoPath) + .screenshots({ + timestamps: [timeOffset], + filename: path.basename(outputPath), + folder: path.dirname(outputPath), + size: size + }) + .on('end', () => { + logger.info('Video thumbnail generated', { videoPath, outputPath }); + resolve(outputPath); + }) + .on('error', (err) => { + logger.error('Error generating video thumbnail', { error: err.message, videoPath }); + reject(err); + }); + }); +} + +/** + * Validate that a file is a valid video + * @param {string} videoPath - Path to the video file + * @returns {Promise} - True if valid video + */ +async function isValidVideo(videoPath) { + try { + const metadata = await extractVideoMetadata(videoPath); + return metadata.duration > 0 && metadata.width > 0 && metadata.height > 0; + } catch (error) { + logger.error('Video validation failed', { error: error.message, videoPath }); + return false; + } +} + +/** + * Get video duration in seconds + * @param {string} videoPath - Path to the video file + * @returns {Promise} - Duration in seconds + */ +async function getVideoDuration(videoPath) { + try { + const metadata = await extractVideoMetadata(videoPath); + return metadata.duration; + } catch (error) { + logger.error('Error getting video duration', { error: error.message }); + return 0; + } +} + +/** + * Process uploaded video - extract metadata and generate thumbnail + * @param {string} videoPath - Path to the video file + * @param {string} thumbnailPath - Path for the thumbnail + * @param {Object} options - Processing options + * @returns {Promise} - Video metadata and processing result + */ +async function processUploadedVideo(videoPath, thumbnailPath, options = {}) { + try { + // Validate video + const isValid = await isValidVideo(videoPath); + if (!isValid) { + throw new Error('Invalid video file'); + } + + // Extract metadata + const metadata = await extractVideoMetadata(videoPath); + + // Generate thumbnail + await generateVideoThumbnail(videoPath, thumbnailPath, options); + + // Verify thumbnail was created + try { + await fs.access(thumbnailPath); + } catch (err) { + throw new Error('Thumbnail generation failed'); + } + + return { + success: true, + metadata, + thumbnailPath + }; + } catch (error) { + logger.error('Error processing video', { error: error.message, videoPath }); + throw error; + } +} + +/** + * Get video thumbnail at specific time + * @param {string} videoPath - Path to video file + * @param {string} outputPath - Output path for thumbnail + * @param {number} timeInSeconds - Time in seconds to capture thumbnail + * @returns {Promise} - Path to thumbnail + */ +async function getThumbnailAtTime(videoPath, outputPath, timeInSeconds = 1) { + const hours = Math.floor(timeInSeconds / 3600); + const minutes = Math.floor((timeInSeconds % 3600) / 60); + const seconds = Math.floor(timeInSeconds % 60); + const timeOffset = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + + return generateVideoThumbnail(videoPath, outputPath, { timeOffset }); +} + +/** + * Check if file is a video based on MIME type + * @param {string} mimeType - MIME type of the file + * @returns {boolean} - True if video MIME type + */ +function isVideoMimeType(mimeType) { + return mimeType && mimeType.startsWith('video/'); +} + +module.exports = { + extractVideoMetadata, + generateVideoThumbnail, + isValidVideo, + getVideoDuration, + processUploadedVideo, + getThumbnailAtTime, + isVideoMimeType +}; diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js index 162e1a3..0a5c362 100644 --- a/backend/src/utils/fileSecurityUtils.js +++ b/backend/src/utils/fileSecurityUtils.js @@ -45,7 +45,7 @@ function isPathSafe(filePath) { } /** - * Enhanced MIME type validation + * Enhanced MIME type validation for images and videos */ const ALLOWED_IMAGE_TYPES = { 'image/jpeg': { @@ -78,24 +78,43 @@ const ALLOWED_IMAGE_TYPES = { extensions: ['.svg'], // SVG files are XML-based text files, so we skip magic number validation magicNumbers: null - }, - // Video types are included here to keep validation centralized + } +}; + +const ALLOWED_VIDEO_TYPES = { 'video/mp4': { - extensions: ['.mp4'], - magicNumbers: null - }, - 'video/quicktime': { - extensions: ['.mov', '.qt'], - magicNumbers: null + extensions: ['.mp4', '.m4v'], + magicNumbers: [ + { offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // 'ftyp' signature for MP4 + ] }, 'video/webm': { extensions: ['.webm'], magicNumbers: [ - { offset: 0, bytes: [0x1A, 0x45, 0xDF, 0xA3] } // WebM/Matroska + { offset: 0, bytes: [0x1A, 0x45, 0xDF, 0xA3] } // EBML header for WebM/MKV + ] + }, + 'video/quicktime': { + extensions: ['.mov'], + magicNumbers: [ + { offset: 4, bytes: [0x66, 0x74, 0x79, 0x70, 0x71, 0x74] } // 'ftypqt' signature for QuickTime + ] + }, + 'video/x-msvideo': { + extensions: ['.avi'], + magicNumbers: [ + { offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF + { offset: 8, bytes: [0x41, 0x56, 0x49, 0x20] } // 'AVI ' ] } }; +// Combined media types +const ALLOWED_MEDIA_TYPES = { + ...ALLOWED_IMAGE_TYPES, + ...ALLOWED_VIDEO_TYPES +}; + /** * Validate file type by MIME type and extension * @param {string} filename - The filename @@ -108,16 +127,16 @@ function validateFileType(filename, mimetype, allowedTypes) { if (!allowedTypes.includes(mimetype)) { return false; } - + // Get file extension const ext = path.extname(filename).toLowerCase(); - + // Check if extension matches the MIME type - const typeConfig = ALLOWED_IMAGE_TYPES[mimetype]; + const typeConfig = ALLOWED_MEDIA_TYPES[mimetype]; if (!typeConfig || !typeConfig.extensions.includes(ext)) { return false; } - + return true; } @@ -129,22 +148,22 @@ function validateFileType(filename, mimetype, allowedTypes) { */ async function validateFileContent(filePath, expectedMimeType) { try { - const typeConfig = ALLOWED_IMAGE_TYPES[expectedMimeType]; + const typeConfig = ALLOWED_MEDIA_TYPES[expectedMimeType]; if (!typeConfig) { return false; } - + // Skip validation for file types without magic numbers (like SVG) if (!typeConfig.magicNumbers) { return true; } - + // Read the first 20 bytes of the file (enough for most magic numbers) const buffer = Buffer.alloc(20); const fileHandle = await fs.open(filePath, 'r'); await fileHandle.read(buffer, 0, 20, 0); await fileHandle.close(); - + // Check magic numbers return typeConfig.magicNumbers.every(magic => { for (let i = 0; i < magic.bytes.length; i++) { @@ -169,36 +188,16 @@ function getSafeFilename(originalFilename) { const timestamp = Date.now(); const randomString = Math.random().toString(36).substring(2, 15); const ext = path.extname(originalFilename).toLowerCase(); - - // Validate extension - const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico']; + + // Validate extension - including both image and video extensions + const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi']; if (!validExtensions.includes(ext)) { throw new Error('Invalid file extension'); } - + return `upload_${timestamp}_${randomString}${ext}`; } -function isVideoMimeType(mimeType, filename) { - const lowerMime = (mimeType || '').toLowerCase(); - if (lowerMime.startsWith('video/')) { - return true; - } - - const ext = filename ? path.extname(filename).toLowerCase() : ''; - const videoExts = ['.mp4', '.mov', '.webm', '.m4v', '.qt']; - - if (videoExts.includes(ext)) { - return true; - } - - if (lowerMime === 'application/mp4' || lowerMime === 'application/x-m4v' || lowerMime === 'application/octet-stream') { - return videoExts.includes(ext) || true; - } - - return false; -} - /** * Create a file upload validator middleware * @param {Object} options - Validation options @@ -265,5 +264,6 @@ module.exports = { getSafeFilename, createFileUploadValidator, ALLOWED_IMAGE_TYPES, - isVideoMimeType -}; + ALLOWED_VIDEO_TYPES, + ALLOWED_MEDIA_TYPES +}; \ No newline at end of file diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index a6adaa8..4c4dc31 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef } from 'react'; -import { Upload, X, Image, Loader2, Video } from 'lucide-react'; +import { Upload, X, Image, Loader2 } from 'lucide-react'; import { Button } from '../common'; import { clsx } from 'clsx'; import { api } from '../../config/api'; @@ -51,18 +51,12 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl const handleFileSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); - const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm']; - const allowedFiles = files.filter(file => allowedTypes.includes(file.type)); - const rejectedFiles = files.filter(file => !allowedTypes.includes(file.type)); - - if (rejectedFiles.length > 0) { - toast.error( - t('upload.unsupportedFiles', 'Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).') - ); - } + const imageFiles = files.filter(file => + ['image/jpeg', 'image/png', 'image/webp'].includes(file.type) + ); // Check total file count with existing files - const totalFiles = selectedFiles.length + allowedFiles.length; + const totalFiles = selectedFiles.length + imageFiles.length; if (totalFiles > maxFilesPerUpload) { const allowedNewFiles = maxFilesPerUpload - selectedFiles.length; if (allowedNewFiles <= 0) { @@ -76,11 +70,11 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) || `Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})` ); - setSelectedFiles(prev => [...prev, ...allowedFiles.slice(0, allowedNewFiles)]); + setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]); return; } - setSelectedFiles(prev => [...prev, ...allowedFiles]); + setSelectedFiles(prev => [...prev, ...imageFiles]); }; const removeFile = (index: number) => { @@ -192,7 +186,7 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl {/* Category Selection */}