fix(tandoor): use File constructor for proper multipart uploads
- Remove unreliable URL pass-through strategy (image_url field) - Always download and upload images as File objects - Get MIME type from HTTP response headers for URLs - Use File constructor (not just Blob) for proper multipart metadata - Add comprehensive error logging with headers and file metadata - Simplify to single reliable upload path Fixes 400 'Upload a valid image' error caused by Blob not providing proper filename/MIME metadata in multipart form data.
This commit is contained in:
@@ -372,15 +372,17 @@ function getExtensionFromMimeType(mimeType: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an image to a Tandoor recipe with intelligent format handling
|
||||
* Uploads an image to a Tandoor recipe using proper multipart/form-data format
|
||||
*
|
||||
* Supports three upload strategies:
|
||||
* 1. Direct URL pass-through (most efficient) - for meta tags, Instagram URLs
|
||||
* 2. Base64 data URL conversion to file upload - for screenshots
|
||||
* 3. Fallback blob upload - for any other format
|
||||
* Always downloads the image and uploads as a File object (not Blob).
|
||||
* This ensures proper multipart encoding with filename and MIME type metadata.
|
||||
*
|
||||
* Handles two source formats:
|
||||
* - Direct HTTP(S) URLs: Downloads from URL, detects MIME from response headers
|
||||
* - Base64 data URLs: Decodes base64, uses embedded MIME type
|
||||
*
|
||||
* @param recipeId - Tandoor recipe ID
|
||||
* @param imageUrl - Image URL (can be HTTP(S) URL or base64 data URL)
|
||||
* @param imageUrl - Image URL (HTTP(S) URL or base64 data URL)
|
||||
* @returns Success status and optional error message
|
||||
*/
|
||||
export async function uploadRecipeImage(
|
||||
@@ -397,111 +399,102 @@ export async function uploadRecipeImage(
|
||||
console.log(`[Tandoor Upload] Image type: ${isDirectUrl(imageUrl) ? 'URL' : isDataUrl(imageUrl) ? 'Base64' : 'Unknown'}`);
|
||||
console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}...`);
|
||||
|
||||
// Strategy 1: Direct URL pass-through (preferred)
|
||||
if (isDirectUrl(imageUrl)) {
|
||||
console.log('[Tandoor Upload] Using URL pass-through strategy');
|
||||
const formData = new FormData();
|
||||
formData.append('image_url', imageUrl);
|
||||
let buffer: Buffer;
|
||||
let mimeType: string;
|
||||
let sourceType: string;
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
|
||||
if (uploadResponse.ok) {
|
||||
console.log('[Tandoor Upload] ✓ Success via URL pass-through');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// If URL strategy fails, fall through to file upload
|
||||
const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText);
|
||||
console.warn(`[Tandoor Upload] URL pass-through failed (${uploadResponse.status}), trying file upload: ${errorText}`);
|
||||
}
|
||||
|
||||
// Strategy 2: Base64 data URL to file upload
|
||||
// Handle base64 data URLs
|
||||
if (isDataUrl(imageUrl)) {
|
||||
console.log('[Tandoor Upload] Using base64 file upload strategy');
|
||||
sourceType = 'base64';
|
||||
console.log('[Tandoor Upload] Decoding base64 data URL');
|
||||
|
||||
const parsed = parseDataUrl(imageUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid data URL format' };
|
||||
}
|
||||
|
||||
// Convert base64 to buffer
|
||||
const imageBuffer = Buffer.from(parsed.base64Data, 'base64');
|
||||
const extension = getExtensionFromMimeType(parsed.mimeType);
|
||||
buffer = Buffer.from(parsed.base64Data, 'base64');
|
||||
mimeType = parsed.mimeType;
|
||||
console.log(`[Tandoor Upload] Decoded ${buffer.length} bytes, MIME: ${mimeType}`);
|
||||
}
|
||||
// Handle direct HTTP(S) URLs
|
||||
else if (isDirectUrl(imageUrl)) {
|
||||
sourceType = 'url';
|
||||
console.log('[Tandoor Upload] Downloading image from URL');
|
||||
|
||||
// Create a proper file blob
|
||||
const blob = new Blob([imageBuffer], { type: parsed.mimeType });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', blob, `recipe-image${extension}`);
|
||||
|
||||
const uploadResponse = await fetch(
|
||||
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText);
|
||||
console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
|
||||
console.error(`[Tandoor Upload] Response: ${errorText.substring(0, 200)}`);
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}`
|
||||
error: `Failed to download image: ${response.status} ${response.statusText}`
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[Tandoor Upload] ✓ Success via base64 file upload (${imageBuffer.length} bytes)`);
|
||||
return { success: true };
|
||||
// Get MIME type from response headers (most reliable)
|
||||
mimeType = response.headers.get('content-type') || 'image/jpeg';
|
||||
// Remove charset if present (e.g., "image/jpeg; charset=utf-8")
|
||||
mimeType = mimeType.split(';')[0].trim();
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
buffer = Buffer.from(arrayBuffer);
|
||||
console.log(`[Tandoor Upload] Downloaded ${buffer.length} bytes, MIME: ${mimeType}`);
|
||||
}
|
||||
// Unknown format
|
||||
else {
|
||||
return { success: false, error: 'Unsupported image format (not HTTP(S) URL or data URL)' };
|
||||
}
|
||||
|
||||
// Strategy 3: Fallback - try to fetch and upload
|
||||
console.log('[Tandoor Upload] Using fallback fetch strategy');
|
||||
const response = await fetch(imageUrl);
|
||||
const imageBlob = await response.blob();
|
||||
// Create proper File object (critical for multipart/form-data)
|
||||
const extension = getExtensionFromMimeType(mimeType);
|
||||
const filename = `recipe-image${extension}`;
|
||||
|
||||
// Determine file extension from blob type or default to jpg
|
||||
let extension = '.jpg';
|
||||
if (imageBlob.type) {
|
||||
extension = getExtensionFromMimeType(imageBlob.type);
|
||||
}
|
||||
// In Node.js, we must create a File from Blob (Blob alone doesn't work)
|
||||
const blob = new Blob([buffer], { type: mimeType });
|
||||
const file = new File([blob], filename, { type: mimeType });
|
||||
|
||||
console.log(`[Tandoor Upload] Created File: ${filename} (${file.size} bytes, ${file.type})`);
|
||||
|
||||
// Upload to Tandoor
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageBlob, `recipe-image${extension}`);
|
||||
formData.append('image', file);
|
||||
|
||||
console.log('[Tandoor Upload] Uploading to Tandoor...');
|
||||
const uploadResponse = await fetch(
|
||||
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
// DO NOT set Content-Type - let fetch set it with boundary
|
||||
},
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText);
|
||||
const responseHeaders = JSON.stringify(Object.fromEntries(uploadResponse.headers.entries()));
|
||||
|
||||
console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
|
||||
console.error(`[Tandoor Upload] Response: ${errorText.substring(0, 200)}`);
|
||||
console.error(`[Tandoor Upload] Response headers: ${responseHeaders}`);
|
||||
console.error(`[Tandoor Upload] Response body: ${errorText.substring(0, 500)}`);
|
||||
console.error(`[Tandoor Upload] File metadata: ${filename}, ${file.size} bytes, ${file.type}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}`
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[Tandoor Upload] ✓ Success via fallback (${imageBlob.size} bytes)`);
|
||||
console.log(`[Tandoor Upload] ✓ Success (${sourceType}, ${file.size} bytes)`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorStack = error instanceof Error ? error.stack : '';
|
||||
console.error(`[Tandoor Upload] Exception: ${errorMsg}`);
|
||||
if (errorStack) {
|
||||
console.error(`[Tandoor Upload] Stack: ${errorStack}`);
|
||||
}
|
||||
// Don't fail recipe creation if image fails
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user