ActiveStorage fallback for disk storage
I’m working on an app using DiskService
for ActiveRecord
storage. To debug a gnarly issue, I had to copy the production database to development, but didn’t want to download the blobs themselves. Here’s how to serve a fallback image when an ActiveStorage
file is missing from the storage
folder.
Two parts of the ActiveStorage gem need to be monkeypatched:
ActiveStorage::DiskController
ActiveStorage::Service::DiskService
When monkeypatching, it’s best to change the minimum amount of code, but since we’re trying to handle errors that are being rescued in the original code, we need to overwrite the original methods entirely. Had Rails handled errors in dedicated methods, we could have monkeypatched just those, but that is not the case.
DiskController
The DiskController#show
method rescues the Errno::ENOENT
error raised by Ruby when a given path does not exist in the filesystem.
This is the only line we need to change. Instead of returning head :not_found
, we tell it to serve a fallback image.
After applying this patch, any image absent from the filesystem will be replaced by default_image.jpg
instead of returning a 404.
ActiveStorage::Service::DiskService
If using blobs in non-web contexts, we also need to override the DiskService#download
method.
In my case, I’m embedding images in PDFs, so I had to dive in.
Instead of raising ActiveStorage::FileNotFoundError
, we just return the contents of the image_unavailable.jpg
file.
I don’t use streaming, otherwise I’d have to monkeypatch the stream method as well probably.
As before, we have to copy the whole method body just to change one line. Had Rails implemented a dedicated method for dealing with errors, this monkeypatch would be less hacky.
The tricky part happens when applying the patch: ActiveStorage only loads the DiskService if it is used (by defining service: Disk
in config/storage.yml
). When preparing the app, the ActiveStorage::Service::DiskService
constant is not yet available, so we need to force Rails to pick it up. After that, prepending our monkeypatch works just fine.
# lib/extras/active_storage_fallback.rb
return unless Rails.env.development?
module ActiveStorageControllerFallback
def show
if key = decode_verified_key
serve_file named_disk_service(key[:service_name]).path_for(key[:key]), content_type: key[:content_type], disposition: key[:disposition]
else
head :not_found
end
rescue Errno::ENOENT
serve_fallback
end
def serve_fallback
default_image = Rails.root.join("app", "assets", "images", "image_unavailable.jpg")
serve_file default_image, content_type: "image/jpg", disposition: :inline
end
end
ActiveStorage::DiskController.prepend ActiveStorageControllerFallback
module ActiveStorageServiceFallback
def download(key, &block)
if block_given?
instrument :streaming_download, key: key do
stream key, &block
end
else
instrument :download, key: key do
File.binread path_for(key)
rescue Errno::ENOENT
File.binread Rails.root.join("app", "assets", "images", "image_unavailable.jpg")
end
end
end
end
# ActiveStorage does not autoload services, so we need to force reopening the class first
module ActiveStorage; class Service::DiskService < Service; end; end
ActiveStorage::Service::DiskService.prepend ActiveStorageServiceFallback
I’m putting monkeypatches in the lib/extras
folder, which is not autoloaded.
Inside config.to_prepare
, I tell Rails to load and execute every monkeypatch file.
# app/config/application.rb
module YourAppName
class Application < Rails::Application
# Execute all monkey patches
config.to_prepare do
Rails.root.join("lib", "extras").children.each do |file|
require file
end
end
end
end
There may be a cleaner way to achieve the same results but my goal here is just to simplify development, so I’m happy to stop here and move on to tasks that bring more value to the customer.