Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ae63890ff | |||
| 5f1affafd8 | |||
| 8315c11d34 | |||
| 0043f2aaf4 | |||
| d494eda301 | |||
| a59a4232ff | |||
| 77326a91ca | |||
| 0d95eab86a | |||
| f3482a9a78 | |||
| 68a9dc5749 | |||
| 8c87f1537b | |||
| 97e54355fb |
@@ -135,6 +135,31 @@ Perfect for:
|
|||||||
- **Docker**: v20.10.0+
|
- **Docker**: v20.10.0+
|
||||||
- **Docker Compose**: v2.0.0+
|
- **Docker Compose**: v2.0.0+
|
||||||
|
|
||||||
|
### Video Support Requirements
|
||||||
|
When enabling video uploads, consider these additional resources:
|
||||||
|
|
||||||
|
| Resource | Recommendation | Notes |
|
||||||
|
|----------|----------------|-------|
|
||||||
|
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
|
||||||
|
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
|
||||||
|
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
|
||||||
|
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
|
||||||
|
|
||||||
|
**Technical Notes:**
|
||||||
|
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
|
||||||
|
- Maximum upload size: **10GB per video file**
|
||||||
|
- Chunked upload support for files >100MB (resumable uploads)
|
||||||
|
- Supported formats: MP4, WebM, MOV, AVI
|
||||||
|
- Video thumbnails are automatically generated from the first few seconds
|
||||||
|
|
||||||
|
**For Nginx/Reverse Proxy:**
|
||||||
|
If using Nginx, increase the client max body size:
|
||||||
|
```nginx
|
||||||
|
client_max_body_size 10G;
|
||||||
|
proxy_read_timeout 3600;
|
||||||
|
proxy_send_timeout 3600;
|
||||||
|
```
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
|
||||||
@@ -222,7 +247,7 @@ These features are currently in beta testing and may have limited functionality
|
|||||||
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
|
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
|
||||||
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
|
||||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
||||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
|
||||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
|
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
|
||||||
| **Filtering & Export Options** | Add filters to show only rated, liked, or marked photos and export filtered selections for Capture One or Lightroom workflows | Low | 🔄 Open |
|
| **Filtering & Export Options** | Add filters to show only rated, liked, or marked photos and export filtered selections for Capture One or Lightroom workflows | Low | 🔄 Open |
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -11,13 +11,16 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
|||||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
|
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||||
|
RUN npm install -g npm@latest
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (--omit=dev replaces deprecated --only=production)
|
||||||
RUN npm ci --only=production
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -30,6 +33,9 @@ WORKDIR /app
|
|||||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||||
RUN apk upgrade --no-cache
|
RUN apk upgrade --no-cache
|
||||||
|
|
||||||
|
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||||
|
RUN npm install -g npm@latest
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||||
RUN apk add --no-cache dumb-init postgresql-client
|
RUN apk add --no-cache dumb-init postgresql-client
|
||||||
|
|
||||||
|
|||||||
@@ -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
+1813
-1213
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
+3
-3
@@ -324,9 +324,9 @@ async function initializeRateLimiters() {
|
|||||||
|
|
||||||
// Note: Rate limiters will be initialized after database connection
|
// Note: Rate limiters will be initialized after database connection
|
||||||
|
|
||||||
// Body parsing middleware with increased limits for large batch uploads
|
<<<<<<< HEAD
|
||||||
app.use(express.json({ limit: '500mb' }));
|
app.use(express.json({ limit: '10gb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '500mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
|
||||||
|
|
||||||
// Request logging for API routes (with timestamps)
|
// Request logging for API routes (with timestamps)
|
||||||
const apiRequestLogger = (req, res, next) => {
|
const apiRequestLogger = (req, res, next) => {
|
||||||
|
|||||||
+245
-216
@@ -4,36 +4,18 @@ 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 { processUploadedPhotos } = require('../services/photoProcessor');
|
||||||
|
const chunkedUpload = require('../services/chunkedUploadService');
|
||||||
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 +45,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: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large 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,23 +59,31 @@ 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 = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
|
const allowedMimeTypes = [
|
||||||
|
'image/jpeg', 'image/png', 'image/webp',
|
||||||
|
'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 {
|
||||||
cb(new Error('Only JPEG, PNG, WebP images or MP4/MOV/WEBM videos are allowed'));
|
cb(new Error('Only JPEG, PNG, WebP images and MP4, WebM, MOV, AVI videos are allowed'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Add abort on limit to stop processing when limits are exceeded
|
// Add abort on limit to stop processing when limits are exceeded
|
||||||
abortOnLimit: true
|
abortOnLimit: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||||
|
|
||||||
// Create content validator middleware
|
// Create content validator middleware
|
||||||
const validateUploadContent = createFileUploadValidator({
|
const validateUploadContent = createFileUploadValidator({
|
||||||
allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'],
|
allowedTypes: [
|
||||||
maxFileSize: 50 * 1024 * 1024,
|
'image/jpeg', 'image/png', 'image/webp',
|
||||||
|
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||||
|
],
|
||||||
|
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
|
||||||
validateContent: true
|
validateContent: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,7 +123,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
|||||||
console.error('Multer error:', err);
|
console.error('Multer error:', err);
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
|
return res.status(400).json({ error: 'File too large. Maximum size is 10GB per file.' });
|
||||||
}
|
}
|
||||||
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||||
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
|
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
|
||||||
@@ -183,14 +175,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 });
|
||||||
@@ -208,49 +212,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 {
|
||||||
@@ -259,15 +234,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
|
||||||
);
|
);
|
||||||
@@ -284,10 +256,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);
|
||||||
@@ -297,8 +266,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);
|
||||||
@@ -308,7 +276,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');
|
||||||
|
|
||||||
@@ -333,31 +301,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);
|
||||||
@@ -424,7 +388,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,
|
||||||
@@ -434,7 +398,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);
|
||||||
@@ -451,7 +415,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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -481,7 +445,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);
|
||||||
@@ -528,9 +492,7 @@ 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
|
||||||
@@ -551,21 +513,11 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update photo
|
// Update photo
|
||||||
const normalizedCategoryId = parseCategoryId(category_id);
|
|
||||||
|
|
||||||
await db('photos')
|
await db('photos')
|
||||||
.where({ id: photoId, event_id: eventId })
|
.where({ id: photoId, event_id: eventId })
|
||||||
.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' });
|
||||||
@@ -645,22 +597,22 @@ 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 (parseInt(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()
|
updated_at: new Date()
|
||||||
@@ -734,25 +686,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 });
|
||||||
}
|
}
|
||||||
@@ -762,20 +706,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) {
|
||||||
@@ -792,11 +722,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))
|
||||||
@@ -813,37 +739,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);
|
||||||
@@ -875,10 +790,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');
|
||||||
|
|
||||||
@@ -904,30 +817,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}`);
|
||||||
@@ -973,4 +864,142 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CHUNKED UPLOAD ENDPOINTS
|
||||||
|
// For large file uploads (videos up to 10GB)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Initialize a chunked upload
|
||||||
|
router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId } = req.params;
|
||||||
|
const { filename, fileSize, mimeType, totalChunks } = req.body;
|
||||||
|
|
||||||
|
// Validate event exists
|
||||||
|
const event = await db('events').where({ id: eventId }).first();
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!filename || !fileSize || !mimeType) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields: filename, fileSize, mimeType' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 10GB)
|
||||||
|
const maxSize = 10 * 1024 * 1024 * 1024;
|
||||||
|
if (fileSize > maxSize) {
|
||||||
|
return res.status(400).json({ error: `File too large. Maximum size is 10GB.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await chunkedUpload.initializeUpload({
|
||||||
|
filename,
|
||||||
|
fileSize,
|
||||||
|
mimeType,
|
||||||
|
eventId: parseInt(eventId),
|
||||||
|
totalChunks
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing chunked upload:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to initialize upload' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload a chunk
|
||||||
|
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { uploadId, chunkIndex } = req.params;
|
||||||
|
|
||||||
|
// Get chunk data from request body
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const chunkData = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
const result = await chunkedUpload.uploadChunk(uploadId, parseInt(chunkIndex), chunkData);
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading chunk:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to upload chunk' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Complete chunked upload and process the file
|
||||||
|
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { eventId, uploadId } = req.params;
|
||||||
|
const { category_id } = req.body;
|
||||||
|
|
||||||
|
// Complete the chunked upload (merge chunks)
|
||||||
|
const mergedFile = await chunkedUpload.completeUpload(uploadId);
|
||||||
|
|
||||||
|
// Process the merged file as a regular upload
|
||||||
|
const fileObj = {
|
||||||
|
originalname: mergedFile.filename,
|
||||||
|
mimetype: mergedFile.mimeType,
|
||||||
|
size: mergedFile.size,
|
||||||
|
path: mergedFile.path
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadedPhotos = await processUploadedPhotos(
|
||||||
|
[fileObj],
|
||||||
|
parseInt(eventId),
|
||||||
|
'admin',
|
||||||
|
category_id || null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up temp directory
|
||||||
|
try {
|
||||||
|
await fs.rm(mergedFile.tempDir, { recursive: true, force: true });
|
||||||
|
} catch (cleanupErr) {
|
||||||
|
console.warn('Failed to clean up temp directory:', cleanupErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
uploaded: uploadedPhotos.length,
|
||||||
|
photos: uploadedPhotos
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing chunked upload:', error);
|
||||||
|
res.status(500).json({ error: error.message || 'Failed to complete upload' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get upload status
|
||||||
|
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { uploadId } = req.params;
|
||||||
|
|
||||||
|
const status = chunkedUpload.getUploadStatus(uploadId);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return res.status(404).json({ error: 'Upload not found or expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting upload status:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get upload status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abort chunked upload
|
||||||
|
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { uploadId } = req.params;
|
||||||
|
|
||||||
|
await chunkedUpload.abortUpload(uploadId);
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Upload aborted' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error aborting upload:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to abort upload' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+81
-103
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Get storage path from environment or default
|
||||||
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
const getChunksPath = () => path.join(getStoragePath(), 'chunks');
|
||||||
|
|
||||||
|
// In-memory store for active uploads (in production, consider Redis)
|
||||||
|
const activeUploads = new Map();
|
||||||
|
|
||||||
|
// Chunk size: 10MB
|
||||||
|
const CHUNK_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Upload expiration: 24 hours
|
||||||
|
const UPLOAD_EXPIRATION_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a new chunked upload
|
||||||
|
* @param {Object} options - Upload options
|
||||||
|
* @returns {Promise<Object>} - Upload metadata
|
||||||
|
*/
|
||||||
|
async function initializeUpload(options) {
|
||||||
|
const {
|
||||||
|
filename,
|
||||||
|
fileSize,
|
||||||
|
mimeType,
|
||||||
|
eventId,
|
||||||
|
totalChunks
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Generate unique upload ID
|
||||||
|
const uploadId = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Create chunks directory for this upload
|
||||||
|
const uploadDir = path.join(getChunksPath(), uploadId);
|
||||||
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
// Calculate expected chunks
|
||||||
|
const expectedChunks = totalChunks || Math.ceil(fileSize / CHUNK_SIZE);
|
||||||
|
|
||||||
|
// Store upload metadata
|
||||||
|
const uploadMeta = {
|
||||||
|
uploadId,
|
||||||
|
filename,
|
||||||
|
fileSize,
|
||||||
|
mimeType,
|
||||||
|
eventId,
|
||||||
|
expectedChunks,
|
||||||
|
receivedChunks: new Set(),
|
||||||
|
uploadDir,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + UPLOAD_EXPIRATION_MS,
|
||||||
|
status: 'in_progress'
|
||||||
|
};
|
||||||
|
|
||||||
|
activeUploads.set(uploadId, uploadMeta);
|
||||||
|
|
||||||
|
logger.info('Initialized chunked upload', {
|
||||||
|
uploadId,
|
||||||
|
filename,
|
||||||
|
fileSize,
|
||||||
|
expectedChunks,
|
||||||
|
eventId
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadId,
|
||||||
|
chunkSize: CHUNK_SIZE,
|
||||||
|
expectedChunks,
|
||||||
|
expiresAt: uploadMeta.expiresAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a single chunk
|
||||||
|
* @param {string} uploadId - Upload ID
|
||||||
|
* @param {number} chunkIndex - Chunk index (0-based)
|
||||||
|
* @param {Buffer} chunkData - Chunk data
|
||||||
|
* @returns {Promise<Object>} - Chunk upload result
|
||||||
|
*/
|
||||||
|
async function uploadChunk(uploadId, chunkIndex, chunkData) {
|
||||||
|
const uploadMeta = activeUploads.get(uploadId);
|
||||||
|
|
||||||
|
if (!uploadMeta) {
|
||||||
|
throw new Error('Upload not found or expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadMeta.status !== 'in_progress') {
|
||||||
|
throw new Error(`Upload is ${uploadMeta.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (Date.now() > uploadMeta.expiresAt) {
|
||||||
|
await abortUpload(uploadId);
|
||||||
|
throw new Error('Upload expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write chunk to disk
|
||||||
|
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
|
||||||
|
await fs.writeFile(chunkPath, chunkData);
|
||||||
|
|
||||||
|
// Mark chunk as received
|
||||||
|
uploadMeta.receivedChunks.add(chunkIndex);
|
||||||
|
|
||||||
|
const progress = (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100;
|
||||||
|
|
||||||
|
logger.debug('Chunk uploaded', {
|
||||||
|
uploadId,
|
||||||
|
chunkIndex,
|
||||||
|
receivedChunks: uploadMeta.receivedChunks.size,
|
||||||
|
expectedChunks: uploadMeta.expectedChunks,
|
||||||
|
progress: progress.toFixed(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
chunkIndex,
|
||||||
|
received: uploadMeta.receivedChunks.size,
|
||||||
|
expected: uploadMeta.expectedChunks,
|
||||||
|
progress,
|
||||||
|
complete: uploadMeta.receivedChunks.size === uploadMeta.expectedChunks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete the upload by merging all chunks
|
||||||
|
* @param {string} uploadId - Upload ID
|
||||||
|
* @returns {Promise<Object>} - Merged file info
|
||||||
|
*/
|
||||||
|
async function completeUpload(uploadId) {
|
||||||
|
const uploadMeta = activeUploads.get(uploadId);
|
||||||
|
|
||||||
|
if (!uploadMeta) {
|
||||||
|
throw new Error('Upload not found or expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all chunks received
|
||||||
|
if (uploadMeta.receivedChunks.size !== uploadMeta.expectedChunks) {
|
||||||
|
throw new Error(`Missing chunks: received ${uploadMeta.receivedChunks.size} of ${uploadMeta.expectedChunks}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadMeta.status = 'merging';
|
||||||
|
|
||||||
|
// Create temp file for merged result
|
||||||
|
const tempDir = path.join(getStoragePath(), 'temp', `merge_${Date.now()}_${Math.random().toString(36).substring(7)}`);
|
||||||
|
await fs.mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
const mergedFilePath = path.join(tempDir, uploadMeta.filename);
|
||||||
|
const writeStream = require('fs').createWriteStream(mergedFilePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Merge chunks in order
|
||||||
|
for (let i = 0; i < uploadMeta.expectedChunks; i++) {
|
||||||
|
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(i).padStart(6, '0')}`);
|
||||||
|
const chunkData = await fs.readFile(chunkPath);
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
writeStream.write(chunkData, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => writeStream.end(resolve));
|
||||||
|
|
||||||
|
// Verify file size
|
||||||
|
const stats = await fs.stat(mergedFilePath);
|
||||||
|
if (stats.size !== uploadMeta.fileSize) {
|
||||||
|
logger.warn('Merged file size mismatch', {
|
||||||
|
expected: uploadMeta.fileSize,
|
||||||
|
actual: stats.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up chunks
|
||||||
|
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
uploadMeta.status = 'completed';
|
||||||
|
activeUploads.delete(uploadId);
|
||||||
|
|
||||||
|
logger.info('Chunked upload completed', {
|
||||||
|
uploadId,
|
||||||
|
filename: uploadMeta.filename,
|
||||||
|
fileSize: stats.size,
|
||||||
|
eventId: uploadMeta.eventId
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: mergedFilePath,
|
||||||
|
filename: uploadMeta.filename,
|
||||||
|
size: stats.size,
|
||||||
|
mimeType: uploadMeta.mimeType,
|
||||||
|
eventId: uploadMeta.eventId,
|
||||||
|
tempDir
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
writeStream.destroy();
|
||||||
|
uploadMeta.status = 'failed';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort and clean up an upload
|
||||||
|
* @param {string} uploadId - Upload ID
|
||||||
|
*/
|
||||||
|
async function abortUpload(uploadId) {
|
||||||
|
const uploadMeta = activeUploads.get(uploadId);
|
||||||
|
|
||||||
|
if (uploadMeta) {
|
||||||
|
try {
|
||||||
|
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to clean up upload directory', { uploadId, error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
activeUploads.delete(uploadId);
|
||||||
|
|
||||||
|
logger.info('Chunked upload aborted', { uploadId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upload status
|
||||||
|
* @param {string} uploadId - Upload ID
|
||||||
|
* @returns {Object|null} - Upload status or null if not found
|
||||||
|
*/
|
||||||
|
function getUploadStatus(uploadId) {
|
||||||
|
const uploadMeta = activeUploads.get(uploadId);
|
||||||
|
|
||||||
|
if (!uploadMeta) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadId,
|
||||||
|
filename: uploadMeta.filename,
|
||||||
|
fileSize: uploadMeta.fileSize,
|
||||||
|
receivedChunks: uploadMeta.receivedChunks.size,
|
||||||
|
expectedChunks: uploadMeta.expectedChunks,
|
||||||
|
progress: (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100,
|
||||||
|
status: uploadMeta.status,
|
||||||
|
createdAt: uploadMeta.createdAt,
|
||||||
|
expiresAt: uploadMeta.expiresAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired uploads
|
||||||
|
*/
|
||||||
|
async function cleanupExpiredUploads() {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiredIds = [];
|
||||||
|
|
||||||
|
for (const [uploadId, meta] of activeUploads.entries()) {
|
||||||
|
if (now > meta.expiresAt) {
|
||||||
|
expiredIds.push(uploadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const uploadId of expiredIds) {
|
||||||
|
await abortUpload(uploadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiredIds.length > 0) {
|
||||||
|
logger.info(`Cleaned up ${expiredIds.length} expired uploads`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return expiredIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run cleanup every hour
|
||||||
|
setInterval(cleanupExpiredUploads, 60 * 60 * 1000);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initializeUpload,
|
||||||
|
uploadChunk,
|
||||||
|
completeUpload,
|
||||||
|
abortUpload,
|
||||||
|
getUploadStatus,
|
||||||
|
cleanupExpiredUploads,
|
||||||
|
CHUNK_SIZE
|
||||||
|
};
|
||||||
@@ -1,10 +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 { 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');
|
||||||
@@ -72,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';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,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,
|
||||||
@@ -155,50 +151,66 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate thumbnail or placeholder
|
// 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;
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
thumbnailPath = await generateVideoPlaceholder(newFilename);
|
// 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')}`);
|
||||||
|
|
||||||
|
const result = await processUploadedVideo(newPath, videoThumbnailPath);
|
||||||
|
videoMetadata = result.metadata;
|
||||||
|
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||||
} 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);
|
||||||
|
|
||||||
|
const photoData = {
|
||||||
|
event_id: eventId,
|
||||||
|
filename: newFilename,
|
||||||
|
path: relativePath,
|
||||||
|
thumbnail_path: relativeThumbPath,
|
||||||
|
type: photoType,
|
||||||
|
size_bytes: file.size,
|
||||||
|
uploaded_by: uploadedBy,
|
||||||
|
source_origin: 'managed',
|
||||||
|
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) {
|
if (supportsReturning) {
|
||||||
insertResult = await trx('photos')
|
insertResult = await trx('photos')
|
||||||
.insert({
|
.insert(photoData)
|
||||||
event_id: eventId,
|
|
||||||
filename: newFilename,
|
|
||||||
path: relativePath,
|
|
||||||
thumbnail_path: relativeThumbPath,
|
|
||||||
type: photoType,
|
|
||||||
size_bytes: file.size,
|
|
||||||
uploaded_by: uploadedBy,
|
|
||||||
source_origin: 'managed',
|
|
||||||
mime_type: resolvedMime
|
|
||||||
})
|
|
||||||
.returning('id');
|
.returning('id');
|
||||||
} else {
|
} else {
|
||||||
insertResult = await trx('photos').insert({
|
insertResult = await trx('photos').insert(photoData);
|
||||||
event_id: eventId,
|
|
||||||
filename: newFilename,
|
|
||||||
path: relativePath,
|
|
||||||
thumbnail_path: relativeThumbPath,
|
|
||||||
type: photoType,
|
|
||||||
size_bytes: file.size,
|
|
||||||
uploaded_by: uploadedBy,
|
|
||||||
source_origin: 'managed',
|
|
||||||
mime_type: resolvedMime
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertedId = Array.isArray(insertResult)
|
const insertedId = Array.isArray(insertResult)
|
||||||
@@ -218,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})`);
|
||||||
|
|||||||
@@ -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 = {
|
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
|
||||||
|
};
|
||||||
@@ -43,6 +43,13 @@ done
|
|||||||
|
|
||||||
>&2 echo "Target database \"$target_db\" is ready."
|
>&2 echo "Target database \"$target_db\" is ready."
|
||||||
|
|
||||||
|
# Ensure storage directories exist with proper permissions (Issue #67 fix)
|
||||||
|
# When host directories are bind-mounted, the container's built-in directories are overridden
|
||||||
|
# This ensures the required directory structure exists before the application starts
|
||||||
|
echo "Ensuring storage directories exist..."
|
||||||
|
STORAGE_BASE="${STORAGE_PATH:-/app/storage}"
|
||||||
|
mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true
|
||||||
|
|
||||||
# Run migrations (use safe runner in production)
|
# Run migrations (use safe runner in production)
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
if [ "$NODE_ENV" = "production" ]; then
|
if [ "$NODE_ENV" = "production" ]; then
|
||||||
|
|||||||
+8
-3
@@ -12,6 +12,9 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
|||||||
LABEL org.opencontainers.image.description="PicPeak Frontend Application"
|
LABEL org.opencontainers.image.description="PicPeak Frontend Application"
|
||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
|
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||||
|
RUN npm install -g npm@latest
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -27,11 +30,13 @@ COPY . .
|
|||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# Production stage (use Alpine with patched libpng)
|
||||||
FROM nginx:alpine
|
FROM nginx:1.27-alpine3.21
|
||||||
|
|
||||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||||
RUN apk upgrade --no-cache
|
RUN apk upgrade --no-cache
|
||||||
|
# Ensure libpng includes CVE fixes (pull patched version from edge)
|
||||||
|
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main 'libpng>=1.6.51-r0'
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install runtime dependencies
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
@@ -63,4 +68,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
USER nginx
|
USER nginx
|
||||||
|
|
||||||
# Start nginx
|
# Start nginx
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
Generated
+1066
-1872
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -99,6 +99,106 @@ class PhotosService {
|
|||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chunked upload methods for large files (videos up to 10GB)
|
||||||
|
private CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks
|
||||||
|
|
||||||
|
async initChunkedUpload(
|
||||||
|
eventId: number,
|
||||||
|
filename: string,
|
||||||
|
fileSize: number,
|
||||||
|
mimeType: string
|
||||||
|
): Promise<{ uploadId: string; chunkSize: number; expectedChunks: number }> {
|
||||||
|
const totalChunks = Math.ceil(fileSize / this.CHUNK_SIZE);
|
||||||
|
const response = await api.post(`/admin/photos/${eventId}/chunked-upload/init`, {
|
||||||
|
filename,
|
||||||
|
fileSize,
|
||||||
|
mimeType,
|
||||||
|
totalChunks
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadChunk(
|
||||||
|
eventId: number,
|
||||||
|
uploadId: string,
|
||||||
|
chunkIndex: number,
|
||||||
|
chunkData: Blob
|
||||||
|
): Promise<{ progress: number; complete: boolean }> {
|
||||||
|
const response = await api.post(
|
||||||
|
`/admin/photos/${eventId}/chunked-upload/${uploadId}/chunk/${chunkIndex}`,
|
||||||
|
chunkData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeChunkedUpload(
|
||||||
|
eventId: number,
|
||||||
|
uploadId: string,
|
||||||
|
categoryId?: number | null
|
||||||
|
): Promise<{ success: boolean; uploaded: number; photos: AdminPhoto[] }> {
|
||||||
|
const response = await api.post(
|
||||||
|
`/admin/photos/${eventId}/chunked-upload/${uploadId}/complete`,
|
||||||
|
{ category_id: categoryId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async abortChunkedUpload(eventId: number, uploadId: string): Promise<void> {
|
||||||
|
await api.delete(`/admin/photos/${eventId}/chunked-upload/${uploadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadLargeFile(
|
||||||
|
eventId: number,
|
||||||
|
file: File,
|
||||||
|
categoryId?: number | null,
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
|
): Promise<AdminPhoto[]> {
|
||||||
|
// Initialize upload
|
||||||
|
const { uploadId, expectedChunks } = await this.initChunkedUpload(
|
||||||
|
eventId,
|
||||||
|
file.name,
|
||||||
|
file.size,
|
||||||
|
file.type
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Upload chunks
|
||||||
|
for (let i = 0; i < expectedChunks; i++) {
|
||||||
|
const start = i * this.CHUNK_SIZE;
|
||||||
|
const end = Math.min(start + this.CHUNK_SIZE, file.size);
|
||||||
|
const chunk = file.slice(start, end);
|
||||||
|
|
||||||
|
const result = await this.uploadChunk(eventId, uploadId, i, chunk);
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(result.progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete upload
|
||||||
|
const result = await this.completeChunkedUpload(eventId, uploadId, categoryId);
|
||||||
|
return result.photos;
|
||||||
|
} catch (error) {
|
||||||
|
// Abort on error
|
||||||
|
try {
|
||||||
|
await this.abortChunkedUpload(eventId, uploadId);
|
||||||
|
} catch (abortError) {
|
||||||
|
console.error('Failed to abort upload:', abortError);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file should use chunked upload (> 100MB)
|
||||||
|
shouldUseChunkedUpload(fileSize: number): boolean {
|
||||||
|
return fileSize > 100 * 1024 * 1024; // 100MB threshold
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const photosService = new PhotosService();
|
export const photosService = new PhotosService();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -823,8 +823,8 @@ EOF
|
|||||||
|
|
||||||
create_systemd_services() {
|
create_systemd_services() {
|
||||||
log_step "Creating systemd services..."
|
log_step "Creating systemd services..."
|
||||||
|
|
||||||
# Backend service
|
# Backend service (includes workers - fileWatcher, expirationChecker are started by server.js)
|
||||||
cat > /etc/systemd/system/picpeak-backend.service <<EOF
|
cat > /etc/systemd/system/picpeak-backend.service <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=PicPeak Backend Service
|
Description=PicPeak Backend Service
|
||||||
@@ -844,27 +844,10 @@ StandardError=append:$NATIVE_APP_DIR/logs/backend-error.log
|
|||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Workers service
|
|
||||||
cat > /etc/systemd/system/picpeak-workers.service <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=PicPeak Background Workers
|
|
||||||
After=network.target picpeak-backend.service
|
|
||||||
|
|
||||||
[Service]
|
# Note: Workers (fileWatcher, expirationChecker, emailProcessor) are now started
|
||||||
Type=simple
|
# automatically by server.js, so a separate workers service is no longer needed.
|
||||||
User=$NATIVE_APP_USER
|
# Legacy picpeak-workers.service will be cleaned up during installation.
|
||||||
WorkingDirectory=$NATIVE_APP_DIR/app/backend
|
|
||||||
Environment="NODE_ENV=production"
|
|
||||||
ExecStart=/usr/bin/node src/services/workerManager.js
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=append:$NATIVE_APP_DIR/logs/workers.log
|
|
||||||
StandardError=append:$NATIVE_APP_DIR/logs/workers-error.log
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_caddy() {
|
setup_caddy() {
|
||||||
|
|||||||
Reference in New Issue
Block a user