Skip to content
Development

Adding Unsplash as a second hero-image provider

By Victor Da Luz
railsrubyimagesdev-logblog-manager

blog-manager already had one image source for hero images: Pexels. A search box hits Pexels’ API, I pick a photo, and the URL gets hotlinked into the post’s frontmatter. An earlier issue turned that into a proper provider-agnostic layer (a registry keyed by provider name, results interleaved round-robin, a shared normalized hash shape). This one was about actually plugging a second provider into that registry: Unsplash.

The interesting part wasn’t the search integration itself. Unsplash’s API guidelines have two requirements Pexels doesn’t: you have to hotlink (no downloading and rehosting, which conveniently matches how this app already treats Pexels), and you have to ping a per-photo “download tracking” endpoint whenever someone actually picks a photo, purely so Unsplash can count it as a download in their own metrics.

What I built

Unsplash::Client mirrors Pexels::Client almost line for line: hand-rolled Net::HTTP, a connection: proc as a test seam instead of pulling in webmock or VCR, the same Error/AuthError hierarchy under ImageSearch::Error. The only real difference is Unsplash’s response shape (results instead of Pexels’ photos, urls.regular/urls.small instead of src.large/src.medium) and a new track_download method for the tracking ping.

The ping itself became its own small job, UnsplashDownloadPingJob, fired from select_image the moment someone picks an Unsplash photo. No retries, no discard_on, just a rescue-and-log. If Unsplash’s tracking endpoint is down, that’s not a reason to block someone from picking a hero image.

A decision I almost skipped

The download-tracking URL (links.download_location) comes back from Unsplash’s search response and rides along as a hidden form field through the existing “Select” button on each search result. Which means, by construction, it’s a value the browser controls. My first pass just took whatever the client sent and fired an authenticated GET at it with the app’s Unsplash key attached. A tampered form field would happily leak that key to any host an attacker put in that field.

Brakeman didn’t catch it, because this isn’t a pattern it checks for, and it’s easy to miss because url_full (the actual image URL) has the exact same “trust the client” shape but is harmless, since the browser fetches that one, not the server. The fix was a one-line host check in track_download before attaching the Client-ID header. Small, but the kind of thing that’s obvious in hindsight and invisible until you go looking for it.

What surprised me

Manually verifying this without a real Unsplash key (registering one is a dashboard trip for later) turned into a small yak-shave. I ended up driving the app through ActionDispatch::Integration::Session inside a rails runner script against the real development database, since the app is behind real OIDC auth and there’s no dev-mode bypass. That worked, right up until I mixed a full request/response dispatch with a plain ActiveRecord query in the same process and hit a genuinely confusing ActiveSupport::ExecutionContext error deep in Rails internals. Nothing to do with my code. Splitting each check into its own runner invocation made it go away. I hadn’t run into that particular interaction before.

The one thing that did work cleanly: the app already had a real Pexels key configured in development, so I could confirm the two-provider registry change didn’t regress the existing Pexels-only search. It returned live Pexels results and the “Photos provided by Pexels” footer, exactly like before.

What’s next

This ships with Unsplash wired into search, selection, attribution, and the settings page, but it’s not live until an Unsplash Access Key is registered and pasted into Settings. Demo mode is 50 requests/hour, which the existing 12-hour search cache makes fine for one person. Production approval (5,000/hour) needs attribution screenshots submitted to Unsplash after this has actually been used for a bit.

Related reading