activestorage 7.2.2.1 → 8.1.3

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -55
  3. data/README.md +8 -5
  4. data/app/assets/javascripts/activestorage.esm.js +38 -2
  5. data/app/assets/javascripts/activestorage.js +38 -1
  6. data/app/controllers/active_storage/direct_uploads_controller.rb +1 -1
  7. data/app/controllers/active_storage/disk_controller.rb +6 -2
  8. data/app/controllers/concerns/active_storage/streaming.rb +17 -1
  9. data/app/javascript/activestorage/direct_upload_controller.js +48 -1
  10. data/app/javascript/activestorage/index.js +2 -1
  11. data/app/models/active_storage/blob/representable.rb +71 -5
  12. data/app/models/active_storage/blob.rb +25 -21
  13. data/app/models/active_storage/filename.rb +2 -1
  14. data/app/models/active_storage/variant.rb +12 -12
  15. data/app/models/active_storage/variation.rb +1 -1
  16. data/lib/active_storage/analyzer/image_analyzer/image_magick.rb +11 -4
  17. data/lib/active_storage/analyzer/image_analyzer/vips.rb +23 -5
  18. data/lib/active_storage/analyzer/image_analyzer.rb +5 -0
  19. data/lib/active_storage/attached/changes/create_one.rb +1 -1
  20. data/lib/active_storage/attached/model.rb +41 -18
  21. data/lib/active_storage/attached.rb +0 -1
  22. data/lib/active_storage/engine.rb +38 -7
  23. data/lib/active_storage/errors.rb +4 -0
  24. data/lib/active_storage/fixture_set.rb +1 -1
  25. data/lib/active_storage/gem_version.rb +4 -4
  26. data/lib/active_storage/log_subscriber.rb +1 -1
  27. data/lib/active_storage/service/configurator.rb +6 -0
  28. data/lib/active_storage/service/disk_service.rb +46 -2
  29. data/lib/active_storage/service/gcs_service.rb +15 -5
  30. data/lib/active_storage/service/mirror_service.rb +13 -4
  31. data/lib/active_storage/service/registry.rb +6 -0
  32. data/lib/active_storage/service/s3_service.rb +19 -4
  33. data/lib/active_storage/service.rb +5 -1
  34. data/lib/active_storage/structured_event_subscriber.rb +79 -0
  35. data/lib/active_storage/transformers/image_magick.rb +72 -0
  36. data/lib/active_storage/transformers/image_processing_transformer.rb +5 -67
  37. data/lib/active_storage/transformers/null_transformer.rb +12 -0
  38. data/lib/active_storage/transformers/vips.rb +11 -0
  39. data/lib/active_storage.rb +19 -3
  40. metadata +19 -19
  41. data/lib/active_storage/service/azure_storage_service.rb +0 -194
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19f4fa25c348a39b38b18a049dd123c5a0c6f80d70e05cdfc4790c7e0dbf96d1
4
- data.tar.gz: 48ba490e74129a1495ed0b3249730bfeba7b12f916e787849e7cfa4f81235bf9
3
+ metadata.gz: 2a42fb3f7bbc7e2032f582d6d934bfb06245afdd8fe2db1b07b35d6c8223a617
4
+ data.tar.gz: aefc082d3ed704bf9c675e40560d9eb3be6f2e315ea28d7313ceabb493a95b93
5
5
  SHA512:
6
- metadata.gz: 6656a5f10b464e63d86f0e4fc60984735e522d56789f93ce4f0d6ec4684b0b7e6a449f14af01a92e5929b1a4854f283b0bf89b37fe67ecb5d8cc207430b81c71
7
- data.tar.gz: a8fe146a25f52b241de32978f344a2303ad5221f05306a57776337194ba67aa2691c715790131056dec44dfe21cb26b7df1fe33fef5d09efba3ff5f226b83e58
6
+ metadata.gz: c5c63ac998f475f5b3b9fb11d6bd57f0697b1ece83903f4283b35eefe490a0b0a6edb4e9b8d6d2fc4673c67948296c66af79bc25821a16ed9ad3144d76bdef5e
7
+ data.tar.gz: 12412531395d736326f067fdb7cdbe23f4a99a3167d4921d1882191baf4d5c79d6260d9ef598b1bcc90c0ffc35a78a2b898bcad9d8d2332c5f5749378eceeaca
data/CHANGELOG.md CHANGED
@@ -1,103 +1,174 @@
1
- ## Rails 7.2.2.1 (December 10, 2024) ##
1
+ ## Rails 8.1.3 (March 24, 2026) ##
2
2
 
3
- * No changes.
3
+ * Fix `ActiveStorage::Blob` content type predicate methods to handle `nil`.
4
4
 
5
+ *Daichi KUDO*
5
6
 
6
- ## Rails 7.2.2 (October 30, 2024) ##
7
7
 
8
- * No changes.
8
+ ## Rails 8.1.2.1 (March 23, 2026) ##
9
9
 
10
+ * Filter user supplied metadata in DirectUploadController
10
11
 
11
- ## Rails 7.2.1.2 (October 23, 2024) ##
12
+ [CVE-2026-33173]
12
13
 
13
- * No changes.
14
+ *Jean Boussier*
14
15
 
16
+ * Configurable maxmimum streaming chunk size
15
17
 
16
- ## Rails 7.2.1.1 (October 15, 2024) ##
18
+ Makes sure that byte ranges for blobs don't exceed 100mb by default.
19
+ Content ranges that are too big can result in denial of service.
17
20
 
18
- * No changes.
21
+ [CVE-2026-33174]
19
22
 
23
+ *Gannon McGibbon*
20
24
 
21
- ## Rails 7.2.1 (August 22, 2024) ##
25
+ * Limit range requests to a single range
22
26
 
23
- * No changes.
27
+ [CVE-2026-33658]
24
28
 
29
+ *Jean Boussier*
25
30
 
26
- ## Rails 7.2.0 (August 09, 2024) ##
27
31
 
28
- * Remove deprecated `config.active_storage.silence_invalid_content_types_warning`.
32
+ * Prevent path traversal in `DiskService`.
29
33
 
30
- *Rafael Mendonça França*
34
+ `DiskService#path_for` now raises an `InvalidKeyError` when passed keys with dot segments (".",
35
+ ".."), or if the resolved path is outside the storage root directory.
31
36
 
32
- * Remove deprecated `config.active_storage.replace_on_assign_to_many`.
37
+ `#path_for` also now consistently raises `InvalidKeyError` if the key is invalid in any way, for
38
+ example containing null bytes or having an incompatible encoding. Previously, the exception
39
+ raised may have been `ArgumentError` or `Encoding::CompatibilityError`.
33
40
 
34
- *Rafael Mendonça França*
41
+ `DiskController` now explicitly rescues `InvalidKeyError` with appropriate HTTP status codes.
35
42
 
36
- * Add support for custom `key` in `ActiveStorage::Blob#compose`.
43
+ [CVE-2026-33195]
37
44
 
38
- *Elvin Efendiev*
45
+ *Mike Dalessio*
39
46
 
40
- * Add `image/webp` to `config.active_storage.web_image_content_types` when `load_defaults "7.2"`
41
- is set.
47
+ * Prevent glob injection in `DiskService#delete_prefixed`.
42
48
 
43
- *Lewis Buckley*
49
+ Escape glob metacharacters in the resolved path before passing to `Dir.glob`.
44
50
 
45
- * Fix JSON-encoding of `ActiveStorage::Filename` instances.
51
+ Note that this change breaks any existing code that is relying on `delete_prefixed` to expand
52
+ glob metacharacters. This change presumes that is unintended behavior (as other storage services
53
+ do not respect these metacharacters).
46
54
 
47
- *Jonathan del Strother*
55
+ [CVE-2026-33202]
48
56
 
49
- * Fix N+1 query when fetching preview images for non-image assets.
57
+ *Mike Dalessio*
50
58
 
51
- *Aaron Patterson & Justin Searls*
52
59
 
53
- * Fix all Active Storage database related models to respect
54
- `ActiveRecord::Base.table_name_prefix` configuration.
60
+ ## Rails 8.1.2 (January 08, 2026) ##
55
61
 
56
- *Chedli Bourguiba*
62
+ * Restore ADC when signing URLs with IAM for GCS
57
63
 
58
- * Fix `ActiveStorage::Representations::ProxyController` not returning the proper
59
- preview image variant for previewable files.
64
+ ADC was previously used for automatic authorization when signing URLs with IAM.
65
+ Now it is again, but the auth client is memoized so that new credentials are only
66
+ requested when the current ones expire. Other auth methods can now be used
67
+ instead by setting the authorization on `ActiveStorage::Service::GCSService#iam_client`.
68
+
69
+ ```ruby
70
+ ActiveStorage::Blob.service.iam_client.authorization = Google::Auth::ImpersonatedServiceAccountCredentials.new(options)
71
+ ```
72
+
73
+ This is safer than setting `Google::Apis::RequestOptions.default.authorization`
74
+ because it only applies to Active Storage and does not affect other Google API
75
+ clients.
76
+
77
+ *Justin Malčić*
78
+
79
+
80
+ ## Rails 8.1.1 (October 28, 2025) ##
81
+
82
+ * No changes.
60
83
 
61
- *Chedli Bourguiba*
62
84
 
63
- * Fix `ActiveStorage::Representations::ProxyController` to proxy untracked
64
- variants.
85
+ ## Rails 8.1.0 (October 22, 2025) ##
86
+
87
+ * Add structured events for Active Storage:
88
+ - `active_storage.service_upload`
89
+ - `active_storage.service_download`
90
+ - `active_storage.service_streaming_download`
91
+ - `active_storage.preview`
92
+ - `active_storage.service_delete`
93
+ - `active_storage.service_delete_prefixed`
94
+ - `active_storage.service_exist`
95
+ - `active_storage.service_url`
96
+ - `active_storage.service_mirror`
97
+
98
+ *Gannon McGibbon*
99
+
100
+ * Allow analyzers and variant transformer to be fully configurable
101
+
102
+ ```ruby
103
+ # ActiveStorage.analyzers can be set to an empty array:
104
+ config.active_storage.analyzers = []
105
+ # => ActiveStorage.analyzers = []
106
+
107
+ # or use custom analyzer:
108
+ config.active_storage.analyzers = [ CustomAnalyzer ]
109
+ # => ActiveStorage.analyzers = [ CustomAnalyzer ]
110
+ ```
111
+
112
+ If no configuration is provided, it will use the default analyzers.
113
+
114
+ You can also disable variant processor to remove warnings on startup about missing gems.
115
+
116
+ ```ruby
117
+ config.active_storage.variant_processor = :disabled
118
+ ```
119
+
120
+ *zzak*, *Alexandre Ruban*
121
+
122
+ * Remove deprecated `:azure` storage service.
123
+
124
+ *Rafael Mendonça França*
125
+
126
+ * Remove unnecessary calls to the GCP metadata server.
65
127
 
66
- *Chedli Bourguiba*
128
+ Calling Google::Auth.get_application_default triggers an explicit call to
129
+ the metadata server - given it was being called for significant number of
130
+ file operations, it can lead to considerable tail latencies and even metadata
131
+ server overloads. Instead, it's preferable (and significantly more efficient)
132
+ that applications use:
67
133
 
68
- * When using the `preprocessed: true` option, avoid enqueuing transform jobs
69
- for blobs that are not representable.
134
+ ```ruby
135
+ Google::Apis::RequestOptions.default.authorization = Google::Auth.get_application_default(...)
136
+ ```
70
137
 
71
- *Chedli Bourguiba*
138
+ In the cases applications do not set that, the GCP libraries automatically determine credentials.
72
139
 
73
- * Prevent `ActiveStorage::Blob#preview` to generate a variant if an empty variation is passed.
140
+ This also enables using credentials other than those of the associated GCP
141
+ service account like when using impersonation.
74
142
 
75
- Calls to `#url`, `#key` or `#download` will now use the original preview
76
- image instead of generating a variant with the exact same dimensions.
143
+ *Alex Coomans*
77
144
 
78
- *Chedli Bourguiba*
145
+ * Direct upload progress accounts for server processing time.
79
146
 
80
- * Process preview image variant when calling `ActiveStorage::Preview#processed`.
147
+ *Jeremy Daer*
81
148
 
82
- For example, `attached_pdf.preview(:thumb).processed` will now immediately
83
- generate the full-sized preview image and the `:thumb` variant of it.
84
- Previously, the `:thumb` variant would not be generated until a further call
85
- to e.g. `processed.url`.
149
+ * Delegate `ActiveStorage::Filename#to_str` to `#to_s`
86
150
 
87
- *Chedli Bourguiba* and *Jonathan Hefner*
151
+ Supports checking String equality:
88
152
 
89
- * Prevent `ActiveRecord::StrictLoadingViolationError` when strict loading is
90
- enabled and the variant of an Active Storage preview has already been
91
- processed (for example, by calling `ActiveStorage::Preview#url`).
153
+ ```ruby
154
+ filename = ActiveStorage::Filename.new("file.txt")
155
+ filename == "file.txt" # => true
156
+ filename in "file.txt" # => true
157
+ "file.txt" == filename # => true
158
+ ```
92
159
 
93
- *Jonathan Hefner*
160
+ *Sean Doyle*
94
161
 
95
- * Fix `preprocessed: true` option for named variants of previewable files.
162
+ * A Blob will no longer autosave associated Attachment.
96
163
 
97
- *Nico Wenterodt*
164
+ This fixes an issue where a record with an attachment would have
165
+ its dirty attributes reset, preventing your `after commit` callbacks
166
+ on that record to behave as expected.
98
167
 
99
- * Allow accepting `service` as a proc as well in `has_one_attached` and `has_many_attached`.
168
+ Note that this change doesn't require any changes on your application
169
+ and is supposed to be internal. Active Storage Attachment will continue
170
+ to be autosaved (through a different relation).
100
171
 
101
- *Yogesh Khater*
172
+ *Edouard-chin*
102
173
 
103
- Please check [7-1-stable](https://github.com/rails/rails/blob/7-1-stable/activestorage/CHANGELOG.md) for previous changes.
174
+ 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
 
@@ -73,7 +73,7 @@ end
73
73
  ```erb
74
74
  <%= form_with model: @message, local: true do |form| %>
75
75
  <%= form.text_field :title, placeholder: "Title" %><br>
76
- <%= form.text_area :content %><br><br>
76
+ <%= form.textarea :content %><br><br>
77
77
 
78
78
  <%= form.file_field :images, multiple: true %><br>
79
79
  <%= form.submit %>
@@ -88,7 +88,7 @@ class MessagesController < ApplicationController
88
88
  end
89
89
 
90
90
  def create
91
- message = Message.create! params.require(:message).permit(:title, :content, images: [])
91
+ message = Message.create! params.expect(message: [ :title, :content, images: [] ])
92
92
  redirect_to message
93
93
  end
94
94
 
@@ -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
 
@@ -203,6 +206,6 @@ Bug reports for the Ruby on \Rails project can be filed here:
203
206
 
204
207
  * https://github.com/rails/rails/issues
205
208
 
206
- Feature requests should be discussed on the rails-core mailing list here:
209
+ Feature requests should be discussed on the rubyonrails-core forum here:
207
210
 
208
211
  * https://discuss.rubyonrails.org/c/rubyonrails-core
@@ -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
 
@@ -845,4 +881,4 @@ function autostart() {
845
881
 
846
882
  setTimeout(autostart, 1);
847
883
 
848
- export { DirectUpload, DirectUploadController, DirectUploadsController, start };
884
+ export { DirectUpload, DirectUploadController, DirectUploadsController, dispatchEvent, start };
@@ -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])";
@@ -822,6 +858,7 @@
822
858
  exports.DirectUpload = DirectUpload;
823
859
  exports.DirectUploadController = DirectUploadController;
824
860
  exports.DirectUploadsController = DirectUploadsController;
861
+ exports.dispatchEvent = dispatchEvent;
825
862
  exports.start = start;
826
863
  Object.defineProperty(exports, "__esModule", {
827
864
  value: true
@@ -11,7 +11,7 @@ class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
11
11
 
12
12
  private
13
13
  def blob_args
14
- params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {}).to_h.symbolize_keys
14
+ params.expect(blob: [:filename, :byte_size, :checksum, :content_type, metadata: {}]).to_h.symbolize_keys
15
15
  end
16
16
 
17
17
  def direct_upload_json(blob)
@@ -17,6 +17,8 @@ class ActiveStorage::DiskController < ActiveStorage::BaseController
17
17
  end
18
18
  rescue Errno::ENOENT
19
19
  head :not_found
20
+ rescue ActiveStorage::InvalidKeyError
21
+ head :not_found
20
22
  end
21
23
 
22
24
  def update
@@ -25,13 +27,15 @@ class ActiveStorage::DiskController < ActiveStorage::BaseController
25
27
  named_disk_service(token[:service_name]).upload token[:key], request.body, checksum: token[:checksum]
26
28
  head :no_content
27
29
  else
28
- head :unprocessable_entity
30
+ head ActionDispatch::Constants::UNPROCESSABLE_CONTENT
29
31
  end
30
32
  else
31
33
  head :not_found
32
34
  end
33
35
  rescue ActiveStorage::IntegrityError
34
- head :unprocessable_entity
36
+ head ActionDispatch::Constants::UNPROCESSABLE_CONTENT
37
+ rescue ActiveStorage::InvalidKeyError
38
+ head ActionDispatch::Constants::UNPROCESSABLE_CONTENT
35
39
  end
36
40
 
37
41
  private
@@ -14,7 +14,8 @@ module ActiveStorage::Streaming
14
14
  def send_blob_byte_range_data(blob, range_header, disposition: nil)
15
15
  ranges = Rack::Utils.get_byte_ranges(range_header, blob.byte_size)
16
16
 
17
- return head(:range_not_satisfiable) if ranges.blank? || ranges.all?(&:blank?)
17
+ return head(:range_not_satisfiable) unless ranges_valid?(ranges)
18
+ return head(:range_not_satisfiable) if ranges.length > ActiveStorage.streaming_max_ranges
18
19
 
19
20
  if ranges.length == 1
20
21
  range = ranges.first
@@ -51,6 +52,12 @@ module ActiveStorage::Streaming
51
52
  )
52
53
  end
53
54
 
55
+ def ranges_valid?(ranges)
56
+ return false if ranges.blank? || ranges.all?(&:blank?)
57
+
58
+ ranges.sum { |range| range.end - range.begin } < ActiveStorage.streaming_chunk_max_size
59
+ end
60
+
54
61
  # Stream the blob from storage directly to the response. The disposition can be controlled by setting +disposition+.
55
62
  # The content type and filename is set directly from the +blob+.
56
63
  def send_blob_stream(blob, disposition: nil) # :doc:
@@ -61,6 +68,15 @@ module ActiveStorage::Streaming
61
68
  blob.download do |chunk|
62
69
  stream.write chunk
63
70
  end
71
+ rescue ActiveStorage::FileNotFoundError
72
+ expires_now
73
+ head :not_found
74
+ rescue
75
+ # Status and caching headers are already set, but not committed.
76
+ # Change the status to 500 manually.
77
+ expires_now
78
+ head :internal_server_error
79
+ raise
64
80
  end
65
81
  end
66
82
  end
@@ -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
  }
@@ -2,7 +2,8 @@ import { start } from "./ujs"
2
2
  import { DirectUpload } from "./direct_upload"
3
3
  import { DirectUploadController } from "./direct_upload_controller"
4
4
  import { DirectUploadsController } from "./direct_uploads_controller"
5
- export { start, DirectUpload, DirectUploadController, DirectUploadsController }
5
+ import { dispatchEvent } from "./helpers"
6
+ export { start, DirectUpload, DirectUploadController, DirectUploadsController, dispatchEvent }
6
7
 
7
8
  function autostart() {
8
9
  if (window.ActiveStorage) {
@@ -25,17 +25,83 @@ module ActiveStorage::Blob::Representable
25
25
  #
26
26
  # <%= image_tag Current.user.avatar.variant(resize_to_limit: [100, 100]) %>
27
27
  #
28
- # This will create a URL for that specific blob with that specific variant, which the ActiveStorage::RepresentationsController
29
- # can then produce on-demand.
28
+ # This will create a URL for that specific blob with that specific variant, which the ActiveStorage::Representations::ProxyController
29
+ # or ActiveStorage::Representations::RedirectController can then produce on-demand.
30
30
  #
31
31
  # Raises ActiveStorage::InvariableError if the variant processor cannot
32
32
  # transform the blob. To determine whether a blob is variable, call
33
33
  # ActiveStorage::Blob#variable?.
34
+ #
35
+ # ==== Options
36
+ #
37
+ # Options are defined by the {image_processing gem}[https://github.com/janko/image_processing],
38
+ # and depend on which variant processor you are using:
39
+ # {Vips}[https://github.com/janko/image_processing/blob/master/doc/vips.md] or
40
+ # {MiniMagick}[https://github.com/janko/image_processing/blob/master/doc/minimagick.md].
41
+ # However, both variant processors support the following options:
42
+ #
43
+ # [+:resize_to_limit+]
44
+ # Downsizes the image to fit within the specified dimensions while retaining
45
+ # the original aspect ratio. Will only resize the image if it's larger than
46
+ # the specified dimensions.
47
+ #
48
+ # user.avatar.variant(resize_to_limit: [100, 100])
49
+ #
50
+ # [+:resize_to_fit+]
51
+ # Resizes the image to fit within the specified dimensions while retaining
52
+ # the original aspect ratio. Will downsize the image if it's larger than the
53
+ # specified dimensions or upsize if it's smaller.
54
+ #
55
+ # user.avatar.variant(resize_to_fit: [100, 100])
56
+ #
57
+ # [+:resize_to_fill+]
58
+ # Resizes the image to fill the specified dimensions while retaining the
59
+ # original aspect ratio. If necessary, will crop the image in the larger
60
+ # dimension.
61
+ #
62
+ # user.avatar.variant(resize_to_fill: [100, 100])
63
+ #
64
+ # [+:resize_and_pad+]
65
+ # Resizes the image to fit within the specified dimensions while retaining
66
+ # the original aspect ratio. If necessary, will pad the remaining area with
67
+ # transparent color if source image has alpha channel, black otherwise.
68
+ #
69
+ # user.avatar.variant(resize_and_pad: [100, 100])
70
+ #
71
+ # [+:crop+]
72
+ # Extracts an area from an image. The first two arguments are the left and
73
+ # top edges of area to extract, while the last two arguments are the width
74
+ # and height of the area to extract.
75
+ #
76
+ # user.avatar.variant(crop: [20, 50, 300, 300])
77
+ #
78
+ # [+:rotate+]
79
+ # Rotates the image by the specified angle.
80
+ #
81
+ # user.avatar.variant(rotate: 90)
82
+ #
83
+ # Some options, including those listed above, can accept additional
84
+ # processor-specific values which can be passed as a trailing hash:
85
+ #
86
+ # <!-- Vips supports configuring `crop` for many of its transformations -->
87
+ # <%= image_tag user.avatar.variant(resize_to_fill: [100, 100, { crop: :centre }]) %>
88
+ #
89
+ # If migrating an existing application between MiniMagick and Vips, you will
90
+ # need to update processor-specific options:
91
+ #
92
+ # <!-- MiniMagick -->
93
+ # <%= image_tag user.avatar.variant(resize_to_limit: [100, 100], format: :jpeg,
94
+ # sampling_factor: "4:2:0", strip: true, interlace: "JPEG", colorspace: "sRGB", quality: 80) %>
95
+ #
96
+ # <!-- Vips -->
97
+ # <%= image_tag user.avatar.variant(resize_to_limit: [100, 100], format: :jpeg,
98
+ # saver: { subsample_mode: "on", strip: true, interlace: true, quality: 80 }) %>
99
+ #
34
100
  def variant(transformations)
35
101
  if variable?
36
102
  variant_class.new(self, ActiveStorage::Variation.wrap(transformations).default_to(default_variant_transformations))
37
103
  else
38
- raise ActiveStorage::InvariableError
104
+ raise ActiveStorage::InvariableError, "Can't transform blob with ID=#{id} and content_type=#{content_type}"
39
105
  end
40
106
  end
41
107
 
@@ -64,7 +130,7 @@ module ActiveStorage::Blob::Representable
64
130
  if previewable?
65
131
  ActiveStorage::Preview.new(self, transformations)
66
132
  else
67
- raise ActiveStorage::UnpreviewableError
133
+ raise ActiveStorage::UnpreviewableError, "No previewer found for blob with ID=#{id} and content_type=#{content_type}"
68
134
  end
69
135
  end
70
136
 
@@ -89,7 +155,7 @@ module ActiveStorage::Blob::Representable
89
155
  when variable?
90
156
  variant transformations
91
157
  else
92
- raise ActiveStorage::UnrepresentableError
158
+ raise ActiveStorage::UnrepresentableError, "No previewer found and can't transform blob with ID=#{id} and content_type=#{content_type}"
93
159
  end
94
160
  end
95
161