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:
Giancarmine Salucci
2025-12-21 05:19:33 +01:00
parent 856c5c26f4
commit cc7b8032cb
4 changed files with 16022 additions and 76 deletions

View File

@@ -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 };
}