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