# Execution Plan: Fix Tandoor Image Upload **Date:** 2025-12-21 **Author:** Analyst Agent **Status:** Draft ## Problem Statement The Tandoor image upload is failing with a **400 Bad Request** error. The current implementation attempts to upload images but the format/method is incorrect. Based on the error logs: ``` Successfully created recipe with ID: 30 Uploading image for recipe ID: 30 URL: https://www.giallozafferano.it/images/recipes/1693 Image upload returned 400 Image upload failed, but recipe created: Upload failed: Bad Request ``` ## Root Cause Analysis ### Current Implementation Issues From `src/lib/server/tandoor.ts` (lines 335-385): ```typescript export async function uploadRecipeImage( recipeId: number, imageUrl: string ): Promise<{ success: boolean; error?: string }> { // ... const response = await fetch(imageUrl); const imageBlob = await response.blob(); const formData = new FormData(); formData.append('image', imageBlob, 'recipe-image.jpg'); // ❌ ISSUE const uploadResponse = await fetch( `${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}` }, // ❌ ISSUE body: formData } ); // ... } ``` ### Tandoor API Requirements (from GitHub research) Based on the Tandoor source code analysis: 1. **Endpoint:** `PUT /api/recipe/{recipeId}/image/` 2. **Parser:** Uses `MultiPartParser` (from `cookbook/views/api.py`) 3. **Serializer:** `RecipeImageSerializer` accepts: - `image`: An actual file (ImageField) - `image_url`: A URL string that Tandoor downloads server-side 4. **Authentication:** Uses `Token` authentication, NOT `Bearer` 5. **Content-Type:** Should be `multipart/form-data` (handled automatically by FormData) ### Key Findings from Tandoor Code From `cookbook/views/api.py` (lines 1625-1677): ```python @decorators.action(detail=True, methods=['PUT'], serializer_class=RecipeImageSerializer, parser_classes=[MultiPartParser], ) def image(self, request, pk): # Accepts 'image' field (file upload) OR 'image_url' field (URL) # If image_url provided, Tandoor fetches it server-side ``` From `cookbook/serializer.py` (lines 1222-1245): ```python class RecipeImageSerializer(WritableNestedModelSerializer): image = serializers.ImageField(required=False, allow_null=True) image_url = serializers.CharField(max_length=4096, required=False, allow_null=True) ``` From Vue3 frontend (`vue3/src/composables/useFileApi.ts`): ```typescript function updateRecipeImage(recipeId: number, file: File | null, imageUrl?: string) { let formData = new FormData() if (file != null) { formData.append('image', file) } if (imageUrl) { formData.append('image_url', imageUrl) } // Uses Token authentication, not Bearer } ``` ### Issues Identified 1. **Authentication Header:** Using `Bearer ${token}` instead of `Token ${token}` 2. **Image Format:** Passing a Blob without proper file extension/mime type 3. **Image Source:** Not leveraging the `image_url` field for direct URLs 4. **Thumbnail Formats:** Multiple thumbnail extraction methods return different formats: - Base64 data URLs (`data:image/jpeg;base64,...`) - Direct URLs (from meta tags, Instagram data) - Screenshots (as base64) ## Proposed Solution ### Architecture Approach Following the hexagonal architecture principle: - **Port:** `uploadRecipeImage()` in `tandoor.ts` (infrastructure layer) - **Adapter:** Thumbnail extraction methods in `extraction.ts` (domain layer) - **Concern:** Separate image format handling from business logic ### Implementation Strategy Implement a **dual-path upload strategy**: 1. **Path 1: URL Pass-through** (Preferred for efficiency) - If thumbnail is a direct URL, use `image_url` field - Let Tandoor download the image server-side - Reduces bandwidth and processing 2. **Path 2: File Upload** (Required for base64/processed images) - If thumbnail is base64 data URL, convert to file - Use proper MIME type and filename - Upload as multipart file 3. **Path 3: Fallback** (Defensive programming) - Handle any other thumbnail format - Convert to buffer/blob with proper metadata - Retry with different approaches ## Implementation Plan ### Story 1: Fix Tandoor Authentication Header **Objective:** Correct the authentication header from `Bearer` to `Token` **Location:** `src/lib/server/tandoor.ts` **Changes:** 1. Update `uploadRecipeWithIngredientsDTO()` authorization header 2. Update `uploadRecipeImage()` authorization header 3. Verify all Tandoor API calls use consistent auth format **Implementation:** ```typescript // Line ~280 and ~365 headers: { 'Authorization': `Token ${token}`, // ✅ Fixed from Bearer 'Content-Type': 'application/json' } ``` **Acceptance Criteria:** - All Tandoor API calls use `Token ${token}` format - Authentication errors eliminated from logs - Recipe creation continues to work **Technical Notes:** - Tandoor uses Django REST Framework's TokenAuthentication - Format must be exactly: `Authorization: Token ` - This is different from JWT Bearer tokens --- ### Story 2: Implement Smart Image Upload Strategy **Objective:** Create intelligent upload logic that handles all thumbnail formats **Location:** `src/lib/server/tandoor.ts` **Changes:** 1. Detect thumbnail type (URL vs base64 vs other) 2. Implement URL pass-through for direct URLs 3. Implement file conversion for base64 data URLs 4. Add proper error handling and fallbacks **Implementation:** ```typescript /** * Determine if a string is a direct HTTP(S) URL */ function isDirectUrl(url: string): boolean { return url.startsWith('http://') || url.startsWith('https://'); } /** * Determine if a string is a base64 data URL */ function isDataUrl(url: string): boolean { return url.startsWith('data:'); } /** * Extract MIME type and base64 data from data URL */ function parseDataUrl(dataUrl: string): { mimeType: string; base64Data: string } | null { const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (!match) return null; return { mimeType: match[1], base64Data: match[2] }; } /** * Convert MIME type to file extension */ function getExtensionFromMimeType(mimeType: string): string { const mimeToExt: Record = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' }; return mimeToExt[mimeType] || '.jpg'; } /** * Uploads an image to a Tandoor recipe with intelligent format handling * * Supports three upload strategies: * 1. Direct URL pass-through (most efficient) * 2. Base64 data URL conversion to file upload * 3. Fallback blob upload */ 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('Uploading image for recipe ID:', recipeId); // Strategy 1: Direct URL pass-through (preferred) if (isDirectUrl(imageUrl)) { console.log('Using URL pass-through strategy'); const formData = new FormData(); formData.append('image_url', imageUrl); const uploadResponse = await fetch( `${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`, { method: 'PUT', headers: { 'Authorization': `Token ${token}` }, body: formData } ); if (uploadResponse.ok) { console.log('Image uploaded successfully via URL'); return { success: true }; } // If URL strategy fails, fall through to file upload console.warn(`URL upload failed with ${uploadResponse.status}, trying file upload`); } // Strategy 2: Base64 data URL to file upload if (isDataUrl(imageUrl)) { console.log('Using base64 file upload strategy'); 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); // 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': `Token ${token}` }, body: formData } ); if (!uploadResponse.ok) { const errorText = await uploadResponse.text(); console.warn(`Image upload returned ${uploadResponse.status}: ${errorText}`); return { success: false, error: `Upload failed: ${uploadResponse.statusText}` }; } console.log('Image uploaded successfully via file upload'); return { success: true }; } // Strategy 3: Fallback - try to fetch and upload console.log('Using fallback fetch strategy'); const response = await fetch(imageUrl); const imageBlob = await response.blob(); // Determine file extension from blob type or URL let extension = '.jpg'; if (imageBlob.type) { extension = getExtensionFromMimeType(imageBlob.type); } const formData = new FormData(); formData.append('image', imageBlob, `recipe-image${extension}`); const uploadResponse = await fetch( `${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`, { method: 'PUT', headers: { 'Authorization': `Token ${token}` }, body: formData } ); if (!uploadResponse.ok) { const errorText = await uploadResponse.text(); console.warn(`Image upload returned ${uploadResponse.status}: ${errorText}`); return { success: false, error: `Upload failed: ${uploadResponse.statusText}` }; } console.log('Image uploaded successfully via fallback'); return { success: true }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; console.error(`Image upload failed: ${errorMsg}`); return { success: false, error: errorMsg }; } } ``` **Acceptance Criteria:** - Direct URLs (from meta tags) upload successfully - Base64 data URLs (from screenshots) upload successfully - All thumbnail extraction methods work with upload - Proper error messages for debugging - No 400 Bad Request errors **Technical Notes:** - Tandoor's `image_url` field triggers server-side download - This is more efficient than downloading client-side - Base64 images must be converted to proper file blobs - MIME type detection is critical for correct file extension --- ### Story 3: Update All Extraction Methods Documentation **Objective:** Document which thumbnail formats each extraction method returns **Location:** `src/lib/server/extraction.ts` **Changes:** 1. Add JSDoc comments to `extractThumbnailStealth()` 2. Document return format for each extraction method 3. Add type safety for thumbnail URLs **Implementation:** ```typescript /** * Extract thumbnail from Instagram post using stealth techniques * * Tries multiple methods in order of stealth: * 1. Meta tags (og:image, twitter:image) - Returns: Direct HTTPS URL * 2. Video poster attribute - Returns: Direct HTTPS URL * 3. Instagram window data structures - Returns: Direct HTTPS URL * 4. Screenshot fallback - Returns: Base64 data URL (data:image/jpeg;base64,...) * * @param page - Playwright page instance * @param progressCallback - Optional progress callback * @returns Base64 data URL or direct HTTPS URL, or null if all methods fail */ async function extractThumbnailStealth( page: Page, progressCallback?: ProgressCallback ): Promise { // ... existing implementation } ``` **Acceptance Criteria:** - Clear documentation of return formats - Developers understand which strategy will be used - Type system enforces correct usage --- ### Story 4: Add Comprehensive Error Handling and Logging **Objective:** Improve debugging and error recovery **Location:** `src/lib/server/tandoor.ts` **Changes:** 1. Add detailed logging for each upload strategy 2. Include response body in error messages 3. Add retry logic for transient failures 4. Log thumbnail type and size information **Implementation:** ```typescript // Enhanced logging console.log(`[Tandoor Upload] Recipe ID: ${recipeId}`); console.log(`[Tandoor Upload] Image type: ${isDirectUrl(imageUrl) ? 'URL' : isDataUrl(imageUrl) ? 'Base64' : 'Unknown'}`); console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}...`); // Include response details in errors if (!uploadResponse.ok) { const errorText = await uploadResponse.text(); console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`); console.error(`[Tandoor Upload] Response: ${errorText}`); return { success: false, error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}` }; } // Log success with details console.log(`[Tandoor Upload] ✓ Success - Strategy: ${strategyUsed}, Size: ${imageSize} bytes`); ``` **Acceptance Criteria:** - Clear logs for debugging upload issues - Error messages include HTTP status and response body - Success messages confirm which strategy worked - Logs include image metadata (size, type, source) --- ### Story 5: Add Unit Tests for Image Upload Logic **Objective:** Ensure all thumbnail formats are handled correctly **Location:** `src/tests/tandoor-image-upload.spec.ts` (new file) **Changes:** 1. Create test file for image upload scenarios 2. Mock Tandoor API responses 3. Test all three upload strategies 4. Test error handling **Implementation:** ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { uploadRecipeImage } from '$lib/server/tandoor'; describe('Tandoor Image Upload', () => { beforeEach(() => { // Mock fetch global.fetch = vi.fn(); }); it('should use URL pass-through for direct URLs', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); global.fetch = mockFetch; const result = await uploadRecipeImage( 1, 'https://example.com/image.jpg' ); expect(result.success).toBe(true); expect(mockFetch).toHaveBeenCalledWith( expect.stringContaining('/api/recipe/1/image/'), expect.objectContaining({ method: 'PUT', headers: expect.objectContaining({ 'Authorization': expect.stringMatching(/^Token /) }) }) ); const formData = mockFetch.mock.calls[0][1].body; expect(formData.get('image_url')).toBe('https://example.com/image.jpg'); }); it('should convert base64 data URLs to file upload', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }); global.fetch = mockFetch; const base64Image = 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='; const result = await uploadRecipeImage(1, base64Image); expect(result.success).toBe(true); const formData = mockFetch.mock.calls[0][1].body; const imageFile = formData.get('image'); expect(imageFile).toBeInstanceOf(Blob); expect(imageFile.type).toBe('image/jpeg'); }); it('should handle upload failures gracefully', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 400, statusText: 'Bad Request', text: async () => 'Invalid image format' }); global.fetch = mockFetch; const result = await uploadRecipeImage(1, 'invalid-url'); expect(result.success).toBe(false); expect(result.error).toContain('400'); }); it('should try file upload if URL pass-through fails', async () => { const mockFetch = vi .fn() .mockResolvedValueOnce({ // First call - URL pass-through fails ok: false, status: 400 }) .mockResolvedValueOnce({ // Second call - fetch image ok: true, blob: async () => new Blob(['fake-image'], { type: 'image/jpeg' }) }) .mockResolvedValueOnce({ // Third call - file upload succeeds ok: true, status: 200 }); global.fetch = mockFetch; const result = await uploadRecipeImage( 1, 'https://example.com/image.jpg' ); expect(result.success).toBe(true); expect(mockFetch).toHaveBeenCalledTimes(3); }); }); ``` **Acceptance Criteria:** - All test cases pass - Coverage includes all upload strategies - Error paths are tested - Fallback logic is verified --- ## Verification Plan ### Manual Testing Checklist 1. **Direct URL Upload** (from meta tags) - [ ] Extract recipe from Instagram with og:image meta tag - [ ] Verify image uploads to Tandoor successfully - [ ] Check Tandoor recipe shows correct image - [ ] Verify logs show "URL pass-through strategy" 2. **Base64 Upload** (from screenshot) - [ ] Extract recipe with screenshot fallback - [ ] Verify base64 image uploads successfully - [ ] Check image quality in Tandoor - [ ] Verify logs show "base64 file upload strategy" 3. **Error Handling** - [ ] Test with invalid URL - [ ] Test with missing TANDOOR_TOKEN - [ ] Test with unreachable Tandoor server - [ ] Verify error messages are informative 4. **All Extraction Methods** - [ ] Test with embedded JSON extraction - [ ] Test with DOM selector extraction - [ ] Test with GraphQL extraction (no thumbnail) - [ ] Test with legacy extraction ### Automated Testing ```bash # Run unit tests npm run test src/tests/tandoor-image-upload.spec.ts # Run integration tests npm run test src/tests/sse-extraction.spec.ts ``` ### Success Metrics - ✅ No more 400 Bad Request errors on image upload - ✅ All thumbnail extraction methods result in successful uploads - ✅ Logs clearly indicate which upload strategy was used - ✅ Error messages are actionable and informative - ✅ Recipe creation + image upload works end-to-end --- ## Rollback Plan If issues arise: 1. **Immediate Rollback:** ```bash git revert HEAD ``` 2. **Partial Rollback:** - Revert authentication header change only - Revert upload strategy changes only - Keep logging improvements 3. **Fallback Behavior:** - Skip image upload on error (recipe still created) - Log detailed error for manual investigation - Alert user that recipe was created without image --- ## Dependencies ### External Systems - Tandoor API must be reachable - TANDOOR_TOKEN must be configured - Tandoor version compatibility (tested with 1.5.x+) ### Internal Components - `extractThumbnailStealth()` in `extraction.ts` - All extraction strategies (embedded JSON, DOM, GraphQL, legacy) - SSE progress tracking in share page ### Environment Variables ```bash TANDOOR_SERVER_URL=https://your-tandoor-instance.com TANDOOR_TOKEN=your_api_token_here ``` --- ## Technical Debt & Future Improvements 1. **Retry Logic:** Add exponential backoff for transient failures 2. **Image Optimization:** Compress images before upload to reduce bandwidth 3. **Caching:** Cache successful uploads to avoid re-uploading same image 4. **Progress Tracking:** Report upload progress via SSE stream 5. **Image Validation:** Validate image format/size before upload attempt 6. **Multiple Images:** Support uploading multiple images per recipe --- ## References ### Tandoor API Documentation - GitHub Issues: #1798, #3854, #4081, #3375 - API Endpoint: `PUT /api/recipe/{id}/image/` - Serializer: `RecipeImageSerializer` - Frontend Reference: `vue3/src/composables/useFileApi.ts` ### Project Documentation - Abstract Architecture: `.system/abstract_architecture.md` - Constants: `.system/constants.md` - Previous Outcomes: - `docs/outcomes/RefactorSharePageAndEnhanceThumbnails.md` - `docs/outcomes/FixProgressCallbackUndefinedErrors.md` --- ## Appendix: Thumbnail Format Matrix | Extraction Method | Thumbnail Source | Format | Upload Strategy | |------------------|------------------|---------|-----------------| | Embedded JSON | Meta tags / Instagram data | Direct URL | URL pass-through | | DOM Selector | Meta tags / Video poster | Direct URL | URL pass-through | | GraphQL API | N/A | null | No upload | | Legacy | Screenshot | Base64 data URL | File conversion | | Stealth Method 1 | og:image meta tag | Direct URL | URL pass-through | | Stealth Method 2 | Video poster | Direct URL | URL pass-through | | Stealth Method 3 | Instagram __additionalDataLoaded | Direct URL | URL pass-through | | Stealth Method 4 | Screenshot fallback | Base64 data URL | File conversion | --- ## Execution Timeline **Estimated Total Time:** 4-6 hours | Story | Estimated Time | Dependencies | |-------|---------------|--------------| | Story 1: Fix Auth Header | 30 minutes | None | | Story 2: Smart Upload Strategy | 2-3 hours | Story 1 | | Story 3: Documentation | 30 minutes | Story 2 | | Story 4: Error Handling | 1 hour | Story 2 | | Story 5: Unit Tests | 1-2 hours | Story 2, 4 | --- **Plan Status:** ✅ Ready for Implementation **Next Step:** Use `@dev FixTandoorImageUpload` to execute this plan