shrine 2.5.0 → 2.6.0

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of shrine might be problematic. Click here for more details.

Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -13
  3. data/doc/attacher.md +7 -6
  4. data/doc/carrierwave.md +19 -17
  5. data/doc/design.md +1 -1
  6. data/doc/direct_s3.md +8 -5
  7. data/doc/multiple_files.md +4 -4
  8. data/doc/paperclip.md +7 -6
  9. data/doc/refile.md +67 -4
  10. data/doc/securing_uploads.md +41 -25
  11. data/doc/testing.md +6 -15
  12. data/lib/shrine.rb +19 -10
  13. data/lib/shrine/plugins/activerecord.rb +4 -4
  14. data/lib/shrine/plugins/add_metadata.rb +7 -3
  15. data/lib/shrine/plugins/background_helpers.rb +1 -1
  16. data/lib/shrine/plugins/backgrounding.rb +19 -6
  17. data/lib/shrine/plugins/cached_attachment_data.rb +4 -4
  18. data/lib/shrine/plugins/data_uri.rb +105 -31
  19. data/lib/shrine/plugins/default_url.rb +1 -1
  20. data/lib/shrine/plugins/delete_raw.rb +7 -3
  21. data/lib/shrine/plugins/determine_mime_type.rb +96 -44
  22. data/lib/shrine/plugins/direct_upload.rb +3 -1
  23. data/lib/shrine/plugins/download_endpoint.rb +14 -5
  24. data/lib/shrine/plugins/logging.rb +4 -4
  25. data/lib/shrine/plugins/metadata_attributes.rb +61 -0
  26. data/lib/shrine/plugins/migration_helpers.rb +1 -1
  27. data/lib/shrine/plugins/rack_file.rb +54 -30
  28. data/lib/shrine/plugins/recache.rb +1 -1
  29. data/lib/shrine/plugins/refresh_metadata.rb +29 -0
  30. data/lib/shrine/plugins/remote_url.rb +26 -4
  31. data/lib/shrine/plugins/remove_invalid.rb +5 -4
  32. data/lib/shrine/plugins/restore_cached_data.rb +10 -13
  33. data/lib/shrine/plugins/sequel.rb +4 -4
  34. data/lib/shrine/plugins/signature.rb +146 -0
  35. data/lib/shrine/plugins/store_dimensions.rb +68 -24
  36. data/lib/shrine/plugins/validation_helpers.rb +48 -29
  37. data/lib/shrine/plugins/versions.rb +16 -8
  38. data/lib/shrine/storage/file_system.rb +27 -16
  39. data/lib/shrine/storage/s3.rb +99 -58
  40. data/lib/shrine/version.rb +1 -1
  41. data/shrine.gemspec +1 -1
  42. metadata +9 -6
@@ -8,8 +8,7 @@ the obvious ones to not-so-obvious ones, and try to provide solutions.
8
8
  ## Validate file type
9
9
 
10
10
  Almost always you will be accepting certain types of files, and it's a good
11
- idea to create a whitelist (or blaclist) of supported extensions and MIME
12
- types.
11
+ idea to create a whitelist (or a blacklist) of extensions and MIME types.
13
12
 
14
13
  By default Shrine stores the MIME type derived from the extension, which means
15
14
  it's not guaranteed to hold the actual MIME type of the the file. However, you
@@ -22,8 +21,8 @@ class MyUploader < Shrine
22
21
  plugin :determine_mime_type
23
22
 
24
23
  Attacher.validate do
25
- validate_extension_inclusion [/jpe?g/, "png", "gif"]
26
- validate_mime_type_inclusion ["image/jpeg", "image/png", "image/gif"]
24
+ validate_extension_inclusion %w[jpg jpeg png gif]
25
+ validate_mime_type_inclusion %w[image/jpeg image/png image/gif]
27
26
  end
28
27
  end
29
28
  ```
@@ -49,7 +48,7 @@ end
49
48
  In the following sections we talk about various strategies to prevent files from
50
49
  being uploaded to cache and the temporary directory.
51
50
 
52
- ### Direct uploads
51
+ ### Limiting filesize in direct uploads
53
52
 
54
53
  If you're doing direct uploads with the `direct_upload` plugin, you can pass
55
54
  in the `:max_size` option, which will refuse too large files and automatically
@@ -68,34 +67,32 @@ plugin :direct_upload, presign: ->(request) do
68
67
  end
69
68
  ```
70
69
 
71
- ### Regular uploads
70
+ ### Limiting filesize at application level
72
71
 
73
- If you're simply accepting uploads synchronously in the form, you can prevent
74
- large files from getting into cache by loading the `remove_invalid` plugin:
72
+ If your application is accepting file uploads, it's good practice to limit the
73
+ maximum allowed `Content-Length` before calling `params` for the first time,
74
+ to avoid Rack parsing the multipart request parameters and creating a Tempfile
75
+ for uploads that are obviously attempts of attacks.
75
76
 
76
77
  ```rb
77
- plugin :remove_invalid
78
- ```
79
-
80
- ### Limiting at application level
78
+ if request.content_length >= 100*1024*1024 # 100MB
79
+ response.status = 413 # Request Entity Too Large
80
+ response.body = "The uploaded file was too large (maximum is 100MB)"
81
+ request.halt
82
+ end
81
83
 
82
- If your application is accepting file uploads directly (either through direct
83
- uploads or regular ones), you can limit the maximum request body size in your
84
- application server (nginx or apache):
84
+ request.params # Rack parses the multipart request params
85
+ ```
85
86
 
86
- ```sh
87
- # nginx.conf
87
+ Alternatively you can allow uploads of any size to temporary Shrine storage,
88
+ but tell Shrine to immediately delete the file if it failed validations by
89
+ loading the `remove_invalid` plugin.
88
90
 
89
- http {
90
- # ...
91
- server {
92
- # ...
93
- client_max_body_size 20M;
94
- }
95
- }
91
+ ```rb
92
+ plugin :remove_invalid
96
93
  ```
97
94
 
98
- ### Paranoid limiting
95
+ ### Paranoid filesize limiting
99
96
 
100
97
  If you want to make sure that no large files ever get to your storages, and
101
98
  you don't really care about the error message, you can use the `hooks` plugin
@@ -140,6 +137,25 @@ end
140
137
  If you're doing processing on caching, you can use the fastimage gem directly
141
138
  in a conditional.
142
139
 
140
+ ## Prevent metadata tampering
141
+
142
+ When cached file is retained on validation errors or it was direct uploaded,
143
+ the uploaded file representation is assigned to the attacher. This also
144
+ includes any file metadata. By default Shrine won't attempt to re-extract
145
+ metadata, because for remote storages that requires an additional HTTP request,
146
+ which might not be feasible depending on the application requirements.
147
+
148
+ However, this means that the attacker can directly upload a malicious file
149
+ (because direct uploads aren't validated), and then modify the metadata hash so
150
+ that it passes Shrine validations, before submitting the cached file to your
151
+ app. To guard yourself from such attacks, you can load the
152
+ `restore_cached_data` plugin, which will automatically re-extract metadata from
153
+ cached files on assignment and override the received metadata.
154
+
155
+ ```rb
156
+ plugin :restore_cached_data
157
+ ```
158
+
143
159
  ## Limit number of files
144
160
 
145
161
  When doing direct uploads, it's a good idea to apply some kind of throttling to
data/doc/testing.md CHANGED
@@ -80,20 +80,11 @@ factory :photo do
80
80
  end
81
81
  ```
82
82
 
83
- On the other hand, if you're setting up test data using YAML fixtures, you
84
- aren't that flexible, because you can only use primitive data types that are
85
- part of the YAML language. In that case you can load the `data_uri` Shrine
86
- plugin, and assign files in form of data URI strings through the
87
- `<attachment>_data_uri` accessor provided by the plugin.
88
-
89
- ```rb
90
- Shrine.plugin :data_uri
91
- ```
92
- ```yml
93
- # test/fixtures/photos.yml
94
- photo:
95
- image_data_uri: "data:image/png;base64,<%= Base64.encode64(File.binread("test/files/image.png")) %>"
96
- ```
83
+ On the other hand, if you're setting up test data using Rails' YAML fixtures,
84
+ you unfortunately won't be able to use them for assigning files. This is
85
+ because Rails fixtures only allow assigning primitive data types, and don't
86
+ allow you to specify Shrine attributes, you can only assign to columns
87
+ directly.
97
88
 
98
89
  ## Background jobs
99
90
 
@@ -222,7 +213,7 @@ return a hash of unprocessed original files:
222
213
  class ImageUploader
223
214
  def process(io, context)
224
215
  if context[:action] == :store
225
- {small: io, medium: io, large: io}
216
+ {small: io.download, medium: io.download, large: io.download}
226
217
  end
227
218
  end
228
219
  end
data/lib/shrine.rb CHANGED
@@ -2,6 +2,7 @@ require "shrine/version"
2
2
 
3
3
  require "securerandom"
4
4
  require "json"
5
+ require "tempfile"
5
6
 
6
7
  class Shrine
7
8
  # A generic exception used by Shrine.
@@ -137,7 +138,7 @@ class Shrine
137
138
  self::Attacher.include(plugin::AttacherMethods) if defined?(plugin::AttacherMethods)
138
139
  self::Attacher.extend(plugin::AttacherClassMethods) if defined?(plugin::AttacherClassMethods)
139
140
  plugin.configure(self, *args, &block) if plugin.respond_to?(:configure)
140
- nil
141
+ plugin
141
142
  end
142
143
 
143
144
  # Retrieves the storage under the given identifier (can be a Symbol or
@@ -151,7 +152,7 @@ class Shrine
151
152
  # model class. Example:
152
153
  #
153
154
  # class Photo
154
- # include Shrine[:image] # creates a Shrine::Attachment object
155
+ # include Shrine.attachment(:image) # creates a Shrine::Attachment object
155
156
  # end
156
157
  def attachment(name)
157
158
  self::Attachment.new(name)
@@ -166,7 +167,7 @@ class Shrine
166
167
  def uploaded_file(object, &block)
167
168
  case object
168
169
  when String
169
- warn "Giving a string to Shrine.uploaded_file is deprecated and won't be possible in Shrine 3. Use Attacher#uploaded_file instead."
170
+ deprecation("Giving a string to Shrine.uploaded_file is deprecated and won't be possible in Shrine 3. Use Attacher#uploaded_file instead.")
170
171
  uploaded_file(JSON.parse(object), &block)
171
172
  when Hash
172
173
  uploaded_file(self::UploadedFile.new(object), &block)
@@ -176,6 +177,11 @@ class Shrine
176
177
  raise Error, "cannot convert #{object.inspect} to a #{self}::UploadedFile"
177
178
  end
178
179
  end
180
+
181
+ # Prints a deprecation warning to standard error.
182
+ def deprecation(message)
183
+ warn "SHRINE DEPRECATION WARNING: #{message}"
184
+ end
179
185
  end
180
186
 
181
187
  module InstanceMethods
@@ -340,7 +346,8 @@ class Shrine
340
346
  # Retrieves the location for the given IO and context. First it looks
341
347
  # for the `:location` option, otherwise it calls #generate_location.
342
348
  def get_location(io, context)
343
- context[:location] || generate_location(io, context)
349
+ location = context[:location] || generate_location(io, context)
350
+ location or raise Error, "location generated for #{io.inspect} was nil (context = #{context})"
344
351
  end
345
352
 
346
353
  # If the IO object is a Shrine::UploadedFile, it simply copies over its
@@ -492,7 +499,7 @@ class Shrine
492
499
  # is then written to the attachment attribute in the JSON format.
493
500
  def assign(value)
494
501
  if value.is_a?(String)
495
- return if value == "" || value == read || !cache.uploaded?(uploaded_file(value))
502
+ return if value == "" || !cache.uploaded?(uploaded_file(value))
496
503
  assign_cached(uploaded_file(value))
497
504
  else
498
505
  uploaded_file = cache!(value, action: :cache) if value
@@ -504,7 +511,7 @@ class Shrine
504
511
  # attribute. It then runs file validations, and records that the
505
512
  # attachment has changed.
506
513
  def set(uploaded_file)
507
- @old = get
514
+ @old = get unless uploaded_file == get
508
515
  _set(uploaded_file)
509
516
  validate
510
517
  end
@@ -516,9 +523,10 @@ class Shrine
516
523
  end
517
524
 
518
525
  # Returns true if a new file has been attached.
519
- def attached?
526
+ def changed?
520
527
  instance_variable_defined?(:@old)
521
528
  end
529
+ alias attached? changed?
522
530
 
523
531
  # Plugins can override this if they want something to be done before
524
532
  # save.
@@ -528,6 +536,7 @@ class Shrine
528
536
  # Deletes the old file and promotes the new one. Typically this should
529
537
  # be called after saving the model instance.
530
538
  def finalize
539
+ return if !instance_variable_defined?(:@old)
531
540
  replace
532
541
  remove_instance_variable(:@old)
533
542
  _promote(action: :store) if cached?
@@ -601,19 +610,19 @@ class Shrine
601
610
 
602
611
  # Uploads the file using the #cache uploader, passing the #context.
603
612
  def cache!(io, **options)
604
- warn "Sending :phase to Shrine::Attacher#cache! is deprecated and will not be supported in Shrine 3. Use :action instead." if options[:phase]
613
+ Shrine.deprecation("Sending :phase to Attacher#cache! is deprecated and will not be supported in Shrine 3. Use :action instead.") if options[:phase]
605
614
  cache.upload(io, context.merge(_equalize_phase_and_action(options)))
606
615
  end
607
616
 
608
617
  # Uploads the file using the #store uploader, passing the #context.
609
618
  def store!(io, **options)
610
- warn "Sending :phase to Shrine::Attacher#store! is deprecated and will not be supported in Shrine 3. Use :action instead." if options[:phase]
619
+ Shrine.deprecation("Sending :phase to Attacher#store! is deprecated and will not be supported in Shrine 3. Use :action instead.") if options[:phase]
611
620
  store.upload(io, context.merge(_equalize_phase_and_action(options)))
612
621
  end
613
622
 
614
623
  # Deletes the file using the uploader, passing the #context.
615
624
  def delete!(uploaded_file, **options)
616
- warn "Sending :phase to Shrine::Attacher#delete! is deprecated and will not be supported in Shrine 3. Use :action instead." if options[:phase]
625
+ Shrine.deprecation("Sending :phase to Attacher#delete! is deprecated and will not be supported in Shrine 3. Use :action instead.") if options[:phase]
617
626
  store.delete(uploaded_file, context.merge(_equalize_phase_and_action(options)))
618
627
  end
619
628
 
@@ -29,7 +29,7 @@ class Shrine
29
29
  # saves again with a stored attachment, you can detect this in callbacks:
30
30
  #
31
31
  # class User < ActiveRecord::Base
32
- # include ImageUploader[:avatar]
32
+ # include ImageUploader::Attachment.new(:avatar)
33
33
  #
34
34
  # before_save do
35
35
  # if avatar_data_changed? && avatar_attacher.cached?
@@ -53,7 +53,7 @@ class Shrine
53
53
  # of the attachment, you can do it directly on the model.
54
54
  #
55
55
  # class User < ActiveRecord::Base
56
- # include ImageUploader[:avatar]
56
+ # include ImageUploader::Attachment.new(:avatar)
57
57
  # validates_presence_of :avatar
58
58
  # end
59
59
  #
@@ -85,11 +85,11 @@ class Shrine
85
85
 
86
86
  model.class_eval <<-RUBY, __FILE__, __LINE__ + 1 if opts[:activerecord_callbacks]
87
87
  before_save do
88
- #{@name}_attacher.save if #{@name}_attacher.attached?
88
+ #{@name}_attacher.save if #{@name}_attacher.changed?
89
89
  end
90
90
 
91
91
  after_commit on: [:create, :update] do
92
- #{@name}_attacher.finalize if #{@name}_attacher.attached?
92
+ #{@name}_attacher.finalize if #{@name}_attacher.changed?
93
93
  end
94
94
 
95
95
  after_commit on: [:destroy] do
@@ -17,7 +17,7 @@ class Shrine
17
17
  # document.pages
18
18
  #
19
19
  # You can also extract multiple metadata values at once, by using
20
- # `add_metadata` without an argument.
20
+ # `add_metadata` without an argument, and returning a hash of metadata.
21
21
  #
22
22
  # add_metadata do |io, context|
23
23
  # movie = FFMPEG::Movie.new(io.path)
@@ -73,9 +73,13 @@ class Shrine
73
73
  metadata = super
74
74
 
75
75
  opts[:metadata].each do |metadata_block|
76
- custom_metadata = instance_exec(io, context, &metadata_block)
76
+ custom_metadata = instance_exec(io, context, &metadata_block) || {}
77
77
  io.rewind
78
- metadata.merge!(custom_metadata) unless custom_metadata.nil?
78
+ # convert symbol keys to strings
79
+ custom_metadata.keys.each do |key|
80
+ custom_metadata[key.to_s] = custom_metadata.delete(key) if key.is_a?(Symbol)
81
+ end
82
+ metadata.merge!(custom_metadata)
79
83
  end
80
84
 
81
85
  metadata
@@ -1,3 +1,3 @@
1
- warn "The background_helpers Shrine plugin has been renamed to \"backgrounding\". Loading the plugin through \"background_helpers\" will stop working in Shrine 3."
1
+ Shrine.deprecation("The background_helpers plugin has been renamed to \"backgrounding\". Loading the plugin through \"background_helpers\" will stop working in Shrine 3.")
2
2
  require "shrine/plugins/backgrounding"
3
3
  Shrine::Plugins.register_plugin(:background_helpers, Shrine::Plugins::Backgrounding)
@@ -5,17 +5,22 @@ class Shrine
5
5
  # useful if you're doing processing and/or you're storing files on an
6
6
  # external storage service.
7
7
  #
8
- # plugin :backgrounding
9
- #
10
- # ## Usage
11
- #
12
8
  # The plugin provides `Attacher.promote` and `Attacher.delete` methods,
13
9
  # which allow you to hook up to promoting and deleting and spawn background
14
10
  # jobs, by passing a block.
15
11
  #
12
+ # Shrine.plugin :backgrounding
16
13
  # Shrine::Attacher.promote { |data| PromoteJob.perform_async(data) }
17
14
  # Shrine::Attacher.delete { |data| DeleteJob.perform_async(data) }
18
15
  #
16
+ # If you don't want to apply backgrounding for all uploaders, you can
17
+ # declare the hooks only for specific uploaders.
18
+ #
19
+ # class MyUploader < Shrine
20
+ # Attacher.promote { |data| PromoteJob.perform_async(data) }
21
+ # Attacher.delete { |data| DeleteJob.perform_async(data) }
22
+ # end
23
+ #
19
24
  # The yielded `data` variable is a serializable hash containing all context
20
25
  # needed for promotion/deletion. Now you just need to declare the job
21
26
  # classes, and inside them call `Attacher.promote` or `Attacher.delete`,
@@ -23,7 +28,6 @@ class Shrine
23
28
  #
24
29
  # class PromoteJob
25
30
  # include Sidekiq::Worker
26
- #
27
31
  # def perform(data)
28
32
  # Shrine::Attacher.promote(data)
29
33
  # end
@@ -31,7 +35,6 @@ class Shrine
31
35
  #
32
36
  # class DeleteJob
33
37
  # include Sidekiq::Worker
34
- #
35
38
  # def perform(data)
36
39
  # Shrine::Attacher.delete(data)
37
40
  # end
@@ -45,6 +48,16 @@ class Shrine
45
48
  # the foreground before kicking off a background job, you can use the
46
49
  # `recache` plugin.
47
50
  #
51
+ # In your application you can use `Attacher#cached?` and `Attacher#stored?`
52
+ # to differentiate between your background job being in progress and
53
+ # having completed.
54
+ #
55
+ # if user.avatar_attacher.cached? # background job is still in progress
56
+ # # ...
57
+ # elsif user.avatar_attacher.stored? # background job has finished
58
+ # # ...
59
+ # end
60
+ #
48
61
  # ## `Attacher.promote` and `Attacher.delete`
49
62
  #
50
63
  # In background jobs, `Attacher.promote` and `Attacher.delete` will resolve
@@ -10,11 +10,11 @@ class Shrine
10
10
  # the cached file as JSON, and should be used to set the value of the
11
11
  # hidden form field.
12
12
  #
13
- # @user.cached_avatar_data #=> '{"storage":"cache","id":"...","metadata":{...}}'
13
+ # @user.cached_avatar_data #=> '{"id":"38k25.jpg","storage":"cache","metadata":{...}}'
14
14
  #
15
15
  # This method delegates to `Attacher#read_cached`:
16
16
  #
17
- # attacher.read_cached #=> '{"storage":"cache","id":"...","metadata":{...}}'
17
+ # attacher.read_cached #=> '{"id":"38k25.jpg","storage":"cache","metadata":{...}}'
18
18
  module CachedAttachmentData
19
19
  module AttachmentMethods
20
20
  def initialize(*)
@@ -26,7 +26,7 @@ class Shrine
26
26
  end
27
27
 
28
28
  def cached_#{@name}_data=(value)
29
- warn "Calling #cached_#{@name}_data= is deprecated and will be removed in Shrine 3. You should use the original field name: `f.hidden_field :#{@name}, value: record.cached_#{@name}_data`."
29
+ Shrine.deprecation("Calling #cached_#{@name}_data= is deprecated and will be removed in Shrine 3. You should use the original field name: `f.hidden_field :#{@name}, value: record.cached_#{@name}_data`.")
30
30
  #{@name}_attacher.assign(value)
31
31
  end
32
32
  RUBY
@@ -35,7 +35,7 @@ class Shrine
35
35
 
36
36
  module AttacherMethods
37
37
  def read_cached
38
- get.to_json if cached? && attached?
38
+ get.to_json if cached? && changed?
39
39
  end
40
40
  end
41
41
  end
@@ -1,5 +1,8 @@
1
1
  require "base64"
2
+ require "strscan"
3
+ require "cgi/util"
2
4
  require "stringio"
5
+ require "forwardable"
3
6
 
4
7
  class Shrine
5
8
  module Plugins
@@ -19,42 +22,105 @@ class Shrine
19
22
  # user.avatar.size #=> 43423
20
23
  #
21
24
  # You can also use `#data_uri=` and `#data_uri` methods directly on the
22
- # `Shrine::Attacher`:
25
+ # `Shrine::Attacher` (which the model methods just delegate to):
23
26
  #
24
27
  # attacher.data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA"
25
28
  #
26
- # If you want the uploaded file to have an extension, you can generate a
27
- # filename based on the content type of the data URI:
28
- #
29
- # plugin :data_uri, filename: ->(content_type) do
30
- # extension = MIME::Types[content_type].first.preferred_extension
31
- # "data_uri.#{extension}"
32
- # end
33
- #
34
29
  # If the data URI wasn't correctly parsed, an error message will be added to
35
30
  # the attachment column. You can change the default error message:
36
31
  #
37
32
  # plugin :data_uri, error_message: "data URI was invalid"
38
33
  # plugin :data_uri, error_message: ->(uri) { I18n.t("errors.data_uri_invalid") }
39
34
  #
35
+ # If you just want to parse the data URI and create an IO object from it,
36
+ # you can do that with `Shrine.data_uri`. If the data URI cannot be parsed,
37
+ # a `Shrine::Plugins::DataUri::ParseError` will be raised.
38
+ #
39
+ # # or YourUploader.data_uri("...")
40
+ # io = Shrine.data_uri("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA")
41
+ # io.content_type #=> "image/png"
42
+ # io.size #=> 21
43
+ #
44
+ # When the content type is ommited, `text/plain` is assumed. The parser
45
+ # also supports raw data URIs which aren't base64-encoded.
46
+ #
47
+ # # or YourUploader.data_uri("...")
48
+ # io = Shrine.data_uri("data:,raw%20content")
49
+ # io.content_type #=> "text/plain"
50
+ # io.size #=> 11
51
+ # io.read #=> "raw content"
52
+ #
53
+ # The created IO object won't convey any file extension (because it doesn't
54
+ # have a filename), but you can generate a filename based on the content
55
+ # type of the data URI:
56
+ #
57
+ # require "mime/types"
58
+ #
59
+ # plugin :data_uri, filename: ->(content_type) do
60
+ # extension = MIME::Types[content_type].first.preferred_extension
61
+ # "data_uri.#{extension}"
62
+ # end
63
+ #
40
64
  # This plugin also adds a `UploadedFile#data_uri` method (and `#base64`),
41
65
  # which returns a base64-encoded data URI of any UploadedFile:
42
66
  #
43
- # user.avatar.data_uri #=> "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA"
44
- # user.avatar.base64 #=> "iVBORw0KGgoAAAANSUhEUgAAAAUA"
67
+ # uploaded_file.data_uri #=> "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA"
68
+ # uploaded_file.base64 #=> "iVBORw0KGgoAAAANSUhEUgAAAAUA"
45
69
  #
46
70
  # [data URIs]: https://tools.ietf.org/html/rfc2397
47
71
  # [HTML5 Canvas]: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
48
72
  module DataUri
49
- DEFAULT_ERROR_MESSAGE = "data URI was invalid"
73
+ class ParseError < Error; end
74
+
75
+ DATA_REGEXP = /data:/
76
+ MEDIA_TYPE_REGEXP = /[-\w.+]+\/[-\w.+]+(;[-\w.+]+=[^;,]+)*/
77
+ BASE64_REGEXP = /;base64/
78
+ CONTENT_SEPARATOR = /,/
50
79
  DEFAULT_CONTENT_TYPE = "text/plain"
51
- DATA_URI_REGEXP = /\Adata:([-\w.+]+\/[-\w.+]+)?(;base64)?,(.*)\z/m
52
80
 
53
81
  def self.configure(uploader, opts = {})
54
82
  uploader.opts[:data_uri_filename] = opts.fetch(:filename, uploader.opts[:data_uri_filename])
55
83
  uploader.opts[:data_uri_error_message] = opts.fetch(:error_message, uploader.opts[:data_uri_error_message])
56
84
  end
57
85
 
86
+ module ClassMethods
87
+ # Parses the given data URI and creates an IO object from it.
88
+ #
89
+ # Shrine.data_uri("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA")
90
+ # #=> #<Shrine::Plugins::DataUri::DataFile>
91
+ def data_uri(uri)
92
+ info = parse_data_uri(uri)
93
+
94
+ content_type = info[:content_type] || DEFAULT_CONTENT_TYPE
95
+ content = info[:base64] ? Base64.decode64(info[:data]) : CGI.unescape(info[:data])
96
+ filename = opts[:data_uri_filename]
97
+ filename = filename.call(content_type) if filename
98
+
99
+ data_file = DataFile.new(content, content_type: content_type, filename: filename)
100
+ info[:data].clear
101
+
102
+ data_file
103
+ end
104
+
105
+ private
106
+
107
+ def parse_data_uri(uri)
108
+ scanner = StringScanner.new(uri)
109
+ scanner.scan(DATA_REGEXP) or raise ParseError, "data URI has invalid format"
110
+ media_type = scanner.scan(MEDIA_TYPE_REGEXP)
111
+ base64 = scanner.scan(BASE64_REGEXP)
112
+ scanner.scan(CONTENT_SEPARATOR) or raise ParseError, "data URI has invalid format"
113
+
114
+ content_type = media_type[/^[^;]+/] if media_type
115
+
116
+ {
117
+ content_type: content_type,
118
+ base64: !!base64,
119
+ data: scanner.post_match,
120
+ }
121
+ end
122
+ end
123
+
58
124
  module AttachmentMethods
59
125
  def initialize(*)
60
126
  super
@@ -79,19 +145,13 @@ class Shrine
79
145
  def data_uri=(uri)
80
146
  return if uri == ""
81
147
 
82
- if match = uri.match(DATA_URI_REGEXP)
83
- content_type = match[1] || DEFAULT_CONTENT_TYPE
84
- content = match[2] ? Base64.decode64(match[3]) : match[3]
85
- filename = shrine_class.opts[:data_uri_filename]
86
- filename = filename.call(content_type) if filename
87
-
88
- assign DataFile.new(content, content_type: content_type, filename: filename)
89
- else
90
- message = shrine_class.opts[:data_uri_error_message] || DEFAULT_ERROR_MESSAGE
91
- message = message.call(uri) if message.respond_to?(:call)
92
- errors.replace [message]
93
- @data_uri = uri
94
- end
148
+ data_file = shrine_class.data_uri(uri)
149
+ assign(data_file)
150
+ rescue ParseError => error
151
+ message = shrine_class.opts[:data_uri_error_message] || error.message
152
+ message = message.call(uri) if message.respond_to?(:call)
153
+ errors.replace [message]
154
+ @data_uri = uri
95
155
  end
96
156
 
97
157
  # Form builders require the reader as well.
@@ -108,18 +168,32 @@ class Shrine
108
168
 
109
169
  # Returns contents of the file base64-encoded.
110
170
  def base64
111
- content = open { |io| io.read }
112
- Base64.encode64(content).chomp
171
+ binary = open { |io| io.read }
172
+ result = Base64.encode64(binary).chomp
173
+ binary.clear # deallocate string
174
+ result
113
175
  end
114
176
  end
115
177
 
116
- class DataFile < StringIO
178
+ class DataFile
117
179
  attr_reader :content_type, :original_filename
118
180
 
119
181
  def initialize(content, content_type: nil, filename: nil)
120
- @content_type = content_type
182
+ @content_type = content_type
121
183
  @original_filename = filename
122
- super(content)
184
+ @io = StringIO.new(content)
185
+ end
186
+
187
+ def to_io
188
+ @io
189
+ end
190
+
191
+ extend Forwardable
192
+ delegate Shrine::IO_METHODS.keys => :@io
193
+
194
+ def close
195
+ @io.close
196
+ @io.string.clear # deallocate string
123
197
  end
124
198
  end
125
199
  end