Deal with webp images on import
This commit is contained in:
@@ -7,6 +7,8 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const fsp = fs.promises;
|
||||
const sharp = require('sharp');
|
||||
const axios = require('axios');
|
||||
const net = require('net');
|
||||
|
||||
// Create uploads directory if it doesn't exist
|
||||
const uploadsDir = path.join('/var/www/html/inventory/uploads/products');
|
||||
@@ -152,6 +154,78 @@ cleanupImagesOnStartup();
|
||||
|
||||
const bytesToMegabytes = (bytes) => Number((bytes / (1024 * 1024)).toFixed(2));
|
||||
|
||||
const getExtensionForImage = (mimetype, sourceName = '') => {
|
||||
const sourceExt = path.extname(sourceName).toLowerCase();
|
||||
if (sourceExt) return sourceExt;
|
||||
|
||||
switch (mimetype) {
|
||||
case 'image/jpeg': return '.jpg';
|
||||
case 'image/png': return '.png';
|
||||
case 'image/gif': return '.gif';
|
||||
case 'image/webp': return '.webp';
|
||||
case 'image/tiff': return '.tif';
|
||||
default: return '.jpg';
|
||||
}
|
||||
};
|
||||
|
||||
const createUploadFilename = (prefix, extension) => {
|
||||
const safePrefix = String(prefix || 'product').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80) || 'product';
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
return `${safePrefix}-${uniqueSuffix}${extension}`;
|
||||
};
|
||||
|
||||
const buildUploadedImageResponse = (reqFile, processingResult, filePath) => {
|
||||
reqFile.size = processingResult.finalSize;
|
||||
|
||||
const effectivePath = processingResult.newFilePath || filePath;
|
||||
if (processingResult.newFilePath) {
|
||||
reqFile.filename = path.basename(processingResult.newFilePath);
|
||||
reqFile.mimetype = 'image/jpeg';
|
||||
}
|
||||
|
||||
const baseUrl = 'https://tools.acherryontop.com';
|
||||
const imageUrl = `${baseUrl}/uploads/products/${reqFile.filename}`;
|
||||
|
||||
scheduleImageDeletion(reqFile.filename, effectivePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
imageUrl,
|
||||
fileName: reqFile.filename,
|
||||
mimetype: reqFile.mimetype,
|
||||
fullPath: effectivePath,
|
||||
notices: processingResult.notices,
|
||||
warnings: processingResult.warnings,
|
||||
metadata: processingResult.metadata,
|
||||
message: 'Image uploaded successfully (will auto-delete after 24 hours)'
|
||||
};
|
||||
};
|
||||
|
||||
const isBlockedImageSourceHost = (hostname) => {
|
||||
const host = String(hostname || '').toLowerCase();
|
||||
if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipVersion = net.isIP(host);
|
||||
if (ipVersion === 4) {
|
||||
const [first, second] = host.split('.').map(Number);
|
||||
return (
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 169 && second === 254) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
);
|
||||
}
|
||||
|
||||
if (ipVersion === 6) {
|
||||
return host === '::1' || host.startsWith('fc') || host.startsWith('fd') || host.startsWith('fe80');
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const processUploadedImage = async (filePath, mimetype) => {
|
||||
const notices = [];
|
||||
const legacyWarnings = [];
|
||||
@@ -211,13 +285,19 @@ const processUploadedImage = async (filePath, mimetype) => {
|
||||
return { notices, warnings: legacyWarnings, metadata, finalSize: metadata.size };
|
||||
}
|
||||
|
||||
// TIFF: convert to JPEG (don't store TIFF files)
|
||||
let convertedFromTiff = false;
|
||||
if (format === 'tiff') {
|
||||
convertedFromTiff = true;
|
||||
// Convert unsupported product image storage formats to JPEG.
|
||||
let convertedToJpegFrom = null;
|
||||
if (format === 'tiff' || format === 'webp') {
|
||||
convertedToJpegFrom = format;
|
||||
format = 'jpeg';
|
||||
const message = 'Converted from TIFF to JPEG.';
|
||||
notices.push({ message, level: 'info', code: 'converted_from_tiff', source: 'server' });
|
||||
const sourceLabel = convertedToJpegFrom === 'tiff' ? 'TIFF' : 'WebP';
|
||||
const message = `Converted from ${sourceLabel} to JPEG.`;
|
||||
notices.push({
|
||||
message,
|
||||
level: 'info',
|
||||
code: `converted_from_${convertedToJpegFrom}`,
|
||||
source: 'server'
|
||||
});
|
||||
legacyWarnings.push(message);
|
||||
}
|
||||
|
||||
@@ -353,7 +433,7 @@ const processUploadedImage = async (filePath, mimetype) => {
|
||||
});
|
||||
legacyWarnings.push(message);
|
||||
}
|
||||
} else if (shouldConvertToRgb || convertedFromTiff) {
|
||||
} else if (shouldConvertToRgb || convertedToJpegFrom) {
|
||||
const { data, info } = await encode({ width: currentWidth, height: currentHeight, quality: targetQuality });
|
||||
mutated = true;
|
||||
finalBuffer = data;
|
||||
@@ -373,10 +453,10 @@ const processUploadedImage = async (filePath, mimetype) => {
|
||||
metadata.optimizedSize = metadata.size;
|
||||
}
|
||||
|
||||
// Rename TIFF files to .jpg after conversion
|
||||
// Rename converted source files to .jpg after conversion
|
||||
let newFilePath = null;
|
||||
if (convertedFromTiff) {
|
||||
newFilePath = filePath.replace(/\.tiff?$/i, '.jpg');
|
||||
if (convertedToJpegFrom) {
|
||||
newFilePath = filePath.replace(/\.(tiff?|webp)$/i, '.jpg');
|
||||
if (newFilePath !== filePath) {
|
||||
await fsp.rename(filePath, newFilePath);
|
||||
}
|
||||
@@ -426,25 +506,8 @@ const storage = multer.diskStorage({
|
||||
cb(null, uploadsDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
// Create unique filename with original extension
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
|
||||
// Make sure we preserve the original file extension
|
||||
let fileExt = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
// Ensure there is a proper extension based on mimetype if none exists
|
||||
if (!fileExt) {
|
||||
switch (file.mimetype) {
|
||||
case 'image/jpeg': fileExt = '.jpg'; break;
|
||||
case 'image/png': fileExt = '.png'; break;
|
||||
case 'image/gif': fileExt = '.gif'; break;
|
||||
case 'image/webp': fileExt = '.webp'; break;
|
||||
case 'image/tiff': fileExt = '.tif'; break;
|
||||
default: fileExt = '.jpg'; // Default to jpg
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = `${req.body.upc || 'product'}-${uniqueSuffix}${fileExt}`;
|
||||
const fileExt = getExtensionForImage(file.mimetype, file.originalname);
|
||||
const fileName = createUploadFilename(req.body.upc || 'product', fileExt);
|
||||
console.log(`Generated filename: ${fileName} with mimetype: ${file.mimetype}`);
|
||||
cb(null, fileName);
|
||||
}
|
||||
@@ -652,34 +715,10 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
|
||||
|
||||
// Process the image (resize/compress/color-space) before responding
|
||||
const processingResult = await processUploadedImage(filePath, req.file.mimetype);
|
||||
req.file.size = processingResult.finalSize;
|
||||
|
||||
// If TIFF was converted to JPG, update filename to match the renamed file
|
||||
const effectivePath = processingResult.newFilePath || filePath;
|
||||
if (processingResult.newFilePath) {
|
||||
req.file.filename = path.basename(processingResult.newFilePath);
|
||||
}
|
||||
|
||||
// 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://tools.acherryontop.com';
|
||||
const imageUrl = `${baseUrl}/uploads/products/${req.file.filename}`;
|
||||
|
||||
// Schedule this image for deletion in 24 hours
|
||||
scheduleImageDeletion(req.file.filename, effectivePath);
|
||||
const responsePayload = buildUploadedImageResponse(req.file, processingResult, filePath);
|
||||
|
||||
// Return success response with image URL
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
imageUrl,
|
||||
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)'
|
||||
});
|
||||
res.status(200).json(responsePayload);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
@@ -697,6 +736,89 @@ router.post('/upload-image', upload.single('image'), async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/upload-image-url', async (req, res) => {
|
||||
let filePath = null;
|
||||
|
||||
try {
|
||||
const { imageUrl, upc, supplier_no } = req.body || {};
|
||||
if (!imageUrl || typeof imageUrl !== 'string') {
|
||||
return res.status(400).json({ error: 'imageUrl is required' });
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(imageUrl.trim());
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Invalid image URL' });
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
return res.status(400).json({ error: 'Image URL must use http or https' });
|
||||
}
|
||||
|
||||
if (isBlockedImageSourceHost(parsedUrl.hostname)) {
|
||||
return res.status(400).json({ error: 'Image URL host is not allowed' });
|
||||
}
|
||||
|
||||
const response = await axios.get(parsedUrl.toString(), {
|
||||
responseType: 'arraybuffer',
|
||||
timeout: 15000,
|
||||
maxContentLength: 15 * 1024 * 1024,
|
||||
maxBodyLength: 15 * 1024 * 1024,
|
||||
headers: {
|
||||
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'User-Agent': 'inventory-image-import/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
const contentType = String(response.headers['content-type'] || '').split(';')[0].toLowerCase();
|
||||
if (!contentType.startsWith('image/')) {
|
||||
return res.status(400).json({ error: 'URL did not return an image' });
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.from(response.data);
|
||||
if (!imageBuffer.length) {
|
||||
return res.status(400).json({ error: 'Downloaded image was empty' });
|
||||
}
|
||||
|
||||
const fileExt = getExtensionForImage(contentType, parsedUrl.pathname);
|
||||
const filename = createUploadFilename(upc || supplier_no || 'product-url', fileExt);
|
||||
filePath = path.join(uploadsDir, filename);
|
||||
await fsp.writeFile(filePath, imageBuffer);
|
||||
|
||||
const reqFile = {
|
||||
filename,
|
||||
originalname: path.basename(parsedUrl.pathname) || filename,
|
||||
mimetype: contentType,
|
||||
size: imageBuffer.length,
|
||||
path: filePath
|
||||
};
|
||||
|
||||
console.log('Image URL downloaded:', {
|
||||
filename: reqFile.filename,
|
||||
originalUrl: parsedUrl.toString(),
|
||||
mimetype: reqFile.mimetype,
|
||||
size: reqFile.size,
|
||||
path: reqFile.path
|
||||
});
|
||||
|
||||
const processingResult = await processUploadedImage(filePath, reqFile.mimetype);
|
||||
const responsePayload = buildUploadedImageResponse(reqFile, processingResult, filePath);
|
||||
|
||||
res.status(200).json(responsePayload);
|
||||
} catch (error) {
|
||||
console.error('Error uploading image from URL:', error);
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (cleanupError) {
|
||||
console.error('Failed to remove URL-downloaded file after processing error:', cleanupError);
|
||||
}
|
||||
}
|
||||
res.status(500).json({ error: error.message || 'Failed to upload image from URL' });
|
||||
}
|
||||
});
|
||||
|
||||
// Image deletion endpoint
|
||||
router.delete('/delete-image', (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user