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.


%{color:#999}app/views/file/index.rhtml%
View a list of downloads:

<pre><code>
<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>
</code></pre>


%{color:#999}app/helpers/file_helper.rb%
Helper to create download links:

<pre><code>
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
</code></pre>


%{color:#999}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.

<pre><code>
require 'abstract_application'
require 'file_helper'

class \FileController < AbstractApplicationController
    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['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
</code></pre>

category:Howto
