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.
- 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 +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- 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/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 +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- 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 +248 -36
- 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 +208 -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 +256 -33
- 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 +289 -37
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
#
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
data/lib/tina4/response.rb
CHANGED
|
@@ -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
|