Rails#send_file + Nginx X-Accel-Redirect

Sometimes you may need to serve some static files (CSV, PDF, XLS etc) to your users, but only after they have logged in. Obviously you can’t just keep the static file in your public folder as anyone could just use the URL to download files.

One possible solution for protected downloads is to just use the #send_file method provided by Rack to send a non-public file to the user, but serving static files with your app server (Unicorn, Mongrel, Thin etc) is a bad idea as it’s really inefficient. The best approach is to allow the app server to handle the authentication/authorization and then hand the actual downloading to your web server (Nginx, Apache, Lighttpd etc).

In Lighttpd server it can be done by returning X-Sendfile header from your script. Nginx have its own implementation of such idea using X-Accel-Redirect header.

The need for X-Accel-Redirect:

  • To deliver large files.
  • For those files to not be available to the public
  • Would be able to free some resources on server while nginx will handle all slow requests to dynamic content

In this article I will assume that the site is located in /home/kranjith/sites/projects/blog directory and there are some static files (like CSV, PDF, XLS etc) located in /home/kranjith/sites/projects/blog/uploads directory.

First of all, lets take a look at our nginx configuration:

# Nginx X-Accel-Redirect configuration for Rails and Unicorn 
upstream unicorn_blog_server {
    server unix:/home/kranjith/sites/projects/blog/tmp/sockets/unicorn.sock
    fail_timeout=0;
}

server {
    listen       80;
    server_name  example.org;
 
    root /home/kranjith/sites/projects/blog/public;
    
    location /downloads {
      internal;
      alias /home/kranjith/sites/projects/blog/uploads;
    }
    
    location / {
       try_files $uri @blog;
    }

    location @blog {
      proxy_redirect    off;
      
      proxy_set_header  Host              $http_host;
      proxy_set_header  X-Real-IP         $remote_addr;
      proxy_set_header  X-Forwarded-For   $proxy_add_x_forwarded_for;
 
      proxy_set_header  X-Sendfile-Type   X-Accel-Redirect;
      proxy_set_header  X-Accel-Mapping   /home/kranjith/sites/projects/blog/uploads/=/downloads/;
      
      proxy_pass http://unicorn_blog_server;
    }
}

The internal keyword for the /downloads location prevents the uploads folder from being publicly accessible.

Next you need to ensure that Rails knows what server you are using

In config/environments/production.rb file.

# Specifies the header that your server uses for sending files.
config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx

In DownloadsController, just do whatever authorization you need to, then use #send_file to serve the file to the user:

class DownloadsController < ApplicationController
  load_and_authorize_resource

  def show
    send_file( Rails.root.to_s + @uploaded_file.file.url, type: @uploaded_file.content_type, filename: @uploaded_file.filename, dispostion: "inline", status: 200, stream: true, x_sendfile: true )   
  end   
end

I am using CarrierWave to upload files from Rails applications. In config/initializers/carrierwave.rb

CarrierWave.configure do |config|
  config.storage = :file
  config.root = Rails.root
  config.store_dir = "uploads"
end

And that’s it! With described approach we are able to create very flexible and extremely performance systems for file distribution!

Now I’m going to run through a specific example of downloading a file.

1.Browser makes a request for a file

# HTTP Headers
GET /downloads/SecretSquirrel.zip

2.Nginx receives this request. It adds on a header with configuration data that will be required by rails.

# Example HTTP Headers with additional header added by nginx
GET /downloads/SecretSquirrel.zip
X-Accel-Mapping:   /home/kranjith/sites/projects/blog/uploads/=/downloads/

3.Nginx passes the request onto Rails and it invokes the relevant controller.

4.The controller makes its authorization checks and calls send_file. Use the absolute path to the file.

# controller code (e.g. app/controllers/downloads_controller.rb)
send_file('/home/kranjith/sites/projects/blog/uploads/SecretSquirrel.zip')

5.Rails (Rack to be precise) then decides what to with the file. Rails knows what server we are using (from config/environments/production.rb). Instead of using the file as the body of the request, it will add a header to the response. It uses the X-Accel-Mapping that nginx added earlier to change the file path.

# HTTP Response
HTTP/1.1 200 OK
X-Accel-Redirect: /downloads/SecretSquirrel.zip
Content-Type: application/octet-stream
Content-length: ...
Content-Disposition: attachment; filename="SecretSquirrel.zip"
<empty body>

6.Nginx receives this header from rails and interprets it. It finds the location directive and reverses the changes to the path that rails made in step 5.

# HTTP Response
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: ...
Content-Disposition: attachment; filename="SecretSquirrel.zip"
<contents of /home/kranjith/sites/projects/blog/uploads/SecretSquirrel.zip>

7.Browser receives the file as if it was a normal download.

Comments