Class: PlaceholderImage::Middleware
- Inherits:
-
Object
- Object
- PlaceholderImage::Middleware
- Defined in:
- lib/placeholder_image/middleware.rb,
sig/placeholder_image/middleware.rbs
Overview
Rack middleware that serves generated placeholder PNGs.
Constant Summary collapse
- DEFAULTS =
{ 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 =
/[1-9]\d*/- ERROR_VALUE_MAX_LENGTH =
Maximum length of a client-supplied value echoed back in an error message.
32
Instance Method Summary collapse
- #cache_headers(etag) ⇒ Hash[String, String]
-
#call(env) ⇒ Array(Integer, Hash, Array)
Rack entry point.
- #error(req_method, status, message, headers = {}) ⇒ rack_response
- #etag_for(spec) ⇒ String
-
#fetch(spec) ⇒ String
Fetch the image from the cache if available; otherwise generate (and cache).
- #fresh?(env, etag) ⇒ Boolean
-
#initialize(app, **options) ⇒ Middleware
constructor
A new instance of Middleware.
- #parse(path, query) ⇒ Hash[Symbol, untyped]
- #parse_color(value, default) ⇒ Array[Integer]
- #parse_dimensions(path) ⇒ [ Integer, Integer ]
-
#parse_hex_color(value) ⇒ Array(Integer, Integer, Integer)?
RGB bytes, or
nilif malformed. - #parse_query_string(query) ⇒ Hash[String, String]
-
#resolve_default_color(key) ⇒ Array[Integer]
Validates and resolves a configured default color to RGB bytes at boot.
- #truncate(value) ⇒ String
Constructor Details
#initialize(app, **options) ⇒ Middleware
Returns a new instance of Middleware.
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, **) unknown = .keys - DEFAULTS.keys raise ArgumentError, "unknown option(s): #{unknown.join(', ')}" unless unknown.empty? @app = app @options = DEFAULTS.merge() %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]
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.
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.) end |
#error(req_method, status, message, headers = {}) ⇒ 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, , headers = {}) body = req_method == "HEAD" ? "" : "#{}\n" [ status, headers.merge( "content-type" => "text/plain; charset=utf-8", "content-length" => body.bytesize.to_s ), [body] ] end |
#etag_for(spec) ⇒ 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.
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
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]
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]
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 ]
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.
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]
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.
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
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 |