tina4ruby 3.13.138 → 3.13.140

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.
data/lib/tina4/graphql.rb CHANGED
@@ -227,6 +227,14 @@ module Tina4
227
227
  @source = source
228
228
  @tokens = tokenize(source)
229
229
  @pos = 0
230
+ # F2: bound the parser's own recursion. A deeply nested query
231
+ # ("{a{a{a...}}}") otherwise recurses in parse_selection_set until Ruby
232
+ # raises SystemStackError — which is NOT a StandardError, so the public
233
+ # execute's `rescue => e` misses it and it escapes as a 500. Raising a
234
+ # GraphQLError (a StandardError) at the depth bound keeps it caught and
235
+ # returned as a clean GraphQL error. Reuses TINA4_GRAPHQL_MAX_DEPTH.
236
+ @depth = 0
237
+ @max_depth = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_DEPTH", "50"), exception: false) || 50
230
238
  end
231
239
 
232
240
  def parse
@@ -439,6 +447,19 @@ module Tina4
439
447
  end
440
448
 
441
449
  def parse_selection_set
450
+ @depth += 1
451
+ if @max_depth.positive? && @depth > @max_depth
452
+ @depth -= 1
453
+ raise GraphQLError, "Query exceeds maximum depth of #{@max_depth}"
454
+ end
455
+ begin
456
+ parse_selection_set_inner
457
+ ensure
458
+ @depth -= 1
459
+ end
460
+ end
461
+
462
+ def parse_selection_set_inner
442
463
  expect(:punct, "{")
443
464
  selections = []
444
465
  until current&.value == "}"
@@ -599,14 +620,47 @@ module Tina4
599
620
  class GraphQLExecutor
600
621
  # max_depth bounds selection-set nesting (DoS / stack-overflow guard).
601
622
  # Threaded down from the owning GraphQL instance; <= 0 disables the guard.
602
- attr_accessor :max_depth
623
+ attr_accessor :max_depth, :max_nodes
603
624
 
604
- def initialize(schema, max_depth: 50)
625
+ def initialize(schema, max_depth: 50, max_nodes: 1000)
605
626
  @schema = schema
606
627
  @max_depth = max_depth
628
+ # F2: maximum expanded selection nodes per query. The depth guard bounds
629
+ # NESTING but not WIDTH — a fragment bomb stays shallow while expanding to
630
+ # millions of fields, and an alias explosion is not deep at all. This
631
+ # budget bounds the expanded selection tree (fragments expanded, each
632
+ # alias counted) so both are rejected before any resolver runs.
633
+ @max_nodes = max_nodes
634
+ end
635
+
636
+ # F2: count the expanded selection nodes, aborting past max_nodes. Fragment
637
+ # spreads are expanded (so a fragment bomb is counted, not the small
638
+ # unexpanded document) and every field — including each alias — counts once.
639
+ # Capped by max_nodes and by the depth cap, so a circular fragment trips the
640
+ # budget instead of looping and the check can never cost more than the budget.
641
+ def query_complexity(selections, fragments, depth, count)
642
+ depth_cap = (@max_depth && @max_depth.positive?) ? @max_depth : 1000
643
+ return count if depth > depth_cap
644
+
645
+ selections.each do |sel|
646
+ count += 1
647
+ return count if count > @max_nodes
648
+
649
+ case sel[:kind]
650
+ when :fragment_spread
651
+ frag = fragments[sel[:name]]
652
+ count = query_complexity(frag[:selection_set], fragments, depth + 1, count) if frag
653
+ when :inline_fragment
654
+ count = query_complexity(sel[:selection_set] || [], fragments, depth + 1, count)
655
+ else
656
+ count = query_complexity(sel[:selection_set], fragments, depth + 1, count) if sel[:selection_set]
657
+ end
658
+ return count if count > @max_nodes
659
+ end
660
+ count
607
661
  end
608
662
 
609
- def execute(document, variables: {}, context: {}, operation_name: nil)
663
+ def execute(document, variables: {}, context: {}, operation_name: nil, allow_mutations: true)
610
664
  # Collect fragments
611
665
  fragments = {}
612
666
  operations = []
@@ -631,9 +685,28 @@ module Tina4
631
685
 
632
686
  raise GraphQLError, "Unknown operation: #{operation_name}" unless operation
633
687
 
688
+ # F2: reject an over-complex query (fragment bomb / alias explosion) before
689
+ # any resolver runs.
690
+ if @max_nodes && @max_nodes.positive?
691
+ complexity = query_complexity(operation[:selection_set], fragments, 1, 0)
692
+ if complexity > @max_nodes
693
+ return { "data" => nil,
694
+ "errors" => [{ "message" => "Query exceeds maximum complexity of #{@max_nodes} nodes" }] }
695
+ end
696
+ end
697
+
634
698
  # Resolve variables
635
699
  resolved_vars = resolve_variables(operation[:variables], variables)
636
700
 
701
+ # F4 (CSRF): a mutation must never run over a GET. A cross-site GET (an
702
+ # <img>/<script> src, a pre-rendered link) carries the victim's cookies
703
+ # and would otherwise trigger a state change. The GET handler passes
704
+ # allow_mutations: false; POST (which a browser cannot cross-site forge
705
+ # with a JSON content-type without a CORS preflight) still allows them.
706
+ if operation[:operation] == :mutation && !allow_mutations
707
+ raise GraphQLError, "Mutations are not allowed over GET"
708
+ end
709
+
637
710
  # Choose root fields
638
711
  root_fields = case operation[:operation]
639
712
  when :query then @schema.queries
@@ -897,6 +970,15 @@ module Tina4
897
970
  @executor&.max_depth = value
898
971
  end
899
972
 
973
+ # F2 complexity budget — see GraphQLExecutor#max_nodes. Writer keeps the
974
+ # executor in sync so tests/app code can override it.
975
+ attr_reader :max_nodes
976
+
977
+ def max_nodes=(value)
978
+ @max_nodes = value
979
+ @executor&.max_nodes = value
980
+ end
981
+
900
982
  # Class-level toggle for ORM auto-schema generation. Defaults to true,
901
983
  # can be disabled via TINA4_GRAPHQL_AUTO_SCHEMA=false. Initializers and
902
984
  # user app code can branch on this before calling `schema.from_orm(...)`.
@@ -969,7 +1051,10 @@ module Tina4
969
1051
  # GraphQL DoS / stack-overflow vector. Default 50 is far beyond any
970
1052
  # legitimate query; TINA4_GRAPHQL_MAX_DEPTH <= 0 disables the guard.
971
1053
  @max_depth = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_DEPTH", "50"), exception: false) || 50
972
- @executor = GraphQLExecutor.new(@schema, max_depth: @max_depth)
1054
+ # F2: expanded-node (complexity) budget — bounds query WIDTH (fragment
1055
+ # bombs, alias explosions), which the depth guard does not.
1056
+ @max_nodes = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_NODES", "1000"), exception: false) || 1000
1057
+ @executor = GraphQLExecutor.new(@schema, max_depth: @max_depth, max_nodes: @max_nodes)
973
1058
  @field_resolvers = {}
974
1059
 
975
1060
  # Drain any resolvers registered via the class-level GraphQL.resolve()
@@ -1010,10 +1095,11 @@ module Tina4
1010
1095
  end
1011
1096
 
1012
1097
  # Execute a query string directly
1013
- def execute(query, variables: {}, context: {}, operation_name: nil)
1098
+ def execute(query, variables: {}, context: {}, operation_name: nil, allow_mutations: true)
1014
1099
  parser = GraphQLParser.new(query)
1015
1100
  document = parser.parse
1016
- @executor.execute(document, variables: variables, context: context, operation_name: operation_name)
1101
+ @executor.execute(document, variables: variables, context: context,
1102
+ operation_name: operation_name, allow_mutations: allow_mutations)
1017
1103
  rescue GraphQLError => e
1018
1104
  { "data" => nil, "errors" => [{ "message" => e.message }] }
1019
1105
  rescue => e
@@ -1083,6 +1169,16 @@ module Tina4
1083
1169
 
1084
1170
  graphql = self
1085
1171
  Tina4.post path, auth: false do |request, response|
1172
+ # F4 (CSRF): require an application/json content-type. A browser can
1173
+ # only send a cross-site POST with text/plain, form-urlencoded or
1174
+ # multipart bodies without a CORS preflight; demanding JSON forces the
1175
+ # preflight (and its same-origin/allow-list check) for any cross-site
1176
+ # caller, so a forged form cannot drive a mutation. Same-origin XHR/
1177
+ # fetch and server clients set application/json and are unaffected.
1178
+ unless request.content_type.to_s.downcase.include?("application/json")
1179
+ next response.json({ "data" => nil, "errors" => [{ "message" =>
1180
+ "GraphQL requires a Content-Type of application/json" }] }, 415)
1181
+ end
1086
1182
  # handle_request expects the raw JSON text (it JSON.parses internally),
1087
1183
  # so read body_raw — request.body now returns the PARSED payload.
1088
1184
  body = request.body_raw
@@ -1090,13 +1186,15 @@ module Tina4
1090
1186
  response.json(result)
1091
1187
  end
1092
1188
 
1093
- # Optional: GET for GraphiQL/introspection
1189
+ # Optional: GET for GraphiQL/introspection. Queries only — a mutation over
1190
+ # GET is refused (F4), since a cross-site GET carries the victim's cookies.
1094
1191
  Tina4.get path, auth: false do |request, response|
1095
1192
  query = request.query["query"]
1096
1193
  if query
1097
1194
  variables = request.query["variables"]
1098
1195
  variables = Tina4.parse_json(variables) if variables.is_a?(String) && !variables.empty?
1099
- result = graphql.execute(query, variables: variables || {}, context: { request: request })
1196
+ result = graphql.execute(query, variables: variables || {},
1197
+ context: { request: request }, allow_mutations: false)
1100
1198
  response.json(result)
1101
1199
  else
1102
1200
  response.html(graphiql_html(path))
data/lib/tina4/job.rb CHANGED
@@ -113,9 +113,22 @@ module Tina4
113
113
  self
114
114
  end
115
115
 
116
- # Reject this job with a reason. Alias for fail().
116
+ # Reject this job permanently — dead-letter it NOW, no retry (ADR-0023).
117
+ #
118
+ # Distinct from fail(): fail() records a failed attempt and only
119
+ # dead-letters once max_retries is exhausted (the job is retried first).
120
+ # reject() is for a message the consumer KNOWS is poison — it goes straight
121
+ # to the dead-letter store on this call. AMQP basic.reject(requeue=false)
122
+ # semantics. Was a literal alias for fail() before 3.13.139.
117
123
  def reject(reason = "")
118
- fail(reason)
124
+ unless @queue
125
+ @attempts += 1
126
+ @status = :dead
127
+ return self
128
+ end
129
+ @queue.backend.reject(self, reason)
130
+ @status = :dead
131
+ self
119
132
  end
120
133
  end
121
134
  end
data/lib/tina4/mcp.rb CHANGED
@@ -159,7 +159,9 @@ module Tina4
159
159
  #
160
160
  # Python master parity: tina4_python.mcp.is_loopback.
161
161
  def self.is_loopback?(ip)
162
- return true if ip.nil? || ip.to_s.empty?
162
+ # An empty or missing peer is UNKNOWN and is not loopback: a runtime path
163
+ # that lost its socket address must fail closed (ADR-0079 s4).
164
+ return false if ip.nil? || ip.to_s.empty?
163
165
 
164
166
  addr = ip.to_s.strip.downcase
165
167
  addr = addr[7..] if addr.start_with?("::ffff:")
@@ -677,7 +677,9 @@ module Tina4
677
677
  auth_header = (headers["authorization"] || headers["Authorization"] || "").to_s
678
678
  if auth_header.start_with?("Bearer ")
679
679
  bearer_token = auth_header[7..].to_s.strip
680
- return [request, response] if !bearer_token.empty? && Tina4::Auth.valid_token(bearer_token)
680
+ # Only an IDENTITY token marks an API client; a form token in the
681
+ # Bearer slot is not one and does not skip the check (ADR-0079 s1).
682
+ return [request, response] if !bearer_token.empty? && Tina4::Auth.identity_payload?(Tina4::Auth.valid_token(bearer_token))
681
683
  end
682
684
 
683
685
  # 7. A token in the query string leaks through logs/referers/history --
data/lib/tina4/push.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "base64"
11
11
  require "json"
12
12
  require "net/http"
13
13
  require "uri"
14
+ require_relative "ssrf"
14
15
 
15
16
  module Tina4
16
17
  class PushError < StandardError; end
@@ -40,7 +41,11 @@ module Tina4
40
41
  bytes.b.rjust(32, "\x00".b)
41
42
  end
42
43
 
43
- def initialize(subject: nil, public_key: nil, private_key: nil, ttl: 60, urgency: nil)
44
+ def initialize(subject: nil, public_key: nil, private_key: nil, ttl: 60, urgency: nil, allow_hosts: nil)
45
+ # SSRF guard (ADR-0084): the push endpoint is refused when it resolves to a
46
+ # private/internal address unless TINA4_ALLOW_PRIVATE_REQUESTS is truthy or
47
+ # the host/CIDR is on this allow-list.
48
+ @allow_hosts = allow_hosts || []
44
49
  @subject = (subject || ENV.fetch("TINA4_VAPID_SUBJECT", "")).strip
45
50
  @public_key = (public_key || ENV.fetch("TINA4_VAPID_PUBLIC", "")).strip
46
51
  @private_key = (private_key || ENV.fetch("TINA4_VAPID_PRIVATE", "")).strip
@@ -88,6 +93,13 @@ module Tina4
88
93
  end
89
94
 
90
95
  def deliver(endpoint, uri, subject, public_key, private, public, body)
96
+ # SSRF guard (ADR-0084): refuse a private/internal push endpoint. Net::HTTP
97
+ # does not follow redirects, so guarding the endpoint is sufficient.
98
+ begin
99
+ Tina4::Ssrf.guard_url!(uri, @allow_hosts)
100
+ rescue Tina4::SsrfError => e
101
+ raise PushError, e.message
102
+ end
91
103
  request = Net::HTTP::Post.new(uri)
92
104
  request["Authorization"] = "vapid t=#{vapid_token(uri, subject, private, public)}, k=#{public_key}"
93
105
  request["Content-Encoding"] = "aes128gcm"
@@ -267,6 +267,14 @@ module Tina4
267
267
  out
268
268
  end
269
269
 
270
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
271
+ def reject(job, reason = "")
272
+ job.attempts = [job.attempts + 1, @max_retries].max
273
+ job.error = reason
274
+ dead_letter(job)
275
+ acknowledge(job)
276
+ end
277
+
270
278
  def failed(_topic, max_retries: 3)
271
279
  raise NotImplementedError,
272
280
  "The kafka queue backend cannot answer failed(): a job that failed " \
@@ -177,6 +177,18 @@ module Tina4
177
177
  # Aligns with retry_job(id) which had always unlinked the dead-letter
178
178
  # file — two spellings of the same intent that previously diverged.
179
179
  # Increments attempts, clears the error.
180
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
181
+ # Unlike fail(), which requeues until max_retries is spent, reject() moves
182
+ # a known-poison job straight to the dead-letter store. attempts is floored
183
+ # at max_retries so dead_letters (attempts >= limit) returns it and
184
+ # dead_letter_count agrees.
185
+ def reject(job, reason = "")
186
+ clear_reservation(job.topic, job.id)
187
+ job.attempts = [job.attempts + 1, @max_retries].max
188
+ job.error = reason
189
+ move_to_dead_letter(job, reason)
190
+ end
191
+
180
192
  def retry(job, delay_seconds: 0)
181
193
  clear_reservation(job.topic, job.id)
182
194
  # Drop any dead-letter file for this id BEFORE the re-queue: if this
@@ -284,6 +284,18 @@ module Tina4
284
284
  # the normal path. This method did not exist at all, and Queue#failed
285
285
  # silently returned [] for it — indistinguishable from "nothing has
286
286
  # failed" (ADR-0022 decision 7).
287
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
288
+ def reject(job, reason = "")
289
+ job.attempts = [job.attempts + 1, @max_retries].max
290
+ job.error = reason
291
+ collection.find_one_and_update(
292
+ { _id: job.id },
293
+ { "$set" => { status: "dead", topic: "#{job.topic}.dead_letter",
294
+ attempts: job.attempts, error: reason, reserved_at: nil } },
295
+ upsert: true
296
+ )
297
+ end
298
+
287
299
  def failed(topic, max_retries: 3)
288
300
  collection.find(
289
301
  topic: topic,
@@ -177,6 +177,14 @@ module Tina4
177
177
  end
178
178
  end
179
179
 
180
+ # Reject a job permanently — dead-letter it NOW, no retry (ADR-0023).
181
+ def reject(job, reason = "")
182
+ job.attempts = [job.attempts + 1, @max_retries].max
183
+ job.error = reason
184
+ dead_letter(job)
185
+ complete(job)
186
+ end
187
+
180
188
  def failed(_topic, max_retries: 3)
181
189
  raise NotImplementedError,
182
190
  "The rabbitmq queue backend cannot answer failed(): a job that " \
@@ -1189,68 +1189,46 @@ module Tina4
1189
1189
  def self.enforce_route_auth(env, route)
1190
1190
  return nil unless route.auth_required
1191
1191
 
1192
- token = nil
1193
- token_source = nil # :header, :body, :session
1194
-
1195
- # Priority 1: Authorization Bearer header
1192
+ unauthorized = [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1193
+
1194
+ # Token slots in priority order: Authorization Bearer header, then the
1195
+ # body formToken (frond.js puts the auth token it received as a
1196
+ # FreshToken there), then the session. The first slot holding a token
1197
+ # decides, EXCEPT that a form token ("type" => "form") proves where a
1198
+ # write came from, not who sent it: it is skipped and the next slot is
1199
+ # tried (ADR-0079 s1). So a logged-in user posting a rendered form (form
1200
+ # token in the body, auth token in the session) is still authenticated.
1201
+ candidates = []
1196
1202
  auth_header = env["HTTP_AUTHORIZATION"] || ""
1197
- if auth_header =~ /\ABearer\s+(.+)\z/i
1198
- token = Regexp.last_match(1)
1199
- token_source = :header
1200
- end
1203
+ candidates << [Regexp.last_match(1), :header] if auth_header =~ /\ABearer\s+(.+)\z/i
1204
+ form_token = _extract_form_token(_read_rack_body(env), env)
1205
+ candidates << [form_token, :body] if form_token && !form_token.empty?
1201
1206
 
1202
- # Priority 2: formToken from request body (for frond.js saveForm with {{ form_token() }})
1203
- if token.nil?
1204
- body_str = _read_rack_body(env)
1205
- form_token = _extract_form_token(body_str, env)
1206
- if form_token && !form_token.empty?
1207
- token = form_token
1208
- token_source = :body
1209
- end
1207
+ verdict = candidates.each do |token, source|
1208
+ outcome = _authenticate_token(env, token, source)
1209
+ break outcome unless outcome == :skip
1210
1210
  end
1211
+ return unauthorized if verdict == :denied
1211
1212
 
1212
- # Priority 3: Session token (for secured GET routes after login)
1213
- if token.nil?
1213
+ unless env.key?("tina4.auth_payload")
1214
1214
  # Request path, so the same log-loud-then-degrade policy as
1215
1215
  # Request#session (ADR-0021): an unreachable session store must not turn
1216
1216
  # the auth gate into a 500. It degrades to an empty session, which means
1217
1217
  # no token, which means the ordinary 401 below - a SERVED request.
1218
1218
  session = Tina4::Session.new(env, degrade_on_backend_failure: true)
1219
- sso = session.get("_tina4_sso")
1220
- identity = sso.is_a?(Hash) ? sso["identity"] : nil
1221
- if identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
1219
+ # A provider-verified OIDC identity counts only while it is live
1220
+ # (ADR-0079 s5).
1221
+ identity = Tina4::Sso.live_session_identity(session.get("_tina4_sso"))
1222
+ if identity
1222
1223
  env["tina4.auth_payload"] = identity
1223
- deny = rbac_forbidden(route, identity)
1224
- return deny if deny
1225
-
1226
- return nil
1227
- end
1228
- session_token = session.get("token")
1229
- if session_token && !session_token.empty?
1230
- token = session_token
1231
- token_source = :session
1232
- end
1233
- end
1234
-
1235
- # API_KEY bypass — routed through the timing-safe Tina4::Auth.validate_api_key
1236
- # (OpenSSL.fixed_length_secure_compare), matching tina4_python's _check_auth.
1237
- # It used to be a plain `token == api_key`, which returns as soon as two
1238
- # bytes differ — so response timing leaks the key prefix and the key can be
1239
- # recovered a character at a time. validate_api_key also covers the unset /
1240
- # blank / wrong-length cases the old guard spelled out by hand.
1241
- if Tina4::Auth.validate_api_key(token)
1242
- env["tina4.auth_payload"] = { "_auth" => "api_key" }
1243
- elsif token
1244
- unless Tina4::Auth.valid_token(token)
1245
- return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1224
+ else
1225
+ session_token = session.get("token")
1226
+ if session_token.is_a?(String) && !session_token.empty?
1227
+ return unauthorized unless _authenticate_token(env, session_token, :session) == :accepted
1228
+ end
1246
1229
  end
1247
- env["tina4.auth_payload"] = Tina4::Auth.get_payload(token)
1248
-
1249
- # When body formToken validates, store a refreshed token for the FreshToken response header
1250
- env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token) if token_source == :body
1251
- else
1252
- return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1253
1230
  end
1231
+ return unauthorized unless env.key?("tina4.auth_payload")
1254
1232
 
1255
1233
  # ── RBAC guards (Feature 138): authorization AFTER authentication ──
1256
1234
  # Auth has passed (401 ruled out above). If the route carries role/
@@ -1261,6 +1239,30 @@ module Tina4
1261
1239
  nil
1262
1240
  end
1263
1241
 
1242
+ # Judge one token from one slot: :accepted (env["tina4.auth_payload"] set),
1243
+ # :skip (a valid form token - not an identity, try the next slot) or
1244
+ # :denied (anything else).
1245
+ #
1246
+ # API_KEY bypass - routed through the timing-safe Tina4::Auth.validate_api_key
1247
+ # (OpenSSL.fixed_length_secure_compare), matching tina4_python's _check_auth.
1248
+ # A plain `token == api_key` returns as soon as two bytes differ, so response
1249
+ # timing would leak the key a character at a time.
1250
+ def self._authenticate_token(env, token, source)
1251
+ if Tina4::Auth.validate_api_key(token)
1252
+ env["tina4.auth_payload"] = { "_auth" => "api_key" }
1253
+ return :accepted
1254
+ end
1255
+ payload = Tina4::Auth.valid_token(token)
1256
+ return :denied unless payload
1257
+ return :skip unless Tina4::Auth.identity_payload?(payload)
1258
+
1259
+ env["tina4.auth_payload"] = payload
1260
+ # When a body token authenticates, store a refreshed token for the
1261
+ # FreshToken response header (identity tokens only).
1262
+ env["tina4.fresh_token"] = Tina4::Auth.refresh_token(token) if source == :body
1263
+ :accepted
1264
+ end
1265
+
1264
1266
  # Build a 403 Rack tuple when a route's RBAC guards are not satisfied by the
1265
1267
  # verified payload, else nil. AND across guard groups, OR within a group.
1266
1268
  # Roles read the `roles` claim (legacy singular `role` coerced); permissions
@@ -15,7 +15,15 @@ module Tina4
15
15
  class S3Storage < StorageBackend
16
16
  def initialize(endpoint: nil, key: nil, secret: nil, bucket: nil, region: nil)
17
17
  super()
18
- require "aws-sdk-s3" # optional dependency, loaded lazily
18
+ begin
19
+ require "aws-sdk-s3" # optional dependency, loaded lazily
20
+ rescue LoadError
21
+ # Storage.select rescues this and falls back to local; its warning
22
+ # carries this message, so the remedy reaches the operator.
23
+ raise LoadError,
24
+ "The 'aws-sdk-s3' gem is required for S3Storage. Install it with: " \
25
+ "bundle add aws-sdk-s3 (or add gem \"aws-sdk-s3\" to your Gemfile)"
26
+ end
19
27
 
20
28
  @bucket = bucket || ENV["TINA4_STORAGE_BUCKET"]
21
29
  raise ArgumentError, "S3Storage requires TINA4_STORAGE_BUCKET" if @bucket.nil? || @bucket.empty?
data/lib/tina4/request.rb CHANGED
@@ -292,6 +292,11 @@ module Tina4
292
292
  # still see the URL the client used. Matches Python/PHP/Node parity.
293
293
  def url
294
294
  scheme = self.class.secure_scheme?(env) ? "https" : "http"
295
+ # X-Forwarded-Host is honoured ONLY when the raw socket peer is a trusted
296
+ # proxy (TINA4_TRUSTED_PROXIES) — the same gate already applied to
297
+ # X-Forwarded-For. An untrusted client can otherwise forge the host and
298
+ # control the absolute request.url (password-reset links, cache keys,
299
+ # open-redirect base). Parity with Python/PHP/Node.
295
300
  forwarded_host = Tina4.trusted_proxy?(@remote_ip) ? env["HTTP_X_FORWARDED_HOST"] : nil
296
301
  host = forwarded_host || env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
297
302
  port = env["SERVER_PORT"]
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+ module Tina4
9
+ # Shared row/key symbolisation for the two SQLite classes.
10
+ #
11
+ # `symbolize_rows`/`symbolize_keys` used to be byte-identical copy-paste in
12
+ # Tina4::Drivers::SqliteDriver and Tina4::Adapters::Sqlite3Adapter (the
13
+ # adapter's own comment pointed at the driver's copy). This module is the
14
+ # single home for the logic; both classes `include` it as private instance
15
+ # methods, so both share ONE implementation and can never drift again.
16
+ #
17
+ # The mapping is computed ONCE per query rather than per cell: the per-row
18
+ # form ran `k.to_s.to_sym` and an `is_a?` guard PER CELL, so a 5,000-row x
19
+ # 6-column fetch did 30,000 conversions for 6 distinct keys and made Tina4 the
20
+ # slowest of the four frameworks at bulk reads (Select ALL 5,000 rows:
21
+ # 10.16ms vs raw sqlite3 2.56ms). Hoisting the mapping out of the loop roughly
22
+ # halves the hydration cost with byte-identical output.
23
+ #
24
+ # The is_a?(String|Symbol) guard is preserved: older sqlite3 gems put
25
+ # positional Integer keys in the hash alongside the string names, and those
26
+ # must still be dropped.
27
+ module RowSymbolizer
28
+ private
29
+
30
+ # Symbolize a whole result set's keys, computing the mapping once per query.
31
+ def symbolize_rows(rows)
32
+ return rows if rows.empty?
33
+
34
+ str_keys = rows.first.keys.select { |k| k.is_a?(String) || k.is_a?(Symbol) }
35
+ sym_keys = str_keys.map(&:to_sym)
36
+ count = str_keys.length
37
+ rows.map do |row|
38
+ out = {}
39
+ i = 0
40
+ while i < count
41
+ out[sym_keys[i]] = row[str_keys[i]]
42
+ i += 1
43
+ end
44
+ out
45
+ end
46
+ end
47
+
48
+ # Single-hash convenience; delegates to the batch path so there is one
49
+ # implementation of the mapping rule.
50
+ def symbolize_keys(hash)
51
+ symbolize_rows([hash]).first
52
+ end
53
+ end
54
+ end
@@ -217,25 +217,49 @@ module Tina4
217
217
  # spells "already exists" differently ("There is already an object named",
218
218
  # "already exists", SQLSTATE 42S01) and a string match would rot the first
219
219
  # time an engine reworded itself or ran under another locale.
220
+ #
221
+ # The re-check is RETRIED, a bounded number of times, because the loser
222
+ # can fail BEFORE the winner has committed. MEASURED on MySQL 8.4: CREATE
223
+ # TABLE IF NOT EXISTS takes a shared metadata lock on the name, checks,
224
+ # then upgrades it to exclusive, and two sessions upgrading at once is a
225
+ # metadata-lock deadlock. MySQL backs the victim off silently only when the
226
+ # session holds no other metadata lock; otherwise the victim gets 1213
227
+ # "Deadlock found" while the winner's table is still invisible, and one
228
+ # immediate re-check turned that lost race into a failed request (tina4-php
229
+ # CI run 35972320442). Same attempts and delays in all four frameworks.
230
+ CREATE_ATTEMPTS = 5
231
+ CREATE_RETRY_DELAY = 0.05
232
+
220
233
  def ensure_table
221
234
  return if @table_ready
222
235
 
223
236
  @table_ready = true
224
- begin
225
- db.execute(create_table_sql)
226
- rescue StandardError
227
- # A failed statement leaves PostgreSQL's transaction ABORTED, so
228
- # without this the re-check would fail for the wrong reason and report
229
- # a missing table that is right there. Best effort: an engine with no
230
- # open transaction is entitled to object to being rolled back.
237
+ attempt = 1
238
+ until db.table_exists?(TABLE_NAME)
231
239
  begin
232
- db.rollback
240
+ db.execute(create_table_sql)
241
+ return
233
242
  rescue StandardError
234
- nil
243
+ # A failed statement leaves PostgreSQL's transaction ABORTED, so
244
+ # without this the re-check would fail for the wrong reason and
245
+ # report a missing table that is right there. Best effort: an engine
246
+ # with no open transaction is entitled to object to being rolled back.
247
+ begin
248
+ db.rollback
249
+ rescue StandardError
250
+ nil
251
+ end
252
+ # Somebody else created it - that is the race, and it is a success.
253
+ # Still absent after the last attempt is a real failure, re-raised
254
+ # untouched.
255
+ if attempt >= CREATE_ATTEMPTS
256
+ return if db.table_exists?(TABLE_NAME)
257
+
258
+ raise
259
+ end
260
+ sleep(CREATE_RETRY_DELAY * attempt)
261
+ attempt += 1
235
262
  end
236
- # Somebody else created it - that is the race, and it is a success.
237
- # Anything else is a real failure and is re-raised untouched.
238
- raise unless db.table_exists?(TABLE_NAME)
239
263
  end
240
264
  end
241
265
  end