tina4ruby 3.13.137 → 3.13.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +6 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +6 -0
  26. data/lib/tina4/cli.rb +22 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +215 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +196 -56
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +28 -27
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +6 -0
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +9 -3
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +6 -0
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +85 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +6 -0
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -140
@@ -1,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. WEBrick's accept
84
- # loop joins its worker threads with NO timeout of its own, so simply
85
- # returning here would hang the process for as long as the slowest
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 WEBrick's accept loop immediately, so its #start
111
- # returns as soon as the in-flight workers are joined - which can be while
112
- # the signal handler's thread is still stopping background tasks and
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. WEBrick's #shutdown wakes its
148
- # accept loop and closes the listeners; it does not touch the worker
149
- # threads already running, which the loop's own ensure joins - so new
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. Measured on webrick 1.9.2 / Ruby 4.0.2:
152
- # #shutdown returns in ~0.1ms, a connection attempted 0.3s later is
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
@@ -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
- require "base64"
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 : JSON.parse(raw)
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 = JSON.parse(response.body)
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
- JSON.parse(Base64.urlsafe_decode64(part.ljust((part.length + 3) / 4 * 4, "=")))
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.params
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.params["return_to"] || "/"))
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.params["return_to"] || "/"))
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
@@ -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 2007 - current Tina4
5
- # License: MIT https://opensource.org/licenses/MIT
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
@@ -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
- JSON.parse(@body)
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
- JSON.parse(response_body)
291
+ Tina4.parse_json(response_body)
285
292
  rescue JSON::ParserError => e
286
293
  raise TestFailure, "Invalid JSON: #{e.message}"
287
294
  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
  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.137"
10
+ VERSION = "3.13.138"
5
11
  end