Pour ceux qui ne connaissent pas Dragonfly, c’est une application Rack qui peut être utilisée seule ou via un middleware.
Le fait que ce soit une application Rack la rend compatible avec toutes les applications écrites en Ruby et utilisant Rack comme point d’entrée pour traiter les requêtes HTTP. Vous pourrez donc vous en servir dans Sinatra, Rails ou encore Hanami.
Pour faciliter la tâche, cette gem est livrée avec tout le nécessaire pour une intégration simple et complète dans Rails et ActiveRecord.
Dragonfly a pour but de faciliter la gestion des uploads de fichiers
et leur manipulation. Vous n’aurez donc pas à gérer un objet
Rack::Multipart::UploadedFile
pour enregistrer son contenu sur le
disque du serveur ou dans le cloud. C’est pratique, mais pas vraiment
innovant.
Là où Dragonfly est différenciant c’est qu’il permet de traiter systématiquement tous les fichiers après upload ou à la demande pour, par exemple, générer des versions alternatives des images (changement de résolution, de format, passage en dégradé de gris, …). Vous pouvez de la même manière traiter tout type de fichier (vidéos, PDF, …).
Si vous ne l’avez jamais testé, vous manquez sans aucun doute quelque chose.
Dragonfly va donc automatiser la gestion des uploads. Vous n’aurez
qu’à ajouter un champ de type file
(file_field
dans Rails) et
déclarer l’attribut concerné comme étant géré par Dragonfly dans votre
modèle.
Dès lors vous n’avez plus rien à faire, à chaque upload, une entrée est créée en base, ce qui permet de persister des informations sur le fichier.
Pour réutiliser ces fichiers, les afficher ou permettre leur téléchargement, Dragonfly met à disposition un certain nombre d’helpers pour faciliter la génération des URLs vers les fichiers.
En effet, pour plusieurs raisons, Dragonfly adopte un chemin et nom de fichier particulier pour accéder aux fichiers. Ça permet plusieurs choses :
Dans le cas de Rails, deux helpers sont mis à votre disposition :
remote_url
pour obtenir une URL d’accès publique qui correspond au
lien direct vers le système de stockage “http://host/system/…” ou le
lien direct vers le stockage S3 par exempleurl
qui permet d’obtenir une URL privée qui passe par
l’application et ne dévoile pas l’URL d’accès direct au fichier sur
le système de stockage. Avec la configuration par défaut, quelque
chose du type “/media/…”L’accès via l’URL publique outrepasse complètement la stack de l’application, ça veut donc dire que pour récupérer le fichier, l’application n’est absolument pas sollicitée. C’est le proxy (Nginx par exemple) ou le service dans le cloud (S3) qui va servir directement la ressource. C’est plus rapide, moins gourmand mais nous n’avons aucun contrôle sur la récupération de la ressource. C’est donc le moyen de récupération idéal pour des ressources destinées à être publiques et cachées par les navigateurs.
L’accès via l’URL privée passe lui au contraire par l’application. Pour être plus précis, la demande va passer par le middleware Dragonfly qui va alors se charger d’aller retrouver l’enregistrement en base qui concerne le fichier demandé grâce à son identifiant unique passé dans l’URL. Cette solution est moins performante puisque le fichier n’est pas servi directement, on passe par une sous-partie de l’application Rails. Elle a néanmoins l’avantage de laisser beaucoup plus de liberté quant à la gestion de ce fichier, de son accès et d’éventuels traitements à lui appliquer. C’est grâce à ça que Dragonfly permet notamment d’accéder à des versions de l’image retravaillées (thumb, grayscale, …) qui sont générées à la volée lors de la requête puis cachées au niveau HTTP.
Il faut donc connaître cette différence et utiliser la méthode de livraison du fichier la plus adaptée en fonction du besoin :
remote_url
qui est l’accès le plus direct au fichier et
ne nécessite absolument aucun traitementurl
qui passe dans l’application et permet
donc d’intervenir dans le cycle de récupération et d’envoi du fichierLes applications ayant une partie privée dans laquelle les utilisateurs peuvent gérer des uploads ont souvent pour but de ne permettre l’accès à ces fichiers qu’à un nombre restreint d’utilisateurs (en fonction des droits, du rôle, de l’uploader, de la date, …).
On va donc naturellement, pour un utilisateur donné, n’afficher que les liens vers les fichiers auxquels il a accès. Aucune raison pour l’utilisateur du groupe A d’accéder aux fichiers réservés au groupe B.
Est-ce réellement suffisant pour garantir la confidentialité des documents ? Non…
Par défaut aucun fichier géré par Dragonfly n’est authentifié
d’aucune sorte que ce soit. Même en passant par la méthode url
, et
donc la stack, le fichier est disponible à tous. Certains me diront
« Oui mais j’ai la gem Devise ! »
et bien peu importe.
Si un utilisateur du groupe B donne un lien d’un fichier à un utilisateur du groupe A ou même à quelqu’un d’étranger à l’application, alors ces personnes seront en mesure de consulter ledit fichier. Tout ce qui se passe quand on clique sur ce lien c’est qu’on entre dans l’application, on passe par le middleware Dragonfly qui ne fait que retrouver le fichier correspondant et le servir, vos filtres et autres vérifications internes mises en place dans les contrôleurs de votre application ne sont pas appelés, la requête n’arrive pas jusque là puisqu’une réponse est servie bien avant par le middleware.
Dans bien des cas ce n’est pas souhaitable, l’accès doit être restreint.
On pourrait se dire qu’il est facile de répondre à cette problématique en n’utilisant jamais les liens Dragonfly directement. On pourrait créer une route dédiée à l’authentification et l’envoi des fichiers.
On aurait donc une action qui vérifierait si l’utilisateur courant a accès au fichier. Effectivement ça fonctionne mais cette solution a plusieurs inconvénients :
send_file
C’est mieux que de ne pas gérer l’authentification du tout, on aura mis en place notre mesure de sécurité mais techniquement ce n’est franchement pas idéal.
La bonne solution selon moi est de venir s’intercaler directement dans le middleware Dragonfly pour ajouter notre logique d’authentification.
Cette solution n’a que des avantages puisqu’elle évite d’avoir à passer dans toute la stack et ne nécessite pas la mise en place de routes, contrôleurs ou actions dédiés.
On reste sur le système “natif” mis en place par Dragonfly qu’on ne fait qu’enrichir de notre logique.
On pourra continuer à utiliser Dragonfly classiquement avec ses différents helpers de manière tout à fait transparente.
Quand vous mettez en place Dragonfly dans votre application Rails, un fichier de configuration est ajouté dans les initializers. Ce fichier a pour but de définir la façon dont vous souhaitez stocker vos ressources, les plugins à activer, le format de l’URL, etc.
Cette configuration se fait dans le bloc configure
de l’application
Rack Dragonfly. Pour notre plus grand bonheur, les développeurs de
Dragonfly ont eu la bonne idée de mettre à disposition un callback
before_serve
qui nous permet d’intervenir dans le processus juste
avant la livraison de la ressource.
C’est parfait pour ce que nous souhaitons faire, on va pouvoir faire des vérifications, modifier des en-têtes, etc.
Par défaut le fichier de configuration doit ressembler à quelque chose comme :
config/initializers/dragonfly.rb :
require 'dragonfly'
Dragonfly.app.configure do
plugin :imagemagick
protect_from_dos_attacks true
secret "3d6f1d7f46c13d9b27ae3379b99b46ea3e7f0e2c89dfe54c8509ad3f3b2554e2"
url_format "/media/:job/:name"
end
# Logger
Dragonfly.logger = Rails.logger
# Mount as middleware
Rails.application.middleware.use Dragonfly::Middleware
# Add model functionality
if defined?(ActiveRecord::Base)
ActiveRecord::Base.extend Dragonfly::Model
ActiveRecord::Base.extend Dragonfly::Model::Validations
end
On a donc le plugin imagemagick
qui est activé. La protection contre
les attaques DOS est également active. Une clé secrète est définie pour
permettre de générer le SHA de protection lors des requêtes d’accès
aux fichiers.
Finalement, le format de l’URL vers les ressources est spécifié.
Tout ce qui est en dehors du bloc configure
sert simplement à bien
intégrer Dragonfly dans Rails en utilisant son logger, en montant le
middleware (celui dans lequel nous allons venir ajouter des choses),
puis en étendant les fonctionnalités d’ActiveRecord pour que les
modèles aient un accès facilité aux ressources Dragonfly.
Pour notre exemple, supposons que notre application
utilise Devise pour
authentifier nos utilisateurs. On a donc à notre disposition une
méthode current_user
qui nous retourne l’utilisateur courant.
Disons également que nous utilisons un système d’autorisation qui nous permet d’affecter des droits spécifiques à chaque utilisateur en fonction de son rôle ou son groupe par exemple. Cette gestion d’autorisation est mise en place grâce à CanCanCan.
On a donc plusieurs cas de figure qui deviennent possibles lorsqu’on veut accéder au fichier :
Comment mettre ces vérifications en place ?
Nous allons simplement profiter du callback before_serve
pour
faire nos vérifications et envoyer la réponse adéquate.
Ce callback est à ajouter dans le bloc configure
:
require 'dragonfly'
# Configure
Dragonfly.app.configure do
# Code de base vu plus tôt supprimé pour la concision
# Authentification et autorisation de la ressource demandée
before_serve do |job, env|
if job.fetch_step
user = env['warden'].user :user
file = Upload.where(file_uid: job.fetch_step.uid).first
if file
ability = Ability.new(user)
unless file.public? || ability.can?(:read, file.group)
throw :halt, [403, { "Content-Type" => "text/plain" }, ["Forbidden"]]
end
else
throw :halt, [404, { "Content-Type" => "text/plain" }, ["Not Found"]]
end
end
end
end
Avant de commencer l’explication, comme vous pouvez le voir le code est très court et c’est tout ce dont nous avons besoin pour gérer tous nos cas de figure.
Avant de servir le fichier, on va donc récupérer l’utilisateur courant s’il existe :
user = env['warden'].user :user
Pour ceux qui ne connaissent pas le fonctionnement interne de Devise,
il faut savoir qu’il se base
sur Warden qui est un framework
d’authentification basé sur Rack. Lorsque vous vous identifiez dans
une application utilisant Devise, ce dernier va déléguer un certain
nombre de choses à Warden et pour se faire il va renseigner l’attribut
user
de Warden avec l’objet User
(par défaut) courant.
N’oubliez pas qu’on est au niveau de Rack, on n’a donc pas accès aux
méthodes définies dans les contrôleurs Rails et donc impossible de
simplement utiliser current_user
. Heureusement que Devise s’appuie
sur Rack et y passe l’objet représentant l’utilisateur courant ! Si ce
n’était pas le cas, il aurait fallu le gérer nous même à
l’identification et passer l’information à Rack par nous-mêmes.
Ensuite nous allons récupérer l’enregistrement correspondant au fichier ayant à l’UID passé à la requête :
file = Upload.where(file_uid: job.fetch_step.uid).first
Rien d’inconnu pour vous. Cet objet nous est utile pour deux raisons. On veut autoriser son accès sur la base de ses attributs, notamment son groupe d’appartenance. Au passage on va en profiter pour vérifier que ce fichier existe toujours en base. Il pourrait en effet toujours exister sur le disque mais plus en base, en connaissant son UID on pourrait encore y accéder… Dragonfly ne vérifie rien en base, il essaie simplement de trouver le fichier sur le disque.
On vérifie donc que l’enregistrement est bien existant puis on procède aux vérifications d’accès :
ability = Ability.new(user)
Avant toute chose on crée une instance de notre classe Ability
en
lui passant notre utilisateur courant. On peut grâce à ça vérifier les
autorisations accordées à notre utilisateur. Cette ligne est
spécifique à l’utilisation de CanCanCan mais pourrait être adaptée à
n’importe quel système d’autorisation.
On passe à la portion de code suivante :
unless file.public? || ability.can?(:read, file.group)
throw :halt, [403, { "Content-Type" => "text/plain" }, ["Forbidden"]]
end
Pour coller à notre logique métier on vérifie deux choses pour autoriser ou refuser l’accès au fichier. Premièrement, si le fichier est marqué comme étant public alors on laisse Dragonfly servir ce dernier.
S’il n’est pas public on continue notre vérification en cherchant une correspondance dans les droits de l’utilisateur pour le groupe associé au fichier qui est demandé.
Si aucune des deux conditions n’est remplie alors l’accès au fichier
est refusé, on doit donc le faire savoir. Pour faire savoir à Rack
qu’on veux annuler la requête, il suffit d’envoyer le tag
:halt
en passant par la méthode throw
. Rack connaît ce tag et va
comprendre qu’on lui demande d’arrêter là.
On précise le contenu de la réponse à envoyer avec le statut 403
qui est le statut adéquat pour une interdiction d’accès. Le deuxième
élément du tableau est un Hash
qui représente les en-têtes de la
réponse et finalement le dernier élément est le contenu du body.
Pour finir nous avons le contenu de notre branche else
qui pour
rappel sert à gérer le cas où un upload a été supprimé de la base mais
le fichier associé est resté disponible sur le disque :
throw :halt, [404, { "Content-Type" => "text/plain" }, ["Not Found"]]
Dans ce cas nous renvoyons simplement une 404
pour signifier
l’absence de disponibilité du fichier.
Le cas que je met en avant dans cet article n’est qu’un exemple parmi tant d’autres. Les applications sont multiples, on pourrait très bien imaginer manipuler les en-têtes en fonction du navigateur pour satisfaire des besoins particulier, notamment pour streamer du contenu vidéo à Safari. Toutes les manipulations possibles et imaginables sur les réponses d’un middleware Rack sont envisageables puisque Rack nous laisse un contrôle total sur la réponse finale.
N’oubliez pas que les middlewares permettent potentiellement d’outrepasser une grosse partie de la stack et donc de gagner en performance. Sachant cela, il y a de nombreuses applications possibles dans lesquelles des actions spécifiques pourraient être gérées par un middleware si vous n’avez pas besoin de l’artillerie complète que propose Rails mais que vous mettez plutôt l’accent sur le temps de réponse.
Rien de tel que de se plonger dans la documentation d’API et le code source des gems que vous utilisez souvent, et qui ajoutent de la “magie” ou des comportements avancés. Cela vous permet, le jour venu, d’être en mesure de vous interfacer au mieux avec ces gems, pour en étendre les possibilités et aller plus loin que la simple utilisation de base décrite dans le README.
L’équipe Synbioz.
Libres d’être ensemble.