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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
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 };
|
||||
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) {
|
||||
|
||||
+41
-3
@@ -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]: '' }));
|
||||
|
||||
Reference in New Issue
Block a user