image_vise 0.0.25 → 0.0.26

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
  SHA1:
3
- metadata.gz: 4505547a1ef87774694ee0a4e0adda3354e9f676
4
- data.tar.gz: c403f4147f907b10b2f13ca6ea2564f73bc21303
3
+ metadata.gz: cb3f1e2fdb272ce247b39af0ac465622ae4aab47
4
+ data.tar.gz: af4c923a506fa1b8e960f06cf6ac6686a0cda9d0
5
5
  SHA512:
6
- metadata.gz: 736ed9bedaf46db49558b3fcec83d992368ccd670df57083be3e8ec66dbfc7bc31420a0debbd085b9237fdb29891b8c495bfbc27d0b6fac7e902b2f3938ef3f5
7
- data.tar.gz: fe83b1a5f073e2b01075f6ba8f7e4451985727650e69ff390a1abc3350113639c8459e3e1341fcb45738390cf1750671694593edbcfd71bfa0db9802b66c49c7
6
+ metadata.gz: 436cf6a7fcbafd57c1d531530cf42e08f8aa746fa85aa99dfd3d672e1c6ec9a49c6775387cc7f0fbd183e9abb01c75b698e7a0664b4cd65d7115294bdf334bb5
7
+ data.tar.gz: b72ead80e081805e1705ea8f22a69dccca06a70967a5ba731c27938aa3a20469f482c23f62789bce1f9a90d71666d570e304dd2f35e22aee85ba0fea42872a72
data/DEVELOPMENT.md ADDED
@@ -0,0 +1,98 @@
1
+ # NOTE: Until 1.0.0 ImageVise API is somewhat in flux
2
+
3
+ ## Defining image operators
4
+
5
+ To add an image operator, define a class roughly like so, and add it to the list of operators.
6
+
7
+ class MyBlur
8
+ # The constructor must accept keyword arguments if your operator accepts them
9
+ def initialize(radius:, sigma:)
10
+ @radius = radius.to_f
11
+ @sigma = sigma.to_f
12
+ end
13
+
14
+ # Needed for the operator to be serialized to parameters
15
+ def to_h
16
+ {radius: @radius, sigma: @sigma}
17
+ end
18
+
19
+ def apply!(magick_image) # The argument is a Magick::Image
20
+ # Apply the method to the function argument
21
+ blurred_image = magick_image.blur_image(@radius, @sigma)
22
+
23
+ # Some methods (without !) return a new Magick::Image.
24
+ # That image has to be composited back into the original.
25
+ magick_image.composite!(blurred_image, Magick::CenterGravity, Magick::CopyCompositeOp)
26
+ ensure
27
+ # Make sure to explicitly destroy() all the intermediate images
28
+ ImageVise.destroy(blurred_image)
29
+ end
30
+ end
31
+
32
+ # This will also make `ImageVise::Pipeline` respond to `blur(radius:.., sigma:..)`
33
+ ImageVise.add_operator 'my_blur', MyBlur
34
+
35
+ ## Defining fetchers
36
+
37
+ Fetchers are based on the scheme of the image URL. Default fetchers are already defined for `http`, `https`
38
+ and `file` schemes. If you need to grab data from a database, for instance, you can define a fetcher and register
39
+ it:
40
+
41
+ module DatabaseFetcher
42
+ def self.fetch_uri_to_tempfile(uri_object)
43
+ tf = Tempfile.new 'data-fetch'
44
+
45
+ object_id = uri_object.path[/\d+/]
46
+ data_model = ProfilePictures.find(object_id)
47
+ tf << data_model.body
48
+
49
+ tf.rewind; tf
50
+ rescue Exception => e
51
+ ImageVise.close_and_unlink(tf) # do not litter Tempfiles
52
+ raise e
53
+ end
54
+ end
55
+
56
+ ImageVise.register_fetcher 'profilepictures', self
57
+
58
+ Once done, you can use URLs like `profilepictures:/5674`
59
+
60
+ ## Overriding the render engine
61
+
62
+ By default, `ImageVise.call` delegates to `ImageVise::RenderEngine.new.call`. You can mount your own subclass
63
+ instead, and it will handle everything the same way:
64
+
65
+ class MyThumbnailer < ImageVise::RenderEngine
66
+ ...
67
+ end
68
+
69
+ map '/thumbs' do
70
+ run MyThumbnailer.new
71
+ end
72
+
73
+ Note that since the API is in flux the methods you can override in `RenderEngine` can change.
74
+ So far none of them are private.
75
+
76
+ ## Performance and memory
77
+
78
+ ImageVise uses ImageMagick and RMagick. It _does not_ shell out to `convert` or `mogrify`, because shelling out
79
+ is _expensive_ in terms of wall clock. It _does_ do it's best to deallocate (`#destroy!`) the image it works on,
80
+ but it is not 100% bullet proof.
81
+
82
+ Additionally, in contrast to `convert` and `mogrify` ImageVise supports _stackable_ operations, and these operations
83
+ might be repeated with different parameters. Unfortunately, `convert` is _not_ Shake, so we cannot pass a DAG of
84
+ image operators to it and just expect it to work. If we want to do processing of multiple steps that `convert` is
85
+ unable to execute in one call, we have to do
86
+
87
+ [fork+exec read + convert + write] -> [fork+exec read + convert + write] + ...
88
+
89
+ for each operator we want to apply in a consistent fashion. We cannot stuff all the operators into one `convert`
90
+ command because the order the operators get applied within `convert` is not clear, whereas we need a reproducible
91
+ deterministic order of operations (as set in the pipeline). A much better solution is - load the image into memory
92
+ **once**, do all the transformations, save. Additionally, if you use things like OpenCL with ImageMagick, the overhead
93
+ of loading the library and compiling the compute kernels will outweigh _any_ performance gains you might get when
94
+ actually using them. If you are using a library it is a one-time cost, with very fast processing afterwards.
95
+
96
+ Also note that image operators are not per definition Imagemagick-specific - it's completely possible to not only use
97
+ a different library for processing them, but even to use a different image processing server complying to the
98
+ same protocol (a signed JSON-encodded waybill of HTTP(S) source-URL + pipeline instructions).
data/Gemfile CHANGED
@@ -1,6 +1,5 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
- gem 'bundler'
4
3
  gem 'patron', '~> 0.6'
5
4
  gem 'rmagick', '~> 2.15', :require => 'rmagick'
6
5
  gem 'exceptional_fork', '~> 1.2'
@@ -8,6 +7,8 @@ gem 'ks'
8
7
  gem 'magic_bytes'
9
8
 
10
9
  group :development do
10
+ gem 'bundler'
11
+ gem 'yard'
11
12
  gem 'simplecov'
12
13
  gem 'rack-cache'
13
14
  gem 'strenv'
data/README.md CHANGED
@@ -129,40 +129,6 @@ image = Magick::Image.read(my_image_path)[0]
129
129
  pipe.apply!(image)
130
130
  ```
131
131
 
132
- ## Performance and memory
133
-
134
- ImageVise uses ImageMagick and RMagick. It _does not_ shell out to `convert` or `mogrify`, because shelling out
135
- is _expensive_ in terms of wall clock. It _does_ do it's best to deallocate (`#destroy!`) the image it works on,
136
- but it is not 100% bullet proof.
137
-
138
- Additionally, in contrast to `convert` and `mogrify` ImageVise supports _stackable_ operations, and these operations
139
- might be repeated with different parameters. Unfortunately, `convert` is _not_ Shake, so we cannot pass a DAG of
140
- image operators to it and just expect it to work. If we want to do processing of multiple steps that `convert` is
141
- unable to execute in one call, we have to do
142
-
143
- [fork+exec read + convert + write] -> [fork+exec read + convert + write] + ...
144
-
145
- for each operator we want to apply in a consistent fashion. We cannot stuff all the operators into one `convert`
146
- command because the order the operators get applied within `convert` is not clear, whereas we need a reproducible
147
- deterministic order of operations (as set in the pipeline). A much better solution is - load the image into memory
148
- **once**, do all the transformations, save. Additionally, if you use things like OpenCL with ImageMagick, the overhead
149
- of loading the library and compiling the compute kernels will outweigh _any_ performance gains you might get when
150
- actually using them. If you are using a library it is a one-time cost, with very fast processing afterwards.
151
-
152
- Also note that image operators are not per definition Imagemagick-specific - it's completely possible to not only use
153
- a different library for processing them, but even to use a different image processing server complying to the
154
- same protocol (a signed JSON-encodded waybill of HTTP(S) source-URL + pipeline instructions).
155
-
156
- ## Using forked child processes for RMagick tasks
157
-
158
- You can optionally set the `IMAGE_VISE_ENABLE_FORK` environment variable to `yes` to enable forking. When this
159
- variable is set, ImageVise will fork a child process and perform the image processing task within that process,
160
- killing it afterwards and deallocating all the memory. This can be extremely efficient for dealing with potential
161
- memory bloat issues in ImageMagick/RMagick. However, loading images into RMagick may hang in a forked child. This
162
- will lead to the child being timeout-terminated, and no image is going to be rendered. This issue is known and
163
- also platform-dependent (it does not happen on OSX but does happen on Docker within Ubuntu Trusty for instance).
164
-
165
- So, this feature _does_ exist but your mileage may vary with regards to it's use.
166
132
 
167
133
  ## Caching
168
134
 
@@ -207,50 +173,18 @@ Note that these are _glob_ patterns. The image path will be checked against them
207
173
  By default, the Rack app within ImageVise swallows all exceptions and returns the error message
208
174
  within a machine-readable JSON payload. If that doesn't work for you, or you want to add error
209
175
  handling using some error tracking provider, either subclass `ImageVise::RenderEngine` or prepend
210
- a module into it that will intercept the errors. For example, [Sentry](https://sentry.io) has a neat
211
- property of picking up `rack.exception` from the Rack request env. Using the hooks in the render engine,
212
- you can add Sentry support by using the following module:
213
-
214
- ```ruby
215
- module ImageViseSentrySupport
216
- ImageVise::RenderEngine.prepend self
217
-
218
- def setup_error_handling(rack_env)
219
- @env = rack_env
220
- end
221
-
222
- def handle_request_error(err)
223
- @env['rack.exception'] = err
224
- end
225
-
226
- def handle_generic_error(err)
227
- @env['rack.exception'] = err
228
- end
229
- end
230
- ```
231
-
232
- For [Appsignal](https://appsignal.com) you can use the following module instead:
176
+ a module into it that will intercept the errors. See error handling in `examples/` for more.
233
177
 
234
- ```ruby
235
- module ImageViseAppsignal
236
- ImageVise::RenderEngine.prepend self
237
-
238
- def setup_error_handling(rack_env)
239
- txn = Appsignal::Transaction.current
240
- txn.set_action('%s#%s' % [self.class, 'call'])
241
- end
242
-
243
- def handle_request_error(err)
244
- Appsignal.add_exception(err)
245
- end
178
+ ## Using forked child processes for RMagick tasks
246
179
 
247
- def handle_generic_error(err)
248
- Appsignal.add_exception(err)
249
- end
250
- end
251
- ```
180
+ You can optionally set the `IMAGE_VISE_ENABLE_FORK` environment variable to `yes` to enable forking. When this
181
+ variable is set, ImageVise will fork a child process and perform the image processing task within that process,
182
+ killing it afterwards and deallocating all the memory. This can be extremely efficient for dealing with potential
183
+ memory bloat issues in ImageMagick/RMagick. However, loading images into RMagick may hang in a forked child. This
184
+ will lead to the child being timeout-terminated, and no image is going to be rendered. This issue is known and
185
+ also platform-dependent (it does not happen on OSX but does happen on Docker within Ubuntu Trusty for instance).
252
186
 
253
- In both cases you need the overall Rack error handling middleware to be wrapping ImageVise, of course.
187
+ So, this feature _does_ exist but your mileage may vary with regards to it's use.
254
188
 
255
189
  ## State
256
190
 
data/SECURITY.md ADDED
@@ -0,0 +1,32 @@
1
+ # Security implications for ImageVise
2
+
3
+ This lists out the implementation details of security-sensitive parts of ImageVise.
4
+
5
+ ## Protection of the URLs
6
+
7
+ URLs are passed as Base64-encoded JSON. The JSON payload is signed using HMAC with SHA256. This should be
8
+ sufficient to prevent too much probing. If this is a critical issue you need to put throttling in front of the application.
9
+ For checking HMAC values `Rack::Utils.secure_compare` constant-time comparison is used.
10
+
11
+ ## Protection for remote URLs from HTTP(s) origins
12
+
13
+ Only URLs referring to permitted hosts are going to be permitted for fetching. If there are no host added,
14
+ any remote URL is going to cause an exception. No special verification for whether the upstream must be HTTP
15
+ or HTTPS is performed at this time.
16
+
17
+ ## Protection for "file:/" URLs
18
+
19
+ The file URLs are going to be decoded, and the path component will be matched against permitted _glob patterns._
20
+ The matching takes links (hard and soft) into account, and uses Ruby's `File.fnmatch?` under the hood. The path
21
+ is always expanded first using `File.expand_path`. The data is not read into ImageMagick from the original location,
22
+ but gets copied into a tempfile first.
23
+
24
+ ## ImageMagick memory constraints
25
+
26
+ ImageVise does not set RMagick limits by itself. You should
27
+ [set them according to the RMagick documentation.](https://rmagick.github.io/magick.html#limit_resource)
28
+
29
+ ## Processing time constraints
30
+
31
+ If you are using forking, there will be a timeout used for how long the forked child process may run,
32
+ which is the default timeout used in ExceptionalFork.
@@ -0,0 +1,27 @@
1
+ class MyBlur
2
+ # The constructor must accept keyword arguments if your operator accepts them
3
+ def initialize(radius:, sigma:)
4
+ @radius = radius.to_f
5
+ @sigma = sigma.to_f
6
+ end
7
+
8
+ # Needed for the operator to be serialized to parameters
9
+ def to_h
10
+ {radius: @radius, sigma: @sigma}
11
+ end
12
+
13
+ def apply!(magick_image) # The argument is a Magick::Image
14
+ # Apply the method to the function argument
15
+ blurred_image = magick_image.blur_image(@radius, @sigma)
16
+
17
+ # Some methods (without !) return a new Magick::Image.
18
+ # That image has to be composited back into the original.
19
+ magick_image.composite!(blurred_image, Magick::CenterGravity, Magick::CopyCompositeOp)
20
+ ensure
21
+ # Make sure to explicitly destroy() all the intermediate images
22
+ ImageVise.destroy(blurred_image)
23
+ end
24
+ end
25
+
26
+ # This will also make `ImageVise::Pipeline` respond to `blur(radius:.., sigma:..)`
27
+ ImageVise.add_operator 'my_blur', MyBlur
data/image_vise.gemspec CHANGED
@@ -2,16 +2,16 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: image_vise 0.0.25 ruby lib
5
+ # stub: image_vise 0.0.26 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "image_vise"
9
- s.version = "0.0.25"
9
+ s.version = "0.0.26"
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib"]
13
13
  s.authors = ["Julik Tarkhanov"]
14
- s.date = "2016-10-18"
14
+ s.date = "2016-10-20"
15
15
  s.description = "Image processing via URLs"
16
16
  s.email = "me@julik.nl"
17
17
  s.extra_rdoc_files = [
@@ -19,11 +19,14 @@ Gem::Specification.new do |s|
19
19
  "README.md"
20
20
  ]
21
21
  s.files = [
22
+ "DEVELOPMENT.md",
22
23
  "Gemfile",
23
24
  "LICENSE.txt",
24
25
  "README.md",
25
26
  "Rakefile",
27
+ "SECURITY.md",
26
28
  "examples/config.ru",
29
+ "examples/custom_image_operator.rb",
27
30
  "examples/error_handline_appsignal.rb",
28
31
  "examples/error_handling_sentry.rb",
29
32
  "image_vise.gemspec",
@@ -72,12 +75,13 @@ Gem::Specification.new do |s|
72
75
  s.specification_version = 4
73
76
 
74
77
  if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then
75
- s.add_runtime_dependency(%q<bundler>, [">= 0"])
76
78
  s.add_runtime_dependency(%q<patron>, ["~> 0.6"])
77
79
  s.add_runtime_dependency(%q<rmagick>, ["~> 2.15"])
78
80
  s.add_runtime_dependency(%q<exceptional_fork>, ["~> 1.2"])
79
81
  s.add_runtime_dependency(%q<ks>, [">= 0"])
80
82
  s.add_runtime_dependency(%q<magic_bytes>, [">= 0"])
83
+ s.add_development_dependency(%q<bundler>, [">= 0"])
84
+ s.add_development_dependency(%q<yard>, [">= 0"])
81
85
  s.add_development_dependency(%q<simplecov>, [">= 0"])
82
86
  s.add_development_dependency(%q<rack-cache>, [">= 0"])
83
87
  s.add_development_dependency(%q<strenv>, [">= 0"])
@@ -89,12 +93,13 @@ Gem::Specification.new do |s|
89
93
  s.add_development_dependency(%q<rake>, ["~> 10"])
90
94
  s.add_development_dependency(%q<jeweler>, [">= 0"])
91
95
  else
92
- s.add_dependency(%q<bundler>, [">= 0"])
93
96
  s.add_dependency(%q<patron>, ["~> 0.6"])
94
97
  s.add_dependency(%q<rmagick>, ["~> 2.15"])
95
98
  s.add_dependency(%q<exceptional_fork>, ["~> 1.2"])
96
99
  s.add_dependency(%q<ks>, [">= 0"])
97
100
  s.add_dependency(%q<magic_bytes>, [">= 0"])
101
+ s.add_dependency(%q<bundler>, [">= 0"])
102
+ s.add_dependency(%q<yard>, [">= 0"])
98
103
  s.add_dependency(%q<simplecov>, [">= 0"])
99
104
  s.add_dependency(%q<rack-cache>, [">= 0"])
100
105
  s.add_dependency(%q<strenv>, [">= 0"])
@@ -107,12 +112,13 @@ Gem::Specification.new do |s|
107
112
  s.add_dependency(%q<jeweler>, [">= 0"])
108
113
  end
109
114
  else
110
- s.add_dependency(%q<bundler>, [">= 0"])
111
115
  s.add_dependency(%q<patron>, ["~> 0.6"])
112
116
  s.add_dependency(%q<rmagick>, ["~> 2.15"])
113
117
  s.add_dependency(%q<exceptional_fork>, ["~> 1.2"])
114
118
  s.add_dependency(%q<ks>, [">= 0"])
115
119
  s.add_dependency(%q<magic_bytes>, [">= 0"])
120
+ s.add_dependency(%q<bundler>, [">= 0"])
121
+ s.add_dependency(%q<yard>, [">= 0"])
116
122
  s.add_dependency(%q<simplecov>, [">= 0"])
117
123
  s.add_dependency(%q<rack-cache>, [">= 0"])
118
124
  s.add_dependency(%q<strenv>, [">= 0"])
@@ -9,14 +9,18 @@ class ImageVise::ImageRequest < Ks.strict(:src_url, :pipeline)
9
9
  def self.to_request(qs_params:, secrets:)
10
10
  base64_encoded_params = qs_params.fetch(:q) rescue qs_params.fetch('q')
11
11
  given_signature = qs_params.fetch(:sig) rescue qs_params.fetch('sig')
12
-
13
- # Decode Base64 first - this gives us a stable serialized form of the request parameters
12
+
13
+ # Decode Base64 first - this gives us a stable serialized form of the request parameters.
14
+ # The encoded parameters might _not_ include ==-padding at the end.
14
15
  decoded_json = Base64.decode64(base64_encoded_params)
15
16
 
16
- # Check the signature before decoding JSON (since we will be creating symbols and stuff)
17
- raise SignatureError, "Invalid or missing signature" unless valid_signature?(decoded_json, given_signature, secrets)
17
+ # Check the signature before decoding JSON (since we will be creating symbols)
18
+ unless valid_signature?(decoded_json, given_signature, secrets)
19
+ raise SignatureError, "Invalid or missing signature"
20
+ end
18
21
 
19
22
  # Decode the JSON
23
+ # (only AFTER the signature has been validated, so we can use symbol keys)
20
24
  params = JSON.parse(decoded_json, symbolize_names: true)
21
25
 
22
26
  # Pick up the URL and validate it
@@ -30,7 +34,8 @@ class ImageVise::ImageRequest < Ks.strict(:src_url, :pipeline)
30
34
 
31
35
  def to_query_string_params(signed_with_secret)
32
36
  payload = JSON.dump(to_h)
33
- {q: Base64.strict_encode64(payload), sig: OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, signed_with_secret, payload)}
37
+ base64_enc = Base64.strict_encode64(payload).gsub(/\=+$/, '')
38
+ {q: base64_enc, sig: OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, signed_with_secret, payload)}
34
39
  end
35
40
 
36
41
  def to_h
@@ -43,17 +43,24 @@ class ImageVise::RenderEngine
43
43
  throw :__bail, response
44
44
  end
45
45
 
46
- # The main entry point URL, at the index so that the Sinatra app can be used
47
- # in-place of a Rails controller (as opposed to having to mount it at the root
48
- # of the Rails app or having all the URLs refer to a subpath)
46
+ # The main entry point for the Rack app. Wraps a call to {#handle_request} in a `catch{}` block
47
+ # so that any method can abort the request by calling {#bail}
48
+ #
49
+ # @param env[Hash] the Rack env
50
+ # @return [Array] the Rack response
49
51
  def call(env)
50
52
  catch(:__bail) { handle_request(env) }
51
53
  end
52
54
 
55
+ # Hadles the Rack request. If one of the steps calls {#bail} the `:__bail` symbol will be
56
+ # thrown and the execution will abort. Any errors will cause either an error response in
57
+ # JSON format or an Exception will be raised (depending on the return value of `raise_exceptions?`)
58
+ #
59
+ # @param env[Hash] the Rack env
60
+ # @return [Array] the Rack response
53
61
  def handle_request(env)
54
62
  setup_error_handling(env)
55
- render_destination_file = binary_tempfile
56
-
63
+
57
64
  # Assume that if _any_ ETag is given the image is being requested anew as a refetch,
58
65
  # and the client already has it. Just respond with a 304.
59
66
  return [304, DEFAULT_HEADERS.dup, []] if env['HTTP_IF_NONE_MATCH']
@@ -61,9 +68,31 @@ class ImageVise::RenderEngine
61
68
  req = Rack::Request.new(env)
62
69
  bail(405, 'Only GET supported') unless req.get?
63
70
 
64
- # Parse and reinstate the URL and pipeline
65
71
  image_request = ImageVise::ImageRequest.to_request(qs_params: req.params, secrets: ImageVise.secret_keys)
72
+ render_destination_file, render_file_type, etag = process_image_request(image_request)
73
+ image_rack_response(render_destination_file, render_file_type, etag)
74
+ rescue *permanent_failures => e
75
+ handle_request_error(e)
76
+ http_status_code = e.respond_to?(:http_status) ? e.http_status : 422
77
+ raise_exception_or_error_response(e, http_status_code)
78
+ rescue Exception => e
79
+ if http_status_code = (e.respond_to?(:http_status) && e.http_status)
80
+ handle_request_error(e)
81
+ raise_exception_or_error_response(e, http_status_code)
82
+ else
83
+ handle_generic_error(e)
84
+ raise_exception_or_error_response(e, 500)
85
+ end
86
+ end
66
87
 
88
+ # Processes the ImageRequest object created from the request parameters,
89
+ # and returns a triplet of the File object containing the rendered image,
90
+ # the MagicBytes::FileType object of the render, and the cache ETag value
91
+ # representing the processing pipeline
92
+ #
93
+ # @param image_request[ImageVise::ImageRequest] the request for the image
94
+ # @return [Array<File, MagicBytes::FileType, String]
95
+ def process_image_request(image_request)
67
96
  # Recover the source image URL and the pipeline instructions (all the image ops)
68
97
  source_image_uri, pipeline = image_request.src_url, image_request.pipeline
69
98
  raise 'Image pipeline has no operators' if pipeline.empty?
@@ -81,21 +110,39 @@ class ImageVise::RenderEngine
81
110
  unless source_file_type_permitted?(source_file_type)
82
111
  raise UnsupportedInputFormat.new("Unsupported/unknown input file format .%s" % source_file_type.ext)
83
112
  end
84
-
113
+
114
+ render_destination_file = Tempfile.new('imagevise-render').tap{|f| f.binmode }
115
+
85
116
  # Perform the processing
86
117
  if enable_forking?
87
118
  require 'exceptional_fork'
88
- ExceptionalFork.fork_and_wait { apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path) }
119
+ ExceptionalFork.fork_and_wait do
120
+ apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path)
121
+ end
89
122
  else
90
123
  apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path)
91
124
  end
92
-
125
+
93
126
  # Catch this one early
127
+ render_destination_file.rewind
94
128
  raise EmptyRender, "The rendered image was empty" if render_destination_file.size.zero?
95
129
 
96
- render_destination_file.rewind
97
130
  render_file_type = detect_file_type(render_destination_file)
98
-
131
+ [render_destination_file, render_file_type, etag]
132
+ ensure
133
+ ImageVise.close_and_unlink(source_file)
134
+ end
135
+
136
+ # Returns a Rack response triplet. Accepts the return value of
137
+ # `process_image_request` unsplatted, and returns a triplet that
138
+ # can be returned as a Rack response. The Rack response will contain
139
+ # an iterable body object that is designed to automatically delete
140
+ # the Tempfile it wraps on close.
141
+ #
142
+ # @param render_destination_file[File] the File handle to the rendered image
143
+ # @param render_file_type[MagicBytes::FileType] the rendered file type
144
+ # @param etag[String] the ETag for the response
145
+ def image_rack_response(render_destination_file, render_file_type, etag)
99
146
  response_headers = DEFAULT_HEADERS.merge({
100
147
  'Content-Type' => render_file_type.mime,
101
148
  'Content-Length' => '%d' % render_destination_file.size,
@@ -106,22 +153,13 @@ class ImageVise::RenderEngine
106
153
  # Wrap the body Tempfile with a self-closing response.
107
154
  # Once the response is read in full, the tempfile is going to be closed and unlinked.
108
155
  [200, response_headers, ImageVise::FileResponse.new(render_destination_file)]
109
- rescue *permanent_failures => e
110
- handle_request_error(e)
111
- http_status_code = e.respond_to?(:http_status) ? e.http_status : 422
112
- raise_exception_or_error_response(e, http_status_code)
113
- rescue Exception => e
114
- if http_status_code = (e.respond_to?(:http_status) && e.http_status)
115
- handle_request_error(e)
116
- raise_exception_or_error_response(e, http_status_code)
117
- else
118
- handle_generic_error(e)
119
- raise_exception_or_error_response(e, 500)
120
- end
121
- ensure
122
- ImageVise.close_and_unlink(source_file)
123
156
  end
124
-
157
+
158
+ # Depending on `raise_exceptions?` will either raise the passed Exception,
159
+ # or force the application to return the error in the Rack response.
160
+ #
161
+ # @param exception[Exception] the error that has to be captured
162
+ # @param status_code[Fixnum] the HTTP status code
125
163
  def raise_exception_or_error_response(exception, status_code)
126
164
  if raise_exceptions?
127
165
  raise exception
@@ -130,25 +168,37 @@ class ImageVise::RenderEngine
130
168
  end
131
169
  end
132
170
 
133
- def binary_tempfile
134
- Tempfile.new('imagevise-tmp').tap{|f| f.binmode }
135
- end
136
-
171
+ # Detects the file type of the given File and returns
172
+ # a MagicBytes::FileType object that contains the extension and
173
+ # the MIME type.
174
+ #
175
+ # @param tempfile[File] the file to perform detection on
176
+ # @return [MagicBytes::FileType] the detected file type
137
177
  def detect_file_type(tempfile)
138
178
  tempfile.rewind
139
- MagicBytes.read_and_detect(tempfile)
179
+ MagicBytes.read_and_detect(tempfile).tap { tempfile.rewind }
140
180
  end
141
181
 
142
- def source_file_type_permitted?(magick_bytes_file_info)
143
- PERMITTED_SOURCE_FILE_EXTENSIONS.include?(magick_bytes_file_info.ext)
182
+ # Tells whether the given file type may be loaded into the image processor.
183
+ #
184
+ # @param magic_bytes_file_info[MagicBytes::FileType] the filetype
185
+ # @return [Boolean]
186
+ def source_file_type_permitted?(magic_bytes_file_info)
187
+ PERMITTED_SOURCE_FILE_EXTENSIONS.include?(magic_bytes_file_info.ext)
144
188
  end
145
189
 
146
- def output_file_type_permitted?(magick_bytes_file_info)
147
- PERMITTED_OUTPUT_FILE_EXTENSIONS.include?(magick_bytes_file_info.ext)
190
+ # Tells whether the given file type may be returned
191
+ # as the result of the render
192
+ #
193
+ # @param magic_bytes_file_info[MagicBytes::FileType] the filetype
194
+ # @return [Boolean]
195
+ def output_file_type_permitted?(magic_bytes_file_info)
196
+ PERMITTED_OUTPUT_FILE_EXTENSIONS.include?(magic_bytes_file_info.ext)
148
197
  end
149
198
 
150
199
  # Lists exceptions that should lead to the request being flagged
151
- # as invalid (and not 5xx). Decent clients should _not_ retry those requests.
200
+ # as invalid (4xx as opposed to 5xx for a generic server error).
201
+ # Decent clients should _not_ retry those requests.
152
202
  def permanent_failures
153
203
  [
154
204
  Magick::ImageMagickError,
@@ -158,32 +208,56 @@ class ImageVise::RenderEngine
158
208
  end
159
209
 
160
210
  # Is meant to be overridden by subclasses,
161
- # will be called at the start of each reauest
211
+ # will be called at the start of each request to set up the error handling
212
+ # library (Appsignal, Honeybadger, Sentry...)
213
+ #
214
+ # @param rack_env[Hash] the Rack env
215
+ # @return [void]
162
216
  def setup_error_handling(rack_env)
163
217
  end
164
218
 
165
219
  # Is meant to be overridden by subclasses,
166
220
  # will be called when a request fails due to a malformed query string,
167
- # unrecognized signature or other client-induced problems
168
- def handle_request_error(err)
221
+ # unrecognized signature or other client-induced problems. The method
222
+ # should _not_ re-raise the exception.
223
+ #
224
+ # @param exception[Exception] the exception to be handled
225
+ # @return [void]
226
+ def handle_request_error(exception)
169
227
  end
170
228
 
171
229
  # Is meant to be overridden by subclasses,
172
230
  # will be called when a request fails due to an error on the server
173
- # (like an unexpected error in an image operator)
174
- def handle_generic_error(err)
231
+ # (like an unexpected error in an image operator). The method
232
+ # should _not_ re-raise the exception.
233
+ #
234
+ # @param exception[Exception] the exception to be handled
235
+ # @return [void]
236
+ def handle_generic_error(exception)
175
237
  end
176
238
 
177
239
  # Tells whether the engine must raise the exceptions further up the Rack stack,
178
240
  # or they should be suppressed and a JSON response must be returned.
241
+ #
242
+ # @return [Boolean]
179
243
  def raise_exceptions?
180
244
  false
181
245
  end
182
246
 
247
+ # Tells whether image processing in a forked subproces should be turned on
248
+ #
249
+ # @return [Boolean]
183
250
  def enable_forking?
184
251
  ENV['IMAGE_VISE_ENABLE_FORK'] == 'yes'
185
252
  end
186
253
 
254
+ # Applies the given {ImageVise::Pipeline} to the image, and writes the render to
255
+ # the given path.
256
+ #
257
+ # @param source_file_path[String] the path to the file containing the source image
258
+ # @param pipeline[#apply!(Magick::Image)] the processing pipeline
259
+ # @param render_to_path[String] the path to write the rendered image to
260
+ # @return [void]
187
261
  def apply_pipeline(source_file_path, pipeline, source_file_type, render_to_path)
188
262
  render_file_type = source_file_type
189
263
  magick_image = Magick::Image.read(source_file_path)[0]
data/lib/image_vise.rb CHANGED
@@ -8,7 +8,7 @@ require 'base64'
8
8
  require 'rack'
9
9
 
10
10
  class ImageVise
11
- VERSION = '0.0.25'
11
+ VERSION = '0.0.26'
12
12
  S_MUTEX = Mutex.new
13
13
  private_constant :S_MUTEX
14
14
 
@@ -75,7 +75,7 @@ class ImageVise
75
75
  #
76
76
  # The query string elements can be then passed on to RenderEngine for validation and execution.
77
77
  #
78
- # @yields {ImageVise::Pipeline}
78
+ # @yield {ImageVise::Pipeline}
79
79
  # @return [Hash]
80
80
  def image_params(src_url:, secret:)
81
81
  p = Pipeline.new
@@ -4,11 +4,10 @@ describe ImageVise::ImageRequest do
4
4
  it 'accepts a set of params and secrets, and returns a Pipeline' do
5
5
  img_params = {src_url: 'http://bucket.s3.aws.com/image.jpg', pipeline: [[:crop, {width: 10, height: 10, gravity: 's'}]]}
6
6
  img_params_json = JSON.dump(img_params)
7
+
8
+ q = Base64.encode64(img_params_json)
7
9
  signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA256.new, 'this is a secret', img_params_json)
8
- params = {
9
- q: Base64.encode64(img_params_json),
10
- sig: signature
11
- }
10
+ params = {q: q, sig: signature}
12
11
 
13
12
  image_request = described_class.to_request(qs_params: params, secrets: ['this is a secret'])
14
13
  request_qs_params = image_request.to_query_string_params('this is a secret')
@@ -29,6 +28,17 @@ describe ImageVise::ImageRequest do
29
28
  expect(image_request.src_url).to be_kind_of(URI)
30
29
  end
31
30
 
31
+
32
+ it 'never apppends "="-padding to the Base64-encoded "q"' do
33
+ parametrized = double(to_params: {foo: 'bar'})
34
+ (1..12).each do |num_chars_in_url|
35
+ uri = URI('http://ex.com/%s' % ('i' * num_chars_in_url))
36
+ image_request = described_class.new(src_url: uri, pipeline: parametrized)
37
+ q = image_request.to_query_string_params('password').fetch(:q)
38
+ expect(q).not_to include('=')
39
+ end
40
+ end
41
+
32
42
  describe 'fails with an invalid pipeline' do
33
43
  it 'when the pipe param is missing'
34
44
  it 'when the pipe param is empty'
@@ -158,6 +158,25 @@ describe ImageVise::RenderEngine do
158
158
  expect(parsed_image.columns).to eq(10)
159
159
  end
160
160
 
161
+ it 'calls all of the internal methods during execution' do
162
+ uri = Addressable::URI.parse(public_url)
163
+ ImageVise.add_allowed_host!(uri.host)
164
+ ImageVise.add_secret_key!('l33tness')
165
+
166
+ p = ImageVise::Pipeline.new.geom(geometry_string: '512x335').fit_crop(width: 10, height: 10, gravity: 'c')
167
+ image_request = ImageVise::ImageRequest.new(src_url: uri.to_s, pipeline: p)
168
+ params = image_request.to_query_string_params('l33tness')
169
+
170
+ expect(app).to receive(:process_image_request).and_call_original
171
+ expect(app).to receive(:image_rack_response).and_call_original
172
+ expect(app).to receive(:source_file_type_permitted?).and_call_original
173
+ expect(app).to receive(:output_file_type_permitted?).and_call_original
174
+ expect(app).to receive(:enable_forking?).and_call_original
175
+
176
+ get '/', params
177
+ expect(last_response.status).to eq(200)
178
+ end
179
+
161
180
  it 'picks the image from the filesystem if that is permitted' do
162
181
  uri = 'file://' + test_image_path
163
182
  ImageVise.allow_filesystem_source!(File.dirname(test_image_path) + '/*.*')
metadata CHANGED
@@ -1,29 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: image_vise
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.25
4
+ version: 0.0.26
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-10-18 00:00:00.000000000 Z
11
+ date: 2016-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: bundler
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '0'
20
- type: :runtime
21
- prerelease: false
22
- version_requirements: !ruby/object:Gem::Requirement
23
- requirements:
24
- - - ">="
25
- - !ruby/object:Gem::Version
26
- version: '0'
27
13
  - !ruby/object:Gem::Dependency
28
14
  name: patron
29
15
  requirement: !ruby/object:Gem::Requirement
@@ -94,6 +80,34 @@ dependencies:
94
80
  - - ">="
95
81
  - !ruby/object:Gem::Version
96
82
  version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: bundler
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: yard
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: simplecov
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -248,11 +262,14 @@ extra_rdoc_files:
248
262
  - LICENSE.txt
249
263
  - README.md
250
264
  files:
265
+ - DEVELOPMENT.md
251
266
  - Gemfile
252
267
  - LICENSE.txt
253
268
  - README.md
254
269
  - Rakefile
270
+ - SECURITY.md
255
271
  - examples/config.ru
272
+ - examples/custom_image_operator.rb
256
273
  - examples/error_handline_appsignal.rb
257
274
  - examples/error_handling_sentry.rb
258
275
  - image_vise.gemspec