Add image processing and related warnings system, update import results page
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user