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*/
ERROR_VALUE_MAX_LENGTH =

Maximum length of a client-supplied value echoed back in an error message.

Returns:

  • (Integer)
32

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.

Raises:

  • (ArgumentError)

    if an option key is not present in DEFAULTS or a default color is invalid.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/placeholder_image/middleware.rb', line 50

def initialize(app, **options)
  unknown = options.keys - DEFAULTS.keys
  raise ArgumentError, "unknown option(s): #{unknown.join(', ')}" unless unknown.empty?

  @app     = app
  @options = DEFAULTS.merge(options)
  %i[image_default_bg image_default_fg].each { |key| @options[key] = resolve_default_color(key) }
  @cache   = {}
  @mutex   = Mutex.new

  path_prefix = Regexp.escape(@options[:path_prefix].chomp("/"))
  @owned = %r{\A#{path_prefix}/}
  @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])


92
93
94
95
# File 'lib/placeholder_image/middleware.rb', line 92

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.



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/placeholder_image/middleware.rb', line 69

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

  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, headers = {}) ⇒ rack_response

Parameters:

  • req_method (Object)
  • status (Integer)
  • message (String)
  • headers (Hash[String, String]) (defaults to: {})

Returns:

  • (rack_response)


105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/placeholder_image/middleware.rb', line 105

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

  [
    status,
    headers.merge(
      "content-type" => "text/plain; charset=utf-8",
      "content-length" => body.bytesize.to_s
    ),
    [body]
  ]
end

#etag_for(spec) ⇒ String

Parameters:

  • spec (Object)

Returns:

  • (String)


101
102
103
# File 'lib/placeholder_image/middleware.rb', line 101

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

#fetch(spec) ⇒ String

Fetch the image from the cache if available; otherwise generate (and cache).

NOTE: Cached entries are compressed PNGs, so worst-case cache memory is ~ cache_max_entries * compressed_size_of_largest_allowed_image. Under the default config (entries=128 max_px=4000x4000) the largest image encodes to ~260 KB; yielding max ~35 MB per cache instance.

Parameters:

  • spec (Object)

Returns:

  • (String)


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

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

  # render outside the mutex: concurrent misses may trigger duplicate rendering
  # but that's better (cheap, idempotent) than serializing all rendering
  img = Renderer.call(**spec)

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

  img
end

#fresh?(env, etag) ⇒ Boolean

Parameters:

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

Returns:

  • (Boolean)


97
98
99
# File 'lib/placeholder_image/middleware.rb', line 97

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


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

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


176
177
178
179
180
# File 'lib/placeholder_image/middleware.rb', line 176

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

  parse_hex_color(value) or raise BadRequest, "invalid color: #{truncate(value)}"
end

#parse_dimensions(path) ⇒ [ Integer, Integer ]

Parameters:

  • path (Object)

Returns:

  • ([ Integer, Integer ])

Raises:



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/placeholder_image/middleware.rb', line 152

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_hex_color(value) ⇒ Array(Integer, Integer, Integer)?

Returns RGB bytes, or nil if malformed.

Parameters:

  • value (String)

Returns:

  • (Array(Integer, Integer, Integer), nil)

    RGB bytes, or nil if malformed.



183
184
185
186
187
188
189
# File 'lib/placeholder_image/middleware.rb', line 183

def parse_hex_color(value)
  hex = value.delete_prefix("#")
  hex = hex.chars.map { |c| c * 2 }.join if hex.length == 3
  return nil unless hex.match?(/\A\h{6}\z/)

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

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

Parameters:

  • query (Object)

Returns:

  • (Hash[String, String])


168
169
170
171
172
173
174
# File 'lib/placeholder_image/middleware.rb', line 168

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

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

#resolve_default_color(key) ⇒ Array[Integer]

Validates and resolves a configured default color to RGB bytes at boot.

Parameters:

  • key (Symbol)

Returns:

  • (Array[Integer])

Raises:

  • (ArgumentError)


196
197
198
199
200
201
202
203
204
205
206
# File 'lib/placeholder_image/middleware.rb', line 196

def resolve_default_color(key)
  value = @options[key]
  if value.is_a?(Array)
    return value if value.length == 3 && value.all? { |c| c.is_a?(Integer) && c.between?(0, 255) }
  elsif value.is_a?(String)
    rgb = parse_hex_color(value)
    return rgb if rgb
  end

  raise ArgumentError, "invalid #{key}: expected hex color string or array of RGB integers, got #{value.inspect}"
end

#truncate(value) ⇒ String

Parameters:

  • value (String)

Returns:

  • (String)


191
192
193
# File 'lib/placeholder_image/middleware.rb', line 191

def truncate(value)
  value.length > ERROR_VALUE_MAX_LENGTH ? "#{value[0, ERROR_VALUE_MAX_LENGTH]}..." : value
end