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:
@@ -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');
|
||||
};
|
||||
Generated
+159
@@ -11,6 +11,7 @@
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.12.2",
|
||||
@@ -22,6 +23,7 @@
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
@@ -1621,6 +1623,132 @@
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg-installer/darwin-arm64": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz",
|
||||
"integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@ffmpeg-installer/darwin-x64": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz",
|
||||
"integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "LGPL-2.1",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@ffmpeg-installer/ffmpeg": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz",
|
||||
"integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==",
|
||||
"license": "LGPL-2.1",
|
||||
"optionalDependencies": {
|
||||
"@ffmpeg-installer/darwin-arm64": "4.1.5",
|
||||
"@ffmpeg-installer/darwin-x64": "4.1.0",
|
||||
"@ffmpeg-installer/linux-arm": "4.1.3",
|
||||
"@ffmpeg-installer/linux-arm64": "4.1.4",
|
||||
"@ffmpeg-installer/linux-ia32": "4.1.0",
|
||||
"@ffmpeg-installer/linux-x64": "4.1.0",
|
||||
"@ffmpeg-installer/win32-ia32": "4.1.0",
|
||||
"@ffmpeg-installer/win32-x64": "4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ffmpeg-installer/linux-arm": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz",
|
||||
"integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "GPLv3",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ffmpeg-installer/linux-arm64": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz",
|
||||
"integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "GPLv3",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ffmpeg-installer/linux-ia32": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz",
|
||||
"integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "GPLv3",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ffmpeg-installer/linux-x64": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz",
|
||||
"integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"hasInstallScript": true,
|
||||
"license": "GPLv3",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@ffmpeg-installer/win32-ia32": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz",
|
||||
"integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "GPLv3",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@ffmpeg-installer/win32-x64": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz",
|
||||
"integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "GPLv3",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@gar/promisify": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
|
||||
@@ -5898,6 +6026,37 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fluent-ffmpeg": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
|
||||
"integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async": "^0.2.9",
|
||||
"which": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fluent-ffmpeg/node_modules/async": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
|
||||
"integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
|
||||
},
|
||||
"node_modules/fluent-ffmpeg/node_modules/which": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
|
||||
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"which": "bin/which"
|
||||
}
|
||||
},
|
||||
"node_modules/fn.name": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.12.2",
|
||||
@@ -26,6 +27,7 @@
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
|
||||
@@ -4,36 +4,16 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { generateThumbnail, ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor');
|
||||
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const router = express.Router();
|
||||
const { isVideoMimeType, validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
const mime = require('mime-types');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
const parseCategoryId = (value) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value === 0 ? null : value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === 'null') return null;
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed === 0 ? null : parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Configure multer for file uploads
|
||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||
const storage = multer.diskStorage({
|
||||
@@ -63,10 +43,12 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
});
|
||||
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit per file
|
||||
fileSize: 500 * 1024 * 1024, // 500MB limit per file to support videos
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
@@ -75,7 +57,7 @@ const upload = multer({
|
||||
headerPairs: 2000 // Maximum number of header key-value pairs
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images and common video formats with proper validation
|
||||
// Accept images and videos with proper validation
|
||||
const allowedMimeTypes = [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
@@ -91,14 +73,15 @@ const upload = multer({
|
||||
abortOnLimit: true
|
||||
});
|
||||
|
||||
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Create content validator middleware
|
||||
const validateUploadContent = createFileUploadValidator({
|
||||
allowedTypes: [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
],
|
||||
// 10GB per file to accommodate large videos; overall limits enforced elsewhere
|
||||
maxFileSize: 10 * 1024 * 1024 * 1024,
|
||||
maxFileSize: 500 * 1024 * 1024, // 500MB to support videos
|
||||
validateContent: true
|
||||
});
|
||||
|
||||
@@ -190,13 +173,25 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
}
|
||||
|
||||
// Parse category_id to number if provided
|
||||
const numericCategoryId = parseCategoryId(category_id);
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
|
||||
const resolveCategoryName = (type) => {
|
||||
if (type === 'collage') return 'collages';
|
||||
if (type === 'video') return 'videos';
|
||||
return 'individual';
|
||||
};
|
||||
// Determine photo type from category_id parameter (for backwards compatibility)
|
||||
let photoType = 'individual'; // default
|
||||
let categoryName = 'individual';
|
||||
|
||||
if (parsedCategoryId === 1 || category_id === 'collage') {
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
} else if (parsedCategoryId === 2 || category_id === 'individual') {
|
||||
photoType = 'individual';
|
||||
categoryName = 'individual';
|
||||
}
|
||||
|
||||
// For backwards compatibility, accept string values
|
||||
if (category_id === 'collage') {
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
}
|
||||
|
||||
// Create final destination directory
|
||||
const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug);
|
||||
@@ -215,49 +210,20 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
const trx = await db.transaction();
|
||||
|
||||
try {
|
||||
const preparedBatch = batch.map((file) => {
|
||||
const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream';
|
||||
const video = isVideoMimeType(resolvedMime, file?.originalname);
|
||||
let inferredType = video ? 'video' : 'individual';
|
||||
|
||||
if (!video) {
|
||||
if (numericCategoryId === 1 || category_id === 'collage') {
|
||||
inferredType = 'collage';
|
||||
} else if (numericCategoryId === 2 || category_id === 'individual') {
|
||||
inferredType = 'individual';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file,
|
||||
resolvedMime,
|
||||
isVideo: video,
|
||||
photoType: inferredType
|
||||
};
|
||||
});
|
||||
|
||||
const typesInBatch = Array.from(new Set(preparedBatch.map((item) => item.photoType)));
|
||||
const typeCounters = {};
|
||||
|
||||
if (typesInBatch.length > 0) {
|
||||
const existingCounts = await trx('photos')
|
||||
.where({ event_id: eventId })
|
||||
.whereIn('type', typesInBatch)
|
||||
.select('type')
|
||||
.count('id as count')
|
||||
.groupBy('type');
|
||||
|
||||
existingCounts.forEach((row) => {
|
||||
typeCounters[row.type] = parseInt(row.count) || 0;
|
||||
});
|
||||
}
|
||||
// Get initial counter for this batch based on photo type
|
||||
const existingCount = await trx('photos')
|
||||
.where({ event_id: eventId, type: photoType })
|
||||
.count('id as count')
|
||||
.first();
|
||||
let batchCounter = (parseInt(existingCount.count) || 0) + 1;
|
||||
|
||||
const batchPhotos = [];
|
||||
const fileRenameOperations = []; // Store rename operations to do after commit
|
||||
|
||||
// First pass: prepare data and move files from temp to final location
|
||||
for (let fileIndex = 0; fileIndex < preparedBatch.length; fileIndex++) {
|
||||
const { file, resolvedMime, isVideo, photoType } = preparedBatch[fileIndex];
|
||||
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
|
||||
const file = batch[fileIndex];
|
||||
const counter = batchCounter + fileIndex;
|
||||
const tempPath = file.path; // Original temp path
|
||||
|
||||
try {
|
||||
@@ -267,14 +233,11 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
throw new Error('File is empty - upload may have been interrupted');
|
||||
}
|
||||
|
||||
typeCounters[photoType] = (typeCounters[photoType] || 0) + 1;
|
||||
const counter = typeCounters[photoType];
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
resolveCategoryName(photoType),
|
||||
categoryName,
|
||||
counter,
|
||||
extension
|
||||
);
|
||||
@@ -291,10 +254,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
type: photoType,
|
||||
size_bytes: tempStats.size, // Use actual file size from stat
|
||||
category_id: numericCategoryId,
|
||||
source_origin: 'managed',
|
||||
mime_type: resolvedMime
|
||||
size_bytes: tempStats.size // Use actual file size from stat
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
@@ -304,8 +264,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
tempPath: tempPath,
|
||||
finalPath: finalPath,
|
||||
filename: newFilename,
|
||||
photoData: photoData,
|
||||
isVideo
|
||||
photoData: photoData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error preparing file ${file.originalname}:`, error);
|
||||
@@ -315,7 +274,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
|
||||
// Insert all photos in this batch
|
||||
if (batchPhotos.length > 0) {
|
||||
console.log(`Inserting batch of ${batchPhotos.length} files with types: ${typesInBatch.join(', ')}`);
|
||||
console.log(`Inserting batch of ${batchPhotos.length} photos with type: ${photoType}`);
|
||||
|
||||
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
|
||||
|
||||
@@ -340,31 +299,27 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
}
|
||||
|
||||
// Generate thumbnail with final path
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
thumbnailPath = operation.isVideo
|
||||
? await generateVideoPlaceholder(operation.filename)
|
||||
: await generateThumbnail(operation.finalPath);
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ thumbnail_path: thumbnailPath });
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ thumbnail_path: thumbnailPath });
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Add to successful uploads
|
||||
uploadedPhotos.push({
|
||||
id: insertedIds[idx]?.id || insertedIds[idx],
|
||||
filename: operation.filename,
|
||||
size: operation.photoData.size_bytes,
|
||||
category_id: operation.photoData.category_id,
|
||||
type: operation.photoData.type,
|
||||
mime_type: operation.photoData.mime_type
|
||||
category_id: operation.photoData.category_id
|
||||
});
|
||||
} catch (moveError) {
|
||||
console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, moveError);
|
||||
@@ -431,7 +386,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
// Prepare response
|
||||
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
|
||||
const response = {
|
||||
message: `Successfully uploaded ${uploadedPhotos.length} files`,
|
||||
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
||||
photos: uploadedPhotos,
|
||||
totalFiles: totalAttempted,
|
||||
successCount: uploadedPhotos.length,
|
||||
@@ -441,7 +396,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
// Include error details if any files failed
|
||||
if (totalInvalidFiles.length > 0) {
|
||||
response.errors = totalInvalidFiles;
|
||||
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} files. ${totalInvalidFiles.length} failed.`;
|
||||
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
@@ -458,7 +413,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
}
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to upload files' });
|
||||
res.status(500).json({ error: 'Failed to upload photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -488,7 +443,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
|
||||
// Delete thumbnail if exists
|
||||
if (photo.thumbnail_path) {
|
||||
const thumbPath = path.join(storagePath, photo.thumbnail_path);
|
||||
const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path);
|
||||
try {
|
||||
// Check if file exists before attempting to delete
|
||||
await fs.access(thumbPath);
|
||||
@@ -535,44 +490,24 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
const updateData = {};
|
||||
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
if (category_id === 'individual' || category_id === 'collage') {
|
||||
updateData.type = category_id;
|
||||
updateData.category_id = null; // Clear legacy category_id
|
||||
} else if (category_id === null || category_id === undefined) {
|
||||
// Explicitly clear category
|
||||
updateData.category_id = null;
|
||||
} else {
|
||||
// Handle numeric category IDs from photo_categories table
|
||||
const numericCategoryId = parseInt(category_id, 10);
|
||||
if (!isNaN(numericCategoryId)) {
|
||||
updateData.category_id = numericCategoryId;
|
||||
} else {
|
||||
updateData.category_id = null;
|
||||
}
|
||||
// Handle legacy numeric category IDs
|
||||
updateData.category_id = category_id || null;
|
||||
}
|
||||
|
||||
// Update photo
|
||||
const normalizedCategoryId = parseCategoryId(category_id);
|
||||
|
||||
await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.where({ id: photoId })
|
||||
.update(updateData);
|
||||
|
||||
// Fetch and return updated photo for confirmation
|
||||
const updatedPhoto = await db('photos')
|
||||
.where({ id: photoId })
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
message: 'Photo updated successfully',
|
||||
photo: updatedPhoto
|
||||
});
|
||||
res.json({ message: 'Photo updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating photo:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo' });
|
||||
@@ -664,32 +599,21 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (parseInt(photoCount.count) !== photoIds.length) {
|
||||
if (photoCount.count !== photoIds.length) {
|
||||
return res.status(400).json({ error: 'Some photos do not belong to this event' });
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
const updateData = {};
|
||||
if (updates.category_id !== undefined) {
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
if (updates.category_id === 'individual' || updates.category_id === 'collage') {
|
||||
updateData.type = updates.category_id;
|
||||
updateData.category_id = null; // Clear legacy category_id
|
||||
} else if (updates.category_id === null) {
|
||||
// Explicitly clear category
|
||||
updateData.category_id = null;
|
||||
} else {
|
||||
// Handle numeric category IDs from photo_categories table
|
||||
const numericCategoryId = parseInt(updates.category_id, 10);
|
||||
if (!isNaN(numericCategoryId)) {
|
||||
updateData.category_id = numericCategoryId;
|
||||
} else {
|
||||
updateData.category_id = null;
|
||||
}
|
||||
// Handle legacy numeric category IDs
|
||||
updateData.category_id = updates.category_id || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -741,25 +665,17 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
|
||||
router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id, type, media_type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
|
||||
let query = db('photos')
|
||||
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
|
||||
.where({ 'photos.event_id': eventId })
|
||||
.select(
|
||||
'photos.*',
|
||||
'pc.name as category_display_name',
|
||||
'pc.slug as category_display_slug'
|
||||
);
|
||||
.select('photos.*');
|
||||
|
||||
// Filter by type (individual/collage) - category_id maps to type
|
||||
if (category_id !== undefined) {
|
||||
if (category_id === '') {
|
||||
// No filter when empty string is provided
|
||||
} else if (category_id === '0') {
|
||||
query = query.whereNull('photos.category_id');
|
||||
} else if (/^\d+$/.test(category_id)) {
|
||||
query = query.where('photos.category_id', parseInt(category_id, 10));
|
||||
if (category_id === '' || category_id === '0') {
|
||||
// For backwards compatibility, empty category means no filter
|
||||
// Don't filter anything
|
||||
} else if (category_id === 'individual' || category_id === 'collage') {
|
||||
query = query.where({ 'photos.type': category_id });
|
||||
}
|
||||
@@ -770,20 +686,6 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
query = query.where({ 'photos.type': type });
|
||||
}
|
||||
|
||||
if (media_type === 'video') {
|
||||
query = query.where((qb) => {
|
||||
qb.where('photos.type', 'video')
|
||||
.orWhere('photos.mime_type', 'like', 'video/%');
|
||||
});
|
||||
} else if (media_type === 'photo') {
|
||||
query = query.where((qb) => {
|
||||
qb.whereNot('photos.type', 'video')
|
||||
.andWhere(function(inner) {
|
||||
inner.whereNull('photos.mime_type').orWhere('photos.mime_type', 'not like', 'video/%');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Search by filename
|
||||
if (search) {
|
||||
const escapedSearch = escapeLikePattern(search);
|
||||
@@ -800,10 +702,6 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
|
||||
const photos = await query.orderBy(orderByColumn, order);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ photos: [] });
|
||||
}
|
||||
|
||||
// Get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
@@ -820,37 +718,26 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
res.json({
|
||||
photos: photos.map(photo => {
|
||||
const mediaType = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video' ? 'video' : 'photo';
|
||||
const categoryName = photo.category_display_name
|
||||
|| (photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages');
|
||||
const normalizedCategoryId = photo.category_id !== null && photo.category_id !== undefined
|
||||
? (Number.isNaN(Number(photo.category_id)) ? photo.category_id : Number(photo.category_id))
|
||||
: null;
|
||||
|
||||
return ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
// Use the correct admin photos router base for serving images
|
||||
url: `/admin/photos/${eventId}/photo/${photo.id}`,
|
||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||
type: photo.type,
|
||||
category_id: normalizedCategoryId,
|
||||
mime_type: photo.mime_type,
|
||||
media_type: mediaType,
|
||||
category_name: categoryName,
|
||||
category_slug: photo.category_display_slug || photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0
|
||||
});
|
||||
})
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
// Use the correct admin photos router base for serving images
|
||||
url: `/admin/photos/${eventId}/photo/${photo.id}`,
|
||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||
type: photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error);
|
||||
@@ -882,10 +769,8 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
const mimeType = photo.mime_type || `image/${path.extname(photo.filename).slice(1)}`;
|
||||
|
||||
// Set appropriate headers
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`);
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
@@ -911,30 +796,8 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
let thumbnailPath = photo.thumbnail_path;
|
||||
const thumbMissing = !thumbnailPath || !(await (async () => {
|
||||
try {
|
||||
const fs = require('fs').promises;
|
||||
await fs.access(path.join(getStoragePath(), thumbnailPath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})());
|
||||
|
||||
if (isVideo) {
|
||||
if (!thumbnailPath || thumbMissing) {
|
||||
const regenerated = await generateVideoPlaceholder(photo.filename, { regenerate: true });
|
||||
if (regenerated) {
|
||||
thumbnailPath = regenerated;
|
||||
await db('photos').where({ id: photo.id }).update({ thumbnail_path: regenerated });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
thumbnailPath = await ensureThumbnail(photo);
|
||||
}
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
if (!thumbnailPath) {
|
||||
console.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
|
||||
@@ -10,8 +10,6 @@ const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor');
|
||||
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
@@ -250,17 +248,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
.distinct('type')
|
||||
.orderBy('type', 'asc');
|
||||
|
||||
const resolveCategoryName = (type, mimeType, filename) => {
|
||||
if (type === 'video' || isVideoMimeType(mimeType, filename)) return 'Videos';
|
||||
if (type === 'individual') return 'Individual Photos';
|
||||
if (type === 'collage') return 'Collages';
|
||||
return type || 'Uncategorized';
|
||||
};
|
||||
|
||||
// Convert types to category-like objects
|
||||
const categories = categoryResults.map(result => ({
|
||||
id: result.type,
|
||||
name: resolveCategoryName(result.type),
|
||||
name: result.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
slug: result.type,
|
||||
is_global: false
|
||||
}));
|
||||
@@ -301,13 +292,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
},
|
||||
categories: categories,
|
||||
photos: photos.map(photo => {
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
const mediaType = isVideo ? 'video' : 'photo';
|
||||
const useJwtUrl = isVideo || (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
||||
const categoryName = resolveCategoryName(photo.type, photo.mime_type, photo.filename);
|
||||
|
||||
return {
|
||||
id: photo.id,
|
||||
@@ -318,14 +306,12 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||
type: photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: categoryName,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
media_type: mediaType,
|
||||
mime_type: photo.mime_type,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl && !isVideo,
|
||||
requires_token: !useJwtUrl,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
@@ -359,7 +345,6 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
// Update download count
|
||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||
|
||||
@@ -388,7 +373,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
@@ -401,9 +386,6 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file
|
||||
if (isVideo) {
|
||||
res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' });
|
||||
}
|
||||
res.download(filePath, photo.filename, (downloadError) => {
|
||||
if (downloadError) {
|
||||
logger.error('Error streaming gallery download', {
|
||||
@@ -481,16 +463,14 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
let archiveName;
|
||||
if (hasMultipleTypes) {
|
||||
// Use photo type as folder
|
||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages';
|
||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||
archiveName = path.join(folderName, photo.filename);
|
||||
} else {
|
||||
// No folders, just the filename
|
||||
archiveName = photo.filename;
|
||||
}
|
||||
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name: archiveName });
|
||||
@@ -585,9 +565,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
try {
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name });
|
||||
@@ -637,13 +615,9 @@ router.get('/:slug/photo/:photoId',
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
const numericPhotoId = parseInt(photoId, 10);
|
||||
if (!Number.isInteger(numericPhotoId)) {
|
||||
return res.status(400).json({ error: 'Invalid photo id' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: numericPhotoId, event_id: req.event.id })
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
|
||||
@@ -651,11 +625,13 @@ router.get('/:slug/photo/:photoId',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const isVideo = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video';
|
||||
// Check if this is a video
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
|
||||
// Check protection level - basic and standard protection allow direct JWT access
|
||||
const protectionLevel = req.event.protection_level || 'standard';
|
||||
|
||||
if (!isVideo && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
|
||||
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
|
||||
// For enhanced/maximum protection, redirect to secure endpoint
|
||||
return res.status(302).json({
|
||||
error: 'Secure access required',
|
||||
@@ -677,10 +653,51 @@ router.get('/:slug/photo/:photoId',
|
||||
// 'view_basic'
|
||||
// );
|
||||
|
||||
// Handle video streaming with range requests
|
||||
if (isVideo) {
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = req.headers.range;
|
||||
|
||||
if (range) {
|
||||
// Parse range header
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
const file = fs.createReadStream(filePath, { start, end });
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Type': photo.mime_type || 'video/mp4',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
file.pipe(res);
|
||||
} else {
|
||||
// No range request, send entire file
|
||||
res.writeHead(200, {
|
||||
'Content-Length': fileSize,
|
||||
'Content-Type': photo.mime_type || 'video/mp4',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle images (existing logic)
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
@@ -699,9 +716,6 @@ router.get('/:slug/photo/:photoId',
|
||||
});
|
||||
// Ensure absolute path for res.sendFile
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
||||
if (isVideo) {
|
||||
res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' });
|
||||
}
|
||||
res.sendFile(absolutePath);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -722,62 +736,32 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
const numericPhotoId = parseInt(photoId, 10);
|
||||
if (!Number.isInteger(numericPhotoId)) {
|
||||
return res.status(400).json({ error: 'Invalid photo id' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: numericPhotoId, event_id: req.event.id })
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
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' });
|
||||
}
|
||||
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
|
||||
|
||||
// Check if file exists
|
||||
const fs = require('fs').promises;
|
||||
try {
|
||||
await fs.access(thumbFilePath);
|
||||
await fs.access(thumbPath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
// Log thumbnail access
|
||||
try {
|
||||
await secureImageService.logImageAccess(
|
||||
numericPhotoId,
|
||||
req.event.id,
|
||||
req.clientInfo,
|
||||
'thumbnail'
|
||||
);
|
||||
} catch (logErr) {
|
||||
logger.warn('Thumbnail access log failed', { photoId, eventId: req.event.id, error: logErr.message });
|
||||
}
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
req.event.id,
|
||||
req.clientInfo,
|
||||
'thumbnail'
|
||||
);
|
||||
|
||||
// Set appropriate headers with enhanced security
|
||||
res.set({
|
||||
@@ -789,14 +773,8 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
});
|
||||
|
||||
// Send file
|
||||
res.sendFile(path.resolve(thumbFilePath));
|
||||
res.sendFile(path.resolve(thumbPath));
|
||||
} catch (error) {
|
||||
console.error('Thumbnail route error', {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id,
|
||||
});
|
||||
logger.error('Error serving thumbnail:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
|
||||
const { processUploadedVideo } = require('./videoProcessor');
|
||||
const { generateThumbnail } = require('./imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
|
||||
const mime = require('mime-types');
|
||||
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -73,15 +71,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
const trx = await db.transaction();
|
||||
|
||||
try {
|
||||
const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream';
|
||||
const isVideo = isVideoMimeType(resolvedMime, file?.originalname);
|
||||
|
||||
// Count existing photos to generate sequence number
|
||||
let counter = 1;
|
||||
let photoType = isVideo ? 'video' : 'individual'; // default type
|
||||
let photoType = 'individual'; // default type
|
||||
|
||||
// If categoryId is provided and matches photo types, use it as type
|
||||
if (!isVideo && categoryId === 'collage') {
|
||||
if (categoryId === 'collage') {
|
||||
photoType = 'collage';
|
||||
}
|
||||
|
||||
@@ -96,7 +91,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const categoryName = photoType === 'collage' ? 'collages' : (isVideo ? 'videos' : 'individual');
|
||||
const categoryName = photoType === 'collage' ? 'collages' : 'individual';
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
categoryName,
|
||||
@@ -156,22 +151,25 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
}
|
||||
}
|
||||
|
||||
// Generate thumbnail and metadata
|
||||
let thumbnailPath = null;
|
||||
// Determine if this is a video or image
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Generate thumbnail and extract metadata
|
||||
let thumbnailPath;
|
||||
let videoMetadata = null;
|
||||
|
||||
if (isVideo) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
const thumbnailDir = path.join(getStoragePath(), 'thumbnails');
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
const videoThumbnailPath = path.join(thumbnailDir, `thumb_${newFilename.replace(/\.[^.]+$/, '.jpg')}`);
|
||||
try {
|
||||
const result = await processUploadedVideo(newPath, videoThumbnailPath);
|
||||
videoMetadata = result?.metadata || null;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} catch (videoErr) {
|
||||
console.error('Failed to process uploaded video, falling back to placeholder:', videoErr.message);
|
||||
thumbnailPath = await generateVideoPlaceholder(newFilename);
|
||||
}
|
||||
|
||||
const result = await processUploadedVideo(newPath, videoThumbnailPath);
|
||||
videoMetadata = result.metadata;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} else {
|
||||
// Process image: generate thumbnail
|
||||
thumbnailPath = await generateThumbnail(newPath);
|
||||
}
|
||||
|
||||
@@ -180,7 +178,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
|
||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||
|
||||
// Add to database with uploaded_by field
|
||||
// Add to database with uploaded_by field and media metadata
|
||||
let insertResult;
|
||||
const clientName = trx?.client?.config?.client;
|
||||
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
|
||||
@@ -194,10 +192,19 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
size_bytes: file.size,
|
||||
uploaded_by: uploadedBy,
|
||||
source_origin: 'managed',
|
||||
mime_type: resolvedMime,
|
||||
media_type: isVideo ? 'video' : 'photo'
|
||||
media_type: mediaType,
|
||||
mime_type: file.mimetype
|
||||
};
|
||||
|
||||
// Add video-specific metadata if applicable
|
||||
if (isVideo && videoMetadata) {
|
||||
photoData.duration = videoMetadata.duration;
|
||||
photoData.video_codec = videoMetadata.videoCodec;
|
||||
photoData.audio_codec = videoMetadata.audioCodec;
|
||||
photoData.width = videoMetadata.width;
|
||||
photoData.height = videoMetadata.height;
|
||||
}
|
||||
|
||||
if (supportsReturning) {
|
||||
insertResult = await trx('photos')
|
||||
.insert(photoData)
|
||||
@@ -223,8 +230,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
id: photoId,
|
||||
filename: newFilename,
|
||||
size: file.size,
|
||||
type: photoType,
|
||||
media_type: isVideo ? 'video' : 'photo'
|
||||
type: photoType
|
||||
});
|
||||
|
||||
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -45,7 +45,7 @@ function isPathSafe(filePath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced MIME type validation
|
||||
* Enhanced MIME type validation for images and videos
|
||||
*/
|
||||
const ALLOWED_IMAGE_TYPES = {
|
||||
'image/jpeg': {
|
||||
@@ -78,24 +78,43 @@ const ALLOWED_IMAGE_TYPES = {
|
||||
extensions: ['.svg'],
|
||||
// SVG files are XML-based text files, so we skip magic number validation
|
||||
magicNumbers: null
|
||||
},
|
||||
// Video types are included here to keep validation centralized
|
||||
}
|
||||
};
|
||||
|
||||
const ALLOWED_VIDEO_TYPES = {
|
||||
'video/mp4': {
|
||||
extensions: ['.mp4'],
|
||||
magicNumbers: null
|
||||
},
|
||||
'video/quicktime': {
|
||||
extensions: ['.mov', '.qt'],
|
||||
magicNumbers: null
|
||||
extensions: ['.mp4', '.m4v'],
|
||||
magicNumbers: [
|
||||
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // 'ftyp' signature for MP4
|
||||
]
|
||||
},
|
||||
'video/webm': {
|
||||
extensions: ['.webm'],
|
||||
magicNumbers: [
|
||||
{ offset: 0, bytes: [0x1A, 0x45, 0xDF, 0xA3] } // WebM/Matroska
|
||||
{ offset: 0, bytes: [0x1A, 0x45, 0xDF, 0xA3] } // EBML header for WebM/MKV
|
||||
]
|
||||
},
|
||||
'video/quicktime': {
|
||||
extensions: ['.mov'],
|
||||
magicNumbers: [
|
||||
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70, 0x71, 0x74] } // 'ftypqt' signature for QuickTime
|
||||
]
|
||||
},
|
||||
'video/x-msvideo': {
|
||||
extensions: ['.avi'],
|
||||
magicNumbers: [
|
||||
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF
|
||||
{ offset: 8, bytes: [0x41, 0x56, 0x49, 0x20] } // 'AVI '
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Combined media types
|
||||
const ALLOWED_MEDIA_TYPES = {
|
||||
...ALLOWED_IMAGE_TYPES,
|
||||
...ALLOWED_VIDEO_TYPES
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate file type by MIME type and extension
|
||||
* @param {string} filename - The filename
|
||||
@@ -113,7 +132,7 @@ function validateFileType(filename, mimetype, allowedTypes) {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
|
||||
// Check if extension matches the MIME type
|
||||
const typeConfig = ALLOWED_IMAGE_TYPES[mimetype];
|
||||
const typeConfig = ALLOWED_MEDIA_TYPES[mimetype];
|
||||
if (!typeConfig || !typeConfig.extensions.includes(ext)) {
|
||||
return false;
|
||||
}
|
||||
@@ -129,7 +148,7 @@ function validateFileType(filename, mimetype, allowedTypes) {
|
||||
*/
|
||||
async function validateFileContent(filePath, expectedMimeType) {
|
||||
try {
|
||||
const typeConfig = ALLOWED_IMAGE_TYPES[expectedMimeType];
|
||||
const typeConfig = ALLOWED_MEDIA_TYPES[expectedMimeType];
|
||||
if (!typeConfig) {
|
||||
return false;
|
||||
}
|
||||
@@ -170,8 +189,8 @@ function getSafeFilename(originalFilename) {
|
||||
const randomString = Math.random().toString(36).substring(2, 15);
|
||||
const ext = path.extname(originalFilename).toLowerCase();
|
||||
|
||||
// Validate extension
|
||||
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico'];
|
||||
// Validate extension - including both image and video extensions
|
||||
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi'];
|
||||
if (!validExtensions.includes(ext)) {
|
||||
throw new Error('Invalid file extension');
|
||||
}
|
||||
@@ -179,26 +198,6 @@ function getSafeFilename(originalFilename) {
|
||||
return `upload_${timestamp}_${randomString}${ext}`;
|
||||
}
|
||||
|
||||
function isVideoMimeType(mimeType, filename) {
|
||||
const lowerMime = (mimeType || '').toLowerCase();
|
||||
if (lowerMime.startsWith('video/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ext = filename ? path.extname(filename).toLowerCase() : '';
|
||||
const videoExts = ['.mp4', '.mov', '.webm', '.m4v', '.qt'];
|
||||
|
||||
if (videoExts.includes(ext)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lowerMime === 'application/mp4' || lowerMime === 'application/x-m4v' || lowerMime === 'application/octet-stream') {
|
||||
return videoExts.includes(ext) || true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file upload validator middleware
|
||||
* @param {Object} options - Validation options
|
||||
@@ -265,5 +264,6 @@ module.exports = {
|
||||
getSafeFilename,
|
||||
createFileUploadValidator,
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
isVideoMimeType
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
ALLOWED_MEDIA_TYPES
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Upload, X, Image, Loader2, Video } from 'lucide-react';
|
||||
import { Upload, X, Image, Loader2 } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { clsx } from 'clsx';
|
||||
import { api } from '../../config/api';
|
||||
@@ -51,18 +51,12 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
|
||||
const allowedFiles = files.filter(file => allowedTypes.includes(file.type));
|
||||
const rejectedFiles = files.filter(file => !allowedTypes.includes(file.type));
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
toast.error(
|
||||
t('upload.unsupportedFiles', 'Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).')
|
||||
);
|
||||
}
|
||||
const imageFiles = files.filter(file =>
|
||||
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||||
);
|
||||
|
||||
// Check total file count with existing files
|
||||
const totalFiles = selectedFiles.length + allowedFiles.length;
|
||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||
if (totalFiles > maxFilesPerUpload) {
|
||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||
if (allowedNewFiles <= 0) {
|
||||
@@ -76,11 +70,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
|
||||
);
|
||||
setSelectedFiles(prev => [...prev, ...allowedFiles.slice(0, allowedNewFiles)]);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFiles(prev => [...prev, ...allowedFiles]);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles]);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
@@ -192,7 +186,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{/* Category Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('upload.mediaCategory', 'Media category')}
|
||||
{t('upload.photoCategory')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategoryId || ''}
|
||||
@@ -222,7 +216,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('upload.fileRequirementsMedia', { limit: maxFilesPerUpload }) || t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
@@ -242,7 +236,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
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}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{file.type.startsWith('video/') ? (
|
||||
<Video className="w-5 h-5 text-neutral-400" />
|
||||
) : (
|
||||
<Image className="w-5 h-5 text-neutral-400" />
|
||||
)}
|
||||
<Image className="w-5 h-5 text-neutral-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
|
||||
{file.name}
|
||||
@@ -298,9 +288,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
disabled={selectedFiles.length === 0 || isUploading}
|
||||
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
>
|
||||
{isUploading
|
||||
? t('upload.uploading')
|
||||
: t('upload.uploadAction', { count: selectedFiles.length }) || `Upload ${selectedFiles.length} files`}
|
||||
{isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { toast as toastify } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -336,14 +336,25 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo type badge */}
|
||||
{photo.type === 'collage' && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
{/* Media type badges */}
|
||||
<div className="absolute bottom-2 left-2 flex gap-2">
|
||||
{photo.type === 'collage' && (
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
Collage
|
||||
</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" />
|
||||
|
||||
@@ -3,10 +3,11 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage, AuthenticatedVideo } from '../common';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
import { PhotoFeedback } from './PhotoFeedback';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
@@ -64,11 +65,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
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
|
||||
useDevToolsProtection({
|
||||
@@ -366,31 +362,27 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isVideo && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
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>
|
||||
<span className="text-white text-sm w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 3}
|
||||
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>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
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>
|
||||
<span className="text-white text-sm w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 3}
|
||||
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>
|
||||
|
||||
<div className="w-px h-6 bg-white/20 mx-2" />
|
||||
</>
|
||||
)}
|
||||
<div className="w-px h-6 bg-white/20 mx-2" />
|
||||
|
||||
{allowDownloads && (
|
||||
<button
|
||||
@@ -457,29 +449,29 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image container */}
|
||||
{/* Image/Video container */}
|
||||
<div
|
||||
className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
|
||||
onClick={isVideo ? undefined : handleImageClick}
|
||||
onMouseDown={isVideo ? undefined : handleMouseDown}
|
||||
onMouseMove={isVideo ? undefined : handleMouseMove}
|
||||
onMouseUp={isVideo ? undefined : handleMouseUp}
|
||||
onMouseLeave={isVideo ? undefined : handleMouseUp}
|
||||
onTouchStart={isVideo ? undefined : handleTouchStart}
|
||||
onTouchMove={isVideo ? undefined : handleTouchMove}
|
||||
onTouchEnd={isVideo ? undefined : handleTouchEnd}
|
||||
onClick={currentPhoto.media_type === 'video' ? undefined : handleImageClick}
|
||||
onMouseDown={currentPhoto.media_type === 'video' ? undefined : handleMouseDown}
|
||||
onMouseMove={currentPhoto.media_type === 'video' ? undefined : handleMouseMove}
|
||||
onMouseUp={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
|
||||
onMouseLeave={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
|
||||
onTouchStart={currentPhoto.media_type === 'video' ? undefined : handleTouchStart}
|
||||
onTouchMove={currentPhoto.media_type === 'video' ? undefined : handleTouchMove}
|
||||
onTouchEnd={currentPhoto.media_type === 'video' ? undefined : handleTouchEnd}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{isVideo ? (
|
||||
<AuthenticatedVideo
|
||||
{currentPhoto.media_type === 'video' ? (
|
||||
<VideoPlayer
|
||||
src={currentPhoto.url}
|
||||
fallbackSrc={currentPhoto.thumbnail_url || undefined}
|
||||
className="max-w-full max-h-full object-contain bg-black"
|
||||
slug={slug}
|
||||
poster={currentPhoto.thumbnail_url || undefined}
|
||||
poster={currentPhoto.thumbnail_url}
|
||||
className="max-w-full max-h-full"
|
||||
controls={true}
|
||||
autoPlay={false}
|
||||
/>
|
||||
) : (
|
||||
<AuthenticatedImage
|
||||
@@ -510,23 +502,23 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
onProtectionViolation={(violationType) => {
|
||||
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
|
||||
|
||||
// Track analytics
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
(window as any).umami.track('lightbox_protection_violation', {
|
||||
photoId: currentPhoto.id,
|
||||
violationType,
|
||||
protectionLevel,
|
||||
zoom
|
||||
});
|
||||
}
|
||||
// Track analytics
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
(window as any).umami.track('lightbox_protection_violation', {
|
||||
photoId: currentPhoto.id,
|
||||
violationType,
|
||||
protectionLevel,
|
||||
zoom
|
||||
});
|
||||
}
|
||||
|
||||
// For maximum protection, close lightbox on violation
|
||||
if (protectionLevel === 'maximum' &&
|
||||
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
// For maximum protection, close lightbox on violation
|
||||
if (protectionLevel === 'maximum' &&
|
||||
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
type="file"
|
||||
className="hidden"
|
||||
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}
|
||||
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;
|
||||
@@ -63,6 +63,14 @@ export interface Photo {
|
||||
category_slug?: string;
|
||||
size: number;
|
||||
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
|
||||
has_feedback?: boolean;
|
||||
average_rating?: number;
|
||||
|
||||
Reference in New Issue
Block a user