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
@@ -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.
@@ -107,7 +113,10 @@ module Tina4
107
113
  return [nil, false] unless token
108
114
 
109
115
  payload = Tina4::Auth.valid_token(token)
110
- [payload, !payload.nil?]
116
+ # A form token is not an identity (ADR-0079 s1).
117
+ return [nil, false] unless Tina4::Auth.identity_payload?(payload)
118
+
119
+ [payload, true]
111
120
  end
112
121
 
113
122
  # Whether the client offered the "bearer" subprotocol — in which case the
@@ -193,6 +202,17 @@ module Tina4
193
202
  @connections
194
203
  end
195
204
 
205
+ # Seconds a client may take to send its upgrade request (Python uses 10).
206
+ HANDSHAKE_TIMEOUT = 10
207
+ # Upper bound on the request head; a longer one is not a WebSocket upgrade.
208
+ MAX_REQUEST_HEAD = 16_384
209
+
210
+ # Run a standalone WebSocket server. port: 0 picks a free port (see #port).
211
+ #
212
+ # Each accepted socket gets its own thread that reads the HTTP request head,
213
+ # builds the Rack-style env handle_upgrade expects, and upgrades. This loop
214
+ # used to hand handle_upgrade an EMPTY env, so no Sec-WebSocket-Key was ever
215
+ # seen and no handshake could complete (the socket was left open, too).
196
216
  def start(host: "0.0.0.0", port: 7147)
197
217
  require "socket"
198
218
  @server_socket = TCPServer.new(host, port)
@@ -201,9 +221,8 @@ module Tina4
201
221
  while @running
202
222
  begin
203
223
  client = @server_socket.accept
204
- env = {}
205
- handle_upgrade(env, client)
206
- rescue => e
224
+ Thread.new(client) { |socket| serve_standalone(socket) }
225
+ rescue StandardError
207
226
  break unless @running
208
227
  end
209
228
  end
@@ -211,6 +230,53 @@ module Tina4
211
230
  self
212
231
  end
213
232
 
233
+ # The port the standalone server is listening on (useful with port: 0).
234
+ def port
235
+ @server_socket&.local_address&.ip_port
236
+ end
237
+
238
+ # Read one upgrade request from +socket+ and hand it to handle_upgrade; a
239
+ # request that is not a WebSocket upgrade gets a 400 and is closed.
240
+ def serve_standalone(socket)
241
+ env = read_upgrade_request(socket)
242
+ unless env && env["HTTP_SEC_WEBSOCKET_KEY"] && upgrade?(env)
243
+ socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n") rescue nil
244
+ socket.close rescue nil
245
+ return
246
+ end
247
+ handle_upgrade(env, socket)
248
+ rescue StandardError => e
249
+ Tina4::Log.warning("WebSocket standalone upgrade failed: #{e.message}") if defined?(Tina4::Log)
250
+ socket.close rescue nil
251
+ end
252
+
253
+ # The request head as a Rack-style env (REQUEST_PATH, QUERY_STRING and
254
+ # HTTP_* headers), or nil when it does not arrive in time or is too long.
255
+ # Read a byte at a time so nothing after the head is consumed.
256
+ def read_upgrade_request(socket)
257
+ head = +""
258
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HANDSHAKE_TIMEOUT
259
+ until head.end_with?("\r\n\r\n")
260
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
261
+ return nil if remaining <= 0 || head.bytesize > MAX_REQUEST_HEAD || !socket.wait_readable(remaining)
262
+
263
+ head << socket.readpartial(1)
264
+ end
265
+ request_line, *header_lines = head.split("\r\n")
266
+ method, target, = request_line.to_s.split(" ")
267
+ path, _, query = target.to_s.partition("?")
268
+ env = { "REQUEST_METHOD" => method.to_s, "REQUEST_PATH" => path, "PATH_INFO" => path, "QUERY_STRING" => query }
269
+ header_lines.each do |line|
270
+ name, value = line.split(":", 2)
271
+ next if value.nil?
272
+
273
+ env["HTTP_#{name.strip.upcase.tr("-", "_")}"] = value.strip
274
+ end
275
+ env
276
+ rescue IOError, SystemCallError
277
+ nil
278
+ end
279
+
214
280
  def stop
215
281
  @running = false
216
282
  @server_socket&.close rescue nil
@@ -499,7 +565,7 @@ module Tina4
499
565
  }
500
566
  # JSON can't carry raw bytes — encode binary as base64, text as text.
501
567
  if binary_payload?(message)
502
- envelope["b64"] = Base64.strict_encode64(message.b)
568
+ envelope["b64"] = Tina4::Base64.strict_encode64(message.b)
503
569
  else
504
570
  envelope["text"] = message
505
571
  end
@@ -514,7 +580,7 @@ module Tina4
514
580
  # carry bytes, so text → {"text": ...} and bytes → {"b64": base64(...)}.
515
581
  def decode_envelope_message(env)
516
582
  return env["text"] if env.key?("text")
517
- return Base64.strict_decode64(env["b64"]).b if env.key?("b64")
583
+ return Tina4::Base64.strict_decode64(env["b64"]).b if env.key?("b64")
518
584
 
519
585
  nil
520
586
  end
@@ -733,13 +799,14 @@ module Tina4
733
799
 
734
800
  def send(message)
735
801
  data = message.is_a?(String) ? message : message.to_s
736
- # Text frames must be valid UTF-8; binary payloads are sent verbatim.
737
- data = data.encode("UTF-8") if data.encoding != Encoding::ASCII_8BIT
738
- frame = build_frame(0x1, data)
739
- @socket.write(frame)
740
- rescue IOError
741
- # Connection closed — mark dead so the broadcast path prunes it.
742
- @closed = true
802
+ # A binary (ASCII-8BIT) payload goes out as a BINARY frame, verbatim. It
803
+ # used to go out as a TEXT frame, which is invalid UTF-8 on the wire and
804
+ # makes a browser fail the connection. Text frames must be valid UTF-8.
805
+ if data.encoding == Encoding::ASCII_8BIT
806
+ write_frame(build_frame(0x2, data))
807
+ else
808
+ write_frame(build_frame(0x1, data.encode("UTF-8")))
809
+ end
743
810
  end
744
811
 
745
812
  alias_method :send_text, :send
@@ -751,16 +818,48 @@ module Tina4
751
818
  end
752
819
 
753
820
  def send_pong(data)
754
- frame = build_frame(0xA, data || "")
755
- @socket.write(frame)
756
- rescue IOError
757
- @closed = true
821
+ write_frame(build_frame(0xA, data || ""))
758
822
  end
759
823
 
760
824
  def close(code: 1000, reason: "")
761
825
  payload = [code].pack("n") + reason
762
826
  frame = build_frame(0x8, payload)
763
- @socket.write(frame) rescue nil
827
+ # Best effort and never blocking: a peer that stopped reading must not
828
+ # hold up the reaper or a shutdown. The close frame only goes out when no
829
+ # earlier frame is still queued, so it can never land mid-frame.
830
+ write_lock.synchronize do
831
+ @socket.write_nonblock(frame, exception: false) if pending.empty?
832
+ end
833
+ rescue IOError, SystemCallError
834
+ nil
835
+ ensure
836
+ @closed = true
837
+ @socket.close rescue nil
838
+ end
839
+
840
+ # Hand a frame to the socket WITHOUT blocking the caller. What the kernel
841
+ # cannot take yet waits in a per-connection queue that a flusher thread
842
+ # drains as the peer reads. A peer whose queue passes TINA4_WS_MAX_BACKLOG
843
+ # bytes (default 1 MiB, 0 = unbounded; the same setting as tina4-nodejs) is
844
+ # hopelessly behind or never reads, so it is closed. A blocking write here
845
+ # used to stall every broadcast forever on one client that stopped reading.
846
+ def write_frame(frame)
847
+ return if @closed
848
+
849
+ write_lock.synchronize do
850
+ pending << frame.b
851
+ flush_pending
852
+ limit = max_backlog
853
+ if limit.positive? && pending.bytesize > limit
854
+ backlog = pending.bytesize
855
+ drop_slow_client("backlog #{backlog} bytes exceeds TINA4_WS_MAX_BACKLOG (#{limit})")
856
+ elsif !pending.empty?
857
+ start_flusher
858
+ end
859
+ end
860
+ rescue IOError, SystemCallError
861
+ # Broken pipe / reset / closed socket: mark dead so the broadcast path prunes it.
862
+ @closed = true
764
863
  @socket.close rescue nil
765
864
  end
766
865
 
@@ -796,6 +895,57 @@ module Tina4
796
895
  def build_frame(opcode, data)
797
896
  Tina4.build_frame(opcode, data)
798
897
  end
898
+
899
+ private
900
+
901
+ def write_lock
902
+ @write_lock ||= Mutex.new
903
+ end
904
+
905
+ def pending
906
+ @pending ||= +"".b
907
+ end
908
+
909
+ def max_backlog
910
+ Integer(ENV["TINA4_WS_MAX_BACKLOG"] || "1048576")
911
+ rescue ArgumentError, TypeError
912
+ 1_048_576
913
+ end
914
+
915
+ # Write as much of the queue as the kernel takes right now. Caller holds the lock.
916
+ def flush_pending
917
+ until pending.empty?
918
+ written = @socket.write_nonblock(pending, exception: false)
919
+ break if written == :wait_writable
920
+
921
+ @pending = pending.byteslice(written..)
922
+ end
923
+ end
924
+
925
+ # One background drainer per connection while bytes are queued.
926
+ def start_flusher
927
+ return if @flusher&.alive?
928
+
929
+ @flusher = Thread.new do
930
+ until @closed
931
+ done = write_lock.synchronize { pending.empty? }
932
+ break if done
933
+
934
+ @socket.wait_writable(1)
935
+ write_lock.synchronize { flush_pending }
936
+ end
937
+ rescue IOError, SystemCallError
938
+ @closed = true
939
+ @socket.close rescue nil
940
+ end
941
+ end
942
+
943
+ def drop_slow_client(reason)
944
+ Tina4::Log.warning("WebSocket client #{@id} dropped as too slow: #{reason}") if defined?(Tina4::Log)
945
+ @pending = +"".b
946
+ @closed = true
947
+ @socket.close rescue nil
948
+ end
799
949
  end
800
950
 
801
951
  # Shared, process-wide manager for the dev-reload channel (/__dev_reload).
@@ -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
@@ -98,6 +115,12 @@ module Tina4
98
115
  block.call(msg) if @running
99
116
  end
100
117
  end
118
+ rescue StandardError => e
119
+ # A refused login or a dropped connection ends this listener for good.
120
+ # Say so in the framework log: before, the thread died with only a raw
121
+ # stack trace on stderr while "backplane active" stood as the last word.
122
+ # The redis client's messages carry the URL without its userinfo.
123
+ Tina4::Log.error("WebSocket backplane subscriber stopped on '#{channel}': #{e.message}") if @running && defined?(Tina4::Log)
101
124
  end
102
125
  end
103
126
 
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,26 @@ 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
+ # The shared body rule, before anything reads the bytes: UTF-8 only, no
283
+ # byte order mark, no NUL byte, no other declared encoding.
284
+ begin
285
+ xml_body = XmlParser.utf8_text(xml_body)
286
+ rescue XmlParser::ParseError
287
+ return soap_fault("Client", "Malformed XML")
288
+ end
289
+
290
+ # SOAP 1.1 (§3) forbids a Document Type Declaration in a SOAP message, so
291
+ # reject any DOCTYPE up front with the same Client fault as the other three
292
+ # frameworks. The parser has no DTD support at all (no entity expansion, no
293
+ # XXE) and accepts UTF-8 only, so a UTF-16 body that hides its DOCTYPE from
294
+ # this byte regex is refused by the parser as Malformed XML.
295
+ if doctype?(xml_body)
281
296
  return soap_fault("Client", "DOCTYPE declarations are not allowed in SOAP messages")
282
297
  end
283
298
 
284
299
  begin
285
- doc = REXML::Document.new(xml_body)
286
- rescue REXML::ParseException
300
+ doc = XmlParser.parse(xml_body)
301
+ rescue XmlParser::ParseError
287
302
  return soap_fault("Client", "Malformed XML")
288
303
  end
289
304
 
@@ -328,7 +343,13 @@ module Tina4
328
343
  soap_response(op_name, result)
329
344
  end
330
345
 
331
- # ── XML helpers (REXML) ──────────────────────────────────────────────
346
+ # ── XML helpers ──────────────────────────────────────────────────────
347
+
348
+ # Byte-level match, so a body whose bytes are not valid UTF-8 cannot raise
349
+ # ArgumentError out of the regex before the parser gets to refuse it.
350
+ def doctype?(xml_body)
351
+ xml_body.to_s.b.match?(/<!DOCTYPE/in)
352
+ end
332
353
 
333
354
  def find_child(parent, local)
334
355
  parent.each_element do |el|
@@ -338,7 +359,7 @@ module Tina4
338
359
  end
339
360
 
340
361
  def local_name(element)
341
- element.name # REXML already strips the prefix for .name
362
+ element.name # XmlParser::Element#name is the local name (prefix stripped)
342
363
  end
343
364
 
344
365
  # ── Type conversion ──────────────────────────────────────────────────
@@ -486,12 +507,27 @@ module Tina4
486
507
  end
487
508
 
488
509
  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
510
+ # Malformed XML is the CLIENT's fault, as in Python and WSDL#process_soap.
511
+ # Both refusals below used to fall into the operation catch-all at the end
512
+ # of this method and answer "Internal server error" (a soap:Server fault).
513
+ begin
514
+ xml_body = XmlParser.utf8_text(xml_body) # the shared body encoding rule
515
+ rescue XmlParser::ParseError
516
+ return _soap_fault("Malformed XML", code: "Client")
517
+ end
493
518
 
494
- doc = REXML::Document.new(xml_body)
519
+ # SOAP 1.1 (§3) forbids a DOCTYPE/DTD. Reject before parsing; the parser
520
+ # itself has no DTD support and accepts UTF-8 only. Mirrors the
521
+ # class-based process_soap path.
522
+ if xml_body.b.match?(/<!DOCTYPE/in)
523
+ return _soap_fault("DOCTYPE declarations are not allowed in SOAP messages")
524
+ end
525
+
526
+ begin
527
+ doc = XmlParser.parse(xml_body)
528
+ rescue XmlParser::ParseError
529
+ return _soap_fault("Malformed XML", code: "Client")
530
+ end
495
531
 
496
532
  # Find Body element (namespace-agnostic)
497
533
  body_el = _find_child(doc.root, "Body")
@@ -575,11 +611,11 @@ module Tina4
575
611
  xml
576
612
  end
577
613
 
578
- def _soap_fault(message)
614
+ def _soap_fault(message, code: "soap:Server")
579
615
  '<?xml version="1.0" encoding="UTF-8"?>' \
580
616
  '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">' \
581
617
  "<soap:Body><soap:Fault>" \
582
- "<faultcode>soap:Server</faultcode>" \
618
+ "<faultcode>#{code}</faultcode>" \
583
619
  "<faultstring>#{_escape_xml(message)}</faultstring>" \
584
620
  "</soap:Fault></soap:Body></soap:Envelope>"
585
621
  end