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 ; )
