Class: ImageVise::RenderEngine

Inherits:
Object
  • Object
show all
Defined in:
lib/image_vise/render_engine.rb

Defined Under Namespace

Classes: EmptyRender, UnsupportedInputFormat

Constant Summary collapse

DEFAULT_HEADERS =
{
  'Allow' => "GET"
}.freeze
JSON_ERROR_HEADERS_REQUEST =

Headers for error responses that denote an invalid or an unsatisfiable request

DEFAULT_HEADERS.merge({
  'Content-Type' => 'application/json',
  'Cache-Control' => 'public, max-age=600'
}).freeze
JSON_ERROR_HEADERS_INTERMITTENT =

Headers for error responses that denote an intermittent error (that permit retries)

DEFAULT_HEADERS.merge({
  'Content-Type' => 'application/json',
  'Cache-Control' => 'public, max-age=5'
}).freeze
IMAGE_CACHE_CONTROL =

Cache details: “public” of course. Add max-age so that there is some revalidation after a time (otherwise some proxies treat it as “must-revalidate” always), and “no-transform” so that various deflate schemes are not applied to it (does happen with Rack::Cache and leads Chrome to throw up on content decoding for example).

"public, no-transform, max-age=%d"
RENDER_TIMEOUT_SECONDS =

How long is a render (the ImageMagick/write part) is allowed to take before we kill it

10
PERMITTED_SOURCE_FILE_EXTENSIONS =

Which input files we permit (based on extensions stored in MagicBytes)

%w( gif png jpg psd tif)
EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS =

How long should we wait when fetching the image from the external host

4

Instance Method Summary collapse

Instance Method Details

#apply_pipeline(source_file_path, pipeline, source_file_type, render_to_path) ⇒ void

This method returns an undefined value.

Applies the given Pipeline to the image, and writes the render to the given path.

Parameters:

  • source_file_path (String)

    the path to the file containing the source image

  • pipeline (#apply!(Magick::Image))

    the processing pipeline

  • render_to_path (String)

    the path to write the rendered image to



278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/image_vise/render_engine.rb', line 278

def apply_pipeline(source_file_path, pipeline, source_file_type, render_to_path)
  render_file_type = source_file_type

  # Load the first frame of the animated GIF _or_ the blended compatibility layer from Photoshop
  image_list = Magick::Image.read(source_file_path)
  magick_image = image_list.first # Picks up the "precomp" PSD layer in compatibility mode, or the first frame of a GIF

  # If any operators want to stash some data for downstream use we use this Hash
   = {}

  # Apply the pipeline (all the image operators)
  pipeline.apply!(magick_image, )

  # Write out the file honoring the possible injected metadata. One of the metadata
  # elements (that an operator might want to alter) is the :writer, we forcibly #fetch
  # it so that we get a KeyError if some operator has deleted it without providing a replacement.
  # If no operators touched the writer we are going to use the automatic format selection
  writer = .fetch(:writer, ImageVise::AutoWriter.new)
  writer.write_image!(magick_image, , render_to_path)
ensure
  # destroy all the loaded images explicitly
  (image_list || []).map {|img| ImageVise.destroy(img) }
end

#bail(status, *errors_array) ⇒ Object



41
42
43
44
45
46
47
48
49
# File 'lib/image_vise/render_engine.rb', line 41

def bail(status, *errors_array)
  headers = if (300...500).cover?(status)
    JSON_ERROR_HEADERS_REQUEST.dup
  else
    JSON_ERROR_HEADERS_INTERMITTENT.dup
  end
  response = [status.to_i, headers, [JSON.pretty_generate({errors: errors_array})]]
  throw :__bail, response
end

#call(env) ⇒ Array

The main entry point for the Rack app. Wraps a call to #handle_request in a ‘catch{}` block so that any method can abort the request by calling #bail

Parameters:

  • env (Hash)

    the Rack env

Returns:

  • (Array)

    the Rack response



56
57
58
# File 'lib/image_vise/render_engine.rb', line 56

def call(env)
  catch(:__bail) { handle_request(env) }
end

#detect_file_type(tempfile) ⇒ MagicBytes::FileType

Detects the file type of the given File and returns a MagicBytes::FileType object that contains the extension and the MIME type.

Parameters:

  • tempfile (File)

    the file to perform detection on

Returns:

  • (MagicBytes::FileType)

    the detected file type



210
211
212
213
# File 'lib/image_vise/render_engine.rb', line 210

def detect_file_type(tempfile)
  tempfile.rewind
  MagicBytes.read_and_detect(tempfile).tap { tempfile.rewind }
end

#extract_params_from_request(rack_request) ⇒ Hash

Extracts the image params from the Rack::Request

Parameters:

  • rack_request (#path_info)

    an object that has a path info

Returns:

  • (Hash)

    the params hash with ‘:q` and `:sig` keys



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/image_vise/render_engine.rb', line 108

def extract_params_from_request(rack_request)
  # Prevent cache bypass DOS attacks by only permitting :sig and :q
  bail(400, 'Query strings are not supported') if rack_request.params.any?

  # Take the last two path components of the request URI.
  # The second-to-last is the Base64-encoded image request, the last is the signature.
  # Slashes within the image request are masked out already, no need to worry about them.
  # Parameters are passed in the path so that ImageVise integrates easier with CDNs and so that
  # it becomes harder to blow the cache by appending spurious query string parameters and/or
  # reordering query string parameters at will.
  *, q_from_path, sig_from_path = rack_request.path_info.split('/')

  # Raise if any of them are empty or blank
  nothing_recovered = [q_from_path, sig_from_path].all?{|v| v.nil? || v.empty? }
  bail(400, 'Need 2 usable path components') if nothing_recovered

  {q: q_from_path, sig: sig_from_path}
end

#handle_generic_error(exception) ⇒ void

This method returns an undefined value.

Is meant to be overridden by subclasses, will be called when a request fails due to an error on the server (like an unexpected error in an image operator). The method should not re-raise the exception.

Parameters:

  • exception (Exception)

    the exception to be handled



260
261
# File 'lib/image_vise/render_engine.rb', line 260

def handle_generic_error(exception)
end

#handle_request(env) ⇒ Array

Hadles the Rack request. If one of the steps calls #bail the ‘:__bail` symbol will be thrown and the execution will abort. Any errors will cause either an error response in JSON format or an Exception will be raised (depending on the return value of `raise_exceptions?`)

Parameters:

  • env (Hash)

    the Rack env

Returns:

  • (Array)

    the Rack response



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/image_vise/render_engine.rb', line 66

def handle_request(env)
  setup_error_handling(env)

  # Assume that if _any_ ETag is given the image is being requested anew as a refetch,
  # and the client already has it. Just respond with a 304.
  return [304, DEFAULT_HEADERS.dup, []] if env['HTTP_IF_NONE_MATCH']

  req = parse_env_into_request(env)
  bail(405, 'Only GET supported') unless req.get?
  params = extract_params_from_request(req)

  image_request = ImageVise::ImageRequest.from_params(qs_params: params, secrets: ImageVise.secret_keys)
  render_destination_file, render_file_type, etag = process_image_request(image_request)
  image_rack_response(render_destination_file, render_file_type, etag)
rescue *permanent_failures => e
  handle_request_error(e)
  http_status_code = e.respond_to?(:http_status) ? e.http_status : 400
  raise_exception_or_error_response(e, http_status_code)
rescue Exception => e
  if http_status_code = (e.respond_to?(:http_status) && e.http_status)
    handle_request_error(e)
    raise_exception_or_error_response(e, http_status_code)
  else
    handle_generic_error(e)
    raise_exception_or_error_response(e, 500)
  end
end

#handle_request_error(exception) ⇒ void

This method returns an undefined value.

Is meant to be overridden by subclasses, will be called when a request fails due to a malformed query string, unrecognized signature or other client-induced problems. The method should not re-raise the exception.

Parameters:

  • exception (Exception)

    the exception to be handled



250
251
# File 'lib/image_vise/render_engine.rb', line 250

def handle_request_error(exception)
end

#image_rack_response(render_destination_file, render_file_type, etag) ⇒ Object

Returns a Rack response triplet. Accepts the return value of ‘process_image_request` unsplatted, and returns a triplet that can be returned as a Rack response. The Rack response will contain an iterable body object that is designed to automatically delete the Tempfile it wraps on close. Sets the cache lifetime to either the default value of 2592000 or the value the user selected using add_custom_cache_max_length.

Parameters:

  • render_destination_file (File)

    the File handle to the rendered image

  • render_file_type (MagicBytes::FileType)

    the rendered file type

  • etag (String)

    the ETag for the response



178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/image_vise/render_engine.rb', line 178

def image_rack_response(render_destination_file, render_file_type, etag)
  response_headers = DEFAULT_HEADERS.merge({
    'Content-Type' => render_file_type.mime,
    'Content-Length' => '%d' % render_destination_file.size,
    'Cache-Control' => IMAGE_CACHE_CONTROL % ImageVise.cache_lifetime_seconds,
    'ETag' => etag
  })

  # Wrap the body Tempfile with a self-closing response.
  # Once the response is read in full, the tempfile is going to be closed and unlinked.
  [200, response_headers, ImageVise::FileResponse.new(render_destination_file)]
end

#parse_env_into_request(rack_env) ⇒ #get?, #params

Parses the Rack environment into a Rack::Reqest. The following methods are going to be called on it: ‘#get?` and `#params`. You can use this method to override path-to-parameter translation for example.

Parameters:

  • rack_env (Hash)

    the Rack environment

Returns:

  • (#get?, #params)

    the Rack request or a compatible object



100
101
102
# File 'lib/image_vise/render_engine.rb', line 100

def parse_env_into_request(rack_env)
  Rack::Request.new(rack_env)
end

#permanent_failuresObject

Lists exceptions that should lead to the request being flagged as invalid (4xx as opposed to 5xx for a generic server error). Decent clients should not retry those requests.



226
227
228
229
230
231
232
# File 'lib/image_vise/render_engine.rb', line 226

def permanent_failures
  [
    Magick::ImageMagickError,
    UnsupportedInputFormat,
    ImageVise::ImageRequest::InvalidRequest
  ]
end

#process_image_request(image_request) ⇒ Array<File, MagicBytes::FileType, String]

Processes the ImageRequest object created from the request parameters, and returns a triplet of the File object containing the rendered image, the MagicBytes::FileType object of the render, and the cache ETag value representing the processing pipeline

Parameters:

Returns:

  • (Array<File, MagicBytes::FileType, String])

    Array<File, MagicBytes::FileType, String]



134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/image_vise/render_engine.rb', line 134

def process_image_request(image_request)
  # Recover the source image URL and the pipeline instructions (all the image ops)
  source_image_uri, pipeline = image_request.src_url, image_request.pipeline
  raise 'Image pipeline has no operators' if pipeline.empty?

  # Compute an ETag which describes this image transform + image source location.
  # Assume the image URL contents does _never_ change.
  etag = image_request.cache_etag

  # Download/copy the original into a Tempfile
  fetcher = ImageVise.fetcher_for(source_image_uri.scheme)
  source_file = fetcher.fetch_uri_to_tempfile(source_image_uri)

  # Make sure we do not try to process something...questionable
  source_file_type = detect_file_type(source_file)
  unless source_file_type_permitted?(source_file_type)
    raise UnsupportedInputFormat.new("Unsupported/unknown input file format .%s" % source_file_type.ext)
  end

  render_destination_file = Tempfile.new('imagevise-render').tap{|f| f.binmode }

  # Do the actual imaging stuff
  apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path)

  # Catch this one early
  render_destination_file.rewind
  raise EmptyRender, "The rendered image was empty" if render_destination_file.size.zero?

  render_file_type = detect_file_type(render_destination_file)
  [render_destination_file, render_file_type, etag]
ensure
  ImageVise.close_and_unlink(source_file)
end

#raise_exception_or_error_response(exception, status_code) ⇒ Object

Depending on ‘raise_exceptions?` will either raise the passed Exception, or force the application to return the error in the Rack response.

Parameters:

  • exception (Exception)

    the error that has to be captured

  • status_code (Fixnum)

    the HTTP status code



196
197
198
199
200
201
202
# File 'lib/image_vise/render_engine.rb', line 196

def raise_exception_or_error_response(exception, status_code)
  if raise_exceptions?
    raise exception
  else
    bail status_code, exception.message
  end
end

#raise_exceptions?Boolean

Tells whether the engine must raise the exceptions further up the Rack stack, or they should be suppressed and a JSON response must be returned.

Returns:

  • (Boolean)


267
268
269
# File 'lib/image_vise/render_engine.rb', line 267

def raise_exceptions?
  false
end

#setup_error_handling(rack_env) ⇒ void

This method returns an undefined value.

Is meant to be overridden by subclasses, will be called at the start of each request to set up the error handling library (Appsignal, Honeybadger, Sentry…)

Parameters:

  • rack_env (Hash)

    the Rack env



240
241
# File 'lib/image_vise/render_engine.rb', line 240

def setup_error_handling(rack_env)
end

#source_file_type_permitted?(magic_bytes_file_info) ⇒ Boolean

Tells whether the given file type may be loaded into the image processor.

Parameters:

  • magic_bytes_file_info (MagicBytes::FileType)

    the filetype

Returns:

  • (Boolean)


219
220
221
# File 'lib/image_vise/render_engine.rb', line 219

def source_file_type_permitted?(magic_bytes_file_info)
  PERMITTED_SOURCE_FILE_EXTENSIONS.include?(magic_bytes_file_info.ext)
end