Deal with webp images on import

This commit is contained in:
2026-05-01 11:23:05 -04:00
parent edfa86608c
commit 38f4db3d15
5 changed files with 290 additions and 78 deletions
+177 -55
View File
@@ -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 {
@@ -93,19 +93,9 @@ export function getFieldValue(product: SearchProduct, field: BulkEditFieldChoice
}
}
/** Get the backend field key for submission */
/** Get the product editor API field key for submission */
export function getSubmitFieldKey(field: BulkEditFieldChoice): string {
switch (field) {
case "name": return "description"; // backend field is "description" for product name
case "description": return "notes"; // backend uses "notes" for product description
case "hts_code": return "harmonized_tariff_code";
case "msrp": return "sellingprice";
case "cost_each": return "cost_each";
case "tax_cat": return "tax_code";
case "size_cat": return "size_cat";
case "ship_restrictions": return "shipping_restrictions";
default: return field;
}
return field;
}
interface BulkEditRowProps {
@@ -188,6 +188,24 @@ function SortableImageCell({
let newImageCounter = 0;
const normalizeImageUrlInput = (url: string) => {
const trimmed = url.trim();
if (!trimmed) return "";
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
return trimmed;
}
return `https://${trimmed}`;
};
const isLikelyWebpUrl = (url: string) => {
try {
const parsed = new URL(normalizeImageUrlInput(url));
return parsed.pathname.toLowerCase().endsWith(".webp");
} catch {
return url.toLowerCase().includes(".webp");
}
};
export function ImageManager({
images,
setImages,
@@ -301,12 +319,36 @@ export function ImageManager({
[addNewImage]
);
const handleUrlAdd = useCallback(() => {
const handleUrlAdd = useCallback(async () => {
const url = urlInput.trim();
if (!url) return;
addNewImage(url);
setUrlInput("");
if (!isLikelyWebpUrl(url)) {
addNewImage(url);
setUrlInput("");
setAddOpen(false);
return;
}
setIsUploading(true);
setAddOpen(false);
try {
const res = await axios.post("/api/import/upload-image-url", {
imageUrl: normalizeImageUrlInput(url),
});
if (res.data?.imageUrl) {
addNewImage(res.data.imageUrl);
setUrlInput("");
} else {
throw new Error("Upload response did not include an image URL");
}
} catch (error) {
console.error("Failed to convert WebP image URL:", error);
toast.error(`Failed to convert WebP image URL: ${error instanceof Error ? error.message : "Unknown error"}`);
} finally {
setIsUploading(false);
}
}, [urlInput, addNewImage]);
const activeImage = activeId
@@ -344,8 +344,10 @@ export function ProductEditForm({
const originalIds = original.map((img) => img.iid);
const currentIds = current.map((img) => img.iid);
const originalExistingIds = originalIds.filter((id): id is number => typeof id === "number");
const currentExistingIds = currentIds.filter((id): id is number => typeof id === "number");
const toDelete = originalIds.filter((id) => !currentIds.includes(id)) as number[];
const toDelete = originalExistingIds.filter((id) => !currentIds.includes(id));
const hidden = current.filter((img) => img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
const show = current.filter((img) => !img.hidden).map((img) => img.iid).filter((id): id is number => typeof id === "number");
const add: Record<string, string> = {};
@@ -357,8 +359,11 @@ export function ProductEditForm({
const order = current.map((img) => img.iid);
const originalHidden = original.filter((img) => img.hidden).map((img) => img.iid);
const orderChanged = JSON.stringify(order.filter((id) => typeof id === "number")) !== JSON.stringify(originalIds);
const originalHidden = original
.filter((img) => img.hidden)
.map((img) => img.iid)
.filter((id): id is number => typeof id === "number");
const orderChanged = JSON.stringify(currentExistingIds) !== JSON.stringify(originalExistingIds);
const hiddenChanged = JSON.stringify([...hidden].sort()) !== JSON.stringify([...(originalHidden as number[])].sort());
const hasDeleted = toDelete.length > 0;
const hasAdded = Object.keys(add).length > 0;
@@ -492,7 +497,22 @@ export function ProductEditForm({
} else {
toast.success("Product updated successfully");
originalValuesRef.current = { ...data };
originalImagesRef.current = [...productImages];
if (imageChanges) {
try {
const res = await axios.get(`/api/import/product-images/${product.pid}`);
originalImagesRef.current = res.data;
setProductImages(res.data);
} catch {
const submittedImages = productImages.map((img) => ({
...img,
isNew: false,
}));
originalImagesRef.current = submittedImages;
setProductImages(submittedImages);
}
} else {
originalImagesRef.current = [...productImages];
}
reset(data);
}
} catch (err) {
@@ -1,9 +1,18 @@
import { useState } from "react";
import { toast } from "sonner";
import config from "@/config";
import { Product, ProductImageSortable } from "../types";
type AddImageToProductFn = (productIndex: number, imageUrl: string) => Product[];
const isLikelyWebpUrl = (url: string) => {
try {
return new URL(url).pathname.toLowerCase().endsWith(".webp");
} catch {
return url.toLowerCase().includes(".webp");
}
};
interface UseUrlImageUploadProps {
data: Product[];
setProductImages: React.Dispatch<React.SetStateAction<ProductImageSortable[]>>;
@@ -46,6 +55,35 @@ export const useUrlImageUpload = ({
return;
}
let imageUrl = validatedUrl;
let fileName = "From URL";
if (isLikelyWebpUrl(validatedUrl)) {
const response = await fetch(`${config.apiUrl}/import/upload-image-url`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
imageUrl: validatedUrl,
upc: data[productIndex].upc || "",
supplier_no: data[productIndex].supplier_no || "",
}),
});
if (!response.ok) {
throw new Error("Failed to convert WebP image URL");
}
const result = await response.json();
if (!result.imageUrl) {
throw new Error("Upload response did not include an image URL");
}
imageUrl = result.imageUrl;
fileName = result.fileName || "Converted WebP URL";
}
// Create a unique ID for this image
const imageId = `image-${productIndex}-url-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
@@ -53,9 +91,9 @@ export const useUrlImageUpload = ({
const newImage: ProductImageSortable = {
id: imageId,
productIndex,
imageUrl: validatedUrl,
imageUrl,
loading: false, // We're not loading from server, so it's ready immediately
fileName: "From URL",
fileName,
// Add required schema fields
pid: data[productIndex].id || 0,
iid: 0,
@@ -70,7 +108,7 @@ export const useUrlImageUpload = ({
setProductImages(prev => [...prev, newImage]);
// Update the product data with the new image URL
addImageToProduct(productIndex, validatedUrl);
addImageToProduct(productIndex, imageUrl);
// Clear the URL input field on success
setUrlInputs(prev => ({ ...prev, [productIndex]: '' }));