activestorage 7.2.3 → 8.0.5
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 +4 -4
- data/CHANGELOG.md +83 -69
- data/README.md +2 -2
- data/app/assets/javascripts/activestorage.esm.js +1 -1
- data/app/assets/javascripts/activestorage.js +1 -0
- data/app/controllers/active_storage/direct_uploads_controller.rb +1 -1
- data/app/controllers/active_storage/disk_controller.rb +4 -0
- data/app/controllers/concerns/active_storage/streaming.rb +17 -1
- data/app/javascript/activestorage/index.js +2 -1
- data/app/models/active_storage/blob/representable.rb +5 -5
- data/app/models/active_storage/blob.rb +27 -7
- data/app/models/active_storage/filename.rb +1 -1
- data/app/models/active_storage/variant.rb +6 -6
- data/lib/active_storage/analyzer/image_analyzer/vips.rb +1 -1
- data/lib/active_storage/attached/model.rb +20 -16
- data/lib/active_storage/engine.rb +1 -0
- data/lib/active_storage/errors.rb +4 -0
- data/lib/active_storage/gem_version.rb +3 -3
- data/lib/active_storage/service/azure_storage_service.rb +7 -0
- data/lib/active_storage/service/disk_service.rb +46 -2
- data/lib/active_storage/service/mirror_service.rb +12 -3
- data/lib/active_storage.rb +14 -0
- metadata +14 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb8d04a9237523518b1a6b00aab432decc944e0432e7296d1f283126c73eb374
|
|
4
|
+
data.tar.gz: 74fabd6db3bfefae7e51f9be208fafb37b76e5df3bdc238bafe60fa8357bd405
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e8555d671d585f19dcecb996f38d4aa4f182e3e98082856916e13cefa8f6f9c0288f11dbf83f7f80f641dbf82e5e93a39559bf7e86b98638b3737029c17660c4
|
|
7
|
+
data.tar.gz: bd3b5a92c37258cecf1bca978f4a59a090c6bb20a30cf290479a173add2efa6de64ffe120d4649c1d4d9aa3b0e500c2ae7b24b44ee4b6413e65efafe7eac91e4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,131 +1,145 @@
|
|
|
1
|
-
## Rails
|
|
1
|
+
## Rails 8.0.5 (March 24, 2026) ##
|
|
2
2
|
|
|
3
|
-
* Fix `
|
|
3
|
+
* Fix `ActiveStorage::Blob` content type predicate methods to handle `nil`.
|
|
4
4
|
|
|
5
|
-
*
|
|
5
|
+
*Daichi KUDO*
|
|
6
6
|
|
|
7
|
-
* A Blob will no longer autosave associated Attachment.
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
its dirty attributes reset, preventing your `after commit` callbacks
|
|
11
|
-
on that record to behave as expected.
|
|
8
|
+
## Rails 8.0.4.1 (March 23, 2026) ##
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
and is supposed to be internal. Active Storage Attachment will continue
|
|
15
|
-
to be autosaved (through a different relation).
|
|
10
|
+
* Filter user supplied metadata in DirectUploadController
|
|
16
11
|
|
|
17
|
-
|
|
12
|
+
[CVE-2026-33173]
|
|
18
13
|
|
|
14
|
+
*Jean Boussier*
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
* Configurable maxmimum streaming chunk size
|
|
21
17
|
|
|
22
|
-
|
|
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.
|
|
23
20
|
|
|
24
|
-
[CVE-
|
|
21
|
+
[CVE-2026-33174]
|
|
25
22
|
|
|
26
|
-
*
|
|
23
|
+
*Gannon McGibbon*
|
|
27
24
|
|
|
25
|
+
* Limit range requests to a single range
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
[CVE-2026-33658]
|
|
30
28
|
|
|
31
|
-
*
|
|
29
|
+
*Jean Boussier*
|
|
32
30
|
|
|
31
|
+
* Prevent path traversal in `DiskService`.
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
`DiskService#path_for` now raises an `InvalidKeyError` when passed keys with dot segments (".",
|
|
34
|
+
".."), or if the resolved path is outside the storage root directory.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
`#path_for` also now consistently raises `InvalidKeyError` if the key is invalid in any way, for
|
|
37
|
+
example containing null bytes or having an incompatible encoding. Previously, the exception
|
|
38
|
+
raised may have been `ArgumentError` or `Encoding::CompatibilityError`.
|
|
37
39
|
|
|
40
|
+
`DiskController` now explicitly rescues `InvalidKeyError` with appropriate HTTP status codes.
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
[CVE-2026-33195]
|
|
40
43
|
|
|
41
|
-
*
|
|
44
|
+
*Mike Dalessio*
|
|
42
45
|
|
|
46
|
+
* Prevent glob injection in `DiskService#delete_prefixed`.
|
|
43
47
|
|
|
44
|
-
|
|
48
|
+
Escape glob metacharacters in the resolved path before passing to `Dir.glob`.
|
|
45
49
|
|
|
46
|
-
|
|
50
|
+
Note that this change breaks any existing code that is relying on `delete_prefixed` to expand
|
|
51
|
+
glob metacharacters. This change presumes that is unintended behavior (as other storage services
|
|
52
|
+
do not respect these metacharacters).
|
|
53
|
+
|
|
54
|
+
[CVE-2026-33202]
|
|
47
55
|
|
|
56
|
+
*Mike Dalessio*
|
|
48
57
|
|
|
49
|
-
|
|
58
|
+
|
|
59
|
+
## Rails 8.0.4 (October 28, 2025) ##
|
|
50
60
|
|
|
51
61
|
* No changes.
|
|
52
62
|
|
|
53
63
|
|
|
54
|
-
## Rails
|
|
64
|
+
## Rails 8.0.3 (September 22, 2025) ##
|
|
65
|
+
|
|
66
|
+
* Address deprecation of `Aws::S3::Object#upload_stream` in `ActiveStorage::Service::S3Service`.
|
|
67
|
+
|
|
68
|
+
*Joshua Young*
|
|
69
|
+
|
|
70
|
+
* Fix `config.active_storage.touch_attachment_records` to work with eager loading.
|
|
71
|
+
|
|
72
|
+
*fatkodima*
|
|
55
73
|
|
|
56
|
-
* Remove deprecated `config.active_storage.silence_invalid_content_types_warning`.
|
|
57
74
|
|
|
58
|
-
|
|
75
|
+
## Rails 8.0.2.1 (August 13, 2025) ##
|
|
59
76
|
|
|
60
|
-
* Remove
|
|
77
|
+
* Remove dangerous transformations
|
|
61
78
|
|
|
62
|
-
|
|
79
|
+
[CVE-2025-24293]
|
|
63
80
|
|
|
64
|
-
*
|
|
81
|
+
*Zack Deveau*
|
|
65
82
|
|
|
66
|
-
|
|
83
|
+
## Rails 8.0.2 (March 12, 2025) ##
|
|
67
84
|
|
|
68
|
-
*
|
|
69
|
-
is set.
|
|
85
|
+
* A Blob will no longer autosave associated Attachment.
|
|
70
86
|
|
|
71
|
-
|
|
87
|
+
This fixes an issue where a record with an attachment would have
|
|
88
|
+
its dirty attributes reset, preventing your `after commit` callbacks
|
|
89
|
+
on that record to behave as expected.
|
|
72
90
|
|
|
73
|
-
|
|
91
|
+
Note that this change doesn't require any changes on your application
|
|
92
|
+
and is supposed to be internal. Active Storage Attachment will continue
|
|
93
|
+
to be autosaved (through a different relation).
|
|
74
94
|
|
|
75
|
-
*
|
|
95
|
+
*Edouard-chin*
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
## Rails 8.0.1 (December 13, 2024) ##
|
|
76
99
|
|
|
77
|
-
*
|
|
100
|
+
* No changes.
|
|
78
101
|
|
|
79
|
-
*Aaron Patterson & Justin Searls*
|
|
80
102
|
|
|
81
|
-
|
|
82
|
-
`ActiveRecord::Base.table_name_prefix` configuration.
|
|
103
|
+
## Rails 8.0.0.1 (December 10, 2024) ##
|
|
83
104
|
|
|
84
|
-
|
|
105
|
+
* No changes.
|
|
85
106
|
|
|
86
|
-
* Fix `ActiveStorage::Representations::ProxyController` not returning the proper
|
|
87
|
-
preview image variant for previewable files.
|
|
88
107
|
|
|
89
|
-
|
|
108
|
+
## Rails 8.0.0 (November 07, 2024) ##
|
|
90
109
|
|
|
91
|
-
*
|
|
92
|
-
variants.
|
|
110
|
+
* No changes.
|
|
93
111
|
|
|
94
|
-
*Chedli Bourguiba*
|
|
95
112
|
|
|
96
|
-
|
|
97
|
-
for blobs that are not representable.
|
|
113
|
+
## Rails 8.0.0.rc2 (October 30, 2024) ##
|
|
98
114
|
|
|
99
|
-
|
|
115
|
+
* No changes.
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
## Rails 8.0.0.rc1 (October 19, 2024) ##
|
|
119
|
+
|
|
120
|
+
* No changes.
|
|
100
121
|
|
|
101
|
-
* Prevent `ActiveStorage::Blob#preview` to generate a variant if an empty variation is passed.
|
|
102
122
|
|
|
103
|
-
|
|
104
|
-
image instead of generating a variant with the exact same dimensions.
|
|
123
|
+
## Rails 8.0.0.beta1 (September 26, 2024) ##
|
|
105
124
|
|
|
106
|
-
|
|
125
|
+
* Deprecate `ActiveStorage::Service::AzureStorageService`.
|
|
107
126
|
|
|
108
|
-
*
|
|
127
|
+
*zzak*
|
|
109
128
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
Previously, the `:thumb` variant would not be generated until a further call
|
|
113
|
-
to e.g. `processed.url`.
|
|
129
|
+
* Improve `ActiveStorage::Filename#sanitized` method to handle special characters more effectively.
|
|
130
|
+
Replace the characters `"*?<>` with `-` if they exist in the Filename to match the Filename convention of Win OS.
|
|
114
131
|
|
|
115
|
-
*
|
|
132
|
+
*Luong Viet Dung(Martin)*
|
|
116
133
|
|
|
117
|
-
*
|
|
118
|
-
enabled and the variant of an Active Storage preview has already been
|
|
119
|
-
processed (for example, by calling `ActiveStorage::Preview#url`).
|
|
134
|
+
* Improve InvariableError, UnpreviewableError and UnrepresentableError message.
|
|
120
135
|
|
|
121
|
-
|
|
136
|
+
Include Blob ID and content_type in the messages.
|
|
122
137
|
|
|
123
|
-
*
|
|
138
|
+
*Petrik de Heus*
|
|
124
139
|
|
|
125
|
-
|
|
140
|
+
* Mark proxied files as `immutable` in their Cache-Control header
|
|
126
141
|
|
|
127
|
-
*
|
|
142
|
+
*Nate Matykiewicz*
|
|
128
143
|
|
|
129
|
-
*Yogesh Khater*
|
|
130
144
|
|
|
131
|
-
Please check [7-
|
|
145
|
+
Please check [7-2-stable](https://github.com/rails/rails/blob/7-2-stable/activestorage/CHANGELOG.md) for previous changes.
|
data/README.md
CHANGED
|
@@ -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.
|
|
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.
|
|
91
|
+
message = Message.create! params.expect(message: [ :title, :content, images: [] ])
|
|
92
92
|
redirect_to message
|
|
93
93
|
end
|
|
94
94
|
|
|
@@ -822,6 +822,7 @@
|
|
|
822
822
|
exports.DirectUpload = DirectUpload;
|
|
823
823
|
exports.DirectUploadController = DirectUploadController;
|
|
824
824
|
exports.DirectUploadsController = DirectUploadsController;
|
|
825
|
+
exports.dispatchEvent = dispatchEvent;
|
|
825
826
|
exports.start = start;
|
|
826
827
|
Object.defineProperty(exports, "__esModule", {
|
|
827
828
|
value: true
|
|
@@ -11,7 +11,7 @@ class ActiveStorage::DirectUploadsController < ActiveStorage::BaseController
|
|
|
11
11
|
|
|
12
12
|
private
|
|
13
13
|
def blob_args
|
|
14
|
-
params.
|
|
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
|
|
@@ -32,6 +34,8 @@ class ActiveStorage::DiskController < ActiveStorage::BaseController
|
|
|
32
34
|
end
|
|
33
35
|
rescue ActiveStorage::IntegrityError
|
|
34
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)
|
|
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
|
|
@@ -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
|
-
|
|
5
|
+
import { dispatchEvent } from "./helpers"
|
|
6
|
+
export { start, DirectUpload, DirectUploadController, DirectUploadsController, dispatchEvent }
|
|
6
7
|
|
|
7
8
|
function autostart() {
|
|
8
9
|
if (window.ActiveStorage) {
|
|
@@ -25,8 +25,8 @@ 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::
|
|
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
|
|
@@ -101,7 +101,7 @@ module ActiveStorage::Blob::Representable
|
|
|
101
101
|
if variable?
|
|
102
102
|
variant_class.new(self, ActiveStorage::Variation.wrap(transformations).default_to(default_variant_transformations))
|
|
103
103
|
else
|
|
104
|
-
raise ActiveStorage::InvariableError
|
|
104
|
+
raise ActiveStorage::InvariableError, "Can't transform blob with ID=#{id} and content_type=#{content_type}"
|
|
105
105
|
end
|
|
106
106
|
end
|
|
107
107
|
|
|
@@ -130,7 +130,7 @@ module ActiveStorage::Blob::Representable
|
|
|
130
130
|
if previewable?
|
|
131
131
|
ActiveStorage::Preview.new(self, transformations)
|
|
132
132
|
else
|
|
133
|
-
raise ActiveStorage::UnpreviewableError
|
|
133
|
+
raise ActiveStorage::UnpreviewableError, "No previewer found for blob with ID=#{id} and content_type=#{content_type}"
|
|
134
134
|
end
|
|
135
135
|
end
|
|
136
136
|
|
|
@@ -155,7 +155,7 @@ module ActiveStorage::Blob::Representable
|
|
|
155
155
|
when variable?
|
|
156
156
|
variant transformations
|
|
157
157
|
else
|
|
158
|
-
raise ActiveStorage::UnrepresentableError
|
|
158
|
+
raise ActiveStorage::UnrepresentableError, "No previewer found and can't transform blob with ID=#{id} and content_type=#{content_type}"
|
|
159
159
|
end
|
|
160
160
|
end
|
|
161
161
|
|
|
@@ -16,10 +16,18 @@
|
|
|
16
16
|
# Blobs are intended to be immutable in as-so-far as their reference to a specific file goes. You're allowed to
|
|
17
17
|
# update a blob's metadata on a subsequent pass, but you should not update the key or change the uploaded file.
|
|
18
18
|
# If you need to create a derivative or otherwise change the blob, simply create a new blob and purge the old one.
|
|
19
|
+
#
|
|
20
|
+
# When using a custom +key+, the value is treated as trusted. Using untrusted user input
|
|
21
|
+
# as the key may result in unexpected behavior.
|
|
19
22
|
class ActiveStorage::Blob < ActiveStorage::Record
|
|
20
23
|
MINIMUM_TOKEN_LENGTH = 28
|
|
21
24
|
|
|
22
25
|
has_secure_token :key, length: MINIMUM_TOKEN_LENGTH
|
|
26
|
+
|
|
27
|
+
# FIXME: these property should never have been stored in the metadata.
|
|
28
|
+
# The blob table should be migrated to have dedicated columns for theses.
|
|
29
|
+
PROTECTED_METADATA = %w(analyzed identified composed)
|
|
30
|
+
private_constant :PROTECTED_METADATA
|
|
23
31
|
store :metadata, accessors: [ :analyzed, :identified, :composed ], coder: ActiveRecord::Coders::JSON
|
|
24
32
|
|
|
25
33
|
class_attribute :services, default: {}
|
|
@@ -71,9 +79,8 @@ class ActiveStorage::Blob < ActiveStorage::Record
|
|
|
71
79
|
end
|
|
72
80
|
|
|
73
81
|
# Works like +find_signed+, but will raise an +ActiveSupport::MessageVerifier::InvalidSignature+
|
|
74
|
-
# exception if the +signed_id+ has either expired, has a purpose mismatch,
|
|
75
|
-
#
|
|
76
|
-
# the valid signed id can't find a record.
|
|
82
|
+
# exception if the +signed_id+ has either expired, has a purpose mismatch, or has been tampered with.
|
|
83
|
+
# It will also raise an +ActiveRecord::RecordNotFound+ exception if the valid signed id can't find a record.
|
|
77
84
|
def find_signed!(id, record: nil, purpose: :blob_id)
|
|
78
85
|
super(id, purpose: purpose)
|
|
79
86
|
end
|
|
@@ -93,6 +100,9 @@ class ActiveStorage::Blob < ActiveStorage::Record
|
|
|
93
100
|
# be saved before the upload begins to prevent the upload clobbering another due to key collisions.
|
|
94
101
|
# When providing a content type, pass <tt>identify: false</tt> to bypass
|
|
95
102
|
# automatic content type inference.
|
|
103
|
+
#
|
|
104
|
+
# The optional +key+ parameter is treated as trusted. Using untrusted user input
|
|
105
|
+
# as the key may result in unexpected behavior.
|
|
96
106
|
def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
|
|
97
107
|
create_after_unfurling!(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify).tap do |blob|
|
|
98
108
|
blob.upload_without_unfurling(io)
|
|
@@ -105,6 +115,7 @@ class ActiveStorage::Blob < ActiveStorage::Record
|
|
|
105
115
|
# Once the form using the direct upload is submitted, the blob can be associated with the right record using
|
|
106
116
|
# the signed ID.
|
|
107
117
|
def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil)
|
|
118
|
+
metadata = filter_metadata(metadata)
|
|
108
119
|
create! key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name
|
|
109
120
|
end
|
|
110
121
|
|
|
@@ -152,6 +163,15 @@ class ActiveStorage::Blob < ActiveStorage::Record
|
|
|
152
163
|
combined_blob.save!
|
|
153
164
|
end
|
|
154
165
|
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
def filter_metadata(metadata)
|
|
169
|
+
if metadata.is_a?(Hash)
|
|
170
|
+
metadata.without(*PROTECTED_METADATA)
|
|
171
|
+
else
|
|
172
|
+
metadata
|
|
173
|
+
end
|
|
174
|
+
end
|
|
155
175
|
end
|
|
156
176
|
|
|
157
177
|
include Analyzable
|
|
@@ -190,22 +210,22 @@ class ActiveStorage::Blob < ActiveStorage::Record
|
|
|
190
210
|
|
|
191
211
|
# Returns true if the content_type of this blob is in the image range, like image/png.
|
|
192
212
|
def image?
|
|
193
|
-
content_type
|
|
213
|
+
content_type&.start_with?("image")
|
|
194
214
|
end
|
|
195
215
|
|
|
196
216
|
# Returns true if the content_type of this blob is in the audio range, like audio/mpeg.
|
|
197
217
|
def audio?
|
|
198
|
-
content_type
|
|
218
|
+
content_type&.start_with?("audio")
|
|
199
219
|
end
|
|
200
220
|
|
|
201
221
|
# Returns true if the content_type of this blob is in the video range, like video/mp4.
|
|
202
222
|
def video?
|
|
203
|
-
content_type
|
|
223
|
+
content_type&.start_with?("video")
|
|
204
224
|
end
|
|
205
225
|
|
|
206
226
|
# Returns true if the content_type of this blob is in the text range, like text/plain.
|
|
207
227
|
def text?
|
|
208
|
-
content_type
|
|
228
|
+
content_type&.start_with?("text")
|
|
209
229
|
end
|
|
210
230
|
|
|
211
231
|
# Returns the URL of the blob on the service. This returns a permanent URL for public files, and returns a
|
|
@@ -57,7 +57,7 @@ class ActiveStorage::Filename
|
|
|
57
57
|
#
|
|
58
58
|
# Characters considered unsafe for storage (e.g. \, $, and the RTL override character) are replaced with a dash.
|
|
59
59
|
def sanitized
|
|
60
|
-
@filename.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}
|
|
60
|
+
@filename.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�").strip.tr("\u{202E}%$|:;/<>?*\"\t\r\n\\", "-")
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Returns the sanitized version of the filename.
|
|
@@ -22,15 +22,15 @@
|
|
|
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
|
|
25
|
-
# ActiveStorage::
|
|
25
|
+
# ActiveStorage::Representations::ProxyController and ActiveStorage::Representations::RedirectController.
|
|
26
26
|
#
|
|
27
27
|
# To refer to such a delayed on-demand variant, simply link to the variant through the resolved route provided
|
|
28
28
|
# by Active Storage like so:
|
|
29
29
|
#
|
|
30
30
|
# <%= image_tag Current.user.avatar.variant(resize_to_limit: [100, 100]) %>
|
|
31
31
|
#
|
|
32
|
-
# This will create a URL for that specific blob with that specific variant, which the ActiveStorage::
|
|
33
|
-
# can then produce on-demand.
|
|
32
|
+
# This will create a URL for that specific blob with that specific variant, which the ActiveStorage::Representations::ProxyController
|
|
33
|
+
# or ActiveStorage::Representations::RedirectController can then produce on-demand.
|
|
34
34
|
#
|
|
35
35
|
# When you do want to actually produce the variant needed, call +processed+. This will check that the variant
|
|
36
36
|
# has already been processed and uploaded to the service, and, if so, just return that. Otherwise it will perform
|
|
@@ -74,11 +74,11 @@ class ActiveStorage::Variant
|
|
|
74
74
|
"variants/#{blob.key}/#{OpenSSL::Digest::SHA256.hexdigest(variation.key)}"
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
-
# Returns the URL of the blob variant on the service. See
|
|
77
|
+
# Returns the URL of the blob variant on the service. See ActiveStorage::Blob#url for details.
|
|
78
78
|
#
|
|
79
79
|
# Use <tt>url_for(variant)</tt> (or the implied form, like <tt>link_to variant</tt> or <tt>redirect_to variant</tt>) to get the stable URL
|
|
80
|
-
# for a variant that points to the ActiveStorage::
|
|
81
|
-
# for its redirection.
|
|
80
|
+
# for a variant that points to the ActiveStorage::Representations::ProxyController or ActiveStorage::Representations::RedirectController,
|
|
81
|
+
# which in turn will use this +service_call+ method for its redirection.
|
|
82
82
|
def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline)
|
|
83
83
|
service.url key, expires_in: expires_in, disposition: disposition, filename: filename, content_type: content_type
|
|
84
84
|
end
|
|
@@ -61,18 +61,16 @@ module ActiveStorage
|
|
|
61
61
|
# There is no column defined on the model side, Active Storage takes
|
|
62
62
|
# care of the mapping between your records and the attachment.
|
|
63
63
|
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
# User.with_attached_avatar
|
|
67
|
-
#
|
|
68
|
-
# Under the covers, this relationship is implemented as a +has_one+ association to a
|
|
69
|
-
# ActiveStorage::Attachment record and a +has_one-through+ association to a
|
|
64
|
+
# Under the covers, this relationship is implemented as a +has_one+ association to an
|
|
65
|
+
# ActiveStorage::Attachment record and a +has_one-through+ association to an
|
|
70
66
|
# ActiveStorage::Blob record. These associations are available as +avatar_attachment+
|
|
71
67
|
# and +avatar_blob+. But you shouldn't need to work with these associations directly in
|
|
72
68
|
# most circumstances.
|
|
73
69
|
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
70
|
+
# Instead, +has_one_attached+ generates an ActiveStorage::Attached::One proxy to
|
|
71
|
+
# provide access to the associations and factory methods, like +attach+:
|
|
72
|
+
#
|
|
73
|
+
# user.avatar.attach(uploaded_file)
|
|
76
74
|
#
|
|
77
75
|
# The +:dependent+ option defaults to +:purge_later+. This means the attachment will be
|
|
78
76
|
# purged (i.e. destroyed) in the background whenever the record is destroyed.
|
|
@@ -92,6 +90,10 @@ module ActiveStorage
|
|
|
92
90
|
# has_one_attached :avatar, service: ->(user) { user.in_europe_region? ? :s3_europe : :s3_usa }
|
|
93
91
|
# end
|
|
94
92
|
#
|
|
93
|
+
# To avoid N+1 queries, you can include the attached blobs in your query like so:
|
|
94
|
+
#
|
|
95
|
+
# User.with_attached_avatar
|
|
96
|
+
#
|
|
95
97
|
# If you need to enable +strict_loading+ to prevent lazy loading of attachment,
|
|
96
98
|
# pass the +:strict_loading+ option. You can do:
|
|
97
99
|
#
|
|
@@ -161,18 +163,16 @@ module ActiveStorage
|
|
|
161
163
|
# There are no columns defined on the model side, Active Storage takes
|
|
162
164
|
# care of the mapping between your records and the attachments.
|
|
163
165
|
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
# Gallery.where(user: Current.user).with_attached_photos
|
|
167
|
-
#
|
|
168
|
-
# Under the covers, this relationship is implemented as a +has_many+ association to a
|
|
169
|
-
# ActiveStorage::Attachment record and a +has_many-through+ association to a
|
|
166
|
+
# Under the covers, this relationship is implemented as a +has_many+ association to an
|
|
167
|
+
# ActiveStorage::Attachment record and a +has_many-through+ association to an
|
|
170
168
|
# ActiveStorage::Blob record. These associations are available as +photos_attachments+
|
|
171
169
|
# and +photos_blobs+. But you shouldn't need to work with these associations directly in
|
|
172
170
|
# most circumstances.
|
|
173
171
|
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
172
|
+
# Instead, +has_many_attached+ generates an ActiveStorage::Attached::Many proxy to
|
|
173
|
+
# provide access to the associations and factory methods, like +attach+:
|
|
174
|
+
#
|
|
175
|
+
# user.photos.attach(uploaded_file)
|
|
176
176
|
#
|
|
177
177
|
# The +:dependent+ option defaults to +:purge_later+. This means the attachments will be
|
|
178
178
|
# purged (i.e. destroyed) in the background whenever the record is destroyed.
|
|
@@ -192,6 +192,10 @@ module ActiveStorage
|
|
|
192
192
|
# has_many_attached :photos, service: ->(gallery) { gallery.personal? ? :personal_s3 : :s3 }
|
|
193
193
|
# end
|
|
194
194
|
#
|
|
195
|
+
# To avoid N+1 queries, you can include the attached blobs in your query like so:
|
|
196
|
+
#
|
|
197
|
+
# Gallery.where(user: Current.user).with_attached_photos
|
|
198
|
+
#
|
|
195
199
|
# If you need to enable +strict_loading+ to prevent lazy loading of attachments,
|
|
196
200
|
# pass the +:strict_loading+ option. You can do:
|
|
197
201
|
#
|
|
@@ -122,6 +122,7 @@ module ActiveStorage
|
|
|
122
122
|
ActiveStorage.binary_content_type = app.config.active_storage.binary_content_type || "application/octet-stream"
|
|
123
123
|
ActiveStorage.video_preview_arguments = app.config.active_storage.video_preview_arguments || "-y -vframes 1 -f image2"
|
|
124
124
|
ActiveStorage.track_variants = app.config.active_storage.track_variants || false
|
|
125
|
+
ActiveStorage.streaming_chunk_max_size = app.config.active_storage.streaming_chunk_max_size || 100.megabytes
|
|
125
126
|
end
|
|
126
127
|
end
|
|
127
128
|
|
|
@@ -26,4 +26,8 @@ module ActiveStorage
|
|
|
26
26
|
|
|
27
27
|
# Raised when a Previewer is unable to generate a preview image.
|
|
28
28
|
class PreviewError < Error; end
|
|
29
|
+
|
|
30
|
+
# Raised when a storage key resolves to a path outside the service's root
|
|
31
|
+
# directory, indicating a potential path traversal attack.
|
|
32
|
+
class InvalidKeyError < Error; end
|
|
29
33
|
end
|
|
@@ -15,6 +15,13 @@ module ActiveStorage
|
|
|
15
15
|
attr_reader :client, :container, :signer
|
|
16
16
|
|
|
17
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
|
+
|
|
18
25
|
@client = Azure::Storage::Blob::BlobService.create(storage_account_name: storage_account_name, storage_access_key: storage_access_key, **options)
|
|
19
26
|
@signer = Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(storage_account_name, storage_access_key)
|
|
20
27
|
@container = container
|
|
@@ -60,7 +60,16 @@ module ActiveStorage
|
|
|
60
60
|
|
|
61
61
|
def delete_prefixed(prefix)
|
|
62
62
|
instrument :delete_prefixed, prefix: prefix do
|
|
63
|
-
|
|
63
|
+
prefix_path = path_for(prefix)
|
|
64
|
+
|
|
65
|
+
# File.expand_path (called within path_for) strips trailing slashes.
|
|
66
|
+
# Restore trailing separator if the original prefix had one, so that
|
|
67
|
+
# the glob "prefix/*" matches files inside the directory, not siblings
|
|
68
|
+
# whose names start with the prefix string.
|
|
69
|
+
prefix_path += "/" if prefix.end_with?("/")
|
|
70
|
+
|
|
71
|
+
escaped = escape_glob_metacharacters(prefix_path)
|
|
72
|
+
Dir.glob("#{escaped}*").each do |path|
|
|
64
73
|
FileUtils.rm_rf(path)
|
|
65
74
|
end
|
|
66
75
|
end
|
|
@@ -98,8 +107,39 @@ module ActiveStorage
|
|
|
98
107
|
{ "Content-Type" => content_type }
|
|
99
108
|
end
|
|
100
109
|
|
|
110
|
+
# Every filesystem operation in DiskService resolves paths through this method (or through
|
|
111
|
+
# make_path_for, which delegates here). This is the primary filesystem security check: all
|
|
112
|
+
# path-traversal protection is enforced here. New methods that touch the filesystem MUST use
|
|
113
|
+
# path_for or make_path_for -- never construct paths from +root+ directly.
|
|
101
114
|
def path_for(key) # :nodoc:
|
|
102
|
-
|
|
115
|
+
if key.blank?
|
|
116
|
+
raise ActiveStorage::InvalidKeyError, "key is blank"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Reject keys with dot segments as defense in depth. This prevents path traversal both outside
|
|
120
|
+
# and within the storage root. The root containment check below is a more fundamental check on
|
|
121
|
+
# path traversal outside of the disk service root.
|
|
122
|
+
begin
|
|
123
|
+
if key.split("/").intersect?(%w[. ..])
|
|
124
|
+
raise ActiveStorage::InvalidKeyError, "key has path traversal segments"
|
|
125
|
+
end
|
|
126
|
+
rescue Encoding::CompatibilityError
|
|
127
|
+
raise ActiveStorage::InvalidKeyError, "key has incompatible encoding"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
begin
|
|
131
|
+
path = File.expand_path(File.join(root, folder_for(key), key))
|
|
132
|
+
rescue ArgumentError
|
|
133
|
+
# ArgumentError catches null bytes
|
|
134
|
+
raise ActiveStorage::InvalidKeyError, "key is an invalid string"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The resolved path must be inside the root directory.
|
|
138
|
+
unless path.start_with?(File.expand_path(root) + "/")
|
|
139
|
+
raise ActiveStorage::InvalidKeyError, "key is outside of disk service root"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
path
|
|
103
143
|
end
|
|
104
144
|
|
|
105
145
|
def compose(source_keys, destination_key, **)
|
|
@@ -156,6 +196,10 @@ module ActiveStorage
|
|
|
156
196
|
[ key[0..1], key[2..3] ].join("/")
|
|
157
197
|
end
|
|
158
198
|
|
|
199
|
+
def escape_glob_metacharacters(path)
|
|
200
|
+
path.gsub(/[\[\]*?{}\\]/) { |c| "\\#{c}" }
|
|
201
|
+
end
|
|
202
|
+
|
|
159
203
|
def make_path_for(key)
|
|
160
204
|
path_for(key).tap { |path| FileUtils.mkdir_p File.dirname(path) }
|
|
161
205
|
end
|
|
@@ -30,6 +30,13 @@ module ActiveStorage
|
|
|
30
30
|
|
|
31
31
|
def initialize(primary:, mirrors:)
|
|
32
32
|
@primary, @mirrors = primary, mirrors
|
|
33
|
+
@executor = Concurrent::ThreadPoolExecutor.new(
|
|
34
|
+
min_threads: 1,
|
|
35
|
+
max_threads: mirrors.size,
|
|
36
|
+
max_queue: 0,
|
|
37
|
+
fallback_policy: :caller_runs,
|
|
38
|
+
idle_time: 60
|
|
39
|
+
)
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
# Upload the +io+ to the +key+ specified to all services. The upload to the primary service is done synchronously
|
|
@@ -75,10 +82,12 @@ module ActiveStorage
|
|
|
75
82
|
end
|
|
76
83
|
|
|
77
84
|
def perform_across_services(method, *args)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
tasks = each_service.collect do |service|
|
|
86
|
+
Concurrent::Promise.execute(executor: @executor) do
|
|
87
|
+
service.public_send method, *args
|
|
88
|
+
end
|
|
81
89
|
end
|
|
90
|
+
tasks.each(&:value!)
|
|
82
91
|
end
|
|
83
92
|
end
|
|
84
93
|
end
|
data/lib/active_storage.rb
CHANGED
|
@@ -27,6 +27,7 @@ require "active_record"
|
|
|
27
27
|
require "active_support"
|
|
28
28
|
require "active_support/rails"
|
|
29
29
|
require "active_support/core_ext/numeric/time"
|
|
30
|
+
require "active_support/core_ext/numeric/bytes"
|
|
30
31
|
|
|
31
32
|
require "active_storage/version"
|
|
32
33
|
require "active_storage/deprecator"
|
|
@@ -350,6 +351,7 @@ module ActiveStorage
|
|
|
350
351
|
]
|
|
351
352
|
mattr_accessor :unsupported_image_processing_arguments
|
|
352
353
|
|
|
354
|
+
mattr_accessor :streaming_chunk_max_size, default: 100.megabytes
|
|
353
355
|
mattr_accessor :service_urls_expire_in, default: 5.minutes
|
|
354
356
|
mattr_accessor :touch_attachment_records, default: true
|
|
355
357
|
mattr_accessor :urls_expire_in
|
|
@@ -360,6 +362,18 @@ module ActiveStorage
|
|
|
360
362
|
|
|
361
363
|
mattr_accessor :track_variants, default: false
|
|
362
364
|
|
|
365
|
+
singleton_class.attr_accessor :checksum_implementation
|
|
366
|
+
@checksum_implementation = OpenSSL::Digest::MD5
|
|
367
|
+
begin
|
|
368
|
+
@checksum_implementation.hexdigest("test")
|
|
369
|
+
rescue # OpenSSL may have MD5 disabled
|
|
370
|
+
require "digest/md5"
|
|
371
|
+
@checksum_implementation = Digest::MD5
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
singleton_class.attr_accessor :streaming_max_ranges
|
|
375
|
+
@streaming_max_ranges = 1
|
|
376
|
+
|
|
363
377
|
mattr_accessor :video_preview_arguments, default: "-y -vframes 1 -f image2"
|
|
364
378
|
|
|
365
379
|
module Transformers
|
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:
|
|
4
|
+
version: 8.0.5
|
|
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:
|
|
18
|
+
version: 8.0.5
|
|
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:
|
|
25
|
+
version: 8.0.5
|
|
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:
|
|
32
|
+
version: 8.0.5
|
|
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:
|
|
39
|
+
version: 8.0.5
|
|
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:
|
|
46
|
+
version: 8.0.5
|
|
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:
|
|
53
|
+
version: 8.0.5
|
|
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:
|
|
60
|
+
version: 8.0.5
|
|
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:
|
|
67
|
+
version: 8.0.5
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: marcel
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -189,10 +189,10 @@ licenses:
|
|
|
189
189
|
- MIT
|
|
190
190
|
metadata:
|
|
191
191
|
bug_tracker_uri: https://github.com/rails/rails/issues
|
|
192
|
-
changelog_uri: https://github.com/rails/rails/blob/
|
|
193
|
-
documentation_uri: https://api.rubyonrails.org/
|
|
192
|
+
changelog_uri: https://github.com/rails/rails/blob/v8.0.5/activestorage/CHANGELOG.md
|
|
193
|
+
documentation_uri: https://api.rubyonrails.org/v8.0.5/
|
|
194
194
|
mailing_list_uri: https://discuss.rubyonrails.org/c/rubyonrails-talk
|
|
195
|
-
source_code_uri: https://github.com/rails/rails/tree/
|
|
195
|
+
source_code_uri: https://github.com/rails/rails/tree/v8.0.5/activestorage
|
|
196
196
|
rubygems_mfa_required: 'true'
|
|
197
197
|
rdoc_options: []
|
|
198
198
|
require_paths:
|
|
@@ -201,14 +201,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
201
201
|
requirements:
|
|
202
202
|
- - ">="
|
|
203
203
|
- !ruby/object:Gem::Version
|
|
204
|
-
version: 3.
|
|
204
|
+
version: 3.2.0
|
|
205
205
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
206
206
|
requirements:
|
|
207
207
|
- - ">="
|
|
208
208
|
- !ruby/object:Gem::Version
|
|
209
209
|
version: '0'
|
|
210
210
|
requirements: []
|
|
211
|
-
rubygems_version:
|
|
211
|
+
rubygems_version: 4.0.6
|
|
212
212
|
specification_version: 4
|
|
213
213
|
summary: Local and cloud file storage framework.
|
|
214
214
|
test_files: []
|