activestorage 6.0.0

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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +198 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +162 -0
  5. data/app/assets/javascripts/activestorage.js +942 -0
  6. data/app/controllers/active_storage/base_controller.rb +8 -0
  7. data/app/controllers/active_storage/blobs_controller.rb +14 -0
  8. data/app/controllers/active_storage/direct_uploads_controller.rb +23 -0
  9. data/app/controllers/active_storage/disk_controller.rb +66 -0
  10. data/app/controllers/active_storage/representations_controller.rb +14 -0
  11. data/app/controllers/concerns/active_storage/set_blob.rb +16 -0
  12. data/app/controllers/concerns/active_storage/set_current.rb +15 -0
  13. data/app/javascript/activestorage/blob_record.js +73 -0
  14. data/app/javascript/activestorage/blob_upload.js +35 -0
  15. data/app/javascript/activestorage/direct_upload.js +48 -0
  16. data/app/javascript/activestorage/direct_upload_controller.js +67 -0
  17. data/app/javascript/activestorage/direct_uploads_controller.js +50 -0
  18. data/app/javascript/activestorage/file_checksum.js +53 -0
  19. data/app/javascript/activestorage/helpers.js +51 -0
  20. data/app/javascript/activestorage/index.js +11 -0
  21. data/app/javascript/activestorage/ujs.js +86 -0
  22. data/app/jobs/active_storage/analyze_job.rb +12 -0
  23. data/app/jobs/active_storage/base_job.rb +4 -0
  24. data/app/jobs/active_storage/purge_job.rb +13 -0
  25. data/app/models/active_storage/attachment.rb +50 -0
  26. data/app/models/active_storage/blob.rb +278 -0
  27. data/app/models/active_storage/blob/analyzable.rb +57 -0
  28. data/app/models/active_storage/blob/identifiable.rb +31 -0
  29. data/app/models/active_storage/blob/representable.rb +93 -0
  30. data/app/models/active_storage/current.rb +5 -0
  31. data/app/models/active_storage/filename.rb +77 -0
  32. data/app/models/active_storage/preview.rb +89 -0
  33. data/app/models/active_storage/variant.rb +131 -0
  34. data/app/models/active_storage/variation.rb +80 -0
  35. data/config/routes.rb +32 -0
  36. data/db/migrate/20170806125915_create_active_storage_tables.rb +26 -0
  37. data/db/update_migrate/20180723000244_add_foreign_key_constraint_to_active_storage_attachments_for_blob_id.rb +9 -0
  38. data/lib/active_storage.rb +73 -0
  39. data/lib/active_storage/analyzer.rb +38 -0
  40. data/lib/active_storage/analyzer/image_analyzer.rb +52 -0
  41. data/lib/active_storage/analyzer/null_analyzer.rb +13 -0
  42. data/lib/active_storage/analyzer/video_analyzer.rb +118 -0
  43. data/lib/active_storage/attached.rb +25 -0
  44. data/lib/active_storage/attached/changes.rb +16 -0
  45. data/lib/active_storage/attached/changes/create_many.rb +46 -0
  46. data/lib/active_storage/attached/changes/create_one.rb +69 -0
  47. data/lib/active_storage/attached/changes/create_one_of_many.rb +10 -0
  48. data/lib/active_storage/attached/changes/delete_many.rb +27 -0
  49. data/lib/active_storage/attached/changes/delete_one.rb +19 -0
  50. data/lib/active_storage/attached/many.rb +65 -0
  51. data/lib/active_storage/attached/model.rb +147 -0
  52. data/lib/active_storage/attached/one.rb +79 -0
  53. data/lib/active_storage/downloader.rb +43 -0
  54. data/lib/active_storage/downloading.rb +47 -0
  55. data/lib/active_storage/engine.rb +149 -0
  56. data/lib/active_storage/errors.rb +26 -0
  57. data/lib/active_storage/gem_version.rb +17 -0
  58. data/lib/active_storage/log_subscriber.rb +58 -0
  59. data/lib/active_storage/previewer.rb +84 -0
  60. data/lib/active_storage/previewer/mupdf_previewer.rb +36 -0
  61. data/lib/active_storage/previewer/poppler_pdf_previewer.rb +35 -0
  62. data/lib/active_storage/previewer/video_previewer.rb +26 -0
  63. data/lib/active_storage/reflection.rb +64 -0
  64. data/lib/active_storage/service.rb +141 -0
  65. data/lib/active_storage/service/azure_storage_service.rb +165 -0
  66. data/lib/active_storage/service/configurator.rb +34 -0
  67. data/lib/active_storage/service/disk_service.rb +166 -0
  68. data/lib/active_storage/service/gcs_service.rb +141 -0
  69. data/lib/active_storage/service/mirror_service.rb +55 -0
  70. data/lib/active_storage/service/s3_service.rb +116 -0
  71. data/lib/active_storage/transformers/image_processing_transformer.rb +39 -0
  72. data/lib/active_storage/transformers/mini_magick_transformer.rb +38 -0
  73. data/lib/active_storage/transformers/transformer.rb +42 -0
  74. data/lib/active_storage/version.rb +10 -0
  75. data/lib/tasks/activestorage.rake +22 -0
  76. metadata +174 -0
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require "digest/md5"
6
+ require "active_support/core_ext/numeric/bytes"
7
+
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
13
+
14
+ def initialize(root:)
15
+ @root = root
16
+ end
17
+
18
+ def upload(key, io, checksum: nil, **)
19
+ instrument :upload, key: key, checksum: checksum do
20
+ IO.copy_stream(io, make_path_for(key))
21
+ ensure_integrity_of(key, checksum) if checksum
22
+ end
23
+ end
24
+
25
+ def download(key, &block)
26
+ if block_given?
27
+ instrument :streaming_download, key: key do
28
+ stream key, &block
29
+ end
30
+ else
31
+ instrument :download, key: key do
32
+ File.binread path_for(key)
33
+ rescue Errno::ENOENT
34
+ raise ActiveStorage::FileNotFoundError
35
+ end
36
+ end
37
+ end
38
+
39
+ def download_chunk(key, range)
40
+ instrument :download_chunk, key: key, range: range do
41
+ File.open(path_for(key), "rb") do |file|
42
+ file.seek range.begin
43
+ file.read range.size
44
+ end
45
+ rescue Errno::ENOENT
46
+ raise ActiveStorage::FileNotFoundError
47
+ end
48
+ end
49
+
50
+ def delete(key)
51
+ instrument :delete, key: key do
52
+ File.delete path_for(key)
53
+ rescue Errno::ENOENT
54
+ # Ignore files already deleted
55
+ end
56
+ end
57
+
58
+ def delete_prefixed(prefix)
59
+ instrument :delete_prefixed, prefix: prefix do
60
+ Dir.glob(path_for("#{prefix}*")).each do |path|
61
+ FileUtils.rm_rf(path)
62
+ end
63
+ end
64
+ end
65
+
66
+ def exist?(key)
67
+ instrument :exist, key: key do |payload|
68
+ answer = File.exist? path_for(key)
69
+ payload[:exist] = answer
70
+ answer
71
+ end
72
+ end
73
+
74
+ def url(key, expires_in:, filename:, disposition:, content_type:)
75
+ instrument :url, key: key do |payload|
76
+ content_disposition = content_disposition_with(type: disposition, filename: filename)
77
+ verified_key_with_expiration = ActiveStorage.verifier.generate(
78
+ {
79
+ key: key,
80
+ disposition: content_disposition,
81
+ content_type: content_type
82
+ },
83
+ { expires_in: expires_in,
84
+ purpose: :blob_key }
85
+ )
86
+
87
+ current_uri = URI.parse(current_host)
88
+
89
+ generated_url = url_helpers.rails_disk_service_url(verified_key_with_expiration,
90
+ protocol: current_uri.scheme,
91
+ host: current_uri.host,
92
+ port: current_uri.port,
93
+ disposition: content_disposition,
94
+ content_type: content_type,
95
+ filename: filename
96
+ )
97
+ payload[:url] = generated_url
98
+
99
+ generated_url
100
+ end
101
+ end
102
+
103
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
104
+ instrument :url, key: key do |payload|
105
+ verified_token_with_expiration = ActiveStorage.verifier.generate(
106
+ {
107
+ key: key,
108
+ content_type: content_type,
109
+ content_length: content_length,
110
+ checksum: checksum
111
+ },
112
+ { expires_in: expires_in,
113
+ purpose: :blob_token }
114
+ )
115
+
116
+ generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host)
117
+
118
+ payload[:url] = generated_url
119
+
120
+ generated_url
121
+ end
122
+ end
123
+
124
+ def headers_for_direct_upload(key, content_type:, **)
125
+ { "Content-Type" => content_type }
126
+ end
127
+
128
+ def path_for(key) #:nodoc:
129
+ File.join root, folder_for(key), key
130
+ end
131
+
132
+ private
133
+ def stream(key)
134
+ File.open(path_for(key), "rb") do |file|
135
+ while data = file.read(5.megabytes)
136
+ yield data
137
+ end
138
+ end
139
+ rescue Errno::ENOENT
140
+ raise ActiveStorage::FileNotFoundError
141
+ end
142
+
143
+ def folder_for(key)
144
+ [ key[0..1], key[2..3] ].join("/")
145
+ end
146
+
147
+ def make_path_for(key)
148
+ path_for(key).tap { |path| FileUtils.mkdir_p File.dirname(path) }
149
+ end
150
+
151
+ def ensure_integrity_of(key, checksum)
152
+ unless Digest::MD5.file(path_for(key)).base64digest == checksum
153
+ delete key
154
+ raise ActiveStorage::IntegrityError
155
+ end
156
+ end
157
+
158
+ def url_helpers
159
+ @url_helpers ||= Rails.application.routes.url_helpers
160
+ end
161
+
162
+ def current_host
163
+ ActiveStorage::Current.host
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ gem "google-cloud-storage", "~> 1.11"
4
+ require "google/cloud/storage"
5
+
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
13
+
14
+ def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil)
15
+ instrument :upload, key: key, checksum: checksum do
16
+ # GCS's signed URLs don't include params such as response-content-type response-content_disposition
17
+ # in the signature, which means an attacker can modify them and bypass our effort to force these to
18
+ # binary and attachment when the file's content type requires it. The only way to force them is to
19
+ # store them as object's metadata.
20
+ content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
21
+ bucket.create_file(io, key, md5: checksum, content_type: content_type, content_disposition: content_disposition)
22
+ rescue Google::Cloud::InvalidArgumentError
23
+ raise ActiveStorage::IntegrityError
24
+ end
25
+ end
26
+
27
+ def download(key, &block)
28
+ if block_given?
29
+ instrument :streaming_download, key: key do
30
+ stream(key, &block)
31
+ end
32
+ else
33
+ instrument :download, key: key do
34
+ file_for(key).download.string
35
+ rescue Google::Cloud::NotFoundError
36
+ raise ActiveStorage::FileNotFoundError
37
+ end
38
+ end
39
+ end
40
+
41
+ def update_metadata(key, content_type:, disposition: nil, filename: nil)
42
+ instrument :update_metadata, key: key, content_type: content_type, disposition: disposition do
43
+ file_for(key).update do |file|
44
+ file.content_type = content_type
45
+ file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
46
+ end
47
+ end
48
+ end
49
+
50
+ def download_chunk(key, range)
51
+ instrument :download_chunk, key: key, range: range do
52
+ file_for(key).download(range: range).string
53
+ rescue Google::Cloud::NotFoundError
54
+ raise ActiveStorage::FileNotFoundError
55
+ end
56
+ end
57
+
58
+ def delete(key)
59
+ instrument :delete, key: key do
60
+ file_for(key).delete
61
+ rescue Google::Cloud::NotFoundError
62
+ # Ignore files already deleted
63
+ end
64
+ end
65
+
66
+ def delete_prefixed(prefix)
67
+ instrument :delete_prefixed, prefix: prefix do
68
+ bucket.files(prefix: prefix).all do |file|
69
+ file.delete
70
+ rescue Google::Cloud::NotFoundError
71
+ # Ignore concurrently-deleted files
72
+ end
73
+ end
74
+ end
75
+
76
+ def exist?(key)
77
+ instrument :exist, key: key do |payload|
78
+ answer = file_for(key).exists?
79
+ payload[:exist] = answer
80
+ answer
81
+ end
82
+ end
83
+
84
+ def url(key, expires_in:, filename:, content_type:, disposition:)
85
+ instrument :url, key: key do |payload|
86
+ generated_url = file_for(key).signed_url expires: expires_in, query: {
87
+ "response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
88
+ "response-content-type" => content_type
89
+ }
90
+
91
+ payload[:url] = generated_url
92
+
93
+ generated_url
94
+ end
95
+ end
96
+
97
+ def url_for_direct_upload(key, expires_in:, checksum:, **)
98
+ instrument :url, key: key do |payload|
99
+ generated_url = bucket.signed_url key, method: "PUT", expires: expires_in, content_md5: checksum
100
+
101
+ payload[:url] = generated_url
102
+
103
+ generated_url
104
+ end
105
+ end
106
+
107
+ def headers_for_direct_upload(key, checksum:, **)
108
+ { "Content-MD5" => checksum }
109
+ end
110
+
111
+ private
112
+ attr_reader :config
113
+
114
+ def file_for(key, skip_lookup: true)
115
+ bucket.file(key, skip_lookup: skip_lookup)
116
+ end
117
+
118
+ # Reads the file for the given key in chunks, yielding each to the block.
119
+ def stream(key)
120
+ file = file_for(key, skip_lookup: false)
121
+
122
+ chunk_size = 5.megabytes
123
+ offset = 0
124
+
125
+ raise ActiveStorage::FileNotFoundError unless file.present?
126
+
127
+ while offset < file.size
128
+ yield file.download(range: offset..(offset + chunk_size - 1)).string
129
+ offset += chunk_size
130
+ end
131
+ end
132
+
133
+ def bucket
134
+ @bucket ||= client.bucket(config.fetch(:bucket), skip_lookup: true)
135
+ end
136
+
137
+ def client
138
+ @client ||= Google::Cloud::Storage.new(config.except(:bucket))
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+
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
11
+
12
+ delegate :download, :download_chunk, :exist?, :url, :path_for, to: :primary
13
+
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
20
+
21
+ def initialize(primary:, mirrors:)
22
+ @primary, @mirrors = primary, mirrors
23
+ end
24
+
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, **options)
28
+ each_service.collect do |service|
29
+ service.upload key, io.tap(&:rewind), checksum: checksum, **options
30
+ end
31
+ end
32
+
33
+ # Delete the file at the +key+ on all services.
34
+ def delete(key)
35
+ perform_across_services :delete, key
36
+ end
37
+
38
+ # Delete files at keys starting with the +prefix+ on all services.
39
+ def delete_prefixed(prefix)
40
+ perform_across_services :delete_prefixed, prefix
41
+ end
42
+
43
+ private
44
+ def each_service(&block)
45
+ [ primary, *mirrors ].each(&block)
46
+ end
47
+
48
+ def perform_across_services(method, *args)
49
+ # FIXME: Convert to be threaded
50
+ each_service.collect do |service|
51
+ service.public_send method, *args
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-s3"
4
+ require "active_support/core_ext/numeric/bytes"
5
+
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
11
+
12
+ def initialize(bucket:, upload: {}, **options)
13
+ @client = Aws::S3::Resource.new(**options)
14
+ @bucket = @client.bucket(bucket)
15
+
16
+ @upload_options = upload
17
+ end
18
+
19
+ def upload(key, io, checksum: nil, content_type: nil, **)
20
+ instrument :upload, key: key, checksum: checksum do
21
+ object_for(key).put(upload_options.merge(body: io, content_md5: checksum, content_type: content_type))
22
+ rescue Aws::S3::Errors::BadDigest
23
+ raise ActiveStorage::IntegrityError
24
+ end
25
+ end
26
+
27
+ def download(key, &block)
28
+ if block_given?
29
+ instrument :streaming_download, key: key do
30
+ stream(key, &block)
31
+ end
32
+ else
33
+ instrument :download, key: key do
34
+ object_for(key).get.body.string.force_encoding(Encoding::BINARY)
35
+ rescue Aws::S3::Errors::NoSuchKey
36
+ raise ActiveStorage::FileNotFoundError
37
+ end
38
+ end
39
+ end
40
+
41
+ def download_chunk(key, range)
42
+ instrument :download_chunk, key: key, range: range do
43
+ object_for(key).get(range: "bytes=#{range.begin}-#{range.exclude_end? ? range.end - 1 : range.end}").body.read.force_encoding(Encoding::BINARY)
44
+ rescue Aws::S3::Errors::NoSuchKey
45
+ raise ActiveStorage::FileNotFoundError
46
+ end
47
+ end
48
+
49
+ def delete(key)
50
+ instrument :delete, key: key do
51
+ object_for(key).delete
52
+ end
53
+ end
54
+
55
+ def delete_prefixed(prefix)
56
+ instrument :delete_prefixed, prefix: prefix do
57
+ bucket.objects(prefix: prefix).batch_delete!
58
+ end
59
+ end
60
+
61
+ def exist?(key)
62
+ instrument :exist, key: key do |payload|
63
+ answer = object_for(key).exists?
64
+ payload[:exist] = answer
65
+ answer
66
+ end
67
+ end
68
+
69
+ def url(key, expires_in:, filename:, disposition:, content_type:)
70
+ instrument :url, key: key do |payload|
71
+ generated_url = object_for(key).presigned_url :get, expires_in: expires_in.to_i,
72
+ response_content_disposition: content_disposition_with(type: disposition, filename: filename),
73
+ response_content_type: content_type
74
+
75
+ payload[:url] = generated_url
76
+
77
+ generated_url
78
+ end
79
+ end
80
+
81
+ def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
82
+ instrument :url, key: key do |payload|
83
+ generated_url = object_for(key).presigned_url :put, expires_in: expires_in.to_i,
84
+ content_type: content_type, content_length: content_length, content_md5: checksum
85
+
86
+ payload[:url] = generated_url
87
+
88
+ generated_url
89
+ end
90
+ end
91
+
92
+ def headers_for_direct_upload(key, content_type:, checksum:, **)
93
+ { "Content-Type" => content_type, "Content-MD5" => checksum }
94
+ end
95
+
96
+ private
97
+ def object_for(key)
98
+ bucket.object(key)
99
+ end
100
+
101
+ # Reads the object for the given key in chunks, yielding each to the block.
102
+ def stream(key)
103
+ object = object_for(key)
104
+
105
+ chunk_size = 5.megabytes
106
+ offset = 0
107
+
108
+ raise ActiveStorage::FileNotFoundError unless object.exists?
109
+
110
+ while offset < object.content_length
111
+ yield object.get(range: "bytes=#{offset}-#{offset + chunk_size - 1}").body.read.force_encoding(Encoding::BINARY)
112
+ offset += chunk_size
113
+ end
114
+ end
115
+ end
116
+ end