├── .rspec ├── spec ├── fixtures │ └── image.png ├── carrierwave │ ├── support │ │ └── uri_filename_spec.rb │ └── storage │ │ ├── aws_spec.rb │ │ ├── aws_options_spec.rb │ │ └── aws_file_spec.rb ├── features │ ├── copying_files_spec.rb │ ├── moving_files_spec.rb │ └── storing_files_spec.rb ├── spec_helper.rb └── carrierwave-aws_spec.rb ├── .env.sample ├── .gitignore ├── lib ├── carrierwave │ ├── aws │ │ └── version.rb │ ├── support │ │ └── uri_filename.rb │ └── storage │ │ ├── aws.rb │ │ ├── aws_options.rb │ │ └── aws_file.rb └── carrierwave-aws.rb ├── Gemfile ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── gemfiles ├── carrierwave-2.gemfile ├── carrierwave-3.gemfile └── carrierwave-master.gemfile ├── Rakefile ├── .rubocop.yml ├── carrierwave-aws.gemspec ├── LICENSE.txt ├── CHANGELOG.md └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /spec/fixtures/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carrierwaveuploader/carrierwave-aws/HEAD/spec/fixtures/image.png -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | S3_BUCKET_NAME=BUCKET_NAME 2 | S3_ACCESS_KEY=YOUR_KEY 3 | S3_SECRET_ACCESS_KEY=YOUR_KEY 4 | AWS_REGION=BUCKET_REGION 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | .config 4 | .env 5 | Gemfile.lock 6 | bin 7 | gemfiles/*.lock 8 | vendor 9 | uploads 10 | -------------------------------------------------------------------------------- /lib/carrierwave/aws/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Carrierwave 4 | module AWS 5 | VERSION = '1.6.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'coveralls', '~> 0.8.21', require: false 6 | gem 'rubocop', '~> 1.28', require: false 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /gemfiles/carrierwave-2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'carrierwave', '~> 2.0' 6 | gem 'concurrent-ruby', '1.3.4' # Tests fail with 1.3.5 on Ruby 2.5 and 2.6 7 | 8 | gemspec path: '../' 9 | -------------------------------------------------------------------------------- /gemfiles/carrierwave-3.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'carrierwave', '~> 3.0' 6 | gem 'concurrent-ruby', '1.3.4' # Tests fail with 1.3.5 on Ruby 2.5 and 2.6 7 | 8 | gemspec path: '../' 9 | -------------------------------------------------------------------------------- /gemfiles/carrierwave-master.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gem 'carrierwave', github: 'carrierwaveuploader/carrierwave' 6 | gem 'concurrent-ruby', '1.3.4' # Tests fail with 1.3.5 on Ruby 2.5 and 2.6 7 | 8 | gemspec path: '../' 9 | -------------------------------------------------------------------------------- /lib/carrierwave/support/uri_filename.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CarrierWave 4 | module Support 5 | module UriFilename 6 | def self.filename(url) 7 | path = url.split('?').first 8 | 9 | CGI.unescape(path).gsub(%r{.*/(.*?$)}, '\1') 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'bundler/gem_tasks' 3 | 4 | Bundler.setup 5 | 6 | begin 7 | require 'rspec/core/rake_task' 8 | RSpec::Core::RakeTask.new(:spec) 9 | rescue LoadError 10 | puts 'rspec not loaded' 11 | end 12 | 13 | begin 14 | require 'rubocop/rake_task' 15 | RuboCop::RakeTask.new 16 | rescue LoadError 17 | puts 'rubocop not loaded' 18 | end 19 | 20 | task default: %i[rubocop spec] 21 | -------------------------------------------------------------------------------- /spec/carrierwave/support/uri_filename_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CarrierWave::Support::UriFilename do 4 | describe '.filename' do 5 | it 'extracts a decoded filename from file uri' do 6 | samples = { 7 | 'http://ex.com/file.txt' => 'file.txt', 8 | 'http://ex.com/files/1/file%201.txt?foo=bar/baz.txt' => 'file 1.txt' 9 | } 10 | 11 | samples.each do |uri, name| 12 | expect(CarrierWave::Support::UriFilename.filename(uri)).to eq(name) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/carrierwave/storage/aws_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CarrierWave::Storage::AWS do 4 | let(:credentials) do 5 | { access_key_id: 'abc', secret_access_key: '123', region: 'us-east-1' } 6 | end 7 | 8 | let(:uploader) { double(:uploader, aws_credentials: credentials) } 9 | 10 | subject(:storage) do 11 | CarrierWave::Storage::AWS.new(uploader) 12 | end 13 | 14 | before do 15 | CarrierWave::Storage::AWS.clear_connection_cache! 16 | end 17 | 18 | describe '#connection' do 19 | it 'instantiates a new connection with credentials' do 20 | expect(Aws::S3::Resource).to receive(:new).with(credentials) 21 | 22 | storage.connection 23 | end 24 | 25 | it 'caches connections by credentials' do 26 | new_storage = CarrierWave::Storage::AWS.new(uploader) 27 | 28 | expect(storage.connection).to be(new_storage.connection) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - "bin/*" 4 | - "tmp/**/*" 5 | - "vendor/**/*" 6 | NewCops: disable 7 | TargetRubyVersion: 2.5 8 | 9 | Metrics/BlockLength: 10 | Exclude: 11 | - "spec/**/*" 12 | 13 | Metrics/ClassLength: 14 | Max: 102 15 | 16 | Naming/FileName: 17 | Exclude: 18 | - "gemfiles/*" 19 | - "lib/carrierwave-aws.rb" 20 | - "spec/carrierwave-aws_spec.rb" 21 | 22 | Style/FrozenStringLiteralComment: 23 | EnforcedStyle: always 24 | Exclude: 25 | - "Gemfile*" 26 | - "Rakefile" 27 | - "*.gemspec" 28 | - "spec/**/*" 29 | 30 | Style/IfUnlessModifier: 31 | Enabled: false 32 | 33 | # Documentation is coming 34 | Style/Documentation: 35 | Enabled: false 36 | 37 | Style/ExplicitBlockArgument: 38 | Enabled: false 39 | 40 | # Extend self preserves private methods 41 | Style/ModuleFunction: 42 | Enabled: false 43 | 44 | Style/PercentLiteralDelimiters: 45 | PreferredDelimiters: 46 | '%w': "[]" 47 | '%W': "[]" 48 | -------------------------------------------------------------------------------- /carrierwave-aws.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | require 'carrierwave/aws/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = 'carrierwave-aws' 8 | gem.version = Carrierwave::AWS::VERSION 9 | gem.authors = ['Parker Selbert'] 10 | gem.email = ['parker@sorentwo.com'] 11 | gem.homepage = 'https://github.com/sorentwo/carrierwave-aws' 12 | gem.description = 'Use aws-sdk for S3 support in CarrierWave' 13 | gem.summary = 'Native aws-sdk support for S3 in CarrierWave' 14 | gem.license = 'MIT' 15 | 16 | gem.files = `git ls-files -z lib spec`.split("\x0") 17 | gem.test_files = gem.files.grep(%r{^(spec)/}) 18 | gem.require_paths = ['lib'] 19 | 20 | gem.required_ruby_version = '>= 2.5.0' 21 | 22 | gem.add_dependency 'aws-sdk-s3', '~> 1.0' 23 | gem.add_dependency 'carrierwave', '>= 2.0', '< 4' 24 | 25 | gem.add_development_dependency 'nokogiri' 26 | gem.add_development_dependency 'rake', '>= 10.0' 27 | gem.add_development_dependency 'rspec', '~> 3.6' 28 | end 29 | -------------------------------------------------------------------------------- /spec/features/copying_files_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Copying Files', type: :feature do 4 | let(:image) { File.open('spec/fixtures/image.png', 'r') } 5 | let(:original) { FeatureUploader.new } 6 | 7 | it 'copies an existing file to the specified path' do 8 | original.store!(image) 9 | original.retrieve_from_store!('image.png') 10 | 11 | without_timestamp = ->(key, _) { key == :last_modified } 12 | 13 | original.file.copy_to("#{original.store_dir}/image2.png") 14 | 15 | copy = FeatureUploader.new 16 | copy.retrieve_from_store!('image2.png') 17 | 18 | original_attributes = original.file.attributes.reject(&without_timestamp) 19 | copy_attributes = copy.file.attributes.reject(&without_timestamp) 20 | 21 | copy_acl_grants = copy.file.file.acl.grants 22 | original_acl_grants = original.file.file.acl.grants 23 | 24 | expect(copy_attributes).to eq(original_attributes) 25 | expect(copy_acl_grants).to eq(original_acl_grants) 26 | 27 | image.close 28 | original.file.delete 29 | copy.file.delete 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/features/moving_files_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Moving Files', type: :feature do 4 | let(:image) { File.open('spec/fixtures/image.png', 'r') } 5 | let(:original) { FeatureUploader.new } 6 | 7 | it 'copies an existing file to the specified path' do 8 | original.store!(image) 9 | original.retrieve_from_store!('image.png') 10 | 11 | without_timestamp = ->(key, _) { key == :last_modified } 12 | 13 | original_attributes = original.file.attributes.reject(&without_timestamp) 14 | original_acl_grants = original.file.file.acl.grants 15 | 16 | original.file.move_to("#{original.store_dir}/image2.png") 17 | 18 | move = FeatureUploader.new 19 | move.retrieve_from_store!('image2.png') 20 | 21 | copy_attributes = move.file.attributes.reject(&without_timestamp) 22 | copy_acl_grants = move.file.file.acl.grants 23 | 24 | expect(copy_attributes).to eq(original_attributes) 25 | expect(copy_acl_grants).to eq(original_acl_grants) 26 | expect(original.file).not_to exist 27 | 28 | image.close 29 | move.file.delete 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Parker Selbert 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/carrierwave/storage/aws.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'aws-sdk-s3' 4 | 5 | module CarrierWave 6 | module Storage 7 | class AWS < Abstract 8 | def self.connection_cache 9 | @connection_cache ||= {} 10 | end 11 | 12 | def self.clear_connection_cache! 13 | @connection_cache = {} 14 | end 15 | 16 | def store!(file) 17 | AWSFile.new(uploader, connection, uploader.store_path).tap do |aws_file| 18 | aws_file.store(file) 19 | end 20 | end 21 | 22 | def retrieve!(identifier) 23 | AWSFile.new(uploader, connection, uploader.store_path(identifier)) 24 | end 25 | 26 | def cache!(file) 27 | AWSFile.new(uploader, connection, uploader.cache_path).tap do |aws_file| 28 | aws_file.store(file) 29 | end 30 | end 31 | 32 | def retrieve_from_cache!(identifier) 33 | AWSFile.new(uploader, connection, uploader.cache_path(identifier)) 34 | end 35 | 36 | def delete_dir!(path) 37 | # NOTE: noop, because there are no directories on S3 38 | end 39 | 40 | def clean_cache!(_seconds) 41 | raise 'use Object Lifecycle Management to clean the cache' 42 | end 43 | 44 | def connection 45 | @connection ||= begin 46 | conn_cache = self.class.connection_cache 47 | 48 | conn_cache[credentials] ||= ::Aws::S3::Resource.new(*credentials) 49 | end 50 | end 51 | 52 | def credentials 53 | [uploader.aws_credentials].compact 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/carrierwave/storage/aws_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CarrierWave 4 | module Storage 5 | class AWSOptions 6 | MULTIPART_THRESHOLD = 15 * 1024 * 1024 7 | 8 | # Backward compatibility 9 | MULTIPART_TRESHOLD = MULTIPART_THRESHOLD 10 | 11 | attr_reader :uploader 12 | 13 | def initialize(uploader) 14 | @uploader = uploader 15 | end 16 | 17 | def read_options 18 | aws_read_options 19 | end 20 | 21 | def write_options(new_file) 22 | { 23 | acl: uploader.aws_acl, 24 | body: new_file.to_file, 25 | content_type: new_file.content_type 26 | }.merge(aws_attributes).merge(aws_write_options) 27 | end 28 | 29 | def move_options(file) 30 | { 31 | acl: uploader.aws_acl, 32 | multipart_copy: file.size >= MULTIPART_THRESHOLD 33 | }.merge(aws_attributes).merge(aws_write_options) 34 | end 35 | alias copy_options move_options 36 | 37 | def expiration_options(options = {}) 38 | uploader_expiration = uploader.aws_authenticated_url_expiration 39 | 40 | { expires_in: uploader_expiration }.merge(options) 41 | end 42 | 43 | private 44 | 45 | def aws_attributes 46 | attributes = uploader.aws_attributes 47 | return {} if attributes.nil? 48 | 49 | attributes.respond_to?(:call) ? attributes.call : attributes 50 | end 51 | 52 | def aws_read_options 53 | uploader.aws_read_options || {} 54 | end 55 | 56 | def aws_write_options 57 | uploader.aws_write_options || {} 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'carrierwave' 2 | require 'carrierwave-aws' 3 | require 'securerandom' 4 | 5 | STORE_DIR = ENV['TRAVIS_JOB_NUMBER'] || SecureRandom.hex(5) 6 | 7 | def source_environment_file! 8 | return unless File.exist?('.env') 9 | 10 | File.readlines('.env').each do |line| 11 | key, value = line.split('=') 12 | ENV[key] = value.chomp 13 | end 14 | end 15 | 16 | FeatureUploader = Class.new(CarrierWave::Uploader::Base) do 17 | def store_dir 18 | STORE_DIR 19 | end 20 | 21 | def filename 22 | 'image.png' 23 | end 24 | end 25 | 26 | RSpec.configure do |config| 27 | source_environment_file! 28 | 29 | config.mock_with :rspec do |mocks| 30 | mocks.verify_partial_doubles = true 31 | end 32 | 33 | config.filter_run :focus 34 | config.filter_run_excluding type: :feature unless ENV.key?('S3_BUCKET_NAME') 35 | config.run_all_when_everything_filtered = true 36 | config.order = :random 37 | 38 | if config.files_to_run.one? 39 | config.default_formatter = 'doc' 40 | end 41 | 42 | Kernel.srand config.seed 43 | 44 | config.before(:all, type: :feature) do 45 | CarrierWave.configure do |cw_config| 46 | cw_config.storage = :aws 47 | cw_config.cache_storage = :aws 48 | cw_config.aws_bucket = ENV['S3_BUCKET_NAME'] 49 | cw_config.aws_acl = :'public-read' 50 | 51 | cw_config.aws_credentials = { 52 | access_key_id: ENV['S3_ACCESS_KEY'], 53 | secret_access_key: ENV['S3_SECRET_ACCESS_KEY'], 54 | region: ENV['AWS_REGION'] 55 | }.merge( 56 | ENV['CI'] && { 57 | endpoint: 'http://127.0.0.1:9000', 58 | force_path_style: true 59 | } || {} 60 | ) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: RSpec 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | ruby: ['2.5', '2.6', '2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] 12 | gemfile: [gemfiles/carrierwave-3.gemfile] 13 | experimental: [false] 14 | include: 15 | - ruby: '3.4' 16 | gemfile: gemfiles/carrierwave-2.gemfile 17 | experimental: false 18 | - ruby: '3.4' 19 | gemfile: gemfiles/carrierwave-master.gemfile 20 | experimental: false 21 | - ruby: ruby-head 22 | gemfile: gemfiles/carrierwave-3.gemfile 23 | experimental: true 24 | - ruby: jruby-head 25 | gemfile: gemfiles/carrierwave-2.gemfile 26 | experimental: false 27 | runs-on: ubuntu-24.04 28 | continue-on-error: ${{ matrix.experimental }} 29 | env: 30 | S3_BUCKET_NAME: test-bucket 31 | S3_ACCESS_KEY: DummyAccessKey 32 | S3_SECRET_ACCESS_KEY: DummySecret 33 | AWS_REGION: us-east-1 34 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 35 | JRUBY_OPTS: --debug 36 | steps: 37 | - name: Run minio 38 | run: | 39 | mkdir -p /tmp/data/test-bucket 40 | docker run -d -p 9000:9000 --name minio \ 41 | -e "MINIO_ACCESS_KEY=${{ env.S3_ACCESS_KEY }}" \ 42 | -e "MINIO_SECRET_KEY=${{ env.S3_SECRET_ACCESS_KEY }}" \ 43 | -v /tmp/data:/data \ 44 | -v /tmp/config:/root/.minio \ 45 | minio/minio server /data 46 | - uses: actions/checkout@v4 47 | - name: Set up Ruby 48 | uses: ruby/setup-ruby@v1 49 | with: 50 | ruby-version: ${{ matrix.ruby }} 51 | bundler-cache: true 52 | - name: Run RSpec 53 | run: bundle exec rake spec 54 | 55 | rubocop: 56 | name: RuboCop 57 | runs-on: ubuntu-24.04 58 | steps: 59 | - uses: actions/checkout@v4 60 | - name: Set up Ruby 61 | uses: ruby/setup-ruby@v1 62 | with: 63 | ruby-version: '3.4' 64 | bundler-cache: true 65 | - name: Run check 66 | run: bundle exec rake rubocop 67 | -------------------------------------------------------------------------------- /spec/carrierwave/storage/aws_options_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CarrierWave::Storage::AWSOptions do 4 | uploader_klass = Class.new do 5 | attr_accessor :aws_attributes, :aws_read_options, :aws_write_options 6 | 7 | def aws_acl 8 | 'public-read' 9 | end 10 | 11 | def aws_authenticated_url_expiration 12 | '60' 13 | end 14 | end 15 | 16 | let(:uploader) { uploader_klass.new } 17 | let(:options) { CarrierWave::Storage::AWSOptions.new(uploader) } 18 | 19 | describe '#read_options' do 20 | it 'uses the uploader aws_read_options' do 21 | uploader.aws_read_options = { encryption_key: 'abc' } 22 | 23 | expect(options.read_options).to eq(uploader.aws_read_options) 24 | end 25 | 26 | it 'ensures that read_options are a hash' do 27 | uploader.aws_read_options = nil 28 | 29 | expect(options.read_options).to eq({}) 30 | end 31 | end 32 | 33 | describe '#write_options' do 34 | let(:file) { CarrierWave::SanitizedFile.new('spec/fixtures/image.png') } 35 | 36 | it 'includes all access and file options' do 37 | uploader.aws_write_options = { encryption_key: 'def' } 38 | 39 | write_options = options.write_options(file) 40 | 41 | expect(write_options).to include( 42 | acl: 'public-read', 43 | content_type: 'image/png', 44 | encryption_key: 'def' 45 | ) 46 | expect(write_options[:body].path).to eq(file.path) 47 | end 48 | 49 | it 'works if aws_attributes is nil' do 50 | expect(uploader).to receive(:aws_attributes) { nil } 51 | 52 | expect { options.write_options(file) }.to_not raise_error 53 | end 54 | 55 | it 'works if aws_attributes is a Proc' do 56 | expect(uploader).to receive(:aws_attributes).and_return( 57 | -> { { expires: (Date.today + 7).httpdate } } 58 | ) 59 | 60 | expect { options.write_options(file) }.to_not raise_error 61 | end 62 | 63 | it 'works if aws_write_options is nil' do 64 | expect(uploader).to receive(:aws_write_options) { nil } 65 | 66 | expect { options.write_options(file) }.to_not raise_error 67 | end 68 | end 69 | 70 | describe '#expiration_options' do 71 | it 'extracts the expiration value' do 72 | expect(options.expiration_options).to eq( 73 | expires_in: uploader.aws_authenticated_url_expiration 74 | ) 75 | end 76 | 77 | it 'allows expiration to be overridden' do 78 | expect(options.expiration_options(expires_in: 10)).to eq(expires_in: 10) 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/carrierwave-aws.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'carrierwave' 4 | require 'carrierwave/aws/version' 5 | require 'carrierwave/storage/aws' 6 | require 'carrierwave/storage/aws_file' 7 | require 'carrierwave/storage/aws_options' 8 | require 'carrierwave/support/uri_filename' 9 | 10 | module CarrierWave 11 | module Uploader 12 | class Base 13 | ACCEPTED_ACL = %w[ 14 | private 15 | public-read 16 | public-read-write 17 | authenticated-read 18 | bucket-owner-read 19 | bucket-owner-full-control 20 | ].freeze 21 | 22 | ConfigurationError = Class.new(StandardError) 23 | 24 | add_config :aws_attributes 25 | add_config :aws_authenticated_url_expiration 26 | add_config :aws_credentials 27 | add_config :aws_bucket 28 | add_config :aws_read_options 29 | add_config :aws_write_options 30 | add_config :aws_acl 31 | add_config :aws_signer 32 | add_config :asset_host_public 33 | 34 | configure do |config| 35 | config.storage_engines[:aws] = 'CarrierWave::Storage::AWS' 36 | end 37 | 38 | def self.aws_acl=(acl) 39 | @aws_acl = normalized_acl(acl) 40 | end 41 | 42 | def self.normalized_acl(acl) 43 | return nil if acl.nil? 44 | 45 | normalized = acl.to_s.downcase.sub('_', '-') 46 | 47 | unless ACCEPTED_ACL.include?(normalized) 48 | raise ConfigurationError, "Invalid ACL option: #{normalized}" 49 | end 50 | 51 | normalized 52 | end 53 | 54 | def self.aws_signer(value = nil) 55 | self.aws_signer = value if value 56 | 57 | if instance_variable_defined?('@aws_signer') 58 | @aws_signer 59 | elsif superclass.respond_to? :aws_signer 60 | superclass.aws_signer 61 | end 62 | end 63 | 64 | def self.aws_signer=(signer) 65 | @aws_signer = validated_signer(signer) 66 | end 67 | 68 | def self.validated_signer(signer) 69 | unless signer.nil? || signer.instance_of?(Proc) && signer.arity == 2 70 | raise ConfigurationError, 71 | 'Invalid signer option. Signer proc has to respond to' \ 72 | '`.call(unsigned_url, options)`' 73 | end 74 | 75 | signer 76 | end 77 | 78 | def aws_acl=(acl) 79 | @aws_acl = self.class.normalized_acl(acl) 80 | end 81 | 82 | def aws_signer 83 | if instance_variable_defined?('@aws_signer') 84 | @aws_signer 85 | else 86 | self.class.aws_signer 87 | end 88 | end 89 | 90 | def asset_host_public 91 | if instance_variable_defined?('@asset_host_public') 92 | @asset_host_public 93 | else 94 | self.class.asset_host_public 95 | end || false 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/carrierwave/storage/aws_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/module/delegation' 4 | 5 | module CarrierWave 6 | module Storage 7 | class AWSFile 8 | attr_writer :file 9 | attr_accessor :uploader, :connection, :path, :aws_options 10 | 11 | delegate :content_type, :delete, :exists?, to: :file 12 | 13 | def initialize(uploader, connection, path) 14 | @uploader = uploader 15 | @connection = connection 16 | @path = path 17 | @aws_options = AWSOptions.new(uploader) 18 | end 19 | 20 | def file 21 | @file ||= bucket.object(path) 22 | end 23 | 24 | def size 25 | file.size 26 | rescue Aws::S3::Errors::NotFound 27 | nil 28 | end 29 | 30 | alias to_file file 31 | 32 | def attributes 33 | file.data.to_h 34 | end 35 | 36 | def extension 37 | elements = path.split('.') 38 | elements.last if elements.size > 1 39 | end 40 | 41 | def filename(options = {}) 42 | file_url = url(options) 43 | 44 | CarrierWave::Support::UriFilename.filename(file_url) if file_url 45 | end 46 | 47 | def read 48 | read_options = aws_options.read_options 49 | if block_given? 50 | file.get(read_options) { |chunk| yield chunk } 51 | nil 52 | else 53 | file.get(read_options).body.read 54 | end 55 | end 56 | 57 | def store(new_file) 58 | if new_file.is_a?(self.class) 59 | new_file.move_to(path) 60 | else 61 | file.upload_file(new_file.path, aws_options.write_options(new_file)) 62 | end 63 | end 64 | 65 | def copy_to(new_path) 66 | file.copy_to( 67 | bucket.object(new_path), 68 | aws_options.copy_options(self) 69 | ) 70 | end 71 | 72 | def move_to(new_path) 73 | file.move_to( 74 | "#{bucket.name}/#{new_path}", 75 | aws_options.move_options(self) 76 | ) 77 | end 78 | 79 | def signed_url(options = {}) 80 | signer.call(public_url.dup, options) 81 | end 82 | 83 | def authenticated_url(options = {}) 84 | if asset_host && asset_host == bucket.name 85 | # Can't use https, since plain S3 doesn't support custom TLS certificates 86 | options = options.reverse_merge(secure: false, virtual_host: true) 87 | end 88 | file.presigned_url(:get, aws_options.expiration_options(options)) 89 | end 90 | 91 | def public_url 92 | if asset_host 93 | "#{asset_host}/#{uri_path}" 94 | else 95 | file.public_url.to_s 96 | end 97 | end 98 | 99 | def url(options = {}) 100 | if signer 101 | signed_url(options) 102 | elsif public? 103 | public_url 104 | else 105 | authenticated_url(options) 106 | end 107 | end 108 | 109 | private 110 | 111 | def bucket 112 | @bucket ||= connection.bucket(uploader.aws_bucket) 113 | end 114 | 115 | def signer 116 | uploader.aws_signer 117 | end 118 | 119 | def uri_path 120 | path.gsub(%r{[^/]+}) { |segment| Seahorse::Util.uri_escape(segment) } 121 | end 122 | 123 | def public? 124 | uploader.aws_acl.to_s == 'public-read' || uploader.asset_host_public 125 | end 126 | 127 | def asset_host 128 | if uploader.asset_host.respond_to? :call 129 | uploader.asset_host.call(self) 130 | else 131 | uploader.asset_host 132 | end 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /spec/features/storing_files_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe 'Storing Files', type: :feature do 4 | let(:image) { File.open('spec/fixtures/image.png', 'r') } 5 | let(:instance) { FeatureUploader.new } 6 | 7 | before do 8 | instance.aws_acl = 'public-read' 9 | end 10 | 11 | it 'uploads the file to the configured bucket' do 12 | instance.store!(image) 13 | instance.retrieve_from_store!('image.png') 14 | 15 | expect(instance.file.size).to eq(image.size) 16 | expect(instance.file.read).to eq(image.read) 17 | expect(instance.file.read).to eq(instance.file.read) 18 | 19 | image.close 20 | instance.file.delete 21 | end 22 | 23 | it 'uploads a StringIO to the configured bucket' do 24 | # https://github.com/carrierwaveuploader/carrierwave/wiki/How-to:-Upload-from-a-string-in-Rails-3-or-later 25 | io = StringIO.new(image.read) 26 | 27 | def io.original_filename 28 | 'image.png' 29 | end 30 | image.rewind 31 | 32 | instance.store!(io) 33 | instance.retrieve_from_store!('image.png') 34 | 35 | expect(instance.file.size).to eq(image.size) 36 | expect(instance.file.read).to eq(image.read) 37 | expect(instance.file.read).to eq(instance.file.read) 38 | 39 | image.close 40 | instance.file.delete 41 | end 42 | 43 | it 'retrieves the attributes for a stored file' do 44 | instance.store!(image) 45 | instance.retrieve_from_store!('image.png') 46 | 47 | expect(instance.file.attributes).to include( 48 | :metadata, 49 | :content_type, 50 | :etag, 51 | :accept_ranges, 52 | :last_modified, 53 | :content_length 54 | ) 55 | 56 | expect(instance.file.content_type).to eq('image/png') 57 | expect(instance.file.filename).to eq('image.png') 58 | 59 | image.close 60 | instance.file.delete 61 | end 62 | 63 | it 'checks if a remote file exists' do 64 | instance.store!(image) 65 | instance.retrieve_from_store!('image.png') 66 | 67 | expect(instance.file.exists?).to be_truthy 68 | 69 | instance.file.delete 70 | 71 | expect(instance.file.exists?).to be_falsy 72 | 73 | image.close 74 | end 75 | 76 | it 'gets a public url for remote files' do 77 | instance.store!(image) 78 | instance.retrieve_from_store!('image.png') 79 | 80 | expect(instance.url).to include(ENV['S3_BUCKET_NAME']) 81 | expect(instance.url).to include(instance.path) 82 | 83 | image.close 84 | instance.file.delete 85 | end 86 | 87 | it 'gets a private url for remote files' do 88 | instance.aws_acl = 'private' 89 | instance.store!(image) 90 | instance.retrieve_from_store!('image.png') 91 | 92 | expect(instance.url).to include(ENV['S3_BUCKET_NAME']) 93 | expect(instance.url).to include(instance.path) 94 | expect(instance.url).to include('X-Amz-Signature=') 95 | 96 | image.close 97 | instance.file.delete 98 | end 99 | 100 | it 'respects asset_host for private urls' do 101 | allow(instance).to receive(:asset_host).and_return(ENV['S3_BUCKET_NAME']) 102 | instance.aws_acl = 'private' 103 | instance.store!(image) 104 | instance.retrieve_from_store!('image.png') 105 | 106 | expect(URI.parse(instance.url).scheme).to eq 'http' 107 | expect(URI.parse(instance.url).hostname).to eq ENV['S3_BUCKET_NAME'] 108 | expect(instance.url).to include(instance.path) 109 | expect(instance.url).to include('X-Amz-Signature=') 110 | 111 | image.close 112 | instance.file.delete 113 | end 114 | 115 | it 'uploads the cache file to the configured bucket' do 116 | instance.cache!(image) 117 | instance.retrieve_from_cache!(instance.cache_name) 118 | 119 | expect(instance.file.size).to be_nonzero 120 | expect(image.size).to eq(instance.file.size) 121 | 122 | image.close 123 | instance.file.delete 124 | end 125 | 126 | it 'moves cached files to final location when storing' do 127 | instance.cache!(image) 128 | cache_name = instance.cache_name 129 | instance.store! 130 | 131 | instance.retrieve_from_cache!(cache_name) 132 | expect(instance.file).not_to exist 133 | 134 | instance.retrieve_from_store!('image.png') 135 | expect(instance.file.size).to eq(image.size) 136 | expect(instance.file.read).to eq(image.read) 137 | expect(instance.file.read).to eq(instance.file.read) 138 | 139 | image.close 140 | instance.file.delete 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /spec/carrierwave-aws_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CarrierWave::Uploader::Base do 4 | let(:uploader) do 5 | Class.new(CarrierWave::Uploader::Base) 6 | end 7 | 8 | let(:derived_uploader) do 9 | Class.new(uploader) 10 | end 11 | 12 | it 'inserts aws as a known storage engine' do 13 | uploader.configure do |config| 14 | expect(config.storage_engines).to have_key(:aws) 15 | end 16 | end 17 | 18 | it 'defines aws specific storage options' do 19 | expect(uploader).to respond_to(:aws_attributes) 20 | end 21 | 22 | describe '#aws_acl' do 23 | it 'allows known access control values' do 24 | expect do 25 | uploader.aws_acl = 'private' 26 | uploader.aws_acl = 'public-read' 27 | uploader.aws_acl = 'authenticated-read' 28 | end.not_to raise_exception 29 | end 30 | 31 | it 'allows nil' do 32 | uploader.aws_acl = 'public-read' 33 | expect do 34 | uploader.aws_acl = nil 35 | end.to change { uploader.aws_acl }.from('public-read').to(nil) 36 | end 37 | 38 | it 'does not allow unknown control values' do 39 | expect do 40 | uploader.aws_acl = 'everybody' 41 | end.to raise_exception(CarrierWave::Uploader::Base::ConfigurationError) 42 | end 43 | 44 | it 'normalizes the set value' do 45 | uploader.aws_acl = :'public-read' 46 | expect(uploader.aws_acl).to eq('public-read') 47 | 48 | uploader.aws_acl = 'PUBLIC_READ' 49 | expect(uploader.aws_acl).to eq('public-read') 50 | end 51 | 52 | it 'can be overridden on an instance level' do 53 | instance = uploader.new 54 | 55 | uploader.aws_acl = 'private' 56 | instance.aws_acl = 'public-read' 57 | 58 | expect(uploader.aws_acl).to eq('private') 59 | expect(instance.aws_acl).to eq('public-read') 60 | end 61 | 62 | it 'can be looked up from superclass' do 63 | uploader.aws_acl = 'private' 64 | instance = derived_uploader.new 65 | 66 | expect(derived_uploader.aws_acl).to eq('private') 67 | expect(instance.aws_acl).to eq('private') 68 | end 69 | 70 | it 'can be overridden on a class level' do 71 | uploader.aws_acl = 'public-read' 72 | derived_uploader.aws_acl = 'private' 73 | 74 | base = uploader.new 75 | expect(base.aws_acl).to eq('public-read') 76 | 77 | instance = derived_uploader.new 78 | expect(instance.aws_acl).to eq('private') 79 | end 80 | 81 | it 'can be set with the configure block' do 82 | uploader.configure do |config| 83 | config.aws_acl = 'public-read' 84 | end 85 | 86 | expect(uploader.aws_acl).to eq('public-read') 87 | end 88 | end 89 | 90 | describe '#aws_signer' do 91 | let(:signer_proc) { ->(_unsigned, _options) {} } 92 | let(:other_signer) { ->(_unsigned, _options) {} } 93 | 94 | it 'allows proper signer object' do 95 | expect { uploader.aws_signer = signer_proc }.not_to raise_exception 96 | end 97 | 98 | it 'does not allow signer with unknown api' do 99 | signer_proc = ->(_unsigned) {} 100 | 101 | expect { uploader.aws_signer = signer_proc } 102 | .to raise_exception(CarrierWave::Uploader::Base::ConfigurationError) 103 | end 104 | 105 | it 'can be overridden on an instance level' do 106 | instance = uploader.new 107 | 108 | uploader.aws_signer = signer_proc 109 | instance.aws_signer = other_signer 110 | 111 | expect(uploader.aws_signer).to eql(signer_proc) 112 | expect(instance.aws_signer).to eql(other_signer) 113 | end 114 | 115 | it 'can be overridden on a class level' do 116 | uploader.aws_signer = signer_proc 117 | derived_uploader.aws_signer = other_signer 118 | 119 | base = uploader.new 120 | expect(base.aws_signer).to eq(signer_proc) 121 | 122 | instance = derived_uploader.new 123 | expect(instance.aws_signer).to eql(other_signer) 124 | end 125 | 126 | it 'can be looked up from superclass' do 127 | uploader.aws_signer = signer_proc 128 | instance = derived_uploader.new 129 | 130 | expect(derived_uploader.aws_signer).to eq(signer_proc) 131 | expect(instance.aws_signer).to eql(signer_proc) 132 | end 133 | 134 | it 'can be set with the configure block' do 135 | uploader.configure do |config| 136 | config.aws_signer = signer_proc 137 | end 138 | 139 | expect(uploader.aws_signer).to eql(signer_proc) 140 | end 141 | 142 | it 'can be set when passed as argument to the class getter method' do 143 | uploader.aws_signer signer_proc 144 | 145 | expect(uploader.aws_signer).to eql(signer_proc) 146 | end 147 | end 148 | 149 | describe '#asset_host_public' do 150 | it 'can be overridden on an instance level' do 151 | instance = uploader.new 152 | 153 | uploader.asset_host_public = true 154 | instance.asset_host_public = false 155 | 156 | expect(uploader.asset_host_public).to eq(true) 157 | expect(instance.asset_host_public).to eq(false) 158 | end 159 | 160 | it 'can be looked up from superclass' do 161 | uploader.asset_host_public = true 162 | instance = derived_uploader.new 163 | 164 | expect(derived_uploader.asset_host_public).to eq(true) 165 | expect(instance.asset_host_public).to eq(true) 166 | end 167 | 168 | it 'can be overridden on a class level' do 169 | uploader.asset_host_public = true 170 | derived_uploader.asset_host_public = false 171 | 172 | base = uploader.new 173 | expect(base.asset_host_public).to eq(true) 174 | 175 | instance = derived_uploader.new 176 | expect(instance.asset_host_public).to eq(false) 177 | end 178 | 179 | it 'can be set with the configure block' do 180 | uploader.configure do |config| 181 | config.asset_host_public = true 182 | end 183 | 184 | expect(uploader.asset_host_public).to eq(true) 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Unreleased 2 | 3 | ## Version 1.6.0 2023-07-23 4 | 5 | * Added: Support setting #aws_acl to nil for bucket-level ACL compatibility 6 | * Added: Support S3 CNAME-style virtual host access for private URLs 7 | * Added: Support dynamic asset host 8 | * Added: Support CarrierWave 3.0 9 | * Changed: Update implementation of `AWSFile#copy_to` to use `S3Object#copy_to` API [Yingbai He] 10 | 11 | ## Version 1.5.0 2020-04-01 12 | 13 | * Fix Setting `asset_host_public`, which was removed in a recent version of 14 | CarrierWave. 15 | * Replace `URI.decode` with `CGI.unescape`, as the former is deprecated 16 | * Relax `CarrierWave` version constraint to any major version matching 2.0 17 | 18 | ## Version 1.4.0 2019-09-03 19 | 20 | * Added: Use `aws_options` for copying and moving files [Fabian Schwahn] 21 | * Added: Add support for serving from a private bucket via a public CDN [Rod 22 | Xavier] 23 | * Changed: Support using a lambda for `aws_attributes` as a collection of options 24 | [Marcus Ilgner] 25 | * Changed: Enable `multipart_copy` for copying / moving files that are larger 26 | than 15mb [Fabian Schwahn] 27 | * Changed: Bumpt the CarrierWave version constraint to allow 2.0 28 | * Fixed: URL encode paths when constructing `public_url` for objects 29 | 30 | ## Version 1.3.0 2017-09-27 31 | 32 | * Changed: Rely on the smaller and more specific `aws-sdk-s3` gem. 33 | 34 | ## Version 1.2.0 2017-07-24 35 | 36 | * Changed: Add support for large uploads via `#upload_file` rather than `#put`. 37 | * Manages multipart uploads for objects larger than 15MB. 38 | * Correctly opens files in binary mode to avoid encoding issues. 39 | * Uses multiple threads for uploading parts of large objects in parallel. 40 | See # #116, thanks to [Ylan Segal](@ylansegal). 41 | * Changed: Upgrade expected `aws-sdk` to `2.1` 42 | * Fixed: Return `nil` rather than raising an error for `File#size` when the file 43 | can't be found. 44 | 45 | ## Version 1.1.0 2017-02-24 46 | 47 | * Added: Enable using AWS for cache storage, making it easy to do direct file 48 | uploads. [Fabian Schwahn] 49 | * Added: Block support for reading from AWS files. This prevents dumping the 50 | entire object into memory, which is a problem with large objects. [Thomas Scholz] 51 | * Fixed: Duplicate the `public_url` before signing. All of the strings are 52 | frozen, and some cloud signing methods attempt to mutate the url. 53 | 54 | ## Version 1.0.2 2016-09-26 55 | 56 | * Fixed: Use `Aws.eager_load` to bypass autoloading for the `S3` resource. This 57 | prevents a race condition in multi threaded environments where an undefined 58 | error is raised for `Aws::S3::Resource` on any request that loads an uploaded 59 | file. 60 | 61 | ## Version 1.0.1 2016-05-13 62 | 63 | * Fixed: The `copy_to` method of `AWS::File` now uses the same `aws_acl` 64 | configuration used on original uploads so ACL on copied files matches original 65 | files. [Olivier Lacan] 66 | 67 | ## Version 1.0.0 2015-09-18 68 | 69 | * Added: ACL options are verified when they are set, and coerced into usable 70 | values when possible. 71 | * Added: Specify an `aws_signer` lambda for use signing authenticated content 72 | served through services like CloudFront. 73 | 74 | ## Version 1.0.0-rc.1 2015-07-02 75 | 76 | * Continues where 0.6.0 left off. This wraps AWS-SDK v2 and all of the breaking 77 | changes that contains. Please see the specific breaking change notes contained 78 | in `0.6.0` below. 79 | 80 | ## Version 0.7.0 2015-07-02 81 | 82 | * Revert to AWS-SDK v1. There are too many breaking changes between v1 and v2 to 83 | be wrapped in a minor version change. This effectively reverts all changes 84 | betwen `0.5.0` and `0.6.0`, restoring the old `0.5.0` behavior. 85 | 86 | ## Version 0.6.0 2015-06-26 87 | 88 | * Breaking Change: Updated to use AWS-SDK v2 [Mark Oleson] 89 | * You must specify a region in your `aws_credentials` configuration 90 | * You must use hyphens in ACLs instead of underscores (`:public_read` becomes 91 | `:'public-read'` or `'public-read'`) 92 | * Authenticated URL's are now longer than 255 characters. If you are caching 93 | url values you'll need to ensure columns allow 255+ characters. 94 | * Authenticated URL expiration has been limited to 7 days. 95 | 96 | ## Version 0.5.0 2015-01-31 97 | 98 | * Change: Nudge the expected AWS-SDK version. 99 | * Fix `exists?` method of AWS::File (previously it always returned true) 100 | [Felix Bünemann] 101 | * Fix `filename` method of AWS::File for private files and remove url encoding. 102 | [Felix Bünemann] 103 | 104 | ## Version 0.4.1 2014-03-28 105 | 106 | * Fix regression in `aws_read_options` defaulting to `nil` rather than an empty 107 | hash. [Johannes Würbach] 108 | 109 | ## Version 0.4.0 2014-03-20 110 | 111 | * Allow custom options for authenticated urls [Filipe Giusti] 112 | * Loosen aws-sdk constraints 113 | * Add `aws_read_options` and `aws_write_options` [Erik Hanson and Greg Woodward] 114 | 115 | ## Version 0.3.2 2013-08-06 116 | 117 | * And we're back to passing the path. An updated integration test confirms it 118 | is working properly. 119 | 120 | ## Version 0.3.1 2013-05-23 121 | 122 | * Use the "alternate" object writing syntax. The primary method (as documented) 123 | only uploads the path itself rather than the file. 124 | 125 | ## Version 0.3.0 2013-05-23 126 | 127 | * Pass the file path directly to aws-sdk to prevent upload timeouts stemming 128 | incorrect `content_length`. 129 | 130 | ## Version 0.2.1 2013-04-20 131 | 132 | * Provide a `to_file` method on AWS::File in an attempt to prevent errors when 133 | re-uploading a cached file. 134 | 135 | ## Version 0.2.0 2013-04-19 136 | 137 | * Update aws-sdk depdendency to 1.8.5 138 | * Clean up some internal storage object passing 139 | 140 | ## Version 0.1.1 2013-04-09 141 | 142 | * Fix storage bug when if `aws_attributes` is blank [#1] 143 | 144 | ## Version 0.1.0 2013-02-04 145 | 146 | * Initial release, experimental with light expectation based spec coverage. 147 | -------------------------------------------------------------------------------- /spec/carrierwave/storage/aws_file_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CarrierWave::Storage::AWSFile do 4 | let(:path) { 'files/1/file.txt' } 5 | let(:file) { double(:file, content_type: 'octet', path: '/file') } 6 | let(:bucket) { double(:bucket, object: file) } 7 | let(:connection) { double(:connection, bucket: bucket) } 8 | 9 | let(:uploader) do 10 | double(:uploader, 11 | aws_bucket: 'example-com', 12 | aws_acl: :'public-read', 13 | aws_attributes: {}, 14 | asset_host: nil, 15 | aws_signer: nil, 16 | aws_read_options: { encryption_key: 'abc' }, 17 | aws_write_options: { encryption_key: 'def' }) 18 | end 19 | 20 | subject(:aws_file) do 21 | CarrierWave::Storage::AWSFile.new(uploader, connection, path) 22 | end 23 | 24 | describe '#to_file' do 25 | it 'returns the internal file instance' do 26 | file = Object.new 27 | aws_file.file = file 28 | 29 | expect(aws_file.to_file).to be(file) 30 | end 31 | end 32 | 33 | describe '#extension' do 34 | it 'extracts the file extension from the path' do 35 | aws_file.path = 'file.txt' 36 | 37 | expect(aws_file.extension).to eq('txt') 38 | end 39 | 40 | it 'is nil if the file has no extension' do 41 | aws_file.path = 'filetxt' 42 | 43 | expect(aws_file.extension).to be_nil 44 | end 45 | end 46 | 47 | describe '#read' do 48 | let(:s3_object) { instance_double('Aws::S3::Object') } 49 | 50 | it 'reads the retrieved body if called without block' do 51 | aws_file.file = s3_object 52 | 53 | expect(s3_object).to receive_message_chain('get.body.read') 54 | aws_file.read 55 | end 56 | 57 | it 'does not retrieve body if block given' do 58 | aws_file.file = s3_object 59 | block = proc {} 60 | 61 | expect(s3_object).to receive('get') 62 | expect(aws_file.read(&block)).to be_nil 63 | end 64 | end 65 | 66 | describe '#url' do 67 | it 'requests a public url if acl is public readable' do 68 | allow(uploader).to receive(:aws_acl) { :'public-read' } 69 | 70 | expect(file).to receive(:public_url) 71 | 72 | aws_file.url 73 | end 74 | 75 | it 'requests a public url if asset_host_public' do 76 | allow(uploader).to receive(:aws_acl) { :'authenticated-read' } 77 | allow(uploader).to receive(:asset_host_public) { true } 78 | 79 | expect(file).to receive(:public_url) 80 | 81 | aws_file.url 82 | end 83 | 84 | it 'requests an authenticated url if acl is not public readable' do 85 | allow(uploader).to receive(:aws_acl) { :private } 86 | allow(uploader).to receive(:aws_authenticated_url_expiration) { 60 } 87 | allow(uploader).to receive(:asset_host_public) { false } 88 | 89 | expect(file).to receive(:presigned_url).with(:get, { expires_in: 60 }) 90 | 91 | aws_file.url 92 | end 93 | 94 | it 'requests an signed url if url signing is configured' do 95 | signature = 'Signature=QWERTZ&Key-Pair-Id=XYZ' 96 | 97 | cloudfront_signer = lambda do |unsigned_url, _| 98 | [unsigned_url, signature].join('?') 99 | end 100 | 101 | allow(uploader).to receive(:aws_signer) { cloudfront_signer } 102 | expect(file).to receive(:public_url) { 'http://example.com' } 103 | 104 | expect(aws_file.url).to eq "http://example.com?#{signature}" 105 | end 106 | 107 | it 'uses the asset_host and file path if asset_host is set' do 108 | allow(uploader).to receive(:aws_acl) { :'public-read' } 109 | allow(uploader).to receive(:asset_host) { 'http://example.com' } 110 | 111 | expect(aws_file.url).to eq('http://example.com/files/1/file.txt') 112 | end 113 | 114 | it 'accepts the asset_host given as a proc' do 115 | allow(uploader).to receive(:aws_acl) { :'public-read' } 116 | allow(uploader).to receive(:asset_host) do 117 | proc do |file| 118 | expect(file).to be_instance_of(CarrierWave::Storage::AWSFile) 119 | 'https://example.com' 120 | end 121 | end 122 | 123 | expect(aws_file.url).to eq('https://example.com/files/1/file.txt') 124 | end 125 | end 126 | 127 | describe '#store' do 128 | context 'when new_file is an AWSFile' do 129 | let(:new_file) do 130 | CarrierWave::Storage::AWSFile.new(uploader, connection, path) 131 | end 132 | 133 | it 'moves the object' do 134 | expect(new_file).to receive(:move_to).with(path) 135 | aws_file.store(new_file) 136 | end 137 | end 138 | 139 | context 'when new file if a SanitizedFile' do 140 | let(:new_file) do 141 | CarrierWave::SanitizedFile.new('spec/fixtures/image.png') 142 | end 143 | 144 | it 'uploads the file using with multipart support' do 145 | expect(file).to(receive(:upload_file) 146 | .with(new_file.path, an_instance_of(Hash))) 147 | aws_file.store(new_file) 148 | end 149 | end 150 | end 151 | 152 | describe '#public_url' do 153 | it 'uri-encodes the path' do 154 | allow(uploader).to receive(:asset_host) { 'http://example.com' } 155 | aws_file.path = 'uploads/images/jekyll+and+hyde.txt' 156 | expect(aws_file.public_url).to eq 'http://example.com/uploads/images/jekyll%2Band%2Bhyde.txt' 157 | end 158 | end 159 | 160 | describe '#copy_to' do 161 | let(:new_s3_object) { instance_double('Aws::S3::Object') } 162 | 163 | it 'copies file to target path' do 164 | new_path = 'files/2/file.txt' 165 | expect(bucket).to receive(:object).with(new_path).and_return(new_s3_object) 166 | expect(file).to receive(:size).at_least(:once).and_return(1024) 167 | expect(file).to( 168 | receive(:copy_to).with( 169 | new_s3_object, 170 | aws_file.aws_options.copy_options(aws_file) 171 | ) 172 | ) 173 | aws_file.copy_to new_path 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Carrierwave AWS Storage 2 | 3 | [![Test](https://github.com/carrierwaveuploader/carrierwave-aws/actions/workflows/test.yml/badge.svg)](https://github.com/carrierwaveuploader/carrierwave-aws/actions/workflows/test.yml) 4 | [![Code Climate](https://codeclimate.com/github/sorentwo/carrierwave-aws.svg)](https://codeclimate.com/github/sorentwo/carrierwave-aws) 5 | [![Gem Version](https://badge.fury.io/rb/carrierwave-aws.svg)](http://badge.fury.io/rb/carrierwave-aws) 6 | 7 | Use the officially supported AWS-SDK library for S3 storage rather than relying 8 | on fog. There are several things going for it: 9 | 10 | * Full featured, it supports more of the API than Fog 11 | * Significantly smaller footprint 12 | * Fewer dependencies 13 | * Clear documentation 14 | 15 | Here is a simple comparison table [07/17/2013] 16 | 17 | | Library | Disk Space | Lines of Code | Boot Time | Runtime Deps | Develop Deps | 18 | | ------- | ---------- | ------------- | --------- | ------------ | ------------ | 19 | | fog | 28.0M | 133469 | 0.693 | 9 | 11 | 20 | | aws-sdk | 5.4M | 90290 | 0.098 | 3 | 8 | 21 | 22 | ## Installation 23 | 24 | Add this line to your application's Gemfile: 25 | 26 | ```ruby 27 | gem 'carrierwave-aws' 28 | ``` 29 | 30 | Run the bundle command from your shell to install it: 31 | ```bash 32 | bundle install 33 | ``` 34 | 35 | ## Usage 36 | 37 | Configure and use it just like you would Fog. The only notable difference is 38 | the use of `aws_bucket` instead of `fog_directory`, and `aws_acl` instead of 39 | `fog_public`. 40 | 41 | ```ruby 42 | CarrierWave.configure do |config| 43 | config.storage = :aws 44 | config.aws_bucket = ENV.fetch('S3_BUCKET_NAME') # for AWS-side bucket access permissions config, see section below 45 | config.aws_acl = 'private' 46 | 47 | # Optionally define an asset host for configurations that are fronted by a 48 | # content host, such as CloudFront. 49 | config.asset_host = 'http://example.com' 50 | # config.asset_host = proc { |file| ... } # or can be a proc 51 | 52 | # The maximum period for authenticated_urls is only 7 days. 53 | config.aws_authenticated_url_expiration = 60 * 60 * 24 * 7 54 | 55 | # Set custom options such as cache control to leverage browser caching. 56 | # You can use either a static Hash or a Proc. 57 | config.aws_attributes = -> { { 58 | expires: 1.week.from_now.httpdate, 59 | cache_control: 'max-age=604800' 60 | } } 61 | 62 | config.aws_credentials = { 63 | access_key_id: ENV.fetch('AWS_ACCESS_KEY_ID'), 64 | secret_access_key: ENV.fetch('AWS_SECRET_ACCESS_KEY'), 65 | region: ENV.fetch('AWS_REGION'), # Required 66 | stub_responses: Rails.env.test? # Optional, avoid hitting S3 actual during tests 67 | } 68 | 69 | # Optional: Signing of download urls, e.g. for serving private content through 70 | # CloudFront. Be sure you have the `cloudfront-signer` gem installed and 71 | # configured: 72 | # config.aws_signer = -> (unsigned_url, options) do 73 | # Aws::CF::Signer.sign_url(unsigned_url, options) 74 | # end 75 | end 76 | ``` 77 | ### Custom options for S3 endpoint 78 | 79 | If you are using a non-standard endpoint for S3 service (eg: Swiss-based Exoscale S3) you can override it like this 80 | 81 | ```ruby 82 | config.aws_credentials[:endpoint] = 'my.custom.s3.service.com' 83 | ``` 84 | 85 | ### Custom options for AWS URLs 86 | 87 | If you have a custom uploader that specifies additional headers for each URL, 88 | please try the following example: 89 | 90 | ```ruby 91 | class MyUploader < Carrierwave::Uploader::Base 92 | # Storage configuration within the uploader supercedes the global CarrierWave 93 | # config, so either comment out `storage :file`, or remove that line, otherwise 94 | # AWS will not be used. 95 | storage :aws 96 | 97 | # You can find a full list of custom headers in AWS SDK documentation on 98 | # AWS::S3::S3Object 99 | def download_url(filename) 100 | url(response_content_disposition: %Q{attachment; filename="#{filename}"}) 101 | end 102 | end 103 | ``` 104 | 105 | ### Configure the role for bucket access 106 | 107 | The IAM role accessing the AWS bucket specified when configuring `CarrierWave` needs to be given access permissions to that bucket. Apart from the obvious permissions required depending on what you want to do (read, write, delete…), you need to grant the `s3:PutObjectAcl` permission ([a permission to manipulate single objects´ access permissions](https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPUTacl.html)) lest you receive an `AccessDenied` error. The policy for the role will look something like this: 108 | 109 | ```yaml 110 | PolicyDocument: 111 | Version: '2012-10-17' 112 | Statement: 113 | - Effect: Allow 114 | Action: 115 | - s3:ListBucket 116 | Resource: !Sub 'arn:aws:s3:::${BucketName}' 117 | - Effect: Allow 118 | Action: 119 | - s3:PutObject 120 | - s3:PutObjectAcl 121 | - s3:GetObject 122 | - s3:DeleteObject 123 | Resource: !Sub 'arn:aws:s3:::${BucketName}/*' 124 | ``` 125 | 126 | Remember to also unblock ACL changes in the bucket settings, in `Permissions > Public access settings > Manage public access control lists (ACLs)`. 127 | 128 | ## Migrating From Fog 129 | 130 | If you migrate from `fog` your uploader may be configured as `storage :fog`, 131 | simply comment out that line, as in the following example, or remove that 132 | specific line. 133 | 134 | ```ruby 135 | class MyUploader < Carrierwave::Uploader::Base 136 | # Storage configuration within the uploader supercedes the global CarrierWave 137 | # config, so adjust accordingly... 138 | 139 | # Choose what kind of storage to use for this uploader: 140 | # storage :file 141 | # storage :fog 142 | storage :aws 143 | 144 | 145 | # More comments below in your file.... 146 | end 147 | ``` 148 | 149 | Another item particular to fog, you may have `url(query: {'my-header': 'my-value'})`. 150 | With `carrierwave-aws` the `query` part becomes obsolete, just use a hash of 151 | headers. Please read [usage][#Usage] for a more detailed explanation about 152 | configuration. 153 | 154 | ## Contributing 155 | 156 | In order to run the integration specs you will need to configure some 157 | environment variables. A sample file is provided as `.env.sample`. Copy it over 158 | and plug in the appropriate values. 159 | 160 | ```bash 161 | cp .env.sample .env 162 | ``` 163 | 164 | 1. Fork it 165 | 2. Create your feature branch (`git checkout -b my-new-feature`) 166 | 3. Commit your changes (`git commit -am 'Add some feature'`) 167 | 4. Push to the branch (`git push origin my-new-feature`) 168 | 5. Create new Pull Request 169 | --------------------------------------------------------------------------------