Merge main into beta for release/beta-to-main

This commit is contained in:
Paul Nothaft
2026-03-11 20:12:52 +01:00
20 changed files with 649 additions and 180 deletions
+151
View File
@@ -0,0 +1,151 @@
const express = require('express');
const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Module-level progress state
let repairProgress = {
isRunning: false,
lastResult: null
};
// Repair photo dimensions (background job)
router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
if (repairProgress.isRunning) {
return res.status(409).json({ error: 'Repair is already running' });
}
const photos = await db('photos')
.where(function () {
this.whereNull('width').orWhereNull('height');
})
.where(function () {
this.where('media_type', '!=', 'video').orWhereNull('media_type');
})
.select('id', 'path', 'filename');
if (photos.length === 0) {
return res.json({ message: 'No photos need dimension repair', count: 0 });
}
// Return immediately
res.json({
message: `Started repairing dimensions for ${photos.length} photos`,
count: photos.length
});
// Process in background
repairProgress.isRunning = true;
repairProgress.lastResult = null;
setImmediate(async () => {
let sharp;
try {
sharp = require('sharp');
} catch (err) {
logger.error('Sharp not available for dimension repair:', err.message);
repairProgress.isRunning = false;
repairProgress.lastResult = { success: 0, failed: 0, error: 'Sharp not available' };
return;
}
let successCount = 0;
let errorCount = 0;
for (const photo of photos) {
try {
if (!photo.path) {
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
errorCount++;
continue;
}
const storagePath = getStoragePath();
const fullPath = path.join(storagePath, 'events/active', photo.path);
try {
await fs.access(fullPath);
} catch (err) {
logger.warn(`File not found for photo ${photo.id}: ${fullPath}`);
errorCount++;
continue;
}
const metadata = await sharp(fullPath).metadata();
if (metadata.width && metadata.height) {
await db('photos')
.where({ id: photo.id })
.update({
width: metadata.width,
height: metadata.height,
updated_at: db.fn.now()
});
successCount++;
if (successCount % 50 === 0) {
logger.info(`Dimension repair progress: ${successCount} updated...`);
}
} else {
logger.warn(`Could not extract dimensions for photo ${photo.id}`);
errorCount++;
}
} catch (error) {
logger.error(`Error repairing dimensions for photo ${photo.id}:`, error);
errorCount++;
}
}
repairProgress.isRunning = false;
repairProgress.lastResult = { success: successCount, failed: errorCount };
logger.info(`Dimension repair complete: ${successCount} success, ${errorCount} errors`);
});
} catch (error) {
logger.error('Error starting dimension repair:', error);
res.status(500).json({ error: 'Failed to start dimension repair' });
}
});
// Get dimension repair status
router.get('/repair-dimensions/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const totalPhotos = await db('photos')
.where(function () {
this.where('media_type', '!=', 'video').orWhereNull('media_type');
})
.count('id as count')
.first();
const withDimensions = await db('photos')
.where(function () {
this.where('media_type', '!=', 'video').orWhereNull('media_type');
})
.whereNotNull('width')
.whereNotNull('height')
.count('id as count')
.first();
const total = Number(totalPhotos.count);
const withDims = Number(withDimensions.count);
res.json({
total,
withDimensions: withDims,
withoutDimensions: total - withDims,
isRunning: repairProgress.isRunning,
lastResult: repairProgress.lastResult
});
} catch (error) {
logger.error('Error fetching dimension repair status:', error);
res.status(500).json({ error: 'Failed to fetch dimension repair status' });
}
});
module.exports = router;
+66 -15
View File
@@ -6,6 +6,7 @@ const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
const { processUploadedVideo, isVideoMimeType } = require('../services/videoProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
@@ -271,6 +272,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
console.log(`Could not extract EXIF date for ${file.originalname}`);
}
// Determine media type
const isVideo = isVideoMimeType(file.mimetype);
const mediaType = isVideo ? 'video' : 'image';
// Prepare photo data for batch insert
const photoData = {
event_id: parseInt(eventId),
@@ -281,7 +286,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
type: photoType,
category_id: parsedCategoryId, // Save the selected category
size_bytes: tempStats.size, // Use actual file size from stat
captured_at: capturedAt // EXIF capture date (if available)
captured_at: capturedAt, // EXIF capture date (if available)
media_type: mediaType,
mime_type: file.mimetype
};
batchPhotos.push(photoData);
@@ -325,26 +332,65 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`);
}
// Generate thumbnail with final path
// Generate thumbnail and extract metadata
const photoId = insertedIds[idx]?.id || insertedIds[idx];
const isVideoFile = isVideoMimeType(operation.photoData.mime_type);
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 });
try {
if (isVideoFile) {
// 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_${operation.filename.replace(/\.[^.]+$/, '.jpg')}`);
const result = await processUploadedVideo(operation.finalPath, videoThumbnailPath);
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
if (photoId && result.metadata) {
await db('photos')
.where({ id: photoId })
.update({
thumbnail_path: thumbnailPath,
duration: result.metadata.duration,
video_codec: result.metadata.videoCodec,
audio_codec: result.metadata.audioCodec,
width: result.metadata.width,
height: result.metadata.height
});
}
} else {
thumbnailPath = await generateThumbnail(operation.finalPath);
// Update the database with thumbnail path and image dimensions
if (photoId) {
const updateData = {};
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
try {
const sharp = require('sharp');
const metadata = await sharp(operation.finalPath).metadata();
if (metadata.width && metadata.height) {
updateData.width = metadata.width;
updateData.height = metadata.height;
}
} catch (metadataError) {
console.warn(`Could not extract image dimensions for ${operation.filename}:`, metadataError.message);
}
if (Object.keys(updateData).length > 0) {
await db('photos')
.where({ id: photoId })
.update(updateData);
}
}
}
} catch (thumbError) {
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
console.error(`Thumbnail/metadata processing failed for ${operation.filename}:`, thumbError.message);
}
// Queue watermark generation in background (non-blocking)
// This pre-generates watermarked versions for fast serving in lightbox
if (insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
// Queue watermark generation in background (non-blocking, images only)
if (photoId && !isVideoFile) {
watermarkGeneratorService.generateForPhoto(photoId)
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
}
@@ -809,6 +855,11 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
category_id: photo.category_id || photo.type,
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
category_slug: photo.pc_slug || photo.type,
media_type: photo.media_type || 'image',
mime_type: photo.mime_type || null,
width: photo.width || null,
height: photo.height || null,
duration: photo.duration || null,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Feedback data