'use node'; import { action } from '../_generated/server'; import { v } from 'convex/values'; /** * Baixa uma imagem de uma URL externa e converte para base64. * * Esta action roda no servidor (Node.js), então não tem restrições de CORS * do navegador. Pode baixar imagens de qualquer domínio. * * @param url - URL da imagem a ser baixada * @returns String base64 da imagem (data URL) ou null se falhar */ export const downloadImageAsBase64 = action({ args: { url: v.string() }, returns: v.union(v.string(), v.null()), handler: async (ctx, args): Promise => { const { url } = args; try { // Validar URL let urlObj: URL; try { urlObj = new URL(url); } catch { console.error('URL inválida:', url); return null; } // Verificar se é uma URL HTTP/HTTPS if (!['http:', 'https:'].includes(urlObj.protocol)) { console.error('Protocolo não suportado:', urlObj.protocol); return null; } // Baixar a imagem (server-side não tem CORS) // Tentar múltiplas estratégias para evitar bloqueios (403) de CDNs type HeadersStrategy = Record; const estrategias: Array<{ name: string; headers: HeadersStrategy }> = [ // Estratégia 1: Headers completos de navegador moderno { name: 'headers-completos', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 'Accept-Language': 'pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7', 'Accept-Encoding': 'gzip, deflate, br', Referer: urlObj.origin + '/', 'Sec-Fetch-Dest': 'image', 'Sec-Fetch-Mode': 'no-cors', 'Sec-Fetch-Site': 'cross-site', 'Cache-Control': 'no-cache' } as HeadersStrategy }, // Estratégia 2: Headers mínimos mas realistas { name: 'headers-minimos', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', Accept: 'image/*', Referer: urlObj.origin + '/' } as HeadersStrategy }, // Estratégia 3: Apenas User-Agent básico { name: 'user-agent-basico', headers: { 'User-Agent': 'Mozilla/5.0' } as HeadersStrategy }, // Estratégia 4: Sem headers (máximo compatibilidade) { name: 'sem-headers', headers: {} as HeadersStrategy } ]; let ultimoErro: { status?: number; statusText?: string; message?: string } | null = null; for (const estrategia of estrategias) { try { const response = await fetch(url, { headers: estrategia.headers, signal: AbortSignal.timeout(10000) // Timeout de 10 segundos }); if (response.ok) { // Verificar Content-Type const contentType = response.headers.get('content-type'); if (contentType && contentType.startsWith('image/')) { const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); const base64 = buffer.toString('base64'); const dataUrl = `data:${contentType};base64,${base64}`; console.log( `✅ Imagem baixada usando estratégia "${estrategia.name}": ${url} (${buffer.length} bytes)` ); return dataUrl; } else { console.warn(`Estratégia "${estrategia.name}": Content-Type inválido:`, contentType); } } else { ultimoErro = { status: response.status, statusText: response.statusText }; console.warn( `Estratégia "${estrategia.name}" falhou: ${response.status} ${response.statusText}` ); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); ultimoErro = { message: errorMessage }; console.warn(`Estratégia "${estrategia.name}" lançou exceção:`, errorMessage); } } // Se todas as estratégias falharam, logar erro detalhado console.error( '❌ Todas as estratégias falharam ao baixar imagem:', url, 'Último erro:', ultimoErro ); return null; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error('Erro ao baixar imagem de URL:', url, errorMessage); return null; } } });