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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -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 +57 -7
- data/lib/tina4/auth.rb +18 -12
- 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 +6 -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 +6 -0
- 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 +6 -0
- data/lib/tina4/cli.rb +22 -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 +215 -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 +8 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +6 -0
- 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 +196 -56
- 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 +28 -27
- 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 +6 -0
- 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 +15 -8
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +24 -9
- 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 +9 -3
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
- data/lib/tina4/queue_backends/lite_backend.rb +6 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
- data/lib/tina4/rack_app.rb +11 -1
- 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 +6 -0
- 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 +85 -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 +6 -0
- 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 +25 -8
- 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 +10 -4
- data/lib/tina4/websocket_backplane.rb +19 -2
- data/lib/tina4/wsdl.rb +31 -16
- data/lib/tina4/xml_parser.rb +259 -0
- data/lib/tina4.rb +87 -31
- data/lib/tina4ruby.rb +6 -0
- metadata +43 -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.
|
|
@@ -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
|
-
|
|
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
|
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,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
|
-
#
|
|
278
|
-
#
|
|
279
|
-
#
|
|
280
|
-
|
|
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 =
|
|
286
|
-
rescue
|
|
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
|
|
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 #
|
|
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
|
|
490
|
-
#
|
|
491
|
-
#
|
|
492
|
-
|
|
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 =
|
|
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
|