Add video support, media filters, and translations
Build and Push Docker Images / build-backend (push) Failing after 14m2s
Build and Push Docker Images / build-frontend (push) Failing after 44m43s
Build and Push Docker Images / summary (push) Successful in 3s

This commit is contained in:
2025-11-28 13:29:44 +01:00
parent bce5f749b1
commit 9a75f1c929
28 changed files with 1635 additions and 294 deletions
+156 -85
View File
@@ -4,12 +4,14 @@ const path = require('path');
const fs = require('fs').promises;
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
const { generateThumbnail, ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const router = express.Router();
const { isVideoMimeType, validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -61,8 +63,6 @@ const storage = multer.diskStorage({
}
});
const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({
storage: storage,
limits: {
@@ -75,24 +75,22 @@ const upload = multer({
headerPairs: 2000 // Maximum number of header key-value pairs
},
fileFilter: (req, file, cb) => {
// Accept images only with proper validation
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
// Accept images and common video formats with proper validation
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true);
} else {
cb(new Error('Only JPEG, PNG and WebP images are allowed'));
cb(new Error('Only JPEG, PNG, WebP images or MP4/MOV/WEBM 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'],
allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'],
maxFileSize: 50 * 1024 * 1024,
validateContent: true
});
@@ -186,25 +184,13 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Parse category_id to number if provided
const numericCategoryId = parseCategoryId(category_id);
// Determine photo type from category_id parameter (for backwards compatibility)
let photoType = 'individual'; // default
let categoryName = 'individual';
if (numericCategoryId === 1 || category_id === 'collage') {
photoType = 'collage';
categoryName = 'collages';
} else if (numericCategoryId === 2 || category_id === 'individual') {
photoType = 'individual';
categoryName = 'individual';
}
// For backwards compatibility, accept string values
if (category_id === 'collage') {
photoType = 'collage';
categoryName = 'collages';
}
const resolveCategoryName = (type) => {
if (type === 'collage') return 'collages';
if (type === 'video') return 'videos';
return 'individual';
};
// Create final destination directory
const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug);
await fs.mkdir(finalDestPath, { recursive: true });
@@ -222,20 +208,49 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
const trx = await db.transaction();
try {
// 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 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;
});
}
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 < batch.length; fileIndex++) {
const file = batch[fileIndex];
const counter = batchCounter + fileIndex;
for (let fileIndex = 0; fileIndex < preparedBatch.length; fileIndex++) {
const { file, resolvedMime, isVideo, photoType } = preparedBatch[fileIndex];
const tempPath = file.path; // Original temp path
try {
@@ -244,12 +259,15 @@ 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,
categoryName,
resolveCategoryName(photoType),
counter,
extension
);
@@ -268,7 +286,8 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
type: photoType,
size_bytes: tempStats.size, // Use actual file size from stat
category_id: numericCategoryId,
source_origin: 'managed'
source_origin: 'managed',
mime_type: resolvedMime
};
batchPhotos.push(photoData);
@@ -278,7 +297,8 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
tempPath: tempPath,
finalPath: finalPath,
filename: newFilename,
photoData: photoData
photoData: photoData,
isVideo
});
} catch (error) {
console.error(`Error preparing file ${file.originalname}:`, error);
@@ -288,7 +308,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} photos with type: ${photoType}`);
console.log(`Inserting batch of ${batchPhotos.length} files with types: ${typesInBatch.join(', ')}`);
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
@@ -313,27 +333,31 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
}
// Generate thumbnail with final path
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);
}
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);
}
// 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
category_id: operation.photoData.category_id,
type: operation.photoData.type,
mime_type: operation.photoData.mime_type
});
} catch (moveError) {
console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, moveError);
@@ -400,7 +424,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} photos`,
message: `Successfully uploaded ${uploadedPhotos.length} files`,
photos: uploadedPhotos,
totalFiles: totalAttempted,
successCount: uploadedPhotos.length,
@@ -410,7 +434,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} photos. ${totalInvalidFiles.length} failed.`;
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} files. ${totalInvalidFiles.length} failed.`;
}
res.json(response);
@@ -427,7 +451,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
}
}
res.status(500).json({ error: 'Failed to upload photos' });
res.status(500).json({ error: 'Failed to upload files' });
}
});
@@ -457,7 +481,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
// Delete thumbnail if exists
if (photo.thumbnail_path) {
const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path);
const thumbPath = path.join(storagePath, photo.thumbnail_path);
try {
// Check if file exists before attempting to delete
await fs.access(thumbPath);
@@ -710,7 +734,7 @@ 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, search, sort = 'date', order = 'desc' } = req.query;
const { category_id, type, media_type, search, sort = 'date', order = 'desc' } = req.query;
let query = db('photos')
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
@@ -738,6 +762,20 @@ 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) {
@@ -775,28 +813,37 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
});
res.json({
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.category_id !== null && photo.category_id !== undefined
? Number(photo.category_id)
: null,
category_name: photo.category_display_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
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 => {
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
});
})
});
} catch (error) {
console.error('Error fetching photos:', error);
@@ -828,8 +875,10 @@ 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', `image/${path.extname(photo.filename).slice(1)}`);
res.setHeader('Content-Type', mimeType);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
@@ -855,8 +904,30 @@ 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
const thumbnailPath = await ensureThumbnail(photo);
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);
}
if (!thumbnailPath) {
console.error(`Failed to generate thumbnail for photo ${photoId}`);
+93 -27
View File
@@ -10,6 +10,8 @@ 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');
@@ -248,10 +250,17 @@ 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: result.type === 'individual' ? 'Individual Photos' : 'Collages',
name: resolveCategoryName(result.type),
slug: result.type,
is_global: false
}));
@@ -292,10 +301,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
},
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
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 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,
@@ -306,12 +318,14 @@ 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: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
category_name: categoryName,
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,
requires_token: !useJwtUrl && !isVideo,
// Feedback data
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
average_rating: photo.average_rating || 0,
@@ -345,6 +359,7 @@ 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);
@@ -373,7 +388,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) {
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
// Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
@@ -386,6 +401,9 @@ 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', {
@@ -463,14 +481,16 @@ 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' : 'Collages';
const folderName = photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages';
archiveName = path.join(folderName, photo.filename);
} else {
// No folders, just the filename
archiveName = photo.filename;
}
if (watermarkSettings && watermarkSettings.enabled) {
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: archiveName });
@@ -565,7 +585,9 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
try {
const filePath = resolvePhotoFilePath(req.event, photo);
const name = photo.filename || `photo-${photo.id}.jpg`;
if (watermarkSettings && watermarkSettings.enabled) {
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name });
@@ -615,9 +637,13 @@ router.get('/:slug/photo/:photoId',
async (req, res) => {
try {
const { photoId } = req.params;
const numericPhotoId = parseInt(photoId, 10);
if (!Number.isInteger(numericPhotoId)) {
return res.status(400).json({ error: 'Invalid photo id' });
}
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.where({ id: numericPhotoId, event_id: req.event.id })
.first();
@@ -625,10 +651,11 @@ router.get('/:slug/photo/:photoId',
return res.status(404).json({ error: 'Photo not found' });
}
const isVideo = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video';
// Check protection level - basic and standard protection allow direct JWT access
const protectionLevel = req.event.protection_level || 'standard';
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
if (!isVideo && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
// For enhanced/maximum protection, redirect to secure endpoint
return res.status(302).json({
error: 'Secure access required',
@@ -653,7 +680,7 @@ router.get('/:slug/photo/:photoId',
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) {
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
// Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
@@ -672,6 +699,9 @@ 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) {
@@ -692,32 +722,62 @@ router.get('/:slug/thumbnail/:photoId',
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo || !photo.thumbnail_path) {
return res.status(404).json({ error: 'Thumbnail not found' });
const numericPhotoId = parseInt(photoId, 10);
if (!Number.isInteger(numericPhotoId)) {
return res.status(400).json({ error: 'Invalid photo id' });
}
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
const photo = await db('photos')
.where({ id: numericPhotoId, 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) {
return res.status(404).json({ error: 'Thumbnail not found' });
}
// Check if file exists
const fs = require('fs').promises;
try {
await fs.access(thumbPath);
await fs.access(thumbFilePath);
} catch (error) {
return res.status(404).json({ error: 'Thumbnail file not found' });
}
// Log thumbnail access
await secureImageService.logImageAccess(
photoId,
req.event.id,
req.clientInfo,
'thumbnail'
);
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 });
}
// Set appropriate headers with enhanced security
res.set({
@@ -729,8 +789,14 @@ router.get('/:slug/thumbnail/:photoId',
});
// Send file
res.sendFile(path.resolve(thumbPath));
res.sendFile(path.resolve(thumbFilePath));
} 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,
+18 -7
View File
@@ -3,8 +3,10 @@ const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const logger = require('../utils/logger');
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
@@ -47,9 +49,11 @@ async function processNewPhoto(filePath) {
const eventSlug = pathParts[0];
const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual';
// Check if this is an image file
// Check if this is an image or video file
const ext = path.extname(filePath).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
const detectedMime = mime.lookup(filePath) || '';
const isVideo = isVideoMimeType(detectedMime, filePath) || ['.mp4', '.mov', '.webm'].includes(ext);
if (!isVideo && !['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Skip temporary upload files
const filename = path.basename(filePath);
@@ -65,11 +69,17 @@ async function processNewPhoto(filePath) {
// Get file stats
const stats = await fs.stat(filePath);
// Generate thumbnail
const thumbnailPath = await generateThumbnail(filePath);
// Generate thumbnail or placeholder
let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(filename);
} else {
thumbnailPath = await generateThumbnail(filePath);
}
// Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
// Check if photo already exists
const existingPhoto = await db('photos')
@@ -83,8 +93,9 @@ async function processNewPhoto(filePath) {
filename: path.basename(filePath),
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: stats.size
type: isVideo ? 'video' : photoType,
size_bytes: stats.size,
mime_type: mimeType
});
logger.info(`Added new photo: ${relativePath}`);
+51 -1
View File
@@ -237,4 +237,54 @@ async function ensureThumbnail(photo) {
return null;
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
async function generateVideoPlaceholder(originalFilename, options = {}) {
const parsed = path.parse(originalFilename || '');
const baseName = parsed.name || 'video';
const thumbnailDir = getThumbnailPath();
const thumbnailFilename = `thumb_${baseName}.jpg`;
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
const settings = await getThumbnailSettings();
const width = settings.width || DEFAULT_THUMBNAIL_WIDTH;
const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT;
if (options.regenerate) {
try {
await fs.unlink(thumbnailPath);
} catch (_) {
// ignore if missing
}
}
try {
await fs.mkdir(thumbnailDir, { recursive: true });
const svg = `
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f172a" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#1e293b" stop-opacity="0.9"/>
</linearGradient>
</defs>
<rect width="${width}" height="${height}" rx="18" fill="url(#grad)"/>
<circle cx="${width / 2}" cy="${height / 2}" r="${Math.min(width, height) / 6}" fill="rgba(255,255,255,0.85)"/>
<polygon points="${width / 2 - 10},${height / 2 - 14} ${width / 2 - 10},${height / 2 + 14} ${width / 2 + 16},${height / 2}" fill="#0f172a"/>
<text x="50%" y="${height - 18}" font-family="Arial, sans-serif" font-size="16" fill="rgba(255,255,255,0.9)" text-anchor="middle">
VIDEO
</text>
</svg>
`;
await sharp(Buffer.from(svg))
.resize(width, height, { fit: 'cover' })
.jpeg({ quality: settings.quality || DEFAULT_THUMBNAIL_QUALITY })
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
logger.error('Failed to generate video placeholder thumbnail:', error.message);
return null;
}
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder };
+28 -15
View File
@@ -1,8 +1,10 @@
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { generateThumbnail } = require('./imageProcessor');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { isVideoMimeType } = 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');
@@ -70,12 +72,15 @@ 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 = 'individual'; // default type
let photoType = isVideo ? 'video' : 'individual'; // default type
// If categoryId is provided and matches photo types, use it as type
if (categoryId === 'collage') {
if (!isVideo && categoryId === 'collage') {
photoType = 'collage';
}
@@ -90,7 +95,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
// Generate new filename
const extension = path.extname(file.originalname);
const categoryName = photoType === 'collage' ? 'collages' : 'individual';
const categoryName = photoType === 'collage' ? 'collages' : (isVideo ? 'videos' : 'individual');
const newFilename = generatePhotoFilename(
event.event_name,
categoryName,
@@ -150,8 +155,13 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
}
}
// Generate thumbnail
const thumbnailPath = await generateThumbnail(newPath);
// Generate thumbnail or placeholder
let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(newFilename);
} else {
thumbnailPath = await generateThumbnail(newPath);
}
// Calculate relative paths
const storagePath = getStoragePath();
@@ -173,20 +183,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed'
source_origin: 'managed',
mime_type: resolvedMime
})
.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'
});
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed',
mime_type: resolvedMime
});
}
const insertedId = Array.isArray(insertResult)
@@ -206,7 +218,8 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
id: photoId,
filename: newFilename,
size: file.size,
type: photoType
type: photoType,
media_type: isVideo ? 'video' : 'photo'
});
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
+38 -2
View File
@@ -78,6 +78,21 @@ 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
'video/mp4': {
extensions: ['.mp4'],
magicNumbers: null
},
'video/quicktime': {
extensions: ['.mov', '.qt'],
magicNumbers: null
},
'video/webm': {
extensions: ['.webm'],
magicNumbers: [
{ offset: 0, bytes: [0x1A, 0x45, 0xDF, 0xA3] } // WebM/Matroska
]
}
};
@@ -164,6 +179,26 @@ function getSafeFilename(originalFilename) {
return `upload_${timestamp}_${randomString}${ext}`;
}
function isVideoMimeType(mimeType, filename) {
const lowerMime = (mimeType || '').toLowerCase();
if (lowerMime.startsWith('video/')) {
return true;
}
const ext = filename ? path.extname(filename).toLowerCase() : '';
const videoExts = ['.mp4', '.mov', '.webm', '.m4v', '.qt'];
if (videoExts.includes(ext)) {
return true;
}
if (lowerMime === 'application/mp4' || lowerMime === 'application/x-m4v' || lowerMime === 'application/octet-stream') {
return videoExts.includes(ext) || true;
}
return false;
}
/**
* Create a file upload validator middleware
* @param {Object} options - Validation options
@@ -229,5 +264,6 @@ module.exports = {
validateFileContent,
getSafeFilename,
createFileUploadValidator,
ALLOWED_IMAGE_TYPES
};
ALLOWED_IMAGE_TYPES,
isVideoMimeType
};