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:
Giancarmine Salucci
2025-12-21 04:58:45 +01:00
parent 281c82e76a
commit d1dc791854
4 changed files with 879 additions and 23 deletions

View 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

View File

@@ -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",

View File

@@ -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,

View File

@@ -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 };
}