tina4ruby 3.13.97 → 3.13.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/lib/tina4/ai.rb +32 -3
  4. data/lib/tina4/api.rb +5 -0
  5. data/lib/tina4/auto_crud.rb +62 -4
  6. data/lib/tina4/background.rb +112 -31
  7. data/lib/tina4/cache.rb +3 -2
  8. data/lib/tina4/cli.rb +55 -67
  9. data/lib/tina4/database.rb +97 -49
  10. data/lib/tina4/database_adapter.rb +169 -15
  11. data/lib/tina4/dev_admin.rb +137 -9
  12. data/lib/tina4/dispatch_pipeline.rb +145 -4
  13. data/lib/tina4/drivers/firebird_driver.rb +59 -12
  14. data/lib/tina4/drivers/mongodb_driver.rb +98 -14
  15. data/lib/tina4/drivers/mssql_driver.rb +39 -2
  16. data/lib/tina4/drivers/mysql_driver.rb +43 -3
  17. data/lib/tina4/drivers/odbc_driver.rb +36 -2
  18. data/lib/tina4/drivers/postgres_driver.rb +5 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +11 -1
  20. data/lib/tina4/env.rb +1 -1
  21. data/lib/tina4/error_overlay.rb +43 -49
  22. data/lib/tina4/field_types.rb +33 -16
  23. data/lib/tina4/frond.rb +24 -2
  24. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
  25. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
  26. data/lib/tina4/graphql.rb +2 -2
  27. data/lib/tina4/log.rb +652 -485
  28. data/lib/tina4/mcp.rb +9 -1
  29. data/lib/tina4/messenger.rb +25 -0
  30. data/lib/tina4/middleware.rb +189 -76
  31. data/lib/tina4/migration.rb +47 -15
  32. data/lib/tina4/orm.rb +280 -59
  33. data/lib/tina4/port_takeover.rb +202 -0
  34. data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
  35. data/lib/tina4/rack_app.rb +201 -59
  36. data/lib/tina4/realtime.rb +6 -1
  37. data/lib/tina4/request.rb +259 -51
  38. data/lib/tina4/router.rb +20 -2
  39. data/lib/tina4/seeder.rb +68 -19
  40. data/lib/tina4/shutdown.rb +4 -0
  41. data/lib/tina4/sql_translator.rb +115 -86
  42. data/lib/tina4/swagger.rb +19 -3
  43. data/lib/tina4/template.rb +61 -6
  44. data/lib/tina4/test_client.rb +49 -3
  45. data/lib/tina4/testing.rb +16 -11
  46. data/lib/tina4/validator.rb +7 -1
  47. data/lib/tina4/version.rb +1 -1
  48. data/lib/tina4/webserver.rb +28 -40
  49. data/lib/tina4.rb +12 -1
  50. metadata +3 -19
  51. data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
  52. data/lib/tina4/scss/tina4css/_badges.scss +0 -22
  53. data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
  54. data/lib/tina4/scss/tina4css/_cards.scss +0 -49
  55. data/lib/tina4/scss/tina4css/_forms.scss +0 -156
  56. data/lib/tina4/scss/tina4css/_grid.scss +0 -81
  57. data/lib/tina4/scss/tina4css/_modals.scss +0 -84
  58. data/lib/tina4/scss/tina4css/_nav.scss +0 -149
  59. data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
  60. data/lib/tina4/scss/tina4css/_reset.scss +0 -94
  61. data/lib/tina4/scss/tina4css/_tables.scss +0 -54
  62. data/lib/tina4/scss/tina4css/_typography.scss +0 -55
  63. data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
  64. data/lib/tina4/scss/tina4css/_variables.scss +0 -117
  65. data/lib/tina4/scss/tina4css/base.scss +0 -1
  66. data/lib/tina4/scss/tina4css/colors.scss +0 -48
  67. data/lib/tina4/scss/tina4css/tina4.scss +0 -18
@@ -3,6 +3,7 @@ require "json"
3
3
  require "securerandom"
4
4
  require "time"
5
5
  require "uri"
6
+ require "cgi"
6
7
 
7
8
  module Tina4
8
9
  # Middleware wrapper that tags requests arriving on the AI dev port.
@@ -31,7 +32,11 @@ module Tina4
31
32
  attr_accessor :current
32
33
  end
33
34
 
34
- STATIC_DIRS = %w[public src/public src/assets assets].freeze
35
+ # ONE static search set + order across the four frameworks
36
+ # (ST-SEARCHDIR-DIVERGE): the app's public then src/public. TINA4_PUBLIC_DIR
37
+ # is prepended per-request in #try_static, and the framework's bundled public
38
+ # dir is appended as the fallback in #initialize.
39
+ STATIC_DIRS = %w[public src/public].freeze
35
40
 
36
41
  # CORS is now handled by Tina4::CorsMiddleware
37
42
 
@@ -80,11 +85,36 @@ module Tina4
80
85
  Tina4::Router.websocket("/__dev_reload", &DEV_RELOAD_WS_HANDLER)
81
86
  end
82
87
 
88
+ # Rack entry point. Establishes the per-request correlation id (feature 43),
89
+ # runs the dispatch pipeline, and echoes the id on the response.
90
+ def call(env)
91
+ # Honour a sanitized inbound X-Request-ID so a client or upstream service
92
+ # can thread its own id through - a CR/LF, over-long or illegal-charset
93
+ # value is rejected (never echoed) - else generate one. Thread it into the
94
+ # logger NOW (thread-local, so a Puma worker thread never sees another
95
+ # request's id), so every log line for this request carries it, and echo it
96
+ # on the response whatever outcome the pipeline produced (200/404/500/413).
97
+ request_id = Tina4::Log.sanitize_request_id(env["HTTP_X_REQUEST_ID"]) || SecureRandom.hex(4)
98
+ Tina4::Log.set_request_id(request_id)
99
+
100
+ begin
101
+ result = dispatch_pipeline(env)
102
+ result[1]["x-request-id"] = request_id if result.is_a?(Array) && result[1].is_a?(Hash)
103
+ result
104
+ ensure
105
+ # The request pipeline installs the id before its first log and
106
+ # clears it in `finally`/`ensure` after its last (Decision 12 /
107
+ # LOG-Q03), so an overlapping request on another thread can never
108
+ # observe a stale id from a request that already finished.
109
+ Tina4::Log.clear_request_id
110
+ end
111
+ end
112
+
83
113
  # Run the dispatch pipeline. See REQUEST_STAGES / RESPONSE_STAGES above.
84
114
  #
85
115
  # Every branch this used to hold now lives in a named stage, so the only
86
116
  # control flow left here is "walk the list, stop when a stage answers".
87
- def call(env)
117
+ def dispatch_pipeline(env)
88
118
  ctx = DispatchContext.new(
89
119
  env: env,
90
120
  method: env["REQUEST_METHOD"],
@@ -188,6 +218,12 @@ module Tina4
188
218
  def try_static(path, env = nil)
189
219
  return nil if path.include?("..")
190
220
 
221
+ # Security: never serve a dotfile (.env, .git/config, .htpasswd, ...).
222
+ # Reject any leading-dot segment in the REQUEST path before touching the
223
+ # filesystem; #confined_static_file re-checks the RESOLVED path so a symlink
224
+ # pointing AT a dotfile inside the public dir is refused too.
225
+ return nil if hidden_segment?(path)
226
+
191
227
  # The framework ships the Swagger UI as STATIC assets under
192
228
  # lib/tina4/public/swagger/. Static serving is independent of the gated
193
229
  # /swagger handler, so without this check the UI stays reachable in
@@ -201,23 +237,52 @@ module Tina4
201
237
  return nil
202
238
  end
203
239
 
204
- @static_roots.each do |root|
205
- full_path = File.join(root, path)
206
- if File.file?(full_path)
207
- return serve_static_file(full_path, env)
208
- end
240
+ # TINA4_PUBLIC_DIR override is honoured first (ST-PUBLICDIR-ENV-PARTIAL),
241
+ # then the pre-computed project + framework roots.
242
+ roots = @static_roots
243
+ custom = ENV["TINA4_PUBLIC_DIR"]
244
+ roots = [custom] + roots if custom && !custom.empty?
245
+
246
+ roots.each do |root|
247
+ served = confined_static_file(root, path, env)
248
+ return served if served
209
249
 
210
250
  # Only try index.html for directory-like paths
211
251
  if path.end_with?("/") || !path.include?(".")
212
- index_path = File.join(full_path, "index.html")
213
- if File.file?(index_path)
214
- return serve_static_file(index_path, env)
215
- end
252
+ served = confined_static_file(root, File.join(path, "index.html"), env)
253
+ return served if served
216
254
  end
217
255
  end
218
256
  nil
219
257
  end
220
258
 
259
+ # Serve a file under `root` ONLY when its REAL path stays confined under the
260
+ # real `root` (realpath + trailing separator, ADR-0050) and names no dotfile
261
+ # segment. File.realpath follows symlinks and collapses `..`, so a symlink
262
+ # pointing outside, a sibling-prefix dir (publicsecret) and a `..` escape all
263
+ # fail the containment a bare string check would miss. Returns the Rack
264
+ # response triple, or nil when the file is absent, escapes, or is hidden.
265
+ def confined_static_file(root, rel_path, env)
266
+ real_dir = File.realpath(root)
267
+ real_path = File.realpath(File.join(root, rel_path))
268
+ return nil unless File.file?(real_path)
269
+ return nil unless real_path.start_with?(real_dir + File::SEPARATOR)
270
+
271
+ relative = real_path[(real_dir.length + 1)..] || ""
272
+ return nil if hidden_segment?(relative)
273
+
274
+ serve_static_file(real_path, env)
275
+ rescue SystemCallError
276
+ # realpath raises when a path component is missing — a plain 404, not an error.
277
+ nil
278
+ end
279
+
280
+ # Whether any separator-delimited segment of `path` is hidden (begins with a
281
+ # dot). Refuses `.env`/`.git`; a `..` segment also begins with a dot.
282
+ def hidden_segment?(path)
283
+ path.split(File::SEPARATOR).any? { |segment| !segment.empty? && segment.start_with?(".") }
284
+ end
285
+
221
286
  # Serve a static asset with cache-revalidation semantics (parity with the
222
287
  # Python master). A static file may be cached by the browser but MUST be
223
288
  # revalidated before use, so a redeployed asset reaches the browser on the
@@ -234,7 +299,11 @@ module Tina4
234
299
  content_type = Tina4::Response::MIME_TYPES[ext] || "application/octet-stream"
235
300
 
236
301
  stat = File.stat(full_path)
237
- etag = %(W/"#{stat.mtime.to_i.to_s(16)}-#{stat.size.to_s(16)}")
302
+ # Format PINNED across all four frameworks (feature 40, CE-DEC-02):
303
+ # decimal `W/"<size>-<mtime>"`, integer-second mtime - a client behind
304
+ # a reverse proxy sees an identical validator for the same file
305
+ # regardless of backend language.
306
+ etag = %(W/"#{stat.size}-#{stat.mtime.to_i}")
238
307
  last_modified = stat.mtime.httpdate
239
308
 
240
309
  headers = {
@@ -262,10 +331,10 @@ module Tina4
262
331
  def static_not_modified?(env, etag, mtime)
263
332
  if_none_match = env["HTTP_IF_NONE_MATCH"]
264
333
  if if_none_match && !if_none_match.empty?
265
- return true if if_none_match.strip == "*"
266
-
267
- want = weak_etag_value(etag)
268
- return if_none_match.split(",").any? { |candidate| weak_etag_value(candidate) == want }
334
+ # Shared with the dynamic conditional-GET path (#etag_matches? in
335
+ # DispatchPipeline, feature 40) so both use IDENTICAL RFC-7232
336
+ # weak-comparison semantics.
337
+ return etag_matches?(if_none_match, etag)
269
338
  end
270
339
 
271
340
  if_modified_since = env["HTTP_IF_MODIFIED_SINCE"]
@@ -326,12 +395,42 @@ module Tina4
326
395
  [200, { "content-type" => "application/json; charset=utf-8" }, [JSON.generate(Tina4::Swagger.generate)]]
327
396
  end
328
397
 
329
- def handle_403(path = "")
330
- body = Tina4::Template.render_error(403, { "path" => path }) rescue "403 Forbidden"
398
+ # The ONE JSON error envelope for a negotiated 403/404/500 (ERR-DEC-02).
399
+ #
400
+ # Reuses the existing Tina4::Response.error_response envelope
401
+ # (error: true, code, message, status) already shared by app-level
402
+ # response.error() calls, plus request_id for correlation (feature 43,
403
+ # ERR-404-REQUESTID) - the SAME shape Python/PHP/Node build.
404
+ ERROR_CODE_NAMES = { 403 => "FORBIDDEN", 404 => "NOT_FOUND", 500 => "INTERNAL_SERVER_ERROR" }.freeze
405
+
406
+ def error_json_body(status, message, request_id)
407
+ Tina4::Response.error_response(
408
+ ERROR_CODE_NAMES[status] || "HTTP_#{status}", message, status
409
+ ).merge(request_id: request_id)
410
+ end
411
+
412
+ # Build the 403 a legacy per-route auth_handler gets. ERR-DEC-01/
413
+ # ERR-DEC-02: negotiated on Accept - a JSON API client gets the canonical
414
+ # JSON envelope directly (no template attempt at all); a browser gets the
415
+ # SAME errors/403.twig-or-fallback HTML path 404/500 use. The path is
416
+ # escaped before it ever reaches the template (Ruby's Frond does not
417
+ # auto-escape {{ }} output - same reflected-path XSS class feature 127
418
+ # already fixed for #handle_404; this closes the SAME gap on 403).
419
+ def handle_403(path = "", accept = "")
420
+ request_id = Tina4::Log.get_request_id || "-"
421
+ if Tina4::Template.wants_json?(accept)
422
+ return [403, { "content-type" => "application/json" }, [JSON.generate(error_json_body(403, "Forbidden", request_id))]]
423
+ end
424
+
425
+ body = begin
426
+ Tina4::Template.render_error(403, { "path" => CGI.escapeHTML(path.to_s), "request_id" => request_id })
427
+ rescue StandardError
428
+ "403 Forbidden"
429
+ end
331
430
  [403, { "content-type" => "text/html" }, [body]]
332
431
  end
333
432
 
334
- def handle_404(path)
433
+ def handle_404(path, accept = "")
335
434
  # Try serving a template file (e.g. /hello -> src/templates/pages/hello.twig)
336
435
  template_response = try_serve_template(path)
337
436
  return template_response if template_response
@@ -342,7 +441,25 @@ module Tina4
342
441
  return render_landing_page if path == "/" && dev_mode?
343
442
 
344
443
  Tina4::Log.warning("404 Not Found: #{path}")
345
- body = Tina4::Template.render_error(404, { "path" => path }) rescue "404 Not Found"
444
+ request_id = Tina4::Log.get_request_id || "-"
445
+
446
+ # ERR-DEC-02: a JSON API client gets the JSON error body directly - no
447
+ # need to even try the HTML template. ERR-404-REQUESTID: the id now
448
+ # rides in the body too (the response header already carries it
449
+ # unconditionally, feature 43).
450
+ if Tina4::Template.wants_json?(accept)
451
+ return [404, { "content-type" => "application/json" }, [JSON.generate(error_json_body(404, "Not Found", request_id))]]
452
+ end
453
+
454
+ # DEVADMIN-DEC-04 (feature 127): the error page reflects the request path
455
+ # into HTML and the Twig error template does NOT auto-escape, so a crafted
456
+ # /x<script> path would run script in the response origin. Escape it here
457
+ # (same reflected-request-path XSS class as the dev-toolbar fix).
458
+ body = begin
459
+ Tina4::Template.render_error(404, { "path" => CGI.escapeHTML(path.to_s), "request_id" => request_id })
460
+ rescue StandardError
461
+ "404 Not Found"
462
+ end
346
463
  [404, { "content-type" => "text/html" }, [body]]
347
464
  end
348
465
 
@@ -354,12 +471,6 @@ module Tina4
354
471
  !%w[off false 0 no disabled].include?(val)
355
472
  end
356
473
 
357
- def should_show_landing_page?
358
- # Check if any index template exists in src/templates/
359
- templates_dir = File.join(@root_dir, "src", "templates")
360
- %w[index.html index.twig index.erb].none? { |f| File.file?(File.join(templates_dir, f)) }
361
- end
362
-
363
474
  def try_serve_template(path)
364
475
  tpl_file = resolve_template(path)
365
476
  return nil unless tpl_file
@@ -431,18 +542,6 @@ module Tina4
431
542
  cache
432
543
  end
433
544
 
434
- def try_serve_index_template
435
- templates_dir = File.join(@root_dir, "src", "templates")
436
- %w[index.html index.twig index.erb].each do |f|
437
- path = File.join(templates_dir, f)
438
- if File.file?(path)
439
- body = Tina4::Template.render(f, {}) rescue File.read(path)
440
- return [200, { "content-type" => "text/html" }, [body]]
441
- end
442
- end
443
- nil
444
- end
445
-
446
545
  def render_landing_page
447
546
  port = ENV["PORT"] || "7145"
448
547
 
@@ -687,23 +786,61 @@ module Tina4
687
786
  end
688
787
  end
689
788
 
789
+ # The canonical per-request id (set at the top of #call), so the id a
790
+ # user reports off the 500 page matches the log lines and the
791
+ # X-Request-ID response header - not a throwaway.
792
+ request_id = Tina4::Log.get_request_id || SecureRandom.hex(4)
793
+ accept = env && env["HTTP_ACCEPT"]
794
+
795
+ # v3.13.7 SECURITY (CWE-209): production response body must NOT contain the
796
+ # stack trace. The trace stays in Log.error above and reaches observability
797
+ # via the tina4.request.error event. error_message is ALWAYS empty here -
798
+ # never touch this: a JSON client's message field stays the generic
799
+ # "Server Error" too, never the real exception (ERR-DEC-02).
800
+ safe_response = lambda do
801
+ if Tina4::Template.wants_json?(accept)
802
+ next [500, { "content-type" => "application/json" }, [JSON.generate(error_json_body(500, "Server Error", request_id))]]
803
+ end
804
+
805
+ body = begin
806
+ Tina4::Template.render_error(500, { "error_message" => "", "request_id" => request_id })
807
+ rescue StandardError
808
+ "500 Internal Server Error"
809
+ end
810
+ [500, { "content-type" => "text/html" }, [body]]
811
+ end
812
+
690
813
  if dev_mode?
691
- # Rich error overlay with stack trace, source context, and line numbers
692
- body = Tina4::ErrorOverlay.render_error_overlay(error, request: env)
693
- else
694
- # v3.13.7 SECURITY (CWE-209): production response body must NOT
695
- # contain the stack trace. The trace stays in Log.error above
696
- # and reaches observability via the tina4.request.error event.
697
- body = Tina4::Template.render_error(500, {
698
- "error_message" => "",
699
- "request_id" => SecureRandom.hex(6)
700
- }) rescue "500 Internal Server Error"
814
+ # OVERLAY-DEC-03: guard the dev-overlay render. The call site sits INSIDE
815
+ # this rescue, so if the overlay itself throws (a malformed frame, an
816
+ # unrenderable request value) it would double-fault out of dispatch. Wrap it
817
+ # and fall back to the same safe production page, so a broken overlay still
818
+ # yields a bounded 500 — never a crash. The overlay itself is unaffected by
819
+ # content negotiation (feature 126, out of scope here; CWE-209 stays locked).
820
+ begin
821
+ body = Tina4::ErrorOverlay.render_error_overlay(error, request: env)
822
+ return [500, { "content-type" => "text/html" }, [body]]
823
+ rescue StandardError => overlay_err
824
+ begin
825
+ Tina4::Log.warning(
826
+ "Error overlay render failed, serving the safe page: " \
827
+ "#{overlay_err.class}: #{overlay_err.message}"
828
+ )
829
+ rescue StandardError
830
+ # Log failures must never block the 500 render.
831
+ end
832
+ return safe_response.call
833
+ end
701
834
  end
702
- [500, { "content-type" => "text/html" }, [body]]
835
+
836
+ safe_response.call
703
837
  end
704
838
 
839
+ # OVERLAY-DEC-04: unify the debug gate on the overlay module's is_debug_mode so
840
+ # the error-overlay gate (and every other dev gate that reads dev_mode?) has ONE
841
+ # definition, instead of recomputing Env.is_truthy(TINA4_DEBUG) separately.
705
842
  def dev_mode?
706
- Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
843
+ Tina4::ErrorOverlay.is_debug_mode
707
844
  end
708
845
 
709
846
  # Whether to emit a per-request log line (v3.13.14). TINA4_LOG_REQUESTS
@@ -780,9 +917,15 @@ module Tina4
780
917
 
781
918
  def inject_dev_overlay(body, request_info, ai_port: false)
782
919
  version = Tina4::VERSION
783
- method = request_info[:method]
784
- path = request_info[:path]
785
- matched_pattern = request_info[:matched_pattern]
920
+ # DEVADMIN-DEC-04 (feature 127): the toolbar is injected into EVERY
921
+ # text/html response (including 404s), so the reflected request
922
+ # method/path/matched-pattern MUST be HTML-escaped or a crafted path
923
+ # reflects <script> that runs in the dev-server origin and can then drive
924
+ # every ungated /__dev mutation route. (Parity with PHP htmlspecialchars
925
+ # and the Python master html.escape; Ruby was reflecting them raw.)
926
+ method = CGI.escapeHTML(request_info[:method].to_s)
927
+ path = CGI.escapeHTML(request_info[:path].to_s)
928
+ matched_pattern = CGI.escapeHTML(request_info[:matched_pattern].to_s)
786
929
  request_id = Tina4::Log.get_request_id || "-"
787
930
  route_count = Tina4::Router.routes.length
788
931
 
@@ -1062,12 +1205,11 @@ module Tina4
1062
1205
  end
1063
1206
 
1064
1207
  def self._read_rack_body(env)
1065
- input = env["rack.input"]
1066
- return "" unless input
1067
- input.rewind if input.respond_to?(:rewind)
1068
- body = input.read || ""
1069
- input.rewind if input.respond_to?(:rewind)
1070
- body
1208
+ # Route through the capped reader so the running per-chunk upload cap is
1209
+ # enforced on the form-token / body path too: an over-limit body raises
1210
+ # PayloadTooLarge (rescued into 413 by dispatch_pipeline) instead of being
1211
+ # read whole.
1212
+ Tina4::Request.read_stream_capped(env["rack.input"])
1071
1213
  end
1072
1214
 
1073
1215
  # Extract a formToken from the request body.
@@ -301,7 +301,12 @@ module Tina4
301
301
  body = request.body
302
302
  return body[name] if body.is_a?(Hash) && body.key?(name)
303
303
 
304
- (request.query && request.query[name]) || (request.params && request.params[name.to_sym])
304
+ # Its only caller is on POST #{paths['files']}, a route with no path
305
+ # segments — a `request.params` fallback here would only ever have
306
+ # matched a route param that this path can never have, so it is not
307
+ # carried forward now that `params` is route-only (REQ-PARAM-POLLUTION,
308
+ # 3.13.99): body, then the query string, is the complete real source list.
309
+ request.query && request.query[name]
305
310
  end
306
311
 
307
312
  def parse_json(data)