tina4ruby 3.13.149 → 3.13.151

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08653f37ab3b3baf01b6103f97ec3893f910fbaf5a50b4b34a5521c872e9a225'
4
- data.tar.gz: 2df60c055ca63a7957f79da033add45a12e3418f1e5890a3a986c254a8f4f6ec
3
+ metadata.gz: 2628729439684b6bb6c01c45a812c8e2e811bc8ad3da8848d4213311d3ecffa2
4
+ data.tar.gz: 78842ef5d13e8fd555baec8b1f25321390060d2be22fab7948de599a8c204fa4
5
5
  SHA512:
6
- metadata.gz: dafb902ac5f0b385862acf78fbc1ded074233082a98d7dac462f095fb82fc4043a0ff5369eb9a7dbcd0c4662daab0c00b34a1c1647bcb6f29332a7d4882bbc30
7
- data.tar.gz: 2408c4a0f724372a555efb9a39d3dd30b4334476109e257e4eb301d38db666f27f932bcd6b278c0087e3080f109042924236b550c6fafb6d8763b0db9e9c505d
6
+ metadata.gz: c036143a0e8fc8206c6091e93d0a5f9d5e9af59f721afb5e33ebd48a86ba4a124b39fa8540ed8a0403e3474941484ad767152ad91775997289685e0c4c2cee7d
7
+ data.tar.gz: b61e7f1708946b5a9b3528a7b9cf1244bb0c9c064bd84661c76075b8f56c9ccf63f17f2b5733ee7514a0412a6c3b9387dab64ad01635c5c65cfccbeed9683072
data/CHANGELOG.md CHANGED
@@ -6,6 +6,32 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.151 - 2026-10-15
10
+
11
+ ### Security
12
+ - Cross-Origin-Opener-Policy joins the default security-header set (`TINA4_COOP`,
13
+ default `same-origin-allow-popups`). It pairs with a COEP header to unlock
14
+ `SharedArrayBuffer` and WASM threads in the browser.
15
+ - S3 storage is a zero-dependency SigV4 client (no aws-sdk-s3). It now fails loud on
16
+ a missing credential, retries 429/5xx and dropped connections with capped backoff,
17
+ honours `Retry-After`, and keeps the endpoint path prefix.
18
+ - MongoDB keeps bound values out of the parsed SQL text.
19
+ - CRUD attributes are de-raw'd, the 302 redirect is guarded, CodeQL core findings
20
+ are cleared, and uploaded files are created owner-only (0600).
21
+
22
+ ### Docs
23
+ - The README built-in feature count is corrected (55 -> 140).
24
+
25
+ ## 3.13.150 — 2026-10-11
26
+
27
+ ### Dev admin
28
+ - The dashboard AI talks to the `tina4 ai serve` gateway on :11435, not a model
29
+ directly; `/__dev/split` serves the shared split-preview asset; the scaffold
30
+ panel is data-driven from the CLI generators; and the Code Landscape metrics
31
+ bubbles are free to spread (zoom-out + Fit frame the layout, was crammed into
32
+ the viewport). The dashboard metrics scan passes `--no-history` so it stops
33
+ self-reloading. Shipped in the shared SPA bundle.
34
+
9
35
  ## 3.13.149 — 2026-10-10
10
36
 
11
37
  ### CRUD
data/lib/tina4/auth.rb CHANGED
@@ -466,7 +466,7 @@ module Tina4
466
466
  # is now honoured here too (explicit > env > HS256).
467
467
  def authenticate_request(headers, secret: nil, algorithm: nil)
468
468
  auth_header = headers["HTTP_AUTHORIZATION"] || headers["Authorization"] || ""
469
- return nil unless auth_header =~ /\ABearer\s+(.+)\z/i
469
+ return nil unless auth_header =~ /\ABearer\s++(.+)\z/i
470
470
 
471
471
  token = Regexp.last_match(1)
472
472
 
@@ -518,7 +518,7 @@ module Tina4
518
518
  def bearer_auth
519
519
  lambda do |env|
520
520
  auth_header = env["HTTP_AUTHORIZATION"] || ""
521
- return false unless auth_header =~ /\ABearer\s+(.+)\z/i
521
+ return false unless auth_header =~ /\ABearer\s++(.+)\z/i
522
522
 
523
523
  token = Regexp.last_match(1)
524
524
 
@@ -664,7 +664,7 @@ module Tina4
664
664
  auth_header = env["HTTP_AUTHORIZATION"] || ""
665
665
  return true if auth_header.empty?
666
666
 
667
- if auth_header =~ /\ABearer\s+(.+)\z/i
667
+ if auth_header =~ /\ABearer\s++(.+)\z/i
668
668
  valid_token(Regexp.last_match(1))
669
669
  else
670
670
  false
data/lib/tina4/cli.rb CHANGED
@@ -457,7 +457,7 @@ module Tina4
457
457
  # per-instance to_snake_case when only the class name is known.
458
458
  def self.to_snake_case_static(name)
459
459
  s = name.to_s
460
- s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
460
+ s.gsub(/([A-Z])(?=[A-Z][a-z])/, '\1_')
461
461
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
462
462
  .downcase
463
463
  end
data/lib/tina4/crud.rb CHANGED
@@ -112,7 +112,7 @@ module Tina4
112
112
  verb = method.to_s.upcase
113
113
  Tina4::Template.render("crud/form.twig", {
114
114
  "wrap" => true,
115
- "form_id_attr" => "",
115
+ "form_id" => "",
116
116
  "action" => h(action),
117
117
  "form_method" => h(verb),
118
118
  "method_override" => (%w[PUT PATCH DELETE].include?(verb) ? verb : nil),
@@ -204,14 +204,14 @@ module Tina4
204
204
  "value" => "",
205
205
  "placeholder" => h("Enter #{label.downcase}"),
206
206
  "type" => "text",
207
- "required_attr" => "",
207
+ "required" => false,
208
208
  "input" => true
209
209
  }
210
210
  end
211
211
 
212
212
  Tina4::Template.render("crud/form.twig", {
213
213
  "wrap" => true,
214
- "form_id_attr" => " id=\"form-#{mode}\"",
214
+ "form_id" => "form-#{mode}",
215
215
  "action" => "",
216
216
  "form_method" => "POST",
217
217
  "method_override" => nil,
@@ -263,7 +263,7 @@ module Tina4
263
263
  "readonly" => !editable,
264
264
  "inline_script" => inline_script,
265
265
  "table_name" => h(table_name),
266
- "table_id_attr" => (table_id ? " id=\"#{h(table_id)}\"" : "")
266
+ "table_id" => (table_id ? table_id.to_s : "")
267
267
  }
268
268
  end
269
269
 
@@ -309,7 +309,7 @@ module Tina4
309
309
  name = field[:name].to_s
310
310
  label = field[:label] || name.capitalize
311
311
  value = field[:value]
312
- required = field[:required] ? " required" : ""
312
+ required = field[:required] ? true : false
313
313
 
314
314
  base = {
315
315
  "id" => h(name),
@@ -317,14 +317,14 @@ module Tina4
317
317
  "label" => h(label.to_s),
318
318
  "value" => h(value.to_s),
319
319
  "placeholder" => "",
320
- "required_attr" => required
320
+ "required" => required
321
321
  }
322
322
 
323
323
  case (field[:type] || :string).to_sym
324
324
  when :text
325
325
  base.merge("textarea" => true)
326
326
  when :boolean
327
- base.merge("checkbox" => true, "checked_attr" => (value ? " checked" : ""))
327
+ base.merge("checkbox" => true, "checked" => (value ? true : false))
328
328
  when :select
329
329
  base.merge("select" => true, "options_html" => build_options(field[:options], value))
330
330
  when :date
@@ -439,6 +439,8 @@ module Tina4
439
439
  serve_dashboard
440
440
  when ["GET", "/__dev/js/tina4-dev-admin.min.js"]
441
441
  serve_dev_js
442
+ when ["GET", "/__dev/split"], ["GET", "/__dev/split/"]
443
+ serve_split
442
444
  when ["GET", "/__dev/toolbar.css"]
443
445
  # tina4stack #115: the injected toolbar's stylesheet, served as an
444
446
  # external asset so the toolbar carries no inline style= and renders
@@ -934,6 +936,18 @@ module Tina4
934
936
  end
935
937
  end
936
938
 
939
+ # Detached split view — dashboard/editor LEFT, live app preview RIGHT.
940
+ # Served from the SHARED asset (the same tina4-dev-admin-split.html the
941
+ # python/php/node frameworks serve); a plain shell, no toolbar injected.
942
+ def serve_split
943
+ path = File.join(File.dirname(__FILE__), "public", "js", "tina4-dev-admin-split.html")
944
+ if File.file?(path)
945
+ [200, { "content-type" => "text/html; charset=utf-8" }, [File.read(path)]]
946
+ else
947
+ [404, { "content-type" => "text/plain" }, ["tina4-dev-admin-split.html not found"]]
948
+ end
949
+ end
950
+
937
951
  def status_payload
938
952
  db_table_count = 0
939
953
  begin
@@ -232,12 +232,26 @@ module Tina4
232
232
 
233
233
  # ── SQL-to-MongoDB translator ──────────────────────────────────────
234
234
 
235
+ # Fiber-local: the driver is shared across threads, the bound values are not.
236
+ BOUND_VALUES_KEY = :tina4_mongodb_bound_values
237
+
235
238
  def parse_sql(sql, params = [])
236
239
  sql_stripped = sql.strip
237
- upper = sql_stripped.upcase
238
240
 
239
241
  # Bind positional ? params
240
- bound_sql = bind_params(sql_stripped, params)
242
+ bound_sql, bound_values = bind_params(sql_stripped, params)
243
+
244
+ previous = Thread.current[BOUND_VALUES_KEY]
245
+ Thread.current[BOUND_VALUES_KEY] = bound_values
246
+ begin
247
+ parse_bound_sql(bound_sql)
248
+ ensure
249
+ Thread.current[BOUND_VALUES_KEY] = previous
250
+ end
251
+ end
252
+
253
+ def parse_bound_sql(bound_sql)
254
+ upper = bound_sql.upcase
241
255
 
242
256
  if upper.start_with?("SELECT")
243
257
  parse_select(bound_sql)
@@ -254,15 +268,34 @@ module Tina4
254
268
  end
255
269
  end
256
270
 
271
+ # Numbers, booleans and nil are written inline. Anything else is replaced
272
+ # by a quoted token with no quote, space, comma or keyword inside it, and the
273
+ # value is looked up again where literals are read (parse_value, LIKE).
274
+ # The parser splits the text on all of those, so a value pasted into it
275
+ # could otherwise change the statement it belongs to.
257
276
  def bind_params(sql, params)
258
- return sql if params.nil? || params.empty?
277
+ bound_values = {}
278
+ return [sql, bound_values] if params.nil? || params.empty?
259
279
 
260
280
  idx = -1
261
- sql.gsub("?") do
281
+ bound_sql = sql.gsub("?") do
262
282
  idx += 1
263
283
  v = params[idx]
264
- v.is_a?(String) ? "'#{v.gsub("'", "\\\\'")}'" : v.to_s
284
+ case v
285
+ when Integer, Float, true, false, nil
286
+ v.to_s
287
+ else
288
+ token = "'\0#{bound_values.size}\0'"
289
+ bound_values[token] = v.to_s
290
+ token
291
+ end
265
292
  end
293
+ [bound_sql, bound_values]
294
+ end
295
+
296
+ def bound_value(literal)
297
+ bound_values = Thread.current[BOUND_VALUES_KEY]
298
+ bound_values && bound_values[literal]
266
299
  end
267
300
 
268
301
  # ── SELECT parsing ─────────────────────────────────────────────────
@@ -296,7 +329,7 @@ module Tina4
296
329
  end
297
330
 
298
331
  def parse_projection(sql)
299
- m = sql.match(/^SELECT\s+(.*?)\s+FROM\b/im)
332
+ m = sql.match(/^SELECT\s++((?:(?!\s+FROM\b).)*+)\s+FROM\b/im)
300
333
  return {} unless m
301
334
 
302
335
  cols = m[1].strip
@@ -313,7 +346,7 @@ module Tina4
313
346
  end
314
347
 
315
348
  def parse_order_by(sql)
316
- m = sql.match(/\bORDER\s+BY\s+(.*?)(?:\s+LIMIT|\s+OFFSET|\s*$)/im)
349
+ m = sql.match(/\bORDER\s+BY\s++((?:(?!\s+LIMIT\b|\s+OFFSET\b|\s*$).)*+)(?:\s+LIMIT|\s+OFFSET|\s*$)/im)
317
350
  return {} unless m
318
351
 
319
352
  sort = {}
@@ -440,13 +473,13 @@ module Tina4
440
473
 
441
474
  # LIKE → $regex
442
475
  if (m = clause.match(/^(\w+)\s+LIKE\s+'(.+)'$/i))
443
- pattern = m[2].gsub("%", ".*").gsub("_", ".")
476
+ pattern = (bound_value("'#{m[2]}'") || m[2]).gsub("%", ".*").gsub("_", ".")
444
477
  return { m[1] => { "$regex" => pattern, "$options" => "i" } }
445
478
  end
446
479
 
447
480
  # NOT LIKE → $not $regex
448
481
  if (m = clause.match(/^(\w+)\s+NOT\s+LIKE\s+'(.+)'$/i))
449
- pattern = m[2].gsub("%", ".*").gsub("_", ".")
482
+ pattern = (bound_value("'#{m[2]}'") || m[2]).gsub("%", ".*").gsub("_", ".")
450
483
  return { m[1] => { "$not" => /#{pattern}/i } }
451
484
  end
452
485
 
@@ -496,7 +529,9 @@ module Tina4
496
529
 
497
530
  def parse_value(str)
498
531
  str = str.strip
499
- if str.start_with?("'") && str.end_with?("'")
532
+ if (value = bound_value(str))
533
+ value
534
+ elsif str.start_with?("'") && str.end_with?("'")
500
535
  str[1..-2]
501
536
  elsif str =~ /\A-?\d+\z/
502
537
  str.to_i
@@ -518,7 +553,7 @@ module Tina4
518
553
  def parse_insert(sql)
519
554
  result = { operation: :insert }
520
555
 
521
- m = sql.match(/INSERT\s+INTO\s+(\w+)\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)/im)
556
+ m = sql.match(/INSERT\s+INTO\s+(\w+)\s*+\(([^)]+)\)\s*+VALUES\s*+\(([^)]+)\)/im)
522
557
  unless m
523
558
  result[:collection] = :unknown
524
559
  result[:document] = {}
@@ -564,7 +599,7 @@ module Tina4
564
599
  def parse_update(sql)
565
600
  result = { operation: :update }
566
601
 
567
- m = sql.match(/UPDATE\s+(\w+)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$/im)
602
+ m = sql.match(/UPDATE\s+(\w+)\s+SET\s+((?:(?!\s+WHERE\b).)+)(?:\s+WHERE\s++(.+))?$/im)
568
603
  unless m
569
604
  raise ArgumentError, "MongodbDriver: cannot parse UPDATE statement: #{sql}"
570
605
  end
@@ -621,7 +656,7 @@ module Tina4
621
656
  def parse_delete(sql)
622
657
  result = { operation: :delete }
623
658
 
624
- m = sql.match(/DELETE\s+FROM\s+(\w+)(?:\s+WHERE\s+(.+))?$/im)
659
+ m = sql.match(/DELETE\s+FROM\s+(\w++)(?:\s++WHERE\s++(.+))?$/im)
625
660
  unless m
626
661
  raise ArgumentError, "MongodbDriver: cannot parse DELETE statement: #{sql}"
627
662
  end
@@ -222,7 +222,12 @@ module Tina4
222
222
 
223
223
  def parse_connection(str)
224
224
  # Format: mssql://user:pass@host:port/database
225
- match = str.match(%r{(?:mssql|sqlserver)://(?:(\w+):([^@]+)@)?([^:/]+)(?::(\d+))?/(.+)})
225
+ # Possessive quantifiers (++): every quantified class is bounded by a
226
+ # delimiter it cannot itself match (\w++ by ':', [^@]++ by '@', [^:/]++
227
+ # by ':'/'/' , \d++ by ')'), so possessive changes nothing but what the
228
+ # engine tries on failure -- it kills the backtracking CodeQL flags as
229
+ # polynomial-redos without altering a single successful parse.
230
+ match = str.match(%r{(?:mssql|sqlserver)://(?:(\w++):([^@]++)@)?([^:/]++)(?::(\d++))?/(.+)})
226
231
  if match
227
232
  { username: match[1], password: match[2], host: match[3],
228
233
  port: match[4]&.to_i, database: match[5] }
@@ -29,6 +29,15 @@ module Tina4
29
29
  # Rack's QueryParser param_depth_limit, kept for the same reason.
30
30
  PARAM_DEPTH_LIMIT = 32
31
31
 
32
+ # A lenient form with more fields than this is not nested at all, so a
33
+ # crafted body cannot turn each small field into a deep structure.
34
+ # Rack's QueryParser params_limit.
35
+ PARAMS_LIMIT = 4096
36
+
37
+ # A lenient field name longer than this (in bytes) keeps its flat key, so
38
+ # nesting never copies a huge name once per level.
39
+ NAME_LIMIT = 1024
40
+
32
41
  # A field name that is both a value and a container (`a=1` then `a[b]=2`)
33
42
  # or nests past PARAM_DEPTH_LIMIT. Rack raised here too.
34
43
  class ParameterError < StandardError; end
@@ -64,7 +73,6 @@ module Tina4
64
73
  # as :error rather than raised, so one bad field name cannot lose the files.
65
74
  def parse_multipart(body, content_type)
66
75
  token = boundary(content_type)
67
- fields = NestedParams.new
68
76
  files = {}
69
77
  return { fields: {}, files: files, error: nil } if token.nil? || body.nil? || body.empty?
70
78
 
@@ -89,12 +97,38 @@ module Tina4
89
97
  # the files were collected independently and survive it.
90
98
  error = nil
91
99
  begin
92
- pairs.each { |name, value| normalize_params(fields, name, value, 0) }
100
+ fields = nest(pairs)
93
101
  rescue ParameterError => e
94
102
  error = e
95
- fields = NestedParams.new
103
+ fields = {}
104
+ end
105
+ { fields: fields, files: files, error: error }
106
+ end
107
+
108
+ # Nest [name, value] pairs the way Rack nests a form: `tags[]` collects an
109
+ # array, `user[name]` builds a hash, a repeated plain name keeps its last
110
+ # value. Shared by multipart and urlencoded bodies, so one form arrives in
111
+ # the same shape whichever way it was sent. Strict (multipart) raises
112
+ # ParameterError on a name conflict or over-deep nesting. Lenient
113
+ # (urlencoded) keeps such a field - or one longer than NAME_LIMIT, or not
114
+ # valid UTF-8 - under its flat key and nests the rest, and nests nothing
115
+ # past PARAMS_LIMIT fields.
116
+ def nest(pairs, lenient: false)
117
+ return pairs.to_h if lenient && pairs.size > PARAMS_LIMIT
118
+
119
+ params = NestedParams.new
120
+ pairs.each do |name, value|
121
+ next params[name] = value if lenient && (name.bytesize > NAME_LIMIT || name.b.count("[") >= PARAM_DEPTH_LIMIT)
122
+
123
+ begin
124
+ normalize_params(params, name, value, 0)
125
+ rescue ParameterError, ArgumentError # ArgumentError: a name that is not valid UTF-8
126
+ raise unless lenient
127
+
128
+ params[name] = value
129
+ end
96
130
  end
97
- { fields: to_plain_hash(fields), files: files, error: error }
131
+ to_plain_hash(params)
98
132
  end
99
133
 
100
134
  # Yield [headers, content] for every part. headers keys are lowercased.
data/lib/tina4/frond.rb CHANGED
@@ -101,9 +101,10 @@ module Tina4
101
101
  FLOAT_RE = /\A-?\d+\.\d+\z/
102
102
  ARRAY_LIT_RE = /\A\[(.+)\]\z/m
103
103
  HASH_LIT_RE = /\A\{(.+)\}\z/m
104
- HASH_PAIR_RE = /\A\s*(?:["']([^"']+)["']|(\w+))\s*:\s*(.+)\z/
105
- RANGE_LIT_RE = /\A(\d+)\.\.(\d+)\z/
106
- ARITHMETIC_OPS = [" + ", " - ", " * ", " // ", " / ", " % ", " ** "].freeze
104
+ HASH_PAIR_RE = /\A\s*(?:["']([^"']+)["']|(\w+)|\((.+?)\))\s*:\s*(.+)\z/m
105
+ # Arithmetic precedence levels, loosest first. `**` is right-associative;
106
+ # every other level is left-associative.
107
+ ARITHMETIC_LEVELS = [%w[+ -], %w[* // / %], %w[**]].freeze
107
108
  # Operators Twig binds LOOSER than the filter pipe `|`. When one of these
108
109
  # sits at the top level alongside a pipe (e.g. `amount|number_format(2) ~
109
110
  # ' EUR'`), the whole expression must go through eval_expr (which resolves
@@ -1714,7 +1715,8 @@ module Tina4
1714
1715
  ternary: [:eval_ternary, :not_ternary, true],
1715
1716
  inline_if: [:eval_inline_if, :not_inline_if, true],
1716
1717
  coalesce: [:eval_null_coalesce, :not_coalesce, true],
1717
- collection: [:eval_collection_literal, :not_collection, true]
1718
+ collection: [:eval_collection_literal, :not_collection, true],
1719
+ range: [:eval_range, :not_range, true]
1718
1720
  }.freeze
1719
1721
 
1720
1722
  # ── Expression evaluation: a cascade, with the branch memoised ──
@@ -1772,6 +1774,9 @@ module Tina4
1772
1774
  return eval_comparison(expr, context)
1773
1775
  end
1774
1776
 
1777
+ result = eval_range(expr, context)
1778
+ return remember_form(expr, :range, result) unless result == :not_range
1779
+
1775
1780
  result = eval_arithmetic(expr, context)
1776
1781
  return remember_form(expr, :arithmetic, result) unless result == :not_arithmetic
1777
1782
 
@@ -1879,16 +1884,15 @@ module Tina4
1879
1884
  inner = Regexp.last_match(1)
1880
1885
  hash = {}
1881
1886
  split_args_toplevel(inner).each do |pair|
1882
- if pair =~ HASH_PAIR_RE
1883
- key = Regexp.last_match(1) || Regexp.last_match(2)
1884
- hash[key] = eval_expr(Regexp.last_match(3).strip, context)
1885
- end
1887
+ next unless pair =~ HASH_PAIR_RE
1888
+
1889
+ quoted, bare, evaluated, value = Regexp.last_match.captures
1890
+ # A bare key is a string; a parenthesised key is evaluated: {(name): 1}.
1891
+ key = quoted || bare || eval_expr(evaluated, context).to_s
1892
+ hash[key] = eval_expr(value.strip, context)
1886
1893
  end
1887
1894
  return hash
1888
1895
  end
1889
- if expr =~ RANGE_LIT_RE
1890
- return (Regexp.last_match(1).to_i..Regexp.last_match(2).to_i).to_a
1891
- end
1892
1896
  :not_collection
1893
1897
  end
1894
1898
 
@@ -1959,14 +1963,112 @@ module Tina4
1959
1963
  # ── Arithmetic: +, -, *, //, /, %, ** ──
1960
1964
 
1961
1965
  def eval_arithmetic(expr, context)
1962
- ARITHMETIC_OPS.each do |op|
1963
- pos = find_outside_quotes(expr, op)
1964
- next unless pos && pos >= 0
1965
- l_val = eval_expr(expr[0...pos].strip, context)
1966
- r_val = eval_expr(expr[(pos + op.length)..].strip, context)
1967
- return apply_math(l_val, op.strip, r_val)
1966
+ split = split_arithmetic(expr)
1967
+ return :not_arithmetic unless split
1968
+
1969
+ op, pos = split
1970
+ l_val = eval_expr(expr[0...pos].strip, context)
1971
+ r_val = eval_expr(expr[(pos + op.length)..].strip, context)
1972
+ apply_math(l_val, op, r_val)
1973
+ end
1974
+
1975
+ # Pick the operator that binds loosest, so it is evaluated last. Within a
1976
+ # left-associative level the RIGHTMOST operator is the split point
1977
+ # (`10 - 2 - 3` is `(10 - 2) - 3`); `**` splits at the leftmost.
1978
+ def split_arithmetic(expr)
1979
+ operators = top_level_arithmetic_operators(expr)
1980
+ ARITHMETIC_LEVELS.each do |level|
1981
+ matches = operators.select { |_pos, op| level.include?(op) }
1982
+ next if matches.empty?
1983
+
1984
+ pos, op = level == %w[**] ? matches.first : matches.last
1985
+ return [op, pos]
1986
+ end
1987
+ nil
1988
+ end
1989
+
1990
+ # Every position in expr outside quotes and brackets.
1991
+ def top_level_positions(expr)
1992
+ positions = []
1993
+ in_quote = nil
1994
+ depth = 0
1995
+ expr.each_char.with_index do |ch, pos|
1996
+ if in_quote
1997
+ in_quote = nil if ch == in_quote
1998
+ elsif ch == '"' || ch == "'"
1999
+ in_quote = ch
2000
+ elsif "([{".include?(ch)
2001
+ depth += 1
2002
+ elsif ")]}".include?(ch)
2003
+ depth -= 1
2004
+ elsif depth.zero?
2005
+ positions << pos
2006
+ end
2007
+ end
2008
+ positions
2009
+ end
2010
+
2011
+ # True when the text before pos ends in an operand, so an operator there is
2012
+ # binary; the `-` in `n * -1` or `-7` is a sign.
2013
+ def follows_operand?(expr, pos)
2014
+ preceding = expr[0...pos].rstrip
2015
+ !preceding.empty? && preceding[-1].match?(/[\w)\]'"]/)
2016
+ end
2017
+
2018
+ # [position, operator] for every binary arithmetic operator outside quotes
2019
+ # and brackets (`**` and `//` are two characters).
2020
+ def top_level_arithmetic_operators(expr)
2021
+ operators = []
2022
+ skip_until = -1
2023
+ top_level_positions(expr).each do |pos|
2024
+ next if pos < skip_until || !"+-*/%".include?(expr[pos])
2025
+
2026
+ op = %w[** //].include?(expr[pos, 2]) ? expr[pos, 2] : expr[pos]
2027
+ skip_until = pos + op.length
2028
+ operators << [pos, op] if follows_operand?(expr, pos)
1968
2029
  end
1969
- :not_arithmetic
2030
+ operators
2031
+ end
2032
+
2033
+ # ── Range: a..b ──
2034
+
2035
+ # Integers from start to finish inclusive, counting down when start is
2036
+ # greater than finish. Backs both range() and `a..b`; returns [] for a
2037
+ # bound that is not a number or a zero step.
2038
+ def self.frond_range(start, finish, step = 1)
2039
+ numeric = ->(v) { v.is_a?(Numeric) || v.to_s.match?(/\A-?\d+(\.\d+)?\z/) }
2040
+ return [] unless numeric.(start) && numeric.(finish) && numeric.(step)
2041
+
2042
+ start = start.to_i
2043
+ finish = finish.to_i
2044
+ step = step.to_i.abs
2045
+ return [] if step.zero?
2046
+
2047
+ start <= finish ? start.step(finish, step).to_a : start.step(finish, -step).to_a
2048
+ end
2049
+
2050
+ def eval_range(expr, context)
2051
+ pos = find_range_operator(expr)
2052
+ return :not_range unless pos
2053
+
2054
+ Frond.frond_range(eval_expr(expr[0...pos].strip, context), eval_expr(expr[(pos + 2)..].strip, context))
2055
+ end
2056
+
2057
+ # Position of a top-level `..` (not part of `...`), or nil. A pipe after the
2058
+ # range (`1..4|join(",")`) applies to the whole range, so it is left to the
2059
+ # filter step.
2060
+ def find_range_operator(expr)
2061
+ pos = find_outside_quotes(expr, "..")
2062
+ while pos && pos >= 0
2063
+ part_of_ellipsis = expr[pos, 3] == "..." || (pos.positive? && expr[pos - 1] == ".")
2064
+ if pos.positive? && !part_of_ellipsis
2065
+ return find_outside_quotes(expr[(pos + 2)..], "|") >= 0 ? nil : pos
2066
+ end
2067
+
2068
+ next_pos = find_outside_quotes(expr[(pos + 2)..], "..")
2069
+ pos = next_pos >= 0 ? pos + 2 + next_pos : nil
2070
+ end
2071
+ nil
1970
2072
  end
1971
2073
 
1972
2074
  # ── Function call: name(arg1, arg2) ──
@@ -2994,7 +3096,9 @@ module Tina4
2994
3096
  v.to_s
2995
3097
  end
2996
3098
  },
2997
- "striptags" => ->(v, *_a) { v.to_s.gsub(STRIPTAGS_RE, "") },
3099
+ # Fixed-point strip (see template.rb): loop the gsub until no tag remains,
3100
+ # so nested `<scr<script>ipt>` cannot leave a `<script>` behind.
3101
+ "striptags" => ->(v, *_a) { s = v.to_s; loop { t = s.gsub(STRIPTAGS_RE, ""); break if t == s; s = t }; s },
2998
3102
 
2999
3103
  # -- Encoding --
3000
3104
  "escape" => ->(v, *a) { Frond.escape_strategy(v, a[0] || "html") },
@@ -3179,6 +3283,7 @@ module Tina4
3179
3283
  @globals["form_token"] = ->(descriptor = "") { Frond.generate_form_token(descriptor.to_s) }
3180
3284
  @globals["formTokenValue"] = ->(descriptor = "") { Frond.generate_form_token_value(descriptor.to_s) }
3181
3285
  @globals["form_token_value"] = ->(descriptor = "") { Frond.generate_form_token_value(descriptor.to_s) }
3286
+ @globals["range"] = ->(start, finish, step = 1) { Frond.frond_range(start, finish, step) }
3182
3287
 
3183
3288
  # Debug helper: {{ dump(x) }} — gated on TINA4_DEBUG=true.
3184
3289
  # Both this global and the |dump filter call Frond.render_dump which
@@ -3236,6 +3341,31 @@ module Tina4
3236
3341
  def set_form_token_session_id(session_id)
3237
3342
  self.form_token_session_id = session_id
3238
3343
  end
3344
+
3345
+ # The Rack env of the request this thread is dispatching. The app sets it
3346
+ # for each request, so a token is bound to the session that rendered it
3347
+ # without sharing a process-wide value between Puma threads.
3348
+ def request_env=(env)
3349
+ Thread.current[:tina4_form_token_env] = env
3350
+ end
3351
+
3352
+ # The current request's session id, or "" outside a request.
3353
+ #
3354
+ # A session that holds nothing is sent no cookie, so the form's own POST
3355
+ # would arrive under a newly minted id and fail its own token. Rendering a
3356
+ # bound token therefore stores a marker, which makes the session persist.
3357
+ def request_session_id
3358
+ env = Thread.current[:tina4_form_token_env]
3359
+ request = env && env["tina4.request"]
3360
+ return "" unless request.respond_to?(:session)
3361
+
3362
+ session = request.session
3363
+ sid = session.respond_to?(:get_session_id) ? session.get_session_id.to_s : ""
3364
+ return "" if sid.empty?
3365
+
3366
+ session.set("_tina4_csrf", true) if session.respond_to?(:fresh?) && session.fresh?
3367
+ sid
3368
+ end
3239
3369
  end
3240
3370
 
3241
3371
  # Generate a raw JWT form token string.
@@ -3262,7 +3392,8 @@ module Tina4
3262
3392
  end
3263
3393
 
3264
3394
  # Include session_id for CSRF session binding
3265
- sid = form_token_session_id.to_s
3395
+ sid = request_session_id
3396
+ sid = form_token_session_id.to_s if sid.empty?
3266
3397
  payload["session_id"] = sid unless sid.empty?
3267
3398
 
3268
3399
  # get_token's expires_in is MINUTES, like every framework's. This used to