Add image processing and related warnings system, update import results page

This commit is contained in:
2025-10-24 12:04:46 -04:00
parent 72930bbc73
commit 0b5f3162c7
11 changed files with 1208 additions and 74 deletions

View File

@@ -5,6 +5,8 @@ const mysql = require('mysql2/promise');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const fsp = fs.promises;
const sharp = require('sharp');
// Create uploads directory if it doesn't exist
const uploadsDir = path.join('/var/www/html/inventory/uploads/products');
@@ -35,6 +37,9 @@ const connectionCache = {
}
};
const MIN_IMAGE_DIMENSION = 1000;
const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024;
// Function to schedule image deletion after 24 hours
const scheduleImageDeletion = (filename, filePath) => {
// Only schedule deletion for images in the products folder
@@ -145,6 +150,255 @@ const cleanupImagesOnStartup = () => {
// Run cleanup on server start
cleanupImagesOnStartup();
const bytesToMegabytes = (bytes) => Number((bytes / (1024 * 1024)).toFixed(2));
const processUploadedImage = async (filePath, mimetype) => {
const notices = [];
const legacyWarnings = [];
const metadata = {};
const originalBuffer = await fsp.readFile(filePath);
let baseMetadata = await sharp(originalBuffer, { failOn: 'none' }).metadata();
metadata.width = baseMetadata.width || 0;
metadata.height = baseMetadata.height || 0;
metadata.size = originalBuffer.length;
metadata.colorSpace = baseMetadata.space || baseMetadata.colourspace || null;
if (
baseMetadata.width &&
baseMetadata.height &&
(baseMetadata.width < MIN_IMAGE_DIMENSION || baseMetadata.height < MIN_IMAGE_DIMENSION)
) {
const message = `Image is ${baseMetadata.width}x${baseMetadata.height}. Recommended minimum is ${MIN_IMAGE_DIMENSION}x${MIN_IMAGE_DIMENSION}.`;
notices.push({
message,
level: 'warning',
code: 'dimensions_too_small',
source: 'server'
});
legacyWarnings.push(message);
}
const colorSpace = (baseMetadata.space || baseMetadata.colourspace || '').toLowerCase();
let shouldConvertToRgb = colorSpace === 'cmyk';
if (shouldConvertToRgb) {
const message = 'Converted image from CMYK to RGB.';
notices.push({
message,
level: 'info',
code: 'converted_to_rgb',
source: 'server'
});
legacyWarnings.push(message);
}
const format = (baseMetadata.format || '').toLowerCase();
if (format === 'gif') {
if (metadata.size > MAX_IMAGE_SIZE_BYTES) {
const message = `GIF optimization is limited; resulting size is ${bytesToMegabytes(metadata.size)}MB (target 5MB).`;
notices.push({
message,
level: 'warning',
code: 'gif_size_limit',
source: 'server'
});
legacyWarnings.push(message);
}
metadata.convertedToRgb = false;
metadata.resized = false;
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
}
const supportsQuality = ['jpeg', 'jpg', 'webp'].includes(format);
let targetQuality = supportsQuality ? 90 : undefined;
let finalQuality = undefined;
let currentWidth = baseMetadata.width || null;
let currentHeight = baseMetadata.height || null;
let resized = false;
let mutated = false;
let finalBuffer = originalBuffer;
let finalInfo = baseMetadata;
const encode = async ({ width, height, quality }) => {
let pipeline = sharp(originalBuffer, { failOn: 'none' });
if (shouldConvertToRgb) {
pipeline = pipeline.toColorspace('srgb');
}
if (width || height) {
pipeline = pipeline.resize({
width: width ?? undefined,
height: height ?? undefined,
fit: 'inside',
withoutEnlargement: true,
});
}
switch (format) {
case 'png':
pipeline = pipeline.png({
compressionLevel: 9,
adaptiveFiltering: true,
palette: true,
});
break;
case 'webp':
pipeline = pipeline.webp({ quality: quality ?? 90 });
break;
case 'jpeg':
case 'jpg':
default:
pipeline = pipeline.jpeg({ quality: quality ?? 90, mozjpeg: true });
break;
}
return pipeline.toBuffer({ resolveWithObject: true });
};
const canResize =
(currentWidth && currentWidth > MIN_IMAGE_DIMENSION) ||
(currentHeight && currentHeight > MIN_IMAGE_DIMENSION);
if (metadata.size > MAX_IMAGE_SIZE_BYTES && (supportsQuality || canResize)) {
const maxAttempts = 8;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let targetWidth = currentWidth;
let targetHeight = currentHeight;
let resizedThisAttempt = false;
if (currentWidth && currentWidth > MIN_IMAGE_DIMENSION) {
targetWidth = Math.max(MIN_IMAGE_DIMENSION, Math.round(currentWidth * 0.85));
}
if (currentHeight && currentHeight > MIN_IMAGE_DIMENSION) {
targetHeight = Math.max(MIN_IMAGE_DIMENSION, Math.round(currentHeight * 0.85));
}
if (
(targetWidth && currentWidth && targetWidth < currentWidth) ||
(targetHeight && currentHeight && targetHeight < currentHeight)
) {
resized = true;
resizedThisAttempt = true;
currentWidth = targetWidth;
currentHeight = targetHeight;
} else if (!supportsQuality || (targetQuality && targetQuality <= 70)) {
// Cannot resize further and quality cannot be adjusted
break;
}
const qualityForAttempt = supportsQuality ? targetQuality : undefined;
const { data, info } = await encode({
width: currentWidth,
height: currentHeight,
quality: qualityForAttempt,
});
mutated = true;
finalBuffer = data;
finalInfo = info;
metadata.optimizedSize = data.length;
if (info.width) metadata.width = info.width;
if (info.height) metadata.height = info.height;
if (info.width) currentWidth = info.width;
if (info.height) currentHeight = info.height;
if (supportsQuality && qualityForAttempt) {
finalQuality = qualityForAttempt;
}
if (data.length <= MAX_IMAGE_SIZE_BYTES) {
break;
}
if (resizedThisAttempt) {
continue;
}
if (supportsQuality && targetQuality && targetQuality > 70) {
const nextQuality = Math.max(70, targetQuality - 10);
if (nextQuality === targetQuality) {
break;
}
targetQuality = nextQuality;
continue;
}
break;
}
if (finalBuffer.length > MAX_IMAGE_SIZE_BYTES) {
const message = `Optimized image remains ${bytesToMegabytes(finalBuffer.length)}MB (target 5MB).`;
notices.push({
message,
level: 'warning',
code: 'size_over_limit',
source: 'server'
});
legacyWarnings.push(message);
}
} else if (shouldConvertToRgb) {
const { data, info } = await encode({ width: currentWidth, height: currentHeight });
mutated = true;
finalBuffer = data;
finalInfo = info;
metadata.optimizedSize = data.length;
if (info.width) metadata.width = info.width;
if (info.height) metadata.height = info.height;
if (info.width) currentWidth = info.width;
if (info.height) currentHeight = info.height;
}
if (mutated) {
await fsp.writeFile(filePath, finalBuffer);
metadata.optimizedSize = finalBuffer.length;
} else {
// No transformation occurred; still need to ensure we report original stats
metadata.optimizedSize = metadata.size;
}
metadata.convertedToRgb = shouldConvertToRgb && mutated;
metadata.resized = resized;
if (finalQuality) {
metadata.quality = finalQuality;
}
if (resized && metadata.width && metadata.height) {
const message = `Image resized to ${metadata.width}x${metadata.height} during optimization.`;
notices.push({
message,
level: 'info',
code: 'resized',
source: 'server'
});
legacyWarnings.push(message);
}
if (finalQuality && finalQuality < 90) {
const message = `Image quality adjusted to ${finalQuality} to reduce file size.`;
notices.push({
message,
level: 'info',
code: 'quality_adjusted',
source: 'server'
});
legacyWarnings.push(message);
}
return {
notices,
warnings: legacyWarnings,
metadata,
finalSize: finalBuffer.length,
};
};
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: function (req, file, cb) {
@@ -178,7 +432,7 @@ const storage = multer.diskStorage({
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max file size
fileSize: 15 * 1024 * 1024, // Allow bigger uploads; processing will reduce to 5MB
},
fileFilter: function (req, file, cb) {
// Accept only image files
@@ -345,7 +599,7 @@ async function setupSshTunnel() {
}
// Image upload endpoint
router.post('/upload-image', upload.single('image'), (req, res) => {
router.post('/upload-image', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No image file provided' });
@@ -375,6 +629,10 @@ router.post('/upload-image', upload.single('image'), (req, res) => {
}
});
// Process the image (resize/compress/color-space) before responding
const processingResult = await processUploadedImage(filePath, req.file.mimetype);
req.file.size = processingResult.finalSize;
// Create URL for the uploaded file - using an absolute URL with domain
// This will generate a URL like: https://acot.site/uploads/products/filename.jpg
const baseUrl = 'https://acot.site';
@@ -390,11 +648,24 @@ router.post('/upload-image', upload.single('image'), (req, res) => {
fileName: req.file.filename,
mimetype: req.file.mimetype,
fullPath: filePath,
notices: processingResult.notices,
warnings: processingResult.warnings,
metadata: processingResult.metadata,
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
});
} catch (error) {
console.error('Error uploading image:', error);
if (req?.file?.filename) {
const cleanupPath = path.join(uploadsDir, req.file.filename);
if (fs.existsSync(cleanupPath)) {
try {
fs.unlinkSync(cleanupPath);
} catch (cleanupError) {
console.error('Failed to remove file after processing error:', cleanupError);
}
}
}
res.status(500).json({ error: error.message || 'Failed to upload image' });
}
});