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.
@@ -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
- "striptags" => ->(v) { v.to_s.gsub(/<[^>]+>/, "") },
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
- content = content.gsub(/\{%.*?%\}/m, "")
276
- content = content.gsub(/\{#.*?#\}/m, "")
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
- if content =~ /\{%\s*extends\s+["'](.+?)["']\s*%\}/
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+["'](.+?)["']\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*-?%\}(.*?)\{%[-\s]*endblock\s*-?%\}/m
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*-?%\}(.*?)\{%[-\s]*endblock\s*-?%\}/m) do
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*%\}(.*?)\{%\s*endblock\s*%\}/m) do
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
- content.gsub(/\{%\s*include\s+["'](.+?)["'](?:\s+with\s+(.+?))?\s*%\}/) do
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+(.+?)\s*%\}(.*?)\{%\s*endfor\s*%\}/m) do
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
- /\{%\s*if\s+(.+?)\s*%\}(.*?)(?:\{%\s*else\s*%\}(.*?))?\{%\s*endif\s*%\}/m
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*=\s*(.+?)\s*%\}/) do
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
- content.gsub(/\{\{\s*(.+?)\s*\}\}/) do
524
- expr = Regexp.last_match(1)
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, required_attr,
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
- (checked_attr) | select (options_html) }.
9
- wrap - render the <form> wrapper; form_id_attr/action/form_method set it
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{{ form_id_attr|raw }} action="{{ action }}" method="{{ form_method }}" class="needs-validation" data-crud-form="1">{% endif %}
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 }}"{{ field.required_attr|raw }}>{{ field.value }}</textarea>{% endif %}{% if field.checkbox %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ field.id }}" name="{{ field.name }}"{{ field.checked_attr|raw }}></div>{% endif %}{% if field.select %}<select class="form-select" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ 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 }}"{{ field.required_attr|raw }}>{% endif %}
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, table_id_attr, inline_script
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"{{ table_id_attr|raw }}>
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={{ redirect_url }}">{% endblock %}
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">{{ redirect_url }}</div>
13
- <a href="{{ redirect_url }}" class="error-home">Click here if not redirected</a>
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
@@ -7,5 +7,5 @@
7
7
 
8
8
 
9
9
  module Tina4
10
- VERSION = "3.13.150"
10
+ VERSION = "3.13.151"
11
11
  end
@@ -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\[+|\]+\z/, "")
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.150
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 00:00:00.000000000 Z
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