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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +74 -8
- data/lib/tina4/auth.rb +108 -26
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +14 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +9 -1
- data/lib/tina4/cli.rb +27 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +222 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +16 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +14 -6
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +200 -58
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +134 -35
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +21 -2
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +18 -9
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +27 -10
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +22 -4
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
- data/lib/tina4/queue_backends/lite_backend.rb +18 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
- data/lib/tina4/rack_app.rb +62 -50
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +15 -1
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +90 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +42 -12
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +52 -12
- data/lib/tina4/ssrf.rb +148 -0
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +170 -20
- data/lib/tina4/websocket_backplane.rb +25 -2
- data/lib/tina4/wsdl.rb +54 -18
- data/lib/tina4/xml_parser.rb +268 -0
- data/lib/tina4.rb +98 -33
- data/lib/tina4ruby.rb +6 -0
- metadata +44 -140
data/lib/tina4/webserver.rb
CHANGED
|
@@ -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
|
-
|
|
93
|
-
@
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
#
|
|
170
|
-
#
|
|
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.
|
|
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
|
data/lib/tina4/websocket.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
|
require "socket"
|
|
3
9
|
require "digest"
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
#
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
277
|
-
#
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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 =
|
|
286
|
-
rescue
|
|
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
|
|
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 #
|
|
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
|
-
#
|
|
490
|
-
#
|
|
491
|
-
#
|
|
492
|
-
|
|
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
|
-
|
|
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
|
|
618
|
+
"<faultcode>#{code}</faultcode>" \
|
|
583
619
|
"<faultstring>#{_escape_xml(message)}</faultstring>" \
|
|
584
620
|
"</soap:Fault></soap:Body></soap:Envelope>"
|
|
585
621
|
end
|