fix(tandoor): use File constructor for proper multipart uploads
- Remove unreliable URL pass-through strategy (image_url field) - Always download and upload images as File objects - Get MIME type from HTTP response headers for URLs - Use File constructor (not just Blob) for proper multipart metadata - Add comprehensive error logging with headers and file metadata - Simplify to single reliable upload path Fixes 400 'Upload a valid image' error caused by Blob not providing proper filename/MIME metadata in multipart form data.
This commit is contained in:
15468
docs/Tandoor (2.3.6).yaml
Normal file
15468
docs/Tandoor (2.3.6).yaml
Normal file
File diff suppressed because it is too large
Load Diff
485
docs/plans/FixTandoorImageUploadV2.md
Normal file
485
docs/plans/FixTandoorImageUploadV2.md
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
# Execution Plan: Fix Tandoor Image Upload (v2)
|
||||||
|
|
||||||
|
**Date:** 2025-12-21
|
||||||
|
**Author:** Analyst Agent
|
||||||
|
**Status:** Draft
|
||||||
|
**Issue:** URL pass-through fails with 500, file upload fails with 400 "Upload a valid image"
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Thumbnail upload to Tandoor is failing with two distinct errors:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Tandoor Upload] Using URL pass-through strategy
|
||||||
|
[Tandoor Upload] URL pass-through failed (500), trying file upload
|
||||||
|
|
||||||
|
[Tandoor Upload] Using fallback fetch strategy
|
||||||
|
[Tandoor Upload] Failed: 400 Bad Request
|
||||||
|
[Tandoor Upload] Response: {"image":["Upload a valid image. The file you uploaded was either not an image or a corrupted image."]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### Issue 1: URL Pass-through Fails (500 Error)
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
```typescript
|
||||||
|
formData.append('image_url', imageUrl);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** The OpenAPI spec shows that `RecipeImage` schema has two fields:
|
||||||
|
- `image`: `type: string, format: uri` (for file upload)
|
||||||
|
- `image_url`: `type: string, maxLength: 4096` (for URL)
|
||||||
|
|
||||||
|
However, the **500 error** suggests Tandoor might not support `image_url` field in this version, or it's encountering an error when trying to fetch the URL server-side.
|
||||||
|
|
||||||
|
### Issue 2: File Upload Fails (400 Error)
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
```typescript
|
||||||
|
const blob = new Blob([imageBuffer], { type: parsed.mimeType });
|
||||||
|
formData.append('image', blob, `recipe-image${extension}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** According to OpenAPI spec:
|
||||||
|
```yaml
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RecipeImage'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `image` field expects `format: uri` which in multipart context means an **actual file with proper headers**. Our current Blob might be missing critical multipart headers or the blob isn't being properly recognized as a file.
|
||||||
|
|
||||||
|
**Root Cause:** In Node.js/server-side context, `Blob` API might not work the same as in browser. We need to use proper Node.js file handling or ensure the Blob is correctly formatted for multipart upload.
|
||||||
|
|
||||||
|
## Analysis from OpenAPI Spec
|
||||||
|
|
||||||
|
### Endpoint Definition
|
||||||
|
```yaml
|
||||||
|
/api/recipe/{id}/image/:
|
||||||
|
put:
|
||||||
|
operationId: apiRecipeImageUpdate
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RecipeImage'
|
||||||
|
```
|
||||||
|
|
||||||
|
### RecipeImage Schema
|
||||||
|
```yaml
|
||||||
|
RecipeImage:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
image:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
nullable: true
|
||||||
|
image_url:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 4096
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Insights
|
||||||
|
|
||||||
|
1. **Both fields are optional** (`nullable: true`)
|
||||||
|
2. **`image_url` exists** but may not be working (500 error suggests server-side issue)
|
||||||
|
3. **`image` expects file upload** via multipart/form-data
|
||||||
|
4. **No Content-Type header** should be set manually (let browser/Node set it for multipart)
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### Strategy Change
|
||||||
|
|
||||||
|
Since `image_url` is causing 500 errors (Tandoor server can't fetch or process the URL), we should:
|
||||||
|
|
||||||
|
1. **Always download and upload the image** (more reliable)
|
||||||
|
2. **Fix the file upload format** to ensure proper multipart headers
|
||||||
|
3. **Remove URL pass-through** (or make it optional/fallback)
|
||||||
|
|
||||||
|
### Technical Fix Required
|
||||||
|
|
||||||
|
The issue is that in **server-side Node.js context** (SvelteKit server), the `Blob` API doesn't create proper multipart form data. We need to:
|
||||||
|
|
||||||
|
1. Use `File` constructor with proper filename and type
|
||||||
|
2. Or use `Buffer` with proper form-data library
|
||||||
|
3. Ensure proper MIME type is set
|
||||||
|
4. Let FormData handle Content-Type header (don't set it manually)
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Story 1: Fix File Upload for Direct URLs
|
||||||
|
|
||||||
|
**Objective:** Make direct URL images download and upload correctly
|
||||||
|
|
||||||
|
**Current Problem:**
|
||||||
|
```typescript
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
const imageBlob = await response.blob();
|
||||||
|
formData.append('image', imageBlob, `recipe-image${extension}`);
|
||||||
|
// Fails with 400: "Upload a valid image"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
In SvelteKit server environment, we need to handle this differently:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Download the image
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
// Get proper MIME type
|
||||||
|
const mimeType = response.headers.get('content-type') || 'image/jpeg';
|
||||||
|
const extension = getExtensionFromMimeType(mimeType);
|
||||||
|
|
||||||
|
// Create a proper File object (if available) or use Blob correctly
|
||||||
|
const blob = new Blob([buffer], { type: mimeType });
|
||||||
|
const file = new File([blob], `recipe-image${extension}`, { type: mimeType });
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Direct URL images (from meta tags) upload successfully
|
||||||
|
- No 400 "Upload a valid image" errors
|
||||||
|
- Proper MIME type detected from response headers
|
||||||
|
- File has correct extension and name
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Story 2: Fix Base64 Data URL Upload
|
||||||
|
|
||||||
|
**Objective:** Make base64 screenshot images upload correctly
|
||||||
|
|
||||||
|
**Current Problem:**
|
||||||
|
```typescript
|
||||||
|
const imageBuffer = Buffer.from(parsed.base64Data, 'base64');
|
||||||
|
const blob = new Blob([imageBuffer], { type: parsed.mimeType });
|
||||||
|
formData.append('image', blob, `recipe-image${extension}`);
|
||||||
|
// Fails with 400: "Upload a valid image"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Parse base64 data URL
|
||||||
|
const parsed = parseDataUrl(imageUrl);
|
||||||
|
const imageBuffer = Buffer.from(parsed.base64Data, 'base64');
|
||||||
|
const extension = getExtensionFromMimeType(parsed.mimeType);
|
||||||
|
|
||||||
|
// Create proper File object
|
||||||
|
const blob = new Blob([imageBuffer], { type: parsed.mimeType });
|
||||||
|
const file = new File([blob], `recipe-image${extension}`, { type: parsed.mimeType });
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Change:** Use `File` constructor instead of just `Blob`
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Base64 images (screenshots) upload successfully
|
||||||
|
- Proper MIME type from data URL is preserved
|
||||||
|
- File has correct extension
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Story 3: Remove or Fix URL Pass-through Strategy
|
||||||
|
|
||||||
|
**Objective:** Handle the 500 error from `image_url` field
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
**Option A: Remove URL Pass-through**
|
||||||
|
- Always download and upload images
|
||||||
|
- More reliable, works around Tandoor server issue
|
||||||
|
- Slightly more bandwidth usage
|
||||||
|
|
||||||
|
**Option B: Make URL Pass-through Optional**
|
||||||
|
- Try `image_url` first
|
||||||
|
- On 500 error, fall back to file upload immediately
|
||||||
|
- Keep current behavior but with better error handling
|
||||||
|
|
||||||
|
**Recommendation:** **Option A** - Remove URL pass-through for now since:
|
||||||
|
1. It's causing 500 errors
|
||||||
|
2. File upload is more reliable
|
||||||
|
3. Performance difference is minimal
|
||||||
|
4. Simpler code (one path instead of multiple fallbacks)
|
||||||
|
|
||||||
|
**If keeping URL pass-through**, improve error handling:
|
||||||
|
```typescript
|
||||||
|
// Try URL pass-through
|
||||||
|
const urlResult = await tryUrlPassthrough(recipeId, imageUrl, token);
|
||||||
|
if (urlResult.success) {
|
||||||
|
return urlResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On ANY error (500, 400, etc.), fall back to file upload
|
||||||
|
console.warn(`URL pass-through failed (${urlResult.status}), using file upload`);
|
||||||
|
return uploadAsFile(recipeId, imageUrl, token);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- No 500 errors in logs
|
||||||
|
- Clear decision: either URL pass-through works or it's removed
|
||||||
|
- Fallback to file upload is automatic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Story 4: Ensure Proper FormData Headers
|
||||||
|
|
||||||
|
**Objective:** Let FormData handle Content-Type automatically
|
||||||
|
|
||||||
|
**Current Problem:**
|
||||||
|
We might be setting headers that conflict with multipart boundaries.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// DON'T set Content-Type manually for multipart uploads
|
||||||
|
const uploadResponse = await fetch(
|
||||||
|
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
// NO Content-Type header - let FormData set it
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Point:** FormData automatically sets `Content-Type: multipart/form-data; boundary=...` and we must not override it.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- No manual Content-Type header for image upload
|
||||||
|
- FormData handles multipart boundaries automatically
|
||||||
|
- Upload succeeds with proper headers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Story 5: Add Comprehensive Error Logging
|
||||||
|
|
||||||
|
**Objective:** Better debugging for future issues
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Log request details
|
||||||
|
console.log(`[Tandoor Upload] Recipe ID: ${recipeId}`);
|
||||||
|
console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}`);
|
||||||
|
console.log(`[Tandoor Upload] MIME type: ${mimeType}`);
|
||||||
|
console.log(`[Tandoor Upload] File size: ${buffer.length} bytes`);
|
||||||
|
|
||||||
|
// Log response details
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
const responseText = await uploadResponse.text();
|
||||||
|
console.error(`[Tandoor Upload] Failed: ${uploadResponse.status}`);
|
||||||
|
console.error(`[Tandoor Upload] Response headers:`, uploadResponse.headers);
|
||||||
|
console.error(`[Tandoor Upload] Response body:`, responseText);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- Response headers logged on error
|
||||||
|
- File metadata logged (size, type)
|
||||||
|
- Clear distinction between different error types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Test Case 1: Direct URL Image
|
||||||
|
```typescript
|
||||||
|
const imageUrl = 'https://www.giallozafferano.it/images/recipe_images/1087263_calamari-e-patate.jpg';
|
||||||
|
const result = await uploadRecipeImage(1, imageUrl);
|
||||||
|
// Expected: success: true
|
||||||
|
// Expected logs: File size, MIME type, success message
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Case 2: Base64 Screenshot
|
||||||
|
```typescript
|
||||||
|
const base64Url = 'data:image/jpeg;base64,/9j/4AAQSkZJRg...';
|
||||||
|
const result = await uploadRecipeImage(1, base64Url);
|
||||||
|
// Expected: success: true
|
||||||
|
// Expected logs: Detected base64, converted to file, success
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Case 3: Error Handling
|
||||||
|
```typescript
|
||||||
|
const invalidUrl = 'https://invalid.url/image.jpg';
|
||||||
|
const result = await uploadRecipeImage(1, invalidUrl);
|
||||||
|
// Expected: success: false, error message with details
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Example
|
||||||
|
|
||||||
|
### Complete Fixed Implementation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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(`[Tandoor Upload] Recipe ID: ${recipeId}`);
|
||||||
|
console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
let buffer: Buffer;
|
||||||
|
let mimeType: string;
|
||||||
|
let extension: string;
|
||||||
|
|
||||||
|
// Handle base64 data URL
|
||||||
|
if (isDataUrl(imageUrl)) {
|
||||||
|
console.log('[Tandoor Upload] Processing base64 data URL');
|
||||||
|
const parsed = parseDataUrl(imageUrl);
|
||||||
|
if (!parsed) {
|
||||||
|
return { success: false, error: 'Invalid data URL format' };
|
||||||
|
}
|
||||||
|
buffer = Buffer.from(parsed.base64Data, 'base64');
|
||||||
|
mimeType = parsed.mimeType;
|
||||||
|
extension = getExtensionFromMimeType(mimeType);
|
||||||
|
}
|
||||||
|
// Handle direct URL
|
||||||
|
else if (isDirectUrl(imageUrl)) {
|
||||||
|
console.log('[Tandoor Upload] Downloading from URL');
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
return { success: false, error: `Failed to fetch image: ${response.statusText}` };
|
||||||
|
}
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
buffer = Buffer.from(arrayBuffer);
|
||||||
|
mimeType = response.headers.get('content-type') || 'image/jpeg';
|
||||||
|
extension = getExtensionFromMimeType(mimeType);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return { success: false, error: 'Invalid image URL format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Tandoor Upload] MIME type: ${mimeType}`);
|
||||||
|
console.log(`[Tandoor Upload] File size: ${buffer.length} bytes`);
|
||||||
|
console.log(`[Tandoor Upload] Extension: ${extension}`);
|
||||||
|
|
||||||
|
// Create proper File object for multipart upload
|
||||||
|
const blob = new Blob([buffer], { type: mimeType });
|
||||||
|
const file = new File([blob], `recipe-image${extension}`, { type: mimeType });
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
// Upload to Tandoor
|
||||||
|
const uploadResponse = await fetch(
|
||||||
|
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
// No Content-Type - let FormData set it
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
const errorText = await uploadResponse.text();
|
||||||
|
console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
|
||||||
|
console.error(`[Tandoor Upload] Response:`, errorText.substring(0, 500));
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Tandoor Upload] ✓ Success - ${buffer.length} bytes uploaded`);
|
||||||
|
return { success: true };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error(`[Tandoor Upload] Exception:`, error);
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Differences from Previous Implementation
|
||||||
|
|
||||||
|
| Aspect | Previous | New |
|
||||||
|
|--------|----------|-----|
|
||||||
|
| URL Handling | URL pass-through first, then fallback | Always download and upload |
|
||||||
|
| Blob Creation | `new Blob()` only | `new Blob()` + `new File()` |
|
||||||
|
| MIME Type Source | Extension guessing | Actual HTTP headers or data URL |
|
||||||
|
| Error Handling | Multiple strategies with fallbacks | Single reliable path |
|
||||||
|
| Headers | May set Content-Type | Never set Content-Type for multipart |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
✅ **Primary Goal:**
|
||||||
|
- Images upload successfully to Tandoor without 400 or 500 errors
|
||||||
|
|
||||||
|
✅ **Code Quality:**
|
||||||
|
- Single, reliable upload path
|
||||||
|
- Proper File object creation
|
||||||
|
- Clear error messages with response details
|
||||||
|
|
||||||
|
✅ **Performance:**
|
||||||
|
- Minimal overhead from download
|
||||||
|
- No unnecessary retry attempts
|
||||||
|
- Fast failure with clear errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If the fix doesn't work:
|
||||||
|
|
||||||
|
1. Add detailed logging at each step
|
||||||
|
2. Test with curl to verify multipart format:
|
||||||
|
```bash
|
||||||
|
curl -X PUT \
|
||||||
|
-H "Authorization: Bearer ${TOKEN}" \
|
||||||
|
-F "image=@test-image.jpg" \
|
||||||
|
${TANDOOR_URL}/api/recipe/1/image/
|
||||||
|
```
|
||||||
|
3. Compare working curl request with our FormData
|
||||||
|
4. Investigate if SvelteKit/Node.js FormData implementation differs from browser
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Node.js `Buffer` API
|
||||||
|
- Fetch API (built-in)
|
||||||
|
- FormData API (built-in)
|
||||||
|
- Blob/File constructors (built-in)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Tandoor OpenAPI Spec: `docs/Tandoor (2.3.6).yaml`
|
||||||
|
- Endpoint: `PUT /api/recipe/{id}/image/`
|
||||||
|
- Schema: `RecipeImage` (lines 13992-14005)
|
||||||
|
- Endpoint definition (lines 5712-5738)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Plan Status:** ✅ Ready for Implementation
|
||||||
|
**Next Step:** Use `@dev FixTandoorImageUploadV2` to execute this plan
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"value": "SDRORLyWEsWWty2ZoVGdER",
|
"value": "SDRORLyWEsWWty2ZoVGdER",
|
||||||
"domain": ".instagram.com",
|
"domain": ".instagram.com",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1800849269.656302,
|
"expires": 1800850168.116943,
|
||||||
"httpOnly": false,
|
"httpOnly": false,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"value": "59661903731",
|
"value": "59661903731",
|
||||||
"domain": ".instagram.com",
|
"domain": ".instagram.com",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1774065269.656394,
|
"expires": 1774066168.117026,
|
||||||
"httpOnly": false,
|
"httpOnly": false,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "None"
|
"sameSite": "None"
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "rur",
|
"name": "rur",
|
||||||
"value": "\"CLN\\05459661903731\\0541797825269:01fe6904bc7d85ccbfea5233062783089ac963caf6202742eb0b112bd5ab4f6ef965e2f4\"",
|
"value": "\"CLN\\05459661903731\\0541797826168:01fe0c70391c9f4322366f6ea648ca6647818cd2ca16c215419a55558746287e2865245d\"",
|
||||||
"domain": ".instagram.com",
|
"domain": ".instagram.com",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": -1,
|
"expires": -1,
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "chatd-deviceid",
|
"name": "chatd-deviceid",
|
||||||
"value": "11f6cbef-22a3-4c0b-9558-7a83fd40e521"
|
"value": "0265e9b3-0083-498a-9424-7a08289dfb45"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "hb_timestamp",
|
"name": "hb_timestamp",
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "IGSession",
|
"name": "IGSession",
|
||||||
"value": "6m2tlb:1766291070793"
|
"value": "6m2tlb:1766291968512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "pixel_fire_ts",
|
"name": "pixel_fire_ts",
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Session",
|
"name": "Session",
|
||||||
"value": "0f4qnx:1766289305793"
|
"value": "nrg3hr:1766290203512"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "has_interop_upgraded",
|
"name": "has_interop_upgraded",
|
||||||
|
|||||||
@@ -372,15 +372,17 @@ function getExtensionFromMimeType(mimeType: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads an image to a Tandoor recipe with intelligent format handling
|
* Uploads an image to a Tandoor recipe using proper multipart/form-data format
|
||||||
*
|
*
|
||||||
* Supports three upload strategies:
|
* Always downloads the image and uploads as a File object (not Blob).
|
||||||
* 1. Direct URL pass-through (most efficient) - for meta tags, Instagram URLs
|
* This ensures proper multipart encoding with filename and MIME type metadata.
|
||||||
* 2. Base64 data URL conversion to file upload - for screenshots
|
*
|
||||||
* 3. Fallback blob upload - for any other format
|
* Handles two source formats:
|
||||||
|
* - Direct HTTP(S) URLs: Downloads from URL, detects MIME from response headers
|
||||||
|
* - Base64 data URLs: Decodes base64, uses embedded MIME type
|
||||||
*
|
*
|
||||||
* @param recipeId - Tandoor recipe ID
|
* @param recipeId - Tandoor recipe ID
|
||||||
* @param imageUrl - Image URL (can be HTTP(S) URL or base64 data URL)
|
* @param imageUrl - Image URL (HTTP(S) URL or base64 data URL)
|
||||||
* @returns Success status and optional error message
|
* @returns Success status and optional error message
|
||||||
*/
|
*/
|
||||||
export async function uploadRecipeImage(
|
export async function uploadRecipeImage(
|
||||||
@@ -397,111 +399,102 @@ export async function uploadRecipeImage(
|
|||||||
console.log(`[Tandoor Upload] Image type: ${isDirectUrl(imageUrl) ? 'URL' : isDataUrl(imageUrl) ? 'Base64' : 'Unknown'}`);
|
console.log(`[Tandoor Upload] Image type: ${isDirectUrl(imageUrl) ? 'URL' : isDataUrl(imageUrl) ? 'Base64' : 'Unknown'}`);
|
||||||
console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}...`);
|
console.log(`[Tandoor Upload] Image source: ${imageUrl.substring(0, 100)}...`);
|
||||||
|
|
||||||
// Strategy 1: Direct URL pass-through (preferred)
|
let buffer: Buffer;
|
||||||
if (isDirectUrl(imageUrl)) {
|
let mimeType: string;
|
||||||
console.log('[Tandoor Upload] Using URL pass-through strategy');
|
let sourceType: string;
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('image_url', imageUrl);
|
|
||||||
|
|
||||||
const uploadResponse = await fetch(
|
// Handle base64 data URLs
|
||||||
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Authorization': `Bearer ${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)) {
|
if (isDataUrl(imageUrl)) {
|
||||||
console.log('[Tandoor Upload] Using base64 file upload strategy');
|
sourceType = 'base64';
|
||||||
|
console.log('[Tandoor Upload] Decoding base64 data URL');
|
||||||
|
|
||||||
const parsed = parseDataUrl(imageUrl);
|
const parsed = parseDataUrl(imageUrl);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return { success: false, error: 'Invalid data URL format' };
|
return { success: false, error: 'Invalid data URL format' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert base64 to buffer
|
buffer = Buffer.from(parsed.base64Data, 'base64');
|
||||||
const imageBuffer = Buffer.from(parsed.base64Data, 'base64');
|
mimeType = parsed.mimeType;
|
||||||
const extension = getExtensionFromMimeType(parsed.mimeType);
|
console.log(`[Tandoor Upload] Decoded ${buffer.length} bytes, MIME: ${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': `Bearer ${token}` },
|
|
||||||
body: formData
|
|
||||||
}
|
}
|
||||||
);
|
// Handle direct HTTP(S) URLs
|
||||||
|
else if (isDirectUrl(imageUrl)) {
|
||||||
|
sourceType = 'url';
|
||||||
|
console.log('[Tandoor Upload] Downloading image from URL');
|
||||||
|
|
||||||
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 response = await fetch(imageUrl);
|
||||||
const imageBlob = await response.blob();
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
// Determine file extension from blob type or default to jpg
|
success: false,
|
||||||
let extension = '.jpg';
|
error: `Failed to download image: ${response.status} ${response.statusText}`
|
||||||
if (imageBlob.type) {
|
};
|
||||||
extension = getExtensionFromMimeType(imageBlob.type);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
// Get MIME type from response headers (most reliable)
|
||||||
formData.append('image', imageBlob, `recipe-image${extension}`);
|
mimeType = response.headers.get('content-type') || 'image/jpeg';
|
||||||
|
// Remove charset if present (e.g., "image/jpeg; charset=utf-8")
|
||||||
|
mimeType = mimeType.split(';')[0].trim();
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
buffer = Buffer.from(arrayBuffer);
|
||||||
|
console.log(`[Tandoor Upload] Downloaded ${buffer.length} bytes, MIME: ${mimeType}`);
|
||||||
|
}
|
||||||
|
// Unknown format
|
||||||
|
else {
|
||||||
|
return { success: false, error: 'Unsupported image format (not HTTP(S) URL or data URL)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create proper File object (critical for multipart/form-data)
|
||||||
|
const extension = getExtensionFromMimeType(mimeType);
|
||||||
|
const filename = `recipe-image${extension}`;
|
||||||
|
|
||||||
|
// In Node.js, we must create a File from Blob (Blob alone doesn't work)
|
||||||
|
const blob = new Blob([buffer], { type: mimeType });
|
||||||
|
const file = new File([blob], filename, { type: mimeType });
|
||||||
|
|
||||||
|
console.log(`[Tandoor Upload] Created File: ${filename} (${file.size} bytes, ${file.type})`);
|
||||||
|
|
||||||
|
// Upload to Tandoor
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
console.log('[Tandoor Upload] Uploading to Tandoor...');
|
||||||
const uploadResponse = await fetch(
|
const uploadResponse = await fetch(
|
||||||
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
|
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
// DO NOT set Content-Type - let fetch set it with boundary
|
||||||
|
},
|
||||||
body: formData
|
body: formData
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!uploadResponse.ok) {
|
if (!uploadResponse.ok) {
|
||||||
const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText);
|
const errorText = await uploadResponse.text().catch(() => uploadResponse.statusText);
|
||||||
|
const responseHeaders = JSON.stringify(Object.fromEntries(uploadResponse.headers.entries()));
|
||||||
|
|
||||||
console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
|
console.error(`[Tandoor Upload] Failed: ${uploadResponse.status} ${uploadResponse.statusText}`);
|
||||||
console.error(`[Tandoor Upload] Response: ${errorText.substring(0, 200)}`);
|
console.error(`[Tandoor Upload] Response headers: ${responseHeaders}`);
|
||||||
|
console.error(`[Tandoor Upload] Response body: ${errorText.substring(0, 500)}`);
|
||||||
|
console.error(`[Tandoor Upload] File metadata: ${filename}, ${file.size} bytes, ${file.type}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}`
|
error: `Upload failed (${uploadResponse.status}): ${errorText.substring(0, 200)}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Tandoor Upload] ✓ Success via fallback (${imageBlob.size} bytes)`);
|
console.log(`[Tandoor Upload] ✓ Success (${sourceType}, ${file.size} bytes)`);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
const errorStack = error instanceof Error ? error.stack : '';
|
||||||
console.error(`[Tandoor Upload] Exception: ${errorMsg}`);
|
console.error(`[Tandoor Upload] Exception: ${errorMsg}`);
|
||||||
|
if (errorStack) {
|
||||||
|
console.error(`[Tandoor Upload] Stack: ${errorStack}`);
|
||||||
|
}
|
||||||
// Don't fail recipe creation if image fails
|
// Don't fail recipe creation if image fails
|
||||||
return { success: false, error: errorMsg };
|
return { success: false, error: errorMsg };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user