tina4ruby 3.13.137 → 3.13.139
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +74 -8
- data/lib/tina4/auth.rb +108 -26
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +14 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +9 -1
- data/lib/tina4/cli.rb +27 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +222 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +16 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +14 -6
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +200 -58
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +134 -35
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +21 -2
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +18 -9
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +27 -10
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +22 -4
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
- data/lib/tina4/queue_backends/lite_backend.rb +18 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
- data/lib/tina4/rack_app.rb +62 -50
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +15 -1
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +90 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +42 -12
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +52 -12
- data/lib/tina4/ssrf.rb +148 -0
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +170 -20
- data/lib/tina4/websocket_backplane.rb +25 -2
- data/lib/tina4/wsdl.rb +54 -18
- data/lib/tina4/xml_parser.rb +268 -0
- data/lib/tina4.rb +98 -33
- data/lib/tina4ruby.rb +6 -0
- metadata +44 -140
data/lib/tina4/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
|
|
@@ -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 :
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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" =>
|
|
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
|
-
|
|
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" =>
|
|
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
|
|
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
|
|
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
|
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.139"
|
|
5
11
|
end
|