activestorage_legacy 0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.babelrc +5 -0
- data/.codeclimate.yml +7 -0
- data/.eslintrc +19 -0
- data/.github/workflows/gem-push.yml +29 -0
- data/.github/workflows/ruby-tests.yml +37 -0
- data/.gitignore +9 -0
- data/.rubocop.yml +125 -0
- data/.travis.yml +25 -0
- data/Gemfile +33 -0
- data/Gemfile.lock +271 -0
- data/MIT-LICENSE +20 -0
- data/README.md +160 -0
- data/Rakefile +12 -0
- data/activestorage.gemspec +27 -0
- data/app/assets/javascripts/activestorage.js +1 -0
- data/app/controllers/active_storage/blobs_controller.rb +22 -0
- data/app/controllers/active_storage/direct_uploads_controller.rb +21 -0
- data/app/controllers/active_storage/disk_controller.rb +52 -0
- data/app/controllers/active_storage/variants_controller.rb +28 -0
- data/app/helpers/active_storage/file_field_with_direct_upload_helper.rb +18 -0
- data/app/javascript/activestorage/blob_record.js +54 -0
- data/app/javascript/activestorage/blob_upload.js +34 -0
- data/app/javascript/activestorage/direct_upload.js +42 -0
- data/app/javascript/activestorage/direct_upload_controller.js +67 -0
- data/app/javascript/activestorage/direct_uploads_controller.js +50 -0
- data/app/javascript/activestorage/file_checksum.js +53 -0
- data/app/javascript/activestorage/helpers.js +42 -0
- data/app/javascript/activestorage/index.js +11 -0
- data/app/javascript/activestorage/ujs.js +74 -0
- data/app/jobs/active_storage/purge_attachment_worker.rb +9 -0
- data/app/jobs/active_storage/purge_blob_worker.rb +9 -0
- data/app/models/active_storage/attachment.rb +33 -0
- data/app/models/active_storage/blob.rb +198 -0
- data/app/models/active_storage/filename.rb +49 -0
- data/app/models/active_storage/variant.rb +82 -0
- data/app/models/active_storage/variation.rb +53 -0
- data/config/routes.rb +9 -0
- data/config/storage_services.yml +34 -0
- data/lib/active_storage/attached/macros.rb +86 -0
- data/lib/active_storage/attached/many.rb +51 -0
- data/lib/active_storage/attached/one.rb +56 -0
- data/lib/active_storage/attached.rb +38 -0
- data/lib/active_storage/engine.rb +81 -0
- data/lib/active_storage/gem_version.rb +15 -0
- data/lib/active_storage/log_subscriber.rb +48 -0
- data/lib/active_storage/messages_metadata.rb +64 -0
- data/lib/active_storage/migration.rb +27 -0
- data/lib/active_storage/patches/active_record.rb +19 -0
- data/lib/active_storage/patches/delegation.rb +98 -0
- data/lib/active_storage/patches/secure_random.rb +26 -0
- data/lib/active_storage/patches.rb +4 -0
- data/lib/active_storage/service/azure_service.rb +115 -0
- data/lib/active_storage/service/configurator.rb +28 -0
- data/lib/active_storage/service/disk_service.rb +124 -0
- data/lib/active_storage/service/gcs_service.rb +79 -0
- data/lib/active_storage/service/mirror_service.rb +46 -0
- data/lib/active_storage/service/s3_service.rb +96 -0
- data/lib/active_storage/service.rb +113 -0
- data/lib/active_storage/verifier.rb +113 -0
- data/lib/active_storage/version.rb +8 -0
- data/lib/active_storage.rb +34 -0
- data/lib/tasks/activestorage.rake +20 -0
- data/package.json +33 -0
- data/test/controllers/direct_uploads_controller_test.rb +123 -0
- data/test/controllers/disk_controller_test.rb +57 -0
- data/test/controllers/variants_controller_test.rb +21 -0
- data/test/database/create_users_migration.rb +7 -0
- data/test/database/setup.rb +6 -0
- data/test/dummy/Rakefile +3 -0
- data/test/dummy/app/assets/config/manifest.js +5 -0
- data/test/dummy/app/assets/images/.keep +0 -0
- data/test/dummy/app/assets/javascripts/application.js +13 -0
- data/test/dummy/app/assets/stylesheets/application.css +15 -0
- data/test/dummy/app/controllers/application_controller.rb +3 -0
- data/test/dummy/app/controllers/concerns/.keep +0 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/jobs/application_job.rb +2 -0
- data/test/dummy/app/models/application_record.rb +3 -0
- data/test/dummy/app/models/concerns/.keep +0 -0
- data/test/dummy/app/views/layouts/application.html.erb +14 -0
- data/test/dummy/bin/bundle +3 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/yarn +11 -0
- data/test/dummy/config/application.rb +22 -0
- data/test/dummy/config/boot.rb +5 -0
- data/test/dummy/config/database.yml +25 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +49 -0
- data/test/dummy/config/environments/production.rb +82 -0
- data/test/dummy/config/environments/test.rb +33 -0
- data/test/dummy/config/initializers/application_controller_renderer.rb +6 -0
- data/test/dummy/config/initializers/assets.rb +14 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/cookies_serializer.rb +5 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/initializers/mime_types.rb +4 -0
- data/test/dummy/config/initializers/secret_key.rb +3 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/test/dummy/config/routes.rb +2 -0
- data/test/dummy/config/secrets.yml +32 -0
- data/test/dummy/config/spring.rb +6 -0
- data/test/dummy/config/storage_services.yml +3 -0
- data/test/dummy/config.ru +5 -0
- data/test/dummy/db/.keep +0 -0
- data/test/dummy/lib/assets/.keep +0 -0
- data/test/dummy/log/.keep +0 -0
- data/test/dummy/package.json +5 -0
- data/test/dummy/public/404.html +67 -0
- data/test/dummy/public/422.html +67 -0
- data/test/dummy/public/500.html +66 -0
- data/test/dummy/public/apple-touch-icon-precomposed.png +0 -0
- data/test/dummy/public/apple-touch-icon.png +0 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/filename_test.rb +36 -0
- data/test/fixtures/files/racecar.jpg +0 -0
- data/test/models/attachments_test.rb +122 -0
- data/test/models/blob_test.rb +47 -0
- data/test/models/variant_test.rb +27 -0
- data/test/service/.gitignore +1 -0
- data/test/service/azure_service_test.rb +14 -0
- data/test/service/configurations-example.yml +31 -0
- data/test/service/configurator_test.rb +14 -0
- data/test/service/disk_service_test.rb +12 -0
- data/test/service/gcs_service_test.rb +42 -0
- data/test/service/mirror_service_test.rb +62 -0
- data/test/service/s3_service_test.rb +52 -0
- data/test/service/shared_service_tests.rb +66 -0
- data/test/sidekiq/minitest_support.rb +6 -0
- data/test/support/assertions.rb +20 -0
- data/test/test_helper.rb +69 -0
- data/webpack.config.js +27 -0
- data/yarn.lock +3164 -0
- metadata +330 -0
@@ -0,0 +1,115 @@
|
|
1
|
+
require "active_support/core_ext/numeric/bytes"
|
2
|
+
require "azure/storage"
|
3
|
+
require "azure/storage/core/auth/shared_access_signature"
|
4
|
+
|
5
|
+
# Wraps the Microsoft Azure Storage Blob Service as a Active Storage service.
|
6
|
+
# See `ActiveStorage::Service` for the generic API documentation that applies to all services.
|
7
|
+
class ActiveStorage::Service::AzureService < ActiveStorage::Service
|
8
|
+
attr_reader :client, :path, :blobs, :container, :signer
|
9
|
+
|
10
|
+
def initialize(path:, storage_account_name:, storage_access_key:, container:)
|
11
|
+
@client = Azure::Storage::Client.create(storage_account_name: storage_account_name, storage_access_key: storage_access_key)
|
12
|
+
@signer = Azure::Storage::Core::Auth::SharedAccessSignature.new(storage_account_name, storage_access_key)
|
13
|
+
@blobs = client.blob_client
|
14
|
+
@container = container
|
15
|
+
@path = path
|
16
|
+
end
|
17
|
+
|
18
|
+
def upload(key, io, checksum: nil)
|
19
|
+
instrument :upload, key, checksum: checksum do
|
20
|
+
begin
|
21
|
+
blobs.create_block_blob(container, key, io, content_md5: checksum)
|
22
|
+
rescue Azure::Core::Http::HTTPError => e
|
23
|
+
raise ActiveStorage::IntegrityError
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def download(key)
|
29
|
+
if block_given?
|
30
|
+
instrument :streaming_download, key do
|
31
|
+
stream(key, &block)
|
32
|
+
end
|
33
|
+
else
|
34
|
+
instrument :download, key do
|
35
|
+
_, io = blobs.get_blob(container, key)
|
36
|
+
io.force_encoding(Encoding::BINARY)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def delete(key)
|
42
|
+
instrument :delete, key do
|
43
|
+
begin
|
44
|
+
blobs.delete_blob(container, key)
|
45
|
+
rescue Azure::Core::Http::HTTPError
|
46
|
+
false
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def exist?(key)
|
52
|
+
instrument :exist, key do |payload|
|
53
|
+
answer = blob_for(key).present?
|
54
|
+
payload[:exist] = answer
|
55
|
+
answer
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def url(key, expires_in:, disposition:, filename:)
|
60
|
+
instrument :url, key do |payload|
|
61
|
+
base_url = url_for(key)
|
62
|
+
generated_url = signer.signed_uri(URI(base_url), false, permissions: "r",
|
63
|
+
expiry: format_expiry(expires_in), content_disposition: "#{disposition}; filename=\"#{filename}\"").to_s
|
64
|
+
|
65
|
+
payload[:url] = generated_url
|
66
|
+
|
67
|
+
generated_url
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
|
72
|
+
instrument :url, key do |payload|
|
73
|
+
base_url = url_for(key)
|
74
|
+
generated_url = signer.signed_uri(URI(base_url), false, permissions: "rw",
|
75
|
+
expiry: format_expiry(expires_in)).to_s
|
76
|
+
|
77
|
+
payload[:url] = generated_url
|
78
|
+
|
79
|
+
generated_url
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def headers_for_direct_upload(key, content_type:, checksum:, **)
|
84
|
+
{ "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-type" => "BlockBlob" }
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
def url_for(key)
|
89
|
+
"#{path}/#{container}/#{key}"
|
90
|
+
end
|
91
|
+
|
92
|
+
def blob_for(key)
|
93
|
+
blobs.get_blob_properties(container, key)
|
94
|
+
rescue Azure::Core::Http::HTTPError
|
95
|
+
false
|
96
|
+
end
|
97
|
+
|
98
|
+
def format_expiry(expires_in)
|
99
|
+
expires_in ? Time.now.utc.advance(seconds: expires_in).iso8601 : nil
|
100
|
+
end
|
101
|
+
|
102
|
+
# Reads the object for the given key in chunks, yielding each to the block.
|
103
|
+
def stream(key, options = {}, &block)
|
104
|
+
blob = blob_for(key)
|
105
|
+
|
106
|
+
chunk_size = 5.megabytes
|
107
|
+
offset = 0
|
108
|
+
|
109
|
+
while offset < blob.properties[:content_length]
|
110
|
+
_, io = blobs.get_blob(container, key, start_range: offset, end_range: offset + chunk_size - 1)
|
111
|
+
yield io
|
112
|
+
offset += chunk_size
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
class ActiveStorage::Service::Configurator #:nodoc:
|
2
|
+
attr_reader :configurations
|
3
|
+
|
4
|
+
def self.build(service_name, configurations)
|
5
|
+
new(configurations).build(service_name)
|
6
|
+
end
|
7
|
+
|
8
|
+
def initialize(configurations)
|
9
|
+
@configurations = configurations.deep_symbolize_keys
|
10
|
+
end
|
11
|
+
|
12
|
+
def build(service_name)
|
13
|
+
config = config_for(service_name.to_sym)
|
14
|
+
resolve(config.fetch(:service)).build(**config, configurator: self)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
def config_for(name)
|
19
|
+
configurations.fetch name do
|
20
|
+
raise "Missing configuration for the #{name.inspect} Active Storage service. Configurations available for #{configurations.keys.inspect}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def resolve(class_name)
|
25
|
+
require "active_storage/service/#{class_name.to_s.downcase}_service"
|
26
|
+
ActiveStorage::Service.const_get(:"#{class_name}Service")
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
require "fileutils"
|
2
|
+
require "pathname"
|
3
|
+
require "digest/md5"
|
4
|
+
require "active_support/core_ext/numeric/bytes"
|
5
|
+
|
6
|
+
# Wraps a local disk path as a Active Storage service. See `ActiveStorage::Service` for the generic API
|
7
|
+
# documentation that applies to all services.
|
8
|
+
class ActiveStorage::Service::DiskService < ActiveStorage::Service
|
9
|
+
attr_reader :root
|
10
|
+
|
11
|
+
def initialize(root:)
|
12
|
+
@root = root
|
13
|
+
end
|
14
|
+
|
15
|
+
def upload(key, io, checksum: nil)
|
16
|
+
instrument :upload, key, checksum: checksum do
|
17
|
+
IO.copy_stream(io, make_path_for(key))
|
18
|
+
ensure_integrity_of(key, checksum) if checksum
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def download(key)
|
23
|
+
if block_given?
|
24
|
+
instrument :streaming_download, key do
|
25
|
+
File.open(path_for(key), "rb") do |file|
|
26
|
+
while data = file.read(64.kilobytes)
|
27
|
+
yield data
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
else
|
32
|
+
instrument :download, key do
|
33
|
+
File.binread path_for(key)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def delete(key)
|
39
|
+
instrument :delete, key do
|
40
|
+
begin
|
41
|
+
File.delete path_for(key)
|
42
|
+
rescue Errno::ENOENT
|
43
|
+
# Ignore files already deleted
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def exist?(key)
|
49
|
+
instrument :exist, key do |payload|
|
50
|
+
answer = File.exist? path_for(key)
|
51
|
+
payload[:exist] = answer
|
52
|
+
answer
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def url(key, expires_in:, disposition:, filename:, content_type:)
|
57
|
+
instrument :url, key do |payload|
|
58
|
+
verified_key_with_expiration = ActiveStorage.verifier.generate(key, expires_in: expires_in, purpose: :blob_key)
|
59
|
+
|
60
|
+
generated_url =
|
61
|
+
if defined?(Rails.application)
|
62
|
+
Rails.application.routes.url_helpers.rails_disk_service_path \
|
63
|
+
verified_key_with_expiration,
|
64
|
+
disposition: disposition, filename: filename, content_type: content_type
|
65
|
+
else
|
66
|
+
"/rails/active_storage/disk/#{verified_key_with_expiration}/#{filename}?disposition=#{disposition}&content_type=#{content_type}"
|
67
|
+
end
|
68
|
+
|
69
|
+
payload[:url] = generated_url
|
70
|
+
|
71
|
+
generated_url
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
|
76
|
+
instrument :url, key do |payload|
|
77
|
+
verified_token_with_expiration = ActiveStorage.verifier.generate(
|
78
|
+
{
|
79
|
+
key: key,
|
80
|
+
content_type: content_type,
|
81
|
+
content_length: content_length,
|
82
|
+
checksum: checksum
|
83
|
+
},
|
84
|
+
expires_in: expires_in,
|
85
|
+
purpose: :blob_token
|
86
|
+
)
|
87
|
+
|
88
|
+
generated_url =
|
89
|
+
if defined?(Rails.application)
|
90
|
+
Rails.application.routes.url_helpers.update_rails_disk_service_path verified_token_with_expiration
|
91
|
+
else
|
92
|
+
"/rails/active_storage/disk/#{verified_token_with_expiration}"
|
93
|
+
end
|
94
|
+
|
95
|
+
payload[:url] = generated_url
|
96
|
+
|
97
|
+
generated_url
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def headers_for_direct_upload(key, content_type:, **)
|
102
|
+
{ "Content-Type" => content_type }
|
103
|
+
end
|
104
|
+
|
105
|
+
private
|
106
|
+
def path_for(key)
|
107
|
+
File.join root, folder_for(key), key
|
108
|
+
end
|
109
|
+
|
110
|
+
def folder_for(key)
|
111
|
+
[ key[0..1], key[2..3] ].join("/")
|
112
|
+
end
|
113
|
+
|
114
|
+
def make_path_for(key)
|
115
|
+
path_for(key).tap { |path| FileUtils.mkdir_p File.dirname(path) }
|
116
|
+
end
|
117
|
+
|
118
|
+
def ensure_integrity_of(key, checksum)
|
119
|
+
unless Digest::MD5.file(path_for(key)).base64digest == checksum
|
120
|
+
delete key
|
121
|
+
raise ActiveStorage::IntegrityError
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require "google/cloud/storage"
|
2
|
+
require "active_support/core_ext/object/to_query"
|
3
|
+
|
4
|
+
# Wraps the Google Cloud Storage as a Active Storage service. See `ActiveStorage::Service` for the generic API
|
5
|
+
# documentation that applies to all services.
|
6
|
+
class ActiveStorage::Service::GCSService < ActiveStorage::Service
|
7
|
+
attr_reader :client, :bucket
|
8
|
+
|
9
|
+
def initialize(project:, keyfile:, bucket:)
|
10
|
+
@client = Google::Cloud::Storage.new(project: project, keyfile: keyfile)
|
11
|
+
@bucket = @client.bucket(bucket)
|
12
|
+
end
|
13
|
+
|
14
|
+
def upload(key, io, checksum: nil)
|
15
|
+
instrument :upload, key, checksum: checksum do
|
16
|
+
begin
|
17
|
+
bucket.create_file(io, key, md5: checksum)
|
18
|
+
rescue Google::Cloud::InvalidArgumentError
|
19
|
+
raise ActiveStorage::IntegrityError
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# FIXME: Add streaming when given a block
|
25
|
+
def download(key)
|
26
|
+
instrument :download, key do
|
27
|
+
io = file_for(key).download
|
28
|
+
io.rewind
|
29
|
+
io.read
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def delete(key)
|
34
|
+
instrument :delete, key do
|
35
|
+
file_for(key).try(:delete)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def exist?(key)
|
40
|
+
instrument :exist, key do |payload|
|
41
|
+
answer = file_for(key).present?
|
42
|
+
payload[:exist] = answer
|
43
|
+
answer
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def url(key, expires_in:, disposition:, filename:, content_type:)
|
48
|
+
instrument :url, key do |payload|
|
49
|
+
generated_url = file_for(key).signed_url expires: expires_in, query: {
|
50
|
+
"response-content-disposition" => "#{disposition}; filename=\"#{filename}\"",
|
51
|
+
"response-content-type" => content_type
|
52
|
+
}
|
53
|
+
|
54
|
+
payload[:url] = generated_url
|
55
|
+
|
56
|
+
generated_url
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
|
61
|
+
instrument :url, key do |payload|
|
62
|
+
generated_url = bucket.signed_url key, method: "PUT", expires: expires_in,
|
63
|
+
content_type: content_type, content_md5: checksum
|
64
|
+
|
65
|
+
payload[:url] = generated_url
|
66
|
+
|
67
|
+
generated_url
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def headers_for_direct_upload(key, content_type:, checksum:, **)
|
72
|
+
{ "Content-Type" => content_type, "Content-MD5" => checksum }
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
def file_for(key)
|
77
|
+
bucket.file(key)
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require "active_storage/patches/delegation"
|
2
|
+
|
3
|
+
# Wraps a set of mirror services and provides a single `ActiveStorage::Service` object that will all
|
4
|
+
# have the files uploaded to them. A `primary` service is designated to answer calls to `download`, `exists?`,
|
5
|
+
# and `url`.
|
6
|
+
class ActiveStorage::Service::MirrorService < ActiveStorage::Service
|
7
|
+
attr_reader :primary, :mirrors
|
8
|
+
|
9
|
+
delegate :download, :exist?, :url, to: :primary
|
10
|
+
|
11
|
+
# Stitch together from named services.
|
12
|
+
def self.build(primary:, mirrors:, configurator:, **options) #:nodoc:
|
13
|
+
new \
|
14
|
+
primary: configurator.build(primary),
|
15
|
+
mirrors: mirrors.collect { |name| configurator.build name }
|
16
|
+
end
|
17
|
+
|
18
|
+
def initialize(primary:, mirrors:)
|
19
|
+
@primary, @mirrors = primary, mirrors
|
20
|
+
end
|
21
|
+
|
22
|
+
# Upload the `io` to the `key` specified to all services. If a `checksum` is provided, all services will
|
23
|
+
# ensure a match when the upload has completed or raise an `ActiveStorage::IntegrityError`.
|
24
|
+
def upload(key, io, checksum: nil)
|
25
|
+
each_service.collect do |service|
|
26
|
+
service.upload key, io.tap(&:rewind), checksum: checksum
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Delete the file at the `key` on all services.
|
31
|
+
def delete(key)
|
32
|
+
perform_across_services :delete, key
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
def each_service(&block)
|
37
|
+
[ primary, *mirrors ].each(&block)
|
38
|
+
end
|
39
|
+
|
40
|
+
def perform_across_services(method, *args)
|
41
|
+
# FIXME: Convert to be threaded
|
42
|
+
each_service.collect do |service|
|
43
|
+
service.public_send method, *args
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require "aws-sdk"
|
2
|
+
require "active_support/core_ext/numeric/bytes"
|
3
|
+
|
4
|
+
# Wraps the Amazon Simple Storage Service (S3) as a Active Storage service.
|
5
|
+
# See `ActiveStorage::Service` for the generic API documentation that applies to all services.
|
6
|
+
class ActiveStorage::Service::S3Service < ActiveStorage::Service
|
7
|
+
attr_reader :client, :bucket, :upload_options
|
8
|
+
|
9
|
+
def initialize(access_key_id:, secret_access_key:, region:, bucket:, upload: {}, **options)
|
10
|
+
@client = Aws::S3::Resource.new(access_key_id: access_key_id, secret_access_key: secret_access_key, region: region, **options)
|
11
|
+
@bucket = @client.bucket(bucket)
|
12
|
+
|
13
|
+
@upload_options = upload
|
14
|
+
end
|
15
|
+
|
16
|
+
def upload(key, io, checksum: nil)
|
17
|
+
instrument :upload, key, checksum: checksum do
|
18
|
+
begin
|
19
|
+
object_for(key).put(upload_options.merge(body: io, content_md5: checksum))
|
20
|
+
rescue Aws::S3::Errors::BadDigest
|
21
|
+
raise ActiveStorage::IntegrityError
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def download(key)
|
27
|
+
if block_given?
|
28
|
+
instrument :streaming_download, key do
|
29
|
+
stream(key, &block)
|
30
|
+
end
|
31
|
+
else
|
32
|
+
instrument :download, key do
|
33
|
+
object_for(key).get.body.read.force_encoding(Encoding::BINARY)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def delete(key)
|
39
|
+
instrument :delete, key do
|
40
|
+
object_for(key).delete
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def exist?(key)
|
45
|
+
instrument :exist, key do |payload|
|
46
|
+
answer = object_for(key).exists?
|
47
|
+
payload[:exist] = answer
|
48
|
+
answer
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def url(key, expires_in:, disposition:, filename:, content_type:)
|
53
|
+
instrument :url, key do |payload|
|
54
|
+
generated_url = object_for(key).presigned_url :get, expires_in: expires_in,
|
55
|
+
response_content_disposition: "#{disposition}; filename=\"#{filename}\"",
|
56
|
+
response_content_type: content_type
|
57
|
+
|
58
|
+
payload[:url] = generated_url
|
59
|
+
|
60
|
+
generated_url
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
|
65
|
+
instrument :url, key do |payload|
|
66
|
+
generated_url = object_for(key).presigned_url :put, expires_in: expires_in,
|
67
|
+
content_type: content_type, content_length: content_length, content_md5: checksum
|
68
|
+
|
69
|
+
payload[:url] = generated_url
|
70
|
+
|
71
|
+
generated_url
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def headers_for_direct_upload(key, content_type:, checksum:, **)
|
76
|
+
{ "Content-Type" => content_type, "Content-MD5" => checksum }
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
def object_for(key)
|
81
|
+
bucket.object(key)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Reads the object for the given key in chunks, yielding each to the block.
|
85
|
+
def stream(key, options = {}, &block)
|
86
|
+
object = object_for(key)
|
87
|
+
|
88
|
+
chunk_size = 5.megabytes
|
89
|
+
offset = 0
|
90
|
+
|
91
|
+
while offset < object.content_length
|
92
|
+
yield object.read(options.merge(range: "bytes=#{offset}-#{offset + chunk_size - 1}"))
|
93
|
+
offset += chunk_size
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
require "active_storage/log_subscriber"
|
2
|
+
|
3
|
+
# Abstract class serving as an interface for concrete services.
|
4
|
+
#
|
5
|
+
# The available services are:
|
6
|
+
#
|
7
|
+
# * +Disk+, to manage attachments saved directly on the hard drive.
|
8
|
+
# * +GCS+, to manage attachments through Google Cloud Storage.
|
9
|
+
# * +S3+, to manage attachments through Amazon S3.
|
10
|
+
# * +Mirror+, to be able to use several services to manage attachments.
|
11
|
+
#
|
12
|
+
# Inside a Rails application, you can set-up your services through the
|
13
|
+
# generated <tt>config/storage_services.yml</tt> file and reference one
|
14
|
+
# of the aforementioned constant under the +service+ key. For example:
|
15
|
+
#
|
16
|
+
# local:
|
17
|
+
# service: Disk
|
18
|
+
# root: <%= Rails.root.join("storage") %>
|
19
|
+
#
|
20
|
+
# You can checkout the service's constructor to know which keys are required.
|
21
|
+
#
|
22
|
+
# Then, in your application's configuration, you can specify the service to
|
23
|
+
# use like this:
|
24
|
+
#
|
25
|
+
# config.active_storage.service = :local
|
26
|
+
#
|
27
|
+
# If you are using Active Storage outside of a Ruby on Rails application, you
|
28
|
+
# can configure the service to use like this:
|
29
|
+
#
|
30
|
+
# ActiveStorage::Blob.service = ActiveStorage::Service.configure(
|
31
|
+
# :Disk,
|
32
|
+
# root: Pathname("/foo/bar/storage")
|
33
|
+
# )
|
34
|
+
class ActiveStorage::Service
|
35
|
+
class ActiveStorage::IntegrityError < StandardError; end
|
36
|
+
|
37
|
+
extend ActiveSupport::Autoload
|
38
|
+
autoload :Configurator
|
39
|
+
|
40
|
+
class_attribute :logger
|
41
|
+
|
42
|
+
class << self
|
43
|
+
# Configure an Active Storage service by name from a set of configurations,
|
44
|
+
# typically loaded from a YAML file. The Active Storage engine uses this
|
45
|
+
# to set the global Active Storage service when the app boots.
|
46
|
+
def configure(service_name, configurations)
|
47
|
+
Configurator.build(service_name, configurations)
|
48
|
+
end
|
49
|
+
|
50
|
+
# Override in subclasses that stitch together multiple services and hence
|
51
|
+
# need to build additional services using the configurator.
|
52
|
+
#
|
53
|
+
# Passes the configurator and all of the service's config as keyword args.
|
54
|
+
#
|
55
|
+
# See MirrorService for an example.
|
56
|
+
def build(configurator:, service: nil, **service_config) #:nodoc:
|
57
|
+
new(**service_config)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Upload the `io` to the `key` specified. If a `checksum` is provided, the service will
|
62
|
+
# ensure a match when the upload has completed or raise an `ActiveStorage::IntegrityError`.
|
63
|
+
def upload(key, io, checksum: nil)
|
64
|
+
raise NotImplementedError
|
65
|
+
end
|
66
|
+
|
67
|
+
# Return the content of the file at the `key`.
|
68
|
+
def download(key)
|
69
|
+
raise NotImplementedError
|
70
|
+
end
|
71
|
+
|
72
|
+
# Delete the file at the `key`.
|
73
|
+
def delete(key)
|
74
|
+
raise NotImplementedError
|
75
|
+
end
|
76
|
+
|
77
|
+
# Return true if a file exists at the `key`.
|
78
|
+
def exist?(key)
|
79
|
+
raise NotImplementedError
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns a signed, temporary URL for the file at the `key`. The URL will be valid for the amount
|
83
|
+
# of seconds specified in `expires_in`. You most also provide the `disposition` (`:inline` or `:attachment`),
|
84
|
+
# `filename`, and `content_type` that you wish the file to be served with on request.
|
85
|
+
def url(key, expires_in:, disposition:, filename:, content_type:)
|
86
|
+
raise NotImplementedError
|
87
|
+
end
|
88
|
+
|
89
|
+
# Returns a signed, temporary URL that a direct upload file can be PUT to on the `key`.
|
90
|
+
# The URL will be valid for the amount of seconds specified in `expires_in`.
|
91
|
+
# You most also provide the `content_type`, `content_length`, and `checksum` of the file
|
92
|
+
# that will be uploaded. All these attributes will be validated by the service upon upload.
|
93
|
+
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
|
94
|
+
raise NotImplementedError
|
95
|
+
end
|
96
|
+
|
97
|
+
# Returns a Hash of headers for `url_for_direct_upload` requests.
|
98
|
+
def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:)
|
99
|
+
{}
|
100
|
+
end
|
101
|
+
|
102
|
+
private
|
103
|
+
def instrument(operation, key, payload = {}, &block)
|
104
|
+
ActiveSupport::Notifications.instrument(
|
105
|
+
"service_#{operation}.active_storage",
|
106
|
+
payload.merge(key: key, service: service_name), &block)
|
107
|
+
end
|
108
|
+
|
109
|
+
def service_name
|
110
|
+
# ActiveStorage::Service::DiskService => Disk
|
111
|
+
self.class.name.split("::").third.gsub!('Service', '')
|
112
|
+
end
|
113
|
+
end
|