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 +4 -4
- data/CHANGELOG.md +26 -0
- data/lib/tina4/auth.rb +3 -3
- data/lib/tina4/cli.rb +1 -1
- data/lib/tina4/crud.rb +7 -7
- data/lib/tina4/dev_admin.rb +14 -0
- data/lib/tina4/drivers/mongodb_driver.rb +48 -13
- data/lib/tina4/drivers/mssql_driver.rb +6 -1
- data/lib/tina4/form_parser.rb +38 -4
- data/lib/tina4/frond.rb +151 -20
- data/lib/tina4/mcp.rb +36 -12
- data/lib/tina4/metrics.rb +5 -1
- data/lib/tina4/middleware.rb +10 -1
- data/lib/tina4/plan.rb +3 -3
- data/lib/tina4/point.rb +1 -1
- data/lib/tina4/project_index.rb +1 -1
- data/lib/tina4/public/bundled-component-provenance.json +2 -2
- data/lib/tina4/public/js/tina4-dev-admin-split.html +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +147 -138
- data/lib/tina4/rack_app.rb +4 -1
- data/lib/tina4/realtime/s3_client.rb +240 -0
- data/lib/tina4/realtime/s3_storage.rb +15 -28
- data/lib/tina4/realtime/storage.rb +4 -5
- data/lib/tina4/realtime.rb +2 -1
- data/lib/tina4/request.rb +41 -3
- data/lib/tina4/response_cache.rb +9 -1
- data/lib/tina4/router.rb +1 -1
- data/lib/tina4/sql_translator.rb +5 -3
- data/lib/tina4/template.rb +32 -17
- data/lib/tina4/templates/crud/form.twig +5 -5
- data/lib/tina4/templates/crud/table.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +4 -3
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2628729439684b6bb6c01c45a812c8e2e811bc8ad3da8848d4213311d3ecffa2
|
|
4
|
+
data.tar.gz: 78842ef5d13e8fd555baec8b1f25321390060d2be22fab7948de599a8c204fa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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] ?
|
|
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
|
-
"
|
|
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, "
|
|
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
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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+
|
|
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+
|
|
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
|
|
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
|
|
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+(
|
|
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
|
|
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
|
-
|
|
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] }
|
data/lib/tina4/form_parser.rb
CHANGED
|
@@ -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
|
-
|
|
100
|
+
fields = nest(pairs)
|
|
93
101
|
rescue ParameterError => e
|
|
94
102
|
error = e
|
|
95
|
-
fields =
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
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
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|