image_vise 0.0.25 → 0.0.26

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 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