-
Notifications
You must be signed in to change notification settings - Fork 275
/
shrine.rb
978 lines (831 loc) · 33.8 KB
/
shrine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
# frozen_string_literal: true
require "shrine/version"
require "securerandom"
require "json"
require "tempfile"
class Shrine
# A generic exception used by Shrine.
class Error < StandardError; end
# Raised when a file is not a valid IO.
class InvalidFile < Error
def initialize(io, missing_methods)
super "#{io.inspect} is not a valid IO object (it doesn't respond to #{missing_methods.map{|m, args|"##{m}"}.join(", ")})"
end
end
# Methods which an object has to respond to in order to be considered
# an IO object, along with their arguments.
IO_METHODS = {
read: [:length, :outbuf],
eof?: [],
rewind: [],
size: [],
close: [],
}
deprecate_constant(:IO_METHODS) if RUBY_VERSION > "2.3"
# Core class that represents a file uploaded to a storage. The instance
# methods for this class are added by Shrine::Plugins::Base::FileMethods, the
# class methods are added by Shrine::Plugins::Base::FileClassMethods.
class UploadedFile
@shrine_class = ::Shrine
end
# Core class which creates attachment modules for specified attribute names
# that are included into model classes. The instance methods for this class
# are added by Shrine::Plugins::Base::AttachmentMethods, the class methods
# are added by Shrine::Plugins::Base::AttachmentClassMethods.
class Attachment < Module
@shrine_class = ::Shrine
end
# Core class which handles attaching files to model instances. The instance
# methods for this class are added by Shrine::Plugins::Base::AttacherMethods,
# the class methods are added by Shrine::Plugins::Base::AttacherClassMethods.
class Attacher
@shrine_class = ::Shrine
end
@opts = {}
@storages = {}
# Module in which all Shrine plugins should be stored. Also contains logic
# for registering and loading plugins.
module Plugins
@plugins = {}
# If the registered plugin already exists, use it. Otherwise, require it
# and return it. This raises a LoadError if such a plugin doesn't exist,
# or a Shrine::Error if it exists but it does not register itself
# correctly.
def self.load_plugin(name)
unless plugin = @plugins[name]
require "shrine/plugins/#{name}"
raise Error, "plugin #{name} did not register itself correctly in Shrine::Plugins" unless plugin = @plugins[name]
end
plugin
end
# Register the given plugin with Shrine, so that it can be loaded using
# `Shrine.plugin` with a symbol. Should be used by plugin files. Example:
#
# Shrine::Plugins.register_plugin(:plugin_name, PluginModule)
def self.register_plugin(name, mod)
@plugins[name] = mod
end
# The base plugin for Shrine, implementing all default functionality.
# Methods are put into a plugin so future plugins can easily override
# them and call `super` to get the default behavior.
module Base
module ClassMethods
# Generic options for this class, plugins store their options here.
attr_reader :opts
# A hash of storages with their symbol identifiers.
attr_accessor :storages
# When inheriting Shrine, copy the instance variables into the subclass,
# and create subclasses of core classes.
def inherited(subclass)
subclass.instance_variable_set(:@opts, opts.dup)
subclass.opts.each do |key, value|
if value.is_a?(Enumerable) && !value.frozen?
subclass.opts[key] = value.dup
end
end
subclass.instance_variable_set(:@storages, storages.dup)
file_class = Class.new(self::UploadedFile)
file_class.shrine_class = subclass
subclass.const_set(:UploadedFile, file_class)
attachment_class = Class.new(self::Attachment)
attachment_class.shrine_class = subclass
subclass.const_set(:Attachment, attachment_class)
attacher_class = Class.new(self::Attacher)
attacher_class.shrine_class = subclass
subclass.const_set(:Attacher, attacher_class)
end
# Load a new plugin into the current class. A plugin can be a module
# which is used directly, or a symbol representing a registered plugin
# which will be required and then loaded.
#
# Shrine.plugin MyPlugin
# Shrine.plugin :my_plugin
def plugin(plugin, *args, &block)
plugin = Plugins.load_plugin(plugin) if plugin.is_a?(Symbol)
plugin.load_dependencies(self, *args, &block) if plugin.respond_to?(:load_dependencies)
self.include(plugin::InstanceMethods) if defined?(plugin::InstanceMethods)
self.extend(plugin::ClassMethods) if defined?(plugin::ClassMethods)
self::UploadedFile.include(plugin::FileMethods) if defined?(plugin::FileMethods)
self::UploadedFile.extend(plugin::FileClassMethods) if defined?(plugin::FileClassMethods)
self::Attachment.include(plugin::AttachmentMethods) if defined?(plugin::AttachmentMethods)
self::Attachment.extend(plugin::AttachmentClassMethods) if defined?(plugin::AttachmentClassMethods)
self::Attacher.include(plugin::AttacherMethods) if defined?(plugin::AttacherMethods)
self::Attacher.extend(plugin::AttacherClassMethods) if defined?(plugin::AttacherClassMethods)
plugin.configure(self, *args, &block) if plugin.respond_to?(:configure)
plugin
end
# Retrieves the storage under the given identifier (can be a Symbol or
# a String), and raises Shrine::Error if the storage is missing.
def find_storage(name)
storages.each { |key, value| return value if key.to_s == name.to_s }
raise Error, "storage #{name.inspect} isn't registered on #{self}"
end
# Generates an instance of Shrine::Attachment to be included in the
# model class. Example:
#
# class Photo
# include Shrine.attachment(:image) # creates a Shrine::Attachment object
# end
def attachment(name, *args)
self::Attachment.new(name, *args)
end
alias [] attachment
# Instantiates a Shrine::UploadedFile from a hash, and optionally
# yields the returned object.
#
# data = {"storage" => "cache", "id" => "abc123.jpg", "metadata" => {}}
# Shrine.uploaded_file(data) #=> #<Shrine::UploadedFile>
def uploaded_file(object, &block)
case object
when String
uploaded_file(JSON.parse(object), &block)
when Hash
uploaded_file(self::UploadedFile.new(object), &block)
when self::UploadedFile
object.tap { |f| yield(f) if block_given? }
else
raise Error, "cannot convert #{object.inspect} to a #{self}::UploadedFile"
end
end
# Temporarily converts an IO-like object into a file. If the input IO
# object is already a file, it simply yields it to the block, otherwise
# it copies IO content into a Tempfile object which is then yielded and
# afterwards deleted.
#
# Shrine.with_file(io) { |file| file.path }
def with_file(io)
if io.respond_to?(:path)
yield io
elsif io.is_a?(UploadedFile)
io.download { |tempfile| yield tempfile }
else
Tempfile.create("shrine-file", binmode: true) do |file|
IO.copy_stream(io, file.path)
io.rewind
yield file
end
end
end
# Prints a deprecation warning to standard error.
def deprecation(message)
warn "SHRINE DEPRECATION WARNING: #{message}"
end
end
module InstanceMethods
# The symbol identifier for the storage used by the uploader.
attr_reader :storage_key
# The storage object used by the uploader.
attr_reader :storage
# Accepts a storage symbol registered in `Shrine.storages`.
def initialize(storage_key)
@storage = self.class.find_storage(storage_key)
@storage_key = storage_key.to_sym
end
# The class-level options hash. This should probably not be modified at
# the instance level.
def opts
self.class.opts
end
# The main method for uploading files. Takes an IO-like object and an
# optional context hash (used internally by Shrine::Attacher). It calls
# user-defined #process, and aferwards it calls #store. The `io` is
# closed after upload.
def upload(io, context = {})
io = processed(io, context) || io
store(io, context)
end
# User is expected to perform processing inside this method, and
# return the processed files. Returning nil signals that no proccessing
# has been done and that the original file should be used.
#
# class ImageUploader < Shrine
# def process(io, context)
# # do processing and return processed files
# end
# end
def process(io, context = {})
end
# Uploads the file and returns an instance of Shrine::UploadedFile. By
# default the location of the file is automatically generated by
# \#generate_location, but you can pass in `:location` to upload to
# a specific location.
#
# uploader.store(io)
def store(io, context = {})
_store(io, context)
end
# Returns true if the storage of the given uploaded file matches the
# storage of this uploader.
def uploaded?(uploaded_file)
uploaded_file.storage_key == storage_key.to_s
end
# Deletes the given uploaded file and returns it.
def delete(uploaded_file, context = {})
_delete(uploaded_file, context)
uploaded_file
end
# Generates a unique location for the uploaded file, preserving the
# file extension. Can be overriden in uploaders for generating custom
# location.
def generate_location(io, context = {})
extension = ".#{io.extension}" if io.is_a?(UploadedFile) && io.extension
extension ||= File.extname(extract_filename(io).to_s).downcase
basename = generate_uid(io)
basename + extension
end
# Extracts filename, size and MIME type from the file, which is later
# accessible through UploadedFile#metadata.
def extract_metadata(io, context = {})
{
"filename" => extract_filename(io),
"size" => extract_size(io),
"mime_type" => extract_mime_type(io),
}
end
private
# Attempts to extract the appropriate filename from the IO object.
def extract_filename(io)
if io.respond_to?(:original_filename)
io.original_filename
elsif io.respond_to?(:path)
File.basename(io.path)
end
end
# Attempts to extract the MIME type from the IO object.
def extract_mime_type(io)
if io.respond_to?(:content_type)
warn "The \"mime_type\" Shrine metadata field will be set from the \"Content-Type\" request header, which might not hold the actual MIME type of the file. It is recommended to load the determine_mime_type plugin which determines MIME type from file content."
io.content_type
end
end
# Extracts the filesize from the IO object.
def extract_size(io)
io.size if io.respond_to?(:size)
end
# It first asserts that `io` is a valid IO object. It then extracts
# metadata and generates the location, before calling the storage to
# upload the IO object, passing the extracted metadata and location.
# Finally it returns a Shrine::UploadedFile object which represents the
# file that was uploaded.
def _store(io, context)
_enforce_io(io)
metadata = get_metadata(io, context)
location = get_location(io, context.merge(metadata: metadata))
put(io, context.merge(location: location, metadata: metadata))
self.class.uploaded_file(
"id" => location,
"storage" => storage_key.to_s,
"metadata" => metadata,
)
end
# Delegates to #remove.
def _delete(uploaded_file, context)
remove(uploaded_file, context)
end
# Delegates to #copy.
def put(io, context)
copy(io, context)
end
# Calls `#upload` on the storage, passing to it the location, metadata
# and any upload options. The storage might modify the location or
# metadata that were passed in. The uploaded IO is then closed.
def copy(io, context)
location = context[:location]
metadata = context[:metadata]
upload_options = context[:upload_options] || {}
storage.upload(io, location, shrine_metadata: metadata, **upload_options)
ensure
io.close rescue nil
end
# Delegates to `UploadedFile#delete`.
def remove(uploaded_file, context)
uploaded_file.delete
end
# Delegates to #process.
def processed(io, context)
process(io, context)
end
# Retrieves the location for the given IO and context. First it looks
# for the `:location` option, otherwise it calls #generate_location.
def get_location(io, context)
location = context[:location] || generate_location(io, context)
location or raise Error, "location generated for #{io.inspect} was nil (context = #{context})"
end
# If the IO object is a Shrine::UploadedFile, it simply copies over its
# metadata, otherwise it calls #extract_metadata.
def get_metadata(io, context)
if io.is_a?(UploadedFile)
io.metadata.dup
else
extract_metadata(io, context)
end
end
# Asserts that the object is a valid IO object, specifically that it
# responds to `#read`, `#eof?`, `#rewind`, `#size` and `#close`. If the
# object doesn't respond to one of these methods, a Shrine::InvalidFile
# error is raised.
def _enforce_io(io)
missing_methods = %i[read eof? rewind close].select { |m| !io.respond_to?(m) }
raise InvalidFile.new(io, missing_methods) if missing_methods.any?
end
# Generates a unique identifier that can be used for a location.
def generate_uid(io)
SecureRandom.hex
end
end
module AttachmentClassMethods
# Returns the Shrine class that this attachment class is
# namespaced under.
attr_accessor :shrine_class
# Since Attachment is anonymously subclassed when Shrine is subclassed,
# and then assigned to a constant of the Shrine subclass, make inspect
# reflect the likely name for the class.
def inspect
"#{shrine_class.inspect}::Attachment"
end
end
module AttachmentMethods
# Instantiates an attachment module for a given attribute name, which
# can then be included to a model class. Second argument will be passed
# to an attacher module.
def initialize(name, **options)
@name = name
@options = options
module_eval <<-RUBY, __FILE__, __LINE__ + 1
def #{name}_attacher(options = {})
@#{name}_attacher = nil if options.any?
@#{name}_attacher ||= (
attachments = self.class.ancestors.grep(Shrine::Attachment)
attachment = attachments.find { |mod| mod.attachment_name == :#{name} }
attacher_class = attachment.shrine_class::Attacher
options = attachment.options.merge(options)
attacher_class.new(self, :#{name}, options)
)
end
def #{name}=(value)
#{name}_attacher.assign(value)
end
def #{name}
#{name}_attacher.get
end
def #{name}_url(*args)
#{name}_attacher.url(*args)
end
RUBY
end
# Returns name of the attachment this module provides.
def attachment_name
@name
end
# Returns options that are to be passed to the Attacher.
def options
@options
end
# Returns class name with attachment name included.
#
# Shrine[:image].to_s #=> "#<Shrine::Attachment(image)>"
def to_s
"#<#{self.class.inspect}(#{attachment_name})>"
end
# Returns class name with attachment name included.
#
# Shrine[:image].inspect #=> "#<Shrine::Attachment(image)>"
def inspect
"#<#{self.class.inspect}(#{attachment_name})>"
end
# Returns the Shrine class that this attachment's class is namespaced
# under.
def shrine_class
self.class.shrine_class
end
end
module AttacherClassMethods
# Returns the Shrine class that this attacher class is namespaced
# under.
attr_accessor :shrine_class
# Since Attacher is anonymously subclassed when Shrine is subclassed,
# and then assigned to a constant of the Shrine subclass, make inspect
# reflect the likely name for the class.
def inspect
"#{shrine_class.inspect}::Attacher"
end
# Block that is executed in context of Shrine::Attacher during
# validation. Example:
#
# Shrine::Attacher.validate do
# if get.size > 5*1024*1024
# errors << "is too big (max is 5 MB)"
# end
# end
def validate(&block)
define_method(:validate_block, &block)
private :validate_block
end
end
module AttacherMethods
# Returns the uploader that is used for the temporary storage.
attr_reader :cache
# Returns the uploader that is used for the permanent storage.
attr_reader :store
# Returns the context that will be sent to the uploader when uploading
# and deleting. Can be modified with additional data to be sent to the
# uploader.
attr_reader :context
# Returns an array of validation errors created on file assignment in
# the `Attacher.validate` block.
attr_reader :errors
# Initializes the necessary attributes.
def initialize(record, name, cache: :cache, store: :store)
@cache = shrine_class.new(cache)
@store = shrine_class.new(store)
@context = {record: record, name: name}
@errors = []
end
# Returns the model instance associated with the attacher.
def record; context[:record]; end
# Returns the attachment name associated with the attacher.
def name; context[:name]; end
# Receives the attachment value from the form. It can receive an
# already cached file as a JSON string, otherwise it assumes that it's
# an IO object and uploads it to the temporary storage. The cached file
# is then written to the attachment attribute in the JSON format.
def assign(value)
if value.is_a?(String)
return if value == "" || !cache.uploaded?(uploaded_file(value))
assign_cached(uploaded_file(value))
else
uploaded_file = cache!(value, action: :cache) if value
set(uploaded_file)
end
end
# Accepts a Shrine::UploadedFile object and writes is to the attachment
# attribute. It then runs file validations, and records that the
# attachment has changed.
def set(uploaded_file)
@old = get unless uploaded_file == get
_set(uploaded_file)
validate
end
# Runs the validations defined by `Attacher.validate`.
def validate
errors.clear
validate_block if get
end
# Returns true if a new file has been attached.
def changed?
instance_variable_defined?(:@old)
end
alias attached? changed?
# Plugins can override this if they want something to be done before
# save.
def save
end
# Deletes the old file and promotes the new one. Typically this should
# be called after saving the model instance.
def finalize
return if !instance_variable_defined?(:@old)
replace
remove_instance_variable(:@old)
_promote(action: :store) if cached?
end
# Delegates to #promote, overriden for backgrounding.
def _promote(uploaded_file = get, **options)
promote(uploaded_file, **options)
end
# Uploads the cached file to store, and writes the stored file to the
# attachment attribute.
def promote(uploaded_file = get, **options)
stored_file = store!(uploaded_file, **options)
result = swap(stored_file) or _delete(stored_file, action: :abort)
result
end
# Calls #update, overriden in ORM plugins, and returns true if the
# attachment was successfully updated.
def swap(uploaded_file)
update(uploaded_file)
uploaded_file if uploaded_file == get
end
# Deletes the previous attachment that was replaced, typically called
# after the model instance is saved with the new attachment.
def replace
_delete(@old, action: :replace) if @old && !cache.uploaded?(@old)
end
# Deletes the current attachment, typically called after destroying the
# record.
def destroy
_delete(get, action: :destroy) if get && !cache.uploaded?(get)
end
# Delegates to #delete!, overriden for backgrounding.
def _delete(uploaded_file, **options)
delete!(uploaded_file, **options)
end
# Returns the URL to the attached file if it's present. It forwards any
# given URL options to the storage.
def url(**options)
get.url(**options) if read
end
# Returns true if attachment is present and cached.
def cached?
get && cache.uploaded?(get)
end
# Returns true if attachment is present and stored.
def stored?
get && store.uploaded?(get)
end
# Returns a Shrine::UploadedFile instantiated from the data written to
# the attachment attribute.
def get
uploaded_file(read) if read
end
# Reads from the `<attachment>_data` attribute on the model instance.
# It returns nil if the value is blank.
def read
value = record.send(data_attribute)
convert_after_read(value) unless value.nil? || value.empty?
end
# Uploads the file using the #cache uploader, passing the #context.
def cache!(io, **options)
Shrine.deprecation("Sending :phase to Attacher#cache! is deprecated and will not be supported in Shrine 3. Use :action instead.") if options[:phase]
cache.upload(io, context.merge(_equalize_phase_and_action(options)))
end
# Uploads the file using the #store uploader, passing the #context.
def store!(io, **options)
Shrine.deprecation("Sending :phase to Attacher#store! is deprecated and will not be supported in Shrine 3. Use :action instead.") if options[:phase]
store.upload(io, context.merge(_equalize_phase_and_action(options)))
end
# Deletes the file using the uploader, passing the #context.
def delete!(uploaded_file, **options)
Shrine.deprecation("Sending :phase to Attacher#delete! is deprecated and will not be supported in Shrine 3. Use :action instead.") if options[:phase]
store.delete(uploaded_file, context.merge(_equalize_phase_and_action(options)))
end
# Enhances `Shrine.uploaded_file` with the ability to recognize uploaded
# files as JSON strings.
def uploaded_file(object, &block)
shrine_class.uploaded_file(object, &block)
end
# The name of the attribute on the model instance that is used to store
# the attachment data. Defaults to `<attachment>_data`.
def data_attribute
:"#{name}_data"
end
# Returns the Shrine class that this attacher's class is namespaced
# under.
def shrine_class
self.class.shrine_class
end
private
# Assigns a cached file.
def assign_cached(cached_file)
set(cached_file)
end
# Writes the uploaded file the attachment attribute. Overriden in ORM
# plugins to additionally save the model instance.
def update(uploaded_file)
_set(uploaded_file)
end
# Performs validation actually.
# This method is redefined with `Attacher.validate`.
def validate_block
end
# Converts the UploadedFile to a data hash and writes it to the
# attribute.
def _set(uploaded_file)
data = convert_to_data(uploaded_file) if uploaded_file
write(data ? convert_before_write(data) : nil)
end
# Writes to the `<attachment>_data` attribute on the model instance.
def write(value)
record.send(:"#{data_attribute}=", value)
end
# Returns the data hash of the given UploadedFile.
def convert_to_data(uploaded_file)
uploaded_file.data
end
# Returns the hash value dumped to JSON.
def convert_before_write(value)
value.to_json
end
# Returns the read value unchanged.
def convert_after_read(value)
value
end
# Temporary method used for transitioning from :phase to :action.
def _equalize_phase_and_action(options)
options[:phase] = options[:action] if options.key?(:action)
options[:action] = options[:phase] if options.key?(:phase)
options
end
end
module FileClassMethods
# Returns the Shrine class that this file class is namespaced under.
attr_accessor :shrine_class
# Since UploadedFile is anonymously subclassed when Shrine is subclassed,
# and then assigned to a constant of the Shrine subclass, make inspect
# reflect the likely name for the class.
def inspect
"#{shrine_class.inspect}::UploadedFile"
end
end
module FileMethods
# The hash of information which defines this uploaded file.
attr_reader :data
# Initializes the uploaded file with the given data hash.
def initialize(data)
raise Error, "#{data.inspect} isn't valid uploaded file data" unless data["id"] && data["storage"]
@data = data
@data["metadata"] ||= {}
storage # ensure storage is registered
end
# The location where the file was uploaded to the storage.
def id
@data.fetch("id")
end
# The string identifier of the storage the file is uploaded to.
def storage_key
@data.fetch("storage")
end
# A hash of file metadata that was extracted during upload.
def metadata
@data.fetch("metadata")
end
# The filename that was extracted from the uploaded file.
def original_filename
metadata["filename"]
end
# The extension derived from #id if present, otherwise it's derived
# from #original_filename.
def extension
result = File.extname(id)[1..-1] || File.extname(original_filename.to_s)[1..-1]
result.downcase if result
end
# The filesize of the uploaded file.
def size
(@io && @io.size) || (metadata["size"] && Integer(metadata["size"]))
end
# The MIME type of the uploaded file.
def mime_type
metadata["mime_type"]
end
alias content_type mime_type
# Calls `#open` on the storage to open the uploaded file for reading.
# Most storages will return a lazy IO object which dynamically
# retrieves file content from the storage as the object is being read.
#
# If a block is given, the opened IO object is yielded to the block,
# and at the end of the block it's automatically closed. In this case
# the return value of the method is the block return value.
#
# If no block is given, the opened IO object is returned.
#
# uploaded_file.open #=> IO object returned by the storage
# uploaded_file.read #=> "..."
# uploaded_file.close
#
# # or
#
# uploaded_file.open { |io| io.read } # the IO is automatically closed
def open(*args)
@io.close if @io && !(@io.respond_to?(:closed?) && @io.closed?)
@io = storage.open(id, *args)
return @io unless block_given?
begin
yield @io
ensure
@io.close
@io = nil
end
end
# Calls `#download` on the storage if the storage implements it,
# otherwise streams content into a newly created Tempfile.
#
# If a block is given, the opened Tempfile object is yielded to the
# block, and at the end of the block it's automatically closed and
# deleted. In this case the return value of the method is the block
# return value.
#
# If no block is given, the opened Tempfile is returned.
#
# uploaded_file.download
# #=> #<File:/var/folders/.../20180302-33119-1h1vjbq.jpg>
#
# # or
#
# uploaded_file.download { |tempfile| tempfile.read } # tempfile is deleted
def download(*args)
if storage.respond_to?(:download)
tempfile = storage.download(id, *args)
else
tempfile = Tempfile.new(["shrine", ".#{extension}"], binmode: true)
stream(tempfile, *args)
tempfile.open
end
block_given? ? yield(tempfile) : tempfile
ensure
tempfile.close! if ($! || block_given?) && tempfile
end
# Streams uploaded file content into the specified destination. The
# destination object is given directly to `IO.copy_stream`, so it can
# be either a path on disk or an object that responds to `#write`.
#
# If the uploaded file is already opened, it will be simply rewinded
# after streaming finishes. Otherwise the uploaded file is opened and
# then closed after streaming.
#
# uploaded_file.stream(StringIO.new)
# # or
# uploaded_file.stream("/path/to/destination")
def stream(destination, *args)
if @io
IO.copy_stream(io, destination)
io.rewind
else
open(*args) { |io| IO.copy_stream(io, destination) }
end
end
# Part of complying to the IO interface. It delegates to the internally
# opened IO object.
def read(*args)
io.read(*args)
end
# Part of complying to the IO interface. It delegates to the internally
# opened IO object.
def eof?
io.eof?
end
# Part of complying to the IO interface. It delegates to the internally
# opened IO object.
def close
io.close if @io
end
# Part of complying to the IO interface. It delegates to the internally
# opened IO object.
def rewind
io.rewind
end
# Calls `#url` on the storage, forwarding any given URL options.
def url(**options)
storage.url(id, **options)
end
# Calls `#exists?` on the storage, which checks whether the file exists
# on the storage.
def exists?
storage.exists?(id)
end
# Uploads a new file to this file's location and returns it.
def replace(io, context = {})
uploader.upload(io, context.merge(location: id))
end
# Calls `#delete` on the storage, which deletes the file from the
# storage.
def delete
storage.delete(id)
end
# Returns an opened IO object for the uploaded file.
def to_io
io
end
# Returns the data hash in the JSON format. Suitable for storing in a
# database column or passing to a background job.
def to_json(*args)
data.to_json(*args)
end
# Conform to ActiveSupport's JSON interface.
def as_json(*args)
data
end
# Returns true if the other UploadedFile is uploaded to the same
# storage and it has the same #id.
def ==(other)
other.is_a?(self.class) &&
self.id == other.id &&
self.storage_key == other.storage_key
end
alias eql? ==
# Enables using UploadedFile objects as hash keys.
def hash
[id, storage_key].hash
end
# Returns an uploader object for the corresponding storage.
def uploader
shrine_class.new(storage_key)
end
# Returns the storage that this file was uploaded to.
def storage
shrine_class.find_storage(storage_key)
end
# Returns the Shrine class that this file's class is namespaced under.
def shrine_class
self.class.shrine_class
end
private
# Returns an opened IO object for the uploaded file by calling `#open`
# on the storage.
def io
@io || open
end
end
end
end
extend Plugins::Base::ClassMethods
plugin Plugins::Base
end