image_vise 0.2.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,298 +0,0 @@
1
- class ImageVise::RenderEngine
2
- class UnsupportedInputFormat < StandardError; end
3
- class EmptyRender < StandardError; end
4
-
5
- DEFAULT_HEADERS = {
6
- 'Allow' => "GET"
7
- }.freeze
8
-
9
- # Headers for error responses that denote an invalid or
10
- # an unsatisfiable request
11
- JSON_ERROR_HEADERS_REQUEST = DEFAULT_HEADERS.merge({
12
- 'Content-Type' => 'application/json',
13
- 'Cache-Control' => 'public, max-age=600'
14
- }).freeze
15
-
16
- # Headers for error responses that denote
17
- # an intermittent error (that permit retries)
18
- JSON_ERROR_HEADERS_INTERMITTENT = DEFAULT_HEADERS.merge({
19
- 'Content-Type' => 'application/json',
20
- 'Cache-Control' => 'public, max-age=5'
21
- }).freeze
22
-
23
- # "public" of course. Add max-age so that there is _some_
24
- # revalidation after a time (otherwise some proxies treat it
25
- # as "must-revalidate" always), and "no-transform" so that
26
- # various deflate schemes are not applied to it (does happen
27
- # with Rack::Cache and leads Chrome to throw up on content
28
- # decoding for example).
29
- IMAGE_CACHE_CONTROL = 'public, no-transform, max-age=2592000'
30
-
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)
37
-
38
- # How long should we wait when fetching the image from the external host
39
- EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS = 4
40
-
41
- def bail(status, *errors_array)
42
- headers = if (300...500).cover?(status)
43
- JSON_ERROR_HEADERS_REQUEST.dup
44
- else
45
- JSON_ERROR_HEADERS_INTERMITTENT.dup
46
- end
47
- response = [status.to_i, headers, [JSON.pretty_generate({errors: errors_array})]]
48
- throw :__bail, response
49
- end
50
-
51
- # The main entry point for the Rack app. Wraps a call to {#handle_request} in a `catch{}` block
52
- # so that any method can abort the request by calling {#bail}
53
- #
54
- # @param env[Hash] the Rack env
55
- # @return [Array] the Rack response
56
- def call(env)
57
- catch(:__bail) { handle_request(env) }
58
- end
59
-
60
- # Hadles the Rack request. If one of the steps calls {#bail} the `:__bail` symbol will be
61
- # thrown and the execution will abort. Any errors will cause either an error response in
62
- # JSON format or an Exception will be raised (depending on the return value of `raise_exceptions?`)
63
- #
64
- # @param env[Hash] the Rack env
65
- # @return [Array] the Rack response
66
- def handle_request(env)
67
- setup_error_handling(env)
68
-
69
- # Assume that if _any_ ETag is given the image is being requested anew as a refetch,
70
- # and the client already has it. Just respond with a 304.
71
- return [304, DEFAULT_HEADERS.dup, []] if env['HTTP_IF_NONE_MATCH']
72
-
73
- req = parse_env_into_request(env)
74
- bail(405, 'Only GET supported') unless req.get?
75
- params = extract_params_from_request(req)
76
-
77
- image_request = ImageVise::ImageRequest.from_params(qs_params: params, secrets: ImageVise.secret_keys)
78
- render_destination_file, render_file_type, etag = process_image_request(image_request)
79
- image_rack_response(render_destination_file, render_file_type, etag)
80
- rescue *permanent_failures => e
81
- handle_request_error(e)
82
- http_status_code = e.respond_to?(:http_status) ? e.http_status : 400
83
- raise_exception_or_error_response(e, http_status_code)
84
- rescue Exception => e
85
- if http_status_code = (e.respond_to?(:http_status) && e.http_status)
86
- handle_request_error(e)
87
- raise_exception_or_error_response(e, http_status_code)
88
- else
89
- handle_generic_error(e)
90
- raise_exception_or_error_response(e, 500)
91
- end
92
- end
93
-
94
- # Parses the Rack environment into a Rack::Reqest. The following methods
95
- # are going to be called on it: `#get?` and `#params`. You can use this
96
- # method to override path-to-parameter translation for example.
97
- #
98
- # @param rack_env[Hash] the Rack environment
99
- # @return [#get?, #params] the Rack request or a compatible object
100
- def parse_env_into_request(rack_env)
101
- Rack::Request.new(rack_env)
102
- end
103
-
104
- # Extracts the image params from the Rack::Request
105
- #
106
- # @param rack_request[#path_info] an object that has a path info
107
- # @return [Hash] the params hash with `:q` and `:sig` keys
108
- def extract_params_from_request(rack_request)
109
- # Prevent cache bypass DOS attacks by only permitting :sig and :q
110
- bail(400, 'Query strings are not supported') if rack_request.params.any?
111
-
112
- # Extract the tail (signature) and the front (the Base64-encoded request).
113
- # Slashes within :q are masked by ImageRequest already, so we don't have
114
- # to worry about them.
115
- *, q_from_path, sig_from_path = rack_request.path_info.split('/')
116
-
117
- # Raise if any of them are empty or blank
118
- nothing_recovered = [q_from_path, sig_from_path].all?{|v| v.nil? || v.empty? }
119
- bail(400, 'Need 2 usable path components') if nothing_recovered
120
-
121
- {q: q_from_path, sig: sig_from_path}
122
- end
123
-
124
- # Processes the ImageRequest object created from the request parameters,
125
- # and returns a triplet of the File object containing the rendered image,
126
- # the MagicBytes::FileType object of the render, and the cache ETag value
127
- # representing the processing pipeline
128
- #
129
- # @param image_request[ImageVise::ImageRequest] the request for the image
130
- # @return [Array<File, MagicBytes::FileType, String]
131
- def process_image_request(image_request)
132
- # Recover the source image URL and the pipeline instructions (all the image ops)
133
- source_image_uri, pipeline = image_request.src_url, image_request.pipeline
134
- raise 'Image pipeline has no operators' if pipeline.empty?
135
-
136
- # Compute an ETag which describes this image transform + image source location.
137
- # Assume the image URL contents does _never_ change.
138
- etag = image_request.cache_etag
139
-
140
- # Download/copy the original into a Tempfile
141
- fetcher = ImageVise.fetcher_for(source_image_uri.scheme)
142
- source_file = fetcher.fetch_uri_to_tempfile(source_image_uri)
143
-
144
- # Make sure we do not try to process something...questionable
145
- source_file_type = detect_file_type(source_file)
146
- unless source_file_type_permitted?(source_file_type)
147
- raise UnsupportedInputFormat.new("Unsupported/unknown input file format .%s" % source_file_type.ext)
148
- end
149
-
150
- render_destination_file = Tempfile.new('imagevise-render').tap{|f| f.binmode }
151
-
152
- # Do the actual imaging stuff
153
- apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path)
154
-
155
- # Catch this one early
156
- render_destination_file.rewind
157
- raise EmptyRender, "The rendered image was empty" if render_destination_file.size.zero?
158
-
159
- render_file_type = detect_file_type(render_destination_file)
160
- [render_destination_file, render_file_type, etag]
161
- ensure
162
- ImageVise.close_and_unlink(source_file)
163
- end
164
-
165
- # Returns a Rack response triplet. Accepts the return value of
166
- # `process_image_request` unsplatted, and returns a triplet that
167
- # can be returned as a Rack response. The Rack response will contain
168
- # an iterable body object that is designed to automatically delete
169
- # the Tempfile it wraps on close.
170
- #
171
- # @param render_destination_file[File] the File handle to the rendered image
172
- # @param render_file_type[MagicBytes::FileType] the rendered file type
173
- # @param etag[String] the ETag for the response
174
- def image_rack_response(render_destination_file, render_file_type, etag)
175
- response_headers = DEFAULT_HEADERS.merge({
176
- 'Content-Type' => render_file_type.mime,
177
- 'Content-Length' => '%d' % render_destination_file.size,
178
- 'Cache-Control' => IMAGE_CACHE_CONTROL,
179
- 'ETag' => etag
180
- })
181
-
182
- # Wrap the body Tempfile with a self-closing response.
183
- # Once the response is read in full, the tempfile is going to be closed and unlinked.
184
- [200, response_headers, ImageVise::FileResponse.new(render_destination_file)]
185
- end
186
-
187
- # Depending on `raise_exceptions?` will either raise the passed Exception,
188
- # or force the application to return the error in the Rack response.
189
- #
190
- # @param exception[Exception] the error that has to be captured
191
- # @param status_code[Fixnum] the HTTP status code
192
- def raise_exception_or_error_response(exception, status_code)
193
- if raise_exceptions?
194
- raise exception
195
- else
196
- bail status_code, exception.message
197
- end
198
- end
199
-
200
- # Detects the file type of the given File and returns
201
- # a MagicBytes::FileType object that contains the extension and
202
- # the MIME type.
203
- #
204
- # @param tempfile[File] the file to perform detection on
205
- # @return [MagicBytes::FileType] the detected file type
206
- def detect_file_type(tempfile)
207
- tempfile.rewind
208
- MagicBytes.read_and_detect(tempfile).tap { tempfile.rewind }
209
- end
210
-
211
- # Tells whether the given file type may be loaded into the image processor.
212
- #
213
- # @param magic_bytes_file_info[MagicBytes::FileType] the filetype
214
- # @return [Boolean]
215
- def source_file_type_permitted?(magic_bytes_file_info)
216
- PERMITTED_SOURCE_FILE_EXTENSIONS.include?(magic_bytes_file_info.ext)
217
- end
218
-
219
- # Lists exceptions that should lead to the request being flagged
220
- # as invalid (4xx as opposed to 5xx for a generic server error).
221
- # Decent clients should _not_ retry those requests.
222
- def permanent_failures
223
- [
224
- Magick::ImageMagickError,
225
- UnsupportedInputFormat,
226
- ImageVise::ImageRequest::InvalidRequest
227
- ]
228
- end
229
-
230
- # Is meant to be overridden by subclasses,
231
- # will be called at the start of each request to set up the error handling
232
- # library (Appsignal, Honeybadger, Sentry...)
233
- #
234
- # @param rack_env[Hash] the Rack env
235
- # @return [void]
236
- def setup_error_handling(rack_env)
237
- end
238
-
239
- # Is meant to be overridden by subclasses,
240
- # will be called when a request fails due to a malformed query string,
241
- # unrecognized signature or other client-induced problems. The method
242
- # should _not_ re-raise the exception.
243
- #
244
- # @param exception[Exception] the exception to be handled
245
- # @return [void]
246
- def handle_request_error(exception)
247
- end
248
-
249
- # Is meant to be overridden by subclasses,
250
- # will be called when a request fails due to an error on the server
251
- # (like an unexpected error in an image operator). The method
252
- # should _not_ re-raise the exception.
253
- #
254
- # @param exception[Exception] the exception to be handled
255
- # @return [void]
256
- def handle_generic_error(exception)
257
- end
258
-
259
- # Tells whether the engine must raise the exceptions further up the Rack stack,
260
- # or they should be suppressed and a JSON response must be returned.
261
- #
262
- # @return [Boolean]
263
- def raise_exceptions?
264
- false
265
- end
266
-
267
- # Applies the given {ImageVise::Pipeline} to the image, and writes the render to
268
- # the given path.
269
- #
270
- # @param source_file_path[String] the path to the file containing the source image
271
- # @param pipeline[#apply!(Magick::Image)] the processing pipeline
272
- # @param render_to_path[String] the path to write the rendered image to
273
- # @return [void]
274
- def apply_pipeline(source_file_path, pipeline, source_file_type, render_to_path)
275
- render_file_type = source_file_type
276
-
277
- # Load the first frame of the animated GIF _or_ the blended compatibility layer from Photoshop
278
- image_list = Magick::Image.read(source_file_path)
279
- magick_image = image_list.first # Picks up the "precomp" PSD layer in compatibility mode, or the first frame of a GIF
280
-
281
- # If any operators want to stash some data for downstream use we use this Hash
282
- metadata = {}
283
-
284
- # Apply the pipeline (all the image operators)
285
- pipeline.apply!(magick_image, metadata)
286
-
287
- # Write out the file honoring the possible injected metadata. One of the metadata
288
- # elements (that an operator might want to alter) is the :writer, we forcibly #fetch
289
- # it so that we get a KeyError if some operator has deleted it without providing a replacement.
290
- # If no operators touched the writer we are going to use the automatic format selection
291
- writer = metadata.fetch(:writer, ImageVise::AutoWriter.new)
292
- writer.write_image!(magick_image, metadata, render_to_path)
293
- ensure
294
- # destroy all the loaded images explicitly
295
- (image_list || []).map {|img| ImageVise.destroy(img) }
296
- end
297
-
298
- end
@@ -1,3 +0,0 @@
1
- class ImageVise
2
- VERSION = '0.2.0'
3
- end
@@ -1,23 +0,0 @@
1
- # Picks the most reasonable "default" output format for web resources. In practice, if the
2
- # image contains transparency (an alpha channel) PNG will be chosen, and if not - JPEG will
3
- # be chosen. Since ImageVise URLs do not contain a file extension we are free to pick
4
- # the suitable format at render time
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
-
12
- def write_image!(magick_image, _, render_to_path)
13
- # If processing the image has created an alpha channel, use PNG always.
14
- # 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
21
- magick_image.write(render_to_path)
22
- end
23
- end
@@ -1,9 +0,0 @@
1
- class ImageVise::JPGWriter < Ks.strict(:quality)
2
- JPG_FILE_TYPE = MagicBytes::FileType.new('jpg','image/jpeg').freeze
3
-
4
- def write_image!(magick_image, _, render_to_path)
5
- q = self.quality # to avoid the changing "self" context
6
- magick_image.format = JPG_FILE_TYPE.ext
7
- magick_image.write(render_to_path) { self.quality = q }
8
- end
9
- end
data/lib/image_vise.rb DELETED
@@ -1,177 +0,0 @@
1
- require 'ks'
2
- require 'json'
3
- require 'patron'
4
- require 'rmagick'
5
- require 'magic_bytes'
6
- require 'thread'
7
- require 'base64'
8
- require 'rack'
9
-
10
- class ImageVise
11
- require_relative 'image_vise/version'
12
- S_MUTEX = Mutex.new
13
- private_constant :S_MUTEX
14
-
15
- @allowed_hosts = Set.new
16
- @keys = Set.new
17
- @operators = {}
18
- @allowed_glob_patterns = Set.new
19
- @fetchers = {}
20
-
21
- class << self
22
- # Resets all allowed hosts
23
- def reset_allowed_hosts!
24
- S_MUTEX.synchronize { @allowed_hosts.clear }
25
- end
26
-
27
- # Add an allowed host
28
- def add_allowed_host!(hostname)
29
- S_MUTEX.synchronize { @allowed_hosts << hostname }
30
- end
31
-
32
- # Returns both the allowed hosts added at runtime and the ones set in the constant
33
- def allowed_hosts
34
- S_MUTEX.synchronize { @allowed_hosts.to_a }
35
- end
36
-
37
- # Removes all set keys
38
- def reset_secret_keys!
39
- S_MUTEX.synchronize { @keys.clear }
40
- end
41
-
42
- def allow_filesystem_source!(glob_pattern)
43
- S_MUTEX.synchronize { @allowed_glob_patterns << glob_pattern }
44
- end
45
-
46
- def allowed_filesystem_sources
47
- S_MUTEX.synchronize { @allowed_glob_patterns.to_a }
48
- end
49
-
50
- def deny_filesystem_sources!
51
- S_MUTEX.synchronize { @allowed_glob_patterns.clear }
52
- end
53
-
54
- # Adds a key against which the parameters are going to be verified.
55
- # Multiple applications may have their own different keys,
56
- # so we need to have multiple keys.
57
- def add_secret_key!(key)
58
- S_MUTEX.synchronize { @keys << key }
59
- self
60
- end
61
-
62
- # Returns the array of defined keys or raises an exception if no keys have been set yet
63
- def secret_keys
64
- keys = S_MUTEX.synchronize { @keys.any? && @keys.to_a }
65
- keys or raise "No keys set, add a key using `ImageVise.add_secret_key!(key)'"
66
- end
67
-
68
- # Generate a set of querystring params for a resized image. Yields a Pipeline object that
69
- # will receive method calls for adding image operations to a stack.
70
- #
71
- # ImageVise.image_params(src_url: image_url_on_s3, secret: '...') do |p|
72
- # p.center_fit width: 128, height: 128
73
- # p.elliptic_stencil
74
- # end #=> {q: '...', sig: '...'}
75
- #
76
- # The query string elements can be then passed on to RenderEngine for validation and execution.
77
- #
78
- # @yield {ImageVise::Pipeline}
79
- # @return [Hash]
80
- def image_params(src_url:, secret:)
81
- p = Pipeline.new
82
- yield(p)
83
- raise ArgumentError, "Image pipeline has no steps defined" if p.empty?
84
- ImageRequest.new(src_url: URI(src_url), pipeline: p).to_query_string_params(secret)
85
- end
86
-
87
- # Generate a path for a resized image. Yields a Pipeline object that
88
- # will receive method calls for adding image operations to a stack.
89
- #
90
- # ImageVise.image_path(src_url: image_url_on_s3, secret: '...') do |p|
91
- # p.center_fit width: 128, height: 128
92
- # p.elliptic_stencil
93
- # end #=> "/abcdef/xyz123"
94
- #
95
- # The query string elements can be then passed on to RenderEngine for validation and execution.
96
- #
97
- # @yield {ImageVise::Pipeline}
98
- # @return [String]
99
- def image_path(src_url:, secret:)
100
- p = Pipeline.new
101
- yield(p)
102
- raise ArgumentError, "Image pipeline has no steps defined" if p.empty?
103
- ImageRequest.new(src_url: URI(src_url), pipeline: p).to_path_params(secret)
104
- end
105
-
106
- # Adds an operator
107
- def add_operator(operator_name, object_responding_to_new)
108
- @operators[operator_name.to_s] = object_responding_to_new
109
- end
110
-
111
- # Gets an operator by name
112
- def operator_from(operator_name)
113
- @operators.fetch(operator_name.to_s)
114
- end
115
-
116
- def defined_operator_names
117
- @operators.keys
118
- end
119
-
120
- def register_fetcher(scheme, fetcher)
121
- S_MUTEX.synchronize { @fetchers[scheme.to_s] = fetcher }
122
- end
123
-
124
- def fetcher_for(scheme)
125
- S_MUTEX.synchronize { @fetchers[scheme.to_s] or raise "No fetcher registered for #{scheme}" }
126
- end
127
-
128
- def operator_name_for(operator)
129
- S_MUTEX.synchronize do
130
- @operators.key(operator.class) or raise "Operator #{operator.inspect} not registered using ImageVise.add_operator"
131
- end
132
- end
133
- end
134
-
135
- # Made available since the object that is used with `mount()` in Rails
136
- # has to, by itself, to respond to `call`.
137
- #
138
- # Thanks to this method you can do this:
139
- #
140
- # mount ImageVise => '/thumbnails'
141
- #
142
- # instead of having to do
143
- #
144
- # mount ImageVise.new => '/thumbnails'
145
- #
146
- def self.call(rack_env)
147
- ImageVise::RenderEngine.new.call(rack_env)
148
- end
149
-
150
- def call(rack_env)
151
- ImageVise::RenderEngine.new.call(rack_env)
152
- end
153
-
154
- # Used as a shorthand to force-destroy Magick images in ensure() blocks. Since
155
- # ensure blocks sometimes deal with variables in inconsistent states (variable
156
- # in scope but not yet set to an image) we take the possibility of nils into account.
157
- # We also deal with Magick::Image objects that already have been destroyed in a clean manner.
158
- def self.destroy(maybe_image)
159
- return unless maybe_image
160
- return unless maybe_image.respond_to?(:destroy!)
161
- return if maybe_image.destroyed?
162
- maybe_image.destroy!
163
- end
164
-
165
- # Used as a shorthand to force-dealloc Tempfiles in an ensure() blocks. Since
166
- # ensure blocks sometimes deal with variables in inconsistent states (variable
167
- # in scope but not yet set to an image) we take the possibility of nils into account.
168
- def self.close_and_unlink(maybe_tempfile)
169
- return unless maybe_tempfile
170
- maybe_tempfile.close unless maybe_tempfile.closed?
171
- maybe_tempfile.unlink
172
- end
173
- end
174
-
175
- Dir.glob(__dir__ + '/**/*.rb').sort.each do |f|
176
- require f unless f == File.expand_path(__FILE__)
177
- end