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
|
* These templates support the RBAC (Role-Based Access Control) feature
|
||||||
*/
|
*/
|
||||||
exports.up = async function(knex) {
|
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
|
// Check which templates already exist
|
||||||
const existingTemplates = await knex('email_templates')
|
const existingTemplates = await knex('email_templates')
|
||||||
.select('template_key')
|
.select('template_key')
|
||||||
|
|||||||
@@ -90,38 +90,50 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
|
|||||||
}, 'view');
|
}, 'view');
|
||||||
|
|
||||||
// Get protection settings from event
|
// Get protection settings from event
|
||||||
|
const eventProtectionLevel = req.event.protection_level || protectionLevel;
|
||||||
const protectionSettings = {
|
const protectionSettings = {
|
||||||
protectionLevel: req.event.protection_level || protectionLevel,
|
protectionLevel: eventProtectionLevel,
|
||||||
quality: req.event.image_quality || 85,
|
quality: req.event.image_quality || 85,
|
||||||
addFingerprint: req.event.add_fingerprint !== false,
|
addFingerprint: req.event.add_fingerprint !== false,
|
||||||
fragmentImage: protectionLevel === 'maximum'
|
fragmentImage: eventProtectionLevel === 'maximum'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build full path to photo
|
// Build full path to photo
|
||||||
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
|
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
|
||||||
|
|
||||||
// Process image with protection
|
// For basic/standard protection without special features, serve original file
|
||||||
const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings);
|
// This avoids unnecessary recompression
|
||||||
|
const needsProcessing = eventProtectionLevel === 'enhanced' ||
|
||||||
// Apply watermark if enabled
|
eventProtectionLevel === 'maximum' ||
|
||||||
|
protectionSettings.addFingerprint;
|
||||||
|
|
||||||
let finalImage;
|
let finalImage;
|
||||||
if (processedImage.type === 'fragmented') {
|
|
||||||
// Return fragmented image data for canvas reconstruction
|
if (!needsProcessing) {
|
||||||
return res.json({
|
// Serve original file without processing
|
||||||
type: 'fragmented',
|
const fs = require('fs').promises;
|
||||||
fragments: processedImage.fragments.map(f => ({
|
finalImage = await fs.readFile(photoPath);
|
||||||
index: f.index,
|
|
||||||
row: f.row,
|
|
||||||
col: f.col,
|
|
||||||
data: f.buffer.toString('base64'),
|
|
||||||
position: f.position
|
|
||||||
})),
|
|
||||||
dimensions: processedImage.originalDimensions,
|
|
||||||
fragmentDimensions: processedImage.fragmentDimensions
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
// Process image with protection measures
|
||||||
finalImage = await watermarkService.applyWatermark(photoPath, watermarkSettings);
|
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
|
// Set security headers
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ class SecureImageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Process image with protection measures
|
* 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 = {}) {
|
async processProtectedImage(imagePath, options = {}) {
|
||||||
const {
|
const {
|
||||||
@@ -169,11 +171,58 @@ class SecureImageService {
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
try {
|
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);
|
let image = sharp(imagePath);
|
||||||
const metadata = await image.metadata();
|
let effectiveQuality = quality;
|
||||||
|
|
||||||
// Resize if too large
|
// Resize if too large
|
||||||
if (metadata.width > maxWidth || metadata.height > maxHeight) {
|
if (needsResize) {
|
||||||
image = image.resize(maxWidth, maxHeight, {
|
image = image.resize(maxWidth, maxHeight, {
|
||||||
fit: 'inside',
|
fit: 'inside',
|
||||||
withoutEnlargement: true
|
withoutEnlargement: true
|
||||||
@@ -182,18 +231,26 @@ class SecureImageService {
|
|||||||
|
|
||||||
// Apply quality reduction for protection
|
// Apply quality reduction for protection
|
||||||
if (protectionLevel === 'enhanced') {
|
if (protectionLevel === 'enhanced') {
|
||||||
quality = Math.min(quality, 70);
|
effectiveQuality = Math.min(quality, 70);
|
||||||
} else if (protectionLevel === 'maximum') {
|
} else if (protectionLevel === 'maximum') {
|
||||||
quality = Math.min(quality, 60);
|
effectiveQuality = Math.min(quality, 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to appropriate format
|
// Preserve original format when possible, apply quality settings
|
||||||
image = image.jpeg({ quality, progressive: true });
|
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
|
// Add invisible watermark/fingerprint
|
||||||
if (addFingerprint) {
|
if (addFingerprint) {
|
||||||
const fingerprint = crypto.randomBytes(16).toString('hex');
|
const fingerprint = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
// Embed fingerprint in metadata
|
// Embed fingerprint in metadata
|
||||||
image = image.withMetadata({
|
image = image.withMetadata({
|
||||||
exif: {
|
exif: {
|
||||||
|
|||||||
Reference in New Issue
Block a user