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:
@@ -632,11 +632,20 @@ async function fetchImageAsBase64(imageUrl: string): Promise<string | null> {
|
||||
|
||||
/**
|
||||
* Extract thumbnail from Instagram post using stealth techniques
|
||||
*
|
||||
* Tries multiple methods in order of stealth:
|
||||
* 1. Meta tags (og:image, twitter:image)
|
||||
* 2. Video poster attribute
|
||||
* 3. Instagram window data structures
|
||||
* 4. Screenshot fallback
|
||||
* 1. Meta tags (og:image, twitter:image) - Returns: Direct HTTPS URL
|
||||
* 2. Video poster attribute - Returns: Direct HTTPS URL
|
||||
* 3. Instagram window data structures - Returns: Direct HTTPS URL
|
||||
* 4. Screenshot fallback - Returns: Base64 data URL (data:image/jpeg;base64,...)
|
||||
*
|
||||
* @param page - Playwright page instance
|
||||
* @param progressCallback - Optional progress callback for SSE updates
|
||||
* @returns Image URL (either direct HTTPS URL or base64 data URL) or null if all methods fail
|
||||
*
|
||||
* **Thumbnail Format Guide:**
|
||||
* - Methods 1-3: Return direct HTTPS URLs → Tandoor can use URL pass-through (efficient)
|
||||
* - Method 4: Returns base64 data URL → Requires conversion to file blob for upload
|
||||
*/
|
||||
async function extractThumbnailStealth(
|
||||
page: Page,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user