carrierwave 3.1.3 → 4.0.0.beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20e93cc014326f32bc9123d74648c047a9eeaf634a421981df01ece46dc142cc
4
- data.tar.gz: e86f6c907282bc12306f916c31084f34a20e815a4b64291971eefbf366f7ec0c
3
+ metadata.gz: 44d2471aedac47f453dcf2e0b0b3a36657ff89a1cd521875528a118cdfc465f4
4
+ data.tar.gz: 4de4a19f9c7b959d8bfd3658c8ee672b03227a5c74d96ec86cbca64ab692d726
5
5
  SHA512:
6
- metadata.gz: 0b1ade5c07dccfc124750e5c066e5a8dff02c52a918e3af496c1de35a42e836d1d1465fe7f48283e3a088214d843b107102fc7a87e0a68d148884d07e0378529
7
- data.tar.gz: 9597174ba08ceb10b8ab91e76e67fd1d37a54c97dac99cb745408da716297bbd960c81b809ec003d6b9695dbe0c2d363c1e958e25a43602b5049f6c2f25bd1a0
6
+ metadata.gz: 5f15fe1cc5ca10c246398c23bfa6d882494434dde71f40c4d044d949fae30e0929ade0d928ae951b028d2c9821538ae3d1e18a71c43f17e7a5256ecc19f4b8ea
7
+ data.tar.gz: 675258a32e180790066ea27d8d12aad87e78c607e51125afc0c24fef5eab42588f7a2dba4d54967ee28b52ae7d4921bf408801ee6ab504ebac05d91ef907cdbd
data/README.md CHANGED
@@ -378,6 +378,32 @@ class MyUploader < CarrierWave::Uploader::Base
378
378
  end
379
379
  ```
380
380
 
381
+ `process convert: format` cannot be given a condition. The file extension follows the
382
+ conversion, and it has to be worked out again when the file is retrieved, where whether
383
+ the condition held is not known. Convert unconditionally, or give the format a version
384
+ of its own:
385
+
386
+ ```ruby
387
+ version :webp, if: :convert_to_webp? do
388
+ process convert: :webp # unconditional within the version
389
+ end
390
+ ```
391
+
392
+ If you need to convert on a condition and keep it on the uploader itself, take the
393
+ naming over so that it still follows from the identifier:
394
+
395
+ ```ruby
396
+ process :to_jpeg, if: :heic?
397
+
398
+ def to_jpeg
399
+ minimagick! { |builder| builder.convert('jpg') }
400
+ end
401
+
402
+ def full_filename(for_file)
403
+ for_file.sub(/\.heic\z/i, '.jpg')
404
+ end
405
+ ```
406
+
381
407
  ### Nested versions
382
408
 
383
409
  It is possible to nest versions within versions:
@@ -424,6 +450,13 @@ end
424
450
  ```
425
451
 
426
452
  The `model` variable points to the instance object the uploader is attached to.
453
+ The condition is evaluated when the version is accessed, so it isn't called at all
454
+ as long as you only use the original file.
455
+
456
+ Note that `present?` on a version only tells whether a file is assigned, not whether
457
+ that version was actually created. Use `uploader.thumb.exists?` to ask the storage,
458
+ which costs a request when the storage is a remote one, or
459
+ [record what was stored](#recording-what-was-stored) to have the answer at hand.
427
460
 
428
461
  ### Create versions from existing versions
429
462
 
@@ -466,7 +499,44 @@ end
466
499
 
467
500
  Please note that `#full_filename` mustn't be constructed based on a dynamic value
468
501
  that can change from the time of store and time of retrieval, since it will result in
469
- being unable to retrieve a file previously stored.
502
+ being unable to retrieve a file previously stored. Storing refuses to leave a file
503
+ somewhere a later request would not look, so this is caught rather than silent.
504
+
505
+ ## Recording what was stored
506
+
507
+ By default CarrierWave persists the identifier alone, and works out everything else on
508
+ retrieval by re-running the uploader's definition and asking the storage. Which versions
509
+ a conditional creates is decided when the file is stored, so re-deciding it later can
510
+ give a different answer than what is actually there, and asking a remote storage for
511
+ things like the size costs a request per record.
512
+
513
+ Give the mount a column to record it in, and it stops guessing:
514
+
515
+ ```ruby
516
+ class Event < ActiveRecord::Base
517
+ mount_uploader :image, ImageUploader, metadata_column: :image_metadata
518
+ end
519
+ ```
520
+
521
+ ```ruby
522
+ add_column :events, :image_metadata, :json
523
+ ```
524
+
525
+ The column can be a `json`/`jsonb` one, a serialized one, or plain text holding JSON.
526
+ For `mount_uploaders` it holds one set of facts per file.
527
+
528
+ Records stored before the column was added have nothing recorded, and keep working as
529
+ they always have, so no backfill is needed.
530
+
531
+ Override `#build_metadata` to record your own facts:
532
+
533
+ ```ruby
534
+ class ImageUploader < CarrierWave::Uploader::Base
535
+ def build_metadata
536
+ super.merge('width' => width, 'height' => height)
537
+ end
538
+ end
539
+ ```
470
540
 
471
541
  ## Making uploads work across form redisplays
472
542
 
@@ -500,6 +570,11 @@ case of images, a small thumbnail would be a good indicator:
500
570
  <% end %>
501
571
  ```
502
572
 
573
+ When the cache storage is a remote one, uploading the cached file to it is deferred until
574
+ it turns out to be needed beyond the current request, which reading `avatar_cache` or
575
+ `avatar_url` tells CarrierWave. If you carry the cache name over by other means, call
576
+ `user.avatar.materialize_cache!` before handing it out.
577
+
503
578
  ## Removing uploaded files
504
579
 
505
580
  If you want to remove a previously uploaded file on a mounted uploader, you can
@@ -1,6 +1,6 @@
1
1
  require 'open-uri'
2
2
  require 'ssrf_filter'
3
- require 'addressable'
3
+ require 'addressable/uri'
4
4
  require 'carrierwave/downloader/remote_file'
5
5
 
6
6
  module CarrierWave
@@ -8,6 +8,14 @@ module CarrierWave
8
8
  class Base
9
9
  include CarrierWave::Utilities::Uri
10
10
 
11
+ # Leftovers from pasting, removed before parsing as in the first steps of
12
+ # https://url.spec.whatwg.org/#concept-basic-url-parser
13
+ LEADING_TRAILING_JUNK = /\A[\x00-\x20]+|[\x00-\x20]+\z/.freeze
14
+ EMBEDDED_JUNK = /[\t\n\r]/.freeze
15
+ # Matches a host name with an optional port, but not a scheme like 'http://' or 'data:'
16
+ HOSTISH = /\A[[:alnum:]][[:alnum:]\-._]*(?::\d+)?(?:[\/?#]|\z)/.freeze
17
+ DEFAULT_SCHEME = 'https'.freeze
18
+
11
19
  attr_reader :uploader
12
20
 
13
21
  def initialize(uploader)
@@ -29,7 +37,7 @@ module CarrierWave
29
37
  uri = process_uri(url.to_s)
30
38
  begin
31
39
  if skip_ssrf_protection?(uri)
32
- response = OpenURI.open_uri(process_uri(url.to_s), headers)
40
+ response = OpenURI.open_uri(uri, headers)
33
41
  else
34
42
  request = nil
35
43
  if ::SsrfFilter::VERSION.to_f < 1.1
@@ -59,22 +67,42 @@ module CarrierWave
59
67
  ##
60
68
  # Processes the given URL by parsing it, and escaping if necessary. Public to allow overriding.
61
69
  #
70
+ # Never decodes, as that would make %2F and '/', %2B and '+' indistinguishable.
71
+ #
62
72
  # === Parameters
63
73
  #
64
74
  # [url (String)] The URL where the remote file is stored
65
75
  #
66
76
  def process_uri(source)
67
- uri = Addressable::URI.parse(source)
77
+ uri = Addressable::URI.parse(normalize_input(source))
68
78
  uri.host = uri.normalized_host
69
- # Perform decode first, as the path is likely to be already encoded
70
- uri.path = encode_path(decode_uri(uri.path)) if uri.path =~ CarrierWave::Utilities::Uri::PATH_UNSAFE
71
- uri.query = encode_non_ascii(uri.query) if uri.query
72
- uri.fragment = encode_non_ascii(uri.fragment) if uri.fragment
79
+ uri.path = sanitize_component(uri.path, SANITIZE_PATH) if uri.path
80
+ uri.query = sanitize_component(uri.query, SANITIZE_QUERY) if uri.query
81
+ uri.fragment = sanitize_component(uri.fragment, SANITIZE_FRAGMENT) if uri.fragment
73
82
  URI.parse(uri.to_s)
74
83
  rescue URI::InvalidURIError, Addressable::URI::InvalidURIError
75
84
  raise CarrierWave::DownloadError, "couldn't parse URL: #{source}"
76
85
  end
77
86
 
87
+ ##
88
+ # Cleans up a URL as pasted by an end user, since `remote_#{column}_url=` is meant to be
89
+ # fed from a text field. Public to allow overriding.
90
+ #
91
+ # === Parameters
92
+ #
93
+ # [source (String)] The URL given by the user
94
+ #
95
+ def normalize_input(source)
96
+ source = source.to_s.gsub(LEADING_TRAILING_JUNK, '').gsub(EMBEDDED_JUNK, '')
97
+
98
+ return "#{DEFAULT_SCHEME}:#{source}" if source.start_with?('//')
99
+ # Don't promote a local path like '/etc/passwd' into a URL
100
+ return source if source.start_with?('/')
101
+ return "#{DEFAULT_SCHEME}://#{source}" if source.match?(HOSTISH)
102
+
103
+ source
104
+ end
105
+
78
106
  ##
79
107
  # If this returns true, SSRF protection will be bypassed.
80
108
  # You can override this if you want to allow accessing specific local URIs that are not SSRF exploitable.
@@ -1,6 +1,8 @@
1
1
  module CarrierWave
2
2
  module Downloader
3
3
  class RemoteFile
4
+ include CarrierWave::Utilities::Uri
5
+
4
6
  attr_reader :file, :uri
5
7
 
6
8
  def initialize(file)
@@ -53,7 +55,7 @@ module CarrierWave
53
55
  end
54
56
 
55
57
  def filename_from_uri
56
- CGI.unescape(File.basename(uri.path))
58
+ decode_path(File.basename(uri.path))
57
59
  end
58
60
 
59
61
  def method_missing(*args, &block)
@@ -5,4 +5,5 @@ module CarrierWave
5
5
  class ProcessingError < UploadError; end
6
6
  class DownloadError < UploadError; end
7
7
  class UnknownStorageError < StandardError; end
8
+ class FilenameNotReproducible < StandardError; end
8
9
  end
@@ -9,6 +9,7 @@ en:
9
9
  content_type_allowlist_error: "You are not allowed to upload %{content_type} files, allowed types: %{allowed_types}"
10
10
  content_type_denylist_error: "You are not allowed to upload %{content_type} files"
11
11
  processing_error: "Failed to manipulate, maybe it is not an image?"
12
+ unsupported_image_format_error: "Failed to manipulate, file format is not supported"
12
13
  min_size_error: "File size should be greater than %{min_size}"
13
14
  max_size_error: "File size should be less than %{max_size}"
14
15
  min_width_error: "Image width should be greater than %{min_width}px"
@@ -102,6 +102,7 @@ module CarrierWave
102
102
  # === Options
103
103
  #
104
104
  # [:mount_on => Symbol] if the name of the column to be serialized to differs you can override it using this option
105
+ # [:metadata_column => Symbol] the column to record what was stored in, so that it does not have to be worked out again
105
106
  # [:ignore_integrity_errors => Boolean] if set to true, integrity errors will result in caching failing silently
106
107
  # [:ignore_processing_errors => Boolean] if set to true, processing errors will result in caching failing silently
107
108
  #
@@ -247,6 +248,7 @@ module CarrierWave
247
248
  # === Options
248
249
  #
249
250
  # [:mount_on => Symbol] if the name of the column to be serialized to differs you can override it using this option
251
+ # [:metadata_column => Symbol] the column to record what was stored in, so that it does not have to be worked out again
250
252
  # [:ignore_integrity_errors => Boolean] if set to true, integrity errors will result in caching failing silently
251
253
  # [:ignore_processing_errors => Boolean] if set to true, processing errors will result in caching failing silently
252
254
  #
@@ -1,3 +1,6 @@
1
+ require "json"
2
+ require "active_support/core_ext/array/wrap"
3
+
1
4
  module CarrierWave
2
5
 
3
6
  # this is an internal class, used by CarrierWave::Mount so that
@@ -8,6 +11,10 @@ module CarrierWave
8
11
  uploaders.first&.identifier
9
12
  end
10
13
 
14
+ def metadata
15
+ uploaders.first&.build_metadata
16
+ end
17
+
11
18
  def temporary_identifier
12
19
  temporary_identifiers.first
13
20
  end
@@ -18,6 +25,10 @@ module CarrierWave
18
25
  uploaders.map(&:identifier).presence
19
26
  end
20
27
 
28
+ def metadata
29
+ uploaders.map(&:build_metadata).presence
30
+ end
31
+
21
32
  def temporary_identifier
22
33
  temporary_identifiers.presence
23
34
  end
@@ -63,11 +74,28 @@ module CarrierWave
63
74
  [record.read_uploader(serialization_column)].flatten.reject(&:blank?)
64
75
  end
65
76
 
77
+ ##
78
+ # The stored metadata, as many as there are identifiers. Whatever the column gives
79
+ # is accepted, so that it can be a json column, a serialized one, or plain text.
80
+ #
81
+ def read_metadata
82
+ return [] unless metadata_column
83
+
84
+ value = record.read_uploader(metadata_column)
85
+ value = JSON.parse(value) rescue nil if value.is_a?(String)
86
+ Array.wrap(value)
87
+ end
88
+
66
89
  def uploaders
67
- @uploaders ||= read_identifiers.map do |identifier|
68
- uploader = blank_uploader
69
- uploader.retrieve_from_store!(identifier)
70
- uploader
90
+ @uploaders ||= begin
91
+ metadata = read_metadata
92
+ read_identifiers.each_with_index.map do |identifier, index|
93
+ uploader = blank_uploader
94
+ # Has to come first, as retrieval derives the path from what was recorded
95
+ uploader.metadata = metadata[index]
96
+ uploader.retrieve_from_store!(identifier)
97
+ uploader
98
+ end
71
99
  end
72
100
  end
73
101
 
@@ -101,6 +129,8 @@ module CarrierWave
101
129
  end
102
130
 
103
131
  def cache_names
132
+ # The names are carried over to the next request, which the files have to survive
133
+ uploaders.each(&:materialize_cache!)
104
134
  uploaders.map(&:cache_name).compact
105
135
  end
106
136
 
@@ -157,6 +187,7 @@ module CarrierWave
157
187
  @added_uploaders += additions
158
188
 
159
189
  record.write_uploader(serialization_column, identifier)
190
+ record.write_uploader(metadata_column, metadata) if metadata_column
160
191
  end
161
192
 
162
193
  def urls(*args)
@@ -197,6 +228,10 @@ module CarrierWave
197
228
  option(:mount_on) || column
198
229
  end
199
230
 
231
+ def metadata_column
232
+ option(:metadata_column)
233
+ end
234
+
200
235
  def remove_previous
201
236
  current_paths = uploaders.map(&:path)
202
237
  @removed_uploaders
@@ -52,6 +52,9 @@ module CarrierWave
52
52
  @_mounters[:"#{column}"] = nil
53
53
  # The attribute needs to be cleared to prevent it from picked up as identifier
54
54
  write_uploader(_mounter(:#{column}).serialization_column, nil)
55
+ if (metadata_column = _mounter(:#{column}).metadata_column)
56
+ write_uploader(metadata_column, nil)
57
+ end
55
58
  _mounter(:"#{column}").cache(old_uploaders)
56
59
  end
57
60
 
@@ -383,6 +383,11 @@ module CarrierWave
383
383
  frame = yield(*[frame, index, options].take(block.arity)) if block_given?
384
384
  frames << frame if frame
385
385
  end
386
+
387
+ if frames.none?
388
+ raise CarrierWave::ProcessingError, I18n.translate(:"errors.messages.unsupported_image_format_error")
389
+ end
390
+
386
391
  frames.append(true) if block_given?
387
392
 
388
393
  write_block = create_info_block(options[:write])
@@ -317,10 +317,18 @@ module CarrierWave
317
317
  def declared_content_type
318
318
  @declared_content_type ||
319
319
  if @file.respond_to?(:content_type) && @file.content_type
320
- Marcel::MimeType.for(declared_type: @file.content_type.to_s.chomp)
320
+ Marcel::MimeType.for(declared_type: first_declared_content_type(@file.content_type))
321
321
  end
322
322
  end
323
323
 
324
+ # Clients occasionally send more than one MIME type in a single header
325
+ # (e.g. "image/png; text/html" or "image/png, text/html"). Marcel 2 validates
326
+ # the declared type against the RFC grammar and rejects such values, so pick
327
+ # the first media type ourselves before handing it over.
328
+ def first_declared_content_type(content_type)
329
+ content_type.to_s.strip.split(/[;,\s]/, 2).first
330
+ end
331
+
324
332
  # Guess content type from its file extension. Limit what to be returned to prevent spoofing.
325
333
  def guessed_safe_content_type
326
334
  return unless path
@@ -31,6 +31,11 @@ module CarrierWave
31
31
  raise NotImplementedError, "Need to implement #retrieve_from_cache! if you want to use #{self.class.name} as a cache storage."
32
32
  end
33
33
 
34
+ # Storage engines which defer writing to the cache have to flush it here
35
+ def materialize_cache!(file)
36
+ file
37
+ end
38
+
34
39
  def delete_dir!(path)
35
40
  raise NotImplementedError, "Need to implement #delete_dir! if you want to use #{self.class.name} as a cache storage."
36
41
  end
@@ -105,7 +105,8 @@ module CarrierWave
105
105
  end
106
106
 
107
107
  ##
108
- # Stores given file to cache directory.
108
+ # Stages given file to be cached, deferring the upload until #materialize_cache!,
109
+ # as a cached file usually goes straight to the store within the same request.
109
110
  #
110
111
  # === Parameters
111
112
  #
@@ -113,12 +114,35 @@ module CarrierWave
113
114
  #
114
115
  # === Returns
115
116
  #
116
- # [CarrierWave::SanitizedFile] a sanitized file
117
+ # [CarrierWave::SanitizedFile] the staged file
118
+ # or
119
+ # [CarrierWave::Storage::Fog::File] the uploaded file, with cache_only
117
120
  #
118
121
  def cache!(new_file)
119
- f = CarrierWave::Storage::Fog::File.new(uploader, self, uploader.cache_path)
120
- f.store(new_file)
121
- f
122
+ # With cache_only there's no store to move to, so the cache is the final location
123
+ return upload_to_cache(new_file) if uploader.cache_only
124
+
125
+ local_storage.cache!(new_file)
126
+ end
127
+
128
+ ##
129
+ # Uploads the staged file to the cache directory.
130
+ #
131
+ # === Parameters
132
+ #
133
+ # [file (CarrierWave::SanitizedFile)] the staged file
134
+ #
135
+ # === Returns
136
+ #
137
+ # [CarrierWave::Storage::Fog::File] the uploaded file
138
+ #
139
+ def materialize_cache!(file)
140
+ return file if file.is_a?(CarrierWave::Storage::Fog::File)
141
+
142
+ upload_to_cache(file).tap do
143
+ file.delete
144
+ local_storage.delete_dir!(uploader.cache_path(nil))
145
+ end
122
146
  end
123
147
 
124
148
  ##
@@ -140,10 +164,12 @@ module CarrierWave
140
164
  # Deletes a cache dir
141
165
  #
142
166
  def delete_dir!(path)
143
- # do nothing, because there's no such things as 'empty directory'
167
+ # Only the local cache has dirs, as there's no such things as 'empty directory'
168
+ local_storage.delete_dir!(path)
144
169
  end
145
170
 
146
171
  def clean_cache!(seconds)
172
+ local_storage.clean_cache!(seconds)
147
173
  connection.directories.new(
148
174
  :key => uploader.fog_directory,
149
175
  :public => uploader.fog_public
@@ -296,7 +322,7 @@ module CarrierWave
296
322
  return read_source_file if ::File.exist?(file_body.path)
297
323
 
298
324
  # If the source file doesn't exist, the remote content is read
299
- @file = nil
325
+ remove_instance_variable(:@file)
300
326
  file.body
301
327
  end
302
328
 
@@ -308,7 +334,7 @@ module CarrierWave
308
334
  # [Integer] size of file body
309
335
  #
310
336
  def size
311
- file.nil? ? 0 : file.content_length
337
+ file&.content_length || 0
312
338
  end
313
339
 
314
340
  ##
@@ -439,7 +465,7 @@ module CarrierWave
439
465
  #
440
466
  def filename(options = {})
441
467
  return unless (file_url = url(options))
442
- CGI.unescape(file_url.split('?').first).gsub(/.*\/(.*?$)/, '\1')
468
+ decode_path(file_url.split('?').first).gsub(/.*\/(.*?$)/, '\1')
443
469
  end
444
470
 
445
471
  ##
@@ -512,7 +538,9 @@ module CarrierWave
512
538
  # [Fog::#{provider}::File] file data from remote service
513
539
  #
514
540
  def file
515
- @file ||= directory.files.head(path)
541
+ return @file if defined?(@file)
542
+
543
+ @file = directory.files.head(path)
516
544
  end
517
545
 
518
546
  def copy_options
@@ -554,6 +582,18 @@ module CarrierWave
554
582
  end
555
583
  end
556
584
 
585
+ private
586
+
587
+ def upload_to_cache(new_file)
588
+ f = CarrierWave::Storage::Fog::File.new(uploader, self, uploader.cache_path)
589
+ f.store(new_file)
590
+ f
591
+ end
592
+
593
+ def local_storage
594
+ @local_storage ||= CarrierWave::Storage::File.new(uploader)
595
+ end
596
+
557
597
  end # Fog
558
598
 
559
599
  end # Storage
@@ -336,6 +336,8 @@ module CarrierWave
336
336
  def self.load_image(filename)
337
337
  if defined? ::MiniMagick
338
338
  MiniMagickWrapper.new(filename)
339
+ elsif defined? ::Vips
340
+ VipsWrapper.new(filename)
339
341
  else
340
342
  unless defined? ::Magick
341
343
  begin
@@ -393,6 +395,28 @@ module CarrierWave
393
395
  end
394
396
  end
395
397
 
398
+ class VipsWrapper # :nodoc:
399
+ attr_reader :image
400
+
401
+ def width
402
+ image.width
403
+ end
404
+
405
+ def height
406
+ image.height
407
+ end
408
+
409
+ def format
410
+ # This returns the name of the vips loader (e.g. pngload) as Vips
411
+ # doesn't do content detection.
412
+ image.get("vips-loader").delete_suffix("load")
413
+ end
414
+
415
+ def initialize(filename)
416
+ @image = ::Vips::Image.new_from_file(filename)
417
+ end
418
+ end
419
+
396
420
  end # Matchers
397
421
  end # Test
398
422
  end # CarrierWave
@@ -151,6 +151,16 @@ module CarrierWave
151
151
  end
152
152
  end
153
153
 
154
+ ##
155
+ # Makes sure the cached file is written to the cache storage, which may have
156
+ # deferred it. Needed when the file has to outlive the current request.
157
+ #
158
+ def materialize_cache!
159
+ with_callbacks(:materialize_cache) do
160
+ @file = cache_storage.materialize_cache!(@file) if @file
161
+ end
162
+ end
163
+
154
164
  ##
155
165
  # Retrieves the file with the given cache_name from the cache.
156
166
  #
@@ -45,6 +45,7 @@ module CarrierWave
45
45
  add_config :validate_processing
46
46
  add_config :validate_download
47
47
  add_config :mount_on
48
+ add_config :metadata_column
48
49
  add_config :cache_only
49
50
  add_config :download_retry_count
50
51
  add_config :download_retry_wait_time
@@ -0,0 +1,100 @@
1
+ require "active_support/core_ext/hash/keys"
2
+
3
+ module CarrierWave
4
+ module Uploader
5
+ module Metadata
6
+ extend ActiveSupport::Concern
7
+
8
+ include CarrierWave::Uploader::Versions
9
+
10
+ ##
11
+ # Facts about the file which were determined when it was stored, as recorded in
12
+ # the column given to the +metadata+ option of the mount. They are the answer to
13
+ # questions the storage cannot answer, like whether a conditional version was
14
+ # created, and they save asking it the ones it can.
15
+ #
16
+ # Returns an empty Hash when nothing was recorded, in which case the facts are
17
+ # derived from the file and the uploader definition as they have always been.
18
+ #
19
+ # === Returns
20
+ #
21
+ # [Hash] the recorded facts
22
+ #
23
+ def metadata
24
+ return @metadata || {} unless parent_version
25
+
26
+ parent_version.metadata.dig('versions', self.class.version_names.last.to_s) || {}
27
+ end
28
+
29
+ def metadata=(metadata)
30
+ @metadata = metadata&.deep_stringify_keys
31
+ end
32
+
33
+ ##
34
+ # Collects the facts to record about the file being stored. Override to record
35
+ # your own, merging them into the result of +super+.
36
+ #
37
+ # === Returns
38
+ #
39
+ # [Hash] the facts to record
40
+ #
41
+ def build_metadata
42
+ {
43
+ 'filename' => full_filename(identifier),
44
+ 'size' => size,
45
+ 'content_type' => content_type,
46
+ 'versions' => active_versions.map { |name, version| [name.to_s, version.build_metadata] }.to_h
47
+ }
48
+ end
49
+
50
+ ##
51
+ # === Returns
52
+ #
53
+ # [Boolean] Whether what is stored gets recorded, which the mount decides
54
+ #
55
+ def metadata_recorded?
56
+ model.class.respond_to?(:uploader_option) &&
57
+ model.class.uploaders.has_key?(mounted_as) &&
58
+ !!model.class.uploader_option(mounted_as, :metadata_column)
59
+ end
60
+
61
+ def size
62
+ metadata['size'] || super
63
+ end
64
+
65
+ def content_type
66
+ metadata['content_type'] || super
67
+ end
68
+
69
+ ##
70
+ # The conditions decide which versions to create, so which ones were created is
71
+ # settled once the file is stored. Consult the record instead of asking them
72
+ # again, which is not only costly but can give a different answer.
73
+ #
74
+ def version_active?(name)
75
+ recorded_versions = metadata['versions']
76
+ return super unless recorded_versions
77
+
78
+ versions.has_key?(name.to_sym) && recorded_versions.has_key?(name.to_s)
79
+ end
80
+
81
+ def cache!(*)
82
+ @metadata = nil
83
+ super
84
+ end
85
+
86
+ def recreate_versions!(*)
87
+ @metadata = nil
88
+ super
89
+ end
90
+
91
+ private
92
+
93
+ def full_filename(for_file)
94
+ return super unless for_file == identifier
95
+
96
+ metadata['filename'] || super
97
+ end
98
+ end # Metadata
99
+ end # Uploader
100
+ end # CarrierWave
@@ -67,13 +67,17 @@ module CarrierWave
67
67
  new_processors.each do |processor, processor_args|
68
68
  self.processors += [[processor, processor_args, condition, condition_type]]
69
69
 
70
- if processor == :convert
71
- # Treat :convert specially, since it should trigger the file extension change
72
- force_extension processor_args
73
- if condition
74
- warn "Use of 'process convert: format' with conditionals has an issue and doesn't work correctly. See https://github.com/carrierwaveuploader/carrierwave/issues/2723 for details. "
75
- end
70
+ next unless processor == :convert
71
+
72
+ # Treat :convert specially, since it should trigger the file extension change.
73
+ # That has to be worked out again when the file is retrieved, where whether
74
+ # the condition held cannot be known, so a conditional one is refused.
75
+ if condition
76
+ raise ArgumentError, "`process convert:` cannot be given `:if` or `:unless`. The file extension follows the conversion, and it has to be worked out again when the file is retrieved, where whether the condition held is not known. " \
77
+ "Convert unconditionally, or put the conversion in a version of its own, or take the naming over by overriding #full_filename."
76
78
  end
79
+
80
+ force_extension processor_args
77
81
  end
78
82
  end
79
83
  end # ClassMethods
@@ -5,10 +5,26 @@ module CarrierWave
5
5
  ##
6
6
  # === Returns
7
7
  #
8
- # [Boolean] Whether the uploaded file is blank
8
+ # [Boolean] Whether a file is assigned. Use #exists? to ask the storage whether
9
+ # the file is actually there.
9
10
  #
10
11
  def blank?
11
- file.blank?
12
+ return true unless file
13
+ # A cache name can point at a cache which is gone, so it is verified. It is
14
+ # local and thus cheap, unlike asking a remote store.
15
+ return file.empty? if cached?
16
+
17
+ false
18
+ end
19
+
20
+ ##
21
+ # === Returns
22
+ #
23
+ # [Boolean] Whether the file exists in the storage. Asking costs a request when
24
+ # the storage is a remote one.
25
+ #
26
+ def exists?
27
+ !!file&.exists?
12
28
  end
13
29
 
14
30
  ##
@@ -94,6 +94,7 @@ module CarrierWave
94
94
  cache!(new_file) if new_file && !cached?
95
95
  if !cache_only && @file && @cache_id
96
96
  with_callbacks(:store, new_file) do
97
+ ensure_the_filename_can_be_worked_out_again
97
98
  new_file = storage.store!(@file)
98
99
  if delete_tmp_file_after_storage
99
100
  @file.delete unless move_to_store
@@ -116,8 +117,10 @@ module CarrierWave
116
117
  #
117
118
  def retrieve_from_store!(identifier)
118
119
  with_callbacks(:retrieve_from_store, identifier) do
119
- @file = storage.retrieve!(identifier)
120
+ # Has to come first, as the storage derives the path from what was recorded
121
+ # for the identifier
120
122
  @identifier = identifier
123
+ @file = storage.retrieve!(identifier)
121
124
  end
122
125
  end
123
126
 
@@ -143,6 +146,25 @@ module CarrierWave
143
146
 
144
147
  private
145
148
 
149
+ ##
150
+ # A later request gets the identifier and nothing else, so the name the file is
151
+ # stored under has to follow from it. Ask a fresh uploader what it would work out,
152
+ # and refuse before anything is written rather than leave the file somewhere it
153
+ # would never be found. Versions are left out: as long as the original is found,
154
+ # they can be made again. So is a mount which records what it stored, which is
155
+ # spared having to work the name out at all.
156
+ #
157
+ def ensure_the_filename_can_be_worked_out_again
158
+ return if parent_version || metadata_recorded?
159
+
160
+ for_file = deduplicated_filename
161
+ would_look_for = self.class.new(model, mounted_as).send(:full_filename, for_file)
162
+ return if would_look_for == full_filename(for_file)
163
+
164
+ raise CarrierWave::FilenameNotReproducible, "The file would be stored as #{full_filename(for_file).inspect}, but a later request given only the identifier #{for_file.inspect} would look for #{would_look_for.inspect}. " \
165
+ "#full_filename has to follow from the identifier, as that is all such a request gets."
166
+ end
167
+
146
168
  def full_filename(for_file)
147
169
  forcing_extension(for_file)
148
170
  end
@@ -15,6 +15,9 @@ module CarrierWave
15
15
  # [String] the location where this file is accessible via a url
16
16
  #
17
17
  def url(options = {})
18
+ # The URL is to be fetched by a browser, which the file has to survive this request for
19
+ materialize_cache! if cached?
20
+
18
21
  if file.respond_to?(:url)
19
22
  tmp_url = file.method(:url).arity.zero? ? file.url : file.url(options)
20
23
  return tmp_url if tmp_url.present?
@@ -9,6 +9,7 @@ module CarrierWave
9
9
  @options = {}
10
10
  @blocks = []
11
11
  @klass = nil
12
+ @mutex = Mutex.new
12
13
  end
13
14
 
14
15
  def configure(options, &block)
@@ -18,55 +19,68 @@ module CarrierWave
18
19
  end
19
20
 
20
21
  def build(superclass)
22
+ # Double-checked locking pattern for thread-safe lazy initialization.
23
+ # Without synchronization, concurrent threads can observe a partially
24
+ # initialized @klass where the class is created but version_options
25
+ # is not yet set, causing NoMethodError in version_active?.
21
26
  return @klass if @klass
22
- @klass = Class.new(superclass)
23
- superclass.const_set("VersionUploader#{@name.to_s.camelize}", @klass)
24
-
25
- @klass.version_names += [@name]
26
- @klass.versions = {}
27
- @klass.processors = []
28
- @klass.version_options = @options
29
- @klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1
30
- # Define the enable_processing method for versions so they get the
31
- # value from the parent class unless explicitly overwritten
32
- def self.enable_processing(value=nil)
33
- self.enable_processing = value if value
34
- if defined?(@enable_processing) && !@enable_processing.nil?
35
- @enable_processing
36
- else
37
- superclass.enable_processing
27
+
28
+ @mutex.synchronize do
29
+ return @klass if @klass
30
+
31
+ klass = Class.new(superclass)
32
+ superclass.const_set("VersionUploader#{@name.to_s.camelize}", klass)
33
+
34
+ klass.version_names += [@name]
35
+ klass.versions = {}
36
+ klass.processors = []
37
+ klass.version_options = @options
38
+ klass.class_eval <<-RUBY, __FILE__, __LINE__ + 1
39
+ # Define the enable_processing method for versions so they get the
40
+ # value from the parent class unless explicitly overwritten
41
+ def self.enable_processing(value=nil)
42
+ self.enable_processing = value if value
43
+ if defined?(@enable_processing) && !@enable_processing.nil?
44
+ @enable_processing
45
+ else
46
+ superclass.enable_processing
47
+ end
38
48
  end
39
- end
40
49
 
41
- # Regardless of what is set in the parent uploader, do not enforce the
42
- # move_to_cache config option on versions because it moves the original
43
- # file to the version's target file.
44
- #
45
- # If you want to enforce this setting on versions, override this method
46
- # in each version:
47
- #
48
- # version :thumb do
49
- # def move_to_cache
50
- # true
51
- # end
52
- # end
53
- #
54
- def move_to_cache
55
- false
56
- end
50
+ # Regardless of what is set in the parent uploader, do not enforce the
51
+ # move_to_cache config option on versions because it moves the original
52
+ # file to the version's target file.
53
+ #
54
+ # If you want to enforce this setting on versions, override this method
55
+ # in each version:
56
+ #
57
+ # version :thumb do
58
+ # def move_to_cache
59
+ # true
60
+ # end
61
+ # end
62
+ #
63
+ def move_to_cache
64
+ false
65
+ end
57
66
 
58
- # Need to rely on the parent version's identifier, as versions don't have its own one.
59
- def identifier
60
- parent_version.identifier
61
- end
62
- RUBY
63
- @blocks.each { |block| @klass.class_eval(&block) }
64
- @klass
67
+ # Need to rely on the parent version's identifier, as versions don't have its own one.
68
+ def identifier
69
+ parent_version.identifier
70
+ end
71
+ RUBY
72
+ @blocks.each { |block| klass.class_eval(&block) }
73
+
74
+ # Only assign to @klass after full initialization to ensure
75
+ # other threads never see a partially initialized class.
76
+ @klass = klass
77
+ end
65
78
  end
66
79
 
67
80
  def deep_dup
68
81
  other = dup
69
82
  other.instance_variable_set(:@blocks, @blocks.dup)
83
+ other.instance_variable_set(:@mutex, Mutex.new)
70
84
  other
71
85
  end
72
86
 
@@ -97,6 +111,7 @@ module CarrierWave
97
111
  attr_accessor :parent_version
98
112
 
99
113
  after :cache, :cache_versions!
114
+ after :materialize_cache, :materialize_versions_cache!
100
115
  after :store, :store_versions!
101
116
  after :remove, :remove_versions!
102
117
  after :retrieve_from_cache, :retrieve_versions_from_cache!
@@ -105,7 +120,7 @@ module CarrierWave
105
120
  prepend Module.new {
106
121
  def initialize(*)
107
122
  super
108
- @versions = nil
123
+ @versions = @deferred_version_retrieval = nil
109
124
  end
110
125
  }
111
126
  end
@@ -170,11 +185,16 @@ module CarrierWave
170
185
  # [Hash{Symbol => CarrierWave::Uploader}] a list of uploader instances
171
186
  #
172
187
  def versions
173
- return @versions if @versions
174
- @versions = {}
175
- self.class.versions.each do |name, version|
176
- @versions[name] = version.build(self.class).new(model, mounted_as)
177
- @versions[name].parent_version = self
188
+ unless @versions
189
+ @versions = {}
190
+ self.class.versions.each do |name, version|
191
+ @versions[name] = version.build(self.class).new(model, mounted_as)
192
+ @versions[name].parent_version = self
193
+ end
194
+ end
195
+ if (retrieval = @deferred_version_retrieval)
196
+ @deferred_version_retrieval = nil
197
+ active_versions.each_value { |v| v.public_send(*retrieval) }
178
198
  end
179
199
  @versions
180
200
  end
@@ -331,6 +351,10 @@ module CarrierWave
331
351
  derived_versions.each_value { |v| v.cache!(new_file) }
332
352
  end
333
353
 
354
+ def materialize_versions_cache!
355
+ versions.each_value(&:materialize_cache!)
356
+ end
357
+
334
358
  def store_versions!(new_file)
335
359
  active_versions.each_value { |v| v.store!(new_file) }
336
360
  end
@@ -339,12 +363,14 @@ module CarrierWave
339
363
  versions.each_value { |v| v.remove! }
340
364
  end
341
365
 
366
+ # Retrieval is deferred until #versions is accessed, as evaluating the versions'
367
+ # conditions can be costly and is pointless when no version is used
342
368
  def retrieve_versions_from_cache!(cache_name)
343
- active_versions.each_value { |v| v.retrieve_from_cache!(cache_name) }
369
+ @deferred_version_retrieval = [:retrieve_from_cache!, cache_name]
344
370
  end
345
371
 
346
372
  def retrieve_versions_from_store!(identifier)
347
- active_versions.each_value { |v| v.retrieve_from_store!(identifier) }
373
+ @deferred_version_retrieval = [:retrieve_from_store!, identifier]
348
374
  end
349
375
 
350
376
  end # Versions
@@ -15,6 +15,7 @@ require "carrierwave/uploader/file_size"
15
15
  require "carrierwave/uploader/dimension"
16
16
  require "carrierwave/uploader/processing"
17
17
  require "carrierwave/uploader/versions"
18
+ require "carrierwave/uploader/metadata"
18
19
  require "carrierwave/uploader/default_url"
19
20
 
20
21
  require "carrierwave/uploader/serialization"
@@ -61,6 +62,7 @@ module CarrierWave
61
62
  include CarrierWave::Uploader::Dimension
62
63
  include CarrierWave::Uploader::Processing
63
64
  include CarrierWave::Uploader::Versions
65
+ include CarrierWave::Uploader::Metadata
64
66
  include CarrierWave::Uploader::DefaultUrl
65
67
  include CarrierWave::Uploader::Serialization
66
68
  end # Base
@@ -3,23 +3,45 @@ require 'uri'
3
3
  module CarrierWave
4
4
  module Utilities
5
5
  module Uri
6
+ # For encoding raw data into a URI component, where '%' is data and becomes '%25'.
6
7
  # based on Ruby < 2.0's URI.encode
7
8
  PATH_SAFE = URI::RFC2396_REGEXP::PATTERN::UNRESERVED + '\/'
8
9
  PATH_UNSAFE = Regexp.new("[^#{PATH_SAFE}]", false)
9
- NON_ASCII = /[^[:ascii:]]/.freeze
10
10
 
11
- private
11
+ # For repairing a string which is already a URI, following RFC 3986 per component.
12
+ UNRESERVED = 'A-Za-z0-9\-._~'.freeze
13
+ SUB_DELIMS = "!$&'()*+,;=".freeze
14
+ PCHAR = "#{UNRESERVED}#{SUB_DELIMS}:@".freeze
15
+ SANITIZE_PATH = Regexp.new("[#{PCHAR}/]").freeze
16
+ # Also allows [ ] { } | \ ^, which browsers and URI.parse accept in a query
17
+ SANITIZE_QUERY = Regexp.new("[#{PCHAR}/?\\[\\]{}|\\\\^]").freeze
18
+ SANITIZE_FRAGMENT = Regexp.new("[#{PCHAR}/?]").freeze
12
19
 
20
+ PERCENT_ENCODED = /%[0-9a-fA-F]{2}/.freeze
21
+
22
+ module_function
23
+
24
+ # Not idempotent, as '%' is escaped to '%25' every time
13
25
  def encode_path(path)
14
26
  URI::DEFAULT_PARSER.escape(path, PATH_UNSAFE)
15
27
  end
16
28
 
17
- def encode_non_ascii(str)
18
- URI::DEFAULT_PARSER.escape(str, NON_ASCII)
29
+ # Only for strings not sent over the wire, like a filename to be shown to the user.
30
+ # CGI.unescape is for form encoding and would turn '+' into a space.
31
+ def decode_path(str)
32
+ URI::DEFAULT_PARSER.unescape(str)
33
+ end
34
+
35
+ # Escapes only what cannot appear in the component, leaving existing %XX alone.
36
+ # Decoding first would make %2F and '/', %2B and '+' indistinguishable.
37
+ def sanitize_component(str, allowed)
38
+ str.gsub(/(#{PERCENT_ENCODED})|(.)/m) do
39
+ $1 || ($2.match?(allowed) ? $2 : escape_octets($2))
40
+ end
19
41
  end
20
42
 
21
- def decode_uri(str)
22
- URI::DEFAULT_PARSER.unescape(str)
43
+ def escape_octets(str)
44
+ str.b.bytes.map { |byte| format('%%%02X', byte) }.join
23
45
  end
24
46
  end # Uri
25
47
  end # Utilities
@@ -1,3 +1,3 @@
1
1
  module CarrierWave
2
- VERSION = "3.1.3".freeze
2
+ VERSION = "4.0.0.beta".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carrierwave
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.3
4
+ version: 4.0.0.beta
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonas Nicklas
@@ -15,56 +15,68 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 6.0.0
18
+ version: 7.0.0
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: 6.0.0
25
+ version: 7.0.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: activemodel
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 6.0.0
32
+ version: 7.0.0
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: 6.0.0
39
+ version: 7.0.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: image_processing
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - "~>"
44
+ - - ">="
45
45
  - !ruby/object:Gem::Version
46
46
  version: '1.1'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '3'
47
50
  type: :runtime
48
51
  prerelease: false
49
52
  version_requirements: !ruby/object:Gem::Requirement
50
53
  requirements:
51
- - - "~>"
54
+ - - ">="
52
55
  - !ruby/object:Gem::Version
53
56
  version: '1.1'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '3'
54
60
  - !ruby/object:Gem::Dependency
55
61
  name: marcel
56
62
  requirement: !ruby/object:Gem::Requirement
57
63
  requirements:
58
- - - "~>"
64
+ - - ">="
59
65
  - !ruby/object:Gem::Version
60
66
  version: 1.0.0
67
+ - - "<"
68
+ - !ruby/object:Gem::Version
69
+ version: '3'
61
70
  type: :runtime
62
71
  prerelease: false
63
72
  version_requirements: !ruby/object:Gem::Requirement
64
73
  requirements:
65
- - - "~>"
74
+ - - ">="
66
75
  - !ruby/object:Gem::Version
67
76
  version: 1.0.0
77
+ - - "<"
78
+ - !ruby/object:Gem::Version
79
+ version: '3'
68
80
  - !ruby/object:Gem::Dependency
69
81
  name: addressable
70
82
  requirement: !ruby/object:Gem::Requirement
@@ -239,6 +251,20 @@ dependencies:
239
251
  - - ">="
240
252
  - !ruby/object:Gem::Version
241
253
  version: '0'
254
+ - !ruby/object:Gem::Dependency
255
+ name: ruby-vips
256
+ requirement: !ruby/object:Gem::Requirement
257
+ requirements:
258
+ - - ">="
259
+ - !ruby/object:Gem::Version
260
+ version: '0'
261
+ type: :development
262
+ prerelease: false
263
+ version_requirements: !ruby/object:Gem::Requirement
264
+ requirements:
265
+ - - ">="
266
+ - !ruby/object:Gem::Version
267
+ version: '0'
242
268
  - !ruby/object:Gem::Dependency
243
269
  name: rmagick
244
270
  requirement: !ruby/object:Gem::Requirement
@@ -350,6 +376,7 @@ files:
350
376
  - lib/carrierwave/uploader/extension_allowlist.rb
351
377
  - lib/carrierwave/uploader/extension_denylist.rb
352
378
  - lib/carrierwave/uploader/file_size.rb
379
+ - lib/carrierwave/uploader/metadata.rb
353
380
  - lib/carrierwave/uploader/mountable.rb
354
381
  - lib/carrierwave/uploader/processing.rb
355
382
  - lib/carrierwave/uploader/proxy.rb
@@ -378,14 +405,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
378
405
  requirements:
379
406
  - - ">="
380
407
  - !ruby/object:Gem::Version
381
- version: 2.5.0
408
+ version: 2.7.0
382
409
  required_rubygems_version: !ruby/object:Gem::Requirement
383
410
  requirements:
384
411
  - - ">="
385
412
  - !ruby/object:Gem::Version
386
413
  version: '0'
387
414
  requirements: []
388
- rubygems_version: 3.6.9
415
+ rubygems_version: 4.0.19
389
416
  specification_version: 4
390
417
  summary: Ruby file upload library
391
418
  test_files: []