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
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -383,6 +383,16 @@ module Tina4
|
|
|
383
383
|
path = env["PATH_INFO"] || "/"
|
|
384
384
|
method = env["REQUEST_METHOD"]
|
|
385
385
|
|
|
386
|
+
# DEVADMIN-DEC-01/02 (feature 127): fail-closed same-origin + loopback
|
|
387
|
+
# gate on every /__dev write, before ANY handler runs. Closes drive-by
|
|
388
|
+
# CSRF (a page the developer also has open POSTing to /file/save then
|
|
389
|
+
# /reload) and a network-exposed debug box. GET/HEAD/OPTIONS skip it;
|
|
390
|
+
# the MCP surface keeps its own richer 404 gate (with_mcp_gate), so the
|
|
391
|
+
# loopback arm skips the mcp prefixes. Mirrors Python's
|
|
392
|
+
# dev_admin._dev_mutation_denial wired into _handle_dev_admin.
|
|
393
|
+
denial = dev_mutation_denial(env)
|
|
394
|
+
return denial if denial
|
|
395
|
+
|
|
386
396
|
# Dynamic-path routes can't live in the case-tuple below — match
|
|
387
397
|
# them up front. /__dev/api/threads/{id}[/messages] is forwarded
|
|
388
398
|
# verbatim to the Rust agent's /threads/{id}[/messages] surface
|
|
@@ -665,7 +675,10 @@ module Tina4
|
|
|
665
675
|
when ["GET", "/__dev/api/files"]
|
|
666
676
|
json_response(files_list(env))
|
|
667
677
|
when ["GET", "/__dev/api/file"]
|
|
668
|
-
|
|
678
|
+
rel = query_param(env, "path")
|
|
679
|
+
# DEVADMIN-DEC-03: a secret path refuses with 403 (parity with Python);
|
|
680
|
+
# file_read_payload also guards so the body never carries the content.
|
|
681
|
+
json_response(file_read_payload(rel), secret_path?(rel) ? 403 : 200)
|
|
669
682
|
when ["GET", "/__dev/api/file/raw"]
|
|
670
683
|
file_raw_response(query_param(env, "path"))
|
|
671
684
|
when ["POST", "/__dev/api/file/save"]
|
|
@@ -758,9 +771,9 @@ module Tina4
|
|
|
758
771
|
JSON.parse(raw) rescue nil
|
|
759
772
|
end
|
|
760
773
|
|
|
761
|
-
def json_response(data)
|
|
774
|
+
def json_response(data, status = 200)
|
|
762
775
|
body = JSON.generate(data)
|
|
763
|
-
[
|
|
776
|
+
[status, { "content-type" => "application/json; charset=utf-8" }, [body]]
|
|
764
777
|
end
|
|
765
778
|
|
|
766
779
|
def version_check_payload
|
|
@@ -1036,7 +1049,12 @@ module Tina4
|
|
|
1036
1049
|
url = grounding_env_value("TINA4_MCP_URL").to_s
|
|
1037
1050
|
url = "https://mcp.tina4.com" if url.empty?
|
|
1038
1051
|
configured = !token.empty?
|
|
1039
|
-
|
|
1052
|
+
# source: the developer's own token → "personal"; otherwise the coder
|
|
1053
|
+
# runs on the shared FREE-TOKEN trial the Rust agent falls back to →
|
|
1054
|
+
# "free", which drives the "register" nudge. (Disabling the free rung
|
|
1055
|
+
# is an agent-side concern.)
|
|
1056
|
+
source = configured ? "personal" : "free"
|
|
1057
|
+
{ configured: configured, source: source, last4: configured ? token[-4..].to_s : "", url: url }
|
|
1040
1058
|
end
|
|
1041
1059
|
|
|
1042
1060
|
# POST /__dev/api/grounding/token — upsert TINA4_MCP_TOKEN into the
|
|
@@ -1349,13 +1367,22 @@ module Tina4
|
|
|
1349
1367
|
# which a bare Tina4.seed_table call never trips (mirrors Python's
|
|
1350
1368
|
# explicit `from tina4_python.seeder import seed_table`).
|
|
1351
1369
|
require_relative "seeder"
|
|
1370
|
+
# SEED-TABLE-SEED-INERT: seed_table no longer takes seed: (it has no
|
|
1371
|
+
# generators of its own). Build the field map from the introspected
|
|
1372
|
+
# columns using a CALLER-SEEDED FakeData closed over per generator —
|
|
1373
|
+
# the same "auto_field_map(db, table, fake)" pattern Python/Node use
|
|
1374
|
+
# for their dev-admin seed endpoints — so reproducibility comes from
|
|
1375
|
+
# the closure, not a dead keyword.
|
|
1376
|
+
fake = Tina4::FakeData.new(seed: seed)
|
|
1377
|
+
field_map = Tina4._normalize_columns(db.columns(table_name)).each_with_object({}) do |(col_name, type_sym), map|
|
|
1378
|
+
map[col_name] = -> { fake.for_field({ type: type_sym }, col_name) }
|
|
1379
|
+
end
|
|
1352
1380
|
# Delegate to the shared resilient seed_table helper so the endpoint
|
|
1353
1381
|
# gets the exact same per-row wrap (P1) — no unhandled row failure can
|
|
1354
|
-
# crash the endpoint — plus clear/
|
|
1355
|
-
# skips auto-increment id PKs from the introspected column list.
|
|
1382
|
+
# crash the endpoint — plus clear/strict (P2/P1).
|
|
1356
1383
|
summary = Tina4.seed_table(
|
|
1357
|
-
table_name,
|
|
1358
|
-
count: count,
|
|
1384
|
+
table_name, field_map,
|
|
1385
|
+
count: count, clear: clear, strict: strict
|
|
1359
1386
|
)
|
|
1360
1387
|
{
|
|
1361
1388
|
table: table_name,
|
|
@@ -1718,7 +1745,10 @@ module Tina4
|
|
|
1718
1745
|
|
|
1719
1746
|
def dev_files_hidden?(name)
|
|
1720
1747
|
return true if DEV_FILES_IGNORED.include?(name)
|
|
1721
|
-
|
|
1748
|
+
# DEVADMIN-DEC-03: hide dotfiles INCLUDING .env (it carries TINA4_SECRET,
|
|
1749
|
+
# the DB password and the MCP token). The .env.example template is
|
|
1750
|
+
# placeholder-only and stays visible.
|
|
1751
|
+
name.start_with?(".") && name != ".env.example"
|
|
1722
1752
|
end
|
|
1723
1753
|
|
|
1724
1754
|
# Same 4-status mapping Python/PHP use for a porcelain code.
|
|
@@ -1792,6 +1822,10 @@ module Tina4
|
|
|
1792
1822
|
next if dev_files_hidden?(name)
|
|
1793
1823
|
full = File.join(target, name)
|
|
1794
1824
|
entry_rel = full[root.length..].to_s.sub(%r{\A/+}, "").tr("\\", "/")
|
|
1825
|
+
# DEVADMIN-DEC-03: never surface secret material in the listing
|
|
1826
|
+
# (.pem/.key, id_rsa, secrets/, .git/). The dotfile filter above
|
|
1827
|
+
# already drops .env; this covers non-dotfile secrets too.
|
|
1828
|
+
next if secret_path?(entry_rel)
|
|
1795
1829
|
is_dir = File.directory?(full)
|
|
1796
1830
|
|
|
1797
1831
|
# git status for this entry (same mapping PHP/Python use)
|
|
@@ -1856,6 +1890,12 @@ module Tina4
|
|
|
1856
1890
|
|
|
1857
1891
|
def file_read_payload(rel)
|
|
1858
1892
|
return { error: "path required" } if rel.nil? || rel.empty?
|
|
1893
|
+
# DEVADMIN-DEC-03: never serve secret material (.env, keys, secrets/).
|
|
1894
|
+
# The dispatch statuses this 403; the guard here keeps the helper safe
|
|
1895
|
+
# for any other caller too. The secret content never enters the payload.
|
|
1896
|
+
if secret_path?(rel)
|
|
1897
|
+
return { error: "Refused: secret file", path: rel.to_s, content: "", language: "text", bytes: 0 }
|
|
1898
|
+
end
|
|
1859
1899
|
begin
|
|
1860
1900
|
target = safe_project_path(rel)
|
|
1861
1901
|
return { error: "Not found" } unless File.exist?(target)
|
|
@@ -1869,6 +1909,8 @@ module Tina4
|
|
|
1869
1909
|
|
|
1870
1910
|
def file_raw_response(rel)
|
|
1871
1911
|
return json_response({ error: "path required" }) if rel.nil? || rel.empty?
|
|
1912
|
+
# DEVADMIN-DEC-03: never serve secret material (.env, keys, secrets/).
|
|
1913
|
+
return json_response({ error: "Refused: secret file" }, 403) if secret_path?(rel)
|
|
1872
1914
|
begin
|
|
1873
1915
|
target = safe_project_path(rel)
|
|
1874
1916
|
return json_response({ error: "Not found" }) unless File.file?(target)
|
|
@@ -2068,6 +2110,92 @@ module Tina4
|
|
|
2068
2110
|
res.zero?
|
|
2069
2111
|
end
|
|
2070
2112
|
|
|
2113
|
+
# ── Dev-admin mutation security (feature 127, DEVADMIN-DEC-01/02/03) ──
|
|
2114
|
+
# The dashboard writes files, runs SQL and installs gems, so it must
|
|
2115
|
+
# assume the developer ALSO browses the web. Two fail-closed gates guard
|
|
2116
|
+
# every /__dev write, and a secret denylist guards the file-read surface.
|
|
2117
|
+
# Mirrors tina4_python/dev_admin _dev_same_origin_ok / _dev_mutation_denial
|
|
2118
|
+
# / _is_secret_path.
|
|
2119
|
+
DEV_SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
2120
|
+
# The MCP surface carries its own richer loopback+token gate
|
|
2121
|
+
# (with_mcp_gate -> 404), so the REST loopback arm skips these prefixes and
|
|
2122
|
+
# lets the MCP gate govern (keeps a disallowed mcp/call a 404, not a 403).
|
|
2123
|
+
DEV_MCP_PREFIXES = ["/__dev/api/mcp", "/__dev/mcp"].freeze
|
|
2124
|
+
# Private-key / credential basenames the file endpoints must never serve.
|
|
2125
|
+
DEV_SECRET_BASENAMES = %w[.env .envrc id_rsa id_dsa id_ecdsa id_ed25519].freeze
|
|
2126
|
+
DEV_SECRET_SUFFIXES = %w[.pem .key .pfx .p12 .keystore .jks].freeze
|
|
2127
|
+
|
|
2128
|
+
# DEVADMIN-DEC-01: fail-closed same-origin check for a dev-admin mutation.
|
|
2129
|
+
#
|
|
2130
|
+
# A drive-by CSRF is a BROWSER cross-origin request, and a modern browser
|
|
2131
|
+
# always sends Sec-Fetch-Site (and any browser sends Origin on a
|
|
2132
|
+
# cross-origin POST), so:
|
|
2133
|
+
# - Sec-Fetch-Site present -> trust the browser's own classification
|
|
2134
|
+
# (cross-site refused; same-origin / same-site / none allowed).
|
|
2135
|
+
# - else Origin present -> require its host to match the request Host.
|
|
2136
|
+
# - else neither -> not a browser cross-origin request at all (curl, a
|
|
2137
|
+
# test client, a server-side caller); it cannot be a drive-by, so it
|
|
2138
|
+
# is allowed here and the loopback gate still constrains the peer.
|
|
2139
|
+
def dev_same_origin_ok?(env)
|
|
2140
|
+
sfs = (env["HTTP_SEC_FETCH_SITE"] || "").strip.downcase
|
|
2141
|
+
return %w[same-origin same-site none].include?(sfs) unless sfs.empty?
|
|
2142
|
+
|
|
2143
|
+
origin = (env["HTTP_ORIGIN"] || "").strip
|
|
2144
|
+
unless origin.empty?
|
|
2145
|
+
netloc = origin.include?("://") ? origin.split("://", 2)[1] : origin
|
|
2146
|
+
host = (env["HTTP_HOST"] || "").strip
|
|
2147
|
+
return !host.empty? && netloc.downcase == host.downcase
|
|
2148
|
+
end
|
|
2149
|
+
true
|
|
2150
|
+
end
|
|
2151
|
+
|
|
2152
|
+
# Return a 403 Rack triple to REFUSE a dev-admin write, or nil to allow.
|
|
2153
|
+
#
|
|
2154
|
+
# Two independent fail-closed gates on every /__dev mutation:
|
|
2155
|
+
# DEC-01 same-origin (all writes, incl. mcp/call) - drive-by CSRF.
|
|
2156
|
+
# DEC-02 loopback peer (all writes EXCEPT the MCP surface, which carries
|
|
2157
|
+
# its own gate) - a network-exposed debug box.
|
|
2158
|
+
# Uses the RAW socket peer (env["REMOTE_ADDR"]), never X-Forwarded-For.
|
|
2159
|
+
def dev_mutation_denial(env)
|
|
2160
|
+
method = (env["REQUEST_METHOD"] || "").to_s.upcase
|
|
2161
|
+
return nil if DEV_SAFE_METHODS.include?(method)
|
|
2162
|
+
path = (env["PATH_INFO"] || "").to_s
|
|
2163
|
+
return nil unless path.start_with?("/__dev")
|
|
2164
|
+
|
|
2165
|
+
return dev_refused("dev-admin: refused (cross-origin request)") unless dev_same_origin_ok?(env)
|
|
2166
|
+
|
|
2167
|
+
unless path.start_with?(*DEV_MCP_PREFIXES)
|
|
2168
|
+
remote_ip = (env["REMOTE_ADDR"] || "").to_s
|
|
2169
|
+
unless Tina4.is_loopback?(remote_ip) || mcp_token_ok?(env)
|
|
2170
|
+
return dev_refused("dev-admin: refused (non-loopback peer)")
|
|
2171
|
+
end
|
|
2172
|
+
end
|
|
2173
|
+
nil
|
|
2174
|
+
end
|
|
2175
|
+
|
|
2176
|
+
def dev_refused(message)
|
|
2177
|
+
[403, { "content-type" => "application/json; charset=utf-8" },
|
|
2178
|
+
[JSON.generate({ "ok" => false, "error" => message })]]
|
|
2179
|
+
end
|
|
2180
|
+
|
|
2181
|
+
# DEVADMIN-DEC-03: true when +rel+ names secret material the file endpoints
|
|
2182
|
+
# must never serve - .env / .env.* (the .env.example template is allowed),
|
|
2183
|
+
# anything under .git/ or secrets/, and private-key material.
|
|
2184
|
+
def secret_path?(rel)
|
|
2185
|
+
norm = rel.to_s.tr("\\", "/").gsub(%r{\A/+|/+\z}, "").downcase
|
|
2186
|
+
return false if norm.empty?
|
|
2187
|
+
|
|
2188
|
+
parts = norm.split("/")
|
|
2189
|
+
return true if parts.any? { |p| p == ".git" || p == "secrets" }
|
|
2190
|
+
|
|
2191
|
+
base = parts.last
|
|
2192
|
+
return false if base == ".env.example"
|
|
2193
|
+
return true if base == ".env" || base.start_with?(".env.")
|
|
2194
|
+
return true if DEV_SECRET_BASENAMES.include?(base)
|
|
2195
|
+
|
|
2196
|
+
base.end_with?(*DEV_SECRET_SUFFIXES)
|
|
2197
|
+
end
|
|
2198
|
+
|
|
2071
2199
|
def mcp_tools_list
|
|
2072
2200
|
return { tools: [], count: 0 } unless defined?(Tina4::McpServer)
|
|
2073
2201
|
server = Tina4._default_mcp_server
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "zlib"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
3
7
|
module Tina4
|
|
4
8
|
# The dispatch pipeline: the concerns of RackApp#call, named and ordered.
|
|
5
9
|
#
|
|
@@ -67,7 +71,15 @@ module Tina4
|
|
|
67
71
|
# the swagger and static branches - which return early - skipped it, and
|
|
68
72
|
# `HEAD /style.css` shipped the whole file body. Measured 2026-07-31: Ruby
|
|
69
73
|
# returned 15 bytes where PHP, Python and Node all returned 0.
|
|
74
|
+
#
|
|
75
|
+
# `compress_and_tag` and `conditional_get` (feature 40, CE-DEC-01/02) run
|
|
76
|
+
# FIRST - before head_strip - so a HEAD response's preserved
|
|
77
|
+
# Content-Length reflects the (possibly compressed) body the equivalent
|
|
78
|
+
# GET would have sent, mirroring the Python master's build_headers() ->
|
|
79
|
+
# app() ordering (compress -> tag -> 304-decide -> strip-for-HEAD).
|
|
70
80
|
ALWAYS_STAGES = %i[
|
|
81
|
+
compress_and_tag
|
|
82
|
+
conditional_get
|
|
71
83
|
head_strip
|
|
72
84
|
apply_cors
|
|
73
85
|
].freeze
|
|
@@ -286,12 +298,136 @@ module Tina4
|
|
|
286
298
|
return static_response
|
|
287
299
|
end
|
|
288
300
|
|
|
289
|
-
handle_404(ctx.path)
|
|
301
|
+
handle_404(ctx.path, ctx.env["HTTP_ACCEPT"])
|
|
290
302
|
end
|
|
291
303
|
|
|
292
|
-
# ──
|
|
304
|
+
# ── ALWAYS STAGES (compression / ETag / conditional-GET) ──────────
|
|
293
305
|
# Each returns a replacement Rack triple, or nil to leave it unchanged.
|
|
294
306
|
|
|
307
|
+
# Gzip-compress + attach an ETag (feature 40, CE-DEC-01) - the ONE header
|
|
308
|
+
# builder every response funnels through (a static asset or a dynamic
|
|
309
|
+
# route), mirroring the Python master's build_headers(). A streaming body
|
|
310
|
+
# (an Enumerator, from Response#stream) is never touched - joining it
|
|
311
|
+
# would drain an SSE generator instead of letting it stream live.
|
|
312
|
+
#
|
|
313
|
+
# Compression: the body exceeds 1024 bytes AND Accept-Encoding offers
|
|
314
|
+
# gzip AND the content type is compressible. Uses stdlib Zlib - zero new
|
|
315
|
+
# dependency.
|
|
316
|
+
#
|
|
317
|
+
# ETag: a strong md5 hash (first 16 hex chars) over the FINAL
|
|
318
|
+
# (post-compression) body, UNLESS a validator is already set - a
|
|
319
|
+
# static-file response (#serve_static_file) pins its own weak
|
|
320
|
+
# size+mtime ETag before this runs (CE-DEC-02), so this never overwrites
|
|
321
|
+
# it with a content hash.
|
|
322
|
+
def compress_and_tag(ctx, response)
|
|
323
|
+
status, headers, body_parts = response
|
|
324
|
+
return nil unless body_parts.is_a?(Array)
|
|
325
|
+
|
|
326
|
+
body = body_parts.join
|
|
327
|
+
new_headers = headers.dup
|
|
328
|
+
changed = false
|
|
329
|
+
|
|
330
|
+
if should_gzip?(ctx, body, new_headers)
|
|
331
|
+
body = gzip_string(body)
|
|
332
|
+
new_headers["content-encoding"] = "gzip"
|
|
333
|
+
new_headers["vary"] = "Accept-Encoding"
|
|
334
|
+
new_headers["content-length"] = body.bytesize.to_s if new_headers["content-length"]
|
|
335
|
+
changed = true
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
if should_tag_etag?(new_headers, body, status)
|
|
339
|
+
new_headers["etag"] = %("#{Digest::MD5.hexdigest(body)[0, 16]}")
|
|
340
|
+
changed = true
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
return nil unless changed
|
|
344
|
+
|
|
345
|
+
[status, new_headers, [body]]
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Split out of compress_and_tag (metrics/6b) to hold the complexity gate:
|
|
349
|
+
# the body-size / Accept-Encoding / content-type gzip decision, named so
|
|
350
|
+
# the caller reads as one line per concern instead of one three-way &&.
|
|
351
|
+
def should_gzip?(ctx, body, headers)
|
|
352
|
+
accept_encoding = ctx.env["HTTP_ACCEPT_ENCODING"] || ""
|
|
353
|
+
content_type = headers["content-type"] || ""
|
|
354
|
+
body.bytesize > 1024 && accept_encoding.include?("gzip") && compressible_content_type?(content_type)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Split out of compress_and_tag (metrics/6b): whether this response earns
|
|
358
|
+
# a content-hash ETag - no validator set already, a non-empty body, a 200.
|
|
359
|
+
def should_tag_etag?(headers, body, status)
|
|
360
|
+
!headers["etag"] && !body.empty? && status == 200
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Answer a matching conditional GET with a 304 that PRESERVES whichever
|
|
364
|
+
# validators (ETag / Last-Modified) the 200 would have carried (feature
|
|
365
|
+
# 40). A static-file 304 (#serve_static_file's own short-circuit) is
|
|
366
|
+
# already terminal by the time this runs (status is 304, not 200), so the
|
|
367
|
+
# guard below leaves it untouched.
|
|
368
|
+
def conditional_get(ctx, response)
|
|
369
|
+
status, headers, body_parts = response
|
|
370
|
+
return nil unless status == 200 && body_parts.is_a?(Array)
|
|
371
|
+
|
|
372
|
+
body = body_parts.join
|
|
373
|
+
return nil if body.empty?
|
|
374
|
+
|
|
375
|
+
etag = headers["etag"]
|
|
376
|
+
last_modified = headers["last-modified"]
|
|
377
|
+
return nil unless request_not_modified?(ctx, etag, last_modified)
|
|
378
|
+
|
|
379
|
+
not_modified_headers = {}
|
|
380
|
+
not_modified_headers["etag"] = etag if etag
|
|
381
|
+
not_modified_headers["last-modified"] = last_modified if last_modified
|
|
382
|
+
[304, not_modified_headers, [""]]
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Split out of conditional_get (metrics/6b) to hold the complexity gate.
|
|
386
|
+
# RFC 7232 S6: If-None-Match, when present, decides it alone; only when
|
|
387
|
+
# the request sends no If-None-Match does If-Modified-Since apply.
|
|
388
|
+
def request_not_modified?(ctx, etag, last_modified)
|
|
389
|
+
if_none_match = ctx.env["HTTP_IF_NONE_MATCH"]
|
|
390
|
+
return etag_matches?(if_none_match, etag) if if_none_match && !if_none_match.empty?
|
|
391
|
+
|
|
392
|
+
if_modified_since = ctx.env["HTTP_IF_MODIFIED_SINCE"]
|
|
393
|
+
return false unless if_modified_since && !if_modified_since.empty? && last_modified
|
|
394
|
+
|
|
395
|
+
begin
|
|
396
|
+
Time.httpdate(last_modified).to_i <= Time.httpdate(if_modified_since).to_i
|
|
397
|
+
rescue ArgumentError
|
|
398
|
+
false # Unparseable date -> serve the body (never 304 on garbage).
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Whether a content type benefits from gzip compression (text-ish, JSON,
|
|
403
|
+
# XML, JS, SVG). Mirrors the Python master's `_is_compressible`.
|
|
404
|
+
def compressible_content_type?(content_type)
|
|
405
|
+
%w[text/ application/json application/xml application/javascript image/svg].any? do |needle|
|
|
406
|
+
content_type.include?(needle)
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Gzip-compress a string at level 6, in memory. Stdlib Zlib - zero new
|
|
411
|
+
# dependency.
|
|
412
|
+
def gzip_string(str)
|
|
413
|
+
io = StringIO.new
|
|
414
|
+
writer = Zlib::GzipWriter.new(io, 6)
|
|
415
|
+
writer.write(str)
|
|
416
|
+
writer.close
|
|
417
|
+
io.string
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Match an If-None-Match header value against `etag` (RFC 7232 S3.2 weak
|
|
421
|
+
# comparison). Shared with the static-file conditional-GET
|
|
422
|
+
# (#static_not_modified?) so BOTH paths use IDENTICAL matching semantics.
|
|
423
|
+
def etag_matches?(if_none_match, etag)
|
|
424
|
+
return false if etag.nil? || etag.empty?
|
|
425
|
+
return true if if_none_match.strip == "*"
|
|
426
|
+
|
|
427
|
+
want = weak_etag_value(etag)
|
|
428
|
+
if_none_match.split(",").any? { |candidate| weak_etag_value(candidate) == want }
|
|
429
|
+
end
|
|
430
|
+
|
|
295
431
|
# RFC 9110 s9.3.2: a HEAD response MUST NOT include content. Strip the body
|
|
296
432
|
# unconditionally and record what Content-Length the GET would have sent -
|
|
297
433
|
# cache validators, link checkers and monitoring probes use that header to
|
|
@@ -458,7 +594,12 @@ module Tina4
|
|
|
458
594
|
# are only known now, so they are attached here.
|
|
459
595
|
def prepare_route_request(ctx)
|
|
460
596
|
ctx.request = ctx.pre_request || Tina4::Request.new(ctx.env)
|
|
461
|
-
ctx.request.
|
|
597
|
+
ctx.request.params = ctx.path_params
|
|
598
|
+
# Attach the matched route so post-match middleware (CsrfMiddleware) can
|
|
599
|
+
# read its metadata -- e.g. auth_required to honour a public write route
|
|
600
|
+
# (.no_auth). Without this the CSRF no_auth skip is dead code, because the
|
|
601
|
+
# request otherwise carries no handler/route reference.
|
|
602
|
+
ctx.request.route = ctx.route
|
|
462
603
|
ctx.env["tina4.request"] = ctx.request # Store for session save after response
|
|
463
604
|
ctx.response = ctx.pre_response || Tina4::Response.new
|
|
464
605
|
nil
|
|
@@ -485,7 +626,7 @@ module Tina4
|
|
|
485
626
|
return nil unless ctx.route.auth_handler
|
|
486
627
|
return nil if ctx.route.auth_handler.call(ctx.env)
|
|
487
628
|
|
|
488
|
-
handle_403(ctx.env["PATH_INFO"] || "/")
|
|
629
|
+
handle_403(ctx.env["PATH_INFO"] || "/", ctx.env["HTTP_ACCEPT"])
|
|
489
630
|
end
|
|
490
631
|
|
|
491
632
|
# Secure-by-default: enforce bearer-token auth on write routes.
|
|
@@ -227,17 +227,25 @@ module Tina4
|
|
|
227
227
|
@connection.query(:hash, sql, *params)
|
|
228
228
|
end
|
|
229
229
|
end
|
|
230
|
-
rows.map { |row| decode_blobs(
|
|
230
|
+
rows.map { |row| decode_blobs(symbolize_keys(row)) }
|
|
231
231
|
end
|
|
232
232
|
|
|
233
233
|
def execute(sql, params = [])
|
|
234
|
-
with_reconnect do
|
|
234
|
+
result = with_reconnect do
|
|
235
235
|
if params.empty?
|
|
236
236
|
@connection.execute(sql)
|
|
237
237
|
else
|
|
238
238
|
@connection.execute(sql, *params)
|
|
239
239
|
end
|
|
240
240
|
end
|
|
241
|
+
# The fb gem's Connection#execute RETURNS the affected row count as an
|
|
242
|
+
# Integer for DML, so capture it for #affected_rows (FB-AFFECTED-FAB --
|
|
243
|
+
# the facade used to default it). Remember an INSERT's target table so
|
|
244
|
+
# #last_insert_id can read its GEN_<TABLE>_ID generator (FB-LASTID-GAP);
|
|
245
|
+
# a non-INSERT clears it so last_insert_id is nil there.
|
|
246
|
+
@affected_rows = result.is_a?(Integer) ? result : 0
|
|
247
|
+
@last_insert_table = insert_target_table(sql)
|
|
248
|
+
result
|
|
241
249
|
end
|
|
242
250
|
|
|
243
251
|
# Public so specs (and curious operators) can verify the matcher
|
|
@@ -249,10 +257,28 @@ module Tina4
|
|
|
249
257
|
DEAD_CONN_MARKERS.any? { |m| lower.include?(m) }
|
|
250
258
|
end
|
|
251
259
|
|
|
260
|
+
# Firebird has no generic last-insert-id, so read the GEN_<TABLE>_ID
|
|
261
|
+
# generator the row's BEFORE INSERT trigger drew from (FB-LASTID-GAP). The
|
|
262
|
+
# generator read is COLUMN-NAME-INDEPENDENT, so it is correct even for a
|
|
263
|
+
# non-`id` primary key. Returns nil when the last write was not an INSERT
|
|
264
|
+
# or the table has no such generator (GEN_ID then raises -> rescued).
|
|
252
265
|
def last_insert_id
|
|
266
|
+
table = @last_insert_table
|
|
267
|
+
return nil if table.nil? || table.empty?
|
|
268
|
+
generator = "GEN_#{table.gsub('"', '').upcase}_ID"
|
|
269
|
+
row = execute_query("SELECT GEN_ID(#{generator}, 0) AS LID FROM RDB\$DATABASE").first
|
|
270
|
+
row && (row[:lid] || row[:LID])
|
|
271
|
+
rescue StandardError
|
|
253
272
|
nil
|
|
254
273
|
end
|
|
255
274
|
|
|
275
|
+
# Rows affected by the most recent write, read from the fb gem's execute()
|
|
276
|
+
# return (an Integer for DML). Best-effort 0 for DDL / a non-count return.
|
|
277
|
+
# The facade's write_affected reads this when the driver implements it.
|
|
278
|
+
def affected_rows
|
|
279
|
+
@affected_rows || 0
|
|
280
|
+
end
|
|
281
|
+
|
|
256
282
|
def placeholder
|
|
257
283
|
"?"
|
|
258
284
|
end
|
|
@@ -317,10 +343,15 @@ module Tina4
|
|
|
317
343
|
@in_transaction = false
|
|
318
344
|
end
|
|
319
345
|
|
|
346
|
+
# ADR-0044 required adapter capability.
|
|
347
|
+
def get_database_type
|
|
348
|
+
'firebird'
|
|
349
|
+
end
|
|
350
|
+
|
|
320
351
|
def tables
|
|
321
352
|
sql = "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL"
|
|
322
353
|
rows = execute_query(sql)
|
|
323
|
-
rows.map { |r| (r["RDB\$RELATION_NAME"] || r["rdb\$relation_name"] || "").strip }
|
|
354
|
+
rows.map { |r| (r[:"RDB\$RELATION_NAME"] || r[:"rdb\$relation_name"] || "").strip }
|
|
324
355
|
end
|
|
325
356
|
|
|
326
357
|
def columns(table_name)
|
|
@@ -341,7 +372,7 @@ module Tina4
|
|
|
341
372
|
"ORDER BY SG.RDB\$FIELD_POSITION"
|
|
342
373
|
pk_names = begin
|
|
343
374
|
execute_query(pk_sql, [table_name.upcase]).map do |r|
|
|
344
|
-
(r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip.upcase
|
|
375
|
+
(r[:"RDB\$FIELD_NAME"] || r[:"rdb\$field_name"] || "").strip.upcase
|
|
345
376
|
end.reject(&:empty?).to_set
|
|
346
377
|
rescue StandardError
|
|
347
378
|
# A table with no primary key is not an error.
|
|
@@ -349,12 +380,12 @@ module Tina4
|
|
|
349
380
|
end
|
|
350
381
|
|
|
351
382
|
rows.map do |r|
|
|
352
|
-
field_name = (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip
|
|
383
|
+
field_name = (r[:"RDB\$FIELD_NAME"] || r[:"rdb\$field_name"] || "").strip
|
|
353
384
|
{
|
|
354
385
|
name: field_name,
|
|
355
|
-
type: r["RDB\$FIELD_TYPE"] || r["rdb\$field_type"],
|
|
356
|
-
nullable: (r["RDB\$NULL_FLAG"] || r["rdb\$null_flag"]).nil?,
|
|
357
|
-
default: r["RDB\$DEFAULT_SOURCE"] || r["rdb\$default_source"],
|
|
386
|
+
type: r[:"RDB\$FIELD_TYPE"] || r[:"rdb\$field_type"],
|
|
387
|
+
nullable: (r[:"RDB\$NULL_FLAG"] || r[:"rdb\$null_flag"]).nil?,
|
|
388
|
+
default: r[:"RDB\$DEFAULT_SOURCE"] || r[:"rdb\$default_source"],
|
|
358
389
|
primary_key: pk_names.include?(field_name.upcase)
|
|
359
390
|
}
|
|
360
391
|
end
|
|
@@ -390,8 +421,24 @@ module Tina4
|
|
|
390
421
|
yield
|
|
391
422
|
end
|
|
392
423
|
|
|
393
|
-
|
|
394
|
-
|
|
424
|
+
# Hydrate a raw fb-gem row into a SYMBOL-keyed Hash — parity with the
|
|
425
|
+
# sqlite/postgres/mssql drivers (SqliteDriver#symbolize_rows,
|
|
426
|
+
# PostgresDriver#symbolize_result, MssqlDriver's `symbolize_keys: true`),
|
|
427
|
+
# all three of which hand back symbol keys. This used to build a
|
|
428
|
+
# STRING-keyed row (hence the old name `stringify_keys`), the one
|
|
429
|
+
# Firebird-only divergence in the whole row-hydration contract:
|
|
430
|
+
# `db.fetch(...).first[:name]` worked on every engine except Firebird,
|
|
431
|
+
# where only `row["name"]` did. column_name's casing fold-back is
|
|
432
|
+
# unchanged — only the key TYPE changes here.
|
|
433
|
+
def symbolize_keys(hash)
|
|
434
|
+
hash.each_with_object({}) { |(k, v), h| h[column_name(k.to_s).to_sym] = v }
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# The target table of an INSERT (so last_insert_id can read its
|
|
438
|
+
# GEN_<TABLE>_ID generator), or nil for any other statement.
|
|
439
|
+
def insert_target_table(sql)
|
|
440
|
+
match = sql.to_s.match(/\AINSERT\s+INTO\s+([^\s(]+)/i)
|
|
441
|
+
match && match[1]
|
|
395
442
|
end
|
|
396
443
|
|
|
397
444
|
# Firebird's stored column name, folded back only when it was folded.
|
|
@@ -399,8 +446,8 @@ module Tina4
|
|
|
399
446
|
# Firebird's identifier folding is ASYMMETRIC. An unquoted `AS x` is
|
|
400
447
|
# stored UPPERCASE, so the driver hands back "X" where every other engine
|
|
401
448
|
# Tina4 supports gives "x" -- PostgreSQL folds to lower, and MySQL, SQLite
|
|
402
|
-
# and MSSQL preserve what you wrote. Portable code reading row[
|
|
403
|
-
# on Firebird alone; the live URL examples in this repo asserted row[
|
|
449
|
+
# and MSSQL preserve what you wrote. Portable code reading row[:x] broke
|
|
450
|
+
# on Firebird alone; the live URL examples in this repo asserted row[:x]
|
|
404
451
|
# and could never have passed once they actually reached a server.
|
|
405
452
|
#
|
|
406
453
|
# A QUOTED `AS "MyCol"` is stored exactly as written, and that case is
|