How to serve protected downloads with Rails

Do 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):

secdownload.secret = 'asupersecretstring' secdownload.document-root = '/a/non-public/path' secdownload.uri-prefix = '/dl/'

And here's some code for creating a link from your Rails application (from the lighttpd wiki):

def gen_sec_link(rel_path) rel_path = "/#{rel_path}" unless rel_path.starts_with?('/') s_secret = 'asupersecretstring' # Secret string uri_prefix = '/dl/' # Arbitrary download prefix timestamp = "%08x" % # 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:

$HTTP["host"] =~ "examplehost\.com" { 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" => "", "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:

$HTTP["url"] !~ "^/(dl|javascripts|stylesheets|images)" {

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.