The hero image bug that was actually three bugs
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
Adding Openverse: the third image provider, and the first one nobody has to configure
800M+ CC and public-domain images, anonymous by default - and the design consequence that an install with zero configured providers now makes real API calls.
Adding Unsplash as a second hero-image provider
Mirroring the Pexels client line for line, a fire-and-forget download-tracking ping, and a browser-controlled URL that almost got the API key attached to it.
Building self-hosted hero images before I had anything that needed them
A download-and-rehost pipeline for CC-licensed images: vips from a buffer, a UTF-8 force-encode that would have corrupted JPEG bytes, and a stale queue that made the guard look like a bug.