- 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.
14 KiB
Execution Plan: Fix Tandoor Image Upload (v2)
Date: 2025-12-21
Author: Analyst Agent
Status: Draft
Issue: URL pass-through fails with 500, file upload fails with 400 "Upload a valid image"
Problem Statement
Thumbnail upload to Tandoor is failing with two distinct errors:
[Tandoor Upload] Using URL pass-through strategy
[Tandoor Upload] URL pass-through failed (500), trying file upload
[Tandoor Upload] Using fallback fetch strategy
[Tandoor Upload] Failed: 400 Bad Request
[Tandoor Upload] Response: {"image":["Upload a valid image. The file you uploaded was either not an image or a corrupted image."]}
Root Cause Analysis
Issue 1: URL Pass-through Fails (500 Error)
Current Implementation:
formData.append('image_url', imageUrl);
Problem: The OpenAPI spec shows that RecipeImage schema has two fields:
image:type: string, format: uri(for file upload)image_url:type: string, maxLength: 4096(for URL)
However, the 500 error suggests Tandoor might not support image_url field in this version, or it's encountering an error when trying to fetch the URL server-side.
Issue 2: File Upload Fails (400 Error)
Current Implementation:
const blob = new Blob([imageBuffer], { type: parsed.mimeType });
formData.append('image', blob, `recipe-image${extension}`);
Problem: According to OpenAPI spec:
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/RecipeImage'
The image field expects format: uri which in multipart context means an actual file with proper headers. Our current Blob might be missing critical multipart headers or the blob isn't being properly recognized as a file.
Root Cause: In Node.js/server-side context, Blob API might not work the same as in browser. We need to use proper Node.js file handling or ensure the Blob is correctly formatted for multipart upload.
Analysis from OpenAPI Spec
Endpoint Definition
/api/recipe/{id}/image/:
put:
operationId: apiRecipeImageUpdate
requestBody:
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/RecipeImage'
RecipeImage Schema
RecipeImage:
type: object
properties:
image:
type: string
format: uri
nullable: true
image_url:
type: string
nullable: true
maxLength: 4096
Key Insights
- Both fields are optional (
nullable: true) image_urlexists but may not be working (500 error suggests server-side issue)imageexpects file upload via multipart/form-data- No Content-Type header should be set manually (let browser/Node set it for multipart)
Proposed Solution
Strategy Change
Since image_url is causing 500 errors (Tandoor server can't fetch or process the URL), we should:
- Always download and upload the image (more reliable)
- Fix the file upload format to ensure proper multipart headers
- Remove URL pass-through (or make it optional/fallback)
Technical Fix Required
The issue is that in server-side Node.js context (SvelteKit server), the Blob API doesn't create proper multipart form data. We need to:
- Use
Fileconstructor with proper filename and type - Or use
Bufferwith proper form-data library - Ensure proper MIME type is set
- Let FormData handle Content-Type header (don't set it manually)
Implementation Plan
Story 1: Fix File Upload for Direct URLs
Objective: Make direct URL images download and upload correctly
Current Problem:
const response = await fetch(imageUrl);
const imageBlob = await response.blob();
formData.append('image', imageBlob, `recipe-image${extension}`);
// Fails with 400: "Upload a valid image"
Solution:
In SvelteKit server environment, we need to handle this differently:
// Download the image
const response = await fetch(imageUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Get proper MIME type
const mimeType = response.headers.get('content-type') || 'image/jpeg';
const extension = getExtensionFromMimeType(mimeType);
// Create a proper File object (if available) or use Blob correctly
const blob = new Blob([buffer], { type: mimeType });
const file = new File([blob], `recipe-image${extension}`, { type: mimeType });
const formData = new FormData();
formData.append('image', file);
Acceptance Criteria:
- Direct URL images (from meta tags) upload successfully
- No 400 "Upload a valid image" errors
- Proper MIME type detected from response headers
- File has correct extension and name
Story 2: Fix Base64 Data URL Upload
Objective: Make base64 screenshot images upload correctly
Current Problem:
const imageBuffer = Buffer.from(parsed.base64Data, 'base64');
const blob = new Blob([imageBuffer], { type: parsed.mimeType });
formData.append('image', blob, `recipe-image${extension}`);
// Fails with 400: "Upload a valid image"
Solution:
// Parse base64 data URL
const parsed = parseDataUrl(imageUrl);
const imageBuffer = Buffer.from(parsed.base64Data, 'base64');
const extension = getExtensionFromMimeType(parsed.mimeType);
// Create proper File object
const blob = new Blob([imageBuffer], { type: parsed.mimeType });
const file = new File([blob], `recipe-image${extension}`, { type: parsed.mimeType });
const formData = new FormData();
formData.append('image', file);
Key Change: Use File constructor instead of just Blob
Acceptance Criteria:
- Base64 images (screenshots) upload successfully
- Proper MIME type from data URL is preserved
- File has correct extension
Story 3: Remove or Fix URL Pass-through Strategy
Objective: Handle the 500 error from image_url field
Options:
Option A: Remove URL Pass-through
- Always download and upload images
- More reliable, works around Tandoor server issue
- Slightly more bandwidth usage
Option B: Make URL Pass-through Optional
- Try
image_urlfirst - On 500 error, fall back to file upload immediately
- Keep current behavior but with better error handling
Recommendation: Option A - Remove URL pass-through for now since:
- It's causing 500 errors
- File upload is more reliable
- Performance difference is minimal
- Simpler code (one path instead of multiple fallbacks)
If keeping URL pass-through, improve error handling:
// Try URL pass-through
const urlResult = await tryUrlPassthrough(recipeId, imageUrl, token);
if (urlResult.success) {
return urlResult;
}
// On ANY error (500, 400, etc.), fall back to file upload
console.warn(`URL pass-through failed (${urlResult.status}), using file upload`);
return uploadAsFile(recipeId, imageUrl, token);
Acceptance Criteria:
- No 500 errors in logs
- Clear decision: either URL pass-through works or it's removed
- Fallback to file upload is automatic
Story 4: Ensure Proper FormData Headers
Objective: Let FormData handle Content-Type automatically
Current Problem: We might be setting headers that conflict with multipart boundaries.
Solution:
// DON'T set Content-Type manually for multipart uploads
const uploadResponse = await fetch(
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`
// NO Content-Type header - let FormData set it
},
body: formData
}
);
Key Point: FormData automatically sets Content-Type: multipart/form-data; boundary=... and we must not override it.
Acceptance Criteria:
- No manual Content-Type header for image upload
- FormData handles multipart boundaries automatically
- Upload succeeds with proper headers
Story 5: Add Comprehensive Error Logging
Objective: Better debugging for future issues
Changes:
// Log request details
console.log(`[Tandoor Upload] Recipe ID: ${recipeId}`);
console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}`);
console.log(`[Tandoor Upload] MIME type: ${mimeType}`);
console.log(`[Tandoor Upload] File size: ${buffer.length} bytes`);
// Log response details
if (!uploadResponse.ok) {
const responseText = await uploadResponse.text();
console.error(`[Tandoor Upload] Failed: ${uploadResponse.status}`);
console.error(`[Tandoor Upload] Response headers:`, uploadResponse.headers);
console.error(`[Tandoor Upload] Response body:`, responseText);
}
Acceptance Criteria:
- Response headers logged on error
- File metadata logged (size, type)
- Clear distinction between different error types
Testing Strategy
Test Case 1: Direct URL Image
const imageUrl = 'https://www.giallozafferano.it/images/recipe_images/1087263_calamari-e-patate.jpg';
const result = await uploadRecipeImage(1, imageUrl);
// Expected: success: true
// Expected logs: File size, MIME type, success message
Test Case 2: Base64 Screenshot
const base64Url = 'data:image/jpeg;base64,/9j/4AAQSkZJRg...';
const result = await uploadRecipeImage(1, base64Url);
// Expected: success: true
// Expected logs: Detected base64, converted to file, success
Test Case 3: Error Handling
const invalidUrl = 'https://invalid.url/image.jpg';
const result = await uploadRecipeImage(1, invalidUrl);
// Expected: success: false, error message with details
Code Example
Complete Fixed Implementation
export async function uploadRecipeImage(
recipeId: number,
imageUrl: string
): Promise<{ success: boolean; error?: string }> {
try {
const token = tandoorConfig.token;
if (!token) {
return { success: false, error: 'TANDOOR_TOKEN not set' };
}
console.log(`[Tandoor Upload] Recipe ID: ${recipeId}`);
console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}...`);
let buffer: Buffer;
let mimeType: string;
let extension: string;
// Handle base64 data URL
if (isDataUrl(imageUrl)) {
console.log('[Tandoor Upload] Processing base64 data URL');
const parsed = parseDataUrl(imageUrl);
if (!parsed) {
return { success: false, error: 'Invalid data URL format' };
}
buffer = Buffer.from(parsed.base64Data, 'base64');
mimeType = parsed.mimeType;
extension = getExtensionFromMimeType(mimeType);
}
// Handle direct URL
else if (isDirectUrl(imageUrl)) {
console.log('[Tandoor Upload] Downloading from URL');
const response = await fetch(imageUrl);
if (!response.ok) {
return { success: false, error: `Failed to fetch image: ${response.statusText}` };
}
const arrayBuffer = await response.arrayBuffer();
buffer = Buffer.from(arrayBuffer);
mimeType = response.headers.get('content-type') || 'image/jpeg';
extension = getExtensionFromMimeType(mimeType);
}
else {
return { success: false, error: 'Invalid image URL format' };
}
console.log(`[Tandoor Upload] MIME type: ${mimeType}`);
console.log(`[Tandoor Upload] File size: ${buffer.length} bytes`);
console.log(`[Tandoor Upload] Extension: ${extension}`);
// Create proper File object for multipart upload
const blob = new Blob([buffer], { type: mimeType });
const file = new File([blob], `recipe-image${extension}`, { type: mimeType });
const formData = new FormData();
formData.append('image', file);
// Upload to Tandoor
const uploadResponse = await fetch(
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`
// No Content-Type - let FormData set it
},
body: formData
}
);
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
console.error(`[Tandoor Upload] Response:`, errorText.substring(0, 500));
return {
success: false,
error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}`
};
}
console.log(`[Tandoor Upload] ✓ Success - ${buffer.length} bytes uploaded`);
return { success: true };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error(`[Tandoor Upload] Exception:`, error);
return { success: false, error: errorMsg };
}
}
Key Differences from Previous Implementation
| Aspect | Previous | New |
|---|---|---|
| URL Handling | URL pass-through first, then fallback | Always download and upload |
| Blob Creation | new Blob() only |
new Blob() + new File() |
| MIME Type Source | Extension guessing | Actual HTTP headers or data URL |
| Error Handling | Multiple strategies with fallbacks | Single reliable path |
| Headers | May set Content-Type | Never set Content-Type for multipart |
Success Metrics
✅ Primary Goal:
- Images upload successfully to Tandoor without 400 or 500 errors
✅ Code Quality:
- Single, reliable upload path
- Proper File object creation
- Clear error messages with response details
✅ Performance:
- Minimal overhead from download
- No unnecessary retry attempts
- Fast failure with clear errors
Rollback Plan
If the fix doesn't work:
- Add detailed logging at each step
- Test with curl to verify multipart format:
curl -X PUT \
-H "Authorization: Bearer ${TOKEN}" \
-F "image=@test-image.jpg" \
${TANDOOR_URL}/api/recipe/1/image/
- Compare working curl request with our FormData
- Investigate if SvelteKit/Node.js FormData implementation differs from browser
Dependencies
- Node.js
BufferAPI - Fetch API (built-in)
- FormData API (built-in)
- Blob/File constructors (built-in)
References
- Tandoor OpenAPI Spec:
docs/Tandoor (2.3.6).yaml - Endpoint:
PUT /api/recipe/{id}/image/ - Schema:
RecipeImage(lines 13992-14005) - Endpoint definition (lines 5712-5738)
Plan Status: ✅ Ready for Implementation
Next Step: Use @dev FixTandoorImageUploadV2 to execute this plan