tina4ruby 3.13.137 → 3.13.138

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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -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 +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  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 +6 -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 +6 -0
  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 +6 -0
  26. data/lib/tina4/cli.rb +22 -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 +215 -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 +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  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 +196 -56
  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 +28 -27
  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 +6 -0
  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 +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  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 +9 -3
  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 +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  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 +6 -0
  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 +85 -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 +6 -0
  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 +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -140
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
data/lib/tina4/push.rb CHANGED
@@ -1,7 +1,13 @@
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 "openssl"
4
- require "base64"
10
+ require_relative "base64"
5
11
  require "json"
6
12
  require "net/http"
7
13
  require "uri"
@@ -113,13 +119,13 @@ module Tina4
113
119
  end
114
120
 
115
121
  def self.b64(value)
116
- Base64.urlsafe_encode64(value, padding: false)
122
+ Tina4::Base64.urlsafe_encode64(value, padding: false)
117
123
  end
118
124
 
119
125
  def decode(value, name)
120
126
  raise PushError, "#{name} must be a non-empty base64url string" unless value.is_a?(String) && value.match?(/\A[A-Za-z0-9_-]+\z/)
121
127
 
122
- Base64.urlsafe_decode64(value)
128
+ Tina4::Base64.urlsafe_decode64(value)
123
129
  rescue ArgumentError => e
124
130
  raise PushError, "#{name} must be base64url encoded: #{e.message}"
125
131
  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
 
3
9
  module Tina4
4
10
  # QueryBuilder — Fluent SQL query builder.
data/lib/tina4/queue.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 "json"
3
9
  require "securerandom"
4
10
  require "uri"
@@ -1,4 +1,12 @@
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
+
8
+
9
+ require_relative "../parse_json"
2
10
 
3
11
  module Tina4
4
12
  module QueueBackends
@@ -120,7 +128,7 @@ module Tina4
120
128
  end
121
129
  return nil unless msg
122
130
 
123
- data = JSON.parse(msg.payload)
131
+ data = Tina4.parse_json(msg.payload)
124
132
  @last_message = msg
125
133
 
126
134
  # attempts and error MUST be carried back. Rebuilding from
@@ -243,7 +251,7 @@ module Tina4
243
251
  loop do
244
252
  msg = reader.poll(500)
245
253
  if msg
246
- data = JSON.parse(msg.payload)
254
+ data = Tina4.parse_json(msg.payload)
247
255
  data["status"] = "dead"
248
256
  out << data
249
257
  next
@@ -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 "fileutils"
4
10
  require "time"
@@ -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
  module QueueBackends
@@ -1,4 +1,12 @@
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
+
8
+
9
+ require_relative "../parse_json"
2
10
 
3
11
  module Tina4
4
12
  module QueueBackends
@@ -62,7 +70,7 @@ module Tina4
62
70
  delivery_info, _properties, payload = queue.pop(manual_ack: true)
63
71
  return nil unless payload
64
72
 
65
- data = JSON.parse(payload)
73
+ data = Tina4.parse_json(payload)
66
74
  # attempts and error MUST be carried back. Rebuilding the Job from
67
75
  # topic/payload/id alone reset attempts to 0 on every redelivery, so
68
76
  # fail()'s attempts >= max_retries check could never trip and a poison
@@ -282,7 +290,7 @@ module Tina4
282
290
  _info, _props, payload = queue.pop(manual_ack: false)
283
291
  break unless payload
284
292
 
285
- out << JSON.parse(payload)
293
+ out << Tina4.parse_json(payload)
286
294
  end
287
295
  out.each { |data| publish_to(name, data) }
288
296
  out
@@ -1,9 +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 "json"
3
9
  require "securerandom"
4
10
  require "time"
5
11
  require "uri"
6
12
  require "cgi"
13
+ require_relative "parse_json"
7
14
 
8
15
  module Tina4
9
16
  # Middleware wrapper that tags requests arriving on the AI dev port.
@@ -503,6 +510,9 @@ module Tina4
503
510
 
504
511
  # Skip underscore-prefixed segments — private files even within pages/.
505
512
  return nil if clean_path.split("/").any? { |seg| seg.start_with?("_") }
513
+ # ADR-0082: a "." / ".." segment (or a backslash) could walk out of
514
+ # pages/ - the only directory that auto-routes. Refuse it outright.
515
+ return nil if clean_path.include?("\\") || clean_path.split("/", -1).any? { |seg| ["", ".", ".."].include?(seg) }
506
516
 
507
517
  is_dev = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "false").downcase)
508
518
 
@@ -1316,7 +1326,7 @@ module Tina4
1316
1326
 
1317
1327
  if content_type.include?("application/json")
1318
1328
  begin
1319
- parsed = JSON.parse(body_str)
1329
+ parsed = Tina4.parse_json(body_str)
1320
1330
  return parsed["formToken"] if parsed.is_a?(Hash) && parsed["formToken"]
1321
1331
  rescue JSON::ParserError
1322
1332
  # Not valid JSON — fall through
@@ -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
  class RateLimiter
@@ -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
  module Realtime
@@ -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
  module Realtime
@@ -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
  module Realtime
@@ -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
 
@@ -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
  module Realtime
@@ -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
  module Realtime
@@ -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
 
@@ -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
  module Realtime
@@ -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
  module Realtime
@@ -1,9 +1,15 @@
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 "json"
4
10
  require "time"
5
11
  require "openssl"
6
- require "base64"
12
+ require_relative "base64"
7
13
 
8
14
  require_relative "realtime/workspace"
9
15
  require_relative "realtime/channel"
@@ -14,6 +20,7 @@ require_relative "realtime/storage_backend"
14
20
  require_relative "realtime/local_storage"
15
21
  require_relative "realtime/s3_storage"
16
22
  require_relative "realtime/storage"
23
+ require_relative "parse_json"
17
24
 
18
25
  module Tina4
19
26
  # Real-time collaboration mount for Tina4 (Ruby), parity with the Python
@@ -57,7 +64,7 @@ module Tina4
57
64
  if turn_url && !turn_url.empty? && secret && !secret.empty?
58
65
  ttl = (ENV["TINA4_RTC_TURN_TTL"] || "3600").to_i
59
66
  username = (Time.now.to_i + ttl).to_s
60
- credential = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA1", secret, username))
67
+ credential = Tina4::Base64.strict_encode64(OpenSSL::HMAC.digest("SHA1", secret, username))
61
68
  servers << { "urls" => split_urls(turn_url), "username" => username, "credential" => credential }
62
69
  end
63
70
  servers
@@ -312,7 +319,7 @@ module Tina4
312
319
  def parse_json(data)
313
320
  return data if data.is_a?(Hash)
314
321
 
315
- JSON.parse(data.to_s)
322
+ Tina4.parse_json(data.to_s)
316
323
  rescue JSON::ParserError, TypeError
317
324
  nil
318
325
  end
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,8 @@ 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
+ forwarded_host = Tina4.trusted_proxy?(@remote_ip) ? env["HTTP_X_FORWARDED_HOST"] : nil
296
+ host = forwarded_host || env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
276
297
  port = env["SERVER_PORT"]
277
298
  url_str = "#{scheme}://#{host}"
278
299
  # Only append :port when the host doesn't already include one
@@ -395,7 +416,7 @@ module Tina4
395
416
 
396
417
  def json_body
397
418
  @json_body ||= begin
398
- JSON.parse(body_raw)
419
+ Tina4.parse_json(body_raw)
399
420
  rescue JSON::ParserError, TypeError
400
421
  {}
401
422
  end
@@ -474,36 +495,47 @@ module Tina4
474
495
  data
475
496
  end
476
497
 
477
- # Resolve the parsed multipart form fields (+ file entries) for this request.
498
+ # The parsed multipart body, memoised: { fields:, files:, error: } from
499
+ # Tina4::FormParser, or nil when there is no raw body to parse.
478
500
  #
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.
501
+ # A live server hands the app the raw `rack.input` stream and parses nothing,
502
+ # so Tina4 parses it here. The body is read through read_stream_capped, so the
503
+ # running upload cap refuses an over-limit body as it arrives - OUTSIDE any
504
+ # rescue on purpose: PayloadTooLarge must reach the 413 handler.
505
+ def multipart_parsed
506
+ return @multipart_parsed if defined?(@multipart_parsed)
507
+
508
+ body = Tina4::Request.read_stream_capped(@env["rack.input"])
509
+ @multipart_parsed =
510
+ if body.nil? || body.empty?
511
+ nil
512
+ else
513
+ parsed = Tina4::FormParser.parse_multipart(body, @content_type)
514
+ if parsed[:error] && defined?(Tina4::Log)
515
+ Tina4::Log.warning("multipart parse failed: #{parsed[:error].message}")
516
+ end
517
+ parsed
518
+ end
519
+ end
520
+
521
+ # The request's form hash: fields plus file entries under the standard
522
+ # `rack.request.form_hash` env key, the same key (and shape) Rack::Request#POST
523
+ # used to fill, so middleware reading it keeps working. A spec or middleware
524
+ # that INJECTS the key short-circuits the parse entirely.
487
525
  def multipart_form_hash
488
526
  existing = @env["rack.request.form_hash"] rescue nil
489
527
  return existing if existing
490
528
 
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
529
+ parsed = multipart_parsed
530
+ return nil unless parsed
531
+
532
+ form = parsed[:fields].dup
533
+ parsed[:files].each do |name, list|
534
+ # Rack left an empty file field (filename="") out of the form hash.
535
+ kept = list.reject { |descriptor| descriptor[:filename].empty? }
536
+ form[name] = kept.length == 1 ? kept.first : kept unless kept.empty?
505
537
  end
506
- @env["rack.request.form_hash"] || parsed
538
+ @env["rack.request.form_hash"] = form
507
539
  end
508
540
 
509
541
  def parse_body
@@ -521,14 +553,14 @@ module Tina4
521
553
  # NOT delegating to #json_body: that method keeps its own documented
522
554
  # always-a-Hash, {}-on-failure contract for direct callers.
523
555
  begin
524
- JSON.parse(body_raw)
556
+ Tina4.parse_json(body_raw)
525
557
  rescue JSON::ParserError, TypeError
526
558
  body_raw
527
559
  end
528
560
  elsif @content_type.include?("application/x-www-form-urlencoded")
529
561
  parse_query_to_hash(body_raw)
530
562
  elsif @content_type.include?("multipart/form-data")
531
- # Extract form fields from Rack's parsed multipart data.
563
+ # Form fields from the parsed multipart body (Tina4::FormParser).
532
564
  # Files are handled separately by extract_files.
533
565
  result = {}
534
566
  form_hash = multipart_form_hash
@@ -560,38 +592,27 @@ module Tina4
560
592
  def extract_files
561
593
  result = {}
562
594
  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
595
 
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
596
+ # LIVE path: the parser keeps a REPEATED file field name as a LIST (Rack's
597
+ # POST collapsed a repeated non-bracket name to last-wins - the multi-file
598
+ # data loss). Falls through to an injected rack.request.form_hash when
599
+ # there is no raw body to parse.
600
+ injected = @env["rack.request.form_hash"] unless multipart_parsed
601
+ if multipart_parsed
602
+ multipart_parsed[:files].each do |key, list|
603
+ files = list.map { |value| build_file_upload(value) }.compact
604
+ result[key] = files.length == 1 ? files.first : files unless files.empty?
605
+ end
606
+ elsif injected
607
+ injected.each do |key, value|
608
+ if value.is_a?(Array)
609
+ files = value.map { |v| build_file_upload(v) }.compact
610
+ result[key] = files.length == 1 ? files.first : files unless files.empty?
611
+ else
612
+ file = build_file_upload(value)
613
+ result[key] = file if file
589
614
  end
590
615
  end
591
- rescue Tina4::Request::PayloadTooLarge
592
- raise
593
- rescue StandardError
594
- # Multipart parsing failed
595
616
  end
596
617
  result
597
618
  end
@@ -612,68 +633,5 @@ module Tina4
612
633
  file["content"] = value[:content] if value.key?(:content) && !value[:content].nil?
613
634
  file
614
635
  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
636
  end
679
637
  end