tina4ruby 3.13.137 → 3.13.139
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 +40 -0
- data/COMMERCIAL-LICENSE.md +8 -0
- data/LICENSE +373 -0
- data/LICENSE.txt +373 -21
- data/NOTICE +11 -0
- data/README.md +11 -7
- data/exe/tina4ruby +6 -0
- data/lib/tina4/ai.rb +6 -0
- data/lib/tina4/ai_client.rb +10 -3
- data/lib/tina4/api.rb +74 -8
- data/lib/tina4/auth.rb +108 -26
- data/lib/tina4/auto_crud.rb +83 -27
- data/lib/tina4/background.rb +7 -1
- data/lib/tina4/base64.rb +59 -0
- data/lib/tina4/cache.rb +6 -0
- data/lib/tina4/cache_backends/base_backend.rb +14 -0
- data/lib/tina4/cache_backends/database_backend.rb +6 -0
- data/lib/tina4/cache_backends/file_backend.rb +6 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +6 -0
- data/lib/tina4/cache_backends/memory_backend.rb +6 -0
- data/lib/tina4/cache_backends/mongo_backend.rb +22 -1
- data/lib/tina4/cache_backends/redis_backend.rb +6 -0
- data/lib/tina4/cache_backends/valkey_backend.rb +6 -0
- data/lib/tina4/cache_backends.rb +9 -1
- data/lib/tina4/cli.rb +27 -7
- data/lib/tina4/constants.rb +12 -8
- data/lib/tina4/container.rb +6 -0
- data/lib/tina4/context/chunker.rb +6 -0
- data/lib/tina4/context.rb +13 -5
- data/lib/tina4/cors.rb +66 -10
- data/lib/tina4/crud.rb +78 -9
- data/lib/tina4/database/sqlite3_adapter.rb +9 -1
- data/lib/tina4/database.rb +260 -44
- data/lib/tina4/database_adapter.rb +6 -0
- data/lib/tina4/database_result.rb +6 -0
- data/lib/tina4/database_url.rb +6 -0
- data/lib/tina4/debug.rb +6 -0
- data/lib/tina4/dev.rb +6 -0
- data/lib/tina4/dev_admin.rb +222 -89
- data/lib/tina4/dev_mailbox.rb +6 -1
- data/lib/tina4/dispatch_pipeline.rb +86 -1
- data/lib/tina4/docs.rb +6 -0
- data/lib/tina4/docstore.rb +23 -4
- data/lib/tina4/drivers/arango_graph_driver.rb +8 -1
- data/lib/tina4/drivers/bolt_graph_driver.rb +6 -0
- data/lib/tina4/drivers/firebird_driver.rb +95 -6
- data/lib/tina4/drivers/mongodb_driver.rb +6 -0
- data/lib/tina4/drivers/mssql_driver.rb +54 -39
- data/lib/tina4/drivers/mysql_driver.rb +6 -0
- data/lib/tina4/drivers/odbc_driver.rb +6 -0
- data/lib/tina4/drivers/postgres_driver.rb +35 -8
- data/lib/tina4/drivers/schema_split.rb +6 -0
- data/lib/tina4/drivers/sqlite_driver.rb +16 -1
- data/lib/tina4/drivers/ultipa_graph_driver.rb +6 -0
- data/lib/tina4/env.rb +14 -6
- data/lib/tina4/error_overlay.rb +6 -0
- data/lib/tina4/events.rb +6 -0
- data/lib/tina4/feedback.rb +9 -2
- data/lib/tina4/field_types.rb +6 -0
- data/lib/tina4/form_parser.rb +241 -0
- data/lib/tina4/frond.rb +200 -58
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +6 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +6 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +6 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +6 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +6 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +6 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +6 -0
- data/lib/tina4/graph.rb +6 -0
- data/lib/tina4/graphql.rb +134 -35
- data/lib/tina4/health.rb +6 -0
- data/lib/tina4/html_element.rb +6 -0
- data/lib/tina4/http_server.rb +690 -0
- data/lib/tina4/imap_client.rb +352 -0
- data/lib/tina4/import_helper.rb +6 -0
- data/lib/tina4/job.rb +21 -2
- data/lib/tina4/localization.rb +6 -0
- data/lib/tina4/log.rb +6 -0
- data/lib/tina4/mail_socket.rb +124 -0
- data/lib/tina4/mcp.rb +18 -9
- data/lib/tina4/messenger.rb +86 -55
- data/lib/tina4/metrics.rb +6 -0
- data/lib/tina4/middleware.rb +27 -10
- data/lib/tina4/migration.rb +6 -0
- data/lib/tina4/model_collection.rb +6 -0
- data/lib/tina4/mqtt.rb +9 -2
- data/lib/tina4/mqtt_message.rb +6 -0
- data/lib/tina4/orm.rb +72 -16
- data/lib/tina4/parse_json.rb +25 -0
- data/lib/tina4/plan.rb +9 -2
- data/lib/tina4/point.rb +6 -0
- data/lib/tina4/port_takeover.rb +6 -0
- data/lib/tina4/project_index.rb +6 -0
- data/lib/tina4/public/THIRD-PARTY-NOTICES.md +55 -0
- data/lib/tina4/public/bundled-component-provenance.json +48 -0
- data/lib/tina4/public/js/tina4js.min.js +13 -3
- data/lib/tina4/public/licenses/swagger-ui-3.10.0-original-notice.txt +11 -0
- data/lib/tina4/public/licenses/swagger-ui-5.17.14-NOTICE.txt +2 -0
- data/lib/tina4/public/licenses/swagger-ui-Apache-2.0.txt +202 -0
- data/lib/tina4/public/licenses/tina4-css-MIT.txt +21 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-components.json +240 -0
- data/lib/tina4/public/licenses/tina4-dev-admin-third-party.txt +919 -0
- data/lib/tina4/public/licenses/tina4-js-MIT.txt +20 -0
- data/lib/tina4/push.rb +22 -4
- data/lib/tina4/query_builder.rb +6 -0
- data/lib/tina4/queue.rb +6 -0
- data/lib/tina4/queue_backends/kafka_backend.rb +18 -2
- data/lib/tina4/queue_backends/lite_backend.rb +18 -0
- data/lib/tina4/queue_backends/mongo_backend.rb +18 -0
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +18 -2
- data/lib/tina4/rack_app.rb +62 -50
- data/lib/tina4/rate_limiter.rb +6 -0
- data/lib/tina4/realtime/attachment.rb +6 -0
- data/lib/tina4/realtime/channel.rb +6 -0
- data/lib/tina4/realtime/channel_member.rb +6 -0
- data/lib/tina4/realtime/local_storage.rb +6 -0
- data/lib/tina4/realtime/message.rb +6 -0
- data/lib/tina4/realtime/s3_storage.rb +15 -1
- data/lib/tina4/realtime/storage.rb +6 -0
- data/lib/tina4/realtime/storage_backend.rb +6 -0
- data/lib/tina4/realtime/workspace.rb +6 -0
- data/lib/tina4/realtime.rb +10 -3
- data/lib/tina4/request.rb +90 -127
- data/lib/tina4/response.rb +71 -9
- data/lib/tina4/response_cache.rb +6 -0
- data/lib/tina4/router.rb +6 -0
- data/lib/tina4/secret_file.rb +34 -0
- data/lib/tina4/seeder.rb +9 -3
- data/lib/tina4/service.rb +8 -2
- data/lib/tina4/service_runner.rb +6 -0
- data/lib/tina4/session.rb +6 -0
- data/lib/tina4/session_handlers/database_handler.rb +42 -12
- data/lib/tina4/session_handlers/file_handler.rb +6 -0
- data/lib/tina4/session_handlers/memcached_handler.rb +6 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +108 -39
- data/lib/tina4/session_handlers/mongo_wire_client.rb +23 -9
- data/lib/tina4/session_handlers/redis_handler.rb +6 -0
- data/lib/tina4/session_handlers/resp_client.rb +6 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -0
- data/lib/tina4/shutdown.rb +17 -14
- data/lib/tina4/smtp_client.rb +222 -0
- data/lib/tina4/sql_translator.rb +93 -0
- data/lib/tina4/sqlite3_gem.rb +27 -0
- data/lib/tina4/sso.rb +52 -12
- data/lib/tina4/ssrf.rb +148 -0
- data/lib/tina4/swagger.rb +6 -0
- data/lib/tina4/template.rb +6 -0
- data/lib/tina4/test.rb +8 -2
- data/lib/tina4/test_client.rb +8 -1
- data/lib/tina4/testing.rb +8 -1
- data/lib/tina4/validator.rb +6 -0
- data/lib/tina4/version.rb +7 -1
- data/lib/tina4/webserver.rb +25 -143
- data/lib/tina4/websocket.rb +170 -20
- data/lib/tina4/websocket_backplane.rb +25 -2
- data/lib/tina4/wsdl.rb +54 -18
- data/lib/tina4/xml_parser.rb +268 -0
- data/lib/tina4.rb +98 -33
- data/lib/tina4ruby.rb +6 -0
- metadata +44 -140
data/lib/tina4/graphql.rb
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: false
|
|
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
|
+
|
|
2
8
|
|
|
3
9
|
require "json"
|
|
10
|
+
require_relative "parse_json"
|
|
4
11
|
|
|
5
12
|
module Tina4
|
|
6
13
|
# Lightweight, zero-dependency GraphQL implementation for Tina4 Ruby.
|
|
@@ -140,9 +147,15 @@ module Tina4
|
|
|
140
147
|
# ── Queries ──
|
|
141
148
|
|
|
142
149
|
# Single record: user(id: ID!): User
|
|
150
|
+
# The row an ID argument addresses, or nil. The ID is bound, never
|
|
151
|
+
# interpolated, and a non-integer ID on an integer key addresses nothing.
|
|
152
|
+
find_addressed = lambda do |raw_id|
|
|
153
|
+
pk_value = klass.respond_to?(:coerce_primary_key) ? klass.coerce_primary_key(raw_id) : raw_id
|
|
154
|
+
pk_value.nil? ? nil : klass.find_by_id(pk_value)
|
|
155
|
+
end
|
|
156
|
+
|
|
143
157
|
add_query(table_lower, { pk_field => { type: "ID!" } }, type_name) do |_root, args, _ctx|
|
|
144
|
-
|
|
145
|
-
record&.to_hash
|
|
158
|
+
find_addressed.call(args[pk_field])&.to_hash
|
|
146
159
|
end
|
|
147
160
|
|
|
148
161
|
# List: users(limit: Int, offset: Int): [User]
|
|
@@ -163,8 +176,10 @@ module Tina4
|
|
|
163
176
|
|
|
164
177
|
# Update
|
|
165
178
|
add_mutation("update#{model_name}", { pk_field => { type: "ID!" }, "input" => { type: "#{type_name}Input!" } }, type_name) do |_root, args, _ctx|
|
|
166
|
-
record =
|
|
167
|
-
return
|
|
179
|
+
record = find_addressed.call(args[pk_field])
|
|
180
|
+
# `next`, not `return`: this block outlives from_orm, so a `return` here
|
|
181
|
+
# raised LocalJumpError (an internal error) for a non-matching id.
|
|
182
|
+
next nil unless record
|
|
168
183
|
(args["input"] || {}).each { |k, v| record.send(:"#{k}=", v) if record.respond_to?(:"#{k}=") }
|
|
169
184
|
record.save
|
|
170
185
|
record.to_hash
|
|
@@ -172,8 +187,8 @@ module Tina4
|
|
|
172
187
|
|
|
173
188
|
# Delete
|
|
174
189
|
add_mutation("delete#{model_name}", { pk_field => { type: "ID!" } }, "Boolean") do |_root, args, _ctx|
|
|
175
|
-
record =
|
|
176
|
-
|
|
190
|
+
record = find_addressed.call(args[pk_field])
|
|
191
|
+
next false unless record
|
|
177
192
|
record.delete
|
|
178
193
|
true
|
|
179
194
|
end
|
|
@@ -212,13 +227,19 @@ module Tina4
|
|
|
212
227
|
@source = source
|
|
213
228
|
@tokens = tokenize(source)
|
|
214
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
|
|
215
238
|
end
|
|
216
239
|
|
|
217
240
|
def parse
|
|
218
241
|
document = { kind: :document, definitions: [] }
|
|
219
242
|
while current
|
|
220
|
-
skip(:comma)
|
|
221
|
-
break unless current
|
|
222
243
|
document[:definitions] << parse_definition
|
|
223
244
|
end
|
|
224
245
|
document
|
|
@@ -234,8 +255,10 @@ module Tina4
|
|
|
234
255
|
while i < src.length
|
|
235
256
|
ch = src[i]
|
|
236
257
|
|
|
237
|
-
# Skip whitespace
|
|
238
|
-
|
|
258
|
+
# Skip whitespace. Commas are insignificant in GraphQL (spec 2.1.7) and
|
|
259
|
+
# are skipped with it; emitting them as punctuation made every
|
|
260
|
+
# comma-separated argument or field list a parse error.
|
|
261
|
+
if ch =~ /\s/ || ch == ","
|
|
239
262
|
i += 1
|
|
240
263
|
next
|
|
241
264
|
end
|
|
@@ -247,7 +270,7 @@ module Tina4
|
|
|
247
270
|
end
|
|
248
271
|
|
|
249
272
|
# Punctuation
|
|
250
|
-
if "{}()[]
|
|
273
|
+
if "{}()[]!:=@$".include?(ch)
|
|
251
274
|
tokens << Token.new(:punct, ch, i)
|
|
252
275
|
i += 1
|
|
253
276
|
next
|
|
@@ -355,10 +378,6 @@ module Tina4
|
|
|
355
378
|
advance
|
|
356
379
|
end
|
|
357
380
|
|
|
358
|
-
def skip(type, value = nil)
|
|
359
|
-
match(type, value) while current && current.type == type && (value.nil? || current.value == value)
|
|
360
|
-
end
|
|
361
|
-
|
|
362
381
|
# ── Parse rules ──
|
|
363
382
|
|
|
364
383
|
def parse_definition
|
|
@@ -401,8 +420,6 @@ module Tina4
|
|
|
401
420
|
expect(:punct, "(")
|
|
402
421
|
vars = []
|
|
403
422
|
until current&.value == ")"
|
|
404
|
-
skip(:comma)
|
|
405
|
-
break if current&.value == ")"
|
|
406
423
|
expect(:punct, "$")
|
|
407
424
|
vname = expect(:name).value
|
|
408
425
|
expect(:punct, ":")
|
|
@@ -430,11 +447,22 @@ module Tina4
|
|
|
430
447
|
end
|
|
431
448
|
|
|
432
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
|
|
433
463
|
expect(:punct, "{")
|
|
434
464
|
selections = []
|
|
435
465
|
until current&.value == "}"
|
|
436
|
-
skip(:comma)
|
|
437
|
-
break if current&.value == "}"
|
|
438
466
|
|
|
439
467
|
if current&.type == :spread
|
|
440
468
|
selections << parse_fragment_spread
|
|
@@ -496,8 +524,6 @@ module Tina4
|
|
|
496
524
|
expect(:punct, "(")
|
|
497
525
|
args = {}
|
|
498
526
|
until current&.value == ")"
|
|
499
|
-
skip(:comma)
|
|
500
|
-
break if current&.value == ")"
|
|
501
527
|
arg_name = expect(:name).value
|
|
502
528
|
expect(:punct, ":")
|
|
503
529
|
args[arg_name] = parse_value
|
|
@@ -547,8 +573,6 @@ module Tina4
|
|
|
547
573
|
expect(:punct, "[")
|
|
548
574
|
items = []
|
|
549
575
|
until current&.value == "]"
|
|
550
|
-
skip(:comma)
|
|
551
|
-
break if current&.value == "]"
|
|
552
576
|
items << parse_value
|
|
553
577
|
end
|
|
554
578
|
expect(:punct, "]")
|
|
@@ -559,8 +583,6 @@ module Tina4
|
|
|
559
583
|
expect(:punct, "{")
|
|
560
584
|
obj = {}
|
|
561
585
|
until current&.value == "}"
|
|
562
|
-
skip(:comma)
|
|
563
|
-
break if current&.value == "}"
|
|
564
586
|
key = expect(:name).value
|
|
565
587
|
expect(:punct, ":")
|
|
566
588
|
obj[key] = parse_value
|
|
@@ -598,14 +620,47 @@ module Tina4
|
|
|
598
620
|
class GraphQLExecutor
|
|
599
621
|
# max_depth bounds selection-set nesting (DoS / stack-overflow guard).
|
|
600
622
|
# Threaded down from the owning GraphQL instance; <= 0 disables the guard.
|
|
601
|
-
attr_accessor :max_depth
|
|
623
|
+
attr_accessor :max_depth, :max_nodes
|
|
602
624
|
|
|
603
|
-
def initialize(schema, max_depth: 50)
|
|
625
|
+
def initialize(schema, max_depth: 50, max_nodes: 1000)
|
|
604
626
|
@schema = schema
|
|
605
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
|
|
606
634
|
end
|
|
607
635
|
|
|
608
|
-
|
|
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
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def execute(document, variables: {}, context: {}, operation_name: nil, allow_mutations: true)
|
|
609
664
|
# Collect fragments
|
|
610
665
|
fragments = {}
|
|
611
666
|
operations = []
|
|
@@ -630,9 +685,28 @@ module Tina4
|
|
|
630
685
|
|
|
631
686
|
raise GraphQLError, "Unknown operation: #{operation_name}" unless operation
|
|
632
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
|
+
|
|
633
698
|
# Resolve variables
|
|
634
699
|
resolved_vars = resolve_variables(operation[:variables], variables)
|
|
635
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
|
+
|
|
636
710
|
# Choose root fields
|
|
637
711
|
root_fields = case operation[:operation]
|
|
638
712
|
when :query then @schema.queries
|
|
@@ -896,6 +970,15 @@ module Tina4
|
|
|
896
970
|
@executor&.max_depth = value
|
|
897
971
|
end
|
|
898
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
|
+
|
|
899
982
|
# Class-level toggle for ORM auto-schema generation. Defaults to true,
|
|
900
983
|
# can be disabled via TINA4_GRAPHQL_AUTO_SCHEMA=false. Initializers and
|
|
901
984
|
# user app code can branch on this before calling `schema.from_orm(...)`.
|
|
@@ -968,7 +1051,10 @@ module Tina4
|
|
|
968
1051
|
# GraphQL DoS / stack-overflow vector. Default 50 is far beyond any
|
|
969
1052
|
# legitimate query; TINA4_GRAPHQL_MAX_DEPTH <= 0 disables the guard.
|
|
970
1053
|
@max_depth = Integer(ENV.fetch("TINA4_GRAPHQL_MAX_DEPTH", "50"), exception: false) || 50
|
|
971
|
-
|
|
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)
|
|
972
1058
|
@field_resolvers = {}
|
|
973
1059
|
|
|
974
1060
|
# Drain any resolvers registered via the class-level GraphQL.resolve()
|
|
@@ -1009,10 +1095,11 @@ module Tina4
|
|
|
1009
1095
|
end
|
|
1010
1096
|
|
|
1011
1097
|
# Execute a query string directly
|
|
1012
|
-
def execute(query, variables: {}, context: {}, operation_name: nil)
|
|
1098
|
+
def execute(query, variables: {}, context: {}, operation_name: nil, allow_mutations: true)
|
|
1013
1099
|
parser = GraphQLParser.new(query)
|
|
1014
1100
|
document = parser.parse
|
|
1015
|
-
@executor.execute(document, variables: variables, context: context,
|
|
1101
|
+
@executor.execute(document, variables: variables, context: context,
|
|
1102
|
+
operation_name: operation_name, allow_mutations: allow_mutations)
|
|
1016
1103
|
rescue GraphQLError => e
|
|
1017
1104
|
{ "data" => nil, "errors" => [{ "message" => e.message }] }
|
|
1018
1105
|
rescue => e
|
|
@@ -1057,7 +1144,7 @@ module Tina4
|
|
|
1057
1144
|
|
|
1058
1145
|
# Handle an HTTP request body (JSON string)
|
|
1059
1146
|
def handle_request(body, context: {})
|
|
1060
|
-
payload =
|
|
1147
|
+
payload = Tina4.parse_json(body)
|
|
1061
1148
|
query = payload["query"] || ""
|
|
1062
1149
|
variables = payload["variables"] || {}
|
|
1063
1150
|
op_name = payload["operationName"]
|
|
@@ -1082,6 +1169,16 @@ module Tina4
|
|
|
1082
1169
|
|
|
1083
1170
|
graphql = self
|
|
1084
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
|
|
1085
1182
|
# handle_request expects the raw JSON text (it JSON.parses internally),
|
|
1086
1183
|
# so read body_raw — request.body now returns the PARSED payload.
|
|
1087
1184
|
body = request.body_raw
|
|
@@ -1089,13 +1186,15 @@ module Tina4
|
|
|
1089
1186
|
response.json(result)
|
|
1090
1187
|
end
|
|
1091
1188
|
|
|
1092
|
-
# 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.
|
|
1093
1191
|
Tina4.get path, auth: false do |request, response|
|
|
1094
1192
|
query = request.query["query"]
|
|
1095
1193
|
if query
|
|
1096
1194
|
variables = request.query["variables"]
|
|
1097
|
-
variables =
|
|
1098
|
-
result = graphql.execute(query, variables: variables || {},
|
|
1195
|
+
variables = Tina4.parse_json(variables) if variables.is_a?(String) && !variables.empty?
|
|
1196
|
+
result = graphql.execute(query, variables: variables || {},
|
|
1197
|
+
context: { request: request }, allow_mutations: false)
|
|
1099
1198
|
response.json(result)
|
|
1100
1199
|
else
|
|
1101
1200
|
response.html(graphiql_html(path))
|
data/lib/tina4/health.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
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
|
+
|
|
2
8
|
|
|
3
9
|
require "json"
|
|
4
10
|
|
data/lib/tina4/html_element.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
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
|
+
|
|
2
8
|
|
|
3
9
|
module Tina4
|
|
4
10
|
# Marker for trusted, pre-sanitised HTML that must render UNESCAPED.
|