class ImageVise::RenderEngine

Constants

DEFAULT_HEADERS
EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS

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

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

JSON_ERROR_HEADERS_INTERMITTENT

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

JSON_ERROR_HEADERS_REQUEST

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

PERMITTED_SOURCE_FORMATS

Which input files we permit (based on format identifiers in format_parser, which are symbols)

Public Instance Methods

apply_pipeline(source_file_path, pipeline, source_format_parser_result, render_to_path) click to toggle source

Applies the given {ImageVise::Pipeline} to the image, and writes the render to the given path.

@param source_file_path the path to the file containing the source image @param pipeline the processing pipeline @param render_to_path the path to write the rendered image to @return [void]

# File lib/image_vise/render_engine.rb, line 296
def apply_pipeline(source_file_path, pipeline, source_format_parser_result, render_to_path)

  # Load the first frame of the animated GIF _or_ the blended compatibility layer from Photoshop
  image_list = Measurometer.instrument('image_vise.load_pixbuf') do
    Magick::Image.read(source_file_path)
  end
    
  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
  metadata = {format_parser_result: source_format_parser_result}

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

  # 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 = metadata.fetch(:writer, ImageVise::AutoWriter.new)
  Measurometer.instrument('image_vise.write_image') do
    writer.write_image!(magick_image, metadata, render_to_path)
  end

  # Another metadata element is the expire_after, which we default to an app-wide setting
  metadata.fetch(:expire_after_seconds, ImageVise.cache_lifetime_seconds)
ensure
  # destroy all the loaded images explicitly
  (image_list || []).map {|img| ImageVise.destroy(img) }
end
bail(status, *errors_array) click to toggle source
# File lib/image_vise/render_engine.rb, line 48
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) click to toggle source

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}

@param env the Rack env @return [Array] the Rack response

# File lib/image_vise/render_engine.rb, line 63
def call(env)
  catch(:__bail) { handle_request(env) }
end
detect_file_type(tempfile) click to toggle source

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

@param tempfile the file to perform detection on @return [Symbol] the detected file format symbol that can be used as an extension

# File lib/image_vise/render_engine.rb, line 223
def detect_file_type(tempfile)
  tempfile.rewind
  parser_result = FormatParser.parse(tempfile, natures: :image).tap { tempfile.rewind }
  raise "Rendered file type detection failed" unless parser_result
  Filetype.new(parser_result.format)
end
extract_params_from_request(rack_request) click to toggle source

Extracts the image params from the Rack::Request

@param rack_request an object that has a path info @return [String, String] the Base64-encoded image request and the signature

# File lib/image_vise/render_engine.rb, line 119
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_from_path, sig_from_path]
end
handle_generic_error(exception) click to toggle source

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.

@param exception the exception to be handled @return [void]

# File lib/image_vise/render_engine.rb, line 278
def handle_generic_error(exception)
end
handle_request(env) click to toggle source

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?`)

@param env the Rack env @return [Array] the Rack response

# File lib/image_vise/render_engine.rb, line 73
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?
  encoded_request, signature = extract_params_from_request(req)

  image_request = ImageVise::ImageRequest.from_params(
    base64_encoded_params: encoded_request,
    given_signature: signature,
    secrets: ImageVise.secret_keys
  )
  render_destination_file, render_file_type, etag, expire_after = process_image_request(image_request)
  image_rack_response(render_destination_file, render_file_type, etag, expire_after)
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 => 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) click to toggle source

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.

@param exception the exception to be handled @return [void]

# File lib/image_vise/render_engine.rb, line 268
def handle_request_error(exception)
end
image_rack_response(render_destination_file, render_file_type, etag, expire_after) click to toggle source

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.

@param render_destination_file the File handle to the rendered image @param render_file_type the rendered file type @param etag the ETag for the response

# File lib/image_vise/render_engine.rb, line 191
def image_rack_response(render_destination_file, render_file_type, etag, expire_after)
  response_headers = DEFAULT_HEADERS.merge({
    'Content-Type' => render_file_type.mime,
    'Content-Length' => '%d' % render_destination_file.size,
    'Cache-Control' => IMAGE_CACHE_CONTROL % expire_after.to_i,
    '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) click to toggle source

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.

@param rack_env the Rack environment @return [#get?, params] the Rack request or a compatible object

# File lib/image_vise/render_engine.rb, line 111
def parse_env_into_request(rack_env)
  Rack::Request.new(rack_env)
end
permanent_failures() click to toggle source

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.

# File lib/image_vise/render_engine.rb, line 244
def permanent_failures
  [
    Magick::ImageMagickError,
    UnsupportedInputFormat,
    ImageVise::ImageRequest::InvalidRequest
  ]
end
permitted_format?(format_parser_result) click to toggle source

Tells whether the file described by the given FormatParser result object can be accepted for processing

@param format_parser_result file information descriptor @return [Boolean]

# File lib/image_vise/render_engine.rb, line 235
def permitted_format?(format_parser_result)
  return false unless PERMITTED_SOURCE_FORMATS.include?(format_parser_result.format)
  return false if format_parser_result.has_multiple_frames
  true
end
process_image_request(image_request) click to toggle source

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

@param image_request the request for the image @return [Array<File, FileType, String]

# File lib/image_vise/render_engine.rb, line 145
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 = Measurometer.instrument('image_vise.fetch') do
    fetcher.fetch_uri_to_tempfile(source_image_uri)
  end
  file_format = FormatParser.parse(source_file, natures: [:image]).tap { source_file.rewind }
  raise UnsupportedInputFormat.new("%s has an unknown input file format" % source_image_uri) unless file_format
  raise UnsupportedInputFormat.new("%s does not pass file constraints" % source_image_uri) unless permitted_format?(file_format)

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

  # Do the actual imaging stuff
  expire_after = Measurometer.instrument('image_vise.render_engine.apply_pipeline') do
    apply_pipeline(source_file.path, pipeline, file_format, render_destination_file.path)
  end

  # 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, expire_after]
ensure
  ImageVise.close_and_unlink(source_file)
end
raise_exception_or_error_response(exception, status_code) click to toggle source

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

@param exception the error that has to be captured @param status_code the HTTP status code

# File lib/image_vise/render_engine.rb, line 209
def raise_exception_or_error_response(exception, status_code)
  if raise_exceptions?
    raise exception
  else
    bail status_code, exception.message
  end
end
raise_exceptions?() click to toggle source

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.

@return [Boolean]

# File lib/image_vise/render_engine.rb, line 285
def raise_exceptions?
  false
end
setup_error_handling(rack_env) click to toggle source

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

@param rack_env the Rack env @return [void]

# File lib/image_vise/render_engine.rb, line 258
def setup_error_handling(rack_env)
end