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

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