Pular para o conteúdo
Development

O bug da imagem de destaque que na verdade eram três bugs

Por Victor Da Luz•
railsrubyimagesdev-logblog-manager

Encontrei este durante teste manual das imagens de destaque self-hosted. Um post, “Self-hosting Backlogia, and fixing it before running it”, tinha uma imagem de destaque ativa no blog de verdade, mas a UI do blog-manager mostrava só uma caixa de busca vazia, como se o post não tivesse hero nenhum. Abri o chamado achando que seria um ajuste de uma linha. Não foi.

O que eu estava tentando fazer

O card de atribuição de hero do blog-manager e a página de detalhe do post tinham o mesmo bug: os dois checavam post.hero_image_url, a escolha em estágio, definida apenas pelo próprio seletor de Pexels/Unsplash do blog-manager, para decidir entre mostrar uma imagem ou uma caixa de busca. Nenhum dos dois checava post.live_hero_image_url, que é o estado canônico de verdade (definido por uma varredura do repositório, ou pelo job de commit logo depois que ele escreve no frontmatter do artigo publicado). Para qualquer post cujo hero foi definido de outra forma, um script irmão no vdaluz.com, ou uma imagem self-hosted que foi capturada na varredura, hero_image_url fica nil para sempre, e o card mente dizendo que o post não tem hero.

<% if post.hero_image_url.present? %>
  <img src="<%= post.hero_image_url %>" ...>
<% else %>
  <%# search box %>
<% end %>

O ajuste parecia óbvio: ampliar a condição para checar também live_hero_image_url. Não foi tão simples assim.

O que eu construí

Primeiro obstáculo: live_hero_image_url nem sempre é uma URL absoluta. Para imagens self-hosted, é um caminho relativo ao site, tipo /assets/images/slug.jpeg, exatamente o que o post de exemplo do relato de bug tinha. Trocar a condição de forma ingênua teria renderizado <img src="/assets/images/slug.jpeg">, que o navegador resolve contra a própria origem do blog-manager, não a do blog. Imagem quebrada, exatamente no cenário que o relato de bug descrevia.

Então adicionei um método no model para resolver isso direito:

def hero_image_display_url
  return hero_image_url if hero_image_url.present?
  return nil if live_hero_image_url.blank?
  return live_hero_image_url if live_hero_image_url.match?(%r{\Ahttps?://}) || blog.base_url.blank?

  begin
    URI.join(blog.base_url, live_hero_image_url).to_s
  rescue URI::InvalidURIError, URI::BadURIError
    live_hero_image_url
  end
end

Esse rescue não estava na minha primeira versão. Mais sobre isso adiante.

Decisões que tomei e por quê

Não mexi no botão “Choose a different image”. Uma vez que o hero de um post está ativo de verdade (commitado no frontmatter), o caminho de escrita é só de inserção por design, ele recusa sobrescrever uma chave heroImage já existente. Então, mesmo antes deste ajuste, escolher uma nova imagem num post já ativo já era um beco sem saída, o commit simplesmente falharia. Ampliar a condição de exibição torna esse botão alcançável em mais um estado (nada em estágio, hero já ativo), onde clicar nele agora é um no-op silencioso em vez de ficar completamente escondido. Deixei como estava em vez de tentar corrigir uma limitação que este chamado não pedia para eu corrigir.

Não fiz backfill da atribuição para heroes definidos fora do fluxo normal. O scanner do repositório lê a chave heroImage do frontmatter ativo para live_hero_image_url, mas nunca traz a informação de crédito (heroImageCredit) de volta para o banco. Então um hero definido pelo script externo agora exibe corretamente, mas sem a linha “Photo by…”. O partial de atribuição já degrada bem quando esses campos estão vazios, então isso não foi uma regressão, só uma lacuna que percebi e deixei como estava.

O que me surpreendeu

A parte que eu não vi vindo: verifiquei o ajuste localmente, o screenshot parecia certo, e aí, testando num navegador de verdade contra a instância de produção, apareceu um ícone de imagem quebrada. Descobri que o vdaluz.com protege seus próprios caminhos de assets contra hotlink pelo header Referer, e não só contra origens aleatórias. Testei contra o hostname de produção de verdade do blog-manager e também tomou 403. A instância de produção do meu próprio app não consegue fazer hotlink das imagens do meu próprio blog sem falsificar o Referer.

curl sem header Referer: 200. curl com qualquer Referer que não seja o próprio vdaluz.com: 403. Navegadores sempre mandam um Referer para uma <img>, a menos que sejam instruídos a não mandar, então o ajuste precisou de mais um atributo:

<img src="<%= post.hero_image_display_url %>" referrerpolicy="no-referrer" ...>

A outra surpresa veio de rodar uma revisão automatizada com múltiplos ângulos antes do merge, oito ângulos de revisão em paralelo, um dos quais encontrou e confirmou empiricamente, de forma independente, que minha chamada URI.join podia levantar exceção e derrubar a página inteira com 500. O campo base_url do blog é validado com URI.regexp, o que parece garantir uma URL limpa, mas essa regex não é ancorada, então ela só precisa dar match em algum lugar da string. "https://vdaluz.com/blog site" (com um espaço perdido) passa direto pela validação e depois explode o URI.join. Eu nunca teria pego isso manualmente, foi preciso um agente de revisão literalmente rodar URI.join contra strings de caso extremo num console Rails para provar. Adicionei o rescue, adicionei um teste de regressão para isso, e publiquei tudo no mesmo PR.

O que vem a seguir

Nada urgente, o ajuste está mergeado, testado, e verificado contra dados reais. Duas coisas que anotei para depois em vez de fazer agora: o gate de publicação do Dev.to ainda checa o campo errado (atualmente inofensivo, já que a publicação nativa no Dev.to está desabilitada até outro chamado ser resolvido), e a pegadinha da proteção contra hotlink só tem um ajuste do lado do cliente, qualquer coisa que busque essas imagens do lado do servidor (um enclosure de RSS, um payload de sindicação) ainda tomaria 403 e precisa de outra solução.

Leitura relacionada

Você também pode achar útil

Proton

Proton Pass

Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
Proton

Proton Drive

Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
Proton

Proton VPN

VPN comercial com filtragem NetShield e interruptor de desligamento automático.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais