From 3cdc0ea7152e63cd72124a91394741a6e6904af3 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 12 Jan 2026 13:20:39 +0100 Subject: [PATCH] fix: prevent unnecessary image recompression and fix SQLite migration #95 - Skip image processing for basic/standard protection levels when no fingerprinting or watermarking is enabled - Preserve original image format (PNG/WebP/JPEG) instead of always converting to JPEG - Fix SQLite migration failure for fresh installations by adding multilingual columns to email_templates table before inserting admin email templates Fixes #95 --- .../core/059_add_admin_email_templates.js | 75 +++++++++++++++++++ backend/src/routes/protectedImages.js | 60 +++++++++------ backend/src/services/secureImageService.js | 71 ++++++++++++++++-- 3 files changed, 175 insertions(+), 31 deletions(-) diff --git a/backend/migrations/core/059_add_admin_email_templates.js b/backend/migrations/core/059_add_admin_email_templates.js index bd25b1db..6f2034f1 100644 --- a/backend/migrations/core/059_add_admin_email_templates.js +++ b/backend/migrations/core/059_add_admin_email_templates.js @@ -3,6 +3,81 @@ * These templates support the RBAC (Role-Based Access Control) feature */ exports.up = async function(knex) { + // First, ensure the email_templates table has multilingual columns + // This is needed for fresh installations where legacy migrations don't run + const columnInfo = await knex('email_templates').columnInfo(); + + if (!columnInfo.subject_en) { + // Need to add multilingual columns + console.log('Adding multilingual columns to email_templates table...'); + + // Check if we're using SQLite or PostgreSQL + const client = knex.client.config.client; + const isSqlite = client === 'sqlite3' || client === 'better-sqlite3'; + + if (isSqlite) { + // SQLite doesn't support column rename directly in all versions + // We need to recreate the table with new structure + + // Get existing data + const existingData = await knex('email_templates').select('*'); + + // Drop the old table + await knex.schema.dropTable('email_templates'); + + // Create new table with multilingual columns + await knex.schema.createTable('email_templates', (table) => { + table.increments('id').primary(); + table.string('template_key').unique().notNullable(); + table.string('subject_en'); + table.string('subject_de'); + table.text('body_html_en'); + table.text('body_html_de'); + table.text('body_text_en'); + table.text('body_text_de'); + table.json('variables'); + table.datetime('updated_at').defaultTo(knex.fn.now()); + }); + + // Re-insert existing data with column mapping + for (const row of existingData) { + await knex('email_templates').insert({ + template_key: row.template_key, + subject_en: row.subject, + subject_de: row.subject, // Copy to German as default + body_html_en: row.body_html, + body_html_de: row.body_html, + body_text_en: row.body_text, + body_text_de: row.body_text, + variables: row.variables, + updated_at: row.updated_at + }); + } + + console.log('Migrated email_templates table to multilingual structure'); + } else { + // PostgreSQL supports ALTER TABLE for column operations + await knex.schema.alterTable('email_templates', (table) => { + table.renameColumn('subject', 'subject_en'); + table.renameColumn('body_html', 'body_html_en'); + table.renameColumn('body_text', 'body_text_en'); + }); + + await knex.schema.alterTable('email_templates', (table) => { + table.string('subject_de'); + table.text('body_html_de'); + table.text('body_text_de'); + }); + + // Copy English values to German as defaults + await knex('email_templates').update({ + subject_de: knex.raw('subject_en'), + body_html_de: knex.raw('body_html_en'), + body_text_de: knex.raw('body_text_en') + }); + } + } + // Check which templates already exist const existingTemplates = await knex('email_templates') .select('template_key') diff --git a/backend/src/routes/protectedImages.js b/backend/src/routes/protectedImages.js index 7f51283e..d99d230f 100644 --- a/backend/src/routes/protectedImages.js +++ b/backend/src/routes/protectedImages.js @@ -90,38 +90,50 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) = }, 'view'); // Get protection settings from event + const eventProtectionLevel = req.event.protection_level || protectionLevel; const protectionSettings = { - protectionLevel: req.event.protection_level || protectionLevel, + protectionLevel: eventProtectionLevel, quality: req.event.image_quality || 85, addFingerprint: req.event.add_fingerprint !== false, - fragmentImage: protectionLevel === 'maximum' + fragmentImage: eventProtectionLevel === 'maximum' }; - + // Build full path to photo const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path); - - // Process image with protection - const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings); - - // Apply watermark if enabled + + // For basic/standard protection without special features, serve original file + // This avoids unnecessary recompression + const needsProcessing = eventProtectionLevel === 'enhanced' || + eventProtectionLevel === 'maximum' || + protectionSettings.addFingerprint; + let finalImage; - if (processedImage.type === 'fragmented') { - // Return fragmented image data for canvas reconstruction - return res.json({ - type: 'fragmented', - fragments: processedImage.fragments.map(f => ({ - index: f.index, - row: f.row, - col: f.col, - data: f.buffer.toString('base64'), - position: f.position - })), - dimensions: processedImage.originalDimensions, - fragmentDimensions: processedImage.fragmentDimensions - }); + + if (!needsProcessing) { + // Serve original file without processing + const fs = require('fs').promises; + finalImage = await fs.readFile(photoPath); } else { - const watermarkSettings = await watermarkService.getWatermarkSettings(); - finalImage = await watermarkService.applyWatermark(photoPath, watermarkSettings); + // Process image with protection measures + const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings); + + if (processedImage.type === 'fragmented') { + // Return fragmented image data for canvas reconstruction + return res.json({ + type: 'fragmented', + fragments: processedImage.fragments.map(f => ({ + index: f.index, + row: f.row, + col: f.col, + data: f.buffer.toString('base64'), + position: f.position + })), + dimensions: processedImage.originalDimensions, + fragmentDimensions: processedImage.fragmentDimensions + }); + } + + finalImage = processedImage; } // Set security headers diff --git a/backend/src/services/secureImageService.js b/backend/src/services/secureImageService.js index 5f818e8a..67338761 100644 --- a/backend/src/services/secureImageService.js +++ b/backend/src/services/secureImageService.js @@ -157,6 +157,8 @@ class SecureImageService { /** * Process image with protection measures + * For basic/standard protection without fingerprinting, returns original file + * For enhanced/maximum protection, applies quality reduction and fingerprinting */ async processProtectedImage(imagePath, options = {}) { const { @@ -169,11 +171,58 @@ class SecureImageService { } = options; try { + // For basic protection level, always return original file without processing + if (protectionLevel === 'basic') { + return await fs.readFile(imagePath); + } + + // For standard protection without fingerprinting, return original file + // This avoids unnecessary recompression when no protection features are needed + if (protectionLevel === 'standard' && !addFingerprint && !fragmentImage) { + return await fs.readFile(imagePath); + } + + // Get metadata to check if processing is actually needed + const metadata = await sharp(imagePath).metadata(); + + // For standard protection with fingerprint only (no resize needed, no quality change), + // we can add fingerprint without full recompression by preserving format + const needsResize = metadata.width > maxWidth || metadata.height > maxHeight; + const needsQualityReduction = protectionLevel === 'enhanced' || protectionLevel === 'maximum'; + + // If standard protection and only fingerprinting is needed, and image doesn't need resize, + // just add metadata without recompressing + if (protectionLevel === 'standard' && addFingerprint && !needsResize) { + let image = sharp(imagePath); + + // Add fingerprint to metadata without changing image quality + const fingerprint = crypto.randomBytes(16).toString('hex'); + + // Preserve original format with high quality + const format = metadata.format || 'jpeg'; + if (format === 'png') { + image = image.png({ compressionLevel: 6 }); + } else if (format === 'webp') { + image = image.webp({ quality: 95 }); + } else { + image = image.jpeg({ quality: 100, mozjpeg: true }); + } + + image = image.withMetadata({ + exif: { + [sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}` + } + }); + + return await image.toBuffer(); + } + + // For enhanced/maximum protection or when resize is needed, do full processing let image = sharp(imagePath); - const metadata = await image.metadata(); + let effectiveQuality = quality; // Resize if too large - if (metadata.width > maxWidth || metadata.height > maxHeight) { + if (needsResize) { image = image.resize(maxWidth, maxHeight, { fit: 'inside', withoutEnlargement: true @@ -182,18 +231,26 @@ class SecureImageService { // Apply quality reduction for protection if (protectionLevel === 'enhanced') { - quality = Math.min(quality, 70); + effectiveQuality = Math.min(quality, 70); } else if (protectionLevel === 'maximum') { - quality = Math.min(quality, 60); + effectiveQuality = Math.min(quality, 60); } - // Convert to appropriate format - image = image.jpeg({ quality, progressive: true }); + // Preserve original format when possible, apply quality settings + const format = metadata.format || 'jpeg'; + if (format === 'png' && !needsQualityReduction) { + image = image.png({ compressionLevel: 6 }); + } else if (format === 'webp') { + image = image.webp({ quality: effectiveQuality }); + } else { + // JPEG or when quality reduction is needed (convert to JPEG) + image = image.jpeg({ quality: effectiveQuality, progressive: true }); + } // Add invisible watermark/fingerprint if (addFingerprint) { const fingerprint = crypto.randomBytes(16).toString('hex'); - + // Embed fingerprint in metadata image = image.withMetadata({ exif: {