- 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
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:
- Endpoint:
PUT /api/recipe/{recipeId}/image/ - Parser: Uses
MultiPartParser(fromcookbook/views/api.py) - Serializer:
RecipeImageSerializeraccepts:image: An actual file (ImageField)image_url: A URL string that Tandoor downloads server-side
- Authentication: Uses
Tokenauthentication, NOTBearer - 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
- Authentication Header: Using
Bearer ${token}instead ofToken ${token} - Image Format: Passing a Blob without proper file extension/mime type
- Image Source: Not leveraging the
image_urlfield for direct URLs - 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)
- Base64 data URLs (
Proposed Solution
Architecture Approach
Following the hexagonal architecture principle:
- Port:
uploadRecipeImage()intandoor.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:
-
Path 1: URL Pass-through (Preferred for efficiency)
- If thumbnail is a direct URL, use
image_urlfield - Let Tandoor download the image server-side
- Reduces bandwidth and processing
- If thumbnail is a direct URL, use
-
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
-
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:
- Update
uploadRecipeWithIngredientsDTO()authorization header - Update
uploadRecipeImage()authorization header - 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:
- Detect thumbnail type (URL vs base64 vs other)
- Implement URL pass-through for direct URLs
- Implement file conversion for base64 data URLs
- 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_urlfield 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:
- Add JSDoc comments to
extractThumbnailStealth() - Document return format for each extraction method
- 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:
- Add detailed logging for each upload strategy
- Include response body in error messages
- Add retry logic for transient failures
- 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:
- Create test file for image upload scenarios
- Mock Tandoor API responses
- Test all three upload strategies
- 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
-
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"
-
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"
-
Error Handling
- Test with invalid URL
- Test with missing TANDOOR_TOKEN
- Test with unreachable Tandoor server
- Verify error messages are informative
-
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:
-
Immediate Rollback:
git revert HEAD -
Partial Rollback:
- Revert authentication header change only
- Revert upload strategy changes only
- Keep logging improvements
-
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()inextraction.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
- Retry Logic: Add exponential backoff for transient failures
- Image Optimization: Compress images before upload to reduce bandwidth
- Caching: Cache successful uploads to avoid re-uploading same image
- Progress Tracking: Report upload progress via SSE stream
- Image Validation: Validate image format/size before upload attempt
- 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.mddocs/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