tina4ruby 3.13.94 → 3.13.97

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +256 -33
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
@@ -19,6 +19,8 @@ module Tina4
19
19
  end
20
20
 
21
21
  class RackApp
22
+ include Tina4::DispatchPipeline
23
+
22
24
  class << self
23
25
  # The process-wide RackApp — the app actually serving traffic. Set by
24
26
  # #initialize (last one wins, the same convention as
@@ -78,234 +80,64 @@ module Tina4
78
80
  Tina4::Router.websocket("/__dev_reload", &DEV_RELOAD_WS_HANDLER)
79
81
  end
80
82
 
83
+ # Run the dispatch pipeline. See REQUEST_STAGES / RESPONSE_STAGES above.
84
+ #
85
+ # Every branch this used to hold now lives in a named stage, so the only
86
+ # control flow left here is "walk the list, stop when a stage answers".
81
87
  def call(env)
82
- method = env["REQUEST_METHOD"]
83
- path = env["PATH_INFO"] || "/"
84
- request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
85
-
86
- # Request-scoped query cache boundary (v3.13.23). Tina4 Ruby runs a
87
- # long-running Rack server, so the request-scoped DB cache (default-on)
88
- # would otherwise serve rows from a previous request. Clear it on every
89
- # live connection at the very start of each request, before any routing.
90
- # No-op for persistent-mode (TINA4_DB_CACHE=true) connections.
91
- Tina4::Database.reset_request_caches if defined?(Tina4::Database)
92
-
93
- # Fast-path: CORS preflight. Real CORS preflight requests carry an
94
- # Origin header AND an Access-Control-Request-Method header — the
95
- # browser is asking "may I send this method?" before the actual
96
- # request. If neither is present, the OPTIONS is a plain protocol-
97
- # introspection request (link checker, monitoring probe, RFC 9110
98
- # §9.3.7 OPTIONS) and must fall through to the router's generic
99
- # Allow-header response. Otherwise we'd shadow the framework's own
100
- # OPTIONS support and force every operator to hand-register CORS
101
- # exceptions for every introspection client.
102
- if method == "OPTIONS" && (env["HTTP_ORIGIN"] || env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"])
103
- return Tina4::CorsMiddleware.preflight_response(env)
104
- end
105
-
106
- # WebSocket upgrade — match against registered ws_routes
107
- if websocket_upgrade?(env)
108
- ws_result = Tina4::Router.find_ws_route(path)
109
- if ws_result
110
- ws_route, ws_params = ws_result
111
- return handle_websocket_upgrade(env, ws_route, ws_params)
112
- end
88
+ ctx = DispatchContext.new(
89
+ env: env,
90
+ method: env["REQUEST_METHOD"],
91
+ path: env["PATH_INFO"] || "/",
92
+ started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
93
+ bypass_response_stages: false
94
+ )
95
+
96
+ response = nil
97
+ REQUEST_STAGES.each do |stage|
98
+ response = send(stage, ctx)
99
+ break if response
113
100
  end
114
101
 
115
- # Dev dashboard routes (handled before anything else)
116
- if path.start_with?("/__dev")
117
- # Block live-reload endpoint on the AI port — AI tools must get stable responses
118
- if path == "/__dev_reload" && env["tina4.ai_port"]
119
- return [404, { "content-type" => "text/plain" }, ["Not available on AI port"]]
102
+ unless ctx.bypass_response_stages
103
+ RESPONSE_STAGES.each do |stage|
104
+ replacement = send(stage, ctx, response)
105
+ response = replacement if replacement
120
106
  end
121
- dev_response = Tina4::DevAdmin.handle_request(env)
122
- return dev_response if dev_response
123
- end
124
-
125
- # Customer feedback widget routes (parity with Python's /__feedback/*
126
- # surface — see tina4/feedback.rb). Always available — the master
127
- # switch (TINA4_ENABLE_FEEDBACK) is enforced INSIDE handle_request
128
- # so route shape stays stable across environments.
129
- if path.start_with?("/__feedback")
130
- fb_response = Tina4::Feedback.handle_request(env)
131
- return fb_response if fb_response
132
107
  end
133
108
 
134
- # Fast-path: API routes skip static file + swagger checks entirely
135
- unless path.start_with?("/api/")
136
- # Swagger
137
- if path == "/swagger" || path == "/swagger/"
138
- return serve_swagger_ui
139
- end
140
- if path == "/swagger/openapi.json"
141
- return serve_openapi_json
142
- end
143
-
144
- # Static files (only for non-API paths)
145
- static_response = try_static(path, env)
146
- return static_response if static_response
147
- end
148
-
149
- # Route matching
150
- result = Tina4::Router.match(method, path)
151
- if result
152
- route, path_params = result
153
- rack_response = handle_route(env, route, path_params)
154
- matched_pattern = route.path
155
- else
156
- # RFC 9110 conformance — before falling through to 404, check whether
157
- # the PATH is known to the router under any OTHER method.
158
- # - OPTIONS request → 204 with Allow header (§9.3.7). Bare OPTIONS
159
- # on an unknown path also returns 204 (empty Allow header) —
160
- # OPTIONS is a discovery method; rejecting unknown probes with
161
- # 404 confuses link checkers and monitoring tools and breaks
162
- # CORS preflight that lacks the Origin/ACRM headers our earlier
163
- # fast-path requires. Matches PHP/Node behaviour. Fixes
164
- # spec/rack_app_spec.rb OPTIONS preflight.
165
- # - Any other method (PUT on GET-only, TRACE, CONNECT, etc.)
166
- # → 405 with Allow header (§15.5.6 + §10.2.1) when the path
167
- # exists; → 404 when nothing about the path is known.
168
- allowed = Tina4::Router.methods_allowed_for_path(path)
169
- if method.to_s.upcase == "OPTIONS"
170
- allow_header = allowed.empty? ? "" : allowed.join(", ")
171
- rack_response = [204, { "allow" => allow_header, "content-length" => "0" }, [""]]
172
- matched_pattern = nil
173
- elsif !allowed.empty?
174
- allow_header = allowed.join(", ")
175
- body = %({"error":"Method Not Allowed","path":"#{path}","method":"#{method}","allow":[#{allowed.map { |m| %("#{m}") }.join(",")}],"status":405})
176
- rack_response = [405, {
177
- "allow" => allow_header,
178
- "content-type" => "application/json",
179
- "content-length" => body.bytesize.to_s
180
- }, [body]]
181
- matched_pattern = nil
182
- else
183
- rack_response = handle_404(path)
184
- matched_pattern = nil
185
- end
186
- end
187
-
188
- # RFC 9110 §9.3.2: a HEAD response MUST NOT include content. Strip
189
- # the body unconditionally and record what Content-Length the GET
190
- # would have sent. Cache validators / link checkers / monitoring
191
- # probes use that header to estimate sizes.
192
- if method.to_s.upcase == "HEAD"
193
- status, headers, body_parts = rack_response
194
- joined = body_parts.respond_to?(:join) ? body_parts.join : body_parts.to_s
195
- unless joined.empty?
196
- new_headers = headers.dup
197
- new_headers["content-length"] = joined.bytesize.to_s
198
- rack_response = [status, new_headers, [""]]
199
- end
200
- end
201
-
202
- # Capture request for dev inspector
203
- if dev_mode? && !path.start_with?("/__dev")
204
- duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000).round(3)
205
- Tina4::DevAdmin.request_inspector.capture(
206
- method: method,
207
- path: path,
208
- status: rack_response[0],
209
- duration: duration_ms
210
- )
211
- end
212
-
213
- # Request log line (v3.13.14). The dev inspector above only feeds the
214
- # /__dev UI — it never reached stdout, so `tina4ruby serve` printed the
215
- # banner then went silent. Emit a per-request line through Tina4::Log so
216
- # it lands on stdout (docker logs / k8s). On by default in dev, opt-in in
217
- # production via TINA4_LOG_REQUESTS. Same format across all four frameworks.
218
- if request_logging_enabled? && !path.start_with?("/__dev")
219
- log_elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000).round(3)
220
- Tina4::Log.info("#{method} #{path} -> #{rack_response[0]} (#{log_elapsed}ms)")
221
- end
222
-
223
- # Inject dev overlay button for HTML responses in dev mode
224
- if dev_mode? && !path.start_with?("/__dev")
225
- status, headers, body_parts = rack_response
226
- content_type = headers["content-type"] || ""
227
- if content_type.include?("text/html")
228
- request_info = {
229
- method: method,
230
- path: path,
231
- matched_pattern: matched_pattern || "(no match)",
232
- }
233
- joined = body_parts.join
234
- overlay = inject_dev_overlay(joined, request_info, ai_port: env["tina4.ai_port"])
235
- rack_response = [status, headers, [overlay]]
236
- end
237
- end
238
-
239
- # Customer feedback widget injection — runs LAST so its <script>
240
- # tag survives any earlier post-processing. No-op if disabled
241
- # (TINA4_ENABLE_FEEDBACK off), the user isn't whitelisted, the
242
- # path is /__dev or /__feedback, or the body isn't text/html with
243
- # a closing </body> tag. Mirrors Python's server.py call site —
244
- # see tina4_python/core/server.py around line 1543.
245
- begin
246
- status, headers, body_parts = rack_response
247
- content_type = headers["content-type"] || ""
248
- if content_type.include?("text/html") && body_parts.respond_to?(:join)
249
- joined = body_parts.join
250
- if joined.include?("</body>")
251
- injected = Tina4::Feedback.inject_feedback_widget(
252
- Struct.new(:path, :env).new(path, env),
253
- joined
254
- )
255
- if injected != joined
256
- new_headers = headers.dup
257
- new_headers["content-length"] = injected.bytesize.to_s if new_headers["content-length"]
258
- rack_response = [status, new_headers, [injected]]
259
- end
260
- end
261
- end
262
- rescue StandardError
263
- # Injection is best-effort — never break the response.
109
+ # LAST, and unconditionally. RFC 9110 s9.3.2 applies to EVERY response
110
+ # however it was produced - the swagger and static branches that skip the
111
+ # stages above, AND anything those stages added.
112
+ #
113
+ # Running it FIRST was wrong twice over: the static branches skipped it
114
+ # (the bug this group was created to fix), and in dev mode
115
+ # dev_toolbar_inject then put 8.5KB of markup back into an
116
+ # already-stripped HEAD response. CI caught the second one because it
117
+ # sets TINA4_DEBUG; a local run without it did not.
118
+ #
119
+ # Running it last also makes Content-Length right: it reports the body
120
+ # AFTER injection, which is exactly what the equivalent GET would send
121
+ # (s9.3.2 SHOULD - same headers as the GET).
122
+ ALWAYS_STAGES.each do |stage|
123
+ replacement = send(stage, ctx, response)
124
+ response = replacement if replacement
264
125
  end
265
126
 
266
- # Save session and set cookie if session was used
267
- if result && defined?(rack_response)
268
- status, headers, body_parts = rack_response
269
- request_obj = env["tina4.request"]
270
- if request_obj&.instance_variable_get(:@session)
271
- sess = request_obj.session
272
- sess.save
273
-
274
- # Probabilistic garbage collection (~1% of requests)
275
- if rand(1..100) == 1
276
- begin
277
- sess.gc
278
- rescue StandardError
279
- # GC failure is non-critical — silently ignore
280
- end
281
- end
282
-
283
- sid = sess.id
284
- # Read the INCOMING session cookie by the SAME configured name the
285
- # write side emits — via the one shared resolver (Session.cookie_name),
286
- # exact `name=` prefix. A hardcoded "tina4_session=" here never matches
287
- # a cookie renamed through TINA4_SESSION_NAME, so the auto-Set-Cookie
288
- # would be needlessly re-emitted on every request that already carries
289
- # the renamed session cookie. Parity with Python's
290
- # core/server._init_session cookie_prefix.
291
- cookie_prefix = "#{Tina4::Session.cookie_name}="
292
- cookie_val = (env["HTTP_COOKIE"] || "").split(";").map(&:strip)
293
- .find { |part| part.start_with?(cookie_prefix) }
294
- &.slice(cookie_prefix.length..)
295
- if sid && sid != cookie_val
296
- # Route through Session#cookie_header rather than hand-writing the
297
- # header, so TINA4_SESSION_SECURE / _SAMESITE / _HTTPONLY / _NAME /
298
- # _TTL are all honoured and Secure reflects the request scheme. The
299
- # old hand-written literal ignored every attribute except TTL and
300
- # hardcoded SameSite=Lax + HttpOnly, making the security env vars
301
- # silent no-ops (issue #31).
302
- headers["set-cookie"] = sess.cookie_header
303
- end
304
- rack_response = [status, headers, body_parts]
305
- end
306
- end
307
-
308
- rack_response
127
+ response
128
+ rescue Tina4::Request::PayloadTooLarge => e
129
+ # 413, not 500. PayloadTooLarge was raised by Request and rescued by
130
+ # nobody, so it fell into the generic handler below and an oversized
131
+ # upload answered "Internal Server Error" - which tells the caller to
132
+ # retry the exact request that will fail again.
133
+ #
134
+ # Measured on Puma with a 1MB TINA4_MAX_UPLOAD_SIZE and an 8MB body:
135
+ # HTTP 500, and the same for a chunked body. Memory stayed flat (Puma
136
+ # bounds the read), so unlike Node and Python this was only ever the
137
+ # status code - but the status code is what a client acts on.
138
+ body = JSON.generate({ "error" => e.message })
139
+ [413, { "content-type" => "application/json",
140
+ "content-length" => body.bytesize.to_s }, [body]]
309
141
  rescue => e
310
142
  handle_500(e, env)
311
143
  end
@@ -320,99 +152,37 @@ module Tina4
320
152
 
321
153
  private
322
154
 
323
- def handle_route(env, route, path_params)
324
- # Auth check (legacy per-route auth_handler)
325
- if route.auth_handler
326
- auth_result = route.auth_handler.call(env)
327
- return handle_403(env["PATH_INFO"] || "/") unless auth_result
328
- end
329
-
330
- # Secure-by-default: enforce bearer-token auth on write routes.
331
- # Extracted into a class method so the in-process TestClient enforces the
332
- # EXACT same gate (parity with Python #PY2 a tokenless write must 401 in
333
- # tests too, or a green test hides a live 401 and the verification lies).
334
- unauthorized = self.class.enforce_route_auth(env, route)
335
- return unauthorized if unauthorized
336
-
337
- request = Tina4::Request.new(env, path_params)
338
- request.user = env["tina4.auth_payload"] if env["tina4.auth_payload"]
339
- env["tina4.request"] = request # Store for session save after response
340
- response = Tina4::Response.new
341
-
342
- # Run global middleware (block-based + class-based before_* methods).
343
- # M2 — AFTER-ON-4xx RULE: when a before_* short-circuits (4xx/skip) or
344
- # throws (clean 500), the after-pass STILL runs so after_* can add
345
- # headers/logging consistent across all 4 frameworks.
346
- unless Tina4::Middleware.run_before(Tina4::Middleware.global_middleware, request, response)
347
- Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, request, response)
348
- return response.to_rack
349
- end
350
-
351
- # Run per-route middleware
352
- if route.respond_to?(:run_middleware)
353
- unless route.run_middleware(request, response)
354
- return [403, { "content-type" => "text/html" }, ["403 Forbidden"]]
355
- end
356
- end
357
-
358
- # Build the route-handler call as a lambda so function-style
359
- # middleware can wrap it. Path params are still bound by name —
360
- # the continuation just forwards the (possibly-mutated)
361
- # request/response pair the outer middleware chose to pass in.
362
- handler_params = route.handler.parameters.map(&:last)
363
- route_params = path_params || {}
364
- invoke_handler = lambda do |req, resp|
365
- args = handler_params.map do |name|
366
- if route_params.key?(name)
367
- route_params[name]
368
- elsif name == :request || name == :req
369
- req
370
- else
371
- resp
372
- end
373
- end
374
- args.empty? ? route.handler.call : route.handler.call(*args)
375
- end
376
-
377
- # Fold any function-style middleware on this route into a
378
- # Russian-doll chain wrapping the handler. First declared is the
379
- # outermost layer — it receives the request first, calls
380
- # next_handler to descend, and runs its "after" code on the way
381
- # out. Class-based middleware (before_*/after_*) is handled
382
- # separately by run_middleware above and never goes through here.
383
- # tina4-book#141 PY-10-01 (cross-framework parity).
384
- fn_mws = route.respond_to?(:function_middleware) ? route.function_middleware : []
385
- if fn_mws.empty?
386
- result = invoke_handler.call(request, response)
387
- else
388
- chain = invoke_handler
389
- fn_mws.reverse_each do |mw|
390
- inner = chain
391
- chain = lambda { |req, resp| mw.call(req, resp, inner) }
392
- end
393
- result = chain.call(request, response)
394
- end
395
-
396
- # Template rendering: when a template is set and the handler returned a Hash,
397
- # render the template with the hash as data and return the HTML response.
398
- if route.template && result.is_a?(Hash)
399
- html = Tina4::Template.render(route.template, result)
400
- response.html(html)
401
- return response.to_rack
402
- end
403
-
404
- # Skip auto_detect if handler already returned the response object
405
- final_response = result.equal?(response) ? result : Tina4::Response.auto_detect(result, response)
406
-
407
- # Run global after middleware (block-based + class-based after_* methods)
408
- Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, request, final_response)
409
-
410
- # Inject FreshToken header when body formToken was used for auth
411
- if env["tina4.fresh_token"]
412
- final_response.add_header("FreshToken", env["tina4.fresh_token"])
155
+ # Order: POST-MATCH globals -> auth gate -> the route's OWN middleware.
156
+ #
157
+ # The globals run BEFORE the gate so a rate limiter can throttle a
158
+ # brute-force login and an access log records the 401 - neither is possible
159
+ # if they only run on authenticated requests. That is what every mainstream
160
+ # framework does: Django ships CsrfViewMiddleware ahead of
161
+ # AuthenticationMiddleware and enforces auth in a view decorator after all
162
+ # MIDDLEWARE, Laravel runs the `web` group before the `auth` route
163
+ # middleware, ASP.NET puts UseAuthorization last before the endpoint. Ruby
164
+ # and Python ran the gate first; Node and PHP did not. Aligned on the
165
+ # mainstream answer (ADR-0012).
166
+ #
167
+ # The route's OWN middleware stays AFTER the gate, so middleware attached to
168
+ # a secured route never processes an unauthenticated request.
169
+ # Run a matched route. See ROUTE_STAGES in DispatchPipeline.
170
+ #
171
+ # Was cyclomatic complexity 24 in one 118-line function; the same two-phase
172
+ # shape as #call, so it gets the same treatment - short-circuit stages
173
+ # first, then invoke and finalise.
174
+ def handle_route(env, route, path_params, pre_request = nil, pre_response = nil)
175
+ ctx = RouteContext.new(
176
+ env: env, route: route, path_params: path_params,
177
+ pre_request: pre_request, pre_response: pre_response
178
+ )
179
+
180
+ ROUTE_STAGES.each do |stage|
181
+ short_circuit = send(stage, ctx)
182
+ return short_circuit if short_circuit
413
183
  end
414
184
 
415
- final_response.to_rack
185
+ finalise_route_response(ctx, invoke_route_handler(ctx))
416
186
  end
417
187
 
418
188
  def try_static(path, env = nil)
@@ -1256,7 +1026,11 @@ module Tina4
1256
1026
 
1257
1027
  # Priority 3: Session token (for secured GET routes after login)
1258
1028
  if token.nil?
1259
- session = Tina4::Session.new(env)
1029
+ # Request path, so the same log-loud-then-degrade policy as
1030
+ # Request#session (ADR-0021): an unreachable session store must not turn
1031
+ # the auth gate into a 500. It degrades to an empty session, which means
1032
+ # no token, which means the ordinary 401 below - a SERVED request.
1033
+ session = Tina4::Session.new(env, degrade_on_backend_failure: true)
1260
1034
  session_token = session.get("token")
1261
1035
  if session_token && !session_token.empty?
1262
1036
  token = session_token
@@ -1264,10 +1038,14 @@ module Tina4
1264
1038
  end
1265
1039
  end
1266
1040
 
1267
- # API_KEY bypass — matches tina4_python behavior
1268
- api_key = ENV["TINA4_API_KEY"]
1269
- if api_key && !api_key.empty? && token == api_key
1270
- env["tina4.auth_payload"] = { "api_key" => true }
1041
+ # API_KEY bypass — routed through the timing-safe Tina4::Auth.validate_api_key
1042
+ # (OpenSSL.fixed_length_secure_compare), matching tina4_python's _check_auth.
1043
+ # It used to be a plain `token == api_key`, which returns as soon as two
1044
+ # bytes differ so response timing leaks the key prefix and the key can be
1045
+ # recovered a character at a time. validate_api_key also covers the unset /
1046
+ # blank / wrong-length cases the old guard spelled out by hand.
1047
+ if Tina4::Auth.validate_api_key(token)
1048
+ env["tina4.auth_payload"] = { "_auth" => "api_key" }
1271
1049
  elsif token
1272
1050
  unless Tina4::Auth.valid_token(token)
1273
1051
  return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
data/lib/tina4/request.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  require "uri"
3
3
  require "json"
4
+ require "ipaddr"
4
5
 
5
6
  module Tina4
6
7
  # A Hash subclass that supports indifferent access (both string and symbol keys).
@@ -114,7 +115,7 @@ module Tina4
114
115
 
115
116
  class Request
116
117
  attr_reader :env, :method, :path, :query_string, :content_type,
117
- :path_params, :ip
118
+ :path_params, :ip, :remote_ip
118
119
  attr_accessor :user
119
120
 
120
121
  # Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var.
@@ -137,7 +138,10 @@ module Tina4
137
138
  "Request body (#{content_length} bytes) exceeds TINA4_MAX_UPLOAD_SIZE (#{TINA4_MAX_UPLOAD_SIZE} bytes)"
138
139
  end
139
140
 
140
- # Client IP with X-Forwarded-For support
141
+ # Raw socket peer — NEVER honours X-Forwarded-For, so it can be trusted
142
+ # for security decisions. Resolved BEFORE @ip: the peer decides whether
143
+ # the forwarding headers may be believed at all.
144
+ @remote_ip = (env["REMOTE_ADDR"] || "").to_s
141
145
  @ip = extract_client_ip
142
146
 
143
147
  # Lazy-initialized fields (nil = not yet computed)
@@ -206,8 +210,16 @@ module Tina4
206
210
  @cookies ||= parse_cookies
207
211
  end
208
212
 
213
+ # The session for THIS request.
214
+ #
215
+ # degrade_on_backend_failure: this is the live request path, so a storage
216
+ # handler that cannot be built (an unreachable database, a refused backend
217
+ # name) is LOGGED and then degraded to an in-memory-only session rather than
218
+ # unwinding into RackApp's 500 handler and taking the request down with it
219
+ # (ADR-0021). TINA4_SESSION_STRICT still re-raises. Direct Session.new
220
+ # callers keep the loud raise - see the guard in Session#initialize.
209
221
  def session
210
- @session ||= Tina4::Session.new(@env)
222
+ @session ||= Tina4::Session.new(@env, degrade_on_backend_failure: true)
211
223
  end
212
224
 
213
225
  # Parsed body (JSON -> Hash, form-urlencoded -> Hash, multipart ->
@@ -240,6 +252,20 @@ module Tina4
240
252
 
241
253
  # Merged params: query + body + path_params (path_params highest priority)
242
254
  # Supports both string and symbol key access (indifferent access).
255
+ # Attach the matched route's path params AFTER construction.
256
+ #
257
+ # The request is built BEFORE route matching now, so pre-match middleware
258
+ # has something to read and mutate. Path params are only known once a route
259
+ # has matched, so they are set here and the memoised #params is dropped -
260
+ # without that reset a pre-match middleware that touched #params would
261
+ # freeze a param-less copy for the handler.
262
+ def path_params=(value)
263
+ @path_params = value || {}
264
+ @params = nil
265
+ end
266
+
267
+ attr_reader :path_params
268
+
243
269
  def params
244
270
  @params ||= build_params
245
271
  end
@@ -275,15 +301,29 @@ module Tina4
275
301
 
276
302
  private
277
303
 
304
+ # Resolve the client IP, honouring forwarding headers ONLY behind a trusted
305
+ # proxy. X-Forwarded-For is set by whoever sends it, so an unfiltered read
306
+ # lets any client choose its own rate-limit bucket - and choose SOMEONE
307
+ # ELSE'S. See ADR-0019.
308
+ #
309
+ # Within the chain the RIGHTMOST entry that is not itself a trusted proxy
310
+ # wins. Taking the leftmost would be no safer than trusting the header
311
+ # outright: a client can prepend its own hop, and the proxy appends rather
312
+ # than replaces. This is the algorithm Rack uses (Rack::Request#ip).
278
313
  def extract_client_ip
279
- # Check X-Forwarded-For first (proxy/load balancer)
314
+ peer = @remote_ip.to_s
315
+ return peer.empty? ? "127.0.0.1" : peer unless Tina4.trusted_proxy?(peer)
316
+
280
317
  forwarded = @env["HTTP_X_FORWARDED_FOR"]
281
318
  if forwarded && !forwarded.empty?
282
- # Take the first (original client) IP
283
- forwarded.split(",").first.strip
284
- else
285
- @env["HTTP_X_REAL_IP"] || @env["REMOTE_ADDR"] || "127.0.0.1"
319
+ hops = forwarded.split(",").map(&:strip).reject(&:empty?)
320
+ client = hops.reverse.find { |hop| !Tina4.trusted_proxy?(hop) }
321
+ # Every hop is itself a trusted proxy - the peer is the best we have.
322
+ return client || peer
286
323
  end
324
+
325
+ real_ip = @env["HTTP_X_REAL_IP"].to_s.strip
326
+ real_ip.empty? ? peer : real_ip
287
327
  end
288
328
 
289
329
  def extract_headers
@@ -156,9 +156,50 @@ module Tina4
156
156
  self
157
157
  end
158
158
 
159
- def file(path, content_type: nil, download: false)
159
+ def file(path, content_type: nil, download: false, root: nil)
160
+ # SECURITY: confine the read. The natural spelling of a download route,
161
+ #
162
+ # response.file("downloads/" + name) # name = "../secret.env"
163
+ #
164
+ # used to serve any file the process could read - measured at 200 with
165
+ # the contents of a .env one directory above the intended one.
166
+ #
167
+ # TWO checks. Containment ALONE does not close it: that payload lands on
168
+ # <project>/secret.env, which IS inside the project root, and the project
169
+ # root is exactly where .env lives. Rejecting ".." on the way in is the
170
+ # check that closes it; containment then catches absolute paths and
171
+ # symlinks, neither of which carries a ".." segment.
172
+ # Containment ONLY when a root is declared; defaulting to Dir.pwd broke
173
+ # every legitimate absolute path.
174
+ base = root ? ::File.expand_path(root) : nil
175
+ forbidden = path.to_s.split(%r{[\\/]}).include?("..")
176
+
177
+ unless forbidden
178
+ candidate = (base.nil? || ::File.absolute_path?(path.to_s)) ? path.to_s : ::File.join(base, path.to_s)
179
+ resolved =
180
+ begin
181
+ ::File.realpath(candidate)
182
+ rescue Errno::ENOENT, Errno::ELOOP, Errno::ENAMETOOLONG, Errno::EACCES
183
+ nil
184
+ end
185
+ if resolved && base && base != ::File::SEPARATOR &&
186
+ resolved != base && !resolved.start_with?(base + ::File::SEPARATOR)
187
+ forbidden = true
188
+ end
189
+ path = resolved || candidate
190
+ end
191
+
192
+ if forbidden
193
+ # Refuse BEFORE reading: never load bytes we will not send.
194
+ @status_code = 403
195
+ @headers["content-type"] = "text/plain"
196
+ @body = "Forbidden"
197
+ return self
198
+ end
199
+
160
200
  unless ::File.exist?(path)
161
201
  @status_code = 404
202
+ @headers["content-type"] = "text/plain"
162
203
  @body = "File not found"
163
204
  return self
164
205
  end