Files
insta-recipe/docs/plans/FixTandoorImageUploadV2.md
Giancarmine Salucci cc7b8032cb 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.
2025-12-21 05:19:33 +01:00

14 KiB

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:

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:

const blob = new Blob([imageBuffer], { type: parsed.mimeType });
formData.append('image', blob, `recipe-image${extension}`);

Problem: According to OpenAPI spec:

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

/api/recipe/{id}/image/:
  put:
    operationId: apiRecipeImageUpdate
    requestBody:
      content:
        multipart/form-data:
          schema:
            $ref: '#/components/schemas/RecipeImage'

RecipeImage Schema

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:

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:

// 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:

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:

// 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:

// 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:

// 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:

// 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

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

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

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

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:
curl -X PUT \
  -H "Authorization: Bearer ${TOKEN}" \
  -F "image=@test-image.jpg" \
  ${TANDOOR_URL}/api/recipe/1/image/
  1. Compare working curl request with our FormData
  2. 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