shrine 2.19.3 → 3.6.0

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 (211) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +523 -41
  3. data/LICENSE.txt +1 -1
  4. data/README.md +83 -979
  5. data/doc/advantages.md +231 -204
  6. data/doc/attacher.md +304 -153
  7. data/doc/carrierwave.md +297 -226
  8. data/doc/changing_derivatives.md +308 -0
  9. data/doc/changing_location.md +103 -21
  10. data/doc/changing_storage.md +110 -0
  11. data/doc/creating_persistence_plugins.md +132 -0
  12. data/doc/creating_plugins.md +43 -23
  13. data/doc/creating_storages.md +19 -5
  14. data/doc/design.md +147 -97
  15. data/doc/direct_s3.md +38 -28
  16. data/doc/external/articles.md +63 -0
  17. data/doc/external/extensions.md +53 -0
  18. data/doc/external/misc.md +32 -0
  19. data/doc/getting_started.md +1156 -0
  20. data/doc/metadata.md +190 -109
  21. data/doc/multiple_files.md +93 -30
  22. data/doc/paperclip.md +384 -262
  23. data/doc/plugins/activerecord.md +177 -46
  24. data/doc/plugins/add_metadata.md +139 -38
  25. data/doc/plugins/atomic_helpers.md +217 -0
  26. data/doc/plugins/backgrounding.md +156 -98
  27. data/doc/plugins/cached_attachment_data.md +7 -5
  28. data/doc/plugins/column.md +121 -0
  29. data/doc/plugins/data_uri.md +23 -22
  30. data/doc/plugins/default_storage.md +36 -10
  31. data/doc/plugins/default_url.md +30 -13
  32. data/doc/plugins/delete_raw.md +4 -2
  33. data/doc/plugins/derivation_endpoint.md +186 -101
  34. data/doc/plugins/derivatives.md +839 -0
  35. data/doc/plugins/determine_mime_type.md +4 -2
  36. data/doc/plugins/download_endpoint.md +64 -8
  37. data/doc/plugins/dynamic_storage.md +5 -3
  38. data/doc/plugins/entity.md +263 -0
  39. data/doc/plugins/form_assign.md +55 -0
  40. data/doc/plugins/included.md +31 -8
  41. data/doc/plugins/infer_extension.md +21 -10
  42. data/doc/plugins/instrumentation.md +38 -16
  43. data/doc/plugins/keep_files.md +16 -17
  44. data/doc/plugins/metadata_attributes.md +42 -13
  45. data/doc/plugins/mirroring.md +118 -0
  46. data/doc/plugins/model.md +210 -0
  47. data/doc/plugins/module_include.md +4 -2
  48. data/doc/plugins/multi_cache.md +24 -0
  49. data/doc/plugins/persistence.md +101 -0
  50. data/doc/plugins/presign_endpoint.md +9 -4
  51. data/doc/plugins/pretty_location.md +16 -3
  52. data/doc/plugins/processing.md +4 -2
  53. data/doc/plugins/rack_file.md +8 -2
  54. data/doc/plugins/rack_response.md +6 -2
  55. data/doc/plugins/recache.md +4 -2
  56. data/doc/plugins/refresh_metadata.md +49 -9
  57. data/doc/plugins/remote_url.md +84 -47
  58. data/doc/plugins/remove_attachment.md +27 -6
  59. data/doc/plugins/remove_invalid.md +21 -6
  60. data/doc/plugins/restore_cached_data.md +11 -3
  61. data/doc/plugins/sequel.md +159 -35
  62. data/doc/plugins/signature.md +16 -5
  63. data/doc/plugins/store_dimensions.md +14 -2
  64. data/doc/plugins/tempfile.md +4 -2
  65. data/doc/plugins/type_predicates.md +96 -0
  66. data/doc/plugins/upload_endpoint.md +13 -13
  67. data/doc/plugins/upload_options.md +6 -4
  68. data/doc/plugins/{default_url_options.md → url_options.md} +9 -7
  69. data/doc/plugins/validation.md +97 -0
  70. data/doc/plugins/validation_helpers.md +16 -13
  71. data/doc/plugins/versions.md +15 -19
  72. data/doc/processing.md +438 -221
  73. data/doc/refile.md +188 -170
  74. data/doc/release_notes/1.0.0.md +4 -0
  75. data/doc/release_notes/1.1.0.md +6 -2
  76. data/doc/release_notes/1.2.0.md +4 -0
  77. data/doc/release_notes/1.3.0.md +4 -0
  78. data/doc/release_notes/1.4.0.md +4 -0
  79. data/doc/release_notes/1.4.1.md +4 -0
  80. data/doc/release_notes/1.4.2.md +4 -0
  81. data/doc/release_notes/2.0.0.md +4 -0
  82. data/doc/release_notes/2.0.1.md +4 -0
  83. data/doc/release_notes/2.1.0.md +5 -1
  84. data/doc/release_notes/2.1.1.md +4 -0
  85. data/doc/release_notes/2.10.0.md +4 -0
  86. data/doc/release_notes/2.10.1.md +4 -0
  87. data/doc/release_notes/2.11.0.md +4 -0
  88. data/doc/release_notes/2.12.0.md +4 -0
  89. data/doc/release_notes/2.13.0.md +4 -0
  90. data/doc/release_notes/2.14.0.md +5 -1
  91. data/doc/release_notes/2.15.0.md +11 -7
  92. data/doc/release_notes/2.16.0.md +4 -0
  93. data/doc/release_notes/2.17.0.md +4 -0
  94. data/doc/release_notes/2.18.0.md +4 -0
  95. data/doc/release_notes/2.19.0.md +6 -3
  96. data/doc/release_notes/2.2.0.md +4 -0
  97. data/doc/release_notes/2.3.0.md +4 -0
  98. data/doc/release_notes/2.3.1.md +4 -0
  99. data/doc/release_notes/2.4.0.md +4 -0
  100. data/doc/release_notes/2.4.1.md +4 -0
  101. data/doc/release_notes/2.5.0.md +4 -0
  102. data/doc/release_notes/2.6.0.md +4 -0
  103. data/doc/release_notes/2.6.1.md +4 -0
  104. data/doc/release_notes/2.7.0.md +4 -0
  105. data/doc/release_notes/2.8.0.md +4 -0
  106. data/doc/release_notes/2.9.0.md +4 -0
  107. data/doc/release_notes/3.0.0.md +981 -0
  108. data/doc/release_notes/3.0.1.md +22 -0
  109. data/doc/release_notes/3.1.0.md +73 -0
  110. data/doc/release_notes/3.2.0.md +96 -0
  111. data/doc/release_notes/3.2.1.md +31 -0
  112. data/doc/release_notes/3.2.2.md +14 -0
  113. data/doc/release_notes/3.3.0.md +105 -0
  114. data/doc/release_notes/3.4.0.md +35 -0
  115. data/doc/release_notes/3.5.0.md +63 -0
  116. data/doc/release_notes/3.6.0.md +23 -0
  117. data/doc/retrieving_uploads.md +5 -2
  118. data/doc/securing_uploads.md +60 -37
  119. data/doc/storage/file_system.md +20 -3
  120. data/doc/storage/memory.md +19 -0
  121. data/doc/storage/s3.md +122 -78
  122. data/doc/testing.md +141 -133
  123. data/doc/upgrading_to_3.md +708 -0
  124. data/doc/validation.md +54 -90
  125. data/lib/shrine/attacher.rb +292 -169
  126. data/lib/shrine/attachment.rb +13 -46
  127. data/lib/shrine/plugins/_persistence.rb +93 -0
  128. data/lib/shrine/plugins/activerecord.rb +77 -34
  129. data/lib/shrine/plugins/add_metadata.rb +25 -17
  130. data/lib/shrine/plugins/atomic_helpers.rb +119 -0
  131. data/lib/shrine/plugins/backgrounding.rb +77 -113
  132. data/lib/shrine/plugins/cached_attachment_data.rb +6 -15
  133. data/lib/shrine/plugins/column.rb +102 -0
  134. data/lib/shrine/plugins/data_uri.rb +38 -36
  135. data/lib/shrine/plugins/default_storage.rb +45 -15
  136. data/lib/shrine/plugins/default_url.rb +12 -24
  137. data/lib/shrine/plugins/default_url_options.rb +3 -30
  138. data/lib/shrine/plugins/delete_raw.rb +10 -16
  139. data/lib/shrine/plugins/derivation_endpoint.rb +130 -171
  140. data/lib/shrine/plugins/derivatives.rb +645 -0
  141. data/lib/shrine/plugins/determine_mime_type.rb +9 -21
  142. data/lib/shrine/plugins/download_endpoint.rb +118 -133
  143. data/lib/shrine/plugins/dynamic_storage.rb +5 -11
  144. data/lib/shrine/plugins/entity.rb +158 -0
  145. data/lib/shrine/plugins/form_assign.rb +108 -0
  146. data/lib/shrine/plugins/included.rb +6 -6
  147. data/lib/shrine/plugins/infer_extension.rb +17 -20
  148. data/lib/shrine/plugins/instrumentation.rb +59 -43
  149. data/lib/shrine/plugins/keep_files.rb +3 -15
  150. data/lib/shrine/plugins/metadata_attributes.rb +28 -19
  151. data/lib/shrine/plugins/mirroring.rb +142 -0
  152. data/lib/shrine/plugins/model.rb +160 -0
  153. data/lib/shrine/plugins/module_include.rb +3 -3
  154. data/lib/shrine/plugins/multi_cache.rb +27 -0
  155. data/lib/shrine/plugins/presign_endpoint.rb +27 -28
  156. data/lib/shrine/plugins/pretty_location.rb +15 -9
  157. data/lib/shrine/plugins/processing.rb +22 -9
  158. data/lib/shrine/plugins/rack_file.rb +2 -42
  159. data/lib/shrine/plugins/rack_response.rb +21 -10
  160. data/lib/shrine/plugins/recache.rb +6 -5
  161. data/lib/shrine/plugins/refresh_metadata.rb +13 -11
  162. data/lib/shrine/plugins/remote_url.rb +49 -49
  163. data/lib/shrine/plugins/remove_attachment.rb +12 -6
  164. data/lib/shrine/plugins/remove_invalid.rb +19 -8
  165. data/lib/shrine/plugins/restore_cached_data.rb +13 -7
  166. data/lib/shrine/plugins/sequel.rb +86 -36
  167. data/lib/shrine/plugins/signature.rb +10 -16
  168. data/lib/shrine/plugins/store_dimensions.rb +35 -40
  169. data/lib/shrine/plugins/tempfile.rb +1 -3
  170. data/lib/shrine/plugins/type_predicates.rb +113 -0
  171. data/lib/shrine/plugins/upload_endpoint.rb +28 -24
  172. data/lib/shrine/plugins/upload_options.rb +14 -15
  173. data/lib/shrine/plugins/url_options.rb +31 -0
  174. data/lib/shrine/plugins/validation.rb +80 -0
  175. data/lib/shrine/plugins/validation_helpers.rb +35 -58
  176. data/lib/shrine/plugins/versions.rb +107 -87
  177. data/lib/shrine/plugins.rb +22 -0
  178. data/lib/shrine/storage/file_system.rb +46 -64
  179. data/lib/shrine/storage/linter.rb +42 -7
  180. data/lib/shrine/storage/memory.rb +49 -0
  181. data/lib/shrine/storage/s3.rb +173 -160
  182. data/lib/shrine/uploaded_file.rb +32 -32
  183. data/lib/shrine/version.rb +3 -3
  184. data/lib/shrine.rb +87 -150
  185. data/shrine.gemspec +11 -12
  186. metadata +92 -82
  187. data/doc/migrating_storage.md +0 -76
  188. data/doc/plugins/backup.md +0 -31
  189. data/doc/plugins/copy.md +0 -24
  190. data/doc/plugins/delete_promoted.md +0 -12
  191. data/doc/plugins/direct_upload.md +0 -172
  192. data/doc/plugins/hooks.md +0 -58
  193. data/doc/plugins/logging.md +0 -42
  194. data/doc/plugins/migration_helpers.md +0 -60
  195. data/doc/plugins/moving.md +0 -19
  196. data/doc/plugins/multi_delete.md +0 -20
  197. data/doc/plugins/parallelize.md +0 -16
  198. data/doc/plugins/parsed_json.md +0 -23
  199. data/doc/regenerating_versions.md +0 -143
  200. data/lib/shrine/plugins/background_helpers.rb +0 -5
  201. data/lib/shrine/plugins/backup.rb +0 -90
  202. data/lib/shrine/plugins/copy.rb +0 -50
  203. data/lib/shrine/plugins/delete_promoted.rb +0 -20
  204. data/lib/shrine/plugins/direct_upload.rb +0 -217
  205. data/lib/shrine/plugins/hooks.rb +0 -90
  206. data/lib/shrine/plugins/logging.rb +0 -142
  207. data/lib/shrine/plugins/migration_helpers.rb +0 -70
  208. data/lib/shrine/plugins/moving.rb +0 -57
  209. data/lib/shrine/plugins/multi_delete.rb +0 -32
  210. data/lib/shrine/plugins/parallelize.rb +0 -78
  211. data/lib/shrine/plugins/parsed_json.rb +0 -29
@@ -1,34 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ gem "aws-sdk-s3", "~> 1.14"
4
+
3
5
  require "shrine"
4
- begin
5
- require "aws-sdk-s3"
6
- if Gem::Version.new(Aws::S3::GEM_VERSION) < Gem::Version.new("1.2.0")
7
- raise "Shrine::Storage::S3 requires aws-sdk-s3 version 1.2.0 or above"
8
- elsif Gem::Version.new(Aws::S3::GEM_VERSION) < Gem::Version.new("1.14.0")
9
- Shrine.deprecation("Using aws-sdk-s3 < 1.14 is deprecated and support for it will be removed in Shrine 3. Update to aws-sdk-s3 version 1.14 or higher")
10
- end
11
- rescue LoadError => exception
12
- begin
13
- require "aws-sdk"
14
- Shrine.deprecation("Using aws-sdk 2.x is deprecated and support for it will be removed in Shrine 3. Use the new aws-sdk-s3 gem instead.")
15
- Aws.eager_autoload!(services: ["S3"])
16
- rescue LoadError
17
- raise exception
18
- end
19
- end
6
+ require "aws-sdk-s3"
20
7
 
21
8
  require "down/chunked_io"
22
9
  require "content_disposition"
23
10
 
24
11
  require "uri"
25
- require "cgi"
26
12
  require "tempfile"
27
13
 
28
14
  class Shrine
29
15
  module Storage
30
16
  class S3
31
- attr_reader :client, :bucket, :prefix, :host, :upload_options, :signer, :public
17
+ attr_reader :client, :bucket, :prefix, :upload_options, :copy_options, :signer, :public
18
+
19
+ MAX_MULTIPART_PARTS = 10_000
20
+ MIN_PART_SIZE = 5*1024*1024
21
+
22
+ MULTIPART_THRESHOLD = { upload: 15*1024*1024, copy: 100*1024*1024 }
23
+
24
+ COPY_OPTIONS = { tagging_directive: "REPLACE" }
32
25
 
33
26
  # Initializes a storage for uploading to S3. All options are forwarded to
34
27
  # [`Aws::S3::Client#initialize`], except the following:
@@ -50,12 +43,20 @@ class Shrine
50
43
  # be passed to [`Aws::S3::Object#put`], [`Aws::S3::Object#copy_from`]
51
44
  # and [`Aws::S3::Bucket#presigned_post`].
52
45
  #
46
+ # :copy_options
47
+ # : Additional options that will be used for copying files, they will
48
+ # be passed to [`Aws::S3::Object#copy_from`].
49
+ #
53
50
  # :multipart_threshold
54
51
  # : If the input file is larger than the specified size, a parallelized
55
52
  # multipart will be used for the upload/copy. Defaults to
56
53
  # `{upload: 15*1024*1024, copy: 100*1024*1024}` (15MB for upload
57
54
  # requests, 100MB for copy requests).
58
55
  #
56
+ # :max_multipart_parts
57
+ # : Limits the number of parts if parellized multipart upload/copy is used.
58
+ # Defaults to 10_000.
59
+ #
59
60
  # In addition to specifying the `:bucket`, you'll also need to provide
60
61
  # AWS credentials. The most common way is to provide them directly via
61
62
  # `:access_key_id`, `:secret_access_key`, and `:region` options. But you
@@ -67,33 +68,20 @@ class Shrine
67
68
  # [`Aws::S3::Bucket#presigned_post`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#presigned_post-instance_method
68
69
  # [`Aws::S3::Client#initialize`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#initialize-instance_method
69
70
  # [configuring AWS SDK]: https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/setup-config.html
70
- def initialize(bucket:, client: nil, prefix: nil, host: nil, upload_options: {}, multipart_threshold: {}, signer: nil, public: nil, **s3_options)
71
+ def initialize(bucket:, client: nil, prefix: nil, upload_options: {}, multipart_threshold: {}, max_multipart_parts: nil, signer: nil, public: nil, copy_options: COPY_OPTIONS, **s3_options)
71
72
  raise ArgumentError, "the :bucket option is nil" unless bucket
72
73
 
73
- Shrine.deprecation("The :host option to Shrine::Storage::S3#initialize is deprecated and will be removed in Shrine 3. Pass :host to S3#url instead, you can also use default_url_options plugin.") if host
74
-
75
- if multipart_threshold.is_a?(Integer)
76
- Shrine.deprecation("Accepting the :multipart_threshold S3 option as an integer is deprecated, use a hash with :upload and :copy keys instead, e.g. {upload: 15*1024*1024, copy: 150*1024*1024}")
77
- multipart_threshold = { upload: multipart_threshold }
78
- end
79
- multipart_threshold = { upload: 15*1024*1024, copy: 100*1024*1024 }.merge(multipart_threshold)
80
-
81
74
  @client = client || Aws::S3::Client.new(**s3_options)
82
75
  @bucket = Aws::S3::Bucket.new(name: bucket, client: @client)
83
76
  @prefix = prefix
84
- @host = host
85
77
  @upload_options = upload_options
86
- @multipart_threshold = multipart_threshold
78
+ @copy_options = copy_options
79
+ @multipart_threshold = MULTIPART_THRESHOLD.merge(multipart_threshold)
80
+ @max_multipart_parts = max_multipart_parts || MAX_MULTIPART_PARTS
87
81
  @signer = signer
88
82
  @public = public
89
83
  end
90
84
 
91
- # Returns an `Aws::S3::Resource` object.
92
- def s3
93
- Shrine.deprecation("Shrine::Storage::S3#s3 that returns an Aws::S3::Resource is deprecated, use Shrine::Storage::S3#client which returns an Aws::S3::Client object.")
94
- Aws::S3::Resource.new(client: @client)
95
- end
96
-
97
85
  # If the file is an UploadedFile from S3, issues a COPY command, otherwise
98
86
  # uploads the file. For files larger than `:multipart_threshold` a
99
87
  # multipart upload/copy will be used for better performance and more
@@ -112,8 +100,6 @@ class Shrine
112
100
  options.merge!(@upload_options)
113
101
  options.merge!(upload_options)
114
102
 
115
- options[:content_disposition] = encode_content_disposition(options[:content_disposition]) if options[:content_disposition]
116
-
117
103
  if copyable?(io)
118
104
  copy(io, id, **options)
119
105
  else
@@ -124,22 +110,18 @@ class Shrine
124
110
  # Returns a `Down::ChunkedIO` object that downloads S3 object content
125
111
  # on-demand. By default, read content will be cached onto disk so that
126
112
  # it can be rewinded, but if you don't need that you can pass
127
- # `rewindable: false`.
113
+ # `rewindable: false`. A required character encoding can be passed in
114
+ # `encoding`; the default is `Encoding::BINARY` via `Down::ChunkedIO`.
128
115
  #
129
116
  # Any additional options are forwarded to [`Aws::S3::Object#get`].
130
117
  #
131
118
  # [`Aws::S3::Object#get`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#get-instance_method
132
- def open(id, rewindable: true, **options)
133
- object = object(id)
134
-
135
- load_data(object, **options)
119
+ def open(id, rewindable: true, encoding: nil, **options)
120
+ chunks, length = get(id, **options)
136
121
 
137
- Down::ChunkedIO.new(
138
- chunks: object.enum_for(:get, **options),
139
- rewindable: rewindable,
140
- size: object.content_length,
141
- data: { object: object },
142
- )
122
+ Down::ChunkedIO.new(chunks: chunks, rewindable: rewindable, size: length, encoding: encoding)
123
+ rescue Aws::S3::Errors::NoSuchKey
124
+ raise Shrine::FileNotFound, "file #{id.inspect} not found on storage"
143
125
  end
144
126
 
145
127
  # Returns true file exists on S3.
@@ -163,13 +145,7 @@ class Shrine
163
145
  #
164
146
  # [`Aws::S3::Object#presigned_url`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#presigned_url-instance_method
165
147
  # [`Aws::S3::Object#public_url`]: http://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Object.html#public_url-instance_method
166
- def url(id, download: nil, public: self.public, host: self.host, **options)
167
- if download
168
- Shrine.deprecation("The :download option in Shrine::Storage::S3#url is deprecated and will be removed in Shrine 3. Use the :response_content_disposition option directly, e.g. `response_content_disposition: \"attachment\"`.")
169
- options[:response_content_disposition] ||= "attachment"
170
- end
171
- options[:response_content_disposition] = encode_content_disposition(options[:response_content_disposition]) if options[:response_content_disposition]
172
-
148
+ def url(id, public: self.public, host: nil, **options)
173
149
  if public || signer
174
150
  url = object(id).public_url(**options)
175
151
  else
@@ -217,27 +193,7 @@ class Shrine
217
193
  options.merge!(@upload_options)
218
194
  options.merge!(presign_options)
219
195
 
220
- options[:content_disposition] = encode_content_disposition(options[:content_disposition]) if options[:content_disposition]
221
-
222
- if method == :post
223
- presigned_post = object(id).presigned_post(options)
224
-
225
- Struct.new(:method, :url, :fields).new(method, presigned_post.url, presigned_post.fields)
226
- else
227
- url = object(id).presigned_url(method, options)
228
-
229
- # When any of these options are specified, the corresponding request
230
- # headers must be included in the upload request.
231
- headers = {}
232
- headers["Content-Length"] = options[:content_length] if options[:content_length]
233
- headers["Content-Type"] = options[:content_type] if options[:content_type]
234
- headers["Content-Disposition"] = options[:content_disposition] if options[:content_disposition]
235
- headers["Content-Encoding"] = options[:content_encoding] if options[:content_encoding]
236
- headers["Content-Language"] = options[:content_language] if options[:content_language]
237
- headers["Content-MD5"] = options[:content_md5] if options[:content_md5]
238
-
239
- { method: method, url: url, headers: headers }
240
- end
196
+ send(:"presign_#{method}", id, options)
241
197
  end
242
198
 
243
199
  # Deletes the file from the storage.
@@ -245,6 +201,16 @@ class Shrine
245
201
  object(id).delete
246
202
  end
247
203
 
204
+ # Deletes objects at keys starting with the specified prefix.
205
+ #
206
+ # s3.delete_prefixed("somekey/derivatives/")
207
+ def delete_prefixed(delete_prefix)
208
+ # We need to make sure to combine with storage prefix, and
209
+ # that it ends in '/' cause S3 can be squirrely about matching interior.
210
+ delete_prefix = delete_prefix.chomp("/") + "/"
211
+ bucket.objects(prefix: [*prefix, delete_prefix].join("/")).batch_delete!
212
+ end
213
+
248
214
  # If block is given, deletes all objects from the storage for which the
249
215
  # block evaluates to true. Otherwise deletes all objects from the storage.
250
216
  #
@@ -260,76 +226,109 @@ class Shrine
260
226
 
261
227
  # Returns an `Aws::S3::Object` for the given id.
262
228
  def object(id)
263
- bucket.object([*prefix, id].join("/"))
229
+ bucket.object(object_key(id))
264
230
  end
265
231
 
266
- # Catches the deprecated `#download` and `#stream` methods.
267
- def method_missing(name, *args, &block)
268
- case name
269
- when :stream then deprecated_stream(*args, &block)
270
- when :download then deprecated_download(*args, &block)
271
- else
272
- super
232
+ private
233
+
234
+ # Uploads the file to S3. Uses multipart upload for large files.
235
+ def put(io, id, **options)
236
+ if io.respond_to?(:size) && io.size && io.size <= @multipart_threshold[:upload]
237
+ object(id).put(body: io, **options)
238
+ else # multipart upload
239
+ object(id).upload_stream(part_size: part_size(io), **options) do |write_stream|
240
+ IO.copy_stream(io, write_stream)
241
+ end
273
242
  end
274
243
  end
275
244
 
276
- private
277
-
278
245
  # Copies an existing S3 object to a new location. Uses multipart copy for
279
246
  # large files.
280
- def copy(io, id, **options)
281
- # pass :content_length on multipart copy to avoid an additional HEAD request
282
- options = { multipart_copy: true, content_length: io.size }.merge!(options) if io.size && io.size >= @multipart_threshold[:copy]
247
+ def copy(io, id, **copy_options)
248
+ # don't inherit source object metadata or AWS tags
249
+ options = {
250
+ metadata_directive: "REPLACE",
251
+ }
252
+
253
+ if io.size && io.size >= @multipart_threshold[:copy]
254
+ # pass :content_length on multipart copy to avoid an additional HEAD request
255
+ options.merge!(multipart_copy: true, content_length: io.size)
256
+ end
257
+
258
+ options.merge!(@copy_options)
259
+ options.merge!(copy_options)
260
+
283
261
  object(id).copy_from(io.storage.object(io.id), **options)
284
262
  end
285
263
 
286
- # Uploads the file to S3. Uses multipart upload for large files.
287
- def put(io, id, **options)
288
- if (path = extract_path(io))
289
- # use `upload_file` for files because it can do multipart upload
290
- options = { multipart_threshold: @multipart_threshold[:upload] }.merge!(options)
291
- object(id).upload_file(path, **options)
292
- else
293
- io.to_io if io.is_a?(UploadedFile) # open if not already opened
294
-
295
- if io.respond_to?(:size) && io.size && (io.size <= @multipart_threshold[:upload] || !object(id).respond_to?(:upload_stream))
296
- object(id).put(body: io, **options)
297
- elsif object(id).respond_to?(:upload_stream)
298
- # `upload_stream` uses multipart upload
299
- object(id).upload_stream(tempfile: true, **options) do |write_stream|
300
- IO.copy_stream(io, write_stream)
301
- end
302
- else
303
- Tempfile.create("shrine-s3", binmode: true) do |file|
304
- IO.copy_stream(io, file.path)
305
- object(id).upload_file(file.path, **options)
306
- end
307
- end
308
- end
264
+ # Generates parameters for a POST upload request.
265
+ def presign_post(id, options)
266
+ presigned_post = object(id).presigned_post(options)
267
+
268
+ { method: :post, url: presigned_post.url, fields: presigned_post.fields }
309
269
  end
310
270
 
311
- # Aws::S3::Object#load doesn't support passing options to #head_object,
312
- # so we call #head_object ourselves and assign the response data
313
- def load_data(object, **options)
314
- # filter out #get_object options that are not valid #head_object options
315
- options = options.select do |key, value|
316
- client.config.api.operation(:head_object).input.shape.member?(key)
317
- end
271
+ # Generates parameters for a PUT upload request.
272
+ def presign_put(id, options)
273
+ url = object(id).presigned_url(:put, options)
274
+
275
+ # When any of these options are specified, the corresponding request
276
+ # headers must be included in the upload request.
277
+ headers = {}
278
+ headers["Content-Length"] = options[:content_length] if options[:content_length]
279
+ headers["Content-Type"] = options[:content_type] if options[:content_type]
280
+ headers["Content-Disposition"] = options[:content_disposition] if options[:content_disposition]
281
+ headers["Content-Encoding"] = options[:content_encoding] if options[:content_encoding]
282
+ headers["Content-Language"] = options[:content_language] if options[:content_language]
283
+ headers["Content-MD5"] = options[:content_md5] if options[:content_md5]
284
+
285
+ { method: :put, url: url, headers: headers }
286
+ end
318
287
 
319
- response = client.head_object(
320
- bucket: bucket.name,
321
- key: object.key,
322
- **options
323
- )
288
+ # Determins the part size that should be used when uploading the given IO
289
+ # object via multipart upload.
290
+ def part_size(io)
291
+ return unless io.respond_to?(:size) && io.size
324
292
 
325
- object.instance_variable_set(:@data, response.data)
293
+ if io.size <= MIN_PART_SIZE * @max_multipart_parts # <= 50 GB
294
+ MIN_PART_SIZE
295
+ else # > 50 GB
296
+ (io.size.to_f / @max_multipart_parts).ceil
297
+ end
326
298
  end
327
299
 
328
- def extract_path(io)
329
- if io.respond_to?(:path)
330
- io.path
331
- elsif io.is_a?(UploadedFile) && defined?(Storage::FileSystem) && io.storage.is_a?(Storage::FileSystem)
332
- io.storage.path(io.id).to_s
300
+ # Aws::S3::Object#get doesn't allow us to get the content length of the
301
+ # object before all content is downloaded, so we hack our way around it.
302
+ # This way get the content length without an additional HEAD request.
303
+ if Gem::Version.new(Aws::CORE_GEM_VERSION) >= Gem::Version.new("3.104.0")
304
+ def get(id, **params)
305
+ enum = object(id).enum_for(:get, **params)
306
+
307
+ begin
308
+ content_length = Integer(enum.peek.last["content-length"])
309
+ rescue StopIteration
310
+ content_length = 0
311
+ end
312
+
313
+ chunks = Enumerator.new { |y| loop { y << enum.next.first } }
314
+
315
+ [chunks, content_length]
316
+ end
317
+ else
318
+ def get(id, **params)
319
+ req = client.build_request(:get_object, bucket: bucket.name, key: object_key(id), **params)
320
+
321
+ body = req.enum_for(:send_request)
322
+ begin
323
+ body.peek # start the request
324
+ rescue StopIteration
325
+ # the S3 object is empty
326
+ end
327
+
328
+ content_length = Integer(req.context.http_response.headers["Content-Length"])
329
+ chunks = Enumerator.new { |y| loop { y << body.next } }
330
+
331
+ [chunks, content_length]
333
332
  end
334
333
  end
335
334
 
@@ -349,42 +348,56 @@ class Shrine
349
348
  end
350
349
  end
351
350
 
352
- # Upload requests will fail if filename has non-ASCII characters, because
353
- # of how S3 generates signatures, so we URI-encode them. Most browsers
354
- # should automatically URI-decode filenames when downloading.
355
- def encode_content_disposition(content_disposition)
356
- content_disposition.sub(/(?<=filename=").+(?=")/) do |filename|
357
- if filename =~ /[^[:ascii:]]/
358
- Shrine.deprecation("Shrine::Storage::S3 will not escape characters in the filename for Content-Disposition header in Shrine 3. Use the content_disposition gem, for example `ContentDisposition.format(disposition: 'inline', filename: '...')`.")
359
- CGI.escape(filename).gsub("+", " ")
360
- else
361
- filename
362
- end
363
- end
351
+ # Returns object key with potential prefix.
352
+ def object_key(id)
353
+ [*prefix, id].join("/")
364
354
  end
365
355
 
366
- def deprecated_stream(id)
367
- Shrine.deprecation("Shrine::Storage::S3#stream is deprecated over calling #each_chunk on S3#open.")
368
- object = object(id)
369
- object.get { |chunk| yield chunk, object.content_length }
370
- end
356
+ # Adds support for Aws::S3::Encryption::Client.
357
+ module ClientSideEncryption
358
+ attr_reader :encryption_client
371
359
 
372
- def deprecated_download(id, **options)
373
- Shrine.deprecation("Shrine::Storage::S3#download is deprecated over S3#open.")
360
+ # Save the encryption client and continue initialization with normal
361
+ # client.
362
+ def initialize(client: nil, **options)
363
+ return super unless client.class.name.start_with?("Aws::S3::Encryption")
374
364
 
375
- tempfile = Tempfile.new(["shrine-s3", File.extname(id)], binmode: true)
376
- data = object(id).get(response_target: tempfile, **options)
377
- tempfile.content_type = data.content_type
378
- tempfile.tap(&:open)
379
- rescue
380
- tempfile.close! if tempfile
381
- raise
382
- end
365
+ super(client: client.client, **options)
366
+ @encryption_client = client
367
+ end
368
+
369
+ private
383
370
 
384
- # Tempfile with #content_type accessor which represents downloaded files.
385
- class Tempfile < ::Tempfile
386
- attr_accessor :content_type
371
+ # Encryption client doesn't support multipart uploads, so we always use
372
+ # #put_object.
373
+ def put(io, id, **options)
374
+ return super unless encryption_client
375
+
376
+ encryption_client.put_object(body: io, bucket: bucket.name, key: object_key(id), **options)
377
+ end
378
+
379
+ def get(id, **options)
380
+ return super unless encryption_client
381
+
382
+ # Encryption client v2 warns against streaming download, so we first
383
+ # download all content into a file.
384
+ tempfile = Tempfile.new("shrine-s3", binmode: true)
385
+ response = encryption_client.get_object(response_target: tempfile, bucket: bucket.name, key: object_key(id), **options)
386
+ tempfile.rewind
387
+
388
+ chunks = Enumerator.new do |yielder|
389
+ begin
390
+ yielder << tempfile.read(16*1024) until tempfile.eof?
391
+ ensure
392
+ tempfile.close!
393
+ end
394
+ end
395
+
396
+ [chunks, tempfile.size]
397
+ end
387
398
  end
399
+
400
+ prepend ClientSideEncryption
388
401
  end
389
402
  end
390
403
  end
@@ -6,7 +6,6 @@ require "uri"
6
6
 
7
7
  class Shrine
8
8
  # Core class that represents a file uploaded to a storage.
9
- # Base implementation is defined in InstanceMethods and ClassMethods.
10
9
  class UploadedFile
11
10
  @shrine_class = ::Shrine
12
11
 
@@ -23,31 +22,24 @@ class Shrine
23
22
  end
24
23
 
25
24
  module InstanceMethods
26
- # The hash of information which defines this uploaded file.
27
- attr_reader :data
25
+ # The location where the file was uploaded to the storage.
26
+ attr_reader :id
28
27
 
29
- # Initializes the uploaded file with the given data hash.
30
- def initialize(data)
31
- raise Error, "#{data.inspect} isn't valid uploaded file data" unless data["id"] && data["storage"]
28
+ # The identifier of the storage the file is uploaded to.
29
+ attr_reader :storage_key
32
30
 
33
- @data = data
34
- @data["metadata"] ||= {}
35
- storage # ensure storage is registered
36
- end
31
+ # A hash of file metadata that was extracted during upload.
32
+ attr_reader :metadata
37
33
 
38
- # The location where the file was uploaded to the storage.
39
- def id
40
- @data.fetch("id")
41
- end
34
+ # Initializes the uploaded file with the given data hash.
35
+ def initialize(data)
36
+ @id = data[:id] || data["id"]
37
+ @storage_key = data[:storage]&.to_sym || data["storage"]&.to_sym
38
+ @metadata = data[:metadata] || data["metadata"] || {}
42
39
 
43
- # The string identifier of the storage the file is uploaded to.
44
- def storage_key
45
- @data.fetch("storage")
46
- end
40
+ fail Error, "#{data.inspect} isn't valid uploaded file data" unless @id && @storage_key
47
41
 
48
- # A hash of file metadata that was extracted during upload.
49
- def metadata
50
- @data.fetch("metadata")
42
+ storage # ensure storage is registered
51
43
  end
52
44
 
53
45
  # The filename that was extracted from the uploaded file.
@@ -58,8 +50,9 @@ class Shrine
58
50
  # The extension derived from #id if present, otherwise it's derived
59
51
  # from #original_filename.
60
52
  def extension
61
- result = File.extname(id)[1..-1] || File.extname(original_filename.to_s)[1..-1]
62
- result.sub!(/\?.+$/, "") if result && id =~ URI::regexp # strip query params for shrine-url
53
+ identifier = id =~ URI::DEFAULT_PARSER.make_regexp ? id.sub(/\?.+$/, "") : id # strip query params for shrine-url
54
+ result = File.extname(identifier)[1..-1]
55
+ result ||= File.extname(original_filename.to_s)[1..-1]
63
56
  result.downcase if result
64
57
  end
65
58
 
@@ -177,6 +170,7 @@ class Shrine
177
170
  # opened IO object.
178
171
  def close
179
172
  io.close if opened?
173
+ @io = nil
180
174
  end
181
175
 
182
176
  # Returns whether the file has already been opened.
@@ -196,8 +190,8 @@ class Shrine
196
190
  end
197
191
 
198
192
  # Uploads a new file to this file's location and returns it.
199
- def replace(io, context = {})
200
- uploader.upload(io, context.merge(location: id))
193
+ def replace(io, **options)
194
+ uploader.upload(io, **options, location: id)
201
195
  end
202
196
 
203
197
  # Calls `#delete` on the storage, which deletes the file from the
@@ -222,11 +216,16 @@ class Shrine
222
216
  data
223
217
  end
224
218
 
219
+ # Returns serializable hash representation of the uploaded file.
220
+ def data
221
+ { "id" => id, "storage" => storage_key.to_s, "metadata" => metadata }
222
+ end
223
+
225
224
  # Returns true if the other UploadedFile is uploaded to the same
226
225
  # storage and it has the same #id.
227
226
  def ==(other)
228
- other.is_a?(self.class) &&
229
- self.id == other.id &&
227
+ self.class == other.class &&
228
+ self.id == other.id &&
230
229
  self.storage_key == other.storage_key
231
230
  end
232
231
  alias eql? ==
@@ -251,6 +250,11 @@ class Shrine
251
250
  self.class.shrine_class
252
251
  end
253
252
 
253
+ # Returns simplified inspect output.
254
+ def inspect
255
+ "#<#{self.class.inspect} storage=#{storage_key.inspect} id=#{id.inspect} metadata=#{metadata.inspect}>"
256
+ end
257
+
254
258
  private
255
259
 
256
260
  # Returns an opened IO object for the uploaded file by calling `#open`
@@ -260,11 +264,7 @@ class Shrine
260
264
  end
261
265
 
262
266
  def _open(**options)
263
- if options.any?
264
- storage.open(id, **options)
265
- else
266
- storage.open(id) # some storage implementations might not accept additional arguments
267
- end
267
+ storage.open(id, **options)
268
268
  end
269
269
  end
270
270
 
@@ -6,9 +6,9 @@ class Shrine
6
6
  end
7
7
 
8
8
  module VERSION
9
- MAJOR = 2
10
- MINOR = 19
11
- TINY = 3
9
+ MAJOR = 3
10
+ MINOR = 6
11
+ TINY = 0
12
12
  PRE = nil
13
13
 
14
14
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")