tina4ruby 3.13.137 → 3.13.138
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +57 -7
- data/lib/tina4/auth.rb +18 -12
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +6 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +6 -0
- data/lib/tina4/cli.rb +22 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +215 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +8 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +6 -0
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +196 -56
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +28 -27
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +6 -0
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +15 -8
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +24 -9
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +9 -3
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
- data/lib/tina4/queue_backends/lite_backend.rb +6 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
- data/lib/tina4/rack_app.rb +11 -1
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +6 -0
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +85 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +6 -0
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +25 -8
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +10 -4
- data/lib/tina4/websocket_backplane.rb +19 -2
- data/lib/tina4/wsdl.rb +31 -16
- data/lib/tina4/xml_parser.rb +259 -0
- data/lib/tina4.rb +87 -31
- data/lib/tina4ruby.rb +6 -0
- metadata +43 -140
data/lib/tina4/messenger.rb
CHANGED
|
@@ -1,16 +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/.
|
|
2
7
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
end
|
|
8
|
-
begin
|
|
9
|
-
require "net/imap"
|
|
10
|
-
rescue LoadError
|
|
11
|
-
# net-imap gem needed on Ruby >= 4.0: gem install net-imap
|
|
12
|
-
end
|
|
13
|
-
require "base64"
|
|
8
|
+
|
|
9
|
+
require_relative "base64"
|
|
10
|
+
require_relative "smtp_client"
|
|
11
|
+
require_relative "imap_client"
|
|
14
12
|
require "securerandom"
|
|
15
13
|
require "time"
|
|
16
14
|
require "socket"
|
|
@@ -37,17 +35,14 @@ module Tina4
|
|
|
37
35
|
# "we talked fine and the mailbox is empty". These must fail loud — LOG and
|
|
38
36
|
# RAISE — never be silently swallowed into an empty result. Mirrors the
|
|
39
37
|
# Python master's _IMAP_CONNECTION_ERRORS tuple.
|
|
40
|
-
#
|
|
41
|
-
# Built lazily so a missing net/imap gem (LoadError above) doesn't break
|
|
42
|
-
# loading this file.
|
|
43
38
|
IMAP_CONNECTION_ERRORS = [
|
|
44
39
|
SocketError, # DNS / host resolution failures
|
|
45
|
-
IOError, # closed/broken stream, EOF
|
|
40
|
+
IOError, # closed/broken stream, EOF, MailSocket::TimeoutError
|
|
46
41
|
SystemCallError, # Errno::ECONNREFUSED / ECONNRESET / ETIMEDOUT etc.
|
|
47
|
-
Timeout::Error, #
|
|
42
|
+
Timeout::Error, # kept for callers that wrap Messenger in Timeout.timeout
|
|
43
|
+
Messenger::ImapClient::Error, # NO / BAD / BYE from the server (was Net::IMAP::Error)
|
|
48
44
|
MessengerError # our own protocol-failure signal (re-raised as-is)
|
|
49
45
|
].tap do |errors|
|
|
50
|
-
errors << Net::IMAP::Error if defined?(Net::IMAP::Error)
|
|
51
46
|
errors << OpenSSL::SSL::SSLError if defined?(OpenSSL::SSL::SSLError)
|
|
52
47
|
end.freeze
|
|
53
48
|
|
|
@@ -124,7 +119,7 @@ module Tina4
|
|
|
124
119
|
# SMTP encryption: constructor > .env > backward-compat use_tls > default "tls"
|
|
125
120
|
env_encryption = encryption || ENV["TINA4_MAIL_ENCRYPTION"]
|
|
126
121
|
if env_encryption
|
|
127
|
-
@encryption = env_encryption
|
|
122
|
+
@encryption = normalize_encryption(env_encryption, "mail")
|
|
128
123
|
elsif !use_tls.nil?
|
|
129
124
|
@encryption = use_tls ? "tls" : "none"
|
|
130
125
|
else
|
|
@@ -138,7 +133,7 @@ module Tina4
|
|
|
138
133
|
# IMAP encryption: dedicated env var TINA4_MAIL_IMAP_ENCRYPTION (tls/starttls/none).
|
|
139
134
|
# Defaults to "tls" — IMAPS over implicit TLS on port 993 is the safe industry norm.
|
|
140
135
|
env_imap_enc = imap_encryption || ENV["TINA4_MAIL_IMAP_ENCRYPTION"]
|
|
141
|
-
@imap_encryption =
|
|
136
|
+
@imap_encryption = env_imap_enc.nil? ? "tls" : normalize_encryption(env_imap_enc, "IMAP")
|
|
142
137
|
@imap_use_tls = %w[tls starttls ssl].include?(@imap_encryption)
|
|
143
138
|
|
|
144
139
|
# IMAP credentials, independent of SMTP. Dedicated
|
|
@@ -150,11 +145,18 @@ module Tina4
|
|
|
150
145
|
@imap_password = imap_password || ENV["TINA4_MAIL_IMAP_PASSWORD"] || @password
|
|
151
146
|
end
|
|
152
147
|
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
def normalize_encryption(value, label)
|
|
149
|
+
normalized = value.to_s.strip.downcase
|
|
150
|
+
unless %w[ssl tls starttls none].include?(normalized)
|
|
151
|
+
raise ArgumentError, "Unknown #{label} encryption '#{value}'. Valid values: ssl, tls, starttls, none."
|
|
152
|
+
end
|
|
153
|
+
normalized
|
|
154
|
+
end
|
|
155
|
+
private :normalize_encryption
|
|
156
|
+
|
|
155
157
|
# The local mailbox, present only once this messenger has captured something
|
|
156
158
|
# (or eagerly, when create_messenger knows it will).
|
|
157
|
-
|
|
159
|
+
attr_writer :dev_mailbox
|
|
158
160
|
|
|
159
161
|
# Should send capture locally instead of talking to SMTP?
|
|
160
162
|
#
|
|
@@ -221,11 +223,8 @@ module Tina4
|
|
|
221
223
|
normalize_recipients(cc) +
|
|
222
224
|
normalize_recipients(bcc)
|
|
223
225
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
smtp.start(@host, @username, @password, auth_method) do |conn|
|
|
228
|
-
conn.send_message(raw, @from_address, all_recipients)
|
|
226
|
+
smtp_session do |smtp|
|
|
227
|
+
smtp.send_message(raw, @from_address, all_recipients)
|
|
229
228
|
end
|
|
230
229
|
|
|
231
230
|
Tina4::Log.info("Email sent to #{Array(to).join(', ')}: #{subject}")
|
|
@@ -256,11 +255,7 @@ module Tina4
|
|
|
256
255
|
# Test SMTP connection
|
|
257
256
|
# Returns { success: true/false, message: "..." }
|
|
258
257
|
def test_connection
|
|
259
|
-
|
|
260
|
-
smtp.enable_starttls if @use_tls
|
|
261
|
-
smtp.start(@host, @username, @password, auth_method) do |_conn|
|
|
262
|
-
# connection succeeded
|
|
263
|
-
end
|
|
258
|
+
smtp_session { |_smtp| } # connect, TLS, AUTH, QUIT
|
|
264
259
|
{ success: true, message: "SMTP connection successful" }
|
|
265
260
|
rescue => e
|
|
266
261
|
{ success: false, message: e.message }
|
|
@@ -403,8 +398,7 @@ module Tina4
|
|
|
403
398
|
def folders
|
|
404
399
|
imap = imap_open("folders")
|
|
405
400
|
begin
|
|
406
|
-
|
|
407
|
-
(boxes || []).map(&:name)
|
|
401
|
+
imap.list("", "*")
|
|
408
402
|
rescue *IMAP_CONNECTION_ERRORS => e
|
|
409
403
|
raise imap_fail("folders", e)
|
|
410
404
|
ensure
|
|
@@ -477,6 +471,25 @@ module Tina4
|
|
|
477
471
|
|
|
478
472
|
# ── SMTP helpers ─────────────────────────────────────────────────────
|
|
479
473
|
|
|
474
|
+
# One SMTP session on Tina4::Messenger::SmtpClient:
|
|
475
|
+
# implicit TLS port 465 (as in Python, PHP and Node) or encryption "ssl"
|
|
476
|
+
# STARTTLS encryption "tls" / "starttls"; REQUIRED, so a server that
|
|
477
|
+
# does not offer it fails the send instead of getting the
|
|
478
|
+
# message and the credentials in clear
|
|
479
|
+
# plain "none"
|
|
480
|
+
# AUTH only when both credentials are set.
|
|
481
|
+
def smtp_session(&block)
|
|
482
|
+
authenticate = !auth_method.nil?
|
|
483
|
+
SmtpClient.start(
|
|
484
|
+
host: @host, port: @port,
|
|
485
|
+
tls: @port == SmtpClient::IMPLICIT_TLS_PORT || @encryption == "ssl",
|
|
486
|
+
starttls: @use_tls,
|
|
487
|
+
username: authenticate ? @username : nil,
|
|
488
|
+
password: authenticate ? @password : nil,
|
|
489
|
+
&block
|
|
490
|
+
)
|
|
491
|
+
end
|
|
492
|
+
|
|
480
493
|
def auth_method
|
|
481
494
|
return :plain if @username && @password
|
|
482
495
|
|
|
@@ -541,19 +554,19 @@ module Tina4
|
|
|
541
554
|
parts << "Content-Type: text/plain; charset=UTF-8"
|
|
542
555
|
parts << "Content-Transfer-Encoding: base64"
|
|
543
556
|
parts << ""
|
|
544
|
-
parts << Base64.encode64(text)
|
|
557
|
+
parts << Tina4::Base64.encode64(text)
|
|
545
558
|
parts << "--#{alt_boundary}"
|
|
546
559
|
parts << "Content-Type: text/html; charset=UTF-8"
|
|
547
560
|
parts << "Content-Transfer-Encoding: base64"
|
|
548
561
|
parts << ""
|
|
549
|
-
parts << Base64.encode64(body)
|
|
562
|
+
parts << Tina4::Base64.encode64(body)
|
|
550
563
|
parts << "--#{alt_boundary}--"
|
|
551
564
|
else
|
|
552
565
|
content_type = html ? "text/html" : "text/plain"
|
|
553
566
|
parts << "Content-Type: #{content_type}; charset=UTF-8"
|
|
554
567
|
parts << "Content-Transfer-Encoding: base64"
|
|
555
568
|
parts << ""
|
|
556
|
-
parts << Base64.encode64(body)
|
|
569
|
+
parts << Tina4::Base64.encode64(body)
|
|
557
570
|
end
|
|
558
571
|
|
|
559
572
|
# Attachment parts
|
|
@@ -570,19 +583,19 @@ module Tina4
|
|
|
570
583
|
parts << "Content-Type: text/plain; charset=UTF-8"
|
|
571
584
|
parts << "Content-Transfer-Encoding: base64"
|
|
572
585
|
parts << ""
|
|
573
|
-
parts << Base64.encode64(text)
|
|
586
|
+
parts << Tina4::Base64.encode64(text)
|
|
574
587
|
parts << "--#{alt_boundary}"
|
|
575
588
|
parts << "Content-Type: text/html; charset=UTF-8"
|
|
576
589
|
parts << "Content-Transfer-Encoding: base64"
|
|
577
590
|
parts << ""
|
|
578
|
-
parts << Base64.encode64(body)
|
|
591
|
+
parts << Tina4::Base64.encode64(body)
|
|
579
592
|
parts << "--#{alt_boundary}--"
|
|
580
593
|
else
|
|
581
594
|
content_type = html ? "text/html" : "text/plain"
|
|
582
595
|
parts << "Content-Type: #{content_type}; charset=UTF-8"
|
|
583
596
|
parts << "Content-Transfer-Encoding: base64"
|
|
584
597
|
parts << ""
|
|
585
|
-
parts << Base64.encode64(body)
|
|
598
|
+
parts << Tina4::Base64.encode64(body)
|
|
586
599
|
end
|
|
587
600
|
|
|
588
601
|
parts.join("\r\n")
|
|
@@ -602,7 +615,7 @@ module Tina4
|
|
|
602
615
|
return []
|
|
603
616
|
end
|
|
604
617
|
|
|
605
|
-
encoded = content.is_a?(String) && !content.ascii_only? ? Base64.encode64(content) : Base64.encode64(content.to_s)
|
|
618
|
+
encoded = content.is_a?(String) && !content.ascii_only? ? Tina4::Base64.encode64(content) : Tina4::Base64.encode64(content.to_s)
|
|
606
619
|
|
|
607
620
|
lines << "Content-Type: #{mime}; name=\"#{filename}\""
|
|
608
621
|
lines << "Content-Disposition: attachment; filename=\"#{filename}\""
|
|
@@ -616,7 +629,7 @@ module Tina4
|
|
|
616
629
|
if value.ascii_only?
|
|
617
630
|
value
|
|
618
631
|
else
|
|
619
|
-
"=?UTF-8?B?#{Base64.strict_encode64(value)}?="
|
|
632
|
+
"=?UTF-8?B?#{Tina4::Base64.strict_encode64(value)}?="
|
|
620
633
|
end
|
|
621
634
|
end
|
|
622
635
|
|
|
@@ -653,13 +666,31 @@ module Tina4
|
|
|
653
666
|
# failure this FAILS LOUD — logs and raises MessengerConnectionError —
|
|
654
667
|
# rather than swallowing the error into an empty result.
|
|
655
668
|
def imap_open(method)
|
|
656
|
-
|
|
657
|
-
imap.login(@imap_username, @imap_password)
|
|
658
|
-
imap
|
|
669
|
+
imap_login
|
|
659
670
|
rescue *IMAP_CONNECTION_ERRORS => e
|
|
660
671
|
raise imap_fail(method, e)
|
|
661
672
|
end
|
|
662
673
|
|
|
674
|
+
# Connect with the configured transport and log in. "tls"/"ssl" is implicit
|
|
675
|
+
# TLS (IMAPS), "starttls" is a plain connect upgraded with STARTTLS before
|
|
676
|
+
# the credentials are sent (it used to open implicit TLS instead, so a
|
|
677
|
+
# STARTTLS-only server on 143 could not be reached), anything else is plain.
|
|
678
|
+
# LOGIN only when both credentials are set, as in the Python master.
|
|
679
|
+
def imap_login
|
|
680
|
+
imap = ImapClient.new(
|
|
681
|
+
@imap_host, port: @imap_port,
|
|
682
|
+
tls: %w[tls ssl].include?(@imap_encryption),
|
|
683
|
+
starttls: @imap_encryption == "starttls"
|
|
684
|
+
)
|
|
685
|
+
begin
|
|
686
|
+
imap.login(@imap_username, @imap_password) if @imap_username && @imap_password
|
|
687
|
+
rescue StandardError
|
|
688
|
+
imap_cleanup(imap)
|
|
689
|
+
raise
|
|
690
|
+
end
|
|
691
|
+
imap
|
|
692
|
+
end
|
|
693
|
+
|
|
663
694
|
# Best-effort teardown of an IMAP connection. Never raises.
|
|
664
695
|
def imap_cleanup(imap)
|
|
665
696
|
return if imap.nil?
|
|
@@ -694,12 +725,12 @@ module Tina4
|
|
|
694
725
|
# test_imap_connection). Read methods use imap_open + ensure directly so
|
|
695
726
|
# they can fail loud.
|
|
696
727
|
def imap_connect(&block)
|
|
697
|
-
imap =
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
728
|
+
imap = imap_login
|
|
729
|
+
begin
|
|
730
|
+
block.call(imap)
|
|
731
|
+
ensure
|
|
732
|
+
imap_cleanup(imap)
|
|
733
|
+
end
|
|
703
734
|
end
|
|
704
735
|
|
|
705
736
|
# An inbox() item — EXACTLY these seven keys (decisions doc G4):
|
|
@@ -828,7 +859,7 @@ module Tina4
|
|
|
828
859
|
line = raw_line.chomp
|
|
829
860
|
if current_key && line =~ /\A[ \t]/
|
|
830
861
|
headers[current_key] = "#{headers[current_key]} #{line.strip}"
|
|
831
|
-
elsif (m = line.match(/\A([\w!\#$%&'
|
|
862
|
+
elsif (m = line.match(/\A([\w!\#$%&'*+\-.^`|~]+):[ \t]?(.*)\z/))
|
|
832
863
|
current_key = m[1]
|
|
833
864
|
headers[current_key] = m[2]
|
|
834
865
|
end
|
|
@@ -846,7 +877,7 @@ module Tina4
|
|
|
846
877
|
|
|
847
878
|
decoded = case encoding
|
|
848
879
|
when "B"
|
|
849
|
-
Base64.decode64(encoded)
|
|
880
|
+
Tina4::Base64.decode64(encoded)
|
|
850
881
|
when "Q"
|
|
851
882
|
encoded.gsub("_", " ").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
|
|
852
883
|
else
|
|
@@ -892,7 +923,7 @@ module Tina4
|
|
|
892
923
|
headers = header_body[0]
|
|
893
924
|
|
|
894
925
|
if headers =~ /Content-Transfer-Encoding:\s*base64/i
|
|
895
|
-
Base64.decode64(body).force_encoding("UTF-8")
|
|
926
|
+
Tina4::Base64.decode64(body).force_encoding("UTF-8")
|
|
896
927
|
elsif headers =~ /Content-Transfer-Encoding:\s*quoted-printable/i
|
|
897
928
|
body.gsub(/=\r?\n/, "").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
|
|
898
929
|
else
|
|
@@ -921,7 +952,7 @@ module Tina4
|
|
|
921
952
|
body = header_body[1].sub(/\r?\n\z/, "")
|
|
922
953
|
|
|
923
954
|
if headers =~ /Content-Transfer-Encoding:\s*base64/i
|
|
924
|
-
Base64.decode64(body)
|
|
955
|
+
Tina4::Base64.decode64(body)
|
|
925
956
|
elsif headers =~ /Content-Transfer-Encoding:\s*quoted-printable/i
|
|
926
957
|
body.gsub(/=\r?\n/, "").gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }.b
|
|
927
958
|
else
|
data/lib/tina4/metrics.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
|
require 'open3'
|
data/lib/tina4/middleware.rb
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
|
|
9
|
+
require "cgi/escape"
|
|
4
10
|
|
|
5
11
|
module Tina4
|
|
6
12
|
class Middleware
|
|
@@ -789,9 +795,24 @@ module Tina4
|
|
|
789
795
|
true
|
|
790
796
|
end
|
|
791
797
|
|
|
798
|
+
# The canonical security header set (SECHDR-DEC-01) without HSTS, as
|
|
799
|
+
# [name, value] pairs, with the env overrides applied. The built-in
|
|
800
|
+
# server puts exactly this on its own transport rejections (ADR-0068),
|
|
801
|
+
# which are written before the request's scheme is known.
|
|
802
|
+
def canonical_headers
|
|
803
|
+
[
|
|
804
|
+
["X-Frame-Options", ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"],
|
|
805
|
+
["X-Content-Type-Options", "nosniff"],
|
|
806
|
+
["Content-Security-Policy", ENV["TINA4_CSP"] || "default-src 'self'"],
|
|
807
|
+
["Referrer-Policy", ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"],
|
|
808
|
+
["X-XSS-Protection", "0"],
|
|
809
|
+
["Permissions-Policy", ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"]
|
|
810
|
+
]
|
|
811
|
+
end
|
|
812
|
+
|
|
792
813
|
def before_security(request, response)
|
|
793
|
-
|
|
794
|
-
response.headers[
|
|
814
|
+
warn_csp_default_once if ENV["TINA4_CSP"].nil?
|
|
815
|
+
canonical_headers.each { |name, value| response.headers[name] = value }
|
|
795
816
|
|
|
796
817
|
# HSTS is HTTPS-only (SECHDR-DEC-02): a downgrade-protection header on a
|
|
797
818
|
# plain-HTTP response is inert at best and ships a bad max-age on an
|
|
@@ -806,12 +827,6 @@ module Tina4
|
|
|
806
827
|
response.headers["Strict-Transport-Security"] = "max-age=#{hsts}; includeSubDomains"
|
|
807
828
|
end
|
|
808
829
|
|
|
809
|
-
warn_csp_default_once if ENV["TINA4_CSP"].nil?
|
|
810
|
-
response.headers["Content-Security-Policy"] = ENV["TINA4_CSP"] || "default-src 'self'"
|
|
811
|
-
response.headers["Referrer-Policy"] = ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"
|
|
812
|
-
response.headers["X-XSS-Protection"] = "0"
|
|
813
|
-
response.headers["Permissions-Policy"] = ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"
|
|
814
|
-
|
|
815
830
|
[request, response]
|
|
816
831
|
end
|
|
817
832
|
|
data/lib/tina4/migration.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 "fileutils"
|
|
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
|
# A list of ORM models that ALSO carries the total rows matching the query.
|
data/lib/tina4/mqtt.rb
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
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 "socket"
|
|
4
10
|
require "securerandom"
|
|
5
11
|
require_relative "mqtt_message"
|
|
12
|
+
require_relative "database_url"
|
|
6
13
|
|
|
7
14
|
module Tina4
|
|
8
15
|
# Any MQTT protocol / connection failure.
|
|
@@ -166,7 +173,7 @@ module Tina4
|
|
|
166
173
|
scheme = raw[%r{\A([A-Za-z][A-Za-z0-9+.-]*)://}, 1]&.downcase
|
|
167
174
|
unless scheme.nil? || %w[mqtt tcp mqtts].include?(scheme)
|
|
168
175
|
raise ArgumentError,
|
|
169
|
-
"unsupported MQTT url scheme #{scheme.inspect} in #{raw.inspect} — " \
|
|
176
|
+
"unsupported MQTT url scheme #{scheme.inspect} in #{Tina4::DatabaseUrl.redact(raw).inspect} — " \
|
|
170
177
|
"this client speaks mqtt://, tcp:// or mqtts:// (TLS). " \
|
|
171
178
|
"WebSocket transports are not implemented."
|
|
172
179
|
end
|
|
@@ -185,7 +192,7 @@ module Tina4
|
|
|
185
192
|
end
|
|
186
193
|
|
|
187
194
|
match = rest.match(%r{\A(?<host>\[[^\]]+\]|[^:/]+)(?::(?<port>\d+))?(?:/.*)?\z})
|
|
188
|
-
raise ArgumentError, "malformed MQTT url #{raw.inspect} — expected mqtt://host:port" unless match
|
|
195
|
+
raise ArgumentError, "malformed MQTT url #{Tina4::DatabaseUrl.redact(raw).inspect} — expected mqtt://host:port" unless match
|
|
189
196
|
|
|
190
197
|
{
|
|
191
198
|
host: match[:host].delete_prefix("[").delete_suffix("]"),
|
data/lib/tina4/mqtt_message.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
|
# One MQTT 3.1.1 application message as delivered by the broker.
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# Copyright (c) 2026 Code Infinity
|
|
3
|
+
# SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
|
|
2
8
|
require "json"
|
|
9
|
+
require_relative "parse_json"
|
|
3
10
|
|
|
4
11
|
module Tina4
|
|
5
12
|
# Convert a snake_case name to camelCase.
|
|
@@ -139,7 +146,8 @@ module Tina4
|
|
|
139
146
|
@soft_delete_field = val
|
|
140
147
|
end
|
|
141
148
|
|
|
142
|
-
# Field mapping: { '
|
|
149
|
+
# Field mapping: { 'ruby_attribute' => 'db_column' } (get_db_column,
|
|
150
|
+
# to_db_hash and from_hash all read it in this direction).
|
|
143
151
|
def field_mapping
|
|
144
152
|
@field_mapping || {}
|
|
145
153
|
end
|
|
@@ -310,13 +318,13 @@ module Tina4
|
|
|
310
318
|
# REL-SOFTDELETE-TRAVERSAL: a soft-deleted child must not surface
|
|
311
319
|
# through eager traversal (parity with lazy + the finders).
|
|
312
320
|
soft = klass.soft_delete ? " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)" : ""
|
|
313
|
-
order_col = klass.primary_key_field || :id
|
|
321
|
+
order_col = klass.get_db_column(klass.primary_key_field || :id)
|
|
314
322
|
# REL-EAGER-UNBOUNDED: chunk the parent PKs so the IN list stays
|
|
315
323
|
# bounded, and page each chunk so no relation is truncated.
|
|
316
324
|
related_records = []
|
|
317
325
|
pk_values.each_slice(EAGER_IN_CHUNK) do |chunk|
|
|
318
326
|
placeholders = chunk.map { "?" }.join(",")
|
|
319
|
-
sql = "SELECT * FROM #{klass.table_name} WHERE #{fk} IN (#{placeholders})#{soft} ORDER BY #{order_col}"
|
|
327
|
+
sql = "SELECT * FROM #{klass.table_name} WHERE #{klass.get_db_column(fk)} IN (#{placeholders})#{soft} ORDER BY #{order_col}"
|
|
320
328
|
offset = 0
|
|
321
329
|
loop do
|
|
322
330
|
batch = klass.db.fetch(sql, chunk, limit: EAGER_PAGE_SIZE, offset: offset).to_a
|
|
@@ -331,8 +339,9 @@ module Tina4
|
|
|
331
339
|
|
|
332
340
|
# Group by FK
|
|
333
341
|
grouped = {}
|
|
342
|
+
fk_attribute = klass.attribute_for(fk)
|
|
334
343
|
related_records.each do |record|
|
|
335
|
-
fk_val = record.__send__(
|
|
344
|
+
fk_val = record.__send__(fk_attribute) if record.respond_to?(fk_attribute)
|
|
336
345
|
(grouped[fk_val] ||= []) << record
|
|
337
346
|
end
|
|
338
347
|
|
|
@@ -347,7 +356,7 @@ module Tina4
|
|
|
347
356
|
end
|
|
348
357
|
|
|
349
358
|
when :belongs_to
|
|
350
|
-
fk = rel[:foreign_key] || "#{rel_name}_id"
|
|
359
|
+
fk = attribute_for(rel[:foreign_key] || "#{rel_name}_id")
|
|
351
360
|
fk_values = instances.map { |inst|
|
|
352
361
|
inst.respond_to?(fk.to_sym) ? inst.__send__(fk.to_sym) : nil
|
|
353
362
|
}.compact.uniq
|
|
@@ -360,7 +369,7 @@ module Tina4
|
|
|
360
369
|
related_records = []
|
|
361
370
|
fk_values.each_slice(EAGER_IN_CHUNK) do |chunk|
|
|
362
371
|
placeholders = chunk.map { "?" }.join(",")
|
|
363
|
-
sql = "SELECT * FROM #{klass.table_name} WHERE #{related_pk} IN (#{placeholders})#{soft}"
|
|
372
|
+
sql = "SELECT * FROM #{klass.table_name} WHERE #{klass.get_db_column(related_pk)} IN (#{placeholders})#{soft}"
|
|
364
373
|
# One row per distinct PK, so limit == chunk size (default 100 would truncate a full chunk).
|
|
365
374
|
related_records.concat(klass.db.fetch(sql, chunk, limit: chunk.length).to_a.map { |row| klass.from_hash(row) })
|
|
366
375
|
end
|
|
@@ -771,7 +780,7 @@ module Tina4
|
|
|
771
780
|
fdef = field_definitions[attr_name.to_sym]
|
|
772
781
|
if fdef && fdef[:type] == :json && value.is_a?(String)
|
|
773
782
|
begin
|
|
774
|
-
value =
|
|
783
|
+
value = Tina4.parse_json(value)
|
|
775
784
|
rescue JSON::ParserError
|
|
776
785
|
# leave the raw string in place
|
|
777
786
|
end
|
|
@@ -791,7 +800,7 @@ module Tina4
|
|
|
791
800
|
# spec/orm_spec.rb:78 verifies public access. find_by_filter stays
|
|
792
801
|
# public for the same reason; both are part of the documented API.
|
|
793
802
|
def find_by_id(id)
|
|
794
|
-
pk = primary_key_field || :id
|
|
803
|
+
pk = get_db_column(primary_key_field || :id)
|
|
795
804
|
sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
|
|
796
805
|
if soft_delete
|
|
797
806
|
sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
|
|
@@ -829,6 +838,46 @@ module Tina4
|
|
|
829
838
|
col.to_sym
|
|
830
839
|
end
|
|
831
840
|
|
|
841
|
+
# tina4: ADR-0069 - the ONE resolver for caller-supplied field names that
|
|
842
|
+
# become SQL identifiers (AutoCrud filter/sort/write body, find(hash)). A
|
|
843
|
+
# key resolves only when it is a DECLARED field's attribute name or that
|
|
844
|
+
# field's mapped DB column, and the declared attribute is returned.
|
|
845
|
+
# Anything else returns nil, so the caller rejects or drops it before any
|
|
846
|
+
# SQL is built.
|
|
847
|
+
def resolve_field(key) # -> Symbol or nil
|
|
848
|
+
wanted = key.to_s
|
|
849
|
+
field_definitions.each_key do |attribute|
|
|
850
|
+
# the model's own attribute -> column mapping
|
|
851
|
+
return attribute if wanted == attribute.to_s || wanted == get_db_column(attribute).to_s
|
|
852
|
+
end
|
|
853
|
+
nil
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# A primary-key value that arrived as text (a URL {id}, a GraphQL ID),
|
|
857
|
+
# ready to bind. An integer key accepts only an integer string and answers
|
|
858
|
+
# nil otherwise, so "3x" can never address row 3; any other key type (a
|
|
859
|
+
# string natural key) is returned unchanged rather than coerced.
|
|
860
|
+
def coerce_primary_key(raw) # -> value or nil
|
|
861
|
+
pk = primary_key_field || :id
|
|
862
|
+
return raw unless field_definitions.dig(pk.to_sym, :type) == :integer
|
|
863
|
+
return raw if raw.is_a?(Integer)
|
|
864
|
+
|
|
865
|
+
raw.to_s.match?(/\A-?\d+\z/) ? raw.to_s.to_i : nil
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# The DB column of the declared field +key+ resolves to (see resolve_field).
|
|
869
|
+
def resolve_field_column(key) # -> String or nil
|
|
870
|
+
attribute = resolve_field(key)
|
|
871
|
+
attribute && get_db_column(attribute).to_s
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
# The attribute that holds +name+, whether +name+ is the attribute itself
|
|
875
|
+
# or the DB column it maps to (the reverse of get_db_column). Relationship
|
|
876
|
+
# foreign keys accept either spelling.
|
|
877
|
+
def attribute_for(name) # -> Symbol
|
|
878
|
+
resolve_field(name) || name.to_sym
|
|
879
|
+
end
|
|
880
|
+
|
|
832
881
|
private
|
|
833
882
|
|
|
834
883
|
def auto_discover_db
|
|
@@ -838,7 +887,13 @@ module Tina4
|
|
|
838
887
|
end
|
|
839
888
|
|
|
840
889
|
def find_by_filter(filter, limit: 100, offset: nil, order_by: nil)
|
|
841
|
-
|
|
890
|
+
# ADR-0069: every key must resolve to a declared field's column.
|
|
891
|
+
where_parts = filter.keys.map do |key|
|
|
892
|
+
column = resolve_field_column(key)
|
|
893
|
+
raise ArgumentError, "Unknown filter field '#{key}' for model #{name.to_s.split('::').last}" unless column
|
|
894
|
+
|
|
895
|
+
"#{column} = ?"
|
|
896
|
+
end
|
|
842
897
|
sql = "SELECT * FROM #{table_name} WHERE #{where_parts.join(' AND ')}"
|
|
843
898
|
if soft_delete
|
|
844
899
|
sql += " AND (#{soft_delete_field} IS NULL OR #{soft_delete_field} = 0)"
|
|
@@ -891,7 +946,7 @@ module Tina4
|
|
|
891
946
|
@assigned_fields = []
|
|
892
947
|
# Accept a JSON object string (parity with Python/PHP/Node):
|
|
893
948
|
# Widget.new('{"id":1,"name":"alpha"}')
|
|
894
|
-
attributes =
|
|
949
|
+
attributes = Tina4.parse_json(attributes) if attributes.is_a?(String)
|
|
895
950
|
# A single model is one record — reject an Array with a clear message.
|
|
896
951
|
if attributes.is_a?(Array)
|
|
897
952
|
raise ArgumentError,
|
|
@@ -973,9 +1028,10 @@ module Tina4
|
|
|
973
1028
|
# Addressing a row by one column of a composite key matches every row
|
|
974
1029
|
# sharing that value. Feature 4 removed that from the raw write path; this
|
|
975
1030
|
# is the same rule for the ORM above it.
|
|
1031
|
+
# { db_column => value } for every key column: it addresses the row in SQL.
|
|
976
1032
|
def pk_filter
|
|
977
1033
|
self.class.primary_key_fields.each_with_object({}) do |name, acc|
|
|
978
|
-
acc[name] = __send__(name) if respond_to?(name)
|
|
1034
|
+
acc[self.class.get_db_column(name)] = __send__(name) if respond_to?(name)
|
|
979
1035
|
end
|
|
980
1036
|
end
|
|
981
1037
|
|
|
@@ -1500,7 +1556,7 @@ module Tina4
|
|
|
1500
1556
|
pk_value = __send__(pk)
|
|
1501
1557
|
return nil unless pk_value
|
|
1502
1558
|
|
|
1503
|
-
where = "#{fk} = ?"
|
|
1559
|
+
where = "#{klass.get_db_column(fk)} = ?"
|
|
1504
1560
|
# REL-SOFTDELETE-TRAVERSAL: exclude a soft-deleted related row.
|
|
1505
1561
|
if klass.soft_delete
|
|
1506
1562
|
where += " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)"
|
|
@@ -1520,13 +1576,13 @@ module Tina4
|
|
|
1520
1576
|
pk_value = __send__(pk)
|
|
1521
1577
|
return [] unless pk_value
|
|
1522
1578
|
|
|
1523
|
-
where = "#{fk} = ?"
|
|
1579
|
+
where = "#{klass.get_db_column(fk)} = ?"
|
|
1524
1580
|
# REL-SOFTDELETE-TRAVERSAL: a soft-deleted child must not surface through
|
|
1525
1581
|
# parent.children, consistent with the finders' default exclusion.
|
|
1526
1582
|
if klass.soft_delete
|
|
1527
1583
|
where += " AND (#{klass.soft_delete_field} IS NULL OR #{klass.soft_delete_field} = 0)"
|
|
1528
1584
|
end
|
|
1529
|
-
order_col = klass.primary_key_field || :id
|
|
1585
|
+
order_col = klass.get_db_column(klass.primary_key_field || :id)
|
|
1530
1586
|
sql = "SELECT * FROM #{klass.table_name} WHERE #{where} ORDER BY #{order_col}"
|
|
1531
1587
|
# REL-EAGER-UNBOUNDED: page through ALL children rather than silently capping
|
|
1532
1588
|
# at the default fetch limit (was 100), so the tail is never lost.
|
|
@@ -1547,8 +1603,8 @@ module Tina4
|
|
|
1547
1603
|
return nil unless rel
|
|
1548
1604
|
|
|
1549
1605
|
klass = Object.const_get(rel[:class_name])
|
|
1550
|
-
fk = rel[:foreign_key] || "#{name}_id"
|
|
1551
|
-
fk_value = __send__(fk
|
|
1606
|
+
fk = self.class.attribute_for(rel[:foreign_key] || "#{name}_id")
|
|
1607
|
+
fk_value = __send__(fk) if respond_to?(fk)
|
|
1552
1608
|
return nil unless fk_value
|
|
1553
1609
|
|
|
1554
1610
|
@relationship_cache[name] = klass.find(fk_value)
|
|
@@ -0,0 +1,25 @@
|
|
|
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 "json"
|
|
10
|
+
|
|
11
|
+
module Tina4
|
|
12
|
+
# JSON.parse for JSON that arrives from outside the process (request bodies,
|
|
13
|
+
# tokens, API responses, queue payloads), with the duplicate-key rule pinned:
|
|
14
|
+
# the LAST occurrence of a repeated key wins.
|
|
15
|
+
#
|
|
16
|
+
# That is what Python's json.loads, PHP's json_decode and JavaScript's
|
|
17
|
+
# JSON.parse do, and what the json gem did until 3.0, which raises on a
|
|
18
|
+
# duplicate key by default. tina4ruby does not pin the json gem (it is a
|
|
19
|
+
# default gem), so without this the same request would parse in an app that
|
|
20
|
+
# resolved json 2.x and fail in one that resolved 3.x. The option is ignored
|
|
21
|
+
# by json versions that predate it, which already take the last key.
|
|
22
|
+
def self.parse_json(text, **options)
|
|
23
|
+
JSON.parse(text, allow_duplicate_key: true, **options)
|
|
24
|
+
end
|
|
25
|
+
end
|