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
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user