tina4ruby 3.11.15 → 3.11.16

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +1289 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -124
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -116
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2087 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +871 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/plan.rb +471 -0
  63. data/lib/tina4/project_index.rb +366 -0
  64. data/lib/tina4/public/css/tina4.css +2463 -2463
  65. data/lib/tina4/public/css/tina4.min.css +1 -1
  66. data/lib/tina4/public/images/logo.svg +5 -5
  67. data/lib/tina4/public/js/frond.min.js +2 -2
  68. data/lib/tina4/public/js/tina4-dev-admin.js +1264 -565
  69. data/lib/tina4/public/js/tina4-dev-admin.min.js +1264 -480
  70. data/lib/tina4/public/js/tina4.min.js +92 -92
  71. data/lib/tina4/public/js/tina4js.min.js +48 -48
  72. data/lib/tina4/public/swagger/index.html +90 -90
  73. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  74. data/lib/tina4/query_builder.rb +380 -380
  75. data/lib/tina4/queue.rb +366 -366
  76. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  77. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  78. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  79. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  80. data/lib/tina4/rack_app.rb +817 -817
  81. data/lib/tina4/rate_limiter.rb +130 -130
  82. data/lib/tina4/request.rb +268 -268
  83. data/lib/tina4/response.rb +346 -346
  84. data/lib/tina4/response_cache.rb +551 -551
  85. data/lib/tina4/router.rb +406 -406
  86. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  87. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  88. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  89. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  90. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  91. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  92. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  93. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  94. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  95. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  96. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  97. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  98. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  99. data/lib/tina4/scss/tina4css/base.scss +1 -1
  100. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  101. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  102. data/lib/tina4/scss_compiler.rb +178 -178
  103. data/lib/tina4/seeder.rb +567 -567
  104. data/lib/tina4/service_runner.rb +303 -303
  105. data/lib/tina4/session.rb +297 -297
  106. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  107. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  108. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  109. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  110. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  111. data/lib/tina4/shutdown.rb +84 -84
  112. data/lib/tina4/sql_translation.rb +158 -158
  113. data/lib/tina4/swagger.rb +124 -124
  114. data/lib/tina4/template.rb +894 -894
  115. data/lib/tina4/templates/base.twig +26 -26
  116. data/lib/tina4/templates/errors/302.twig +14 -14
  117. data/lib/tina4/templates/errors/401.twig +9 -9
  118. data/lib/tina4/templates/errors/403.twig +29 -29
  119. data/lib/tina4/templates/errors/404.twig +29 -29
  120. data/lib/tina4/templates/errors/500.twig +38 -38
  121. data/lib/tina4/templates/errors/502.twig +9 -9
  122. data/lib/tina4/templates/errors/503.twig +12 -12
  123. data/lib/tina4/templates/errors/base.twig +37 -37
  124. data/lib/tina4/test_client.rb +159 -159
  125. data/lib/tina4/testing.rb +340 -340
  126. data/lib/tina4/validator.rb +174 -174
  127. data/lib/tina4/version.rb +1 -1
  128. data/lib/tina4/webserver.rb +312 -312
  129. data/lib/tina4/websocket.rb +343 -343
  130. data/lib/tina4/websocket_backplane.rb +190 -190
  131. data/lib/tina4/wsdl.rb +564 -564
  132. data/lib/tina4.rb +460 -458
  133. data/lib/tina4ruby.rb +4 -4
  134. metadata +5 -3
@@ -1,445 +1,445 @@
1
- # frozen_string_literal: true
2
-
3
- module Tina4
4
- class Middleware
5
- class << self
6
- def before_handlers
7
- @before_handlers ||= []
8
- end
9
-
10
- def after_handlers
11
- @after_handlers ||= []
12
- end
13
-
14
- # Registry of class-based middleware (registered via Router.use)
15
- def global_middleware
16
- @global_middleware ||= []
17
- end
18
-
19
- # Parity alias matching Python/PHP/Node orchestrators.
20
- def get_global
21
- global_middleware.dup
22
- end
23
-
24
- def before(pattern = nil, &block)
25
- before_handlers << { pattern: pattern, handler: block }
26
- end
27
-
28
- def after(pattern = nil, &block)
29
- after_handlers << { pattern: pattern, handler: block }
30
- end
31
-
32
- # Register a class-based middleware globally.
33
- # The class should define static before_* and/or after_* methods.
34
- def use(klass)
35
- global_middleware << klass unless global_middleware.include?(klass)
36
- end
37
-
38
- def clear!
39
- @before_handlers = []
40
- @after_handlers = []
41
- @global_middleware = []
42
- end
43
-
44
- # Run all "before" hooks: block-based handlers, then class-based before_* methods.
45
- #
46
- # Signature matches Python/PHP/Node orchestrators: pass the list of
47
- # middleware classes explicitly.
48
- #
49
- # Returns [request, response] on success, or false to halt the request.
50
- def run_before(middleware_classes, request, response)
51
- # 1. Block-based before handlers (pattern-matched)
52
- before_handlers.each do |entry|
53
- next unless matches_pattern?(request.path, entry[:pattern])
54
- result = entry[:handler].call(request, response)
55
- return false if result == false
56
- end
57
-
58
- # 2. Class-based middleware: call every before_* method
59
- middleware_classes.each do |klass|
60
- before_methods_for(klass).each do |method_name|
61
- result = klass.send(method_name, request, response)
62
- # Support returning [request, response] (Python convention) or false to halt
63
- if result == false
64
- return false
65
- elsif result.is_a?(Array) && result.length == 2
66
- request, response = result
67
- # If response already has a non-2xx status, halt processing
68
- return false if response.status_code >= 400
69
- end
70
- end
71
- end
72
-
73
- true
74
- end
75
-
76
- # Run all "after" hooks: block-based handlers, then class-based after_* methods.
77
- #
78
- # Signature matches Python/PHP/Node orchestrators: pass the list of
79
- # middleware classes explicitly.
80
- def run_after(middleware_classes, request, response)
81
- # 1. Block-based after handlers (pattern-matched)
82
- after_handlers.each do |entry|
83
- next unless matches_pattern?(request.path, entry[:pattern])
84
- entry[:handler].call(request, response)
85
- end
86
-
87
- # 2. Class-based middleware: call every after_* method
88
- middleware_classes.each do |klass|
89
- after_methods_for(klass).each do |method_name|
90
- result = klass.send(method_name, request, response)
91
- if result.is_a?(Array) && result.length == 2
92
- request, response = result
93
- end
94
- end
95
- end
96
- end
97
-
98
- private
99
-
100
- def matches_pattern?(path, pattern)
101
- return true if pattern.nil?
102
- case pattern
103
- when String
104
- path.start_with?(pattern)
105
- when Regexp
106
- pattern.match?(path)
107
- else
108
- true
109
- end
110
- end
111
-
112
- # Collect all public class methods matching before_*
113
- def before_methods_for(klass)
114
- klass.methods(false).select { |m| m.to_s.start_with?("before_") }.sort
115
- end
116
-
117
- # Collect all public class methods matching after_*
118
- def after_methods_for(klass)
119
- klass.methods(false).select { |m| m.to_s.start_with?("after_") }.sort
120
- end
121
- end
122
- end
123
-
124
- # ---------------------------------------------------------------------------
125
- # Built-in class-based middleware
126
- # ---------------------------------------------------------------------------
127
-
128
- # CorsClassMiddleware -- sets CORS headers from env vars on every response.
129
- # Uses the same config source as CorsMiddleware module.
130
- class CorsClassMiddleware
131
- class << self
132
- def before_cors(request, response)
133
- config = load_config
134
- origin = resolve_origin(request, config)
135
-
136
- response.headers["access-control-allow-origin"] = origin
137
- response.headers["access-control-allow-methods"] = config[:methods]
138
- response.headers["access-control-allow-headers"] = config[:headers]
139
- response.headers["access-control-max-age"] = config[:max_age]
140
- if config[:credentials] == "true"
141
- response.headers["access-control-allow-credentials"] = "true"
142
- end
143
-
144
- [request, response]
145
- end
146
-
147
- private
148
-
149
- def load_config
150
- {
151
- origins: ENV["TINA4_CORS_ORIGINS"] || "*",
152
- methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
153
- headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
154
- max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
155
- credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
156
- }
157
- end
158
-
159
- def is_preflight(request)
160
- request.method&.upcase == "OPTIONS" &&
161
- request.headers["origin"] &&
162
- request.headers["access-control-request-method"]
163
- end
164
-
165
- def resolve_origin(request, config)
166
- request_origin = request.headers["origin"] || request.headers["referer"]
167
-
168
- if config[:origins] == "*"
169
- "*"
170
- elsif request_origin
171
- allowed = config[:origins].split(",").map(&:strip)
172
- clean = request_origin.chomp("/")
173
- allowed.include?(clean) ? clean : allowed.first || "*"
174
- else
175
- config[:origins].split(",").first&.strip || "*"
176
- end
177
- end
178
- end
179
- end
180
-
181
- # RateLimiterMiddleware -- tracks requests per IP, returns 429 when exceeded.
182
- # Config via env: TINA4_RATE_LIMIT (default 100), TINA4_RATE_WINDOW (default 60s).
183
- class RateLimiterMiddleware
184
- @store = {}
185
- @mutex = Mutex.new
186
- @last_cleanup = Time.now
187
-
188
- class << self
189
- def before_rate_limit(request, response)
190
- limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
191
- window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
192
- ip = request.ip || "unknown"
193
- now = Time.now
194
-
195
- cleanup_if_needed(now, window)
196
-
197
- @mutex.synchronize do
198
- @store[ip] ||= []
199
- entries = @store[ip]
200
-
201
- # Sliding window -- drop expired timestamps
202
- cutoff = now - window
203
- entries.reject! { |t| t < cutoff }
204
-
205
- if entries.length >= limit
206
- oldest = entries.first
207
- retry_after = [(oldest + window - now).ceil, 1].max
208
-
209
- response.headers["X-RateLimit-Limit"] = limit.to_s
210
- response.headers["X-RateLimit-Remaining"] = "0"
211
- response.headers["X-RateLimit-Reset"] = (oldest + window).to_i.to_s
212
- response.headers["Retry-After"] = retry_after.to_s
213
- response.json({ error: "Too Many Requests", retry_after: retry_after }, 429)
214
-
215
- return [request, response]
216
- end
217
-
218
- entries << now
219
-
220
- response.headers["X-RateLimit-Limit"] = limit.to_s
221
- response.headers["X-RateLimit-Remaining"] = (limit - entries.length).to_s
222
- response.headers["X-RateLimit-Reset"] = (now + window).to_i.to_s
223
- end
224
-
225
- [request, response]
226
- end
227
-
228
- def check(ip)
229
- limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
230
- window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
231
- now = Time.now
232
-
233
- @mutex.synchronize do
234
- @store[ip] ||= []
235
- entries = @store[ip]
236
- entries.reject! { |t| t < now - window }
237
-
238
- remaining = [limit - entries.length, 0].max
239
- reset_at = entries.empty? ? window : (entries.first + window - now).ceil
240
-
241
- if entries.length >= limit
242
- return [false, { limit: limit, remaining: 0, reset: reset_at, window: window }]
243
- end
244
-
245
- entries << now
246
- [true, { limit: limit, remaining: remaining - 1, reset: window, window: window }]
247
- end
248
- end
249
-
250
- # Allow resetting state (useful in tests)
251
- def reset!
252
- @mutex.synchronize { @store.clear }
253
- end
254
-
255
- private
256
-
257
- def cleanup_if_needed(now, window)
258
- return if now - @last_cleanup < window
259
-
260
- @mutex.synchronize do
261
- return if now - @last_cleanup < window
262
-
263
- cutoff = now - window
264
- @store.delete_if do |_ip, entries|
265
- entries.reject! { |t| t < cutoff }
266
- entries.empty?
267
- end
268
- @last_cleanup = now
269
- end
270
- end
271
- end
272
- end
273
-
274
- # RequestLoggerMiddleware -- logs method, path, and elapsed time for every request.
275
- class RequestLoggerMiddleware
276
- @request_times = {}
277
- @mutex = Mutex.new
278
-
279
- class << self
280
- def before_log(request, response)
281
- request_key = "#{request.object_id}"
282
- @mutex.synchronize do
283
- @request_times[request_key] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
284
- end
285
- [request, response]
286
- end
287
-
288
- def after_log(request, response)
289
- request_key = "#{request.object_id}"
290
- start_time = @mutex.synchronize { @request_times.delete(request_key) }
291
-
292
- if start_time
293
- elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(3)
294
- else
295
- elapsed_ms = 0.0
296
- end
297
-
298
- Tina4::Log.info("[RequestLogger] #{request.method} #{request.path} -> #{response.status_code} (#{elapsed_ms}ms)")
299
- [request, response]
300
- end
301
-
302
- def reset!
303
- @mutex.synchronize { @request_times.clear }
304
- end
305
- end
306
- end
307
-
308
- # CsrfMiddleware -- validates form tokens on state-changing requests.
309
- #
310
- # Off by default -- only active when TINA4_CSRF=true in .env or when
311
- # registered explicitly via Router.use(CsrfMiddleware).
312
- #
313
- # Behaviour:
314
- # - Skips GET, HEAD, OPTIONS requests.
315
- # - Skips routes marked .no_auth.
316
- # - Skips requests with a valid Authorization: Bearer header (API clients).
317
- # - Checks request.body["formToken"] then request.headers["X-Form-Token"].
318
- # - Rejects if token found in request.query["formToken"] (log warning, 403).
319
- # - Validates token with Auth.valid_token using SECRET env var.
320
- # - If token payload has session_id, verifies it matches request.session.session_id.
321
- # - Returns 403 with response.json({error: "CSRF_INVALID", message: ...}, 403) on failure.
322
- class CsrfMiddleware
323
- class << self
324
- def before_csrf(request, response)
325
- # Allow disabling CSRF via env var
326
- csrf_env = ENV["TINA4_CSRF"].to_s.downcase
327
- return [request, response] if %w[false 0 no].include?(csrf_env)
328
-
329
- # Skip safe HTTP methods
330
- method = (request.method || "GET").upcase
331
- return [request, response] if %w[GET HEAD OPTIONS].include?(method)
332
-
333
- # Skip routes marked no_auth
334
- handler = request.respond_to?(:handler) ? request.handler : nil
335
- if handler
336
- no_auth = if handler.is_a?(Hash)
337
- handler[:no_auth] || handler[:noAuth]
338
- elsif handler.respond_to?(:no_auth)
339
- handler.no_auth
340
- end
341
- return [request, response] if no_auth
342
- end
343
-
344
- # Skip requests with valid Bearer token (API clients)
345
- headers = request.respond_to?(:headers) ? request.headers : {}
346
- auth_header = headers["authorization"] || headers["Authorization"] || ""
347
- if auth_header.start_with?("Bearer ")
348
- bearer_token = auth_header[7..].strip
349
- unless bearer_token.empty?
350
- return [request, response] if Tina4::Auth.valid_token(bearer_token)
351
- end
352
- end
353
-
354
- # Reject if token is in query string (security risk)
355
- query = if request.respond_to?(:params)
356
- request.params
357
- elsif request.respond_to?(:query)
358
- request.query
359
- else
360
- {}
361
- end
362
- query ||= {}
363
-
364
- if query.is_a?(Hash) && query["formToken"] && !query["formToken"].to_s.empty?
365
- Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
366
- response.json({ error: "CSRF_INVALID", message: "Form token must not be sent in the URL query string" }, 403)
367
- return [request, response]
368
- end
369
-
370
- # Extract token: body first, then header
371
- token = nil
372
- body = request.respond_to?(:body) ? request.body : nil
373
- body ||= {}
374
- token = body["formToken"] if body.is_a?(Hash)
375
-
376
- if token.nil? || token.to_s.empty?
377
- token = headers["X-Form-Token"] || headers["x-form-token"] || ""
378
- end
379
-
380
- if token.nil? || token.to_s.empty?
381
- response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
382
- return [request, response]
383
- end
384
-
385
- # Validate the token
386
- unless Tina4::Auth.valid_token(token.to_s)
387
- response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
388
- return [request, response]
389
- end
390
-
391
- # Session binding — if token has session_id, verify it matches
392
- payload = Tina4::Auth.get_payload(token.to_s) || {}
393
- token_session_id = payload["session_id"]
394
- if token_session_id
395
- current_session_id = nil
396
- session = request.respond_to?(:session) ? request.session : nil
397
- if session
398
- current_session_id = if session.respond_to?(:session_id)
399
- session.session_id
400
- elsif session.is_a?(Hash)
401
- session["session_id"]
402
- elsif session.respond_to?(:get)
403
- session.get("session_id")
404
- end
405
- end
406
-
407
- if current_session_id && token_session_id != current_session_id
408
- response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
409
- return [request, response]
410
- end
411
- end
412
-
413
- [request, response]
414
- end
415
- end
416
- end
417
-
418
- # SecurityHeadersMiddleware -- injects security headers on every response.
419
- # Config via env:
420
- # TINA4_FRAME_OPTIONS — X-Frame-Options (default: SAMEORIGIN)
421
- # TINA4_HSTS — Strict-Transport-Security max-age (default: "" = off)
422
- # TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
423
- # TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
424
- # TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
425
- class SecurityHeadersMiddleware
426
- class << self
427
- def before_security(request, response)
428
- response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
429
- response.headers["X-Content-Type-Options"] = "nosniff"
430
-
431
- hsts = ENV["TINA4_HSTS"] || ""
432
- unless hsts.empty?
433
- response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
434
- end
435
-
436
- response.headers["Content-Security-Policy"] = ENV["TINA4_CSP"] || "default-src 'self'"
437
- response.headers["Referrer-Policy"] = ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"
438
- response.headers["X-XSS-Protection"] = "0"
439
- response.headers["Permissions-Policy"] = ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"
440
-
441
- [request, response]
442
- end
443
- end
444
- end
445
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ class Middleware
5
+ class << self
6
+ def before_handlers
7
+ @before_handlers ||= []
8
+ end
9
+
10
+ def after_handlers
11
+ @after_handlers ||= []
12
+ end
13
+
14
+ # Registry of class-based middleware (registered via Router.use)
15
+ def global_middleware
16
+ @global_middleware ||= []
17
+ end
18
+
19
+ # Parity alias matching Python/PHP/Node orchestrators.
20
+ def get_global
21
+ global_middleware.dup
22
+ end
23
+
24
+ def before(pattern = nil, &block)
25
+ before_handlers << { pattern: pattern, handler: block }
26
+ end
27
+
28
+ def after(pattern = nil, &block)
29
+ after_handlers << { pattern: pattern, handler: block }
30
+ end
31
+
32
+ # Register a class-based middleware globally.
33
+ # The class should define static before_* and/or after_* methods.
34
+ def use(klass)
35
+ global_middleware << klass unless global_middleware.include?(klass)
36
+ end
37
+
38
+ def clear!
39
+ @before_handlers = []
40
+ @after_handlers = []
41
+ @global_middleware = []
42
+ end
43
+
44
+ # Run all "before" hooks: block-based handlers, then class-based before_* methods.
45
+ #
46
+ # Signature matches Python/PHP/Node orchestrators: pass the list of
47
+ # middleware classes explicitly.
48
+ #
49
+ # Returns [request, response] on success, or false to halt the request.
50
+ def run_before(middleware_classes, request, response)
51
+ # 1. Block-based before handlers (pattern-matched)
52
+ before_handlers.each do |entry|
53
+ next unless matches_pattern?(request.path, entry[:pattern])
54
+ result = entry[:handler].call(request, response)
55
+ return false if result == false
56
+ end
57
+
58
+ # 2. Class-based middleware: call every before_* method
59
+ middleware_classes.each do |klass|
60
+ before_methods_for(klass).each do |method_name|
61
+ result = klass.send(method_name, request, response)
62
+ # Support returning [request, response] (Python convention) or false to halt
63
+ if result == false
64
+ return false
65
+ elsif result.is_a?(Array) && result.length == 2
66
+ request, response = result
67
+ # If response already has a non-2xx status, halt processing
68
+ return false if response.status_code >= 400
69
+ end
70
+ end
71
+ end
72
+
73
+ true
74
+ end
75
+
76
+ # Run all "after" hooks: block-based handlers, then class-based after_* methods.
77
+ #
78
+ # Signature matches Python/PHP/Node orchestrators: pass the list of
79
+ # middleware classes explicitly.
80
+ def run_after(middleware_classes, request, response)
81
+ # 1. Block-based after handlers (pattern-matched)
82
+ after_handlers.each do |entry|
83
+ next unless matches_pattern?(request.path, entry[:pattern])
84
+ entry[:handler].call(request, response)
85
+ end
86
+
87
+ # 2. Class-based middleware: call every after_* method
88
+ middleware_classes.each do |klass|
89
+ after_methods_for(klass).each do |method_name|
90
+ result = klass.send(method_name, request, response)
91
+ if result.is_a?(Array) && result.length == 2
92
+ request, response = result
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def matches_pattern?(path, pattern)
101
+ return true if pattern.nil?
102
+ case pattern
103
+ when String
104
+ path.start_with?(pattern)
105
+ when Regexp
106
+ pattern.match?(path)
107
+ else
108
+ true
109
+ end
110
+ end
111
+
112
+ # Collect all public class methods matching before_*
113
+ def before_methods_for(klass)
114
+ klass.methods(false).select { |m| m.to_s.start_with?("before_") }.sort
115
+ end
116
+
117
+ # Collect all public class methods matching after_*
118
+ def after_methods_for(klass)
119
+ klass.methods(false).select { |m| m.to_s.start_with?("after_") }.sort
120
+ end
121
+ end
122
+ end
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Built-in class-based middleware
126
+ # ---------------------------------------------------------------------------
127
+
128
+ # CorsClassMiddleware -- sets CORS headers from env vars on every response.
129
+ # Uses the same config source as CorsMiddleware module.
130
+ class CorsClassMiddleware
131
+ class << self
132
+ def before_cors(request, response)
133
+ config = load_config
134
+ origin = resolve_origin(request, config)
135
+
136
+ response.headers["access-control-allow-origin"] = origin
137
+ response.headers["access-control-allow-methods"] = config[:methods]
138
+ response.headers["access-control-allow-headers"] = config[:headers]
139
+ response.headers["access-control-max-age"] = config[:max_age]
140
+ if config[:credentials] == "true"
141
+ response.headers["access-control-allow-credentials"] = "true"
142
+ end
143
+
144
+ [request, response]
145
+ end
146
+
147
+ private
148
+
149
+ def load_config
150
+ {
151
+ origins: ENV["TINA4_CORS_ORIGINS"] || "*",
152
+ methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
153
+ headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
154
+ max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
155
+ credentials: ENV["TINA4_CORS_CREDENTIALS"] || "false"
156
+ }
157
+ end
158
+
159
+ def is_preflight(request)
160
+ request.method&.upcase == "OPTIONS" &&
161
+ request.headers["origin"] &&
162
+ request.headers["access-control-request-method"]
163
+ end
164
+
165
+ def resolve_origin(request, config)
166
+ request_origin = request.headers["origin"] || request.headers["referer"]
167
+
168
+ if config[:origins] == "*"
169
+ "*"
170
+ elsif request_origin
171
+ allowed = config[:origins].split(",").map(&:strip)
172
+ clean = request_origin.chomp("/")
173
+ allowed.include?(clean) ? clean : allowed.first || "*"
174
+ else
175
+ config[:origins].split(",").first&.strip || "*"
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ # RateLimiterMiddleware -- tracks requests per IP, returns 429 when exceeded.
182
+ # Config via env: TINA4_RATE_LIMIT (default 100), TINA4_RATE_WINDOW (default 60s).
183
+ class RateLimiterMiddleware
184
+ @store = {}
185
+ @mutex = Mutex.new
186
+ @last_cleanup = Time.now
187
+
188
+ class << self
189
+ def before_rate_limit(request, response)
190
+ limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
191
+ window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
192
+ ip = request.ip || "unknown"
193
+ now = Time.now
194
+
195
+ cleanup_if_needed(now, window)
196
+
197
+ @mutex.synchronize do
198
+ @store[ip] ||= []
199
+ entries = @store[ip]
200
+
201
+ # Sliding window -- drop expired timestamps
202
+ cutoff = now - window
203
+ entries.reject! { |t| t < cutoff }
204
+
205
+ if entries.length >= limit
206
+ oldest = entries.first
207
+ retry_after = [(oldest + window - now).ceil, 1].max
208
+
209
+ response.headers["X-RateLimit-Limit"] = limit.to_s
210
+ response.headers["X-RateLimit-Remaining"] = "0"
211
+ response.headers["X-RateLimit-Reset"] = (oldest + window).to_i.to_s
212
+ response.headers["Retry-After"] = retry_after.to_s
213
+ response.json({ error: "Too Many Requests", retry_after: retry_after }, 429)
214
+
215
+ return [request, response]
216
+ end
217
+
218
+ entries << now
219
+
220
+ response.headers["X-RateLimit-Limit"] = limit.to_s
221
+ response.headers["X-RateLimit-Remaining"] = (limit - entries.length).to_s
222
+ response.headers["X-RateLimit-Reset"] = (now + window).to_i.to_s
223
+ end
224
+
225
+ [request, response]
226
+ end
227
+
228
+ def check(ip)
229
+ limit = (ENV["TINA4_RATE_LIMIT"] || 100).to_i
230
+ window = (ENV["TINA4_RATE_WINDOW"] || 60).to_i
231
+ now = Time.now
232
+
233
+ @mutex.synchronize do
234
+ @store[ip] ||= []
235
+ entries = @store[ip]
236
+ entries.reject! { |t| t < now - window }
237
+
238
+ remaining = [limit - entries.length, 0].max
239
+ reset_at = entries.empty? ? window : (entries.first + window - now).ceil
240
+
241
+ if entries.length >= limit
242
+ return [false, { limit: limit, remaining: 0, reset: reset_at, window: window }]
243
+ end
244
+
245
+ entries << now
246
+ [true, { limit: limit, remaining: remaining - 1, reset: window, window: window }]
247
+ end
248
+ end
249
+
250
+ # Allow resetting state (useful in tests)
251
+ def reset!
252
+ @mutex.synchronize { @store.clear }
253
+ end
254
+
255
+ private
256
+
257
+ def cleanup_if_needed(now, window)
258
+ return if now - @last_cleanup < window
259
+
260
+ @mutex.synchronize do
261
+ return if now - @last_cleanup < window
262
+
263
+ cutoff = now - window
264
+ @store.delete_if do |_ip, entries|
265
+ entries.reject! { |t| t < cutoff }
266
+ entries.empty?
267
+ end
268
+ @last_cleanup = now
269
+ end
270
+ end
271
+ end
272
+ end
273
+
274
+ # RequestLoggerMiddleware -- logs method, path, and elapsed time for every request.
275
+ class RequestLoggerMiddleware
276
+ @request_times = {}
277
+ @mutex = Mutex.new
278
+
279
+ class << self
280
+ def before_log(request, response)
281
+ request_key = "#{request.object_id}"
282
+ @mutex.synchronize do
283
+ @request_times[request_key] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
284
+ end
285
+ [request, response]
286
+ end
287
+
288
+ def after_log(request, response)
289
+ request_key = "#{request.object_id}"
290
+ start_time = @mutex.synchronize { @request_times.delete(request_key) }
291
+
292
+ if start_time
293
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(3)
294
+ else
295
+ elapsed_ms = 0.0
296
+ end
297
+
298
+ Tina4::Log.info("[RequestLogger] #{request.method} #{request.path} -> #{response.status_code} (#{elapsed_ms}ms)")
299
+ [request, response]
300
+ end
301
+
302
+ def reset!
303
+ @mutex.synchronize { @request_times.clear }
304
+ end
305
+ end
306
+ end
307
+
308
+ # CsrfMiddleware -- validates form tokens on state-changing requests.
309
+ #
310
+ # Off by default -- only active when TINA4_CSRF=true in .env or when
311
+ # registered explicitly via Router.use(CsrfMiddleware).
312
+ #
313
+ # Behaviour:
314
+ # - Skips GET, HEAD, OPTIONS requests.
315
+ # - Skips routes marked .no_auth.
316
+ # - Skips requests with a valid Authorization: Bearer header (API clients).
317
+ # - Checks request.body["formToken"] then request.headers["X-Form-Token"].
318
+ # - Rejects if token found in request.query["formToken"] (log warning, 403).
319
+ # - Validates token with Auth.valid_token using SECRET env var.
320
+ # - If token payload has session_id, verifies it matches request.session.session_id.
321
+ # - Returns 403 with response.json({error: "CSRF_INVALID", message: ...}, 403) on failure.
322
+ class CsrfMiddleware
323
+ class << self
324
+ def before_csrf(request, response)
325
+ # Allow disabling CSRF via env var
326
+ csrf_env = ENV["TINA4_CSRF"].to_s.downcase
327
+ return [request, response] if %w[false 0 no].include?(csrf_env)
328
+
329
+ # Skip safe HTTP methods
330
+ method = (request.method || "GET").upcase
331
+ return [request, response] if %w[GET HEAD OPTIONS].include?(method)
332
+
333
+ # Skip routes marked no_auth
334
+ handler = request.respond_to?(:handler) ? request.handler : nil
335
+ if handler
336
+ no_auth = if handler.is_a?(Hash)
337
+ handler[:no_auth] || handler[:noAuth]
338
+ elsif handler.respond_to?(:no_auth)
339
+ handler.no_auth
340
+ end
341
+ return [request, response] if no_auth
342
+ end
343
+
344
+ # Skip requests with valid Bearer token (API clients)
345
+ headers = request.respond_to?(:headers) ? request.headers : {}
346
+ auth_header = headers["authorization"] || headers["Authorization"] || ""
347
+ if auth_header.start_with?("Bearer ")
348
+ bearer_token = auth_header[7..].strip
349
+ unless bearer_token.empty?
350
+ return [request, response] if Tina4::Auth.valid_token(bearer_token)
351
+ end
352
+ end
353
+
354
+ # Reject if token is in query string (security risk)
355
+ query = if request.respond_to?(:params)
356
+ request.params
357
+ elsif request.respond_to?(:query)
358
+ request.query
359
+ else
360
+ {}
361
+ end
362
+ query ||= {}
363
+
364
+ if query.is_a?(Hash) && query["formToken"] && !query["formToken"].to_s.empty?
365
+ Tina4::Log.warning("[CSRF] Token found in query string — rejected for security")
366
+ response.json({ error: "CSRF_INVALID", message: "Form token must not be sent in the URL query string" }, 403)
367
+ return [request, response]
368
+ end
369
+
370
+ # Extract token: body first, then header
371
+ token = nil
372
+ body = request.respond_to?(:body) ? request.body : nil
373
+ body ||= {}
374
+ token = body["formToken"] if body.is_a?(Hash)
375
+
376
+ if token.nil? || token.to_s.empty?
377
+ token = headers["X-Form-Token"] || headers["x-form-token"] || ""
378
+ end
379
+
380
+ if token.nil? || token.to_s.empty?
381
+ response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
382
+ return [request, response]
383
+ end
384
+
385
+ # Validate the token
386
+ unless Tina4::Auth.valid_token(token.to_s)
387
+ response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
388
+ return [request, response]
389
+ end
390
+
391
+ # Session binding — if token has session_id, verify it matches
392
+ payload = Tina4::Auth.get_payload(token.to_s) || {}
393
+ token_session_id = payload["session_id"]
394
+ if token_session_id
395
+ current_session_id = nil
396
+ session = request.respond_to?(:session) ? request.session : nil
397
+ if session
398
+ current_session_id = if session.respond_to?(:session_id)
399
+ session.session_id
400
+ elsif session.is_a?(Hash)
401
+ session["session_id"]
402
+ elsif session.respond_to?(:get)
403
+ session.get("session_id")
404
+ end
405
+ end
406
+
407
+ if current_session_id && token_session_id != current_session_id
408
+ response.json({ error: "CSRF_INVALID", message: "Invalid or missing form token" }, 403)
409
+ return [request, response]
410
+ end
411
+ end
412
+
413
+ [request, response]
414
+ end
415
+ end
416
+ end
417
+
418
+ # SecurityHeadersMiddleware -- injects security headers on every response.
419
+ # Config via env:
420
+ # TINA4_FRAME_OPTIONS — X-Frame-Options (default: SAMEORIGIN)
421
+ # TINA4_HSTS — Strict-Transport-Security max-age (default: "" = off)
422
+ # TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
423
+ # TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
424
+ # TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
425
+ class SecurityHeadersMiddleware
426
+ class << self
427
+ def before_security(request, response)
428
+ response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
429
+ response.headers["X-Content-Type-Options"] = "nosniff"
430
+
431
+ hsts = ENV["TINA4_HSTS"] || ""
432
+ unless hsts.empty?
433
+ response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
434
+ end
435
+
436
+ response.headers["Content-Security-Policy"] = ENV["TINA4_CSP"] || "default-src 'self'"
437
+ response.headers["Referrer-Policy"] = ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"
438
+ response.headers["X-XSS-Protection"] = "0"
439
+ response.headers["Permissions-Policy"] = ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"
440
+
441
+ [request, response]
442
+ end
443
+ end
444
+ end
445
+ end