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:
Giancarmine Salucci
2025-12-21 05:19:33 +01:00
parent 856c5c26f4
commit cc7b8032cb
4 changed files with 16022 additions and 76 deletions

15468
docs/Tandoor (2.3.6).yaml Normal file

File diff suppressed because it is too large Load Diff

View 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