64

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?

David Tuite
  • 22,258
  • 25
  • 106
  • 176

7 Answers7

92

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"
wpp
  • 7,093
  • 4
  • 33
  • 65
nzajt
  • 1,937
  • 1
  • 15
  • 16
  • 4
    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? – Homan Jul 24 '13 at 18:28
  • Definitely this way, however it seems that the `stream` & `buffer_size` options are not needed https://github.com/rails/rails/blob/master/actionpack/lib/action_controller/metal/data_streaming.rb http://api.rubyonrails.org/classes/ActionController/DataStreaming.html – equivalent8 Mar 07 '14 at 14:01
  • http://api.rubyonrails.org/classes/ActionController/DataStreaming.html#method-i-send_data so just `send_data data.read, type: image.content_type, disposition: 'inline'` to render in browser – equivalent8 Mar 07 '14 at 14:13
  • 4
    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 Apr 18 '14 at 19:53
  • 2
    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'``` – ArnoHolo Apr 27 '16 at 13:32
  • 3
    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](http://stackoverflow.com/questions/12277971/using-send-file-to-download-a-file-from-amazon-s3#comment63642384_12278021). – Dennis Jun 29 '16 at 15:52
  • 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. – maikovich Jun 13 '17 at 17:19
  • 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. – nzajt Jun 19 '17 at 18:20
  • @HoloHokkaido Your comment is what finally worked for me. – Meyer Nov 20 '18 at 08:28
38

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.

Joshua Pinter
  • 45,245
  • 23
  • 243
  • 245
  • Does this download the whole bucket? I have a similar method where I am downloading individual bucket objects based on their key. – BigRon Apr 09 '15 at 18:08
  • @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 Apr 10 '15 at 15:45
  • @BigRon Take a look at that now. Added the piece where you use the S3 bucket to actually get the object. – Joshua Pinter Apr 10 '15 at 15:56
  • 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 – BigRon Apr 12 '15 at 01:18
  • @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 Apr 12 '15 at 16:23
  • 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. – Joshua Pinter Nov 22 '15 at 19:52
  • 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? – jsurf Sep 20 '16 at 22:41
  • 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 ? – Maxence Aug 17 '17 at 15:08
  • @Maxence What if you just add a param for the models' `download_url` method that allows you to change how it's dealt with? – Joshua Pinter Aug 17 '17 at 16:18
32

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)

Solid
  • 307
  • 1
  • 12
sumskyi
  • 1,827
  • 13
  • 13
  • 4
    how can I use this with non-public files on S3? – mehulkar Dec 05 '12 at 03:37
  • 1
    @MehulKar in that case you need to use ``@attachment.file.expiring_url`` – dgilperez Sep 11 '13 at 15:09
  • 2
    Note when accessing private files on S3 as shown [here](http://stackoverflow.com/questions/9138994/awss3s3object-url-for-how-to-do-this-with-the-new-aws-sdk-gem), 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 Jun 29 '16 at 15:45
  • 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 – kittyminky Jun 14 '17 at 04:43
  • 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 – Sampson Crowley Jul 18 '21 at 21:43
6

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.

Kenneth John
  • 257
  • 4
  • 8
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
David Morrow
  • 8,965
  • 4
  • 29
  • 24
0

How do I allow users to download S3 files?

If you're able to set some metadata on the file BEFORE you upload it to S3 instead of trying to patch it when the user wants to download it later, then this solution is much simpler:

https://stackoverflow.com/a/24297799/763231

If you are using fog then you can do something like this:

has_attached_file :report,
  fog_file: lambda { |attachment|
    {
      content_type: 'text/csv',
      content_disposition: "attachment; filename=#{attachment.original_filename}",
    }
  }

If you are using Amazon S3 as your storage provider, then something like this should work:

has_attached_file :report
  s3_headers: lambda { |attachment|
    { 
      'Content-Type' => 'text/csv',
      'Content-Disposition' => "attachment; filename=#{attachment.original_filename}",
    }
  }
Kevin Cooper
  • 5,018
  • 4
  • 37
  • 51
-1

def download_pdf @post= @post.avatar.service_url

send_data(

    "#{Rails.root}/public/#{@post}",
    filename: "#{@post}",
    type: "image/*",
    disposition: 'inline', stream: 'true', buffer_size: '4096'
)

end

  • 2
    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 Jun 12 '20 at 12:14
  • 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 Jun 12 '20 at 12:15