How to serve protected downloads with Rails
24 Nov 2006Do you have a site where you sell downloads? Perhaps you sell e-books, screencasts, or MP3s. Would you like a way to protect these downloads by using your Rails application to authenticate users before they can download the file, but you don’t want to tie up a Rails process to actually serve the file? Lighttpd’s mod_secdownload was built specifically for this situation, but there’s a little gotcha for those of you who are also using mod_proxy to pass traffic to mongrel…
First, here’s a quick summary of how to use mod_secdownload to offload file serving from your Rails application while still having your application verify that someone can access the files. Here’s the lighttpd configuration (don’t forget to load mod_secdownload via server.modules):
And here’s some code for creating a link from your Rails application (from the lighttpd wiki):
rel_path = "/#{rel_path}" unless rel_path.starts_with?(’/’)
s_secret = ‘asupersecretstring’ # Secret string
uri_prefix = ’/dl/’ # Arbitrary download prefix
timestamp = "%08x" % Time.now.to_i # Timestamp, to hex
token = MD5::md5(s_s + rel_path + timestamp).to_s # Token Creation
’%s%s/%s%s’ % [uri_prefix, token, timestamp, rel_path] # Return the properly formatted string
end
The relative path here is relative from secdownload.document-root, so for the file /a/non-public/path/seo-secrets.pdf, rel_path would be /seo-secrets.pdf.
In my application I have a model associated with files that can be purchased, and I have an instance method on that class that generates the mod_secdownload link. My view links to a controller action that redirects the user to the generated link. This prevents a download link from expiring while the user is viewing the page. This view can only be accessed once the user has logged in, and the view contains links only to the files that have been purchased. Similarly, the controller uses scoping to ensure has purchased the file that he is attempting to download.
Now for the gotcha. You’ll notice from the configuration for the module that you specify a virtual root path that is used in generating the URLs. In my case, I chose ’/dl’. The problem comes when you are also using mod_proxy like so:
server.document-root = "/var/rails/exampleapp/current/public/"
server.indexfiles = ( "index.html", "dispatch.fcgi" )
proxy.balance = "fair"
$HTTP["url"] !~ "/(javascripts|stylesheets|images)" {
proxy.server = ( "/" => (
( "host" => "127.0.0.1", "port" => 3000 )
) )
}
}
This is a typical lighttpd configuration for serving a Rails app from mongrel. The key part to notice is the proxy configuation, which passes all requests on to mongrel except for those for the javascripts, stylesheets, and images paths in the public path. The problem with this configuration is that traffic destined for the fake ’/dl’ path also gets sent to mongrel, which passes it on to the Rails application, which has no idea how to handle it (unless you happen to have a ‘dl’ controller). So, all you have to do is add ‘dl’ to the list of excluded URLs in the proxy configuration:
Now requests for ’/dl/...’ will get skipped by the proxy and consumed by mod_secdownload.
This is an excellent way to serve up content without typing up your Rails application and without just giving it away.







with the next version of Lightty you can use the “x-send-file” header to specify a file from disk to be served up:
http://blog.lighttpd.net/articles/2006/07/22/mod_proxy_core-got-x-sendfile-support
and with nginx you can specify “x-accel-redirect” header to accomplish the same thing:
http://blog.kovyrin.net/2006/11/01/nginx-x-accel-redirect-php-rails/
I’ve got mod_secdownload running on one project but I’m definitely thinking about switching to this as it makes things much simpler.
Wouldn’t it be simpler to use send_file and an authenticated action? Move the file(s) out of your Rails app’s root and then use send_file to send them to the user. That’s how I do my eBooks at least. :)
@Tim: I’m looking forward to the next version of lighty because of conveniences like x-send-file. It’s definitely a simpler solution.
@Jeremy: It is simpler to use send_file, but the problem is that it ties up a Rails process. This isn’t a big deal if you are sending small files, but the bigger the files get the worse of a problem this becomes.
Another alternative would be to put the downloadables onto Amazon S3 under time-expiring URLs with hashed signatures. Although in that situation you would have to pay for the bandwidth through S3, of course.