diff options
21 files changed, 296 insertions, 53 deletions
@@ -18,4 +18,6 @@ gem "httparty" gem "aws-sdk", "~> 2", require: false gem "google-cloud-storage", "~> 1.3", require: false +gem 'mini_magick' + gem "rubocop", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 5b283272f7..e18acab95b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,16 @@ GIT remote: https://github.com/rails/rails.git revision: 5c16dd35a23f75038baf1527143ee44accf081ff specs: + actioncable (5.2.0.alpha) + actionpack (= 5.2.0.alpha) + nio4r (~> 2.0) + websocket-driver (~> 0.6.1) + actionmailer (5.2.0.alpha) + actionpack (= 5.2.0.alpha) + actionview (= 5.2.0.alpha) + activejob (= 5.2.0.alpha) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) actionpack (5.2.0.alpha) actionview (= 5.2.0.alpha) activesupport (= 5.2.0.alpha) @@ -29,15 +39,30 @@ GIT i18n (~> 0.7) minitest (~> 5.1) tzinfo (~> 1.1) + rails (5.2.0.alpha) + actioncable (= 5.2.0.alpha) + actionmailer (= 5.2.0.alpha) + actionpack (= 5.2.0.alpha) + actionview (= 5.2.0.alpha) + activejob (= 5.2.0.alpha) + activemodel (= 5.2.0.alpha) + activerecord (= 5.2.0.alpha) + activesupport (= 5.2.0.alpha) + bundler (>= 1.3.0) + railties (= 5.2.0.alpha) + sprockets-rails (>= 2.0.0) + railties (5.2.0.alpha) + actionpack (= 5.2.0.alpha) + activesupport (= 5.2.0.alpha) + method_source + rake (>= 0.8.7) + thor (>= 0.18.1, < 2.0) PATH remote: . specs: activestorage (0.1) - actionpack (>= 5.2.0.alpha) - activejob (>= 5.2.0.alpha) - activerecord (>= 5.2.0.alpha) - activesupport (>= 5.2.0.alpha) + rails (>= 5.2.0.alpha) GEM remote: https://rubygems.org/ @@ -101,15 +126,20 @@ GEM multi_json (~> 1.10) loofah (2.0.3) nokogiri (>= 1.5.9) + mail (2.6.5) + mime-types (>= 1.16, < 4) memoist (0.16.0) + method_source (0.8.2) mime-types (3.1) mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) + mini_magick (4.8.0) mini_portile2 (2.2.0) minitest (5.10.2) multi_json (1.12.1) multi_xml (0.6.0) multipart-post (2.0.0) + nio4r (2.1.0) nokogiri (1.8.0) mini_portile2 (~> 2.2.0) os (0.9.6) @@ -147,12 +177,23 @@ GEM faraday (~> 0.9) jwt (~> 1.5) multi_json (~> 1.10) + sprockets (3.7.1) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.2.0) + actionpack (>= 4.0) + activesupport (>= 4.0) + sprockets (>= 3.0.0) sqlite3 (1.3.13) + thor (0.19.4) thread_safe (0.3.6) tzinfo (1.2.3) thread_safe (~> 0.1) uber (0.1.0) unicode-display_width (1.3.0) + websocket-driver (0.6.5) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.2) PLATFORMS ruby @@ -168,9 +209,10 @@ DEPENDENCIES byebug google-cloud-storage (~> 1.3) httparty + mini_magick rake rubocop sqlite3 BUNDLED WITH - 1.15.1 + 1.15.2 @@ -63,6 +63,13 @@ class MessagesController < ApplicationController end ``` +Variation of image attachment: + +```erb +<%# Hitting the variant URL will lazy transform the original blob and then redirect to its new service location %> +<%= image_tag url_for(user.avatar.variant(resize: "100x100")) %> +``` + ## Installation 1. Add `gem "activestorage", git: "https://github.com/rails/activestorage.git"` to your Gemfile. @@ -70,6 +77,7 @@ end 3. Run `rails activestorage:install` to create needed directories, migrations, and configuration. 4. Configure the storage service in `config/environments/*` with `config.active_storage.service = :local` that references the services configured in `config/storage_services.yml`. +5. Optional: Add `gem "mini_magick"` to your Gemfile if you want to use variants. ## Todos @@ -3,6 +3,7 @@ require "bundler/gem_tasks" require "rake/testtask" Rake::TestTask.new do |test| + test.libs << "app/controllers" test.libs << "test" test.test_files = FileList["test/**/*_test.rb"] test.warning = false diff --git a/activestorage.gemspec b/activestorage.gemspec index 884d3287e6..9546b60783 100644 --- a/activestorage.gemspec +++ b/activestorage.gemspec @@ -9,10 +9,7 @@ Gem::Specification.new do |s| s.required_ruby_version = ">= 2.3.0" - s.add_dependency "activesupport", ">= 5.2.0.alpha" - s.add_dependency "activerecord", ">= 5.2.0.alpha" - s.add_dependency "actionpack", ">= 5.2.0.alpha" - s.add_dependency "activejob", ">= 5.2.0.alpha" + s.add_dependency "rails", ">= 5.2.0.alpha" s.add_development_dependency "bundler", "~> 1.15" diff --git a/lib/active_storage/direct_uploads_controller.rb b/app/controllers/active_storage/direct_uploads_controller.rb index 99ff27f903..dccd864e8d 100644 --- a/lib/active_storage/direct_uploads_controller.rb +++ b/app/controllers/active_storage/direct_uploads_controller.rb @@ -1,6 +1,3 @@ -require "action_controller" -require "active_storage/blob" - class ActiveStorage::DirectUploadsController < ActionController::Base def create blob = ActiveStorage::Blob.create_before_direct_upload!(blob_args) diff --git a/lib/active_storage/disk_controller.rb b/app/controllers/active_storage/disk_controller.rb index 16a295d00d..16a295d00d 100644 --- a/lib/active_storage/disk_controller.rb +++ b/app/controllers/active_storage/disk_controller.rb diff --git a/app/controllers/active_storage/variants_controller.rb b/app/controllers/active_storage/variants_controller.rb new file mode 100644 index 0000000000..dde7e1458f --- /dev/null +++ b/app/controllers/active_storage/variants_controller.rb @@ -0,0 +1,25 @@ +class ActiveStorage::VariantsController < ActionController::Base + def show + if blob_key = decode_verified_blob_key + redirect_to processed_variant_for(blob_key).url(disposition: disposition_param) + else + head :not_found + end + end + + private + def decode_verified_blob_key + ActiveStorage::VerifiedKeyWithExpiration.decode(params[:encoded_blob_key]) + end + + def processed_variant_for(blob_key) + ActiveStorage::Variant.new( + ActiveStorage::Blob.find_by!(key: blob_key), + ActiveStorage::Variation.decode(params[:variation_key]) + ).processed + end + + def disposition_param + params[:disposition].presence_in(%w( inline attachment )) || 'inline' + end +end diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000000..d25f2c82f0 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,16 @@ +Rails.application.routes.draw do + get "/rails/active_storage/disk/:encoded_key/*filename" => "active_storage/disk#show", as: :rails_disk_blob + post "/rails/active_storage/direct_uploads" => "active_storage/direct_uploads#create", as: :rails_direct_uploads + + get "/rails/active_storage/variants/:encoded_blob_key/:variation_key/*filename" => "active_storage/variants#show", as: :rails_blob_variation + + direct :rails_variant do |variant| + encoded_blob_key = ActiveStorage::VerifiedKeyWithExpiration.encode(variant.blob.key) + variation_key = variant.variation.key + filename = variant.blob.filename + + route_for(:rails_blob_variation, encoded_blob_key, variation_key, filename) + end + + resolve('ActiveStorage::Variant') { |variant| route_for(:rails_variant, variant) } +end diff --git a/lib/active_storage/blob.rb b/lib/active_storage/blob.rb index 1a15361747..6bd3941cd8 100644 --- a/lib/active_storage/blob.rb +++ b/lib/active_storage/blob.rb @@ -1,6 +1,7 @@ require "active_storage/service" require "active_storage/filename" require "active_storage/purge_job" +require "active_storage/variant" # Schema: id, key, filename, content_type, metadata, byte_size, checksum, created_at class ActiveStorage::Blob < ActiveRecord::Base @@ -31,8 +32,9 @@ class ActiveStorage::Blob < ActiveRecord::Base end end - # We can't wait until the record is first saved to have a key for it + def key + # We can't wait until the record is first saved to have a key for it self[:key] ||= self.class.generate_unique_secure_token end @@ -40,6 +42,11 @@ class ActiveStorage::Blob < ActiveRecord::Base ActiveStorage::Filename.new(self[:filename]) end + def variant(transformations) + ActiveStorage::Variant.new(self, ActiveStorage::Variation.new(transformations)) + end + + def url(expires_in: 5.minutes, disposition: :inline) service.url key, expires_in: expires_in, disposition: disposition, filename: filename end diff --git a/lib/active_storage/engine.rb b/lib/active_storage/engine.rb index 5f0b62809e..b32ae34516 100644 --- a/lib/active_storage/engine.rb +++ b/lib/active_storage/engine.rb @@ -14,17 +14,6 @@ module ActiveStorage end end - initializer "active_storage.routes" do - require "active_storage/disk_controller" - require "active_storage/direct_uploads_controller" - - config.after_initialize do |app| - app.routes.prepend do - eval(File.read(File.expand_path("../routes.rb", __FILE__))) - end - end - end - initializer "active_storage.attached" do require "active_storage/attached" @@ -33,32 +22,42 @@ module ActiveStorage end end - config.after_initialize do |app| - if config_choice = app.config.active_storage.service - config_file = Pathname.new(Rails.root.join("config/storage_services.yml")) - raise("Couldn't find Active Storage configuration in #{config_file}") unless config_file.exist? + initializer "active_storage.verifiers" do + require "active_storage/verified_key_with_expiration" + require "active_storage/variation" - require "yaml" - require "erb" + config.after_initialize do |app| + ActiveStorage::VerifiedKeyWithExpiration.verifier = \ + ActiveStorage::Variation.verifier = \ + Rails.application.message_verifier('ActiveStorage') + end + end + + initializer "active_storage.services" do + config.after_initialize do |app| + if config_choice = app.config.active_storage.service + config_file = Pathname.new(Rails.root.join("config/storage_services.yml")) + raise("Couldn't find Active Storage configuration in #{config_file}") unless config_file.exist? + + require "yaml" + require "erb" - configs = - begin - YAML.load(ERB.new(config_file.read).result) || {} - rescue Psych::SyntaxError => e - raise "YAML syntax error occurred while parsing #{config_file}. " \ - "Please note that YAML must be consistently indented using spaces. Tabs are not allowed. " \ - "Error: #{e.message}" - end + configs = + begin + YAML.load(ERB.new(config_file.read).result) || {} + rescue Psych::SyntaxError => e + raise "YAML syntax error occurred while parsing #{config_file}. " \ + "Please note that YAML must be consistently indented using spaces. Tabs are not allowed. " \ + "Error: #{e.message}" + end - ActiveStorage::Blob.service = - begin - ActiveStorage::Service.configure config_choice, configs - rescue => e - raise e, "Cannot load `Rails.config.active_storage.service`:\n#{e.message}", e.backtrace - end - else - raise "No storage service specified for current env (#{Rails.env}). " \ - "Add config.active_storage.service = :local into your config/environments/#{Rails.env}.rb." + ActiveStorage::Blob.service = + begin + ActiveStorage::Service.configure config_choice, configs + rescue => e + raise e, "Cannot load `Rails.config.active_storage.service`:\n#{e.message}", e.backtrace + end + end end end end diff --git a/lib/active_storage/routes.rb b/lib/active_storage/routes.rb deleted file mode 100644 index 748427a776..0000000000 --- a/lib/active_storage/routes.rb +++ /dev/null @@ -1,2 +0,0 @@ -get "/rails/active_storage/disk/:encoded_key/*filename" => "active_storage/disk#show", as: :rails_disk_blob -post "/rails/active_storage/direct_uploads" => "active_storage/direct_uploads#create", as: :rails_direct_uploads diff --git a/lib/active_storage/variant.rb b/lib/active_storage/variant.rb new file mode 100644 index 0000000000..435033f980 --- /dev/null +++ b/lib/active_storage/variant.rb @@ -0,0 +1,35 @@ +require "active_storage/blob" +require "mini_magick" + +# Image blobs can have variants that are the result of a set of transformations applied to the original. +class ActiveStorage::Variant + attr_reader :blob, :variation + delegate :service, to: :blob + + def initialize(blob, variation) + @blob, @variation = blob, variation + end + + def processed + process unless service.exist?(key) + self + end + + def key + "variants/#{blob.key}/#{variation.key}" + end + + def url(expires_in: 5.minutes, disposition: :inline) + service.url key, expires_in: expires_in, disposition: disposition, filename: blob.filename + end + + + private + def process + service.upload key, transform(service.download(blob.key)) + end + + def transform(io) + File.open MiniMagick::Image.read(io).tap { |image| variation.transform(image) }.path + end +end diff --git a/lib/active_storage/variation.rb b/lib/active_storage/variation.rb new file mode 100644 index 0000000000..abff288ac1 --- /dev/null +++ b/lib/active_storage/variation.rb @@ -0,0 +1,53 @@ +require "active_support/core_ext/object/inclusion" + +# A set of transformations that can be applied to a blob to create a variant. +class ActiveStorage::Variation + class_attribute :verifier + + ALLOWED_TRANSFORMATIONS = %i( + resize rotate format flip fill monochrome orient quality roll scale sharpen shave shear size thumbnail + transparent transpose transverse trim background bordercolor compress crop + ) + + attr_reader :transformations + + class << self + def decode(key) + new verifier.verify(key) + end + + def encode(transformations) + verifier.generate(transformations) + end + end + + def initialize(transformations) + @transformations = transformations + end + + def transform(image) + transformations.each do |(method, argument)| + next unless eligible_transformation?(method) + + if eligible_argument?(argument) + image.public_send(method, argument) + else + image.public_send(method) + end + end + end + + def key + self.class.encode(transformations) + end + + private + def eligible_transformation?(method) + method.to_sym.in?(ALLOWED_TRANSFORMATIONS) + end + + # FIXME: Consider whitelisting allowed arguments as well? + def eligible_argument?(argument) + argument.present? && argument != true + end +end diff --git a/lib/active_storage/verified_key_with_expiration.rb b/lib/active_storage/verified_key_with_expiration.rb index e429ee21ce..4a46483db5 100644 --- a/lib/active_storage/verified_key_with_expiration.rb +++ b/lib/active_storage/verified_key_with_expiration.rb @@ -1,5 +1,5 @@ class ActiveStorage::VerifiedKeyWithExpiration - class_attribute :verifier, default: defined?(Rails) ? Rails.application.message_verifier("ActiveStorage") : nil + class_attribute :verifier class << self def encode(key, expires_in: nil) diff --git a/test/controllers/variants_controller.rb b/test/controllers/variants_controller.rb new file mode 100644 index 0000000000..6753584d4d --- /dev/null +++ b/test/controllers/variants_controller.rb @@ -0,0 +1,24 @@ +require "test_helper" +require "database/setup" + +require "active_storage/variants_controller" +require "active_storage/verified_key_with_expiration" + +class ActiveStorage::VariantsControllerTest < ActionController::TestCase + setup do + @routes = Routes + @controller = ActiveStorage::VariantsController.new + + @blob = create_image_blob filename: "racecar.jpg" + end + + test "showing variant inline" do + get :show, params: { + filename: @blob.filename, + encoded_blob_key: ActiveStorage::VerifiedKeyWithExpiration.encode(@blob.key, expires_in: 5.minutes), + variation_key: ActiveStorage::Variation.encode(resize: "100x100") } + + assert_redirected_to /racecar.jpg\?disposition=inline/ + assert_same_image "racecar-100x100.jpg", @blob.variant(resize: "100x100") + end +end diff --git a/test/fixtures/files/racecar-100x100-monochrome.jpg b/test/fixtures/files/racecar-100x100-monochrome.jpg Binary files differnew file mode 100644 index 0000000000..39e683747e --- /dev/null +++ b/test/fixtures/files/racecar-100x100-monochrome.jpg diff --git a/test/fixtures/files/racecar-100x100.jpg b/test/fixtures/files/racecar-100x100.jpg Binary files differnew file mode 100644 index 0000000000..2a515a4912 --- /dev/null +++ b/test/fixtures/files/racecar-100x100.jpg diff --git a/test/fixtures/files/racecar.jpg b/test/fixtures/files/racecar.jpg Binary files differnew file mode 100644 index 0000000000..934b4caa22 --- /dev/null +++ b/test/fixtures/files/racecar.jpg diff --git a/test/test_helper.rb b/test/test_helper.rb index 03593b12c7..20b22049b3 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,5 @@ +$LOAD_PATH << File.expand_path("../../app/controllers", __FILE__) + require "bundler/setup" require "active_support" require "active_support/test_case" @@ -15,7 +17,6 @@ rescue Errno::ENOENT {} end - require "active_storage/service/disk_service" require "tmpdir" ActiveStorage::Blob.service = ActiveStorage::Service::DiskService.new(root: Dir.mktmpdir("active_storage_tests")) @@ -24,20 +25,35 @@ ActiveStorage::Service.logger = ActiveSupport::Logger.new(STDOUT) require "active_storage/verified_key_with_expiration" ActiveStorage::VerifiedKeyWithExpiration.verifier = ActiveSupport::MessageVerifier.new("Testing") +require "active_storage/variation" +ActiveStorage::Variation.verifier = ActiveSupport::MessageVerifier.new("Testing") + class ActiveSupport::TestCase private def create_blob(data: "Hello world!", filename: "hello.txt", content_type: "text/plain") ActiveStorage::Blob.create_after_upload! io: StringIO.new(data), filename: filename, content_type: content_type end + + def create_image_blob(filename: "racecar.jpg", content_type: "image/jpeg") + ActiveStorage::Blob.create_after_upload! \ + io: File.open(File.expand_path("../fixtures/files/#{filename}", __FILE__)), + filename: filename, content_type: content_type + end + + def assert_same_image(fixture_filename, variant) + assert_equal \ + File.binread(File.expand_path("../fixtures/files/#{fixture_filename}", __FILE__)), + File.binread(variant.service.send(:path_for, variant.key)) + end end require "action_controller" require "action_controller/test_case" - class ActionController::TestCase Routes = ActionDispatch::Routing::RouteSet.new.tap do |routes| routes.draw do - eval(File.read(File.expand_path("../../lib/active_storage/routes.rb", __FILE__))) + # FIXME: Hacky way to avoid having to instantiate the real engine + eval(File.readlines(File.expand_path("../../config/routes.rb", __FILE__)).slice(1..-2).join("\n")) end end end diff --git a/test/variant_test.rb b/test/variant_test.rb new file mode 100644 index 0000000000..5294b87135 --- /dev/null +++ b/test/variant_test.rb @@ -0,0 +1,23 @@ +require "test_helper" +require "database/setup" +require "active_storage/variant" + +class ActiveStorage::VariantTest < ActiveSupport::TestCase + setup do + @blob = create_image_blob filename: "racecar.jpg" + end + + test "resized variation" do + variant = @blob.variant(resize: "100x100").processed + + assert_match /racecar.jpg/, variant.url + assert_same_image "racecar-100x100.jpg", variant + end + + test "resized and monochrome variation" do + variant = @blob.variant(resize: "100x100", monochrome: true).processed + + assert_match /racecar.jpg/, variant.url + assert_same_image "racecar-100x100-monochrome.jpg", variant + end +end |