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*/
Instance Method Summary collapse
- #cache_headers(etag) ⇒ Hash[String, String]
-
#call(env) ⇒ Array(Integer, Hash, Array)
Rack entry point.
- #error(req_method, status, message) ⇒ rack_response
- #etag_for(spec) ⇒ String
- #fetch(spec) ⇒ String
- #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_query_string(query) ⇒ Hash[String, String]
Constructor Details
#initialize(app, **options) ⇒ Middleware
Returns a new instance of Middleware.
42 43 44 45 46 47 48 49 50 51 |
# File 'lib/placeholder_image/middleware.rb', line 42 def initialize(app, **) @app = app @options = DEFAULTS.merge() @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]
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.
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.) end |
#error(req_method, status, message) ⇒ 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, ) body = req_method == "HEAD" ? "" : "#{}\n" [ status, { "content-type" => "text/plain; charset=utf-8", "content-length" => body.bytesize.to_s }, [body] ] end |
#etag_for(spec) ⇒ 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
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
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]
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]
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 ]
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]
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 |