Ruby on Rails
HowtoSendFiles

A file download gateway. This is particularly useful for keeping a private file repository out of the web server’s document root and governing access per user. Assumes you are using Action Pack 0.8.0 (Rails 0.6.5) or later.

app/views/file/index.rhtml
View a list of downloads:


<p style="color: red;"><%=h @flash['error'] %></p>

<ul><strong>Permitted</strong>
<% for file in @good %>
    <li><%= link_download file %></li>
<% end %>
</ul>

<ul><strong>Denied</strong>
<% for file in @bad %>
    <li><%= link_download file %></li>
<% end %>
</ul>

app/helpers/file_helper.rb
Helper to create download links:


module FileHelper
    def self.append_features(controller) #:nodoc:
        if controller.ancestors.include? ActionController::Base
            controller.add_template_helper self
        else
            super
        end
    end

    def link_download(file)
        link_to CGI.escapeHTML(file), :action => 'download',
                :params => { 'file' => file }
    end
end

app/controllers/file_controller.rb
Controller acts as a file download gateway. Respond with 304 Not Modified unless the file has been modified since the If-Modified-Since date in the request header. Possible extension: resumable downloads by responding to the Range request header with 206 Partial Content status and Content-Range header.

In the below code, what is ‘abstract_application’? Also, this doesn’t seem to work – trying to add the MissingFile class raises an exception about an undefined defined constant ActionControllerError.

With Rails 0.9.0, the Abstract Application Controller was renamed ApplicationController, which means that you must require ‘application’ rather than ‘abstract_application’. To avoid the undefined constant errors, move the class declaration outside of the FileController class declaration, which allows the MissingFile class to see the ActionController classes.

Additionally, to protect your files from direct access, but still allow them to be downloaded, create a directory for your files outside of the web space (a /files/ directory on the same level as the /public/ directory and path your files from there. Next, using RAILS_ROOT as the root, change the bath_path method to return your file directory path (e.g. RAILS_ROOT + “/files”) and when you get the path (by calling the sanitize_file_path, pass the filename, and then base_path + (e.g. path = sanitize_file_path (@params‘file’, base_path + ‘/documents/’) ). You will need to make similar changes to any FILE reference, which will give you the filepath of the current controller. Finally, you can control file download access by manipulating the permit_file? method.


require 'application'
#require 'abstract_application'
require 'file_helper'

    class MissingFile < ActionController::ActionControllerError #:nodoc:
    end

#class FileController < AbstractApplicationController
class FileController < ApplicationController
    include FileHelper

    #class MissingFile < ActionController::ActionControllerError #:nodoc:
    #end

  protected
    def base_path
        File.dirname(__FILE__)
    end

    def permit_file?(path)
        true
        #@session['user'] and @session['user'].permit_file?(path)
    end

  public
    def index
        to_root = '../' * File.dirname(__FILE__).count(File::SEPARATOR)

        @good = [ File.basename(__FILE__) ]

        @bad  = [
            '../<< "&',
            '/tmp/mysql.sock',
            '/etc/passwd',
            "#{to_root}etc/passwd",
            '`cat /etc/passwd`',
            '../../config/database.yml',
        ]
    end

    def download
        begin
            path = sanitize_file_path(@params['file'], base_path)
            raise MissingFile, 'permission denied' unless permit_file? path

            if http_if_modified_since? path
                send_file path
            else
                render_text '', '304 Not Modified'
            end

        rescue MissingFile => e
            flash['error'] = "Download error: #{e}"
            redirect_to :action => 'index'
        end
    end

  protected
    # Safely resolve an absolute file path given a malicious filename.
    def sanitize_file_path(filename, base_path)
        # Resolve absolute path.
        path = File.expand_path("#{base_path}/#{filename}")
        logger.info("Resolving file download:  #{filename}\n => #{base_path}/#{filename}\n => #{path}") unless logger.nil?

        # Deny ./../etc/passwd and friends.
        # File must exist, be readable, and not be a directory, pipe, etc.
        raise MissingFile, "couldn't read #{filename}" unless
            path =~ /^#{File.expand_path(base_path)}/ and
            File.readable?(path) and
            File.file?(path)

        return path
    end

    # Check whether the file has been modified since the date provided
    # in the If-Modified-Since request header.
    def http_if_modified_since?(path)
        if since = @request.env['HTTP_IF_MODIFIED_SINCE']
            begin
                require 'time'
                since = Time.httpdate(since) rescue Time.parse(since)
                return since < File.mtime(path)
            rescue Exception
            end
        end
        return true
    end
end

category:Howto

Additional Material

*Quick rundown of how to use send_file and a few concerns regarding it