otto 2.10.0 → 2.11.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/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.rubocop_todo.yml +1 -0
- data/AGENTS.md +18 -0
- data/CHANGELOG.rst +32 -0
- data/Gemfile.lock +1 -1
- data/docs/guides/configuration_freezing.md +17 -6
- data/docs/guides/routing.md +112 -0
- data/lib/otto/core/configuration.rb +4 -1
- data/lib/otto/core/error_handler.rb +40 -2
- data/lib/otto/core/file_safety.rb +24 -8
- data/lib/otto/core/router.rb +39 -6
- data/lib/otto/core/static_mounts.rb +172 -0
- data/lib/otto/core.rb +1 -0
- data/lib/otto/static.rb +38 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +121 -2
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b8303453d265fcf2d5b0fa0754aafdbdb3e24eb89be9dc5a7b5611b4f2bcc699
|
|
4
|
+
data.tar.gz: 2081706f65a3475f95555d96547841691f906b015f8b012739abc7804923fc76
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 75c496ee29c9c4ec0b05a8effda77e4e1dafc02080fb341a2320313011437f2b0bc0511ad6ca7d1b53928d6f75130693c974c72ed053aa35e5c6d16f60af0a5e
|
|
7
|
+
data.tar.gz: 9d9615efa73c7ebafafe7f7e1eb3e098f8c07f3200ac62882c52c39954087c73053fc9574beda1b1e11ecc836e0b9bacb70fe267c90fa92600531ef14fca1b63
|
|
@@ -61,7 +61,7 @@ jobs:
|
|
|
61
61
|
|
|
62
62
|
- name: Run Claude Code Review
|
|
63
63
|
id: claude-review
|
|
64
|
-
uses: anthropics/claude-code-action@
|
|
64
|
+
uses: anthropics/claude-code-action@19dda84776b3518d98b8798e591daee763049ed3 # v1.0.220
|
|
65
65
|
with:
|
|
66
66
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
67
67
|
|
|
@@ -32,7 +32,7 @@ jobs:
|
|
|
32
32
|
|
|
33
33
|
- name: Run Claude Code
|
|
34
34
|
id: claude
|
|
35
|
-
uses: anthropics/claude-code-action@
|
|
35
|
+
uses: anthropics/claude-code-action@19dda84776b3518d98b8798e591daee763049ed3 # v1.0.220
|
|
36
36
|
with:
|
|
37
37
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
38
38
|
|
data/.rubocop_todo.yml
CHANGED
|
@@ -859,6 +859,7 @@ RSpec/SpecFilePathFormat:
|
|
|
859
859
|
- 'spec/otto/enhanced_routing_spec.rb'
|
|
860
860
|
- 'spec/otto/error_handler_registration_spec.rb'
|
|
861
861
|
- 'spec/otto/error_handling_spec.rb'
|
|
862
|
+
- 'spec/otto/fallback_response_isolation_spec.rb'
|
|
862
863
|
- 'spec/otto/file_safety_spec.rb'
|
|
863
864
|
- 'spec/otto/locale_config_spec.rb'
|
|
864
865
|
- 'spec/otto/mcp/rate_limiting_spec.rb'
|
data/AGENTS.md
CHANGED
|
@@ -69,6 +69,24 @@ Helper modules should avoid overriding these methods inherited from Rack::Reques
|
|
|
69
69
|
|
|
70
70
|
No runtime validation is performed for performance reasons. Overriding these methods will cause undefined behavior.
|
|
71
71
|
|
|
72
|
+
## Static File Registration
|
|
73
|
+
|
|
74
|
+
Files under the `public:` directory are served without registration. Use
|
|
75
|
+
`mount_static` to bind a URL prefix to a directory outside it, or to verify a
|
|
76
|
+
required asset directory at boot:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
otto = Otto.new('routes.txt', public: 'public')
|
|
80
|
+
otto.mount_static('/assets', root: 'build/assets')
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- Roots are canonicalized at registration; a missing or unsafe root raises `ArgumentError`
|
|
84
|
+
- Precedence is fixed: literal routes, then mounts (longest prefix first), then `public:`, then dynamic routes
|
|
85
|
+
- Must be registered before first request (before configuration freezing)
|
|
86
|
+
- `add_static_path` was removed in v2.10.0 and has no shim
|
|
87
|
+
|
|
88
|
+
See `docs/guides/routing.md` for the full contract.
|
|
89
|
+
|
|
72
90
|
## Authentication Architecture
|
|
73
91
|
|
|
74
92
|
Authentication is handled by `RouteAuthWrapper` at the handler level, NOT by middleware.
|
data/CHANGELOG.rst
CHANGED
|
@@ -7,6 +7,38 @@ The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.1.0/>`
|
|
|
7
7
|
|
|
8
8
|
<!--scriv-insert-here-->
|
|
9
9
|
|
|
10
|
+
.. _changelog-2.11.0:
|
|
11
|
+
|
|
12
|
+
2.11.0 — 2026-09-12
|
|
13
|
+
===================
|
|
14
|
+
|
|
15
|
+
Added
|
|
16
|
+
-----
|
|
17
|
+
|
|
18
|
+
- ``Otto#mount_static(prefix, root:)`` serves an explicit directory at a URL
|
|
19
|
+
prefix. See ``docs/guides/routing.md`` for configuration, dispatch
|
|
20
|
+
precedence, and migration from ``add_static_path``. (#267)
|
|
21
|
+
|
|
22
|
+
- ``Otto#not_found=`` and ``Otto#server_error=`` now accept callables for
|
|
23
|
+
per-request fallback responses. A server-error callable can receive the
|
|
24
|
+
exception; see ``docs/guides/routing.md`` for the callback contract. (#272)
|
|
25
|
+
|
|
26
|
+
Security
|
|
27
|
+
--------
|
|
28
|
+
|
|
29
|
+
- Static Rack triples configured with ``Otto#not_found=`` or
|
|
30
|
+
``Otto#server_error=`` are now copied for each request, preventing in-place
|
|
31
|
+
header changes, including ``Set-Cookie``, from being shared between fallback
|
|
32
|
+
responses. (#272)
|
|
33
|
+
|
|
34
|
+
Documentation
|
|
35
|
+
-------------
|
|
36
|
+
|
|
37
|
+
- Documented static-file dispatch precedence and migration from the removed
|
|
38
|
+
``add_static_path`` API in ``docs/guides/routing.md``. Corrected stale
|
|
39
|
+
``routes_static`` cache guidance in ``docs/guides/configuration_freezing.md``.
|
|
40
|
+
(#267)
|
|
41
|
+
|
|
10
42
|
.. _changelog-2.10.0:
|
|
11
43
|
|
|
12
44
|
2.10.0 — 2026-09-04
|
data/Gemfile.lock
CHANGED
|
@@ -51,15 +51,17 @@ otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
|
|
|
51
51
|
- the middleware stack;
|
|
52
52
|
- authentication configuration and instance options;
|
|
53
53
|
- dynamic and literal route tables;
|
|
54
|
-
- route definitions and reverse-route indexes
|
|
54
|
+
- route definitions and reverse-route indexes;
|
|
55
|
+
- the explicit static mount table (`mount_static`).
|
|
55
56
|
|
|
56
57
|
Hashes and arrays inside those structures are recursively frozen. Configuration
|
|
57
58
|
objects that implement `deep_freeze!` can prepare memoized values before they
|
|
58
59
|
are frozen.
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
Static-file dispatch keeps no mutable routing state. Files under the implicit
|
|
62
|
+
`public:` directory are resolved on each request against the current canonical
|
|
63
|
+
root, and explicit mounts are an immutable snapshot from the moment they are
|
|
64
|
+
registered; `mount_static` raises `FrozenError` after the freeze boundary.
|
|
63
65
|
|
|
64
66
|
## Scope and current limitations
|
|
65
67
|
|
|
@@ -69,8 +71,12 @@ some state that is not included in `freeze_configuration!`:
|
|
|
69
71
|
|
|
70
72
|
- `error_handlers` remains a mutable Hash, although
|
|
71
73
|
`register_error_handler` rejects calls after freezing;
|
|
72
|
-
- the `not_found` and `server_error` fallback response writers remain
|
|
73
|
-
|
|
74
|
+
- the `not_found` and `server_error` fallback response writers remain
|
|
75
|
+
available. A configured static triple is never returned by reference:
|
|
76
|
+
Otto copies it per request, so header writes by cookie middleware do not
|
|
77
|
+
reach the configured object even though it is not frozen;
|
|
78
|
+
- the `Rack::Files` instance for the implicit `public:` directory is rebuilt
|
|
79
|
+
when that directory is repointed between requests.
|
|
74
80
|
|
|
75
81
|
Application code should not mutate those objects directly after boot. Do not
|
|
76
82
|
state or depend on a guarantee that every object reachable from an Otto instance
|
|
@@ -86,6 +92,9 @@ otto.security_config.disable_csrf_protection!
|
|
|
86
92
|
otto.add_trusted_proxy('192.0.2.10')
|
|
87
93
|
otto.add_rate_limit_rule('uploads', limit: 5, period: 60)
|
|
88
94
|
|
|
95
|
+
# Static mounts
|
|
96
|
+
otto.mount_static('/assets', root: 'public/assets')
|
|
97
|
+
|
|
89
98
|
# Middleware and authentication
|
|
90
99
|
otto.use MyApp::OtherMiddleware
|
|
91
100
|
otto.add_auth_strategy('other', MyApp::OtherStrategy.new)
|
|
@@ -144,3 +153,5 @@ first requests therefore do not run the freeze operation concurrently.
|
|
|
144
153
|
freezing behavior.
|
|
145
154
|
- [Configuration-freezing specs](../../spec/otto/configuration_freezing_spec.rb).
|
|
146
155
|
- [Static-file freezing specs](../../spec/otto/static_file_freezing_spec.rb).
|
|
156
|
+
- [Static mount specs](../../spec/otto/core/static_mounts_spec.rb) — registration
|
|
157
|
+
after the freeze boundary and serving from a frozen mount table.
|
data/docs/guides/routing.md
CHANGED
|
@@ -163,6 +163,118 @@ silently weakening the route. Do not use `csrf=exempt` as a general API switch;
|
|
|
163
163
|
choose an independent request-authentication and replay-protection model for
|
|
164
164
|
webhooks or other non-browser endpoints.
|
|
165
165
|
|
|
166
|
+
## Static files
|
|
167
|
+
|
|
168
|
+
Otto serves static files in two ways. Both apply the same safety policy: the
|
|
169
|
+
requested path is joined to a canonical root, resolved with `File.realpath`
|
|
170
|
+
(which follows every `..`, `.`, and symlink component), and served only when
|
|
171
|
+
the result is still inside that root and is a regular, readable file owned by
|
|
172
|
+
the process user or group. Anything else, including a symlink that points
|
|
173
|
+
outside the root, is treated as not found.
|
|
174
|
+
|
|
175
|
+
### Implicit public directory
|
|
176
|
+
|
|
177
|
+
Passing `public:` serves every file under that directory at its relative path.
|
|
178
|
+
Nothing needs registering; a file added after boot is served on the next
|
|
179
|
+
request, and a symlinked public directory that is repointed by a deploy is
|
|
180
|
+
re-resolved on every request.
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
otto = Otto.new('routes', public: File.expand_path('public', __dir__))
|
|
184
|
+
# public/css/site.css is served at GET /css/site.css
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Explicit static mounts
|
|
188
|
+
|
|
189
|
+
`mount_static` binds one URL prefix to one directory. Use it when the files do
|
|
190
|
+
not live under a single public directory, when a URL prefix should map to a
|
|
191
|
+
different directory name, or when a required asset directory must be verified
|
|
192
|
+
at boot.
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
otto = Otto.new('routes')
|
|
196
|
+
otto.mount_static('/assets', root: 'public/assets')
|
|
197
|
+
otto.mount_static('/vendor', root: File.join(Gem.loaded_specs['some-ui-kit'].full_gem_path, 'dist'))
|
|
198
|
+
otto.mount_static('/', root: 'public/root-files') # favicon.ico, robots.txt
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- The prefix must start with `/`. A trailing slash is ignored, and `/`
|
|
202
|
+
mounts the root at the top level. Empty, `.`, and `..` segments are
|
|
203
|
+
rejected.
|
|
204
|
+
- The root is expanded and canonicalized once, at registration. A root that
|
|
205
|
+
is missing, unreadable, not a directory, not owned by the process user or
|
|
206
|
+
group, or a symlink that cannot be resolved raises `ArgumentError`, so a
|
|
207
|
+
misconfigured application does not boot. Because the root is fixed at
|
|
208
|
+
registration, a deploy that repoints a symlinked root takes effect at the
|
|
209
|
+
next restart.
|
|
210
|
+
- A mount authorizes only files inside its own root. It never exposes the
|
|
211
|
+
root's parent or siblings, and it does not widen the implicit public
|
|
212
|
+
directory. Registering the same prefix twice on one instance raises
|
|
213
|
+
`ArgumentError`; different Otto instances are fully independent.
|
|
214
|
+
- Requests are matched on the decoded, trailing-slash-stripped path, the same
|
|
215
|
+
normalization every other dispatch stage uses. Only `GET` is served, the
|
|
216
|
+
prefix itself is not (mounts serve files, not directory listings), and a
|
|
217
|
+
request for a file the root does not contain falls through to the next
|
|
218
|
+
dispatch stage.
|
|
219
|
+
- `mount_static` must be called before the first request. After configuration
|
|
220
|
+
freezing it raises `FrozenError`, and `otto.static_mounts` is a frozen,
|
|
221
|
+
read-only table.
|
|
222
|
+
|
|
223
|
+
### Dispatch precedence
|
|
224
|
+
|
|
225
|
+
Precedence is fixed and does not depend on request history:
|
|
226
|
+
|
|
227
|
+
1. literal routes, such as `GET /assets/app.css Assets#show`;
|
|
228
|
+
2. explicit static mounts, consulted longest prefix first; when the longest
|
|
229
|
+
matching mount does not contain the file, shorter matching mounts are tried
|
|
230
|
+
in turn;
|
|
231
|
+
3. the implicit `public:` directory;
|
|
232
|
+
4. dynamic routes, such as `GET /assets/:name Assets#show`.
|
|
233
|
+
|
|
234
|
+
So a literal route at a mounted path always wins, a mounted file always beats
|
|
235
|
+
a file at the same URL in the public directory, and a dynamic route only sees
|
|
236
|
+
requests that no static source could serve.
|
|
237
|
+
|
|
238
|
+
### Migrating from `add_static_path`
|
|
239
|
+
|
|
240
|
+
`add_static_path` was removed in v2.10.0. It only populated a request-time
|
|
241
|
+
cache; it never registered or restricted anything. Callers that used it to
|
|
242
|
+
"register" files under the public directory can delete the call, because the
|
|
243
|
+
public directory is served without registration. Callers that used it to reach
|
|
244
|
+
files outside the public directory should replace it with `mount_static` and
|
|
245
|
+
an explicit root. There is no compatibility shim: calling the removed method
|
|
246
|
+
raises `NoMethodError` at boot.
|
|
247
|
+
|
|
248
|
+
## Fallback 404 and 500 responses
|
|
249
|
+
|
|
250
|
+
A `GET /404` or `GET /500` route in the routes file handles misses and
|
|
251
|
+
unhandled errors like any other route. Without one, Otto uses `not_found=` and
|
|
252
|
+
`server_error=`, which accept either a Rack triple or a callable:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
|
|
256
|
+
|
|
257
|
+
otto.server_error = lambda do |env, error|
|
|
258
|
+
[500, { 'content-type' => 'text/plain' }, ["Error #{env['otto.error_id']}"]]
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
A callable is invoked on every request with `env` (`not_found`) or `env` and
|
|
263
|
+
the exception (`server_error`), trimmed to the positional parameters it
|
|
264
|
+
declares, so `->(env) { ... }` and `->(env = nil) { ... }` both work for
|
|
265
|
+
`server_error`. It must return a Rack triple: an Integer status, Hash-like
|
|
266
|
+
headers, and a body that responds to `each` (a bare String is rejected, at
|
|
267
|
+
assignment time for a static triple). A static triple is copied per request
|
|
268
|
+
before it is returned, so middleware that writes response headers in place
|
|
269
|
+
(rack-session, Otto's CSRF middleware, anything calling
|
|
270
|
+
`Rack::Utils.set_cookie_header!`) never mutates the configured object or
|
|
271
|
+
leaks one client's `Set-Cookie` into another's response. Do not rely on
|
|
272
|
+
mutating the configured triple after boot; assign a new value or use the
|
|
273
|
+
callable form instead.
|
|
274
|
+
|
|
275
|
+
For JSON clients, an unhandled error returns Otto's built-in JSON error body
|
|
276
|
+
regardless of `server_error`; a `/500` route applies to every client.
|
|
277
|
+
|
|
166
278
|
## Configuration timing
|
|
167
279
|
|
|
168
280
|
Construct and configure the Otto instance before the first request:
|
|
@@ -308,11 +308,14 @@ class Otto
|
|
|
308
308
|
deep_freeze_value(@routes_literal) if @routes_literal
|
|
309
309
|
deep_freeze_value(@route_definitions) if @route_definitions
|
|
310
310
|
deep_freeze_value(@routes_by_definition) if @routes_by_definition
|
|
311
|
+
# Explicit static mounts are already immutable snapshots; freezing the
|
|
312
|
+
# array here records that fact and makes any in-place mutation raise.
|
|
313
|
+
deep_freeze_value(@static_mounts) if @static_mounts
|
|
311
314
|
|
|
312
315
|
@configuration_frozen = true
|
|
313
316
|
|
|
314
317
|
duration = Otto::Utils.now_in_μs - start_time
|
|
315
|
-
frozen_objects = %w[security_config locale_config middleware auth_config option routes]
|
|
318
|
+
frozen_objects = %w[security_config locale_config middleware auth_config option routes static_mounts]
|
|
316
319
|
Otto.structured_log(:info, 'Freezing completed',
|
|
317
320
|
{
|
|
318
321
|
duration: duration,
|
|
@@ -71,8 +71,8 @@ class Otto
|
|
|
71
71
|
# Content negotiation for built-in error response
|
|
72
72
|
return json_error_response(error_id) if wants_json_response?(env)
|
|
73
73
|
|
|
74
|
-
# Fallback to built-in
|
|
75
|
-
|
|
74
|
+
# Fallback to the configured server_error response, else the built-in one
|
|
75
|
+
server_error_response(env, error, error_id)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
78
|
# Register an error handler for expected business logic errors
|
|
@@ -268,6 +268,44 @@ class Otto
|
|
|
268
268
|
end
|
|
269
269
|
end
|
|
270
270
|
|
|
271
|
+
# Build the fallback 500 response for an unhandled error.
|
|
272
|
+
#
|
|
273
|
+
# A configured +server_error+ callable is invoked per request with
|
|
274
|
+
# +env+ and the original +error+, trimmed to the positional parameters
|
|
275
|
+
# it declares; +env+ carries +otto.error_id+ so the response can
|
|
276
|
+
# reference the logged error. A configured static triple is copied per
|
|
277
|
+
# request (see {Otto::Static.copy_response}) so header writes by cookie
|
|
278
|
+
# middleware cannot accumulate on the shared object. A callable that
|
|
279
|
+
# raises is logged and replaced by the built-in secure response,
|
|
280
|
+
# mirroring how a failing custom +/500+ route is handled.
|
|
281
|
+
#
|
|
282
|
+
# @param env [Hash] Rack environment
|
|
283
|
+
# @param error [Exception] the unhandled error
|
|
284
|
+
# @param error_id [String] correlation id already logged for +error+
|
|
285
|
+
# @return [Array] a fresh Rack triple
|
|
286
|
+
def server_error_response(env, error, error_id)
|
|
287
|
+
fallback = @server_error
|
|
288
|
+
return secure_error_response(error_id) if fallback.nil?
|
|
289
|
+
|
|
290
|
+
env['otto.error_id'] = error_id
|
|
291
|
+
resolve_fallback_response(:server_error, fallback, env, error)
|
|
292
|
+
rescue StandardError => e
|
|
293
|
+
fallback_error_id = SecureRandom.hex(8)
|
|
294
|
+
base_context = Otto::LoggingHelpers.request_context(env)
|
|
295
|
+
|
|
296
|
+
Otto.structured_log(:error, 'Error in server_error fallback',
|
|
297
|
+
base_context.merge(
|
|
298
|
+
error: e.message,
|
|
299
|
+
error_class: e.class.name,
|
|
300
|
+
error_id: fallback_error_id,
|
|
301
|
+
original_error_id: error_id
|
|
302
|
+
))
|
|
303
|
+
Otto::LoggingHelpers.log_backtrace(e,
|
|
304
|
+
base_context.merge(error_id: fallback_error_id, original_error_id: error_id))
|
|
305
|
+
|
|
306
|
+
secure_error_response(error_id)
|
|
307
|
+
end
|
|
308
|
+
|
|
271
309
|
def secure_error_response(error_id)
|
|
272
310
|
body = if Otto.env?(:dev, :development)
|
|
273
311
|
"Server error (ID: #{error_id}). Check logs for details."
|
|
@@ -36,16 +36,32 @@ class Otto
|
|
|
36
36
|
# callers never have to re-run realpath (one resolution per request).
|
|
37
37
|
StaticFile = Struct.new(:root, :path, :relative)
|
|
38
38
|
|
|
39
|
-
# Resolve a request path to a canonical, contained, servable file
|
|
39
|
+
# Resolve a request path to a canonical, contained, servable file under
|
|
40
|
+
# the implicit +public:+ directory.
|
|
40
41
|
#
|
|
41
42
|
# @param path [String, nil] request-relative path (may start with '/')
|
|
42
43
|
# @return [StaticFile, nil] the validated file, or nil when unsafe
|
|
43
44
|
def resolve_static_file(path)
|
|
44
45
|
return nil if option[:public].nil? || option[:public].empty?
|
|
45
|
-
return nil if path.nil? || path.empty?
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
resolve_file_under(canonical_public_dir, path)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolve +path+ against an already-canonical +root+ and return it only
|
|
51
|
+
# when it is a contained, readable, owned regular file.
|
|
52
|
+
#
|
|
53
|
+
# Shared by the implicit public directory and explicit static mounts
|
|
54
|
+
# (Otto::Core::StaticMounts) so both apply one containment policy.
|
|
55
|
+
# +root+ must be a File.realpath result: containment compares canonical
|
|
56
|
+
# strings on a separator boundary, so a non-canonical root would never
|
|
57
|
+
# match the canonicalized candidate.
|
|
58
|
+
#
|
|
59
|
+
# @param root [String, nil] canonical directory
|
|
60
|
+
# @param path [String, nil] root-relative path (may start with '/')
|
|
61
|
+
# @return [StaticFile, nil] the validated file, or nil when unsafe
|
|
62
|
+
def resolve_file_under(root, path)
|
|
63
|
+
return nil if root.nil? || root.empty?
|
|
64
|
+
return nil if path.nil? || path.empty?
|
|
49
65
|
|
|
50
66
|
# A NUL byte in a request path is never legitimate; it is a truncation
|
|
51
67
|
# attack on downstream C string handling. Reject it rather than
|
|
@@ -57,18 +73,18 @@ class Otto
|
|
|
57
73
|
|
|
58
74
|
# Join, then canonicalize: realpath resolves '..', '.' AND every
|
|
59
75
|
# symlink component, so the containment check below cannot be fooled
|
|
60
|
-
# by a link that points outside the
|
|
61
|
-
candidate = File.join(
|
|
76
|
+
# by a link that points outside the root.
|
|
77
|
+
candidate = File.join(root, clean_path)
|
|
62
78
|
real_path = safe_realpath(candidate)
|
|
63
79
|
return nil if real_path.nil?
|
|
64
80
|
|
|
65
|
-
return nil unless contained?(real_path,
|
|
81
|
+
return nil unless contained?(real_path, root)
|
|
66
82
|
|
|
67
83
|
# Second gate: it must be a readable regular file we (or our group) own.
|
|
68
84
|
return nil unless File.file?(real_path) && File.readable?(real_path)
|
|
69
85
|
return nil unless File.owned?(real_path) || File.grpowned?(real_path)
|
|
70
86
|
|
|
71
|
-
StaticFile.new(
|
|
87
|
+
StaticFile.new(root, real_path, real_path.delete_prefix(root + File::SEPARATOR))
|
|
72
88
|
end
|
|
73
89
|
|
|
74
90
|
def safe_file?(path)
|
data/lib/otto/core/router.rb
CHANGED
|
@@ -142,9 +142,12 @@ class Otto
|
|
|
142
142
|
|
|
143
143
|
static_candidate = !static_route.nil? && http_verb == :GET
|
|
144
144
|
|
|
145
|
-
# Dispatch precedence is fixed: literal routes, then static
|
|
146
|
-
#
|
|
147
|
-
#
|
|
145
|
+
# Dispatch precedence is fixed: literal routes, then explicit static
|
|
146
|
+
# mounts (longest prefix first), then the implicit public directory,
|
|
147
|
+
# then dynamic routes. Every static-file request passes through
|
|
148
|
+
# containment validation before it is served (issues #257, #260 and
|
|
149
|
+
# #267). A mount or the public directory claims files, not paths: when
|
|
150
|
+
# the file is absent the request falls through to the next stage.
|
|
148
151
|
if literal_routes.has_key?(path_info_clean)
|
|
149
152
|
route = literal_routes[path_info_clean]
|
|
150
153
|
Otto.structured_log(:debug, 'Route matched',
|
|
@@ -158,6 +161,13 @@ class Otto
|
|
|
158
161
|
@route_matched_callbacks.each { |cb| cb.call(env, route.route_definition) }
|
|
159
162
|
end
|
|
160
163
|
route.call(env)
|
|
164
|
+
elsif http_verb == :GET && (mounted = resolve_mounted_file(dispatch_path))
|
|
165
|
+
mount, static_file = mounted
|
|
166
|
+
Otto.structured_log(:debug, 'Route matched',
|
|
167
|
+
Otto::LoggingHelpers.request_context(env).merge(
|
|
168
|
+
type: 'static_mount', prefix: mount.display_prefix
|
|
169
|
+
))
|
|
170
|
+
serve_static_file(env, static_file, mount.files)
|
|
161
171
|
elsif static_candidate && (static_file = resolve_static_file(dispatch_path))
|
|
162
172
|
Otto.structured_log(:debug, 'Route matched',
|
|
163
173
|
Otto::LoggingHelpers.request_context(env).merge(type: 'static'))
|
|
@@ -201,12 +211,16 @@ class Otto
|
|
|
201
211
|
# Rack::Files could still redirect the open. Closing that requires an
|
|
202
212
|
# O_NOFOLLOW-per-component or fd-based serve, i.e. replacing
|
|
203
213
|
# Rack::Files. Accepted for now; see issue #257.
|
|
204
|
-
|
|
214
|
+
#
|
|
215
|
+
# +files+ is the Rack::Files instance rooted at +static_file.root+: a
|
|
216
|
+
# mount's own frozen instance, or (by default) the public-directory one
|
|
217
|
+
# that #static_route_for keeps in step with the current root.
|
|
218
|
+
def serve_static_file(env, static_file, files = static_route_for(static_file.root))
|
|
205
219
|
static_env = env.dup
|
|
206
220
|
# Rack::Files unescapes PATH_INFO, so escape the canonical path to
|
|
207
221
|
# survive the round trip (escape_path preserves '/').
|
|
208
222
|
static_env['PATH_INFO'] = "/#{Rack::Utils.escape_path(static_file.relative)}"
|
|
209
|
-
|
|
223
|
+
files.call(static_env)
|
|
210
224
|
end
|
|
211
225
|
|
|
212
226
|
# Rack::Files rooted at the root +static_file+ was validated against.
|
|
@@ -287,10 +301,29 @@ class Otto
|
|
|
287
301
|
Otto::LoggingHelpers.request_context(env).merge(
|
|
288
302
|
fallback_to: 'default_not_found'
|
|
289
303
|
))
|
|
290
|
-
|
|
304
|
+
not_found_response(env)
|
|
291
305
|
end
|
|
292
306
|
end
|
|
293
307
|
|
|
308
|
+
# Build the response for a request that matched no route and has no
|
|
309
|
+
# +/404+ route configured.
|
|
310
|
+
#
|
|
311
|
+
# A configured +not_found+ callable is invoked with +env+ on every miss.
|
|
312
|
+
# A configured static triple is copied per request (see
|
|
313
|
+
# {Otto::Static.copy_response}) so header writes by cookie middleware
|
|
314
|
+
# cannot accumulate on, or leak between requests through, the shared
|
|
315
|
+
# object. With nothing configured the built-in {Otto::Static.not_found}
|
|
316
|
+
# response is used.
|
|
317
|
+
#
|
|
318
|
+
# @param env [Hash] Rack environment
|
|
319
|
+
# @return [Array] a fresh Rack triple
|
|
320
|
+
def not_found_response(env)
|
|
321
|
+
fallback = @not_found
|
|
322
|
+
return Otto::Static.not_found if fallback.nil?
|
|
323
|
+
|
|
324
|
+
resolve_fallback_response(:not_found, fallback, env)
|
|
325
|
+
end
|
|
326
|
+
|
|
294
327
|
def build_route_params(route, values)
|
|
295
328
|
if route.keys.any?
|
|
296
329
|
route.keys.zip(values).each_with_object({}) do |(k, v), hash|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# lib/otto/core/static_mounts.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'rack/files'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Core
|
|
9
|
+
# Explicit static-file registration (issue #267).
|
|
10
|
+
#
|
|
11
|
+
# A static mount binds a URL prefix to one directory on disk:
|
|
12
|
+
#
|
|
13
|
+
# otto.mount_static('/assets', root: 'public/assets')
|
|
14
|
+
#
|
|
15
|
+
# Requests for GET /assets/<rest> are then resolved against
|
|
16
|
+
# public/assets/<rest> using the same containment policy as the implicit
|
|
17
|
+
# +public:+ directory (Otto::Core::FileSafety): the candidate is
|
|
18
|
+
# canonicalized with File.realpath and must land inside the mount's
|
|
19
|
+
# canonical root, be a regular readable file, and be owned by the process
|
|
20
|
+
# user or group. A mount never authorizes anything outside its own root,
|
|
21
|
+
# so several mounts can point into unrelated directories without exposing
|
|
22
|
+
# their parents or siblings.
|
|
23
|
+
#
|
|
24
|
+
# Registration is a boot-time operation. The root is canonicalized once,
|
|
25
|
+
# when the mount is registered, and every failure mode (missing,
|
|
26
|
+
# unreadable, not a directory, escaping symlink, malformed prefix,
|
|
27
|
+
# duplicate prefix) raises ArgumentError immediately so a misconfigured
|
|
28
|
+
# application does not start. Mounts participate in configuration
|
|
29
|
+
# freezing: mount_static raises FrozenError after freeze_configuration!,
|
|
30
|
+
# and the mount table is an immutable, sorted snapshot that dispatch reads
|
|
31
|
+
# without any per-request mutation.
|
|
32
|
+
#
|
|
33
|
+
# Dispatch precedence is fixed: literal routes, then static mounts (longest
|
|
34
|
+
# prefix first), then the implicit +public:+ directory, then dynamic
|
|
35
|
+
# routes. A mount claims files, not the prefix: when no mount root
|
|
36
|
+
# contains the requested file the request falls through to the next
|
|
37
|
+
# stage exactly as an unregistered path would. See Otto::Core::Router.
|
|
38
|
+
module StaticMounts
|
|
39
|
+
# One registered mount. +prefix+ is the normalized URL prefix ('' for a
|
|
40
|
+
# root mount), +root+ the canonical directory, +files+ the Rack::Files
|
|
41
|
+
# instance rooted there. Instances are frozen at construction.
|
|
42
|
+
StaticMount = Struct.new(:prefix, :root, :files) do
|
|
43
|
+
# Request-relative path under this mount, or nil when +path+ is not
|
|
44
|
+
# beneath the prefix. The prefix itself (the directory) never matches:
|
|
45
|
+
# a mount serves files, not directory listings.
|
|
46
|
+
#
|
|
47
|
+
# @param path [String] normalized dispatch path (leading '/')
|
|
48
|
+
# @return [String, nil]
|
|
49
|
+
def relative_path_for(path)
|
|
50
|
+
if prefix.empty?
|
|
51
|
+
path
|
|
52
|
+
elsif path.start_with?("#{prefix}/")
|
|
53
|
+
path[(prefix.length + 1)..]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Prefix as an operator would write it ('/' for a root mount).
|
|
58
|
+
def display_prefix
|
|
59
|
+
prefix.empty? ? '/' : prefix
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Registered mounts, longest prefix first. Frozen snapshot; a new array
|
|
64
|
+
# replaces it on every registration so readers never observe a partial
|
|
65
|
+
# update.
|
|
66
|
+
#
|
|
67
|
+
# @return [Array<StaticMount>]
|
|
68
|
+
def static_mounts
|
|
69
|
+
@static_mounts
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Serve the files under +root+ at URLs beneath +prefix+.
|
|
73
|
+
#
|
|
74
|
+
# @param prefix [String] URL prefix starting with '/'. A trailing slash
|
|
75
|
+
# is ignored; '/' mounts the root at the top level.
|
|
76
|
+
# @param root [String] directory path; relative paths resolve against
|
|
77
|
+
# the process working directory and are canonicalized immediately.
|
|
78
|
+
# @return [StaticMount] the registered mount
|
|
79
|
+
# @raise [ArgumentError] on a malformed prefix, a duplicate prefix, or a
|
|
80
|
+
# root that is missing, unreadable, not a directory, not owned by the
|
|
81
|
+
# process user or group, or that cannot be canonicalized.
|
|
82
|
+
# @raise [FrozenError] after configuration freezing
|
|
83
|
+
def mount_static(prefix, root:)
|
|
84
|
+
ensure_not_frozen!
|
|
85
|
+
|
|
86
|
+
clean_prefix = normalize_mount_prefix(prefix)
|
|
87
|
+
if @static_mounts.any? { |mount| mount.prefix == clean_prefix }
|
|
88
|
+
raise ArgumentError,
|
|
89
|
+
"Static mount prefix #{display_mount_prefix(clean_prefix).inspect} is already registered"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
canonical_root = canonicalize_mount_root(clean_prefix, root)
|
|
93
|
+
mount = StaticMount.new(clean_prefix, canonical_root, Rack::Files.new(canonical_root).freeze).freeze
|
|
94
|
+
|
|
95
|
+
# Longest prefix first so an overlay ('/assets/vendor') is consulted
|
|
96
|
+
# before the mount that contains it ('/assets'). Ties cannot happen:
|
|
97
|
+
# prefixes are unique. Rebuild rather than mutate so in-flight readers
|
|
98
|
+
# keep their snapshot.
|
|
99
|
+
@static_mounts = (@static_mounts + [mount]).sort_by { |m| -m.prefix.length }.freeze
|
|
100
|
+
|
|
101
|
+
Otto.structured_log(:debug, 'Static mount registered',
|
|
102
|
+
{ prefix: mount.display_prefix, root: mount.root })
|
|
103
|
+
mount
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Resolve +path+ through the registered mounts, longest prefix first.
|
|
109
|
+
# Read-only: safe to call from concurrent request threads.
|
|
110
|
+
#
|
|
111
|
+
# @param path [String] normalized dispatch path (leading '/')
|
|
112
|
+
# @return [Array(StaticMount, Otto::Core::FileSafety::StaticFile), nil]
|
|
113
|
+
def resolve_mounted_file(path)
|
|
114
|
+
@static_mounts.each do |mount|
|
|
115
|
+
relative = mount.relative_path_for(path)
|
|
116
|
+
next if relative.nil?
|
|
117
|
+
|
|
118
|
+
static_file = resolve_file_under(mount.root, relative)
|
|
119
|
+
return [mount, static_file] if static_file
|
|
120
|
+
end
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Validate and normalize a mount prefix. Returns '' for the root mount
|
|
125
|
+
# and a leading-slash, no-trailing-slash prefix otherwise, matching the
|
|
126
|
+
# normalized request path the router compares against.
|
|
127
|
+
def normalize_mount_prefix(prefix)
|
|
128
|
+
raise ArgumentError, "Static mount prefix must be a String, got #{prefix.class}" unless prefix.is_a?(String)
|
|
129
|
+
raise ArgumentError, "Static mount prefix #{prefix.inspect} contains a NUL byte" if prefix.include?("\0")
|
|
130
|
+
raise ArgumentError, "Static mount prefix #{prefix.inspect} must start with '/'" unless prefix.start_with?('/')
|
|
131
|
+
|
|
132
|
+
clean = prefix.sub(%r{/+\z}, '')
|
|
133
|
+
return '' if clean.empty?
|
|
134
|
+
|
|
135
|
+
segments = clean.split('/', -1).drop(1)
|
|
136
|
+
if segments.any? { |segment| segment.empty? || segment == '.' || segment == '..' }
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"Static mount prefix #{prefix.inspect} must not contain empty, '.', or '..' segments"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
clean.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Canonicalize a mount root under Otto's static-file safety policy and
|
|
145
|
+
# fail loudly on anything that could not be served safely.
|
|
146
|
+
def canonicalize_mount_root(prefix, root)
|
|
147
|
+
label = "Static mount #{display_mount_prefix(prefix).inspect}"
|
|
148
|
+
raise ArgumentError, "#{label} root must be a String, got #{root.class}" unless root.is_a?(String)
|
|
149
|
+
raise ArgumentError, "#{label} root must not be empty" if root.strip.empty?
|
|
150
|
+
raise ArgumentError, "#{label} root #{root.inspect} contains a NUL byte" if root.include?("\0")
|
|
151
|
+
|
|
152
|
+
real = safe_realpath(File.expand_path(root))
|
|
153
|
+
if real.nil?
|
|
154
|
+
raise ArgumentError,
|
|
155
|
+
"#{label} root #{root.inspect} cannot be resolved " \
|
|
156
|
+
'(missing, unreadable component, or symlink loop)'
|
|
157
|
+
end
|
|
158
|
+
raise ArgumentError, "#{label} root #{root.inspect} is not a directory" unless File.directory?(real)
|
|
159
|
+
raise ArgumentError, "#{label} root #{root.inspect} is not readable" unless File.readable?(real)
|
|
160
|
+
|
|
161
|
+
owned = File.owned?(real) || File.grpowned?(real)
|
|
162
|
+
raise ArgumentError, "#{label} root #{root.inspect} is not owned by the process user or group" unless owned
|
|
163
|
+
|
|
164
|
+
real.freeze
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def display_mount_prefix(prefix)
|
|
168
|
+
prefix.empty? ? '/' : prefix
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
data/lib/otto/core.rb
CHANGED
data/lib/otto/static.rb
CHANGED
|
@@ -15,6 +15,44 @@ class Otto
|
|
|
15
15
|
[404, security_headers.merge({ 'content-type' => 'text/plain' }), ['Not Found']]
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# Return a per-request copy of a Rack triple so callers can never hand a
|
|
19
|
+
# shared object back to the Rack stack.
|
|
20
|
+
#
|
|
21
|
+
# Middleware above Otto (rack-session, Otto's own CSRF middleware, anything
|
|
22
|
+
# that calls +Rack::Utils.set_cookie_header!+) writes response headers in
|
|
23
|
+
# place. Returning a configured triple by reference lets those writes
|
|
24
|
+
# accumulate on the shared object for the life of the process, so every
|
|
25
|
+
# subsequent 404/500 replays every Set-Cookie any earlier one committed.
|
|
26
|
+
#
|
|
27
|
+
# The copy is intentionally shallow-plus-one: the headers container keeps
|
|
28
|
+
# its class (a +Rack::Headers+ stays case-insensitive), each Array-valued
|
|
29
|
+
# header (Rack 3's representation of a repeated header) is copied so an
|
|
30
|
+
# append cannot reach the shared Array, and an Array body is copied so a
|
|
31
|
+
# middleware appending chunks cannot grow the shared body. A frozen
|
|
32
|
+
# configured triple yields an unfrozen copy, so cookie middleware works
|
|
33
|
+
# after configuration freezing as well.
|
|
34
|
+
#
|
|
35
|
+
# @param response [Array] a Rack triple +[status, headers, body]+
|
|
36
|
+
# @return [Array] a new triple that shares no mutable container with +response+
|
|
37
|
+
def copy_response(response)
|
|
38
|
+
status, headers, body = response
|
|
39
|
+
[status, copy_headers(headers), body.is_a?(Array) ? body.dup : body]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Copy a Rack headers container, keeping its class and copying Array values.
|
|
43
|
+
#
|
|
44
|
+
# @param headers [Hash, Rack::Headers, nil] the headers to copy
|
|
45
|
+
# @return [Hash, Rack::Headers] a new container of the same class
|
|
46
|
+
def copy_headers(headers)
|
|
47
|
+
return {} if headers.nil?
|
|
48
|
+
|
|
49
|
+
copied = headers.dup
|
|
50
|
+
copied.each_pair do |key, value|
|
|
51
|
+
copied[key] = value.dup if value.is_a?(Array)
|
|
52
|
+
end
|
|
53
|
+
copied
|
|
54
|
+
end
|
|
55
|
+
|
|
18
56
|
def security_headers
|
|
19
57
|
{
|
|
20
58
|
'x-frame-options' => 'DENY',
|
data/lib/otto/version.rb
CHANGED
data/lib/otto.rb
CHANGED
|
@@ -55,6 +55,7 @@ require_relative 'otto/logging_helpers'
|
|
|
55
55
|
class Otto
|
|
56
56
|
include Otto::Core::Router
|
|
57
57
|
include Otto::Core::FileSafety
|
|
58
|
+
include Otto::Core::StaticMounts
|
|
58
59
|
include Otto::Core::Configuration
|
|
59
60
|
include Otto::Core::ErrorHandler
|
|
60
61
|
include Otto::Core::UriGenerator
|
|
@@ -68,6 +69,10 @@ class Otto
|
|
|
68
69
|
|
|
69
70
|
LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
|
|
70
71
|
|
|
72
|
+
# Parameter types (from Proc#parameters / Method#parameters) that consume
|
|
73
|
+
# one positional argument each. See {#fallback_call_args}.
|
|
74
|
+
POSITIONAL_PARAMETER_TYPES = %i[req opt].freeze
|
|
75
|
+
|
|
71
76
|
@debug = case ENV.fetch('OTTO_DEBUG', nil)
|
|
72
77
|
in 'true' | '1' | 'yes' | 'on'
|
|
73
78
|
true
|
|
@@ -80,8 +85,54 @@ class Otto
|
|
|
80
85
|
:routes_by_definition, :option,
|
|
81
86
|
:static_route, :security_config, :locale_config, :auth_config,
|
|
82
87
|
:route_handler_factory, :mcp_server, :caddy_tls_server, :security, :middleware,
|
|
83
|
-
:error_handlers, :request_class, :response_class
|
|
84
|
-
|
|
88
|
+
:error_handlers, :request_class, :response_class,
|
|
89
|
+
:not_found, :server_error
|
|
90
|
+
|
|
91
|
+
# Configure the response returned when no route (and no +/404+ route) matches.
|
|
92
|
+
#
|
|
93
|
+
# Accepts either a Rack triple +[status, headers, body]+ (the body must
|
|
94
|
+
# respond to +each+, or +call+ for a streaming body) or anything that
|
|
95
|
+
# responds to +call(env)+ and returns one. A static triple is never handed
|
|
96
|
+
# back to the Rack stack by reference: Otto returns a per-request copy whose
|
|
97
|
+
# headers (and Array-valued header entries, and Array body) are fresh
|
|
98
|
+
# containers, so cookie middleware such as rack-session or Otto's own CSRF
|
|
99
|
+
# middleware cannot accumulate +Set-Cookie+ values on the configured object
|
|
100
|
+
# and replay them to later clients. Prefer the callable form when the
|
|
101
|
+
# response should vary per request.
|
|
102
|
+
#
|
|
103
|
+
# @param response [Array, #call, nil] a Rack triple, a callable, or nil to
|
|
104
|
+
# restore the built-in {Otto::Static.not_found} response
|
|
105
|
+
# @raise [ArgumentError] when +response+ is neither a Rack triple nor callable
|
|
106
|
+
#
|
|
107
|
+
# @example Static triple (copied per request)
|
|
108
|
+
# otto.not_found = [404, { 'content-type' => 'application/json' }, ['{"error":"Not Found"}']]
|
|
109
|
+
#
|
|
110
|
+
# @example Callable, built fresh on every miss
|
|
111
|
+
# otto.not_found = ->(env) { [404, { 'content-type' => 'text/plain' }, ["No #{env['PATH_INFO']}"]] }
|
|
112
|
+
def not_found=(response)
|
|
113
|
+
@not_found = validate_fallback_response!(:not_found, response)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Configure the response returned for an unhandled error when no +/500+
|
|
117
|
+
# route is configured and the client does not prefer JSON.
|
|
118
|
+
#
|
|
119
|
+
# Accepts either a Rack triple or anything that responds to +call(env, error)+
|
|
120
|
+
# and returns one; a callable declaring a single positional parameter (or
|
|
121
|
+
# none) receives only what it declares. +env['otto.error_id']+ carries the
|
|
122
|
+
# logged correlation id. As with {#not_found=}, a static triple is copied
|
|
123
|
+
# per request so header writes by cookie middleware never touch the
|
|
124
|
+
# configured object. A callable that raises is logged and replaced by the
|
|
125
|
+
# built-in secure error response.
|
|
126
|
+
#
|
|
127
|
+
# @param response [Array, #call, nil] a Rack triple, a callable, or nil to
|
|
128
|
+
# restore the built-in secure error response
|
|
129
|
+
# @raise [ArgumentError] when +response+ is neither a Rack triple nor callable
|
|
130
|
+
#
|
|
131
|
+
# @example Callable receiving the error
|
|
132
|
+
# otto.server_error = ->(env, error) { [500, { 'content-type' => 'text/plain' }, ['Oops']] }
|
|
133
|
+
def server_error=(response)
|
|
134
|
+
@server_error = validate_fallback_response!(:server_error, response)
|
|
135
|
+
end
|
|
85
136
|
|
|
86
137
|
def initialize(path = nil, opts = {})
|
|
87
138
|
constructed = false
|
|
@@ -188,6 +239,10 @@ class Otto
|
|
|
188
239
|
# so reverse lookups (Otto#uri) consult this index instead of the
|
|
189
240
|
# single-route @route_definitions entry (issue #190).
|
|
190
241
|
@routes_by_definition = {}
|
|
242
|
+
# Explicit static mounts (Core::StaticMounts#mount_static). Always a
|
|
243
|
+
# frozen snapshot, replaced wholesale on registration; dispatch reads it
|
|
244
|
+
# without locking.
|
|
245
|
+
@static_mounts = [].freeze
|
|
191
246
|
@security_config = Otto::Security::Config.new
|
|
192
247
|
@middleware = Otto::Core::MiddlewareStack.new
|
|
193
248
|
# Initialize @auth_config first so it can be shared with the configurator
|
|
@@ -301,6 +356,70 @@ class Otto
|
|
|
301
356
|
end
|
|
302
357
|
end
|
|
303
358
|
|
|
359
|
+
# Validate a value assigned to {#not_found=} or {#server_error=}.
|
|
360
|
+
#
|
|
361
|
+
# @param name [Symbol] the setting name, for the error message
|
|
362
|
+
# @param response [Object] the assigned value
|
|
363
|
+
# @return [Array, #call, nil] the value, when acceptable
|
|
364
|
+
# @raise [ArgumentError] otherwise
|
|
365
|
+
def validate_fallback_response!(name, response)
|
|
366
|
+
return response if response.nil? || response.respond_to?(:call)
|
|
367
|
+
return response if rack_triple?(response)
|
|
368
|
+
|
|
369
|
+
raise ArgumentError,
|
|
370
|
+
"#{name} must be a Rack triple [status, headers, body] or respond to #call, got #{response.inspect}"
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# A Rack triple: an Integer-like status, Hash-like headers, and a body that
|
|
374
|
+
# responds to +each+ (or +call+, for a streaming body).
|
|
375
|
+
def rack_triple?(response)
|
|
376
|
+
return false unless response.is_a?(Array) && response.length == 3
|
|
377
|
+
|
|
378
|
+
status, headers, body = response
|
|
379
|
+
status.respond_to?(:to_int) && headers.respond_to?(:each_pair) &&
|
|
380
|
+
(body.respond_to?(:each) || body.respond_to?(:call))
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Resolve a configured fallback into a fresh Rack triple for one request.
|
|
384
|
+
#
|
|
385
|
+
# A callable is invoked with as many of +args+ as it accepts (see
|
|
386
|
+
# {#fallback_call_args}); a static triple is used as-is. Either way the
|
|
387
|
+
# result is copied (see {Otto::Static.copy_response}) so the Rack stack
|
|
388
|
+
# never receives a container shared with the configuration or with another
|
|
389
|
+
# request.
|
|
390
|
+
#
|
|
391
|
+
# @param name [Symbol] the setting name, for the error message
|
|
392
|
+
# @param fallback [Array, #call] the configured value
|
|
393
|
+
# @param args [Array] positional arguments offered to a callable fallback
|
|
394
|
+
# @return [Array] a new Rack triple
|
|
395
|
+
# @raise [TypeError] when a callable returns something other than a Rack triple
|
|
396
|
+
def resolve_fallback_response(name, fallback, *args)
|
|
397
|
+
response = fallback.respond_to?(:call) ? fallback.call(*fallback_call_args(fallback, args)) : fallback
|
|
398
|
+
unless rack_triple?(response)
|
|
399
|
+
raise TypeError,
|
|
400
|
+
"#{name} callable must return a Rack triple [status, headers, body], got #{response.inspect}"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
Otto::Static.copy_response(response)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Trim +args+ to the positional parameters +callable+ declares, so a lambda
|
|
407
|
+
# or Method that takes fewer (or optional) parameters is never handed an
|
|
408
|
+
# argument it would reject. A splat parameter receives everything. Arity
|
|
409
|
+
# alone cannot express this: +->(env = nil) {}+ has arity -1, the same as
|
|
410
|
+
# +proc { |*a| }+, yet accepts at most one argument.
|
|
411
|
+
#
|
|
412
|
+
# @param callable [#call]
|
|
413
|
+
# @param args [Array] the arguments on offer, in order
|
|
414
|
+
# @return [Array] the leading subset of +args+ the callable accepts
|
|
415
|
+
def fallback_call_args(callable, args)
|
|
416
|
+
params = callable.respond_to?(:parameters) ? callable.parameters : callable.method(:call).parameters
|
|
417
|
+
return args if params.any? { |type, _name| type == :rest }
|
|
418
|
+
|
|
419
|
+
args.first(params.count { |type, _name| POSITIONAL_PARAMETER_TYPES.include?(type) })
|
|
420
|
+
end
|
|
421
|
+
private :validate_fallback_response!, :rack_triple?, :resolve_fallback_response, :fallback_call_args
|
|
422
|
+
|
|
304
423
|
# Class methods for Otto framework providing singleton access and configuration
|
|
305
424
|
module ClassMethods
|
|
306
425
|
def default
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: otto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Delano Mandelbaum
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|
|
@@ -223,6 +223,7 @@ files:
|
|
|
223
223
|
- lib/otto/core/middleware_management.rb
|
|
224
224
|
- lib/otto/core/middleware_stack.rb
|
|
225
225
|
- lib/otto/core/router.rb
|
|
226
|
+
- lib/otto/core/static_mounts.rb
|
|
226
227
|
- lib/otto/core/uri_generator.rb
|
|
227
228
|
- lib/otto/design_system.rb
|
|
228
229
|
- lib/otto/env_keys.rb
|