Authenticated attachments in Rails with Paperclip

July 08, 2010

Since Thoughtbot released it a couple of years ago, Paperclip has practically become synonymous with file upload for many Rails developers. Out of the box, Paperclip saves uploaded files in your application’s public directory, which makes good sense for files that should be accessible to everyone—by storing them there, you can serve them without the additional overhead of Rails. But what if you don’t want the world at large to access uploaded files? In that case, that additional overhead becomes an asset. With a couple of additional parameters sent to Paperclip, and some modified controller code, you’ll be able to keep uploaded files safe from prying eyes.

For this tutorial, I’m going to assume you’re familiar with Paperclip, as there are plenty of tutorials already out there for using it. Refer to the Railscasts episode on Paperclip to get up to speed. It would also be a good idea if you’re familiar with Rails authentication using Devise, Authlogic, Restful Authentication, etc.—this tutorial is authentication-agnostic, but authenticating users before allowing downloads is kind of the point here.

Finally, I’m using Haml for markup and starting from scaffolds generated by the Nifty Generators gem—see my tutorial on using nifty_scaffold if interested, but this really only applies to the sample views.

A basic example

To demonstrate this, let’s imagine a simple application. We want to allow people to share documents with one another, but some of those documents may be sensitve—we thus need to make sure users are logged in before they can download them. Since we can’t protect files saved in the application’s public directory to this level, we’ll need to go beyond Paperclip’s default options. To do this, we’ll take a generated scaffold, then modify its model and controller to allow us verify a user is logged in before downloading a file.

You could also sprinkle in some authorization here—for example, I can only post an updated version of a file if I’m the one who posted it in the first place—but that’s beyond the scope of this tutorial. If you needed to do this you’d use an authorization system like Declarative Authorization, CanCan, or RESTful_ACL.

Let’s begin with the Document model. I’m adding one option to Paperclip’s standard has_attached_file here: I’m overriding the default save path—instead of my public directory, I’m saving to a directory called uploads, which gets created at my application’s root level.

app/models/document.rb
  class Document < ActiveRecord::Base
    attr_accessible :document

    has_attached_file :upload,
      :path => ":rails_root/uploads/:class/:id/:basename.:extension"
      
    # rest of the model ...
  end

Jumping over to the controller, there are two changes of note:

  1. A before_filter to verify that someone is logged in. This example uses Restful Authentication’s :login_required; replace as needed with your preferred login system’s filter.
  2. I’ve replaced the show generated by my scaffold, so that instead of returning HTML code to my browser it returns the file. I do this by using basic Ruby File methods—first I open the file at the specified document’s path, then I use send_data to push the binary data back to the web browser.

Update: As pointed out by James Bebbington, there’s a better way to return files to the browser. I’ve commented out my original method and replaced it with a more appropriate one—sorry, I let some code carry over from an older project that retrieved binary data from a database. For more information on this correct method, including using the X-SendFile server module to offload the file transfer from your app to your web server, see File Downloads Done Right>

app/controllers/documents_controller.rb
  class DocumentsController < ApplicationController

    # checks for login; replace with your login system's method
    before_filter :login_required

    def show
      @document = Document.find(params[:id])
      # file = File.new(@document.document.path, "r")
      # send_data(file.read,
      #  :filename     =>  @document.document_file_name,
      #  :type         =>  @document.document_content_type,
      #  :disposition  =>  'inline')
      
      send_file @document.document.path, :type => @document.document_content_type, :disposition => 'inline' 
    end
    
    # rest of the controller ...
  end

For completion’s sake, here is what my index view looks like for the Documents controller. Since I’m using a basic RESTful scaffold, this is pretty straightforward. I’ve added a couple of extra columns to display the content type and file size of each document.

app/views/documents/index.html.haml
  %table
    %tr
      %th Name
      %th Type
      %th Size
    - documents.each do |document|
      %tr
        %td= link_to h(document.document_file_name), document
        %td= document.document_content_type
        %td= number_to_human_size(document.document_file_size)
        %td= link_to 'Replace', edit_document_path(document)
        %td= link_to 'Delete', document_path(document), :method => :delete, :confirm => 'Are you sure?'

At this point you could delete the show.html.haml view file; it’s not needed given how we’ve modified the controller. Or you could hold off on deleting it after you read the section “A slightly fancier example” below.

Finally, you’ll want to tell your version control system to ignore files that get added to your application’s uploads directory. Here’s how to add it to a .gitignore file:

.gitignore.rb
  uploads/*

A slightly fancier example

So far, we’ve used standard Rails RESTful scaffolding techniques to serve up files. This works because we don’t otherwise need the show method to return anything to the browser. If we did need to use it—say, to display more information about the document, or add an interface for our users to add comments, we’d need to change a couple of things.

First, in the model, we need to change the URL used to download the file. I’m going to point it to a download method that will be in documents_controller.rb:

app/models/document.rb
  class Document < ActiveRecord::Base
    attr_accessible :document, :description

    has_attached_file :upload,
      :path => ":rails_root/uploads/:class/:id/:basename.:extension",
      :url => "/documents/:id/download"
      
    # rest of the model ...
  end

Then we need to add that new download method to the controller. Note this is the same code we were using earlier in show; now, since we need show to do typical show-type stuff, we just need to move this code elsewhere.

As noted above, my original controller code worked but was not optimal. The old code is commented out here with the correct send_file method now being used.

app/controllers/documents_controller.rb
  class DocumentsController < ApplicationController

    # checks for login; replace with your login system's method
    before_filter :login_required

    # generated CRUD actions ...

    def download
      @document = Document.find(params[:id])
      # file = File.new(@document.document.path, "r")
      # send_data(file.read,
      #  :filename     =>  @document.document_file_name,
      #  :type         =>  @document.document_content_type,
      #  :disposition  =>  'inline') 
       
      send_file @document.document.path, :type => @document.document_content_type, :disposition => 'inline'
    end
  end

Don’t forget to add download as a member route to documents in your routes.rb file.

Finally, here’s what the show view might look like—let’s just add a simple Download link:

app/views/documents/show.html.haml
  - title h(@document.document.name)

  %p
    %strong Description:
    =h @document.description
    
  %p
    = link_to "Download File", @document.document.url

That’s all there is to it—now, instead of exposing uploaded files to the world by sticking them in your public directory, you’ve got some extra Rails logic to make sure only the right people can access them.

Rails testing made simple

Learn to test Rails apps the way I learned, building up tests step-by-step, in Everyday Rails Testing with RSpec. Expanded to include exclusive content and a complete sample Rails application. Learn more »

Also available on Amazon.com.

blog comments powered by Disqus