image_vise 0.5.0 → 0.8.1
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.
- checksums.yaml +4 -4
- data/.travis.yml +1 -4
- data/SECURITY.md +2 -2
- data/examples/error_handling_appsignal.rb +1 -1
- data/image_vise.gemspec +7 -5
- data/lib/image_vise.rb +12 -5
- data/lib/image_vise/fetchers/fetcher_file.rb +23 -3
- data/lib/image_vise/fetchers/fetcher_http.rb +19 -7
- data/lib/image_vise/image_request.rb +2 -2
- data/lib/image_vise/pipeline.rb +1 -1
- data/lib/image_vise/render_engine.rb +40 -27
- data/lib/image_vise/version.rb +1 -1
- data/lib/image_vise/writers/auto_writer.rb +4 -12
- data/lib/image_vise/writers/jpeg_writer.rb +2 -2
- data/spec/image_vise/fetcher_file_spec.rb +18 -1
- data/spec/image_vise/fetcher_http_spec.rb +15 -0
- data/spec/image_vise/render_engine_spec.rb +9 -6
- data/spec/image_vise_spec.rb +33 -0
- data/spec/spec_helper.rb +1 -0
- metadata +50 -25
- data/lib/measurometer.rb +0 -92
- data/spec/measurometer_spec.rb +0 -58
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c6f634532f563e0b00eb6045e7865f32bf88b23981087b51c144961b7b876622
|
4
|
+
data.tar.gz: 5876a712fb46e83ec0c36bbc2db4527ffe60b9d094591c85c3de7ee445dafb9a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 877999dcdfb883a007cfa81989a40e394e95960c92efa87eded7f5b09b226b31d287fa17941f813413d5a3e0a02b448d4295b34e538c8f7739b230fca55ec285
|
7
|
+
data.tar.gz: 8c3cb09419af7583bb2ecafa7f47925c900cec0024b5705e5d9c09214947f2cbda2f957bdfbd7d2c7175b73de65159726a9b3737be4f74fdb3d4f2ffbf89c9cf
|
data/.travis.yml
CHANGED
data/SECURITY.md
CHANGED
@@ -31,9 +31,9 @@ CDN cache because the query string params contain extra data.
|
|
31
31
|
|
32
32
|
## Protection for remote URLs from HTTP(s) origins
|
33
33
|
|
34
|
-
Only URLs
|
34
|
+
Only URLs on whitelisted hosts are going to be fetched. If there are no hosts added,
|
35
35
|
any remote URL is going to cause an exception. No special verification for whether the upstream must be HTTP
|
36
|
-
or HTTPS is performed at this time.
|
36
|
+
or HTTPS is performed at this time, but HTTPS upstreams' SSL certificats _will_ be verified.
|
37
37
|
|
38
38
|
## Protection for "file:/" URLs
|
39
39
|
|
@@ -1,7 +1,7 @@
|
|
1
1
|
# Anywhere in your app code
|
2
2
|
module ImageViseAppsignal
|
3
3
|
ImageVise::RenderEngine.prepend self
|
4
|
-
|
4
|
+
Measurometer.drivers << Appsignal # to obtain ImageVise instrumentation
|
5
5
|
def setup_error_handling(rack_env)
|
6
6
|
txn = Appsignal::Transaction.current
|
7
7
|
txn.set_action('%s#%s' % [self.class, 'call'])
|
data/image_vise.gemspec
CHANGED
@@ -21,19 +21,21 @@ Gem::Specification.new do |spec|
|
|
21
21
|
else
|
22
22
|
raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
|
23
23
|
end
|
24
|
-
|
24
|
+
|
25
25
|
spec.files = `git ls-files -z`.split("\x0")
|
26
26
|
spec.bindir = "exe"
|
27
27
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
28
28
|
spec.require_paths = ["lib"]
|
29
29
|
|
30
|
-
spec.add_dependency 'patron', '~> 0.
|
31
|
-
spec.add_dependency 'rmagick', '~>
|
30
|
+
spec.add_dependency 'patron', '~> 0.9'
|
31
|
+
spec.add_dependency 'rmagick', '~> 3'
|
32
32
|
spec.add_dependency 'ks'
|
33
|
-
spec.add_dependency 'magic_bytes', '~> 1'
|
34
33
|
spec.add_dependency 'rack', '>= 1', '< 3'
|
34
|
+
spec.add_dependency 'format_parser', '~> 0.24.0'
|
35
|
+
spec.add_dependency 'measurometer', '~> 1'
|
35
36
|
|
36
|
-
spec.add_development_dependency
|
37
|
+
spec.add_development_dependency 'magic_bytes', '~> 1'
|
38
|
+
spec.add_development_dependency "bundler"
|
37
39
|
spec.add_development_dependency "rake", "~> 12.2"
|
38
40
|
spec.add_development_dependency "rack-test"
|
39
41
|
spec.add_development_dependency "rspec", "~> 3"
|
data/lib/image_vise.rb
CHANGED
@@ -2,10 +2,11 @@ require 'ks'
|
|
2
2
|
require 'json'
|
3
3
|
require 'patron'
|
4
4
|
require 'rmagick'
|
5
|
-
require 'magic_bytes'
|
6
5
|
require 'thread'
|
7
6
|
require 'base64'
|
8
7
|
require 'rack'
|
8
|
+
require 'measurometer'
|
9
|
+
require 'format_parser'
|
9
10
|
|
10
11
|
class ImageVise
|
11
12
|
require_relative 'image_vise/version'
|
@@ -16,13 +17,19 @@ class ImageVise
|
|
16
17
|
# The default cache liftime is 30 days, and will be used if no custom lifetime is set.
|
17
18
|
DEFAULT_CACHE_LIFETIME = 2_592_000
|
18
19
|
|
20
|
+
# The default limit on how large may a file loaded for processing be, in bytes. This
|
21
|
+
# is in addition to the constraints on the file format.
|
22
|
+
DEFAULT_MAXIMUM_SOURCE_FILE_SIZE = 48 * 1024 * 1024
|
23
|
+
|
19
24
|
@allowed_hosts = Set.new
|
20
25
|
@keys = Set.new
|
21
26
|
@operators = {}
|
22
27
|
@allowed_glob_patterns = Set.new
|
23
28
|
@fetchers = {}
|
24
29
|
@cache_lifetime = DEFAULT_CACHE_LIFETIME
|
25
|
-
|
30
|
+
|
31
|
+
const_set(:Measurometer, ::Measurometer)
|
32
|
+
|
26
33
|
class << self
|
27
34
|
# Resets all allowed hosts
|
28
35
|
def reset_allowed_hosts!
|
@@ -160,7 +167,7 @@ class ImageVise
|
|
160
167
|
return unless maybe_image
|
161
168
|
return unless maybe_image.respond_to?(:destroy!)
|
162
169
|
return if maybe_image.destroyed?
|
163
|
-
|
170
|
+
Measurometer.instrument('image_vise.image_destroy_dealloc') do
|
164
171
|
maybe_image.destroy!
|
165
172
|
end
|
166
173
|
end
|
@@ -170,9 +177,9 @@ class ImageVise
|
|
170
177
|
# in scope but not yet set to an image) we take the possibility of nils into account.
|
171
178
|
def self.close_and_unlink(maybe_tempfile)
|
172
179
|
return unless maybe_tempfile
|
173
|
-
|
180
|
+
Measurometer.instrument('image_vise.tempfile_unlink') do
|
174
181
|
maybe_tempfile.close unless maybe_tempfile.closed?
|
175
|
-
maybe_tempfile.unlink
|
182
|
+
maybe_tempfile.unlink if maybe_tempfile.respond_to?(:unlink)
|
176
183
|
end
|
177
184
|
end
|
178
185
|
end
|
@@ -2,11 +2,16 @@ class ImageVise::FetcherFile
|
|
2
2
|
class AccessError < StandardError
|
3
3
|
def http_status; 403; end
|
4
4
|
end
|
5
|
+
|
6
|
+
class SizeError < AccessError
|
7
|
+
def http_status; 400; end
|
8
|
+
end
|
9
|
+
|
5
10
|
def self.fetch_uri_to_tempfile(uri)
|
6
11
|
tf = Tempfile.new 'imagevise-localfs-copy'
|
7
|
-
real_path_on_filesystem =
|
8
|
-
verify_filesystem_access!
|
9
|
-
|
12
|
+
real_path_on_filesystem = uri_to_path(uri)
|
13
|
+
verify_filesystem_access!(real_path_on_filesystem)
|
14
|
+
verify_file_size_within_limit!(real_path_on_filesystem)
|
10
15
|
File.open(real_path_on_filesystem, 'rb') do |f|
|
11
16
|
IO.copy_stream(f, tf)
|
12
17
|
end
|
@@ -16,6 +21,10 @@ class ImageVise::FetcherFile
|
|
16
21
|
raise e
|
17
22
|
end
|
18
23
|
|
24
|
+
def self.uri_to_path(uri)
|
25
|
+
File.expand_path(URI.decode(uri.path))
|
26
|
+
end
|
27
|
+
|
19
28
|
def self.verify_filesystem_access!(path_on_filesystem)
|
20
29
|
patterns = ImageVise.allowed_filesystem_sources
|
21
30
|
matches = patterns.any? { |glob_pattern| File.fnmatch?(glob_pattern, path_on_filesystem) }
|
@@ -23,5 +32,16 @@ class ImageVise::FetcherFile
|
|
23
32
|
raise AccessError, "#{path_on_filesystem} is not on the path whitelist" unless matches
|
24
33
|
end
|
25
34
|
|
35
|
+
def self.verify_file_size_within_limit!(path_on_filesystem)
|
36
|
+
file_size = File.size(path_on_filesystem)
|
37
|
+
if file_size > maximum_source_file_size_bytes
|
38
|
+
raise SizeError, "#{path_on_filesystem} is too large to process (#{file_size} bytes)"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.maximum_source_file_size_bytes
|
43
|
+
ImageVise::DEFAULT_MAXIMUM_SOURCE_FILE_SIZE
|
44
|
+
end
|
45
|
+
|
26
46
|
ImageVise.register_fetcher 'file', self
|
27
47
|
end
|
@@ -14,23 +14,35 @@ class ImageVise::FetcherHTTP
|
|
14
14
|
def self.fetch_uri_to_tempfile(uri)
|
15
15
|
tf = Tempfile.new 'imagevise-http-download'
|
16
16
|
verify_uri_access!(uri)
|
17
|
+
|
17
18
|
s = Patron::Session.new
|
18
|
-
s
|
19
|
-
s.timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
|
20
|
-
s.connect_timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
|
21
|
-
|
19
|
+
configure_patron_session!(s)
|
22
20
|
response = s.get_file(uri.to_s, tf.path)
|
23
|
-
|
21
|
+
|
24
22
|
if response.status != 200
|
25
23
|
raise UpstreamError.new(response.status, "Unfortunate upstream response #{response.status} on #{uri}")
|
26
24
|
end
|
27
|
-
|
25
|
+
|
28
26
|
tf
|
27
|
+
rescue Patron::Aborted # File size exceeds permitted size
|
28
|
+
ImageVise.close_and_unlink(tf)
|
29
|
+
raise UpstreamError.new(400, "Upstream resource at #{uri} is too large to load")
|
29
30
|
rescue Exception => e
|
30
31
|
ImageVise.close_and_unlink(tf)
|
31
32
|
raise e
|
32
33
|
end
|
33
|
-
|
34
|
+
|
35
|
+
def self.maximum_response_size_bytes
|
36
|
+
ImageVise::DEFAULT_MAXIMUM_SOURCE_FILE_SIZE
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.configure_patron_session!(session)
|
40
|
+
session.automatic_content_encoding = true
|
41
|
+
session.timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
|
42
|
+
session.connect_timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
|
43
|
+
session.download_byte_limit = maximum_response_size_bytes
|
44
|
+
end
|
45
|
+
|
34
46
|
def self.verify_uri_access!(uri)
|
35
47
|
host = uri.host
|
36
48
|
return if ImageVise.allowed_hosts.include?(uri.host)
|
@@ -14,11 +14,11 @@ class ImageVise::ImageRequest < Ks.strict(:src_url, :pipeline)
|
|
14
14
|
|
15
15
|
# Check the signature before decoding JSON (since we will be creating symbols)
|
16
16
|
unless valid_signature?(base64_encoded_params, given_signature, secrets)
|
17
|
-
|
17
|
+
Measurometer.increment_counter('image_vise.params.invalid_signatures', 1)
|
18
18
|
raise SignatureError, "Invalid or missing signature"
|
19
19
|
end
|
20
20
|
|
21
|
-
|
21
|
+
Measurometer.increment_counter('image_vise.params.valid_signatures', 1)
|
22
22
|
|
23
23
|
# Decode the JSON - only AFTER the signature has been validated,
|
24
24
|
# so we can use symbol keys
|
data/lib/image_vise/pipeline.rb
CHANGED
@@ -47,7 +47,7 @@ class ImageVise::Pipeline
|
|
47
47
|
def apply!(magick_image, image_metadata)
|
48
48
|
@ops.each do |operator|
|
49
49
|
operator_short_classname = operator.class.to_s.split('::').pop
|
50
|
-
|
50
|
+
Measurometer.instrument('image_vise.op.%s' % operator_short_classname) do
|
51
51
|
apply_operator_passing_metadata(magick_image, operator, image_metadata)
|
52
52
|
end
|
53
53
|
end
|
@@ -2,8 +2,19 @@
|
|
2
2
|
class UnsupportedInputFormat < StandardError; end
|
3
3
|
class EmptyRender < StandardError; end
|
4
4
|
|
5
|
+
class Filetype < Struct.new(:format_parser_format)
|
6
|
+
def mime
|
7
|
+
Rack::Mime.mime_type(ext)
|
8
|
+
end
|
9
|
+
|
10
|
+
def ext
|
11
|
+
".#{format_parser_format}"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
5
15
|
DEFAULT_HEADERS = {
|
6
|
-
'Allow' =>
|
16
|
+
'Allow' => 'GET',
|
17
|
+
'X-Content-Type-Options' => 'nosniff',
|
7
18
|
}.freeze
|
8
19
|
|
9
20
|
# Headers for error responses that denote an invalid or
|
@@ -28,12 +39,8 @@
|
|
28
39
|
# decoding for example).
|
29
40
|
IMAGE_CACHE_CONTROL = "public, no-transform, max-age=%d"
|
30
41
|
|
31
|
-
#
|
32
|
-
|
33
|
-
RENDER_TIMEOUT_SECONDS = 10
|
34
|
-
|
35
|
-
# Which input files we permit (based on extensions stored in MagicBytes)
|
36
|
-
PERMITTED_SOURCE_FILE_EXTENSIONS = %w( gif png jpg psd tif)
|
42
|
+
# Which input files we permit (based on format identifiers in format_parser, which are symbols)
|
43
|
+
PERMITTED_SOURCE_FORMATS = [:bmp, :tif, :jpg, :psd, :gif, :png]
|
37
44
|
|
38
45
|
# How long should we wait when fetching the image from the external host
|
39
46
|
EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS = 4
|
@@ -85,7 +92,7 @@
|
|
85
92
|
handle_request_error(e)
|
86
93
|
http_status_code = e.respond_to?(:http_status) ? e.http_status : 400
|
87
94
|
raise_exception_or_error_response(e, http_status_code)
|
88
|
-
rescue
|
95
|
+
rescue => e
|
89
96
|
if http_status_code = (e.respond_to?(:http_status) && e.http_status)
|
90
97
|
handle_request_error(e)
|
91
98
|
raise_exception_or_error_response(e, http_status_code)
|
@@ -134,7 +141,7 @@
|
|
134
141
|
# representing the processing pipeline
|
135
142
|
#
|
136
143
|
# @param image_request[ImageVise::ImageRequest] the request for the image
|
137
|
-
# @return [Array<File,
|
144
|
+
# @return [Array<File, FileType, String]
|
138
145
|
def process_image_request(image_request)
|
139
146
|
# Recover the source image URL and the pipeline instructions (all the image ops)
|
140
147
|
source_image_uri, pipeline = image_request.src_url, image_request.pipeline
|
@@ -146,24 +153,26 @@
|
|
146
153
|
|
147
154
|
# Download/copy the original into a Tempfile
|
148
155
|
fetcher = ImageVise.fetcher_for(source_image_uri.scheme)
|
149
|
-
source_file =
|
150
|
-
|
151
|
-
# Make sure we do not try to process something...questionable
|
152
|
-
source_file_type = detect_file_type(source_file)
|
153
|
-
unless source_file_type_permitted?(source_file_type)
|
154
|
-
raise UnsupportedInputFormat.new("Unsupported/unknown input file format .%s" % source_file_type.ext)
|
156
|
+
source_file = Measurometer.instrument('image_vise.fetch') do
|
157
|
+
fetcher.fetch_uri_to_tempfile(source_image_uri)
|
155
158
|
end
|
159
|
+
file_format = FormatParser.parse(source_file, natures: [:image]).tap { source_file.rewind }
|
160
|
+
raise UnsupportedInputFormat.new("%s has an unknown input file format" % source_image_uri) unless file_format
|
161
|
+
raise UnsupportedInputFormat.new("%s does not pass file constraints" % source_image_uri) unless permitted_format?(file_format)
|
156
162
|
|
157
163
|
render_destination_file = Tempfile.new('imagevise-render').tap{|f| f.binmode }
|
158
164
|
|
159
165
|
# Do the actual imaging stuff
|
160
|
-
expire_after =
|
166
|
+
expire_after = Measurometer.instrument('image_vise.render_engine.apply_pipeline') do
|
167
|
+
apply_pipeline(source_file.path, pipeline, file_format, render_destination_file.path)
|
168
|
+
end
|
161
169
|
|
162
170
|
# Catch this one early
|
163
171
|
render_destination_file.rewind
|
164
172
|
raise EmptyRender, "The rendered image was empty" if render_destination_file.size.zero?
|
165
173
|
|
166
174
|
render_file_type = detect_file_type(render_destination_file)
|
175
|
+
|
167
176
|
[render_destination_file, render_file_type, etag, expire_after]
|
168
177
|
ensure
|
169
178
|
ImageVise.close_and_unlink(source_file)
|
@@ -210,18 +219,23 @@
|
|
210
219
|
# the MIME type.
|
211
220
|
#
|
212
221
|
# @param tempfile[File] the file to perform detection on
|
213
|
-
# @return [
|
222
|
+
# @return [Symbol] the detected file format symbol that can be used as an extension
|
214
223
|
def detect_file_type(tempfile)
|
215
224
|
tempfile.rewind
|
216
|
-
|
225
|
+
parser_result = FormatParser.parse(tempfile, natures: :image).tap { tempfile.rewind }
|
226
|
+
raise "Rendered file type detection failed" unless parser_result
|
227
|
+
Filetype.new(parser_result.format)
|
217
228
|
end
|
218
229
|
|
219
|
-
# Tells whether the
|
230
|
+
# Tells whether the file described by the given FormatParser result object
|
231
|
+
# can be accepted for processing
|
220
232
|
#
|
221
|
-
# @param
|
233
|
+
# @param format_parser_result[FormatParser::Image] file information descriptor
|
222
234
|
# @return [Boolean]
|
223
|
-
def
|
224
|
-
|
235
|
+
def permitted_format?(format_parser_result)
|
236
|
+
return false unless PERMITTED_SOURCE_FORMATS.include?(format_parser_result.format)
|
237
|
+
return false if format_parser_result.has_multiple_frames
|
238
|
+
true
|
225
239
|
end
|
226
240
|
|
227
241
|
# Lists exceptions that should lead to the request being flagged
|
@@ -279,18 +293,17 @@
|
|
279
293
|
# @param pipeline[#apply!(Magick::Image)] the processing pipeline
|
280
294
|
# @param render_to_path[String] the path to write the rendered image to
|
281
295
|
# @return [void]
|
282
|
-
def apply_pipeline(source_file_path, pipeline,
|
283
|
-
render_file_type = source_file_type
|
296
|
+
def apply_pipeline(source_file_path, pipeline, source_format_parser_result, render_to_path)
|
284
297
|
|
285
298
|
# Load the first frame of the animated GIF _or_ the blended compatibility layer from Photoshop
|
286
|
-
image_list =
|
299
|
+
image_list = Measurometer.instrument('image_vise.load_pixbuf') do
|
287
300
|
Magick::Image.read(source_file_path)
|
288
301
|
end
|
289
302
|
|
290
303
|
magick_image = image_list.first # Picks up the "precomp" PSD layer in compatibility mode, or the first frame of a GIF
|
291
304
|
|
292
305
|
# If any operators want to stash some data for downstream use we use this Hash
|
293
|
-
metadata = {}
|
306
|
+
metadata = {format_parser_result: source_format_parser_result}
|
294
307
|
|
295
308
|
# Apply the pipeline (all the image operators)
|
296
309
|
pipeline.apply!(magick_image, metadata)
|
@@ -300,7 +313,7 @@
|
|
300
313
|
# it so that we get a KeyError if some operator has deleted it without providing a replacement.
|
301
314
|
# If no operators touched the writer we are going to use the automatic format selection
|
302
315
|
writer = metadata.fetch(:writer, ImageVise::AutoWriter.new)
|
303
|
-
|
316
|
+
Measurometer.instrument('image_vise.write_image') do
|
304
317
|
writer.write_image!(magick_image, metadata, render_to_path)
|
305
318
|
end
|
306
319
|
|
data/lib/image_vise/version.rb
CHANGED
@@ -3,21 +3,13 @@
|
|
3
3
|
# be chosen. Since ImageVise URLs do not contain a file extension we are free to pick
|
4
4
|
# the suitable format at render time
|
5
5
|
class ImageVise::AutoWriter
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
# Renders the file as a jpg if the custom output filetype operator is used
|
10
|
-
JPG_FILE_TYPE = MagicBytes::FileType.new('jpg','image/jpeg').freeze
|
11
|
-
|
6
|
+
PNG_EXT = 'png'
|
7
|
+
JPG_EXT = 'jpg'
|
12
8
|
def write_image!(magick_image, _, render_to_path)
|
13
9
|
# If processing the image has created an alpha channel, use PNG always.
|
14
10
|
# Otherwise, keep the original format for as far as the supported formats list goes.
|
15
|
-
|
16
|
-
|
17
|
-
else
|
18
|
-
JPG_FILE_TYPE
|
19
|
-
end
|
20
|
-
magick_image.format = render_file_type.ext
|
11
|
+
extension = magick_image.alpha? ? PNG_EXT : JPG_EXT
|
12
|
+
magick_image.format = extension
|
21
13
|
magick_image.write(render_to_path)
|
22
14
|
end
|
23
15
|
end
|
@@ -1,9 +1,9 @@
|
|
1
1
|
class ImageVise::JPGWriter < Ks.strict(:quality)
|
2
|
-
|
2
|
+
JPG_EXT = 'jpg'
|
3
3
|
|
4
4
|
def write_image!(magick_image, _, render_to_path)
|
5
5
|
q = self.quality # to avoid the changing "self" context
|
6
|
-
magick_image.format =
|
6
|
+
magick_image.format = JPG_EXT
|
7
7
|
magick_image.write(render_to_path) { self.quality = q }
|
8
8
|
end
|
9
9
|
end
|
@@ -32,7 +32,7 @@ describe ImageVise::FetcherFile do
|
|
32
32
|
ImageVise::FetcherFile.fetch_uri_to_tempfile(uri)
|
33
33
|
}.to raise_error(ImageVise::FetcherFile::AccessError)
|
34
34
|
end
|
35
|
-
|
35
|
+
|
36
36
|
it 'raises a meaningful exception if this file is not permitted as source' do
|
37
37
|
path = File.expand_path(__FILE__)
|
38
38
|
|
@@ -45,4 +45,21 @@ describe ImageVise::FetcherFile do
|
|
45
45
|
ImageVise::FetcherFile.fetch_uri_to_tempfile(uri)
|
46
46
|
}.to raise_error(ImageVise::FetcherFile::AccessError)
|
47
47
|
end
|
48
|
+
|
49
|
+
it 'raises a meaningful exception if the image exceeds the maximum permitted size' do
|
50
|
+
path = File.expand_path(__FILE__)
|
51
|
+
ruby_files_in_this_directory = __dir__ + '/*.rb'
|
52
|
+
ImageVise.allow_filesystem_source! ruby_files_in_this_directory
|
53
|
+
|
54
|
+
uri = URI('file://' + URI.encode(path))
|
55
|
+
expect(ImageVise::FetcherFile).to receive(:maximum_source_file_size_bytes).and_return(1)
|
56
|
+
|
57
|
+
expect {
|
58
|
+
ImageVise::FetcherFile.fetch_uri_to_tempfile(uri)
|
59
|
+
}.to raise_error {|e|
|
60
|
+
expect(e).to be_kind_of(ImageVise::FetcherFile::AccessError)
|
61
|
+
expect(e.message).to match(/is too large to process/)
|
62
|
+
expect(e.http_status).to eq(400)
|
63
|
+
}
|
64
|
+
end
|
48
65
|
end
|
@@ -31,6 +31,21 @@ describe ImageVise::FetcherHTTP do
|
|
31
31
|
}
|
32
32
|
end
|
33
33
|
|
34
|
+
it 'raises an error if the image exceeds the maximum permitted size' do
|
35
|
+
uri = URI(public_url_psd)
|
36
|
+
ImageVise.add_allowed_host! 'localhost'
|
37
|
+
expect(ImageVise::FetcherHTTP).to receive(:maximum_response_size_bytes).and_return(10)
|
38
|
+
|
39
|
+
expect {
|
40
|
+
ImageVise::FetcherHTTP.fetch_uri_to_tempfile(uri)
|
41
|
+
}.to raise_error {|e|
|
42
|
+
expect(e).to be_kind_of(ImageVise::FetcherHTTP::UpstreamError)
|
43
|
+
expect(e.message).to include(uri.to_s)
|
44
|
+
expect(e.message).to match(/is too large to load/)
|
45
|
+
expect(e.http_status).to eq(400)
|
46
|
+
}
|
47
|
+
end
|
48
|
+
|
34
49
|
it 'fetches the image into a Tempfile' do
|
35
50
|
uri = URI(public_url_psd)
|
36
51
|
ImageVise.add_allowed_host! 'localhost'
|
@@ -51,16 +51,15 @@ describe ImageVise::RenderEngine do
|
|
51
51
|
p = ImageVise::Pipeline.new.crop(width: 10, height: 10, gravity: 'c')
|
52
52
|
image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
|
53
53
|
|
54
|
-
|
55
|
-
|
56
|
-
double(status: 200)
|
57
|
-
}
|
54
|
+
bad_data = StringIO.new('totally not an image')
|
55
|
+
expect(ImageVise::FetcherHTTP).to receive(:fetch_uri_to_tempfile).and_return(bad_data)
|
58
56
|
expect(app).to receive(:handle_request_error).and_call_original
|
59
57
|
|
60
58
|
get image_request.to_path_params('l33tness')
|
59
|
+
|
61
60
|
expect(last_response.status).to eq(400)
|
62
61
|
expect(last_response['Cache-Control']).to match(/public/)
|
63
|
-
expect(last_response.body).to include('
|
62
|
+
expect(last_response.body).to include('unknown')
|
64
63
|
end
|
65
64
|
|
66
65
|
it 'halts with 400 when a file:// URL is given and filesystem access is not enabled' do
|
@@ -111,6 +110,8 @@ describe ImageVise::RenderEngine do
|
|
111
110
|
expect(last_response.headers['Cache-Control']).to match(/public/)
|
112
111
|
|
113
112
|
expect(last_response.headers['Content-Type']).to eq('application/json')
|
113
|
+
expect(last_response['X-Content-Type-Options']).to eq('nosniff')
|
114
|
+
|
114
115
|
parsed = JSON.load(last_response.body)
|
115
116
|
expect(parsed['errors'].to_s).to include("Unfortunate upstream response")
|
116
117
|
end
|
@@ -183,6 +184,8 @@ describe ImageVise::RenderEngine do
|
|
183
184
|
expect(last_response.status).to eq(200)
|
184
185
|
|
185
186
|
expect(last_response.headers['Content-Type']).to eq('image/jpeg')
|
187
|
+
expect(last_response['X-Content-Type-Options']).to eq('nosniff')
|
188
|
+
|
186
189
|
expect(last_response.headers).to have_key('Content-Length')
|
187
190
|
parsed_image = Magick::Image.from_blob(last_response.body)[0]
|
188
191
|
expect(parsed_image.columns).to eq(10)
|
@@ -224,7 +227,7 @@ describe ImageVise::RenderEngine do
|
|
224
227
|
expect(app).to receive(:process_image_request).and_call_original
|
225
228
|
expect(app).to receive(:extract_params_from_request).and_call_original
|
226
229
|
expect(app).to receive(:image_rack_response).and_call_original
|
227
|
-
expect(app).to receive(:
|
230
|
+
expect(app).to receive(:permitted_format?).and_call_original
|
228
231
|
|
229
232
|
get image_request.to_path_params('l33tness')
|
230
233
|
expect(last_response.status).to eq(200)
|
data/spec/image_vise_spec.rb
CHANGED
@@ -102,6 +102,39 @@ describe ImageVise do
|
|
102
102
|
end
|
103
103
|
end
|
104
104
|
|
105
|
+
describe '.close_and_unlink' do
|
106
|
+
it 'closes and unlinks a Tempfile' do
|
107
|
+
tf = Tempfile.new('x')
|
108
|
+
tf << "foo"
|
109
|
+
expect(tf).to receive(:close).and_call_original
|
110
|
+
expect(tf).to receive(:unlink).and_call_original
|
111
|
+
|
112
|
+
ImageVise.close_and_unlink(tf)
|
113
|
+
|
114
|
+
expect(tf).to be_closed
|
115
|
+
end
|
116
|
+
|
117
|
+
it 'unlinks a closed Tempfile' do
|
118
|
+
tf = Tempfile.new('x')
|
119
|
+
tf << "foo"
|
120
|
+
tf.close
|
121
|
+
expect(tf).to receive(:unlink).and_call_original
|
122
|
+
|
123
|
+
ImageVise.close_and_unlink(tf)
|
124
|
+
end
|
125
|
+
|
126
|
+
it 'works on a nil since it gets used in ensure blocks, where the variable might be empty' do
|
127
|
+
ImageVise.close_and_unlink(nil) # Should not raise anything
|
128
|
+
end
|
129
|
+
|
130
|
+
it 'works for a StringIO which does not have unlink' do
|
131
|
+
sio = StringIO.new('some gunk')
|
132
|
+
expect(sio).not_to be_closed
|
133
|
+
ImageVise.close_and_unlink(sio)
|
134
|
+
expect(sio).to be_closed
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
105
138
|
describe 'methods dealing with the operator list' do
|
106
139
|
it 'have the basic operators already set up' do
|
107
140
|
oplist = ImageVise.defined_operator_names
|
data/spec/spec_helper.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: image_vise
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Julik Tarkhanov
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2020-09-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: patron
|
@@ -16,28 +16,28 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '0.
|
19
|
+
version: '0.9'
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '0.
|
26
|
+
version: '0.9'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: rmagick
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: '
|
33
|
+
version: '3'
|
34
34
|
type: :runtime
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version: '
|
40
|
+
version: '3'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
42
|
name: ks
|
43
43
|
requirement: !ruby/object:Gem::Requirement
|
@@ -53,22 +53,18 @@ dependencies:
|
|
53
53
|
- !ruby/object:Gem::Version
|
54
54
|
version: '0'
|
55
55
|
- !ruby/object:Gem::Dependency
|
56
|
-
name:
|
56
|
+
name: rack
|
57
57
|
requirement: !ruby/object:Gem::Requirement
|
58
58
|
requirements:
|
59
|
-
- - "
|
59
|
+
- - ">="
|
60
60
|
- !ruby/object:Gem::Version
|
61
61
|
version: '1'
|
62
|
+
- - "<"
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: '3'
|
62
65
|
type: :runtime
|
63
66
|
prerelease: false
|
64
67
|
version_requirements: !ruby/object:Gem::Requirement
|
65
|
-
requirements:
|
66
|
-
- - "~>"
|
67
|
-
- !ruby/object:Gem::Version
|
68
|
-
version: '1'
|
69
|
-
- !ruby/object:Gem::Dependency
|
70
|
-
name: rack
|
71
|
-
requirement: !ruby/object:Gem::Requirement
|
72
68
|
requirements:
|
73
69
|
- - ">="
|
74
70
|
- !ruby/object:Gem::Version
|
@@ -76,30 +72,62 @@ dependencies:
|
|
76
72
|
- - "<"
|
77
73
|
- !ruby/object:Gem::Version
|
78
74
|
version: '3'
|
75
|
+
- !ruby/object:Gem::Dependency
|
76
|
+
name: format_parser
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - "~>"
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: 0.24.0
|
79
82
|
type: :runtime
|
80
83
|
prerelease: false
|
81
84
|
version_requirements: !ruby/object:Gem::Requirement
|
82
85
|
requirements:
|
83
|
-
- - "
|
86
|
+
- - "~>"
|
87
|
+
- !ruby/object:Gem::Version
|
88
|
+
version: 0.24.0
|
89
|
+
- !ruby/object:Gem::Dependency
|
90
|
+
name: measurometer
|
91
|
+
requirement: !ruby/object:Gem::Requirement
|
92
|
+
requirements:
|
93
|
+
- - "~>"
|
84
94
|
- !ruby/object:Gem::Version
|
85
95
|
version: '1'
|
86
|
-
|
96
|
+
type: :runtime
|
97
|
+
prerelease: false
|
98
|
+
version_requirements: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - "~>"
|
87
101
|
- !ruby/object:Gem::Version
|
88
|
-
version: '
|
102
|
+
version: '1'
|
89
103
|
- !ruby/object:Gem::Dependency
|
90
|
-
name:
|
104
|
+
name: magic_bytes
|
91
105
|
requirement: !ruby/object:Gem::Requirement
|
92
106
|
requirements:
|
93
107
|
- - "~>"
|
94
108
|
- !ruby/object:Gem::Version
|
95
|
-
version: '1
|
109
|
+
version: '1'
|
96
110
|
type: :development
|
97
111
|
prerelease: false
|
98
112
|
version_requirements: !ruby/object:Gem::Requirement
|
99
113
|
requirements:
|
100
114
|
- - "~>"
|
101
115
|
- !ruby/object:Gem::Version
|
102
|
-
version: '1
|
116
|
+
version: '1'
|
117
|
+
- !ruby/object:Gem::Dependency
|
118
|
+
name: bundler
|
119
|
+
requirement: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - ">="
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: '0'
|
124
|
+
type: :development
|
125
|
+
prerelease: false
|
126
|
+
version_requirements: !ruby/object:Gem::Requirement
|
127
|
+
requirements:
|
128
|
+
- - ">="
|
129
|
+
- !ruby/object:Gem::Version
|
130
|
+
version: '0'
|
103
131
|
- !ruby/object:Gem::Dependency
|
104
132
|
name: rake
|
105
133
|
requirement: !ruby/object:Gem::Requirement
|
@@ -240,7 +268,6 @@ files:
|
|
240
268
|
- lib/image_vise/version.rb
|
241
269
|
- lib/image_vise/writers/auto_writer.rb
|
242
270
|
- lib/image_vise/writers/jpeg_writer.rb
|
243
|
-
- lib/measurometer.rb
|
244
271
|
- spec/image_vise/auto_orient_spec.rb
|
245
272
|
- spec/image_vise/background_fill_spec.rb
|
246
273
|
- spec/image_vise/crop_spec.rb
|
@@ -262,7 +289,6 @@ files:
|
|
262
289
|
- spec/image_vise/writers/jpeg_writer_spec.rb
|
263
290
|
- spec/image_vise_spec.rb
|
264
291
|
- spec/layers-with-blending.psd
|
265
|
-
- spec/measurometer_spec.rb
|
266
292
|
- spec/spec_helper.rb
|
267
293
|
- spec/test_server.rb
|
268
294
|
- spec/waterside_magic_hour.jpg
|
@@ -291,8 +317,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
291
317
|
- !ruby/object:Gem::Version
|
292
318
|
version: '0'
|
293
319
|
requirements: []
|
294
|
-
|
295
|
-
rubygems_version: 2.7.3
|
320
|
+
rubygems_version: 3.0.3
|
296
321
|
signing_key:
|
297
322
|
specification_version: 4
|
298
323
|
summary: Runtime thumbnailing proxy
|
data/lib/measurometer.rb
DELETED
@@ -1,92 +0,0 @@
|
|
1
|
-
# Measurometer is 1-1 API compatible with Appsignal,
|
2
|
-
# which we use a lot
|
3
|
-
class ImageVise::Measurometer
|
4
|
-
class << self
|
5
|
-
# Permits adding instrumentation drivers. To magically
|
6
|
-
# obtain all Appsignal instrumentation, add the Appsignal module
|
7
|
-
# as a driver.
|
8
|
-
#
|
9
|
-
# Measurometer.drivers << Appsignal
|
10
|
-
#
|
11
|
-
# A driver must be reentrant and thread-safe - it should be possible
|
12
|
-
# to have multiple `instrument` calls open from different threads at the
|
13
|
-
# same time.
|
14
|
-
#
|
15
|
-
# The driver must support the same interface as the Measurometer class
|
16
|
-
# itself, minus the `drivers` and `instrument_instance_method` methods.
|
17
|
-
#
|
18
|
-
# @return Array
|
19
|
-
def drivers
|
20
|
-
@drivers ||= []
|
21
|
-
@drivers
|
22
|
-
end
|
23
|
-
|
24
|
-
# Runs a given block within a cascade of `instrument` blocks of all the
|
25
|
-
# added drivers.
|
26
|
-
#
|
27
|
-
# Measurometer.instrument('do_foo') { compute! }
|
28
|
-
#
|
29
|
-
# unfolds to
|
30
|
-
#
|
31
|
-
# Appsignal.instrument('do_foo') do
|
32
|
-
# Statsd.timing('do_foo') do
|
33
|
-
# compute!
|
34
|
-
# end
|
35
|
-
# end
|
36
|
-
#
|
37
|
-
# Note that it is _imperative_ that the block return value is preserved
|
38
|
-
# by the drivers and passed as the result of the block.
|
39
|
-
#
|
40
|
-
# @param block_name[String] under which path to push the metric
|
41
|
-
# @param blk[#call] the block to instrument
|
42
|
-
# @return [Object] the return value of &blk
|
43
|
-
def instrument(block_name, &blk)
|
44
|
-
return yield unless @drivers && @drivers.any? # The block wrapping business is not free
|
45
|
-
@drivers.inject(blk) { |outer_block, driver|
|
46
|
-
-> {
|
47
|
-
driver.instrument(block_name, &outer_block)
|
48
|
-
}
|
49
|
-
}.call
|
50
|
-
end
|
51
|
-
|
52
|
-
# Adds a distribution value (sample) under a given path
|
53
|
-
#
|
54
|
-
# @param value_path[String] under which path to push the metric
|
55
|
-
# @param value[Numeric] distribution value
|
56
|
-
# @return nil
|
57
|
-
def add_distribution_value(value_path, value)
|
58
|
-
(@drivers || []).each { |d| d.add_distribution_value(value_path, value) }
|
59
|
-
nil
|
60
|
-
end
|
61
|
-
|
62
|
-
# Increment a named counter under a given path
|
63
|
-
#
|
64
|
-
# @param counter_path[String] under which path to push the metric
|
65
|
-
# @param by[Integer] the counter increment to apply
|
66
|
-
# @return nil
|
67
|
-
def increment_counter(counter_path, by)
|
68
|
-
(@drivers || []).each { |d| d.increment_counter(counter_path, by) }
|
69
|
-
nil
|
70
|
-
end
|
71
|
-
|
72
|
-
# Wrap an anonymous module around an instance method in the given class to have
|
73
|
-
# it instrumented automatically. The name of the measurement will be interpolated as:
|
74
|
-
#
|
75
|
-
# "#{prefix}.#{rightmost_class_constant_name}.#{instance_method_name}"
|
76
|
-
#
|
77
|
-
# @param target_class[Class] the class to instrument
|
78
|
-
# @param instance_method_name_to_instrument[Symbol] the method name to instrument
|
79
|
-
# @param path_prefix[String] under which path to push the instrumented metric
|
80
|
-
# @return void
|
81
|
-
def instrument_instance_method(target_class, instance_method_name_to_instrument, path_prefix)
|
82
|
-
short_class_name = target_class.to_s.split('::').last
|
83
|
-
instrumentation_name = [path_prefix, short_class_name, instance_method_name_to_instrument].join('.')
|
84
|
-
instrumenter_module = Module.new do
|
85
|
-
define_method(instance_method_name_to_instrument) do |*any|
|
86
|
-
::ImageVise::Measurometer.instrument(instrumentation_name) { super(*any) }
|
87
|
-
end
|
88
|
-
end
|
89
|
-
target_class.prepend(instrumenter_module)
|
90
|
-
end
|
91
|
-
end
|
92
|
-
end
|
data/spec/measurometer_spec.rb
DELETED
@@ -1,58 +0,0 @@
|
|
1
|
-
require 'spec_helper'
|
2
|
-
|
3
|
-
describe ImageVise::Measurometer do
|
4
|
-
RSpec::Matchers.define :include_counter_or_measurement_named do |named|
|
5
|
-
match do |actual|
|
6
|
-
actual.any? do |e|
|
7
|
-
e[0] == named && e[1] > 0
|
8
|
-
end
|
9
|
-
end
|
10
|
-
end
|
11
|
-
|
12
|
-
it 'instruments a full cycle FormatParser.parse' do
|
13
|
-
driver_class = Class.new do
|
14
|
-
attr_accessor :timings, :counters, :distributions
|
15
|
-
def initialize
|
16
|
-
@timings = []
|
17
|
-
@distributions = []
|
18
|
-
@counters = []
|
19
|
-
end
|
20
|
-
|
21
|
-
def instrument(block_name)
|
22
|
-
s = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
23
|
-
yield.tap do
|
24
|
-
delta = Process.clock_gettime(Process::CLOCK_MONOTONIC) - s
|
25
|
-
@timings << [block_name, delta * 1000]
|
26
|
-
end
|
27
|
-
end
|
28
|
-
|
29
|
-
def add_distribution_value(value_path, value)
|
30
|
-
@distributions << [value_path, value]
|
31
|
-
end
|
32
|
-
|
33
|
-
def increment_counter(value_path, value)
|
34
|
-
@counters << [value_path, value]
|
35
|
-
end
|
36
|
-
end
|
37
|
-
|
38
|
-
instrumenter = driver_class.new
|
39
|
-
described_class.drivers << instrumenter
|
40
|
-
|
41
|
-
builder = ImageVise::Pipeline.new
|
42
|
-
pipeline = builder.
|
43
|
-
auto_orient.
|
44
|
-
fit_crop(width: 48, height: 48, gravity: 'c').
|
45
|
-
sharpen(radius: 2, sigma: 0.5).
|
46
|
-
ellipse_stencil.
|
47
|
-
strip_metadata
|
48
|
-
|
49
|
-
image = Magick::Image.read(test_image_path)[0]
|
50
|
-
pipeline.apply! image, {}
|
51
|
-
|
52
|
-
described_class.drivers.delete(instrumenter)
|
53
|
-
expect(described_class.drivers).not_to include(instrumenter)
|
54
|
-
|
55
|
-
expect(instrumenter.timings).to include_counter_or_measurement_named('image_vise.op.AutoOrient')
|
56
|
-
expect(instrumenter.timings).to include_counter_or_measurement_named('image_vise.op.StripMetadata')
|
57
|
-
end
|
58
|
-
end
|