Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c7498385f | |||
| 1ae63890ff | |||
| 5f1affafd8 | |||
| 8315c11d34 | |||
| 0043f2aaf4 | |||
| d494eda301 | |||
| a59a4232ff | |||
| 77326a91ca | |||
| 0d95eab86a | |||
| f3482a9a78 | |||
| 68a9dc5749 | |||
| 8c87f1537b | |||
| 97e54355fb |
@@ -135,6 +135,31 @@ Perfect for:
|
||||
- **Docker**: v20.10.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
|
||||
|
||||
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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
| **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.licenses="MIT"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
RUN npm install -g npm@latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
# Install dependencies (--omit=dev replaces deprecated --only=production)
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
@@ -30,6 +33,9 @@ WORKDIR /app
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
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
|
||||
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/lib-storage": "^3.850.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.12.2",
|
||||
@@ -26,6 +27,7 @@
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"form-data": "^4.0.4",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
|
||||
+3
-3
@@ -324,9 +324,9 @@ async function initializeRateLimiters() {
|
||||
|
||||
// Note: Rate limiters will be initialized after database connection
|
||||
|
||||
// Body parsing middleware with increased limits for large batch uploads
|
||||
app.use(express.json({ limit: '500mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '500mb' }));
|
||||
<<<<<<< HEAD
|
||||
app.use(express.json({ limit: '10gb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
|
||||
|
||||
// Request logging for API routes (with timestamps)
|
||||
const apiRequestLogger = (req, res, next) => {
|
||||
|
||||
+245
-216
@@ -4,36 +4,18 @@ const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { generateThumbnail, ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor');
|
||||
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const chunkedUpload = require('../services/chunkedUploadService');
|
||||
const router = express.Router();
|
||||
const { isVideoMimeType, validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
const mime = require('mime-types');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
const parseCategoryId = (value) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||
return value === 0 ? null : value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === 'null') return null;
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const parsed = parseInt(trimmed, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed === 0 ? null : parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Configure multer for file uploads
|
||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||
const storage = multer.diskStorage({
|
||||
@@ -63,10 +45,12 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
});
|
||||
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit per file
|
||||
fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
@@ -75,23 +59,31 @@ const upload = multer({
|
||||
headerPairs: 2000 // Maximum number of header key-value pairs
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images and common video formats with proper validation
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
|
||||
|
||||
// Accept images and videos with proper validation
|
||||
const allowedMimeTypes = [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
];
|
||||
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
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
|
||||
abortOnLimit: true
|
||||
});
|
||||
|
||||
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Create content validator middleware
|
||||
const validateUploadContent = createFileUploadValidator({
|
||||
allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'],
|
||||
maxFileSize: 50 * 1024 * 1024,
|
||||
allowedTypes: [
|
||||
'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
|
||||
});
|
||||
|
||||
@@ -131,7 +123,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
console.error('Multer error:', err);
|
||||
if (err instanceof multer.MulterError) {
|
||||
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') {
|
||||
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
|
||||
const numericCategoryId = parseCategoryId(category_id);
|
||||
|
||||
const resolveCategoryName = (type) => {
|
||||
if (type === 'collage') return 'collages';
|
||||
if (type === 'video') return 'videos';
|
||||
return 'individual';
|
||||
};
|
||||
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
|
||||
// Determine photo type from category_id parameter (for backwards compatibility)
|
||||
let photoType = 'individual'; // default
|
||||
let categoryName = 'individual';
|
||||
|
||||
if (parsedCategoryId === 1 || category_id === 'collage') {
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
} else if (parsedCategoryId === 2 || category_id === 'individual') {
|
||||
photoType = 'individual';
|
||||
categoryName = 'individual';
|
||||
}
|
||||
|
||||
// For backwards compatibility, accept string values
|
||||
if (category_id === 'collage') {
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
}
|
||||
|
||||
// Create final destination directory
|
||||
const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug);
|
||||
await fs.mkdir(finalDestPath, { recursive: true });
|
||||
@@ -208,49 +212,20 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
const trx = await db.transaction();
|
||||
|
||||
try {
|
||||
const preparedBatch = batch.map((file) => {
|
||||
const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream';
|
||||
const video = isVideoMimeType(resolvedMime, file?.originalname);
|
||||
let inferredType = video ? 'video' : 'individual';
|
||||
|
||||
if (!video) {
|
||||
if (numericCategoryId === 1 || category_id === 'collage') {
|
||||
inferredType = 'collage';
|
||||
} else if (numericCategoryId === 2 || category_id === 'individual') {
|
||||
inferredType = 'individual';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file,
|
||||
resolvedMime,
|
||||
isVideo: video,
|
||||
photoType: inferredType
|
||||
};
|
||||
});
|
||||
|
||||
const typesInBatch = Array.from(new Set(preparedBatch.map((item) => item.photoType)));
|
||||
const typeCounters = {};
|
||||
|
||||
if (typesInBatch.length > 0) {
|
||||
const existingCounts = await trx('photos')
|
||||
.where({ event_id: eventId })
|
||||
.whereIn('type', typesInBatch)
|
||||
.select('type')
|
||||
.count('id as count')
|
||||
.groupBy('type');
|
||||
|
||||
existingCounts.forEach((row) => {
|
||||
typeCounters[row.type] = parseInt(row.count) || 0;
|
||||
});
|
||||
}
|
||||
// Get initial counter for this batch based on photo type
|
||||
const existingCount = await trx('photos')
|
||||
.where({ event_id: eventId, type: photoType })
|
||||
.count('id as count')
|
||||
.first();
|
||||
let batchCounter = (parseInt(existingCount.count) || 0) + 1;
|
||||
|
||||
const batchPhotos = [];
|
||||
const fileRenameOperations = []; // Store rename operations to do after commit
|
||||
|
||||
// First pass: prepare data and move files from temp to final location
|
||||
for (let fileIndex = 0; fileIndex < preparedBatch.length; fileIndex++) {
|
||||
const { file, resolvedMime, isVideo, photoType } = preparedBatch[fileIndex];
|
||||
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
|
||||
const file = batch[fileIndex];
|
||||
const counter = batchCounter + fileIndex;
|
||||
const tempPath = file.path; // Original temp path
|
||||
|
||||
try {
|
||||
@@ -259,15 +234,12 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
if (tempStats.size === 0) {
|
||||
throw new Error('File is empty - upload may have been interrupted');
|
||||
}
|
||||
|
||||
typeCounters[photoType] = (typeCounters[photoType] || 0) + 1;
|
||||
const counter = typeCounters[photoType];
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
resolveCategoryName(photoType),
|
||||
categoryName,
|
||||
counter,
|
||||
extension
|
||||
);
|
||||
@@ -284,10 +256,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
type: photoType,
|
||||
size_bytes: tempStats.size, // Use actual file size from stat
|
||||
category_id: numericCategoryId,
|
||||
source_origin: 'managed',
|
||||
mime_type: resolvedMime
|
||||
size_bytes: tempStats.size // Use actual file size from stat
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
@@ -297,8 +266,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
tempPath: tempPath,
|
||||
finalPath: finalPath,
|
||||
filename: newFilename,
|
||||
photoData: photoData,
|
||||
isVideo
|
||||
photoData: photoData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error preparing file ${file.originalname}:`, error);
|
||||
@@ -308,7 +276,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
|
||||
// Insert all photos in this batch
|
||||
if (batchPhotos.length > 0) {
|
||||
console.log(`Inserting batch of ${batchPhotos.length} files with types: ${typesInBatch.join(', ')}`);
|
||||
console.log(`Inserting batch of ${batchPhotos.length} photos with type: ${photoType}`);
|
||||
|
||||
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
|
||||
|
||||
@@ -333,31 +301,27 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
}
|
||||
|
||||
// Generate thumbnail with final path
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
thumbnailPath = operation.isVideo
|
||||
? await generateVideoPlaceholder(operation.filename)
|
||||
: await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ thumbnail_path: thumbnailPath });
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ thumbnail_path: thumbnailPath });
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Add to successful uploads
|
||||
uploadedPhotos.push({
|
||||
id: insertedIds[idx]?.id || insertedIds[idx],
|
||||
filename: operation.filename,
|
||||
size: operation.photoData.size_bytes,
|
||||
category_id: operation.photoData.category_id,
|
||||
type: operation.photoData.type,
|
||||
mime_type: operation.photoData.mime_type
|
||||
category_id: operation.photoData.category_id
|
||||
});
|
||||
} catch (moveError) {
|
||||
console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, moveError);
|
||||
@@ -424,7 +388,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
// Prepare response
|
||||
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
|
||||
const response = {
|
||||
message: `Successfully uploaded ${uploadedPhotos.length} files`,
|
||||
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
|
||||
photos: uploadedPhotos,
|
||||
totalFiles: totalAttempted,
|
||||
successCount: uploadedPhotos.length,
|
||||
@@ -434,7 +398,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
||||
// Include error details if any files failed
|
||||
if (totalInvalidFiles.length > 0) {
|
||||
response.errors = totalInvalidFiles;
|
||||
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} files. ${totalInvalidFiles.length} failed.`;
|
||||
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
@@ -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
|
||||
if (photo.thumbnail_path) {
|
||||
const thumbPath = path.join(storagePath, photo.thumbnail_path);
|
||||
const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path);
|
||||
try {
|
||||
// Check if file exists before attempting to delete
|
||||
await fs.access(thumbPath);
|
||||
@@ -528,9 +492,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
const updateData = {};
|
||||
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
// These are string values that map to the photo.type field
|
||||
@@ -551,21 +513,11 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Update photo
|
||||
const normalizedCategoryId = parseCategoryId(category_id);
|
||||
|
||||
await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.update(updateData);
|
||||
|
||||
// Fetch and return updated photo for confirmation
|
||||
const updatedPhoto = await db('photos')
|
||||
.where({ id: photoId })
|
||||
.first();
|
||||
|
||||
res.json({
|
||||
message: 'Photo updated successfully',
|
||||
photo: updatedPhoto
|
||||
});
|
||||
res.json({ message: 'Photo updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error updating photo:', error);
|
||||
res.status(500).json({ error: 'Failed to update photo' });
|
||||
@@ -645,22 +597,22 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { photoIds, updates } = req.body;
|
||||
|
||||
|
||||
if (!Array.isArray(photoIds) || photoIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Invalid photo IDs' });
|
||||
}
|
||||
|
||||
|
||||
// Verify all photos belong to the event
|
||||
const photoCount = await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
.where('event_id', eventId)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
|
||||
if (parseInt(photoCount.count) !== photoIds.length) {
|
||||
return res.status(400).json({ error: 'Some photos do not belong to this event' });
|
||||
}
|
||||
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
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) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id, type, media_type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||
|
||||
let query = db('photos')
|
||||
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
|
||||
.where({ 'photos.event_id': eventId })
|
||||
.select(
|
||||
'photos.*',
|
||||
'pc.name as category_display_name',
|
||||
'pc.slug as category_display_slug'
|
||||
);
|
||||
.select('photos.*');
|
||||
|
||||
// Filter by type (individual/collage) - category_id maps to type
|
||||
if (category_id !== undefined) {
|
||||
if (category_id === '') {
|
||||
// No filter when empty string is provided
|
||||
} else if (category_id === '0') {
|
||||
query = query.whereNull('photos.category_id');
|
||||
} else if (/^\d+$/.test(category_id)) {
|
||||
query = query.where('photos.category_id', parseInt(category_id, 10));
|
||||
if (category_id === '' || category_id === '0') {
|
||||
// For backwards compatibility, empty category means no filter
|
||||
// Don't filter anything
|
||||
} else if (category_id === 'individual' || category_id === 'collage') {
|
||||
query = query.where({ 'photos.type': category_id });
|
||||
}
|
||||
@@ -762,20 +706,6 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
if (type) {
|
||||
query = query.where({ 'photos.type': type });
|
||||
}
|
||||
|
||||
if (media_type === 'video') {
|
||||
query = query.where((qb) => {
|
||||
qb.where('photos.type', 'video')
|
||||
.orWhere('photos.mime_type', 'like', 'video/%');
|
||||
});
|
||||
} else if (media_type === 'photo') {
|
||||
query = query.where((qb) => {
|
||||
qb.whereNot('photos.type', 'video')
|
||||
.andWhere(function(inner) {
|
||||
inner.whereNull('photos.mime_type').orWhere('photos.mime_type', 'not like', 'video/%');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Search by filename
|
||||
if (search) {
|
||||
@@ -792,11 +722,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const photos = await query.orderBy(orderByColumn, order);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ photos: [] });
|
||||
}
|
||||
|
||||
|
||||
// Get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.whereIn('photo_id', photos.map(p => p.id))
|
||||
@@ -813,37 +739,26 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
});
|
||||
|
||||
res.json({
|
||||
photos: photos.map(photo => {
|
||||
const mediaType = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video' ? 'video' : 'photo';
|
||||
const categoryName = photo.category_display_name
|
||||
|| (photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages');
|
||||
const normalizedCategoryId = photo.category_id !== null && photo.category_id !== undefined
|
||||
? (Number.isNaN(Number(photo.category_id)) ? photo.category_id : Number(photo.category_id))
|
||||
: null;
|
||||
|
||||
return ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
// Use the correct admin photos router base for serving images
|
||||
url: `/admin/photos/${eventId}/photo/${photo.id}`,
|
||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||
type: photo.type,
|
||||
category_id: normalizedCategoryId,
|
||||
mime_type: photo.mime_type,
|
||||
media_type: mediaType,
|
||||
category_name: categoryName,
|
||||
category_slug: photo.category_display_slug || photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0
|
||||
});
|
||||
})
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
// Use the correct admin photos router base for serving images
|
||||
url: `/admin/photos/${eventId}/photo/${photo.id}`,
|
||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||
type: photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
comment_count: commentMap[photo.id] || 0,
|
||||
like_count: photo.like_count || 0,
|
||||
favorite_count: photo.favorite_count || 0
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching photos:', error);
|
||||
@@ -875,10 +790,8 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
const mimeType = photo.mime_type || `image/${path.extname(photo.filename).slice(1)}`;
|
||||
|
||||
// Set appropriate headers
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`);
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
@@ -904,30 +817,8 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
let thumbnailPath = photo.thumbnail_path;
|
||||
const thumbMissing = !thumbnailPath || !(await (async () => {
|
||||
try {
|
||||
const fs = require('fs').promises;
|
||||
await fs.access(path.join(getStoragePath(), thumbnailPath));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})());
|
||||
|
||||
if (isVideo) {
|
||||
if (!thumbnailPath || thumbMissing) {
|
||||
const regenerated = await generateVideoPlaceholder(photo.filename, { regenerate: true });
|
||||
if (regenerated) {
|
||||
thumbnailPath = regenerated;
|
||||
await db('photos').where({ id: photo.id }).update({ thumbnail_path: regenerated });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
thumbnailPath = await ensureThumbnail(photo);
|
||||
}
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
if (!thumbnailPath) {
|
||||
console.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
@@ -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;
|
||||
|
||||
+81
-103
@@ -10,8 +10,6 @@ const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor');
|
||||
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
@@ -250,17 +248,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
.distinct('type')
|
||||
.orderBy('type', 'asc');
|
||||
|
||||
const resolveCategoryName = (type, mimeType, filename) => {
|
||||
if (type === 'video' || isVideoMimeType(mimeType, filename)) return 'Videos';
|
||||
if (type === 'individual') return 'Individual Photos';
|
||||
if (type === 'collage') return 'Collages';
|
||||
return type || 'Uncategorized';
|
||||
};
|
||||
|
||||
// Convert types to category-like objects
|
||||
const categories = categoryResults.map(result => ({
|
||||
id: result.type,
|
||||
name: resolveCategoryName(result.type),
|
||||
name: result.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
slug: result.type,
|
||||
is_global: false
|
||||
}));
|
||||
@@ -301,13 +292,10 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
},
|
||||
categories: categories,
|
||||
photos: photos.map(photo => {
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
const mediaType = isVideo ? 'video' : 'photo';
|
||||
const useJwtUrl = isVideo || (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
||||
const categoryName = resolveCategoryName(photo.type, photo.mime_type, photo.filename);
|
||||
|
||||
return {
|
||||
id: photo.id,
|
||||
@@ -318,14 +306,12 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||
type: photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: categoryName,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
media_type: mediaType,
|
||||
mime_type: photo.mime_type,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl && !isVideo,
|
||||
requires_token: !useJwtUrl,
|
||||
// Feedback data
|
||||
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
|
||||
average_rating: photo.average_rating || 0,
|
||||
@@ -359,7 +345,6 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
// Update download count
|
||||
await db('photos').where('id', photoId).increment('download_count', 1);
|
||||
|
||||
@@ -388,7 +373,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
@@ -401,9 +386,6 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file
|
||||
if (isVideo) {
|
||||
res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' });
|
||||
}
|
||||
res.download(filePath, photo.filename, (downloadError) => {
|
||||
if (downloadError) {
|
||||
logger.error('Error streaming gallery download', {
|
||||
@@ -481,16 +463,14 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
let archiveName;
|
||||
if (hasMultipleTypes) {
|
||||
// Use photo type as folder
|
||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages';
|
||||
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||
archiveName = path.join(folderName, photo.filename);
|
||||
} else {
|
||||
// No folders, just the filename
|
||||
archiveName = photo.filename;
|
||||
}
|
||||
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name: archiveName });
|
||||
@@ -585,9 +565,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
try {
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name });
|
||||
@@ -632,43 +610,41 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
|
||||
|
||||
// View single photo (with watermark if enabled)
|
||||
router.get('/:slug/photo/:photoId',
|
||||
verifyGalleryAccess,
|
||||
router.get('/:slug/photo/:photoId',
|
||||
verifyGalleryAccess,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
const numericPhotoId = parseInt(photoId, 10);
|
||||
if (!Number.isInteger(numericPhotoId)) {
|
||||
return res.status(400).json({ error: 'Invalid photo id' });
|
||||
}
|
||||
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: numericPhotoId, event_id: req.event.id })
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const isVideo = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video';
|
||||
// Check if this is a video
|
||||
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
|
||||
|
||||
// Check protection level - basic and standard protection allow direct JWT access
|
||||
const protectionLevel = req.event.protection_level || 'standard';
|
||||
|
||||
if (!isVideo && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
|
||||
|
||||
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
|
||||
// For enhanced/maximum protection, redirect to secure endpoint
|
||||
return res.status(302).json({
|
||||
return res.status(302).json({
|
||||
error: 'Secure access required',
|
||||
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
|
||||
photoId: photoId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Resolve the absolute file path for this photo, supporting both managed and external reference modes
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
|
||||
|
||||
|
||||
|
||||
// Log access - temporarily disabled for debugging
|
||||
// await secureImageService.logImageAccess(
|
||||
// photoId,
|
||||
@@ -676,20 +652,61 @@ router.get('/:slug/photo/:photoId',
|
||||
// req.clientInfo,
|
||||
// 'view_basic'
|
||||
// );
|
||||
|
||||
|
||||
// Handle video streaming with range requests
|
||||
if (isVideo) {
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = req.headers.range;
|
||||
|
||||
if (range) {
|
||||
// Parse range header
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
const file = fs.createReadStream(filePath, { start, end });
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Type': photo.mime_type || 'video/mp4',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
file.pipe(res);
|
||||
} else {
|
||||
// No range request, send entire file
|
||||
res.writeHead(200, {
|
||||
'Content-Length': fileSize,
|
||||
'Content-Type': photo.mime_type || 'video/mp4',
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle images (existing logic)
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file with basic protection headers
|
||||
@@ -699,9 +716,6 @@ router.get('/:slug/photo/:photoId',
|
||||
});
|
||||
// Ensure absolute path for res.sendFile
|
||||
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
||||
if (isVideo) {
|
||||
res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' });
|
||||
}
|
||||
res.sendFile(absolutePath);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -722,62 +736,32 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
const numericPhotoId = parseInt(photoId, 10);
|
||||
if (!Number.isInteger(numericPhotoId)) {
|
||||
return res.status(400).json({ error: 'Invalid photo id' });
|
||||
}
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: numericPhotoId, event_id: req.event.id })
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
}
|
||||
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
|
||||
|
||||
let thumbnailPath = photo.thumbnail_path;
|
||||
let thumbFilePath = thumbnailPath ? path.join(getStoragePath(), thumbnailPath) : null;
|
||||
|
||||
if (isVideo) {
|
||||
const fs = require('fs').promises;
|
||||
const missing = !thumbFilePath || !(await (async () => { try { await fs.access(thumbFilePath); return true; } catch { return false; } })());
|
||||
if (missing) {
|
||||
const regenerated = await generateVideoPlaceholder(photo.filename, { regenerate: true });
|
||||
if (regenerated) {
|
||||
thumbnailPath = regenerated;
|
||||
thumbFilePath = path.join(getStoragePath(), regenerated);
|
||||
await db('photos').where({ id: photo.id }).update({ thumbnail_path: regenerated });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
thumbnailPath = await ensureThumbnail(photo);
|
||||
thumbFilePath = thumbnailPath ? path.join(getStoragePath(), thumbnailPath) : null;
|
||||
}
|
||||
|
||||
if (!thumbFilePath) {
|
||||
if (!photo || !photo.thumbnail_path) {
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
|
||||
|
||||
// Check if file exists
|
||||
const fs = require('fs').promises;
|
||||
try {
|
||||
await fs.access(thumbFilePath);
|
||||
await fs.access(thumbPath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
// Log thumbnail access
|
||||
try {
|
||||
await secureImageService.logImageAccess(
|
||||
numericPhotoId,
|
||||
req.event.id,
|
||||
req.clientInfo,
|
||||
'thumbnail'
|
||||
);
|
||||
} catch (logErr) {
|
||||
logger.warn('Thumbnail access log failed', { photoId, eventId: req.event.id, error: logErr.message });
|
||||
}
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
req.event.id,
|
||||
req.clientInfo,
|
||||
'thumbnail'
|
||||
);
|
||||
|
||||
// Set appropriate headers with enhanced security
|
||||
res.set({
|
||||
@@ -789,14 +773,8 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
});
|
||||
|
||||
// Send file
|
||||
res.sendFile(path.resolve(thumbFilePath));
|
||||
res.sendFile(path.resolve(thumbPath));
|
||||
} catch (error) {
|
||||
console.error('Thumbnail route error', {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id,
|
||||
});
|
||||
logger.error('Error serving thumbnail:', {
|
||||
error: error.message,
|
||||
photoId: req.params.photoId,
|
||||
|
||||
@@ -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 fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
|
||||
const { generateThumbnail } = require('./imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
|
||||
const mime = require('mime-types');
|
||||
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -72,15 +71,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
const trx = await db.transaction();
|
||||
|
||||
try {
|
||||
const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream';
|
||||
const isVideo = isVideoMimeType(resolvedMime, file?.originalname);
|
||||
|
||||
// Count existing photos to generate sequence number
|
||||
let counter = 1;
|
||||
let photoType = isVideo ? 'video' : 'individual'; // default type
|
||||
let photoType = 'individual'; // default type
|
||||
|
||||
// If categoryId is provided and matches photo types, use it as type
|
||||
if (!isVideo && categoryId === 'collage') {
|
||||
if (categoryId === 'collage') {
|
||||
photoType = 'collage';
|
||||
}
|
||||
|
||||
@@ -95,7 +91,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const categoryName = photoType === 'collage' ? 'collages' : (isVideo ? 'videos' : 'individual');
|
||||
const categoryName = photoType === 'collage' ? 'collages' : 'individual';
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
categoryName,
|
||||
@@ -155,50 +151,66 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
}
|
||||
}
|
||||
|
||||
// Generate thumbnail or placeholder
|
||||
let thumbnailPath = null;
|
||||
// Determine if this is a video or image
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Generate thumbnail and extract metadata
|
||||
let thumbnailPath;
|
||||
let videoMetadata = null;
|
||||
|
||||
if (isVideo) {
|
||||
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 {
|
||||
// Process image: generate thumbnail
|
||||
thumbnailPath = await generateThumbnail(newPath);
|
||||
}
|
||||
|
||||
|
||||
// Calculate relative paths
|
||||
const storagePath = getStoragePath();
|
||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
|
||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||
|
||||
// Add to database with uploaded_by field
|
||||
|
||||
// Add to database with uploaded_by field and media metadata
|
||||
let insertResult;
|
||||
const clientName = trx?.client?.config?.client;
|
||||
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
|
||||
|
||||
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) {
|
||||
insertResult = await trx('photos')
|
||||
.insert({
|
||||
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
|
||||
})
|
||||
.insert(photoData)
|
||||
.returning('id');
|
||||
} else {
|
||||
insertResult = await trx('photos').insert({
|
||||
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
|
||||
});
|
||||
insertResult = await trx('photos').insert(photoData);
|
||||
}
|
||||
|
||||
const insertedId = Array.isArray(insertResult)
|
||||
@@ -218,8 +230,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
id: photoId,
|
||||
filename: newFilename,
|
||||
size: file.size,
|
||||
type: photoType,
|
||||
media_type: isVideo ? 'video' : 'photo'
|
||||
type: photoType
|
||||
});
|
||||
|
||||
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Set FFmpeg path
|
||||
ffmpeg.setFfmpegPath(ffmpegPath);
|
||||
|
||||
/**
|
||||
* Extract video metadata using FFmpeg
|
||||
* @param {string} videoPath - Path to the video file
|
||||
* @returns {Promise<Object>} - Video metadata
|
||||
*/
|
||||
async function extractVideoMetadata(videoPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg.ffprobe(videoPath, (err, metadata) => {
|
||||
if (err) {
|
||||
logger.error('Error extracting video metadata', { error: err.message, videoPath });
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
try {
|
||||
const videoStream = metadata.streams.find(s => s.codec_type === 'video');
|
||||
const audioStream = metadata.streams.find(s => s.codec_type === 'audio');
|
||||
|
||||
const result = {
|
||||
duration: Math.floor(metadata.format.duration || 0),
|
||||
width: videoStream?.width || null,
|
||||
height: videoStream?.height || null,
|
||||
videoCodec: videoStream?.codec_name || null,
|
||||
audioCodec: audioStream?.codec_name || null,
|
||||
size: metadata.format.size || 0,
|
||||
bitrate: metadata.format.bit_rate || null,
|
||||
format: metadata.format.format_name || null
|
||||
};
|
||||
|
||||
resolve(result);
|
||||
} catch (parseErr) {
|
||||
logger.error('Error parsing video metadata', { error: parseErr.message });
|
||||
reject(parseErr);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail from video
|
||||
* @param {string} videoPath - Path to the video file
|
||||
* @param {string} outputPath - Path for the output thumbnail
|
||||
* @param {Object} options - Thumbnail options
|
||||
* @returns {Promise<string>} - Path to generated thumbnail
|
||||
*/
|
||||
async function generateVideoThumbnail(videoPath, outputPath, options = {}) {
|
||||
const {
|
||||
timeOffset = '00:00:01', // Take screenshot at 1 second
|
||||
size = '300x300',
|
||||
quality = 2 // 1-31, lower is better quality
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(videoPath)
|
||||
.screenshots({
|
||||
timestamps: [timeOffset],
|
||||
filename: path.basename(outputPath),
|
||||
folder: path.dirname(outputPath),
|
||||
size: size
|
||||
})
|
||||
.on('end', () => {
|
||||
logger.info('Video thumbnail generated', { videoPath, outputPath });
|
||||
resolve(outputPath);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
logger.error('Error generating video thumbnail', { error: err.message, videoPath });
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a file is a valid video
|
||||
* @param {string} videoPath - Path to the video file
|
||||
* @returns {Promise<boolean>} - True if valid video
|
||||
*/
|
||||
async function isValidVideo(videoPath) {
|
||||
try {
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
return metadata.duration > 0 && metadata.width > 0 && metadata.height > 0;
|
||||
} catch (error) {
|
||||
logger.error('Video validation failed', { error: error.message, videoPath });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video duration in seconds
|
||||
* @param {string} videoPath - Path to the video file
|
||||
* @returns {Promise<number>} - Duration in seconds
|
||||
*/
|
||||
async function getVideoDuration(videoPath) {
|
||||
try {
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
return metadata.duration;
|
||||
} catch (error) {
|
||||
logger.error('Error getting video duration', { error: error.message });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process uploaded video - extract metadata and generate thumbnail
|
||||
* @param {string} videoPath - Path to the video file
|
||||
* @param {string} thumbnailPath - Path for the thumbnail
|
||||
* @param {Object} options - Processing options
|
||||
* @returns {Promise<Object>} - Video metadata and processing result
|
||||
*/
|
||||
async function processUploadedVideo(videoPath, thumbnailPath, options = {}) {
|
||||
try {
|
||||
// Validate video
|
||||
const isValid = await isValidVideo(videoPath);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid video file');
|
||||
}
|
||||
|
||||
// Extract metadata
|
||||
const metadata = await extractVideoMetadata(videoPath);
|
||||
|
||||
// Generate thumbnail
|
||||
await generateVideoThumbnail(videoPath, thumbnailPath, options);
|
||||
|
||||
// Verify thumbnail was created
|
||||
try {
|
||||
await fs.access(thumbnailPath);
|
||||
} catch (err) {
|
||||
throw new Error('Thumbnail generation failed');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata,
|
||||
thumbnailPath
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error processing video', { error: error.message, videoPath });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video thumbnail at specific time
|
||||
* @param {string} videoPath - Path to video file
|
||||
* @param {string} outputPath - Output path for thumbnail
|
||||
* @param {number} timeInSeconds - Time in seconds to capture thumbnail
|
||||
* @returns {Promise<string>} - Path to thumbnail
|
||||
*/
|
||||
async function getThumbnailAtTime(videoPath, outputPath, timeInSeconds = 1) {
|
||||
const hours = Math.floor(timeInSeconds / 3600);
|
||||
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
||||
const seconds = Math.floor(timeInSeconds % 60);
|
||||
const timeOffset = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
|
||||
return generateVideoThumbnail(videoPath, outputPath, { timeOffset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is a video based on MIME type
|
||||
* @param {string} mimeType - MIME type of the file
|
||||
* @returns {boolean} - True if video MIME type
|
||||
*/
|
||||
function isVideoMimeType(mimeType) {
|
||||
return mimeType && mimeType.startsWith('video/');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractVideoMetadata,
|
||||
generateVideoThumbnail,
|
||||
isValidVideo,
|
||||
getVideoDuration,
|
||||
processUploadedVideo,
|
||||
getThumbnailAtTime,
|
||||
isVideoMimeType
|
||||
};
|
||||
@@ -45,7 +45,7 @@ function isPathSafe(filePath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced MIME type validation
|
||||
* Enhanced MIME type validation for images and videos
|
||||
*/
|
||||
const ALLOWED_IMAGE_TYPES = {
|
||||
'image/jpeg': {
|
||||
@@ -78,24 +78,43 @@ const ALLOWED_IMAGE_TYPES = {
|
||||
extensions: ['.svg'],
|
||||
// SVG files are XML-based text files, so we skip magic number validation
|
||||
magicNumbers: null
|
||||
},
|
||||
// Video types are included here to keep validation centralized
|
||||
}
|
||||
};
|
||||
|
||||
const ALLOWED_VIDEO_TYPES = {
|
||||
'video/mp4': {
|
||||
extensions: ['.mp4'],
|
||||
magicNumbers: null
|
||||
},
|
||||
'video/quicktime': {
|
||||
extensions: ['.mov', '.qt'],
|
||||
magicNumbers: null
|
||||
extensions: ['.mp4', '.m4v'],
|
||||
magicNumbers: [
|
||||
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // 'ftyp' signature for MP4
|
||||
]
|
||||
},
|
||||
'video/webm': {
|
||||
extensions: ['.webm'],
|
||||
magicNumbers: [
|
||||
{ offset: 0, bytes: [0x1A, 0x45, 0xDF, 0xA3] } // WebM/Matroska
|
||||
{ offset: 0, bytes: [0x1A, 0x45, 0xDF, 0xA3] } // EBML header for WebM/MKV
|
||||
]
|
||||
},
|
||||
'video/quicktime': {
|
||||
extensions: ['.mov'],
|
||||
magicNumbers: [
|
||||
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70, 0x71, 0x74] } // 'ftypqt' signature for QuickTime
|
||||
]
|
||||
},
|
||||
'video/x-msvideo': {
|
||||
extensions: ['.avi'],
|
||||
magicNumbers: [
|
||||
{ offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF
|
||||
{ offset: 8, bytes: [0x41, 0x56, 0x49, 0x20] } // 'AVI '
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Combined media types
|
||||
const ALLOWED_MEDIA_TYPES = {
|
||||
...ALLOWED_IMAGE_TYPES,
|
||||
...ALLOWED_VIDEO_TYPES
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate file type by MIME type and extension
|
||||
* @param {string} filename - The filename
|
||||
@@ -108,16 +127,16 @@ function validateFileType(filename, mimetype, allowedTypes) {
|
||||
if (!allowedTypes.includes(mimetype)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Get file extension
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
|
||||
|
||||
// Check if extension matches the MIME type
|
||||
const typeConfig = ALLOWED_IMAGE_TYPES[mimetype];
|
||||
const typeConfig = ALLOWED_MEDIA_TYPES[mimetype];
|
||||
if (!typeConfig || !typeConfig.extensions.includes(ext)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -129,22 +148,22 @@ function validateFileType(filename, mimetype, allowedTypes) {
|
||||
*/
|
||||
async function validateFileContent(filePath, expectedMimeType) {
|
||||
try {
|
||||
const typeConfig = ALLOWED_IMAGE_TYPES[expectedMimeType];
|
||||
const typeConfig = ALLOWED_MEDIA_TYPES[expectedMimeType];
|
||||
if (!typeConfig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Skip validation for file types without magic numbers (like SVG)
|
||||
if (!typeConfig.magicNumbers) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Read the first 20 bytes of the file (enough for most magic numbers)
|
||||
const buffer = Buffer.alloc(20);
|
||||
const fileHandle = await fs.open(filePath, 'r');
|
||||
await fileHandle.read(buffer, 0, 20, 0);
|
||||
await fileHandle.close();
|
||||
|
||||
|
||||
// Check magic numbers
|
||||
return typeConfig.magicNumbers.every(magic => {
|
||||
for (let i = 0; i < magic.bytes.length; i++) {
|
||||
@@ -169,36 +188,16 @@ function getSafeFilename(originalFilename) {
|
||||
const timestamp = Date.now();
|
||||
const randomString = Math.random().toString(36).substring(2, 15);
|
||||
const ext = path.extname(originalFilename).toLowerCase();
|
||||
|
||||
// Validate extension
|
||||
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico'];
|
||||
|
||||
// Validate extension - including both image and video extensions
|
||||
const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico', '.mp4', '.m4v', '.webm', '.mov', '.avi'];
|
||||
if (!validExtensions.includes(ext)) {
|
||||
throw new Error('Invalid file extension');
|
||||
}
|
||||
|
||||
|
||||
return `upload_${timestamp}_${randomString}${ext}`;
|
||||
}
|
||||
|
||||
function isVideoMimeType(mimeType, filename) {
|
||||
const lowerMime = (mimeType || '').toLowerCase();
|
||||
if (lowerMime.startsWith('video/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ext = filename ? path.extname(filename).toLowerCase() : '';
|
||||
const videoExts = ['.mp4', '.mov', '.webm', '.m4v', '.qt'];
|
||||
|
||||
if (videoExts.includes(ext)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (lowerMime === 'application/mp4' || lowerMime === 'application/x-m4v' || lowerMime === 'application/octet-stream') {
|
||||
return videoExts.includes(ext) || true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file upload validator middleware
|
||||
* @param {Object} options - Validation options
|
||||
@@ -265,5 +264,6 @@ module.exports = {
|
||||
getSafeFilename,
|
||||
createFileUploadValidator,
|
||||
ALLOWED_IMAGE_TYPES,
|
||||
isVideoMimeType
|
||||
};
|
||||
ALLOWED_VIDEO_TYPES,
|
||||
ALLOWED_MEDIA_TYPES
|
||||
};
|
||||
@@ -43,6 +43,13 @@ done
|
||||
|
||||
>&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)
|
||||
echo "Running database migrations..."
|
||||
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.licenses="MIT"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
RUN npm install -g npm@latest
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -27,11 +30,13 @@ COPY . .
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
# Production stage (use Alpine with patched libpng)
|
||||
FROM nginx:1.27-alpine3.21
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
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
|
||||
RUN apk add --no-cache curl
|
||||
@@ -63,4 +68,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
USER nginx
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
Generated
+1073
-1196
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Upload, X, Image, Loader2, Video } from 'lucide-react';
|
||||
import { Upload, X, Image, Loader2 } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { clsx } from 'clsx';
|
||||
import { api } from '../../config/api';
|
||||
@@ -51,18 +51,12 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
|
||||
const allowedFiles = files.filter(file => allowedTypes.includes(file.type));
|
||||
const rejectedFiles = files.filter(file => !allowedTypes.includes(file.type));
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
toast.error(
|
||||
t('upload.unsupportedFiles', 'Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).')
|
||||
);
|
||||
}
|
||||
const imageFiles = files.filter(file =>
|
||||
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||||
);
|
||||
|
||||
// Check total file count with existing files
|
||||
const totalFiles = selectedFiles.length + allowedFiles.length;
|
||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||
if (totalFiles > maxFilesPerUpload) {
|
||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||
if (allowedNewFiles <= 0) {
|
||||
@@ -76,11 +70,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
|
||||
);
|
||||
setSelectedFiles(prev => [...prev, ...allowedFiles.slice(0, allowedNewFiles)]);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFiles(prev => [...prev, ...allowedFiles]);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles]);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
@@ -192,7 +186,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{/* Category Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('upload.mediaCategory', 'Media category')}
|
||||
{t('upload.photoCategory')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategoryId || ''}
|
||||
@@ -222,7 +216,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('upload.fileRequirementsMedia', { limit: maxFilesPerUpload }) || t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
@@ -242,7 +236,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm"
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -261,11 +255,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{file.type.startsWith('video/') ? (
|
||||
<Video className="w-5 h-5 text-neutral-400" />
|
||||
) : (
|
||||
<Image className="w-5 h-5 text-neutral-400" />
|
||||
)}
|
||||
<Image className="w-5 h-5 text-neutral-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
|
||||
{file.name}
|
||||
@@ -298,9 +288,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
disabled={selectedFiles.length === 0 || isUploading}
|
||||
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
>
|
||||
{isUploading
|
||||
? t('upload.uploading')
|
||||
: t('upload.uploadAction', { count: selectedFiles.length }) || `Upload ${selectedFiles.length} files`}
|
||||
{isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, Maximize2, Check, Package, MessageSquare, Star } from 'lucide-react';
|
||||
import { Download, Maximize2, Check, Package, MessageSquare, Star, Play } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { toast as toastify } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -336,14 +336,25 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Photo type badge */}
|
||||
{photo.type === 'collage' && (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
{/* Media type badges */}
|
||||
<div className="absolute bottom-2 left-2 flex gap-2">
|
||||
{photo.type === 'collage' && (
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
Collage
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{photo.media_type === 'video' && (
|
||||
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded flex items-center gap-1">
|
||||
<Play className="w-3 h-3" fill="white" />
|
||||
Video
|
||||
{photo.duration && (
|
||||
<span className="ml-1">
|
||||
{Math.floor(photo.duration / 60)}:{String(photo.duration % 60).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="skeleton aspect-square w-full" />
|
||||
|
||||
@@ -3,10 +3,11 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage, AuthenticatedVideo } from '../common';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
import { PhotoFeedback } from './PhotoFeedback';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[];
|
||||
@@ -64,11 +65,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
const isVideo = currentPhoto
|
||||
? (currentPhoto.media_type === 'video' ||
|
||||
(currentPhoto.mime_type && currentPhoto.mime_type.startsWith('video/')) ||
|
||||
currentPhoto.type === 'video')
|
||||
: false;
|
||||
|
||||
// DevTools protection for the lightbox when enhanced protection is enabled
|
||||
useDevToolsProtection({
|
||||
@@ -366,31 +362,27 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isVideo && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<span className="text-white text-sm w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 3}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-white/20 mx-2" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<span className="text-white text-sm w-12 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 3}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-white/20 mx-2" />
|
||||
|
||||
{allowDownloads && (
|
||||
<button
|
||||
@@ -457,29 +449,29 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image container */}
|
||||
{/* Image/Video container */}
|
||||
<div
|
||||
className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
|
||||
onClick={isVideo ? undefined : handleImageClick}
|
||||
onMouseDown={isVideo ? undefined : handleMouseDown}
|
||||
onMouseMove={isVideo ? undefined : handleMouseMove}
|
||||
onMouseUp={isVideo ? undefined : handleMouseUp}
|
||||
onMouseLeave={isVideo ? undefined : handleMouseUp}
|
||||
onTouchStart={isVideo ? undefined : handleTouchStart}
|
||||
onTouchMove={isVideo ? undefined : handleTouchMove}
|
||||
onTouchEnd={isVideo ? undefined : handleTouchEnd}
|
||||
onClick={currentPhoto.media_type === 'video' ? undefined : handleImageClick}
|
||||
onMouseDown={currentPhoto.media_type === 'video' ? undefined : handleMouseDown}
|
||||
onMouseMove={currentPhoto.media_type === 'video' ? undefined : handleMouseMove}
|
||||
onMouseUp={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
|
||||
onMouseLeave={currentPhoto.media_type === 'video' ? undefined : handleMouseUp}
|
||||
onTouchStart={currentPhoto.media_type === 'video' ? undefined : handleTouchStart}
|
||||
onTouchMove={currentPhoto.media_type === 'video' ? undefined : handleTouchMove}
|
||||
onTouchEnd={currentPhoto.media_type === 'video' ? undefined : handleTouchEnd}
|
||||
style={{
|
||||
cursor: isVideo ? 'default' : zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
|
||||
cursor: currentPhoto.media_type === 'video' ? 'default' : (zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'),
|
||||
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
|
||||
}}
|
||||
>
|
||||
{isVideo ? (
|
||||
<AuthenticatedVideo
|
||||
{currentPhoto.media_type === 'video' ? (
|
||||
<VideoPlayer
|
||||
src={currentPhoto.url}
|
||||
fallbackSrc={currentPhoto.thumbnail_url || undefined}
|
||||
className="max-w-full max-h-full object-contain bg-black"
|
||||
slug={slug}
|
||||
poster={currentPhoto.thumbnail_url || undefined}
|
||||
poster={currentPhoto.thumbnail_url}
|
||||
className="max-w-full max-h-full"
|
||||
controls={true}
|
||||
autoPlay={false}
|
||||
/>
|
||||
) : (
|
||||
<AuthenticatedImage
|
||||
@@ -509,24 +501,24 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
onProtectionViolation={(violationType) => {
|
||||
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
|
||||
|
||||
// Track analytics
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
(window as any).umami.track('lightbox_protection_violation', {
|
||||
photoId: currentPhoto.id,
|
||||
violationType,
|
||||
protectionLevel,
|
||||
zoom
|
||||
});
|
||||
}
|
||||
|
||||
// For maximum protection, close lightbox on violation
|
||||
if (protectionLevel === 'maximum' &&
|
||||
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
// Track analytics
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
(window as any).umami.track('lightbox_protection_violation', {
|
||||
photoId: currentPhoto.id,
|
||||
violationType,
|
||||
protectionLevel,
|
||||
zoom
|
||||
});
|
||||
}
|
||||
|
||||
// For maximum protection, close lightbox on violation
|
||||
if (protectionLevel === 'maximum' &&
|
||||
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
|
||||
onChange={handleFileSelect}
|
||||
disabled={uploading}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Play, Pause, Volume2, VolumeX, Maximize, Minimize } from 'lucide-react';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
src: string;
|
||||
poster?: string;
|
||||
className?: string;
|
||||
autoPlay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
controls?: boolean;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
src,
|
||||
poster,
|
||||
className = '',
|
||||
autoPlay = false,
|
||||
muted = false,
|
||||
loop = false,
|
||||
controls = true,
|
||||
width = '100%',
|
||||
height = 'auto'
|
||||
}) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(muted);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const controlsTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(video.currentTime);
|
||||
setProgress((video.currentTime / video.duration) * 100 || 0);
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
};
|
||||
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handleEnded);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePlayPause = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.play();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.muted = !video.muted;
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
try {
|
||||
if (!isFullscreen) {
|
||||
if (video.requestFullscreen) {
|
||||
await video.requestFullscreen();
|
||||
}
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling fullscreen:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pos = (e.clientX - rect.left) / rect.width;
|
||||
video.currentTime = pos * video.duration;
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
setShowControls(true);
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
controlsTimeoutRef.current = setTimeout(() => {
|
||||
if (isPlaying) {
|
||||
setShowControls(false);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative bg-black rounded-lg overflow-hidden ${className}`}
|
||||
style={{ width, height: height === 'auto' ? undefined : height }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={() => isPlaying && setShowControls(false)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
autoPlay={autoPlay}
|
||||
muted={muted}
|
||||
loop={loop}
|
||||
className="w-full h-full object-contain"
|
||||
playsInline
|
||||
onClick={togglePlayPause}
|
||||
/>
|
||||
|
||||
{controls && (
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
|
||||
showControls ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
className="w-full h-1 bg-gray-600 rounded-full cursor-pointer mb-3"
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-white rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between text-white">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="hover:bg-white/20 p-2 rounded-full transition-colors"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? <Pause size={20} /> : <Play size={20} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="hover:bg-white/20 p-2 rounded-full transition-colors"
|
||||
aria-label={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
|
||||
<span className="text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="hover:bg-white/20 p-2 rounded-full transition-colors"
|
||||
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize size={20} /> : <Maximize size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play button overlay when paused */}
|
||||
{!isPlaying && showControls && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<button
|
||||
onClick={togglePlayPause}
|
||||
className="bg-black/50 hover:bg-black/70 text-white rounded-full p-6 transition-colors"
|
||||
aria-label="Play"
|
||||
>
|
||||
<Play size={48} fill="white" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
@@ -99,6 +99,106 @@ class PhotosService {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
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();
|
||||
|
||||
@@ -63,6 +63,14 @@ export interface Photo {
|
||||
category_slug?: string;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
// Media type fields
|
||||
media_type?: 'image' | 'video';
|
||||
mime_type?: string;
|
||||
duration?: number; // Duration in seconds for videos
|
||||
video_codec?: string;
|
||||
audio_codec?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
// Feedback fields
|
||||
has_feedback?: boolean;
|
||||
average_rating?: number;
|
||||
|
||||
@@ -823,8 +823,8 @@ EOF
|
||||
|
||||
create_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
|
||||
[Unit]
|
||||
Description=PicPeak Backend Service
|
||||
@@ -844,27 +844,10 @@ StandardError=append:$NATIVE_APP_DIR/logs/backend-error.log
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Workers service
|
||||
cat > /etc/systemd/system/picpeak-workers.service <<EOF
|
||||
[Unit]
|
||||
Description=PicPeak Background Workers
|
||||
After=network.target picpeak-backend.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$NATIVE_APP_USER
|
||||
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
|
||||
# Note: Workers (fileWatcher, expirationChecker, emailProcessor) are now started
|
||||
# automatically by server.js, so a separate workers service is no longer needed.
|
||||
# Legacy picpeak-workers.service will be cleaned up during installation.
|
||||
}
|
||||
|
||||
setup_caddy() {
|
||||
|
||||
Reference in New Issue
Block a user