Construir imágenes de portada autoalojadas antes de tener algo que las necesitara
Tomé esto sobre todo como trabajo de plomería: blog-manager tiene una integración con Pexels que permite buscar una foto de stock y prepararla como imagen de portada de un post, pero la URL que guarda es un hotlink directo al CDN de Pexels. Eso está bien para Pexels, ya que sus términos de uso quieren el hotlinking de todas formas. No está bien para Openverse, el buscador de imágenes con licencia CC que quiero agregar después, porque Openverse solo hace de proxy para búsquedas en Wikimedia, Flickr, y servidores de museos al azar, cualquiera de los cuales puede devolver un 404 o empezar a bloquear el hotlinking cuando se le ocurra. Las licencias CC permiten explícitamente descargar y realojar, así que la solución es descargar la imagen de verdad y confirmarla en el repo del blog, como cualquier otra imagen de portada que ya está ahí.
Una sesión anterior ya había resuelto las dos preguntas de alcance abiertas (backend de almacenamiento, forma del pipeline de descarga) investigando directamente en el repo real de vdaluz.com, así que pasé directo a construir.
Lo que construí
Un servicio HeroImage::Downloader: trae los bytes desde una URL, rechaza cualquier cosa que no sea image/* o pese más de 10MB, sigue hasta 3 redirecciones (muchos hosts de imágenes CC redirigen), y normaliza a JPEG con calidad 82 usando ruby-vips:
::Vips::Image.new_from_buffer(body, "").jpegsave_buffer(Q: JPEG_QUALITY)
Después, una bandera por fuente en Post para que el comportamiento de autoalojamiento sea opcional por proveedor, no un interruptor global:
HERO_IMAGE_SOURCE_INFO = {
"pexels" => { name: "Pexels", url: "...", self_host: false },
"unsplash" => { name: "Unsplash", url: "...", self_host: false },
"openverse" => { name: "Openverse", url: "...", self_host: true }
}.freeze
Y HeroImage::RepoCommitter sumó un segundo camino de commit. La API de Contents de GitHub solo acepta un archivo por PUT, así que una portada autoalojada ahora cuesta dos commits en vez de uno: primero los bytes de la imagen llegan a public/assets/images/<slug>.jpeg, luego corre el commit de frontmatter que ya existía, salvo que ahora escribe la ruta autoalojada relativa al sitio en heroImage en vez de la URL cruda del proveedor.
Decisiones
Descarté la gema image_processing aunque ya está sin usar en el Gemfile. Su backend Vips quiere una ruta de archivo (llama a Vips::Image.new_from_file), así que usarla acá habría significado escribir los bytes descargados en un archivo temporal solo para pasarle una ruta. Vips::Image.new_from_buffer en crudo hace el mismo trabajo directo desde memoria. Como no estoy redimensionando nada, solo recodificando el formato, la abstracción extra no aportaba nada.
También estuve a punto de usar el get_file del cliente de contenido de GitHub para buscar el sha de una imagen existente y hacer sobrescrituras idempotentes, pero después noté que fuerza la codificación de la respuesta a UTF-8:
decoded = Base64.decode64(encoded).force_encoding("UTF-8")
Eso está bien para markdown, no está bien para bytes de JPEG. list_directory devuelve metadatos del archivo (nombre, sha, tamaño) sin tocar el contenido, así que usé eso en su lugar para encontrar el sha de un archivo existente antes de sobrescribirlo.
Lo que me sorprendió
Quería verificar el pipeline de descarga contra algo más parecido a lo que Openverse realmente va a hacer de proxy, así que probé con una URL real de Wikimedia Commons. Obtuve un 400, y después un 403 incluso con un User-Agent descriptivo. No lo perseguí más porque no es algo que controlo, y los endpoints de prueba /image/jpeg y /image/png de httpbin cumplieron la misma función de demostrar que una descarga de red real funciona de verdad y convierte formatos de verdad.
La sorpresa más grande fue en las pruebas manuales, y no tuvo nada que ver con mi código. La cola de “portada faltante” en blog-manager depende de una columna de la base de datos (live_hero_image_url) que solo se actualiza escaneando el repo, y este blog no se había escaneado en más de un mes. Un post tenía una portada activa en su frontmatter que la base de datos todavía no conocía, así que mi nueva protección de solo inserción correctamente se negó a sobrescribirla dos veces, lo cual parecía un error pero en realidad era la protección haciendo su trabajo contra datos desactualizados. Correr un escaneo nuevo para arreglarlo tomó unos cuatro minutos y pareció colgado todo el tiempo. Resultó que Solid Queue simplemente no da ninguna barra de progreso, y la señal real de que no estaba trabado fue ver las marcas de tiempo de Post#updated_at avanzar en tiempo real mientras procesaba 166 posts, 135 de los cuales estaban desactualizados.
Ese mismo reescaneo también expuso una brecha real y preexistente en la interfaz: la tarjeta de asignación de portada solo revisa la columna hero_image_url en borrador, no lo que realmente está activo, así que cualquier post cuya portada se haya definido fuera del propio selector de Pexels de blog-manager (hay un script independiente que hace esto directamente) aparece como si no tuviera portada. Lo registré por separado en vez de meterlo por expansión de alcance en este issue.
Qué sigue
Esta funcionalidad todavía no hace nada por sí sola, ya que nada produce una fuente openverse. El proveedor de Openverse es lo que realmente la va a ejercitar de verdad. Hasta entonces solo tiene cobertura automatizada: pruebas unitarias sobre la secuencia de dos commits y el comportamiento de sobrescritura idempotente, más ese único viaje de red real contra httpbin que demuestra que el paso de descarga y normalización no es puro mock de principio a fin.
Lecturas relacionadas
El error de la imagen hero que en realidad eran tres errores
Una tarjeta que mentía sobre un hero faltante, una ruta relativa mal resuelta, y un bloqueo de hotlinking de mi propio blog contra mi propia app.
Agregar Unsplash como segundo proveedor de imágenes hero
Replicando el cliente de Pexels línea por línea, un ping fire-and-forget de descargas, y una URL del navegador que casi terminó con la clave de API pegada.
Qué pasa cuando un job transmite y nadie escucha
Cerrando el ciclo de la imagen destacada: frontmatter que solo inserta, una validación que detectó drift real, y un broadcast sin oyentes.
También te podría ser útil
AdGuard para iOS
Bloqueo de anuncios y rastreadores en todo el sistema en iOS, sin necesidad de un servidor DNS aparte.
Como afiliado de AdGuard, obtengo ingresos por las compras que califican.
Más informaciónProton Mail
Correo electrónico cifrado de extremo a extremo, con arquitectura de acceso cero.
Como socio de Proton, obtengo ingresos por las compras que califican de los servicios de privacidad y seguridad de Proton (Pass, Mail, VPN, Drive).
Más informaciónProton VPN
VPN comercial con filtrado NetShield e interruptor de apagado automático.
Como socio de Proton, obtengo ingresos por las compras que califican de los servicios de privacidad y seguridad de Proton (Pass, Mail, VPN, Drive).
Más información