# 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:** ```typescript 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:** ```typescript const blob = new Blob([imageBuffer], { type: parsed.mimeType }); formData.append('image', blob, `recipe-image${extension}`); ``` **Problem:** According to OpenAPI spec: ```yaml 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 ```yaml /api/recipe/{id}/image/: put: operationId: apiRecipeImageUpdate requestBody: content: multipart/form-data: schema: $ref: '#/components/schemas/RecipeImage' ``` ### RecipeImage Schema ```yaml RecipeImage: type: object properties: image: type: string format: uri nullable: true image_url: type: string nullable: true maxLength: 4096 ``` ### Key Insights 1. **Both fields are optional** (`nullable: true`) 2. **`image_url` exists** but may not be working (500 error suggests server-side issue) 3. **`image` expects file upload** via multipart/form-data 4. **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: 1. **Always download and upload the image** (more reliable) 2. **Fix the file upload format** to ensure proper multipart headers 3. **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: 1. Use `File` constructor with proper filename and type 2. Or use `Buffer` with proper form-data library 3. Ensure proper MIME type is set 4. 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:** ```typescript 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: ```typescript // 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:** ```typescript 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:** ```typescript // 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_url` first - 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: 1. It's causing 500 errors 2. File upload is more reliable 3. Performance difference is minimal 4. Simpler code (one path instead of multiple fallbacks) **If keeping URL pass-through**, improve error handling: ```typescript // 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:** ```typescript // 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:** ```typescript // 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 ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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: 1. Add detailed logging at each step 2. Test with curl to verify multipart format: ```bash curl -X PUT \ -H "Authorization: Bearer ${TOKEN}" \ -F "image=@test-image.jpg" \ ${TANDOOR_URL}/api/recipe/1/image/ ``` 3. Compare working curl request with our FormData 4. Investigate if SvelteKit/Node.js FormData implementation differs from browser --- ## Dependencies - Node.js `Buffer` API - 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