activestorage 8.0.3 → 8.1.0.rc1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 022126fe7ad33aff5dd19e9cb369c9a4264b20b87e33af853f07cf8946bc6b4a
4
- data.tar.gz: e32c5d37045bb9e94d86ae04c24b290a538fe9931eca6d8debdbc8d5d049cdd4
3
+ metadata.gz: 5fdd6b9b99061ff4e07861b93dbf9136f8f38f8e424a7225fcb2ad920e26d397
4
+ data.tar.gz: a9187612f84e46a680fed02e26e279154a3ce93d3bba051e2f128d81a3bc0fcd
5
5
  SHA512:
6
- metadata.gz: 16d1b5343105d7b22c8b3e85b2d3200ab1fa9d4b88e0313a2154f26b0a8dcfcf3039ad86acaad6a631bccb6e91bf560f570c5e0ab9f962881edf7d52d75da5ba
7
- data.tar.gz: f6c5e9f142ee4ddce2be8cb7ae89009d60249ab454160ed3ab4be9fdc55336d2021f9ba411957bdb1aa128d0c7de74d5b4a45e9b5cd4c26500abc75fe0903945
6
+ metadata.gz: '08713db028801c930b720d6ed6d498218f85d62d6b8fb4d5d209bbdbb3e193dd68cbb6969cb42cd3078c0c38d6822408b26df59076892f49a077b5442c8f84ce'
7
+ data.tar.gz: 8ce0d30a68bc92bf8e34b14b6d638cf03c09873b0b925a307d5a43ac3cd345cd63d0cf3d91d358652ec86593a5bf02f4a970be744cca9f6a0e20d757df4350ae
data/CHANGELOG.md CHANGED
@@ -1,82 +1,92 @@
1
- ## Rails 8.0.3 (September 22, 2025) ##
1
+ ## Rails 8.1.0.rc1 (October 15, 2025) ##
2
2
 
3
- * Address deprecation of `Aws::S3::Object#upload_stream` in `ActiveStorage::Service::S3Service`.
3
+ * Add structured events for Active Storage:
4
+ - `active_storage.service_upload`
5
+ - `active_storage.service_download`
6
+ - `active_storage.service_streaming_download`
7
+ - `active_storage.preview`
8
+ - `active_storage.service_delete`
9
+ - `active_storage.service_delete_prefixed`
10
+ - `active_storage.service_exist`
11
+ - `active_storage.service_url`
12
+ - `active_storage.service_mirror`
4
13
 
5
- *Joshua Young*
14
+ *Gannon McGibbon*
6
15
 
7
- * Fix `config.active_storage.touch_attachment_records` to work with eager loading.
16
+ * Allow analyzers and variant transformer to be fully configurable
8
17
 
9
- *fatkodima*
18
+ ```ruby
19
+ # ActiveStorage.analyzers can be set to an empty array:
20
+ config.active_storage.analyzers = []
21
+ # => ActiveStorage.analyzers = []
10
22
 
23
+ # or use custom analyzer:
24
+ config.active_storage.analyzers = [ CustomAnalyzer ]
25
+ # => ActiveStorage.analyzers = [ CustomAnalyzer ]
26
+ ```
11
27
 
12
- ## Rails 8.0.2.1 (August 13, 2025) ##
28
+ If no configuration is provided, it will use the default analyzers.
13
29
 
14
- * Remove dangerous transformations
30
+ You can also disable variant processor to remove warnings on startup about missing gems.
15
31
 
16
- [CVE-2025-24293]
32
+ ```ruby
33
+ config.active_storage.variant_processor = :disabled
34
+ ```
17
35
 
18
- *Zack Deveau*
36
+ *zzak*, *Alexandre Ruban*
19
37
 
20
- ## Rails 8.0.2 (March 12, 2025) ##
21
-
22
- * A Blob will no longer autosave associated Attachment.
23
-
24
- This fixes an issue where a record with an attachment would have
25
- its dirty attributes reset, preventing your `after commit` callbacks
26
- on that record to behave as expected.
27
-
28
- Note that this change doesn't require any changes on your application
29
- and is supposed to be internal. Active Storage Attachment will continue
30
- to be autosaved (through a different relation).
31
-
32
- *Edouard-chin*
33
-
34
-
35
- ## Rails 8.0.1 (December 13, 2024) ##
36
-
37
- * No changes.
38
+ ## Rails 8.1.0.beta1 (September 04, 2025) ##
38
39
 
40
+ * Remove deprecated `:azure` storage service.
39
41
 
40
- ## Rails 8.0.0.1 (December 10, 2024) ##
42
+ *Rafael Mendonça França*
41
43
 
42
- * No changes.
44
+ * Remove unnecessary calls to the GCP metadata server.
43
45
 
46
+ Calling Google::Auth.get_application_default triggers an explicit call to
47
+ the metadata server - given it was being called for significant number of
48
+ file operations, it can lead to considerable tail latencies and even metadata
49
+ server overloads. Instead, it's preferable (and significantly more efficient)
50
+ that applications use:
44
51
 
45
- ## Rails 8.0.0 (November 07, 2024) ##
52
+ ```ruby
53
+ Google::Apis::RequestOptions.default.authorization = Google::Auth.get_application_default(...)
54
+ ```
46
55
 
47
- * No changes.
56
+ In the cases applications do not set that, the GCP libraries automatically determine credentials.
48
57
 
58
+ This also enables using credentials other than those of the associated GCP
59
+ service account like when using impersonation.
49
60
 
50
- ## Rails 8.0.0.rc2 (October 30, 2024) ##
61
+ *Alex Coomans*
51
62
 
52
- * No changes.
63
+ * Direct upload progress accounts for server processing time.
53
64
 
65
+ *Jeremy Daer*
54
66
 
55
- ## Rails 8.0.0.rc1 (October 19, 2024) ##
67
+ * Delegate `ActiveStorage::Filename#to_str` to `#to_s`
56
68
 
57
- * No changes.
69
+ Supports checking String equality:
58
70
 
71
+ ```ruby
72
+ filename = ActiveStorage::Filename.new("file.txt")
73
+ filename == "file.txt" # => true
74
+ filename in "file.txt" # => true
75
+ "file.txt" == filename # => true
76
+ ```
59
77
 
60
- ## Rails 8.0.0.beta1 (September 26, 2024) ##
78
+ *Sean Doyle*
61
79
 
62
- * Deprecate `ActiveStorage::Service::AzureStorageService`.
63
-
64
- *zzak*
65
-
66
- * Improve `ActiveStorage::Filename#sanitized` method to handle special characters more effectively.
67
- Replace the characters `"*?<>` with `-` if they exist in the Filename to match the Filename convention of Win OS.
68
-
69
- *Luong Viet Dung(Martin)*
70
-
71
- * Improve InvariableError, UnpreviewableError and UnrepresentableError message.
72
-
73
- Include Blob ID and content_type in the messages.
74
-
75
- *Petrik de Heus*
80
+ * A Blob will no longer autosave associated Attachment.
76
81
 
77
- * Mark proxied files as `immutable` in their Cache-Control header
82
+ This fixes an issue where a record with an attachment would have
83
+ its dirty attributes reset, preventing your `after commit` callbacks
84
+ on that record to behave as expected.
78
85
 
79
- *Nate Matykiewicz*
86
+ Note that this change doesn't require any changes on your application
87
+ and is supposed to be internal. Active Storage Attachment will continue
88
+ to be autosaved (through a different relation).
80
89
 
90
+ *Edouard-chin*
81
91
 
82
- Please check [7-2-stable](https://github.com/rails/rails/blob/7-2-stable/activestorage/CHANGELOG.md) for previous changes.
92
+ Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/activestorage/CHANGELOG.md) for previous changes.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Active Storage
2
2
 
3
- Active Storage makes it simple to upload and reference files in cloud services like [Amazon S3](https://aws.amazon.com/s3/), [Google Cloud Storage](https://cloud.google.com/storage/docs/), or [Microsoft Azure Storage](https://azure.microsoft.com/en-us/services/storage/), and attach those files to Active Records. Supports having one main service and mirrors in other services for redundancy. It also provides a disk service for testing or local deployments, but the focus is on cloud storage.
3
+ Active Storage makes it simple to upload and reference files in cloud services like [Amazon S3](https://aws.amazon.com/s3/), or [Google Cloud Storage](https://cloud.google.com/storage/docs/), and attach those files to Active Records. Supports having one main service and mirrors in other services for redundancy. It also provides a disk service for testing or local deployments, but the focus is on cloud storage.
4
4
 
5
5
  Files can be uploaded from the server to the cloud or directly from the client to the cloud.
6
6
 
@@ -173,7 +173,10 @@ Active Storage, with its included JavaScript library, supports uploading directl
173
173
  ```erb
174
174
  <%= form.file_field :attachments, multiple: true, direct_upload: true %>
175
175
  ```
176
- 3. That's it! Uploads begin upon form submission.
176
+
177
+ 3. Configure CORS on third-party storage services to allow direct upload requests.
178
+
179
+ 4. That's it! Uploads begin upon form submission.
177
180
 
178
181
  ### Direct upload JavaScript events
179
182
 
@@ -672,7 +672,7 @@ class DirectUploadController {
672
672
  }));
673
673
  }
674
674
  uploadRequestDidProgress(event) {
675
- const progress = event.loaded / event.total * 100;
675
+ const progress = event.loaded / event.total * 90;
676
676
  if (progress) {
677
677
  this.dispatch("progress", {
678
678
  progress: progress
@@ -707,6 +707,42 @@ class DirectUploadController {
707
707
  xhr: xhr
708
708
  });
709
709
  xhr.upload.addEventListener("progress", (event => this.uploadRequestDidProgress(event)));
710
+ xhr.upload.addEventListener("loadend", (() => {
711
+ this.simulateResponseProgress(xhr);
712
+ }));
713
+ }
714
+ simulateResponseProgress(xhr) {
715
+ let progress = 90;
716
+ const startTime = Date.now();
717
+ const updateProgress = () => {
718
+ const elapsed = Date.now() - startTime;
719
+ const estimatedResponseTime = this.estimateResponseTime();
720
+ const responseProgress = Math.min(elapsed / estimatedResponseTime, 1);
721
+ progress = 90 + responseProgress * 9;
722
+ this.dispatch("progress", {
723
+ progress: progress
724
+ });
725
+ if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) {
726
+ requestAnimationFrame(updateProgress);
727
+ }
728
+ };
729
+ xhr.addEventListener("loadend", (() => {
730
+ this.dispatch("progress", {
731
+ progress: 100
732
+ });
733
+ }));
734
+ requestAnimationFrame(updateProgress);
735
+ }
736
+ estimateResponseTime() {
737
+ const fileSize = this.file.size;
738
+ const MB = 1024 * 1024;
739
+ if (fileSize < MB) {
740
+ return 1e3;
741
+ } else if (fileSize < 10 * MB) {
742
+ return 2e3;
743
+ } else {
744
+ return 3e3 + fileSize / MB * 50;
745
+ }
710
746
  }
711
747
  }
712
748
 
@@ -662,7 +662,7 @@
662
662
  }));
663
663
  }
664
664
  uploadRequestDidProgress(event) {
665
- const progress = event.loaded / event.total * 100;
665
+ const progress = event.loaded / event.total * 90;
666
666
  if (progress) {
667
667
  this.dispatch("progress", {
668
668
  progress: progress
@@ -697,6 +697,42 @@
697
697
  xhr: xhr
698
698
  });
699
699
  xhr.upload.addEventListener("progress", (event => this.uploadRequestDidProgress(event)));
700
+ xhr.upload.addEventListener("loadend", (() => {
701
+ this.simulateResponseProgress(xhr);
702
+ }));
703
+ }
704
+ simulateResponseProgress(xhr) {
705
+ let progress = 90;
706
+ const startTime = Date.now();
707
+ const updateProgress = () => {
708
+ const elapsed = Date.now() - startTime;
709
+ const estimatedResponseTime = this.estimateResponseTime();
710
+ const responseProgress = Math.min(elapsed / estimatedResponseTime, 1);
711
+ progress = 90 + responseProgress * 9;
712
+ this.dispatch("progress", {
713
+ progress: progress
714
+ });
715
+ if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) {
716
+ requestAnimationFrame(updateProgress);
717
+ }
718
+ };
719
+ xhr.addEventListener("loadend", (() => {
720
+ this.dispatch("progress", {
721
+ progress: 100
722
+ });
723
+ }));
724
+ requestAnimationFrame(updateProgress);
725
+ }
726
+ estimateResponseTime() {
727
+ const fileSize = this.file.size;
728
+ const MB = 1024 * 1024;
729
+ if (fileSize < MB) {
730
+ return 1e3;
731
+ } else if (fileSize < 10 * MB) {
732
+ return 2e3;
733
+ } else {
734
+ return 3e3 + fileSize / MB * 50;
735
+ }
700
736
  }
701
737
  }
702
738
  const inputSelector = "input[type=file][data-direct-upload-url]:not([disabled])";
@@ -31,7 +31,8 @@ export class DirectUploadController {
31
31
  }
32
32
 
33
33
  uploadRequestDidProgress(event) {
34
- const progress = event.loaded / event.total * 100
34
+ // Scale upload progress to 0-90% range
35
+ const progress = (event.loaded / event.total) * 90
35
36
  if (progress) {
36
37
  this.dispatch("progress", { progress })
37
38
  }
@@ -63,5 +64,51 @@ export class DirectUploadController {
63
64
  directUploadWillStoreFileWithXHR(xhr) {
64
65
  this.dispatch("before-storage-request", { xhr })
65
66
  xhr.upload.addEventListener("progress", event => this.uploadRequestDidProgress(event))
67
+
68
+ // Start simulating progress after upload completes
69
+ xhr.upload.addEventListener("loadend", () => {
70
+ this.simulateResponseProgress(xhr)
71
+ })
72
+ }
73
+
74
+ simulateResponseProgress(xhr) {
75
+ let progress = 90
76
+ const startTime = Date.now()
77
+
78
+ const updateProgress = () => {
79
+ // Simulate progress from 90% to 99% over estimated time
80
+ const elapsed = Date.now() - startTime
81
+ const estimatedResponseTime = this.estimateResponseTime()
82
+ const responseProgress = Math.min(elapsed / estimatedResponseTime, 1)
83
+ progress = 90 + (responseProgress * 9) // 90% to 99%
84
+
85
+ this.dispatch("progress", { progress })
86
+
87
+ // Continue until response arrives or we hit 99%
88
+ if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) {
89
+ requestAnimationFrame(updateProgress)
90
+ }
91
+ }
92
+
93
+ // Stop simulation when response arrives
94
+ xhr.addEventListener("loadend", () => {
95
+ this.dispatch("progress", { progress: 100 })
96
+ })
97
+
98
+ requestAnimationFrame(updateProgress)
99
+ }
100
+
101
+ estimateResponseTime() {
102
+ // Base estimate: 1 second for small files, scaling up for larger files
103
+ const fileSize = this.file.size
104
+ const MB = 1024 * 1024
105
+
106
+ if (fileSize < MB) {
107
+ return 1000 // 1 second for files under 1MB
108
+ } else if (fileSize < 10 * MB) {
109
+ return 2000 // 2 seconds for files 1-10MB
110
+ } else {
111
+ return 3000 + (fileSize / MB * 50) // 3+ seconds for larger files
112
+ }
66
113
  }
67
114
  }
@@ -64,6 +64,7 @@ class ActiveStorage::Filename
64
64
  def to_s
65
65
  sanitized.to_s
66
66
  end
67
+ alias_method :to_str, :to_s
67
68
 
68
69
  def as_json(*)
69
70
  to_s
@@ -8,17 +8,17 @@
8
8
  #
9
9
  # Variants rely on {ImageProcessing}[https://github.com/janko/image_processing] gem for the actual transformations
10
10
  # of the file, so you must add <tt>gem "image_processing"</tt> to your Gemfile if you wish to use variants. By
11
- # default, images will be processed with {ImageMagick}[http://imagemagick.org] using the
12
- # {MiniMagick}[https://github.com/minimagick/minimagick] gem, but you can also switch to the
13
- # {libvips}[http://libvips.github.io/libvips/] processor operated by the {ruby-vips}[https://github.com/libvips/ruby-vips]
11
+ # default, images will be processed with {libvips}[http://libvips.github.io/libvips/] using the
12
+ # {ruby-vips}[https://github.com/libvips/ruby-vips] gem, but you can also switch to the
13
+ # {ImageMagick}[http://imagemagick.org] processor operated by the {MiniMagick}[https://github.com/minimagick/minimagick]
14
14
  # gem).
15
15
  #
16
16
  # Rails.application.config.active_storage.variant_processor
17
- # # => :mini_magick
18
- #
19
- # Rails.application.config.active_storage.variant_processor = :vips
20
17
  # # => :vips
21
18
  #
19
+ # Rails.application.config.active_storage.variant_processor = :mini_magick
20
+ # # => :mini_magick
21
+ #
22
22
  # Note that to create a variant it's necessary to download the entire blob file from the service. Because of this process,
23
23
  # you also want to be considerate about when the variant is actually processed. You shouldn't be processing variants inline
24
24
  # in a template, for example. Delay the processing to an on-demand controller, like the one provided in
@@ -82,6 +82,6 @@ class ActiveStorage::Variation
82
82
 
83
83
  private
84
84
  def transformer
85
- ActiveStorage::Transformers::ImageProcessingTransformer.new(transformations.except(:format))
85
+ ActiveStorage.variant_transformer.new(transformations.except(:format))
86
86
  end
87
87
  end
@@ -1,5 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ begin
4
+ gem "mini_magick"
5
+ require "mini_magick"
6
+ ActiveStorage::MINIMAGICK_AVAILABLE = true # :nodoc:
7
+ rescue LoadError => error
8
+ ActiveStorage::MINIMAGICK_AVAILABLE = false # :nodoc:
9
+ raise error unless error.message.include?("mini_magick")
10
+ end
11
+
3
12
  module ActiveStorage
4
13
  # This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem. MiniMagick requires
5
14
  # the {ImageMagick}[http://www.imagemagick.org] system library.
@@ -10,10 +19,8 @@ module ActiveStorage
10
19
 
11
20
  private
12
21
  def read_image
13
- begin
14
- require "mini_magick"
15
- rescue LoadError
16
- logger.info "Skipping image analysis because the mini_magick gem isn't installed"
22
+ unless MINIMAGICK_AVAILABLE
23
+ logger.error "Skipping image analysis because the mini_magick gem isn't installed"
17
24
  return {}
18
25
  end
19
26
 
@@ -1,5 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ begin
4
+ require "nokogiri"
5
+ rescue LoadError
6
+ # Ensure nokogiri is loaded before vips, which also depends on libxml2.
7
+ # See Nokogiri RFC: Stop exporting symbols:
8
+ # https://github.com/sparklemotion/nokogiri/discussions/2746
9
+ end
10
+
11
+ begin
12
+ gem "ruby-vips"
13
+ require "ruby-vips"
14
+ ActiveStorage::VIPS_AVAILABLE = true # :nodoc:
15
+ rescue LoadError => error
16
+ ActiveStorage::VIPS_AVAILABLE = false # :nodoc:
17
+ raise error unless error.message.match?(/libvips|ruby-vips/)
18
+ end
19
+
3
20
  module ActiveStorage
4
21
  # This analyzer relies on the third-party {ruby-vips}[https://github.com/libvips/ruby-vips] gem. Ruby-vips requires
5
22
  # the {libvips}[https://libvips.github.io/libvips/] system library.
@@ -10,10 +27,8 @@ module ActiveStorage
10
27
 
11
28
  private
12
29
  def read_image
13
- begin
14
- require "ruby-vips"
15
- rescue LoadError
16
- logger.info "Skipping image analysis because the ruby-vips gem isn't installed"
30
+ unless VIPS_AVAILABLE
31
+ logger.error "Skipping image analysis because the ruby-vips gem isn't installed"
17
32
  return {}
18
33
  end
19
34
 
@@ -35,6 +50,9 @@ module ActiveStorage
35
50
  logger.error "Skipping image analysis due to a Vips error: #{error.message}"
36
51
  {}
37
52
  end
53
+ rescue ::Vips::Error => error
54
+ logger.error "Skipping image analysis due to an Vips error: #{error.message}"
55
+ {}
38
56
  end
39
57
 
40
58
  ROTATIONS = /Right-top|Left-bottom|Top-right|Bottom-left/
@@ -12,6 +12,11 @@ module ActiveStorage
12
12
  # ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick.new(blob).metadata
13
13
  # # => { width: 4104, height: 2736 }
14
14
  class Analyzer::ImageAnalyzer < Analyzer
15
+ extend ActiveSupport::Autoload
16
+
17
+ autoload :Vips
18
+ autoload :ImageMagick
19
+
15
20
  def self.accept?(blob)
16
21
  blob.image?
17
22
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/module/delegation"
4
3
 
5
4
  module ActiveStorage
6
5
  # = Active Storage \Attached
@@ -12,8 +12,6 @@ require "active_storage/previewer/mupdf_previewer"
12
12
  require "active_storage/previewer/video_previewer"
13
13
 
14
14
  require "active_storage/analyzer/image_analyzer"
15
- require "active_storage/analyzer/image_analyzer/image_magick"
16
- require "active_storage/analyzer/image_analyzer/vips"
17
15
  require "active_storage/analyzer/video_analyzer"
18
16
  require "active_storage/analyzer/audio_analyzer"
19
17
 
@@ -93,6 +91,35 @@ module ActiveStorage
93
91
  ActiveStorage.variant_processor = app.config.active_storage.variant_processor || :mini_magick
94
92
  ActiveStorage.previewers = app.config.active_storage.previewers || []
95
93
  ActiveStorage.analyzers = app.config.active_storage.analyzers || []
94
+
95
+ begin
96
+ ActiveStorage.variant_transformer =
97
+ case ActiveStorage.variant_processor
98
+ when :disabled
99
+ ActiveStorage::Transformers::NullTransformer
100
+ when :vips
101
+ ActiveStorage::Transformers::Vips
102
+ when :mini_magick
103
+ ActiveStorage::Transformers::ImageMagick
104
+ end
105
+ rescue LoadError => error
106
+ case error.message
107
+ when /libvips/
108
+ ActiveStorage.logger.warn <<~WARNING.squish
109
+ Using vips to process variants requires the libvips library.
110
+ Please install libvips using the instructions on the libvips website.
111
+ WARNING
112
+ when /image_processing/
113
+ ActiveStorage.logger.warn <<~WARNING.squish
114
+ Generating image variants require the image_processing gem.
115
+ Please add `gem "image_processing", "~> 1.2"` to your Gemfile
116
+ or set `config.active_storage.variant_processor = :disabled`.
117
+ WARNING
118
+ else
119
+ raise
120
+ end
121
+ end
122
+
96
123
  ActiveStorage.paths = app.config.active_storage.paths || {}
97
124
  ActiveStorage.routes_prefix = app.config.active_storage.routes_prefix || "/rails/active_storage"
98
125
  ActiveStorage.draw_routes = app.config.active_storage.draw_routes != false
@@ -139,9 +166,9 @@ module ActiveStorage
139
166
  end
140
167
  end
141
168
 
142
- initializer "active_storage.services" do
169
+ initializer "active_storage.services" do |app|
143
170
  ActiveSupport.on_load(:active_storage_blob) do
144
- configs = Rails.configuration.active_storage.service_configurations ||=
171
+ configs = app.config.active_storage.service_configurations ||=
145
172
  begin
146
173
  config_file = Rails.root.join("config/storage/#{Rails.env}.yml")
147
174
  config_file = Rails.root.join("config/storage.yml") unless config_file.exist?
@@ -152,7 +179,7 @@ module ActiveStorage
152
179
 
153
180
  ActiveStorage::Blob.services = ActiveStorage::Service::Registry.new(configs)
154
181
 
155
- if config_choice = Rails.configuration.active_storage.service
182
+ if config_choice = app.config.active_storage.service
156
183
  ActiveStorage::Blob.service = ActiveStorage::Blob.services.fetch(config_choice)
157
184
  end
158
185
  end
@@ -174,7 +201,7 @@ module ActiveStorage
174
201
  initializer "action_view.configuration" do
175
202
  config.after_initialize do |app|
176
203
  ActiveSupport.on_load(:action_view) do
177
- multiple_file_field_include_hidden = app.config.active_storage.delete(:multiple_file_field_include_hidden)
204
+ multiple_file_field_include_hidden = app.config.active_storage.multiple_file_field_include_hidden
178
205
 
179
206
  unless multiple_file_field_include_hidden.nil?
180
207
  ActionView::Helpers::FormHelper.multiple_file_field_include_hidden = multiple_file_field_include_hidden
@@ -8,9 +8,9 @@ module ActiveStorage
8
8
 
9
9
  module VERSION
10
10
  MAJOR = 8
11
- MINOR = 0
12
- TINY = 3
13
- PRE = nil
11
+ MINOR = 1
12
+ TINY = 0
13
+ PRE = "rc1"
14
14
 
15
15
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
16
16
  end
@@ -3,7 +3,7 @@
3
3
  require "active_support/log_subscriber"
4
4
 
5
5
  module ActiveStorage
6
- class LogSubscriber < ActiveSupport::LogSubscriber
6
+ class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc:
7
7
  def service_upload(event)
8
8
  message = "Uploaded file to key: #{key_in(event)}"
9
9
  message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum]
@@ -19,6 +19,12 @@ module ActiveStorage
19
19
  )
20
20
  end
21
21
 
22
+ def inspect # :nodoc:
23
+ attrs = configurations.any? ?
24
+ " configurations=[#{configurations.keys.map(&:inspect).join(", ")}]" : ""
25
+ "#<#{self.class}#{attrs}>"
26
+ end
27
+
22
28
  private
23
29
  def config_for(name)
24
30
  configurations.fetch name do
@@ -213,8 +213,16 @@ module ActiveStorage
213
213
  lambda do |string_to_sign|
214
214
  iam_client = Google::Apis::IamcredentialsV1::IAMCredentialsService.new
215
215
 
216
- scopes = ["https://www.googleapis.com/auth/iam"]
217
- iam_client.authorization = Google::Auth.get_application_default(scopes)
216
+ # We explicitly do not set iam_client.authorization so that it uses the
217
+ # credentials set by the application at Google::Apis::RequestOptions.default.authorization.
218
+ # If the application does not set it, the GCP libraries will automatically
219
+ # determine it on each call. This code previously explicitly set the
220
+ # authorization to Google::Auth.get_application_default which triggers
221
+ # an explicit call to the metadata server - given this lambda is called
222
+ # for a significant number of file operations, it can lead to considerable
223
+ # tail latencies and even metadata server overloads. Additionally, that
224
+ # prevented applications from being able to configure the credentials
225
+ # used to perform the signature operation.
218
226
 
219
227
  request = Google::Apis::IamcredentialsV1::SignBlobRequest.new(
220
228
  payload: string_to_sign
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_support/core_ext/module/delegation"
4
3
 
5
4
  module ActiveStorage
6
5
  # = Active Storage Mirror \Service
@@ -31,6 +30,7 @@ module ActiveStorage
31
30
  def initialize(primary:, mirrors:)
32
31
  @primary, @mirrors = primary, mirrors
33
32
  @executor = Concurrent::ThreadPoolExecutor.new(
33
+ name: "ActiveStorage-mirror-service",
34
34
  min_threads: 1,
35
35
  max_threads: mirrors.size,
36
36
  max_queue: 0,
@@ -22,6 +22,12 @@ module ActiveStorage
22
22
  end
23
23
  end
24
24
 
25
+ def inspect # :nodoc:
26
+ attrs = configurations.any? ?
27
+ " configurations=[#{configurations.keys.map(&:inspect).join(", ")}]" : ""
28
+ "#<#{self.class}#{attrs}>"
29
+ end
30
+
25
31
  private
26
32
  attr_reader :configurations, :services
27
33
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_storage/log_subscriber"
4
+ require "active_storage/structured_event_subscriber"
4
5
  require "active_storage/downloader"
5
6
  require "action_dispatch"
6
7
  require "action_dispatch/http/content_disposition"
@@ -15,7 +16,6 @@ module ActiveStorage
15
16
  # * +Disk+, to manage attachments saved directly on the hard drive.
16
17
  # * +GCS+, to manage attachments through Google Cloud Storage.
17
18
  # * +S3+, to manage attachments through Amazon S3.
18
- # * +AzureStorage+, to manage attachments through Microsoft Azure Storage.
19
19
  # * +Mirror+, to be able to use several services to manage attachments.
20
20
  #
21
21
  # Inside a \Rails application, you can set-up your services through the
@@ -148,6 +148,10 @@ module ActiveStorage
148
148
  @public
149
149
  end
150
150
 
151
+ def inspect # :nodoc:
152
+ "#<#{self.class}#{name.present? ? " name=#{name.inspect}" : ""}>"
153
+ end
154
+
151
155
  private
152
156
  def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
153
157
  raise NotImplementedError
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/structured_event_subscriber"
4
+
5
+ module ActiveStorage
6
+ class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc:
7
+ def service_upload(event)
8
+ emit_event("active_storage.service_upload",
9
+ key: event.payload[:key],
10
+ checksum: event.payload[:checksum],
11
+ duration_ms: event.duration.round(2),
12
+ )
13
+ end
14
+
15
+ def service_download(event)
16
+ emit_event("active_storage.service_download",
17
+ key: event.payload[:key],
18
+ duration_ms: event.duration.round(2),
19
+ )
20
+ end
21
+
22
+ def service_streaming_download(event)
23
+ emit_event("active_storage.service_streaming_download",
24
+ key: event.payload[:key],
25
+ duration_ms: event.duration.round(2),
26
+ )
27
+ end
28
+
29
+ def preview(event)
30
+ emit_event("active_storage.preview",
31
+ key: event.payload[:key],
32
+ duration_ms: event.duration.round(2),
33
+ )
34
+ end
35
+
36
+ def service_delete(event)
37
+ emit_event("active_storage.service_delete",
38
+ key: event.payload[:key],
39
+ duration_ms: event.duration.round(2),
40
+ )
41
+ end
42
+
43
+ def service_delete_prefixed(event)
44
+ emit_event("active_storage.service_delete_prefixed",
45
+ prefix: event.payload[:prefix],
46
+ duration_ms: event.duration.round(2),
47
+ )
48
+ end
49
+
50
+ def service_exist(event)
51
+ emit_debug_event("active_storage.service_exist",
52
+ key: event.payload[:key],
53
+ exist: event.payload[:exist],
54
+ duration_ms: event.duration.round(2),
55
+ )
56
+ end
57
+ debug_only :service_exist
58
+
59
+ def service_url(event)
60
+ emit_debug_event("active_storage.service_url",
61
+ key: event.payload[:key],
62
+ url: event.payload[:url],
63
+ duration_ms: event.duration.round(2),
64
+ )
65
+ end
66
+ debug_only :service_url
67
+
68
+ def service_mirror(event)
69
+ emit_debug_event("active_storage.service_mirror",
70
+ key: event.payload[:key],
71
+ checksum: event.payload[:checksum],
72
+ duration_ms: event.duration.round(2),
73
+ )
74
+ end
75
+ debug_only :service_mirror
76
+ end
77
+ end
78
+
79
+ ActiveStorage::StructuredEventSubscriber.attach_to :active_storage
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module Transformers
5
+ class ImageMagick < ImageProcessingTransformer
6
+ private
7
+ def processor
8
+ ImageProcessing::MiniMagick
9
+ end
10
+
11
+ def validate_transformation(name, argument)
12
+ method_name = name.to_s.tr("-", "_")
13
+
14
+ unless ActiveStorage.supported_image_processing_methods.include?(method_name)
15
+ raise UnsupportedImageProcessingMethod, <<~ERROR.squish
16
+ The provided transformation method is not supported: #{method_name}.
17
+ ERROR
18
+ end
19
+
20
+ if argument.present?
21
+ if argument.is_a?(String) || argument.is_a?(Symbol)
22
+ validate_arg_string(argument)
23
+ elsif argument.is_a?(Array)
24
+ validate_arg_array(argument)
25
+ elsif argument.is_a?(Hash)
26
+ validate_arg_hash(argument)
27
+ end
28
+ end
29
+
30
+ super
31
+ end
32
+
33
+ def validate_arg_string(argument)
34
+ unsupported_arguments = ActiveStorage.unsupported_image_processing_arguments.any? do |bad_arg|
35
+ argument.to_s.downcase.include?(bad_arg)
36
+ end
37
+
38
+ raise UnsupportedImageProcessingArgument if unsupported_arguments
39
+ end
40
+
41
+ def validate_arg_array(argument)
42
+ argument.each do |arg|
43
+ if arg.is_a?(Integer) || arg.is_a?(Float)
44
+ next
45
+ elsif arg.is_a?(String) || arg.is_a?(Symbol)
46
+ validate_arg_string(arg)
47
+ elsif arg.is_a?(Array)
48
+ validate_arg_array(arg)
49
+ elsif arg.is_a?(Hash)
50
+ validate_arg_hash(arg)
51
+ end
52
+ end
53
+ end
54
+
55
+ def validate_arg_hash(argument)
56
+ argument.each do |key, value|
57
+ validate_arg_string(key)
58
+
59
+ if value.is_a?(Integer) || value.is_a?(Float)
60
+ next
61
+ elsif value.is_a?(String) || value.is_a?(Symbol)
62
+ validate_arg_string(value)
63
+ elsif value.is_a?(Array)
64
+ validate_arg_array(value)
65
+ elsif value.is_a?(Hash)
66
+ validate_arg_hash(value)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -25,22 +25,9 @@ module ActiveStorage
25
25
  call
26
26
  end
27
27
 
28
- def processor
29
- ImageProcessing.const_get(ActiveStorage.variant_processor.to_s.camelize)
30
- end
31
-
32
28
  def operations
33
29
  transformations.each_with_object([]) do |(name, argument), list|
34
- if ActiveStorage.variant_processor == :mini_magick
35
- validate_transformation(name, argument)
36
- end
37
-
38
- if name.to_s == "combine_options"
39
- raise ArgumentError, <<~ERROR.squish
40
- Active Storage's ImageProcessing transformer doesn't support :combine_options,
41
- as it always generates a single command.
42
- ERROR
43
- end
30
+ validate_transformation(name, argument)
44
31
 
45
32
  if argument.present?
46
33
  list << [ name, argument ]
@@ -49,61 +36,12 @@ module ActiveStorage
49
36
  end
50
37
 
51
38
  def validate_transformation(name, argument)
52
- method_name = name.to_s.tr("-", "_")
53
-
54
- unless ActiveStorage.supported_image_processing_methods.any? { |method| method_name == method }
55
- raise UnsupportedImageProcessingMethod, <<~ERROR.squish
56
- One or more of the provided transformation methods is not supported.
39
+ if name.to_s == "combine_options"
40
+ raise ArgumentError, <<~ERROR.squish
41
+ Active Storage's ImageProcessing transformer doesn't support :combine_options,
42
+ as it always generates a single command.
57
43
  ERROR
58
44
  end
59
-
60
- if argument.present?
61
- if argument.is_a?(String) || argument.is_a?(Symbol)
62
- validate_arg_string(argument)
63
- elsif argument.is_a?(Array)
64
- validate_arg_array(argument)
65
- elsif argument.is_a?(Hash)
66
- validate_arg_hash(argument)
67
- end
68
- end
69
- end
70
-
71
- def validate_arg_string(argument)
72
- unsupported_arguments = ActiveStorage.unsupported_image_processing_arguments.any? do |bad_arg|
73
- argument.to_s.downcase.include?(bad_arg)
74
- end
75
-
76
- raise UnsupportedImageProcessingArgument if unsupported_arguments
77
- end
78
-
79
- def validate_arg_array(argument)
80
- argument.each do |arg|
81
- if arg.is_a?(Integer) || arg.is_a?(Float)
82
- next
83
- elsif arg.is_a?(String) || arg.is_a?(Symbol)
84
- validate_arg_string(arg)
85
- elsif arg.is_a?(Array)
86
- validate_arg_array(arg)
87
- elsif arg.is_a?(Hash)
88
- validate_arg_hash(arg)
89
- end
90
- end
91
- end
92
-
93
- def validate_arg_hash(argument)
94
- argument.each do |key, value|
95
- validate_arg_string(key)
96
-
97
- if value.is_a?(Integer) || value.is_a?(Float)
98
- next
99
- elsif value.is_a?(String) || value.is_a?(Symbol)
100
- validate_arg_string(value)
101
- elsif value.is_a?(Array)
102
- validate_arg_array(value)
103
- elsif value.is_a?(Hash)
104
- validate_arg_hash(value)
105
- end
106
- end
107
45
  end
108
46
  end
109
47
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module Transformers
5
+ class NullTransformer < Transformer # :nodoc:
6
+ private
7
+ def process(file, format:)
8
+ file
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module Transformers
5
+ class Vips < ImageProcessingTransformer
6
+ def processor
7
+ ImageProcessing::Vips
8
+ end
9
+ end
10
+ end
11
+ end
@@ -49,6 +49,8 @@ module ActiveStorage
49
49
  mattr_accessor :verifier
50
50
  mattr_accessor :variant_processor, default: :mini_magick
51
51
 
52
+ mattr_accessor :variant_transformer
53
+
52
54
  mattr_accessor :queues, default: {}
53
55
 
54
56
  mattr_accessor :previewers, default: []
@@ -366,6 +368,9 @@ module ActiveStorage
366
368
  extend ActiveSupport::Autoload
367
369
 
368
370
  autoload :Transformer
371
+ autoload :NullTransformer
369
372
  autoload :ImageProcessingTransformer
373
+ autoload :Vips
374
+ autoload :ImageMagick
370
375
  end
371
376
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activestorage
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.3
4
+ version: 8.1.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
@@ -15,56 +15,56 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 8.0.3
18
+ version: 8.1.0.rc1
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 8.0.3
25
+ version: 8.1.0.rc1
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: actionpack
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - '='
31
31
  - !ruby/object:Gem::Version
32
- version: 8.0.3
32
+ version: 8.1.0.rc1
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - '='
38
38
  - !ruby/object:Gem::Version
39
- version: 8.0.3
39
+ version: 8.1.0.rc1
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: activejob
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - '='
45
45
  - !ruby/object:Gem::Version
46
- version: 8.0.3
46
+ version: 8.1.0.rc1
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - '='
52
52
  - !ruby/object:Gem::Version
53
- version: 8.0.3
53
+ version: 8.1.0.rc1
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: activerecord
56
56
  requirement: !ruby/object:Gem::Requirement
57
57
  requirements:
58
58
  - - '='
59
59
  - !ruby/object:Gem::Version
60
- version: 8.0.3
60
+ version: 8.1.0.rc1
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - '='
66
66
  - !ruby/object:Gem::Version
67
- version: 8.0.3
67
+ version: 8.1.0.rc1
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: marcel
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -173,15 +173,18 @@ files:
173
173
  - lib/active_storage/previewer/video_previewer.rb
174
174
  - lib/active_storage/reflection.rb
175
175
  - lib/active_storage/service.rb
176
- - lib/active_storage/service/azure_storage_service.rb
177
176
  - lib/active_storage/service/configurator.rb
178
177
  - lib/active_storage/service/disk_service.rb
179
178
  - lib/active_storage/service/gcs_service.rb
180
179
  - lib/active_storage/service/mirror_service.rb
181
180
  - lib/active_storage/service/registry.rb
182
181
  - lib/active_storage/service/s3_service.rb
182
+ - lib/active_storage/structured_event_subscriber.rb
183
+ - lib/active_storage/transformers/image_magick.rb
183
184
  - lib/active_storage/transformers/image_processing_transformer.rb
185
+ - lib/active_storage/transformers/null_transformer.rb
184
186
  - lib/active_storage/transformers/transformer.rb
187
+ - lib/active_storage/transformers/vips.rb
185
188
  - lib/active_storage/version.rb
186
189
  - lib/tasks/activestorage.rake
187
190
  homepage: https://rubyonrails.org
@@ -189,10 +192,10 @@ licenses:
189
192
  - MIT
190
193
  metadata:
191
194
  bug_tracker_uri: https://github.com/rails/rails/issues
192
- changelog_uri: https://github.com/rails/rails/blob/v8.0.3/activestorage/CHANGELOG.md
193
- documentation_uri: https://api.rubyonrails.org/v8.0.3/
195
+ changelog_uri: https://github.com/rails/rails/blob/v8.1.0.rc1/activestorage/CHANGELOG.md
196
+ documentation_uri: https://api.rubyonrails.org/v8.1.0.rc1/
194
197
  mailing_list_uri: https://discuss.rubyonrails.org/c/rubyonrails-talk
195
- source_code_uri: https://github.com/rails/rails/tree/v8.0.3/activestorage
198
+ source_code_uri: https://github.com/rails/rails/tree/v8.1.0.rc1/activestorage
196
199
  rubygems_mfa_required: 'true'
197
200
  rdoc_options: []
198
201
  require_paths:
@@ -1,201 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- gem "azure-storage-blob", ">= 2.0"
4
-
5
- require "active_support/core_ext/numeric/bytes"
6
- require "azure/storage/blob"
7
- require "azure/storage/common/core/auth/shared_access_signature"
8
-
9
- module ActiveStorage
10
- # = Active Storage \Azure Storage \Service
11
- #
12
- # Wraps the Microsoft Azure Storage Blob Service as an Active Storage service.
13
- # See ActiveStorage::Service for the generic API documentation that applies to all services.
14
- class Service::AzureStorageService < Service
15
- attr_reader :client, :container, :signer
16
-
17
- def initialize(storage_account_name:, storage_access_key:, container:, public: false, **options)
18
- ActiveStorage.deprecator.warn <<~MSG.squish
19
- `ActiveStorage::Service::AzureStorageService` is deprecated and will be
20
- removed in Rails 8.1.
21
- Please try the `azure-blob` gem instead.
22
- This gem is not maintained by the Rails team, so please test your applications before deploying to production.
23
- MSG
24
-
25
- @client = Azure::Storage::Blob::BlobService.create(storage_account_name: storage_account_name, storage_access_key: storage_access_key, **options)
26
- @signer = Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(storage_account_name, storage_access_key)
27
- @container = container
28
- @public = public
29
- end
30
-
31
- def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}, **)
32
- instrument :upload, key: key, checksum: checksum do
33
- handle_errors do
34
- content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename
35
-
36
- client.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata)
37
- end
38
- end
39
- end
40
-
41
- def download(key, &block)
42
- if block_given?
43
- instrument :streaming_download, key: key do
44
- stream(key, &block)
45
- end
46
- else
47
- instrument :download, key: key do
48
- handle_errors do
49
- _, io = client.get_blob(container, key)
50
- io.force_encoding(Encoding::BINARY)
51
- end
52
- end
53
- end
54
- end
55
-
56
- def download_chunk(key, range)
57
- instrument :download_chunk, key: key, range: range do
58
- handle_errors do
59
- _, io = client.get_blob(container, key, start_range: range.begin, end_range: range.exclude_end? ? range.end - 1 : range.end)
60
- io.force_encoding(Encoding::BINARY)
61
- end
62
- end
63
- end
64
-
65
- def delete(key)
66
- instrument :delete, key: key do
67
- client.delete_blob(container, key)
68
- rescue Azure::Core::Http::HTTPError => e
69
- raise unless e.type == "BlobNotFound"
70
- # Ignore files already deleted
71
- end
72
- end
73
-
74
- def delete_prefixed(prefix)
75
- instrument :delete_prefixed, prefix: prefix do
76
- marker = nil
77
-
78
- loop do
79
- results = client.list_blobs(container, prefix: prefix, marker: marker)
80
-
81
- results.each do |blob|
82
- client.delete_blob(container, blob.name)
83
- end
84
-
85
- break unless marker = results.continuation_token.presence
86
- end
87
- end
88
- end
89
-
90
- def exist?(key)
91
- instrument :exist, key: key do |payload|
92
- answer = blob_for(key).present?
93
- payload[:exist] = answer
94
- answer
95
- end
96
- end
97
-
98
- def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {})
99
- instrument :url, key: key do |payload|
100
- generated_url = signer.signed_uri(
101
- uri_for(key), false,
102
- service: "b",
103
- permissions: "rw",
104
- expiry: format_expiry(expires_in)
105
- ).to_s
106
-
107
- payload[:url] = generated_url
108
-
109
- generated_url
110
- end
111
- end
112
-
113
- def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, custom_metadata: {}, **)
114
- content_disposition = content_disposition_with(type: disposition, filename: filename) if filename
115
-
116
- { "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob", **custom_metadata_headers(custom_metadata) }
117
- end
118
-
119
- def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
120
- content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
121
-
122
- client.create_append_blob(
123
- container,
124
- destination_key,
125
- content_type: content_type,
126
- content_disposition: content_disposition,
127
- metadata: custom_metadata,
128
- ).tap do |blob|
129
- source_keys.each do |source_key|
130
- stream(source_key) do |chunk|
131
- client.append_blob_block(container, blob.name, chunk)
132
- end
133
- end
134
- end
135
- end
136
-
137
- private
138
- def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
139
- signer.signed_uri(
140
- uri_for(key), false,
141
- service: "b",
142
- permissions: "r",
143
- expiry: format_expiry(expires_in),
144
- content_disposition: content_disposition_with(type: disposition, filename: filename),
145
- content_type: content_type
146
- ).to_s
147
- end
148
-
149
- def public_url(key, **)
150
- uri_for(key).to_s
151
- end
152
-
153
-
154
- def uri_for(key)
155
- client.generate_uri("#{container}/#{key}")
156
- end
157
-
158
- def blob_for(key)
159
- client.get_blob_properties(container, key)
160
- rescue Azure::Core::Http::HTTPError
161
- false
162
- end
163
-
164
- def format_expiry(expires_in)
165
- expires_in ? Time.now.utc.advance(seconds: expires_in).iso8601 : nil
166
- end
167
-
168
- # Reads the object for the given key in chunks, yielding each to the block.
169
- def stream(key)
170
- blob = blob_for(key)
171
-
172
- chunk_size = 5.megabytes
173
- offset = 0
174
-
175
- raise ActiveStorage::FileNotFoundError unless blob.present?
176
-
177
- while offset < blob.properties[:content_length]
178
- _, chunk = client.get_blob(container, key, start_range: offset, end_range: offset + chunk_size - 1)
179
- yield chunk.force_encoding(Encoding::BINARY)
180
- offset += chunk_size
181
- end
182
- end
183
-
184
- def handle_errors
185
- yield
186
- rescue Azure::Core::Http::HTTPError => e
187
- case e.type
188
- when "BlobNotFound"
189
- raise ActiveStorage::FileNotFoundError
190
- when "Md5Mismatch"
191
- raise ActiveStorage::IntegrityError
192
- else
193
- raise
194
- end
195
- end
196
-
197
- def custom_metadata_headers(metadata)
198
- metadata.transform_keys { |key| "x-ms-meta-#{key}" }
199
- end
200
- end
201
- end