tina4ruby 3.11.13 → 3.11.15

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 (132) 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 +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  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 -106
  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 +2025 -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 +696 -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/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -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