activestorage 0.1 → 5.2.0.beta1

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of activestorage might be problematic. Click here for more details.

Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -0
  3. data/README.md +94 -25
  4. data/app/assets/javascripts/activestorage.js +1 -0
  5. data/app/controllers/active_storage/blobs_controller.rb +16 -0
  6. data/app/controllers/active_storage/direct_uploads_controller.rb +23 -0
  7. data/app/controllers/active_storage/disk_controller.rb +51 -0
  8. data/app/controllers/active_storage/previews_controller.rb +12 -0
  9. data/app/controllers/active_storage/variants_controller.rb +16 -0
  10. data/app/javascript/activestorage/blob_record.js +54 -0
  11. data/app/javascript/activestorage/blob_upload.js +35 -0
  12. data/app/javascript/activestorage/direct_upload.js +42 -0
  13. data/app/javascript/activestorage/direct_upload_controller.js +67 -0
  14. data/app/javascript/activestorage/direct_uploads_controller.js +50 -0
  15. data/app/javascript/activestorage/file_checksum.js +53 -0
  16. data/app/javascript/activestorage/helpers.js +42 -0
  17. data/app/javascript/activestorage/index.js +11 -0
  18. data/app/javascript/activestorage/ujs.js +75 -0
  19. data/app/jobs/active_storage/analyze_job.rb +8 -0
  20. data/app/jobs/active_storage/base_job.rb +5 -0
  21. data/app/jobs/active_storage/purge_job.rb +11 -0
  22. data/app/models/active_storage/attachment.rb +35 -0
  23. data/app/models/active_storage/blob.rb +313 -0
  24. data/app/models/active_storage/filename.rb +73 -0
  25. data/app/models/active_storage/filename/parameters.rb +36 -0
  26. data/app/models/active_storage/preview.rb +90 -0
  27. data/app/models/active_storage/variant.rb +86 -0
  28. data/app/models/active_storage/variation.rb +67 -0
  29. data/config/routes.rb +43 -0
  30. data/lib/active_storage.rb +37 -2
  31. data/lib/active_storage/analyzer.rb +33 -0
  32. data/lib/active_storage/analyzer/image_analyzer.rb +36 -0
  33. data/lib/active_storage/analyzer/null_analyzer.rb +13 -0
  34. data/lib/active_storage/analyzer/video_analyzer.rb +79 -0
  35. data/lib/active_storage/attached.rb +28 -22
  36. data/lib/active_storage/attached/macros.rb +89 -16
  37. data/lib/active_storage/attached/many.rb +53 -21
  38. data/lib/active_storage/attached/one.rb +74 -20
  39. data/lib/active_storage/downloading.rb +26 -0
  40. data/lib/active_storage/engine.rb +72 -0
  41. data/lib/active_storage/gem_version.rb +17 -0
  42. data/lib/active_storage/log_subscriber.rb +52 -0
  43. data/lib/active_storage/previewer.rb +58 -0
  44. data/lib/active_storage/previewer/pdf_previewer.rb +17 -0
  45. data/lib/active_storage/previewer/video_previewer.rb +23 -0
  46. data/lib/active_storage/service.rb +112 -24
  47. data/lib/active_storage/service/azure_storage_service.rb +124 -0
  48. data/lib/active_storage/service/configurator.rb +32 -0
  49. data/lib/active_storage/service/disk_service.rb +103 -44
  50. data/lib/active_storage/service/gcs_service.rb +87 -29
  51. data/lib/active_storage/service/mirror_service.rb +38 -22
  52. data/lib/active_storage/service/s3_service.rb +83 -38
  53. data/lib/active_storage/version.rb +10 -0
  54. data/lib/tasks/activestorage.rake +4 -15
  55. metadata +64 -108
  56. data/.gitignore +0 -1
  57. data/Gemfile +0 -11
  58. data/Gemfile.lock +0 -235
  59. data/Rakefile +0 -11
  60. data/activestorage.gemspec +0 -21
  61. data/lib/active_storage/attachment.rb +0 -30
  62. data/lib/active_storage/blob.rb +0 -80
  63. data/lib/active_storage/disk_controller.rb +0 -28
  64. data/lib/active_storage/download.rb +0 -90
  65. data/lib/active_storage/filename.rb +0 -31
  66. data/lib/active_storage/migration.rb +0 -28
  67. data/lib/active_storage/purge_job.rb +0 -10
  68. data/lib/active_storage/railtie.rb +0 -56
  69. data/lib/active_storage/storage_services.yml +0 -27
  70. data/lib/active_storage/verified_key_with_expiration.rb +0 -24
  71. data/test/attachments_test.rb +0 -95
  72. data/test/blob_test.rb +0 -28
  73. data/test/database/create_users_migration.rb +0 -7
  74. data/test/database/setup.rb +0 -6
  75. data/test/disk_controller_test.rb +0 -34
  76. data/test/filename_test.rb +0 -36
  77. data/test/service/.gitignore +0 -1
  78. data/test/service/configurations-example.yml +0 -11
  79. data/test/service/disk_service_test.rb +0 -8
  80. data/test/service/gcs_service_test.rb +0 -20
  81. data/test/service/mirror_service_test.rb +0 -50
  82. data/test/service/s3_service_test.rb +0 -11
  83. data/test/service/shared_service_tests.rb +0 -68
  84. data/test/test_helper.rb +0 -28
  85. data/test/verified_key_with_expiration_test.rb +0 -19
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ class Service::Configurator #:nodoc:
5
+ attr_reader :configurations
6
+
7
+ def self.build(service_name, configurations)
8
+ new(configurations).build(service_name)
9
+ end
10
+
11
+ def initialize(configurations)
12
+ @configurations = configurations.deep_symbolize_keys
13
+ end
14
+
15
+ def build(service_name)
16
+ config = config_for(service_name.to_sym)
17
+ resolve(config.fetch(:service)).build(**config, configurator: self)
18
+ end
19
+
20
+ private
21
+ def config_for(name)
22
+ configurations.fetch name do
23
+ raise "Missing configuration for the #{name.inspect} Active Storage service. Configurations available for #{configurations.keys.inspect}"
24
+ end
25
+ end
26
+
27
+ def resolve(class_name)
28
+ require "active_storage/service/#{class_name.to_s.underscore}_service"
29
+ ActiveStorage::Service.const_get(:"#{class_name}Service")
30
+ end
31
+ end
32
+ end
@@ -1,70 +1,129 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "fileutils"
2
4
  require "pathname"
5
+ require "digest/md5"
3
6
  require "active_support/core_ext/numeric/bytes"
4
7
 
5
- class ActiveStorage::Service::DiskService < ActiveStorage::Service
6
- attr_reader :root
8
+ module ActiveStorage
9
+ # Wraps a local disk path as an Active Storage service. See ActiveStorage::Service for the generic API
10
+ # documentation that applies to all services.
11
+ class Service::DiskService < Service
12
+ attr_reader :root
7
13
 
8
- def initialize(root:)
9
- @root = root
10
- end
14
+ def initialize(root:)
15
+ @root = root
16
+ end
11
17
 
12
- def upload(key, io, checksum: nil)
13
- File.open(make_path_for(key), "wb") do |file|
14
- while chunk = io.read(64.kilobytes)
15
- file.write(chunk)
18
+ def upload(key, io, checksum: nil)
19
+ instrument :upload, key, checksum: checksum do
20
+ IO.copy_stream(io, make_path_for(key))
21
+ ensure_integrity_of(key, checksum) if checksum
16
22
  end
17
23
  end
18
24
 
19
- ensure_integrity_of(key, checksum) if checksum
20
- end
25
+ def download(key)
26
+ if block_given?
27
+ instrument :streaming_download, key do
28
+ File.open(path_for(key), "rb") do |file|
29
+ while data = file.read(64.kilobytes)
30
+ yield data
31
+ end
32
+ end
33
+ end
34
+ else
35
+ instrument :download, key do
36
+ File.binread path_for(key)
37
+ end
38
+ end
39
+ end
21
40
 
22
- def download(key)
23
- if block_given?
24
- File.open(path_for(key)) do |file|
25
- while data = file.read(64.kilobytes)
26
- yield data
41
+ def delete(key)
42
+ instrument :delete, key do
43
+ begin
44
+ File.delete path_for(key)
45
+ rescue Errno::ENOENT
46
+ # Ignore files already deleted
27
47
  end
28
48
  end
29
- else
30
- File.open path_for(key), &:read
31
49
  end
32
- end
33
50
 
34
- def delete(key)
35
- File.delete path_for(key) rescue Errno::ENOENT # Ignore files already deleted
36
- end
51
+ def exist?(key)
52
+ instrument :exist, key do |payload|
53
+ answer = File.exist? path_for(key)
54
+ payload[:exist] = answer
55
+ answer
56
+ end
57
+ end
37
58
 
38
- def exist?(key)
39
- File.exist? path_for(key)
40
- end
59
+ def url(key, expires_in:, filename:, disposition:, content_type:)
60
+ instrument :url, key do |payload|
61
+ verified_key_with_expiration = ActiveStorage.verifier.generate(key, expires_in: expires_in, purpose: :blob_key)
41
62
 
42
- def url(key, expires_in:, disposition:, filename:)
43
- verified_key_with_expiration = ActiveStorage::VerifiedKeyWithExpiration.encode(key, expires_in: expires_in)
63
+ generated_url =
64
+ if defined?(Rails.application)
65
+ Rails.application.routes.url_helpers.rails_disk_service_path \
66
+ verified_key_with_expiration,
67
+ filename: filename, disposition: content_disposition_with(type: disposition, filename: filename), content_type: content_type
68
+ else
69
+ "/rails/active_storage/disk/#{verified_key_with_expiration}/#{filename}?content_type=#{content_type}" \
70
+ "&disposition=#{content_disposition_with(type: disposition, filename: filename)}"
71
+ end
44
72
 
45
- if defined?(Rails) && defined?(Rails.application)
46
- Rails.application.routes.url_helpers.rails_disk_blob_path(verified_key_with_expiration, disposition: disposition)
47
- else
48
- "/rails/blobs/#{verified_key_with_expiration}?disposition=#{disposition}"
49
- end
50
- end
73
+ payload[:url] = generated_url
51
74
 
52
- private
53
- def path_for(key)
54
- File.join root, folder_for(key), key
75
+ generated_url
76
+ end
55
77
  end
56
78
 
57
- def folder_for(key)
58
- [ key[0..1], key[2..3] ].join("/")
79
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
80
+ instrument :url, key do |payload|
81
+ verified_token_with_expiration = ActiveStorage.verifier.generate(
82
+ {
83
+ key: key,
84
+ content_type: content_type,
85
+ content_length: content_length,
86
+ checksum: checksum
87
+ },
88
+ { expires_in: expires_in,
89
+ purpose: :blob_token }
90
+ )
91
+
92
+ generated_url =
93
+ if defined?(Rails.application)
94
+ Rails.application.routes.url_helpers.update_rails_disk_service_path verified_token_with_expiration
95
+ else
96
+ "/rails/active_storage/disk/#{verified_token_with_expiration}"
97
+ end
98
+
99
+ payload[:url] = generated_url
100
+
101
+ generated_url
102
+ end
59
103
  end
60
104
 
61
- def make_path_for(key)
62
- path_for(key).tap { |path| FileUtils.mkdir_p File.dirname(path) }
105
+ def headers_for_direct_upload(key, content_type:, **)
106
+ { "Content-Type" => content_type }
63
107
  end
64
108
 
65
- def ensure_integrity_of(key, checksum)
66
- unless Digest::MD5.file(path_for(key)).base64digest == checksum
67
- raise ActiveStorage::IntegrityError
109
+ private
110
+ def path_for(key)
111
+ File.join root, folder_for(key), key
68
112
  end
69
- end
113
+
114
+ def folder_for(key)
115
+ [ key[0..1], key[2..3] ].join("/")
116
+ end
117
+
118
+ def make_path_for(key)
119
+ path_for(key).tap { |path| FileUtils.mkdir_p File.dirname(path) }
120
+ end
121
+
122
+ def ensure_integrity_of(key, checksum)
123
+ unless Digest::MD5.file(path_for(key)).base64digest == checksum
124
+ delete key
125
+ raise ActiveStorage::IntegrityError
126
+ end
127
+ end
128
+ end
70
129
  end
@@ -1,41 +1,99 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "google/cloud/storage"
2
4
  require "active_support/core_ext/object/to_query"
3
5
 
4
- class ActiveStorage::Service::GCSService < ActiveStorage::Service
5
- attr_reader :client, :bucket
6
+ module ActiveStorage
7
+ # Wraps the Google Cloud Storage as an Active Storage service. See ActiveStorage::Service for the generic API
8
+ # documentation that applies to all services.
9
+ class Service::GCSService < Service
10
+ def initialize(**config)
11
+ @config = config
12
+ end
6
13
 
7
- def initialize(project:, keyfile:, bucket:)
8
- @client = Google::Cloud::Storage.new(project: project, keyfile: keyfile)
9
- @bucket = @client.bucket(bucket)
10
- end
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
11
23
 
12
- def upload(key, io, checksum: nil)
13
- # FIXME: Ensure integrity by sending the checksum for service side verification
14
- bucket.create_file(io, key)
15
- end
24
+ # FIXME: Download in chunks when given a block.
25
+ def download(key)
26
+ instrument :download, key do
27
+ io = file_for(key).download
28
+ io.rewind
16
29
 
17
- # FIXME: Add streaming when given a block
18
- def download(key)
19
- io = file_for(key).download
20
- io.rewind
21
- io.read
22
- end
30
+ if block_given?
31
+ yield io.read
32
+ else
33
+ io.read
34
+ end
35
+ end
36
+ end
23
37
 
24
- def delete(key)
25
- file_for(key).try(:delete)
26
- end
38
+ def delete(key)
39
+ instrument :delete, key do
40
+ begin
41
+ file_for(key).delete
42
+ rescue Google::Cloud::NotFoundError
43
+ # Ignore files already deleted
44
+ end
45
+ end
46
+ end
27
47
 
28
- def exist?(key)
29
- file_for(key).present?
30
- end
48
+ def exist?(key)
49
+ instrument :exist, key do |payload|
50
+ answer = file_for(key).exists?
51
+ payload[:exist] = answer
52
+ answer
53
+ end
54
+ end
31
55
 
32
- def url(key, expires_in:, disposition:, filename:)
33
- file_for(key).signed_url(expires: expires_in) + "&" +
34
- { "response-content-disposition" => "#{disposition}; filename=\"#{filename}\"" }.to_query
35
- end
56
+ def url(key, expires_in:, filename:, content_type:, disposition:)
57
+ instrument :url, key do |payload|
58
+ generated_url = file_for(key).signed_url expires: expires_in, query: {
59
+ "response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
60
+ "response-content-type" => content_type
61
+ }
36
62
 
37
- private
38
- def file_for(key)
39
- bucket.file(key)
63
+ payload[:url] = generated_url
64
+
65
+ generated_url
66
+ end
67
+ end
68
+
69
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
70
+ instrument :url, key do |payload|
71
+ generated_url = bucket.signed_url key, method: "PUT", expires: expires_in,
72
+ content_type: content_type, content_md5: checksum
73
+
74
+ payload[:url] = generated_url
75
+
76
+ generated_url
77
+ end
78
+ end
79
+
80
+ def headers_for_direct_upload(key, content_type:, checksum:, **)
81
+ { "Content-Type" => content_type, "Content-MD5" => checksum }
40
82
  end
83
+
84
+ private
85
+ attr_reader :config
86
+
87
+ def file_for(key)
88
+ bucket.file(key, skip_lookup: true)
89
+ end
90
+
91
+ def bucket
92
+ @bucket ||= client.bucket(config.fetch(:bucket))
93
+ end
94
+
95
+ def client
96
+ @client ||= Google::Cloud::Storage.new(config.except(:bucket))
97
+ end
98
+ end
41
99
  end
@@ -1,34 +1,50 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_support/core_ext/module/delegation"
2
4
 
3
- class ActiveStorage::Service::MirrorService < ActiveStorage::Service
4
- attr_reader :services
5
+ module ActiveStorage
6
+ # Wraps a set of mirror services and provides a single ActiveStorage::Service object that will all
7
+ # have the files uploaded to them. A +primary+ service is designated to answer calls to +download+, +exists?+,
8
+ # and +url+.
9
+ class Service::MirrorService < Service
10
+ attr_reader :primary, :mirrors
5
11
 
6
- delegate :download, :exist?, :url, :byte_size, :checksum, to: :primary_service
12
+ delegate :download, :exist?, :url, to: :primary
7
13
 
8
- def initialize(services:)
9
- @services = services
10
- end
14
+ # Stitch together from named services.
15
+ def self.build(primary:, mirrors:, configurator:, **options) #:nodoc:
16
+ new \
17
+ primary: configurator.build(primary),
18
+ mirrors: mirrors.collect { |name| configurator.build name }
19
+ end
11
20
 
12
- def upload(key, io, checksum: nil)
13
- services.collect do |service|
14
- service.upload key, io, checksum: checksum
15
- io.rewind
21
+ def initialize(primary:, mirrors:)
22
+ @primary, @mirrors = primary, mirrors
16
23
  end
17
- end
18
24
 
19
- def delete(key)
20
- perform_across_services :delete, key
21
- end
25
+ # Upload the +io+ to the +key+ specified to all services. If a +checksum+ is provided, all services will
26
+ # ensure a match when the upload has completed or raise an ActiveStorage::IntegrityError.
27
+ def upload(key, io, checksum: nil)
28
+ each_service.collect do |service|
29
+ service.upload key, io.tap(&:rewind), checksum: checksum
30
+ end
31
+ end
22
32
 
23
- private
24
- def primary_service
25
- services.first
33
+ # Delete the file at the +key+ on all services.
34
+ def delete(key)
35
+ perform_across_services :delete, key
26
36
  end
27
37
 
28
- def perform_across_services(method, *args)
29
- # FIXME: Convert to be threaded
30
- services.collect do |service|
31
- service.public_send method, *args
38
+ private
39
+ def each_service(&block)
40
+ [ primary, *mirrors ].each(&block)
32
41
  end
33
- end
42
+
43
+ def perform_across_services(method, *args)
44
+ # FIXME: Convert to be threaded
45
+ each_service.collect do |service|
46
+ service.public_send method, *args
47
+ end
48
+ end
49
+ end
34
50
  end
@@ -1,55 +1,100 @@
1
- require "aws-sdk"
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-s3"
2
4
  require "active_support/core_ext/numeric/bytes"
3
5
 
4
- class ActiveStorage::Service::S3Service < ActiveStorage::Service
5
- attr_reader :client, :bucket
6
+ module ActiveStorage
7
+ # Wraps the Amazon Simple Storage Service (S3) as an Active Storage service.
8
+ # See ActiveStorage::Service for the generic API documentation that applies to all services.
9
+ class Service::S3Service < Service
10
+ attr_reader :client, :bucket, :upload_options
6
11
 
7
- def initialize(access_key_id:, secret_access_key:, region:, bucket:)
8
- @client = Aws::S3::Resource.new(access_key_id: access_key_id, secret_access_key: secret_access_key, region: region)
9
- @bucket = @client.bucket(bucket)
10
- end
12
+ def initialize(access_key_id:, secret_access_key:, region:, bucket:, upload: {}, **options)
13
+ @client = Aws::S3::Resource.new(access_key_id: access_key_id, secret_access_key: secret_access_key, region: region, **options)
14
+ @bucket = @client.bucket(bucket)
11
15
 
12
- def upload(key, io, checksum: nil)
13
- # FIXME: Ensure integrity by sending the checksum for service side verification
14
- object_for(key).put(body: io)
15
- end
16
+ @upload_options = upload
17
+ end
16
18
 
17
- def download(key)
18
- if block_given?
19
- stream(key, &block)
20
- else
21
- object_for(key).get.body.read
19
+ def upload(key, io, checksum: nil)
20
+ instrument :upload, key, checksum: checksum do
21
+ begin
22
+ object_for(key).put(upload_options.merge(body: io, content_md5: checksum))
23
+ rescue Aws::S3::Errors::BadDigest
24
+ raise ActiveStorage::IntegrityError
25
+ end
26
+ end
22
27
  end
23
- end
24
28
 
25
- def delete(key)
26
- object_for(key).delete
27
- end
29
+ def download(key, &block)
30
+ if block_given?
31
+ instrument :streaming_download, key do
32
+ stream(key, &block)
33
+ end
34
+ else
35
+ instrument :download, key do
36
+ object_for(key).get.body.read.force_encoding(Encoding::BINARY)
37
+ end
38
+ end
39
+ end
28
40
 
29
- def exist?(key)
30
- object_for(key).exists?
31
- end
41
+ def delete(key)
42
+ instrument :delete, key do
43
+ object_for(key).delete
44
+ end
45
+ end
32
46
 
33
- def url(key, expires_in:, disposition:, filename:)
34
- object_for(key).presigned_url :get, expires_in: expires_in,
35
- response_content_disposition: "#{disposition}; filename=\"#{filename}\""
36
- end
47
+ def exist?(key)
48
+ instrument :exist, key do |payload|
49
+ answer = object_for(key).exists?
50
+ payload[:exist] = answer
51
+ answer
52
+ end
53
+ end
54
+
55
+ def url(key, expires_in:, filename:, disposition:, content_type:)
56
+ instrument :url, key do |payload|
57
+ generated_url = object_for(key).presigned_url :get, expires_in: expires_in.to_i,
58
+ response_content_disposition: content_disposition_with(type: disposition, filename: filename),
59
+ response_content_type: content_type
37
60
 
38
- private
39
- def object_for(key)
40
- bucket.object(key)
61
+ payload[:url] = generated_url
62
+
63
+ generated_url
64
+ end
41
65
  end
42
66
 
43
- # Reads the object for the given key in chunks, yielding each to the block.
44
- def stream(key, options = {}, &block)
45
- object = object_for(key)
67
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
68
+ instrument :url, key do |payload|
69
+ generated_url = object_for(key).presigned_url :put, expires_in: expires_in.to_i,
70
+ content_type: content_type, content_length: content_length, content_md5: checksum
46
71
 
47
- chunk_size = 5.megabytes
48
- offset = 0
72
+ payload[:url] = generated_url
49
73
 
50
- while offset < object.content_length
51
- yield object.read(options.merge(:range => "bytes=#{offset}-#{offset + chunk_size - 1}"))
52
- offset += chunk_size
74
+ generated_url
53
75
  end
54
76
  end
77
+
78
+ def headers_for_direct_upload(key, content_type:, checksum:, **)
79
+ { "Content-Type" => content_type, "Content-MD5" => checksum }
80
+ end
81
+
82
+ private
83
+ def object_for(key)
84
+ bucket.object(key)
85
+ end
86
+
87
+ # Reads the object for the given key in chunks, yielding each to the block.
88
+ def stream(key)
89
+ object = object_for(key)
90
+
91
+ chunk_size = 5.megabytes
92
+ offset = 0
93
+
94
+ while offset < object.content_length
95
+ yield object.get(range: "bytes=#{offset}-#{offset + chunk_size - 1}").body.read.force_encoding(Encoding::BINARY)
96
+ offset += chunk_size
97
+ end
98
+ end
99
+ end
55
100
  end