send_file - a link to download a file

2006-07-24   [ 15 comments ]

Well I have alot to talk about, but instead of putting all my recent Rails experience into one large post - I will instead segment it into multiple small and highly focused posts. You can expect to see one every couple of days as I cover the things I have learned from converting a previous PHP site into a Rails hatchling.

Today will be the first of said posts, and the topic will be something that may be natural to most RoR enthusiasts - but to this Rails newb, it was nothing but a frustrating 2 hours of my life.

In the pre-rails era if I wanted to allow someone to download a file I could easily make a hyperlink that pointed right to the file. Once they clicked on the link the server would prompt a download and begin the transfer. Easy enough. However in the Rails world things are not always as easy at first - thus enter the dreaded error I faced for some time:

Routing Error

Recognition failed for "/AMV/Azumanga%20Inc.mpg"

Yep, a routing error that was coming from a link I was trying to make available to the public. This link was made to download a file on one of my other sites. The link itself was simple - an href="/AMV/Az...".

But Rails decided that it should route the request to some controller named AMV and some action named Azumanga... (as I suppose it should). The problem then was that anyone that clicked on the download link was not allowed to download the file, instead Rails bombed out and said Routing Error!

So, how did I fix this little problem? Well it took a lot of digging on my part (and one of my friends was also looking into it) to find an answer. Quite simply I had do something I wasn't at first remotely pleased about:

def a new action in your controller that will manage the access to your files

How exactly do you do that? Well after referencing the Rails API I came across this little function:

send_file(path, options = {})

* :filename - suggests a filename for the browser to use. Defaults to File.basename(path).
* :type - specifies an HTTP content type. Defaults to ‘application/octet-stream’.
* :disposition - specifies whether the file will be shown inline or downloaded. Valid values are ‘inline’ and ‘attachment’ (default).
* :stream - whether to send the file to the user agent as it is read (true) or to read the entire file before sending (false). Defaults to true.
* :buffer_size - specifies size (in bytes) of the buffer used to stream the file. Defaults to 4096.

So, after a bit of tinkering I setup my the links in my view like so:

<a href="/public/send_amv?amv=<%= amv.video_title %>">Download HERE</a>

The href is clearly pointing to /public which would be the public_controller.rb - then it is calling the send_amv action within that controller. My link is sending a GET request with the variable "amv" = --the_contents_of_my_ruby_variable. Pretty simple, right?

So now the action in my controller was written exactly like:

def send_amv
   send_file('/AMV/' << params[:amv], :filename => params[:amv])
end

So once you click on the link that calls the action send_amv in the public controller - the action calls a built in rails function called send_file and takes the path_to_file as first parameter, and the filename as second.

Sounds great, however it didn't work as I thought it would right off the bat. So let me share the error message I received:

ActionController::MissingFile in PublicController#send_amv

Cannot read file /AMV/AzumangaInc.avi

This error was a bit easy to diagnose, rails didn't like the absolute path from the root public directory. Instead all I had to do was change the '/AMV/' part to just 'AMV/' -- (removed the initial forward slash).

After that was changed I did stumble across another problem - Every time I clicked the link I got to listen to my server (which sits right next to me) churn away as if it was busy grinding peanuts but it never updated my screen until... this appeared:

Application error

Rails application failed to start properly

C'mon all this trouble just to download a file - which in any other framework may have taken me a whole 20 seconds to right the link (if that!)?

I decided to consult the API a bit more and re-read the options for the send_file function. One option caught my eye and I decided to change it first. I was glad to discover that I chose correctly - and by changing its value my server wasted no time in prompting me to download the file each time I clicked the link. So what was this server-breaking option?

send_file('AMV/' << params[:amv], :filename => params[:amv], :stream => false)

:stream is set to true by default, and by changing that to false my server didn't waste time trying to stream the file - instead it said "here take this" and all was well.

The one thing you should be aware of with this method is the possibility of a slick malicious mind trying to use this action to access any file on your system.

Consider if they created a link to this action that read something like so: /public/send_amv?amv=../../config/database.yml

THIS WILL ALLOW ANYONE TO DOWNLOAD ANY FILE ON YOUR SYSTEM - EVEN IF THE FILE IS READ-ONLY FOR ROOT!

Can I make that any more clear? Believe me I tried to access some files in folders across my system - and I did, without one problem. This is not a good thing. You must protect against it. In my case I already had the list of valid files in the database, so I test to make certain that the requested file is listed in the database, otherwise they get nothing. However, this is still a bit unsettling just thinking about the possibility if someone did not have this secure. Here is my brief code to stop a theif:

def send_amv
@is_valid = Amv.count(:conditions => ["video_title = ?", params[:amv]])
if @is_valid > 0
  send_file(..send file stuff..)
else
   flash[:notice] = "Your attempted access to a non-public file has been recorded"
   redirect_to :action => "index"
end
end

If there is anything I learned while working with Ruby on Rails - it is - don't expect to do anything exactly the way you are used to. I can see promise in this method, perhaps quite a bit of value (prevent hot linking to your files). But unfortunately I felt like I wasted a bit of my own time just trying to find out what to do, and had I not tested I would have left my server wide open to any mediocre hack. Hopefully you won't have to do the same or worry about security - alas the job is never done. Happy coding ; )

:author => "Charles Abbott"
Converting to Ruby on Rails
 


AJ said:

The file access issue is a standard CGI type issue. Look for any article or book on cgi best practices and you'll find some examples of how to prevent random file access. Probably worth looking at some of the other security issues with CGI as well - rails prevents some of them with its framework, but probably not all of them

2006-08-11 08:50:26 UTC

Charles said:

Thanks for the comment AJ. I'm not a security expert yet, but I love to learn - so I'll definitely take your advice.

2006-08-12 16:42:38 UTC

Ken said:

I read this after coding a download action in an app I am currently writting, and I wouldn't have even thought of going through the trouble you did. For me it was simple, my view looked something like this:

<%= link_to "download", :action=>'download', :id=>@file_detail.id %>

in my controller I simply did something like this:

@file_detail = FileDetail.find_by_id(params[:id])
send_file(@file_detail.filename) and return if @file_detail
redirect_to :action=>'index'

2006-08-18 18:13:48 UTC

Charles said:

Thanks for the comment Ken. You are correct, your code is much simpler than my own, and likewise less troublesome.

2006-08-18 20:23:12 UTC

Bud said:

Your comments were very useful to me. Here's my solution if you are interested. Files are assumed to be in rails_root/public/icons. Thanks.
class iconController < ApplicationController
# Catch the UnkownAction error and use Action name as icon file name to send
def rescue_action(exception)
iconfilename = "icons/"+action_name
if exception.to_s[0,9] == "No action" && File.exist?(iconfilename)
send_file(iconfilename, :stream => false, :type =>'image/png')
else
render :nothing => true
end
end
end

2006-09-02 17:24:30 UTC

Charles said:

Hey Bud, very interesting take on this - using this action as a rescue for unroutable actions. Thanks for leaving this snippet - perhaps others can use it too.

2006-09-03 18:31:56 UTC

Robert said:

There is another approach which is very simple. Just put your files into your public directory, and reference them with a normal href. For example, you could place your files to be downloaded into
public/downloads
Then,
Download HERE
will download your file without going through rails at all. Security is handled by Apache. Rails will not be bogged down with big file transfers.

So, I'm wondering why you want to use rails at all for your downloads?

2006-09-22 22:30:01 UTC

Charles said:

Hi Robert, as quoted from my post "The link itself was simple - an href=/AMV/Az..."

I did in fact use an href just like in any other setting, and my file was in the public directory - however rails reacted as if it was trying to route to a particular action.

I cannot remember if I was already running Lighttpd behind apache at this time, or if I was only using apache2. However, the only way for downloads to work in this situation was to use the send_file command.

2006-09-24 11:39:47 UTC

Charles said:

Additionally, this may have been a quirk on a particular release of rails (if possible?) - so depending on your setup you may not have run across this.

I would have loved to use a simple href at the time (in fact, when I was porting over the PHP site, alot of the download pages were static html, and the hrefs is where I noticed the problem initially - something I never expected to see problems with)

2006-09-24 11:45:42 UTC

Bud said:

My experience was similar to Charles'. Rails tried to handle the plain href. Perhaps assigning the file href's to a separate port would have worked, but the files I wanted to transfer were built dynamically by rails and it makes sense to keep the downloads within the application.

2006-09-25 10:22:58 UTC

Charles said:

Good point - dynamically generated files would be better served by send_file or send_data. I used send_data when I generate my FDF files.

2006-09-25 22:50:23 UTC

Matte said:

Great solution!! I used it in one of my application!

thanks!

2007-01-19 06:52:00 UTC

Charles said:

Glad it helped! Send_file and send_data are both really useful...

2007-01-19 14:06:15 UTC

Tony said:

So I've got files in a public directory and using a complete path - http://serve.addr/.../ - as the href - apache is serving up the file no problem. the thing i'd like to do is intercept the browser download dialog to find out what link was clicked and then track the dialog to see if the user completed the action or cancelled. if the user completed the download action - then i want to log that in our database. so, does anyone know how to access the download dialog that pops up when a user clicks a link to a file?

2007-08-23 04:06:20 UTC

Charles said:

Tony, I am not certain this is possible. You can tell if they clicked the link - but I dont believe you can change the download dialog.

2007-11-26 22:18:55 UTC

What?

Who?              Link?



Frameworks Good or Bad?   :date => "2007-10-06"
Where is ForTheCode.com Going?   :date => "2007-09-23"
Refactoring - Vital to Software Development   :date => "2007-09-23"
Mongrel Cluster a quick note - and extra notes   :date => "2007-05-20"
Linux Mongrel and Rails   :date => "2007-05-15"
form_remote_tag revisited   :date => "2007-01-07"
How To: Ubuntu 6.10 Edgy on Rails part 3   :date => "2006-12-30"
How To: Ubuntu 6.10 Edgy on Rails part 2   :date => "2006-12-24"
How To: Ubuntu 6.10 Edgy on Rails   :date => "2006-12-22"
verify ... 5.times do cycle   :date => "2006-09-25"
country_select, country_options_for_select, mail_to   :date => "2006-09-05"
Generate and Send Email in Rails   :date => "2006-08-26"
FDF Model, gsub, and send_data   :date => "2006-08-18"
Active Directory Authentication with acts_as_authenticated   :date => "2006-08-17"
Apache2 proxy with Lighttpd - FastCGI for Rails   :date => "2006-08-08"
reverse! && a simple file Upload Class   :date => "2006-07-29"
send_file - a link to download a file   :date => "2006-07-24"
Environments (production, development, test) and cache_pages   :date => "2006-07-04"
.class .methods .instance_variables   :date => "2006-06-14"
select_tag :multiple => true   :date => "2006-06-01"
FileUtils, action_controller rescues   :date => "2006-05-20"
file_field_tag, File.size, File.path, FileUtils.mv   :date => "2006-05-15"
javascript_include_tag, stylesheet_link_tag   :date => "2006-05-02"
submit_to_remote, form_remote_tag, script.aculo.us   :date => "2006-04-30"
periodically_call_remote, simple_format   :date => "2006-04-26"
observe_field - Ajax!   :date => "2006-04-21"
h method, TextHelper, sanitize(), strip_tags()   :date => "2006-04-15"
Rails API :My API [.count(), link_to, text_area :size]   :date => "2006-04-13"
Rails - HTML Select Tag   :date => "2006-04-05"
Pruning Old Sessions   :date => "2006-03-21"
If Elsif Else, and Searching Too!   :date => "2006-03-17"
SHA1 - A quick update   :date => "2006-03-15"
Initialized! good, Authorized? Great! part 2   :date => "2006-03-11"
Initialized! good, Authorized? Great!   :date => "2006-03-08"
Forms and Routing in RoR   :date => "2006-03-06"
My First RoR Post !   :date => "2006-03-05"