diff --git a/lib/parklife/rails/activestorage.rb b/lib/parklife/rails/activestorage.rb index a0e98ce..21fcd0f 100644 --- a/lib/parklife/rails/activestorage.rb +++ b/lib/parklife/rails/activestorage.rb @@ -1,4 +1,6 @@ # frozen_string_literal: true +require_relative 'blob_modifications' + module Parklife module Rails module ActiveStorage diff --git a/lib/parklife/rails/blob_modifications.rb b/lib/parklife/rails/blob_modifications.rb new file mode 100644 index 0000000..01d7a35 --- /dev/null +++ b/lib/parklife/rails/blob_modifications.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true +module Parklife + module Rails + module BlobModifications + def self.prepended(base) + base.before_validation :set_parklife_key + end + + # Generate a deterministic MD5 hash for the Blob based on its attributes. + # + # This means that for a completely fresh Parklife build the same file with + # the same contents will be stored with the same filename so there's no + # unnecessary cache-busting between freshly-built deployments. + # + # The hash is also guaranteed to be unique so it's possible that the same + # filename/contents creates a different value, in this case it's also + # produced deterministically so successive hashes will be the same - but + # ideally you should prevent this from happening in the first place. + # + # @return [String] + def generate_parklife_key + key = nil + i = 0 + + loop do + key = OpenSSL::Digest::MD5.hexdigest( + [ + byte_size, + checksum, + content_type, + filename, + service_name, + i, + ].map(&:to_s).join + ) + + break if self.class.where(key: key).none? + + i += 1 + end + + key + end + + # Override the standard setter so `has_secure_token :key` does nothing. + def key=(_) + nil + end + + # Override the standard getter which otherwise assigns a random token. + def key + self[:key] + end + + private + def set_parklife_key + self[:key] ||= generate_parklife_key + end + end + end +end + +ActiveSupport.on_load(:active_storage_blob) do + ActiveStorage::Blob.prepend Parklife::Rails::BlobModifications +end diff --git a/spec/parklife/integration_spec.rb b/spec/integration/parklife_spec.rb similarity index 100% rename from spec/parklife/integration_spec.rb rename to spec/integration/parklife_spec.rb diff --git a/spec/parklife/blob_modifications_spec.rb b/spec/parklife/blob_modifications_spec.rb new file mode 100755 index 0000000..46cf8ac --- /dev/null +++ b/spec/parklife/blob_modifications_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +RSpec.describe Parklife::Rails::BlobModifications do + context 'when the same file/filename is attached' do + it 'the same key is assigned' do + post = FactoryBot.create(:post) + post.hero.attach( + filename: 'plasma.jpg', + io: file_fixture('plasma.jpg').open, + ) + + id1 = post.hero.blob.id + key1 = post.hero.blob.key + + post.hero.purge + post.hero.attach( + filename: 'plasma.jpg', + io: file_fixture('plasma.jpg').open, + ) + + id2 = post.hero.blob.id + key2 = post.hero.blob.key + + expect(id2).not_to eql(id1) + expect(key2).to eql(key1) + end + end + + context 'when the same file with a different filename is attached' do + it 'a different key is assigned' do + post1 = FactoryBot.create(:post) + post1.hero.attach( + filename: 'foo.jpg', + io: file_fixture('plasma.jpg').open, + ) + + post2 = FactoryBot.create(:post) + post2.hero.attach( + filename: 'bar.jpg', + io: file_fixture('plasma.jpg').open, + ) + + key1 = post1.hero.blob.key + key2 = post2.hero.blob.key + + expect(key1).not_to eql(key2) + end + end + + context 'when a duplicate file/filename is attached' do + it 'a different key is assigned' do + post1 = FactoryBot.create(:post) + post1.hero.attach( + filename: 'plasma.jpg', + io: file_fixture('plasma.jpg').open, + ) + + post2 = FactoryBot.create(:post) + post2.hero.attach( + filename: 'plasma.jpg', + io: file_fixture('plasma.jpg').open, + ) + + key1 = post1.hero.blob.key + key2 = post2.hero.blob.key + + expect(key1).not_to eql(key2) + + post1.hero.purge + post2.hero.purge + + post1.hero.attach( + filename: 'plasma.jpg', + io: file_fixture('plasma.jpg').open, + ) + post2.hero.attach( + filename: 'plasma.jpg', + io: file_fixture('plasma.jpg').open, + ) + + key3 = post1.hero.blob.key + key4 = post2.hero.blob.key + + expect(key3).to eql(key1) + expect(key4).to eql(key2) + end + end +end