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
|
||||
Reference in New Issue
Block a user