Skip to content
Development

The hero image bug that was actually three bugs

By Victor Da Luz
railsrubyimagesdev-logblog-manager

Found this one during manual testing of self-hosted hero images. One post - “Self-hosting Backlogia, and fixing it before running it” - had a hero image live on the actual blog, but blog-manager’s UI showed a bare search box, like the post had no hero at all. Filed it and figured it’d be a one-line fix. It wasn’t.

What I was trying to do

blog-manager’s hero-assignment card and the post detail page both had the same bug: they checked post.hero_image_url - the staged pick, only ever set by blog-manager’s own Pexels/Unsplash picker - to decide whether to show an image or a search box. They never checked post.live_hero_image_url, which is the actual canonical state (set by a repo scan, or by the commit job right after it writes into the live article’s frontmatter). For any post whose hero was set some other way - a sibling script on vdaluz.com, or a self-hosted image that got scanned in - hero_image_url stays nil forever, and the card lies about the post having no hero.

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

The fix looked obvious: widen the condition to also check live_hero_image_url. It was not that simple.

What I built

First snag: live_hero_image_url isn’t always an absolute URL. For self-hosted images it’s a site-relative path like /assets/images/slug.jpeg - which is exactly what the bug report’s example post had. Naively swapping the condition would’ve rendered <img src="/assets/images/slug.jpeg">, which the browser resolves against blog-manager’s own origin, not the blog’s. Broken image, for the exact scenario the bug report was about.

So I added a model method to resolve it properly:

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

That rescue wasn’t in my first draft. More on that below.

Decisions I made and why

Didn’t touch the “Choose a different image” button. Once a post’s hero is live for real (committed into the frontmatter), the write path is insertion-only by design - it refuses to overwrite an existing heroImage key. So even before this fix, re-picking an image on an already-live post was already a dead end; committing it would just fail. Widening the display condition makes that button reachable in one more state (nothing staged, hero already live) where clicking it is now a silent no-op instead of hidden entirely. I left it alone rather than trying to fix a limitation this issue didn’t ask me to fix.

Didn’t backfill attribution for out-of-band heroes. The repo scanner reads the live frontmatter’s heroImage key into live_hero_image_url, but never reads the credit info (heroImageCredit) back into the DB. So a hero set by the external script now displays correctly, but with no “Photo by…” line. The attribution partial already degrades gracefully when those fields are blank, so this wasn’t a regression - just a gap I noticed and left alone.

What surprised me

The part I didn’t see coming: I verified the fix locally, the screenshot looked right, and then trying it in a real browser against the production instance got a broken image icon. Turned out vdaluz.com hotlink-protects its own asset paths by Referer header - and not just against random origins. I tested it against blog-manager’s actual production hostname and it 403’d too. My own app’s production instance can’t hotlink my own blog’s images without spoofing its Referer.

curl without a Referer header: 200. curl with any Referer that isn’t vdaluz.com itself: 403. Browsers always send a Referer for an <img> unless told otherwise, so the fix needed one more attribute:

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

The other surprise came out of running an automated multi-angle review before merging - eight parallel review angles, one of which independently found and empirically confirmed that my URI.join call could raise and 500 the whole page. The blog’s base_url field is validated with URI.regexp, which sounds like it guarantees a clean URL - but that regex is unanchored, so it just has to match somewhere in the string. "https://vdaluz.com/blog site" (stray space) sails through validation and then blows up URI.join. I’d never have caught that manually; it took a review agent literally running URI.join against edge-case strings in a Rails console to prove it. Added the rescue, added a regression test for it, shipped it in the same PR.

What’s next

Nothing urgent - the fix is merged, tested, and verified against real data. Two things I wrote down for later rather than doing now: the Dev.to publish gate still checks the wrong field (currently harmless, since native Dev.to publishing is disabled pending another issue), and the hotlink-protection gotcha only has a client-side fix - anything that fetches these images server-side (an RSS enclosure, a syndication payload) would still 403 and needs a different answer.

Related reading