Renvoyer une image par défaut avec ActiveStorage stocké sur disque

Pour une application utilisant le stockage DiskService d’ActiveStorage, j’ai eu besoin de copier des données de la base de production dans ma base de développement. Je ne voulais pas récupérer les fichiers stockés, j’ai donc fait en sorte d’afficher une image ActiveStorage par défaut au lieu d’une erreur 404.

Tags : Ruby on Rails ActiveStorage

Publié le 19 avril 2023

Nous devons modifier le code de la gemme ActiveStorage à deux endroits :

  • ActiveStorage::DiskController
  • ActiveStorage::Service::DiskService

Il est préférable d’éviter les monkeypatches en général, et quand c’est inévitable de limiter au maximum le volume de code modifié. Malheureusement, le code original ne permet pas d’intercepter les erreurs sans recopier une méthode entière.

DiskController

L’action DiskController#show intercepte l’exception Errno::ENOENT renvoyée par Ruby quand le chemin ne correspond à rien de connu sur le disque. Nous devons modifier cette ligne pour qu’elle renvoie l’image par défaut au lieu de renvoyer l’en-tête HTTP not_found.

Grâce au patch suivant, si une image n’est pas trouvée sur le disque, c’est l’image par défaut qui sera affichée.

ActiveStorage::Service::DiskService

Si les images sont utilisées hors d’un contexte web, il faut également surcharger la méthode DiskService#download. Dans mon cas les images sont insérées dans des PDF, j’avais donc besoin de patcher cette partie de la gemme également.

Cette fois, au lieu de lever l’exception ActiveStorage::FileNotFoundError, nous allons renvoyer le contenu du fichier image_unavailable.jpg.

Note : je n’ai été jusqu’à patcher le streaming vu que ça ne me concernait pas, mais si c’est votre cas il faudra adapter le code.

Comme pour la méthode du contrôleur, nous sommes obligés de surcharger la méthode entière pour pouvoir remplacer une seule ligne, ce qui est dommage mais inévitable.

La petite feinte c’est qu’en mode développement les classes sont chargées à la volée, il faut en tenir compte lorsqu’on applique le patch. En effet, la classe ActiveStorage::Service::DiskService n’est pas chargée au moment où le patch est appliqué, il faut forcer Rails à lire l’original en premier.

# 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

Je suis la convention qui recommande de placer les patches dans le répertoire lib/extras, qui n’est pas chargé automatiquement. Pour que les patches soient chargés, il faut indiquer à Rails de charger le contenu des fichiers placés à cet endroit dans un bloc config.to_prepare.

# 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

Il est peut-être possible d’améliorer ce code mais il fait ce dont j’ai besoin pour me faciliter le développement, je préfère donc me consacrer à des tâches plus utiles. J’espère que cela vous aidera si vous avez la même problématique !