tina4ruby 3.13.93 → 3.13.96
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 +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +35 -43
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +458 -48
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dev_mailbox.rb +5 -1
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +25 -3
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/frond.rb +102 -10
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +317 -82
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +243 -29
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +301 -38
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
# The dispatch pipeline: the concerns of RackApp#call, named and ordered.
|
|
5
|
+
#
|
|
6
|
+
# #call was one 260-line function at cyclomatic complexity 53 against a
|
|
7
|
+
# ceiling of 10, on the path of every single request. Splitting it into a
|
|
8
|
+
# module is not only about that number: rack_app.rb also holds static file
|
|
9
|
+
# serving, the swagger UI, WebSocket upgrades and the error pages, so the
|
|
10
|
+
# pipeline was interleaved with four unrelated subsystems in one 999-line
|
|
11
|
+
# file. This is the pipeline, on its own, readable end to end.
|
|
12
|
+
#
|
|
13
|
+
# Mixed into RackApp, so a stage can still call the app's helpers
|
|
14
|
+
# (#handle_route, #try_static, #dev_mode?) directly.
|
|
15
|
+
module DispatchPipeline
|
|
16
|
+
# ── The stages ────────────────────────────────────────
|
|
17
|
+
#
|
|
18
|
+
# #call was one 260-line function at cyclomatic complexity 53 against a
|
|
19
|
+
# ceiling of 10, and it is on the path of every single request. The stages
|
|
20
|
+
# below are that function's concerns, named and ordered as DATA so the
|
|
21
|
+
# pipeline can be read, tested and compared across the four frameworks
|
|
22
|
+
# without reading an implementation.
|
|
23
|
+
#
|
|
24
|
+
# Two phases, because the function genuinely has two:
|
|
25
|
+
#
|
|
26
|
+
# REQUEST_STAGES run in order until one RETURNS a Rack triple. That
|
|
27
|
+
# triple is the response; later request stages do not
|
|
28
|
+
# run. `match_route` is terminal - it always produces
|
|
29
|
+
# one.
|
|
30
|
+
# ALWAYS_STAGES run over the triple no matter how it was produced -
|
|
31
|
+
# including the swagger and static branches, which
|
|
32
|
+
# return early and skip everything else.
|
|
33
|
+
# RESPONSE_STAGES run in order over the triple, each returning a new
|
|
34
|
+
# triple or nil to leave it unchanged. These are the
|
|
35
|
+
# post-processing steps (logging, injection, session
|
|
36
|
+
# save) that only apply to a dispatched response.
|
|
37
|
+
#
|
|
38
|
+
# Contract each stage obeys, asserted by spec/dispatch_pipeline_spec.rb:
|
|
39
|
+
# * it takes (ctx) or (ctx, response) and nothing else - no stage reads a
|
|
40
|
+
# local of #call, because there are none left to read
|
|
41
|
+
# * it never calls another stage directly; ordering lives in these lists.
|
|
42
|
+
# `method_not_allowed` and `not_found` are therefore STAGES, not
|
|
43
|
+
# helpers called by `match_route` - the fallback chain is expressed as
|
|
44
|
+
# list order like everything else
|
|
45
|
+
# * a request stage returning nil means "not mine, keep going"
|
|
46
|
+
#
|
|
47
|
+
# Ordering is BEHAVIOUR here, not taste: dev routes must beat route
|
|
48
|
+
# matching, the pre-match middleware must run before the match so its
|
|
49
|
+
# headers survive a 401 (ADR-0012), and static resolution happens inside
|
|
50
|
+
# `match_route`'s not-found fallback because routes beat files (ADR-0010).
|
|
51
|
+
REQUEST_STAGES = %i[
|
|
52
|
+
reset_request_caches
|
|
53
|
+
cors_preflight
|
|
54
|
+
websocket_upgrade
|
|
55
|
+
dev_routes
|
|
56
|
+
feedback_routes
|
|
57
|
+
global_middleware_pre
|
|
58
|
+
match_route
|
|
59
|
+
method_not_allowed
|
|
60
|
+
not_found
|
|
61
|
+
].freeze
|
|
62
|
+
|
|
63
|
+
# Runs on EVERY response, including the ones that bypass the rest.
|
|
64
|
+
#
|
|
65
|
+
# RFC 9110 s9.3.2 is not conditional: a HEAD response MUST NOT carry
|
|
66
|
+
# content, whatever produced it. This used to live in RESPONSE_STAGES, so
|
|
67
|
+
# the swagger and static branches - which return early - skipped it, and
|
|
68
|
+
# `HEAD /style.css` shipped the whole file body. Measured 2026-07-31: Ruby
|
|
69
|
+
# returned 15 bytes where PHP, Python and Node all returned 0.
|
|
70
|
+
ALWAYS_STAGES = %i[
|
|
71
|
+
head_strip
|
|
72
|
+
apply_cors
|
|
73
|
+
].freeze
|
|
74
|
+
|
|
75
|
+
RESPONSE_STAGES = %i[
|
|
76
|
+
dev_inspector_capture
|
|
77
|
+
request_log
|
|
78
|
+
dev_toolbar_inject
|
|
79
|
+
feedback_inject
|
|
80
|
+
session_save
|
|
81
|
+
].freeze
|
|
82
|
+
|
|
83
|
+
# ── The route pipeline ───────────────────────────────────────────
|
|
84
|
+
#
|
|
85
|
+
# #handle_route was cyclomatic complexity 24 in one 118-line function, with
|
|
86
|
+
# the same two-phase shape as #call. These stages run in order until one
|
|
87
|
+
# returns a Rack triple (a 401/403 or a short-circuiting middleware); if
|
|
88
|
+
# none does, the handler is invoked and the result finalised.
|
|
89
|
+
#
|
|
90
|
+
# Ordering is BEHAVIOUR, decided and written down (ADR-0012): the post-match
|
|
91
|
+
# global middleware runs BEFORE the auth gate so a rate limiter can throttle
|
|
92
|
+
# a brute-force login and an access log records the 401, while the route's
|
|
93
|
+
# OWN middleware runs AFTER it, so middleware attached to a secured route
|
|
94
|
+
# never processes an unauthenticated request.
|
|
95
|
+
ROUTE_STAGES = %i[
|
|
96
|
+
prepare_route_request
|
|
97
|
+
global_middleware_post
|
|
98
|
+
route_auth_handler
|
|
99
|
+
route_auth_gate
|
|
100
|
+
route_middleware
|
|
101
|
+
].freeze
|
|
102
|
+
|
|
103
|
+
# Per-route state shared between route stages.
|
|
104
|
+
RouteContext = Struct.new(
|
|
105
|
+
:env, :route, :path_params, :pre_request, :pre_response,
|
|
106
|
+
:request, :response,
|
|
107
|
+
keyword_init: true
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Per-request state shared between stages.
|
|
111
|
+
#
|
|
112
|
+
# This exists so a stage can be called on its own with nothing but a
|
|
113
|
+
# context - the alternative is stages reading each other's locals, which is
|
|
114
|
+
# the coupling the extraction is removing. `bypass_response_stages` records
|
|
115
|
+
# an EXISTING quirk rather than introducing one: the swagger and static
|
|
116
|
+
# branches used to `return` straight out of #call, skipping every
|
|
117
|
+
# post-processing step. See the comment on #match_route.
|
|
118
|
+
DispatchContext = Struct.new(
|
|
119
|
+
:env, :method, :path, :started_at,
|
|
120
|
+
:pre_request, :pre_response, :matched_pattern, :matched,
|
|
121
|
+
:bypass_response_stages,
|
|
122
|
+
keyword_init: true
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# ── REQUEST STAGES ───────────────────────────────────────────────
|
|
128
|
+
# Each returns a Rack triple to answer the request, or nil to pass.
|
|
129
|
+
|
|
130
|
+
# Request-scoped query cache boundary (v3.13.23). Tina4 Ruby runs a
|
|
131
|
+
# long-running Rack server, so the request-scoped DB cache (default-on)
|
|
132
|
+
# would otherwise serve rows from a previous request. Clear it on every
|
|
133
|
+
# live connection at the very start of each request, before any routing.
|
|
134
|
+
# No-op for persistent-mode (TINA4_DB_CACHE=true) connections.
|
|
135
|
+
def reset_request_caches(_ctx)
|
|
136
|
+
Tina4::Database.reset_request_caches if defined?(Tina4::Database)
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Fast-path: CORS preflight. Real CORS preflight requests carry an Origin
|
|
141
|
+
# header AND an Access-Control-Request-Method header - the browser is
|
|
142
|
+
# asking "may I send this method?" before the actual request. If neither is
|
|
143
|
+
# present, the OPTIONS is a plain protocol-introspection request (link
|
|
144
|
+
# checker, monitoring probe, RFC 9110 s9.3.7 OPTIONS) and must fall through
|
|
145
|
+
# to the router's generic Allow-header response. Otherwise we would shadow
|
|
146
|
+
# the framework's own OPTIONS support and force every operator to
|
|
147
|
+
# hand-register CORS exceptions for every introspection client.
|
|
148
|
+
def cors_preflight(ctx)
|
|
149
|
+
return nil unless ctx.method == "OPTIONS"
|
|
150
|
+
return nil unless ctx.env["HTTP_ORIGIN"] || ctx.env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"]
|
|
151
|
+
|
|
152
|
+
# Carry the resource's REAL method set as Allow (RFC 9110 s9.3.7) so a
|
|
153
|
+
# preflight answers the same question a bare OPTIONS does, on top of the
|
|
154
|
+
# CORS policy headers. See ADR-0013.
|
|
155
|
+
Tina4::CorsMiddleware.preflight_response(
|
|
156
|
+
ctx.env, allow: Tina4::Router.methods_allowed_for_path(ctx.path)
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# WebSocket upgrade - match against registered ws_routes.
|
|
161
|
+
def websocket_upgrade(ctx)
|
|
162
|
+
return nil unless websocket_upgrade?(ctx.env)
|
|
163
|
+
|
|
164
|
+
ws_result = Tina4::Router.find_ws_route(ctx.path)
|
|
165
|
+
return nil unless ws_result
|
|
166
|
+
|
|
167
|
+
ws_route, ws_params = ws_result
|
|
168
|
+
handle_websocket_upgrade(ctx.env, ws_route, ws_params)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Dev dashboard routes (handled before anything else).
|
|
172
|
+
def dev_routes(ctx)
|
|
173
|
+
return nil unless ctx.path.start_with?("/__dev")
|
|
174
|
+
|
|
175
|
+
# Block live-reload endpoint on the AI port - AI tools must get stable
|
|
176
|
+
# responses.
|
|
177
|
+
if ctx.path == "/__dev_reload" && ctx.env["tina4.ai_port"]
|
|
178
|
+
return [404, { "content-type" => "text/plain" }, ["Not available on AI port"]]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
Tina4::DevAdmin.handle_request(ctx.env)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Customer feedback widget routes (parity with Python's /__feedback/*
|
|
185
|
+
# surface - see tina4/feedback.rb). Always available - the master switch
|
|
186
|
+
# (TINA4_ENABLE_FEEDBACK) is enforced INSIDE handle_request so route shape
|
|
187
|
+
# stays stable across environments.
|
|
188
|
+
def feedback_routes(ctx)
|
|
189
|
+
return nil unless ctx.path.start_with?("/__feedback")
|
|
190
|
+
|
|
191
|
+
Tina4::Feedback.handle_request(ctx.env)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# PRE-MATCH global middleware. The request/response pair is built HERE,
|
|
195
|
+
# before matching, so CORS and anything else that must survive a
|
|
196
|
+
# short-circuit can set headers that outlive a 401/403 - a browser shown a
|
|
197
|
+
# 401 with no CORS headers reports a CORS error and hides the real status.
|
|
198
|
+
#
|
|
199
|
+
# The SAME pair is threaded into handle_route; building a second one would
|
|
200
|
+
# silently discard whatever the pre-match pass set, which is the entire
|
|
201
|
+
# point of running it. So this stage ALWAYS runs and always populates the
|
|
202
|
+
# context, even when no pre-match middleware is registered.
|
|
203
|
+
def global_middleware_pre(ctx)
|
|
204
|
+
ctx.pre_request = Tina4::Request.new(ctx.env)
|
|
205
|
+
ctx.pre_request.user = ctx.env["tina4.auth_payload"] if ctx.env["tina4.auth_payload"]
|
|
206
|
+
ctx.pre_response = Tina4::Response.new
|
|
207
|
+
|
|
208
|
+
middleware = Tina4::Middleware.pre_match_middleware
|
|
209
|
+
return nil if middleware.empty?
|
|
210
|
+
return nil if Tina4::Middleware.run_before(middleware, ctx.pre_request, ctx.pre_response)
|
|
211
|
+
|
|
212
|
+
Tina4::Middleware.run_after(middleware, ctx.pre_request, ctx.pre_response)
|
|
213
|
+
ctx.pre_response.to_rack
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Match a route and run it. Returns nil when nothing matched, so the
|
|
217
|
+
# next stages (#method_not_allowed, then #not_found) get their turn.
|
|
218
|
+
#
|
|
219
|
+
# ROUTES BEAT FILES (ADR-0010): static assets and the swagger UI are
|
|
220
|
+
# resolved in the not-found fallback, only once no route has claimed the
|
|
221
|
+
# path. A file in public/ can arrive from a build step or a careless
|
|
222
|
+
# deploy, and it must never silently shadow a reviewed route. This also
|
|
223
|
+
# retired the `unless path.start_with?("/api/")` guard that used to wrap
|
|
224
|
+
# the static check - a partial patch for exactly that hazard.
|
|
225
|
+
def match_route(ctx)
|
|
226
|
+
result = Tina4::Router.match(ctx.method, ctx.path)
|
|
227
|
+
ctx.matched = result
|
|
228
|
+
|
|
229
|
+
return nil unless result
|
|
230
|
+
|
|
231
|
+
route, path_params = result
|
|
232
|
+
ctx.matched_pattern = route.path
|
|
233
|
+
handle_route(ctx.env, route, path_params, ctx.pre_request, ctx.pre_response)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# RFC 9110 conformance - before falling through to 404, check whether the
|
|
237
|
+
# PATH is known to the router under any OTHER method.
|
|
238
|
+
# - OPTIONS -> 204 with Allow (s9.3.7). A bare OPTIONS on an unknown
|
|
239
|
+
# path also returns 204 (empty Allow): OPTIONS is a discovery method,
|
|
240
|
+
# and rejecting unknown probes with 404 confuses link checkers and
|
|
241
|
+
# monitoring tools.
|
|
242
|
+
# - Any other method (PUT on GET-only, TRACE, CONNECT) -> 405 with Allow
|
|
243
|
+
# (s15.5.6 + s10.2.1) when the path exists.
|
|
244
|
+
# Returns nil when nothing about the path is known, so #not_found runs.
|
|
245
|
+
def method_not_allowed(ctx)
|
|
246
|
+
allowed = Tina4::Router.methods_allowed_for_path(ctx.path)
|
|
247
|
+
|
|
248
|
+
if ctx.method.to_s.upcase == "OPTIONS"
|
|
249
|
+
allow_header = allowed.empty? ? "" : allowed.join(", ")
|
|
250
|
+
return [204, { "allow" => allow_header, "content-length" => "0" }, [""]]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
return nil if allowed.empty?
|
|
254
|
+
|
|
255
|
+
allow_header = allowed.join(", ")
|
|
256
|
+
body = %({"error":"Method Not Allowed","path":"#{ctx.path}","method":"#{ctx.method}","allow":[#{allowed.map { |m| %("#{m}") }.join(",")}],"status":405})
|
|
257
|
+
[405, {
|
|
258
|
+
"allow" => allow_header,
|
|
259
|
+
"content-type" => "application/json",
|
|
260
|
+
"content-length" => body.bytesize.to_s
|
|
261
|
+
}, [body]]
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# No route claimed the path. NOW try the swagger UI and the filesystem -
|
|
265
|
+
# after matching, per ADR-0010.
|
|
266
|
+
#
|
|
267
|
+
# The swagger and static branches set `bypass_response_stages`, which
|
|
268
|
+
# PRESERVES an existing quirk rather than introducing one: they used to
|
|
269
|
+
# `return` straight out of #call, so they skipped HEAD stripping, the dev
|
|
270
|
+
# toolbar, feedback injection and the session save. That is why a HEAD
|
|
271
|
+
# request for a static file currently returns a body, in violation of RFC
|
|
272
|
+
# 9110 s9.3.2. Recorded as a finding and fixed separately with its own test
|
|
273
|
+
# pair - NOT silently inside this extraction.
|
|
274
|
+
def not_found(ctx)
|
|
275
|
+
if ctx.path == "/swagger" || ctx.path == "/swagger/"
|
|
276
|
+
ctx.bypass_response_stages = true
|
|
277
|
+
return serve_swagger_ui
|
|
278
|
+
elsif ctx.path == "/swagger/openapi.json"
|
|
279
|
+
ctx.bypass_response_stages = true
|
|
280
|
+
return serve_openapi_json
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
static_response = try_static(ctx.path, ctx.env)
|
|
284
|
+
if static_response
|
|
285
|
+
ctx.bypass_response_stages = true
|
|
286
|
+
return static_response
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
handle_404(ctx.path)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# ── RESPONSE STAGES ──────────────────────────────────────────────
|
|
293
|
+
# Each returns a replacement Rack triple, or nil to leave it unchanged.
|
|
294
|
+
|
|
295
|
+
# RFC 9110 s9.3.2: a HEAD response MUST NOT include content. Strip the body
|
|
296
|
+
# unconditionally and record what Content-Length the GET would have sent -
|
|
297
|
+
# cache validators, link checkers and monitoring probes use that header to
|
|
298
|
+
# estimate sizes.
|
|
299
|
+
def head_strip(ctx, response)
|
|
300
|
+
return nil unless ctx.method.to_s.upcase == "HEAD"
|
|
301
|
+
|
|
302
|
+
status, headers, body_parts = response
|
|
303
|
+
joined = body_parts.respond_to?(:join) ? body_parts.join : body_parts.to_s
|
|
304
|
+
return nil if joined.empty?
|
|
305
|
+
|
|
306
|
+
new_headers = headers.dup
|
|
307
|
+
new_headers["content-length"] = joined.bytesize.to_s
|
|
308
|
+
[status, new_headers, [""]]
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Stamp the CORS policy headers on every response.
|
|
312
|
+
#
|
|
313
|
+
# This was the gap that made Ruby's CORS unusable: CorsMiddleware only ever
|
|
314
|
+
# answered the PREFLIGHT (see #cors_preflight), and `apply_headers` - the
|
|
315
|
+
# method that handles the ACTUAL response - was never called from anywhere
|
|
316
|
+
# in the dispatch path. Measured 2026-07-31 through the real Rack app: a
|
|
317
|
+
# preflight came back 204 with Access-Control-Allow-Origin, then the real
|
|
318
|
+
# GET came back 200 with no CORS headers at all, so the browser blocked it.
|
|
319
|
+
# The preflight said "yes you may" and the response did not follow through.
|
|
320
|
+
#
|
|
321
|
+
# It lives in ALWAYS_STAGES, not RESPONSE_STAGES, so the headers survive a
|
|
322
|
+
# short-circuited 401/403 and the swagger/static early-return branches. A
|
|
323
|
+
# browser shown a 401 without CORS headers reports a CORS error and the
|
|
324
|
+
# real status never reaches the developer debugging it (ADR-0012).
|
|
325
|
+
#
|
|
326
|
+
# A preflight already carries them from #cors_preflight, so re-applying
|
|
327
|
+
# would be harmless but wasteful - policy_headers is idempotent either way.
|
|
328
|
+
def apply_cors(ctx, response)
|
|
329
|
+
status, headers, body = response
|
|
330
|
+
new_headers = headers.dup
|
|
331
|
+
Tina4::CorsMiddleware.apply_headers(new_headers, ctx.env)
|
|
332
|
+
return nil if new_headers == headers
|
|
333
|
+
|
|
334
|
+
[status, new_headers, body]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Capture the request for the dev inspector.
|
|
338
|
+
def dev_inspector_capture(ctx, response)
|
|
339
|
+
return nil unless dev_mode? && !ctx.path.start_with?("/__dev")
|
|
340
|
+
|
|
341
|
+
Tina4::DevAdmin.request_inspector.capture(
|
|
342
|
+
method: ctx.method,
|
|
343
|
+
path: ctx.path,
|
|
344
|
+
status: response[0],
|
|
345
|
+
duration: elapsed_ms(ctx)
|
|
346
|
+
)
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Request log line (v3.13.14). The dev inspector only feeds the /__dev UI -
|
|
351
|
+
# it never reached stdout, so `tina4ruby serve` printed the banner then went
|
|
352
|
+
# silent. Emit a per-request line through Tina4::Log so it lands on stdout
|
|
353
|
+
# (docker logs / k8s). On by default in dev, opt-in in production via
|
|
354
|
+
# TINA4_LOG_REQUESTS. Same format across all four frameworks.
|
|
355
|
+
def request_log(ctx, response)
|
|
356
|
+
return nil unless request_logging_enabled? && !ctx.path.start_with?("/__dev")
|
|
357
|
+
|
|
358
|
+
Tina4::Log.info("#{ctx.method} #{ctx.path} -> #{response[0]} (#{elapsed_ms(ctx)}ms)")
|
|
359
|
+
nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Inject the dev overlay button for HTML responses in dev mode.
|
|
363
|
+
def dev_toolbar_inject(ctx, response)
|
|
364
|
+
return nil unless dev_mode? && !ctx.path.start_with?("/__dev")
|
|
365
|
+
|
|
366
|
+
status, headers, body_parts = response
|
|
367
|
+
content_type = headers["content-type"] || ""
|
|
368
|
+
return nil unless content_type.include?("text/html")
|
|
369
|
+
|
|
370
|
+
request_info = {
|
|
371
|
+
method: ctx.method,
|
|
372
|
+
path: ctx.path,
|
|
373
|
+
matched_pattern: ctx.matched_pattern || "(no match)"
|
|
374
|
+
}
|
|
375
|
+
overlay = inject_dev_overlay(body_parts.join, request_info, ai_port: ctx.env["tina4.ai_port"])
|
|
376
|
+
[status, headers, [overlay]]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Customer feedback widget injection - runs LAST of the injectors so its
|
|
380
|
+
# <script> tag survives any earlier post-processing. No-op if disabled
|
|
381
|
+
# (TINA4_ENABLE_FEEDBACK off), the user is not whitelisted, the path is
|
|
382
|
+
# /__dev or /__feedback, or the body is not text/html with a closing
|
|
383
|
+
# </body>. Mirrors Python's server.py call site.
|
|
384
|
+
def feedback_inject(ctx, response)
|
|
385
|
+
status, headers, body_parts = response
|
|
386
|
+
content_type = headers["content-type"] || ""
|
|
387
|
+
return nil unless content_type.include?("text/html") && body_parts.respond_to?(:join)
|
|
388
|
+
|
|
389
|
+
joined = body_parts.join
|
|
390
|
+
return nil unless joined.include?("</body>")
|
|
391
|
+
|
|
392
|
+
injected = Tina4::Feedback.inject_feedback_widget(
|
|
393
|
+
Struct.new(:path, :env).new(ctx.path, ctx.env), joined
|
|
394
|
+
)
|
|
395
|
+
return nil if injected == joined
|
|
396
|
+
|
|
397
|
+
new_headers = headers.dup
|
|
398
|
+
new_headers["content-length"] = injected.bytesize.to_s if new_headers["content-length"]
|
|
399
|
+
[status, new_headers, [injected]]
|
|
400
|
+
rescue StandardError
|
|
401
|
+
# Injection is best-effort - never break the response.
|
|
402
|
+
nil
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Save the session and set the cookie if a session was used.
|
|
406
|
+
def session_save(ctx, response)
|
|
407
|
+
return nil unless ctx.matched
|
|
408
|
+
|
|
409
|
+
request_obj = ctx.env["tina4.request"]
|
|
410
|
+
return nil unless request_obj&.instance_variable_get(:@session)
|
|
411
|
+
|
|
412
|
+
status, headers, body_parts = response
|
|
413
|
+
sess = request_obj.session
|
|
414
|
+
sess.save
|
|
415
|
+
|
|
416
|
+
# Probabilistic garbage collection (~1% of requests).
|
|
417
|
+
if rand(1..100) == 1
|
|
418
|
+
begin
|
|
419
|
+
sess.gc
|
|
420
|
+
rescue StandardError
|
|
421
|
+
# GC failure is non-critical - silently ignore
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Read the INCOMING session cookie by the SAME configured name the write
|
|
426
|
+
# side emits - via the one shared resolver (Session.cookie_name), exact
|
|
427
|
+
# `name=` prefix. A hardcoded "tina4_session=" here never matches a cookie
|
|
428
|
+
# renamed through TINA4_SESSION_NAME, so the auto-Set-Cookie would be
|
|
429
|
+
# needlessly re-emitted on every request that already carries the renamed
|
|
430
|
+
# cookie. Parity with Python's core/server._init_session cookie_prefix.
|
|
431
|
+
sid = sess.id
|
|
432
|
+
cookie_prefix = "#{Tina4::Session.cookie_name}="
|
|
433
|
+
cookie_val = (ctx.env["HTTP_COOKIE"] || "").split(";").map(&:strip)
|
|
434
|
+
.find { |part| part.start_with?(cookie_prefix) }
|
|
435
|
+
&.slice(cookie_prefix.length..)
|
|
436
|
+
if sid && sid != cookie_val
|
|
437
|
+
# Route through Session#cookie_header rather than hand-writing the
|
|
438
|
+
# header, so TINA4_SESSION_SECURE / _SAMESITE / _HTTPONLY / _NAME / _TTL
|
|
439
|
+
# are all honoured and Secure reflects the request scheme. The old
|
|
440
|
+
# hand-written literal ignored every attribute except TTL and hardcoded
|
|
441
|
+
# SameSite=Lax + HttpOnly, making the security env vars silent no-ops
|
|
442
|
+
# (issue #31).
|
|
443
|
+
headers["set-cookie"] = sess.cookie_header
|
|
444
|
+
end
|
|
445
|
+
[status, headers, body_parts]
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Milliseconds since the request entered the pipeline.
|
|
449
|
+
def elapsed_ms(ctx)
|
|
450
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.started_at) * 1000).round(3)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# ── ROUTE STAGES ─────────────────────────────────────────────────
|
|
454
|
+
# Each returns a Rack triple to answer the request, or nil to pass.
|
|
455
|
+
|
|
456
|
+
# Reuse the pre-match pair when one was built, so anything the pre-match
|
|
457
|
+
# middleware set (headers, request.user) reaches the handler. Path params
|
|
458
|
+
# are only known now, so they are attached here.
|
|
459
|
+
def prepare_route_request(ctx)
|
|
460
|
+
ctx.request = ctx.pre_request || Tina4::Request.new(ctx.env)
|
|
461
|
+
ctx.request.path_params = ctx.path_params
|
|
462
|
+
ctx.env["tina4.request"] = ctx.request # Store for session save after response
|
|
463
|
+
ctx.response = ctx.pre_response || Tina4::Response.new
|
|
464
|
+
nil
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# POST-match global middleware (block-based + class-based before_* methods).
|
|
468
|
+
# It runs after the route matched, because middleware like CSRF reads the
|
|
469
|
+
# matched route's metadata to honour no_auth. Pre-match middleware already
|
|
470
|
+
# ran in #call.
|
|
471
|
+
#
|
|
472
|
+
# M2 - AFTER-ON-4xx RULE: when a before_* short-circuits (4xx/skip) or
|
|
473
|
+
# throws (clean 500), the after-pass STILL runs so after_* can add
|
|
474
|
+
# headers/logging - consistent across all 4 frameworks.
|
|
475
|
+
def global_middleware_post(ctx)
|
|
476
|
+
middleware = Tina4::Middleware.post_match_middleware
|
|
477
|
+
return nil if Tina4::Middleware.run_before(middleware, ctx.request, ctx.response)
|
|
478
|
+
|
|
479
|
+
Tina4::Middleware.run_after(middleware, ctx.request, ctx.response)
|
|
480
|
+
ctx.response.to_rack
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Legacy per-route auth_handler.
|
|
484
|
+
def route_auth_handler(ctx)
|
|
485
|
+
return nil unless ctx.route.auth_handler
|
|
486
|
+
return nil if ctx.route.auth_handler.call(ctx.env)
|
|
487
|
+
|
|
488
|
+
handle_403(ctx.env["PATH_INFO"] || "/")
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Secure-by-default: enforce bearer-token auth on write routes.
|
|
492
|
+
#
|
|
493
|
+
# Extracted onto the class so the in-process TestClient enforces the EXACT
|
|
494
|
+
# same gate (parity with Python #PY2 - a tokenless write must 401 in tests
|
|
495
|
+
# too, or a green test hides a live 401 and the verification lies).
|
|
496
|
+
def route_auth_gate(ctx)
|
|
497
|
+
unauthorized = self.class.enforce_route_auth(ctx.env, ctx.route)
|
|
498
|
+
|
|
499
|
+
if unauthorized
|
|
500
|
+
# Carry the middleware headers onto the 401. This is the case the
|
|
501
|
+
# pre/post split exists for: a browser shown a 401 with no CORS headers
|
|
502
|
+
# reports a CORS error, so the real status never reaches the developer.
|
|
503
|
+
# enforce_route_auth builds a bare Rack tuple (a class method shared
|
|
504
|
+
# with TestClient, with no Response object), so the merge happens here.
|
|
505
|
+
# ctx.response carries BOTH middleware passes, not just pre-match.
|
|
506
|
+
if ctx.response.respond_to?(:headers) && ctx.response.headers.is_a?(Hash)
|
|
507
|
+
status, headers, body = unauthorized
|
|
508
|
+
# The auth tuple wins on a genuine clash - it owns content-type.
|
|
509
|
+
unauthorized = [status, ctx.response.headers.merge(headers), body]
|
|
510
|
+
end
|
|
511
|
+
return unauthorized
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# The verified JWT payload is only on env once the gate has run.
|
|
515
|
+
ctx.request.user = ctx.env["tina4.auth_payload"] if ctx.env["tina4.auth_payload"]
|
|
516
|
+
nil
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Per-route class-based middleware — its before_* pass.
|
|
520
|
+
def route_middleware(ctx)
|
|
521
|
+
return nil unless ctx.route.respond_to?(:run_middleware)
|
|
522
|
+
return nil if ctx.route.run_middleware(ctx.request, ctx.response)
|
|
523
|
+
|
|
524
|
+
# AFTER-ON-HALT: the route's own after_* hooks still run when one of its
|
|
525
|
+
# before_* hooks short-circuited, exactly as the global ones do.
|
|
526
|
+
ctx.route.run_after_middleware(ctx.request, ctx.response) if ctx.route.respond_to?(:run_after_middleware)
|
|
527
|
+
|
|
528
|
+
# Send the response the middleware SET. This used to be a hardcoded
|
|
529
|
+
# [403, {"content-type" => "text/html"}, ["403 Forbidden"]], which threw
|
|
530
|
+
# away the middleware's own answer: a 401 with a WWW-Authenticate header,
|
|
531
|
+
# a 302 to /login and a JSON error body all arrived as the same bare 403.
|
|
532
|
+
# A middleware that halts having set nothing still gets a 403 — that is
|
|
533
|
+
# applied by the return-value table (Tina4::Middleware.refuse), not here.
|
|
534
|
+
ctx.response.to_rack
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Invoke the route handler, wrapped in any function-style middleware.
|
|
538
|
+
#
|
|
539
|
+
# The call is built as a lambda so function-style middleware can wrap it.
|
|
540
|
+
# Path params are still bound by name - the continuation just forwards the
|
|
541
|
+
# (possibly-mutated) request/response pair the outer middleware passed in.
|
|
542
|
+
#
|
|
543
|
+
# Function middleware folds into a Russian-doll chain around the handler:
|
|
544
|
+
# first declared is the OUTERMOST layer, receiving the request first,
|
|
545
|
+
# calling next_handler to descend, and running its "after" code on the way
|
|
546
|
+
# out. Class-based middleware (before_*/after_*) never comes through here:
|
|
547
|
+
# its before_* pass is #route_middleware above and its after_* pass is
|
|
548
|
+
# #finalise_route_response below, both via Tina4::Middleware.
|
|
549
|
+
# tina4-book#141 PY-10-01 (cross-framework parity).
|
|
550
|
+
def invoke_route_handler(ctx)
|
|
551
|
+
handler_params = ctx.route.handler.parameters.map(&:last)
|
|
552
|
+
route_params = ctx.path_params || {}
|
|
553
|
+
|
|
554
|
+
invoke = lambda do |req, resp|
|
|
555
|
+
args = handler_params.map do |name|
|
|
556
|
+
if route_params.key?(name)
|
|
557
|
+
route_params[name]
|
|
558
|
+
elsif name == :request || name == :req
|
|
559
|
+
req
|
|
560
|
+
else
|
|
561
|
+
resp
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
args.empty? ? ctx.route.handler.call : ctx.route.handler.call(*args)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
fn_mws = ctx.route.respond_to?(:function_middleware) ? ctx.route.function_middleware : []
|
|
568
|
+
return invoke.call(ctx.request, ctx.response) if fn_mws.empty?
|
|
569
|
+
|
|
570
|
+
chain = invoke
|
|
571
|
+
fn_mws.reverse_each do |mw|
|
|
572
|
+
inner = chain
|
|
573
|
+
chain = ->(req, resp) { mw.call(req, resp, inner) }
|
|
574
|
+
end
|
|
575
|
+
chain.call(ctx.request, ctx.response)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Turn the handler's return value into a Rack triple.
|
|
579
|
+
def finalise_route_response(ctx, result)
|
|
580
|
+
# Template rendering: when a template is set and the handler returned a
|
|
581
|
+
# Hash, render the template with the hash as data and return the HTML.
|
|
582
|
+
if ctx.route.template && result.is_a?(Hash)
|
|
583
|
+
ctx.response.html(Tina4::Template.render(ctx.route.template, result))
|
|
584
|
+
return ctx.response.to_rack
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Skip auto_detect if the handler already returned the response object.
|
|
588
|
+
final = result.equal?(ctx.response) ? result : Tina4::Response.auto_detect(result, ctx.response)
|
|
589
|
+
|
|
590
|
+
# Global after middleware (block-based + class-based after_* methods).
|
|
591
|
+
Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, ctx.request, final)
|
|
592
|
+
|
|
593
|
+
# The route's OWN class middleware after_* hooks. Same orchestrator and
|
|
594
|
+
# same discovery as the global pass. Scope order matches the before pass
|
|
595
|
+
# (global, then route) — after hooks are deliberately NOT unwound in
|
|
596
|
+
# reverse; that decision is being taken separately.
|
|
597
|
+
ctx.route.run_after_middleware(ctx.request, final) if ctx.route.respond_to?(:run_after_middleware)
|
|
598
|
+
|
|
599
|
+
# Inject FreshToken when a body formToken was used for auth.
|
|
600
|
+
final.add_header("FreshToken", ctx.env["tina4.fresh_token"]) if ctx.env["tina4.fresh_token"]
|
|
601
|
+
|
|
602
|
+
final.to_rack
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
end
|