tina4ruby 3.13.137 → 3.13.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +74 -8
  12. data/lib/tina4/auth.rb +108 -26
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +14 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +9 -1
  26. data/lib/tina4/cli.rb +27 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +222 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +16 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +14 -6
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +200 -58
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +134 -35
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +21 -2
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +18 -9
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +27 -10
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +22 -4
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +18 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
  112. data/lib/tina4/rack_app.rb +62 -50
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +15 -1
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +90 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +42 -12
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +52 -12
  146. data/lib/tina4/ssrf.rb +148 -0
  147. data/lib/tina4/swagger.rb +6 -0
  148. data/lib/tina4/template.rb +6 -0
  149. data/lib/tina4/test.rb +8 -2
  150. data/lib/tina4/test_client.rb +8 -1
  151. data/lib/tina4/testing.rb +8 -1
  152. data/lib/tina4/validator.rb +6 -0
  153. data/lib/tina4/version.rb +7 -1
  154. data/lib/tina4/webserver.rb +25 -143
  155. data/lib/tina4/websocket.rb +170 -20
  156. data/lib/tina4/websocket_backplane.rb +25 -2
  157. data/lib/tina4/wsdl.rb +54 -18
  158. data/lib/tina4/xml_parser.rb +268 -0
  159. data/lib/tina4.rb +98 -33
  160. data/lib/tina4ruby.rb +6 -0
  161. metadata +44 -140
@@ -1,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
@@ -15,6 +22,30 @@ module Tina4
15
22
  class Sso
16
23
  PENDING_KEY = "_tina4_sso_pending"
17
24
  SESSION_KEY = "_tina4_sso"
25
+
26
+ # The stored SSO identity, only while it is live (ADR-0079 s5). +stored+ is
27
+ # the reserved _tina4_sso session value. The identity needs an issuer and a
28
+ # subject, and a numeric expires_at that is 0 (the provider gave no
29
+ # lifetime) or still in the future.
30
+ def self.live_session_identity(stored)
31
+ return nil unless stored.is_a?(Hash)
32
+
33
+ identity = stored["identity"]
34
+ return nil unless identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
35
+
36
+ expires_at = stored.fetch("expires_at", 0)
37
+ return nil unless expires_at.is_a?(Integer) || expires_at.is_a?(Float)
38
+ return nil if expires_at.positive? && Time.now.to_i >= expires_at
39
+
40
+ identity
41
+ end
42
+
43
+ # When the provider's access token lapses; 0 when it gave no lifetime.
44
+ # Never "now", which is already in the past by the next request.
45
+ def self.expires_at(tokens)
46
+ lifetime = tokens.fetch("expires_in", 0).to_i
47
+ lifetime.positive? ? Time.now.to_i + lifetime : 0
48
+ end
18
49
  attr_reader :issuer, :client_id, :client_secret, :redirect_uri, :scopes,
19
50
  :verify, :post_logout_redirect_uri, :claim_map
20
51
  @mounted = false
@@ -42,7 +73,7 @@ module Tina4
42
73
 
43
74
  def json_env(name, fallback)
44
75
  raw = ENV[name]
45
- raw.nil? || raw.empty? ? fallback : JSON.parse(raw)
76
+ raw.nil? || raw.empty? ? fallback : Tina4.parse_json(raw)
46
77
  rescue JSON::ParserError => e
47
78
  raise SsoError, "#{name} must be valid JSON", cause: e
48
79
  end
@@ -83,7 +114,7 @@ module Tina4
83
114
  http.read_timeout = 10
84
115
  response = http.request(request)
85
116
  raise SsoError, "OIDC provider request failed" unless response.is_a?(Net::HTTPSuccess)
86
- result = JSON.parse(response.body)
117
+ result = Tina4.parse_json(response.body)
87
118
  raise SsoError, "OIDC provider returned a non-object response" unless result.is_a?(Hash)
88
119
  result
89
120
  rescue JSON::ParserError, IOError, SystemCallError, Timeout::Error => e
@@ -119,7 +150,7 @@ module Tina4
119
150
  state = SecureRandom.urlsafe_base64(32)
120
151
  nonce = SecureRandom.urlsafe_base64(32)
121
152
  verifier = SecureRandom.urlsafe_base64(64)
122
- challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
153
+ challenge = Tina4::Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
123
154
  current.set(PENDING_KEY, {
124
155
  "state" => state, "nonce" => nonce, "verifier" => verifier,
125
156
  "return_to" => self.class.safe_return(return_to), "created_at" => Time.now.to_i
@@ -140,7 +171,7 @@ module Tina4
140
171
 
141
172
  def self.jwt_payload(token)
142
173
  part = token.split(".")[1].to_s
143
- JSON.parse(Base64.urlsafe_decode64(part.ljust((part.length + 3) / 4 * 4, "=")))
174
+ Tina4.parse_json(Tina4::Base64.urlsafe_decode64(part.ljust((part.length + 3) / 4 * 4, "=")))
144
175
  rescue JSON::ParserError, ArgumentError
145
176
  raise SsoError, "provider returned an invalid ID token"
146
177
  end
@@ -178,9 +209,19 @@ module Tina4
178
209
  }
179
210
  end
180
211
 
212
+ # The provider's code and state arrive in the QUERY STRING. They used to be
213
+ # read from request.params, which carries ROUTE path params only
214
+ # (REQ-PARAM-POLLUTION), so the built-in /auth/callback never saw them and
215
+ # could not complete a sign-in. Parity with the Python master (sso.py reads
216
+ # request.query). A bare Session has no query - pass +query+ explicitly.
217
+ def self.query_of(request_or_session)
218
+ query = request_or_session.respond_to?(:query) ? request_or_session.query : nil
219
+ query.is_a?(Hash) ? query : {}
220
+ end
221
+
181
222
  def callback(request_or_session, query = nil)
182
223
  current = session(request_or_session)
183
- values = query || request_or_session.params
224
+ values = query || self.class.query_of(request_or_session)
184
225
  pending = current&.get(PENDING_KEY)
185
226
  current&.delete(PENDING_KEY)
186
227
  unless pending.is_a?(Hash) && !values["code"].to_s.empty? && self.class.secure_equal(values["state"], pending["state"])
@@ -204,14 +245,13 @@ module Tina4
204
245
  current.set(SESSION_KEY, {
205
246
  "version" => 1, "identity" => identity, "access_token" => tokens["access_token"],
206
247
  "refresh_token" => tokens["refresh_token"], "id_token" => tokens["id_token"],
207
- "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
248
+ "expires_at" => self.class.expires_at(tokens)
208
249
  })
209
250
  { "identity" => identity, "return_to" => self.class.safe_return(pending["return_to"]) }
210
251
  end
211
252
 
212
253
  def identity(request_or_session)
213
- stored = session(request_or_session)&.get(SESSION_KEY)
214
- value = stored.is_a?(Hash) ? stored["identity"] : nil
254
+ value = self.class.live_session_identity(session(request_or_session)&.get(SESSION_KEY))
215
255
  request_or_session.user = value if value && !request_or_session.is_a?(Tina4::Session)
216
256
  value
217
257
  end
@@ -234,7 +274,7 @@ module Tina4
234
274
  "identity" => value, "access_token" => tokens["access_token"],
235
275
  "refresh_token" => tokens["refresh_token"] || stored["refresh_token"],
236
276
  "id_token" => tokens["id_token"] || stored["id_token"],
237
- "expires_at" => Time.now.to_i + tokens.fetch("expires_in", 0).to_i
277
+ "expires_at" => self.class.expires_at(tokens)
238
278
  ))
239
279
  value
240
280
  rescue StandardError
@@ -263,7 +303,7 @@ module Tina4
263
303
  end
264
304
  sso = from_issuer
265
305
  Tina4::Router.get("/auth/login") do |request, response|
266
- response.redirect(sso.login(request, request.params["return_to"] || "/"))
306
+ response.redirect(sso.login(request, query_of(request)["return_to"] || "/"))
267
307
  end
268
308
  Tina4::Router.get("/auth/callback") do |request, response|
269
309
  begin
@@ -273,7 +313,7 @@ module Tina4
273
313
  end
274
314
  end
275
315
  Tina4::Router.post("/auth/logout") do |request, response|
276
- response.redirect(sso.logout(request, request.params["return_to"] || "/"))
316
+ response.redirect(sso.logout(request, query_of(request)["return_to"] || "/"))
277
317
  end
278
318
  @mounted = true
279
319
  true
data/lib/tina4/ssrf.rb ADDED
@@ -0,0 +1,148 @@
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
+ require "ipaddr"
9
+ require "resolv"
10
+ require "socket"
11
+ require "uri"
12
+
13
+ module Tina4
14
+ # Raised when the SSRF guard refuses an outbound request.
15
+ class SsrfError < StandardError; end
16
+
17
+ # Outbound SSRF guard (ADR-0084).
18
+ #
19
+ # The Api client and Web Push refuse, by default, to connect to a private or
20
+ # internal address. Before each connection - the initial URL and every redirect
21
+ # hop the Api client follows - the host is resolved to its IP address(es) and
22
+ # the request is refused if any resolved address is loopback, private,
23
+ # link-local (including the cloud metadata address 169.254.169.254), unspecified
24
+ # or CGNAT. A non-http(s) scheme is refused.
25
+ #
26
+ # Off by default; TINA4_ALLOW_PRIVATE_REQUESTS (truthy) or an explicit allow-list
27
+ # of hosts / host:port / CIDRs opts out. Zero external dependencies - Ruby
28
+ # stdlib IPAddr + Resolv.
29
+ module Ssrf
30
+ ALLOW_PRIVATE_ENV = "TINA4_ALLOW_PRIVATE_REQUESTS"
31
+
32
+ # Truthiness identical to ADR-0070's set (trimmed, lower-cased).
33
+ TRUTHY = %w[1 true yes on].freeze
34
+
35
+ # The blocked address space, one definition shared with the contract fixture.
36
+ # 0.0.0.0/8 covers the unspecified address and the "this network" block.
37
+ BLOCKED = %w[
38
+ 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16
39
+ 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10
40
+ ::1/128 ::/128 fc00::/7 fe80::/10
41
+ ].map { |cidr| IPAddr.new(cidr) }.freeze
42
+
43
+ module_function
44
+
45
+ # True when TINA4_ALLOW_PRIVATE_REQUESTS opts out of the guard.
46
+ def allow_private_requests?
47
+ TRUTHY.include?(ENV.fetch(ALLOW_PRIVATE_ENV, "").strip.downcase)
48
+ end
49
+
50
+ # Classify one IP string: true = private/internal, refuse it. An IPv4-mapped
51
+ # IPv6 address is classified by its embedded IPv4. An unparseable value is
52
+ # treated as blocked - the guard refuses what it cannot classify.
53
+ def blocked_address?(ip)
54
+ addr = IPAddr.new(ip.to_s.split("%").first)
55
+ addr = addr.native if addr.ipv6? && addr.ipv4_mapped?
56
+ BLOCKED.any? { |network| network.include?(addr) }
57
+ rescue IPAddr::Error
58
+ true
59
+ end
60
+
61
+ # Refuse +url+ when it targets a private/internal address. Raises SsrfError
62
+ # for a non-http(s) scheme, an unresolvable host, or any resolved address in
63
+ # the blocked space. allow_hosts is a list of hosts / host:port / CIDRs.
64
+ def guard_url!(url, allow_hosts = nil)
65
+ uri = url.is_a?(URI) ? url : URI.parse(url.to_s)
66
+ scheme = uri.scheme&.downcase
67
+ unless %w[http https].include?(scheme)
68
+ raise SsrfError, "Blocked request: URL scheme '#{scheme || '(none)'}' is not http or https"
69
+ end
70
+ host = uri.host.to_s
71
+ raise SsrfError, "Blocked request: URL has no host" if host.empty?
72
+
73
+ host = host.delete_prefix("[").delete_suffix("]")
74
+ port = uri.port || (scheme == "https" ? 443 : 80)
75
+
76
+ return if allow_private_requests?
77
+
78
+ resolved = resolve(host)
79
+ return if matches_allow_list?(host, port, resolved, allow_hosts)
80
+
81
+ resolved.each do |ip|
82
+ next unless blocked_address?(ip)
83
+
84
+ log_block(host, ip)
85
+ raise SsrfError,
86
+ "Blocked request to private/internal address #{ip} (host #{host}): " \
87
+ "set #{ALLOW_PRIVATE_ENV}=true to allow, or pass an allow-list."
88
+ end
89
+ end
90
+
91
+ # Resolve a host to its IP address(es). An IP literal resolves to itself.
92
+ def resolve(host)
93
+ begin
94
+ IPAddr.new(host)
95
+ return [host]
96
+ rescue IPAddr::Error
97
+ # not a literal - fall through to DNS
98
+ end
99
+ addresses = begin
100
+ Resolv.getaddresses(host)
101
+ rescue StandardError
102
+ []
103
+ end
104
+ if addresses.empty?
105
+ begin
106
+ addresses = [IPSocket.getaddress(host)]
107
+ rescue SocketError
108
+ addresses = []
109
+ end
110
+ end
111
+ raise SsrfError, "Blocked request to #{host}: cannot resolve host" if addresses.empty?
112
+
113
+ addresses
114
+ end
115
+
116
+ def matches_allow_list?(host, port, resolved, allow_hosts)
117
+ host_lower = host.downcase
118
+ Array(allow_hosts).each do |raw|
119
+ entry = raw.to_s.strip.downcase
120
+ next if entry.empty?
121
+ return true if entry == host_lower || entry == "#{host_lower}:#{port}"
122
+
123
+ next unless entry.include?("/")
124
+
125
+ begin
126
+ network = IPAddr.new(entry)
127
+ rescue IPAddr::Error
128
+ next
129
+ end
130
+ resolved.each do |ip|
131
+ begin
132
+ candidate = IPAddr.new(ip.to_s.split("%").first)
133
+ rescue IPAddr::Error
134
+ next
135
+ end
136
+ return true if network.include?(candidate)
137
+ end
138
+ end
139
+ false
140
+ end
141
+
142
+ def log_block(host, ip)
143
+ Tina4::Log.warning("SSRF guard blocked outbound request to #{host} (#{ip})") if defined?(Tina4::Log)
144
+ rescue StandardError
145
+ nil
146
+ end
147
+ end
148
+ end
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.139"
5
11
  end