Files
insta-recipe/docs/plans/FixTandoorImageUpload.md
Giancarmine Salucci d1dc791854 fix(tandoor): implement smart image upload with auth fix
- Fix authentication header from 'Bearer' to 'Token' (DRF TokenAuth)
- Implement three-strategy upload system:
  1. URL pass-through for direct URLs (most efficient)
  2. Base64 data URL conversion for screenshots
  3. Fallback blob upload for any other format
- Add comprehensive error handling with response details
- Add detailed logging for debugging upload strategies
- Document thumbnail formats in extractThumbnailStealth()

Fixes #30 - Tandoor image upload 400 Bad Request error

Based on Tandoor source code analysis (cookbook/views/api.py):
- RecipeImageSerializer accepts 'image_url' field for server-side download
- Uses Token authentication, not Bearer
- Supports multipart file upload with proper MIME types
2025-12-21 04:58:45 +01:00

21 KiB

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):

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):

@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):

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):

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:

// 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 <token_value>
  • 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:

/**
 * 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<string, string> = {
        '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:

/**
 * 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<string | null> {
	// ... 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:

// 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:

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

# 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:

    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

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