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

PASSTHROUGH_STATUS_CODES =

Codes that have to be sent through to the requester

[404, 403, 503, 504, 500]
DEFAULT_HEADERS =
{
  'Allow' => "GET"
}.freeze
JSON_ERROR_HEADERS =

To prevent some string allocations

DEFAULT_HEADERS.merge({
  'Content-Type' => 'application/json',
  'Cache-Control' => 'private, max-age=0, no-cache'
}).freeze
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 )
PERMITTED_OUTPUT_FILE_EXTENSIONS =

Which output files are permitted (regardless of the input format the processed images will be converted to one of these types)

%W( gif png jpg)
EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS =

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

4
PNG_FILE_TYPE =

The default file type for images with alpha

Class.new do
  def self.mime; 'image/png'; end
  def self.ext; 'png'; end
end

Instance Method Summary collapse

Instance Method Details

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



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/image_vise/render_engine.rb', line 194

def apply_pipeline(source_file_path, pipeline, source_file_type, render_to_path)
  render_file_type = source_file_type
  magick_image = Magick::Image.read(source_file_path)[0]
  pipeline.apply!(magick_image)
  
  # If processing the image has created an alpha channel, use PNG always.
  # Otherwise, keep the original format for as far as the supported formats list goes.
  render_file_type = PNG_FILE_TYPE if magick_image.alpha?
  render_file_type = PNG_FILE_TYPE unless output_file_type_permitted?(render_file_type)
  
  magick_image.format = render_file_type.ext
  magick_image.write(render_to_path)
ensure
  ImageVise.destroy(magick_image)
end

#bail(status, *errors_array) ⇒ Object



48
49
50
51
52
# File 'lib/image_vise/render_engine.rb', line 48

def bail(status, *errors_array)
  h = JSON_ERROR_HEADERS.dup # Needed because some upstream middleware migh be modifying headers
  response = [status.to_i, h, [JSON.pretty_generate({errors: errors_array})]]
  throw :__bail, response
end

#binary_tempfileObject



140
141
142
# File 'lib/image_vise/render_engine.rb', line 140

def binary_tempfile
  Tempfile.new('imagevise-tmp').tap{|f| f.binmode }
end

#call(env) ⇒ Object

The main entry point URL, at the index so that the Sinatra app can be used in-place of a Rails controller (as opposed to having to mount it at the root of the Rails app or having all the URLs refer to a subpath)



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

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


134
135
136
137
138
# File 'lib/image_vise/render_engine.rb', line 134

def close_and_unlink(f)
  return unless f
  f.close unless f.closed?
  f.unlink
end

#copy_path_into_tempfile(path_on_filesystem) ⇒ Object



235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/image_vise/render_engine.rb', line 235

def copy_path_into_tempfile(path_on_filesystem)
  tf = binary_tempfile
  File.open(path_on_filesystem, 'rb') do |f|
    IO.copy_stream(f, tf)
  end
  tf.rewind; tf
rescue Errno::ENOENT
  tf.close; tf.unlink;
  bail 404, "Image file not found" 
rescue Exception => e
  tf.close; tf.unlink;
  raise e
end

#detect_file_type(tempfile) ⇒ Object



144
145
146
147
# File 'lib/image_vise/render_engine.rb', line 144

def detect_file_type(tempfile)
  tempfile.rewind
  MagicBytes.read_and_detect(tempfile)
end

#enable_forking?Boolean

Returns:

  • (Boolean)


190
191
192
# File 'lib/image_vise/render_engine.rb', line 190

def enable_forking?
  ENV['IMAGE_VISE_ENABLE_FORK'] == 'yes'
end

#fetch_url(source_image_uri) ⇒ Object



218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/image_vise/render_engine.rb', line 218

def fetch_url(source_image_uri)
  tf = binary_tempfile
  s = Patron::Session.new
  s.automatic_content_encoding = true
  s.timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
  s.connect_timeout = EXTERNAL_IMAGE_FETCH_TIMEOUT_SECONDS
  response = s.get_file(source_image_uri, tf.path)
  if PASSTHROUGH_STATUS_CODES.include?(response.status)
    tf.close; tf.unlink;
    bail response.status, "Unfortunate upstream response: #{response.status}" 
  end
  tf
rescue Exception => e
  tf.close; tf.unlink;
  raise e
end

#fetch_url_into_tempfile(source_image_uri) ⇒ Object

Fetch the given URL into a Tempfile and return the File object



39
40
41
42
43
44
45
46
# File 'lib/image_vise/render_engine.rb', line 39

def fetch_url_into_tempfile(source_image_uri)
  parsed = URI.parse(source_image_uri)
  if parsed.scheme == 'file'
    copy_path_into_tempfile(URI.decode(parsed.path))
  else
    fetch_url(source_image_uri)
  end
end

#handle_generic_error(err) ⇒ Object

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)



181
182
# File 'lib/image_vise/render_engine.rb', line 181

def handle_generic_error(err)
end

#handle_request(env) ⇒ Object



61
62
63
64
65
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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/image_vise/render_engine.rb', line 61

def handle_request(env)
  setup_error_handling(env)
  render_destination_file = binary_tempfile
  
  # 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 = Rack::Request.new(env)
  bail(405, 'Only GET supported') unless req.get?

  # Validate the inputs
  image_request = ImageVise::ImageRequest.to_request(qs_params: req.params, **image_request_options)

  # 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 the original into a Tempfile
  source_file = fetch_url_into_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
  
  # Perform the processing
  if enable_forking?
    require 'exceptional_fork'
    ExceptionalFork.fork_and_wait { apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path) }
  else
    apply_pipeline(source_file.path, pipeline, source_file_type, render_destination_file.path)
  end
  
  # Catch this one early
  raise EmptyRender, "The rendered image was empty" if render_destination_file.size.zero?

  render_destination_file.rewind
  render_file_type = detect_file_type(render_destination_file)

  response_headers = DEFAULT_HEADERS.merge({
    'Content-Type' => render_file_type.mime,
    'Content-Length' => '%d' % render_destination_file.size,
    'Cache-Control' => 'public',
    '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)]
rescue *permanent_failures => e
  handle_request_error(e)
  raise_exception_or_error_response(e, 422)
rescue Exception => e
  handle_generic_error(e)
  raise_exception_or_error_response(e, 500)
ensure
  close_and_unlink(source_file)
end

#handle_request_error(err) ⇒ Object

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



175
176
# File 'lib/image_vise/render_engine.rb', line 175

def handle_request_error(err)
end

#image_request_optionsObject



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

def image_request_options
  {
    secrets: ImageVise.secret_keys,
    permitted_source_hosts: ImageVise.allowed_hosts,
    allowed_filesystem_patterns: ImageVise.allowed_filesystem_sources,
  }
end

#output_file_type_permitted?(magick_bytes_file_info) ⇒ Boolean

Returns:

  • (Boolean)


153
154
155
# File 'lib/image_vise/render_engine.rb', line 153

def output_file_type_permitted?(magick_bytes_file_info)
  PERMITTED_OUTPUT_FILE_EXTENSIONS.include?(magick_bytes_file_info.ext)
end

#permanent_failuresObject

Lists exceptions that should lead to the request being flagged as invalid (and not 5xx). Decent clients should not retry those requests.



159
160
161
162
163
164
165
# File 'lib/image_vise/render_engine.rb', line 159

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

#raise_exception_or_error_response(exception, status_code) ⇒ Object



126
127
128
129
130
131
132
# File 'lib/image_vise/render_engine.rb', line 126

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)


186
187
188
# File 'lib/image_vise/render_engine.rb', line 186

def raise_exceptions?
  false
end

#setup_error_handling(rack_env) ⇒ Object

Is meant to be overridden by subclasses, will be called at the start of each reauest



169
170
# File 'lib/image_vise/render_engine.rb', line 169

def setup_error_handling(rack_env)
end

#source_file_type_permitted?(magick_bytes_file_info) ⇒ Boolean

Returns:

  • (Boolean)


149
150
151
# File 'lib/image_vise/render_engine.rb', line 149

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