image_vise 0.5.0 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b65aacfbdbada4bb3d52c9883955ff2204ee84aa66bc82d05fc81320259ac900
4
- data.tar.gz: d8576cfba12f8793a75dc36f8a5c2c206f7650f402d048432f763de82ddeb0b0
3
+ metadata.gz: c6f634532f563e0b00eb6045e7865f32bf88b23981087b51c144961b7b876622
4
+ data.tar.gz: 5876a712fb46e83ec0c36bbc2db4527ffe60b9d094591c85c3de7ee445dafb9a
5
5
  SHA512:
6
- metadata.gz: 767f28b82f290cfefdf023f83a6794901744d585eff9f0f2af92a6c408718b4663162156270d51f0ea7def4de2e14339a0bc0183b80ba62d4d16f11b69359e11
7
- data.tar.gz: bb8534e292d19e51015b24586d0db5c472da2b02c3463180c35f42d8cff1d2d39aca875d5446255ea5b67b1bf59f849dda08065792438324a6ed3c9a753d9549
6
+ metadata.gz: 877999dcdfb883a007cfa81989a40e394e95960c92efa87eded7f5b09b226b31d287fa17941f813413d5a3e0a02b448d4295b34e538c8f7739b230fca55ec285
7
+ data.tar.gz: 8c3cb09419af7583bb2ecafa7f47925c900cec0024b5705e5d9c09214947f2cbda2f957bdfbd7d2c7175b73de65159726a9b3737be4f74fdb3d4f2ffbf89c9cf
@@ -1,13 +1,10 @@
1
1
  rvm:
2
- - 2.2.10
3
- - 2.3.7
4
2
  - 2.4.4
5
3
  - 2.5.1
4
+ - 2.6.2
6
5
  sudo: false
7
6
  cache: bundler
8
-
9
7
  env:
10
8
  global:
11
9
  - SKIP_INTERACTIVE=yes
12
- before_install: gem install bundler
13
10
  script: bundle exec rspec
@@ -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 referring to permitted hosts are going to be permitted for fetching. If there are no hosts added,
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
- ImageVise::Measurometer.drivers << Appsignal # to obtain ImageVise instrumentation
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'])
@@ -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.6'
31
- spec.add_dependency 'rmagick', '~> 2.15'
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 "bundler", "~> 1.7"
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"
@@ -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
- ImageVise::Measurometer.instrument('image_vise.image_destroy_dealloc') do
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
- ImageVise::Measurometer.instrument('image_vise.tempfile_unlink') do
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 = File.expand_path(URI.decode(uri.path))
8
- verify_filesystem_access! real_path_on_filesystem
9
- # Do the checks
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.automatic_content_encoding = true
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
- ImageVise::Measurometer.increment_counter('image_vise.params.invalid_signatures', 1)
17
+ Measurometer.increment_counter('image_vise.params.invalid_signatures', 1)
18
18
  raise SignatureError, "Invalid or missing signature"
19
19
  end
20
20
 
21
- ImageVise::Measurometer.increment_counter('image_vise.params.valid_signatures', 1)
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
@@ -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
- ImageVise::Measurometer.instrument('image_vise.op.%s' % operator_short_classname) do
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' => "GET"
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
- # How long is a render (the ImageMagick/write part) is allowed to
32
- # take before we kill it
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 Exception => e
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, MagicBytes::FileType, String]
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 = fetcher.fetch_uri_to_tempfile(source_image_uri)
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 = apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path)
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 [MagicBytes::FileType] the detected file type
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
- MagicBytes.read_and_detect(tempfile).tap { tempfile.rewind }
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 given file type may be loaded into the image processor.
230
+ # Tells whether the file described by the given FormatParser result object
231
+ # can be accepted for processing
220
232
  #
221
- # @param magic_bytes_file_info[MagicBytes::FileType] the filetype
233
+ # @param format_parser_result[FormatParser::Image] file information descriptor
222
234
  # @return [Boolean]
223
- def source_file_type_permitted?(magic_bytes_file_info)
224
- PERMITTED_SOURCE_FILE_EXTENSIONS.include?(magic_bytes_file_info.ext)
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, source_file_type, render_to_path)
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 = ImageVise::Measurometer.instrument('image_vise.load_pixbuf') do
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
- ImageVise::Measurometer.instrument('image_vise.write_image') do
316
+ Measurometer.instrument('image_vise.write_image') do
304
317
  writer.write_image!(magick_image, metadata, render_to_path)
305
318
  end
306
319
 
@@ -1,3 +1,3 @@
1
1
  class ImageVise
2
- VERSION = '0.5.0'
2
+ VERSION = '0.8.1'
3
3
  end
@@ -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
- # The default file type for images with alpha
7
- PNG_FILE_TYPE = MagicBytes::FileType.new('png','image/png').freeze
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
- render_file_type = if magick_image.alpha?
16
- PNG_FILE_TYPE
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
- JPG_FILE_TYPE = MagicBytes::FileType.new('jpg','image/jpeg').freeze
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 = JPG_FILE_TYPE.ext
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
- expect_any_instance_of(Patron::Session).to receive(:get_file) {|_self, url, path|
55
- File.open(path, 'wb') {|f| f << 'totally not an image' }
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('Unsupported/unknown')
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(:source_file_type_permitted?).and_call_original
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)
@@ -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
@@ -11,6 +11,7 @@ require 'securerandom'
11
11
  require 'addressable/uri'
12
12
  require 'strenv'
13
13
  require 'pry'
14
+ require 'magic_bytes'
14
15
  require_relative 'test_server'
15
16
 
16
17
  TEST_RENDERS_DIR = Dir.mktmpdir
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.5.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: 2018-04-19 00:00:00.000000000 Z
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.6'
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.6'
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: '2.15'
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: '2.15'
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: magic_bytes
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: '3'
102
+ version: '1'
89
103
  - !ruby/object:Gem::Dependency
90
- name: bundler
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.7'
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.7'
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
- rubyforge_project:
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
@@ -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
@@ -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