Using send_file to download a file from Amazon S3?

42,529

Solution 1

In order to send a file from your web server,

  • you need to download it from S3 (see @nzajt's answer) or

  • you can redirect_to @attachment.file.expiring_url(10)

Solution 2

You can also use send_data.

I like this option because you have better control. You are not sending users to s3, which might be confusing to some users.

I would just add a download method to the AttachmentsController

def download
  data = open("https://s3.amazonaws.com/PATTH TO YOUR FILE") 
  send_data data.read, filename: "NAME YOU WANT.pdf", type: "application/pdf", disposition: 'inline', stream: 'true', buffer_size: '4096' 
end 

and add the route

get "attachments/download"

Solution 3

Keep Things Simple For The User

I think the best way to handle this is using an expiring S3 url. The other methods have the following issues:

  • The file downloads to the server first and then to the user.
  • Using send_data doesn't produce the expected "browser download".
  • Ties up the Ruby process.
  • Requires an additional download controller action.

My implementation looks like this:

In your attachment.rb

def download_url
  S3 = AWS::S3.new.buckets[ 'bucket_name' ] # This can be done elsewhere as well,
                                            # e.g config/environments/development.rb
  url_options = { 
    expires_in:                   60.minutes, 
    use_ssl:                      true, 
    response_content_disposition: "attachment; filename=\"#{attachment_file_name}\""
  }

  S3.objects[ self.path ].url_for( :read, url_options ).to_s
end

In your views

<%= link_to 'Download Avicii by Avicii', attachment.download_url %>

That's it.


If you still wanted to keep your download action for some reason then just use this:

In your attachments_controller.rb

def download
  redirect_to @attachment.download_url
end

Thanks to guilleva for his guidance.

Solution 4

I have just migrated my public/system folder to Amazon S3. Solutions above help but my app accepts different kinds of documents. So if you need the same behavior, this helps for me:

@document = DriveDocument.where(id: params[:id])
if @document.present?
  @document.track_downloads(current_user) if current_user
  data = open(@document.attachment.expiring_url)
  send_data data.read, filename: @document.attachment_file_name, type: @document.attachment_content_type, disposition: 'attachment'
end

The file is being saved in the attachment field of DriveDocument object. I hope this helps.

Solution 5

The following is what ended up working well for me. Getting the raw data from the S3 object and then using send_data to pass that on to the browser.

Using the aws-sdk gem documentation found here http://docs.aws.amazon.com/AWSRubySDK/latest/AWS/S3/S3Object.html

full controller method

def download
  AWS.config({
    access_key_id: "SECRET_KEY",
    secret_access_key: "SECRET_ACCESS_KEY"
  })

  send_data( 
    AWS::S3.new.buckets["S3_BUCKET"].objects["FILENAME"].read, {
      filename: "NAME_YOUR_FILE.pdf", 
      type: "application/pdf", 
      disposition: 'attachment', 
      stream: 'true', 
      buffer_size: '4096'
    }
  )
end
Share:
42,529
David Tuite
Author by

David Tuite

Founder and CEO of Developer Portal company roadie.io

Updated on July 30, 2020

Comments

  • David Tuite
    David Tuite almost 4 years

    I have a download link in my app from which users should be able to download files which are stored on s3. These files will be publicly accessible on urls which look something like

    https://s3.amazonaws.com/:bucket_name/:path/:to/:file.png
    

    The download link hits an action in my controller:

    class AttachmentsController < ApplicationController
      def show
        @attachment = Attachment.find(params[:id])
        send_file(@attachment.file.url, disposition: 'attachment')
      end
    end
    

    But I get the following error when I try to download a file:

    ActionController::MissingFile in AttachmentsController#show
    
    Cannot read file https://s3.amazonaws.com/:bucket_name/:path/:to/:file.png
    Rails.root: /Users/user/dev/rails/print
    
    Application Trace | Framework Trace | Full Trace
    app/controllers/attachments_controller.rb:9:in `show'
    

    The file definitely exists and is publicly accessible at the url in the error message.

    How do I allow users to download S3 files?

  • mehulkar
    mehulkar over 11 years
    how can I use this with non-public files on S3?
  • Homan
    Homan almost 11 years
    So in this solution the open method does not download the entire file first? And send_data can stream the file from amazon to the user without the user ever knowing the real s3 file path?
  • dgilperez
    dgilperez over 10 years
    @MehulKar in that case you need to use @attachment.file.expiring_url
  • equivalent8
    equivalent8 about 10 years
    Definitely this way, however it seems that the stream & buffer_size options are not needed github.com/rails/rails/blob/master/actionpack/lib/… api.rubyonrails.org/classes/ActionController/DataStreaming.h‌​tml
  • equivalent8
    equivalent8 about 10 years
    api.rubyonrails.org/classes/ActionController/… so just send_data data.read, type: image.content_type, disposition: 'inline' to render in browser
  • Joshua Pinter
    Joshua Pinter about 10 years
    For large files, this can be confusing for the user who waits for the server to download the file first and then stream it to the user. Plus, it takes up to twice as long then just having the user download directly from S3.
  • Joshua Pinter
    Joshua Pinter about 10 years
    This has to download the file to the server before sending to the user, correct?
  • David Morrow
    David Morrow about 10 years
    Correct, may not be an option depending on how large your files are. In my case they are small PDFs and this was acceptable.
  • BigRon
    BigRon about 9 years
    Does this download the whole bucket? I have a similar method where I am downloading individual bucket objects based on their key.
  • Joshua Pinter
    Joshua Pinter about 9 years
    @BigRon No, just the individual object in the bucket. But looking at my code snippets again, I think I've sliced out an important part! Thanks for pointing that out!
  • Joshua Pinter
    Joshua Pinter about 9 years
    @BigRon Take a look at that now. Added the piece where you use the S3 bucket to actually get the object.
  • BigRon
    BigRon about 9 years
    nice correction, that looks right. I'll have to try your use of self.path. It looks like a more simple usage than my current method
  • Joshua Pinter
    Joshua Pinter about 9 years
    @BigRon Let me know if self.path works for you or not because we actually have a check that strips out the leading slash but I wasn't sure if that was necessary in all cases or if we have something special in our case: self.path.chr == '/' ? self.path[1..-1] : self.path )
  • Joshua Pinter
    Joshua Pinter over 8 years
    BTW, I edited this to use double quotes around the header filename. We ran into an issue where some versions of Internet Explorer (of course) was including the single quotes as part of the downloaded filename. Using double quotes seemed to fix the issue.
  • ArnoHolo
    ArnoHolo about 8 years
    WIth a carrierwave+S3 file, I made it work like this : article = Article.find params[:id] file_data = open(article.file.url) send_data file_data.read, filename: article.filename, type: article.file.content_type, disposition: 'attachment'
  • Dennis
    Dennis almost 8 years
    Note when accessing private files on S3 as shown here, the S3 URL will contain your secret AWS credentials so the browser can make an authenticated request. It won't be obvious if the download works and the user flow stays on your page, but when it doesn't work (for example the file doesn't exist) then the error page on S3 will have the credentials visible on the URL. This is a pretty big security risk.
  • Dennis
    Dennis almost 8 years
    Worth pointing out in addition to @JoshPinter's observation of it being slower (because the data goes through an intermediary instead of direct), it also puts extra load on your server and is blocking, though you can offload it to a background task. Downloading the file directly from S3 is more efficient plus S3 has decent error pages - but your AWS credentials will be visible when accessing private files.
  • jsurf
    jsurf over 7 years
    How can I download the file inline? My browser opens files in a new tab. I'm using Heroku + Passenger, maybe I have to add an .htaccess file to specify inline downloads?
  • maikovich
    maikovich almost 7 years
    This is really not a good idea. You store all the file data in memory before you pass it on. Depending on the file size, this can get you ending up with a "No space left on device" exception.
  • kittyminky
    kittyminky almost 7 years
    redirecting to the url doesn't automatically download the file, it just brings you to a browser view of it where you can then download it
  • nzajt
    nzajt almost 7 years
    For us redirecting to S3 isn't a good option, the files we send are not public, so we only want certain users to download said files. I have been doing this for years and never run into a disk space issue, but it could happen if you are using a small server. I also like controlling the user experience, so the download doesn't come from some random S3 or cloudfront url.
  • Maxence
    Maxence over 6 years
    Hi Joshua, I am using paperclip with files as private. it works all good. Thoguh on certain occasions i would like the user be able to download the file instead of opening it in the browser tab. Is there a way to add response_content_disposition in the controller instead of the model ?
  • Joshua Pinter
    Joshua Pinter over 6 years
    @Maxence What if you just add a param for the models' download_url method that allows you to change how it's dealt with?
  • Meyer
    Meyer over 5 years
    @HoloHokkaido Your comment is what finally worked for me.
  • Rajan
    Rajan almost 4 years
    When possible, please make an effort to provide additional explanation instead of just code. Such answers tend to be more useful as they help members of the community and especially new developers better understand the reasoning of the solution, and can help prevent the need to address follow-up questions.
  • Rajan
    Rajan almost 4 years
    When possible, please make an effort to provide additional explanation instead of just code. Such answers tend to be more useful as they help members of the community and especially new developers better understand the reasoning of the solution, and can help prevent the need to address follow-up questions.
  • Sampson Crowley
    Sampson Crowley almost 3 years
    I know this post is old, but because this comment is referenced multiple times: no, expiring urls do not leak your credentials. they show your access id which is not a secret, and a presigned hash for authentication. if presigned urls gave out your credentials, people could just recreate urls over and over again when the url expired