diff --git a/docs/plans/FixTandoorImageUpload.md b/docs/plans/FixTandoorImageUpload.md new file mode 100644 index 0000000..83a7fdd --- /dev/null +++ b/docs/plans/FixTandoorImageUpload.md @@ -0,0 +1,719 @@ +# 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 diff --git a/secrets/auth.json b/secrets/auth.json index 064dfb6..130a024 100644 --- a/secrets/auth.json +++ b/secrets/auth.json @@ -5,7 +5,7 @@ "value": "SDRORLyWEsWWty2ZoVGdER", "domain": ".instagram.com", "path": "/", - "expires": 1800848369.155388, + "expires": 1800849269.656302, "httpOnly": false, "secure": true, "sameSite": "Lax" @@ -45,7 +45,7 @@ "value": "59661903731", "domain": ".instagram.com", "path": "/", - "expires": 1774064369.155498, + "expires": 1774065269.656394, "httpOnly": false, "secure": true, "sameSite": "None" @@ -55,7 +55,7 @@ "value": "1280x720", "domain": ".instagram.com", "path": "/", - "expires": 1766893170, + "expires": 1766894070, "httpOnly": false, "secure": true, "sameSite": "Lax" @@ -72,7 +72,7 @@ }, { "name": "rur", - "value": "\"CLN\\05459661903731\\0541797824369:01fe8c862f2dd54b808a334f6088f2bf4dad9b4c8965f2abd3762be986fe5c03e8e410df\"", + "value": "\"CLN\\05459661903731\\0541797825269:01fe6904bc7d85ccbfea5233062783089ac963caf6202742eb0b112bd5ab4f6ef965e2f4\"", "domain": ".instagram.com", "path": "/", "expires": -1, @@ -87,7 +87,7 @@ "localStorage": [ { "name": "chatd-deviceid", - "value": "712a4dc5-cc51-4b0c-a373-611ef9b65c23" + "value": "11f6cbef-22a3-4c0b-9558-7a83fd40e521" }, { "name": "hb_timestamp", @@ -95,7 +95,7 @@ }, { "name": "IGSession", - "value": "6m2tlb:1766290170601" + "value": "6m2tlb:1766291070793" }, { "name": "pixel_fire_ts", @@ -107,7 +107,7 @@ }, { "name": "Session", - "value": "wayu8j:1766288405601" + "value": "0f4qnx:1766289305793" }, { "name": "has_interop_upgraded", diff --git a/src/lib/server/extraction.ts b/src/lib/server/extraction.ts index fa70167..9ec3b3e 100644 --- a/src/lib/server/extraction.ts +++ b/src/lib/server/extraction.ts @@ -632,11 +632,20 @@ async function fetchImageAsBase64(imageUrl: string): Promise { /** * Extract thumbnail from Instagram post using stealth techniques + * * Tries multiple methods in order of stealth: - * 1. Meta tags (og:image, twitter:image) - * 2. Video poster attribute - * 3. Instagram window data structures - * 4. Screenshot fallback + * 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 for SSE updates + * @returns Image URL (either direct HTTPS URL or base64 data URL) or null if all methods fail + * + * **Thumbnail Format Guide:** + * - Methods 1-3: Return direct HTTPS URLs → Tandoor can use URL pass-through (efficient) + * - Method 4: Returns base64 data URL → Requires conversion to file blob for upload */ async function extractThumbnailStealth( page: Page, diff --git a/src/lib/server/tandoor.ts b/src/lib/server/tandoor.ts index a201ddb..aa394a4 100644 --- a/src/lib/server/tandoor.ts +++ b/src/lib/server/tandoor.ts @@ -108,7 +108,7 @@ async function fetchFromTandoor( const headers = new Headers({ 'Content-Type': 'application/json', 'Accept': 'application/json', - Authorization: `Bearer ${tandoorConfig.token}` + Authorization: `Token ${tandoorConfig.token}` }); // Merge any additional headers from options @@ -332,7 +332,56 @@ export async function uploadRecipeWithIngredientsDTO( } /** - * Uploads an image to a Tandoor recipe + * 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) - for meta tags, Instagram URLs + * 2. Base64 data URL conversion to file upload - for screenshots + * 3. Fallback blob upload - for any other format + * + * @param recipeId - Tandoor recipe ID + * @param imageUrl - Image URL (can be HTTP(S) URL or base64 data URL) + * @returns Success status and optional error message */ export async function uploadRecipeImage( recipeId: number, @@ -344,36 +393,115 @@ export async function uploadRecipeImage( return { success: false, error: 'TANDOOR_TOKEN not set' }; } - console.log('Uploading image for recipe ID:', recipeId, 'URL:', imageUrl.substring(0, 50)); + 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)}...`); - // Convert base64 data URL to Blob for multipart upload + // 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); + + const uploadResponse = await fetch( + `${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`, + { + method: 'PUT', + headers: { 'Authorization': `Token ${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 + if (isDataUrl(imageUrl)) { + console.log('[Tandoor Upload] 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().catch(() => uploadResponse.statusText); + console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`); + console.error(`[Tandoor Upload] Response: ${errorText.substring(0, 200)}`); + return { + success: false, + error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}` + }; + } + + console.log(`[Tandoor Upload] ✓ Success via base64 file upload (${imageBuffer.length} bytes)`); + return { success: true }; + } + + // 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(); + + // Determine file extension from blob type or default to jpg + let extension = '.jpg'; + if (imageBlob.type) { + extension = getExtensionFromMimeType(imageBlob.type); + } - // Use image field with multipart form data (Tandoor's binary upload support) const formData = new FormData(); - formData.append('image', imageBlob, 'recipe-image.jpg'); + formData.append('image', imageBlob, `recipe-image${extension}`); - // Upload to Tandoor const uploadResponse = await fetch( `${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`, { method: 'PUT', - headers: { 'Authorization': `Bearer ${token}` }, + headers: { 'Authorization': `Token ${token}` }, body: formData } ); if (!uploadResponse.ok) { - console.warn(`Image upload returned ${uploadResponse.status}`); - return { success: false, error: `Upload failed: ${uploadResponse.statusText}` }; + 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)}`); + return { + success: false, + error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}` + }; } - console.log('Image uploaded successfully'); + console.log(`[Tandoor Upload] ✓ Success via fallback (${imageBlob.size} bytes)`); return { success: true }; } catch (error) { const errorMsg = error instanceof Error ? error.message : 'Unknown error'; - console.warn(`Image upload failed: ${errorMsg}`); + console.error(`[Tandoor Upload] Exception: ${errorMsg}`); // Don't fail recipe creation if image fails return { success: false, error: errorMsg }; }