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/shutdown.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
module Tina4
|
|
4
10
|
# Graceful shutdown on SIGTERM / SIGINT.
|
|
@@ -80,10 +86,9 @@ module Tina4
|
|
|
80
86
|
@shutdown_complete = true
|
|
81
87
|
return if drained
|
|
82
88
|
|
|
83
|
-
# The deadline expired with requests still in flight.
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
# handler runs - exactly what TINA4_SHUTDOWN_TIMEOUT exists to prevent.
|
|
89
|
+
# The deadline expired with requests still in flight. Returning here
|
|
90
|
+
# would let the process live for as long as the slowest handler runs -
|
|
91
|
+
# exactly what TINA4_SHUTDOWN_TIMEOUT exists to prevent.
|
|
87
92
|
# Flush first: exit! runs no at_exit handlers and does not flush stdio,
|
|
88
93
|
# which would swallow the warning that explains the forced exit.
|
|
89
94
|
$stdout.flush
|
|
@@ -107,10 +112,9 @@ module Tina4
|
|
|
107
112
|
|
|
108
113
|
# Block until initiate_shutdown has finished every teardown step.
|
|
109
114
|
#
|
|
110
|
-
# stop_accepting unblocks
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
# closing database connections. The server's main thread calls this so the
|
|
115
|
+
# stop_accepting unblocks the built-in server's accept loop immediately, so
|
|
116
|
+
# its #start returns while the signal handler's thread is still draining
|
|
117
|
+
# requests, stopping background tasks and closing database connections. The server's main thread calls this so the
|
|
114
118
|
# process does not exit out from under that teardown.
|
|
115
119
|
def wait_for_completion(timeout = nil)
|
|
116
120
|
return unless @shutting_down
|
|
@@ -144,13 +148,12 @@ module Tina4
|
|
|
144
148
|
DEFAULT_TIMEOUT
|
|
145
149
|
end
|
|
146
150
|
|
|
147
|
-
# Close the listening socket BEFORE draining.
|
|
148
|
-
#
|
|
149
|
-
#
|
|
151
|
+
# Close the listening socket BEFORE draining. Tina4::HttpServer#shutdown
|
|
152
|
+
# closes every listener and every keep-alive connection that is waiting
|
|
153
|
+
# for its next request; it does not touch a request being served - so new
|
|
150
154
|
# connections are refused while in-flight requests still run to completion
|
|
151
|
-
# and write their full response
|
|
152
|
-
#
|
|
153
|
-
# ECONNREFUSED, and the 2s handler mid-flight still returns a full 200.
|
|
155
|
+
# and write their full response (spec/graceful_shutdown_spec.rb proves all
|
|
156
|
+
# three on a real SIGTERM).
|
|
154
157
|
def stop_accepting
|
|
155
158
|
return unless @server.respond_to?(:shutdown)
|
|
156
159
|
|
|
@@ -0,0 +1,222 @@
|
|
|
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 "socket"
|
|
10
|
+
require_relative "mail_socket"
|
|
11
|
+
require_relative "base64"
|
|
12
|
+
|
|
13
|
+
module Tina4
|
|
14
|
+
class Messenger
|
|
15
|
+
# Tina4's own SMTP client (RFC 5321), replacing the net-smtp gem. It covers
|
|
16
|
+
# exactly what Messenger sends with:
|
|
17
|
+
#
|
|
18
|
+
# * transport: plain, STARTTLS, or implicit TLS. Implicit TLS defaults to
|
|
19
|
+
# port 465, the rule Python (SMTP_SSL), PHP (ssl://) and Node (tls.connect)
|
|
20
|
+
# already follow; the caller may also ask for it on any port (tls: true).
|
|
21
|
+
# STARTTLS is REQUIRED when asked for: a server that does not offer it
|
|
22
|
+
# fails the send rather than silently getting the message and the
|
|
23
|
+
# credentials in clear (Net::SMTP#enable_starttls did the same).
|
|
24
|
+
# * AUTH PLAIN, or AUTH LOGIN when that is all the server offers.
|
|
25
|
+
# * MAIL FROM / RCPT TO / DATA with CRLF normalisation and dot-stuffing.
|
|
26
|
+
#
|
|
27
|
+
# SmtpClient.start(host: "smtp.example.com", port: 587, starttls: true,
|
|
28
|
+
# username: "u", password: "p") do |smtp|
|
|
29
|
+
# smtp.send_message(raw_message, "from@example.com", ["to@example.com"])
|
|
30
|
+
# end
|
|
31
|
+
class SmtpClient
|
|
32
|
+
# A server reply outside the expected codes; the message is the reply text.
|
|
33
|
+
class Error < StandardError
|
|
34
|
+
attr_reader :code
|
|
35
|
+
|
|
36
|
+
def initialize(code, message)
|
|
37
|
+
@code = code
|
|
38
|
+
super(message)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
IMPLICIT_TLS_PORT = 465
|
|
43
|
+
|
|
44
|
+
def self.start(**options)
|
|
45
|
+
client = new(**options)
|
|
46
|
+
client.connect
|
|
47
|
+
return client unless block_given?
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
yield client
|
|
51
|
+
ensure
|
|
52
|
+
client.quit
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# auth_mechanism forces "PLAIN" or "LOGIN"; nil picks from the server's
|
|
57
|
+
# EHLO list (PLAIN preferred, as Net::SMTP's :plain always sent).
|
|
58
|
+
def initialize(host:, port:, tls: nil, starttls: false, username: nil, password: nil,
|
|
59
|
+
auth_mechanism: nil, timeout: 30, helo_domain: nil)
|
|
60
|
+
@host = host
|
|
61
|
+
@port = port.to_i
|
|
62
|
+
@implicit_tls = tls.nil? ? @port == IMPLICIT_TLS_PORT : tls
|
|
63
|
+
@starttls = starttls
|
|
64
|
+
@username = username
|
|
65
|
+
@password = password
|
|
66
|
+
@auth_mechanism = auth_mechanism&.upcase
|
|
67
|
+
@timeout = timeout
|
|
68
|
+
@helo_domain = helo_domain || local_host_name
|
|
69
|
+
@capabilities = []
|
|
70
|
+
@socket = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
attr_reader :capabilities
|
|
74
|
+
|
|
75
|
+
def implicit_tls?
|
|
76
|
+
@implicit_tls
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def tls?
|
|
80
|
+
@socket&.tls? || false
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def connect
|
|
84
|
+
@socket = MailSocket.open(@host, @port, tls: implicit_tls?, timeout: @timeout)
|
|
85
|
+
read_reply(220)
|
|
86
|
+
greet
|
|
87
|
+
upgrade_to_tls if @starttls && !implicit_tls?
|
|
88
|
+
authenticate if @username && @password
|
|
89
|
+
self
|
|
90
|
+
rescue StandardError
|
|
91
|
+
close
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def send_message(message, from, recipients)
|
|
96
|
+
recipients = Array(recipients).map { |recipient| envelope_address(recipient) }
|
|
97
|
+
raise ArgumentError, "at least one recipient is required" if recipients.empty?
|
|
98
|
+
|
|
99
|
+
command("MAIL FROM:<#{envelope_address(from)}>", 250)
|
|
100
|
+
recipients.each { |recipient| command("RCPT TO:<#{recipient}>", 250, 251) }
|
|
101
|
+
command("DATA", 354)
|
|
102
|
+
@socket.write(self.class.data_block(message))
|
|
103
|
+
read_reply(250)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# QUIT politely, then close. Never raises: the message is already queued.
|
|
107
|
+
def quit
|
|
108
|
+
return unless @socket
|
|
109
|
+
|
|
110
|
+
begin
|
|
111
|
+
command("QUIT", 221)
|
|
112
|
+
rescue StandardError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
close
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def close
|
|
119
|
+
@socket&.close
|
|
120
|
+
@socket = nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The DATA payload: every line ending normalised to CRLF, a line starting
|
|
124
|
+
# with "." doubled (RFC 5321 4.5.2), terminated by CRLF.CRLF.
|
|
125
|
+
def self.data_block(message)
|
|
126
|
+
data = message.to_s.b.gsub(/\r\n|\r|\n/n, "\r\n")
|
|
127
|
+
data << "\r\n" unless data.end_with?("\r\n")
|
|
128
|
+
data.gsub(/^\./n, "..") + ".\r\n"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def greet
|
|
134
|
+
@capabilities = command("EHLO #{@helo_domain}", 250).drop(1)
|
|
135
|
+
rescue Error => e
|
|
136
|
+
raise unless (500..599).cover?(e.code)
|
|
137
|
+
|
|
138
|
+
# A server that does not speak ESMTP: plain HELO, no extensions.
|
|
139
|
+
command("HELO #{@helo_domain}", 250)
|
|
140
|
+
@capabilities = []
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def upgrade_to_tls
|
|
144
|
+
unless capability?("STARTTLS")
|
|
145
|
+
raise Error.new(0, "STARTTLS was requested but #{@host}:#{@port} does not offer it")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
command("STARTTLS", 220)
|
|
149
|
+
@socket.start_tls
|
|
150
|
+
greet # capabilities change once the channel is encrypted (AUTH often appears)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def authenticate
|
|
154
|
+
case auth_mechanism
|
|
155
|
+
when "PLAIN"
|
|
156
|
+
command("AUTH PLAIN #{Tina4::Base64.strict_encode64("\0#{@username}\0#{@password}")}", 235)
|
|
157
|
+
when "LOGIN"
|
|
158
|
+
command("AUTH LOGIN", 334)
|
|
159
|
+
command(Tina4::Base64.strict_encode64(@username.to_s), 334)
|
|
160
|
+
command(Tina4::Base64.strict_encode64(@password.to_s), 235)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def auth_mechanism
|
|
165
|
+
return @auth_mechanism if @auth_mechanism
|
|
166
|
+
|
|
167
|
+
offered = @capabilities.filter_map { |line| line[/\AAUTH[ =](.*)\z/i, 1] }
|
|
168
|
+
.flat_map { |list| list.upcase.split }
|
|
169
|
+
return "PLAIN" if offered.empty? || offered.include?("PLAIN")
|
|
170
|
+
return "LOGIN" if offered.include?("LOGIN")
|
|
171
|
+
|
|
172
|
+
raise Error.new(0, "#{@host}:#{@port} offers no supported AUTH mechanism (offered: #{offered.join(' ')})")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def capability?(name)
|
|
176
|
+
@capabilities.any? { |line| line.split(/[ =]/, 2).first.casecmp?(name) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Send one command line and read its reply; returns the reply lines.
|
|
180
|
+
def command(line, *expected)
|
|
181
|
+
if line.match?(/[\r\n]/)
|
|
182
|
+
raise ArgumentError, "SMTP command contains a line break"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@socket.write("#{line}\r\n")
|
|
186
|
+
read_reply(*expected)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A reply is one or more "NNN-text" lines ending with "NNN text".
|
|
190
|
+
def read_reply(*expected)
|
|
191
|
+
lines = []
|
|
192
|
+
code = nil
|
|
193
|
+
loop do
|
|
194
|
+
raw = @socket.read_line.force_encoding(Encoding::UTF_8).scrub.chomp
|
|
195
|
+
code = raw[0, 3].to_i
|
|
196
|
+
lines << raw[4..].to_s
|
|
197
|
+
break unless raw[3] == "-"
|
|
198
|
+
end
|
|
199
|
+
return lines if expected.empty? || expected.include?(code)
|
|
200
|
+
|
|
201
|
+
raise Error.new(code, "#{code} #{lines.join(' ')}".strip)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The bare address for the envelope: "Name <a@b>" -> "a@b". CR, LF and
|
|
205
|
+
# angle brackets inside it would let a caller inject SMTP commands.
|
|
206
|
+
def envelope_address(address)
|
|
207
|
+
text = address.to_s.strip
|
|
208
|
+
text = Regexp.last_match(1).strip if text =~ /<([^<>]*)>\s*\z/
|
|
209
|
+
raise ArgumentError, "invalid email address #{address.inspect}" if text.match?(/[\r\n<>]/)
|
|
210
|
+
|
|
211
|
+
text
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def local_host_name
|
|
215
|
+
name = Socket.gethostname.to_s
|
|
216
|
+
name.match?(/\A[A-Za-z0-9.\-]+\z/) ? name : "localhost"
|
|
217
|
+
rescue StandardError
|
|
218
|
+
"localhost"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
data/lib/tina4/sql_translator.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
require "digest"
|
|
4
10
|
require_relative "cache"
|
|
@@ -150,11 +156,98 @@ module Tina4
|
|
|
150
156
|
[out, literals]
|
|
151
157
|
end
|
|
152
158
|
|
|
159
|
+
# Rewrite every PLACEHOLDER `?` in +sql+ with the block's answer for its
|
|
160
|
+
# zero-based index, leaving every `?` that is not a placeholder alone
|
|
161
|
+
# (tina4-python #138). Skipped: string literals ('...' with '' escapes,
|
|
162
|
+
# Postgres E'...' with backslash escapes, $$...$$ and $tag$...$tag$),
|
|
163
|
+
# quoted identifiers ("..." and `...`, plus [...] when +brackets+ is true
|
|
164
|
+
# for SQL Server - never elsewhere, where arr[?] is a real placeholder)
|
|
165
|
+
# and comments (-- and /* */). A `%` is never special here.
|
|
166
|
+
#
|
|
167
|
+
# The two Ruby drivers that rewrite `?` both did a plain text rewrite:
|
|
168
|
+
# Postgres (? -> $n) turned SELECT 'why?', ? into 'why$1', $2; MSSQL
|
|
169
|
+
# (? -> escaped value, one sub at a time) also rewrote a `?` inside a
|
|
170
|
+
# value it had just spliced in.
|
|
171
|
+
def replace_placeholders(sql, brackets: false)
|
|
172
|
+
out = +""
|
|
173
|
+
index = 0
|
|
174
|
+
i = 0
|
|
175
|
+
n = sql.length
|
|
176
|
+
while i < n
|
|
177
|
+
span_end = placeholder_skip_span(sql, i, brackets)
|
|
178
|
+
if span_end
|
|
179
|
+
out << sql[i...span_end]
|
|
180
|
+
i = span_end
|
|
181
|
+
elsif sql[i] == "?"
|
|
182
|
+
out << yield(index).to_s
|
|
183
|
+
index += 1
|
|
184
|
+
i += 1
|
|
185
|
+
else
|
|
186
|
+
out << sql[i]
|
|
187
|
+
i += 1
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
out
|
|
191
|
+
end
|
|
192
|
+
|
|
153
193
|
# Inverse of #mask_literals.
|
|
154
194
|
def restore_literals(masked, literals)
|
|
155
195
|
masked.gsub(/\x00(\d+)\x00/) { literals[::Regexp.last_match(1).to_i] }
|
|
156
196
|
end
|
|
157
197
|
|
|
198
|
+
# When a literal, quoted identifier or comment STARTS at +i+, the index
|
|
199
|
+
# just past its end; otherwise nil. An unterminated span runs to the end.
|
|
200
|
+
def placeholder_skip_span(sql, i, brackets)
|
|
201
|
+
ch = sql[i]
|
|
202
|
+
nxt = sql[i + 1]
|
|
203
|
+
return sql.index("\n", i) || sql.length if ch == "-" && nxt == "-"
|
|
204
|
+
return (sql.index("*/", i + 2)&.+(2)) || sql.length if ch == "/" && nxt == "*"
|
|
205
|
+
return quoted_span_end(sql, i, "'", backslash: e_string?(sql, i)) if ch == "'"
|
|
206
|
+
return quoted_span_end(sql, i, ch) if ch == '"' || ch == "`"
|
|
207
|
+
return quoted_span_end(sql, i, "]") if brackets && ch == "["
|
|
208
|
+
return dollar_span_end(sql, i) if ch == "$"
|
|
209
|
+
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The end of a span opened at +i+ and closed by +close+, where a doubled
|
|
214
|
+
# closer is an escaped one ('' "" `` ]]) and, for an E'...' string, a
|
|
215
|
+
# backslash escapes the next character.
|
|
216
|
+
def quoted_span_end(sql, i, close, backslash: false)
|
|
217
|
+
j = i + 1
|
|
218
|
+
n = sql.length
|
|
219
|
+
while j < n
|
|
220
|
+
if backslash && sql[j] == "\\"
|
|
221
|
+
j += 2
|
|
222
|
+
elsif sql[j] == close
|
|
223
|
+
return j + 1 unless sql[j + 1] == close
|
|
224
|
+
|
|
225
|
+
j += 2
|
|
226
|
+
else
|
|
227
|
+
j += 1
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
n
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A Postgres escape string: E'...' / e'...' where the E is its own token.
|
|
234
|
+
def e_string?(sql, i)
|
|
235
|
+
i.positive? && sql[i - 1].match?(/[eE]/) && (i < 2 || !sql[i - 2].match?(/[A-Za-z0-9_$]/))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A Postgres dollar-quoted string ($$...$$ or $tag$...$tag$) starting at
|
|
239
|
+
# +i+, or nil. `$1` is a numbered parameter, not a tag (tags cannot start
|
|
240
|
+
# with a digit), and a `$` inside an identifier (a$b) opens nothing.
|
|
241
|
+
def dollar_span_end(sql, i)
|
|
242
|
+
return nil if i.positive? && sql[i - 1].match?(/[A-Za-z0-9_]/)
|
|
243
|
+
|
|
244
|
+
tag = sql[i..][/\A\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/]
|
|
245
|
+
return nil unless tag
|
|
246
|
+
|
|
247
|
+
close = sql.index(tag, i + tag.length)
|
|
248
|
+
close ? close + tag.length : sql.length
|
|
249
|
+
end
|
|
250
|
+
|
|
158
251
|
# Convert || string concatenation to CONCAT() for MySQL/MSSQL. Rewrites ONLY
|
|
159
252
|
# || operators joining expression operands OUTSIDE any string literal or
|
|
160
253
|
# comment, and only the operand chain - never the whole statement.
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
module Tina4
|
|
10
|
+
# The sqlite3 gem is an APPLICATION dependency, not a framework one
|
|
11
|
+
# (ADR-0067): `tina4 init ruby` writes gem "sqlite3" into the project's
|
|
12
|
+
# Gemfile, and tina4ruby itself never declares it, exactly like pg, mysql2 and
|
|
13
|
+
# the other drivers. Everything that opens SQLite goes through here so a
|
|
14
|
+
# project without the gem gets one actionable message instead of a bare
|
|
15
|
+
# "cannot load such file -- sqlite3".
|
|
16
|
+
SQLITE3_MISSING_MESSAGE =
|
|
17
|
+
"The 'sqlite3' gem is required for SQLite connections. Add gem \"sqlite3\" " \
|
|
18
|
+
"to your Gemfile (tina4 init ruby does this for you), or install one of:\n" \
|
|
19
|
+
" bundle add sqlite3 # if your project uses Bundler\n" \
|
|
20
|
+
" gem install sqlite3 # bare driver"
|
|
21
|
+
|
|
22
|
+
def self.require_sqlite3!
|
|
23
|
+
require "sqlite3"
|
|
24
|
+
rescue LoadError
|
|
25
|
+
raise LoadError, SQLITE3_MISSING_MESSAGE
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/tina4/sso.rb
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
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 "base64"
|
|
4
10
|
require "digest"
|
|
5
11
|
require "json"
|
|
6
12
|
require "net/http"
|
|
7
13
|
require "openssl"
|
|
8
14
|
require "securerandom"
|
|
9
15
|
require "uri"
|
|
16
|
+
require_relative "parse_json"
|
|
10
17
|
|
|
11
18
|
module Tina4
|
|
12
19
|
class SsoError < StandardError; end
|
|
@@ -42,7 +49,7 @@ module Tina4
|
|
|
42
49
|
|
|
43
50
|
def json_env(name, fallback)
|
|
44
51
|
raw = ENV[name]
|
|
45
|
-
raw.nil? || raw.empty? ? fallback :
|
|
52
|
+
raw.nil? || raw.empty? ? fallback : Tina4.parse_json(raw)
|
|
46
53
|
rescue JSON::ParserError => e
|
|
47
54
|
raise SsoError, "#{name} must be valid JSON", cause: e
|
|
48
55
|
end
|
|
@@ -83,7 +90,7 @@ module Tina4
|
|
|
83
90
|
http.read_timeout = 10
|
|
84
91
|
response = http.request(request)
|
|
85
92
|
raise SsoError, "OIDC provider request failed" unless response.is_a?(Net::HTTPSuccess)
|
|
86
|
-
result =
|
|
93
|
+
result = Tina4.parse_json(response.body)
|
|
87
94
|
raise SsoError, "OIDC provider returned a non-object response" unless result.is_a?(Hash)
|
|
88
95
|
result
|
|
89
96
|
rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error => e
|
|
@@ -119,7 +126,7 @@ module Tina4
|
|
|
119
126
|
state = SecureRandom.urlsafe_base64(32)
|
|
120
127
|
nonce = SecureRandom.urlsafe_base64(32)
|
|
121
128
|
verifier = SecureRandom.urlsafe_base64(64)
|
|
122
|
-
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
129
|
+
challenge = Tina4::Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
123
130
|
current.set(PENDING_KEY, {
|
|
124
131
|
"state" => state, "nonce" => nonce, "verifier" => verifier,
|
|
125
132
|
"return_to" => self.class.safe_return(return_to), "created_at" => Time.now.to_i
|
|
@@ -140,7 +147,7 @@ module Tina4
|
|
|
140
147
|
|
|
141
148
|
def self.jwt_payload(token)
|
|
142
149
|
part = token.split(".")[1].to_s
|
|
143
|
-
|
|
150
|
+
Tina4.parse_json(Tina4::Base64.urlsafe_decode64(part.ljust((part.length + 3) / 4 * 4, "=")))
|
|
144
151
|
rescue JSON::ParserError, ArgumentError
|
|
145
152
|
raise SsoError, "provider returned an invalid ID token"
|
|
146
153
|
end
|
|
@@ -178,9 +185,19 @@ module Tina4
|
|
|
178
185
|
}
|
|
179
186
|
end
|
|
180
187
|
|
|
188
|
+
# The provider's code and state arrive in the QUERY STRING. They used to be
|
|
189
|
+
# read from request.params, which carries ROUTE path params only
|
|
190
|
+
# (REQ-PARAM-POLLUTION), so the built-in /auth/callback never saw them and
|
|
191
|
+
# could not complete a sign-in. Parity with the Python master (sso.py reads
|
|
192
|
+
# request.query). A bare Session has no query - pass +query+ explicitly.
|
|
193
|
+
def self.query_of(request_or_session)
|
|
194
|
+
query = request_or_session.respond_to?(:query) ? request_or_session.query : nil
|
|
195
|
+
query.is_a?(Hash) ? query : {}
|
|
196
|
+
end
|
|
197
|
+
|
|
181
198
|
def callback(request_or_session, query = nil)
|
|
182
199
|
current = session(request_or_session)
|
|
183
|
-
values = query || request_or_session
|
|
200
|
+
values = query || self.class.query_of(request_or_session)
|
|
184
201
|
pending = current&.get(PENDING_KEY)
|
|
185
202
|
current&.delete(PENDING_KEY)
|
|
186
203
|
unless pending.is_a?(Hash) && !values["code"].to_s.empty? && self.class.secure_equal(values["state"], pending["state"])
|
|
@@ -263,7 +280,7 @@ module Tina4
|
|
|
263
280
|
end
|
|
264
281
|
sso = from_issuer
|
|
265
282
|
Tina4::Router.get("/auth/login") do |request, response|
|
|
266
|
-
response.redirect(sso.login(request, request
|
|
283
|
+
response.redirect(sso.login(request, query_of(request)["return_to"] || "/"))
|
|
267
284
|
end
|
|
268
285
|
Tina4::Router.get("/auth/callback") do |request, response|
|
|
269
286
|
begin
|
|
@@ -273,7 +290,7 @@ module Tina4
|
|
|
273
290
|
end
|
|
274
291
|
end
|
|
275
292
|
Tina4::Router.post("/auth/logout") do |request, response|
|
|
276
|
-
response.redirect(sso.logout(request, request
|
|
293
|
+
response.redirect(sso.logout(request, query_of(request)["return_to"] || "/"))
|
|
277
294
|
end
|
|
278
295
|
@mounted = true
|
|
279
296
|
true
|
data/lib/tina4/swagger.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
require "json"
|
|
3
9
|
|
|
4
10
|
module Tina4
|
data/lib/tina4/template.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
module Tina4
|
|
4
10
|
module Template
|
data/lib/tina4/test.rb
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
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
|
# Tina4 — The Intelligent Native Application 4ramework
|
|
4
|
-
# Copyright
|
|
5
|
-
# License:
|
|
10
|
+
# Copyright (c) 2026 Code Infinity
|
|
11
|
+
# License: MPL-2.0 https://mozilla.org/MPL/2.0/
|
|
6
12
|
|
|
7
13
|
module Tina4
|
|
8
14
|
# Tina4 xUnit-style test base class — class-based test suites with HTTP
|
data/lib/tina4/test_client.rb
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
# Copyright (c) 2026 Code Infinity
|
|
2
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
4
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
6
|
+
|
|
1
7
|
# Tina4 Test Client — Test routes without starting a server.
|
|
2
8
|
#
|
|
3
9
|
# Usage:
|
|
@@ -14,6 +20,7 @@
|
|
|
14
20
|
# response = client.get("/api/users/1", headers: { "Authorization" => "Bearer token123" })
|
|
15
21
|
#
|
|
16
22
|
require "stringio" # rack.input is a StringIO; require it here so a clean `require "tina4"` boot never NameErrors
|
|
23
|
+
require_relative "parse_json"
|
|
17
24
|
|
|
18
25
|
module Tina4
|
|
19
26
|
class TestResponse
|
|
@@ -64,7 +71,7 @@ module Tina4
|
|
|
64
71
|
# Parse body as JSON.
|
|
65
72
|
def json
|
|
66
73
|
return nil if @body.nil? || @body.empty?
|
|
67
|
-
|
|
74
|
+
Tina4.parse_json(@body)
|
|
68
75
|
rescue JSON::ParserError
|
|
69
76
|
nil
|
|
70
77
|
end
|
data/lib/tina4/testing.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
|
require "json"
|
|
3
9
|
require "stringio"
|
|
10
|
+
require_relative "parse_json"
|
|
4
11
|
|
|
5
12
|
module Tina4
|
|
6
13
|
module Testing
|
|
@@ -281,7 +288,7 @@ module Tina4
|
|
|
281
288
|
end
|
|
282
289
|
|
|
283
290
|
def assert_json(response_body)
|
|
284
|
-
|
|
291
|
+
Tina4.parse_json(response_body)
|
|
285
292
|
rescue JSON::ParserError => e
|
|
286
293
|
raise TestFailure, "Invalid JSON: #{e.message}"
|
|
287
294
|
end
|
data/lib/tina4/validator.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
require "json"
|
|
4
10
|
|
data/lib/tina4/version.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
|
|
3
9
|
module Tina4
|
|
4
|
-
VERSION = "3.13.
|
|
10
|
+
VERSION = "3.13.138"
|
|
5
11
|
end
|