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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +1 -0
- data/lib/placeholder_image/middleware.rb +58 -11
- data/lib/placeholder_image/version.rb +1 -1
- data/sig/placeholder_image/middleware.rbs +8 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2e4bba850d4f417d66bad57f0453411c81c21b01e6a87fdcd66fffe8b87b5d00
|
|
4
|
+
data.tar.gz: b3c4d216ab6ac08e03c0058df1caa3024d3a491134f3b6bb332efb65bfce9d85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
"content-
|
|
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
|
-
@
|
|
108
|
-
@
|
|
109
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|