The Right Way to Store and Serve Dragonfly Thumbnails
An improved method for caching thumbnailed images with the Dragonfly Ruby library.
We love and use Dragonfly to manage file uploads in our Rails applications. Specifically, its API for generating thumbnails is a huge improvement over its predecessors. There is one area where the library falls short, though: out of the box, Dragonfly doesn't do anything to cache the result of a resize/crop, meaning a naïve implementation would rerun these operations every time we wanted to show a thumbnailed image to a user.
The Dragonfly documentation offers some suggestion about how to handle this issue, but makes it clear that you're pretty much on your own:
Dragonfly.app.configure do
# Override the .url method...
define_url do |app, job, opts|
thumb = Thumb.find_by_signature(job.signature)
# If (fetch 'some_uid' then resize to '40x40') has been stored already, give the datastore's remote url ...
if thumb
app.datastore.url_for(thumb.uid)
# ...otherwise give the local Dragonfly server url
else
app.server.url_for(job)
end
end
# Before serving from the local Dragonfly server...
before_serve do |job, env|
# ...store the thumbnail in the datastore...
uid = job.store
# ...keep track of its uid so next time we can serve directly from the datastore
Thumb.create!(uid: uid, signature: job.signature)
end
end
To summarize: create a Thumb
model to track uploaded crops. The define_url
callback executes when you ask for the URL for a thumbnail, checking if a record exists in the database with a matching signature and, if so, returning the URL to the stored image (e.g. on S3). The before_serve
block defines what happens when Dragonfly receives a request for a thumbnailed image (the ones that look like /media/...
), storing the thumbnail and then creating a corresponding record in the database.
The problem with this approach is that if someone gets ahold of the initial /media/...
URL, they can cause your app to reprocess the same image multiple times, or store multiple copies of the same image, or just fail outright. Here's how we can do it better.
First, create the Thumbs
table, and put unique indexes on both columns. This ensures we'll never store multiple versions of the same cropping of any given image.
class CreateThumbs < ActiveRecord::Migration[5.2]
def change
create_table :thumbs do |t|
t.string :signature, null: false
t.string :uid, null: false
t.timestamps
end
add_index :thumbs, :signature, unique: true
add_index :thumbs, :uid, unique: true
end
end
Then, create the model. Same idea: ensure uniqueness of signature and UID.
class Thumb < ApplicationRecord
validates :signature,
:uid,
presence: true,
uniqueness: true
end
Then replace the before_serve
block from above with the following:
before_serve do |job, env|
thumb = Thumb.find_by_signature(job.signature)
if thumb
throw :halt,
[301, { "Location" => job.app.remote_url_for(thumb.uid) }, [""]]
else
uid = job.store
Thumb.create!(uid: uid, signature: job.signature)
end
end
(Here's the full resulting config.)
The key difference here is that, before manipulating, storing, and serving an image, we check if we already have a thumbnail with the matching signature. If we do, we take advantage of a cool feature of Dragonfly (and of Ruby) and throw
1 a Rack response that redirects to the existing asset which Dragonfly catches and returns to the user.
So that's that: a bare minimum approach to storing and serving your Dragonfly thumbnails without the risk of duplicates. Your app's needs may vary slightly, but I think this serves as a better default than what the docs recommend. Let me know if you have any suggestions for improvement in the comments below.
Dragonfly illustration courtesy of Vecteezy.
- For more information on Ruby's
throw
/catch
mechanism, here is a good explanation from Programming Ruby or see chapter 4.7 of Avdi Grimm's Confident Ruby.