tina4ruby 3.13.137 → 3.13.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +74 -8
  12. data/lib/tina4/auth.rb +108 -26
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +14 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +9 -1
  26. data/lib/tina4/cli.rb +27 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +222 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +16 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +14 -6
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +200 -58
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +134 -35
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +21 -2
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +18 -9
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +27 -10
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +22 -4
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +18 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
  112. data/lib/tina4/rack_app.rb +62 -50
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +15 -1
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +90 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +42 -12
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +52 -12
  146. data/lib/tina4/ssrf.rb +148 -0
  147. data/lib/tina4/swagger.rb +6 -0
  148. data/lib/tina4/template.rb +6 -0
  149. data/lib/tina4/test.rb +8 -2
  150. data/lib/tina4/test_client.rb +8 -1
  151. data/lib/tina4/testing.rb +8 -1
  152. data/lib/tina4/validator.rb +6 -0
  153. data/lib/tina4/version.rb +7 -1
  154. data/lib/tina4/webserver.rb +25 -143
  155. data/lib/tina4/websocket.rb +170 -20
  156. data/lib/tina4/websocket_backplane.rb +25 -2
  157. data/lib/tina4/wsdl.rb +54 -18
  158. data/lib/tina4/xml_parser.rb +268 -0
  159. data/lib/tina4.rb +98 -33
  160. data/lib/tina4ruby.rb +6 -0
  161. metadata +44 -140
@@ -0,0 +1,268 @@
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 "strscan"
10
+
11
+ module Tina4
12
+ class WSDL
13
+ # A small, strict XML parser for SOAP request bodies (replaces REXML).
14
+ #
15
+ # Ported from the stack-based parser in tina4-nodejs (packages/core/src/wsdl.ts)
16
+ # and made strict, because the Ruby WSDL server has always answered
17
+ # "Malformed XML" for a broken body (Python's ElementTree does the same):
18
+ # tags must nest, there is exactly one root, and only the five predefined
19
+ # entities plus character references are understood.
20
+ #
21
+ # Deliberately NOT supported, so the attack surface does not exist:
22
+ # * DTDs. `<!DOCTYPE` and every other `<!` declaration is a ParseError, so
23
+ # there is no entity expansion (billion laughs) and no external entity
24
+ # (XXE). The WSDL server also rejects DOCTYPE before parsing.
25
+ # * Any encoding but UTF-8. Any byte order mark (UTF-8 included), a NUL
26
+ # byte, invalid UTF-8, or an XML declaration naming another encoding is a
27
+ # ParseError. A UTF-16 body used to slip past the `<!DOCTYPE` regex guard,
28
+ # because the guard read "<\0!\0D\0..." while REXML decoded the BOM and
29
+ # parsed the DTD.
30
+ #
31
+ # Element#text follows ElementTree (the Python master): the character data
32
+ # before the first child element, entity- and CDATA-decoded; nil when there
33
+ # is none. Element#name is the local name (prefix stripped), as REXML gave.
34
+ module XmlParser
35
+ class ParseError < StandardError; end
36
+
37
+ class Element
38
+ attr_reader :name, :prefix, :attributes, :elements
39
+
40
+ def initialize(qualified_name, attributes)
41
+ @prefix, _, local = qualified_name.rpartition(":")
42
+ @prefix = nil if @prefix.empty?
43
+ @name = local
44
+ @attributes = attributes
45
+ @elements = []
46
+ @text = nil
47
+ @seen_child = false
48
+ end
49
+
50
+ # Character data before the first child element (ElementTree .text).
51
+ def text
52
+ @text
53
+ end
54
+
55
+ def each_element(&block)
56
+ @elements.each(&block)
57
+ end
58
+
59
+ def append_text(data) # :nodoc:
60
+ return if @seen_child || data.empty?
61
+
62
+ @text = @text ? @text + data : data.dup
63
+ end
64
+
65
+ def append_child(element) # :nodoc:
66
+ @seen_child = true
67
+ @elements << element
68
+ end
69
+ end
70
+
71
+ Document = Struct.new(:root)
72
+
73
+ PREDEFINED_ENTITIES = { "lt" => "<", "gt" => ">", "amp" => "&", "quot" => '"', "apos" => "'" }.freeze
74
+ NAME = /[A-Za-z_:\u00C0-\u{EFFFF}][A-Za-z0-9_:.\-\u00B7\u00C0-\u{EFFFF}]*/
75
+ # Every byte order mark, the UTF-8 one included (the shared SOAP body rule:
76
+ # a body is plain UTF-8, never a BOM, in all four frameworks).
77
+ BYTE_ORDER_MARKS = ["\xEF\xBB\xBF".b, "\xFE\xFF".b, "\xFF\xFE".b, "\x00\x00\xFE\xFF".b].freeze
78
+ DECLARED_ENCODING = /\A<\?xml\b[^>]*?\bencoding\s*=\s*["']([^"']*)["']/
79
+
80
+ module_function
81
+
82
+ # Parse +xml+ (a String of any encoding tag; its BYTES must be UTF-8).
83
+ # Returns a Document whose #root is the single root Element.
84
+ def parse(xml)
85
+ Reader.new(utf8_text(xml)).document
86
+ end
87
+
88
+ # The body as UTF-8 text, or a ParseError. Callers that must refuse BEFORE
89
+ # any parse (the WSDL server) call this first; #parse calls it too.
90
+ def utf8_text(xml)
91
+ bytes = xml.to_s.b
92
+ raise ParseError, "only UTF-8 XML without a byte order mark is accepted" if BYTE_ORDER_MARKS.any? { |bom| bytes.start_with?(bom) }
93
+ raise ParseError, "only UTF-8 XML is accepted (NUL byte, UTF-16/UTF-32 body)" if bytes.include?("\x00")
94
+
95
+ text = bytes.force_encoding(Encoding::UTF_8)
96
+ raise ParseError, "only UTF-8 XML is accepted (invalid UTF-8 bytes)" unless text.valid_encoding?
97
+
98
+ declared = text[DECLARED_ENCODING, 1]
99
+ if declared && !declared.casecmp?("UTF-8")
100
+ raise ParseError, "only UTF-8 XML is accepted (declared encoding #{declared.inspect})"
101
+ end
102
+
103
+ text
104
+ end
105
+
106
+ def decode_references(raw)
107
+ return raw unless raw.include?("&")
108
+
109
+ raw.gsub(/&([^;&\s]*);?/) do |match|
110
+ raise ParseError, "unterminated reference #{match[0, 20].inspect}" unless match.end_with?(";")
111
+
112
+ name = Regexp.last_match(1)
113
+ if (replacement = PREDEFINED_ENTITIES[name])
114
+ replacement
115
+ elsif name =~ /\A#x([0-9A-Fa-f]{1,6})\z/ || name =~ /\A#([0-9]{1,7})\z/
116
+ codepoint = name.start_with?("#x") ? Regexp.last_match(1).to_i(16) : Regexp.last_match(1).to_i
117
+ raise ParseError, "invalid character reference &#{name};" unless xml_char?(codepoint)
118
+
119
+ [codepoint].pack("U")
120
+ else
121
+ raise ParseError, "undefined entity &#{name}; (DTDs are not supported)"
122
+ end
123
+ end
124
+ end
125
+
126
+ def xml_char?(codepoint)
127
+ [0x9, 0xA, 0xD].include?(codepoint) ||
128
+ (0x20..0xD7FF).cover?(codepoint) ||
129
+ (0xE000..0xFFFD).cover?(codepoint) ||
130
+ (0x10000..0x10FFFF).cover?(codepoint)
131
+ end
132
+
133
+ # Iterative (no recursion, so nesting depth cannot exhaust the stack).
134
+ class Reader
135
+ def initialize(text)
136
+ @scanner = StringScanner.new(text)
137
+ @stack = [] # open Elements, innermost last
138
+ @open_names = [] # their qualified names, for end-tag matching
139
+ @root = nil
140
+ end
141
+
142
+ def document
143
+ skip_xml_declaration
144
+ until @scanner.eos?
145
+ if @stack.empty?
146
+ read_outside_root
147
+ else
148
+ read_content
149
+ end
150
+ end
151
+ raise ParseError, "unclosed element <#{@stack.last.name}>" unless @stack.empty?
152
+ raise ParseError, "no root element" unless @root
153
+
154
+ Document.new(@root)
155
+ end
156
+
157
+ private
158
+
159
+ def skip_xml_declaration
160
+ return unless @scanner.check(/<\?xml[\s?]/)
161
+
162
+ @scanner.scan_until(/\?>/) or raise ParseError, "unterminated XML declaration"
163
+ end
164
+
165
+ # Between the prolog and the end: whitespace, comments, PIs, the root.
166
+ def read_outside_root
167
+ return if @scanner.skip(/\s+/)
168
+ return skip_comment if @scanner.check(/<!--/)
169
+ return skip_processing_instruction if @scanner.check(/<\?/)
170
+ raise ParseError, "DOCTYPE and other declarations are not supported" if @scanner.check(/<!/)
171
+ raise ParseError, "content after the root element" if @root
172
+ raise ParseError, "text outside the root element" unless @scanner.check(/</)
173
+
174
+ open_element
175
+ end
176
+
177
+ def read_content
178
+ if (data = @scanner.scan(/[^<]+/))
179
+ raise ParseError, "']]>' is not allowed in character data" if data.include?("]]>")
180
+
181
+ @stack.last.append_text(XmlParser.decode_references(data))
182
+ elsif @scanner.check(/<\//)
183
+ close_element
184
+ elsif @scanner.check(/<!--/)
185
+ skip_comment
186
+ elsif @scanner.skip(/<!\[CDATA\[/)
187
+ cdata = @scanner.scan_until(/\]\]>/) or raise ParseError, "unterminated CDATA section"
188
+ @stack.last.append_text(cdata[0...-3])
189
+ elsif @scanner.check(/<!/)
190
+ raise ParseError, "DOCTYPE and other declarations are not supported"
191
+ elsif @scanner.check(/<\?/)
192
+ skip_processing_instruction
193
+ else
194
+ open_element
195
+ end
196
+ end
197
+
198
+ def open_element
199
+ @scanner.skip(/</)
200
+ name = @scanner.scan(NAME) or raise ParseError, "invalid tag name at byte #{@scanner.pos}"
201
+ attributes = read_attributes
202
+ self_closing = !@scanner.skip(%r{/>}).nil?
203
+ raise ParseError, "unterminated tag <#{name}>" unless self_closing || @scanner.skip(/>/)
204
+
205
+ element = Element.new(name, attributes)
206
+ if @stack.empty?
207
+ @root = element
208
+ else
209
+ @stack.last.append_child(element)
210
+ end
211
+ return if self_closing
212
+
213
+ @stack.push(element)
214
+ @open_names.push(name)
215
+ end
216
+
217
+ def read_attributes
218
+ attributes = {}
219
+ loop do
220
+ had_space = @scanner.skip(/\s+/)
221
+ break if @scanner.check(%r{/?>})
222
+
223
+ raise ParseError, "attributes must be separated by whitespace" unless had_space
224
+
225
+ key = @scanner.scan(NAME) or raise ParseError, "invalid attribute at byte #{@scanner.pos}"
226
+ @scanner.skip(/\s*=\s*/) or raise ParseError, "attribute #{key} has no value"
227
+ quote = @scanner.getch
228
+ raise ParseError, "attribute #{key} value must be quoted" unless ['"', "'"].include?(quote)
229
+
230
+ value = @scanner.scan_until(/#{quote}/) or raise ParseError, "unterminated attribute #{key}"
231
+ value = value[0...-1]
232
+ raise ParseError, "'<' is not allowed in attribute #{key}" if value.include?("<")
233
+ raise ParseError, "duplicate attribute #{key}" if attributes.key?(key)
234
+
235
+ attributes[key] = XmlParser.decode_references(value)
236
+ end
237
+ attributes
238
+ end
239
+
240
+ def close_element
241
+ @scanner.skip(%r{</})
242
+ name = @scanner.scan(NAME) or raise ParseError, "invalid closing tag at byte #{@scanner.pos}"
243
+ @scanner.skip(/\s*/)
244
+ @scanner.skip(/>/) or raise ParseError, "unterminated closing tag </#{name}>"
245
+ expected = @open_names.last
246
+ raise ParseError, "unexpected closing tag </#{name}>" unless expected
247
+ raise ParseError, "mismatched tag: expected </#{expected}>, found </#{name}>" unless expected == name
248
+
249
+ @open_names.pop
250
+ @stack.pop
251
+ end
252
+
253
+ def skip_comment
254
+ @scanner.skip(/<!--/)
255
+ @scanner.skip_until(/-->/) or raise ParseError, "unterminated comment"
256
+ end
257
+
258
+ def skip_processing_instruction
259
+ @scanner.skip(/<\?/)
260
+ target = @scanner.check(NAME)
261
+ raise ParseError, "an XML declaration is only allowed at the very start" if target&.casecmp?("xml")
262
+
263
+ @scanner.skip_until(/\?>/) or raise ParseError, "unterminated processing instruction"
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
data/lib/tina4.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
  # ── Core (always loaded) ──────────────────────────────────────────────
4
10
  require_relative "tina4/version"
@@ -144,6 +150,8 @@ module Tina4
144
150
 
145
151
  # ── Lazy-loaded: web server ───────────────────────────────────────────
146
152
  autoload :WebServer, File.expand_path("tina4/webserver", __dir__)
153
+ autoload :HttpServer, File.expand_path("tina4/http_server", __dir__)
154
+ autoload :FormParser, File.expand_path("tina4/form_parser", __dir__)
147
155
 
148
156
  # ── Lazy-loaded: optional modules ─────────────────────────────────────
149
157
  autoload :Swagger, File.expand_path("tina4/swagger", __dir__)
@@ -151,6 +159,8 @@ module Tina4
151
159
  autoload :CRUD, File.expand_path("tina4/crud", __dir__)
152
160
  autoload :API, File.expand_path("tina4/api", __dir__)
153
161
  autoload :APIResponse, File.expand_path("tina4/api", __dir__)
162
+ autoload :Ssrf, File.expand_path("tina4/ssrf", __dir__)
163
+ autoload :SsrfError, File.expand_path("tina4/ssrf", __dir__)
154
164
  autoload :GraphQLType, File.expand_path("tina4/graphql", __dir__)
155
165
  autoload :GraphQLSchema, File.expand_path("tina4/graphql", __dir__)
156
166
  autoload :GraphQLParser, File.expand_path("tina4/graphql", __dir__)
@@ -351,6 +361,13 @@ module Tina4
351
361
  /_/ /_/_/ /_/\__,_/ /_/
352
362
  BANNER
353
363
 
364
+ # Environment variables that mean "this is a CI run" - the ADR-0070 union
365
+ # across the four frameworks and the CLI. No one is there to see a tab.
366
+ CI_ENV_VARS = %w[CI CONTINUOUS_INTEGRATION GITHUB_ACTIONS GITLAB_CI BUILDKITE
367
+ JENKINS_URL TF_BUILD TEAMCITY_VERSION].freeze
368
+ # A CI variable set to one of these does not mean CI (ADR-0070 ci_false).
369
+ CI_FALSE_VALUES = %w[false 0 no off].freeze
370
+
354
371
  class << self
355
372
  attr_accessor :root_dir
356
373
  attr_reader :database
@@ -404,18 +421,14 @@ module Tina4
404
421
  log_level = (ENV["TINA4_LOG_LEVEL"] || "ALL").upcase
405
422
  display = (host == "0.0.0.0" || host == "::") ? "localhost" : host
406
423
 
407
- # Auto-detect server name if not provided
424
+ # Auto-detect server name if not provided: Puma only when production
425
+ # would actually pick it (ADR-0067), otherwise Tina4's own server.
408
426
  if server_name.nil?
409
- if is_debug
410
- server_name = "WEBrick"
411
- else
412
- begin
413
- require "puma"
414
- server_name = "puma"
415
- rescue LoadError
416
- server_name = "WEBrick"
417
- end
418
- end
427
+ server_name = if !is_debug && !builtin_webserver_pinned? && puma_available?
428
+ "puma"
429
+ else
430
+ Tina4::HttpServer::SOFTWARE
431
+ end
419
432
  end
420
433
 
421
434
  puts "#{color}#{BANNER}#{reset}"
@@ -438,8 +451,10 @@ module Tina4
438
451
  def initialize!(root_dir = Dir.pwd)
439
452
  @root_dir = root_dir
440
453
 
441
- # Print banner
442
- print_banner
454
+ # No banner here. The server start prints it (start_puma_server,
455
+ # WebServer#start) once the bind host and port are resolved; printed from
456
+ # here it named print_banner's defaults, localhost:7147, whatever the
457
+ # server really bound. Python prints it from run() for the same reason.
443
458
 
444
459
  # Load environment. Precedence: real-env > .env.local > .env
445
460
  # (.env.local loads first, both first-wins, so a real env var always wins).
@@ -466,8 +481,15 @@ module Tina4
466
481
  # boot after env load, before any auth use. Never crashes boot.
467
482
  Tina4::Auth.ensure_dev_secret(root_dir)
468
483
 
469
- # Setup auth keys
470
- Tina4::Auth.setup(root_dir)
484
+ # Point RS256 at <root>/.keys without creating anything: an
485
+ # operator-provisioned key pair keeps working, and a blank secret no
486
+ # longer mints one implicitly (ADR-0079 s2).
487
+ Tina4::Auth.keys_root = root_dir
488
+
489
+ # Refuse to serve with a secret anyone can reproduce: blank outside dev,
490
+ # or set but shorter than 32 bytes in any mode (ADR-0079 s2). Raises
491
+ # Tina4::Auth::InsecureSecretError naming TINA4_SECRET.
492
+ Tina4::Auth.require_boot_secret!
471
493
 
472
494
  # Auto-attach CSRF protection when TINA4_CSRF is enabled (OFF by default).
473
495
  # Mirrors the Python master's attach_csrf_from_env -- the env flag is the
@@ -530,14 +552,48 @@ module Tina4
530
552
  start
531
553
  end
532
554
 
533
- def open_browser(url)
555
+ # Open the app in a browser only when ALL of these hold (ADR-0070):
556
+ # * development: TINA4_DEBUG is truthy - never production or Puma
557
+ # * TINA4_NO_BROWSER is not truthy (true/1/yes/on, trimmed, any case)
558
+ # * --no-browser was not passed, and no_browser: true was not given
559
+ # * no CI variable vetoes (see ci_run?)
560
+ # open_browser used to check none of it, so every app.rb booted through
561
+ # run! - and every spec that did so - popped a real tab.
562
+ def browser_launch_allowed?(argv: ARGV, no_browser: false)
563
+ return false if no_browser
564
+ return false unless Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
565
+ return false if Tina4::Env.is_truthy(ENV["TINA4_NO_BROWSER"])
566
+ return false if argv.include?("--no-browser")
567
+
568
+ !ci_run?
569
+ end
570
+
571
+ # A CI variable vetoes when it is set to a non-empty value (after
572
+ # trimming) that is not one of CI_FALSE_VALUES, case-insensitively - so
573
+ # CI=woodpecker vetoes and CI=false does not (ADR-0070).
574
+ def ci_run?
575
+ CI_ENV_VARS.any? do |name|
576
+ value = ENV[name].to_s.strip.downcase
577
+ !value.empty? && !CI_FALSE_VALUES.include?(value)
578
+ end
579
+ end
580
+
581
+ # TINA4_BROWSER_COMMAND names the launcher to run instead of the OS default
582
+ # (open / start / xdg-open); it receives the URL as its only argument.
583
+ def open_browser(url, no_browser: false)
584
+ return unless browser_launch_allowed?(no_browser: no_browser)
585
+
534
586
  require "rbconfig"
587
+ command = ENV["TINA4_BROWSER_COMMAND"].to_s.strip
535
588
  Thread.new do
536
589
  sleep 2
537
- case RbConfig::CONFIG["host_os"]
538
- when /darwin/i then system("open", url)
539
- when /mswin|mingw/i then system("start", url)
540
- else system("xdg-open", url)
590
+ if !command.empty? then system(command, url)
591
+ else
592
+ case RbConfig::CONFIG["host_os"]
593
+ when /darwin/i then system("open", url)
594
+ when /mswin|mingw/i then system("start", url)
595
+ else system("xdg-open", url)
596
+ end
541
597
  end
542
598
  end
543
599
  end
@@ -550,18 +606,25 @@ module Tina4
550
606
  Tina4::Frond.register_live_endpoint!
551
607
  end
552
608
 
553
- def run!(root_dir = nil, port: nil, host: nil, debug: nil)
609
+ # no_browser: true keeps the browser shut (Python run(no_browser=True));
610
+ # false never forces one open (ADR-0070).
611
+ def run!(root_dir = nil, port: nil, host: nil, debug: nil, no_browser: false)
554
612
  # Handle legacy call: run!(port: 7147) where root_dir receives the hash
555
613
  if root_dir.is_a?(Hash)
556
614
  port ||= root_dir[:port]
557
615
  host ||= root_dir[:host]
558
616
  debug = root_dir[:debug] if debug.nil? && root_dir.key?(:debug)
617
+ no_browser ||= root_dir[:no_browser]
559
618
  root_dir = nil
560
619
  end
561
620
  root_dir ||= Dir.pwd
562
621
 
563
- ENV["PORT"] = port.to_s if port
564
- ENV["HOST"] = host.to_s if host
622
+ # An explicit argument beats the environment (ADR-0041), so it goes into
623
+ # the framework's own variables, which outrank everything else. Written
624
+ # into the bare PORT/HOST it lost to TINA4_PORT/TINA4_HOST and warned the
625
+ # app about a deprecated variable it never set.
626
+ ENV["TINA4_PORT"] = port.to_s if port
627
+ ENV["TINA4_HOST"] = host.to_s if host
565
628
  ENV["TINA4_DEBUG"] = debug.to_s unless debug.nil?
566
629
 
567
630
  initialize!(root_dir) unless @root_dir
@@ -603,22 +666,21 @@ module Tina4
603
666
  app = Tina4::RackApp.new(root_dir: root_dir)
604
667
  is_debug = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
605
668
 
606
- # Try Puma first (production-grade), fall back to WEBrick
669
+ # Puma when the APPLICATION bundles it (ADR-0067), otherwise the
670
+ # built-in server - in development AND production, as in Python.
607
671
  if !is_debug && !builtin_webserver_pinned? && puma_available?
608
- open_browser(url)
609
672
  start_puma_server(app, host: host, port: port)
610
673
  return
611
674
  end
612
675
 
613
- Tina4::Log.info("Development server: WEBrick")
614
- open_browser(url)
676
+ open_browser(url, no_browser: no_browser)
615
677
  server = Tina4::WebServer.new(app, host: host, port: port)
616
678
  server.start
617
679
  end
618
680
 
619
- # TINA4_DEFAULT_WEBSERVER=TRUE pins Tina4's BUILT-IN server (WEBrick) even
620
- # in production, where Puma would otherwise be chosen. Unset/FALSE keeps
621
- # today's behaviour, so this is non-breaking.
681
+ # TINA4_DEFAULT_WEBSERVER=TRUE pins Tina4's BUILT-IN server even when the
682
+ # application bundles Puma, which production would otherwise pick
683
+ # (ADR-0067). Unset/FALSE: Puma if it is loadable, else the built-in server.
622
684
  #
623
685
  # This is NOT the remedy for a production shutdown problem - an operator
624
686
  # must never have to give up Puma to get their database connections closed.
@@ -628,8 +690,10 @@ module Tina4
628
690
  Tina4::Env.is_truthy(ENV["TINA4_DEFAULT_WEBSERVER"])
629
691
  end
630
692
 
631
- # Is Puma loadable? Separate from start_puma_server so the caller can fall
632
- # back to WEBrick BEFORE any side effect (opening a browser) happens.
693
+ # Is Puma loadable? Puma is NOT a Tina4 dependency (ADR-0067): it is used
694
+ # only when the application installs it (under Bundler, when its Gemfile
695
+ # lists it). Separate from start_puma_server so the caller can fall back to
696
+ # the built-in server BEFORE any side effect (opening a browser) happens.
633
697
  def puma_available?
634
698
  require "puma"
635
699
  require "puma/configuration"
@@ -674,6 +738,7 @@ module Tina4
674
738
  user_config.raise_exception_on_sigterm false
675
739
  end
676
740
 
741
+ print_banner(host: host, port: port, server_name: "puma")
677
742
  Tina4::Log.info("Production server: puma (TINA4_SHUTDOWN_TIMEOUT=#{shutdown_timeout}s)")
678
743
  begin
679
744
  Puma::Launcher.new(config).run
@@ -916,7 +981,7 @@ module Tina4
916
981
  if db_url && !db_url.empty?
917
982
  begin
918
983
  bind_database(Tina4::Database.new(db_url))
919
- Tina4::Log.info("Database connected: #{db_url.sub(/:[^:@]+@/, ':***@')}")
984
+ Tina4::Log.info("Database connected: #{Tina4::DatabaseUrl.redact(db_url)}")
920
985
  rescue => e
921
986
  Tina4::Log.error("Database connection failed: #{e.message}")
922
987
  end
data/lib/tina4ruby.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
  # Alias: `require "tina4ruby"` loads the same code as `require "tina4"`
4
10
  require_relative "tina4"