shrine 2.14.0 → 2.15.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +384 -374
  3. data/README.md +132 -63
  4. data/doc/advantages.md +191 -109
  5. data/doc/attacher.md +1 -1
  6. data/doc/carrierwave.md +4 -4
  7. data/doc/creating_storages.md +2 -2
  8. data/doc/design.md +2 -2
  9. data/doc/direct_s3.md +3 -3
  10. data/doc/metadata.md +1 -1
  11. data/doc/multiple_files.md +2 -2
  12. data/doc/paperclip.md +3 -3
  13. data/doc/plugins/activerecord.md +92 -0
  14. data/doc/plugins/add_metadata.md +93 -0
  15. data/doc/plugins/backgrounding.md +148 -0
  16. data/doc/plugins/backup.md +29 -0
  17. data/doc/plugins/cached_attachment_data.md +23 -0
  18. data/doc/plugins/copy.md +22 -0
  19. data/doc/plugins/data_uri.md +92 -0
  20. data/doc/plugins/default_storage.md +16 -0
  21. data/doc/plugins/default_url.md +33 -0
  22. data/doc/plugins/default_url_options.md +22 -0
  23. data/doc/plugins/delete_promoted.md +10 -0
  24. data/doc/plugins/delete_raw.md +16 -0
  25. data/doc/plugins/derivation_endpoint.md +747 -0
  26. data/doc/plugins/determine_mime_type.md +64 -0
  27. data/doc/plugins/direct_upload.md +170 -0
  28. data/doc/plugins/download_endpoint.md +83 -0
  29. data/doc/plugins/dynamic_storage.md +20 -0
  30. data/doc/plugins/hooks.md +56 -0
  31. data/doc/plugins/included.md +15 -0
  32. data/doc/plugins/infer_extension.md +57 -0
  33. data/doc/plugins/keep_files.md +20 -0
  34. data/doc/plugins/logging.md +39 -0
  35. data/doc/plugins/metadata_attribues.md +43 -0
  36. data/doc/plugins/migration_helpers.md +58 -0
  37. data/doc/plugins/module_include.md +40 -0
  38. data/doc/plugins/moving.md +17 -0
  39. data/doc/plugins/multi_delete.md +18 -0
  40. data/doc/plugins/parallelize.md +14 -0
  41. data/doc/plugins/parsed_json.md +9 -0
  42. data/doc/plugins/presign_endpoint.md +133 -0
  43. data/doc/plugins/pretty_location.md +29 -0
  44. data/doc/plugins/processing.md +68 -0
  45. data/doc/plugins/rack_file.md +49 -0
  46. data/doc/plugins/rack_response.md +96 -0
  47. data/doc/plugins/recache.md +27 -0
  48. data/doc/plugins/refresh_metadata.md +31 -0
  49. data/doc/plugins/remote_url.md +104 -0
  50. data/doc/plugins/remove_attachment.md +16 -0
  51. data/doc/plugins/remove_invalid.md +9 -0
  52. data/doc/plugins/restore_cached_data.md +14 -0
  53. data/doc/plugins/sequel.md +64 -0
  54. data/doc/plugins/signature.md +49 -0
  55. data/doc/plugins/store_dimensions.md +68 -0
  56. data/doc/plugins/tempfile.md +40 -0
  57. data/doc/plugins/upload_endpoint.md +123 -0
  58. data/doc/plugins/upload_options.md +28 -0
  59. data/doc/plugins/validation_helpers.md +129 -0
  60. data/doc/plugins/versions.md +179 -0
  61. data/doc/processing.md +217 -247
  62. data/doc/refile.md +3 -3
  63. data/doc/release_notes/1.0.0.md +143 -0
  64. data/doc/release_notes/1.1.0.md +184 -0
  65. data/doc/release_notes/1.2.0.md +37 -0
  66. data/doc/release_notes/1.3.0.md +90 -0
  67. data/doc/release_notes/1.4.0.md +167 -0
  68. data/doc/release_notes/1.4.1.md +9 -0
  69. data/doc/release_notes/1.4.2.md +20 -0
  70. data/doc/release_notes/2.0.0.md +173 -0
  71. data/doc/release_notes/2.0.1.md +12 -0
  72. data/doc/release_notes/2.1.0.md +59 -0
  73. data/doc/release_notes/2.1.1.md +8 -0
  74. data/doc/release_notes/2.10.0.md +52 -0
  75. data/doc/release_notes/2.10.1.md +6 -0
  76. data/doc/release_notes/2.11.0.md +69 -0
  77. data/doc/release_notes/2.12.0.md +65 -0
  78. data/doc/release_notes/2.13.0.md +146 -0
  79. data/doc/release_notes/2.14.0.md +278 -0
  80. data/doc/release_notes/2.15.0.md +82 -0
  81. data/doc/release_notes/2.2.0.md +98 -0
  82. data/doc/release_notes/2.3.0.md +50 -0
  83. data/doc/release_notes/2.3.1.md +10 -0
  84. data/doc/release_notes/2.4.0.md +87 -0
  85. data/doc/release_notes/2.4.1.md +29 -0
  86. data/doc/release_notes/2.5.0.md +130 -0
  87. data/doc/release_notes/2.6.0.md +254 -0
  88. data/doc/release_notes/2.6.1.md +14 -0
  89. data/doc/release_notes/2.7.0.md +180 -0
  90. data/doc/release_notes/2.8.0.md +95 -0
  91. data/doc/release_notes/2.9.0.md +82 -0
  92. data/doc/retrieving_uploads.md +1 -1
  93. data/doc/storage/file_system.md +96 -0
  94. data/doc/storage/s3.md +293 -0
  95. data/doc/validation.md +1 -1
  96. data/lib/shrine/plugins/_urlsafe_serialization.rb +33 -125
  97. data/lib/shrine/plugins/activerecord.rb +0 -78
  98. data/lib/shrine/plugins/add_metadata.rb +0 -80
  99. data/lib/shrine/plugins/backgrounding.rb +0 -134
  100. data/lib/shrine/plugins/backup.rb +0 -22
  101. data/lib/shrine/plugins/cached_attachment_data.rb +0 -15
  102. data/lib/shrine/plugins/copy.rb +0 -14
  103. data/lib/shrine/plugins/data_uri.rb +0 -73
  104. data/lib/shrine/plugins/default_storage.rb +0 -11
  105. data/lib/shrine/plugins/default_url.rb +0 -25
  106. data/lib/shrine/plugins/default_url_options.rb +0 -16
  107. data/lib/shrine/plugins/delete_promoted.rb +0 -6
  108. data/lib/shrine/plugins/delete_raw.rb +0 -10
  109. data/lib/shrine/plugins/derivation_endpoint.rb +652 -0
  110. data/lib/shrine/plugins/determine_mime_type.rb +1 -85
  111. data/lib/shrine/plugins/direct_upload.rb +0 -155
  112. data/lib/shrine/plugins/download_endpoint.rb +11 -73
  113. data/lib/shrine/plugins/dynamic_storage.rb +0 -17
  114. data/lib/shrine/plugins/hooks.rb +0 -48
  115. data/lib/shrine/plugins/included.rb +0 -12
  116. data/lib/shrine/plugins/infer_extension.rb +0 -49
  117. data/lib/shrine/plugins/keep_files.rb +0 -19
  118. data/lib/shrine/plugins/logging.rb +0 -39
  119. data/lib/shrine/plugins/metadata_attributes.rb +0 -35
  120. data/lib/shrine/plugins/migration_helpers.rb +0 -50
  121. data/lib/shrine/plugins/module_include.rb +0 -32
  122. data/lib/shrine/plugins/moving.rb +0 -12
  123. data/lib/shrine/plugins/multi_delete.rb +0 -13
  124. data/lib/shrine/plugins/parallelize.rb +0 -8
  125. data/lib/shrine/plugins/parsed_json.rb +0 -5
  126. data/lib/shrine/plugins/presign_endpoint.rb +2 -117
  127. data/lib/shrine/plugins/pretty_location.rb +0 -22
  128. data/lib/shrine/plugins/processing.rb +0 -55
  129. data/lib/shrine/plugins/rack_file.rb +0 -39
  130. data/lib/shrine/plugins/rack_response.rb +0 -81
  131. data/lib/shrine/plugins/recache.rb +0 -21
  132. data/lib/shrine/plugins/refresh_metadata.rb +0 -24
  133. data/lib/shrine/plugins/remote_url.rb +0 -85
  134. data/lib/shrine/plugins/remove_attachment.rb +0 -10
  135. data/lib/shrine/plugins/remove_invalid.rb +0 -6
  136. data/lib/shrine/plugins/restore_cached_data.rb +0 -10
  137. data/lib/shrine/plugins/sequel.rb +0 -54
  138. data/lib/shrine/plugins/signature.rb +0 -37
  139. data/lib/shrine/plugins/store_dimensions.rb +0 -63
  140. data/lib/shrine/plugins/tempfile.rb +4 -35
  141. data/lib/shrine/plugins/upload_endpoint.rb +2 -109
  142. data/lib/shrine/plugins/upload_options.rb +0 -20
  143. data/lib/shrine/plugins/validation_helpers.rb +0 -36
  144. data/lib/shrine/plugins/versions.rb +0 -156
  145. data/lib/shrine/storage/file_system.rb +0 -77
  146. data/lib/shrine/storage/s3.rb +0 -249
  147. data/lib/shrine/version.rb +1 -1
  148. data/shrine.gemspec +2 -2
  149. metadata +86 -6
@@ -2,28 +2,6 @@
2
2
 
3
3
  class Shrine
4
4
  module Plugins
5
- # The `backup` plugin allows you to automatically back up stored files to
6
- # an additional storage.
7
- #
8
- # storages[:backup_store] = Shrine::Storage::S3.new(options)
9
- # plugin :backup, storage: :backup_store
10
- #
11
- # After a file is stored, it will be reuploaded from store to the provided
12
- # backup storage.
13
- #
14
- # user.update(avatar: file) # uploaded both to :store and :backup_store
15
- #
16
- # By default whenever stored files are deleted backed up files are deleted
17
- # as well, but you can keep files on the "backup" storage by passing
18
- # `delete: false`:
19
- #
20
- # plugin :backup, storage: :backup_store, delete: false
21
- #
22
- # Note that when adding this plugin with already existing stored files,
23
- # Shrine won't know whether a stored file is backed up or not, so
24
- # attempting to delete the backup could result in an error. To avoid that
25
- # you can set `delete: false` until you manually back up the existing
26
- # stored files.
27
5
  module Backup
28
6
  def self.configure(uploader, opts = {})
29
7
  uploader.opts[:backup_storage] = opts.fetch(:storage, uploader.opts[:backup_storage])
@@ -2,21 +2,6 @@
2
2
 
3
3
  class Shrine
4
4
  module Plugins
5
- # The `cached_attachment_data` plugin adds the ability to retain the cached
6
- # file across form redisplays, which means the file doesn't have to be
7
- # reuploaded in case of validation errors.
8
- #
9
- # plugin :cached_attachment_data
10
- #
11
- # The plugin adds `#cached_<attachment>_data` to the model, which returns
12
- # the cached file as JSON, and should be used to set the value of the
13
- # hidden form field.
14
- #
15
- # @user.cached_avatar_data #=> '{"id":"38k25.jpg","storage":"cache","metadata":{...}}'
16
- #
17
- # This method delegates to `Attacher#read_cached`:
18
- #
19
- # attacher.read_cached #=> '{"id":"38k25.jpg","storage":"cache","metadata":{...}}'
20
5
  module CachedAttachmentData
21
6
  module AttachmentMethods
22
7
  def initialize(*)
@@ -2,20 +2,6 @@
2
2
 
3
3
  class Shrine
4
4
  module Plugins
5
- # The `copy` plugin allows copying attachment from one record to another.
6
- #
7
- # plugin :copy
8
- #
9
- # It adds a `Attacher#copy` method, which accepts another attacher, and
10
- # copies the attachment from it:
11
- #
12
- # photo.image_attacher.copy(other_photo.image_attacher)
13
- #
14
- # This method will automatically be called when the record is duplicated:
15
- #
16
- # duplicated_photo = photo.dup
17
- # duplicated_photo.image #=> #<Shrine::UploadedFile>
18
- # duplicated_photo.image != photo.image
19
5
  module Copy
20
6
  module AttachmentMethods
21
7
  def initialize(*)
@@ -8,79 +8,6 @@ require "forwardable"
8
8
 
9
9
  class Shrine
10
10
  module Plugins
11
- # The `data_uri` plugin enables you to upload files as [data URIs].
12
- # This plugin is useful for example when using [HTML5 Canvas].
13
- #
14
- # plugin :data_uri
15
- #
16
- # If your attachment is called "avatar", this plugin will add
17
- # `#avatar_data_uri` and `#avatar_data_uri=` methods to your model.
18
- #
19
- # user.avatar #=> nil
20
- # user.avatar_data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA"
21
- # user.avatar #=> #<Shrine::UploadedFile>
22
- #
23
- # user.avatar.mime_type #=> "image/png"
24
- # user.avatar.size #=> 43423
25
- #
26
- # You can also use `#data_uri=` and `#data_uri` methods directly on the
27
- # `Shrine::Attacher` (which the model methods just delegate to):
28
- #
29
- # attacher.data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA"
30
- #
31
- # If the data URI wasn't correctly parsed, an error message will be added to
32
- # the attachment column. You can change the default error message:
33
- #
34
- # plugin :data_uri, error_message: "data URI was invalid"
35
- # plugin :data_uri, error_message: ->(uri) { I18n.t("errors.data_uri_invalid") }
36
- #
37
- # ## File extension
38
- #
39
- # A data URI doesn't convey any information about the file extension, so
40
- # when attaching from a data URI, the uploaded file location will be
41
- # missing an extension. If you want the upload location to always have an
42
- # extension, you can load the `infer_extension` plugin to infer it from the
43
- # MIME type.
44
- #
45
- # plugin :infer_extension
46
- #
47
- # ## `Shrine.data_uri`
48
- #
49
- # If you just want to parse the data URI and create an IO object from it,
50
- # you can do that with `Shrine.data_uri`. If the data URI cannot be parsed,
51
- # a `Shrine::Plugins::DataUri::ParseError` will be raised.
52
- #
53
- # # or YourUploader.data_uri("...")
54
- # io = Shrine.data_uri("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA")
55
- # io.content_type #=> "image/png"
56
- # io.size #=> 21
57
- # io.read # decoded content
58
- #
59
- # When the content type is ommited, `text/plain` is assumed. The parser
60
- # also supports raw data URIs which aren't base64-encoded.
61
- #
62
- # # or YourUploader.data_uri("...")
63
- # io = Shrine.data_uri("data:,raw%20content")
64
- # io.content_type #=> "text/plain"
65
- # io.size #=> 11
66
- # io.read #=> "raw content"
67
- #
68
- # You can also assign a filename:
69
- #
70
- # io = Shrine.data_uri("data:,content", filename: "foo.txt")
71
- # io.original_filename #=> "foo.txt"
72
- #
73
- # ## `UploadedFile#data_uri` and `UploadedFile#base64`
74
- #
75
- # This plugin also adds UploadedFile#data_uri method, which returns a
76
- # base64-encoded data URI of the file content, and UploadedFile#base64,
77
- # which simply returns the file content base64-encoded.
78
- #
79
- # uploaded_file.data_uri #=> "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA"
80
- # uploaded_file.base64 #=> "iVBORw0KGgoAAAANSUhEUgAAAAUA"
81
- #
82
- # [data URIs]: https://tools.ietf.org/html/rfc2397
83
- # [HTML5 Canvas]: https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API
84
11
  module DataUri
85
12
  class ParseError < Error; end
86
13
 
@@ -2,17 +2,6 @@
2
2
 
3
3
  class Shrine
4
4
  module Plugins
5
- # The `default_storage` plugin enables you to change which storages are going
6
- # to be used for this uploader's attacher (the default is `:cache` and
7
- # `:store`).
8
- #
9
- # plugin :default_storage, cache: :special_cache, store: :special_store
10
- #
11
- # You can also pass a block and choose the values depending on the record
12
- # values and the name of the attachment. This is useful if you're using the
13
- # `dynamic_storage` plugin. Example:
14
- #
15
- # plugin :default_storage, store: ->(record, name) { :"store_#{record.username}" }
16
5
  module DefaultStorage
17
6
  def self.configure(uploader, opts = {})
18
7
  uploader.opts[:default_storage_cache] = opts.fetch(:cache, uploader.opts[:default_storage_cache])
@@ -2,31 +2,6 @@
2
2
 
3
3
  class Shrine
4
4
  module Plugins
5
- # The `default_url` plugin allows setting the URL which will be returned when
6
- # the attachment is missing.
7
- #
8
- # plugin :default_url
9
- #
10
- # Attacher.default_url do |options|
11
- # "/#{name}/missing.jpg"
12
- # end
13
- #
14
- # `Attacher#url` returns the default URL when attachment is missing. Any
15
- # passed in URL options will be present in the `options` hash.
16
- #
17
- # attacher.url #=> "/avatar/missing.jpg"
18
- # # or
19
- # user.avatar_url #=> "/avatar/missing.jpg"
20
- #
21
- # The default URL block is evaluated in the context of an instance of
22
- # `Shrine::Attacher`.
23
- #
24
- # Attacher.default_url do |options|
25
- # self #=> #<Shrine::Attacher>
26
- #
27
- # name #=> :avatar
28
- # record #=> #<User>
29
- # end
30
5
  module DefaultUrl
31
6
  def self.configure(uploader, &block)
32
7
  if block
@@ -2,22 +2,6 @@
2
2
 
3
3
  class Shrine
4
4
  module Plugins
5
- # The `default_url_options` plugin allows you to specify URL options that
6
- # will be applied by default for uploaded files of specified storages.
7
- #
8
- # plugin :default_url_options, store: { download: true }
9
- #
10
- # You can also generate the default URL options dynamically by using a
11
- # block, which will receive the UploadedFile object along with any options
12
- # that were passed to `UploadedFile#url`.
13
- #
14
- # plugin :default_url_options, store: -> (io, **options) do
15
- # { response_content_disposition: ContentDisposition.attachment(io.original_filename) }
16
- # end
17
- #
18
- # In both cases the default options are merged with options passed to
19
- # `UploadedFile#url`, and the latter will always have precedence over
20
- # default options.
21
5
  module DefaultUrlOptions
22
6
  def self.configure(uploader, options = {})
23
7
  uploader.opts[:default_url_options] ||= {}
@@ -2,12 +2,6 @@
2
2
 
3
3
  class Shrine
4
4
  module Plugins
5
- # The `delete_promoted` plugin deletes files that have been promoted, after
6
- # the record is saved. This means that cached files handled by the attacher
7
- # will automatically get deleted once they're uploaded to store. This also
8
- # applies to any other uploaded file passed to `Attacher#promote`.
9
- #
10
- # plugin :delete_promoted
11
5
  module DeletePromoted
12
6
  module AttacherMethods
13
7
  def promote(uploaded_file = get, **options)
@@ -2,16 +2,6 @@
2
2
 
3
3
  class Shrine
4
4
  module Plugins
5
- # The `delete_raw` plugin will automatically delete raw files that have been
6
- # uploaded. This is especially useful when doing processing, to ensure that
7
- # temporary files have been deleted after upload.
8
- #
9
- # plugin :delete_raw
10
- #
11
- # By default any raw file that was uploaded will be deleted, but you can
12
- # limit this only to files uploaded to certain storages:
13
- #
14
- # plugin :delete_raw, storages: [:store]
15
5
  module DeleteRaw
16
6
  def self.configure(uploader, opts = {})
17
7
  uploader.opts[:delete_raw_storages] = opts.fetch(:storages, uploader.opts[:delete_raw_storages])
@@ -0,0 +1,652 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "content_disposition"
5
+
6
+ require "openssl"
7
+ require "tempfile"
8
+
9
+ class Shrine
10
+ module Plugins
11
+ module DerivationEndpoint
12
+ def self.load_dependencies(uploader, opts = {})
13
+ uploader.plugin :rack_response
14
+ uploader.plugin :_urlsafe_serialization
15
+ end
16
+
17
+ def self.configure(uploader, opts = {})
18
+ uploader.opts[:derivation_endpoint_options] ||= {}
19
+ uploader.opts[:derivation_endpoint_options].merge!(opts)
20
+
21
+ uploader.opts[:derivation_endpoint_derivations] ||= {}
22
+
23
+ unless uploader.opts[:derivation_endpoint_options][:secret_key]
24
+ fail Error, "must provide :secret_key option to derivation_endpoint plugin"
25
+ end
26
+ end
27
+
28
+ module ClassMethods
29
+ def derivation_endpoint(**options)
30
+ Shrine::DerivationEndpoint.new(shrine_class: self, options: options)
31
+ end
32
+
33
+ def derivation_response(env, **options)
34
+ script_name = env["SCRIPT_NAME"]
35
+ path_info = env["PATH_INFO"]
36
+
37
+ prefix = derivation_options[:prefix]
38
+ match = path_info.match(/^\/#{prefix}/)
39
+
40
+ fail Error, "request path must start with \"/#{prefix}\", but is \"#{path_info}\"" unless match
41
+
42
+ begin
43
+ env["SCRIPT_NAME"] += match.to_s
44
+ env["PATH_INFO"] = match.post_match
45
+
46
+ derivation_endpoint(**options).call(env)
47
+ ensure
48
+ env["SCRIPT_NAME"] = script_name
49
+ env["PATH_INFO"] = path_info
50
+ end
51
+ end
52
+
53
+ def derivation(name, &block)
54
+ derivations[name] = block
55
+ end
56
+
57
+ def derivations
58
+ opts[:derivation_endpoint_derivations]
59
+ end
60
+
61
+ def derivation_options
62
+ opts[:derivation_endpoint_options]
63
+ end
64
+ end
65
+
66
+ module FileMethods
67
+ def derivation_url(name, *args, **options)
68
+ derivation(name, *args).url(**options)
69
+ end
70
+
71
+ def derivation_response(name, *args, env:, **options)
72
+ derivation(name, *args, **options).response(env)
73
+ end
74
+
75
+ def derivation(name, *args, **options)
76
+ Shrine::Derivation.new(
77
+ name: name,
78
+ args: args,
79
+ source: self,
80
+ options: options,
81
+ )
82
+ end
83
+ end
84
+ end
85
+
86
+ register_plugin(:derivation_endpoint, DerivationEndpoint)
87
+ end
88
+
89
+ class Derivation
90
+ class NotFound < Error; end
91
+ class SourceNotFound < Error; end
92
+
93
+ attr_reader :name, :args, :source, :options
94
+
95
+ def initialize(name:, args:, source:, options:)
96
+ @name = name
97
+ @args = args
98
+ @source = source
99
+ @options = options
100
+ end
101
+
102
+ def url(**options)
103
+ Derivation::Url.new(self).call(
104
+ host: option(:host),
105
+ prefix: option(:prefix),
106
+ expires_in: option(:expires_in),
107
+ version: option(:version),
108
+ metadata: option(:metadata),
109
+ **options,
110
+ )
111
+ end
112
+
113
+ def response(env)
114
+ Derivation::Response.new(self).call(env)
115
+ end
116
+
117
+ def processed
118
+ Derivation::Processed.new(self).call
119
+ end
120
+
121
+ def generate(file = nil)
122
+ Derivation::Generate.new(self).call(file)
123
+ end
124
+
125
+ def upload(file = nil)
126
+ Derivation::Upload.new(self).call(file)
127
+ end
128
+
129
+ def retrieve
130
+ Derivation::Retrieve.new(self).call
131
+ end
132
+
133
+ def delete
134
+ Derivation::Delete.new(self).call
135
+ end
136
+
137
+ def self.options
138
+ @options ||= {}
139
+ end
140
+
141
+ def self.option(name, default: nil, result: nil)
142
+ options[name] = { default: default, result: result }
143
+ end
144
+
145
+ option :cache_control, default: -> { default_cache_control }
146
+ option :disposition, default: -> { "inline" }
147
+ option :download, default: -> { true }
148
+ option :download_errors, default: -> { [] }
149
+ option :download_options, default: -> { {} }
150
+ option :expires_in
151
+ option :filename, default: -> { default_filename }
152
+ option :host
153
+ option :include_uploaded_file, default: -> { false }
154
+ option :metadata, default: -> { [] }
155
+ option :prefix
156
+ option :secret_key
157
+ option :type
158
+ option :upload, default: -> { false }
159
+ option :upload_location, default: -> { default_upload_location }, result: -> (o) { upload_location(o) }
160
+ option :upload_options, default: -> { {} }
161
+ option :upload_redirect, default: -> { false }
162
+ option :upload_redirect_url_options, default: -> { {} }
163
+ option :upload_storage, default: -> { source.storage_key.to_sym }
164
+ option :version
165
+
166
+ def option(name)
167
+ option_definition = self.class.options.fetch(name)
168
+
169
+ value = options.fetch(name) { shrine_class.derivation_options[name] }
170
+ value = instance_exec(&value) if value.is_a?(Proc)
171
+
172
+ if value.nil?
173
+ default = option_definition[:default]
174
+ value = instance_exec(&default) if default
175
+ end
176
+
177
+ result = option_definition[:result]
178
+ value = instance_exec(value, &result) if result
179
+
180
+ value
181
+ end
182
+
183
+ def shrine_class
184
+ source.shrine_class
185
+ end
186
+
187
+ private
188
+
189
+ # append version and extension to upload location if specified
190
+ def upload_location(location)
191
+ location = location.sub(/(?=(\.\w+)?$)/, "-#{option(:version)}") if option(:version)
192
+ location
193
+ end
194
+
195
+ def default_filename
196
+ [name, *args, File.basename(source.id, ".*")].join("-")
197
+ end
198
+
199
+ def default_upload_location
200
+ directory = source.id.sub(/\.[^\/]+/, "")
201
+ filename = [name, *args].join("-")
202
+
203
+ [directory, filename].join("/")
204
+ end
205
+
206
+ def default_cache_control
207
+ if option(:expires_in)
208
+ "public, max-age=#{option(:expires_in)}"
209
+ else
210
+ "public, max-age=#{365*24*60*60}"
211
+ end
212
+ end
213
+
214
+ class Command
215
+ attr_reader :derivation
216
+
217
+ def initialize(derivation)
218
+ @derivation = derivation
219
+ end
220
+
221
+ def self.delegate(*names)
222
+ names.each do |name|
223
+ protected define_method(name) {
224
+ if [:name, :args, :source].include?(name)
225
+ derivation.public_send(name)
226
+ else
227
+ derivation.option(name)
228
+ end
229
+ }
230
+ end
231
+ end
232
+
233
+ private
234
+
235
+ def shrine_class
236
+ derivation.shrine_class
237
+ end
238
+ end
239
+ end
240
+
241
+ class Derivation::Url < Derivation::Command
242
+ delegate :name, :args, :source, :secret_key
243
+
244
+ def call(host: nil, prefix: nil, **options)
245
+ [host, *prefix, identifier(**options)].join("/")
246
+ end
247
+
248
+ private
249
+
250
+ def identifier(expires_in: nil,
251
+ version: nil,
252
+ type: nil,
253
+ filename: nil,
254
+ disposition: nil,
255
+ metadata: [])
256
+
257
+ params = {}
258
+ params[:expires_at] = (Time.now.utc + expires_in).to_i if expires_in
259
+ params[:version] = version if version
260
+ params[:type] = type if type
261
+ params[:filename] = filename if filename
262
+ params[:disposition] = disposition if disposition
263
+
264
+ source_component = source.urlsafe_dump(metadata: metadata)
265
+
266
+ signed_url(name, *args, source_component, params)
267
+ end
268
+
269
+ def signed_url(*components)
270
+ signer = UrlSigner.new(secret_key)
271
+ signer.signed_url(*components)
272
+ end
273
+ end
274
+
275
+ class DerivationEndpoint
276
+ attr_reader :shrine_class, :options
277
+
278
+ def initialize(shrine_class:, options: {})
279
+ @shrine_class = shrine_class
280
+ @options = options
281
+ end
282
+
283
+ def call(env)
284
+ request = Rack::Request.new(env)
285
+
286
+ status, headers, body = catch(:halt) do
287
+ error!(405, "Method not allowed") unless request.get? || request.head?
288
+
289
+ handle_request(request)
290
+ end
291
+
292
+ headers["Content-Length"] ||= body.map(&:bytesize).inject(0, :+).to_s
293
+
294
+ [status, headers, body]
295
+ end
296
+
297
+ def handle_request(request)
298
+ verify_signature!(request)
299
+ check_expiry!(request)
300
+
301
+ name, *args, serialized_file = request.path_info.split("/")[1..-1]
302
+
303
+ name = name.to_sym
304
+ uploaded_file = shrine_class::UploadedFile.urlsafe_load(serialized_file)
305
+
306
+ # request params override statically configured options
307
+ options = self.options.dup
308
+
309
+ options[:type] = request.params["type"] if request.params["type"]
310
+ options[:disposition] = request.params["disposition"] if request.params["disposition"]
311
+ options[:filename] = request.params["filename"] if request.params["filename"]
312
+
313
+ options[:expires_in] = expires_in(request) if request.params["expires_at"]
314
+
315
+ derivation = uploaded_file.derivation(name, *args, **options)
316
+
317
+ begin
318
+ status, headers, body = derivation.response(request.env)
319
+ rescue Derivation::NotFound
320
+ error!(404, "Unknown derivation \"#{name}\"")
321
+ rescue Derivation::SourceNotFound
322
+ error!(404, "Source file not found")
323
+ end
324
+
325
+ if status == 200 || status == 206
326
+ headers["Cache-Control"] = derivation.option(:cache_control)
327
+ end
328
+
329
+ [status, headers, body]
330
+ end
331
+
332
+ private
333
+
334
+ def verify_signature!(request)
335
+ signer = UrlSigner.new(secret_key)
336
+ signer.verify_url("#{request.path_info[1..-1]}?#{request.query_string}")
337
+ rescue UrlSigner::InvalidSignature => error
338
+ error!(403, error.message.capitalize)
339
+ end
340
+
341
+ def check_expiry!(request)
342
+ if request.params["expires_at"]
343
+ error!(403, "Request has expired") if expires_in(request) <= 0
344
+ end
345
+ end
346
+
347
+ def expires_in(request)
348
+ expires_at = Integer(request.params["expires_at"])
349
+
350
+ (Time.at(expires_at) - Time.now).to_i
351
+ end
352
+
353
+ # Halts the request with the error message.
354
+ def error!(status, message)
355
+ throw :halt, [status, { "Content-Type" => "text/plain" }, [message]]
356
+ end
357
+
358
+ def secret_key
359
+ derivation_options[:secret_key]
360
+ end
361
+
362
+ def derivation_options
363
+ shrine_class.derivation_options.merge(self.options)
364
+ end
365
+ end
366
+
367
+ class Derivation::Response < Derivation::Command
368
+ delegate :type, :disposition, :filename,
369
+ :upload, :upload_redirect, :upload_redirect_url_options
370
+
371
+ def call(env)
372
+ if upload
373
+ upload_response(env)
374
+ else
375
+ local_response(env)
376
+ end
377
+ end
378
+
379
+ private
380
+
381
+ def local_response(env)
382
+ derivative = derivation.generate
383
+
384
+ file_response(derivative, env)
385
+ end
386
+
387
+ def file_response(file, env)
388
+ file.close
389
+ response = rack_file_response(file.path, env)
390
+
391
+ status = response[0]
392
+
393
+ filename = self.filename
394
+ filename += File.extname(file.path) if File.extname(filename).empty?
395
+
396
+ headers = {}
397
+ headers["Content-Type"] = type || response[1]["Content-Type"]
398
+ headers["Content-Disposition"] = content_disposition(filename)
399
+ headers["Content-Length"] = response[1]["Content-Length"]
400
+ headers["Content-Range"] = response[1]["Content-Range"] if response[1]["Content-Range"]
401
+ headers["Accept-Ranges"] = "bytes"
402
+
403
+ body = Rack::BodyProxy.new(response[2]) { File.delete(file.path) }
404
+
405
+ [status, headers, body]
406
+ end
407
+
408
+ def upload_response(env)
409
+ uploaded_file = derivation.retrieve
410
+
411
+ unless uploaded_file
412
+ derivative = derivation.generate
413
+ uploaded_file = derivation.upload(derivative)
414
+ end
415
+
416
+ if upload_redirect
417
+ File.delete(derivative.path) if derivative
418
+
419
+ redirect_url = uploaded_file.url(upload_redirect_url_options)
420
+
421
+ [302, { "Location" => redirect_url }, []]
422
+ else
423
+ if derivative
424
+ file_response(derivative, env)
425
+ else
426
+ uploaded_file.to_rack_response(
427
+ type: type,
428
+ disposition: disposition,
429
+ filename: filename,
430
+ range: env["HTTP_RANGE"],
431
+ )
432
+ end
433
+ end
434
+ end
435
+
436
+ def rack_file_response(path, env)
437
+ server = Rack::File.new("", {}, "application/octet-stream")
438
+
439
+ if Rack.release > "2"
440
+ server.serving(Rack::Request.new(env), path)
441
+ else
442
+ server = server.dup
443
+ server.path = path
444
+ server.serving(env)
445
+ end
446
+ end
447
+
448
+ def content_disposition(filename)
449
+ ContentDisposition.format(disposition: disposition, filename: filename)
450
+ end
451
+ end
452
+
453
+ class Derivation::Processed < Derivation::Command
454
+ delegate :upload
455
+
456
+ def call
457
+ if upload
458
+ upload_result
459
+ else
460
+ local_result
461
+ end
462
+ end
463
+
464
+ private
465
+
466
+ def local_result
467
+ derivation.generate
468
+ end
469
+
470
+ def upload_result
471
+ uploaded_file = derivation.retrieve
472
+
473
+ unless uploaded_file
474
+ derivative = derivation.generate
475
+ uploaded_file = derivation.upload(derivative)
476
+
477
+ derivative.unlink
478
+ end
479
+
480
+ uploaded_file
481
+ end
482
+ end
483
+
484
+ class Derivation::Generate < Derivation::Command
485
+ delegate :name, :args, :source,
486
+ :download, :download_errors, :download_options,
487
+ :include_uploaded_file
488
+
489
+ def call(file = nil)
490
+ derivative = generate(file)
491
+ derivative = normalize(derivative)
492
+ derivative
493
+ end
494
+
495
+ private
496
+
497
+ def generate(file)
498
+ if download
499
+ with_downloaded(file) do |file|
500
+ if include_uploaded_file
501
+ uploader.instance_exec(file, source, *args, &derivation_block)
502
+ else
503
+ uploader.instance_exec(file, *args, &derivation_block)
504
+ end
505
+ end
506
+ else
507
+ uploader.instance_exec(source, *args, &derivation_block)
508
+ end
509
+ end
510
+
511
+ def normalize(derivative)
512
+ if derivative.is_a?(Tempfile)
513
+ derivative.open
514
+ elsif derivative.is_a?(File)
515
+ derivative.close
516
+ derivative = File.open(derivative.path)
517
+ elsif derivative.is_a?(String)
518
+ derivative = File.open(derivative)
519
+ elsif defined?(Pathname) && derivative.is_a?(Pathname)
520
+ derivative = derivative.open
521
+ else
522
+ fail Error, "unexpected derivation result: #{derivation.inspect} (expected File, Tempfile, String, or Pathname object)"
523
+ end
524
+
525
+ derivative.binmode
526
+ derivative
527
+ end
528
+
529
+ def with_downloaded(file, &block)
530
+ if file
531
+ yield file
532
+ else
533
+ download_source(&block)
534
+ end
535
+ end
536
+
537
+ def download_source
538
+ download_args = download_options.any? ? [download_options] : []
539
+ downloaded = false
540
+
541
+ source.download(*download_args) do |file|
542
+ downloaded = true
543
+ yield file
544
+ end
545
+ rescue *download_errors
546
+ raise if downloaded # re-raise if the error didn't happen on download
547
+ raise Derivation::SourceNotFound, "source file \"#{source.id}\" was not found on storage :#{source.storage_key}"
548
+ end
549
+
550
+ def derivation_block
551
+ shrine_class.derivations[name] or fail Derivation::NotFound, "derivation #{name.inspect} is not defined"
552
+ end
553
+
554
+ def uploader
555
+ source.uploader
556
+ end
557
+ end
558
+
559
+ class Derivation::Upload < Derivation::Command
560
+ delegate :upload_location, :upload_storage, :upload_options
561
+
562
+ def call(derivative = nil)
563
+ derivative ||= derivation.generate
564
+
565
+ uploader.upload derivative,
566
+ location: upload_location,
567
+ upload_options: upload_options
568
+ end
569
+
570
+ private
571
+
572
+ def uploader
573
+ shrine_class.new(upload_storage)
574
+ end
575
+ end
576
+
577
+ class Derivation::Retrieve < Derivation::Command
578
+ delegate :upload_location, :upload_storage
579
+
580
+ def call
581
+ if storage.exists?(upload_location)
582
+ shrine_class::UploadedFile.new(
583
+ "storage" => upload_storage.to_s,
584
+ "id" => upload_location,
585
+ )
586
+ end
587
+ end
588
+
589
+ private
590
+
591
+ def storage
592
+ shrine_class.find_storage(upload_storage)
593
+ end
594
+ end
595
+
596
+ class Derivation::Delete < Derivation::Command
597
+ delegate :upload_location, :upload_storage
598
+
599
+ def call
600
+ storage.delete(upload_location)
601
+ end
602
+
603
+ private
604
+
605
+ def storage
606
+ shrine_class.find_storage(upload_storage)
607
+ end
608
+ end
609
+
610
+ class UrlSigner
611
+ class InvalidSignature < Error; end
612
+
613
+ attr_reader :secret_key
614
+
615
+ def initialize(secret_key)
616
+ @secret_key = secret_key
617
+ end
618
+
619
+ def signed_url(*components, params)
620
+ path = Rack::Utils.escape_path(components.join("/"))
621
+ query = Rack::Utils.build_query(params)
622
+
623
+ signature = generate_signature("#{path}?#{query}")
624
+
625
+ query = Rack::Utils.build_query(params.merge(signature: signature))
626
+
627
+ "#{path}?#{query}"
628
+ end
629
+
630
+ def verify_url(path_with_query)
631
+ path, query = path_with_query.split("?")
632
+
633
+ params = Rack::Utils.parse_query(query.to_s)
634
+ signature = params.delete("signature")
635
+ query = Rack::Utils.build_query(params)
636
+
637
+ verify_signature("#{path}?#{query}", signature)
638
+ end
639
+
640
+ def verify_signature(string, signature)
641
+ if signature.nil?
642
+ fail InvalidSignature, "missing \"signature\" param"
643
+ elsif signature != generate_signature(string)
644
+ fail InvalidSignature, "provided signature does not match the calculated signature"
645
+ end
646
+ end
647
+
648
+ def generate_signature(string)
649
+ OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, secret_key, string)
650
+ end
651
+ end
652
+ end