shrine 3.0.0.beta2 → 3.0.0.beta3
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +100 -106
- data/doc/advantages.md +90 -88
- data/doc/attacher.md +322 -152
- data/doc/carrierwave.md +105 -113
- data/doc/changing_derivatives.md +308 -0
- data/doc/changing_location.md +92 -21
- data/doc/changing_storage.md +107 -0
- data/doc/creating_plugins.md +1 -1
- data/doc/design.md +8 -9
- data/doc/direct_s3.md +3 -2
- data/doc/metadata.md +97 -78
- data/doc/multiple_files.md +3 -3
- data/doc/paperclip.md +89 -88
- data/doc/plugins/activerecord.md +3 -12
- data/doc/plugins/backgrounding.md +126 -100
- data/doc/plugins/derivation_endpoint.md +4 -5
- data/doc/plugins/derivatives.md +63 -32
- data/doc/plugins/download_endpoint.md +54 -1
- data/doc/plugins/entity.md +1 -0
- data/doc/plugins/form_assign.md +53 -0
- data/doc/plugins/mirroring.md +37 -16
- data/doc/plugins/multi_cache.md +22 -0
- data/doc/plugins/presign_endpoint.md +1 -1
- data/doc/plugins/remote_url.md +19 -4
- data/doc/plugins/validation.md +83 -0
- data/doc/processing.md +149 -133
- data/doc/refile.md +68 -63
- data/doc/release_notes/3.0.0.md +835 -0
- data/doc/securing_uploads.md +56 -36
- data/doc/storage/s3.md +2 -2
- data/doc/testing.md +104 -120
- data/doc/upgrading_to_3.md +538 -0
- data/doc/validation.md +48 -87
- data/lib/shrine.rb +7 -4
- data/lib/shrine/attacher.rb +16 -6
- data/lib/shrine/plugins/activerecord.rb +33 -14
- data/lib/shrine/plugins/atomic_helpers.rb +1 -1
- data/lib/shrine/plugins/backgrounding.rb +23 -89
- data/lib/shrine/plugins/data_uri.rb +13 -2
- data/lib/shrine/plugins/derivation_endpoint.rb +7 -11
- data/lib/shrine/plugins/derivatives.rb +44 -20
- data/lib/shrine/plugins/download_endpoint.rb +26 -0
- data/lib/shrine/plugins/form_assign.rb +6 -3
- data/lib/shrine/plugins/keep_files.rb +2 -2
- data/lib/shrine/plugins/mirroring.rb +62 -22
- data/lib/shrine/plugins/model.rb +2 -2
- data/lib/shrine/plugins/multi_cache.rb +27 -0
- data/lib/shrine/plugins/remote_url.rb +25 -10
- data/lib/shrine/plugins/remove_invalid.rb +1 -1
- data/lib/shrine/plugins/sequel.rb +39 -20
- data/lib/shrine/plugins/validation.rb +3 -0
- data/lib/shrine/storage/s3.rb +16 -1
- data/lib/shrine/uploaded_file.rb +1 -0
- data/lib/shrine/version.rb +1 -1
- data/shrine.gemspec +1 -1
- metadata +12 -7
- data/doc/migrating_storage.md +0 -76
- data/doc/regenerating_versions.md +0 -143
- data/lib/shrine/plugins/attacher_options.rb +0 -55
@@ -43,11 +43,11 @@ class Shrine
|
|
43
43
|
super if defined?(super)
|
44
44
|
|
45
45
|
define_method :"#{name}_data_uri=" do |uri|
|
46
|
-
send(:"#{name}_attacher").
|
46
|
+
send(:"#{name}_attacher").data_uri = uri
|
47
47
|
end
|
48
48
|
|
49
49
|
define_method :"#{name}_data_uri" do
|
50
|
-
#
|
50
|
+
send(:"#{name}_attacher").data_uri
|
51
51
|
end
|
52
52
|
end
|
53
53
|
end
|
@@ -115,6 +115,17 @@ class Shrine
|
|
115
115
|
false
|
116
116
|
end
|
117
117
|
|
118
|
+
# Used by `<name>_data_uri=` attachment method.
|
119
|
+
def data_uri=(uri)
|
120
|
+
assign_data_uri(uri)
|
121
|
+
@data_uri = uri
|
122
|
+
end
|
123
|
+
|
124
|
+
# Used by `<name>_data_uri` attachment method.
|
125
|
+
def data_uri
|
126
|
+
@data_uri
|
127
|
+
end
|
128
|
+
|
118
129
|
private
|
119
130
|
|
120
131
|
# Generates an error message for failed data URI parse.
|
@@ -605,18 +605,14 @@ class Shrine
|
|
605
605
|
|
606
606
|
# Massages the derivation result, ensuring it's opened in binary mode,
|
607
607
|
# rewinded and flushed to disk.
|
608
|
-
def normalize(
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
when File then File.open(derivative.tap(&:close))
|
613
|
-
when String, Pathname then File.open(derivative)
|
614
|
-
else
|
615
|
-
fail Error, "unexpected derivation result: #{derivation.inspect} (expected File, Tempfile, String, or Pathname object)"
|
616
|
-
end
|
608
|
+
def normalize(file)
|
609
|
+
unless file.is_a?(File) || file.is_a?(Tempfile)
|
610
|
+
fail Error, "expected File or Tempfile object as derivation result, got #{file.inspect}"
|
611
|
+
end
|
617
612
|
|
618
|
-
|
619
|
-
|
613
|
+
file.open if file.is_a?(Tempfile) # refresh file descriptor
|
614
|
+
file.binmode # ensure binary mode
|
615
|
+
file
|
620
616
|
end
|
621
617
|
|
622
618
|
def with_downloaded(file, &block)
|
@@ -6,6 +6,8 @@ class Shrine
|
|
6
6
|
#
|
7
7
|
# [doc/plugins/derivatives.md]: https://github.com/shrinerb/shrine/blob/master/doc/plugins/derivatives.md
|
8
8
|
module Derivatives
|
9
|
+
NOOP_PROCESSOR = -> (*) { Hash.new }
|
10
|
+
|
9
11
|
LOG_SUBSCRIBER = -> (event) do
|
10
12
|
Shrine.logger.info "Derivatives (#{event.duration}ms) – #{{
|
11
13
|
processor: event[:processor],
|
@@ -52,12 +54,16 @@ class Shrine
|
|
52
54
|
# Attacher.derivatives_processor :thumbnails do |original|
|
53
55
|
# # ...
|
54
56
|
# end
|
55
|
-
def derivatives_processor(name, &block)
|
57
|
+
def derivatives_processor(name = :default, &block)
|
56
58
|
if block
|
57
59
|
shrine_class.opts[:derivatives][:processors][name.to_sym] = block
|
58
60
|
else
|
59
|
-
shrine_class.opts[:derivatives][:processors][name.to_sym]
|
60
|
-
|
61
|
+
processor = shrine_class.opts[:derivatives][:processors][name.to_sym]
|
62
|
+
processor ||= NOOP_PROCESSOR if name == :default
|
63
|
+
|
64
|
+
fail Error, "derivatives processor #{name.inspect} not registered" unless processor
|
65
|
+
|
66
|
+
processor
|
61
67
|
end
|
62
68
|
end
|
63
69
|
|
@@ -139,9 +145,9 @@ class Shrine
|
|
139
145
|
# attacher.add_derivative(:thumb, file, storage: :cache)
|
140
146
|
# attacher.promote
|
141
147
|
# attacher.stored?(attacher.derivatives[:thumb]) #=> true
|
142
|
-
def promote(
|
148
|
+
def promote(**options)
|
143
149
|
super
|
144
|
-
promote_derivatives
|
150
|
+
promote_derivatives
|
145
151
|
end
|
146
152
|
|
147
153
|
# Uploads any cached derivatives to permanent storage.
|
@@ -163,9 +169,9 @@ class Shrine
|
|
163
169
|
# attacher.derivatives[:thumb].exists? #=> true
|
164
170
|
# attacher.destroy
|
165
171
|
# attacher.derivatives[:thumb].exists? #=> false
|
166
|
-
def destroy
|
172
|
+
def destroy
|
167
173
|
super
|
168
|
-
delete_derivatives
|
174
|
+
delete_derivatives
|
169
175
|
end
|
170
176
|
|
171
177
|
# Calls processor and adds returned derivatives.
|
@@ -235,6 +241,9 @@ class Shrine
|
|
235
241
|
def upload_derivative(path, file, storage: nil, **options)
|
236
242
|
storage ||= derivative_storage(path)
|
237
243
|
|
244
|
+
file.open if file.is_a?(Tempfile) # refresh file descriptor
|
245
|
+
file.binmode if file.respond_to?(:binmode) # ensure binary mode
|
246
|
+
|
238
247
|
upload(file, storage, derivative: path, delete: true, **options)
|
239
248
|
end
|
240
249
|
|
@@ -252,22 +261,22 @@ class Shrine
|
|
252
261
|
#
|
253
262
|
# attacher.process_derivatives(:thumbnails)
|
254
263
|
# #=> { small: #<File:...>, medium: #<File:...>, large: #<File:...> }
|
255
|
-
def process_derivatives(processor_name, source =
|
256
|
-
processor
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
fetch_source.call do |source_file|
|
261
|
-
instrument_derivatives(processor_name, options) do
|
262
|
-
result = instance_exec(source_file, **options, &processor)
|
263
|
-
end
|
264
|
+
def process_derivatives(processor_name = :default, source = nil, **options)
|
265
|
+
# handle receiving only source file without a processor
|
266
|
+
unless processor_name.respond_to?(:to_sym)
|
267
|
+
source = processor_name
|
268
|
+
processor_name = :default
|
264
269
|
end
|
265
270
|
|
266
|
-
|
267
|
-
fail Error, "expected derivatives processor #{processor_name.inspect} to return a Hash, got #{result.inspect}"
|
268
|
-
end
|
271
|
+
source ||= file!
|
269
272
|
|
270
|
-
|
273
|
+
if source.is_a?(UploadedFile)
|
274
|
+
source.download do |file|
|
275
|
+
_process_derivatives(processor_name, file, **options)
|
276
|
+
end
|
277
|
+
else
|
278
|
+
_process_derivatives(processor_name, source, **options)
|
279
|
+
end
|
271
280
|
end
|
272
281
|
|
273
282
|
# Deep merges given uploaded derivatives with current derivatives.
|
@@ -461,6 +470,21 @@ class Shrine
|
|
461
470
|
|
462
471
|
private
|
463
472
|
|
473
|
+
# Calls the derivatives processor with the source file and options.
|
474
|
+
def _process_derivatives(processor_name, source, **options)
|
475
|
+
processor = self.class.derivatives_processor(processor_name)
|
476
|
+
|
477
|
+
result = instrument_derivatives(processor_name, options) do
|
478
|
+
instance_exec(source, **options, &processor)
|
479
|
+
end
|
480
|
+
|
481
|
+
unless result.is_a?(Hash)
|
482
|
+
fail Error, "expected derivatives processor #{processor_name.inspect} to return a Hash, got #{result.inspect}"
|
483
|
+
end
|
484
|
+
|
485
|
+
result
|
486
|
+
end
|
487
|
+
|
464
488
|
# Sends a `derivatives.shrine` event for instrumentation plugin.
|
465
489
|
def instrument_derivatives(processor_name, processor_options, &block)
|
466
490
|
return yield unless shrine_class.respond_to?(:instrument)
|
@@ -25,6 +25,32 @@ class Shrine
|
|
25
25
|
**options,
|
26
26
|
)
|
27
27
|
end
|
28
|
+
|
29
|
+
# Calls the download endpoint passing the request information, and
|
30
|
+
# returns the Rack response triple.
|
31
|
+
#
|
32
|
+
# It uses a trick where it removes the download path prefix from the
|
33
|
+
# path info before calling the Rack app, which is what web framework
|
34
|
+
# routers do before they're calling a mounted Rack app.
|
35
|
+
def download_response(env, **options)
|
36
|
+
script_name = env["SCRIPT_NAME"]
|
37
|
+
path_info = env["PATH_INFO"]
|
38
|
+
|
39
|
+
prefix = opts[:download_endpoint][:prefix]
|
40
|
+
match = path_info.match(/^\/#{prefix}/)
|
41
|
+
|
42
|
+
fail Error, "request path must start with \"/#{prefix}\", but is \"#{path_info}\"" unless match
|
43
|
+
|
44
|
+
begin
|
45
|
+
env["SCRIPT_NAME"] += match.to_s
|
46
|
+
env["PATH_INFO"] = match.post_match
|
47
|
+
|
48
|
+
download_endpoint(**options).call(env)
|
49
|
+
ensure
|
50
|
+
env["SCRIPT_NAME"] = script_name
|
51
|
+
env["PATH_INFO"] = path_info
|
52
|
+
end
|
53
|
+
end
|
28
54
|
end
|
29
55
|
|
30
56
|
module FileMethods
|
@@ -2,6 +2,9 @@
|
|
2
2
|
|
3
3
|
class Shrine
|
4
4
|
module Plugins
|
5
|
+
# Documentation lives in [doc/plugins/form_assign.md] on GitHub.
|
6
|
+
#
|
7
|
+
# [doc/plugins/form_assign.md]: https://github.com/shrinerb/shrine/blob/master/doc/plugins/form_assign.md
|
5
8
|
module FormAssign
|
6
9
|
def self.load_dependencies(uploader)
|
7
10
|
uploader.plugin :entity
|
@@ -77,8 +80,8 @@ class Shrine
|
|
77
80
|
return fields unless changed?
|
78
81
|
|
79
82
|
case result_type
|
80
|
-
when :params then fields[name]
|
81
|
-
when :attributes then fields[
|
83
|
+
when :params then fields[name] = file&.to_json
|
84
|
+
when :attributes then fields[attribute] = column_data
|
82
85
|
else
|
83
86
|
fail ArgumentError, "unrecognized result type: #{result_type.inspect}"
|
84
87
|
end
|
@@ -93,7 +96,7 @@ class Shrine
|
|
93
96
|
shrine_subclass.plugin :model
|
94
97
|
|
95
98
|
# create a model class with attachment methods
|
96
|
-
form_class = Struct.new(
|
99
|
+
form_class = Struct.new(attribute)
|
97
100
|
form_class.include shrine_subclass::Attachment(name)
|
98
101
|
|
99
102
|
# instantiate form object
|
@@ -3,11 +3,8 @@
|
|
3
3
|
class Shrine
|
4
4
|
module Plugins
|
5
5
|
module Mirroring
|
6
|
-
UPLOAD = -> (uploaded_file) { uploaded_file.mirror_upload }
|
7
|
-
DELETE = -> (uploaded_file) { uploaded_file.mirror_delete }
|
8
|
-
|
9
6
|
def self.configure(uploader, **opts)
|
10
|
-
uploader.opts[:mirroring] ||= { upload:
|
7
|
+
uploader.opts[:mirroring] ||= { upload: true, delete: true }
|
11
8
|
uploader.opts[:mirroring].merge!(opts)
|
12
9
|
|
13
10
|
fail Error, ":mirror option is required for mirroring plugin" unless uploader.opts[:mirroring][:mirror]
|
@@ -26,36 +23,61 @@ class Shrine
|
|
26
23
|
end
|
27
24
|
end
|
28
25
|
|
29
|
-
def
|
26
|
+
def mirror_upload_block(&block)
|
30
27
|
if block
|
31
|
-
opts[:mirroring][:
|
28
|
+
opts[:mirroring][:upload_block] = block
|
32
29
|
else
|
33
|
-
opts[:mirroring][:
|
30
|
+
opts[:mirroring][:upload_block]
|
34
31
|
end
|
35
32
|
end
|
36
33
|
|
37
|
-
def
|
34
|
+
def mirror_delete_block(&block)
|
38
35
|
if block
|
39
|
-
opts[:mirroring][:
|
36
|
+
opts[:mirroring][:delete_block] = block
|
40
37
|
else
|
41
|
-
opts[:mirroring][:
|
38
|
+
opts[:mirroring][:delete_block]
|
42
39
|
end
|
43
40
|
end
|
41
|
+
|
42
|
+
def mirror_upload?
|
43
|
+
opts[:mirroring][:upload]
|
44
|
+
end
|
45
|
+
|
46
|
+
def mirror_delete?
|
47
|
+
opts[:mirroring][:delete]
|
48
|
+
end
|
44
49
|
end
|
45
50
|
|
46
51
|
module InstanceMethods
|
47
|
-
|
48
|
-
|
52
|
+
# Mirrors upload to other mirror storages.
|
53
|
+
def upload(io, mirror: true, **options)
|
54
|
+
file = super(io, **options)
|
55
|
+
file.trigger_mirror_upload if mirror
|
56
|
+
file
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
module FileMethods
|
61
|
+
# Mirrors upload if mirrors are defined. Calls mirror block if
|
62
|
+
# registered, otherwise mirrors synchronously.
|
63
|
+
def trigger_mirror_upload
|
64
|
+
return unless shrine_class.mirrors[storage_key] && shrine_class.mirror_upload?
|
49
65
|
|
50
|
-
if
|
51
|
-
|
66
|
+
if shrine_class.mirror_upload_block
|
67
|
+
mirror_upload_background
|
68
|
+
else
|
69
|
+
mirror_upload
|
52
70
|
end
|
71
|
+
end
|
53
72
|
|
54
|
-
|
73
|
+
# Calls mirror upload block.
|
74
|
+
def mirror_upload_background
|
75
|
+
fail Error, "mirror upload block is not registered" unless shrine_class.mirror_upload_block
|
76
|
+
|
77
|
+
shrine_class.mirror_upload_block.call(self)
|
55
78
|
end
|
56
|
-
end
|
57
79
|
|
58
|
-
|
80
|
+
# Uploads the file to each mirror storage.
|
59
81
|
def mirror_upload
|
60
82
|
previously_opened = opened?
|
61
83
|
|
@@ -71,16 +93,33 @@ class Shrine
|
|
71
93
|
end
|
72
94
|
end
|
73
95
|
|
74
|
-
|
75
|
-
|
96
|
+
# Mirrors delete to other mirror storages.
|
97
|
+
def delete(mirror: true)
|
98
|
+
result = super()
|
99
|
+
trigger_mirror_delete if mirror
|
100
|
+
result
|
101
|
+
end
|
102
|
+
|
103
|
+
# Mirrors delete if mirrors are defined. Calls mirror block if
|
104
|
+
# registered, otherwise mirrors synchronously.
|
105
|
+
def trigger_mirror_delete
|
106
|
+
return unless shrine_class.mirrors[storage_key] && shrine_class.mirror_delete?
|
76
107
|
|
77
|
-
if shrine_class.
|
78
|
-
|
108
|
+
if shrine_class.mirror_delete_block
|
109
|
+
mirror_delete_background
|
110
|
+
else
|
111
|
+
mirror_delete
|
79
112
|
end
|
113
|
+
end
|
80
114
|
|
81
|
-
|
115
|
+
# Calls mirror delete block.
|
116
|
+
def mirror_delete_background
|
117
|
+
fail Error, "mirror delete block is not registered" unless shrine_class.mirror_delete_block
|
118
|
+
|
119
|
+
shrine_class.mirror_delete_block.call(self)
|
82
120
|
end
|
83
121
|
|
122
|
+
# Deletes the file from each mirror storage.
|
84
123
|
def mirror_delete
|
85
124
|
each_mirror do |mirror|
|
86
125
|
self.class.new(id: id, storage: mirror).delete
|
@@ -89,6 +128,7 @@ class Shrine
|
|
89
128
|
|
90
129
|
private
|
91
130
|
|
131
|
+
# Iterates over mirror storages.
|
92
132
|
def each_mirror(&block)
|
93
133
|
mirrors = shrine_class.mirrors(storage_key)
|
94
134
|
mirrors.map(&block)
|
data/lib/shrine/plugins/model.rb
CHANGED
@@ -18,8 +18,8 @@ class Shrine
|
|
18
18
|
module AttachmentMethods
|
19
19
|
# Allows disabling model behaviour:
|
20
20
|
#
|
21
|
-
# Shrine::Attachment
|
22
|
-
# Shrine::Attachment
|
21
|
+
# Shrine::Attachment(:image) # model (default)
|
22
|
+
# Shrine::Attachment(:image, model: false) # entity
|
23
23
|
def initialize(name, model: true, **options)
|
24
24
|
super(name, **options)
|
25
25
|
@model = model
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class Shrine
|
2
|
+
module Plugins
|
3
|
+
# Documentation lives in [doc/plugins/multi_cache.md] on GitHub.
|
4
|
+
#
|
5
|
+
# [doc/plugins/multi_cache.md]: https://github.com/shrinerb/shrine/blob/master/doc/plugins/multi_cache.md
|
6
|
+
module MultiCache
|
7
|
+
def self.configure(uploader, **opts)
|
8
|
+
uploader.opts[:multi_cache] ||= {}
|
9
|
+
uploader.opts[:multi_cache].merge!(opts)
|
10
|
+
end
|
11
|
+
|
12
|
+
module AttacherMethods
|
13
|
+
def cached?(file = self.file)
|
14
|
+
super || additional_cache.any? { |key| uploaded?(file, key) }
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def additional_cache
|
20
|
+
Array(shrine_class.opts[:multi_cache][:additional_cache])
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
register_plugin(:multi_cache, MultiCache)
|
26
|
+
end
|
27
|
+
end
|
@@ -18,13 +18,7 @@ class Shrine
|
|
18
18
|
}.inspect}"
|
19
19
|
end
|
20
20
|
|
21
|
-
DOWNLOADER = -> (url, options)
|
22
|
-
begin
|
23
|
-
Down.download(url, options)
|
24
|
-
rescue Down::Error => error
|
25
|
-
raise DownloadError, error.message
|
26
|
-
end
|
27
|
-
end
|
21
|
+
DOWNLOADER = -> (url, options) { Down.download(url, options) }
|
28
22
|
|
29
23
|
def self.load_dependencies(uploader, *)
|
30
24
|
uploader.plugin :validation
|
@@ -47,11 +41,11 @@ class Shrine
|
|
47
41
|
super if defined?(super)
|
48
42
|
|
49
43
|
define_method :"#{name}_remote_url=" do |url|
|
50
|
-
send(:"#{name}_attacher").
|
44
|
+
send(:"#{name}_attacher").remote_url = url
|
51
45
|
end
|
52
46
|
|
53
47
|
define_method :"#{name}_remote_url" do
|
54
|
-
#
|
48
|
+
send(:"#{name}_attacher").remote_url
|
55
49
|
end
|
56
50
|
end
|
57
51
|
end
|
@@ -64,12 +58,22 @@ class Shrine
|
|
64
58
|
options = { max_size: opts[:remote_url][:max_size] }.merge(options)
|
65
59
|
|
66
60
|
instrument_remote_url(url, options) do
|
67
|
-
|
61
|
+
download_remote_url(url, options)
|
68
62
|
end
|
69
63
|
end
|
70
64
|
|
71
65
|
private
|
72
66
|
|
67
|
+
def download_remote_url(url, options)
|
68
|
+
opts[:remote_url][:downloader].call(url, options)
|
69
|
+
rescue Down::NotFound
|
70
|
+
fail DownloadError, "remote file not found"
|
71
|
+
rescue Down::TooLarge
|
72
|
+
fail DownloadError, "remote file too large"
|
73
|
+
rescue DownloadError
|
74
|
+
fail
|
75
|
+
end
|
76
|
+
|
73
77
|
# Sends a `remote_url.shrine` event for instrumentation plugin.
|
74
78
|
def instrument_remote_url(url, options, &block)
|
75
79
|
return yield unless respond_to?(:instrument)
|
@@ -92,6 +96,17 @@ class Shrine
|
|
92
96
|
false
|
93
97
|
end
|
94
98
|
|
99
|
+
# Used by `<name>_data_uri=` attachment method.
|
100
|
+
def remote_url=(url)
|
101
|
+
assign_remote_url(url)
|
102
|
+
@remote_url = url
|
103
|
+
end
|
104
|
+
|
105
|
+
# Used by `<name>_data_uri` attachment method.
|
106
|
+
def remote_url
|
107
|
+
@remote_url
|
108
|
+
end
|
109
|
+
|
95
110
|
private
|
96
111
|
|
97
112
|
# Generates an error message for failed remote URL download.
|