Encrypting your files with Rails – Part II

This is the second post in a two part series (part 1 is here) on adding encryption to attachment_fu for Rails applications.

What about making the file available for download? AVOIDING THE ISSUE OF SCALABILITY FOR A MOMENT (since sendfile is not the right way to serve files from Rails), we want to use a variant of sendfile to do the decryption and send the file. Here’s a modified version of send_file that uses an extra hash parameter (acme) to decrypt if provided:

module ActionController
   module Streaming

   def send_file_x(path, options = {}) #:doc:
      raise MissingFile, "Cannot read file #{path}" unless File.file?(path) and File.readable?(path)

      options[:length]   ||= File.size(path)
      options[:filename] ||= File.basename(path)
      send_file_headers! options

      @performed_render = false
      logger.warn("Sending file #{path}")
      if options[:stream]
        render :status => options[:status], :text => Proc.new { |response, output|
          logger.info "Streaming file #{path}" unless logger.nil?
          len = options[:buffer_size] || 4096
          if options[:acme]
            c = OpenSSL::Cipher::Cipher.new("aes-256-cbc")
            c.decrypt
            c.key = key = options[:acme]
            c.iv = iv = Digest::SHA1.hexdigest("OneFishTwoFish")
          end
          File.open(path, 'rb') do |file|
            while buf = file.read(len)
              if options[:acme]
                output.write(c.update(buf))
              else
                output.write(buf)
              end
            end
          end
        }
      else
        logger.info "Sending file #{path}" unless logger.nil?
        File.open(path, 'rb') { |file| render :status => options[:status], :text => file.read }
      end
    end
  end
end

The code could be made more efficient by not performing the options[:acme] test each time a buffer is written. Our controller action that downloads a file would call it like so:

send_file_x(@file_item.stored_filename,
  :filename      =>  @file_item.filename,
  :type             =>  @file_item.content_type,
  :disposition  =>  'attachment',
  :stream    =>  'true',
  :buffer_size  =>  4096,
  :acme => @file_item.acme)

In a production environment, send_file consumes to many server resources – the rails application, and method used to service it (FastCGI, Mongrel, etc.) are tied up serving the file.

It’s more likely the case that the rails application will be behind a reverse proxy like nginx; in that case, a directive is sent to the server to provide the file (usually through an HTTP header). For nginx, serving a non-encrypted, static file would be done by sending a header with the location of a file:

if defined?(NGINX_FOR_DOWNLOAD) && NGINX_FOR_DOWNLOAD
  # code omitted – set up file name and path
  response.headers['X-Accel-Redirect'] = NGINX_PATH_FOR_ _DOWNLOAD + path
  response.headers['Content-Type'] = file_item.content_type
  render :nothing=>true;
else
  send_file_x(File.join(RAILS_ROOT, FILE_STORAGE_PATH, path_parts, file_item.filename),
      :type         => file_item.content_type,
      :disposition  => 'attachment',
      :stream    => 'true',
      :buffer_size  => 4096,
      :acme      => nil,
      :encoding     => 'utf8',
      :filename     => URI.encode(file_item.filename))
End

For more information on nginx and rails, learn more about NginxXSendfile.

To perform a similar feat of decrypting and sending a file for nginx, a new module would need to be written for nginx that takes an additional header variable ‘X-Accel-Redirect-Key’ and uses that as the key to send the file, decrypting as it goes.