tina4ruby 3.13.37 → 3.13.39

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.
@@ -312,6 +312,19 @@ module Tina4
312
312
  @error_tracker ||= ErrorTracker.new
313
313
  end
314
314
 
315
+ # Drop the lazily-memoized dev singletons so the next access rebuilds
316
+ # them from the CURRENT environment. The mailbox in particular resolves
317
+ # its directory from `TINA4_MAILBOX_DIR`/`data/mailbox` at construction
318
+ # time, so a singleton built under one env must not leak into a later
319
+ # caller (or test) running under a different env. Safe to call anytime —
320
+ # it only nils the caches.
321
+ def reset_singletons!
322
+ @message_log = nil
323
+ @request_inspector = nil
324
+ @mailbox = nil
325
+ @error_tracker = nil
326
+ end
327
+
315
328
  def enabled?
316
329
  Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
317
330
  end
@@ -537,7 +550,11 @@ module Tina4
537
550
  body = read_json_body(env)
538
551
  table_name = (body && body["table"]) || ""
539
552
  count = (body && body["count"]) || 10
540
- json_response(seed_table_data(table_name, count.to_i))
553
+ seed = body && body["seed"]
554
+ seed = (Integer(seed) rescue nil) unless seed.nil?
555
+ clear = body && (body["clear"] == true || body["clear"].to_s == "true")
556
+ strict = body && (body["strict"] == true || body["strict"].to_s == "true")
557
+ json_response(seed_table_data(table_name, count.to_i, seed: seed, clear: clear, strict: strict))
541
558
  when ["POST", "/__dev/api/tool"]
542
559
  body = read_json_body(env)
543
560
  tool = (body && body["tool"]) || ""
@@ -633,13 +650,16 @@ module Tina4
633
650
  body = read_json_body(env) || {}
634
651
  json_response(mcp_tool_call(body))
635
652
  # JSON-RPC + SSE endpoints that real MCP clients (Claude Code/Desktop)
636
- # speak. Mounted on the same dispatch as the REST shim above and gated
637
- # on the same enabled? (TINA4_DEBUG) check, so disabled → 404. They
653
+ # speak. The dev tools expose powerful ops (DB query, file read/WRITE,
654
+ # route listing), so beyond the dev-toolbar's TINA4_DEBUG check they are
655
+ # gated on Tina4.mcp_enabled? — explicit TINA4_MCP wins on any host, else
656
+ # dev auto-enable is LOCALHOST-ONLY unless TINA4_MCP_REMOTE=true. Not
657
+ # enabled → falls through to the `else` (nil), so RackApp 404s it. They
638
658
  # share the default MCP server's tool registry with the REST shim.
639
659
  when ["POST", "/__dev/mcp"], ["POST", "/__dev/mcp/message"]
640
- mcp_jsonrpc(env)
660
+ Tina4.mcp_enabled? ? mcp_jsonrpc(env) : nil
641
661
  when ["GET", "/__dev/mcp/sse"]
642
- mcp_sse_handshake
662
+ Tina4.mcp_enabled? ? mcp_sse_handshake : nil
643
663
  when ["GET", "/__dev/api/scaffold"]
644
664
  json_response(scaffold_templates)
645
665
  when ["POST", "/__dev/api/scaffold/run"]
@@ -873,19 +893,21 @@ module Tina4
873
893
  end
874
894
  end
875
895
 
876
- # Execute all statements (single write or multi-statement batch)
896
+ # Execute all statements (single write or multi-statement batch).
897
+ # db.execute() now RAISES on a SQL error (it no longer returns false),
898
+ # so a bad statement is caught by the rescue below and surfaced as a
899
+ # clean { error: } payload — the dead "if result == false" check is
900
+ # gone. true is returned for plain writes; a DatabaseResult for
901
+ # RETURNING/CALL/EXEC (which carries affected_rows).
877
902
  total_affected = 0
878
903
  statements.each do |stmt|
879
904
  result = db.execute(stmt)
880
- if result == false
881
- return { error: db.get_error || "Statement failed: #{stmt}" }
882
- end
883
905
  total_affected += (result.respond_to?(:affected_rows) ? result.affected_rows : 0)
884
906
  end
885
907
 
886
908
  { affected: total_affected, success: true }
887
909
  rescue => e
888
- { error: e.message }
910
+ { error: db.get_error || e.message }
889
911
  end
890
912
  end
891
913
 
@@ -917,16 +939,27 @@ module Tina4
917
939
  end
918
940
  end
919
941
 
920
- def seed_table_data(table_name, count)
942
+ def seed_table_data(table_name, count, seed: nil, clear: false, strict: false)
921
943
  return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
922
944
 
923
945
  db = Tina4.database
924
946
  return { error: "No database configured" } unless db
925
947
 
926
948
  begin
927
- columns = db.columns(table_name)
928
- seeded = Tina4.seed_table(table_name, columns, count: count)
929
- { table: table_name, seeded: seeded }
949
+ # Delegate to the shared resilient seed_table helper so the endpoint
950
+ # gets the exact same per-row wrap (P1) no unhandled row failure can
951
+ # crash the endpoint plus clear/seed/strict (P2/P3). _normalize_columns
952
+ # skips auto-increment id PKs from the introspected column list.
953
+ summary = Tina4.seed_table(
954
+ table_name, db.columns(table_name),
955
+ count: count, seed: seed, clear: clear, strict: strict
956
+ )
957
+ {
958
+ table: table_name,
959
+ seeded: summary.seeded,
960
+ failed: summary.failed,
961
+ errors: summary.errors
962
+ }
930
963
  rescue => e
931
964
  { error: e.message }
932
965
  end
@@ -8,6 +8,18 @@ module Tina4
8
8
  include SchemaSplit
9
9
  attr_reader :connection
10
10
 
11
+ # Process-wide write lock — parity with Python's SQLiteAdapter._write_lock.
12
+ #
13
+ # DB-contract B (v3.13.37): get_next_id's atomic sequence-table increment
14
+ # serialises the ENTIRE ensure-table + seed + increment op under this lock
15
+ # so concurrent callers can never read the same counter and return a
16
+ # duplicate id. Class-level (one per process) so every Database instance /
17
+ # pooled connection contends on the same lock for the same SQLite file.
18
+ @write_lock = Mutex.new
19
+ class << self
20
+ attr_reader :write_lock
21
+ end
22
+
11
23
  def connect(connection_string, username: nil, password: nil)
12
24
  require "sqlite3"
13
25
  db_path = self.class.resolve_path(connection_string)
@@ -87,12 +99,23 @@ module Tina4
87
99
  @connection.execute("BEGIN TRANSACTION")
88
100
  end
89
101
 
102
+ # Committing/rolling back when no transaction is open is a harmless no-op,
103
+ # NOT a failure — SQLite raises "cannot commit - no transaction is active"
104
+ # in that case. Swallow ONLY that specific condition so a stray commit
105
+ # (e.g. after an autocommit standalone write) doesn't poison the
106
+ # Database-level @last_error. A genuine commit/rollback failure (disk I/O,
107
+ # constraint deferral, locked DB) still propagates so Database#commit can
108
+ # FAIL LOUD per the DB-contract.
90
109
  def commit
91
110
  @connection.execute("COMMIT")
111
+ rescue SQLite3::SQLException => e
112
+ raise unless e.message.to_s.downcase.include?("no transaction is active")
92
113
  end
93
114
 
94
115
  def rollback
95
116
  @connection.execute("ROLLBACK")
117
+ rescue SQLite3::SQLException => e
118
+ raise unless e.message.to_s.downcase.include?("no transaction is active")
96
119
  end
97
120
 
98
121
  # v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget").
data/lib/tina4/env.rb CHANGED
@@ -74,13 +74,17 @@ module Tina4
74
74
  end
75
75
 
76
76
  module Env
77
+ # NOTE: TINA4_SECRET is deliberately ABSENT here. The default signing
78
+ # secret must never become a guessable built-in. A blank secret is the
79
+ # signal for Auth.ensure_dev_secret to mint a per-machine random dev secret
80
+ # (saved to gitignored .env.local) in dev, or to emit the actionable
81
+ # "set TINA4_SECRET" warning in CI/prod. Parity with the Python master.
77
82
  DEFAULT_ENV = {
78
83
  "PROJECT_NAME" => "Tina4 Ruby Project",
79
84
  "TINA4_SWAGGER_VERSION" => "1.0.0",
80
85
  "TINA4_LOCALE" => "en",
81
86
  "TINA4_DEBUG" => "true",
82
- "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]",
83
- "TINA4_SECRET" => "tina4-secret-change-me"
87
+ "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]"
84
88
  }.freeze
85
89
 
86
90
  # Typed env-var coercion — parity with tina4_python's Env class,
@@ -157,9 +161,27 @@ module Tina4
157
161
  unless File.exist?(env_file)
158
162
  create_default_env(env_file)
159
163
  end
164
+ # Precedence: real-env > .env.local > .env. Both loads are first-wins
165
+ # (override=false / `ENV[key] ||= value`), so a key already present in
166
+ # the real process environment is NEVER clobbered. .env.local loads
167
+ # FIRST so its values beat .env, but a real env var set before boot
168
+ # still wins over both. This is the security-correct ordering: a stray
169
+ # gitignored .env.local (e.g. a stale auto-generated dev secret) must
170
+ # not override an explicitly-set real TINA4_SECRET. The ensure-dev-secret
171
+ # bootstrap runs AFTER this (only mints a secret if still unset in dev).
172
+ load_local_env(root_dir)
160
173
  parse_env_file(env_file)
161
174
  end
162
175
 
176
+ # Load .env.local with first-wins semantics (override=false). A real
177
+ # process env var already present wins; this only fills keys not already
178
+ # set. Loaded BEFORE .env so .env.local beats .env (real-env > .env.local
179
+ # > .env). No-op when the file is absent (common for fresh checkouts).
180
+ def load_local_env(root_dir = Dir.pwd)
181
+ local_file = File.join(root_dir, ".env.local")
182
+ parse_env_file(local_file) if File.exist?(local_file)
183
+ end
184
+
163
185
  # Get an env var value, with optional default
164
186
  def get_env(key, default = nil)
165
187
  ENV[key.to_s] || default
@@ -213,7 +235,17 @@ module Tina4
213
235
  File.write(path, content)
214
236
  end
215
237
 
216
- def parse_env_file(path)
238
+ # Parse a dotenv file into ENV.
239
+ #
240
+ # override=false (default): `ENV[key] ||= value` — first-wins; a key
241
+ # already present (real env var, or a higher-precedence file loaded
242
+ # earlier) is never clobbered. Both .env.local and .env are loaded this
243
+ # way; ordering (.env.local first) gives the precedence real-env >
244
+ # .env.local > .env.
245
+ # override=true: `ENV[key] = value` — unconditional set. NOT used by the
246
+ # boot load sequence; kept only as an explicit opt-in for callers that
247
+ # genuinely need to force values.
248
+ def parse_env_file(path, override: false)
217
249
  return unless File.exist?(path)
218
250
  File.readlines(path).each do |line|
219
251
  line = line.strip
@@ -221,7 +253,11 @@ module Tina4
221
253
  if (match = line.match(/\A([A-Za-z_][A-Za-z0-9_]*)=["']?(.*)["']?\z/))
222
254
  key = match[1]
223
255
  value = match[2].gsub(/["']\z/, "")
224
- ENV[key] ||= value
256
+ if override
257
+ ENV[key] = value
258
+ else
259
+ ENV[key] ||= value
260
+ end
225
261
  @loaded_keys ||= []
226
262
  @loaded_keys << key
227
263
  end
data/lib/tina4/events.rb CHANGED
@@ -60,13 +60,28 @@ module Tina4
60
60
  #
61
61
  # results = Tina4::Events.emit("user.created", user_data)
62
62
  #
63
- def emit(event, *args)
63
+ # Listener isolation (visible-but-resilient): each listener is called
64
+ # inside a rescue so ONE throwing listener never aborts the rest of the
65
+ # emit. A failed listener is LOGGED (never silent) and contributes a nil
66
+ # slot, so N listeners always yield N results in priority order. Pass
67
+ # strict: true to RE-RAISE on the first listener error instead of
68
+ # isolating (later listeners then do NOT run).
69
+ def emit(event, *args, strict: false)
64
70
  entries = @listeners[event].dup
65
71
  results = []
66
72
  entries.each do |entry|
67
- # Remove one-time listeners before calling so re-entrant emits are safe
73
+ # Remove one-time listeners before calling so re-entrant emits are
74
+ # safe AND so the one-shot is gone before any throw — cleanup stays
75
+ # correct under isolation.
68
76
  @listeners[event].delete(entry) if entry[:once]
69
- results << entry[:callback].call(*args)
77
+ begin
78
+ results << entry[:callback].call(*args)
79
+ rescue StandardError, ScriptError => error
80
+ raise if strict
81
+
82
+ log_listener_error(event, error)
83
+ results << nil
84
+ end
70
85
  end
71
86
  results
72
87
  end
@@ -82,19 +97,29 @@ module Tina4
82
97
  end
83
98
 
84
99
  # Fire an event asynchronously. Each listener runs in its own thread.
85
- # Errors in listeners are silently caught.
86
100
  #
87
101
  # Tina4::Events.emit_async("user.created", user_data)
88
102
  #
89
- def emit_async(event, *args)
103
+ # Listener isolation: each threaded listener is wrapped so one rejection
104
+ # never aborts the others. A failed listener is LOGGED (never silent).
105
+ # Pass strict: true to RE-RAISE the first listener error on the main
106
+ # thread (the thread that raised aborts on join) instead of isolating.
107
+ def emit_async(event, *args, strict: false)
90
108
  return unless @listeners&.key?(event)
91
109
 
92
- @listeners[event].sort_by { |l| -(l[:priority] || 0) }.each do |listener|
110
+ @listeners[event].sort_by { |l| -(l[:priority] || 0) }.map do |listener|
93
111
  Thread.new do
112
+ # In strict mode the error is re-raised to surface on #join; mute
113
+ # the per-thread auto-report so the deliberate raise doesn't spam
114
+ # stderr (the caller still sees it via join).
115
+ Thread.current.report_on_exception = false if strict
94
116
  begin
95
117
  listener[:callback].call(*args)
96
- rescue => e
97
- # Async emit silently catches errors
118
+ rescue StandardError, ScriptError => error
119
+ raise if strict
120
+
121
+ log_listener_error(event, error)
122
+ nil
98
123
  end
99
124
  end
100
125
  end
@@ -104,6 +129,27 @@ module Tina4
104
129
  def clear
105
130
  @listeners.clear
106
131
  end
132
+
133
+ private
134
+
135
+ # NEVER silently swallow a listener error — always surface it. Logs via
136
+ # Tina4::Log.warning with BOTH the event name and the error class+message.
137
+ # Wrapped so a broken logger can't break the bus: on any logger failure it
138
+ # falls back to $stderr so the error is still seen.
139
+ def log_listener_error(event, error)
140
+ Tina4::Log.warning(
141
+ "Event listener for '#{event}' raised #{error.class.name}: #{error.message}"
142
+ )
143
+ rescue StandardError
144
+ begin
145
+ $stderr.puts(
146
+ "Event listener for '#{event}' raised #{error.class.name}: #{error.message}"
147
+ )
148
+ $stderr.flush
149
+ rescue StandardError
150
+ # Last-resort: never let logging break the event bus.
151
+ end
152
+ end
107
153
  end
108
154
  end
109
155
  end
@@ -20,8 +20,11 @@ module Tina4
20
20
  @table_name = name
21
21
  else
22
22
  base = self.name.split("::").last.downcase
23
- # Pluralize by default (add "s") unless ORM_PLURAL_TABLE_NAMES is explicitly disabled
24
- unless ENV.fetch("TINA4_ORM_PLURAL_TABLE_NAMES", "").match?(/\A(false|0|no)\z/i)
23
+ # Pluralization is OFF by default (canonical, matching the Python
24
+ # master): the table name is the bare class name lowercased. Opt in by
25
+ # setting TINA4_ORM_PLURAL_TABLE_NAMES to a truthy value (true/1/yes/on)
26
+ # to append "s".
27
+ if ENV.fetch("TINA4_ORM_PLURAL_TABLE_NAMES", "").match?(/\A(true|1|yes|on)\z/i)
25
28
  base += "s" unless base.end_with?("s")
26
29
  end
27
30
  @table_name || base
data/lib/tina4/graphql.rb CHANGED
@@ -463,12 +463,33 @@ module Tina4
463
463
  arguments = parse_arguments
464
464
  end
465
465
 
466
+ # Directives (@skip, @include, @auth, @role, @guest, ...) appear after the
467
+ # field's arguments and before its selection set. Without parsing them the
468
+ # executor's check_directives never fires — @skip/@include are silently
469
+ # ignored. Each directive is { name:, arguments: } to match check_directives.
470
+ directives = parse_directives
471
+
466
472
  selection_set = nil
467
473
  if current&.value == "{"
468
474
  selection_set = parse_selection_set
469
475
  end
470
476
 
471
- { kind: :field, name: field_name, alias: alias_name, arguments: arguments, selection_set: selection_set }
477
+ { kind: :field, name: field_name, alias: alias_name, arguments: arguments, directives: directives, selection_set: selection_set }
478
+ end
479
+
480
+ # Parse a run of leading @directive(args) tokens into an array of
481
+ # { name:, arguments: } hashes (the shape GraphQLExecutor#check_directives
482
+ # consumes). Returns [] when no directive is present.
483
+ def parse_directives
484
+ directives = []
485
+ while current && current.type == :punct && current.value == "@"
486
+ advance # consume '@'
487
+ name = expect(:name).value
488
+ args = {}
489
+ args = parse_arguments if current&.value == "("
490
+ directives << { name: name, arguments: args }
491
+ end
492
+ directives
472
493
  end
473
494
 
474
495
  def parse_arguments
@@ -575,8 +596,13 @@ module Tina4
575
596
  # ─── Executor ─────────────────────────────────────────────────────────
576
597
 
577
598
  class GraphQLExecutor
578
- def initialize(schema)
599
+ # max_depth bounds selection-set nesting (DoS / stack-overflow guard).
600
+ # Threaded down from the owning GraphQL instance; <= 0 disables the guard.
601
+ attr_accessor :max_depth
602
+
603
+ def initialize(schema, max_depth: 50)
579
604
  @schema = schema
605
+ @max_depth = max_depth
580
606
  end
581
607
 
582
608
  def execute(document, variables: {}, context: {}, operation_name: nil)
@@ -618,8 +644,12 @@ module Tina4
618
644
  data = {}
619
645
  errors = []
620
646
 
647
+ # Top-level selections start at depth 1. Depth is incremented on every
648
+ # recursive entry (sub-selections, fragment spreads, inline fragments) so
649
+ # an over-deep query OR a circular fragment is caught before the
650
+ # interpreter stack overflows.
621
651
  operation[:selection_set].each do |selection|
622
- resolve_selection(selection, root_fields, nil, resolved_vars, context, fragments, data, errors)
652
+ resolve_selection(selection, root_fields, nil, resolved_vars, context, fragments, data, errors, 1)
623
653
  end
624
654
 
625
655
  result = { "data" => data }
@@ -629,25 +659,31 @@ module Tina4
629
659
 
630
660
  private
631
661
 
632
- def resolve_selection(selection, fields, parent, variables, context, fragments, data, errors)
662
+ def resolve_selection(selection, fields, parent, variables, context, fragments, data, errors, depth = 1)
663
+ # Depth guard — append a structured error and stop recursing this branch.
664
+ if @max_depth && @max_depth > 0 && depth > @max_depth
665
+ errors << { "message" => "Query exceeds maximum depth of #{@max_depth}" }
666
+ return
667
+ end
668
+
633
669
  case selection[:kind]
634
670
  when :field
635
- resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
671
+ resolve_field(selection, fields, parent, variables, context, fragments, data, errors, depth)
636
672
  when :fragment_spread
637
673
  frag = fragments[selection[:name]]
638
674
  if frag
639
675
  frag[:selection_set].each do |sel|
640
- resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
676
+ resolve_selection(sel, fields, parent, variables, context, fragments, data, errors, depth + 1)
641
677
  end
642
678
  end
643
679
  when :inline_fragment
644
680
  selection[:selection_set].each do |sel|
645
- resolve_selection(sel, fields, parent, variables, context, fragments, data, errors)
681
+ resolve_selection(sel, fields, parent, variables, context, fragments, data, errors, depth + 1)
646
682
  end
647
683
  end
648
684
  end
649
685
 
650
- def resolve_field(selection, fields, parent, variables, context, fragments, data, errors)
686
+ def resolve_field(selection, fields, parent, variables, context, fragments, data, errors, depth = 1)
651
687
  field_name = selection[:name]
652
688
  output_name = selection[:alias] || field_name
653
689
 
@@ -687,7 +723,7 @@ module Tina4
687
723
  nested = {}
688
724
  sub_fields = item.is_a?(Hash) ? item_fields(item) : {}
689
725
  selection[:selection_set].each do |sel|
690
- resolve_selection(sel, sub_fields, item, variables, context, fragments, nested, errors)
726
+ resolve_selection(sel, sub_fields, item, variables, context, fragments, nested, errors, depth + 1)
691
727
  end
692
728
  nested
693
729
  end
@@ -695,7 +731,7 @@ module Tina4
695
731
  nested = {}
696
732
  sub_fields = item_fields(value)
697
733
  selection[:selection_set].each do |sel|
698
- resolve_selection(sel, sub_fields, value, variables, context, fragments, nested, errors)
734
+ resolve_selection(sel, sub_fields, value, variables, context, fragments, nested, errors, depth + 1)
699
735
  end
700
736
  data[output_name] = nested
701
737
  else
@@ -705,7 +741,12 @@ module Tina4
705
741
  data[output_name] = coerce_value(value)
706
742
  end
707
743
  rescue => e
708
- errors << { "message" => e.message, "path" => [output_name] }
744
+ # Log the real cause; only surface the detail to the client in debug
745
+ # mode — a resolver exception can carry internal state (DB errors,
746
+ # credentials) that must not leak. Mirrors the Python master.
747
+ Tina4::Log.error("GraphQL resolver '#{field_name}' failed: #{e.message}")
748
+ detail = Tina4::Env.is_truthy(ENV["TINA4_DEBUG"]) ? e.message : "Internal server error"
749
+ errors << { "message" => detail, "path" => [output_name] }
709
750
  data[output_name] = nil
710
751
  end
711
752
  end
@@ -845,6 +886,16 @@ module Tina4
845
886
  class GraphQL
846
887
  attr_reader :schema
847
888
 
889
+ # Maximum selection-set nesting depth (DoS / stack-overflow guard).
890
+ # Read from TINA4_GRAPHQL_MAX_DEPTH (default 50; <= 0 disables). Exposed so
891
+ # tests/app code can override it; the writer keeps the executor in sync.
892
+ attr_reader :max_depth
893
+
894
+ def max_depth=(value)
895
+ @max_depth = value
896
+ @executor&.max_depth = value
897
+ end
898
+
848
899
  # Class-level toggle for ORM auto-schema generation. Defaults to true,
849
900
  # can be disabled via TINA4_GRAPHQL_AUTO_SCHEMA=false. Initializers and
850
901
  # user app code can branch on this before calling `schema.from_orm(...)`.
@@ -912,7 +963,12 @@ module Tina4
912
963
 
913
964
  def initialize(schema = nil)
914
965
  @schema = schema || GraphQLSchema.new
915
- @executor = GraphQLExecutor.new(@schema)
966
+ # Maximum selection-set nesting depth. A deeply nested query (or a
967
+ # circular fragment) would otherwise recurse without bound — a classic
968
+ # GraphQL DoS / stack-overflow vector. Default 50 is far beyond any
969
+ # legitimate query; TINA4_GRAPHQL_MAX_DEPTH <= 0 disables the guard.
970
+ @max_depth = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_DEPTH", "50"), exception: false) || 50
971
+ @executor = GraphQLExecutor.new(@schema, max_depth: @max_depth)
916
972
  @field_resolvers = {}
917
973
 
918
974
  # Drain any resolvers registered via the class-level GraphQL.resolve()
@@ -1,6 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
+ # Marker for trusted, pre-sanitised HTML that must render UNESCAPED.
5
+ #
6
+ # String/scalar children of an HtmlElement are HTML-escaped by default to
7
+ # prevent stored/reflected XSS. Wrap a value in Raw to opt out of escaping
8
+ # when (and only when) you have already sanitised it yourself.
9
+ #
10
+ # Tina4::HtmlElement.new("div").call("<b>x</b>") # &lt;b&gt;x&lt;/b&gt; (escaped)
11
+ # Tina4::HtmlElement.new("div").call(Tina4::Raw.new("<b>x</b>")) # <b>x</b> (raw)
12
+ #
13
+ # Raw is the primary name; SafeString is the alias (and is the same class
14
+ # Frond already uses to mark trusted output, so a SafeString returned by a
15
+ # Frond filter also renders raw as an HtmlElement child). Defined defensively
16
+ # in case this file is ever loaded before Frond.
17
+ SafeString = Class.new(String) unless defined?(Tina4::SafeString)
18
+
19
+ # Primary name for the trusted-markup wrapper — an alias of SafeString.
20
+ Raw = SafeString
21
+
22
+ # Convenience constructor so callers can write Tina4::Raw("<b>x</b>")
23
+ # in addition to Tina4::Raw.new("<b>x</b>").
24
+ def self.Raw(value)
25
+ Raw.new(value.to_s)
26
+ end
27
+
4
28
  # Programmatic HTML builder — avoids string concatenation.
5
29
  #
6
30
  # Usage:
@@ -14,6 +38,9 @@ module Tina4
14
38
  # include Tina4::HtmlHelpers
15
39
  # html = _div({ class: "card" }, _p("Hello"))
16
40
  #
41
+ # String/scalar children are HTML-escaped by default to defeat XSS; nested
42
+ # HtmlElement children render themselves (already escaped, no double-escape);
43
+ # a Raw / SafeString child renders verbatim (explicit opt-in for trusted markup).
17
44
  class HtmlElement
18
45
  VOID_TAGS = %w[
19
46
  area base br col embed hr img input
@@ -90,12 +117,7 @@ module Tina4
90
117
  when false, nil
91
118
  next
92
119
  else
93
- escaped = value.to_s
94
- .gsub("&", "&amp;")
95
- .gsub('"', "&quot;")
96
- .gsub("<", "&lt;")
97
- .gsub(">", "&gt;")
98
- html << " #{key}=\"#{escaped}\""
120
+ html << " #{key}=\"#{escape_text(value.to_s)}\""
99
121
  end
100
122
  end
101
123
 
@@ -107,12 +129,38 @@ module Tina4
107
129
  html << ">"
108
130
 
109
131
  @children.each do |child|
110
- html << child.to_s
132
+ case child
133
+ when HtmlElement
134
+ # Nested elements render themselves (they already escape their own
135
+ # children) — emit as-is to avoid double-escaping.
136
+ html << child.to_s
137
+ when Raw
138
+ # Explicitly trusted markup — emit unescaped. Raw subclasses String,
139
+ # so this branch MUST come before the generic scalar escape below.
140
+ html << child.to_s
141
+ else
142
+ # Plain string/scalar child — escape to defeat stored/reflected XSS.
143
+ html << escape_text(child.to_s)
144
+ end
111
145
  end
112
146
 
113
147
  html << "</#{@tag}>"
114
148
  html
115
149
  end
150
+
151
+ private
152
+
153
+ # HTML-escape a string for safe inclusion in text content or a
154
+ # double-quoted attribute value. Mirrors Python's html.escape(quote=True):
155
+ # & < > " ' are all encoded.
156
+ def escape_text(value)
157
+ value
158
+ .gsub("&", "&amp;")
159
+ .gsub("<", "&lt;")
160
+ .gsub(">", "&gt;")
161
+ .gsub('"', "&quot;")
162
+ .gsub("'", "&#x27;")
163
+ end
116
164
  end
117
165
 
118
166
  # Module providing _div, _p, _span, etc. helper methods.