乐者为王

Do one thing, and do it well.

保护你的Paperclip下载

英文原文:https://thewebfellas.com/blog/protecting-your-paperclip-downloads

去年11月份,当我首次在博客上谈到Paperclip时,我简要介绍了在控制器后面隐藏文件,而不是简单地将它们放在公共目录中展示给大家看。从那时起,我就注意到如何真正做到这点的问题定期地在Rails论坛上出现。几周前,我不得不弄清楚如何更新我们的某些代码来保护我们已经从本地文件系统迁移到Amazon S3的资产。所以我认为这可能是一个值得分享的技巧。

模型

我将使用来自我原先的Paperclip博文中的Getting clever with validations部分的Track模型的一个轻微更新版本。以下是代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Track < ActiveRecord::Base

  has_attached_file :mp3,
                    :url => '/:class/:id/:style.:extension',
                    :path => ':rails_root/assets/:class/:id_partition/:style.:extension'

  validates_attachment_presence :mp3
  validates_attachment_content_type :mp3, :content_type => ['application/mp3', 'application/x-mp3', 'audio/mpeg', 'audio/mp3']
  validates_attachment_size :mp3, :less_than => 20.megabytes

  def downloadable?(user)
    user != :guest
  end

end

这段代码执行以下操作:

  • 定义含有名为mp3的Paperclip附件的Track模型。
  • 配置用于访问mp3资源的URL,例如/track/1/original.mp3。在URL中留下样式属性可以使未来版本的代码去做像生成轨道的10秒预览这样的事情(使用自定义的Paperclip处理器),这些预览可以使用像/track/1/preview.mp3这样的URL访问。
  • 配置Paperclip存储上传文件的路径(例如RAILS_ROOT/assets/tracks/000/000/001/original.mp3,其中RAILS_ROOT是Rails应用程序根目录的路径)——这里重要的是,文件存储在/public目录之外。
  • 定义验证器以确保是mp3文件被上传,该验证器包含有效的内容类型并且限制文件不能太大。
  • 定义downloadable?方法,可用于实现对轨道的用户访问权限。为简单起见,它只允许所有登录的用户访问轨道,不过你可以用应用程序需要的任何逻辑来替换它。

路由和控制器

现在,如果你尝试在视图中提供像下面这样的链接来下载mp3:

1
link_to('Listen', track.mp3.url)

当你点击它的时候将得到这样的路由错误:

1
2
Routing Error
No route matches "/tracks/1/original.mp3" with {:method=>:get}

我通常在routes.rb文件中使用map.connect将Paperclip URL映射到控制器中的download动作,就像下面这样:

1
2
3
ActionController::Routing::Routes.draw do |map|
  map.connect 'tracks/:id/:style.:format', :controller => 'tracks', :action => 'download', :conditions => { :method => :get }
end

不需要使用命名路由,因为你不太可能需要通过名称引用路由。如果需要的话,通过映射到自定义的download动作,你还可以在控制器中提供标准的资源丰富的CRUD动作。清教徒式的REST粉丝可能会坚持将下载映射到单独的资源,并且使用POST请求创建一个新的下载资源:如果你打算做更多而不仅仅是将文件流传输到客户端(例如日志统计,更新账单信息),这可能值得考虑,否则为什么要复杂化事情?

然后,TracksController需要一个download动作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class TracksController < ApplicationController

  SEND_FILE_METHOD = :default

  def download
    head(:not_found) and return if (track = Track.find_by_id(params[:id])).nil?
    head(:forbidden) and return unless track.downloadable?(current_user)

    path = track.mp3.path(params[:style])
    head(:bad_request) and return unless File.exist?(path) && params[:format].to_s == File.extname(path).gsub(/^\.+/, '')

    send_file_options = { :type => File.mime_type?(path) }

    case SEND_FILE_METHOD
      when :apache then send_file_options[:x_sendfile] = true
      when :nginx then head(:x_accel_redirect => path.gsub(Rails.root, ''), :content_type => send_file_options[:type]) and return
    end

    send_file(path, send_file_options)
  end

end

控制器中有很多事情发生,这里是download动作的细节:

  1. 尝试使用:id参数找到Track模型,如果轨道不存在则返回404 Not Found响应。
  2. 把当前用户传递给downloadable?方法,决定是否允许用户下载轨道。
    • 这里假设current_user是应用程序的身份验证代码提供的方法,如果没有用户登录则返回:guest符号,否则返回User对象。这里以及downloadable?方法中使用的实际代码可能需要修改以适应你自己的身份验证代码。
    • 如果不允许用户下载轨道,控制器将返回403 Forbidden响应。
  3. 然后为传递的:style参数生成服务器上的mp3文件的路径。
  4. 然后,控制器确保文件存在(如果使用无效的:style参数则不会),并且请求的文件扩展名与文件的实际扩展名相匹配。如果需要,它返回400 Bad Request。
  5. 被用于send_file方法的散列选项将与mp3文件的MIME类型一起被初始化。
    • 我在这里使用mimetype-fu插件(使用ruby script/plugin install git://github.com/mattetti/mimetype-fu.git安装),而不是mp3附件的content_type属性,以允许将来不同的样式可能会使用不同的文件类型的功能。
  6. 控制器支持标准流传输,使用Lighttpd/Apache X-Sendfile或Nginx X-Accel-Redirect方法下载文件。
    • 为简单起见,这里使用SEND_FILE_METHOD常量来配置要使用的方法,但是在实际的应用程序中,应该将其存储在某种配置设置中(我推荐使用Luke Redpath的SimpleConfig插件进行此类操作)。
    • 取决于配置的流传输方法,控制器既可以使用send_file(用于标准和X-Sendfile流传输)也可以使用带报头的响应(用于X-Accel-Redirect流传输)。
    • 在Rails应用程序中我们通常使用Nginx,因此我们借助如下的Nginx配置以使用X-Accel-Redirect方法进行文件的流传输:
1
2
3
4
location /assets/ {
  root /path/to/rails_root;
  internal;
}

其中/path/to/rails_root包含我们的Rails应用程序根目录的完整路径。

视图

Track模型通过downloadable?方法提供访问控制逻辑,而在TracksController中的download动作处理mp3文件流传输,这两者使得使用带有Paperclip提供的url方法的link_to帮助器在视图中提供下载链接成为可能。例如,index视图可能如下所示:

1
2
3
4
5
<ul>
<% @tracks.each do |track| %>
  <li><%= link_to('Listen', track.mp3.url) %></li>
<% end %>
</ul>

扩展到Amazon S3

更新!Paperclip的最新版本包含到期的URL support

如果你的mp3存储在本地文件系统上,上面的代码可以正常地工作,但如果你的站点开始增长,并且你需要在存储空间和下载容量方面进行扩展,那么你可能需要迁移到S3。

Paperclip提供的S3存储模块目前使用RightAWS,而2.3.1+版已经开始使用AWS::S3作为替代。请注意,如果要使用位于欧洲的S3存储桶,则此更改确实会导致一些问题,因此如果这对你来说是个问题的话,你可能需要继续使用2.3.0版本。我将在下面的代码中覆盖这两个版本。

更改存储模块

首先要更改的是Track模型中的has_attached_file定义:

1
2
3
4
5
6
7
has_attached_file :mp3,
                  :url => ':s3_domain_url',
                  :path => 'assets/:class/:id/:style.:extension',
                  :storage => :s3,
                  :s3_credentials => File.join(Rails.root, 'config', 's3.yml'),
                  :s3_permissions => 'authenticated-read',
                  :s3_protocol => 'http'

这里最明显的更改分别依次为指定存储模块,凭据文件位置,上传文件权限和通信协议方面的:storage、:s3_credentials、:s3_permissions和:s3_protocol选项。

凭据YAML文件用于指定你的S3帐户的访问密钥、私有访问密钥和存储桶名称,应该如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
common: &common
  access_key_id: your_access_key_id_goes_here
  secret_access_key: your_secret_access_key_goes_here

development:
  <<: *common
  bucket: app-name-development

test:
  <<: *common
  bucket: app-name-test

production:
  <<: *common
  bucket: app-name-production

为每个环境配置单独的S3桶是个好主意,可以防止意外混淆测试文件与生产文件!

:s3_permissions选项允许你指定四个固定访问策略中的一个,在这种情况下,authenticated-read被用于设置读取访问仅限于对象所有者或经过身份验证的用户。

对:url和:path选项的更改略微不那么明显::url被设置为“:s3_domain_url”,它是使用虚拟托管风格的桶访问的Paperclip插补(如果使用欧洲桶这尤其重要,因为这是首选的访问方式);:path被Paperclip用于生成存储在S3上的对象的密钥名称。

通过这些更改,上传到Track模型的新文件将使用从:path选项生成的密钥存储在相应的S3桶中,例如http://app-name-development.s3.amazonaws.com/assets/tracks/1/original.mp3。

重定向不再有流传输和时间

迁移到S3的好处之一是,你的服务器不再需要自己涉及将数据流传输到客户端(使用X-SendFile和X-Accel-Redirect等技术可以帮助你给Rails进程解除负担,但服务器仍然必须做所有的工作)。作为将文件数据发送给客户端的替代,控制器中的download动作现在需要处理权限检查,然后将客户端重定向到S3上的文件以进行下载。

然而,这会有个问题,因为上传文件正在使用阻止公开访问它们的authenticated-read固定访问策略。幸运的是,S3提供一种方法,可以为仅在特定时间段内运行的私有内容生成已验证的URL:然后我们的控制器可以将客户端重定向到此临时URL以启动下载,但如果有人获取URL的详细信息并尝试在以后访问该文件,那么他们将被告知该URL已经过期。

更新的代码如下所示:

1
2
3
4
5
6
7
8
9
def download
  head(:not_found) and return if (track = Track.find_by_id(params[:id])).nil?
  head(:forbidden) and return unless track.downloadable?(current_user)

  path = track.mp3.path(params[:style])
  head(:bad_request) and return unless params[:format].to_s == File.extname(path).gsub(/^\.+/, '')

  redirect_to(AWS::S3::S3Object.url_for(path, track.mp3.bucket_name, :expires_in => 10.seconds))
end

在控制器中有两个主要区别:

  • 没有检查以确保对象存在,因为这将增加到S3的额外请求的开销——让S3去为不存在的对象返回适当的响应吧。
  • 作为流传输文件的替代,它重定向到使用url_for方法生成的临时URL。临时URL设置为在10秒后过期,该时间应足够长,以便重定向去启动下载:如果下载时间超过10秒,只要已经开始,它将继续下去,直到完成为止。

如果你正在使用Paperclip 2.3.0或更旧的版本,因为RightAWS的缘故,控制器重定向代码应如下所示:

1
redirect_to(track.mp3.s3.interface.get_link(track.mp3.s3_bucket.to_s, path, 10.seconds))

重新考虑视图

当使用本地文件系统存储时,Paperclip附件的url方法可被用于链接到TracksController的download动作,但现在文件托管在S3上,url方法返回的是S3桶的URL,这不是我们想要的。

这里有几个选择,路由可以被更改为命名路由,然后视图可以使用如下所示的代码:

1
link_to('Listen', download_track_path(track.id, 'original', 'mp3')

我认为更好的方法是在Track模型中添加一个新方法,使用Paperclip插补来生成下载URL。与此同时,还可以将验证S3 URL的代码从控制器中移到模型中。这里是完整的Track模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Track < ActiveRecord::Base

  has_attached_file :mp3,
                    :url => ':s3_domain_url',
                    :path => 'assets/:class/:id/:style.:extension',
                    :storage => :s3,
                    :s3_credentials => File.join(Rails.root, 'config', 's3.yml'),
                    :s3_permissions => 'authenticated-read',
                    :s3_protocol => 'http'

  validates_attachment_presence :mp3
  validates_attachment_content_type :mp3, :content_type => ['application/mp3', 'application/x-mp3', 'audio/mpeg', 'audio/mp3']
  validates_attachment_size :mp3, :less_than => 20.megabytes

  def downloadable?(user)
    user != :guest
  end

  def download_url(style = nil, include_updated_timestamp = true)
    url = Paperclip::Interpolations.interpolate('/:class/:id/:style.:extension', mp3, style || mp3.default_style)
    include_updated_timestamp && mp3.updated_at ? [url, mp3.updated_at].compact.join(url.include?("?") ? "&" : "?") : url
  end

  def authenticated_url(style = nil, expires_in = 10.seconds)
    AWS::S3::S3Object.url_for(mp3.path(style || mp3.default_style), mp3.bucket_name, :expires_in => expires_in, :use_ssl => mp3.s3_protocol == 'https')
  end

end

对于RightAWS (Paperclip <= 2.3.0),验证URL的代码略有不同:

1
2
3
def authenticated_url(style = nil, expires_in = 10.seconds)
  mp3.s3.interface.get_link(mp3.s3_bucket.to_s, mp3.path(style || mp3.default_style), expires_in)
end

然后整理download动作,将重定向更改为:

1
redirect_to(track.authenticated_url(params[:style]))

现在视图可以链接到下载了:

1
link_to('Listen', track.download_url)

结束

希望这里我给了你足够的信息,以便你开始在自己的Rails应用程序中实施受保护的下载。但是请不要忘记,如果你使用的是欧洲S3桶,需要进行一些修补,否则会出现以下错误:

1
AWS::S3::PermanentRedirect: The bucket you are attempting to access must be addressed using the specified endpoint. Please send all future requests to this endpoint.

当你尝试并且上传时——彻底地修复将是另一天的工作!

FAQ

如何强制下载文件,而不是在浏览器中显示?

如果你使用的是文件系统存储模块,那么当使用send_file方法时,Rails会给attachment设置Content-Disposition报头,你不需要做任何事情。IE可能仍然会坚持在线显示文件,所以你可能需要用点蛮力,在控制器中给application/octet-stream设置Content-Type报头:

1
send_file_options = { :type => 'application/octet-stream' }

当然你也可以使用一些简单的浏览器嗅探来为智能浏览器返回正确的Content-Type,以及为IE返回application/octet-stream报头。

如果使用S3存储模块,那么你需要将s3_headers选项添加到模型的has_attached_file定义中:

1
2
3
4
5
6
7
8
has_attached_file :mp3,
            :url => ':s3_domain_url',
            :path => 'assets/:class/:id/:style.:extension',
            :storage => :s3,
            :s3_credentials => File.join(Rails.root, 'config', 's3.yml'),
            :s3_permissions => 'authenticated-read',
            :s3_protocol => 'http',
            :s3_headers => { :content_disposition => 'attachment' }

当控制器重定向到S3 URL时,Amazon将发送你在此处指定的报头,强制下载。就像使用文件系统存储一样,你也可以使用这种方法强制Content-Type报头,尽管你将无法基于用户浏览器使用任何类型的浏览器嗅探来选择内容类型:

1
2
3
4
5
6
7
8
has_attached_file :mp3,
            :url => ':s3_domain_url',
            :path => 'assets/:class/:id/:style.:extension',
            :storage => :s3,
            :s3_credentials => File.join(Rails.root, 'config', 's3.yml'),
            :s3_permissions => 'authenticated-read',
            :s3_protocol => 'http',
            :s3_headers => { :content_type => 'application/octet-stream', :content_disposition => 'attachment' }

Comments