placeholder-image 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1b9fc489b1812d0f1f82cbc9bfa765d6e01cdba612da2697a416bbcbc460ae0e
4
- data.tar.gz: 1e0d6fe2e8fd81ca37bad24219771fd4125190e92d1f0a9cdc0b216b526ef7d5
3
+ metadata.gz: 2e4bba850d4f417d66bad57f0453411c81c21b01e6a87fdcd66fffe8b87b5d00
4
+ data.tar.gz: b3c4d216ab6ac08e03c0058df1caa3024d3a491134f3b6bb332efb65bfce9d85
5
5
  SHA512:
6
- metadata.gz: 28682fa1cfa37908f42663ba841ff50de20dcb578d7cbeabd8eb09b46e32bd67b92d1d2cabcb8010d1a1cba1469f3de68b43b690b0858fbcb5f65797a749d236
7
- data.tar.gz: ddd2662d54dbdf2bd81125763a3ccf9cf4c927e57e32a2952cab2e47f76f446f13ffa3c959b9ceab41d04a19443a552f1c6a8a4e38868fc9ec8c4e9d1337a877
6
+ metadata.gz: 73e14b26d74235e274609bf768db885de909c1d00cdeaa1515c941568f726877bf1e92731a2712f9b29054a783ee74643b76f3a15febe10280194ca53dcd0225
7
+ data.tar.gz: cd28a77f3fa31481dbfd111fee819c140562ec26e0b882523e34232e974ffef3be3c9c34aa9460f8d9c0ff89a2de8f439c519ebf4e49303a16e8f97f366dbe56
data/CHANGELOG.md CHANGED
@@ -3,6 +3,15 @@
3
3
  All notable changes to this project will be documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## v1.1.0 - 2026-08-05
7
+
8
+ - Validate configuration at boot: unknown option keys and invalid default colors now raise `ArgumentError` when the middleware is constructed.
9
+ - Narrow path ownership to `<path_prefix>/`: the bare prefix (`/placeholder`) and sibling paths such as `/placeholder.css` are now passed through to the downstream application instead of returning `400 Bad Request`.
10
+ - Include the `Allow` header in `405 Method Not Allowed` responses.
11
+ - Truncate client-supplied color values echoed in `400 Bad Request` messages.
12
+ - Make container builds reproducible: `docker/Gemfile.lock` is now committed and the image build installs with a frozen bundle.
13
+ - Publish multi-architecture container images to GitHub Container Registry (`ghcr.io/rodw/placeholder-image`) on release.
14
+
6
15
  ## v1.0.0 - 2026-08-05
7
16
 
8
17
  - Initial release. Includes PNG-generating Rack middleware, stand-alone Docker service, and complete examples of integrating with Sinatra, Rails, and generic Rack applications.
data/README.md CHANGED
@@ -90,6 +90,7 @@ use PlaceholderImage::Middleware,
90
90
  | `image_default_fg` | `[0x99,0x99,0x99]`| Default foreground color used for the border and dimension label. It accepts the same array and hex-string formats as `image_default_bg`; the request's `fg` parameter overrides it. |
91
91
  | `cache_max_entries` | `128` | Maximum number of generated images retained in each middleware instance's in-memory FIFO cache. Set to `0` to disable caching. |
92
92
 
93
+ Note that cached entries are compressed PNGs, so worst-case cache memory is roughly `cache_max_entries` times the compressed size of the largest allowed image. Under the default configuration (128 entries, max size 4000x4000 pixels) the largest image encodes to roughly 260 KB yielding a max cache size around 35 MB.
93
94
 
94
95
  ## Running as a Stand-Alone Container
95
96
 
@@ -12,6 +12,10 @@ require "digest"
12
12
  # GET /placeholder/640x480.png -> 640x480, default colors
13
13
  # GET /placeholder/300x200.png?bg=eee&fg=999 -> 300x200, specified colors
14
14
  #
15
+ # Only requests beginning with "<path_prefix>/" are handled; everything else
16
+ # (including the bare prefix and sibling paths such as "/placeholder.css") is
17
+ # passed through to the downstream application.
18
+ #
15
19
  # No image gems required: PNGs are encoded with stdlib zlib.
16
20
  module PlaceholderImage
17
21
  class BadRequest < StandardError; end
@@ -30,6 +34,9 @@ module PlaceholderImage
30
34
 
31
35
  DIMENSION = /[1-9]\d*/
32
36
 
37
+ # Maximum length of a client-supplied value echoed back in an error message.
38
+ ERROR_VALUE_MAX_LENGTH = 32
39
+
33
40
  # @param app [#call] the downstream Rack application.
34
41
  # @param options [Hash] configuration overrides merged over {DEFAULTS}.
35
42
  # @option options [String] :path_prefix ("/placeholder") URL prefix the middleware serves.
@@ -39,14 +46,19 @@ module PlaceholderImage
39
46
  # @option options [Integer] :cache_max_entries (128) in-memory FIFO cache size; +0+ disables caching.
40
47
  # @option options [Array(Integer, Integer, Integer), String] :image_default_bg default background color.
41
48
  # @option options [Array(Integer, Integer, Integer), String] :image_default_fg default foreground color.
49
+ # @raise [ArgumentError] if an option key is not present in {DEFAULTS} or a default color is invalid.
42
50
  def initialize(app, **options)
51
+ unknown = options.keys - DEFAULTS.keys
52
+ raise ArgumentError, "unknown option(s): #{unknown.join(', ')}" unless unknown.empty?
53
+
43
54
  @app = app
44
55
  @options = DEFAULTS.merge(options)
56
+ %i[image_default_bg image_default_fg].each { |key| @options[key] = resolve_default_color(key) }
45
57
  @cache = {}
46
58
  @mutex = Mutex.new
47
59
 
48
60
  path_prefix = Regexp.escape(@options[:path_prefix].chomp("/"))
49
- @owned = %r{\A#{path_prefix}(?:[./]|\z)}
61
+ @owned = %r{\A#{path_prefix}/}
50
62
  @route = %r{\A#{path_prefix}/(#{DIMENSION})(?:x(#{DIMENSION}))?\.png\z}
51
63
  end
52
64
 
@@ -56,7 +68,9 @@ module PlaceholderImage
56
68
  # @return [Array(Integer, Hash, Array)] a Rack response tuple.
57
69
  def call(env)
58
70
  return @app.call(env) unless @owned.match?(env["PATH_INFO"])
59
- return error(env["REQUEST_METHOD"], 405, "method not allowed") unless %w[GET HEAD].include?(env["REQUEST_METHOD"])
71
+ unless %w[GET HEAD].include?(env["REQUEST_METHOD"])
72
+ return error(env["REQUEST_METHOD"], 405, "method not allowed", { "allow" => "GET, HEAD" })
73
+ end
60
74
 
61
75
  spec = parse(env["PATH_INFO"], env["QUERY_STRING"].to_s)
62
76
  etag = etag_for(spec)
@@ -88,25 +102,37 @@ module PlaceholderImage
88
102
  %("#{Digest::SHA256.hexdigest(spec.inspect)[0, 16]}")
89
103
  end
90
104
 
91
- def error(req_method, status, message)
105
+ def error(req_method, status, message, headers = {})
92
106
  body = req_method == "HEAD" ? "" : "#{message}\n"
93
107
 
94
108
  [
95
109
  status,
96
- { "content-type" => "text/plain; charset=utf-8",
97
- "content-length" => body.bytesize.to_s },
110
+ headers.merge(
111
+ "content-type" => "text/plain; charset=utf-8",
112
+ "content-length" => body.bytesize.to_s
113
+ ),
98
114
  [body]
99
115
  ]
100
116
  end
101
117
 
118
+ # Fetch the image from the cache if available; otherwise generate (and cache).
119
+ #
120
+ # NOTE: Cached entries are compressed PNGs, so worst-case cache memory is
121
+ # ~ cache_max_entries * compressed_size_of_largest_allowed_image.
122
+ # Under the default config (entries=128 max_px=4000x4000) the largest
123
+ # image encodes to ~260 KB; yielding max ~35 MB per cache instance.
102
124
  def fetch(spec)
103
125
  @mutex.synchronize { return @cache[spec] if @cache.key?(spec) }
104
126
 
127
+ # render outside the mutex: concurrent misses may trigger duplicate rendering
128
+ # but that's better (cheap, idempotent) than serializing all rendering
105
129
  img = Renderer.call(**spec)
106
130
 
107
- @mutex.synchronize do
108
- @cache[spec] = img if @options[:cache_max_entries].positive?
109
- @cache.shift while @cache.size > @options[:cache_max_entries] # FIFO eviction
131
+ if @options[:cache_max_entries].positive?
132
+ @mutex.synchronize do
133
+ @cache[spec] = img
134
+ @cache.shift while @cache.size > @options[:cache_max_entries] # FIFO eviction
135
+ end
110
136
  end
111
137
 
112
138
  img
@@ -148,14 +174,35 @@ module PlaceholderImage
148
174
  end
149
175
 
150
176
  def parse_color(value, default)
151
- value = default if value.nil? || value.empty?
152
- return value if value.is_a?(Array)
177
+ return default if value.nil? || value.empty?
178
+
179
+ parse_hex_color(value) or raise BadRequest, "invalid color: #{truncate(value)}"
180
+ end
153
181
 
182
+ # @return [Array(Integer, Integer, Integer), nil] RGB bytes, or +nil+ if malformed.
183
+ def parse_hex_color(value)
154
184
  hex = value.delete_prefix("#")
155
185
  hex = hex.chars.map { |c| c * 2 }.join if hex.length == 3
156
- raise BadRequest, "invalid color: #{value}" unless hex.match?(/\A\h{6}\z/)
186
+ return nil unless hex.match?(/\A\h{6}\z/)
157
187
 
158
188
  [hex[0, 2], hex[2, 2], hex[4, 2]].map { |pair| pair.to_i(16) }
159
189
  end
190
+
191
+ def truncate(value)
192
+ value.length > ERROR_VALUE_MAX_LENGTH ? "#{value[0, ERROR_VALUE_MAX_LENGTH]}..." : value
193
+ end
194
+
195
+ # Validates and resolves a configured default color to RGB bytes at boot.
196
+ def resolve_default_color(key)
197
+ value = @options[key]
198
+ if value.is_a?(Array)
199
+ return value if value.length == 3 && value.all? { |c| c.is_a?(Integer) && c.between?(0, 255) }
200
+ elsif value.is_a?(String)
201
+ rgb = parse_hex_color(value)
202
+ return rgb if rgb
203
+ end
204
+
205
+ raise ArgumentError, "invalid #{key}: expected hex color string or array of RGB integers, got #{value.inspect}"
206
+ end
160
207
  end
161
208
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PlaceholderImage
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -9,6 +9,7 @@ module PlaceholderImage
9
9
 
10
10
  DEFAULTS: Hash[Symbol, untyped]
11
11
  DIMENSION: Regexp
12
+ ERROR_VALUE_MAX_LENGTH: Integer
12
13
 
13
14
  @app: untyped
14
15
  @options: Hash[Symbol, untyped]
@@ -29,7 +30,7 @@ module PlaceholderImage
29
30
 
30
31
  def etag_for: (untyped spec) -> String
31
32
 
32
- def error: (untyped req_method, Integer status, String message) -> rack_response
33
+ def error: (untyped req_method, Integer status, String message, ?Hash[String, String] headers) -> rack_response
33
34
 
34
35
  def fetch: (untyped spec) -> String
35
36
 
@@ -40,5 +41,11 @@ module PlaceholderImage
40
41
  def parse_query_string: (untyped query) -> Hash[String, String]
41
42
 
42
43
  def parse_color: (untyped value, untyped default) -> Array[Integer]
44
+
45
+ def parse_hex_color: (String value) -> Array[Integer]?
46
+
47
+ def truncate: (String value) -> String
48
+
49
+ def resolve_default_color: (Symbol key) -> Array[Integer]
43
50
  end
44
51
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: placeholder-image
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodney Waldhoff