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
@@ -1,6 +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_relative "port_takeover"
10
+ require_relative "http_server"
4
11
 
5
12
  module Tina4
6
13
  class WebServer
@@ -69,8 +76,6 @@ module Tina4
69
76
  exit 1
70
77
  end
71
78
 
72
- require "webrick"
73
- require "stringio"
74
79
  require "socket"
75
80
 
76
81
  # Ensure the main port is available — kill whatever is on it if needed
@@ -88,14 +93,11 @@ module Tina4
88
93
  end
89
94
  end
90
95
 
91
- Tina4.print_banner(host: @host, port: @port)
92
- Tina4::Log.info("Starting Tina4 WEBrick server on http://#{@host}:#{@port}")
93
- @server = WEBrick::HTTPServer.new(
94
- BindAddress: @host,
95
- Port: @port,
96
- Logger: WEBrick::Log.new(File::NULL),
97
- AccessLog: []
98
- )
96
+ Tina4.print_banner(host: @host, port: @port, server_name: Tina4::HttpServer::SOFTWARE)
97
+ display = (@host == "0.0.0.0" || @host == "::") ? "localhost" : @host
98
+ Tina4::Log.info("Server started http://#{display}:#{@port} (#{Tina4::HttpServer::SOFTWARE})")
99
+ @server = Tina4::HttpServer.new(server_name: @host)
100
+ @server.listen(@host, @port, @app)
99
101
 
100
102
  # Dual-stack loopback: ALSO listen on the sibling loopback family on the
101
103
  # MAIN port, so `localhost` reaches this server whether the OS resolves it
@@ -104,14 +106,13 @@ module Tina4
104
106
  # IPv4 wildcard, which does NOT cover IPv6 -- refuses the browser with
105
107
  # ERR_CONNECTION_REFUSED even though it is serving. The primary bind above
106
108
  # is unchanged (keeps its fail-closed throw + port-takeover); each sibling
107
- # is BEST-EFFORT via WEBrick::GenericServer#listen, which appends to the
108
- # listener set the accept loop already services. A family that is
109
- # unavailable, or that the primary bind already answers, raises and is
110
- # skipped -- a sibling failure NEVER fails the boot. Main port only
111
- # (mirrors tina4-php PR #206); the AI/debug port is left alone.
109
+ # is BEST-EFFORT: a family that is unavailable, or that the primary bind
110
+ # already answers, raises and is skipped -- a sibling failure NEVER fails
111
+ # the boot. Main port only (mirrors tina4-php PR #206); the AI/debug port
112
+ # is left alone.
112
113
  self.class.loopback_bind_hosts(@host).each do |sibling_host|
113
114
  begin
114
- @server.listen(sibling_host, @port)
115
+ @server.listen(sibling_host, @port, @app)
115
116
  rescue Errno::EADDRINUSE, Errno::EADDRNOTAVAIL, SocketError, Errno::EAFNOSUPPORT => e
116
117
  Tina4::Log.debug("Dual-stack loopback: skipped #{sibling_host}:#{@port} (#{e.class})")
117
118
  end
@@ -121,42 +122,19 @@ module Tina4
121
122
  # `tina4 serve` can identify it as reclaimable (TAKEOVER-DEC-01).
122
123
  Tina4::PortTakeover.write_pidfile(@port)
123
124
 
124
- # Setup graceful shutdown with WEBrick server reference
125
+ # Graceful shutdown: Tina4::Shutdown closes the listeners first, then
126
+ # drains in-flight requests (bounded by TINA4_SHUTDOWN_TIMEOUT).
125
127
  Tina4::Shutdown.setup(server: @server)
126
128
 
127
- # Use a custom servlet that passes ALL methods (including OPTIONS) to Rack
128
- rack_app = @app
129
- servlet = build_rack_servlet(@host, @port.to_s)
130
-
131
- @server.mount("/", servlet, rack_app)
132
-
133
129
  # Test port (port + 1000) — stable, no-browser
134
- @ai_server = nil
135
- @ai_thread = nil
136
130
  no_ai_port = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_AI_PORT", "").downcase)
137
131
  is_debug = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "").downcase)
138
132
 
139
133
  if is_debug && !no_ai_port
140
134
  ai_port = @port + 1000
141
135
  begin
142
- test = TCPServer.new("0.0.0.0", ai_port)
143
- test.close
144
-
145
- @ai_server = WEBrick::HTTPServer.new(
146
- BindAddress: @host,
147
- Port: ai_port,
148
- Logger: WEBrick::Log.new(File::NULL),
149
- AccessLog: []
150
- )
151
-
152
- # Wrap the rack app so AI-port requests are tagged
153
- ai_rack_app = Tina4::AiPortRackApp.new(@app)
154
-
155
- # Same servlet as the main port, bound to the AI port's host/port.
156
- ai_servlet = build_rack_servlet(@host, ai_port.to_s)
157
-
158
- @ai_server.mount("/", ai_servlet, ai_rack_app)
159
- @ai_thread = Thread.new { @ai_server.start }
136
+ # Tagged so the pipeline suppresses live reload on this port.
137
+ @server.listen(@host, ai_port, Tina4::AiPortRackApp.new(@app))
160
138
  puts " Test Port: http://localhost:#{ai_port} (stable — no hot-reload)"
161
139
  rescue Errno::EADDRINUSE
162
140
  puts " Test Port: SKIPPED (port #{ai_port} in use)"
@@ -166,15 +144,13 @@ module Tina4
166
144
  @server.start
167
145
 
168
146
  # Shutdown closes the listener FIRST, so #start returns as soon as the
169
- # in-flight workers are joined - potentially while the signal handler's
170
- # thread is still stopping background tasks and closing the database.
171
- # Wait for that teardown instead of exiting out from under it.
147
+ # accept loop stops - potentially while the signal handler's thread is
148
+ # still draining requests, stopping background tasks and closing the
149
+ # database. Wait for that teardown instead of exiting out from under it.
172
150
  Tina4::Shutdown.wait_for_completion
173
151
  end
174
152
 
175
153
  def stop
176
- @ai_server&.shutdown
177
- @ai_thread&.join(5)
178
154
  @server&.shutdown
179
155
  # Drop our identity marker so a later takeover does not match a dead PID.
180
156
  Tina4::PortTakeover.remove_pidfile(@port)
@@ -200,7 +176,7 @@ module Tina4
200
176
  # is normalised (downcased, surrounding whitespace and brackets stripped) so
201
177
  # "[::1]", " ::1 " and "::1" all resolve alike.
202
178
  #
203
- # Mirrors the tina4-php Server::loopbackBindHosts mapping exactly. WEBrick
179
+ # Mirrors the tina4-php Server::loopbackBindHosts mapping exactly. Ruby
204
180
  # binds "::1" WITHOUT brackets (PHP's stream URL needed "[::1]"; the brackets
205
181
  # are not carried into Ruby).
206
182
  #
@@ -215,99 +191,5 @@ module Tina4
215
191
  else []
216
192
  end
217
193
  end
218
-
219
- private
220
-
221
- # Build the Rack->WEBrick servlet class bound to a specific host/port.
222
- # The main port and the debug AI port mount an identical servlet; the ONLY
223
- # difference is the host/port reported in the Rack env, bound below via
224
- # define_method. Extracted from two verbatim-identical copies.
225
- def build_rack_servlet(host, port)
226
- servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
227
- define_method(:initialize) do |server, app|
228
- super(server)
229
- @app = app
230
- end
231
-
232
- %w[GET POST PUT DELETE PATCH HEAD OPTIONS].each do |http_method|
233
- define_method("do_#{http_method}") do |webrick_req, webrick_res|
234
- handle_request(webrick_req, webrick_res)
235
- end
236
- end
237
-
238
- define_method(:handle_request) do |webrick_req, webrick_res|
239
- # Belt-and-braces, NOT the primary mechanism. Shutdown closes the
240
- # listening socket FIRST, so a connection arriving after the signal is
241
- # refused by the kernel and never reaches here. What is left is a
242
- # genuine race: WEBrick accepts a connection and parses its request in
243
- # a worker thread, and the @shutting_down flag can flip in the gap
244
- # before that worker reaches this line.
245
- #
246
- # Measured (macOS 26.5.2, Ruby 4.0.2, webrick 1.9.2, 48 threads
247
- # hammering across a real SIGTERM): the window is the ~10-90ms between
248
- # the flag flip and the listener actually closing, and 0-2 requests per
249
- # run land in it out of ~2000. Every request after that is
250
- # ECONNREFUSED. Rare, but reachable - so the guard stays.
251
- if Tina4::Shutdown.shutting_down?
252
- webrick_res.status = 503
253
- webrick_res.body = '{"error":"Service shutting down"}'
254
- webrick_res["content-type"] = "application/json"
255
- return
256
- end
257
-
258
- Tina4::Shutdown.track_request do
259
- env = build_rack_env(webrick_req)
260
- status, headers, body = @app.call(env)
261
-
262
- webrick_res.status = status
263
- headers.each do |key, value|
264
- if key.downcase == "set-cookie"
265
- Array(value.split("\n")).each { |c| webrick_res.cookies << WEBrick::Cookie.parse_set_cookie(c) }
266
- else
267
- webrick_res[key] = value
268
- end
269
- end
270
-
271
- response_body = ""
272
- body.each { |chunk| response_body += chunk }
273
- webrick_res.body = response_body
274
- end
275
- end
276
-
277
- define_method(:build_rack_env) do |req|
278
- input = StringIO.new(req.body || "")
279
- env = {
280
- "REQUEST_METHOD" => req.request_method,
281
- "PATH_INFO" => req.path,
282
- "QUERY_STRING" => req.query_string || "",
283
- "SERVER_NAME" => webrick_req_host,
284
- "SERVER_PORT" => webrick_req_port,
285
- "CONTENT_TYPE" => req.content_type || "",
286
- "CONTENT_LENGTH" => (req.content_length rescue 0).to_s,
287
- "REMOTE_ADDR" => req.peeraddr&.last || "127.0.0.1",
288
- "rack.input" => input,
289
- "rack.errors" => $stderr,
290
- "rack.url_scheme" => "http",
291
- "rack.version" => [1, 3],
292
- "rack.multithread" => true,
293
- "rack.multiprocess" => false,
294
- "rack.run_once" => false
295
- }
296
-
297
- req.header.each do |key, values|
298
- env_key = "HTTP_#{key.upcase.gsub('-', '_')}"
299
- env[env_key] = values.join(", ")
300
- end
301
-
302
- env
303
- end
304
- end
305
-
306
- # Bind the per-server host/port the Rack env reports (the ONLY thing that
307
- # differs between the main port and the AI port).
308
- servlet.define_method(:webrick_req_host) { host }
309
- servlet.define_method(:webrick_req_port) { port }
310
- servlet
311
- end
312
194
  end
313
195
  end
@@ -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
  require "socket"
3
9
  require "digest"
4
- require "base64"
10
+ require_relative "base64"
5
11
  require "json"
6
12
  require "set"
7
13
  require "securerandom"
@@ -22,7 +28,7 @@ module Tina4
22
28
 
23
29
  # Compute Sec-WebSocket-Accept from Sec-WebSocket-Key per RFC 6455.
24
30
  def self.compute_accept_key(key)
25
- Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}"))
31
+ Tina4::Base64.strict_encode64(Digest::SHA1.digest("#{key}#{WEBSOCKET_GUID}"))
26
32
  end
27
33
 
28
34
  # Return true if the request's Origin is permitted to upgrade to a WebSocket.
@@ -499,7 +505,7 @@ module Tina4
499
505
  }
500
506
  # JSON can't carry raw bytes — encode binary as base64, text as text.
501
507
  if binary_payload?(message)
502
- envelope["b64"] = Base64.strict_encode64(message.b)
508
+ envelope["b64"] = Tina4::Base64.strict_encode64(message.b)
503
509
  else
504
510
  envelope["text"] = message
505
511
  end
@@ -514,7 +520,7 @@ module Tina4
514
520
  # carry bytes, so text → {"text": ...} and bytes → {"b64": base64(...)}.
515
521
  def decode_envelope_message(env)
516
522
  return env["text"] if env.key?("text")
517
- return Base64.strict_decode64(env["b64"]).b if env.key?("b64")
523
+ return Tina4::Base64.strict_decode64(env["b64"]).b if env.key?("b64")
518
524
 
519
525
  nil
520
526
  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
  # WebSocket Backplane Abstraction for Tina4 Ruby.
4
10
  #
@@ -17,6 +23,8 @@
17
23
  # backplane.publish("chat", '{"user":"A","text":"hello"}')
18
24
  # end
19
25
 
26
+ require_relative "database_url"
27
+
20
28
  module Tina4
21
29
  # Base backplane interface for scaling WebSocket broadcast across instances.
22
30
  #
@@ -81,8 +89,17 @@ module Tina4
81
89
  end
82
90
 
83
91
  @url = url || ENV.fetch("TINA4_WS_BACKPLANE_URL", "redis://localhost:6379")
84
- @redis = Redis.new(url: @url)
85
- @subscriber = Redis.new(url: @url)
92
+ begin
93
+ @redis = Redis.new(url: @url)
94
+ @subscriber = Redis.new(url: @url)
95
+ rescue StandardError => e
96
+ # A URL the client cannot parse raises URI::InvalidURIError quoting the
97
+ # WHOLE raw URL, password included, and WebSocket#ensure_backplane logs
98
+ # e.message. Re-raise naming only the redacted form.
99
+ raise ArgumentError,
100
+ "Invalid TINA4_WS_BACKPLANE_URL for the Redis backplane: " \
101
+ "#{Tina4::DatabaseUrl.redact(@url)} (#{e.class})"
102
+ end
86
103
  @threads = {}
87
104
  @running = true
88
105
  end
data/lib/tina4/wsdl.rb CHANGED
@@ -1,6 +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/.
2
7
 
3
- require "rexml/document"
8
+
9
+ require_relative "xml_parser"
4
10
 
5
11
  module Tina4
6
12
  # SOAP 1.1 / WSDL server — zero-dependency, mirrors tina4-python's wsdl module.
@@ -20,7 +26,7 @@ module Tina4
20
26
  #
21
27
  # Supported:
22
28
  # - WSDL 1.1 generation from Ruby type declarations
23
- # - SOAP 1.1 request/response handling via REXML
29
+ # - SOAP 1.1 request/response handling via Tina4::WSDL::XmlParser (UTF-8 only, no DTDs)
24
30
  # - Lifecycle hooks (on_request, on_result)
25
31
  # - Auto type mapping (Integer -> int, String -> string, Float -> double, etc.)
26
32
  # - XML escaping on all response values
@@ -273,17 +279,18 @@ module Tina4
273
279
  def process_soap(xml_body)
274
280
  on_request(@request)
275
281
 
276
- # SOAP 1.1 (§3) forbids a Document Type Declaration in a SOAP message.
277
- # Rejecting any DOCTYPE/DTD up front also closes the XML entity-expansion
278
- # (billion-laughs) and external-entity (XXE) attack surface — REXML expands
279
- # internal entities, so a DTD is a live DoS vector. Reject before parsing.
280
- if xml_body =~ /<!DOCTYPE/i
282
+ # SOAP 1.1 (§3) forbids a Document Type Declaration in a SOAP message, so
283
+ # reject any DOCTYPE up front with the same Client fault as the other three
284
+ # frameworks. The parser has no DTD support at all (no entity expansion, no
285
+ # XXE) and accepts UTF-8 only, so a UTF-16 body that hides its DOCTYPE from
286
+ # this byte regex is refused by the parser as Malformed XML.
287
+ if doctype?(xml_body)
281
288
  return soap_fault("Client", "DOCTYPE declarations are not allowed in SOAP messages")
282
289
  end
283
290
 
284
291
  begin
285
- doc = REXML::Document.new(xml_body)
286
- rescue REXML::ParseException
292
+ doc = XmlParser.parse(xml_body)
293
+ rescue XmlParser::ParseError
287
294
  return soap_fault("Client", "Malformed XML")
288
295
  end
289
296
 
@@ -328,7 +335,13 @@ module Tina4
328
335
  soap_response(op_name, result)
329
336
  end
330
337
 
331
- # ── XML helpers (REXML) ──────────────────────────────────────────────
338
+ # ── XML helpers ──────────────────────────────────────────────────────
339
+
340
+ # Byte-level match, so a body whose bytes are not valid UTF-8 cannot raise
341
+ # ArgumentError out of the regex before the parser gets to refuse it.
342
+ def doctype?(xml_body)
343
+ xml_body.to_s.b.match?(/<!DOCTYPE/in)
344
+ end
332
345
 
333
346
  def find_child(parent, local)
334
347
  parent.each_element do |el|
@@ -338,7 +351,7 @@ module Tina4
338
351
  end
339
352
 
340
353
  def local_name(element)
341
- element.name # REXML already strips the prefix for .name
354
+ element.name # XmlParser::Element#name is the local name (prefix stripped)
342
355
  end
343
356
 
344
357
  # ── Type conversion ──────────────────────────────────────────────────
@@ -486,12 +499,14 @@ module Tina4
486
499
  end
487
500
 
488
501
  def handle_soap_request(xml_body)
489
- # SOAP 1.1 (§3) forbids a DOCTYPE/DTD. Reject before parsing — this
490
- # closes the REXML internal-entity expansion (billion-laughs) and XXE
491
- # surface. Mirrors the class-based process_soap path.
492
- return _soap_fault("DOCTYPE declarations are not allowed in SOAP messages") if xml_body =~ /<!DOCTYPE/i
502
+ # SOAP 1.1 (§3) forbids a DOCTYPE/DTD. Reject before parsing; the parser
503
+ # itself has no DTD support and accepts UTF-8 only. Mirrors the
504
+ # class-based process_soap path.
505
+ if xml_body.to_s.b.match?(/<!DOCTYPE/in)
506
+ return _soap_fault("DOCTYPE declarations are not allowed in SOAP messages")
507
+ end
493
508
 
494
- doc = REXML::Document.new(xml_body)
509
+ doc = XmlParser.parse(xml_body)
495
510
 
496
511
  # Find Body element (namespace-agnostic)
497
512
  body_el = _find_child(doc.root, "Body")
@@ -0,0 +1,259 @@
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 "strscan"
10
+
11
+ module Tina4
12
+ class WSDL
13
+ # A small, strict XML parser for SOAP request bodies (replaces REXML).
14
+ #
15
+ # Ported from the stack-based parser in tina4-nodejs (packages/core/src/wsdl.ts)
16
+ # and made strict, because the Ruby WSDL server has always answered
17
+ # "Malformed XML" for a broken body (Python's ElementTree does the same):
18
+ # tags must nest, there is exactly one root, and only the five predefined
19
+ # entities plus character references are understood.
20
+ #
21
+ # Deliberately NOT supported, so the attack surface does not exist:
22
+ # * DTDs. `<!DOCTYPE` and every other `<!` declaration is a ParseError, so
23
+ # there is no entity expansion (billion laughs) and no external entity
24
+ # (XXE). The WSDL server also rejects DOCTYPE before parsing.
25
+ # * Any encoding but UTF-8. A UTF-16/UTF-32 body (BOM or NUL bytes) or
26
+ # invalid UTF-8 is a ParseError. A UTF-16 body used to slip past the
27
+ # `<!DOCTYPE` regex guard, because the guard read "<\0!\0D\0..." while
28
+ # REXML decoded the BOM and parsed the DTD. A UTF-8 BOM is skipped.
29
+ #
30
+ # Element#text follows ElementTree (the Python master): the character data
31
+ # before the first child element, entity- and CDATA-decoded; nil when there
32
+ # is none. Element#name is the local name (prefix stripped), as REXML gave.
33
+ module XmlParser
34
+ class ParseError < StandardError; end
35
+
36
+ class Element
37
+ attr_reader :name, :prefix, :attributes, :elements
38
+
39
+ def initialize(qualified_name, attributes)
40
+ @prefix, _, local = qualified_name.rpartition(":")
41
+ @prefix = nil if @prefix.empty?
42
+ @name = local
43
+ @attributes = attributes
44
+ @elements = []
45
+ @text = nil
46
+ @seen_child = false
47
+ end
48
+
49
+ # Character data before the first child element (ElementTree .text).
50
+ def text
51
+ @text
52
+ end
53
+
54
+ def each_element(&block)
55
+ @elements.each(&block)
56
+ end
57
+
58
+ def append_text(data) # :nodoc:
59
+ return if @seen_child || data.empty?
60
+
61
+ @text = @text ? @text + data : data.dup
62
+ end
63
+
64
+ def append_child(element) # :nodoc:
65
+ @seen_child = true
66
+ @elements << element
67
+ end
68
+ end
69
+
70
+ Document = Struct.new(:root)
71
+
72
+ PREDEFINED_ENTITIES = { "lt" => "<", "gt" => ">", "amp" => "&", "quot" => '"', "apos" => "'" }.freeze
73
+ NAME = /[A-Za-z_:\u00C0-\u{EFFFF}][A-Za-z0-9_:.\-\u00B7\u00C0-\u{EFFFF}]*/
74
+ UTF16_OR_32_BOMS = ["\xFE\xFF".b, "\xFF\xFE".b, "\x00\x00\xFE\xFF".b].freeze
75
+
76
+ module_function
77
+
78
+ # Parse +xml+ (a String of any encoding tag; its BYTES must be UTF-8).
79
+ # Returns a Document whose #root is the single root Element.
80
+ def parse(xml)
81
+ Reader.new(utf8_text(xml)).document
82
+ end
83
+
84
+ def utf8_text(xml)
85
+ bytes = xml.to_s.b
86
+ if UTF16_OR_32_BOMS.any? { |bom| bytes.start_with?(bom) } || bytes.include?("\x00")
87
+ raise ParseError, "only UTF-8 XML is accepted (UTF-16/UTF-32 body)"
88
+ end
89
+
90
+ bytes = bytes.byteslice(3..) if bytes.start_with?("\xEF\xBB\xBF".b)
91
+ text = bytes.force_encoding(Encoding::UTF_8)
92
+ raise ParseError, "only UTF-8 XML is accepted (invalid UTF-8 bytes)" unless text.valid_encoding?
93
+
94
+ text
95
+ end
96
+
97
+ def decode_references(raw)
98
+ return raw unless raw.include?("&")
99
+
100
+ raw.gsub(/&([^;&\s]*);?/) do |match|
101
+ raise ParseError, "unterminated reference #{match[0, 20].inspect}" unless match.end_with?(";")
102
+
103
+ name = Regexp.last_match(1)
104
+ if (replacement = PREDEFINED_ENTITIES[name])
105
+ replacement
106
+ elsif name =~ /\A#x([0-9A-Fa-f]{1,6})\z/ || name =~ /\A#([0-9]{1,7})\z/
107
+ codepoint = name.start_with?("#x") ? Regexp.last_match(1).to_i(16) : Regexp.last_match(1).to_i
108
+ raise ParseError, "invalid character reference &#{name};" unless xml_char?(codepoint)
109
+
110
+ [codepoint].pack("U")
111
+ else
112
+ raise ParseError, "undefined entity &#{name}; (DTDs are not supported)"
113
+ end
114
+ end
115
+ end
116
+
117
+ def xml_char?(codepoint)
118
+ [0x9, 0xA, 0xD].include?(codepoint) ||
119
+ (0x20..0xD7FF).cover?(codepoint) ||
120
+ (0xE000..0xFFFD).cover?(codepoint) ||
121
+ (0x10000..0x10FFFF).cover?(codepoint)
122
+ end
123
+
124
+ # Iterative (no recursion, so nesting depth cannot exhaust the stack).
125
+ class Reader
126
+ def initialize(text)
127
+ @scanner = StringScanner.new(text)
128
+ @stack = [] # open Elements, innermost last
129
+ @open_names = [] # their qualified names, for end-tag matching
130
+ @root = nil
131
+ end
132
+
133
+ def document
134
+ skip_xml_declaration
135
+ until @scanner.eos?
136
+ if @stack.empty?
137
+ read_outside_root
138
+ else
139
+ read_content
140
+ end
141
+ end
142
+ raise ParseError, "unclosed element <#{@stack.last.name}>" unless @stack.empty?
143
+ raise ParseError, "no root element" unless @root
144
+
145
+ Document.new(@root)
146
+ end
147
+
148
+ private
149
+
150
+ def skip_xml_declaration
151
+ return unless @scanner.check(/<\?xml[\s?]/)
152
+
153
+ @scanner.scan_until(/\?>/) or raise ParseError, "unterminated XML declaration"
154
+ end
155
+
156
+ # Between the prolog and the end: whitespace, comments, PIs, the root.
157
+ def read_outside_root
158
+ return if @scanner.skip(/\s+/)
159
+ return skip_comment if @scanner.check(/<!--/)
160
+ return skip_processing_instruction if @scanner.check(/<\?/)
161
+ raise ParseError, "DOCTYPE and other declarations are not supported" if @scanner.check(/<!/)
162
+ raise ParseError, "content after the root element" if @root
163
+ raise ParseError, "text outside the root element" unless @scanner.check(/</)
164
+
165
+ open_element
166
+ end
167
+
168
+ def read_content
169
+ if (data = @scanner.scan(/[^<]+/))
170
+ raise ParseError, "']]>' is not allowed in character data" if data.include?("]]>")
171
+
172
+ @stack.last.append_text(XmlParser.decode_references(data))
173
+ elsif @scanner.check(/<\//)
174
+ close_element
175
+ elsif @scanner.check(/<!--/)
176
+ skip_comment
177
+ elsif @scanner.skip(/<!\[CDATA\[/)
178
+ cdata = @scanner.scan_until(/\]\]>/) or raise ParseError, "unterminated CDATA section"
179
+ @stack.last.append_text(cdata[0...-3])
180
+ elsif @scanner.check(/<!/)
181
+ raise ParseError, "DOCTYPE and other declarations are not supported"
182
+ elsif @scanner.check(/<\?/)
183
+ skip_processing_instruction
184
+ else
185
+ open_element
186
+ end
187
+ end
188
+
189
+ def open_element
190
+ @scanner.skip(/</)
191
+ name = @scanner.scan(NAME) or raise ParseError, "invalid tag name at byte #{@scanner.pos}"
192
+ attributes = read_attributes
193
+ self_closing = !@scanner.skip(%r{/>}).nil?
194
+ raise ParseError, "unterminated tag <#{name}>" unless self_closing || @scanner.skip(/>/)
195
+
196
+ element = Element.new(name, attributes)
197
+ if @stack.empty?
198
+ @root = element
199
+ else
200
+ @stack.last.append_child(element)
201
+ end
202
+ return if self_closing
203
+
204
+ @stack.push(element)
205
+ @open_names.push(name)
206
+ end
207
+
208
+ def read_attributes
209
+ attributes = {}
210
+ loop do
211
+ had_space = @scanner.skip(/\s+/)
212
+ break if @scanner.check(%r{/?>})
213
+
214
+ raise ParseError, "attributes must be separated by whitespace" unless had_space
215
+
216
+ key = @scanner.scan(NAME) or raise ParseError, "invalid attribute at byte #{@scanner.pos}"
217
+ @scanner.skip(/\s*=\s*/) or raise ParseError, "attribute #{key} has no value"
218
+ quote = @scanner.getch
219
+ raise ParseError, "attribute #{key} value must be quoted" unless ['"', "'"].include?(quote)
220
+
221
+ value = @scanner.scan_until(/#{quote}/) or raise ParseError, "unterminated attribute #{key}"
222
+ value = value[0...-1]
223
+ raise ParseError, "'<' is not allowed in attribute #{key}" if value.include?("<")
224
+ raise ParseError, "duplicate attribute #{key}" if attributes.key?(key)
225
+
226
+ attributes[key] = XmlParser.decode_references(value)
227
+ end
228
+ attributes
229
+ end
230
+
231
+ def close_element
232
+ @scanner.skip(%r{</})
233
+ name = @scanner.scan(NAME) or raise ParseError, "invalid closing tag at byte #{@scanner.pos}"
234
+ @scanner.skip(/\s*/)
235
+ @scanner.skip(/>/) or raise ParseError, "unterminated closing tag </#{name}>"
236
+ expected = @open_names.last
237
+ raise ParseError, "unexpected closing tag </#{name}>" unless expected
238
+ raise ParseError, "mismatched tag: expected </#{expected}>, found </#{name}>" unless expected == name
239
+
240
+ @open_names.pop
241
+ @stack.pop
242
+ end
243
+
244
+ def skip_comment
245
+ @scanner.skip(/<!--/)
246
+ @scanner.skip_until(/-->/) or raise ParseError, "unterminated comment"
247
+ end
248
+
249
+ def skip_processing_instruction
250
+ @scanner.skip(/<\?/)
251
+ target = @scanner.check(NAME)
252
+ raise ParseError, "an XML declaration is only allowed at the very start" if target&.casecmp?("xml")
253
+
254
+ @scanner.skip_until(/\?>/) or raise ParseError, "unterminated processing instruction"
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end