Add comprehensive video support to galleries

This commit implements full video upload, storage, streaming, and playback functionality
for the PicPeak photo sharing platform, allowing users to upload and view videos alongside
photos in galleries.

Backend Changes:
- Added video processing dependencies (fluent-ffmpeg, @ffmpeg-installer/ffmpeg)
- Created videoProcessor.js service for video metadata extraction and thumbnail generation
- Updated photoProcessor.js to handle both images and videos
- Modified adminPhotos.js to accept video files with 500MB size limit
- Enhanced gallery.js with HTTP range request support for video streaming
- Expanded fileSecurityUtils.js with video MIME types and magic number validation
- Added database migration for video support columns (media_type, duration, codecs, dimensions)

Frontend Changes:
- Updated TypeScript types to include video metadata fields
- Created VideoPlayer.tsx component with custom controls
- Modified PhotoUpload.tsx to accept video files (.mp4, .webm, .mov, .avi)
- Updated UserPhotoUpload.tsx for guest video uploads
- Enhanced PhotoGrid.tsx with video badges and duration display
- Modified PhotoLightbox.tsx to conditionally render VideoPlayer for videos

Database Schema:
- Added media_type column ('image' | 'video')
- Added mime_type, duration, video_codec, audio_codec columns
- Added width and height columns for media dimensions
- Migrated existing photos to media_type 'image'

Features:
- Video thumbnail generation from video frames
- Streaming support with range requests for efficient playback
- Video duration display on thumbnails
- Play button indicators on video items
- Full-featured video player with playback controls
- Support for MP4, WebM, MOV, and AVI formats
This commit is contained in:
Claude
2025-11-04 20:57:51 +00:00
committed by paul
parent 8c87f1537b
commit 68a9dc5749
14 changed files with 1039 additions and 509 deletions
@@ -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');
};
+159
View File
@@ -11,6 +11,7 @@
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"axios": "^1.12.2", "axios": "^1.12.2",
@@ -22,6 +23,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^6.7.0", "express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4", "form-data": "^4.0.4",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"helmet": "^7.0.0", "helmet": "^7.0.0",
@@ -1621,6 +1623,132 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@gar/promisify": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -5898,6 +6026,37 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/fn.name": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
+2
View File
@@ -15,6 +15,7 @@
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"axios": "^1.12.2", "axios": "^1.12.2",
@@ -26,6 +27,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^6.7.0", "express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4", "form-data": "^4.0.4",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"helmet": "^7.0.0", "helmet": "^7.0.0",
+102 -239
View File
@@ -4,36 +4,16 @@ 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, ensureThumbnail, generateVideoPlaceholder } = 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 { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings'); const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const router = express.Router(); const router = express.Router();
const { isVideoMimeType, validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); 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 // Configure multer for file uploads
// IMPORTANT: Using synchronous functions to prevent file corruption // IMPORTANT: Using synchronous functions to prevent file corruption
const storage = multer.diskStorage({ const storage = multer.diskStorage({
@@ -63,10 +43,12 @@ const storage = multer.diskStorage({
} }
}); });
const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({ const upload = multer({
storage: storage, storage: storage,
limits: { 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 files: 2000, // Hard safety ceiling; actual limit enforced dynamically
// Set a reasonable field size limit to prevent memory issues // Set a reasonable field size limit to prevent memory issues
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields 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 headerPairs: 2000 // Maximum number of header key-value pairs
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Accept images and common video formats with proper validation // Accept images and videos with proper validation
const allowedMimeTypes = [ const allowedMimeTypes = [
'image/jpeg', 'image/png', 'image/webp', 'image/jpeg', 'image/png', 'image/webp',
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo' 'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
]; ];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true); return cb(null, true);
} else { } else {
@@ -91,14 +73,15 @@ const upload = multer({
abortOnLimit: true abortOnLimit: true
}); });
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
// Create content validator middleware // Create content validator middleware
const validateUploadContent = createFileUploadValidator({ const validateUploadContent = createFileUploadValidator({
allowedTypes: [ allowedTypes: [
'image/jpeg', 'image/png', 'image/webp', 'image/jpeg', 'image/png', 'image/webp',
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo' 'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
], ],
// 10GB per file to accommodate large videos; overall limits enforced elsewhere maxFileSize: 500 * 1024 * 1024, // 500MB to support videos
maxFileSize: 10 * 1024 * 1024 * 1024,
validateContent: true validateContent: true
}); });
@@ -190,14 +173,26 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
} }
// Parse category_id to number if provided // Parse category_id to number if provided
const numericCategoryId = parseCategoryId(category_id); const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
const resolveCategoryName = (type) => { // Determine photo type from category_id parameter (for backwards compatibility)
if (type === 'collage') return 'collages'; let photoType = 'individual'; // default
if (type === 'video') return 'videos'; let categoryName = 'individual';
return '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 // Create final destination directory
const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug); const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug);
await fs.mkdir(finalDestPath, { recursive: true }); 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(); const trx = await db.transaction();
try { try {
const preparedBatch = batch.map((file) => { // Get initial counter for this batch based on photo type
const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream'; const existingCount = await trx('photos')
const video = isVideoMimeType(resolvedMime, file?.originalname); .where({ event_id: eventId, type: photoType })
let inferredType = video ? 'video' : 'individual'; .count('id as count')
.first();
if (!video) { let batchCounter = (parseInt(existingCount.count) || 0) + 1;
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;
});
}
const batchPhotos = []; const batchPhotos = [];
const fileRenameOperations = []; // Store rename operations to do after commit const fileRenameOperations = []; // Store rename operations to do after commit
// First pass: prepare data and move files from temp to final location // First pass: prepare data and move files from temp to final location
for (let fileIndex = 0; fileIndex < preparedBatch.length; fileIndex++) { for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
const { file, resolvedMime, isVideo, photoType } = preparedBatch[fileIndex]; const file = batch[fileIndex];
const counter = batchCounter + fileIndex;
const tempPath = file.path; // Original temp path const tempPath = file.path; // Original temp path
try { try {
@@ -266,15 +232,12 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
if (tempStats.size === 0) { if (tempStats.size === 0) {
throw new Error('File is empty - upload may have been interrupted'); throw new Error('File is empty - upload may have been interrupted');
} }
typeCounters[photoType] = (typeCounters[photoType] || 0) + 1;
const counter = typeCounters[photoType];
// Generate new filename // Generate new filename
const extension = path.extname(file.originalname); const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename( const newFilename = generatePhotoFilename(
event.event_name, event.event_name,
resolveCategoryName(photoType), categoryName,
counter, counter,
extension extension
); );
@@ -291,10 +254,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
path: relativePath, path: relativePath,
thumbnail_path: null, // Will generate after successful commit thumbnail_path: null, // Will generate after successful commit
type: photoType, type: photoType,
size_bytes: tempStats.size, // Use actual file size from stat size_bytes: tempStats.size // Use actual file size from stat
category_id: numericCategoryId,
source_origin: 'managed',
mime_type: resolvedMime
}; };
batchPhotos.push(photoData); batchPhotos.push(photoData);
@@ -304,8 +264,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
tempPath: tempPath, tempPath: tempPath,
finalPath: finalPath, finalPath: finalPath,
filename: newFilename, filename: newFilename,
photoData: photoData, photoData: photoData
isVideo
}); });
} catch (error) { } catch (error) {
console.error(`Error preparing file ${file.originalname}:`, 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 // Insert all photos in this batch
if (batchPhotos.length > 0) { 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'); 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 // Generate thumbnail with final path
let thumbnailPath = null; let thumbnailPath = null;
try { try {
thumbnailPath = operation.isVideo thumbnailPath = await generateThumbnail(operation.finalPath);
? await generateVideoPlaceholder(operation.filename)
: await generateThumbnail(operation.finalPath); // Update the database with thumbnail path
if (thumbnailPath && insertedIds[idx]) {
// Update the database with thumbnail path const photoId = insertedIds[idx]?.id || insertedIds[idx];
if (thumbnailPath && insertedIds[idx]) { await db('photos')
const photoId = insertedIds[idx]?.id || insertedIds[idx]; .where({ id: photoId })
await db('photos') .update({ thumbnail_path: thumbnailPath });
.where({ id: photoId }) }
.update({ thumbnail_path: thumbnailPath }); } catch (thumbError) {
} console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
} catch (thumbError) { }
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
}
// Add to successful uploads // Add to successful uploads
uploadedPhotos.push({ uploadedPhotos.push({
id: insertedIds[idx]?.id || insertedIds[idx], id: insertedIds[idx]?.id || insertedIds[idx],
filename: operation.filename, filename: operation.filename,
size: operation.photoData.size_bytes, size: operation.photoData.size_bytes,
category_id: operation.photoData.category_id, category_id: operation.photoData.category_id
type: operation.photoData.type,
mime_type: operation.photoData.mime_type
}); });
} catch (moveError) { } catch (moveError) {
console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, 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 // Prepare response
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0); const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
const response = { const response = {
message: `Successfully uploaded ${uploadedPhotos.length} files`, message: `Successfully uploaded ${uploadedPhotos.length} photos`,
photos: uploadedPhotos, photos: uploadedPhotos,
totalFiles: totalAttempted, totalFiles: totalAttempted,
successCount: uploadedPhotos.length, successCount: uploadedPhotos.length,
@@ -441,7 +396,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Include error details if any files failed // Include error details if any files failed
if (totalInvalidFiles.length > 0) { if (totalInvalidFiles.length > 0) {
response.errors = totalInvalidFiles; 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); 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 // Delete thumbnail if exists
if (photo.thumbnail_path) { if (photo.thumbnail_path) {
const thumbPath = path.join(storagePath, photo.thumbnail_path); const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path);
try { try {
// Check if file exists before attempting to delete // Check if file exists before attempting to delete
await fs.access(thumbPath); await fs.access(thumbPath);
@@ -535,44 +490,24 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
} }
// Prepare update data // Prepare update data
const updateData = { const updateData = {};
updated_at: new Date()
};
// Handle type-based categories ('individual' or 'collage') // Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field // These are string values that map to the photo.type field
if (category_id === 'individual' || category_id === 'collage') { if (category_id === 'individual' || category_id === 'collage') {
updateData.type = category_id; updateData.type = category_id;
updateData.category_id = null; // Clear legacy 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 { } else {
// Handle numeric category IDs from photo_categories table // Handle legacy numeric category IDs
const numericCategoryId = parseInt(category_id, 10); updateData.category_id = category_id || null;
if (!isNaN(numericCategoryId)) {
updateData.category_id = numericCategoryId;
} else {
updateData.category_id = null;
}
} }
// Update photo // Update photo
const normalizedCategoryId = parseCategoryId(category_id);
await db('photos') await db('photos')
.where({ id: photoId, event_id: eventId }) .where({ id: photoId })
.update(updateData); .update(updateData);
// Fetch and return updated photo for confirmation res.json({ message: 'Photo updated successfully' });
const updatedPhoto = await db('photos')
.where({ id: photoId })
.first();
res.json({
message: 'Photo updated successfully',
photo: updatedPhoto
});
} catch (error) { } catch (error) {
console.error('Error updating photo:', error); console.error('Error updating photo:', error);
res.status(500).json({ error: 'Failed to update photo' }); res.status(500).json({ error: 'Failed to update photo' });
@@ -652,44 +587,33 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
try { try {
const { eventId } = req.params; const { eventId } = req.params;
const { photoIds, updates } = req.body; const { photoIds, updates } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) { if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' }); return res.status(400).json({ error: 'Invalid photo IDs' });
} }
// Verify all photos belong to the event // Verify all photos belong to the event
const photoCount = await db('photos') const photoCount = await db('photos')
.whereIn('id', photoIds) .whereIn('id', photoIds)
.where('event_id', eventId) .where('event_id', eventId)
.count('id as count') .count('id as count')
.first(); .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' }); return res.status(400).json({ error: 'Some photos do not belong to this event' });
} }
// Prepare update data // Prepare update data
const updateData = { const updateData = {};
updated_at: new Date()
};
if (updates.category_id !== undefined) { if (updates.category_id !== undefined) {
// Handle type-based categories ('individual' or 'collage') // Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field // These are string values that map to the photo.type field
if (updates.category_id === 'individual' || updates.category_id === 'collage') { if (updates.category_id === 'individual' || updates.category_id === 'collage') {
updateData.type = updates.category_id; updateData.type = updates.category_id;
updateData.category_id = null; // Clear legacy category_id updateData.category_id = null; // Clear legacy category_id
} else if (updates.category_id === null) {
// Explicitly clear category
updateData.category_id = null;
} else { } else {
// Handle numeric category IDs from photo_categories table // Handle legacy numeric category IDs
const numericCategoryId = parseInt(updates.category_id, 10); updateData.category_id = updates.category_id || null;
if (!isNaN(numericCategoryId)) {
updateData.category_id = numericCategoryId;
} else {
updateData.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) => { router.get('/:eventId/photos', adminAuth, async (req, res) => {
try { try {
const { eventId } = req.params; 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') let query = db('photos')
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
.where({ 'photos.event_id': eventId }) .where({ 'photos.event_id': eventId })
.select( .select('photos.*');
'photos.*',
'pc.name as category_display_name',
'pc.slug as category_display_slug'
);
// Filter by type (individual/collage) - category_id maps to type // Filter by type (individual/collage) - category_id maps to type
if (category_id !== undefined) { if (category_id !== undefined) {
if (category_id === '') { if (category_id === '' || category_id === '0') {
// No filter when empty string is provided // For backwards compatibility, empty category means no filter
} else if (category_id === '0') { // Don't filter anything
query = query.whereNull('photos.category_id');
} else if (/^\d+$/.test(category_id)) {
query = query.where('photos.category_id', parseInt(category_id, 10));
} else if (category_id === 'individual' || category_id === 'collage') { } else if (category_id === 'individual' || category_id === 'collage') {
query = query.where({ 'photos.type': category_id }); query = query.where({ 'photos.type': category_id });
} }
@@ -769,20 +685,6 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
if (type) { if (type) {
query = query.where({ 'photos.type': 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 // Search by filename
if (search) { if (search) {
@@ -799,11 +701,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
} }
const photos = await query.orderBy(orderByColumn, order); const photos = await query.orderBy(orderByColumn, order);
if (photos.length === 0) {
return res.json({ photos: [] });
}
// Get comment counts separately // Get comment counts separately
const commentCounts = await db('photo_feedback') const commentCounts = await db('photo_feedback')
.whereIn('photo_id', photos.map(p => p.id)) .whereIn('photo_id', photos.map(p => p.id))
@@ -820,37 +718,26 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
}); });
res.json({ res.json({
photos: photos.map(photo => { photos: photos.map(photo => ({
const mediaType = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video' ? 'video' : 'photo'; id: photo.id,
const categoryName = photo.category_display_name filename: photo.filename,
|| (photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages'); // Use the correct admin photos router base for serving images
const normalizedCategoryId = photo.category_id !== null && photo.category_id !== undefined url: `/admin/photos/${eventId}/photo/${photo.id}`,
? (Number.isNaN(Number(photo.category_id)) ? photo.category_id : Number(photo.category_id)) // Always expose a thumbnail URL; backend will generate on demand if missing
: null; thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type,
return ({ category_id: photo.type,
id: photo.id, category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
filename: photo.filename, category_slug: photo.type,
// Use the correct admin photos router base for serving images size: photo.size_bytes,
url: `/admin/photos/${eventId}/photo/${photo.id}`, uploaded_at: photo.uploaded_at,
// Always expose a thumbnail URL; backend will generate on demand if missing // Feedback data
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`, has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
type: photo.type, average_rating: photo.average_rating || 0,
category_id: normalizedCategoryId, comment_count: commentMap[photo.id] || 0,
mime_type: photo.mime_type, like_count: photo.like_count || 0,
media_type: mediaType, favorite_count: photo.favorite_count || 0
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
});
})
}); });
} catch (error) { } catch (error) {
console.error('Error fetching photos:', 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' }); 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 // 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('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); 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' }); 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 // Ensure thumbnail exists and is valid, regenerate if needed
let thumbnailPath = photo.thumbnail_path; const thumbnailPath = await ensureThumbnail(photo);
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);
}
if (!thumbnailPath) { if (!thumbnailPath) {
console.error(`Failed to generate thumbnail for photo ${photoId}`); console.error(`Failed to generate thumbnail for photo ${photoId}`);
+81 -103
View File
@@ -10,8 +10,6 @@ const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver'); const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); 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 // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
@@ -250,17 +248,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
.distinct('type') .distinct('type')
.orderBy('type', 'asc'); .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 // Convert types to category-like objects
const categories = categoryResults.map(result => ({ const categories = categoryResults.map(result => ({
id: result.type, id: result.type,
name: resolveCategoryName(result.type), name: result.type === 'individual' ? 'Individual Photos' : 'Collages',
slug: result.type, slug: result.type,
is_global: false is_global: false
})); }));
@@ -301,13 +292,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
}, },
categories: categories, categories: categories,
photos: photos.map(photo => { photos: photos.map(photo => {
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
const mediaType = isVideo ? 'video' : 'photo';
const useJwtUrl = isVideo || (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
const photoUrl = useJwtUrl ? const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}` : `/api/gallery/${req.params.slug}/photo/${photo.id}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`; `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
const categoryName = resolveCategoryName(photo.type, photo.mime_type, photo.filename);
return { return {
id: photo.id, 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}}`, download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type, type: photo.type,
category_id: photo.type, category_id: photo.type,
category_name: categoryName, category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
category_slug: photo.type, category_slug: photo.type,
size: photo.size_bytes, size: photo.size_bytes,
uploaded_at: photo.uploaded_at, uploaded_at: photo.uploaded_at,
media_type: mediaType,
mime_type: photo.mime_type,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating // Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl && !isVideo, requires_token: !useJwtUrl,
// Feedback data // Feedback data
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
average_rating: photo.average_rating || 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' }); return res.status(404).json({ error: 'Photo not found' });
} }
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
// Update download count // Update download count
await db('photos').where('id', photoId).increment('download_count', 1); 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 // Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled && !isVideo) { if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send // Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
@@ -401,9 +386,6 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send original file // Send original file
if (isVideo) {
res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' });
}
res.download(filePath, photo.filename, (downloadError) => { res.download(filePath, photo.filename, (downloadError) => {
if (downloadError) { if (downloadError) {
logger.error('Error streaming gallery download', { logger.error('Error streaming gallery download', {
@@ -481,16 +463,14 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
let archiveName; let archiveName;
if (hasMultipleTypes) { if (hasMultipleTypes) {
// Use photo type as folder // 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); archiveName = path.join(folderName, photo.filename);
} else { } else {
// No folders, just the filename // No folders, just the filename
archiveName = photo.filename; archiveName = photo.filename;
} }
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); if (watermarkSettings && watermarkSettings.enabled) {
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
try { try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: archiveName }); archive.append(watermarkedBuffer, { name: archiveName });
@@ -585,9 +565,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
try { try {
const filePath = resolvePhotoFilePath(req.event, photo); const filePath = resolvePhotoFilePath(req.event, photo);
const name = photo.filename || `photo-${photo.id}.jpg`; const name = photo.filename || `photo-${photo.id}.jpg`;
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename); if (watermarkSettings && watermarkSettings.enabled) {
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
try { try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name }); archive.append(watermarkedBuffer, { name });
@@ -632,43 +610,41 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
// View single photo (with watermark if enabled) // View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId', router.get('/:slug/photo/:photoId',
verifyGalleryAccess, verifyGalleryAccess,
async (req, res) => { async (req, res) => {
try { try {
const { photoId } = req.params; 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') const photo = await db('photos')
.where({ id: numericPhotoId, event_id: req.event.id }) .where({ id: photoId, event_id: req.event.id })
.first(); .first();
if (!photo) { if (!photo) {
return res.status(404).json({ error: 'Photo not found' }); 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 // Check protection level - basic and standard protection allow direct JWT access
const protectionLevel = req.event.protection_level || 'standard'; 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 // For enhanced/maximum protection, redirect to secure endpoint
return res.status(302).json({ return res.status(302).json({
error: 'Secure access required', error: 'Secure access required',
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`, secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
photoId: photoId photoId: photoId
}); });
} }
// Resolve the absolute file path for this photo, supporting both managed and external reference modes // Resolve the absolute file path for this photo, supporting both managed and external reference modes
const { resolvePhotoFilePath } = require('../services/photoResolver'); const { resolvePhotoFilePath } = require('../services/photoResolver');
const filePath = resolvePhotoFilePath(req.event, photo); const filePath = resolvePhotoFilePath(req.event, photo);
// Log access - temporarily disabled for debugging // Log access - temporarily disabled for debugging
// await secureImageService.logImageAccess( // await secureImageService.logImageAccess(
// photoId, // photoId,
@@ -676,20 +652,61 @@ router.get('/:slug/photo/:photoId',
// req.clientInfo, // req.clientInfo,
// 'view_basic' // '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 // Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled && !isVideo) { if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send // Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
res.set({ res.set({
'Content-Type': photo.mime_type || 'image/jpeg', 'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes 'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
'X-Protection-Level': 'basic' 'X-Protection-Level': 'basic'
}); });
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send original file with basic protection headers // Send original file with basic protection headers
@@ -699,9 +716,6 @@ router.get('/:slug/photo/:photoId',
}); });
// Ensure absolute path for res.sendFile // Ensure absolute path for res.sendFile
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
if (isVideo) {
res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' });
}
res.sendFile(absolutePath); res.sendFile(absolutePath);
} }
} catch (error) { } catch (error) {
@@ -722,62 +736,32 @@ router.get('/:slug/thumbnail/:photoId',
async (req, res) => { async (req, res) => {
try { try {
const { photoId } = req.params; 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') const photo = await db('photos')
.where({ id: numericPhotoId, event_id: req.event.id }) .where({ id: photoId, event_id: req.event.id })
.first(); .first();
if (!photo) { if (!photo || !photo.thumbnail_path) {
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) {
return res.status(404).json({ error: 'Thumbnail not found' }); return res.status(404).json({ error: 'Thumbnail not found' });
} }
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
// Check if file exists // Check if file exists
const fs = require('fs').promises; const fs = require('fs').promises;
try { try {
await fs.access(thumbFilePath); await fs.access(thumbPath);
} catch (error) { } catch (error) {
return res.status(404).json({ error: 'Thumbnail file not found' }); return res.status(404).json({ error: 'Thumbnail file not found' });
} }
// Log thumbnail access // Log thumbnail access
try { await secureImageService.logImageAccess(
await secureImageService.logImageAccess( photoId,
numericPhotoId, req.event.id,
req.event.id, req.clientInfo,
req.clientInfo, 'thumbnail'
'thumbnail' );
);
} catch (logErr) {
logger.warn('Thumbnail access log failed', { photoId, eventId: req.event.id, error: logErr.message });
}
// Set appropriate headers with enhanced security // Set appropriate headers with enhanced security
res.set({ res.set({
@@ -789,14 +773,8 @@ router.get('/:slug/thumbnail/:photoId',
}); });
// Send file // Send file
res.sendFile(path.resolve(thumbFilePath)); res.sendFile(path.resolve(thumbPath));
} catch (error) { } 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:', { logger.error('Error serving thumbnail:', {
error: error.message, error: error.message,
photoId: req.params.photoId, photoId: req.params.photoId,
+33 -27
View File
@@ -1,11 +1,9 @@
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { db } = require('../database/db'); const { db } = require('../database/db');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor'); const { generateThumbnail } = require('./imageProcessor');
const { processUploadedVideo } = require('./videoProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { isVideoMimeType } = require('../utils/fileSecurityUtils'); const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
const mime = require('mime-types');
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); 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(); const trx = await db.transaction();
try { 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 // Count existing photos to generate sequence number
let counter = 1; 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 categoryId is provided and matches photo types, use it as type
if (!isVideo && categoryId === 'collage') { if (categoryId === 'collage') {
photoType = 'collage'; photoType = 'collage';
} }
@@ -96,7 +91,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
// Generate new filename // Generate new filename
const extension = path.extname(file.originalname); const extension = path.extname(file.originalname);
const categoryName = photoType === 'collage' ? 'collages' : (isVideo ? 'videos' : 'individual'); const categoryName = photoType === 'collage' ? 'collages' : 'individual';
const newFilename = generatePhotoFilename( const newFilename = generatePhotoFilename(
event.event_name, event.event_name,
categoryName, categoryName,
@@ -156,31 +151,34 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
} }
} }
// Generate thumbnail and metadata // Determine if this is a video or image
let thumbnailPath = null; const isVideo = isVideoMimeType(file.mimetype);
const mediaType = isVideo ? 'video' : 'image';
// Generate thumbnail and extract metadata
let thumbnailPath;
let videoMetadata = null; let videoMetadata = null;
if (isVideo) { if (isVideo) {
// Process video: extract metadata and generate thumbnail
const thumbnailDir = path.join(getStoragePath(), 'thumbnails'); const thumbnailDir = path.join(getStoragePath(), 'thumbnails');
await fs.mkdir(thumbnailDir, { recursive: true }); await fs.mkdir(thumbnailDir, { recursive: true });
const videoThumbnailPath = path.join(thumbnailDir, `thumb_${newFilename.replace(/\.[^.]+$/, '.jpg')}`); const videoThumbnailPath = path.join(thumbnailDir, `thumb_${newFilename.replace(/\.[^.]+$/, '.jpg')}`);
try {
const result = await processUploadedVideo(newPath, videoThumbnailPath); const result = await processUploadedVideo(newPath, videoThumbnailPath);
videoMetadata = result?.metadata || null; videoMetadata = result.metadata;
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath); thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
} catch (videoErr) {
console.error('Failed to process uploaded video, falling back to placeholder:', videoErr.message);
thumbnailPath = await generateVideoPlaceholder(newFilename);
}
} else { } else {
// Process image: generate thumbnail
thumbnailPath = await generateThumbnail(newPath); thumbnailPath = await generateThumbnail(newPath);
} }
// Calculate relative paths // Calculate relative paths
const storagePath = getStoragePath(); const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath); const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root 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; let insertResult;
const clientName = trx?.client?.config?.client; const clientName = trx?.client?.config?.client;
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName); const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
@@ -194,10 +192,19 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
size_bytes: file.size, size_bytes: file.size,
uploaded_by: uploadedBy, uploaded_by: uploadedBy,
source_origin: 'managed', source_origin: 'managed',
mime_type: resolvedMime, media_type: mediaType,
media_type: isVideo ? 'video' : 'photo' 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) { if (supportsReturning) {
insertResult = await trx('photos') insertResult = await trx('photos')
.insert(photoData) .insert(photoData)
@@ -223,8 +230,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
id: photoId, id: photoId,
filename: newFilename, filename: newFilename,
size: file.size, size: file.size,
type: photoType, type: photoType
media_type: isVideo ? 'video' : 'photo'
}); });
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`); console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
+182
View File
@@ -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<Object>} - 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<string>} - 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<boolean>} - 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<number>} - 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<Object>} - 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<string>} - 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
};
+44 -44
View File
@@ -45,7 +45,7 @@ function isPathSafe(filePath) {
} }
/** /**
* Enhanced MIME type validation * Enhanced MIME type validation for images and videos
*/ */
const ALLOWED_IMAGE_TYPES = { const ALLOWED_IMAGE_TYPES = {
'image/jpeg': { 'image/jpeg': {
@@ -78,24 +78,43 @@ const ALLOWED_IMAGE_TYPES = {
extensions: ['.svg'], extensions: ['.svg'],
// SVG files are XML-based text files, so we skip magic number validation // SVG files are XML-based text files, so we skip magic number validation
magicNumbers: null magicNumbers: null
}, }
// Video types are included here to keep validation centralized };
const ALLOWED_VIDEO_TYPES = {
'video/mp4': { 'video/mp4': {
extensions: ['.mp4'], extensions: ['.mp4', '.m4v'],
magicNumbers: null magicNumbers: [
}, { offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // 'ftyp' signature for MP4
'video/quicktime': { ]
extensions: ['.mov', '.qt'],
magicNumbers: null
}, },
'video/webm': { 'video/webm': {
extensions: ['.webm'], extensions: ['.webm'],
magicNumbers: [ 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 * Validate file type by MIME type and extension
* @param {string} filename - The filename * @param {string} filename - The filename
@@ -108,16 +127,16 @@ function validateFileType(filename, mimetype, allowedTypes) {
if (!allowedTypes.includes(mimetype)) { if (!allowedTypes.includes(mimetype)) {
return false; return false;
} }
// Get file extension // Get file extension
const ext = path.extname(filename).toLowerCase(); const ext = path.extname(filename).toLowerCase();
// Check if extension matches the MIME type // 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)) { if (!typeConfig || !typeConfig.extensions.includes(ext)) {
return false; return false;
} }
return true; return true;
} }
@@ -129,22 +148,22 @@ function validateFileType(filename, mimetype, allowedTypes) {
*/ */
async function validateFileContent(filePath, expectedMimeType) { async function validateFileContent(filePath, expectedMimeType) {
try { try {
const typeConfig = ALLOWED_IMAGE_TYPES[expectedMimeType]; const typeConfig = ALLOWED_MEDIA_TYPES[expectedMimeType];
if (!typeConfig) { if (!typeConfig) {
return false; return false;
} }
// Skip validation for file types without magic numbers (like SVG) // Skip validation for file types without magic numbers (like SVG)
if (!typeConfig.magicNumbers) { if (!typeConfig.magicNumbers) {
return true; return true;
} }
// Read the first 20 bytes of the file (enough for most magic numbers) // Read the first 20 bytes of the file (enough for most magic numbers)
const buffer = Buffer.alloc(20); const buffer = Buffer.alloc(20);
const fileHandle = await fs.open(filePath, 'r'); const fileHandle = await fs.open(filePath, 'r');
await fileHandle.read(buffer, 0, 20, 0); await fileHandle.read(buffer, 0, 20, 0);
await fileHandle.close(); await fileHandle.close();
// Check magic numbers // Check magic numbers
return typeConfig.magicNumbers.every(magic => { return typeConfig.magicNumbers.every(magic => {
for (let i = 0; i < magic.bytes.length; i++) { for (let i = 0; i < magic.bytes.length; i++) {
@@ -169,36 +188,16 @@ function getSafeFilename(originalFilename) {
const timestamp = Date.now(); const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15); const randomString = Math.random().toString(36).substring(2, 15);
const ext = path.extname(originalFilename).toLowerCase(); const ext = path.extname(originalFilename).toLowerCase();
// Validate extension // Validate extension - including both image and video extensions
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico']; const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi'];
if (!validExtensions.includes(ext)) { if (!validExtensions.includes(ext)) {
throw new Error('Invalid file extension'); throw new Error('Invalid file extension');
} }
return `upload_${timestamp}_${randomString}${ext}`; 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 * Create a file upload validator middleware
* @param {Object} options - Validation options * @param {Object} options - Validation options
@@ -265,5 +264,6 @@ module.exports = {
getSafeFilename, getSafeFilename,
createFileUploadValidator, createFileUploadValidator,
ALLOWED_IMAGE_TYPES, ALLOWED_IMAGE_TYPES,
isVideoMimeType ALLOWED_VIDEO_TYPES,
}; ALLOWED_MEDIA_TYPES
};
+12 -24
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef } from 'react'; 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 { Button } from '../common';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { api } from '../../config/api'; import { api } from '../../config/api';
@@ -51,18 +51,12 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm']; const imageFiles = files.filter(file =>
const allowedFiles = files.filter(file => allowedTypes.includes(file.type)); ['image/jpeg', 'image/png', 'image/webp'].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).')
);
}
// Check total file count with existing files // Check total file count with existing files
const totalFiles = selectedFiles.length + allowedFiles.length; const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > maxFilesPerUpload) { if (totalFiles > maxFilesPerUpload) {
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length; const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
if (allowedNewFiles <= 0) { if (allowedNewFiles <= 0) {
@@ -76,11 +70,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) || t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
`Only ${allowedNewFiles} more files can be added (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; return;
} }
setSelectedFiles(prev => [...prev, ...allowedFiles]); setSelectedFiles(prev => [...prev, ...imageFiles]);
}; };
const removeFile = (index: number) => { const removeFile = (index: number) => {
@@ -192,7 +186,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{/* Category Selection */} {/* Category Selection */}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-2"> <label className="block text-sm font-medium text-neutral-700 mb-2">
{t('upload.mediaCategory', 'Media category')} {t('upload.photoCategory')}
</label> </label>
<select <select
value={selectedCategoryId || ''} value={selectedCategoryId || ''}
@@ -222,7 +216,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{t('upload.clickToUpload')} {t('upload.clickToUpload')}
</p> </p>
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-500">
{t('upload.fileRequirementsMedia', { limit: maxFilesPerUpload }) || t('upload.fileRequirements', { limit: maxFilesPerUpload })} {t('upload.fileRequirements', { limit: maxFilesPerUpload })}
</p> </p>
<p <p
className={clsx( className={clsx(
@@ -242,7 +236,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm" accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
onChange={handleFileSelect} onChange={handleFileSelect}
className="hidden" className="hidden"
/> />
@@ -261,11 +255,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg" className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{file.type.startsWith('video/') ? ( <Image className="w-5 h-5 text-neutral-400" />
<Video className="w-5 h-5 text-neutral-400" />
) : (
<Image className="w-5 h-5 text-neutral-400" />
)}
<div> <div>
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs"> <p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
{file.name} {file.name}
@@ -298,9 +288,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
disabled={selectedFiles.length === 0 || isUploading} disabled={selectedFiles.length === 0 || isUploading}
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />} leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
> >
{isUploading {isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`}
? t('upload.uploading')
: t('upload.uploadAction', { count: selectedFiles.length }) || `Upload ${selectedFiles.length} files`}
</Button> </Button>
</div> </div>
+17 -6
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, Package, MessageSquare, Star } from 'lucide-react'; import { Download, Maximize2, Check, Package, MessageSquare, Star, Play } from 'lucide-react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import { toast as toastify } from 'react-toastify'; import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -336,14 +336,25 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
</div> </div>
)} )}
{/* Photo type badge */} {/* Media type badges */}
{photo.type === 'collage' && ( <div className="absolute bottom-2 left-2 flex gap-2">
<div className="absolute bottom-2 left-2"> {photo.type === 'collage' && (
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded"> <span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
Collage Collage
</span> </span>
</div> )}
)} {photo.media_type === 'video' && (
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded flex items-center gap-1">
<Play className="w-3 h-3" fill="white" />
Video
{photo.duration && (
<span className="ml-1">
{Math.floor(photo.duration / 60)}:{String(photo.duration % 60).padStart(2, '0')}
</span>
)}
</span>
)}
</div>
</> </>
) : ( ) : (
<div className="skeleton aspect-square w-full" /> <div className="skeleton aspect-square w-full" />
@@ -3,10 +3,11 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import type { Photo } from '../../types'; import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery'; import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage, AuthenticatedVideo } from '../common'; import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback'; import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService } from '../../services/feedback.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal'; import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { VideoPlayer } from './VideoPlayer';
interface PhotoLightboxProps { interface PhotoLightboxProps {
photos: Photo[]; photos: Photo[];
@@ -64,11 +65,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const downloadPhotoMutation = useDownloadPhoto(); const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex]; const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
? (currentPhoto.media_type === 'video' ||
(currentPhoto.mime_type && currentPhoto.mime_type.startsWith('video/')) ||
currentPhoto.type === 'video')
: false;
// DevTools protection for the lightbox when enhanced protection is enabled // DevTools protection for the lightbox when enhanced protection is enabled
useDevToolsProtection({ useDevToolsProtection({
@@ -366,31 +362,27 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isVideo && ( <button
<> onClick={handleZoomOut}
<button disabled={zoom <= 1}
onClick={handleZoomOut} className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={zoom <= 1} aria-label="Zoom out"
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed" >
aria-label="Zoom out" <ZoomOut className="w-5 h-5 text-white" />
> </button>
<ZoomOut className="w-5 h-5 text-white" /> <span className="text-white text-sm w-12 text-center">
</button> {Math.round(zoom * 100)}%
<span className="text-white text-sm w-12 text-center"> </span>
{Math.round(zoom * 100)}% <button
</span> onClick={handleZoomIn}
<button disabled={zoom >= 3}
onClick={handleZoomIn} className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={zoom >= 3} aria-label="Zoom in"
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed" >
aria-label="Zoom in" <ZoomIn className="w-5 h-5 text-white" />
> </button>
<ZoomIn className="w-5 h-5 text-white" />
</button> <div className="w-px h-6 bg-white/20 mx-2" />
<div className="w-px h-6 bg-white/20 mx-2" />
</>
)}
{allowDownloads && ( {allowDownloads && (
<button <button
@@ -457,29 +449,29 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div> </div>
</div> </div>
{/* Image container */} {/* Image/Video container */}
<div <div
className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0" className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
onClick={isVideo ? undefined : handleImageClick} onClick={currentPhoto.media_type === 'video' ? undefined : handleImageClick}
onMouseDown={isVideo ? undefined : handleMouseDown} onMouseDown={currentPhoto.media_type === 'video' ? undefined : handleMouseDown}
onMouseMove={isVideo ? undefined : handleMouseMove} onMouseMove={currentPhoto.media_type === 'video' ? undefined : handleMouseMove}
onMouseUp={isVideo ? undefined : handleMouseUp} onMouseUp={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
onMouseLeave={isVideo ? undefined : handleMouseUp} onMouseLeave={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
onTouchStart={isVideo ? undefined : handleTouchStart} onTouchStart={currentPhoto.media_type === 'video' ? undefined : handleTouchStart}
onTouchMove={isVideo ? undefined : handleTouchMove} onTouchMove={currentPhoto.media_type === 'video' ? undefined : handleTouchMove}
onTouchEnd={isVideo ? undefined : handleTouchEnd} onTouchEnd={currentPhoto.media_type === 'video' ? undefined : handleTouchEnd}
style={{ style={{
cursor: isVideo ? 'default' : zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default', cursor: currentPhoto.media_type === 'video' ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'),
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0, right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}} }}
> >
{isVideo ? ( {currentPhoto.media_type === 'video' ? (
<AuthenticatedVideo <VideoPlayer
src={currentPhoto.url} src={currentPhoto.url}
fallbackSrc={currentPhoto.thumbnail_url || undefined} poster={currentPhoto.thumbnail_url}
className="max-w-full max-h-full object-contain bg-black" className="max-w-full max-h-full"
slug={slug} controls={true}
poster={currentPhoto.thumbnail_url || undefined} autoPlay={false}
/> />
) : ( ) : (
<AuthenticatedImage <AuthenticatedImage
@@ -509,24 +501,24 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'} detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => { onProtectionViolation={(violationType) => {
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`); console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
// Track analytics // Track analytics
if (typeof window !== 'undefined' && (window as any).umami) { if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', { (window as any).umami.track('lightbox_protection_violation', {
photoId: currentPhoto.id, photoId: currentPhoto.id,
violationType, violationType,
protectionLevel, protectionLevel,
zoom zoom
}); });
} }
// For maximum protection, close lightbox on violation // For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' && if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) { ['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
onClose(); onClose();
} }
}} }}
/> />
)} )}
</div> </div>
@@ -143,7 +143,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
type="file" type="file"
className="hidden" className="hidden"
multiple multiple
accept="image/jpeg,image/png,image/webp" accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
onChange={handleFileSelect} onChange={handleFileSelect}
disabled={uploading} disabled={uploading}
/> />
@@ -0,0 +1,232 @@
import React, { useRef, useState, useEffect } from 'react';
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from 'lucide-react';
interface VideoPlayerProps {
src: string;
poster?: string;
className?: string;
autoPlay?: boolean;
muted?: boolean;
loop?: boolean;
controls?: boolean;
width?: string | number;
height?: string | number;
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
src,
poster,
className = '',
autoPlay = false,
muted = false,
loop = false,
controls = true,
width = '100%',
height = 'auto'
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(muted);
const [isFullscreen, setIsFullscreen] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [showControls, setShowControls] = useState(true);
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
setProgress((video.currentTime / video.duration) * 100 || 0);
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
};
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleEnded = () => setIsPlaying(false);
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('loadedmetadata', handleLoadedMetadata);
video.addEventListener('play', handlePlay);
video.addEventListener('pause', handlePause);
video.addEventListener('ended', handleEnded);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('play', handlePlay);
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
};
}, []);
const togglePlayPause = () => {
const video = videoRef.current;
if (!video) return;
if (isPlaying) {
video.pause();
} else {
video.play();
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(!isMuted);
};
const toggleFullscreen = async () => {
const video = videoRef.current;
if (!video) return;
try {
if (!isFullscreen) {
if (video.requestFullscreen) {
await video.requestFullscreen();
}
setIsFullscreen(true);
} else {
if (document.exitFullscreen) {
await document.exitFullscreen();
}
setIsFullscreen(false);
}
} catch (error) {
console.error('Error toggling fullscreen:', error);
}
};
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const video = videoRef.current;
if (!video) return;
const rect = e.currentTarget.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
video.currentTime = pos * video.duration;
};
const formatTime = (seconds: number): string => {
if (!seconds || isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleMouseMove = () => {
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
controlsTimeoutRef.current = setTimeout(() => {
if (isPlaying) {
setShowControls(false);
}
}, 3000);
};
useEffect(() => {
return () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
};
}, []);
return (
<div
className={`relative bg-black rounded-lg overflow-hidden ${className}`}
style={{ width, height: height === 'auto' ? undefined : height }}
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
<video
ref={videoRef}
src={src}
poster={poster}
autoPlay={autoPlay}
muted={muted}
loop={loop}
className="w-full h-full object-contain"
playsInline
onClick={togglePlayPause}
/>
{controls && (
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
showControls ? 'opacity-100' : 'opacity-0'
}`}
>
{/* Progress bar */}
<div
className="w-full h-1 bg-gray-600 rounded-full cursor-pointer mb-3"
onClick={handleProgressClick}
>
<div
className="h-full bg-white rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
{/* Controls */}
<div className="flex items-center justify-between text-white">
<div className="flex items-center gap-3">
<button
onClick={togglePlayPause}
className="hover:bg-white/20 p-2 rounded-full transition-colors"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
</button>
<button
onClick={toggleMute}
className="hover:bg-white/20 p-2 rounded-full transition-colors"
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
<span className="text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
<button
onClick={toggleFullscreen}
className="hover:bg-white/20 p-2 rounded-full transition-colors"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
</button>
</div>
</div>
)}
{/* Play button overlay when paused */}
{!isPlaying && showControls && (
<div className="absolute inset-0 flex items-center justify-center">
<button
onClick={togglePlayPause}
className="bg-black/50 hover:bg-black/70 text-white rounded-full p-6 transition-colors"
aria-label="Play"
>
<Play size={48} fill="white" />
</button>
</div>
)}
</div>
);
};
export default VideoPlayer;
+8
View File
@@ -63,6 +63,14 @@ export interface Photo {
category_slug?: string; category_slug?: string;
size: number; size: number;
uploaded_at: string; uploaded_at: string;
// Media type fields
media_type?: 'image' | 'video';
mime_type?: string;
duration?: number; // Duration in seconds for videos
video_codec?: string;
audio_codec?: string;
width?: number;
height?: number;
// Feedback fields // Feedback fields
has_feedback?: boolean; has_feedback?: boolean;
average_rating?: number; average_rating?: number;