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