salebot_uploader 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +0 -0
  3. data/lib/generators/templates/uploader.rb.erb +9 -0
  4. data/lib/generators/uploader_generator.rb +7 -0
  5. data/lib/salebot_uploader/compatibility/paperclip.rb +104 -0
  6. data/lib/salebot_uploader/downloader/base.rb +101 -0
  7. data/lib/salebot_uploader/downloader/remote_file.rb +68 -0
  8. data/lib/salebot_uploader/error.rb +8 -0
  9. data/lib/salebot_uploader/locale/en.yml +17 -0
  10. data/lib/salebot_uploader/mount.rb +446 -0
  11. data/lib/salebot_uploader/mounter.rb +255 -0
  12. data/lib/salebot_uploader/orm/activerecord.rb +68 -0
  13. data/lib/salebot_uploader/processing/mini_magick.rb +194 -0
  14. data/lib/salebot_uploader/processing/rmagick.rb +402 -0
  15. data/lib/salebot_uploader/processing/vips.rb +284 -0
  16. data/lib/salebot_uploader/processing.rb +3 -0
  17. data/lib/salebot_uploader/sanitized_file.rb +357 -0
  18. data/lib/salebot_uploader/storage/abstract.rb +41 -0
  19. data/lib/salebot_uploader/storage/file.rb +124 -0
  20. data/lib/salebot_uploader/storage/fog.rb +547 -0
  21. data/lib/salebot_uploader/storage.rb +3 -0
  22. data/lib/salebot_uploader/test/matchers.rb +398 -0
  23. data/lib/salebot_uploader/uploader/cache.rb +223 -0
  24. data/lib/salebot_uploader/uploader/callbacks.rb +33 -0
  25. data/lib/salebot_uploader/uploader/configuration.rb +184 -0
  26. data/lib/salebot_uploader/uploader/content_type_allowlist.rb +61 -0
  27. data/lib/salebot_uploader/uploader/content_type_denylist.rb +62 -0
  28. data/lib/salebot_uploader/uploader/default_url.rb +17 -0
  29. data/lib/salebot_uploader/uploader/dimension.rb +66 -0
  30. data/lib/salebot_uploader/uploader/download.rb +24 -0
  31. data/lib/salebot_uploader/uploader/extension_allowlist.rb +63 -0
  32. data/lib/salebot_uploader/uploader/extension_denylist.rb +64 -0
  33. data/lib/salebot_uploader/uploader/file_size.rb +43 -0
  34. data/lib/salebot_uploader/uploader/mountable.rb +44 -0
  35. data/lib/salebot_uploader/uploader/processing.rb +125 -0
  36. data/lib/salebot_uploader/uploader/proxy.rb +99 -0
  37. data/lib/salebot_uploader/uploader/remove.rb +21 -0
  38. data/lib/salebot_uploader/uploader/serialization.rb +28 -0
  39. data/lib/salebot_uploader/uploader/store.rb +142 -0
  40. data/lib/salebot_uploader/uploader/url.rb +44 -0
  41. data/lib/salebot_uploader/uploader/versions.rb +350 -0
  42. data/lib/salebot_uploader/uploader.rb +53 -0
  43. data/lib/salebot_uploader/utilities/file_name.rb +47 -0
  44. data/lib/salebot_uploader/utilities/uri.rb +26 -0
  45. data/lib/salebot_uploader/utilities.rb +7 -0
  46. data/lib/salebot_uploader/validations/active_model.rb +76 -0
  47. data/lib/salebot_uploader/version.rb +3 -0
  48. data/lib/salebot_uploader.rb +62 -0
  49. metadata +392 -0
@@ -0,0 +1,398 @@
1
+ module SalebotUploader
2
+ module Test
3
+
4
+ ##
5
+ # These are some matchers that can be used in RSpec specs, to simplify the testing
6
+ # of uploaders.
7
+ #
8
+ module Matchers
9
+
10
+ class BeIdenticalTo # :nodoc:
11
+ def initialize(expected)
12
+ @expected = expected
13
+ end
14
+
15
+ def matches?(actual)
16
+ @actual = actual
17
+ FileUtils.identical?(@actual, @expected)
18
+ end
19
+
20
+ def failure_message
21
+ "expected #{@actual.inspect} to be identical to #{@expected.inspect}"
22
+ end
23
+
24
+ def failure_message_when_negated
25
+ "expected #{@actual.inspect} to not be identical to #{@expected.inspect}"
26
+ end
27
+
28
+ def description
29
+ "be identical to #{@expected.inspect}"
30
+ end
31
+
32
+ # RSpec 2 compatibility:
33
+ alias_method :negative_failure_message, :failure_message_when_negated
34
+ end
35
+
36
+ def be_identical_to(expected)
37
+ BeIdenticalTo.new(expected)
38
+ end
39
+
40
+ class HavePermissions # :nodoc:
41
+ def initialize(expected)
42
+ @expected = expected
43
+ end
44
+
45
+ def matches?(actual)
46
+ @actual = actual
47
+ # Satisfy expectation here. Return false or raise an error if it's not met.
48
+ (File.stat(@actual.path).mode & 0o777) == @expected
49
+ end
50
+
51
+ def failure_message
52
+ "expected #{@actual.current_path.inspect} to have permissions #{@expected.to_s(8)}, but they were #{(File.stat(@actual.path).mode & 0o777).to_s(8)}"
53
+ end
54
+
55
+ def failure_message_when_negated
56
+ "expected #{@actual.current_path.inspect} not to have permissions #{@expected.to_s(8)}, but it did"
57
+ end
58
+
59
+ def description
60
+ "have permissions #{@expected.to_s(8)}"
61
+ end
62
+
63
+ # RSpec 2 compatibility:
64
+ alias_method :negative_failure_message, :failure_message_when_negated
65
+ end
66
+
67
+ def have_permissions(expected)
68
+ HavePermissions.new(expected)
69
+ end
70
+
71
+ class HaveDirectoryPermissions # :nodoc:
72
+ def initialize(expected)
73
+ @expected = expected
74
+ end
75
+
76
+ def matches?(actual)
77
+ @actual = actual
78
+ # Satisfy expectation here. Return false or raise an error if it's not met.
79
+ (File.stat(File.dirname(@actual.path)).mode & 0o777) == @expected
80
+ end
81
+
82
+ def failure_message
83
+ "expected #{File.dirname @actual.current_path.inspect} to have permissions #{@expected.to_s(8)}, but they were #{(File.stat(@actual.path).mode & 0o777).to_s(8)}"
84
+ end
85
+
86
+ def failure_message_when_negated
87
+ "expected #{File.dirname @actual.current_path.inspect} not to have permissions #{@expected.to_s(8)}, but it did"
88
+ end
89
+
90
+ def description
91
+ "have permissions #{@expected.to_s(8)}"
92
+ end
93
+
94
+ # RSpec 2 compatibility:
95
+ alias_method :negative_failure_message, :failure_message_when_negated
96
+ end
97
+
98
+ def have_directory_permissions(expected)
99
+ HaveDirectoryPermissions.new(expected)
100
+ end
101
+
102
+ class BeNoLargerThan # :nodoc:
103
+ def initialize(width, height)
104
+ @width, @height = width, height
105
+ end
106
+
107
+ def matches?(actual)
108
+ @actual = actual
109
+ # Satisfy expectation here. Return false or raise an error if it's not met.
110
+ image = ImageLoader.load_image(@actual.current_path)
111
+ @actual_width = image.width
112
+ @actual_height = image.height
113
+ @actual_width <= @width && @actual_height <= @height
114
+ end
115
+
116
+ def failure_message
117
+ "expected #{@actual.current_path.inspect} to be no larger than #{@width} by #{@height}, but it was #{@actual_width} by #{@actual_height}."
118
+ end
119
+
120
+ def failure_message_when_negated
121
+ "expected #{@actual.current_path.inspect} to be larger than #{@width} by #{@height}, but it wasn't."
122
+ end
123
+
124
+ def description
125
+ "be no larger than #{@width} by #{@height}"
126
+ end
127
+
128
+ # RSpec 2 compatibility:
129
+ alias_method :negative_failure_message, :failure_message_when_negated
130
+ end
131
+
132
+ def be_no_larger_than(width, height)
133
+ BeNoLargerThan.new(width, height)
134
+ end
135
+
136
+ class HaveDimensions # :nodoc:
137
+ def initialize(width, height)
138
+ @width, @height = width, height
139
+ end
140
+
141
+ def matches?(actual)
142
+ @actual = actual
143
+ # Satisfy expectation here. Return false or raise an error if it's not met.
144
+ image = ImageLoader.load_image(@actual.current_path)
145
+ @actual_width = image.width
146
+ @actual_height = image.height
147
+ @actual_width == @width && @actual_height == @height
148
+ end
149
+
150
+ def failure_message
151
+ "expected #{@actual.current_path.inspect} to have an exact size of #{@width} by #{@height}, but it was #{@actual_width} by #{@actual_height}."
152
+ end
153
+
154
+ def failure_message_when_negated
155
+ "expected #{@actual.current_path.inspect} not to have an exact size of #{@width} by #{@height}, but it did."
156
+ end
157
+
158
+ def description
159
+ "have an exact size of #{@width} by #{@height}"
160
+ end
161
+
162
+ # RSpec 2 compatibility:
163
+ alias_method :negative_failure_message, :failure_message_when_negated
164
+ end
165
+
166
+ def have_dimensions(width, height)
167
+ HaveDimensions.new(width, height)
168
+ end
169
+
170
+ class HaveHeight # :nodoc:
171
+ def initialize(height)
172
+ @height = height
173
+ end
174
+
175
+ def matches?(actual)
176
+ @actual = actual
177
+ # Satisfy expectation here. Return false or raise an error if it's not met.
178
+ image = ImageLoader.load_image(@actual.current_path)
179
+ @actual_height = image.height
180
+ @actual_height == @height
181
+ end
182
+
183
+ def failure_message
184
+ "expected #{@actual.current_path.inspect} to have an exact size of #{@height}, but it was #{@actual_height}."
185
+ end
186
+
187
+ def failure_message_when_negated
188
+ "expected #{@actual.current_path.inspect} not to have an exact size of #{@height}, but it did."
189
+ end
190
+
191
+ def description
192
+ "have an exact height of #{@height}"
193
+ end
194
+
195
+ # RSpec 2 compatibility:
196
+ alias_method :negative_failure_message, :failure_message_when_negated
197
+ end
198
+
199
+ def have_height(height)
200
+ HaveHeight.new(height)
201
+ end
202
+
203
+ class HaveWidth # :nodoc:
204
+ def initialize(width)
205
+ @width = width
206
+ end
207
+
208
+ def matches?(actual)
209
+ @actual = actual
210
+ # Satisfy expectation here. Return false or raise an error if it's not met.
211
+ image = ImageLoader.load_image(@actual.current_path)
212
+ @actual_width = image.width
213
+ @actual_width == @width
214
+ end
215
+
216
+ def failure_message
217
+ "expected #{@actual.current_path.inspect} to have an exact size of #{@width}, but it was #{@actual_width}."
218
+ end
219
+
220
+ def failure_message_when_negated
221
+ "expected #{@actual.current_path.inspect} not to have an exact size of #{@width}, but it did."
222
+ end
223
+
224
+ def description
225
+ "have an exact width of #{@width}"
226
+ end
227
+
228
+ # RSpec 2 compatibility:
229
+ alias_method :negative_failure_message, :failure_message_when_negated
230
+ end
231
+
232
+ def have_width(width)
233
+ HaveWidth.new(width)
234
+ end
235
+
236
+ class BeNoWiderThan # :nodoc:
237
+ def initialize(width)
238
+ @width = width
239
+ end
240
+
241
+ def matches?(actual)
242
+ @actual = actual
243
+ # Satisfy expectation here. Return false or raise an error if it's not met.
244
+ image = ImageLoader.load_image(@actual.current_path)
245
+ @actual_width = image.width
246
+ @actual_width <= @width
247
+ end
248
+
249
+ def failure_message
250
+ "expected #{@actual.current_path.inspect} to be no wider than #{@width}, but it was #{@actual_width}."
251
+ end
252
+
253
+ def failure_message_when_negated
254
+ "expected #{@actual.current_path.inspect} not to be wider than #{@width}, but it is."
255
+ end
256
+
257
+ def description
258
+ "have a width less than or equal to #{@width}"
259
+ end
260
+
261
+ # RSpec 2 compatibility:
262
+ alias_method :negative_failure_message, :failure_message_when_negated
263
+ end
264
+
265
+ def be_no_wider_than(width)
266
+ BeNoWiderThan.new(width)
267
+ end
268
+
269
+ class BeNoTallerThan # :nodoc:
270
+ def initialize(height)
271
+ @height = height
272
+ end
273
+
274
+ def matches?(actual)
275
+ @actual = actual
276
+ # Satisfy expectation here. Return false or raise an error if it's not met.
277
+ image = ImageLoader.load_image(@actual.current_path)
278
+ @actual_height = image.height
279
+ @actual_height <= @height
280
+ end
281
+
282
+ def failure_message
283
+ "expected #{@actual.current_path.inspect} to be no taller than #{@height}, but it was #{@actual_height}."
284
+ end
285
+
286
+ def failure_message_when_negated
287
+ "expected #{@actual.current_path.inspect} not to be taller than #{@height}, but it is."
288
+ end
289
+
290
+ def description
291
+ "have a height less than or equal to #{@height}"
292
+ end
293
+
294
+ # RSpec 2 compatibility:
295
+ alias_method :negative_failure_message, :failure_message_when_negated
296
+ end
297
+
298
+ def be_no_taller_than(height)
299
+ BeNoTallerThan.new(height)
300
+ end
301
+
302
+ class BeFormat # :nodoc:
303
+ def initialize(expected)
304
+ @expected = expected
305
+ end
306
+
307
+ def matches?(actual)
308
+ @actual = actual
309
+ # Satisfy expectation here. Return false or raise an error if it's not met.
310
+ image = ImageLoader.load_image(@actual.current_path)
311
+ @actual_expected = image.format
312
+ !@expected.nil? && @actual_expected.casecmp(@expected).zero?
313
+ end
314
+
315
+ def failure_message
316
+ "expected #{@actual.current_path.inspect} to have #{@expected} format, but it was #{@actual_expected}."
317
+ end
318
+
319
+ def failure_message_when_negated
320
+ "expected #{@actual.current_path.inspect} not to have #{@expected} format, but it did."
321
+ end
322
+
323
+ def description
324
+ "have #{@expected} format"
325
+ end
326
+
327
+ # RSpec 2 compatibility:
328
+ alias_method :negative_failure_message, :failure_message_when_negated
329
+ end
330
+
331
+ def be_format(expected)
332
+ BeFormat.new(expected)
333
+ end
334
+
335
+ class ImageLoader # :nodoc:
336
+ def self.load_image(filename)
337
+ if defined? ::MiniMagick
338
+ MiniMagickWrapper.new(filename)
339
+ else
340
+ unless defined? ::Magick
341
+ begin
342
+ require 'rmagick'
343
+ rescue LoadError
344
+ begin
345
+ require 'RMagick'
346
+ rescue LoadError
347
+ puts "WARNING: Failed to require rmagick, image processing may fail!"
348
+ end
349
+ end
350
+ end
351
+ MagickWrapper.new(filename)
352
+ end
353
+ end
354
+ end
355
+
356
+ class MagickWrapper # :nodoc:
357
+ attr_reader :image
358
+
359
+ def width
360
+ image.columns
361
+ end
362
+
363
+ def height
364
+ image.rows
365
+ end
366
+
367
+ def format
368
+ image.format
369
+ end
370
+
371
+ def initialize(filename)
372
+ @image = ::Magick::Image.read(filename).first
373
+ end
374
+ end
375
+
376
+ class MiniMagickWrapper # :nodoc:
377
+ attr_reader :image
378
+
379
+ def width
380
+ image[:width]
381
+ end
382
+
383
+ def height
384
+ image[:height]
385
+ end
386
+
387
+ def format
388
+ image[:format]
389
+ end
390
+
391
+ def initialize(filename)
392
+ @image = ::MiniMagick::Image.open(filename)
393
+ end
394
+ end
395
+
396
+ end # Matchers
397
+ end # Test
398
+ end # SalebotUploader
@@ -0,0 +1,223 @@
1
+ require 'securerandom'
2
+
3
+ module SalebotUploader
4
+
5
+ class FormNotMultipart < UploadError
6
+ def message
7
+ "You tried to assign a String or a Pathname to an uploader, for security reasons, this is not allowed.\n\n If this is a file upload, please check that your upload form is multipart encoded."
8
+ end
9
+ end
10
+
11
+ class CacheCounter
12
+ @@counter = 0
13
+
14
+ def self.increment
15
+ @@counter += 1
16
+ end
17
+ end
18
+
19
+ ##
20
+ # Generates a unique cache id for use in the caching system
21
+ #
22
+ # === Returns
23
+ #
24
+ # [String] a cache id in the format TIMEINT-PID-COUNTER-RND
25
+ #
26
+ def self.generate_cache_id
27
+ [
28
+ Time.now.utc.to_i,
29
+ SecureRandom.random_number(1_000_000_000_000_000),
30
+ '%04d' % (SalebotUploader::CacheCounter.increment % 10_000),
31
+ '%04d' % SecureRandom.random_number(10_000)
32
+ ].map(&:to_s).join('-')
33
+ end
34
+
35
+ module Uploader
36
+ module Cache
37
+ extend ActiveSupport::Concern
38
+
39
+ include SalebotUploader::Uploader::Callbacks
40
+ include SalebotUploader::Uploader::Configuration
41
+
42
+ included do
43
+ prepend Module.new {
44
+ def initialize(*)
45
+ super
46
+ @staged = false
47
+ end
48
+ }
49
+ attr_accessor :staged
50
+ end
51
+
52
+ module ClassMethods
53
+
54
+ ##
55
+ # Removes cached files which are older than one day. You could call this method
56
+ # from a rake task to clean out old cached files.
57
+ #
58
+ # You can call this method directly on the module like this:
59
+ #
60
+ # SalebotUploader.clean_cached_files!
61
+ #
62
+ # === Note
63
+ #
64
+ # This only works as long as you haven't done anything funky with your cache_dir.
65
+ # It's recommended that you keep cache files in one place only.
66
+ #
67
+ def clean_cached_files!(seconds=60*60*24)
68
+ (cache_storage || storage).new(new).clean_cache!(seconds)
69
+ end
70
+ end
71
+
72
+ ##
73
+ # Returns true if the uploader has been cached
74
+ #
75
+ # === Returns
76
+ #
77
+ # [Bool] whether the current file is cached
78
+ #
79
+ def cached?
80
+ !!@cache_id
81
+ end
82
+
83
+ ##
84
+ # Caches the remotely stored file
85
+ #
86
+ # This is useful when about to process images. Most processing solutions
87
+ # require the file to be stored on the local filesystem.
88
+ #
89
+ def cache_stored_file!
90
+ cache!
91
+ end
92
+
93
+ def sanitized_file
94
+ ActiveSupport::Deprecation.warn('#sanitized_file is deprecated, use #file instead.')
95
+ file
96
+ end
97
+
98
+ ##
99
+ # Returns a String which uniquely identifies the currently cached file for later retrieval
100
+ #
101
+ # === Returns
102
+ #
103
+ # [String] a cache name, in the format TIMEINT-PID-COUNTER-RND/filename.txt
104
+ #
105
+ def cache_name
106
+ File.join(cache_id, original_filename) if cache_id && original_filename
107
+ end
108
+
109
+ ##
110
+ # Caches the given file. Calls process! to trigger any process callbacks.
111
+ #
112
+ # By default, cache!() uses copy_to(), which operates by copying the file
113
+ # to the cache, then deleting the original file. If move_to_cache() is
114
+ # overridden to return true, then cache!() uses move_to(), which simply
115
+ # moves the file to the cache. Useful for large files.
116
+ #
117
+ # === Parameters
118
+ #
119
+ # [new_file (File, IOString, Tempfile)] any kind of file object
120
+ #
121
+ # === Raises
122
+ #
123
+ # [SalebotUploader::FormNotMultipart] if the assigned parameter is a string
124
+ #
125
+ def cache!(new_file = file)
126
+ new_file = SalebotUploader::SanitizedFile.new(new_file)
127
+ return if new_file.empty?
128
+
129
+ raise SalebotUploader::FormNotMultipart if new_file.is_path? && ensure_multipart_form
130
+
131
+ self.cache_id = SalebotUploader.generate_cache_id unless cache_id
132
+
133
+ @identifier = nil
134
+ @staged = true
135
+ @filename = new_file.filename
136
+ self.original_filename = new_file.filename
137
+
138
+ begin
139
+ # first, create a workfile on which we perform processings
140
+ if move_to_cache
141
+ @file = new_file.move_to(File.expand_path(workfile_path, root), permissions, directory_permissions)
142
+ else
143
+ @file = new_file.copy_to(File.expand_path(workfile_path, root), permissions, directory_permissions)
144
+ end
145
+
146
+ with_callbacks(:cache, @file) do
147
+ @file = cache_storage.cache!(@file)
148
+ end
149
+ ensure
150
+ FileUtils.rm_rf(workfile_path(''))
151
+ end
152
+ end
153
+
154
+ ##
155
+ # Retrieves the file with the given cache_name from the cache.
156
+ #
157
+ # === Parameters
158
+ #
159
+ # [cache_name (String)] uniquely identifies a cache file
160
+ #
161
+ # === Raises
162
+ #
163
+ # [SalebotUploader::InvalidParameter] if the cache_name is incorrectly formatted.
164
+ #
165
+ def retrieve_from_cache!(cache_name)
166
+ with_callbacks(:retrieve_from_cache, cache_name) do
167
+ self.cache_id, self.original_filename = cache_name.to_s.split('/', 2)
168
+ @staged = true
169
+ @filename = original_filename
170
+ @file = cache_storage.retrieve_from_cache!(full_original_filename)
171
+ end
172
+ end
173
+
174
+ ##
175
+ # Calculates the path where the cache file should be stored.
176
+ #
177
+ # === Parameters
178
+ #
179
+ # [for_file (String)] name of the file <optional>
180
+ #
181
+ # === Returns
182
+ #
183
+ # [String] the cache path
184
+ #
185
+ def cache_path(for_file=full_original_filename)
186
+ File.join(*[cache_dir, @cache_id, for_file].compact)
187
+ end
188
+
189
+ protected
190
+
191
+ attr_reader :cache_id
192
+
193
+ private
194
+
195
+ def workfile_path(for_file=original_filename)
196
+ File.join(SalebotUploader.tmp_path, @cache_id, version_name.to_s, for_file)
197
+ end
198
+
199
+ attr_reader :original_filename
200
+
201
+ def cache_id=(cache_id)
202
+ # Earlier version used 3 part cache_id. Thus we should allow for
203
+ # the cache_id to have both 3 part and 4 part formats.
204
+ raise SalebotUploader::InvalidParameter, "invalid cache id" unless cache_id =~ /\A(-)?[\d]+\-[\d]+(\-[\d]{4})?\-[\d]{4}\z/
205
+ @cache_id = cache_id
206
+ end
207
+
208
+ def original_filename=(filename)
209
+ raise SalebotUploader::InvalidParameter, "invalid filename" if filename =~ SalebotUploader::SanitizedFile.sanitize_regexp
210
+ @original_filename = filename
211
+ end
212
+
213
+ def cache_storage
214
+ @cache_storage ||= (self.class.cache_storage || self.class.storage).new(self)
215
+ end
216
+
217
+ # We can override the full_original_filename method in other modules
218
+ def full_original_filename
219
+ forcing_extension(original_filename)
220
+ end
221
+ end # Cache
222
+ end # Uploader
223
+ end # SalebotUploader
@@ -0,0 +1,33 @@
1
+ module SalebotUploader
2
+ module Uploader
3
+ module Callbacks
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ class_attribute :_before_callbacks, :_after_callbacks,
8
+ :instance_writer => false
9
+ self._before_callbacks = Hash.new []
10
+ self._after_callbacks = Hash.new []
11
+ end
12
+
13
+ def with_callbacks(kind, *args)
14
+ self.class._before_callbacks[kind].each { |c| send c, *args }
15
+ yield
16
+ self.class._after_callbacks[kind].each { |c| send c, *args }
17
+ end
18
+
19
+ module ClassMethods
20
+ def before(kind, callback)
21
+ self._before_callbacks = self._before_callbacks.
22
+ merge kind => _before_callbacks[kind] + [callback]
23
+ end
24
+
25
+ def after(kind, callback)
26
+ self._after_callbacks = self._after_callbacks.
27
+ merge kind => _after_callbacks[kind] + [callback]
28
+ end
29
+ end # ClassMethods
30
+
31
+ end # Callbacks
32
+ end # Uploader
33
+ end # SalebotUploader