tina4ruby 3.13.97 → 3.13.99
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 +84 -0
- data/lib/tina4/ai.rb +32 -3
- data/lib/tina4/api.rb +5 -0
- data/lib/tina4/auto_crud.rb +62 -4
- data/lib/tina4/background.rb +112 -31
- data/lib/tina4/cache.rb +3 -2
- data/lib/tina4/cli.rb +55 -67
- data/lib/tina4/database.rb +97 -49
- data/lib/tina4/database_adapter.rb +169 -15
- data/lib/tina4/dev_admin.rb +137 -9
- data/lib/tina4/dispatch_pipeline.rb +145 -4
- data/lib/tina4/drivers/firebird_driver.rb +59 -12
- data/lib/tina4/drivers/mongodb_driver.rb +98 -14
- data/lib/tina4/drivers/mssql_driver.rb +39 -2
- data/lib/tina4/drivers/mysql_driver.rb +43 -3
- data/lib/tina4/drivers/odbc_driver.rb +36 -2
- data/lib/tina4/drivers/postgres_driver.rb +5 -0
- data/lib/tina4/drivers/sqlite_driver.rb +11 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/error_overlay.rb +43 -49
- data/lib/tina4/field_types.rb +33 -16
- data/lib/tina4/frond.rb +24 -2
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
- data/lib/tina4/graphql.rb +2 -2
- data/lib/tina4/log.rb +652 -485
- data/lib/tina4/mcp.rb +9 -1
- data/lib/tina4/messenger.rb +25 -0
- data/lib/tina4/middleware.rb +189 -76
- data/lib/tina4/migration.rb +47 -15
- data/lib/tina4/orm.rb +280 -59
- data/lib/tina4/port_takeover.rb +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
- data/lib/tina4/rack_app.rb +201 -59
- data/lib/tina4/realtime.rb +6 -1
- data/lib/tina4/request.rb +259 -51
- data/lib/tina4/router.rb +20 -2
- data/lib/tina4/seeder.rb +68 -19
- data/lib/tina4/shutdown.rb +4 -0
- data/lib/tina4/sql_translator.rb +115 -86
- data/lib/tina4/swagger.rb +19 -3
- data/lib/tina4/template.rb +61 -6
- data/lib/tina4/test_client.rb +49 -3
- data/lib/tina4/testing.rb +16 -11
- data/lib/tina4/validator.rb +7 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -40
- data/lib/tina4.rb +12 -1
- metadata +3 -19
- data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
- data/lib/tina4/scss/tina4css/_badges.scss +0 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
- data/lib/tina4/scss/tina4css/_cards.scss +0 -49
- data/lib/tina4/scss/tina4css/_forms.scss +0 -156
- data/lib/tina4/scss/tina4css/_grid.scss +0 -81
- data/lib/tina4/scss/tina4css/_modals.scss +0 -84
- data/lib/tina4/scss/tina4css/_nav.scss +0 -149
- data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
- data/lib/tina4/scss/tina4css/_reset.scss +0 -94
- data/lib/tina4/scss/tina4css/_tables.scss +0 -54
- data/lib/tina4/scss/tina4css/_typography.scss +0 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
- data/lib/tina4/scss/tina4css/_variables.scss +0 -117
- data/lib/tina4/scss/tina4css/base.scss +0 -1
- data/lib/tina4/scss/tina4css/colors.scss +0 -48
- data/lib/tina4/scss/tina4css/tina4.scss +0 -18
data/lib/tina4/sql_translator.rb
CHANGED
|
@@ -17,84 +17,131 @@ module Tina4
|
|
|
17
17
|
#
|
|
18
18
|
class SQLTranslator
|
|
19
19
|
class << self
|
|
20
|
-
#
|
|
20
|
+
# ── Literal-safe rewriting ────────────────────────────────────
|
|
21
21
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
22
|
+
# A dialect rewrite (|| -> CONCAT, TRUE -> 1, ILIKE -> LOWER LIKE) must NEVER
|
|
23
|
+
# touch text inside a string literal, a quoted identifier or a comment: a
|
|
24
|
+
# column value of 'a||b', a label 'TRUE', or a LIKE pattern that mentions
|
|
25
|
+
# ILIKE is DATA, not SQL. Each transform masks every literal/identifier/
|
|
26
|
+
# comment to an opaque token, rewrites the masked SQL, then restores the
|
|
27
|
+
# tokens, so the rewrite only ever sees real SQL structure.
|
|
24
28
|
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return sql[0...m.begin(0)] + "ROWS #{start_row} TO #{end_row}"
|
|
35
|
-
end
|
|
29
|
+
# NOTE on the removed methods (SQLTRANS-DEC-02): +limit_to_rows+,
|
|
30
|
+
# +limit_to_top+ and +placeholder_style+ were deleted here. Ruby's DRIVERS
|
|
31
|
+
# own pagination and placeholders by design and do it correctly per engine
|
|
32
|
+
# (Firebird +SELECT FIRST/SKIP+, MSSQL +OFFSET ... FETCH+, Postgres +$1+),
|
|
33
|
+
# whereas these translator helpers emitted a DIFFERENT and inferior shape
|
|
34
|
+
# (MSSQL +TOP+, Firebird +ROWS x TO y+) that nothing called. Keeping a dead
|
|
35
|
+
# public method that also disagrees with the live driver is a footgun, so
|
|
36
|
+
# they are gone. concat/bool/ilike stay (they fill a real gap - the drivers
|
|
37
|
+
# do not translate them) and are now WIRED into the MySQL driver.
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
# Replace string literals, quoted identifiers and comments with opaque
|
|
40
|
+
# "\x00N\x00" tokens. Returns [masked_sql, literals]; doubled-quote escapes
|
|
41
|
+
# ('' "" ``) are handled so an embedded quote never ends the span early.
|
|
42
|
+
def mask_literals(sql)
|
|
43
|
+
literals = []
|
|
44
|
+
out = +""
|
|
45
|
+
i = 0
|
|
46
|
+
n = sql.length
|
|
47
|
+
while i < n
|
|
48
|
+
ch = sql[i]
|
|
49
|
+
nxt = sql[i + 1]
|
|
50
|
+
if ch == "'" || ch == '"' || ch == "`"
|
|
51
|
+
start = i
|
|
52
|
+
i += 1
|
|
53
|
+
while i < n
|
|
54
|
+
if sql[i] == ch
|
|
55
|
+
if sql[i + 1] == ch
|
|
56
|
+
i += 2
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
i += 1
|
|
60
|
+
break
|
|
61
|
+
end
|
|
62
|
+
i += 1
|
|
63
|
+
end
|
|
64
|
+
out << "\x00#{literals.length}\x00"
|
|
65
|
+
literals << sql[start...i]
|
|
66
|
+
next
|
|
67
|
+
end
|
|
68
|
+
if ch == "-" && nxt == "-"
|
|
69
|
+
start = i
|
|
70
|
+
i += 1 while i < n && sql[i] != "\n"
|
|
71
|
+
out << "\x00#{literals.length}\x00"
|
|
72
|
+
literals << sql[start...i]
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
if ch == "/" && nxt == "*"
|
|
76
|
+
start = i
|
|
77
|
+
i += 2
|
|
78
|
+
i += 1 while i < n && !(sql[i] == "*" && sql[i + 1] == "/")
|
|
79
|
+
i = [i + 2, n].min
|
|
80
|
+
out << "\x00#{literals.length}\x00"
|
|
81
|
+
literals << sql[start...i]
|
|
82
|
+
next
|
|
83
|
+
end
|
|
84
|
+
out << ch
|
|
85
|
+
i += 1
|
|
41
86
|
end
|
|
42
|
-
|
|
43
|
-
sql
|
|
87
|
+
[out, literals]
|
|
44
88
|
end
|
|
45
89
|
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# OFFSET queries are left unchanged (not supported by TOP).
|
|
50
|
-
#
|
|
51
|
-
# @param sql [String]
|
|
52
|
-
# @return [String]
|
|
53
|
-
def limit_to_top(sql)
|
|
54
|
-
if (m = sql.match(/\bLIMIT\s+(\d+)\s*$/i)) && !sql.match?(/\bOFFSET\b/i)
|
|
55
|
-
limit = m[1].to_i
|
|
56
|
-
body = sql[0...m.begin(0)].strip
|
|
57
|
-
return body.sub(/^(SELECT)\b/i, "\\1 TOP #{limit}")
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
sql
|
|
90
|
+
# Inverse of #mask_literals.
|
|
91
|
+
def restore_literals(masked, literals)
|
|
92
|
+
masked.gsub(/\x00(\d+)\x00/) { literals[::Regexp.last_match(1).to_i] }
|
|
61
93
|
end
|
|
62
94
|
|
|
63
|
-
# Convert || concatenation to CONCAT() for MySQL/MSSQL.
|
|
95
|
+
# Convert || string concatenation to CONCAT() for MySQL/MSSQL. Rewrites ONLY
|
|
96
|
+
# || operators joining expression operands OUTSIDE any string literal or
|
|
97
|
+
# comment, and only the operand chain - never the whole statement.
|
|
64
98
|
#
|
|
65
|
-
#
|
|
99
|
+
# SELECT a || b FROM t => SELECT CONCAT(a, b) FROM t
|
|
100
|
+
# WHERE data = 'a||b' => WHERE data = 'a||b' (literal untouched)
|
|
66
101
|
#
|
|
67
102
|
# @param sql [String]
|
|
68
103
|
# @return [String]
|
|
69
104
|
def concat_pipes_to_func(sql)
|
|
70
105
|
return sql unless sql.include?("||")
|
|
71
106
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
107
|
+
masked, literals = mask_literals(sql)
|
|
108
|
+
return sql unless masked.include?("||")
|
|
109
|
+
|
|
110
|
+
chain = /#{SQLTranslator::PRIMARY}(?:\s*\|\|\s*#{SQLTranslator::PRIMARY})+/
|
|
111
|
+
rewritten = masked.gsub(chain) do |m|
|
|
112
|
+
"CONCAT(#{m.split(/\s*\|\|\s*/).join(', ')})"
|
|
77
113
|
end
|
|
114
|
+
restore_literals(rewritten, literals)
|
|
78
115
|
end
|
|
79
116
|
|
|
80
|
-
# Convert TRUE/FALSE to 1/0 for engines without boolean type.
|
|
117
|
+
# Convert a bare TRUE/FALSE to 1/0 for engines without a boolean type. A
|
|
118
|
+
# TRUE/FALSE INSIDE a string literal is data and is left untouched.
|
|
81
119
|
#
|
|
82
120
|
# @param sql [String]
|
|
83
121
|
# @return [String]
|
|
84
122
|
def boolean_to_int(sql)
|
|
85
|
-
sql.
|
|
123
|
+
return sql unless sql.match?(/\b(?:TRUE|FALSE)\b/i)
|
|
124
|
+
|
|
125
|
+
masked, literals = mask_literals(sql)
|
|
126
|
+
masked = masked.gsub(/\bTRUE\b/i, "1").gsub(/\bFALSE\b/i, "0")
|
|
127
|
+
restore_literals(masked, literals)
|
|
86
128
|
end
|
|
87
129
|
|
|
88
|
-
# Convert ILIKE to LOWER() LIKE LOWER() for engines
|
|
130
|
+
# Convert +col ILIKE pattern+ to +LOWER(col) LIKE LOWER(pattern)+ for engines
|
|
131
|
+
# without ILIKE. The pattern operand is captured whole (a multi-word
|
|
132
|
+
# '%two words%' survives), and an ILIKE INSIDE a string literal is untouched.
|
|
89
133
|
#
|
|
90
134
|
# @param sql [String]
|
|
91
135
|
# @return [String]
|
|
92
136
|
def ilike_to_like(sql)
|
|
93
|
-
sql
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
137
|
+
return sql unless sql =~ /ilike/i
|
|
138
|
+
|
|
139
|
+
masked, literals = mask_literals(sql)
|
|
140
|
+
pattern = /(#{SQLTranslator::PRIMARY})\s+ILIKE\s+(#{SQLTranslator::PRIMARY})/i
|
|
141
|
+
rewritten = masked.gsub(pattern) do
|
|
142
|
+
"LOWER(#{::Regexp.last_match(1)}) LIKE LOWER(#{::Regexp.last_match(2)})"
|
|
97
143
|
end
|
|
144
|
+
restore_literals(rewritten, literals)
|
|
98
145
|
end
|
|
99
146
|
|
|
100
147
|
# Translate AUTOINCREMENT across engines in DDL.
|
|
@@ -107,7 +154,13 @@ module Tina4
|
|
|
107
154
|
when "mysql"
|
|
108
155
|
sql.gsub("AUTOINCREMENT", "AUTO_INCREMENT")
|
|
109
156
|
when "postgresql"
|
|
110
|
-
|
|
157
|
+
# BIGINT PRIMARY KEY AUTOINCREMENT -> BIGSERIAL (a real 64-bit sequence);
|
|
158
|
+
# INTEGER -> SERIAL. A plain BIGINT with the keyword merely stripped has
|
|
159
|
+
# no sequence and cannot auto-increment.
|
|
160
|
+
sql
|
|
161
|
+
.gsub(/\bBIGINT\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/i, "BIGSERIAL PRIMARY KEY")
|
|
162
|
+
.gsub(/\bINTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT\b/i, "SERIAL PRIMARY KEY")
|
|
163
|
+
.gsub(/\s*\bAUTOINCREMENT\b/i, "")
|
|
111
164
|
when "mssql"
|
|
112
165
|
sql.gsub(/AUTOINCREMENT/i, "IDENTITY(1,1)")
|
|
113
166
|
when "firebird"
|
|
@@ -117,42 +170,13 @@ module Tina4
|
|
|
117
170
|
end
|
|
118
171
|
end
|
|
119
172
|
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
# @return [String]
|
|
128
|
-
def placeholder_style(sql, style)
|
|
129
|
-
case style
|
|
130
|
-
when "%s"
|
|
131
|
-
sql.gsub("?", "%s")
|
|
132
|
-
when ":"
|
|
133
|
-
count = 0
|
|
134
|
-
sql.chars.map do |ch|
|
|
135
|
-
if ch == "?"
|
|
136
|
-
count += 1
|
|
137
|
-
":#{count}"
|
|
138
|
-
else
|
|
139
|
-
ch
|
|
140
|
-
end
|
|
141
|
-
end.join
|
|
142
|
-
else
|
|
143
|
-
sql
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Generate a cache key for a query and its parameters.
|
|
148
|
-
#
|
|
149
|
-
# @param sql [String]
|
|
150
|
-
# @param params [Array, nil]
|
|
151
|
-
# @return [String]
|
|
152
|
-
def query_key(sql, params = nil)
|
|
153
|
-
raw = params ? "#{sql}|#{params.inspect}" : sql
|
|
154
|
-
"query:#{Digest::SHA256.hexdigest(raw)}"
|
|
155
|
-
end
|
|
173
|
+
# NOTE (SQLTRANS-DEC-02): +placeholder_style+ was removed - every Ruby
|
|
174
|
+
# driver owns its own placeholder shape (+?+ for MySQL/SQLite/MSSQL/Firebird,
|
|
175
|
+
# +$1+ for Postgres), so this helper had zero callers and disagreed with the
|
|
176
|
+
# live drivers. The cache KEY helper +query_key+ was also removed here: it
|
|
177
|
+
# duplicated the live +.query_key+ class method the ORM cache path (defined
|
|
178
|
+
# in lib/tina4/cache.rb, per spec/docs_truth_spec.rb's D12 lock-in that this
|
|
179
|
+
# file stays free of that class's name) actually uses. One source, not two.
|
|
156
180
|
|
|
157
181
|
# Collapse a row-at-a-time INSERT batch into chunked multi-row VALUES.
|
|
158
182
|
#
|
|
@@ -233,6 +257,11 @@ module Tina4
|
|
|
233
257
|
end
|
|
234
258
|
end
|
|
235
259
|
|
|
260
|
+
# A concat/ilike operand: a masked literal-or-identifier token, a simple
|
|
261
|
+
# function call, a (qualified) identifier, a placeholder, or a number. The
|
|
262
|
+
# function-call args exclude +|+ so a nested +||+ never splits the chain.
|
|
263
|
+
PRIMARY = '(?:\x00\d+\x00|[A-Za-z_][\w$]*\s*\([^()|]*\)|[A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)*|:[A-Za-z_]\w*|\$\d+|\?|%s|\d+(?:\.\d+)?)'
|
|
264
|
+
|
|
236
265
|
# Hard per-statement bind-parameter ceiling per engine. 0 = never collapse.
|
|
237
266
|
# Sourced from spec/fixtures/batch_write_contract.json, byte-identical in
|
|
238
267
|
# all four frameworks.
|
data/lib/tina4/swagger.rb
CHANGED
|
@@ -173,10 +173,22 @@ module Tina4
|
|
|
173
173
|
scheme && !scheme.empty? ? scheme : "bearerAuth"
|
|
174
174
|
end
|
|
175
175
|
|
|
176
|
-
#
|
|
176
|
+
# Framework-internal route prefixes that are NEVER part of an
|
|
177
|
+
# application's public API document. SHARED across all four frameworks
|
|
178
|
+
# (SWAG-EXCLUSION-NOT-SHARED, ADR-0004) so the exclusion is one rule
|
|
179
|
+
# everywhere, not three mechanisms: the dev tools (/swagger, /__dev),
|
|
180
|
+
# the feedback widget (/__feedback), and the built-in AI/RAG service
|
|
181
|
+
# probes (/ai, /rag, /vision, /embed, /image). Ruby dispatches these
|
|
182
|
+
# OUTSIDE Tina4::Router today, so most never reach included? in
|
|
183
|
+
# practice — the list is here so the RULE holds regardless of how a
|
|
184
|
+
# given route got registered, not because of where each internal
|
|
185
|
+
# happens to be wired.
|
|
186
|
+
INTERNAL_PREFIXES = ["/swagger", "/__dev", "/__feedback", "/ai", "/rag", "/vision", "/embed", "/image"].freeze
|
|
187
|
+
|
|
188
|
+
# Path filtering. Framework internals (INTERNAL_PREFIXES) are ALWAYS
|
|
177
189
|
# excluded; then TINA4_SWAGGER_INCLUDE (allow-list) / _EXCLUDE apply.
|
|
178
190
|
def included?(raw_path)
|
|
179
|
-
|
|
191
|
+
INTERNAL_PREFIXES.each do |internal|
|
|
180
192
|
return false if raw_path == internal || raw_path.start_with?("#{internal}/")
|
|
181
193
|
end
|
|
182
194
|
|
|
@@ -240,11 +252,15 @@ module Tina4
|
|
|
240
252
|
operation = {
|
|
241
253
|
"operationId" => unique_operation_id(method, path, ctx[:seen_ids]),
|
|
242
254
|
"summary" => meta[:summary] || "#{method.upcase} #{route.path}",
|
|
243
|
-
"description" => meta[:description] || "",
|
|
244
255
|
"tags" => tags,
|
|
245
256
|
"parameters" => build_parameters(route),
|
|
246
257
|
"responses" => build_responses(meta, ref, ctx)
|
|
247
258
|
}
|
|
259
|
+
# description is OMITTED when unset (SWAG-SHAPE-DRIFT, ADR-0004) —
|
|
260
|
+
# python/php/node never fabricate a description key either; Ruby used
|
|
261
|
+
# to always stamp "" on every undecorated operation, the one shape
|
|
262
|
+
# drift where Ruby (not Python) was the odd one out.
|
|
263
|
+
operation["description"] = meta[:description] if meta[:description]
|
|
248
264
|
|
|
249
265
|
operation["deprecated"] = true if meta[:deprecated]
|
|
250
266
|
operation["security"] = security unless security.nil?
|
data/lib/tina4/template.rb
CHANGED
|
@@ -5,6 +5,50 @@ module Tina4
|
|
|
5
5
|
TEMPLATE_DIRS = %w[templates src/templates src/views views].freeze
|
|
6
6
|
|
|
7
7
|
class << self
|
|
8
|
+
# Content negotiation for an error response (feature 42, ERR-DEC-02): does
|
|
9
|
+
# an Accept header prefer application/json over text/html?
|
|
10
|
+
#
|
|
11
|
+
# `Accept: application/json` (an API client) prefers JSON; a browser
|
|
12
|
+
# Accept (`text/html`, `*/*`, or no header at all) prefers HTML. A mixed
|
|
13
|
+
# Accept header - a real browser's
|
|
14
|
+
# `text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8` - is
|
|
15
|
+
# resolved by q-value: whichever of the two media types this method cares
|
|
16
|
+
# about is weighted higher wins; a tie or neither present defaults to
|
|
17
|
+
# HTML, the historical/back-compatible behaviour for an unspecified
|
|
18
|
+
# client. This is the ONE shared decision reused by the 403/404/500 error
|
|
19
|
+
# paths (#render_error's callers in rack_app.rb and middleware.rb), so a
|
|
20
|
+
# JSON API client sees the SAME negotiated shape everywhere
|
|
21
|
+
# (ERR-403-SPLIT) - ported with the same algorithm to Python/PHP/Node.
|
|
22
|
+
def wants_json?(accept)
|
|
23
|
+
accept = accept.to_s
|
|
24
|
+
return false if accept.empty?
|
|
25
|
+
|
|
26
|
+
best_json = -1.0
|
|
27
|
+
best_html = -1.0
|
|
28
|
+
accept.split(",").each do |part|
|
|
29
|
+
segments = part.strip.split(";")
|
|
30
|
+
media = segments[0].to_s.strip.downcase
|
|
31
|
+
q = 1.0
|
|
32
|
+
segments[1..].each do |param|
|
|
33
|
+
param = param.strip
|
|
34
|
+
next unless param.start_with?("q=")
|
|
35
|
+
|
|
36
|
+
q_str = param[2..]
|
|
37
|
+
q = q_str.to_f if q_str =~ /\A-?\d+(\.\d+)?\z/
|
|
38
|
+
end
|
|
39
|
+
if media == "application/json"
|
|
40
|
+
best_json = q if q > best_json
|
|
41
|
+
elsif ["text/html", "*/*", "application/xhtml+xml"].include?(media)
|
|
42
|
+
best_html = q if q > best_html
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
return false if best_json.negative?
|
|
47
|
+
return true if best_html.negative?
|
|
48
|
+
|
|
49
|
+
best_json > best_html
|
|
50
|
+
end
|
|
51
|
+
|
|
8
52
|
def globals
|
|
9
53
|
@globals ||= {}
|
|
10
54
|
end
|
|
@@ -19,7 +63,7 @@ module Tina4
|
|
|
19
63
|
raise "Template not found: #{template_path}"
|
|
20
64
|
end
|
|
21
65
|
|
|
22
|
-
content = File.read(full_path)
|
|
66
|
+
content = File.read(full_path, encoding: "utf-8")
|
|
23
67
|
ext = File.extname(full_path).downcase
|
|
24
68
|
context = globals.merge(data.transform_keys(&:to_s))
|
|
25
69
|
|
|
@@ -43,7 +87,18 @@ module Tina4
|
|
|
43
87
|
%w[.twig .html .erb].each do |ext|
|
|
44
88
|
path = File.join(dir, "#{code}#{ext}")
|
|
45
89
|
if File.exist?(path)
|
|
46
|
-
|
|
90
|
+
# encoding: "utf-8" (feature 42, ERR-DEC-01): 403.twig ships an
|
|
91
|
+
# em-dash. Without a forced encoding, File.read tags the string
|
|
92
|
+
# with Encoding.default_external, which is UTF-8 on a typical
|
|
93
|
+
# dev machine but US-ASCII on a bare/minimal locale (no LANG/
|
|
94
|
+
# LC_ALL - common in a container) - and Ruby's regex engine then
|
|
95
|
+
# raises ArgumentError: invalid byte sequence in US-ASCII the
|
|
96
|
+
# moment TwigEngine tries to match against it. render_error's
|
|
97
|
+
# caller used to `rescue` broadly and silently fall back to a
|
|
98
|
+
# bare string, so every 403 request on such a host rendered NO
|
|
99
|
+
# template at all and nobody noticed. Matches lib/tina4/frond.rb,
|
|
100
|
+
# which already reads its templates this way.
|
|
101
|
+
content = File.read(path, encoding: "utf-8")
|
|
47
102
|
return TwigEngine.new(context, dir).render(content)
|
|
48
103
|
end
|
|
49
104
|
end
|
|
@@ -123,7 +178,7 @@ module Tina4
|
|
|
123
178
|
|
|
124
179
|
def error_overlay_source(file, line)
|
|
125
180
|
return "" unless file && line && File.exist?(file)
|
|
126
|
-
lines = File.readlines(file)
|
|
181
|
+
lines = File.readlines(file, encoding: "utf-8")
|
|
127
182
|
start = [line.to_i - 4, 0].max
|
|
128
183
|
finish = [line.to_i + 3, lines.length - 1].min
|
|
129
184
|
snippet = lines[start..finish].each_with_index.map do |l, i|
|
|
@@ -230,7 +285,7 @@ module Tina4
|
|
|
230
285
|
parent_path = Regexp.last_match(1)
|
|
231
286
|
full_parent = resolve_template(parent_path)
|
|
232
287
|
if full_parent && File.exist?(full_parent)
|
|
233
|
-
parent_source = File.read(full_parent)
|
|
288
|
+
parent_source = File.read(full_parent, encoding: "utf-8")
|
|
234
289
|
child_blocks = extract_blocks(content)
|
|
235
290
|
@blocks.merge!(child_blocks)
|
|
236
291
|
content = render_with_blocks(parent_source, @blocks)
|
|
@@ -296,7 +351,7 @@ module Tina4
|
|
|
296
351
|
grandparent_name = Regexp.last_match(1)
|
|
297
352
|
full_grandparent = resolve_template(grandparent_name)
|
|
298
353
|
if full_grandparent && File.exist?(full_grandparent)
|
|
299
|
-
grandparent_source = File.read(full_grandparent)
|
|
354
|
+
grandparent_source = File.read(full_grandparent, encoding: "utf-8")
|
|
300
355
|
|
|
301
356
|
# Extract block defaults defined in the parent template
|
|
302
357
|
parent_blocks = extract_blocks(parent_source)
|
|
@@ -349,7 +404,7 @@ module Tina4
|
|
|
349
404
|
inc_path = Regexp.last_match(1)
|
|
350
405
|
full_path = resolve_template(inc_path)
|
|
351
406
|
if full_path && File.exist?(full_path)
|
|
352
|
-
inc_content = File.read(full_path)
|
|
407
|
+
inc_content = File.read(full_path, encoding: "utf-8")
|
|
353
408
|
TwigEngine.new(@context.dup, File.dirname(full_path)).render(inc_content)
|
|
354
409
|
else
|
|
355
410
|
"<!-- include not found: #{inc_path} -->"
|
data/lib/tina4/test_client.rb
CHANGED
|
@@ -22,12 +22,45 @@ module Tina4
|
|
|
22
22
|
# Build from a Rack response tuple [status, headers, body_array]
|
|
23
23
|
def initialize(rack_response)
|
|
24
24
|
@status = rack_response[0]
|
|
25
|
-
|
|
25
|
+
raw_headers = rack_response[1] || {}
|
|
26
|
+
|
|
27
|
+
# Rack folds a genuinely repeated header (Tina4::Response#to_rack only
|
|
28
|
+
# ever repeats Set-Cookie, joined "\n" — the Rack 2/3 convention for a
|
|
29
|
+
# multi-value header carried in a single hash slot) into ONE value; a
|
|
30
|
+
# header value can never legitimately contain a raw newline (RFC 7230),
|
|
31
|
+
# so splitting on "\n" is safe and general, not a Set-Cookie special
|
|
32
|
+
# case. @header_list keeps every occurrence, in emission order, so a
|
|
33
|
+
# duplicate is assertable via get_all(); @headers stays the back-compat
|
|
34
|
+
# single-value view — the LAST occurrence, the "last wins" contract the
|
|
35
|
+
# other three frameworks converge on — so nothing that reads a plain
|
|
36
|
+
# (never-repeated) header sees any change (TC-HEADER-COLLAPSE,
|
|
37
|
+
# TC-DEC-02).
|
|
38
|
+
@header_list = {}
|
|
39
|
+
flat = {}
|
|
40
|
+
raw_headers.each do |name, value|
|
|
41
|
+
key = name.to_s.downcase
|
|
42
|
+
values = value.is_a?(Array) ? value.map(&:to_s) : value.to_s.split("\n")
|
|
43
|
+
values = [""] if values.empty?
|
|
44
|
+
@header_list[key] = values
|
|
45
|
+
flat[key] = values.last
|
|
46
|
+
end
|
|
47
|
+
@headers = flat
|
|
48
|
+
|
|
26
49
|
@content_type = @headers["content-type"] || ""
|
|
27
50
|
raw_body = rack_response[2]
|
|
28
51
|
@body = raw_body.is_a?(Array) ? raw_body.join : raw_body.to_s
|
|
29
52
|
end
|
|
30
53
|
|
|
54
|
+
# Every value sent for +name+ (case-insensitive), in emission order.
|
|
55
|
+
#
|
|
56
|
+
# A header sent once returns a one-item array; a header never sent
|
|
57
|
+
# returns an empty array. This is the one place a duplicate response
|
|
58
|
+
# header (two Set-Cookie) is visible — headers[name] always collapses to
|
|
59
|
+
# the LAST value, same as before (TC-HEADER-COLLAPSE, TC-DEC-02).
|
|
60
|
+
def get_all(name)
|
|
61
|
+
(@header_list[name.to_s.downcase] || []).dup
|
|
62
|
+
end
|
|
63
|
+
|
|
31
64
|
# Parse body as JSON.
|
|
32
65
|
def json
|
|
33
66
|
return nil if @body.nil? || @body.empty?
|
|
@@ -138,10 +171,23 @@ module Tina4
|
|
|
138
171
|
env["CONTENT_TYPE"] = content_type unless content_type.empty?
|
|
139
172
|
env["CONTENT_LENGTH"] = raw_body.bytesize.to_s unless raw_body.empty?
|
|
140
173
|
|
|
141
|
-
# Add custom headers (convert to Rack format: X-Custom → HTTP_X_CUSTOM)
|
|
174
|
+
# Add custom headers (convert to Rack format: X-Custom → HTTP_X_CUSTOM).
|
|
175
|
+
# Content-Type and Content-Length are the two CGI/Rack headers that do
|
|
176
|
+
# NOT get an HTTP_ prefix (Request#content_type reads env["CONTENT_TYPE"]
|
|
177
|
+
# directly) — without this a caller-supplied `headers: {"Content-Type"
|
|
178
|
+
# => ...}` silently landed in HTTP_CONTENT_TYPE, which nothing reads, so
|
|
179
|
+
# #content_type stayed "" and body parsing fell through to the generic
|
|
180
|
+
# {} branch regardless of what the caller asked for. PHP's/Node's/
|
|
181
|
+
# Python's TestClient never had this gap (their headers carry no CGI
|
|
182
|
+
# prefix convention to special-case).
|
|
142
183
|
if headers
|
|
143
184
|
headers.each do |key, value|
|
|
144
|
-
|
|
185
|
+
normalised = key.to_s.downcase
|
|
186
|
+
rack_key = case normalised
|
|
187
|
+
when "content-type" then "CONTENT_TYPE"
|
|
188
|
+
when "content-length" then "CONTENT_LENGTH"
|
|
189
|
+
else "HTTP_#{key.upcase.tr('-', '_')}"
|
|
190
|
+
end
|
|
145
191
|
env[rack_key] = value
|
|
146
192
|
end
|
|
147
193
|
end
|
data/lib/tina4/testing.rb
CHANGED
|
@@ -41,32 +41,37 @@ module Tina4
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# ── Inline testing (parity with Python/PHP/Node decorator pattern) ──
|
|
44
|
+
#
|
|
45
|
+
# The builders are named expect_* — DESCRIPTORS that record an expectation
|
|
46
|
+
# for run_all to execute later — deliberately distinct from the immediate
|
|
47
|
+
# xUnit-style TestContext#assert_* used inside describe/it blocks, so the two
|
|
48
|
+
# surfaces never collide on a name with an incompatible signature.
|
|
44
49
|
|
|
45
|
-
#
|
|
46
|
-
def
|
|
50
|
+
# Expectation builder: expect_equal(args, expected)
|
|
51
|
+
def expect_equal(args, expected)
|
|
47
52
|
{ type: :equal, args: args, expected: expected }
|
|
48
53
|
end
|
|
49
54
|
|
|
50
|
-
#
|
|
51
|
-
def
|
|
55
|
+
# Expectation builder: expect_raises(exception_class, args)
|
|
56
|
+
def expect_raises(exception_class, args)
|
|
52
57
|
{ type: :raises, exception: exception_class, args: args }
|
|
53
58
|
end
|
|
54
59
|
|
|
55
|
-
#
|
|
56
|
-
def
|
|
60
|
+
# Expectation builder: expect_true(args)
|
|
61
|
+
def expect_true(args)
|
|
57
62
|
{ type: :true, args: args }
|
|
58
63
|
end
|
|
59
64
|
|
|
60
|
-
#
|
|
61
|
-
def
|
|
65
|
+
# Expectation builder: expect_false(args)
|
|
66
|
+
def expect_false(args)
|
|
62
67
|
{ type: :false, args: args }
|
|
63
68
|
end
|
|
64
69
|
|
|
65
|
-
# Register a callable with inline
|
|
70
|
+
# Register a callable with inline expectations (mirrors Python's @tests decorator).
|
|
66
71
|
#
|
|
67
72
|
# Tina4::Testing.tests(
|
|
68
|
-
# Tina4::Testing.
|
|
69
|
-
# Tina4::Testing.
|
|
73
|
+
# Tina4::Testing.expect_equal([5, 3], 8),
|
|
74
|
+
# Tina4::Testing.expect_raises(ArgumentError, [nil]),
|
|
70
75
|
# ) { |a, b| raise ArgumentError, "b required" if b.nil?; a + b }
|
|
71
76
|
#
|
|
72
77
|
def tests(*assertions, name: nil, &block)
|
data/lib/tina4/validator.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Tina4
|
|
4
6
|
# Request body validator with chainable rules.
|
|
5
7
|
#
|
|
@@ -140,7 +142,11 @@ module Tina4
|
|
|
140
142
|
return self if value.nil?
|
|
141
143
|
|
|
142
144
|
unless allowed.include?(value)
|
|
143
|
-
|
|
145
|
+
# Canonical wording (VALID-TWO-MESSAGES): render the allowed set as
|
|
146
|
+
# compact JSON so every framework prints the SAME list -- Node
|
|
147
|
+
# JSON.stringify, PHP json_encode, Python json.dumps and this to_json all
|
|
148
|
+
# emit ["admin","user","guest"] (no spaces), never a language repr.
|
|
149
|
+
@validation_errors << { field: key, message: "#{key} must be one of #{allowed.to_json}" }
|
|
144
150
|
end
|
|
145
151
|
self
|
|
146
152
|
end
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/webserver.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "port_takeover"
|
|
4
|
+
|
|
3
5
|
module Tina4
|
|
4
6
|
class WebServer
|
|
5
7
|
DEFAULT_HOST = "0.0.0.0"
|
|
@@ -17,49 +19,29 @@ module Tina4
|
|
|
17
19
|
@port = port || Tina4.resolve_bind_port(DEFAULT_PORT)
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
22
|
+
# Reclaim *port* from a stale Tina4 dev server via the shared, guarded path.
|
|
23
|
+
#
|
|
24
|
+
# This is the runtime bind-failure fallback. It used to SIGTERM whatever held
|
|
25
|
+
# the port with NONE of the CLI's guards -- no identity check, no container
|
|
26
|
+
# guard, no PID-safety filter -- so a foreign holder (another dev server, a
|
|
27
|
+
# database) was killed on any bind failure. It now routes through the SAME
|
|
28
|
+
# identity-checked helper the CLI uses (TAKEOVER-DEC-02), so only a
|
|
29
|
+
# PID-file-confirmed Tina4 dev server is ever signalled.
|
|
30
|
+
#
|
|
31
|
+
# Raises RuntimeError when the port is held by a non-Tina4 process (or
|
|
32
|
+
# takeover is opted out / disabled outside dev), so the bind fails loudly with
|
|
33
|
+
# a clear message instead of killing an innocent process.
|
|
23
34
|
def free_port(port)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if line.include?(":#{port}") && (line.include?("LISTENING") || line.include?("ESTABLISHED"))
|
|
31
|
-
parts = line.strip.split(/\s+/)
|
|
32
|
-
candidate = parts.last
|
|
33
|
-
if candidate =~ /^\d+$/
|
|
34
|
-
pid = candidate
|
|
35
|
-
break
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
if pid
|
|
40
|
-
system("taskkill /PID #{pid} /F")
|
|
41
|
-
else
|
|
42
|
-
raise "Could not free port #{port}: no PID found"
|
|
43
|
-
end
|
|
44
|
-
else
|
|
45
|
-
pids = `lsof -ti :#{port} 2>/dev/null`.strip.split("\n")
|
|
46
|
-
if pids.empty?
|
|
47
|
-
return # Nothing found — port may have freed itself
|
|
48
|
-
end
|
|
49
|
-
pids.each do |pid|
|
|
50
|
-
pid = pid.strip
|
|
51
|
-
next unless pid =~ /^\d+$/
|
|
52
|
-
begin
|
|
53
|
-
Process.kill("TERM", pid.to_i)
|
|
54
|
-
rescue Errno::ESRCH
|
|
55
|
-
# Process already gone
|
|
56
|
-
end
|
|
57
|
-
end
|
|
35
|
+
result = Tina4::PortTakeover.take_over_port(
|
|
36
|
+
port, dev: Tina4::PortTakeover.dev?, no_takeover: Tina4::PortTakeover.no_takeover_opted_out?
|
|
37
|
+
)
|
|
38
|
+
if result.reclaimed?
|
|
39
|
+
puts " #{result.message}"
|
|
40
|
+
return
|
|
58
41
|
end
|
|
42
|
+
raise result.message if result.refused?
|
|
59
43
|
|
|
60
|
-
#
|
|
61
|
-
sleep(0.5)
|
|
62
|
-
puts " Port #{port} freed"
|
|
44
|
+
# NOTHING / container: nothing to reclaim -- let the real bind decide.
|
|
63
45
|
end
|
|
64
46
|
|
|
65
47
|
def start
|
|
@@ -115,6 +97,10 @@ module Tina4
|
|
|
115
97
|
AccessLog: []
|
|
116
98
|
)
|
|
117
99
|
|
|
100
|
+
# Record THIS process as the Tina4 dev server on the main port, so a later
|
|
101
|
+
# `tina4 serve` can identify it as reclaimable (TAKEOVER-DEC-01).
|
|
102
|
+
Tina4::PortTakeover.write_pidfile(@port)
|
|
103
|
+
|
|
118
104
|
# Setup graceful shutdown with WEBrick server reference
|
|
119
105
|
Tina4::Shutdown.setup(server: @server)
|
|
120
106
|
|
|
@@ -327,6 +313,8 @@ module Tina4
|
|
|
327
313
|
@ai_server&.shutdown
|
|
328
314
|
@ai_thread&.join(5)
|
|
329
315
|
@server&.shutdown
|
|
316
|
+
# Drop our identity marker so a later takeover does not match a dead PID.
|
|
317
|
+
Tina4::PortTakeover.remove_pidfile(@port)
|
|
330
318
|
end
|
|
331
319
|
|
|
332
320
|
# Dispatch a Rack-style env through the Tina4 app and return [status, headers, body].
|