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
This commit is contained in:
719
docs/plans/FixTandoorImageUpload.md
Normal file
719
docs/plans/FixTandoorImageUpload.md
Normal file
@@ -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 <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:**
|
||||
|
||||
```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<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:**
|
||||
|
||||
```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<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:**
|
||||
|
||||
```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
|
||||
@@ -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",
|
||||
|
||||
@@ -632,11 +632,20 @@ async function fetchImageAsBase64(imageUrl: string): Promise<string | null> {
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -108,7 +108,7 @@ async function fetchFromTandoor<T>(
|
||||
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<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) - 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user