From 38f4db3d15722057f75711e43c0fe72ae34c7c0d Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 1 May 2026 11:23:05 -0400 Subject: [PATCH] Deal with webp images on import --- inventory-server/src/routes/import.js | 232 +++++++++++++----- .../src/components/bulk-edit/BulkEditRow.tsx | 14 +- .../product-editor/ImageManager.tsx | 48 +++- .../product-editor/ProductEditForm.tsx | 28 ++- .../hooks/useUrlImageUpload.ts | 46 +++- 5 files changed, 290 insertions(+), 78 deletions(-) diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index 509d416..45399f1 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -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 { diff --git a/inventory/src/components/bulk-edit/BulkEditRow.tsx b/inventory/src/components/bulk-edit/BulkEditRow.tsx index e66ca62..c70a0a6 100644 --- a/inventory/src/components/bulk-edit/BulkEditRow.tsx +++ b/inventory/src/components/bulk-edit/BulkEditRow.tsx @@ -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 { diff --git a/inventory/src/components/product-editor/ImageManager.tsx b/inventory/src/components/product-editor/ImageManager.tsx index 536c856..4d0a094 100644 --- a/inventory/src/components/product-editor/ImageManager.tsx +++ b/inventory/src/components/product-editor/ImageManager.tsx @@ -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 diff --git a/inventory/src/components/product-editor/ProductEditForm.tsx b/inventory/src/components/product-editor/ProductEditForm.tsx index 1578630..ba62b1f 100644 --- a/inventory/src/components/product-editor/ProductEditForm.tsx +++ b/inventory/src/components/product-editor/ProductEditForm.tsx @@ -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 = {}; @@ -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) { diff --git a/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useUrlImageUpload.ts b/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useUrlImageUpload.ts index db1a287..160d224 100644 --- a/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useUrlImageUpload.ts +++ b/inventory/src/components/product-import/steps/ImageUploadStep/hooks/useUrlImageUpload.ts @@ -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>; @@ -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]: '' })); @@ -95,4 +133,4 @@ export const useUrlImageUpload = ({ handleAddImageFromUrl, updateUrlInput }; -}; \ No newline at end of file +};