tina4ruby 3.13.137 → 3.13.139

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.
Files changed (161) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/COMMERCIAL-LICENSE.md +8 -0
  4. data/LICENSE +373 -0
  5. data/LICENSE.txt +373 -21
  6. data/NOTICE +11 -0
  7. data/README.md +11 -7
  8. data/exe/tina4ruby +6 -0
  9. data/lib/tina4/ai.rb +6 -0
  10. data/lib/tina4/ai_client.rb +10 -3
  11. data/lib/tina4/api.rb +74 -8
  12. data/lib/tina4/auth.rb +108 -26
  13. data/lib/tina4/auto_crud.rb +83 -27
  14. data/lib/tina4/background.rb +7 -1
  15. data/lib/tina4/base64.rb +59 -0
  16. data/lib/tina4/cache.rb +6 -0
  17. data/lib/tina4/cache_backends/base_backend.rb +14 -0
  18. data/lib/tina4/cache_backends/database_backend.rb +6 -0
  19. data/lib/tina4/cache_backends/file_backend.rb +6 -0
  20. data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
  21. data/lib/tina4/cache_backends/memory_backend.rb +6 -0
  22. data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
  23. data/lib/tina4/cache_backends/redis_backend.rb +6 -0
  24. data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
  25. data/lib/tina4/cache_backends.rb +9 -1
  26. data/lib/tina4/cli.rb +27 -7
  27. data/lib/tina4/constants.rb +12 -8
  28. data/lib/tina4/container.rb +6 -0
  29. data/lib/tina4/context/chunker.rb +6 -0
  30. data/lib/tina4/context.rb +13 -5
  31. data/lib/tina4/cors.rb +66 -10
  32. data/lib/tina4/crud.rb +78 -9
  33. data/lib/tina4/database/sqlite3_adapter.rb +9 -1
  34. data/lib/tina4/database.rb +260 -44
  35. data/lib/tina4/database_adapter.rb +6 -0
  36. data/lib/tina4/database_result.rb +6 -0
  37. data/lib/tina4/database_url.rb +6 -0
  38. data/lib/tina4/debug.rb +6 -0
  39. data/lib/tina4/dev.rb +6 -0
  40. data/lib/tina4/dev_admin.rb +222 -89
  41. data/lib/tina4/dev_mailbox.rb +6 -1
  42. data/lib/tina4/dispatch_pipeline.rb +86 -1
  43. data/lib/tina4/docs.rb +6 -0
  44. data/lib/tina4/docstore.rb +23 -4
  45. data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
  46. data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
  47. data/lib/tina4/drivers/firebird_driver.rb +95 -6
  48. data/lib/tina4/drivers/mongodb_driver.rb +6 -0
  49. data/lib/tina4/drivers/mssql_driver.rb +54 -39
  50. data/lib/tina4/drivers/mysql_driver.rb +6 -0
  51. data/lib/tina4/drivers/odbc_driver.rb +6 -0
  52. data/lib/tina4/drivers/postgres_driver.rb +35 -8
  53. data/lib/tina4/drivers/schema_split.rb +6 -0
  54. data/lib/tina4/drivers/sqlite_driver.rb +16 -1
  55. data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
  56. data/lib/tina4/env.rb +14 -6
  57. data/lib/tina4/error_overlay.rb +6 -0
  58. data/lib/tina4/events.rb +6 -0
  59. data/lib/tina4/feedback.rb +9 -2
  60. data/lib/tina4/field_types.rb +6 -0
  61. data/lib/tina4/form_parser.rb +241 -0
  62. data/lib/tina4/frond.rb +200 -58
  63. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
  64. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
  65. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
  66. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
  67. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
  68. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
  69. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
  70. data/lib/tina4/graph.rb +6 -0
  71. data/lib/tina4/graphql.rb +134 -35
  72. data/lib/tina4/health.rb +6 -0
  73. data/lib/tina4/html_element.rb +6 -0
  74. data/lib/tina4/http_server.rb +690 -0
  75. data/lib/tina4/imap_client.rb +352 -0
  76. data/lib/tina4/import_helper.rb +6 -0
  77. data/lib/tina4/job.rb +21 -2
  78. data/lib/tina4/localization.rb +6 -0
  79. data/lib/tina4/log.rb +6 -0
  80. data/lib/tina4/mail_socket.rb +124 -0
  81. data/lib/tina4/mcp.rb +18 -9
  82. data/lib/tina4/messenger.rb +86 -55
  83. data/lib/tina4/metrics.rb +6 -0
  84. data/lib/tina4/middleware.rb +27 -10
  85. data/lib/tina4/migration.rb +6 -0
  86. data/lib/tina4/model_collection.rb +6 -0
  87. data/lib/tina4/mqtt.rb +9 -2
  88. data/lib/tina4/mqtt_message.rb +6 -0
  89. data/lib/tina4/orm.rb +72 -16
  90. data/lib/tina4/parse_json.rb +25 -0
  91. data/lib/tina4/plan.rb +9 -2
  92. data/lib/tina4/point.rb +6 -0
  93. data/lib/tina4/port_takeover.rb +6 -0
  94. data/lib/tina4/project_index.rb +6 -0
  95. data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
  96. data/lib/tina4/public/bundled-component-provenance.json +48 -0
  97. data/lib/tina4/public/js/tina4js.min.js +13 -3
  98. data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
  99. data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
  100. data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
  101. data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
  102. data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
  103. data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
  104. data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
  105. data/lib/tina4/push.rb +22 -4
  106. data/lib/tina4/query_builder.rb +6 -0
  107. data/lib/tina4/queue.rb +6 -0
  108. data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
  109. data/lib/tina4/queue_backends/lite_backend.rb +18 -0
  110. data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
  111. data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
  112. data/lib/tina4/rack_app.rb +62 -50
  113. data/lib/tina4/rate_limiter.rb +6 -0
  114. data/lib/tina4/realtime/attachment.rb +6 -0
  115. data/lib/tina4/realtime/channel.rb +6 -0
  116. data/lib/tina4/realtime/channel_member.rb +6 -0
  117. data/lib/tina4/realtime/local_storage.rb +6 -0
  118. data/lib/tina4/realtime/message.rb +6 -0
  119. data/lib/tina4/realtime/s3_storage.rb +15 -1
  120. data/lib/tina4/realtime/storage.rb +6 -0
  121. data/lib/tina4/realtime/storage_backend.rb +6 -0
  122. data/lib/tina4/realtime/workspace.rb +6 -0
  123. data/lib/tina4/realtime.rb +10 -3
  124. data/lib/tina4/request.rb +90 -127
  125. data/lib/tina4/response.rb +71 -9
  126. data/lib/tina4/response_cache.rb +6 -0
  127. data/lib/tina4/router.rb +6 -0
  128. data/lib/tina4/secret_file.rb +34 -0
  129. data/lib/tina4/seeder.rb +9 -3
  130. data/lib/tina4/service.rb +8 -2
  131. data/lib/tina4/service_runner.rb +6 -0
  132. data/lib/tina4/session.rb +6 -0
  133. data/lib/tina4/session_handlers/database_handler.rb +42 -12
  134. data/lib/tina4/session_handlers/file_handler.rb +6 -0
  135. data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
  136. data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
  137. data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
  138. data/lib/tina4/session_handlers/redis_handler.rb +6 -0
  139. data/lib/tina4/session_handlers/resp_client.rb +6 -0
  140. data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
  141. data/lib/tina4/shutdown.rb +17 -14
  142. data/lib/tina4/smtp_client.rb +222 -0
  143. data/lib/tina4/sql_translator.rb +93 -0
  144. data/lib/tina4/sqlite3_gem.rb +27 -0
  145. data/lib/tina4/sso.rb +52 -12
  146. data/lib/tina4/ssrf.rb +148 -0
  147. data/lib/tina4/swagger.rb +6 -0
  148. data/lib/tina4/template.rb +6 -0
  149. data/lib/tina4/test.rb +8 -2
  150. data/lib/tina4/test_client.rb +8 -1
  151. data/lib/tina4/testing.rb +8 -1
  152. data/lib/tina4/validator.rb +6 -0
  153. data/lib/tina4/version.rb +7 -1
  154. data/lib/tina4/webserver.rb +25 -143
  155. data/lib/tina4/websocket.rb +170 -20
  156. data/lib/tina4/websocket_backplane.rb +25 -2
  157. data/lib/tina4/wsdl.rb +54 -18
  158. data/lib/tina4/xml_parser.rb +268 -0
  159. data/lib/tina4.rb +98 -33
  160. data/lib/tina4ruby.rb +6 -0
  161. metadata +44 -140
data/lib/tina4/frond.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  # Tina4 Frond Engine -- Lexer, parser, and runtime.
4
10
  # Zero-dependency twig-like template engine.
@@ -8,13 +14,14 @@
8
14
 
9
15
  require "json"
10
16
  require "digest"
11
- require "base64"
12
- require "cgi"
17
+ require_relative "base64"
18
+ require "cgi/escape"
13
19
  require "erb"
14
20
  require "uri"
15
21
  require "date"
16
22
  require "time"
17
23
  require "securerandom"
24
+ require_relative "parse_json"
18
25
 
19
26
  module Tina4
20
27
  # Marker class for strings that should not be auto-escaped in Frond.
@@ -484,6 +491,38 @@ module Tina4
484
491
  # The escaping decision has to ask this rather than read the filter name out
485
492
  # of the source: a denied `raw` that still marked its value safe made the
486
493
  # allow-list entry governing XSS escaping inert.
494
+ # Words in an expression that are not variable references.
495
+ SANDBOX_EXPR_KEYWORDS = %w[true false none null nil and or not in is loop
496
+ if else empty starts ends with matches].freeze
497
+ SANDBOX_IDENT_RE = /(?<![.\w'"])([A-Za-z_][A-Za-z0-9_]*)/
498
+ SANDBOX_STRING_RE = /'[^']*'|"[^"]*"/
499
+ # Methods that must never be invoked through {{ obj.method }} — reflection,
500
+ # metaprogramming and process control (F6/ADR-0077).
501
+ UNSAFE_METHODS = %w[send __send__ public_send instance_eval instance_exec
502
+ eval class methods public_methods private_methods
503
+ instance_variables instance_variable_get
504
+ instance_variable_set define_singleton_method method
505
+ tap then object_id __id__ system exec spawn binding
506
+ singleton_class ancestors superclass constants
507
+ const_get remove_method].to_set.freeze
508
+
509
+ # Sandbox gate for a WHOLE expression (F6/ADR-0077): refuse a dunder walk
510
+ # or a root variable outside the allow-list, in output, conditions, set
511
+ # right-hand sides and for iterables alike.
512
+ def sandbox_expr_ok?(expr)
513
+ return true unless @sandbox
514
+ return false if expr.include?("__")
515
+ return true unless @allowed_vars
516
+
517
+ stripped = expr.gsub(SANDBOX_STRING_RE, "")
518
+ stripped.scan(SANDBOX_IDENT_RE).each do |(ident)|
519
+ next if SANDBOX_EXPR_KEYWORDS.include?(ident.downcase)
520
+ next if @filters.key?(ident)
521
+ return false unless @allowed_vars.include?(ident)
522
+ end
523
+ true
524
+ end
525
+
487
526
  def filter_permitted?(name)
488
527
  return true unless @sandbox && @allowed_filters
489
528
 
@@ -506,6 +545,83 @@ module Tina4
506
545
  str.to_s.gsub(HTML_ESCAPE_RE, HTML_ESCAPE_MAP)
507
546
  end
508
547
 
548
+ # -- Escape strategies (ADR-0077) ------------------------------------
549
+ # Shared by js_escape and e(strategy); byte-identical to the Python
550
+ # master. Twig-compatible.
551
+ JS_SAFE_RE = /[A-Za-z0-9,._]/
552
+ CSS_SAFE_RE = /[A-Za-z0-9]/
553
+ ATTR_SAFE_RE = /[A-Za-z0-9,.\-_]/
554
+
555
+ def self.js_escape_str(value)
556
+ out = +""
557
+ value.to_s.each_char do |ch|
558
+ if ch =~ JS_SAFE_RE
559
+ out << ch
560
+ next
561
+ end
562
+ code = ch.ord
563
+ if code < 0x80
564
+ out << format("\\x%02X", code)
565
+ elsif code <= 0xFFFF
566
+ out << format("\\u%04X", code)
567
+ else
568
+ code -= 0x10000
569
+ out << format("\\u%04X", 0xD800 + (code >> 10))
570
+ out << format("\\u%04X", 0xDC00 + (code & 0x3FF))
571
+ end
572
+ end
573
+ out
574
+ end
575
+
576
+ def self.css_escape(value)
577
+ out = +""
578
+ value.to_s.each_char do |ch|
579
+ out << (ch =~ CSS_SAFE_RE ? ch : format("\\%06X ", ch.ord))
580
+ end
581
+ out
582
+ end
583
+
584
+ def self.html_attr_escape(value)
585
+ out = +""
586
+ value.to_s.each_char do |ch|
587
+ out << (ch =~ ATTR_SAFE_RE ? ch : format("&#x%02X;", ch.ord))
588
+ end
589
+ out
590
+ end
591
+
592
+ # RFC-3986 rawurlencode: unreserved set stays, everything else percent-encoded.
593
+ def self.url_escape(value)
594
+ value.to_s.b.gsub(/[^A-Za-z0-9\-_.~]/) { |c| format("%%%02X", c.ord) }
595
+ end
596
+
597
+ # Dispatch e(strategy)/escape(strategy). An unknown strategy raises, so a
598
+ # typo can never silently pass the value through unescaped.
599
+ def self.escape_strategy(value, strategy = "html")
600
+ s = strategy.to_s.strip.gsub(/\A['"]|['"]\z/, "")
601
+ case s
602
+ when "", "html"
603
+ Tina4::SafeString.new(escape_html(value.to_s))
604
+ when "js"
605
+ Tina4::SafeString.new(js_escape_str(value))
606
+ when "url"
607
+ Tina4::SafeString.new(url_escape(value))
608
+ when "css"
609
+ Tina4::SafeString.new(css_escape(value))
610
+ when "html_attr", "attr"
611
+ Tina4::SafeString.new(html_attr_escape(value))
612
+ else
613
+ raise ArgumentError, "Unknown escape strategy: #{strategy}"
614
+ end
615
+ end
616
+
617
+ # Confine a client-supplied media type to a strict type/subtype grammar
618
+ # (F3/ADR-0077); anything malformed falls back to a safe default.
619
+ MEDIA_TYPE_RE = %r{\A[A-Za-z0-9][A-Za-z0-9!\#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!\#$&^_.+-]*\z}
620
+ def self.sanitise_media_type(type)
621
+ t = type.to_s.strip
622
+ t =~ MEDIA_TYPE_RE ? t : "application/octet-stream"
623
+ end
624
+
509
625
  # Serializes a value to compact JSON text that is always valid JSON.
510
626
  #
511
627
  # Never raises and never returns an empty string: a non-finite float becomes
@@ -616,35 +732,54 @@ module Tina4
616
732
  # Tokenizer
617
733
  # -----------------------------------------------------------------------
618
734
 
619
- # Regex to extract {% raw %}...{% endraw %} blocks before tokenizing
620
- RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}(.*?)\{%-?\s*endraw\s*-?%\}/m
621
-
735
+ # Walk literal delimiters instead of retrying a whole-template regex at
736
+ # every unmatched opener. This also bounds parsing on Ruby versions that
737
+ # do not memoize regexp backtracking.
622
738
  def tokenize(source)
623
- # 1. Extract raw blocks and replace with placeholders
624
739
  raw_blocks = []
625
- source = source.gsub(RAW_BLOCK_RE) do
626
- idx = raw_blocks.length
627
- raw_blocks << Regexp.last_match(1)
628
- "\x00RAW_#{idx}\x00"
740
+ chunks = []
741
+ copied = 0
742
+ cursor = 0
743
+ raw_start = nil
744
+ while (start = source.index("{%", cursor))
745
+ cursor = start + 2
746
+ match = /\G\{%-?\s*(raw|endraw)\s*-?%\}/.match(source, start)
747
+ next unless match
748
+ finish = match.end(0) - 2
749
+ tag = match[1]
750
+ if tag == "raw" && raw_start.nil?
751
+ raw_start = [start, finish + 2]
752
+ elsif tag == "endraw" && raw_start
753
+ chunks << source[copied...raw_start[0]]
754
+ chunks << "\x00RAW_#{raw_blocks.length}\x00"
755
+ raw_blocks << source[raw_start[1]...start]
756
+ copied = finish + 2
757
+ raw_start = nil
758
+ end
629
759
  end
760
+ chunks << source[copied..]
761
+ source = chunks.join
630
762
 
631
- # 2. Normal tokenization
632
763
  tokens = []
633
764
  pos = 0
634
- source.scan(TOKEN_RE) do
635
- m = Regexp.last_match
636
- start = m.begin(0)
637
- tokens << [TEXT, source[pos...start]] if start > pos
638
-
639
- raw = m[0]
640
- if raw.start_with?("{#")
641
- tokens << [COMMENT, raw]
642
- elsif raw.start_with?("{{")
643
- tokens << [VAR, raw]
644
- elsif raw.start_with?("{%")
645
- tokens << [BLOCK, raw]
765
+ cursor = 0
766
+ missing_close = {}
767
+ closers = { "{%" => "%}", "{{" => "}}", "{#" => "#}" }
768
+ while (match = /\{%|\{\{|\{#/.match(source, cursor))
769
+ start = match.begin(0)
770
+ opener = match[0]
771
+ cursor = start + 2
772
+ next if missing_close[opener]
773
+ finish = source.index(closers.fetch(opener), cursor)
774
+ unless finish
775
+ missing_close[opener] = true
776
+ next
646
777
  end
647
- pos = m.end(0)
778
+ tokens << [TEXT, source[pos...start]] if start > pos
779
+ raw = source[start...(finish + 2)]
780
+ type = { "{#" => COMMENT, "{{" => VAR, "{%" => BLOCK }.fetch(opener)
781
+ tokens << [type, raw]
782
+ pos = cursor = finish + 2
648
783
  end
649
784
  tokens << [TEXT, source[pos..]] if pos < source.length
650
785
 
@@ -1155,6 +1290,10 @@ module Tina4
1155
1290
  end
1156
1291
 
1157
1292
  def eval_var_raw(expr, context)
1293
+ # Sandbox: gate the whole condition/RHS (F6) so a comparison or iterable
1294
+ # cannot leak a blocked variable.
1295
+ return nil if @sandbox && !sandbox_expr_ok?(expr)
1296
+
1158
1297
  var_name, filters = parse_filter_chain(expr)
1159
1298
  value = eval_expr(var_name, context)
1160
1299
  filters.each do |fname, args|
@@ -1232,16 +1371,8 @@ module Tina4
1232
1371
 
1233
1372
  var_name, filters = parse_filter_chain(expr)
1234
1373
 
1235
- # Sandbox: check variable access
1236
- if @sandbox && @allowed_vars
1237
- root_var = @dotted_split_cache[var_name]
1238
- unless root_var
1239
- root_var = var_name.split(".")[0].split("[")[0].strip
1240
- cap_cache(@dotted_split_cache, MEMO_CACHE_MAX)
1241
- @dotted_split_cache[var_name] = root_var
1242
- end
1243
- return "" if !root_var.empty? && !@allowed_vars.include?(root_var) && root_var != "loop"
1244
- end
1374
+ # Sandbox: gate the whole expression (allow-list + dunder block, F6)
1375
+ return "" if @sandbox && !sandbox_expr_ok?(expr)
1245
1376
 
1246
1377
  value = eval_expr(var_name, context)
1247
1378
 
@@ -1283,7 +1414,7 @@ module Tina4
1283
1414
  when "title" then value.to_s.split.map(&:capitalize).join(" ")
1284
1415
  when "string" then value.to_s
1285
1416
  when "int" then value.to_i
1286
- when "escape", "e" then Tina4::SafeString.new(Frond.escape_html(value.to_s))
1417
+ when "escape", "e" then Frond.escape_strategy(value, "html")
1287
1418
  else value
1288
1419
  end
1289
1420
  next
@@ -1296,9 +1427,17 @@ module Tina4
1296
1427
  end
1297
1428
  end
1298
1429
 
1299
- # Auto-escape HTML unless marked safe or SafeString
1300
- if @auto_escape && !is_safe && value.is_a?(String) && !value.is_a?(SafeString)
1301
- value = Frond.escape_html(value)
1430
+ # Auto-escape HTML unless marked safe or SafeString. A plain string is
1431
+ # escaped directly; an Array/Hash/object is escaped on its rendered
1432
+ # string form (F4/ADR-0077) -- before this, only String values were
1433
+ # escaped, so {{ items }} holding markup emitted it raw. nil, booleans
1434
+ # and Numerics carry no HTML-special characters and are left untouched.
1435
+ if @auto_escape && !is_safe && !value.is_a?(SafeString)
1436
+ if value.is_a?(String)
1437
+ value = Frond.escape_html(value)
1438
+ elsif !value.nil? && value != true && value != false && !value.is_a?(Numeric)
1439
+ value = Tina4::SafeString.new(Frond.escape_html(value.to_s))
1440
+ end
1302
1441
  end
1303
1442
 
1304
1443
  value
@@ -2060,8 +2199,10 @@ module Tina4
2060
2199
  end
2061
2200
  idx = idx.to_i if idx.is_a?(Numeric)
2062
2201
  value = idx.is_a?(Integer) ? value[idx] : nil
2063
- elsif value.respond_to?(part.to_sym)
2064
- value = value.send(part.to_sym)
2202
+ elsif !UNSAFE_METHODS.include?(part) && value.respond_to?(part.to_sym)
2203
+ # Only call a method the object publicly exposes, and never a
2204
+ # reflection/metaprogramming method (F6/ADR-0077).
2205
+ value = value.public_send(part.to_sym)
2065
2206
  else
2066
2207
  return nil
2067
2208
  end
@@ -2235,7 +2376,11 @@ module Tina4
2235
2376
  i += 1
2236
2377
  end
2237
2378
 
2238
- iterable = eval_expr(iterable_expr, context)
2379
+ iterable = if @sandbox && !sandbox_expr_ok?(iterable_expr)
2380
+ nil
2381
+ else
2382
+ eval_expr(iterable_expr, context)
2383
+ end
2239
2384
 
2240
2385
  if iterable.nil? || (iterable.respond_to?(:empty?) && iterable.empty?)
2241
2386
  if else_tokens.any?
@@ -2852,25 +2997,25 @@ module Tina4
2852
2997
  "striptags" => ->(v, *_a) { v.to_s.gsub(STRIPTAGS_RE, "") },
2853
2998
 
2854
2999
  # -- Encoding --
2855
- "escape" => ->(v, *_a) { Tina4::SafeString.new(Frond.escape_html(v.to_s)) },
2856
- "e" => ->(v, *_a) { Tina4::SafeString.new(Frond.escape_html(v.to_s)) },
3000
+ "escape" => ->(v, *a) { Frond.escape_strategy(v, a[0] || "html") },
3001
+ "e" => ->(v, *a) { Frond.escape_strategy(v, a[0] || "html") },
2857
3002
  "raw" => ->(v, *_a) { v },
2858
3003
  "safe" => ->(v, *_a) { v },
2859
3004
  # Frond.json_safe, never a bare JSON.generate: generate RAISES on an
2860
3005
  # Infinity/NaN float, and the old `rescue v.to_s` answered that raise with
2861
3006
  # Ruby inspect output that no JSON.parse will read. See Frond.json_safe.
2862
3007
  "json_encode" => ->(v, *_a) { Frond.json_safe(v) },
2863
- "json_decode" => ->(v, *_a) { v.is_a?(String) ? (JSON.parse(v) rescue v) : v },
2864
- "base64_encode" => ->(v, *_a) { Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
2865
- "base64encode" => ->(v, *_a) { Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
2866
- "base64_decode" => ->(v, *_a) { Base64.decode64(v.to_s) },
2867
- "base64decode" => ->(v, *_a) { Base64.decode64(v.to_s) },
3008
+ "json_decode" => ->(v, *_a) { v.is_a?(String) ? (Tina4.parse_json(v) rescue v) : v },
3009
+ "base64_encode" => ->(v, *_a) { Tina4::Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
3010
+ "base64encode" => ->(v, *_a) { Tina4::Base64.strict_encode64(v.is_a?(String) ? v : v.to_s) },
3011
+ "base64_decode" => ->(v, *_a) { Tina4::Base64.decode64(v.to_s) },
3012
+ "base64decode" => ->(v, *_a) { Tina4::Base64.decode64(v.to_s) },
2868
3013
  "data_uri" => ->(v, *_a) {
2869
3014
  if v.is_a?(Hash)
2870
- ct = v[:type] || v["type"] || "application/octet-stream"
3015
+ ct = Frond.sanitise_media_type(v[:type] || v["type"] || "application/octet-stream")
2871
3016
  raw = v[:content] || v["content"] || ""
2872
3017
  raw = raw.respond_to?(:read) ? raw.read : raw
2873
- "data:#{ct};base64,#{Base64.strict_encode64(raw.to_s)}"
3018
+ Tina4::SafeString.new("data:#{ct};base64,#{Tina4::Base64.strict_encode64(raw.to_s)}")
2874
3019
  else
2875
3020
  v.to_s
2876
3021
  end
@@ -2884,12 +3029,7 @@ module Tina4
2884
3029
  # broke byte-parity for the one filter whose whole job is a wire format.
2885
3030
  "to_json" => ->(v, *_a) { Frond.json_safe(v) },
2886
3031
  "tojson" => ->(v, *_a) { Frond.json_safe(v) },
2887
- "js_escape" => ->(v, *_a) {
2888
- Tina4::SafeString.new(
2889
- v.to_s.gsub("\\", "\\\\").gsub("'", "\\'").gsub('"', '\\"')
2890
- .gsub("\n", "\\n").gsub("\r", "\\r").gsub("\t", "\\t")
2891
- )
2892
- },
3032
+ "js_escape" => ->(v, *_a) { Tina4::SafeString.new(Frond.js_escape_str(v)) },
2893
3033
 
2894
3034
  # -- Hashing --
2895
3035
  "md5" => ->(v, *_a) { Digest::MD5.hexdigest(v.to_s) },
@@ -3118,9 +3258,11 @@ module Tina4
3118
3258
  sid = form_token_session_id.to_s
3119
3259
  payload["session_id"] = sid unless sid.empty?
3120
3260
 
3261
+ # get_token's expires_in is MINUTES, like every framework's. This used to
3262
+ # pass minutes * 60, so a form token lived 60 hours, not 60 minutes
3263
+ # (ADR-0079 s5).
3121
3264
  ttl_minutes = (ENV["TINA4_TOKEN_LIMIT"] || "60").to_i
3122
- expires_in = ttl_minutes * 60
3123
- Tina4::Auth.create_token(payload, expires_in: expires_in)
3265
+ Tina4::Auth.create_token(payload, expires_in: ttl_minutes)
3124
3266
  end
3125
3267
 
3126
3268
  def self.generate_form_token(descriptor = "")
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  # Gallery: Auth — JWT login with a visual demo page.
2
8
 
3
9
  Tina4::Router.get("/gallery/auth") do |request, response|
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  # Gallery: Database — raw SQL query demo.
2
8
 
3
9
  Tina4::Router.get("/api/gallery/db/tables") do |request, response|
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  # Gallery: Error Overlay — deliberately crash to demo the debug overlay.
2
8
  #
3
9
  # This route deliberately raises an error to showcase the error overlay.
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  # Gallery: ORM — Product CRUD endpoints.
2
8
 
3
9
  Tina4::Router.get("/api/gallery/products") do |request, response|
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  # Gallery: Queue — interactive queue demo with visual web UI.
2
8
  #
3
9
  # Uses a SQLite database directly for the demo queue table,
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  # Gallery: REST API — simple JSON endpoints.
2
8
 
3
9
  Tina4::Router.get("/api/gallery/hello") do |request, response|
@@ -1,3 +1,9 @@
1
+ # Copyright (c) 2026 Code Infinity
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
1
7
  # Gallery: Templates — render an HTML page with dynamic data via template.
2
8
 
3
9
  Tina4::Router.get "/gallery/page", template: "gallery_page.twig" do |request, response|
data/lib/tina4/graph.rb CHANGED
@@ -1,4 +1,10 @@
1
1
  # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
2
8
 
3
9
  require "uri"
4
10