tina4ruby 3.13.97 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +84 -0
- data/lib/tina4/ai.rb +32 -3
- data/lib/tina4/api.rb +5 -0
- data/lib/tina4/auto_crud.rb +62 -4
- data/lib/tina4/background.rb +112 -31
- data/lib/tina4/cache.rb +3 -2
- data/lib/tina4/cli.rb +55 -67
- data/lib/tina4/database.rb +97 -49
- data/lib/tina4/database_adapter.rb +169 -15
- data/lib/tina4/dev_admin.rb +137 -9
- data/lib/tina4/dispatch_pipeline.rb +145 -4
- data/lib/tina4/drivers/firebird_driver.rb +59 -12
- data/lib/tina4/drivers/mongodb_driver.rb +98 -14
- data/lib/tina4/drivers/mssql_driver.rb +39 -2
- data/lib/tina4/drivers/mysql_driver.rb +43 -3
- data/lib/tina4/drivers/odbc_driver.rb +36 -2
- data/lib/tina4/drivers/postgres_driver.rb +5 -0
- data/lib/tina4/drivers/sqlite_driver.rb +11 -1
- data/lib/tina4/env.rb +1 -1
- data/lib/tina4/error_overlay.rb +43 -49
- data/lib/tina4/field_types.rb +33 -16
- data/lib/tina4/frond.rb +24 -2
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +1 -1
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +1 -1
- data/lib/tina4/graphql.rb +2 -2
- data/lib/tina4/log.rb +652 -485
- data/lib/tina4/mcp.rb +9 -1
- data/lib/tina4/messenger.rb +25 -0
- data/lib/tina4/middleware.rb +189 -76
- data/lib/tina4/migration.rb +47 -15
- data/lib/tina4/orm.rb +280 -59
- data/lib/tina4/port_takeover.rb +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +23 -19
- data/lib/tina4/rack_app.rb +201 -59
- data/lib/tina4/realtime.rb +6 -1
- data/lib/tina4/request.rb +259 -51
- data/lib/tina4/router.rb +20 -2
- data/lib/tina4/seeder.rb +68 -19
- data/lib/tina4/shutdown.rb +4 -0
- data/lib/tina4/sql_translator.rb +115 -86
- data/lib/tina4/swagger.rb +19 -3
- data/lib/tina4/template.rb +61 -6
- data/lib/tina4/test_client.rb +49 -3
- data/lib/tina4/testing.rb +16 -11
- data/lib/tina4/validator.rb +7 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -40
- data/lib/tina4.rb +12 -1
- metadata +3 -19
- data/lib/tina4/scss/tina4css/_alerts.scss +0 -34
- data/lib/tina4/scss/tina4css/_badges.scss +0 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +0 -69
- data/lib/tina4/scss/tina4css/_cards.scss +0 -49
- data/lib/tina4/scss/tina4css/_forms.scss +0 -156
- data/lib/tina4/scss/tina4css/_grid.scss +0 -81
- data/lib/tina4/scss/tina4css/_modals.scss +0 -84
- data/lib/tina4/scss/tina4css/_nav.scss +0 -149
- data/lib/tina4/scss/tina4css/_pagination.scss +0 -63
- data/lib/tina4/scss/tina4css/_reset.scss +0 -94
- data/lib/tina4/scss/tina4css/_tables.scss +0 -54
- data/lib/tina4/scss/tina4css/_typography.scss +0 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +0 -208
- data/lib/tina4/scss/tina4css/_variables.scss +0 -117
- data/lib/tina4/scss/tina4css/base.scss +0 -1
- data/lib/tina4/scss/tina4css/colors.scss +0 -48
- data/lib/tina4/scss/tina4css/tina4.scss +0 -18
|
@@ -70,9 +70,11 @@ module Tina4
|
|
|
70
70
|
@last_insert_id = result.inserted_id.to_s
|
|
71
71
|
result
|
|
72
72
|
when :update
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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[:
|
|
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
|
-
|
|
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[:
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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" => "
|
|
92
|
+
"TINA4_LOG_LEVEL" => "ALL"
|
|
93
93
|
}.freeze
|
|
94
94
|
|
|
95
95
|
# The ONE env truthiness table. Every env boolean in every Tina4 framework
|
data/lib/tina4/error_overlay.rb
CHANGED
|
@@ -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
|
-
#
|
|
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
|
+
"… #{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
|
|
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("&", "&")
|