tina4ruby 3.13.137 → 3.13.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +57 -7
  12. data/lib/tina4/auth.rb +18 -12
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +6 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +6 -0
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +6 -0
  26. data/lib/tina4/cli.rb +22 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +215 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +8 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +6 -0
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +196 -56
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +28 -27
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +6 -0
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +15 -8
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +24 -9
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +9 -3
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +10 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +6 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +6 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +10 -2
  112. data/lib/tina4/rack_app.rb +11 -1
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +6 -0
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +85 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +6 -0
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +25 -8
  146. data/lib/tina4/swagger.rb +6 -0
  147. data/lib/tina4/template.rb +6 -0
  148. data/lib/tina4/test.rb +8 -2
  149. data/lib/tina4/test_client.rb +8 -1
  150. data/lib/tina4/testing.rb +8 -1
  151. data/lib/tina4/validator.rb +6 -0
  152. data/lib/tina4/version.rb +7 -1
  153. data/lib/tina4/webserver.rb +25 -143
  154. data/lib/tina4/websocket.rb +10 -4
  155. data/lib/tina4/websocket_backplane.rb +19 -2
  156. data/lib/tina4/wsdl.rb +31 -16
  157. data/lib/tina4/xml_parser.rb +259 -0
  158. data/lib/tina4.rb +87 -31
  159. data/lib/tina4ruby.rb +6 -0
  160. metadata +43 -140
@@ -1,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
- begin
4
- require "net/smtp"
5
- rescue LoadError
6
- # net-smtp gem needed on Ruby >= 4.0: gem install net-smtp
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 mid-conversation
40
+ IOError, # closed/broken stream, EOF, MailSocket::TimeoutError
46
41
  SystemCallError, # Errno::ECONNREFUSED / ECONNRESET / ETIMEDOUT etc.
47
- Timeout::Error, # connect/read timeout (Net::OpenTimeout descends from this)
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.downcase
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 = (env_imap_enc && !env_imap_enc.to_s.empty?) ? env_imap_enc.to_s.downcase : "tls"
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
- # Send email using Ruby's Net::SMTP
154
- # Returns { success: true/false, message: "...", id: "..." }
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
- attr_accessor :dev_mailbox
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
- smtp = Net::SMTP.new(@host, @port)
225
- smtp.enable_starttls if @use_tls
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
- smtp = Net::SMTP.new(@host, @port)
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
- boxes = imap.list("", "*")
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
- imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
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 = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
698
- imap.login(@imap_username, @imap_password)
699
- result = block.call(imap)
700
- imap.logout
701
- imap.disconnect
702
- result
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!\#$%&'*+\-.^_`|~]+):[ \t]?(.*)\z/))
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'
@@ -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
- require "cgi"
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
- response.headers["X-Frame-Options"] = ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"
794
- response.headers["X-Content-Type-Options"] = "nosniff"
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
 
@@ -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("]"),
@@ -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: { 'db_column' => 'ruby_attribute' }
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__(fk.to_sym) if record.respond_to?(fk.to_sym)
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 = JSON.parse(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
- where_parts = filter.keys.map { |k| "#{k} = ?" }
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 = JSON.parse(attributes) if attributes.is_a?(String)
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.to_sym) if respond_to?(fk.to_sym)
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