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.

Tags : Ruby on Rails ActiveStorage

Published: April 19, 2023

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.