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
Agregando Openverse: el tercer proveedor de imágenes, y el primero que nadie tiene que configurar
Más de 800 millones de imágenes CC y de dominio público, anónimo por defecto, y la consecuencia de diseño de que una instalación sin ningún proveedor configurado ahora hace llamadas reales a la API.
El error de la imagen hero que en realidad eran tres errores
Una tarjeta que mentía diciendo que un post no tenía imagen hero, una ruta relativa resuelta contra el origen equivocado, y un 403 de hotlinking basado en Referer 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 de seguimiento de descargas fire-and-forget, y una URL controlada por el navegador que casi terminó con la clave de la API pegada a ella.