tina4ruby 3.13.94 → 3.13.97
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 +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +248 -36
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +256 -33
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +289 -37
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
|
@@ -8,7 +8,15 @@ module Tina4
|
|
|
8
8
|
attr_reader :records, :columns, :count, :limit, :offset, :sql,
|
|
9
9
|
:affected_rows, :last_id, :error
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
# `limit` is the row cap ACTUALLY APPLIED to the statement that produced
|
|
12
|
+
# these records — what Database#fetch appended — and `0` means none was
|
|
13
|
+
# (an explicit no-limit read, SQL carrying its own trailing LIMIT, or a
|
|
14
|
+
# write). The default used to be `10`: Database#fetch_direct never passed
|
|
15
|
+
# limit:/offset:, so that untouched default was reported on EVERY fetch
|
|
16
|
+
# whatever limit ran — and 10 is the stale v2 documentation number that the
|
|
17
|
+
# buried PHP row-cap test also asserted as the cap. 0 says "no cap recorded"
|
|
18
|
+
# instead of quietly naming a number nothing applied.
|
|
19
|
+
def initialize(records = [], sql: "", columns: [], count: nil, limit: 0, offset: 0,
|
|
12
20
|
affected_rows: 0, last_id: nil, error: nil, db: nil)
|
|
13
21
|
@records = records || []
|
|
14
22
|
@sql = sql
|
|
@@ -91,26 +99,60 @@ module Tina4
|
|
|
91
99
|
lines.join("\n")
|
|
92
100
|
end
|
|
93
101
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
# Describe the page this result already IS. Takes NO arguments (ADR-0043).
|
|
103
|
+
#
|
|
104
|
+
# Every field is derived from the query that produced this result:
|
|
105
|
+
# per_page = the query's limit
|
|
106
|
+
# page = floor(offset / limit) + 1
|
|
107
|
+
# total = the true total for the filter (@count, from the COUNT probe
|
|
108
|
+
# Database#fetch runs when it applied the limit), NEVER the
|
|
109
|
+
# number of rows this page returned
|
|
110
|
+
# total_pages = ceil(total / per_page)
|
|
111
|
+
# records = the rows the query returned, VERBATIM, never re-sliced
|
|
112
|
+
# limit/offset= the SQL limit/offset actually applied
|
|
113
|
+
#
|
|
114
|
+
# The envelope is EXACTLY seven snake_case keys, identical in all four
|
|
115
|
+
# frameworks: records, total, page, per_page, total_pages, limit, offset. The
|
|
116
|
+
# old duplicate/camelCase spellings (data, count, totalPages, has_next,
|
|
117
|
+
# has_prev) are gone - a JSON key is data and does not change spelling by host
|
|
118
|
+
# language, so the same integer never ships twice under two names.
|
|
119
|
+
#
|
|
120
|
+
# PASSING ANY ARGUMENT RAISES. A DatabaseResult holds no connection, so an
|
|
121
|
+
# argument could only re-slice rows already in memory and then report
|
|
122
|
+
# total_pages for pages it can never reach. To read page N, FETCH page N
|
|
123
|
+
# (limit + offset) and call this with no arguments. The removed page:/per_page:
|
|
124
|
+
# slicing mode is a hard error, never a silent reinterpretation - superseding
|
|
125
|
+
# the in-memory slice GitHub #106 asked for.
|
|
126
|
+
#
|
|
127
|
+
# MEASURED 2026-08-05 on a real 250-row table read with limit=20 offset=40
|
|
128
|
+
# (page 3 of 13): the old two-mode method re-sliced @records by the ABSOLUTE
|
|
129
|
+
# offset (40) against an array already only 20 long and returned ZERO records
|
|
130
|
+
# for a valid page, while still shipping a page number and total - an envelope
|
|
131
|
+
# that looked authoritative and was empty.
|
|
132
|
+
def to_paginate(*args, **kwargs)
|
|
133
|
+
unless args.empty? && kwargs.empty?
|
|
134
|
+
raise ArgumentError,
|
|
135
|
+
"to_paginate takes no arguments (ADR-0043): it describes the page " \
|
|
136
|
+
"this result already IS, derived from the query that produced it. A " \
|
|
137
|
+
"DatabaseResult holds no connection, so an argument could only " \
|
|
138
|
+
"re-slice rows already in memory and lie about total_pages. To read " \
|
|
139
|
+
"another page, FETCH it - fetch(sql, limit: per_page, offset: " \
|
|
140
|
+
"(page - 1) * per_page) - then call to_paginate with no arguments."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
per_page = @limit.to_i > 0 ? @limit.to_i : @records.size
|
|
144
|
+
page = per_page > 0 ? (@offset.to_i / per_page) + 1 : 1
|
|
145
|
+
total = @count
|
|
146
|
+
total_pages = per_page > 0 ? [1, (total.to_f / per_page).ceil].max : 1
|
|
147
|
+
|
|
101
148
|
{
|
|
102
|
-
records:
|
|
103
|
-
data: page_records,
|
|
104
|
-
count: total,
|
|
149
|
+
records: @records,
|
|
105
150
|
total: total,
|
|
106
|
-
limit: per_page,
|
|
107
|
-
offset: (page - 1) * per_page,
|
|
108
151
|
page: page,
|
|
109
152
|
per_page: per_page,
|
|
110
|
-
totalPages: total_pages,
|
|
111
153
|
total_pages: total_pages,
|
|
112
|
-
|
|
113
|
-
|
|
154
|
+
limit: per_page,
|
|
155
|
+
offset: @offset.to_i
|
|
114
156
|
}
|
|
115
157
|
end
|
|
116
158
|
|
|
@@ -179,7 +221,11 @@ module Tina4
|
|
|
179
221
|
size: size,
|
|
180
222
|
decimals: decimals,
|
|
181
223
|
nullable: col.key?(:nullable) ? col[:nullable] : (col.key?("nullable") ? col["nullable"] : true),
|
|
182
|
-
|
|
224
|
+
# Symbol OR string key, because a driver row may arrive either way.
|
|
225
|
+
# The `:primary` / `"primary"` fallbacks that used to sit here were
|
|
226
|
+
# dead: no Ruby driver has ever emitted that spelling. It was mirroring
|
|
227
|
+
# PHP's odd `primary`, which PHP itself has now dropped for `primaryKey`.
|
|
228
|
+
primary_key: col[:primary_key] || col["primary_key"] || false
|
|
183
229
|
}
|
|
184
230
|
end
|
|
185
231
|
end
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Tina4
|
|
6
|
+
# A parsed database connection URL, as a VALUE.
|
|
7
|
+
#
|
|
8
|
+
# Feature 5 of the feature audit. Ruby had no parser to call: URL handling was
|
|
9
|
+
# inline in +Database#initialize+, so a URL could not be parsed without
|
|
10
|
+
# building a connection object. The parse could not be unit tested on its own,
|
|
11
|
+
# the four frameworks could not be compared without standing up a database,
|
|
12
|
+
# and +tina4 doctor+ or the setup wizard had nothing to call to validate a URL
|
|
13
|
+
# before using it.
|
|
14
|
+
#
|
|
15
|
+
# What was there instead was +detect_driver+: substring regex matching over the
|
|
16
|
+
# whole connection string with a silent <tt>else "sqlite"</tt> fallback, so an
|
|
17
|
+
# unrecognised URL did not fail - it quietly became SQLite. The app boots,
|
|
18
|
+
# writes to a local file, and nobody learns the real database was never
|
|
19
|
+
# reached. This class raises instead.
|
|
20
|
+
#
|
|
21
|
+
# Core Principle 6 says a connection string must mean literally the same thing
|
|
22
|
+
# in every framework. +spec/fixtures/database_url_corpus.json+ is the answer
|
|
23
|
+
# key, byte-identical in all four.
|
|
24
|
+
# DISPLAY REDACTS, FIDELITY DOES NOT. #inspect, #to_s and #to_safe_string
|
|
25
|
+
# replace the password with the redaction marker, so a log line, a backtrace or
|
|
26
|
+
# a status payload is safe. Marshal deliberately does not: its contract is a
|
|
27
|
+
# faithful round trip, and a masked Marshal would load an object whose password
|
|
28
|
+
# is the literal "***".
|
|
29
|
+
#
|
|
30
|
+
# The consequence: DO NOT PERSIST THIS OBJECT. A DatabaseUrl marshalled into a
|
|
31
|
+
# cache, a session or a queue payload puts the password in cleartext on disk.
|
|
32
|
+
# Record #to_safe_string instead. spec/database_url_redaction_spec.rb fails the
|
|
33
|
+
# build if framework code ever marshals one.
|
|
34
|
+
class DatabaseUrl
|
|
35
|
+
# URL scheme to CANONICAL engine. Aliases resolve ONCE, here, so nothing
|
|
36
|
+
# downstream ever compares raw schemes.
|
|
37
|
+
#
|
|
38
|
+
# +sqlite3+ is accepted because the driver is literally named sqlite3 in
|
|
39
|
+
# every framework (Python's sqlite3 module, Ruby's sqlite3 gem, PHP's
|
|
40
|
+
# ext-sqlite3, Node's node:sqlite), so people type it. The "3" is a
|
|
41
|
+
# file-format version, not a different engine, which is why the canonical
|
|
42
|
+
# name stays +sqlite+.
|
|
43
|
+
ENGINE_ALIASES = {
|
|
44
|
+
"sqlite" => "sqlite",
|
|
45
|
+
"sqlite3" => "sqlite",
|
|
46
|
+
"postgres" => "postgres",
|
|
47
|
+
"postgresql" => "postgres",
|
|
48
|
+
"pgsql" => "postgres",
|
|
49
|
+
"mysql" => "mysql",
|
|
50
|
+
"mssql" => "mssql",
|
|
51
|
+
"sqlserver" => "mssql",
|
|
52
|
+
"firebird" => "firebird",
|
|
53
|
+
"mongodb" => "mongodb",
|
|
54
|
+
"mongo" => "mongodb",
|
|
55
|
+
"odbc" => "odbc"
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
# Applied AT PARSE. The port is part of our contract, not the driver's
|
|
59
|
+
# business: a URL with no port must yield the same struct in all four.
|
|
60
|
+
DEFAULT_PORTS = {
|
|
61
|
+
"postgres" => 5432,
|
|
62
|
+
"mysql" => 3306,
|
|
63
|
+
"mssql" => 1433,
|
|
64
|
+
"firebird" => 3050,
|
|
65
|
+
"mongodb" => 27017
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
# What replaces a credential everywhere. One spelling, so a test can assert
|
|
69
|
+
# on it and a reader recognises it on sight.
|
|
70
|
+
REDACTED = "***"
|
|
71
|
+
|
|
72
|
+
# RFC 3986 scheme grammar. A scheme cannot contain ":", "@", "/" or a space,
|
|
73
|
+
# which is what makes it the ONLY part of an unparseable string that is
|
|
74
|
+
# provably not a password.
|
|
75
|
+
SCHEME = /\A[A-Za-z][A-Za-z0-9+.\-]*\z/.freeze
|
|
76
|
+
|
|
77
|
+
# host[:port][/path] with a NUMERIC port. The digit requirement is what does
|
|
78
|
+
# the work: it is why "user:secret" (a URL with the host left off) fails to
|
|
79
|
+
# match and gets redacted, while "192.168.88.99:55432/tina4_py" is echoed.
|
|
80
|
+
HOST_PORT = %r{\A(?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+)(?::\d+)?(?:/.*)?\z}m.freeze
|
|
81
|
+
|
|
82
|
+
# Credential KEYWORDS in the two keyword=value forms that carry one: the
|
|
83
|
+
# ODBC connection string (";"-separated) and the libpq/JDBC query string
|
|
84
|
+
# ("&"-separated). A braced value is matched FIRST because an ODBC value
|
|
85
|
+
# containing ";" must be braced - stopping at the ";" would leave the tail
|
|
86
|
+
# of the password in the output, which is exactly the bug shape this file
|
|
87
|
+
# exists to close.
|
|
88
|
+
#
|
|
89
|
+
# UID/USER are deliberately NOT redacted: a username is not a secret, and it
|
|
90
|
+
# is most of what makes a failed connection diagnosable.
|
|
91
|
+
CREDENTIAL_KEYWORDS = /\b(PWD|PASSWORD)\s*=\s*(?:\{[^}]*\}|[^;&]*)/i.freeze
|
|
92
|
+
|
|
93
|
+
attr_reader :engine, :host, :port, :database, :username, :password,
|
|
94
|
+
:connection_string
|
|
95
|
+
|
|
96
|
+
def initialize(url, username: nil, password: nil)
|
|
97
|
+
raise ArgumentError, "DatabaseUrl: the URL is empty" if url.nil? || url.to_s.strip.empty?
|
|
98
|
+
|
|
99
|
+
url = url.to_s
|
|
100
|
+
@host = nil
|
|
101
|
+
@port = nil
|
|
102
|
+
@database = ""
|
|
103
|
+
@username = nil
|
|
104
|
+
@password = nil
|
|
105
|
+
@connection_string = nil
|
|
106
|
+
|
|
107
|
+
if url.start_with?("sqlite:", "sqlite3:")
|
|
108
|
+
parse_sqlite(url)
|
|
109
|
+
elsif url.start_with?("odbc:///")
|
|
110
|
+
@engine = "odbc"
|
|
111
|
+
@connection_string = url[("odbc:///".length)..]
|
|
112
|
+
else
|
|
113
|
+
parse_standard(url)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Separate credentials fill in only when the URL carried NONE.
|
|
117
|
+
#
|
|
118
|
+
# ABSENT and BLANK are different values, and the difference is load
|
|
119
|
+
# bearing: the gate is `nil?`, never `empty?`. A URL written
|
|
120
|
+
# postgres://user:@host/db
|
|
121
|
+
# states an explicitly EMPTY password, so the TINA4_DATABASE_PASSWORD
|
|
122
|
+
# fallback must NOT fire; only
|
|
123
|
+
# postgres://user@host/db
|
|
124
|
+
# is absent and takes the env value. Measured 2026-08-02: Ruby and PHP
|
|
125
|
+
# obey this, Python and Node return None for the blank form and therefore
|
|
126
|
+
# authenticate with a DIFFERENT password off the SAME .env - which is the
|
|
127
|
+
# kind of divergence nobody notices until an account locks out.
|
|
128
|
+
@username = username if @username.nil? && username && !username.empty?
|
|
129
|
+
@password = password if @password.nil? && password && !password.empty?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Parse the configured URL, or nil when the variable is not set.
|
|
133
|
+
def self.from_env(key = "TINA4_DATABASE_URL")
|
|
134
|
+
url = (ENV[key] || "").strip
|
|
135
|
+
return nil if url.empty?
|
|
136
|
+
|
|
137
|
+
new(url, username: ENV["TINA4_DATABASE_USERNAME"], password: ENV["TINA4_DATABASE_PASSWORD"])
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# THE redaction primitive. Every connection string that is about to reach a
|
|
141
|
+
# log line, an exception message or a dump goes through here.
|
|
142
|
+
#
|
|
143
|
+
# It takes a RAW string and never raises, because the paths that most need
|
|
144
|
+
# redacting are exactly the ones where parsing already failed.
|
|
145
|
+
#
|
|
146
|
+
# Measured in this repo on 2026-08-02, before this existed:
|
|
147
|
+
# * Database#safe_connection_target carried its OWN regex, and
|
|
148
|
+
# postgres://u:p@ss@h:5432/db -> postgres://u:***@ss@h:5432/db
|
|
149
|
+
# leaked the password tail past the first "@" (the Ruby twin of the PHP
|
|
150
|
+
# `password=\S*` tail leak), and
|
|
151
|
+
# odbc:///...;PWD=<secret> -> returned VERBATIM
|
|
152
|
+
# went straight into DatabaseConnectionError, which is raised at the
|
|
153
|
+
# first query and lands in a 500 body.
|
|
154
|
+
# * DatabaseUrl's own invalid-URL ArgumentError interpolated the whole raw
|
|
155
|
+
# URL, and detect_driver raises it on the BOOT path, so one typo in
|
|
156
|
+
# TINA4_DATABASE_URL wrote the password into the boot log and CI output.
|
|
157
|
+
# One primitive means the next such bug is fixed once, not four times.
|
|
158
|
+
def self.redact(raw)
|
|
159
|
+
text = raw.to_s
|
|
160
|
+
return "" if text.empty?
|
|
161
|
+
|
|
162
|
+
begin
|
|
163
|
+
# A URL that PARSES gets the round-tripping form rebuilt from the parsed
|
|
164
|
+
# FIELDS. That form cannot leak: the password is never copied into it.
|
|
165
|
+
new(text).to_safe_string
|
|
166
|
+
rescue StandardError
|
|
167
|
+
# Unparseable - fall back to the structural scrub. Redaction must never
|
|
168
|
+
# raise; a raise here would mask the error we were called to describe.
|
|
169
|
+
scrub(text)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Structural redaction of a string that did NOT parse.
|
|
174
|
+
def self.scrub(text)
|
|
175
|
+
head, separator, rest = text.to_s.partition("://")
|
|
176
|
+
|
|
177
|
+
# No "://" - a bare file path, or an ODBC/keyword string. There is no
|
|
178
|
+
# userinfo to find, so only the keyword form can hide a secret here.
|
|
179
|
+
return scrub_keywords(text.to_s) if separator.empty?
|
|
180
|
+
|
|
181
|
+
# A head that is not a scheme is unknown text, and unknown text may BE the
|
|
182
|
+
# secret (the measured Python case was `notaurl-with-SuperSecret123`).
|
|
183
|
+
# Say nothing about it rather than guess.
|
|
184
|
+
return REDACTED unless head.match?(SCHEME)
|
|
185
|
+
|
|
186
|
+
# The authority is taken to the first "?" or "#", NOT to the first "/".
|
|
187
|
+
# An unencoded "/" inside a password would otherwise cut the region short,
|
|
188
|
+
# push the "@" outside it, and let the password prefix survive.
|
|
189
|
+
cut = rest.index(/[?#]/) || rest.length
|
|
190
|
+
"#{head}://#{redact_authority(rest[0, cut])}#{scrub_keywords(rest[cut..].to_s)}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Take the credential out of a URL authority.
|
|
194
|
+
#
|
|
195
|
+
# user:pass@host:5432/db -> user:***@host:5432/db
|
|
196
|
+
# host:5432/db -> host:5432/db (no credential present)
|
|
197
|
+
# user:pass -> user:*** (host left off - a real typo,
|
|
198
|
+
# and the only reason the no-"@"
|
|
199
|
+
# branch cannot echo blindly)
|
|
200
|
+
def self.redact_authority(authority)
|
|
201
|
+
# The LAST "@", because userinfo ends at the last "@" (RFC 3986). Splitting
|
|
202
|
+
# on the FIRST one is how an unencoded "@" inside a password leaves a tail
|
|
203
|
+
# in the output.
|
|
204
|
+
at = authority.rindex("@")
|
|
205
|
+
if at
|
|
206
|
+
userinfo = authority[0, at]
|
|
207
|
+
host = authority[(at + 1)..].to_s
|
|
208
|
+
name, colon, = userinfo.partition(":")
|
|
209
|
+
return colon.empty? ? "#{name}@#{host}" : "#{name}:#{REDACTED}@#{host}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
return authority if authority.match?(HOST_PORT)
|
|
213
|
+
|
|
214
|
+
name, colon, = authority.partition(":")
|
|
215
|
+
colon.empty? ? name : "#{name}:#{REDACTED}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Redact the value side of a credential keyword, leaving every other
|
|
219
|
+
# keyword visible so the string is still diagnosable.
|
|
220
|
+
def self.scrub_keywords(text)
|
|
221
|
+
text.gsub(CREDENTIAL_KEYWORDS) { "#{Regexp.last_match(1)}=#{REDACTED}" }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The invalid-URL message, with the credential taken OUT.
|
|
225
|
+
#
|
|
226
|
+
# It still has to be diagnosable, so it keeps the scheme, the host, the port
|
|
227
|
+
# as written (the usual fault) and the database name - only the password
|
|
228
|
+
# becomes ***. When the value has no "://" there is no structure to trust,
|
|
229
|
+
# so the value is not shown at all and the message says why.
|
|
230
|
+
def self.invalid_url_error(url)
|
|
231
|
+
text = url.to_s
|
|
232
|
+
if text.include?("://")
|
|
233
|
+
"DatabaseUrl: Invalid URL format '#{scrub(text)}' - expected " \
|
|
234
|
+
"scheme://user:password@host:port/database"
|
|
235
|
+
else
|
|
236
|
+
"DatabaseUrl: Invalid URL format - no '://', so there is no scheme to " \
|
|
237
|
+
"connect with. The URL itself is not shown because it can contain a " \
|
|
238
|
+
"password. Expected scheme://user:password@host:port/database"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Connection target. sqlite and odbc are the whole value.
|
|
243
|
+
def dsn
|
|
244
|
+
return @database if @engine == "sqlite"
|
|
245
|
+
return @connection_string.to_s if @engine == "odbc"
|
|
246
|
+
|
|
247
|
+
out = @host.to_s
|
|
248
|
+
out += ":#{@port}" unless @port.nil?
|
|
249
|
+
out += "/#{@database}" unless @database.empty?
|
|
250
|
+
out
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The URL with the password replaced by <tt>***</tt>.
|
|
254
|
+
#
|
|
255
|
+
# The ONLY form allowed in a log line or an error message: a connection URL
|
|
256
|
+
# in a log is a credential leak. It round-trips the input, so it stays
|
|
257
|
+
# readable as well as safe.
|
|
258
|
+
def to_safe_string
|
|
259
|
+
return "sqlite:///#{@database}" if @engine == "sqlite"
|
|
260
|
+
# ODBC used to return the connection string VERBATIM here, PWD= included -
|
|
261
|
+
# measured 2026-08-02. The negative test "never leaks the password" passed
|
|
262
|
+
# anyway, because the shared corpus had no odbc row for it to check: the
|
|
263
|
+
# guard existed, the test was green, and it protected nothing. Every other
|
|
264
|
+
# keyword stays visible so a failed ODBC connection is still diagnosable.
|
|
265
|
+
return "odbc:///#{self.class.scrub_keywords(@connection_string.to_s)}" if @engine == "odbc"
|
|
266
|
+
|
|
267
|
+
out = "#{@engine}://"
|
|
268
|
+
unless @username.nil?
|
|
269
|
+
out += @username
|
|
270
|
+
out += ":***" unless @password.nil?
|
|
271
|
+
out += "@"
|
|
272
|
+
end
|
|
273
|
+
out += @host.to_s
|
|
274
|
+
out += ":#{@port}" unless @port.nil?
|
|
275
|
+
out += "/#{@database}" unless @database.empty?
|
|
276
|
+
out
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# inspect lands in backtraces and the console, so it MUST be the safe form.
|
|
280
|
+
def inspect
|
|
281
|
+
"#<Tina4::DatabaseUrl #{to_safe_string}>"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Interpolation ("connecting to #{url}") and JSON both have to be safe, not
|
|
285
|
+
# just inspect. Ruby's inspect already guarded the console and backtraces -
|
|
286
|
+
# these two close the same class of leak PHP has through print_r/var_dump
|
|
287
|
+
# and Node through JSON.stringify, and they make the useless default
|
|
288
|
+
# "#<Tina4::DatabaseUrl:0x000...>" say something instead.
|
|
289
|
+
def to_s
|
|
290
|
+
to_safe_string
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def to_json(*args)
|
|
294
|
+
to_safe_string.to_json(*args)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
private
|
|
298
|
+
|
|
299
|
+
# Strip EXACTLY ONE leading slash: the URL path separator, never more.
|
|
300
|
+
def strip_one_slash(path)
|
|
301
|
+
path.start_with?("/") ? path[1..] : path
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# sqlite is parsed on the RAW string, never through URI. URI collapses
|
|
305
|
+
# "sqlite:/x" and "sqlite:///x", losing the difference between a one-slash
|
|
306
|
+
# ABSOLUTE path and the documented three-slash RELATIVE form.
|
|
307
|
+
#
|
|
308
|
+
# sqlite:///app.db -> app.db (three slashes = relative)
|
|
309
|
+
# sqlite:////abs/app.db -> /abs/app.db (four slashes = absolute)
|
|
310
|
+
# sqlite:/abs/app.db -> /abs/app.db (one slash = a real absolute)
|
|
311
|
+
# sqlite:app.db -> app.db
|
|
312
|
+
def parse_sqlite(url)
|
|
313
|
+
@engine = "sqlite"
|
|
314
|
+
url = "sqlite:#{url[("sqlite3:".length)..]}" if url.start_with?("sqlite3:")
|
|
315
|
+
|
|
316
|
+
@database = if ["sqlite::memory:", "sqlite:///:memory:"].include?(url)
|
|
317
|
+
":memory:"
|
|
318
|
+
elsif url.start_with?("sqlite:///")
|
|
319
|
+
strip_one_slash(url[("sqlite://".length)..])
|
|
320
|
+
elsif url.start_with?("sqlite://")
|
|
321
|
+
url[("sqlite://".length)..]
|
|
322
|
+
else
|
|
323
|
+
url[("sqlite:".length)..]
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def parse_standard(url)
|
|
328
|
+
parsed = begin
|
|
329
|
+
URI.parse(url)
|
|
330
|
+
rescue URI::InvalidURIError
|
|
331
|
+
# NEVER interpolate the raw URL here. Measured 2026-08-02: a malformed
|
|
332
|
+
# TINA4_DATABASE_URL put the password verbatim into this ArgumentError,
|
|
333
|
+
# and Database#detect_driver raises it during boot - so the credential
|
|
334
|
+
# reached the boot log, the error overlay, a crash report and CI output.
|
|
335
|
+
raise ArgumentError, self.class.invalid_url_error(url)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
scheme = parsed.scheme.to_s.downcase
|
|
339
|
+
raise ArgumentError, self.class.invalid_url_error(url) if scheme.empty?
|
|
340
|
+
|
|
341
|
+
engine = ENGINE_ALIASES[scheme]
|
|
342
|
+
if engine.nil?
|
|
343
|
+
raise ArgumentError,
|
|
344
|
+
"DatabaseUrl: Unsupported database scheme '#{scheme}'. " \
|
|
345
|
+
"Supported: #{ENGINE_ALIASES.keys.join(', ')}"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
@engine = engine
|
|
349
|
+
@host = parsed.host && !parsed.host.empty? ? parsed.host : nil
|
|
350
|
+
@port = parsed.port || DEFAULT_PORTS[engine]
|
|
351
|
+
@username = parsed.user ? URI.decode_www_form_component(parsed.user) : nil
|
|
352
|
+
@password = parsed.password ? URI.decode_www_form_component(parsed.password) : nil
|
|
353
|
+
|
|
354
|
+
# Strip EXACTLY ONE leading slash - the URL path separator. Stripping every
|
|
355
|
+
# slash turns the documented absolute Firebird form
|
|
356
|
+
# `firebird://host:3050//var/lib/db.fdb` into the RELATIVE
|
|
357
|
+
# `var/lib/db.fdb`. Verified against live Firebird 5.0.4: the driver takes
|
|
358
|
+
# one or two leading slashes and rejects a relative path outright.
|
|
359
|
+
database = strip_one_slash(parsed.path.to_s)
|
|
360
|
+
@database = database.empty? && engine == "mongodb" ? "tina4" : database
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
data/lib/tina4/dev.rb
CHANGED
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -467,18 +467,13 @@ module Tina4
|
|
|
467
467
|
when ["GET", "/__dev/api/system"]
|
|
468
468
|
json_response(system_payload)
|
|
469
469
|
when ["GET", "/__dev/api/queue/topics"]
|
|
470
|
-
|
|
471
|
-
topics = Dir.exist?(queue_dir) ? Dir.children(queue_dir).select { |d| File.directory?(File.join(queue_dir, d)) }.sort : []
|
|
472
|
-
topics = ["default"] if topics.empty?
|
|
473
|
-
json_response({ topics: topics })
|
|
470
|
+
json_response({ topics: queue_topics })
|
|
474
471
|
when ["GET", "/__dev/api/queue/dead-letters"]
|
|
475
472
|
topic = query_param(env, "topic") || "default"
|
|
476
|
-
jobs =
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
rescue StandardError => e
|
|
481
|
-
jobs = []
|
|
473
|
+
jobs = begin
|
|
474
|
+
queue_dead_letters(topic)
|
|
475
|
+
rescue StandardError
|
|
476
|
+
[]
|
|
482
477
|
end
|
|
483
478
|
json_response({ jobs: jobs, count: jobs.size, topic: topic })
|
|
484
479
|
when ["GET", "/__dev/api/queue"]
|
|
@@ -492,15 +487,14 @@ module Tina4
|
|
|
492
487
|
# positionally raises ArgumentError, which the rescue below
|
|
493
488
|
# swallows — silently zeroing every stat. Use keyword form.
|
|
494
489
|
stats = {
|
|
495
|
-
pending: queue.
|
|
496
|
-
completed: queue.
|
|
497
|
-
failed: queue.
|
|
498
|
-
reserved: queue.
|
|
490
|
+
pending: queue.size(status: "pending"),
|
|
491
|
+
completed: queue.size(status: "completed"),
|
|
492
|
+
failed: queue.size(status: "failed"),
|
|
493
|
+
reserved: queue.size(status: "reserved"),
|
|
499
494
|
}
|
|
500
|
-
jobs
|
|
501
|
-
jobs.concat(queue.dead_letters.map { |j| j.merge(status: "dead_letter") }) if queue.respond_to?(:dead_letters)
|
|
495
|
+
jobs = queue_jobs(topic, query_param(env, "status"))
|
|
502
496
|
end
|
|
503
|
-
rescue StandardError
|
|
497
|
+
rescue StandardError
|
|
504
498
|
# fall through to empty stats
|
|
505
499
|
end
|
|
506
500
|
json_response({ jobs: jobs, stats: stats })
|
|
@@ -631,10 +625,25 @@ module Tina4
|
|
|
631
625
|
when ["GET", "/__dev/api/metrics"]
|
|
632
626
|
json_response(Tina4::Metrics.quick_metrics)
|
|
633
627
|
when ["GET", "/__dev/api/metrics/full"]
|
|
634
|
-
|
|
628
|
+
# No fallback (ADR-0002). A missing or stale CLI is a 503 naming the
|
|
629
|
+
# install command, never zeros that read as a healthy codebase.
|
|
630
|
+
begin
|
|
631
|
+
json_response(Tina4::Metrics.full_analysis)
|
|
632
|
+
rescue Tina4::MetricsEngineError => e
|
|
633
|
+
json_response({ "error" => e.message }, 503)
|
|
634
|
+
end
|
|
635
635
|
when ["GET", "/__dev/api/metrics/file"]
|
|
636
636
|
file_path = (query_param(env, "path") || "").to_s
|
|
637
|
-
|
|
637
|
+
begin
|
|
638
|
+
json_response(Tina4::Metrics.file_detail(file_path))
|
|
639
|
+
rescue Tina4::MetricsEngineError => e
|
|
640
|
+
# A bad path is the caller's mistake (404); anything else is the
|
|
641
|
+
# engine being unavailable (503).
|
|
642
|
+
bad_path = e.message.include?("no such file") ||
|
|
643
|
+
e.message.include?("not a file") ||
|
|
644
|
+
e.message.include?("needs a path")
|
|
645
|
+
json_response({ "error" => e.message }, bad_path ? 404 : 503)
|
|
646
|
+
end
|
|
638
647
|
when ["GET", "/__dev/api/thoughts"]
|
|
639
648
|
json_response(thoughts_payload)
|
|
640
649
|
when ["POST", "/__dev/api/supervise/create"]
|
|
@@ -805,7 +814,16 @@ module Tina4
|
|
|
805
814
|
platform: RUBY_PLATFORM,
|
|
806
815
|
debug: ENV["TINA4_DEBUG"] || "false",
|
|
807
816
|
log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
|
|
808
|
-
|
|
817
|
+
# REDACTED, not raw. Measured 2026-08-02: this served
|
|
818
|
+
# TINA4_DATABASE_URL verbatim, so GET /__dev/api/status answered any
|
|
819
|
+
# caller that could reach the debug port with the database password.
|
|
820
|
+
# It is a display field - nothing round-trips it back - so the safe
|
|
821
|
+
# form is strictly better here. (The connections EDITOR at
|
|
822
|
+
# /__dev/api/connections still returns the raw URL on purpose: it
|
|
823
|
+
# populates a form that is saved back to .env, and redacting it there
|
|
824
|
+
# would write *** into the file. That one needs an unchanged-sentinel
|
|
825
|
+
# design, in all four frameworks.)
|
|
826
|
+
database: (ENV["TINA4_DATABASE_URL"].to_s.empty? ? "not configured" : Tina4::DatabaseUrl.redact(ENV["TINA4_DATABASE_URL"])),
|
|
809
827
|
db_tables: db_table_count,
|
|
810
828
|
uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
|
|
811
829
|
route_count: Tina4::Router.routes.size,
|
|
@@ -1053,6 +1071,86 @@ module Tina4
|
|
|
1053
1071
|
{ ok: false, error: e.message }
|
|
1054
1072
|
end
|
|
1055
1073
|
|
|
1074
|
+
# ── Queue panel: the list must describe the set the stats count ──
|
|
1075
|
+
#
|
|
1076
|
+
# Every reader below goes through Tina4::Queue.base_path / .topic_path,
|
|
1077
|
+
# or through the backend itself — the SAME answers the lite backend
|
|
1078
|
+
# writes to and Queue#size counts. These handlers used to re-derive the
|
|
1079
|
+
# path as
|
|
1080
|
+
# Dir.pwd/data/queue, which no Ruby app ever wrote to, so the topic list
|
|
1081
|
+
# could not name a real topic and the job list described a store that was
|
|
1082
|
+
# not the one being counted.
|
|
1083
|
+
#
|
|
1084
|
+
# Each job appears EXACTLY ONCE, in the bucket its OWN stat counts it in:
|
|
1085
|
+
#
|
|
1086
|
+
# stats.pending <- <base>/<topic>/*.queue-data
|
|
1087
|
+
# stats.reserved <- <base>/<topic>/reserved/*.queue-data
|
|
1088
|
+
# stats.failed <- <base>/dead_letter/*.queue-data tagged with topic
|
|
1089
|
+
# stats.completed <- always 0; the file backend deletes on complete
|
|
1090
|
+
#
|
|
1091
|
+
# so sum(stats) == jobs.length by construction on a quiescent store, and
|
|
1092
|
+
# every ?status= filter returns exactly what its own stat counts.
|
|
1093
|
+
|
|
1094
|
+
# Topic directories in the real store. The shared dead-letter directory
|
|
1095
|
+
# is a sibling of the topic directories, not a topic.
|
|
1096
|
+
def queue_topics
|
|
1097
|
+
base = Tina4::Queue.base_path
|
|
1098
|
+
return ["default"] unless Dir.exist?(base)
|
|
1099
|
+
|
|
1100
|
+
topics = Dir.children(base)
|
|
1101
|
+
.select { |entry| File.directory?(File.join(base, entry)) }
|
|
1102
|
+
.reject { |entry| entry == Tina4::Queue::DEAD_LETTER_DIRNAME }
|
|
1103
|
+
.sort
|
|
1104
|
+
topics.empty? ? ["default"] : topics
|
|
1105
|
+
rescue StandardError
|
|
1106
|
+
["default"]
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
# Jobs for +topic+, optionally narrowed to one bucket.
|
|
1110
|
+
def queue_jobs(topic, status_filter = nil)
|
|
1111
|
+
wanted = status_filter.to_s.strip
|
|
1112
|
+
topic_dir = Tina4::Queue.topic_path(topic)
|
|
1113
|
+
jobs = []
|
|
1114
|
+
jobs.concat(read_queue_dir(topic_dir, "pending")) if wanted.empty? || wanted == "pending"
|
|
1115
|
+
if wanted.empty? || wanted == "reserved"
|
|
1116
|
+
jobs.concat(read_queue_dir(File.join(topic_dir, "reserved"), "reserved"))
|
|
1117
|
+
end
|
|
1118
|
+
jobs.concat(queue_dead_letters(topic)) if wanted.empty? || wanted == "failed" || wanted == "dead"
|
|
1119
|
+
jobs
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
# Read one queue directory the way Queue#size COUNTS it. size globs the
|
|
1123
|
+
# files without parsing them, so a job file that cannot be parsed still
|
|
1124
|
+
# counts — and must therefore still be LISTED, or the panel shows a total
|
|
1125
|
+
# it cannot account for. Listing it is also the only way an operator ever
|
|
1126
|
+
# learns the file is there.
|
|
1127
|
+
def read_queue_dir(dir, status)
|
|
1128
|
+
return [] unless Dir.exist?(dir)
|
|
1129
|
+
|
|
1130
|
+
Tina4::Queue.job_files(dir).filter_map do |file|
|
|
1131
|
+
begin
|
|
1132
|
+
JSON.parse(File.read(file)).merge("status" => status)
|
|
1133
|
+
rescue JSON::ParserError
|
|
1134
|
+
{ "id" => File.basename(file, Tina4::Queue::JOB_EXTENSION), "status" => status,
|
|
1135
|
+
"error" => "unreadable job file" }
|
|
1136
|
+
rescue Errno::ENOENT
|
|
1137
|
+
nil # consumed between the glob and the read
|
|
1138
|
+
end
|
|
1139
|
+
end
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
# Every dead letter for +topic+ — the same set stats.failed counts.
|
|
1143
|
+
#
|
|
1144
|
+
# max_retries: 0 is the established "no attempt-count filter" spelling
|
|
1145
|
+
# (the `tina4ruby queue retry` command uses it for the same reason). The
|
|
1146
|
+
# DEFAULT filters on the max_retries of the Queue this dev admin happened
|
|
1147
|
+
# to construct, which is 3 and has nothing to do with the app's: an app
|
|
1148
|
+
# configured max_retries: 1 dead-letters a job at ONE attempt, and every
|
|
1149
|
+
# one of those was counted by stats.failed and never listed.
|
|
1150
|
+
def queue_dead_letters(topic)
|
|
1151
|
+
dev_queue(topic).dead_letters(max_retries: 0).map { |job| job.merge("status" => "dead_letter") }
|
|
1152
|
+
end
|
|
1153
|
+
|
|
1056
1154
|
# ── Queue run-chips (Tier 3) ───────────────────────────────────
|
|
1057
1155
|
|
|
1058
1156
|
# A file-backed dev Queue for +topic+ (matches the GET /queue handler).
|