activestorage_legacy 0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|