tina4ruby 3.13.37 → 3.13.38
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/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +106 -2
- data/lib/tina4/database.rb +356 -46
- data/lib/tina4/dev_admin.rb +27 -10
- 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/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/mcp.rb +10 -3
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +238 -47
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +6 -4
- data/lib/tina4/orm.rb +13 -10
- data/lib/tina4/rack_app.rb +17 -10
- data/lib/tina4/response.rb +31 -11
- 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 +354 -18
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +11 -9
- metadata +6 -47
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/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.
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -651,9 +651,16 @@ module Tina4
|
|
|
651
651
|
db = Tina4.database
|
|
652
652
|
return { "error" => "No database connection" } if db.nil?
|
|
653
653
|
param_list = params.is_a?(String) ? JSON.parse(params) : params
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
654
|
+
# db.execute() now RAISES on a SQL error (it no longer returns false).
|
|
655
|
+
# Catch it and return a clean { error: } payload instead of letting the
|
|
656
|
+
# exception escape the tool handler.
|
|
657
|
+
begin
|
|
658
|
+
result = db.execute(sql, param_list)
|
|
659
|
+
db.commit rescue nil
|
|
660
|
+
{ "success" => true, "affected_rows" => (result.respond_to?(:count) ? result.count : 0) }
|
|
661
|
+
rescue => e
|
|
662
|
+
{ "error" => db.get_error || e.message }
|
|
663
|
+
end
|
|
657
664
|
}, "Execute arbitrary SQL (INSERT/UPDATE/DELETE/DDL)")
|
|
658
665
|
|
|
659
666
|
server.register_tool("database_tables", lambda {
|
data/lib/tina4/messenger.rb
CHANGED
|
@@ -13,8 +13,44 @@ end
|
|
|
13
13
|
require "base64"
|
|
14
14
|
require "securerandom"
|
|
15
15
|
require "time"
|
|
16
|
+
require "socket"
|
|
17
|
+
require "timeout"
|
|
18
|
+
begin
|
|
19
|
+
require "openssl"
|
|
20
|
+
rescue LoadError
|
|
21
|
+
# openssl is part of stdlib; absence only affects TLS error classification
|
|
22
|
+
end
|
|
16
23
|
|
|
17
24
|
module Tina4
|
|
25
|
+
# Raised on a messenger failure (base class).
|
|
26
|
+
class MessengerError < StandardError; end
|
|
27
|
+
|
|
28
|
+
# Raised when an IMAP read fails to connect, authenticate, or speak the
|
|
29
|
+
# protocol. Distinct from a successful fetch that simply has no messages —
|
|
30
|
+
# that still returns an empty result ([]/nil/0/{}), NOT an error.
|
|
31
|
+
#
|
|
32
|
+
# Subclasses MessengerError so existing `rescue Tina4::MessengerError`
|
|
33
|
+
# handlers still catch it.
|
|
34
|
+
class MessengerConnectionError < MessengerError; end
|
|
35
|
+
|
|
36
|
+
# Errors that mean "we could not talk to the mail server", as opposed to
|
|
37
|
+
# "we talked fine and the mailbox is empty". These must fail loud — LOG and
|
|
38
|
+
# RAISE — never be silently swallowed into an empty result. Mirrors the
|
|
39
|
+
# Python master's _IMAP_CONNECTION_ERRORS tuple.
|
|
40
|
+
#
|
|
41
|
+
# Built lazily so a missing net/imap gem (LoadError above) doesn't break
|
|
42
|
+
# loading this file.
|
|
43
|
+
IMAP_CONNECTION_ERRORS = [
|
|
44
|
+
SocketError, # DNS / host resolution failures
|
|
45
|
+
IOError, # closed/broken stream, EOF mid-conversation
|
|
46
|
+
SystemCallError, # Errno::ECONNREFUSED / ECONNRESET / ETIMEDOUT etc.
|
|
47
|
+
Timeout::Error, # connect/read timeout (Net::OpenTimeout descends from this)
|
|
48
|
+
MessengerError # our own protocol-failure signal (re-raised as-is)
|
|
49
|
+
].tap do |errors|
|
|
50
|
+
errors << Net::IMAP::Error if defined?(Net::IMAP::Error)
|
|
51
|
+
errors << OpenSSL::SSL::SSLError if defined?(OpenSSL::SSL::SSLError)
|
|
52
|
+
end.freeze
|
|
53
|
+
|
|
18
54
|
# Tina4 Messenger — Email sending (SMTP) and reading (IMAP).
|
|
19
55
|
#
|
|
20
56
|
# Unified .env-driven configuration with constructor override.
|
|
@@ -120,9 +156,14 @@ module Tina4
|
|
|
120
156
|
|
|
121
157
|
# ── IMAP operations ──────────────────────────────────────────────────
|
|
122
158
|
|
|
123
|
-
# List messages in a folder
|
|
159
|
+
# List messages in a folder.
|
|
160
|
+
#
|
|
161
|
+
# Raises Tina4::MessengerConnectionError on a connection/auth/protocol
|
|
162
|
+
# failure (FAILS LOUD — never returns [] to hide it). A successful fetch
|
|
163
|
+
# from an empty folder returns [] (that is NOT an error).
|
|
124
164
|
def inbox(folder: "INBOX", limit: 20, offset: 0)
|
|
125
|
-
|
|
165
|
+
imap = imap_open("inbox")
|
|
166
|
+
begin
|
|
126
167
|
imap.select(folder)
|
|
127
168
|
uids = imap.uid_search(["ALL"])
|
|
128
169
|
uids = uids.reverse # newest first
|
|
@@ -131,15 +172,20 @@ module Tina4
|
|
|
131
172
|
|
|
132
173
|
envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
|
|
133
174
|
(envelopes || []).map { |msg| parse_envelope(msg) }
|
|
175
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
176
|
+
raise imap_fail("inbox", e)
|
|
177
|
+
ensure
|
|
178
|
+
imap_cleanup(imap)
|
|
134
179
|
end
|
|
135
|
-
rescue => e
|
|
136
|
-
Tina4::Log.error("IMAP inbox failed: #{e.message}")
|
|
137
|
-
[]
|
|
138
180
|
end
|
|
139
181
|
|
|
140
|
-
# Read a single message by UID
|
|
182
|
+
# Read a single message by UID.
|
|
183
|
+
#
|
|
184
|
+
# Raises Tina4::MessengerConnectionError on a connection/protocol failure.
|
|
185
|
+
# A successful fetch for a non-existent UID returns nil (that is NOT an error).
|
|
141
186
|
def read(uid, folder: "INBOX", mark_read: true)
|
|
142
|
-
|
|
187
|
+
imap = imap_open("read")
|
|
188
|
+
begin
|
|
143
189
|
imap.select(folder)
|
|
144
190
|
data = imap.uid_fetch(uid, ["ENVELOPE", "FLAGS", "BODY[]", "RFC822.SIZE"])
|
|
145
191
|
return nil if data.nil? || data.empty?
|
|
@@ -150,28 +196,38 @@ module Tina4
|
|
|
150
196
|
|
|
151
197
|
msg = data.first
|
|
152
198
|
parse_full_message(msg)
|
|
199
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
200
|
+
raise imap_fail("read", e)
|
|
201
|
+
ensure
|
|
202
|
+
imap_cleanup(imap)
|
|
153
203
|
end
|
|
154
|
-
rescue => e
|
|
155
|
-
Tina4::Log.error("IMAP read failed: #{e.message}")
|
|
156
|
-
nil
|
|
157
204
|
end
|
|
158
205
|
|
|
159
|
-
# Count unread messages
|
|
206
|
+
# Count unread messages.
|
|
207
|
+
#
|
|
208
|
+
# Raises Tina4::MessengerConnectionError on a connection/protocol failure.
|
|
209
|
+
# A successful query with no unseen messages returns 0 (NOT an error).
|
|
160
210
|
def unread(folder: "INBOX")
|
|
161
|
-
|
|
211
|
+
imap = imap_open("unread")
|
|
212
|
+
begin
|
|
162
213
|
imap.select(folder)
|
|
163
214
|
uids = imap.uid_search(["UNSEEN"])
|
|
164
215
|
uids.length
|
|
216
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
217
|
+
raise imap_fail("unread", e)
|
|
218
|
+
ensure
|
|
219
|
+
imap_cleanup(imap)
|
|
165
220
|
end
|
|
166
|
-
rescue => e
|
|
167
|
-
Tina4::Log.error("IMAP unread count failed: #{e.message}")
|
|
168
|
-
0
|
|
169
221
|
end
|
|
170
222
|
|
|
171
|
-
# Search messages with filters
|
|
223
|
+
# Search messages with filters.
|
|
224
|
+
#
|
|
225
|
+
# Raises Tina4::MessengerConnectionError on a connection/protocol failure.
|
|
226
|
+
# A successful search with no matches returns [] (NOT an error).
|
|
172
227
|
def search(folder: "INBOX", subject: nil, sender: nil, since: nil,
|
|
173
228
|
before: nil, unseen_only: false, limit: 20)
|
|
174
|
-
|
|
229
|
+
imap = imap_open("search")
|
|
230
|
+
begin
|
|
175
231
|
imap.select(folder)
|
|
176
232
|
criteria = build_search_criteria(
|
|
177
233
|
subject: subject, sender: sender, since: since,
|
|
@@ -184,21 +240,26 @@ module Tina4
|
|
|
184
240
|
|
|
185
241
|
envelopes = imap.uid_fetch(page, ["ENVELOPE", "FLAGS", "RFC822.SIZE"])
|
|
186
242
|
(envelopes || []).map { |msg| parse_envelope(msg) }
|
|
243
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
244
|
+
raise imap_fail("search", e)
|
|
245
|
+
ensure
|
|
246
|
+
imap_cleanup(imap)
|
|
187
247
|
end
|
|
188
|
-
rescue => e
|
|
189
|
-
Tina4::Log.error("IMAP search failed: #{e.message}")
|
|
190
|
-
[]
|
|
191
248
|
end
|
|
192
249
|
|
|
193
|
-
# List all IMAP folders
|
|
250
|
+
# List all IMAP folders.
|
|
251
|
+
#
|
|
252
|
+
# Raises Tina4::MessengerConnectionError on a connection/protocol failure.
|
|
194
253
|
def folders
|
|
195
|
-
|
|
254
|
+
imap = imap_open("folders")
|
|
255
|
+
begin
|
|
196
256
|
boxes = imap.list("", "*")
|
|
197
257
|
(boxes || []).map(&:name)
|
|
258
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
259
|
+
raise imap_fail("folders", e)
|
|
260
|
+
ensure
|
|
261
|
+
imap_cleanup(imap)
|
|
198
262
|
end
|
|
199
|
-
rescue => e
|
|
200
|
-
Tina4::Log.error("IMAP folders failed: #{e.message}")
|
|
201
|
-
[]
|
|
202
263
|
end
|
|
203
264
|
|
|
204
265
|
# Mark a message as read (set \Seen flag).
|
|
@@ -393,6 +454,50 @@ module Tina4
|
|
|
393
454
|
|
|
394
455
|
# ── IMAP helpers ─────────────────────────────────────────────────────
|
|
395
456
|
|
|
457
|
+
# Open and authenticate an IMAP connection. On a connection/auth/protocol
|
|
458
|
+
# failure this FAILS LOUD — logs and raises MessengerConnectionError —
|
|
459
|
+
# rather than swallowing the error into an empty result.
|
|
460
|
+
def imap_open(method)
|
|
461
|
+
imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
|
|
462
|
+
imap.login(@username, @password)
|
|
463
|
+
imap
|
|
464
|
+
rescue *IMAP_CONNECTION_ERRORS => e
|
|
465
|
+
raise imap_fail(method, e)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Best-effort teardown of an IMAP connection. Never raises.
|
|
469
|
+
def imap_cleanup(imap)
|
|
470
|
+
return if imap.nil?
|
|
471
|
+
|
|
472
|
+
begin
|
|
473
|
+
imap.logout
|
|
474
|
+
rescue StandardError
|
|
475
|
+
# ignore — already closing
|
|
476
|
+
end
|
|
477
|
+
begin
|
|
478
|
+
imap.disconnect
|
|
479
|
+
rescue StandardError
|
|
480
|
+
# ignore — already closed
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Log an IMAP connection/protocol failure and return the error to raise.
|
|
485
|
+
# A genuinely empty mailbox is NOT an error and never reaches here.
|
|
486
|
+
# Re-raises a MessengerError as-is; otherwise wraps in
|
|
487
|
+
# MessengerConnectionError. Callers do `raise imap_fail(name, exc)`.
|
|
488
|
+
def imap_fail(method, exc)
|
|
489
|
+
Tina4::Log.error(
|
|
490
|
+
"Messenger IMAP #{method}() failed: #{exc.class}: #{exc.message}"
|
|
491
|
+
)
|
|
492
|
+
return exc if exc.is_a?(MessengerError)
|
|
493
|
+
|
|
494
|
+
MessengerConnectionError.new("IMAP #{method} failed: #{exc.message}")
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Connect, yield, then tear down — used by the mutators / connectivity test
|
|
498
|
+
# that keep their own result-style error handling (mark_read,
|
|
499
|
+
# test_imap_connection). Read methods use imap_open + ensure directly so
|
|
500
|
+
# they can fail loud.
|
|
396
501
|
def imap_connect(&block)
|
|
397
502
|
imap = Net::IMAP.new(@imap_host, port: @imap_port, ssl: @imap_use_tls)
|
|
398
503
|
imap.login(@username, @password)
|