tina4ruby 3.13.98 → 3.13.99

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/lib/tina4/ai.rb +32 -3
  4. data/lib/tina4/api.rb +5 -0
  5. data/lib/tina4/auto_crud.rb +62 -4
  6. data/lib/tina4/background.rb +112 -31
  7. data/lib/tina4/cache.rb +3 -2
  8. data/lib/tina4/cli.rb +55 -67
  9. data/lib/tina4/database.rb +97 -49
  10. data/lib/tina4/database_adapter.rb +169 -15
  11. data/lib/tina4/dev_admin.rb +137 -9
  12. data/lib/tina4/dispatch_pipeline.rb +145 -4
  13. data/lib/tina4/drivers/firebird_driver.rb +59 -12
  14. data/lib/tina4/drivers/mongodb_driver.rb +98 -14
  15. data/lib/tina4/drivers/mssql_driver.rb +39 -2
  16. data/lib/tina4/drivers/mysql_driver.rb +43 -3
  17. data/lib/tina4/drivers/odbc_driver.rb +36 -2
  18. data/lib/tina4/drivers/postgres_driver.rb +5 -0
  19. data/lib/tina4/drivers/sqlite_driver.rb +11 -1
  20. data/lib/tina4/env.rb +1 -1
  21. data/lib/tina4/error_overlay.rb +43 -49
  22. data/lib/tina4/field_types.rb +33 -16
  23. data/lib/tina4/frond.rb +24 -2
  24. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
  25. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
  26. data/lib/tina4/graphql.rb +2 -2
  27. data/lib/tina4/log.rb +652 -485
  28. data/lib/tina4/mcp.rb +9 -1
  29. data/lib/tina4/messenger.rb +25 -0
  30. data/lib/tina4/middleware.rb +189 -76
  31. data/lib/tina4/migration.rb +47 -15
  32. data/lib/tina4/orm.rb +280 -59
  33. data/lib/tina4/port_takeover.rb +202 -0
  34. data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
  35. data/lib/tina4/rack_app.rb +201 -59
  36. data/lib/tina4/realtime.rb +6 -1
  37. data/lib/tina4/request.rb +259 -51
  38. data/lib/tina4/router.rb +20 -2
  39. data/lib/tina4/seeder.rb +68 -19
  40. data/lib/tina4/shutdown.rb +4 -0
  41. data/lib/tina4/sql_translator.rb +115 -86
  42. data/lib/tina4/swagger.rb +19 -3
  43. data/lib/tina4/template.rb +61 -6
  44. data/lib/tina4/test_client.rb +49 -3
  45. data/lib/tina4/testing.rb +16 -11
  46. data/lib/tina4/validator.rb +7 -1
  47. data/lib/tina4/version.rb +1 -1
  48. data/lib/tina4/webserver.rb +28 -40
  49. data/lib/tina4.rb +12 -1
  50. metadata +3 -2
@@ -70,9 +70,11 @@ module Tina4
70
70
  @last_insert_id = result.inserted_id.to_s
71
71
  result
72
72
  when :update
73
- collection.update_many(parsed[:filter] || {}, { "$set" => parsed[:updates] })
73
+ # parse_update guarantees a scoped filter (or it raised) no || {} fallback.
74
+ collection.update_many(parsed[:filter], { "$set" => parsed[:updates] })
74
75
  when :delete
75
- collection.delete_many(parsed[:filter] || {})
76
+ # parse_delete guarantees a scoped filter (or it raised) — no || {} fallback.
77
+ collection.delete_many(parsed[:filter])
76
78
  when :create_collection
77
79
  begin
78
80
  @db.command(create: parsed[:collection].to_s)
@@ -91,6 +93,49 @@ module Tina4
91
93
  @last_insert_id
92
94
  end
93
95
 
96
+ # Atomic, monotonic, concurrency-safe next id — feature 16. A
97
+ # findOneAndUpdate($inc) on the tina4_sequences collection, keyed by _id
98
+ # (its built-in unique index makes concurrent first-use upserts race-safe:
99
+ # two callers can never create two counters for one table). Seeds from
100
+ # MAX(pk_column) the FIRST time only ($setOnInsert). Raises on an
101
+ # impossible empty result rather than returning a fixed id that could
102
+ # collide with an existing row.
103
+ def get_next_id(table, pk_column = "id")
104
+ sequences = @db["tina4_sequences"]
105
+ seq_name = "#{table}.#{pk_column}"
106
+
107
+ if sequences.find("_id" => seq_name).first.nil?
108
+ seed = 0
109
+ begin
110
+ max_doc = @db[table.to_s].find.sort(pk_column => -1).limit(1).first
111
+ seed = max_doc[pk_column].to_i if max_doc && max_doc[pk_column]
112
+ rescue StandardError
113
+ # Collection may not exist yet — seed 0.
114
+ end
115
+ begin
116
+ sequences.update_one(
117
+ { "_id" => seq_name },
118
+ { "$setOnInsert" => { "current_value" => seed } },
119
+ upsert: true
120
+ )
121
+ rescue StandardError
122
+ # Race — another caller seeded first; the atomic $inc below still holds.
123
+ end
124
+ end
125
+
126
+ doc = sequences.find_one_and_update(
127
+ { "_id" => seq_name },
128
+ { "$inc" => { "current_value" => 1 } },
129
+ return_document: :after,
130
+ upsert: true
131
+ )
132
+ if doc.nil? || doc["current_value"].nil?
133
+ raise "get_next_id: MongoDB counter '#{seq_name}' produced no value"
134
+ end
135
+
136
+ doc["current_value"].to_i
137
+ end
138
+
94
139
  def placeholder
95
140
  "?"
96
141
  end
@@ -126,6 +171,11 @@ module Tina4
126
171
  # no-op
127
172
  end
128
173
 
174
+ # ADR-0044 required adapter capability.
175
+ def get_database_type
176
+ 'mongodb'
177
+ end
178
+
129
179
  def tables
130
180
  @db.collection_names.reject { |n| n.start_with?("system.") }
131
181
  end
@@ -351,6 +401,17 @@ module Tina4
351
401
  def parse_condition(clause)
352
402
  clause = clause.strip.gsub(/^\(+/, "").gsub(/\)+$/, "").strip
353
403
 
404
+ # Explicit 1=1 tautology -- the WHERE clause truncate() passes -- means
405
+ # MATCH-ALL: translate it to an empty {} filter so truncate() empties the
406
+ # collection, exactly as PHP already does. Without this, "1 = 1" fell
407
+ # through to the "=" comparison below and parsed as { "1" => 1 }, which
408
+ # matches NOTHING, so a truncate() silently deleted 0 documents while the
409
+ # caller believed the collection was emptied. This does NOT weaken the
410
+ # fail-closed guard: 1=1 is an EXPLICIT tautology, distinct from an
411
+ # unparseable WHERE (the raise at the end of this method still fires) and
412
+ # from a blank/absent WHERE (which require_where_for_write still rejects).
413
+ return {} if clause.match?(/\A1\s*=\s*1\z/)
414
+
354
415
  # IS NULL / IS NOT NULL
355
416
  if (m = clause.match(/^(\w+)\s+IS\s+NOT\s+NULL$/i))
356
417
  return { m[1] => { "$ne" => nil } }
@@ -398,8 +459,33 @@ module Tina4
398
459
  end
399
460
  end
400
461
 
401
- # Fallback return as a raw string comment (best-effort)
402
- {}
462
+ # Fail closed. An unrecognised condition must NEVER degrade to an empty
463
+ # (match-all) filter: on a DELETE/UPDATE that empty filter reaches
464
+ # delete_many({})/update_many({}) and wipes or rewrites the WHOLE
465
+ # collection. Raise so the caller sees the unsupported SQL instead of
466
+ # silently losing data.
467
+ raise ArgumentError,
468
+ "Unsupported MongoDB WHERE condition: #{clause.inspect}. The MongoDB " \
469
+ "SQL provider fails closed rather than matching every document. " \
470
+ "Supported: = != <> > >= < <= LIKE, NOT LIKE, IN, NOT IN, " \
471
+ "IS [NOT] NULL, AND, OR."
472
+ end
473
+
474
+ # Fail closed: a DELETE/UPDATE must carry a WHERE clause.
475
+ #
476
+ # A missing or blank WHERE translates to an empty MongoDB filter, which
477
+ # matches EVERY document, so delete_many({})/update_many({}) would wipe or
478
+ # rewrite the whole collection. Refuse it. The explicit whole-collection
479
+ # spelling is truncate() (it passes WHERE 1 = 1); the native driver via
480
+ # #connection is the escape hatch for anything the SQL subset cannot
481
+ # express. Shared by both write paths so the guard cannot drift.
482
+ def require_where_for_write(where_str, operation, collection)
483
+ return unless where_str.nil? || where_str.strip.empty?
484
+
485
+ raise ArgumentError,
486
+ "Refusing to #{operation} every document in '#{collection}': the " \
487
+ "statement has no WHERE clause, which would affect the whole " \
488
+ "collection. Add a WHERE, or use truncate() to clear it explicitly."
403
489
  end
404
490
 
405
491
  def parse_value(str)
@@ -474,10 +560,7 @@ module Tina4
474
560
 
475
561
  m = sql.match(/UPDATE\s+(\w+)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$/im)
476
562
  unless m
477
- result[:collection] = :unknown
478
- result[:updates] = {}
479
- result[:filter] = {}
480
- return result
563
+ raise ArgumentError, "MongodbDriver: cannot parse UPDATE statement: #{sql}"
481
564
  end
482
565
 
483
566
  result[:collection] = m[1].to_sym
@@ -497,9 +580,10 @@ module Tina4
497
580
  end
498
581
  result[:updates] = updates
499
582
 
500
- # Parse WHERE
583
+ # Parse WHERE — refuse a filterless UPDATE (would rewrite the whole collection).
501
584
  where_str = m[3]&.strip
502
- result[:filter] = where_str && !where_str.empty? ? parse_where(where_str) : {}
585
+ require_where_for_write(where_str, "UPDATE", result[:collection])
586
+ result[:filter] = parse_where(where_str)
503
587
 
504
588
  result
505
589
  end
@@ -533,14 +617,14 @@ module Tina4
533
617
 
534
618
  m = sql.match(/DELETE\s+FROM\s+(\w+)(?:\s+WHERE\s+(.+))?$/im)
535
619
  unless m
536
- result[:collection] = :unknown
537
- result[:filter] = {}
538
- return result
620
+ raise ArgumentError, "MongodbDriver: cannot parse DELETE statement: #{sql}"
539
621
  end
540
622
 
541
623
  result[:collection] = m[1].to_sym
624
+ # Refuse a filterless DELETE (would empty the whole collection).
542
625
  where_str = m[2]&.strip
543
- result[:filter] = where_str && !where_str.empty? ? parse_where(where_str) : {}
626
+ require_where_for_write(where_str, "DELETE", result[:collection])
627
+ result[:filter] = parse_where(where_str)
544
628
 
545
629
  result
546
630
  end
@@ -153,6 +153,11 @@ module Tina4
153
153
  !rows.empty?
154
154
  end
155
155
 
156
+ # ADR-0044 required adapter capability.
157
+ def get_database_type
158
+ 'mssql'
159
+ end
160
+
156
161
  def tables
157
162
  rows = execute_query("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'")
158
163
  rows.map { |r| r[:TABLE_NAME] || r[:table_name] }
@@ -224,14 +229,46 @@ module Tina4
224
229
  elsif param.is_a?(Time) || param.is_a?(DateTime)
225
230
  "'#{(param.respond_to?(:iso8601) ? param.iso8601 : param.to_s).gsub("'", "''")}'"
226
231
  elsif param.is_a?(String)
227
- "'#{param.gsub("'", "''")}'"
228
- else
232
+ interpolate_string(param)
233
+ elsif param.is_a?(Integer)
234
+ param.to_s
235
+ elsif param.is_a?(Float)
236
+ # A finite Float is a valid numeric literal; NaN/Infinity are not
237
+ # representable in T-SQL and would stringify to a bareword.
238
+ raise ArgumentError, "MssqlDriver cannot bind a non-finite Float (#{param})" unless param.finite?
239
+
240
+ param.to_s
241
+ elsif param.is_a?(Numeric)
242
+ # BigDecimal / Rational -> a plain decimal literal.
229
243
  param.to_s
244
+ else
245
+ # MSSQL-INTERP-RUBY: the old `else param.to_s` emitted a BAREWORD for
246
+ # any unrecognised type (a Symbol became `WHERE x = active`, invalid
247
+ # or unintended SQL - a breakage / injection vector). Refuse it loudly
248
+ # instead of splicing an arbitrary object's #to_s into the statement.
249
+ raise ArgumentError,
250
+ "MssqlDriver cannot safely bind a #{param.class} parameter to MSSQL " \
251
+ "(#{param.inspect}); pass nil, true/false, a Time, a String " \
252
+ "(text, or ASCII-8BIT bytes), or a Numeric - never a bareword"
230
253
  end
231
254
  result = result.sub("?", escaped)
232
255
  end
233
256
  result
234
257
  end
258
+
259
+ # Bind a String parameter. ASCII-8BIT (binary) bytes become a T-SQL
260
+ # varbinary literal `0x...`, NOT a quoted string: FreeTDS (tiny_tds) cannot
261
+ # carry raw binary through a quoted literal - a NUL byte breaks the TDS
262
+ # stream / truncates the value - whereas a `0x` literal round-trips
263
+ # byte-for-byte (MEASURED against real SQL Server). A text String keeps the
264
+ # quote-doubled literal (correct T-SQL escaping).
265
+ def interpolate_string(param)
266
+ if param.encoding == Encoding::BINARY
267
+ param.empty? ? "0x" : "0x#{param.unpack1('H*')}"
268
+ else
269
+ "'#{param.gsub("'", "''")}'"
270
+ end
271
+ end
235
272
  end
236
273
  end
237
274
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "schema_split"
4
+ require_relative "../sql_translator"
4
5
 
5
6
  module Tina4
6
7
  module Drivers
@@ -64,7 +65,19 @@ module Tina4
64
65
  @connection&.close
65
66
  end
66
67
 
68
+ # Apply the MySQL dialect rewrites the translator owns: +||+ -> CONCAT and
69
+ # ILIKE -> LOWER() LIKE LOWER() (both literal-safe). MySQL reads a bare +||+
70
+ # as logical OR and has no ILIKE, so portable canonical SQL must be rewritten
71
+ # before it reaches the driver. A statement with neither token short-circuits
72
+ # unchanged, so ordinary queries are untouched. (SQLTRANS-DEC-02: this is the
73
+ # wiring that makes the previously-dead concat/ilike helpers live in Ruby.)
74
+ def translate_dialect(sql)
75
+ sql = Tina4::SQLTranslator.concat_pipes_to_func(sql)
76
+ Tina4::SQLTranslator.ilike_to_like(sql)
77
+ end
78
+
67
79
  def execute_query(sql, params = [])
80
+ sql = translate_dialect(sql)
68
81
  if params.empty?
69
82
  results = @connection.query(sql, symbolize_keys: true)
70
83
  else
@@ -75,6 +88,7 @@ module Tina4
75
88
  end
76
89
 
77
90
  def execute(sql, params = [])
91
+ sql = translate_dialect(sql)
78
92
  stmt = nil
79
93
  result =
80
94
  if params.empty?
@@ -117,10 +131,14 @@ module Tina4
117
131
 
118
132
  if sql.to_s.lstrip[0, 6].casecmp?("INSERT")
119
133
  first_id = @connection.last_id
120
- rows = @affected_rows.to_i
134
+ # MySQL reports the FIRST id of a multi-row INSERT; normalise to the
135
+ # LAST via the shared SQLTranslator helper (the one place that knows the
136
+ # first id and the row count) instead of the inline arithmetic
137
+ # (MYSQL-BATCH-ID-DUP). Guarded on a positive id so a non-auto-increment
138
+ # insert keeps whatever the driver reported.
121
139
  @last_insert_id =
122
140
  if first_id.to_i.positive?
123
- first_id.to_i + [rows, 1].max - 1
141
+ Tina4::SQLTranslator.batch_last_id(first_id, @affected_rows.to_i, "mysql")
124
142
  else
125
143
  first_id
126
144
  end
@@ -179,13 +197,23 @@ module Tina4
179
197
  !rows.empty?
180
198
  end
181
199
 
200
+ # ADR-0044 required adapter capability.
201
+ def get_database_type
202
+ 'mysql'
203
+ end
204
+
182
205
  def tables
183
206
  rows = execute_query("SHOW TABLES")
184
207
  rows.map { |r| r.values.first }
185
208
  end
186
209
 
187
210
  def columns(table_name)
188
- rows = execute_query("DESCRIBE #{table_name}")
211
+ # DESCRIBE takes an IDENTIFIER, not a bind parameter, so the table name is
212
+ # made injection-safe by STRICT backtick-quoting (escaping embedded
213
+ # backticks) rather than interpolated raw (MYSQL-DESCRIBE-UNPARAM): a
214
+ # crafted/odd name becomes ONE escaped identifier - a clean "unknown
215
+ # table", never runnable SQL - and an odd-but-valid name introspects.
216
+ rows = execute_query("DESCRIBE #{quote_mysql_identifier(table_name)}")
189
217
  rows.map do |r|
190
218
  {
191
219
  name: r[:Field],
@@ -196,6 +224,18 @@ module Tina4
196
224
  }
197
225
  end
198
226
  end
227
+
228
+ private
229
+
230
+ # Strict backtick-quote a (possibly schema-qualified) identifier, ESCAPING
231
+ # embedded backticks, for use where a bind parameter is not accepted
232
+ # (DESCRIBE). A crafted/odd name becomes ONE escaped identifier - a clean
233
+ # "unknown table", never runnable SQL (MYSQL-DESCRIBE-UNPARAM).
234
+ def quote_mysql_identifier(name)
235
+ schema, table = split_schema(name.to_s)
236
+ q = ->(part) { "`#{part.to_s.gsub('`', '``')}`" }
237
+ schema.nil? ? q.call(table) : "#{q.call(schema)}.#{q.call(table)}"
238
+ end
199
239
  end
200
240
  end
201
241
  end
@@ -70,7 +70,15 @@ module Tina4
70
70
  dsn_string = "#{dsn_string};PWD=#{password}"
71
71
  end
72
72
 
73
- @connection = ODBC::Database.new(dsn_string)
73
+ # ODBC::Database.new(string) routes to SQLConnect, which takes a DSN NAME
74
+ # (data source), NOT a connection string - so a DRIVER={...} / DSN=...
75
+ # string (exactly what Tina4's odbc:/// URL produces) raised "Invalid
76
+ # string or buffer length" against a real driver. SQLDriverConnect is the
77
+ # call that parses a connection string; reach it via #drvconnect on an
78
+ # unconnected handle. MEASURED against a real psqlodbc source: this adapter
79
+ # could never connect with a connection string until now.
80
+ @connection = ODBC::Database.new
81
+ @connection.drvconnect(dsn_string)
74
82
  @in_transaction = false
75
83
  self
76
84
  end
@@ -177,6 +185,11 @@ module Tina4
177
185
  end
178
186
 
179
187
  # List all user tables via ODBC metadata.
188
+ # ADR-0044 required adapter capability.
189
+ def get_database_type
190
+ 'odbc'
191
+ end
192
+
180
193
  def tables
181
194
  stmt = @connection.tables
182
195
  rows = []
@@ -194,6 +207,7 @@ module Tina4
194
207
 
195
208
  # Return column metadata for a table via ODBC metadata.
196
209
  def columns(table_name)
210
+ pk = primary_key_columns(table_name)
197
211
  stmt = @connection.columns(table_name.to_s)
198
212
  result = []
199
213
  while (row = stmt.fetch_hash)
@@ -206,7 +220,10 @@ module Tina4
206
220
  type: type.to_s,
207
221
  nullable: nullable_val.to_i == 1,
208
222
  default: default,
209
- primary_key: false # ODBC metadata does not reliably expose PK flag here
223
+ # Real PK, from the ODBC catalog (SQLPrimaryKeys) - not the old
224
+ # `false` stub. The write-guard reads primary_key, so without this a
225
+ # PK-keyed update(table, data) on ODBC could not introspect the key.
226
+ primary_key: pk.include?(name.to_s.downcase)
210
227
  }
211
228
  end
212
229
  stmt.drop
@@ -216,6 +233,23 @@ module Tina4
216
233
  raise e
217
234
  end
218
235
 
236
+ # The table's primary-key columns from the ODBC catalog (SQLPrimaryKeys),
237
+ # down-cased for case-insensitive matching. Empty on any target that does
238
+ # not report them - the write-guard then requires an explicit filter.
239
+ def primary_key_columns(table_name)
240
+ stmt = @connection.primary_keys(table_name.to_s)
241
+ cols = []
242
+ while (row = stmt.fetch_hash)
243
+ col = row["COLUMN_NAME"] || row[:COLUMN_NAME]
244
+ cols << col.to_s.downcase if col
245
+ end
246
+ stmt.drop
247
+ cols
248
+ rescue StandardError
249
+ stmt&.drop rescue nil
250
+ []
251
+ end
252
+
219
253
  private
220
254
 
221
255
  # (No symbolize_keys here: execute_query already hydrates by zipping a
@@ -218,6 +218,11 @@ module Tina4
218
218
  !rows.empty? && !rows[0][:oid].nil?
219
219
  end
220
220
 
221
+ # ADR-0044 required adapter capability.
222
+ def get_database_type
223
+ "postgres"
224
+ end
225
+
221
226
  def tables
222
227
  # v3.13.14 (#48): list every user schema; public tables stay bare,
223
228
  # others are returned schema-qualified.
@@ -183,6 +183,7 @@ module Tina4
183
183
  pragma = schema && identifier?(schema) && identifier?(tbl) ? "#{schema}.table_info(#{tbl})" : "table_info(#{table_name})"
184
184
  rows = execute_query("PRAGMA #{pragma}")
185
185
  rows.map do |r|
186
+ pk = r[:pk].to_i
186
187
  {
187
188
  name: r[:name],
188
189
  type: r[:type],
@@ -191,11 +192,20 @@ module Tina4
191
192
  # PRAGMA table_info reports `pk` as the 1-BASED POSITION within the
192
193
  # primary key, not a boolean: a composite key gives pk=1, pk=2, ...
193
194
  # Testing `== 1` reported only the first column of a composite key.
194
- primary_key: r[:pk].to_i.positive?
195
+ primary_key: pk.positive?,
196
+ # ADR-0044 amendment (Feature 5 Decision 7): null for a non-key
197
+ # column; for a composite key this IS the declared PRIMARY KEY
198
+ # (...) order, not table-column order.
199
+ primary_key_position: pk.positive? ? pk : nil
195
200
  }
196
201
  end
197
202
  end
198
203
 
204
+ # ADR-0044 required adapter capability.
205
+ def get_database_type
206
+ "sqlite"
207
+ end
208
+
199
209
  private
200
210
 
201
211
  # A safe-to-interpolate SQL identifier (no quoting/escaping needed).
data/lib/tina4/env.rb CHANGED
@@ -89,7 +89,7 @@ module Tina4
89
89
  "TINA4_SWAGGER_VERSION" => "1.0.0",
90
90
  "TINA4_LOCALE" => "en",
91
91
  "TINA4_DEBUG" => "true",
92
- "TINA4_LOG_LEVEL" => "[TINA4_LOG_ALL]"
92
+ "TINA4_LOG_LEVEL" => "ALL"
93
93
  }.freeze
94
94
 
95
95
  # The ONE env truthiness table. Every env boolean in every Tina4 framework
@@ -11,8 +11,14 @@
11
11
  # Tina4::ErrorOverlay.render_error_overlay(e, request: env)
12
12
  # end
13
13
  #
14
- # Only activate when TINA4_DEBUG is true.
15
- # In production, call Tina4::ErrorOverlay.render_production_error instead.
14
+ # Only activate when TINA4_DEBUG is true. The production 500 is NOT rendered here —
15
+ # the dispatch renders the generic errors/500 page with an empty error_message
16
+ # (CWE-209), so the exception detail stays in the server log only, never in the body.
17
+ #
18
+ # Sensitive request fields (Authorization / Cookie / Set-Cookie headers and
19
+ # password-like body/param keys) are redacted even in the dev overlay, the frame count
20
+ # is capped, and the caller wraps this render in a rescue, so a broken overlay or a
21
+ # recursive stack still yields a bounded, safe 500.
16
22
 
17
23
  module Tina4
18
24
  module ErrorOverlay
@@ -32,6 +38,18 @@ module Tina4
32
38
 
33
39
  CONTEXT_LINES = 7
34
40
 
41
+ # OVERLAY-DEC-03: cap the rendered frames so a deep/recursive stack yields a
42
+ # bounded page, not one source-file read per frame.
43
+ MAX_FRAMES = 50
44
+
45
+ # OVERLAY-DEC-02: request fields whose KEY matches this are masked in the dev
46
+ # overlay (Authorization/Cookie/Set-Cookie headers via authorization|cookie —
47
+ # Rack spells them HTTP_AUTHORIZATION / HTTP_COOKIE; password/token/secret/api_key
48
+ # body/param keys via the rest). Over-matching a benign field is the SAFE direction
49
+ # in a dev tool — over-masking leaks nothing; under-masking leaks a secret.
50
+ SENSITIVE_KEY_RE = /password|passwd|secret|token|authorization|cookie|key/i
51
+ REDACTED = "[redacted]"
52
+
35
53
  class << self
36
54
  # Render a rich HTML error overlay.
37
55
  #
@@ -50,25 +68,36 @@ module Tina4
50
68
  # what actually raised the error.
51
69
  captured_at = Time.now.to_f
52
70
 
53
- # ── Stack trace ──
71
+ # ── Stack trace (OVERLAY-DEC-03: capped) ──
72
+ # A recursive stack of thousands of frames would otherwise do one source-file
73
+ # read per frame and emit an unbounded page; render only the innermost
74
+ # MAX_FRAMES and note the rest.
54
75
  frames_html = +""
55
76
  backtrace = exception.backtrace || []
56
- backtrace.each do |line|
77
+ backtrace.first(MAX_FRAMES).each do |line|
57
78
  file, lineno, method = parse_backtrace_line(line)
58
79
  frames_html << format_frame(file, lineno, method, captured_at: captured_at)
59
80
  end
81
+ hidden = backtrace.length - [backtrace.length, MAX_FRAMES].min
82
+ if hidden.positive?
83
+ frames_html << "<div style=\"color:#{SUBTEXT};padding:8px 0;font-size:13px;\">" \
84
+ "&#8230; #{hidden} more stack frames hidden (truncated at #{MAX_FRAMES})</div>"
85
+ end
60
86
 
61
- # ── Request info ──
87
+ # ── Request info (OVERLAY-DEC-02: sensitive fields redacted) ──
62
88
  request_pairs = []
63
89
  if request.is_a?(Hash)
64
90
  request.each do |k, v|
65
91
  key = k.to_s
66
92
  if v.is_a?(Hash)
67
- v.each { |hk, hv| request_pairs << ["#{key}.#{hk}", hv.to_s] }
93
+ v.each do |hk, hv|
94
+ pair_key = "#{key}.#{hk}"
95
+ request_pairs << [pair_key, redact(pair_key, hv.to_s)]
96
+ end
68
97
  elsif key.start_with?("HTTP_") || %w[REQUEST_METHOD REQUEST_URI SERVER_PROTOCOL
69
98
  REMOTE_ADDR SERVER_PORT QUERY_STRING CONTENT_TYPE CONTENT_LENGTH
70
99
  method url path].include?(key)
71
- request_pairs << [key, v.to_s]
100
+ request_pairs << [key, redact(key, v.to_s)]
72
101
  end
73
102
  end
74
103
  end
@@ -120,48 +149,6 @@ module Tina4
120
149
  HTML
121
150
  end
122
151
 
123
- # Render a safe, generic error page for production.
124
- def render_production_error(status_code: 500, message: "Internal Server Error", path: "")
125
- # Determine color based on status code
126
- code_color = case status_code
127
- when 403 then "#f59e0b"
128
- when 404 then "#3b82f6"
129
- else "#ef4444"
130
- end
131
-
132
- <<~HTML
133
- <!DOCTYPE html>
134
- <html lang="en">
135
- <head>
136
- <meta charset="utf-8">
137
- <meta name="viewport" content="width=device-width, initial-scale=1">
138
- <title>#{status_code} — #{esc(message)}</title>
139
- <style>
140
- * { box-sizing: border-box; margin: 0; padding: 0; }
141
- body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
142
- .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
143
- .error-code { font-size: 8rem; font-weight: 900; color: #{code_color}; opacity: 0.6; line-height: 1; margin-bottom: 0.5rem; }
144
- .error-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.75rem; }
145
- .error-msg { color: #94a3b8; font-size: 1rem; margin-bottom: 1.5rem; line-height: 1.5; }
146
- .error-path { font-family: 'SF Mono', monospace; background: #0f172a; color: #{code_color}; padding: 0.5rem 1rem; border-radius: 0.5rem; font-size: 0.85rem; word-break: break-all; margin-bottom: 1.5rem; display: inline-block; }
147
- .error-home { display: inline-block; padding: 0.6rem 2rem; background: #3b82f6; color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; }
148
- .error-home:hover { opacity: 0.9; }
149
- .logo { font-size: 1.5rem; margin-bottom: 1rem; opacity: 0.5; }
150
- </style>
151
- </head>
152
- <body>
153
- <div class="error-card">
154
- <div class="error-code">#{status_code}</div>
155
- <div class="error-title">#{esc(message)}</div>
156
- <div class="error-msg">Something went wrong while processing your request.</div>
157
- #{path.to_s.empty? ? '' : "<div class=\"error-path\">#{esc(path)}</div><br>"}
158
- <a href="/" class="error-home">Go Home</a>
159
- </div>
160
- </body>
161
- </html>
162
- HTML
163
- end
164
-
165
152
  # Return true if TINA4_DEBUG is enabled.
166
153
  def is_debug_mode
167
154
  Tina4::Env.is_truthy(ENV.fetch("TINA4_DEBUG", ""))
@@ -169,6 +156,13 @@ module Tina4
169
156
 
170
157
  private
171
158
 
159
+ # Mask a sensitive request value (OVERLAY-DEC-02). Returns "[redacted]" when
160
+ # +key+ names a secret field (an Authorization/Cookie/Set-Cookie header or a
161
+ # password/token/secret/key-like body/param key), otherwise the value unchanged.
162
+ def redact(key, value)
163
+ key.to_s.match?(SENSITIVE_KEY_RE) ? REDACTED : value
164
+ end
165
+
172
166
  def esc(text)
173
167
  text.to_s
174
168
  .gsub("&", "&amp;")