From 2cdeb39a514242df2fdc8a75e193dcecde5a74d5 Mon Sep 17 00:00:00 2001 From: Mike Bailey Date: Thu, 10 Jun 2010 23:59:02 +1000 Subject: [PATCH] Added fingerprinting support Leverage browser caching and proxy caching by setting far future Expires headers and changing filenames when file contents change. This can make your web app faster for users and also reduce your bandwidth costs. By adding the column :avatar_fingerprint to our db table and including :fingerprint in the attachment filename, we ensure the filename will change whenever the file contents do. has_attached_file :avatar, :styles => { :medium => "300x300>", :thumb => "100x100>" }, :path => "users/:id/:attachment/:fingerprint-:style.:extension", :storage => :s3, :s3_headers => {'Expires' => 1.year.from_now.httpdate}, :s3_credentials => "#{RAILS_ROOT}/config/s3.yml", :include_updated_timestamp => false This enables us to set far future expire headers so that browsers don't need to check for a newer version. If a change does occur, say because a user uploads a new avatar, the new filename will be rendered in your html and the cached version will be ignored. The example above will set Expires headers in S3. If you're using local storage you can configure your webserver to do something similar. We disable the timestamped query string because some proxies refuse to cache items with query strings. For more info on optimizing for caching: http://code.google.com/speed/page-speed/docs/caching.html --- lib/paperclip.rb | 1 + lib/paperclip/attachment.rb | 12 +++++++++++- lib/paperclip/interpolations.rb | 5 +++++ lib/paperclip/upfile.rb | 10 +++++++++- test/attachment_test.rb | 26 ++++++++++++++++++++++++++ test/helper.rb | 2 ++ 6 files changed, 54 insertions(+), 2 deletions(-) diff --git a/lib/paperclip.rb b/lib/paperclip.rb index f40fe7980..cdb7d55f7 100644 --- a/lib/paperclip.rb +++ b/lib/paperclip.rb @@ -26,6 +26,7 @@ # See the +has_attached_file+ documentation for more details. require 'erb' +require 'digest' require 'tempfile' require 'paperclip/version' require 'paperclip/upfile' diff --git a/lib/paperclip/attachment.rb b/lib/paperclip/attachment.rb index 39f0ac1ba..ab07dda40 100644 --- a/lib/paperclip/attachment.rb +++ b/lib/paperclip/attachment.rb @@ -15,6 +15,7 @@ def self.default_options :default_url => "/:attachment/:style/missing.png", :default_style => :original, :storage => :filesystem, + :include_updated_timestamp => true, :whiny => Paperclip.options[:whiny] || Paperclip.options[:whiny_thumbnails] } end @@ -39,6 +40,7 @@ def initialize name, instance, options = {} @default_url = options[:default_url] @default_style = options[:default_style] @storage = options[:storage] + @include_updated_timestamp = options[:include_updated_timestamp] @whiny = options[:whiny_thumbnails] || options[:whiny] @convert_options = options[:convert_options] @processors = options[:processors] @@ -90,6 +92,7 @@ def assign uploaded_file instance_write(:file_name, uploaded_file.original_filename.strip) instance_write(:content_type, uploaded_file.content_type.to_s.strip) instance_write(:file_size, uploaded_file.size.to_i) + instance_write(:fingerprint, uploaded_file.fingerprint) instance_write(:updated_at, Time.now) @dirty = true @@ -98,6 +101,7 @@ def assign uploaded_file # Reset the file size if the original file was reprocessed. instance_write(:file_size, @queued_for_write[:original].size.to_i) + instance_write(:fingerprint, @queued_for_write[:original].fingerprint) ensure uploaded_file.close if close_uploaded_file end @@ -109,7 +113,7 @@ def assign uploaded_file # security, however, for performance reasons. set # include_updated_timestamp to false if you want to stop the attachment # update time appended to the url - def url style_name = default_style, include_updated_timestamp = true + def url(style_name = default_style, include_updated_timestamp = @include_updated_timestamp) url = original_filename.nil? ? interpolate(@default_url, style_name) : interpolate(@url, style_name) include_updated_timestamp && updated_at ? [url, updated_at].compact.join(url.include?("?") ? "&" : "?") : url end @@ -174,6 +178,12 @@ def size instance_read(:file_size) || (@queued_for_write[:original] && @queued_for_write[:original].size) end + # Returns the hash of the file as originally assigned, and lives in the + # _fingerprint attribute of the model. + def fingerprint + instance_read(:fingerprint) || (@queued_for_write[:original] && @queued_for_write[:original].fingerprint) + end + # Returns the content_type of the file as originally assigned, and lives # in the _content_type attribute of the model. def content_type diff --git a/lib/paperclip/interpolations.rb b/lib/paperclip/interpolations.rb index 42f25f150..ad52914ed 100644 --- a/lib/paperclip/interpolations.rb +++ b/lib/paperclip/interpolations.rb @@ -88,6 +88,11 @@ def id attachment, style_name attachment.instance.id end + # Returns the fingerprint of the instance. + def fingerprint attachment, style_name + attachment.fingerprint + end + # Returns the id of the instance in a split path form. e.g. returns # 000/001/234 for an id of 1234. def id_partition attachment, style_name diff --git a/lib/paperclip/upfile.rb b/lib/paperclip/upfile.rb index a8d28c505..6db66cf7f 100644 --- a/lib/paperclip/upfile.rb +++ b/lib/paperclip/upfile.rb @@ -32,18 +32,26 @@ def original_filename def size File.size(self) end + + # Returns the hash of the file. + def fingerprint + Digest::MD5.hexdigest(self.read) + end end end if defined? StringIO class StringIO - attr_accessor :original_filename, :content_type + attr_accessor :original_filename, :content_type, :fingerprint def original_filename @original_filename ||= "stringio.txt" end def content_type @content_type ||= "text/plain" end + def fingerprint + @fingerprint ||= Digest::MD5.hexdigest(self.string) + end end end diff --git a/test/attachment_test.rb b/test/attachment_test.rb index 1974c15da..53c83f92b 100644 --- a/test/attachment_test.rb +++ b/test/attachment_test.rb @@ -446,6 +446,8 @@ def do_after_all; end @not_file = mock @tempfile = mock @not_file.stubs(:nil?).returns(false) + @not_file.stubs(:fingerprint).returns('bd94545193321376b70136f8b223abf8') + @tempfile.stubs(:fingerprint).returns('bd94545193321376b70136f8b223abf8') @not_file.expects(:size).returns(10) @tempfile.expects(:size).returns(10) @not_file.expects(:to_tempfile).returns(@tempfile) @@ -754,5 +756,29 @@ def do_after_all; end assert_equal @file.size, @dummy.avatar.size end end + + context "and avatar_fingerprint column" do + setup do + ActiveRecord::Base.connection.add_column :dummies, :avatar_fingerprint, :string + rebuild_class + @dummy = Dummy.new + end + + should "not error when assigned an attachment" do + assert_nothing_raised { @dummy.avatar = @file } + end + + should "return the right value when sent #avatar_fingerprint" do + @dummy.avatar = @file + assert_equal 'aec488126c3b33c08a10c3fa303acf27', @dummy.avatar_fingerprint + end + + should "return the right value when saved, reloaded, and sent #avatar_fingerprint" do + @dummy.avatar = @file + @dummy.save + @dummy = Dummy.find(@dummy.id) + assert_equal 'aec488126c3b33c08a10c3fa303acf27', @dummy.avatar_fingerprint + end + end end end diff --git a/test/helper.rb b/test/helper.rb index 6ee3e78c3..b89dd889a 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -84,6 +84,7 @@ def rebuild_model options = {} table.column :avatar_content_type, :string table.column :avatar_file_size, :integer table.column :avatar_updated_at, :datetime + table.column :avatar_fingerprint, :string end rebuild_class options end @@ -103,6 +104,7 @@ class FakeModel :avatar_file_size, :avatar_last_updated, :avatar_content_type, + :avatar_fingerprint, :id def errors