shrine-transloadit 0.5.1 → 1.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.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "transloadit"
2
4
 
3
5
  require "uri"
@@ -7,405 +9,244 @@ require "openssl"
7
9
  class Shrine
8
10
  module Plugins
9
11
  module Transloadit
10
- class Error < Shrine::Error; end
12
+ # Transloadit's default destination path for export robots.
13
+ DEFAULT_PATH = "${unique_prefix}/${file.url_name}"
11
14
 
12
- class ResponseError < Error
13
- attr_reader :response
15
+ class Error < Shrine::Error
16
+ end
14
17
 
15
- def initialize(response)
16
- @response = response
17
- super("#{response["error"]}: #{response["reason"] || response["message"]}")
18
- end
18
+ class InvalidSignature < Error
19
19
  end
20
20
 
21
- # Accepts Transloadit credentials via `:auth_key` and `:auth_secret`.
22
- #
23
- # If :cache storage wasn't assigned, it will be assigned to a URL storage
24
- # for direct uploads.
25
- #
26
- # If promoting was not yet overriden, it is set to automatically trigger
27
- # Transloadit processing defined in `Shrine#transloadit_process`.
28
- def self.configure(uploader, opts = {})
29
- uploader.opts[:transloadit_auth_key] = opts.fetch(:auth_key, uploader.opts[:transloadit_auth_key])
30
- uploader.opts[:transloadit_auth_secret] = opts.fetch(:auth_secret, uploader.opts[:transloadit_auth_secret])
31
-
32
- raise Error, "The :auth_key is required for transloadit plugin" if uploader.opts[:transloadit_auth_key].nil?
33
- raise Error, "The :auth_secret is required for transloadit plugin" if uploader.opts[:transloadit_auth_secret].nil?
34
-
35
- uploader.opts[:backgrounding_promote] ||= proc { transloadit_process }
21
+ LOG_SUBSCRIBER = -> (event) do
22
+ Shrine.logger.info "Transloadit (#{event.duration}ms) – #{{
23
+ processor: event[:processor],
24
+ uploader: event[:uploader],
25
+ }.inspect}"
36
26
  end
37
27
 
38
- # It loads the backgrounding plugin, so that it can override promoting.
39
- def self.load_dependencies(uploader, opts = {})
40
- uploader.plugin :backgrounding
28
+ # Accepts Transloadit credentials via `:auth_key` and `:auth_secret`.
29
+ def self.configure(uploader, log_subscriber: LOG_SUBSCRIBER, **opts)
30
+ uploader.opts[:transloadit] ||= { processors: {}, savers: {}, credentials: {} }
31
+ uploader.opts[:transloadit].merge!(opts)
32
+
33
+ fail Error, "The :auth option is required" unless uploader.opts[:transloadit][:auth]
34
+
35
+ # instrumentation plugin integration
36
+ uploader.subscribe(:transloadit, &log_subscriber) if uploader.respond_to?(:subscribe)
41
37
  end
42
38
 
43
39
  module AttacherClassMethods
44
- # Loads the attacher from the data, and triggers Transloadit
45
- # processing. Intended to be used in a background job.
46
- def transloadit_process(data)
47
- attacher = self.load(data)
48
- cached_file = attacher.uploaded_file(data["attachment"])
49
- attacher.transloadit_process(cached_file)
50
- attacher
40
+ def transloadit_processor(name, &block)
41
+ if block
42
+ shrine_class.opts[:transloadit][:processors][name.to_sym] = block
43
+ else
44
+ shrine_class.opts[:transloadit][:processors][name.to_sym] or
45
+ fail Error, "transloadit processor #{name.inspect} not registered"
46
+ end
51
47
  end
52
48
 
53
- # This should be called when receiving a webhook, where arguments are
54
- # params that Transloadit POSTed to the endpoint. It checks the
55
- # signature, loads the attacher, saves processing results to the record.
56
- def transloadit_save(params)
57
- shrine_class.verify_transloadit_signature!(params)
58
- response = JSON.parse(params["transloadit"])
59
- data = response["fields"]["attacher"]
60
- attacher = self.load(data)
61
- cached_file = attacher.uploaded_file(data["attachment"])
62
- attacher.transloadit_save(response, valid: attacher.get == cached_file)
63
- attacher
49
+ def transloadit_saver(name, &block)
50
+ if block
51
+ shrine_class.opts[:transloadit][:savers][name.to_sym] = block
52
+ else
53
+ shrine_class.opts[:transloadit][:savers][name.to_sym] or
54
+ fail Error, "transloadit saver #{name.inspect} not registered"
55
+ end
64
56
  end
65
57
  end
66
58
 
67
59
  module AttacherMethods
68
- # Triggers Transloadit processing defined by the user in
69
- # `Shrine#transloadit_process`. It dumps the attacher in the payload of
70
- # the request, so that it's included in the webhook and that we know
71
- # which webhook belongs to which record/attachment.
72
- #
73
- # After the Transloadit assembly was submitted, the response is saved
74
- # into cached file's metadata, which can then be reloaded at will for
75
- # checking progress of the assembly.
76
- #
77
- # Raises a `Shrine::Plugins::Transloadit::ResponseError` if Transloadit returned an error.
78
- def transloadit_process(cached_file = get)
79
- assembly = store.transloadit_process(cached_file, context)
80
- assembly.options[:fields] ||= {}
81
- assembly.options[:fields]["attacher"] = self.dump.merge("attachment" => cached_file.to_json)
82
- response = assembly.create!
83
- raise ResponseError.new(response.body) if response["error"]
84
- cached_file.metadata["transloadit_response"] = response.body.to_json
85
- swap(cached_file) or _set(cached_file)
60
+ def transloadit_process(name, *args)
61
+ processor = self.class.transloadit_processor(name)
62
+ instrument_transloadit(name) do
63
+ instance_exec(*args, &processor)
64
+ end
86
65
  end
87
66
 
88
- # It receives the result of Transloadit processing, and converts it into
89
- # Shrine's representation(s) of an uploaded file (either as a single
90
- # file or a hash of versions).
91
- #
92
- # If attachment has changed in the meanwhile, meaning the result of
93
- # this processing is no longer valid, it deletes the processed files
94
- # from the main storage.
95
- #
96
- # Raises a `Shrine::Plugins::Transloadit::ResponseError` if Transloadit returned an error.
97
- def transloadit_save(response, valid: true)
98
- raise ResponseError.new(response) if response["error"]
99
-
100
- if versions = response["fields"]["versions"]
101
- stored_file = versions.inject({}) do |hash, (name, step_name)|
102
- results = response["results"].fetch(step_name) { [] }
103
- uploaded_files = results.map { |result| store.transloadit_uploaded_file(result) }
104
- multiple = response["fields"]["multiple"].to_h[name]
105
-
106
- if multiple == "list"
107
- hash.merge!(name => uploaded_files)
108
- elsif uploaded_files.one?
109
- hash.merge!(name => uploaded_files[0])
110
- elsif uploaded_files.empty?
111
- hash
112
- else
113
- raise Error, "Step produced multiple files but wasn't marked as multiple"
114
- end
115
- end
116
- else
117
- results = response["results"].values.last
118
- uploaded_files = results.map { |result| store.transloadit_uploaded_file(result) }
119
- multiple = response["fields"]["multiple"]
120
-
121
- if multiple == "list"
122
- stored_file = uploaded_files
123
- elsif uploaded_files.one?
124
- stored_file = uploaded_files[0]
125
- else
126
- raise Error, "Step produced multiple files but wasn't marked as multiple"
127
- end
128
- end
67
+ def transloadit_save(name, *args)
68
+ saver = self.class.transloadit_saver(name)
69
+ instance_exec(*args, &saver)
70
+ end
129
71
 
130
- if valid
131
- swap(stored_file)
132
- else
133
- _delete(stored_file, action: :abort)
134
- end
72
+ def transloadit_step(*args)
73
+ shrine_class.transloadit_step(*args)
135
74
  end
136
- end
137
75
 
138
- module ClassMethods
139
- # Creates a new Transloadit client, so that the expiration timestamp is
140
- # refreshed on new processing requests.
141
76
  def transloadit
142
- ::Transloadit.new(
143
- key: opts[:transloadit_auth_key],
144
- secret: opts[:transloadit_auth_secret],
145
- )
77
+ shrine_class.transloadit
146
78
  end
147
79
 
148
- # Checks if the webhook has indeed been triggered by Transloadit, by
149
- # checking if sent signature matches the calculated signature, and
150
- # raising a `Shrine::Plugins::Transloadit::Error` if signatures don't
151
- # match.
152
- def verify_transloadit_signature!(params)
153
- sent_signature = params["signature"]
154
- payload = params["transloadit"]
155
- algorithm = OpenSSL::Digest.new('sha1')
156
- secret = opts[:transloadit_auth_secret]
157
- calculated_signature = OpenSSL::HMAC.hexdigest(algorithm, secret, payload)
158
- raise Error, "Received signature doesn't match the calculated signature" if calculated_signature != sent_signature
80
+ private
81
+
82
+ def instrument_transloadit(processor, &block)
83
+ return yield unless shrine_class.respond_to?(:instrument)
84
+
85
+ shrine_class.instrument(:transloadit, processor: processor, &block)
159
86
  end
160
87
  end
161
88
 
162
- module InstanceMethods
163
- # Converts Transloadit's representation of an uploaded file into
164
- # Shrine's representation. It currently only accepts files exported to
165
- # S3. All Transloadit's metadata is saved into a "transloadit"
166
- # attribute.
167
- #
168
- # When doing direct uploads to Transloadit you will only get a
169
- # temporary URL, which will be saved in the "id" attribute and it's
170
- # expected that the URL storage is used.
171
- def transloadit_uploaded_file(result)
172
- case url = result.fetch("url")
173
- when /amazonaws\.com/
174
- raise Error, "Cannot save a processed file which wasn't exported: #{url.inspect}" if url.include?("tmp.transloadit.com")
175
- path = URI(url).path
176
- id = path.match(%r{^(/#{storage.prefix})?/}).post_match
89
+ module ClassMethods
90
+ def transloadit_step(name, robot, use: nil, **options)
91
+ if Array(use).first.is_a?(::Transloadit::Step)
92
+ step = transloadit.step(name, robot, **options)
93
+ step.use(use) if use
94
+ step
177
95
  else
178
- raise Error, "The transloadit Shrine plugin doesn't support storage identified by #{url.inspect}"
96
+ transloadit.step(name, robot, use: use, **options)
179
97
  end
180
-
181
- self.class::UploadedFile.new(
182
- "id" => id,
183
- "storage" => storage_key.to_s,
184
- "metadata" => {
185
- "filename" => result.fetch("name"),
186
- "size" => result.fetch("size"),
187
- "mime_type" => result.fetch("mime"),
188
- "width" => (result["meta"] && result["meta"]["width"]),
189
- "height" => (result["meta"] && result["meta"]["height"]),
190
- "transloadit" => result["meta"],
191
- }
192
- )
193
98
  end
194
99
 
195
- # Generates a Transloadit import step from the Shrine::UploadedFile.
196
- # If it's from the S3 storage, an S3 import step will be generated.
197
- # Otherwise either a generic HTTP(S) or an FTP import will be generated.
198
- def transloadit_import_step(name, io, **step_options)
199
- uri = URI.parse(io.url)
200
-
201
- if defined?(Storage::S3) && io.storage.is_a?(Storage::S3)
202
- step = transloadit.step(name, "/s3/import",
203
- key: io.storage.client.config.access_key_id,
204
- secret: io.storage.client.config.secret_access_key,
205
- bucket: io.storage.bucket.name,
206
- bucket_region: io.storage.client.config.region,
207
- path: [*io.storage.prefix, io.id].join("/"),
208
- )
209
- elsif uri.scheme == "http" || uri.scheme == "https"
210
- step = transloadit.step(name, "/http/import",
211
- url: uri.to_s,
212
- )
213
- elsif uri.scheme == "ftp"
214
- step = transloadit.step(name, "/ftp/import",
215
- host: uri.host,
216
- user: uri.user,
217
- password: uri.password,
218
- path: uri.path,
219
- )
220
- else
221
- raise Error, "Cannot construct a transloadit import step from #{io.inspect}"
100
+ # Verifies the Transloadit signature of a webhook request. Raises
101
+ # `Shrine::Plugins::Transloadit::InvalidSignature` if signatures
102
+ # don't match.
103
+ def transloadit_verify!(params)
104
+ if transloadit_sign(params["transloadit"]) != params["signature"]
105
+ raise InvalidSignature, "received signature doesn't match calculated"
222
106
  end
107
+ end
223
108
 
224
- step.options.update(step_options)
109
+ # Creates a new Transloadit client each time. This way the expiration
110
+ # timestamp is refreshed on new processing requests.
111
+ def transloadit
112
+ ::Transloadit.new(**opts[:transloadit][:auth])
113
+ end
225
114
 
226
- step
115
+ def transloadit_credentials(storage_key)
116
+ opts[:transloadit][:credentials][storage_key] or
117
+ fail Error, "credentials not registered for storage #{storage_key.inspect}"
227
118
  end
228
119
 
229
- # Generates an export step from the current (permanent) storage.
230
- # At the moment only Amazon S3 is supported.
231
- def transloadit_export_step(name, path: nil, **step_options)
232
- if defined?(Storage::S3) && storage.is_a?(Storage::S3)
233
- path ||= "${unique_prefix}/${file.url_name}" # Transloadit's default path
234
-
235
- step = transloadit.step(name, "/s3/store",
236
- key: storage.client.config.access_key_id,
237
- secret: storage.client.config.secret_access_key,
238
- bucket: storage.bucket.name,
239
- bucket_region: storage.client.config.region,
240
- path: [*storage.prefix, path].join("/"),
241
- )
242
- else
243
- raise Error, "Cannot construct a transloadit export step from #{storage.inspect}"
244
- end
120
+ private
245
121
 
246
- step.options.update(step_options)
122
+ # Signs given string with Transloadit secret key.
123
+ def transloadit_sign(string)
124
+ algorithm = OpenSSL::Digest::SHA1.new
125
+ secret_key = opts[:transloadit][:auth][:secret]
247
126
 
248
- step
127
+ OpenSSL::HMAC.hexdigest(algorithm, secret_key, string)
249
128
  end
129
+ end
250
130
 
251
- # Creates a new TransloaditFile for building steps, with an optional
252
- # import step applied.
253
- def transloadit_file(io = nil)
254
- file = TransloaditFile.new(transloadit: transloadit)
255
- file = file.add_step(transloadit_import_step("import", io)) if io
256
- file
131
+ module InstanceMethods
132
+ def transloadit_files(results)
133
+ results.map { |result| transloadit_file(result) }
257
134
  end
258
135
 
259
- # Accepts a TransloaditFile, a hash of TransloaditFiles or a template,
260
- # and converts it into a Transloadit::Assembly.
261
- #
262
- # If a hash of versions are given, the version information is saved in
263
- # assembly's payload, so that later in the webhook it can be used to
264
- # construct a hash of versions.
265
- def transloadit_assembly(value, context: {}, **options)
266
- options = { steps: [], fields: {} }.merge(options)
267
-
268
- case value
269
- when TransloaditFile then transloadit_assembly_update_single!(value, context, options)
270
- when Hash then transloadit_assembly_update_versions!(value, context, options)
271
- when String then transloadit_assembly_update_template!(value, context, options)
136
+ def transloadit_file(result)
137
+ result = result.first if result.is_a?(Array)
138
+ uri = URI.parse(result.fetch("url"))
139
+
140
+ if defined?(Storage::S3) && storage.is_a?(Storage::S3)
141
+ prefix = "#{storage.prefix}/" if storage.prefix
142
+ id = uri.path.match(%r{^/#{prefix}})&.post_match or
143
+ fail Error, "URL path doesn't start with storage prefix: #{uri}"
144
+ elsif defined?(Storage::Url) && storage.is_a?(Storage::Url)
145
+ id = uri.to_s
272
146
  else
273
- raise Error, "Assembly value has to be either a TransloaditFile, a hash of TransloaditFile objects, or a template"
147
+ fail Error, "storage not supported: #{storage.inspect}"
274
148
  end
275
149
 
276
- if options[:steps].uniq(&:name) != options[:steps]
277
- raise Error, "There are different transloadit steps using the same name"
278
- end
150
+ metadata = {
151
+ "filename" => result.fetch("name"),
152
+ "size" => result.fetch("size"),
153
+ "mime_type" => result.fetch("mime"),
154
+ }
279
155
 
280
- transloadit.assembly(options)
281
- end
156
+ # merge transloadit's meatadata, but don't let it override ours
157
+ metadata.merge!(result.fetch("meta")) { |k, v1, v2| v1 }
282
158
 
283
- # An cached instance of a Tranloadit client.
284
- def transloadit
285
- @transloadit ||= self.class.transloadit
159
+ self.class::UploadedFile.new(
160
+ id: id,
161
+ storage: storage_key,
162
+ metadata: metadata,
163
+ )
286
164
  end
287
165
 
288
- private
289
-
290
- # Updates assembly options for single files.
291
- def transloadit_assembly_update_single!(transloadit_file, context, options)
292
- raise Error, "The given TransloaditFile is missing an import step" if !transloadit_file.imported?
293
- unless transloadit_file.exported?
294
- path = generate_location(transloadit_file, context) + ".${file.ext}"
295
- export_step = transloadit_export_step("export", path: path)
296
- transloadit_file = transloadit_file.add_step(export_step)
166
+ def transloadit_export_step(name = "export", **options)
167
+ unless options.key?(:credentials)
168
+ options[:credentials] = self.class.transloadit_credentials(storage_key).to_s
297
169
  end
298
- options[:steps] += transloadit_file.steps
299
- options[:fields]["multiple"] = transloadit_file.multiple
300
- end
301
170
 
302
- # Updates assembly options for a hash of versions.
303
- def transloadit_assembly_update_versions!(versions, context, options)
304
- raise Error, "The versions Shrine plugin isn't loaded" if !defined?(Shrine::Plugins::Versions)
305
- options[:fields]["versions"] = {}
306
- options[:fields]["multiple"] = {}
307
- versions.each do |name, transloadit_file|
308
- raise Error, "The :#{name} version value is not a TransloaditFile" if !transloadit_file.is_a?(TransloaditFile)
309
- raise Error, "The given TransloaditFile is missing an import step" if !transloadit_file.imported?
310
- unless transloadit_file.exported?
311
- path = generate_location(transloadit_file, context.merge(version: name)) + ".${file.ext}"
312
- export_step = transloadit_export_step("export_#{name}", path: path)
313
- transloadit_file = transloadit_file.add_step(export_step)
314
- end
315
- options[:steps] |= transloadit_file.steps
316
- options[:fields]["versions"][name] = transloadit_file.name
317
- options[:fields]["multiple"][name] = transloadit_file.multiple
171
+ if defined?(Storage::S3) && storage.is_a?(Storage::S3)
172
+ transloadit_s3_store_step(name, **options)
173
+ elsif defined?(Storage::GoogleCloudStorage) && storage.is_a?(Storage::GoogleCloudStorage)
174
+ transloadit_google_store_step(name, **options)
175
+ elsif defined?(Storage::YouTube) && storage.is_a?(Storage::YouTube)
176
+ transloadit_youtube_store_step(name, **options)
177
+ else
178
+ fail Error, "cannot construct export step for #{storage.inspect}"
318
179
  end
319
180
  end
320
181
 
321
- # Updates assembly options for a template.
322
- def transloadit_assembly_update_template!(template, context, options)
323
- options[:template_id] = template
324
- end
325
- end
182
+ private
326
183
 
327
- module FileMethods
328
- # Converts the "transloadit_response" that was saved in cached file's
329
- # metadata into a reloadable object which is normally returned by the
330
- # Transloadit gem.
331
- def transloadit_response
332
- @transloadit_response ||= (
333
- body = metadata["transloadit_response"] or return
334
- body.instance_eval { def body; self; end }
335
- response = ::Transloadit::Response.new(body)
336
- response.extend ::Transloadit::Response::Assembly
337
- response
338
- )
184
+ def transloadit_s3_store_step(name, path: DEFAULT_PATH, **options)
185
+ transloadit_step name, "/s3/store",
186
+ path: [*storage.prefix, path].join("/"),
187
+ **options
339
188
  end
340
- end
341
189
 
342
- class TransloaditFile
343
- attr_reader :transloadit, :steps
344
-
345
- def initialize(transloadit:, steps: [], multiple: nil)
346
- @transloadit = transloadit
347
- @steps = steps
348
- @multiple = multiple
190
+ def transloadit_google_store_step(name, path: DEFAULT_PATH, **options)
191
+ transloadit_step name, "/google/store",
192
+ path: [*storage.prefix, path].join("/"),
193
+ **options
349
194
  end
350
195
 
351
- # Adds a step to the pipeline, automatically setting :use to the
352
- # previous step, so that later multiple pipelines can be seamlessly
353
- # joined into a single assembly.
354
- #
355
- # It returns a new TransloaditFile with the step added.
356
- def add_step(*args)
357
- if args[0].is_a?(::Transloadit::Step)
358
- step = args[0]
359
- else
360
- step = transloadit.step(*args)
361
- end
362
-
363
- unless step.options[:use]
364
- step.use steps.last if steps.any?
365
- end
196
+ def transloadit_youtube_store_step(name, **options)
197
+ transloadit_step name, "/youtube/store",
198
+ **options
199
+ end
366
200
 
367
- transloadit_file(steps: steps + [step])
201
+ def transloadit_step(*args)
202
+ self.class.transloadit_step(*args)
368
203
  end
204
+ end
369
205
 
370
- # Specify the result format.
371
- def multiple(format = nil)
372
- if format
373
- transloadit_file(multiple: format)
206
+ module FileMethods
207
+ def transloadit_import_step(name = "import", **options)
208
+ if defined?(Storage::S3) && storage.is_a?(Storage::S3)
209
+ transloadit_s3_import_step(name, **options)
210
+ elsif url && URI(url).is_a?(URI::HTTP)
211
+ transloadit_http_import_step(name, **options)
212
+ elsif url && URI(url).is_a?(URI::FTP)
213
+ transloadit_ftp_import_step(name, **options)
374
214
  else
375
- @multiple
215
+ fail Error, "cannot construct import step from #{self.inspect}"
376
216
  end
377
217
  end
378
218
 
379
- # The key that Transloadit will use in its processing results for the
380
- # corresponding processed file. This equals to the name of the last
381
- # step, unless the last step is an export step.
382
- def name
383
- if exported?
384
- @steps[-2].name
385
- else
386
- @steps.last.name
219
+ private
220
+
221
+ def transloadit_s3_import_step(name, **options)
222
+ unless options.key?(:credentials)
223
+ options[:credentials] = shrine_class.transloadit_credentials(storage_key).to_s
387
224
  end
388
- end
389
225
 
390
- # Returns true if this TransloaditFile includes an import step.
391
- def imported?
392
- @steps.any? && @steps.first.robot.end_with?("/import")
226
+ transloadit_step name, "/s3/import",
227
+ path: [*storage.prefix, id].join("/"),
228
+ **options
393
229
  end
394
230
 
395
- # Returns true if this TransloaditFile includes an export step.
396
- def exported?
397
- @steps.any? && @steps.last.robot.end_with?("/store")
231
+ def transloadit_http_import_step(name, **options)
232
+ transloadit_step name, "/http/import",
233
+ url: url,
234
+ **options
398
235
  end
399
236
 
400
- private
237
+ def transloadit_ftp_import_step(name, **options)
238
+ uri = URI.parse(url)
401
239
 
402
- def transloadit_file(**options)
403
- TransloaditFile.new(
404
- transloadit: @transloadit,
405
- steps: @steps,
406
- multiple: @multiple,
240
+ transloadit_step name, "/ftp/import",
241
+ host: uri.host,
242
+ user: uri.user,
243
+ password: uri.password,
244
+ path: uri.path,
407
245
  **options
408
- )
246
+ end
247
+
248
+ def transloadit_step(*args)
249
+ shrine_class.transloadit_step(*args)
409
250
  end
410
251
  end
411
252
  end