tina4ruby 3.13.137 → 3.13.139

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 (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +74 -8
  12. data/lib/tina4/auth.rb +108 -26
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +14 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +9 -1
  26. data/lib/tina4/cli.rb +27 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +222 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +16 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +14 -6
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +200 -58
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +134 -35
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +21 -2
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +18 -9
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +27 -10
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +22 -4
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +18 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
  112. data/lib/tina4/rack_app.rb +62 -50
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +15 -1
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +90 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +42 -12
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +52 -12
  146. data/lib/tina4/ssrf.rb +148 -0
  147. data/lib/tina4/swagger.rb +6 -0
  148. data/lib/tina4/template.rb +6 -0
  149. data/lib/tina4/test.rb +8 -2
  150. data/lib/tina4/test_client.rb +8 -1
  151. data/lib/tina4/testing.rb +8 -1
  152. data/lib/tina4/validator.rb +6 -0
  153. data/lib/tina4/version.rb +7 -1
  154. data/lib/tina4/webserver.rb +25 -143
  155. data/lib/tina4/websocket.rb +170 -20
  156. data/lib/tina4/websocket_backplane.rb +25 -2
  157. data/lib/tina4/wsdl.rb +54 -18
  158. data/lib/tina4/xml_parser.rb +268 -0
  159. data/lib/tina4.rb +98 -33
  160. data/lib/tina4ruby.rb +6 -0
  161. metadata +44 -140
data/lib/tina4/request.rb CHANGED
@@ -1,8 +1,16 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "uri"
3
9
  require "json"
4
10
  require "ipaddr"
5
11
  require "stringio"
12
+ require_relative "parse_json"
13
+ require_relative "form_parser"
6
14
 
7
15
  module Tina4
8
16
  # A Hash subclass that supports indifferent access (both string and symbol keys).
@@ -122,24 +130,36 @@ module Tina4
122
130
  # reads route.auth_required to honour a public write route (.no_auth).
123
131
  attr_accessor :user, :route
124
132
 
125
- # Maximum upload size in bytes (default 10 MB). Override via TINA4_MAX_UPLOAD_SIZE env var.
126
- TINA4_MAX_UPLOAD_SIZE = Integer(ENV.fetch("TINA4_MAX_UPLOAD_SIZE", 10_485_760))
133
+ # Default upload cap in bytes (10 MB). The live cap is max_upload_size, which
134
+ # reads TINA4_MAX_UPLOAD_SIZE when it is used (ADR-0072). This constant used
135
+ # to read the environment at require time, which raised on a bad value.
136
+ TINA4_MAX_UPLOAD_SIZE = 10_485_760
127
137
 
128
138
  class PayloadTooLarge < StandardError; end
129
139
 
130
140
  # Effective upload cap in bytes. Read at CALL TIME (not frozen into the
131
141
  # constant at load) so the limit honours a TINA4_MAX_UPLOAD_SIZE set after
132
142
  # this file was required, and so a test can lower it. Falls back to the
133
- # constant default when the env var is unset/blank.
143
+ # constant default when the env var is unset/blank. A value that is not a
144
+ # positive whole number warns once and uses the default (ADR-0068): to_i
145
+ # used to turn "abc" into 0, which refused every request with a body.
134
146
  def self.max_upload_size
135
- value = ENV["TINA4_MAX_UPLOAD_SIZE"]
136
- value.nil? || value.empty? ? TINA4_MAX_UPLOAD_SIZE : value.to_i
147
+ raw = ENV["TINA4_MAX_UPLOAD_SIZE"].to_s.strip
148
+ return TINA4_MAX_UPLOAD_SIZE if raw.empty?
149
+ return raw.to_i if raw.match?(/\A\d+\z/) && raw.to_i.positive?
150
+
151
+ @warned_upload_limits ||= {}
152
+ unless @warned_upload_limits[raw]
153
+ @warned_upload_limits[raw] = true
154
+ Tina4::Log.warning("TINA4_MAX_UPLOAD_SIZE=#{raw} is not a usable limit - using #{TINA4_MAX_UPLOAD_SIZE}")
155
+ end
156
+ TINA4_MAX_UPLOAD_SIZE
137
157
  end
138
158
 
139
159
  # Read an IO in bounded chunks, raising PayloadTooLarge the MOMENT the
140
160
  # running total exceeds the upload cap, so an over-limit body is refused as
141
161
  # it arrives instead of after the whole thing is buffered. Rewinds the input
142
- # before and after so a later reader (Rack's parser, form-token extraction)
162
+ # before and after so a later reader (the form parser, form-token extraction)
143
163
  # sees the same stream. Parity with the Python/Node per-chunk body readers.
144
164
  def self.read_stream_capped(input, limit = nil)
145
165
  return "" unless input
@@ -258,7 +278,7 @@ module Tina4
258
278
  # @return [Boolean] true when the client's scheme is https.
259
279
  def self.secure_scheme?(env)
260
280
  forwarded = (env["HTTP_X_FORWARDED_PROTO"] || "").to_s
261
- unless forwarded.strip.empty?
281
+ if Tina4.trusted_proxy?(env["REMOTE_ADDR"].to_s) && !forwarded.strip.empty?
262
282
  return forwarded.split(",").first.to_s.strip.casecmp("https").zero?
263
283
  end
264
284
  scheme = (env["rack.url_scheme"] || "").to_s
@@ -272,7 +292,13 @@ module Tina4
272
292
  # still see the URL the client used. Matches Python/PHP/Node parity.
273
293
  def url
274
294
  scheme = self.class.secure_scheme?(env) ? "https" : "http"
275
- host = env["HTTP_X_FORWARDED_HOST"] || env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
295
+ # X-Forwarded-Host is honoured ONLY when the raw socket peer is a trusted
296
+ # proxy (TINA4_TRUSTED_PROXIES) — the same gate already applied to
297
+ # X-Forwarded-For. An untrusted client can otherwise forge the host and
298
+ # control the absolute request.url (password-reset links, cache keys,
299
+ # open-redirect base). Parity with Python/PHP/Node.
300
+ forwarded_host = Tina4.trusted_proxy?(@remote_ip) ? env["HTTP_X_FORWARDED_HOST"] : nil
301
+ host = forwarded_host || env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
276
302
  port = env["SERVER_PORT"]
277
303
  url_str = "#{scheme}://#{host}"
278
304
  # Only append :port when the host doesn't already include one
@@ -395,7 +421,7 @@ module Tina4
395
421
 
396
422
  def json_body
397
423
  @json_body ||= begin
398
- JSON.parse(body_raw)
424
+ Tina4.parse_json(body_raw)
399
425
  rescue JSON::ParserError, TypeError
400
426
  {}
401
427
  end
@@ -474,36 +500,47 @@ module Tina4
474
500
  data
475
501
  end
476
502
 
477
- # Resolve the parsed multipart form fields (+ file entries) for this request.
503
+ # The parsed multipart body, memoised: { fields:, files:, error: } from
504
+ # Tina4::FormParser, or nil when there is no raw body to parse.
478
505
  #
479
- # A live Rack server (Puma/WEBrick) does NOT parse the request body for us -
480
- # it hands the app the raw `rack.input` stream. `Rack::Request#POST` parses
481
- # a multipart (or urlencoded) body and caches the result into the standard
482
- # `rack.request.form_hash` env key, which both #parse_body and #extract_files
483
- # then read. We only invoke it when the key isn't already populated, so a
484
- # spec that injects `rack.request.form_hash` directly still short-circuits
485
- # here (and we never re-parse a body twice). Without this, live multipart
486
- # uploads arrived empty (fields AND files) - the parse only ran in specs.
506
+ # A live server hands the app the raw `rack.input` stream and parses nothing,
507
+ # so Tina4 parses it here. The body is read through read_stream_capped, so the
508
+ # running upload cap refuses an over-limit body as it arrives - OUTSIDE any
509
+ # rescue on purpose: PayloadTooLarge must reach the 413 handler.
510
+ def multipart_parsed
511
+ return @multipart_parsed if defined?(@multipart_parsed)
512
+
513
+ body = Tina4::Request.read_stream_capped(@env["rack.input"])
514
+ @multipart_parsed =
515
+ if body.nil? || body.empty?
516
+ nil
517
+ else
518
+ parsed = Tina4::FormParser.parse_multipart(body, @content_type)
519
+ if parsed[:error] && defined?(Tina4::Log)
520
+ Tina4::Log.warning("multipart parse failed: #{parsed[:error].message}")
521
+ end
522
+ parsed
523
+ end
524
+ end
525
+
526
+ # The request's form hash: fields plus file entries under the standard
527
+ # `rack.request.form_hash` env key, the same key (and shape) Rack::Request#POST
528
+ # used to fill, so middleware reading it keeps working. A spec or middleware
529
+ # that INJECTS the key short-circuits the parse entirely.
487
530
  def multipart_form_hash
488
531
  existing = @env["rack.request.form_hash"] rescue nil
489
532
  return existing if existing
490
533
 
491
- # Enforce the running per-chunk upload cap BEFORE Rack reads the body, so
492
- # an over-limit body is refused as it arrives rather than after Rack has
493
- # buffered the whole thing. Outside the begin/rescue below on purpose: the
494
- # PayloadTooLarge must propagate to the 413 handler, not be swallowed.
495
- Tina4::Request.read_stream_capped(@env["rack.input"])
496
-
497
- parsed = begin
498
- require "rack"
499
- Rack::Request.new(@env).POST
500
- rescue Tina4::Request::PayloadTooLarge
501
- raise
502
- rescue StandardError => e
503
- Tina4::Log.warning("multipart parse failed: #{e.message}") if defined?(Tina4::Log)
504
- nil
534
+ parsed = multipart_parsed
535
+ return nil unless parsed
536
+
537
+ form = parsed[:fields].dup
538
+ parsed[:files].each do |name, list|
539
+ # Rack left an empty file field (filename="") out of the form hash.
540
+ kept = list.reject { |descriptor| descriptor[:filename].empty? }
541
+ form[name] = kept.length == 1 ? kept.first : kept unless kept.empty?
505
542
  end
506
- @env["rack.request.form_hash"] || parsed
543
+ @env["rack.request.form_hash"] = form
507
544
  end
508
545
 
509
546
  def parse_body
@@ -521,14 +558,14 @@ module Tina4
521
558
  # NOT delegating to #json_body: that method keeps its own documented
522
559
  # always-a-Hash, {}-on-failure contract for direct callers.
523
560
  begin
524
- JSON.parse(body_raw)
561
+ Tina4.parse_json(body_raw)
525
562
  rescue JSON::ParserError, TypeError
526
563
  body_raw
527
564
  end
528
565
  elsif @content_type.include?("application/x-www-form-urlencoded")
529
566
  parse_query_to_hash(body_raw)
530
567
  elsif @content_type.include?("multipart/form-data")
531
- # Extract form fields from Rack's parsed multipart data.
568
+ # Form fields from the parsed multipart body (Tina4::FormParser).
532
569
  # Files are handled separately by extract_files.
533
570
  result = {}
534
571
  form_hash = multipart_form_hash
@@ -560,38 +597,27 @@ module Tina4
560
597
  def extract_files
561
598
  result = {}
562
599
  return result unless @content_type.include?("multipart/form-data")
563
- begin
564
- # LIVE path: hand-scan the raw body so a REPEATED file field name is
565
- # preserved as a LIST (Rack's POST collapses a repeated non-bracket name
566
- # to last-wins - the multi-file data-loss this fixes). The scan reads the
567
- # body through the capped reader, so an over-limit upload is refused as
568
- # it arrives. Falls through to the parsed form_hash when there is no raw
569
- # body (e.g. a spec injecting rack.request.form_hash).
570
- scanned = scan_multipart_files
571
- if scanned && !scanned.empty?
572
- scanned.each do |key, list|
573
- files = list.map { |value| build_file_upload(value) }.compact
574
- result[key] = files.length == 1 ? files.first : files unless files.empty?
575
- end
576
- return result
577
- end
578
600
 
579
- form_hash = multipart_form_hash
580
- if form_hash
581
- form_hash.each do |key, value|
582
- if value.is_a?(Array)
583
- files = value.map { |v| build_file_upload(v) }.compact
584
- result[key] = files.length == 1 ? files.first : files unless files.empty?
585
- else
586
- file = build_file_upload(value)
587
- result[key] = file if file
588
- end
601
+ # LIVE path: the parser keeps a REPEATED file field name as a LIST (Rack's
602
+ # POST collapsed a repeated non-bracket name to last-wins - the multi-file
603
+ # data loss). Falls through to an injected rack.request.form_hash when
604
+ # there is no raw body to parse.
605
+ injected = @env["rack.request.form_hash"] unless multipart_parsed
606
+ if multipart_parsed
607
+ multipart_parsed[:files].each do |key, list|
608
+ files = list.map { |value| build_file_upload(value) }.compact
609
+ result[key] = files.length == 1 ? files.first : files unless files.empty?
610
+ end
611
+ elsif injected
612
+ injected.each do |key, value|
613
+ if value.is_a?(Array)
614
+ files = value.map { |v| build_file_upload(v) }.compact
615
+ result[key] = files.length == 1 ? files.first : files unless files.empty?
616
+ else
617
+ file = build_file_upload(value)
618
+ result[key] = file if file
589
619
  end
590
620
  end
591
- rescue Tina4::Request::PayloadTooLarge
592
- raise
593
- rescue StandardError
594
- # Multipart parsing failed
595
621
  end
596
622
  result
597
623
  end
@@ -612,68 +638,5 @@ module Tina4
612
638
  file["content"] = value[:content] if value.key?(:content) && !value[:content].nil?
613
639
  file
614
640
  end
615
-
616
- # Extract the boundary token from a multipart Content-Type header.
617
- def multipart_boundary(content_type)
618
- content_type.to_s.split(";").each do |part|
619
- part = part.strip
620
- next unless part.start_with?("boundary=")
621
-
622
- return part[9..].to_s.delete_prefix('"').delete_suffix('"')
623
- end
624
- nil
625
- end
626
-
627
- # Hand-roll the FILE parts out of the raw multipart body, keyed by field
628
- # name, each name mapping to a LIST of descriptors so a repeated name keeps
629
- # every file. Reads through read_stream_capped, so the running per-chunk
630
- # upload cap is enforced here too (raising PayloadTooLarge). Returns {} when
631
- # there is no raw body (the injected-form_hash path is used instead). Fields
632
- # are handled by parse_body (via Rack) - this scans files only.
633
- def scan_multipart_files
634
- boundary = multipart_boundary(@content_type)
635
- return {} unless boundary
636
-
637
- body = Tina4::Request.read_stream_capped(@env["rack.input"])
638
- return {} if body.nil? || body.empty?
639
-
640
- body = body.dup.force_encoding("BINARY")
641
- files = {}
642
- delimiter = "--#{boundary}"
643
- body.split(delimiter).each do |segment|
644
- next if segment.empty? || segment.start_with?("--")
645
-
646
- segment = segment.sub(/\A\r\n/, "")
647
- header_end = segment.index("\r\n\r\n")
648
- next unless header_end
649
-
650
- header_section = segment[0...header_end]
651
- content = segment[(header_end + 4)..] || ""
652
- content = content.sub(/\r\n\z/, "")
653
-
654
- name = nil
655
- filename = nil
656
- type = "application/octet-stream"
657
- header_section.split("\r\n").each do |line|
658
- if line =~ /content-disposition/i
659
- name = Regexp.last_match(1) if line =~ /name="([^"]*)"/
660
- filename = Regexp.last_match(1) if line =~ /filename="([^"]*)"/
661
- elsif line =~ /content-type:\s*(.+)/i
662
- type = Regexp.last_match(1).strip
663
- end
664
- end
665
- next if name.nil? || filename.nil?
666
-
667
- bytes = content.dup.force_encoding("BINARY")
668
- (files[name] ||= []) << {
669
- filename: filename,
670
- type: type,
671
- content: bytes,
672
- size: bytes.bytesize,
673
- tempfile: StringIO.new(bytes)
674
- }
675
- end
676
- files
677
- end
678
641
  end
679
642
  end
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "json"
3
9
  require "uri"
4
10
 
@@ -59,11 +65,30 @@ module Tina4
59
65
 
60
66
  attr_accessor :status_code, :headers, :body, :cookies
61
67
 
68
+ # ADR-0068: a header name must be an RFC 9110 token, and a header value,
69
+ # redirect location or cookie attribute may never carry CR, LF or NUL (a
70
+ # cookie attribute not ';' either). They are REFUSED where the developer
71
+ # sets them - never stripped - so the stack trace points at the line that
72
+ # built the value. The messages are identical in all four frameworks.
73
+ HEADER_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
74
+ UNSAFE_HEADER_VALUE = /[\r\n\0]/
75
+ UNSAFE_COOKIE_CONTENT = /[\r\n\0;]/
76
+
77
+ def self.check_header!(name, value)
78
+ unless name.to_s.match?(HEADER_TOKEN)
79
+ raise ArgumentError, "Header name must be a valid HTTP token [#{JSON.generate(name.to_s)}]"
80
+ end
81
+ return value unless value.to_s.match?(UNSAFE_HEADER_VALUE)
82
+
83
+ raise ArgumentError, "Invalid character in header content [#{JSON.generate(name.to_s)}]"
84
+ end
85
+
62
86
  def initialize
63
87
  @status_code = 200
64
88
  @headers = { "content-type" => HTML_CONTENT_TYPE }
65
89
  @body = ""
66
90
  @cookies = nil # Lazy -- most responses have no cookies
91
+ @content_type_from_header = false
67
92
  end
68
93
 
69
94
  # Chainable status setter
@@ -79,16 +104,17 @@ module Tina4
79
104
  # Callable response — auto-detects content type from data.
80
105
  # Matches Python __call__ / PHP __invoke / Node response() pattern.
81
106
  def call(data = nil, status_code = 200, content_type = nil)
107
+ Response.check_header!("Content-Type", content_type) if content_type
82
108
  @status_code = status_code
83
109
  data = jsonable(data)
84
110
  if content_type
85
111
  @headers["content-type"] = content_type
86
- @body = data.to_s
112
+ @body = data.is_a?(Hash) || data.is_a?(Array) ? JSON.generate(data) : data.to_s
87
113
  elsif data.is_a?(Hash) || data.is_a?(Array)
88
- @headers["content-type"] = JSON_CONTENT_TYPE
114
+ detected_content_type(JSON_CONTENT_TYPE)
89
115
  @body = JSON.generate(data)
90
116
  else
91
- @headers["content-type"] = HTML_CONTENT_TYPE
117
+ detected_content_type(HTML_CONTENT_TYPE)
92
118
  @body = data.to_s
93
119
  end
94
120
  self
@@ -142,14 +168,16 @@ module Tina4
142
168
  end
143
169
 
144
170
  def csv(content, filename: "export.csv", status: 200)
171
+ disposition = Response.check_header!("Content-Disposition", "attachment; filename=\"#{filename}\"")
145
172
  @status_code = status
146
173
  @headers["content-type"] = "text/csv"
147
- @headers["content-disposition"] = "attachment; filename=\"#{filename}\""
174
+ @headers["content-disposition"] = disposition
148
175
  @body = content.to_s
149
176
  self
150
177
  end
151
178
 
152
179
  def redirect(url, status_or_opts = nil, status: nil)
180
+ Response.check_header!("Location", url)
153
181
  @status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 302)
154
182
  @headers["location"] = url
155
183
  @body = ""
@@ -157,6 +185,7 @@ module Tina4
157
185
  end
158
186
 
159
187
  def file(path, content_type: nil, download: false, root: nil)
188
+ Response.check_header!("Content-Type", content_type) if content_type
160
189
  # SECURITY: confine the read. The natural spelling of a download route,
161
190
  #
162
191
  # response.file("downloads/" + name) # name = "../secret.env"
@@ -206,7 +235,8 @@ module Tina4
206
235
  ext = ::File.extname(path).downcase
207
236
  @headers["content-type"] = content_type || MIME_TYPES[ext] || "application/octet-stream"
208
237
  if download
209
- @headers["content-disposition"] = "attachment; filename=\"#{::File.basename(path)}\""
238
+ @headers["content-disposition"] =
239
+ Response.check_header!("Content-Disposition", "attachment; filename=\"#{::File.basename(path)}\"")
210
240
  end
211
241
  @body = ::File.binread(path)
212
242
  self
@@ -279,12 +309,14 @@ module Tina4
279
309
  { error: true, code: code, message: message, status: status }
280
310
  end
281
311
 
282
- # Chainable header setter
312
+ # Chainable header setter. Content-Type (any case) is not a second header:
313
+ # it replaces the response's one content type, and call(data) keeps it
314
+ # instead of detecting one (ADR-0072, tina4-python#144).
283
315
  def header(name, value = nil)
284
316
  if value.nil?
285
- @headers[name]
317
+ @headers[content_type_key?(name) ? "content-type" : name]
286
318
  else
287
- @headers[name] = value
319
+ set_header(name, Response.check_header!(name, value))
288
320
  self
289
321
  end
290
322
  end
@@ -294,7 +326,16 @@ module Tina4
294
326
  set_cookie(name, value, opts)
295
327
  end
296
328
 
329
+ # The value is percent-encoded on the way out, so it can never carry CR,
330
+ # LF, NUL or ';' to the wire and is not refused (ADR-0068 keeps encoding
331
+ # where a framework already did). The name and every attribute are refused.
297
332
  def set_cookie(name, value, opts = {})
333
+ label = JSON.generate(name.to_s)
334
+ raise ArgumentError, "Cookie name must be a valid HTTP token [#{label}]" unless name.to_s.match?(HEADER_TOKEN)
335
+ if [opts[:path], opts[:same_site], opts[:max_age]].any? { |attribute| attribute.to_s.match?(UNSAFE_COOKIE_CONTENT) }
336
+ raise ArgumentError, "Invalid character in cookie content [#{label}]"
337
+ end
338
+
298
339
  cookie_str = "#{name}=#{URI.encode_www_form_component(value)}"
299
340
  cookie_str += "; Path=#{opts[:path] || '/'}"
300
341
  cookie_str += "; HttpOnly" if opts.fetch(:http_only, true)
@@ -312,7 +353,7 @@ module Tina4
312
353
  end
313
354
 
314
355
  def add_header(key, value)
315
- @headers[key] = value
356
+ set_header(key, Response.check_header!(key, value))
316
357
  self
317
358
  end
318
359
 
@@ -353,6 +394,7 @@ module Tina4
353
394
  # @yield [Enumerator::Yielder] Block receives a yielder to push chunks
354
395
  # @return [self]
355
396
  def stream(generator = nil, content_type: "text/event-stream", &block)
397
+ Response.check_header!("Content-Type", content_type)
356
398
  @status_code = @status_code || 200
357
399
  @headers["content-type"] = content_type
358
400
  @headers["cache-control"] = "no-cache"
@@ -366,6 +408,7 @@ module Tina4
366
408
 
367
409
  # Finalize and return the response — matches Python/Node API.
368
410
  def send(data = nil, status_code: nil, content_type: nil)
411
+ Response.check_header!("Content-Type", content_type) if content_type
369
412
  if data
370
413
  if data.is_a?(Hash) || data.is_a?(Array)
371
414
  return json(data, status_code || 200)
@@ -378,6 +421,25 @@ module Tina4
378
421
  to_rack
379
422
  end
380
423
 
424
+ def content_type_key?(name)
425
+ name.to_s.casecmp?("content-type")
426
+ end
427
+
428
+ def set_header(name, value)
429
+ if content_type_key?(name)
430
+ @headers["content-type"] = value
431
+ @content_type_from_header = true
432
+ else
433
+ @headers[name] = value
434
+ end
435
+ end
436
+
437
+ # A detected content type never replaces one the route set with header().
438
+ def detected_content_type(content_type)
439
+ @headers["content-type"] = content_type unless @content_type_from_header
440
+ end
441
+ private :content_type_key?, :set_header, :detected_content_type
442
+
381
443
  def to_rack
382
444
  final_headers = @headers.dup
383
445
  final_headers["set-cookie"] = @cookies.join("\n") if @cookies && !@cookies.empty?
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  # Multi-backend response cache for GET requests.
data/lib/tina4/router.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "fileutils"
4
10
  require "json"
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+ module Tina4
9
+ # Internal credential persistence: validate and read/write the same descriptor.
10
+ module SecretFile
11
+ def self.update(path)
12
+ begin
13
+ before = File.lstat(path)
14
+ raise IOError, "credential target must be an unlinked regular file" unless before.file? && before.nlink == 1
15
+ rescue Errno::ENOENT
16
+ # The O_CREAT open below creates the file with owner-only permissions.
17
+ end
18
+ flags = File::RDWR | File::CREAT
19
+ flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
20
+ flags |= File::NONBLOCK if defined?(File::NONBLOCK)
21
+ File.open(path, flags, 0o600) do |file|
22
+ file.flock(File::LOCK_EX)
23
+ stat = file.stat
24
+ raise IOError, "credential target must be an unlinked regular file" unless stat.file? && stat.nlink == 1
25
+ file.chmod(0o600)
26
+ content = yield file.read
27
+ file.rewind
28
+ file.truncate(0)
29
+ file.write(content)
30
+ file.flush
31
+ end
32
+ end
33
+ end
34
+ end
data/lib/tina4/seeder.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "securerandom"
4
10
  require "json"
@@ -856,10 +862,10 @@ module Tina4
856
862
  db = ref_class.get_db
857
863
  next unless db
858
864
 
859
- pk = ref_class.primary_key_field || :id
860
- rows = db.fetch("SELECT #{pk} FROM #{ref_class.table_name}", [], limit: 100_000)
865
+ pk_column = ref_class.get_db_column(ref_class.primary_key_field || :id)
866
+ rows = db.fetch("SELECT #{pk_column} FROM #{ref_class.table_name}", [], limit: 100_000)
861
867
  list = rows.respond_to?(:to_a) ? rows.to_a : Array(rows)
862
- values = list.map { |r| r[pk] || r[pk.to_s] }.compact
868
+ values = list.map { |r| r[pk_column] || r[pk_column.to_s] }.compact
863
869
  pools[name] = values unless values.empty?
864
870
  rescue => e
865
871
  Tina4::Log.warning("Seeder: could not resolve FK pool for #{name}: #{e.message}")
data/lib/tina4/service.rb CHANGED
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Tina4 — The Intelligent Native Application 4ramework
4
- # Copyright 2007 - current Tina4
5
- # License: MIT https://opensource.org/licenses/MIT
10
+ # Copyright (c) 2026 Code Infinity
11
+ # License: MPL-2.0 https://mozilla.org/MPL/2.0/
6
12
 
7
13
  module Tina4
8
14
  # Base class for class-based background services managed by
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  module Tina4
4
10
  # Context object passed to each service handler, giving it control
data/lib/tina4/session.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
  require "securerandom"
3
9
  require "json"
4
10