Preparation
To attach files to your records, you will need a new database column representing the filename of the file. To do this, add a new migration (rails g migration <name>
) with the following content:
class AddAttachmentToNotes < ActiveRecord::Migration[6.0]
def change
add_column :notes, :attachment, :string
end
end
Don't forget to rename the class and change the column details to fit your purpose. Run it.
1) Deliver attachments through Rails
The first way is to store your Carrierwave attachments not in the default public/system
, but in a private path like storage
inside the current release. You should prefer this method when dealing with sensitive data as it allows you to deliver your attachments only to users with corresponding permissions, even if they know the URL.
For that, write a controller action that sends the attachment to authorized users. That action should be protected by
Consul
Show archive.org snapshot
or a before_action
.
class NotesController < ApplicationController
# Authorization through Consul
power :notes, map: {
...,
[:attachment] => :downloadable_attachment_notes
}, as: :note_scope
def attachment
note = note_scope.find(params[:id])
send_file note.attachment.path
end
end
downloadable_attachment_notes
is your power that regulates which users may have access to which attachments by providing a scope of corresponding notes.
Connect the action in your config/routes.rb
:
resources :notes do
member do
get 'attachment'
end
end
Your attachments will be managed by an instance of a custom uploader class that inherits from CarrierWave::Uploader::Base
. To create a boilerplate class, run rails g uploader <name>
(where name is in our example NotesAttachment
). After getting rid of method definitions that we don't need here, it looks like this:
class NotesAttachmentUploader < CarrierWave::Uploader::Base
# Choose what kind of storage to use for this uploader:
storage :file
# Override the directory where uploaded files will be stored.
# This is a sensible default for uploaders that are meant to be mounted:
def store_dir
"#{Rails.root}/storage/#{Rails.env}#{ENV['RAILS_TEST_NUMBER']}/#{model_class_name}/#{mounted_as}/#{model.id}"
end
def extension_allowlist
%w[pdf] # or e.g. %w(jpg jpeg png)
end
def content_type_allowlist
%w[application/pdf] # or e.g. [%r{image/}]
end
def file_identifier
file.identifier
end
private
def model_class_name
if model.class.respond_to?(:extended_record_base_class)
model.class.extended_record_base_class.to_s.underscore
else
model.class.to_s.underscore
end
end
end
For details on each method or possible features (versioning, processing using mini_magick
, etc.), check the official
Carrierwave ReadMe
Show archive.org snapshot
.
Important: You need to specify the Rails root in the storage path to get an absolute path that links to the private storage
folder inside of your release. If you don't prepend it, Carrierwave will automatically assume its location in the public
directory and put your uploads there. By letting this happen, all your efforts are in vain, because the requests will never hit the Rails controller you've secured so perfectly and you won't even have any further protection (like hashed paths) which makes it possible to guess further attachment URLs.
The model_class_name
method is important for
form models
Show archive.org snapshot
. Assuming you have a specific model Note::Form
that inherits from ActiveType::Record[Note]
and introduces further validations you only want to run when saving a note instance from the UI. When resolving an attachment supplied inside the form, Carrierwave will use the underlying model for determining the storage path (see the store_dir
method we've overwritten in our uploader). As the model will be Note::Form
, using model.class.to_s.underscore
in the path would result in .../note/form/attachments/1/...
. This means your uploads would be stored in this subdirectory, but when using the core model itself, they would be expected to be stored directly inside .../note/attachments/1/...
. This generates problems due to the fact that the filename is saved to the database record of your Note
, but it is not found at its expected location when using a plain Note
instance (e.g. in the show view).
This won't crash your app, but you won't be able to see or link to your attachment correctly. To fix this, ignore all specific form models and fall back to the actual extended base class which is brought to you by ActiveType's extended_record_base_class
method inside the RecordExtension::Inheritance
module. Of course, this requires you to use ActiveType in the first place. Also, don't nest classes to deeply, which you shouldn't do anyway.
Finally, mount your uploader inside your model with the name of your database column:
class Note
mount_uploader :attachment, NotesAttachmentUploader
end
You may then access this uploader using @note.attachment
. Additionally, it will define some methods on your model. For example, you may query if an attachment is available using the attachment?
method. Do not just check this using if @note.attachment
as this will always be truthy cause the object you'll get is, as mentioned, the uploader instance.
To link to the protecting controller actions, e.g. in a view:
link_to 'Download attachment', attachment_note_path(@note)
2) Store attachments in hashed paths
The method above is very secure, but it hits the framework stack everytime an attachment is downloaded. E.g. when you display an image gallery with 40 thumbnails, that would generate 40 expensive Rails requests.
An alternative is to do store Carrierwave attachments in the default public/system
, but generate the path from a hash function, e.g. attachments/519b6d87b1225ba3519b6d87b1225ba3/9/beach.jpg
. The hash function hashes the containing record id, class name and a secret that must be unique to every application you deploy. This way attachments will only be accessible to users who know the link. However, you can never take authorization away (rarely an issue with images). Do not forget to check to whom you will deliver the link in a template, if only certain roles may access it. Also, if you have directory indexes activated in Apache, you screwed up big time.
Modify your uploader like this:
class NotesAttachmentUploader < CarrierWave::Uploader::Base
# ...
def store_dir
"system/#{Rails.env}#{ENV['RAILS_TEST_NUMBER']}/#{model_class_name}/#{model.id}/#{path_hash}/#{mounted_as}"
end
private
# ...
def path_hash
secret = Rails.application.secrets.uploader_secret.presence or raise "Set uploader_secret in secrets.yml"
hash = Digest::SHA512.hexdigest("--#{Rails.env}--#{model_class_name}--#{model.id}--#{mounted_as}--#{secret}--")
# 4 bit per character, so 128 bit for the full hash. Check if still appropriate!
hash.slice(0, 32)
end
end
Remember to change the secret for every application you deploy! Changing a duplicated secret is very painful. We have a note on how to create strong secrets. The path_hash
function should then receive this secret from your secrets.yml
file. Don't hardcode it.
Carrierwave will now automatically store attachments using hashed paths, and generate URLs accordingly. You don't need a protecting controller action here, because all security lies in the hashed secret.
To link to the attachment, e.g. in a view:
link_to 'Download attachment', @note.attachment_url
After you deploy, make sure you do not get a directory index when you access http://yourpage.com/system/attachments
.
3) Using expiring URLs
There are also option to generate self-expiring URLs, which might be a good compromise between performance and safety. It is more complicated to setup though. See this card for details.