tina4ruby 3.13.150 → 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 +16 -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/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/middleware.rb +10 -1
- data/lib/tina4/plan.rb +2 -2
- data/lib/tina4/point.rb +1 -1
- data/lib/tina4/project_index.rb +1 -1
- 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 +3 -2
data/lib/tina4/template.rb
CHANGED
|
@@ -232,7 +232,10 @@ module Tina4
|
|
|
232
232
|
"nl2br" => ->(v) { v.to_s.gsub("\n", "<br>") },
|
|
233
233
|
"number_format" => ->(v, d) { format("%.#{d || 0}f", v.to_f) },
|
|
234
234
|
"raw" => ->(v) { v },
|
|
235
|
-
|
|
235
|
+
# Fixed-point strip: a single gsub leaves a tag that only surfaces once the
|
|
236
|
+
# surrounding one is removed (`<scr<script>ipt>` -> `<script>`). Repeat
|
|
237
|
+
# until nothing more matches (incomplete multi-character sanitization).
|
|
238
|
+
"striptags" => ->(v) { s = v.to_s; loop { t = s.gsub(/<[^>]+>/, ""); break if t == s; s = t }; s },
|
|
236
239
|
"sort" => ->(v) { v.respond_to?(:sort) ? v.sort : v },
|
|
237
240
|
"keys" => ->(v) { v.respond_to?(:keys) ? v.keys : [] },
|
|
238
241
|
"values" => ->(v) { v.respond_to?(:values) ? v.values : [v] },
|
|
@@ -272,8 +275,10 @@ module Tina4
|
|
|
272
275
|
content = process_conditionals(content)
|
|
273
276
|
content = process_set(content)
|
|
274
277
|
content = process_expressions(content)
|
|
275
|
-
|
|
276
|
-
|
|
278
|
+
# ReDoS-safe: a tempered token ([^%] | % not before }) walks to the first
|
|
279
|
+
# %} in one pass — no lazy `.*?` re-scanning each `{%` start (that was O(n^2)).
|
|
280
|
+
content = content.gsub(/\{%(?:[^%]|%(?!\}))*%\}/m, "")
|
|
281
|
+
content = content.gsub(/\{#(?:[^#]|#(?!\}))*#\}/m, "")
|
|
277
282
|
content
|
|
278
283
|
end
|
|
279
284
|
|
|
@@ -295,7 +300,9 @@ module Tina4
|
|
|
295
300
|
private
|
|
296
301
|
|
|
297
302
|
def process_extends(content)
|
|
298
|
-
|
|
303
|
+
# ReDoS-safe: a negated class ([^"']*) for the quoted path can't backtrack
|
|
304
|
+
# across the closing quote the way a lazy (.+?) does.
|
|
305
|
+
if content =~ /\{%\s*extends\s+["']([^"']*)["']\s*%\}/
|
|
299
306
|
parent_path = Regexp.last_match(1)
|
|
300
307
|
full_parent = resolve_template(parent_path)
|
|
301
308
|
if full_parent && File.exist?(full_parent)
|
|
@@ -358,7 +365,7 @@ module Tina4
|
|
|
358
365
|
# grandparent, blocks are merged (child overrides parent) and the
|
|
359
366
|
# chain is followed recursively.
|
|
360
367
|
def render_with_blocks(parent_source, child_blocks)
|
|
361
|
-
extends_re = /\A\s*\{%\s*extends\s+["'](
|
|
368
|
+
extends_re = /\A\s*\{%\s*extends\s+["']([^"']*)["']\s*%\}/
|
|
362
369
|
|
|
363
370
|
# Multi-level: if the parent itself extends another template, recurse
|
|
364
371
|
if parent_source =~ extends_re
|
|
@@ -375,7 +382,7 @@ module Tina4
|
|
|
375
382
|
|
|
376
383
|
# Resolve nested blocks: if a block value contains {% block inner %}
|
|
377
384
|
# tags, replace them with merged_blocks values too
|
|
378
|
-
block_re = /\{%[-\s]*block\s+(\w+)\s*-?%\}(
|
|
385
|
+
block_re = /\{%[-\s]*block\s+(\w+)\s*-?%\}((?:[^{]|\{(?!%[-\s]*endblock\s*-?%\}))*)\{%[-\s]*endblock\s*-?%\}/m
|
|
379
386
|
changed = true
|
|
380
387
|
while changed
|
|
381
388
|
changed = false
|
|
@@ -398,7 +405,7 @@ module Tina4
|
|
|
398
405
|
end
|
|
399
406
|
|
|
400
407
|
# Leaf parent (no extends) — resolve blocks and render
|
|
401
|
-
parent_source.gsub(/\{%[-\s]*block\s+(\w+)\s*-?%\}(
|
|
408
|
+
parent_source.gsub(/\{%[-\s]*block\s+(\w+)\s*-?%\}((?:[^{]|\{(?!%[-\s]*endblock\s*-?%\}))*)\{%[-\s]*endblock\s*-?%\}/m) do
|
|
402
409
|
name = Regexp.last_match(1)
|
|
403
410
|
default_body = Regexp.last_match(2)
|
|
404
411
|
child_blocks[name] || default_body
|
|
@@ -406,7 +413,7 @@ module Tina4
|
|
|
406
413
|
end
|
|
407
414
|
|
|
408
415
|
def process_blocks(content)
|
|
409
|
-
content.gsub(/\{%\s*block\s+(\w+)\s*%\}(
|
|
416
|
+
content.gsub(/\{%\s*block\s+(\w+)\s*%\}((?:[^{]|\{(?!%\s*endblock\s*%\}))*)\{%\s*endblock\s*%\}/m) do
|
|
410
417
|
name = Regexp.last_match(1)
|
|
411
418
|
default_body = Regexp.last_match(2)
|
|
412
419
|
@blocks[name] || default_body
|
|
@@ -414,7 +421,9 @@ module Tina4
|
|
|
414
421
|
end
|
|
415
422
|
|
|
416
423
|
def process_includes(content)
|
|
417
|
-
|
|
424
|
+
# ReDoS-safe: negated class for the quoted path, and the (unused) `with`
|
|
425
|
+
# clause is consumed by a tempered token instead of a lazy (.+?)\s* pair.
|
|
426
|
+
content.gsub(/\{%\s*include\s+["']([^"']*)["'](?:\s+with\s++(?:[^%]|%(?!\}))*+)?\s*+%\}/) do
|
|
418
427
|
inc_path = Regexp.last_match(1)
|
|
419
428
|
full_path = resolve_template(inc_path)
|
|
420
429
|
if full_path && File.exist?(full_path)
|
|
@@ -430,10 +439,10 @@ module Tina4
|
|
|
430
439
|
max_depth = 10
|
|
431
440
|
depth = 0
|
|
432
441
|
while content =~ /\{%\s*for\s+/ && depth < max_depth
|
|
433
|
-
content = content.gsub(/\{%\s*for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s
|
|
442
|
+
content = content.gsub(/\{%\s*for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\s++((?:[^%]|%(?!\}))++)%\}((?:[^{]|\{(?!%\s*endfor\s*%\}))*+)\{%\s*endfor\s*%\}/m) do
|
|
434
443
|
key_or_val = Regexp.last_match(1)
|
|
435
444
|
val_name = Regexp.last_match(2)
|
|
436
|
-
collection_expr = Regexp.last_match(3)
|
|
445
|
+
collection_expr = Regexp.last_match(3).strip
|
|
437
446
|
body = Regexp.last_match(4)
|
|
438
447
|
collection = evaluate_expression(collection_expr)
|
|
439
448
|
output = +""
|
|
@@ -475,9 +484,11 @@ module Tina4
|
|
|
475
484
|
depth = 0
|
|
476
485
|
while content =~ /\{%\s*if\s+/ && depth < max_depth
|
|
477
486
|
content = content.gsub(
|
|
478
|
-
|
|
487
|
+
# ReDoS-safe: tempered tokens — condition walks to %}, bodies stop at
|
|
488
|
+
# the first else/endif, so no lazy (.+?)/\s* overlap to backtrack on.
|
|
489
|
+
/\{%\s*if\s++((?:[^%]|%(?!\}))++)%\}((?:[^{]|\{(?!%\s*(?:else|endif)\s*%\}))*+)(?:\{%\s*else\s*%\}((?:[^{]|\{(?!%\s*endif\s*%\}))*+))?\{%\s*endif\s*%\}/m
|
|
479
490
|
) do
|
|
480
|
-
condition = Regexp.last_match(1)
|
|
491
|
+
condition = Regexp.last_match(1).strip
|
|
481
492
|
true_body = Regexp.last_match(2)
|
|
482
493
|
false_body = Regexp.last_match(3) || ""
|
|
483
494
|
|
|
@@ -511,17 +522,21 @@ module Tina4
|
|
|
511
522
|
end
|
|
512
523
|
|
|
513
524
|
def process_set(content)
|
|
514
|
-
content.gsub(/\{%\s*set\s+(\w+)\s
|
|
525
|
+
content.gsub(/\{%\s*set\s+(\w+)\s*=((?:[^%]|%(?!\}))+)%\}/) do
|
|
515
526
|
var_name = Regexp.last_match(1)
|
|
516
|
-
expr = Regexp.last_match(2)
|
|
527
|
+
expr = Regexp.last_match(2).strip
|
|
517
528
|
@context[var_name] = evaluate_expression(expr)
|
|
518
529
|
""
|
|
519
530
|
end
|
|
520
531
|
end
|
|
521
532
|
|
|
522
533
|
def process_expressions(content)
|
|
523
|
-
|
|
524
|
-
|
|
534
|
+
# ReDoS-safe: the old /\{\{\s*(.+?)\s*\}\}/ let the surrounding \s* AND the
|
|
535
|
+
# lazy (.+?) both match spaces — two ways to assign the whitespace, so
|
|
536
|
+
# polynomial backtracking. A tempered token ([^}] | } not before }) walks
|
|
537
|
+
# to the first }} in one deterministic pass; strip the whitespace in code.
|
|
538
|
+
content.gsub(/\{\{((?:[^}]|\}(?!\}))++)\}\}/) do
|
|
539
|
+
expr = Regexp.last_match(1).strip
|
|
525
540
|
evaluate_piped_expression(expr).to_s
|
|
526
541
|
end
|
|
527
542
|
end
|
|
@@ -3,23 +3,23 @@
|
|
|
3
3
|
|
|
4
4
|
Override: drop your own templates/crud/form.twig to change how inputs render.
|
|
5
5
|
|
|
6
|
-
fields - one per input: { id, name, label, value, placeholder,
|
|
6
|
+
fields - one per input: { id, name, label, value, placeholder, required (bool),
|
|
7
7
|
and exactly ONE control flag: input (with type) | textarea | checkbox
|
|
8
|
-
(
|
|
9
|
-
wrap - render the <form> wrapper;
|
|
8
|
+
(checked bool) | select (options_html) }.
|
|
9
|
+
wrap - render the <form> wrapper; form_id/action/form_method set it
|
|
10
10
|
edit - add the hidden primary-key input (edit modal)
|
|
11
11
|
modal_footer- render the Cancel/Save modal buttons; submit_button - a plain Submit
|
|
12
12
|
|
|
13
13
|
No inline styles or on*= handlers: the modal buttons carry data-crud-action and
|
|
14
14
|
are wired by the page script's delegated listener (ADR-0088).
|
|
15
15
|
#}
|
|
16
|
-
{% if wrap %}<form{{
|
|
16
|
+
{% if wrap %}<form{% if form_id %} id="{{ form_id }}"{% endif %} action="{{ action }}" method="{{ form_method }}" class="needs-validation" data-crud-form="1">{% endif %}
|
|
17
17
|
{% if method_override %}<input type="hidden" name="_method" value="{{ method_override }}">{% endif %}
|
|
18
18
|
{% if edit %}<input type="hidden" id="{{ mode }}-pk-value" name="{{ pk }}">{% endif %}
|
|
19
19
|
{% if modal_footer %}<div class="crud-errors" data-crud-errors hidden></div>{% endif %}
|
|
20
20
|
{% for field in fields %}<div class="mb-3">
|
|
21
21
|
<label for="{{ field.id }}" class="form-label">{{ field.label }}</label>
|
|
22
|
-
{% if field.textarea %}<textarea class="form-control" id="{{ field.id }}" name="{{ field.name }}"{
|
|
22
|
+
{% if field.textarea %}<textarea class="form-control" id="{{ field.id }}" name="{{ field.name }}"{% if field.required %} required{% endif %}>{{ field.value }}</textarea>{% endif %}{% if field.checkbox %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ field.id }}" name="{{ field.name }}"{% if field.checked %} checked{% endif %}></div>{% endif %}{% if field.select %}<select class="form-select" id="{{ field.id }}" name="{{ field.name }}"{% if field.required %} required{% endif %}>{{ field.options_html|raw }}</select>{% endif %}{% if field.input %}<input type="{{ field.type }}" class="form-control" id="{{ field.id }}" name="{{ field.name }}" value="{{ field.value }}" placeholder="{{ field.placeholder }}"{% if field.required %} required{% endif %}>{% endif %}
|
|
23
23
|
</div>
|
|
24
24
|
{% endfor %}
|
|
25
25
|
{% if modal_footer %}<div class="modal-footer"><button type="button" class="btn btn-secondary" data-crud-action="close" data-crud-modal="{{ mode }}">Cancel</button><button type="button" class="btn btn-primary" data-crud-action="save" data-crud-mode="{{ mode }}">Save</button></div>{% endif %}{% if submit_button %}<button type="submit" class="btn btn-primary">Submit</button>{% endif %}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
empty - true when there are no rows
|
|
12
12
|
colspan - column count + 1 (for the empty row)
|
|
13
13
|
editable - inline-edit table (Save/Delete); readonly - view table (Edit/Delete)
|
|
14
|
-
table_name,
|
|
14
|
+
table_name, table_id, inline_script
|
|
15
15
|
|
|
16
16
|
Alignment: each header/cell carries a tina4-css class (text-start for text,
|
|
17
17
|
text-end for numbers). Actions is always text-end. No inline styles (ADR-0088).
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
re-renders <tbody data-crud-body> over fetch() as the user searches/sorts/pages.
|
|
21
21
|
#}
|
|
22
22
|
<div class="table-responsive">
|
|
23
|
-
<table class="table table-striped table-hover"{{
|
|
23
|
+
<table class="table table-striped table-hover"{% if table_id %} id="{{ table_id }}"{% endif %}>
|
|
24
24
|
<thead class="table-dark"><tr>
|
|
25
25
|
{% for header in headers %}{% if header.sortable %}<th class="{{ header.align }}"><a class="sort-link" href="{{ header.url }}" data-crud-sort="{{ header.col }}" data-crud-dir="{{ header.next_dir }}">{{ header.label }}{{ header.indicator|raw }}</a></th>{% endif %}{% if header.plain %}<th class="{{ header.align }}">{{ header.label }}</th>{% endif %}{% endfor %}<th class="text-end">Actions</th>
|
|
26
26
|
</tr></thead>
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{% extends "errors/base.twig" %}
|
|
2
2
|
{% block title %}Redirecting…{% endblock %}
|
|
3
|
-
{% block meta %}<meta http-equiv="refresh" content="0;url={{
|
|
3
|
+
{% block meta %}{% set p1 = redirect_url|slice(0,1) %}{% set p2 = redirect_url|slice(0,2) %}{% set p7 = redirect_url|slice(0,7) %}{% set p8 = redirect_url|slice(0,8) %}{% set rel = (p1 == '/') ? ((p2 == '//') ? 'no' : 'yes') : 'no' %}{% set safe_redirect_url = (rel == 'yes' or p7 == 'http://' or p8 == 'https://') ? redirect_url : '/' %}<meta http-equiv="refresh" content="0;url={{ safe_redirect_url }}">{% endblock %}
|
|
4
4
|
{% block extra_styles %}
|
|
5
5
|
.spinner { display: inline-block; width: 1.5rem; height: 1.5rem; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 1rem; }
|
|
6
6
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
7
7
|
{% endblock %}
|
|
8
8
|
{% block content %}
|
|
9
|
+
{% set p1 = redirect_url|slice(0,1) %}{% set p2 = redirect_url|slice(0,2) %}{% set p7 = redirect_url|slice(0,7) %}{% set p8 = redirect_url|slice(0,8) %}{% set rel = (p1 == '/') ? ((p2 == '//') ? 'no' : 'yes') : 'no' %}{% set safe_redirect_url = (rel == 'yes' or p7 == 'http://' or p8 == 'https://') ? redirect_url : '/' %}
|
|
9
10
|
<div class="spinner"></div>
|
|
10
11
|
<div class="error-title">Redirecting…</div>
|
|
11
12
|
<div class="error-msg">You are being redirected to a new location.</div>
|
|
12
|
-
<div class="error-path c-primary">{{
|
|
13
|
-
<a href="{{
|
|
13
|
+
<div class="error-path c-primary">{{ safe_redirect_url }}</div>
|
|
14
|
+
<a href="{{ safe_redirect_url }}" class="error-home">Click here if not redirected</a>
|
|
14
15
|
{% endblock %}
|
data/lib/tina4/version.rb
CHANGED
data/lib/tina4/webserver.rb
CHANGED
|
@@ -199,7 +199,7 @@ module Tina4
|
|
|
199
199
|
# @param host [String] the host the main socket binds
|
|
200
200
|
# @return [Array<String>] extra bind addresses (possibly empty)
|
|
201
201
|
def self.loopback_bind_hosts(host)
|
|
202
|
-
normalized = host.to_s.strip.downcase.gsub(/\A\[
|
|
202
|
+
normalized = host.to_s.strip.downcase.gsub(/\A\[++|\]++\z/, "")
|
|
203
203
|
case normalized
|
|
204
204
|
when "localhost" then ["127.0.0.1", "::1"]
|
|
205
205
|
when "127.0.0.1", "0.0.0.0" then ["::1"]
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.151
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: sqlite3
|
|
@@ -322,6 +322,7 @@ files:
|
|
|
322
322
|
- lib/tina4/realtime/channel_member.rb
|
|
323
323
|
- lib/tina4/realtime/local_storage.rb
|
|
324
324
|
- lib/tina4/realtime/message.rb
|
|
325
|
+
- lib/tina4/realtime/s3_client.rb
|
|
325
326
|
- lib/tina4/realtime/s3_storage.rb
|
|
326
327
|
- lib/tina4/realtime/storage.rb
|
|
327
328
|
- lib/tina4/realtime/storage_backend.rb
|