tina4ruby 3.13.93 → 3.13.96
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 +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +35 -43
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +458 -48
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dev_mailbox.rb +5 -1
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +25 -3
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/frond.rb +102 -10
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +317 -82
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +243 -29
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +301 -38
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/env.rb
CHANGED
|
@@ -92,32 +92,38 @@ module Tina4
|
|
|
92
92
|
"TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]"
|
|
93
93
|
}.freeze
|
|
94
94
|
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
# The ONE env truthiness table. Every env boolean in every Tina4 framework
|
|
96
|
+
# answers from this set (case-insensitive after strip):
|
|
97
|
+
#
|
|
98
|
+
# truthy: "true", "1", "yes", "on" falsy: everything else
|
|
99
|
+
#
|
|
100
|
+
# BREAKING (parity): "y", "t", "n" and "f" were accepted here and NOWHERE
|
|
101
|
+
# ELSE — not by Ruby's own Log/Mcp checks, and not by Python, PHP or Node.
|
|
102
|
+
# So TINA4_LOG_FUNC=y switched function logging ON while TINA4_DEBUG=y left
|
|
103
|
+
# debug OFF, in the same process, from the same .env. Single letters are
|
|
104
|
+
# dropped rather than spread: systemd's boolean set is 1/yes/true/on with no
|
|
105
|
+
# letters, and YAML 1.2 removed y/n precisely because a bare letter reads as
|
|
106
|
+
# a value (the Norway problem). Use "true"/"false".
|
|
107
|
+
# There is deliberately NO falsy table. Falsy is "not in TRUTHY", so there
|
|
108
|
+
# is exactly one list to keep correct. A second table is a second thing
|
|
109
|
+
# that can drift, and it is what let `bool` and `is_truthy` disagree.
|
|
110
|
+
TRUTHY = %w[true 1 yes on].freeze
|
|
102
111
|
|
|
103
112
|
# Check if a value is truthy for env boolean checks.
|
|
104
113
|
#
|
|
105
114
|
# Accepts: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "On", "ON".
|
|
106
115
|
# Everything else is falsy (including empty string, nil, not set).
|
|
107
116
|
def self.is_truthy(val)
|
|
108
|
-
|
|
117
|
+
TRUTHY.include?(val.to_s.strip.downcase)
|
|
109
118
|
end
|
|
110
119
|
|
|
111
|
-
# Read an env var and coerce to Boolean. Returns +default+ when the
|
|
112
|
-
# var is
|
|
113
|
-
#
|
|
120
|
+
# Read an env var and coerce to Boolean. Returns +default+ only when the
|
|
121
|
+
# var is UNSET — a value that IS set is answered by the one truthiness
|
|
122
|
+
# table, never quietly replaced by the default. Never raises.
|
|
114
123
|
def self.bool(name, default: false)
|
|
115
124
|
raw = ENV[name.to_s]
|
|
116
125
|
return default if raw.nil?
|
|
117
|
-
|
|
118
|
-
return true if TRUTHY.include?(token)
|
|
119
|
-
return false if FALSY.include?(token) || token.empty?
|
|
120
|
-
default
|
|
126
|
+
is_truthy(raw)
|
|
121
127
|
end
|
|
122
128
|
|
|
123
129
|
# Read an env var and coerce to Integer. Logs a warning via Tina4::Log
|
|
@@ -174,8 +180,11 @@ module Tina4
|
|
|
174
180
|
# gitignored .env.local (e.g. a stale auto-generated dev secret) must
|
|
175
181
|
# not override an explicitly-set real TINA4_SECRET. The ensure-dev-secret
|
|
176
182
|
# bootstrap runs AFTER this (only mints a secret if still unset in dev).
|
|
177
|
-
load_local_env(root_dir)
|
|
178
|
-
|
|
183
|
+
local = load_local_env(root_dir)
|
|
184
|
+
# .env.local WINS on a duplicate key, so it merges last. Both hashes
|
|
185
|
+
# already report the value in effect, so a real env var that beat both
|
|
186
|
+
# is what a caller reads back either way.
|
|
187
|
+
parse_env_file(env_file).merge(local)
|
|
179
188
|
end
|
|
180
189
|
|
|
181
190
|
# Load .env.local with first-wins semantics (override=false). A real
|
|
@@ -184,7 +193,8 @@ module Tina4
|
|
|
184
193
|
# > .env). No-op when the file is absent (common for fresh checkouts).
|
|
185
194
|
def load_local_env(root_dir = Dir.pwd)
|
|
186
195
|
local_file = File.join(root_dir, ".env.local")
|
|
187
|
-
|
|
196
|
+
return {} unless File.exist?(local_file)
|
|
197
|
+
parse_env_file(local_file)
|
|
188
198
|
end
|
|
189
199
|
|
|
190
200
|
# Get an env var value, with optional default
|
|
@@ -203,11 +213,28 @@ module Tina4
|
|
|
203
213
|
end
|
|
204
214
|
|
|
205
215
|
# Raise if any of the given keys are missing from ENV
|
|
206
|
-
|
|
207
|
-
|
|
216
|
+
# Validate that required env vars exist, and return them.
|
|
217
|
+
#
|
|
218
|
+
# RENAMED from require_env! on 2026-07-31. The bang was Ruby-idiomatic for
|
|
219
|
+
# "raises", but the concept is named require_env in the other three, and
|
|
220
|
+
# the surface-table rule is one name per concept with idiomatic CASING
|
|
221
|
+
# only. An alias would paper over the mismatch instead of fixing it, so
|
|
222
|
+
# the primary is renamed. Breaking for anyone calling require_env!.
|
|
223
|
+
#
|
|
224
|
+
# Returns a hash of every requested key to its value, matching Python,
|
|
225
|
+
# PHP and Node - it used to return nothing, so a caller could validate but
|
|
226
|
+
# not read in one step.
|
|
227
|
+
#
|
|
228
|
+
# Reports every missing name in one raise rather than the first: an
|
|
229
|
+
# operator fixing a deployment wants the whole list, not one name per
|
|
230
|
+
# restart.
|
|
231
|
+
def require_env(*keys)
|
|
232
|
+
names = keys.flatten.map(&:to_s)
|
|
233
|
+
missing = names.reject { |k| ENV.key?(k) }
|
|
208
234
|
unless missing.empty?
|
|
209
|
-
raise KeyError, "Missing required
|
|
235
|
+
raise KeyError, "Missing required environment variables: #{missing.join(', ')}"
|
|
210
236
|
end
|
|
237
|
+
names.to_h { |k| [k, ENV[k]] }
|
|
211
238
|
end
|
|
212
239
|
|
|
213
240
|
# Reset: clear all env vars that were loaded (restore to process defaults)
|
|
@@ -250,23 +277,138 @@ module Tina4
|
|
|
250
277
|
# override=true: `ENV[key] = value` — unconditional set. NOT used by the
|
|
251
278
|
# boot load sequence; kept only as an explicit opt-in for callers that
|
|
252
279
|
# genuinely need to force values.
|
|
280
|
+
# Returns a hash of the keys this file declared, mapped to the value that
|
|
281
|
+
# WON - not the value the file declared. The two differ exactly when the
|
|
282
|
+
# real environment beat the file, which is the case an operator most needs
|
|
283
|
+
# to see: reporting the file's value there would return "from_local" while
|
|
284
|
+
# the process is actually running on the real env var. A map that
|
|
285
|
+
# disagrees with ENV is worse than no map, because it looks authoritative.
|
|
253
286
|
def parse_env_file(path, override: false)
|
|
254
|
-
|
|
255
|
-
File.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
287
|
+
effective = {}
|
|
288
|
+
return effective unless File.exist?(path)
|
|
289
|
+
warned_refs = {}
|
|
290
|
+
File.readlines(path).each_with_index do |raw, index|
|
|
291
|
+
key, value = parse_env_line(raw, path, index + 1, warned_refs)
|
|
292
|
+
next if key.nil?
|
|
293
|
+
if override
|
|
294
|
+
ENV[key] = value
|
|
295
|
+
else
|
|
296
|
+
ENV[key] ||= value
|
|
297
|
+
end
|
|
298
|
+
@loaded_keys ||= []
|
|
299
|
+
@loaded_keys << key
|
|
300
|
+
effective[key] = ENV[key]
|
|
301
|
+
end
|
|
302
|
+
effective
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Parse ONE dotenv line into [key, value], or [nil, nil] for a line that
|
|
306
|
+
# sets nothing (blank, comment, or malformed).
|
|
307
|
+
#
|
|
308
|
+
# The rules are the cross-framework behaviour table (feature 1 of the
|
|
309
|
+
# feature audit): identical in Python, PHP, Ruby and Node, driven off one
|
|
310
|
+
# committed fixture. Ruby used to differ on two of them, silently.
|
|
311
|
+
def parse_env_line(raw, path, line_no, warned_refs)
|
|
312
|
+
line = raw.strip
|
|
313
|
+
return [nil, nil] if line.empty? || line.start_with?("#")
|
|
314
|
+
|
|
315
|
+
# Rule 2: a shell-style `export FOO=bar` is valid input, not an error.
|
|
316
|
+
# Ruby used to fall straight through this line, leaving the variable
|
|
317
|
+
# UNSET with no warning -- so a .env copied out of a shell profile lost
|
|
318
|
+
# keys, and the failure surfaced somewhere unrelated (a blank
|
|
319
|
+
# TINA4_SECRET, a missing database URL).
|
|
320
|
+
line = line.sub(/\Aexport\s+/, "")
|
|
321
|
+
|
|
322
|
+
# Rule 3: split on the FIRST `=` only. A line with no `=` is skipped
|
|
323
|
+
# with a warning naming the line -- never in silence.
|
|
324
|
+
eq = line.index("=")
|
|
325
|
+
if eq.nil?
|
|
326
|
+
warn_env("#{path}:#{line_no}: no '=' in \"#{line}\", line skipped")
|
|
327
|
+
return [nil, nil]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
key = line[0...eq].strip
|
|
331
|
+
# Rule 4: reject a key that is not a valid identifier, by name and line.
|
|
332
|
+
unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
333
|
+
warn_env("#{path}:#{line_no}: invalid key #{key.inspect}, line skipped")
|
|
334
|
+
return [nil, nil]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
[key, parse_env_value(line[(eq + 1)..].to_s.strip, path, line_no, warned_refs)]
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Rule 5 + 6: quoting decides escapes AND interpolation, in that order.
|
|
341
|
+
def parse_env_value(value, path, line_no, warned_refs)
|
|
342
|
+
# A QUOTED value ends at its CLOSING QUOTE, and anything after it is a
|
|
343
|
+
# comment. Testing the LAST character instead was wrong: a trailing
|
|
344
|
+
# comment makes the last character non-quote, so `PW="s3cret" # note`
|
|
345
|
+
# fell through to the unquoted branch below, which strips only the ` #`
|
|
346
|
+
# and left the QUOTE CHARACTERS in the value -- a credential handed to a
|
|
347
|
+
# driver as "\"s3cret\"". PHP already scanned for the terminator; this is
|
|
348
|
+
# that mechanism, and the scan SKIPS a quote preceded by a backslash so
|
|
349
|
+
# an escaped \" cannot end the value early.
|
|
350
|
+
if !value.empty? && (value.start_with?("'") || value.start_with?('"'))
|
|
351
|
+
quote = value[0]
|
|
352
|
+
i = 1
|
|
353
|
+
while i < value.length
|
|
354
|
+
if value[i] == "\\" && quote == '"' && i + 1 < value.length
|
|
355
|
+
i += 2
|
|
356
|
+
next
|
|
265
357
|
end
|
|
266
|
-
|
|
267
|
-
|
|
358
|
+
break if value[i] == quote
|
|
359
|
+
i += 1
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
if i < value.length
|
|
363
|
+
inner = value[1...i]
|
|
364
|
+
# Single quotes are verbatim: no escapes, no interpolation. Shell
|
|
365
|
+
# semantics, and the documented way to keep a literal ${...}.
|
|
366
|
+
return inner if quote == "'"
|
|
367
|
+
|
|
368
|
+
inner = inner.gsub("\\n", "\n")
|
|
369
|
+
.gsub("\\r", "\r")
|
|
370
|
+
.gsub("\\t", "\t")
|
|
371
|
+
.gsub('\\"', '"')
|
|
372
|
+
.gsub("\\\\", "\\")
|
|
373
|
+
return interpolate_env(inner, path, line_no, warned_refs)
|
|
268
374
|
end
|
|
269
375
|
end
|
|
376
|
+
|
|
377
|
+
# Rule 5: an unquoted value is truncated at the first SPACE-HASH, then
|
|
378
|
+
# trimmed. Ruby used to keep the whole line, so `FOO=value # note`
|
|
379
|
+
# became "value # note" -- a wrong value rather than an absent one,
|
|
380
|
+
# which is the harder kind to notice.
|
|
381
|
+
hash = value.index(" #")
|
|
382
|
+
value = value[0...hash] if hash
|
|
383
|
+
interpolate_env(value.rstrip, path, line_no, warned_refs)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Rule 6: expand ${VAR} against already-loaded keys plus the real
|
|
387
|
+
# environment. An unresolved name is left LITERAL and warned about once
|
|
388
|
+
# per name, so a typo is visible without breaking the load.
|
|
389
|
+
def interpolate_env(value, path, line_no, warned_refs)
|
|
390
|
+
value.gsub(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) do
|
|
391
|
+
name = Regexp.last_match(1)
|
|
392
|
+
if ENV.key?(name)
|
|
393
|
+
ENV[name]
|
|
394
|
+
else
|
|
395
|
+
unless warned_refs[name]
|
|
396
|
+
warned_refs[name] = true
|
|
397
|
+
warn_env("#{path}:#{line_no}: ${#{name}} is not set, left as-is")
|
|
398
|
+
end
|
|
399
|
+
"${#{name}}"
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# One place to emit a parse warning. Routed through Tina4::Log when it is
|
|
405
|
+
# loaded (env.rb is required before the logger during boot), else $stderr.
|
|
406
|
+
def warn_env(message)
|
|
407
|
+
if defined?(Tina4::Log) && Tina4::Log.respond_to?(:warning)
|
|
408
|
+
Tina4::Log.warning(message)
|
|
409
|
+
else
|
|
410
|
+
$stderr.puts("[tina4] #{message}")
|
|
411
|
+
end
|
|
270
412
|
end
|
|
271
413
|
end
|
|
272
414
|
end
|
data/lib/tina4/field_types.rb
CHANGED
|
@@ -15,6 +15,18 @@ module Tina4
|
|
|
15
15
|
@primary_key_field
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
+
# EVERY primary-key field name, in declaration order.
|
|
19
|
+
#
|
|
20
|
+
# A key may span several columns. +primary_key_field+ returns only ONE and
|
|
21
|
+
# is kept for the auto-increment paths, which are single-column by
|
|
22
|
+
# definition. Anything that ADDRESSES a row must use this: keying on one
|
|
23
|
+
# column of a composite key matches every row sharing that value, which is
|
|
24
|
+
# the data-loss shape feature 4 removed from the raw write path below.
|
|
25
|
+
def primary_key_fields
|
|
26
|
+
keys = field_definitions.select { |_n, o| o[:primary_key] }.keys
|
|
27
|
+
keys.empty? ? [:id] : keys
|
|
28
|
+
end
|
|
29
|
+
|
|
18
30
|
def table_name(name = nil)
|
|
19
31
|
if name
|
|
20
32
|
@table_name = name
|
data/lib/tina4/frond.rb
CHANGED
|
@@ -163,6 +163,29 @@ module Tina4
|
|
|
163
163
|
elif else elseif endautoescape endblock endcache endfor endif endlive
|
|
164
164
|
endmacro endraw endset endspaceless
|
|
165
165
|
].freeze
|
|
166
|
+
|
|
167
|
+
# Author-written tags the sandbox allow-list governs. Mirrors Python's
|
|
168
|
+
# _GATEABLE_TAGS and PHP's GATEABLE_TAGS. A tag absent from this list is
|
|
169
|
+
# structural, not an author capability, and is never gated -- `block` and
|
|
170
|
+
# `extends` are template inheritance, and `raw` is consumed by the tokenizer.
|
|
171
|
+
# Both spellings of set ({% set x = 1 %} and {% set x %}...{% endset %})
|
|
172
|
+
# dispatch under "set", so one entry covers the pair.
|
|
173
|
+
GATEABLE_TAGS = %w[
|
|
174
|
+
autoescape cache for from if import include live macro set spaceless
|
|
175
|
+
].freeze
|
|
176
|
+
|
|
177
|
+
# Gateable tags that OWN A BODY, mapped to the terminator closing it. A
|
|
178
|
+
# denied tag has to consume its body -- see skip_block.
|
|
179
|
+
BLOCK_TAG_ENDS = {
|
|
180
|
+
"autoescape" => "endautoescape",
|
|
181
|
+
"cache" => "endcache",
|
|
182
|
+
"for" => "endfor",
|
|
183
|
+
"if" => "endif",
|
|
184
|
+
"live" => "endlive",
|
|
185
|
+
"macro" => "endmacro",
|
|
186
|
+
"set" => "endset",
|
|
187
|
+
"spaceless" => "endspaceless"
|
|
188
|
+
}.freeze
|
|
166
189
|
AUTOESCAPE_RE = /\Aautoescape\s+(false|true)/
|
|
167
190
|
STRIPTAGS_RE = /<[^>]+>/
|
|
168
191
|
THOUSANDS_RE = /(\d)(?=(\d{3})+(?!\d))/
|
|
@@ -444,6 +467,28 @@ module Tina4
|
|
|
444
467
|
self
|
|
445
468
|
end
|
|
446
469
|
|
|
470
|
+
# May this filter RUN under the current sandbox?
|
|
471
|
+
#
|
|
472
|
+
# The escaping decision has to ask this rather than read the filter name out
|
|
473
|
+
# of the source: a denied `raw` that still marked its value safe made the
|
|
474
|
+
# allow-list entry governing XSS escaping inert.
|
|
475
|
+
def filter_permitted?(name)
|
|
476
|
+
return true unless @sandbox && @allowed_filters
|
|
477
|
+
|
|
478
|
+
@allowed_filters.include?(name.to_s)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# May this tag run under the current sandbox?
|
|
482
|
+
#
|
|
483
|
+
# One gate for every tag, so the allow-list governs the whole tag vocabulary
|
|
484
|
+
# instead of whichever names somebody remembered to check individually.
|
|
485
|
+
def tag_permitted?(tag)
|
|
486
|
+
return true unless @sandbox && @allowed_tags
|
|
487
|
+
return true unless GATEABLE_TAGS.include?(tag)
|
|
488
|
+
|
|
489
|
+
@allowed_tags.include?(tag)
|
|
490
|
+
end
|
|
491
|
+
|
|
447
492
|
# Utility: HTML escape
|
|
448
493
|
def self.escape_html(str)
|
|
449
494
|
str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
|
|
@@ -692,6 +737,43 @@ module Tina4
|
|
|
692
737
|
# Token renderer
|
|
693
738
|
# -----------------------------------------------------------------------
|
|
694
739
|
|
|
740
|
+
# Consume a denied tag WITHOUT running it, returning the index past its body.
|
|
741
|
+
#
|
|
742
|
+
# Ruby walks a token stream, so a denied tag cannot simply be dropped the way
|
|
743
|
+
# an AST node can. Advancing by one token would leave the body's tokens to
|
|
744
|
+
# render at the TOP level, leaking exactly the content the sandbox denied.
|
|
745
|
+
# Running the handler and discarding its output is no better: handle_set binds
|
|
746
|
+
# its variable before it returns. Consuming the block gives all three
|
|
747
|
+
# properties at once -- no output, no side effects, correct index.
|
|
748
|
+
def skip_block(tokens, i, tag, content)
|
|
749
|
+
terminator = BLOCK_TAG_ENDS[tag]
|
|
750
|
+
# {% set x = 1 %} is an assignment and owns no body; {% set x %}...{% endset %}
|
|
751
|
+
# captures one. Same exact-"=" test the dispatch uses.
|
|
752
|
+
terminator = nil if tag == "set" && content.include?("=")
|
|
753
|
+
return i + 1 if terminator.nil?
|
|
754
|
+
|
|
755
|
+
depth = 1
|
|
756
|
+
j = i + 1
|
|
757
|
+
while j < tokens.length
|
|
758
|
+
if tokens[j][0] == BLOCK
|
|
759
|
+
inner = strip_tag(tokens[j][1])[0]
|
|
760
|
+
case inner.split[0]
|
|
761
|
+
when tag
|
|
762
|
+
# A nested assignment-form set opens no body, so it must not nest.
|
|
763
|
+
depth += 1 unless tag == "set" && inner.include?("=")
|
|
764
|
+
when terminator
|
|
765
|
+
depth -= 1
|
|
766
|
+
return j + 1 if depth.zero?
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
j += 1
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Unterminated block: consume to the end. Nothing renders, which is the
|
|
773
|
+
# safe answer -- the alternative leaks the body of a denied tag.
|
|
774
|
+
tokens.length
|
|
775
|
+
end
|
|
776
|
+
|
|
695
777
|
def render_tokens(tokens, context)
|
|
696
778
|
output = []
|
|
697
779
|
i = 0
|
|
@@ -725,6 +807,18 @@ module Tina4
|
|
|
725
807
|
|
|
726
808
|
tag = content.split[0] || ""
|
|
727
809
|
|
|
810
|
+
# ONE sandbox gate for the whole tag vocabulary. Previously only
|
|
811
|
+
# `include` was checked, so every other tag ignored the allow-list --
|
|
812
|
+
# {% autoescape false %} could switch escaping off from inside a
|
|
813
|
+
# sandbox whose tags were restricted to something else entirely.
|
|
814
|
+
unless tag_permitted?(tag)
|
|
815
|
+
i = skip_block(tokens, i, tag, content)
|
|
816
|
+
if strip_a && i < tokens.length && tokens[i][0] == TEXT
|
|
817
|
+
tokens[i] = [TEXT, tokens[i][1].lstrip]
|
|
818
|
+
end
|
|
819
|
+
next
|
|
820
|
+
end
|
|
821
|
+
|
|
728
822
|
case tag
|
|
729
823
|
when "if"
|
|
730
824
|
result, i = handle_if(tokens, i, context)
|
|
@@ -745,12 +839,8 @@ module Tina4
|
|
|
745
839
|
i = handle_set_block(tokens, i, context)
|
|
746
840
|
end
|
|
747
841
|
when "include"
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
else
|
|
751
|
-
output << handle_include(content, context)
|
|
752
|
-
i += 1
|
|
753
|
-
end
|
|
842
|
+
output << handle_include(content, context)
|
|
843
|
+
i += 1
|
|
754
844
|
when "macro"
|
|
755
845
|
i = handle_macro(tokens, i, context)
|
|
756
846
|
when "import"
|
|
@@ -913,14 +1003,16 @@ module Tina4
|
|
|
913
1003
|
is_safe = false
|
|
914
1004
|
filters.each do |fname, args|
|
|
915
1005
|
if fname == "raw" || fname == "safe"
|
|
916
|
-
|
|
1006
|
+
# Decide from what was permitted to RUN, not from what the source
|
|
1007
|
+
# asked for. Marking the value safe here regardless meant a DENIED
|
|
1008
|
+
# raw produced byte-identical output to an allowed one -- the
|
|
1009
|
+
# allow-list entry that governs XSS escaping did nothing at all.
|
|
1010
|
+
is_safe = true if filter_permitted?(fname)
|
|
917
1011
|
next
|
|
918
1012
|
end
|
|
919
1013
|
|
|
920
1014
|
# Sandbox: check filter access
|
|
921
|
-
if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
|
|
922
|
-
next
|
|
923
|
-
end
|
|
1015
|
+
next if @sandbox && @allowed_filters && !@allowed_filters.include?(fname)
|
|
924
1016
|
|
|
925
1017
|
# Filter + property-access chain: `first.groupSummary` — apply
|
|
926
1018
|
# the filter, then traverse the path on the result. Done BEFORE
|
data/lib/tina4/health.rb
CHANGED
|
@@ -6,29 +6,45 @@ module Tina4
|
|
|
6
6
|
module Health
|
|
7
7
|
START_TIME = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
8
8
|
|
|
9
|
+
# The path used when TINA4_HEALTH_PATH is unset. Identical in all four
|
|
10
|
+
# frameworks; the under-prefix keeps it clear of an application route that
|
|
11
|
+
# happens to be called /health.
|
|
12
|
+
DEFAULT_PATH = "/__health"
|
|
13
|
+
|
|
14
|
+
# Always registered as well, whatever the configured path, so a probe
|
|
15
|
+
# written before TINA4_HEALTH_PATH existed keeps working. Setting the env
|
|
16
|
+
# var adds a path, it never takes one away.
|
|
17
|
+
LEGACY_PATH = "/health"
|
|
18
|
+
|
|
9
19
|
class << self
|
|
10
20
|
# Return the configured health endpoint path.
|
|
11
|
-
# TINA4_HEALTH_PATH overrides the default
|
|
21
|
+
# TINA4_HEALTH_PATH overrides the default, kept consistent across all 4 frameworks.
|
|
12
22
|
def path
|
|
13
23
|
configured = ENV["TINA4_HEALTH_PATH"]
|
|
14
|
-
return
|
|
24
|
+
return DEFAULT_PATH if configured.nil? || configured.empty?
|
|
15
25
|
configured.start_with?("/") ? configured : "/#{configured}"
|
|
16
26
|
end
|
|
17
27
|
|
|
18
28
|
def register!
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
#
|
|
29
|
-
#
|
|
29
|
+
# Unconditional, and idempotent for free: Router.add REPLACES a repeated
|
|
30
|
+
# (method, path) in place rather than appending, so calling this twice
|
|
31
|
+
# cannot duplicate and a Router.clear! is self-healing on the next call.
|
|
32
|
+
#
|
|
33
|
+
# There used to be a guard here -- `return if find_route("/health",
|
|
34
|
+
# "GET")` -- whose arguments were swapped: the signature is
|
|
35
|
+
# find_route(METHOD, PATH). It usually matched nothing and did nothing,
|
|
36
|
+
# but an ANY route matches any path, so an app with an ordinary
|
|
37
|
+
# catch-all made the guard fire and health was never registered at all.
|
|
38
|
+
# A probe then received the application's catch-all response instead of
|
|
39
|
+
# the health payload. Correcting the order would not have fixed it,
|
|
40
|
+
# because find_route("GET", path) matches that catch-all too; asking the
|
|
41
|
+
# router "does something answer here" can never distinguish "health is
|
|
42
|
+
# registered" from "something else happens to match".
|
|
43
|
+
#
|
|
44
|
+
# The legacy "/health" is always registered alongside the configured
|
|
45
|
+
# path so an existing probe keeps working when TINA4_HEALTH_PATH is set.
|
|
30
46
|
Tina4::Router.add("GET", path, method(:handle))
|
|
31
|
-
Tina4::Router.add("GET",
|
|
47
|
+
Tina4::Router.add("GET", LEGACY_PATH, method(:handle)) unless path == LEGACY_PATH
|
|
32
48
|
end
|
|
33
49
|
|
|
34
50
|
|
data/lib/tina4/job.rb
CHANGED
|
@@ -86,14 +86,24 @@ module Tina4
|
|
|
86
86
|
# queue.dead_letters returns it. No manual retry_failed is required.
|
|
87
87
|
def fail(reason = "")
|
|
88
88
|
@error = reason
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
else
|
|
93
|
-
# No backend reference — degrade to in-memory bookkeeping only.
|
|
89
|
+
# A job with no queue is a detached value object (built by hand, or read
|
|
90
|
+
# out of a serialised payload) — in-memory bookkeeping is all there is.
|
|
91
|
+
unless @queue
|
|
94
92
|
@attempts += 1
|
|
95
93
|
@status = :failed
|
|
94
|
+
return self
|
|
96
95
|
end
|
|
96
|
+
|
|
97
|
+
# NO respond_to?(:fail) GUARD. It used to fall through to the same
|
|
98
|
+
# in-memory branch when the backend had no fail method, which is exactly
|
|
99
|
+
# what rabbitmq and kafka were: job.fail() bumped a counter in this
|
|
100
|
+
# process and the BROKER WAS NEVER TOLD. No dead letter was written, the
|
|
101
|
+
# delivery stayed unacked, and failed()/dead_letters() both reported
|
|
102
|
+
# nothing — the job was lost as far as the application could see. A
|
|
103
|
+
# backend that cannot record a failure must say so (invariant 6), and a
|
|
104
|
+
# backend missing the method must fail loudly here rather than pretend.
|
|
105
|
+
@queue.backend.fail(self, reason)
|
|
106
|
+
@status = @attempts >= @queue.max_retries ? :dead : :pending
|
|
97
107
|
self
|
|
98
108
|
end
|
|
99
109
|
|