Class: PlaceholderImage::Middleware

Inherits:
Object
  • Object
show all
Defined in:
lib/placeholder_image/middleware.rb,
sig/placeholder_image/middleware.rbs

Overview

Rack middleware that serves generated placeholder PNGs.

Constant Summary collapse

DEFAULTS =

Returns:

  • (Hash[Symbol, untyped])
{
  path_prefix: "/placeholder",
  image_max_dim_px: 4_000,
  image_max_total_px: 4_000 * 4_000,
  http_header_cache_control: "public, max-age=31536000, immutable",
  cache_max_entries: 128,
  image_default_bg: [0xEE, 0xEE, 0xEE],
  image_default_fg: [0x99, 0x99, 0x99]
}.freeze
DIMENSION =

Returns:

  • (Regexp)
/[1-9]\d*/

Instance Method Summary collapse

Constructor Details

#initialize(app, **options) ⇒ Middleware

Returns a new instance of Middleware.

Parameters:

  • app (#call)

    the downstream Rack application.

  • options (Hash)

    configuration overrides merged over DEFAULTS.

Options Hash (**options):

  • :path_prefix (String) — default: "/placeholder"

    URL prefix the middleware serves.

  • :image_max_dim_px (Integer) — default: 4000

    maximum width or height, in pixels.

  • :image_max_total_px (Integer) — default: 16_000_000

    maximum total pixel count.

  • :http_header_cache_control (String)

    the Cache-Control response header value.

  • :cache_max_entries (Integer) — default: 128

    in-memory FIFO cache size; 0 disables caching.

  • :image_default_bg (Array(Integer, Integer, Integer), String)

    default background color.

  • :image_default_fg (Array(Integer, Integer, Integer), String)

    default foreground color.



42
43
44
45
46
47
48
49
50
51
# File 'lib/placeholder_image/middleware.rb', line 42

def initialize(app, **options)
  @app     = app
  @options = DEFAULTS.merge(options)
  @cache   = {}
  @mutex   = Mutex.new

  path_prefix = Regexp.escape(@options[:path_prefix].chomp("/"))
  @owned = %r{\A#{path_prefix}(?:[./]|\z)}
  @route = %r{\A#{path_prefix}/(#{DIMENSION})(?:x(#{DIMENSION}))?\.png\z}
end

Instance Method Details

#cache_headers(etag) ⇒ Hash[String, String]

Parameters:

  • etag (String)

Returns:

  • (Hash[String, String])


78
79
80
81
# File 'lib/placeholder_image/middleware.rb', line 78

def cache_headers(etag)
  { "etag" => etag,
    "cache-control" => @options[:http_header_cache_control] }
end

#call(env) ⇒ Array(Integer, Hash, Array)

Rack entry point. Serves a PNG for owned paths and delegates everything else downstream.

Parameters:

  • env (Hash)

    the Rack environment.

Returns:

  • (Array(Integer, Hash, Array))

    a Rack response tuple.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/placeholder_image/middleware.rb', line 57

def call(env)
  return @app.call(env) unless @owned.match?(env["PATH_INFO"])
  return error(env["REQUEST_METHOD"], 405, "method not allowed") unless %w[GET HEAD].include?(env["REQUEST_METHOD"])

  spec = parse(env["PATH_INFO"], env["QUERY_STRING"].to_s)
  etag = etag_for(spec)
  return [304, cache_headers(etag), []] if fresh?(env, etag)

  img  = fetch(spec)
  body = env["REQUEST_METHOD"] == "HEAD" ? [] : [img]

  [200, cache_headers(etag).merge(
    "content-type" => "image/png",
    "content-length" => img.bytesize.to_s
  ), body]
rescue BadRequest => e
  error(env["REQUEST_METHOD"], 400, e.message)
end

#error(req_method, status, message) ⇒ rack_response

Parameters:

  • req_method (Object)
  • status (Integer)
  • message (String)

Returns:

  • (rack_response)


91
92
93
94
95
96
97
98
99
100
# File 'lib/placeholder_image/middleware.rb', line 91

def error(req_method, status, message)
  body = req_method == "HEAD" ? "" : "#{message}\n"

  [
    status,
    { "content-type" => "text/plain; charset=utf-8",
      "content-length" => body.bytesize.to_s },
    [body]
  ]
end

#etag_for(spec) ⇒ String

Parameters:

  • spec (Object)

Returns:

  • (String)


87
88
89
# File 'lib/placeholder_image/middleware.rb', line 87

def etag_for(spec)
  %("#{Digest::SHA256.hexdigest(spec.inspect)[0, 16]}")
end

#fetch(spec) ⇒ String

Parameters:

  • spec (Object)

Returns:

  • (String)


102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/placeholder_image/middleware.rb', line 102

def fetch(spec)
  @mutex.synchronize { return @cache[spec] if @cache.key?(spec) }

  img = Renderer.call(**spec)

  @mutex.synchronize do
    @cache[spec] = img if @options[:cache_max_entries].positive?
    @cache.shift while @cache.size > @options[:cache_max_entries] # FIFO eviction
  end

  img
end

#fresh?(env, etag) ⇒ Boolean

Parameters:

  • env (Hash[String, untyped])
  • etag (String)

Returns:

  • (Boolean)


83
84
85
# File 'lib/placeholder_image/middleware.rb', line 83

def fresh?(env, etag)
  env["HTTP_IF_NONE_MATCH"].to_s.split(",").map(&:strip).include?(etag)
end

#parse(path, query) ⇒ Hash[Symbol, untyped]

Parameters:

  • path (Object)
  • query (Object)

Returns:

  • (Hash[Symbol, untyped])


115
116
117
118
119
120
121
122
123
124
# File 'lib/placeholder_image/middleware.rb', line 115

def parse(path, query)
  width, height = parse_dimensions(path)
  params = parse_query_string(query)

  { width: width,
    height: height,
    bg: parse_color(params["bg"], @options[:image_default_bg]),
    fg: parse_color(params["fg"], @options[:image_default_fg]),
    text: "#{width}x#{height}" }
end

#parse_color(value, default) ⇒ Array[Integer]

Parameters:

  • value (Object)
  • default (Object)

Returns:

  • (Array[Integer])

Raises:



150
151
152
153
154
155
156
157
158
159
# File 'lib/placeholder_image/middleware.rb', line 150

def parse_color(value, default)
  value = default if value.nil? || value.empty?
  return value if value.is_a?(Array)

  hex = value.delete_prefix("#")
  hex = hex.chars.map { |c| c * 2 }.join if hex.length == 3
  raise BadRequest, "invalid color: #{value}" unless hex.match?(/\A\h{6}\z/)

  [hex[0, 2], hex[2, 2], hex[4, 2]].map { |pair| pair.to_i(16) }
end

#parse_dimensions(path) ⇒ [ Integer, Integer ]

Parameters:

  • path (Object)

Returns:

  • ([ Integer, Integer ])

Raises:



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/placeholder_image/middleware.rb', line 126

def parse_dimensions(path)
  match = @route.match(path) or
    raise BadRequest, "no such image; expected #{@options[:path_prefix]}/<SIZE>.png or " \
                      "#{@options[:path_prefix]}/<W>x<H>.png"

  width  = match[1].to_i
  height = (match[2] || match[1]).to_i
  max    = @options[:image_max_dim_px]

  raise BadRequest, "dimensions must be between 1 and #{max}" if width > max || height > max
  raise BadRequest, "image exceeds #{@options[:image_max_total_px]} pixels" if
    width * height > @options[:image_max_total_px]

  [width, height]
end

#parse_query_string(query) ⇒ Hash[String, String]

Parameters:

  • query (Object)

Returns:

  • (Hash[String, String])


142
143
144
145
146
147
148
# File 'lib/placeholder_image/middleware.rb', line 142

def parse_query_string(query)
  return {} if query.empty?

  URI.decode_www_form(query).to_h
rescue ArgumentError
  raise BadRequest, "malformed query string"
end