fix: harden gallery downloads and per-gallery auth
Test and Lint / backend-test (push) Successful in 1m57s
Test and Lint / frontend-test (push) Successful in 1m57s

This commit is contained in:
2025-10-01 15:55:02 +02:00
parent 5d6c061f1c
commit fc1bf53412
8 changed files with 282 additions and 61 deletions
@@ -0,0 +1,75 @@
const path = require('path');
const mockPath = path;
jest.mock('../../src/services/externalMediaService', () => ({
resolveExternalPath: jest.fn((event, relPath) => mockPath.join('/mock/external', event.external_path || '', relPath || '')),
}));
const { resolveExternalPath } = require('../../src/services/externalMediaService');
const { resolvePhotoFilePath } = require('../../src/services/photoResolver');
describe('resolvePhotoFilePath', () => {
const backendRoot = path.resolve(__dirname, '../../');
const originalStoragePath = process.env.STORAGE_PATH;
beforeEach(() => {
process.env.STORAGE_PATH = path.join(backendRoot, 'storage');
});
afterEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
if (typeof originalStoragePath === 'string') {
process.env.STORAGE_PATH = originalStoragePath;
} else {
delete process.env.STORAGE_PATH;
}
});
it('returns absolute path for managed photos with legacy slug paths', () => {
const event = { slug: 'wedding-party', source_mode: 'managed' };
const photo = { path: 'wedding-party/hero.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
});
it('normalizes prefixed managed paths without duplicating segments', () => {
const event = { slug: 'wedding-party', source_mode: 'managed' };
const photo = { path: 'events/active/wedding-party/hero.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
});
it('delegates external photos to external media resolver', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { source_origin: 'external', external_relpath: 'individual/look-01.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'individual/look-01.jpg');
expect(result).toBe(path.join('/mock/external', 'picsum-demo', 'individual', 'look-01.jpg'));
});
it('deduplicates folder names when event external path already ends with segment', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo/individual' };
const photo = { source_origin: 'external', external_relpath: 'individual/look-02.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'look-02.jpg');
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
});
it('throws when external photo is missing relative path data', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { source_origin: 'external' };
expect(() => resolvePhotoFilePath(event, photo)).toThrow('Missing external_relpath for external photo');
});
});
+103 -42
View File
@@ -1,5 +1,4 @@
const express = require('express'); const express = require('express');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const archiver = require('archiver'); const archiver = require('archiver');
@@ -8,8 +7,8 @@ const router = express.Router();
const watermarkService = require('../services/watermarkService'); const watermarkService = require('../services/watermarkService');
const { verifyGalleryAccess } = require('../middleware/gallery'); const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
@@ -48,9 +47,22 @@ router.get('/:slug/info', async (req, res) => {
const { token } = req.query; const { token } = req.query;
const event = await db('events') const event = await db('events')
.where({ slug: slug }) .where({ slug })
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link', .select(
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text', 'require_password', 'color_theme') 'event_name',
'event_type',
'event_date',
'expires_at',
'is_active',
'is_archived',
'share_link',
'allow_downloads',
'disable_right_click',
'watermark_downloads',
'watermark_text',
'require_password',
'color_theme'
)
.first(); .first();
if (!event) { if (!event) {
@@ -101,7 +113,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try { try {
// Get filter parameters from query // Get filter parameters from query
const { filter, guest_id } = req.query; const { filter, guest_id } = req.query;
const feedbackService = require('../services/feedbackService');
// First get all photos // First get all photos
let photos = await db('photos') let photos = await db('photos')
@@ -319,16 +330,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
photo_id: photoId photo_id: photoId
}); });
// Photo path should be in storage/events/active directory
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
const storagePath = getStoragePath();
let filePath; let filePath;
if (photo.path.startsWith('events/active/')) { try {
// New format: path already includes events/active/ prefix filePath = resolvePhotoFilePath(req.event, photo);
filePath = path.join(storagePath, photo.path); } catch (resolveError) {
} else { logger.error('Failed to resolve photo path for download', {
// Legacy format: path is just slug/filename slug: req.params.slug,
filePath = path.join(storagePath, 'events/active', photo.path); photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
} }
// Get watermark settings // Get watermark settings
@@ -347,9 +359,24 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send original file // Send original file
res.download(filePath, photo.filename); res.download(filePath, photo.filename, (downloadError) => {
if (downloadError) {
logger.error('Error streaming gallery download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: downloadError.message,
});
}
});
} }
} catch (error) { } catch (error) {
logger.error('Unexpected error processing gallery download', {
slug: req.params.slug,
photoId: req.params.photoId,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to download photo' }); res.status(500).json({ error: 'Failed to download photo' });
} }
}); });
@@ -392,16 +419,17 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
// Add photos to archive // Add photos to archive
for (const photo of photos) { for (const photo of photos) {
// Photo path should be in storage/events/active directory
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
const storagePath = getStoragePath();
let filePath; let filePath;
if (photo.path.startsWith('events/active/')) { try {
// New format: path already includes events/active/ prefix filePath = resolvePhotoFilePath(req.event, photo);
filePath = path.join(storagePath, photo.path); } catch (resolveError) {
} else { logger.warn('Skipping photo in bulk download due to unresolved path', {
// Legacy format: path is just slug/filename slug: req.params.slug,
filePath = path.join(storagePath, 'events/active', photo.path); photoId: photo.id,
eventId: req.event.id,
error: resolveError.message,
});
continue;
} }
// Determine the file name in the archive // Determine the file name in the archive
@@ -416,11 +444,18 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
} }
if (watermarkSettings && watermarkSettings.enabled) { if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: archiveName }); archive.append(watermarkedBuffer, { name: archiveName });
} catch (watermarkError) {
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: watermarkError.message,
});
}
} else { } else {
// Add original file
archive.file(filePath, { name: archiveName }); archive.file(filePath, { name: archiveName });
} }
} }
@@ -435,6 +470,11 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
action: 'download_all' action: 'download_all'
}); });
} catch (error) { } catch (error) {
logger.error('Error creating bulk gallery download', {
slug: req.params.slug,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to create download archive' }); res.status(500).json({ error: 'Failed to create download archive' });
} }
}); });
@@ -479,30 +519,47 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
const archive = archiver('zip', { zlib: { level: 5 } }); const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', (err) => { archive.on('error', (err) => {
console.error('Zip error:', err); logger.error('Zip error generating selected download', {
try { res.status(500).end(); } catch (e) {} slug: req.params.slug,
eventId: req.event?.id,
error: err.message,
});
try {
res.status(500).end();
} catch (_) {
// ignore double-send errors
}
}); });
archive.pipe(res); archive.pipe(res);
const { resolvePhotoFilePath } = require('../services/photoResolver');
const fs = require('fs');
// Check watermark settings similar to download-all // Check watermark settings similar to download-all
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
for (const photo of photos) { for (const photo of photos) {
try { try {
const filePath = resolvePhotoFilePath(req.event, photo); const filePath = resolvePhotoFilePath(req.event, photo);
if (filePath && fs.existsSync(filePath)) { const name = photo.filename || `photo-${photo.id}.jpg`;
const name = photo.filename || `photo-${photo.id}.jpg`; if (watermarkSettings && watermarkSettings.enabled) {
if (watermarkSettings && watermarkSettings.enabled) { try {
// Apply watermark like download-all
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name }); archive.append(watermarkedBuffer, { name });
} else { } catch (watermarkError) {
archive.file(filePath, { name }); logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: watermarkError.message,
});
} }
} else {
archive.file(filePath, { name });
} }
} catch (e) { } catch (resolveError) {
// skip missing/inaccessible files logger.warn('Skipping selected photo due to unresolved path', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: resolveError.message,
});
} }
} }
@@ -515,7 +572,11 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
action: 'download_selected' action: 'download_selected'
}); });
} catch (error) { } catch (error) {
console.error('Error in download-selected:', error); logger.error('Error in download-selected:', {
slug: req.params.slug,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to download selected photos' }); res.status(500).json({ error: 'Failed to download selected photos' });
} }
}); });
+29 -10
View File
@@ -1,17 +1,14 @@
const express = require('express'); const express = require('express');
const path = require('path');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { verifyGalleryAccess } = require('../middleware/gallery'); const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware'); const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const router = express.Router(); const router = express.Router();
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/** /**
* Generate secure token for image access * Generate secure token for image access
*/ */
@@ -94,11 +91,11 @@ router.get('/:slug/secure/:photoId/:token',
const { slug, photoId, token } = req.params; // Move outside try block for error handler access const { slug, photoId, token } = req.params; // Move outside try block for error handler access
try { try {
console.log('Secure image route hit:', { logger.debug('Secure image route hit', {
slug: slug, slug,
photoId: photoId, photoId,
tokenLength: token?.length, tokenLength: token?.length,
headers: req.headers.authorization ? 'present' : 'absent' hasAuthHeader: Boolean(req.headers.authorization),
}); });
const { fragment } = req.query; const { fragment } = req.query;
@@ -142,7 +139,18 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' }); return res.status(404).json({ error: 'Photo not found' });
} }
const filePath = path.join(getStoragePath(), 'events/active', photo.path); let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for secure token generation', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Get protection settings for this event // Get protection settings for this event
const protectionSettings = { const protectionSettings = {
@@ -284,7 +292,18 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' }); return res.status(404).json({ error: 'Photo not found' });
} }
const filePath = path.join(getStoragePath(), 'events/active', photo.path); let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for secure download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Apply watermark if enabled // Apply watermark if enabled
const watermarkService = require('../services/watermarkService'); const watermarkService = require('../services/watermarkService');
+44 -2
View File
@@ -1,9 +1,52 @@
const fs = require('fs').promises; const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path'); const path = require('path');
const { safePathJoin } = require('../utils/fileSecurityUtils'); const { safePathJoin } = require('../utils/fileSecurityUtils');
let cachedRoot = null;
function resolveDefaultRoot() {
const containerDefault = '/external-media';
try {
if (fsSync.existsSync(containerDefault)) {
return containerDefault;
}
} catch (error) {
// ignore lookup errors, fallback below
}
const localFallback = path.resolve(__dirname, '../../..', 'storage/external-media');
try {
if (fsSync.existsSync(localFallback)) {
return localFallback;
}
} catch (error) {
// ignore and return container default
}
return containerDefault;
}
function getExternalMediaRoot() { function getExternalMediaRoot() {
return process.env.EXTERNAL_MEDIA_ROOT || '/external-media'; if (cachedRoot) {
return cachedRoot;
}
const configured = process.env.EXTERNAL_MEDIA_ROOT;
if (configured && configured.trim()) {
const resolvedConfigured = path.resolve(configured.trim());
try {
if (fsSync.existsSync(resolvedConfigured)) {
cachedRoot = resolvedConfigured;
return cachedRoot;
}
} catch (error) {
// ignore lookup errors and fall back to defaults
}
}
cachedRoot = resolveDefaultRoot();
return cachedRoot;
} }
function isUnderRoot(p) { function isUnderRoot(p) {
@@ -64,4 +107,3 @@ module.exports = {
list, list,
resolveExternalPath, resolveExternalPath,
}; };
+11 -4
View File
@@ -241,20 +241,22 @@ export const CreateEventPage: React.FC = () => {
const selectedTheme = COLOR_THEMES.find(t => t.value === formData.color_theme); const selectedTheme = COLOR_THEMES.find(t => t.value === formData.color_theme);
createMutation.mutate({ const payload = {
event_type: formData.event_type, event_type: formData.event_type,
event_name: formData.event_name, event_name: formData.event_name,
event_date: formData.event_date, event_date: formData.event_date,
host_email: formData.host_email, host_email: formData.host_email,
admin_email: formData.admin_email, admin_email: formData.admin_email,
require_password: formData.require_password, require_password: formData.require_password,
password: formData.require_password ? formData.password : '', password: formData.require_password ? formData.password : undefined,
welcome_message: formData.welcome_message || '', welcome_message: formData.welcome_message || '',
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined, color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
expiration_days: formData.expires_in_days, expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads, allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id, upload_category_id: formData.upload_category_id,
}); };
createMutation.mutate(payload);
}; };
const handleInputChange = (field: keyof FormData) => ( const handleInputChange = (field: keyof FormData) => (
@@ -438,7 +440,12 @@ export const CreateEventPage: React.FC = () => {
checked={formData.require_password} checked={formData.require_password}
onChange={(e) => { onChange={(e) => {
const checked = e.target.checked; const checked = e.target.checked;
setFormData(prev => ({ ...prev, require_password: checked })); setFormData(prev => ({
...prev,
require_password: checked,
password: checked ? prev.password : '',
confirm_password: checked ? prev.confirm_password : ''
}));
if (!checked) { if (!checked) {
setErrors(prev => ({ ...prev, password: '', confirm_password: '' })); setErrors(prev => ({ ...prev, password: '', confirm_password: '' }));
} }
@@ -240,7 +240,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
host_email: formData.host_email, host_email: formData.host_email,
admin_email: formData.admin_email, admin_email: formData.admin_email,
require_password: formData.require_password, require_password: formData.require_password,
password: formData.require_password ? formData.password : '', password: formData.require_password ? formData.password : undefined,
welcome_message: formData.welcome_message || '', welcome_message: formData.welcome_message || '',
color_theme: JSON.stringify(formData.theme_config), color_theme: JSON.stringify(formData.theme_config),
expiration_days: formData.expires_in_days, expiration_days: formData.expires_in_days,
@@ -511,6 +511,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
require_password: checked, require_password: checked,
password: checked ? prev.password : '',
confirm_password: checked ? prev.confirm_password : '',
})); }));
if (!checked) { if (!checked) {
setErrors(prev => ({ ...prev, password: undefined, confirm_password: undefined })); setErrors(prev => ({ ...prev, password: undefined, confirm_password: undefined }));
+1 -1
View File
@@ -14,7 +14,7 @@ interface CreateEventData {
host_email: string; host_email: string;
admin_email: string; admin_email: string;
require_password?: boolean; require_password?: boolean;
password: string; password?: string;
welcome_message?: string; welcome_message?: string;
color_theme?: string; color_theme?: string;
expiration_days: number; expiration_days: number;
+15
View File
@@ -24,4 +24,19 @@ export const cleanupOldGalleryAuth = () => {
sessionStorage.removeItem('gallery_event'); sessionStorage.removeItem('gallery_event');
sessionStorage.removeItem('gallery_token'); sessionStorage.removeItem('gallery_token');
sessionStorage.removeItem('gallery_active_slug'); sessionStorage.removeItem('gallery_active_slug');
// Remove slug-specific session storage entries as well
try {
const sessionKeysToRemove: string[] = [];
for (let i = 0; i < sessionStorage.length; i += 1) {
const key = sessionStorage.key(i);
if (key && (key.startsWith('gallery_event_') || key.startsWith('gallery_token_'))) {
sessionKeysToRemove.push(key);
}
}
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
} catch {
// Session storage may be unavailable; ignore cleanup failures
}
}; };