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/request.rb
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
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
|
require "uri"
|
|
3
9
|
require "json"
|
|
4
10
|
require "ipaddr"
|
|
5
11
|
require "stringio"
|
|
12
|
+
require_relative "parse_json"
|
|
13
|
+
require_relative "form_parser"
|
|
6
14
|
|
|
7
15
|
module Tina4
|
|
8
16
|
# A Hash subclass that supports indifferent access (both string and symbol keys).
|
|
@@ -122,24 +130,36 @@ module Tina4
|
|
|
122
130
|
# reads route.auth_required to honour a public write route (.no_auth).
|
|
123
131
|
attr_accessor :user, :route
|
|
124
132
|
|
|
125
|
-
#
|
|
126
|
-
TINA4_MAX_UPLOAD_SIZE
|
|
133
|
+
# Default upload cap in bytes (10 MB). The live cap is max_upload_size, which
|
|
134
|
+
# reads TINA4_MAX_UPLOAD_SIZE when it is used (ADR-0072). This constant used
|
|
135
|
+
# to read the environment at require time, which raised on a bad value.
|
|
136
|
+
TINA4_MAX_UPLOAD_SIZE = 10_485_760
|
|
127
137
|
|
|
128
138
|
class PayloadTooLarge < StandardError; end
|
|
129
139
|
|
|
130
140
|
# Effective upload cap in bytes. Read at CALL TIME (not frozen into the
|
|
131
141
|
# constant at load) so the limit honours a TINA4_MAX_UPLOAD_SIZE set after
|
|
132
142
|
# this file was required, and so a test can lower it. Falls back to the
|
|
133
|
-
# constant default when the env var is unset/blank.
|
|
143
|
+
# constant default when the env var is unset/blank. A value that is not a
|
|
144
|
+
# positive whole number warns once and uses the default (ADR-0068): to_i
|
|
145
|
+
# used to turn "abc" into 0, which refused every request with a body.
|
|
134
146
|
def self.max_upload_size
|
|
135
|
-
|
|
136
|
-
|
|
147
|
+
raw = ENV["TINA4_MAX_UPLOAD_SIZE"].to_s.strip
|
|
148
|
+
return TINA4_MAX_UPLOAD_SIZE if raw.empty?
|
|
149
|
+
return raw.to_i if raw.match?(/\A\d+\z/) && raw.to_i.positive?
|
|
150
|
+
|
|
151
|
+
@warned_upload_limits ||= {}
|
|
152
|
+
unless @warned_upload_limits[raw]
|
|
153
|
+
@warned_upload_limits[raw] = true
|
|
154
|
+
Tina4::Log.warning("TINA4_MAX_UPLOAD_SIZE=#{raw} is not a usable limit - using #{TINA4_MAX_UPLOAD_SIZE}")
|
|
155
|
+
end
|
|
156
|
+
TINA4_MAX_UPLOAD_SIZE
|
|
137
157
|
end
|
|
138
158
|
|
|
139
159
|
# Read an IO in bounded chunks, raising PayloadTooLarge the MOMENT the
|
|
140
160
|
# running total exceeds the upload cap, so an over-limit body is refused as
|
|
141
161
|
# it arrives instead of after the whole thing is buffered. Rewinds the input
|
|
142
|
-
# before and after so a later reader (
|
|
162
|
+
# before and after so a later reader (the form parser, form-token extraction)
|
|
143
163
|
# sees the same stream. Parity with the Python/Node per-chunk body readers.
|
|
144
164
|
def self.read_stream_capped(input, limit = nil)
|
|
145
165
|
return "" unless input
|
|
@@ -258,7 +278,7 @@ module Tina4
|
|
|
258
278
|
# @return [Boolean] true when the client's scheme is https.
|
|
259
279
|
def self.secure_scheme?(env)
|
|
260
280
|
forwarded = (env["HTTP_X_FORWARDED_PROTO"] || "").to_s
|
|
261
|
-
|
|
281
|
+
if Tina4.trusted_proxy?(env["REMOTE_ADDR"].to_s) && !forwarded.strip.empty?
|
|
262
282
|
return forwarded.split(",").first.to_s.strip.casecmp("https").zero?
|
|
263
283
|
end
|
|
264
284
|
scheme = (env["rack.url_scheme"] || "").to_s
|
|
@@ -272,7 +292,13 @@ module Tina4
|
|
|
272
292
|
# still see the URL the client used. Matches Python/PHP/Node parity.
|
|
273
293
|
def url
|
|
274
294
|
scheme = self.class.secure_scheme?(env) ? "https" : "http"
|
|
275
|
-
|
|
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.
|
|
300
|
+
forwarded_host = Tina4.trusted_proxy?(@remote_ip) ? env["HTTP_X_FORWARDED_HOST"] : nil
|
|
301
|
+
host = forwarded_host || env["HTTP_HOST"] || env["SERVER_NAME"] || "localhost"
|
|
276
302
|
port = env["SERVER_PORT"]
|
|
277
303
|
url_str = "#{scheme}://#{host}"
|
|
278
304
|
# Only append :port when the host doesn't already include one
|
|
@@ -395,7 +421,7 @@ module Tina4
|
|
|
395
421
|
|
|
396
422
|
def json_body
|
|
397
423
|
@json_body ||= begin
|
|
398
|
-
|
|
424
|
+
Tina4.parse_json(body_raw)
|
|
399
425
|
rescue JSON::ParserError, TypeError
|
|
400
426
|
{}
|
|
401
427
|
end
|
|
@@ -474,36 +500,47 @@ module Tina4
|
|
|
474
500
|
data
|
|
475
501
|
end
|
|
476
502
|
|
|
477
|
-
#
|
|
503
|
+
# The parsed multipart body, memoised: { fields:, files:, error: } from
|
|
504
|
+
# Tina4::FormParser, or nil when there is no raw body to parse.
|
|
478
505
|
#
|
|
479
|
-
# A live
|
|
480
|
-
# it
|
|
481
|
-
#
|
|
482
|
-
#
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
506
|
+
# A live server hands the app the raw `rack.input` stream and parses nothing,
|
|
507
|
+
# so Tina4 parses it here. The body is read through read_stream_capped, so the
|
|
508
|
+
# running upload cap refuses an over-limit body as it arrives - OUTSIDE any
|
|
509
|
+
# rescue on purpose: PayloadTooLarge must reach the 413 handler.
|
|
510
|
+
def multipart_parsed
|
|
511
|
+
return @multipart_parsed if defined?(@multipart_parsed)
|
|
512
|
+
|
|
513
|
+
body = Tina4::Request.read_stream_capped(@env["rack.input"])
|
|
514
|
+
@multipart_parsed =
|
|
515
|
+
if body.nil? || body.empty?
|
|
516
|
+
nil
|
|
517
|
+
else
|
|
518
|
+
parsed = Tina4::FormParser.parse_multipart(body, @content_type)
|
|
519
|
+
if parsed[:error] && defined?(Tina4::Log)
|
|
520
|
+
Tina4::Log.warning("multipart parse failed: #{parsed[:error].message}")
|
|
521
|
+
end
|
|
522
|
+
parsed
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# The request's form hash: fields plus file entries under the standard
|
|
527
|
+
# `rack.request.form_hash` env key, the same key (and shape) Rack::Request#POST
|
|
528
|
+
# used to fill, so middleware reading it keeps working. A spec or middleware
|
|
529
|
+
# that INJECTS the key short-circuits the parse entirely.
|
|
487
530
|
def multipart_form_hash
|
|
488
531
|
existing = @env["rack.request.form_hash"] rescue nil
|
|
489
532
|
return existing if existing
|
|
490
533
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
Rack::Request.new(@env).POST
|
|
500
|
-
rescue Tina4::Request::PayloadTooLarge
|
|
501
|
-
raise
|
|
502
|
-
rescue StandardError => e
|
|
503
|
-
Tina4::Log.warning("multipart parse failed: #{e.message}") if defined?(Tina4::Log)
|
|
504
|
-
nil
|
|
534
|
+
parsed = multipart_parsed
|
|
535
|
+
return nil unless parsed
|
|
536
|
+
|
|
537
|
+
form = parsed[:fields].dup
|
|
538
|
+
parsed[:files].each do |name, list|
|
|
539
|
+
# Rack left an empty file field (filename="") out of the form hash.
|
|
540
|
+
kept = list.reject { |descriptor| descriptor[:filename].empty? }
|
|
541
|
+
form[name] = kept.length == 1 ? kept.first : kept unless kept.empty?
|
|
505
542
|
end
|
|
506
|
-
@env["rack.request.form_hash"]
|
|
543
|
+
@env["rack.request.form_hash"] = form
|
|
507
544
|
end
|
|
508
545
|
|
|
509
546
|
def parse_body
|
|
@@ -521,14 +558,14 @@ module Tina4
|
|
|
521
558
|
# NOT delegating to #json_body: that method keeps its own documented
|
|
522
559
|
# always-a-Hash, {}-on-failure contract for direct callers.
|
|
523
560
|
begin
|
|
524
|
-
|
|
561
|
+
Tina4.parse_json(body_raw)
|
|
525
562
|
rescue JSON::ParserError, TypeError
|
|
526
563
|
body_raw
|
|
527
564
|
end
|
|
528
565
|
elsif @content_type.include?("application/x-www-form-urlencoded")
|
|
529
566
|
parse_query_to_hash(body_raw)
|
|
530
567
|
elsif @content_type.include?("multipart/form-data")
|
|
531
|
-
#
|
|
568
|
+
# Form fields from the parsed multipart body (Tina4::FormParser).
|
|
532
569
|
# Files are handled separately by extract_files.
|
|
533
570
|
result = {}
|
|
534
571
|
form_hash = multipart_form_hash
|
|
@@ -560,38 +597,27 @@ module Tina4
|
|
|
560
597
|
def extract_files
|
|
561
598
|
result = {}
|
|
562
599
|
return result unless @content_type.include?("multipart/form-data")
|
|
563
|
-
begin
|
|
564
|
-
# LIVE path: hand-scan the raw body so a REPEATED file field name is
|
|
565
|
-
# preserved as a LIST (Rack's POST collapses a repeated non-bracket name
|
|
566
|
-
# to last-wins - the multi-file data-loss this fixes). The scan reads the
|
|
567
|
-
# body through the capped reader, so an over-limit upload is refused as
|
|
568
|
-
# it arrives. Falls through to the parsed form_hash when there is no raw
|
|
569
|
-
# body (e.g. a spec injecting rack.request.form_hash).
|
|
570
|
-
scanned = scan_multipart_files
|
|
571
|
-
if scanned && !scanned.empty?
|
|
572
|
-
scanned.each do |key, list|
|
|
573
|
-
files = list.map { |value| build_file_upload(value) }.compact
|
|
574
|
-
result[key] = files.length == 1 ? files.first : files unless files.empty?
|
|
575
|
-
end
|
|
576
|
-
return result
|
|
577
|
-
end
|
|
578
600
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
601
|
+
# LIVE path: the parser keeps a REPEATED file field name as a LIST (Rack's
|
|
602
|
+
# POST collapsed a repeated non-bracket name to last-wins - the multi-file
|
|
603
|
+
# data loss). Falls through to an injected rack.request.form_hash when
|
|
604
|
+
# there is no raw body to parse.
|
|
605
|
+
injected = @env["rack.request.form_hash"] unless multipart_parsed
|
|
606
|
+
if multipart_parsed
|
|
607
|
+
multipart_parsed[:files].each do |key, list|
|
|
608
|
+
files = list.map { |value| build_file_upload(value) }.compact
|
|
609
|
+
result[key] = files.length == 1 ? files.first : files unless files.empty?
|
|
610
|
+
end
|
|
611
|
+
elsif injected
|
|
612
|
+
injected.each do |key, value|
|
|
613
|
+
if value.is_a?(Array)
|
|
614
|
+
files = value.map { |v| build_file_upload(v) }.compact
|
|
615
|
+
result[key] = files.length == 1 ? files.first : files unless files.empty?
|
|
616
|
+
else
|
|
617
|
+
file = build_file_upload(value)
|
|
618
|
+
result[key] = file if file
|
|
589
619
|
end
|
|
590
620
|
end
|
|
591
|
-
rescue Tina4::Request::PayloadTooLarge
|
|
592
|
-
raise
|
|
593
|
-
rescue StandardError
|
|
594
|
-
# Multipart parsing failed
|
|
595
621
|
end
|
|
596
622
|
result
|
|
597
623
|
end
|
|
@@ -612,68 +638,5 @@ module Tina4
|
|
|
612
638
|
file["content"] = value[:content] if value.key?(:content) && !value[:content].nil?
|
|
613
639
|
file
|
|
614
640
|
end
|
|
615
|
-
|
|
616
|
-
# Extract the boundary token from a multipart Content-Type header.
|
|
617
|
-
def multipart_boundary(content_type)
|
|
618
|
-
content_type.to_s.split(";").each do |part|
|
|
619
|
-
part = part.strip
|
|
620
|
-
next unless part.start_with?("boundary=")
|
|
621
|
-
|
|
622
|
-
return part[9..].to_s.delete_prefix('"').delete_suffix('"')
|
|
623
|
-
end
|
|
624
|
-
nil
|
|
625
|
-
end
|
|
626
|
-
|
|
627
|
-
# Hand-roll the FILE parts out of the raw multipart body, keyed by field
|
|
628
|
-
# name, each name mapping to a LIST of descriptors so a repeated name keeps
|
|
629
|
-
# every file. Reads through read_stream_capped, so the running per-chunk
|
|
630
|
-
# upload cap is enforced here too (raising PayloadTooLarge). Returns {} when
|
|
631
|
-
# there is no raw body (the injected-form_hash path is used instead). Fields
|
|
632
|
-
# are handled by parse_body (via Rack) - this scans files only.
|
|
633
|
-
def scan_multipart_files
|
|
634
|
-
boundary = multipart_boundary(@content_type)
|
|
635
|
-
return {} unless boundary
|
|
636
|
-
|
|
637
|
-
body = Tina4::Request.read_stream_capped(@env["rack.input"])
|
|
638
|
-
return {} if body.nil? || body.empty?
|
|
639
|
-
|
|
640
|
-
body = body.dup.force_encoding("BINARY")
|
|
641
|
-
files = {}
|
|
642
|
-
delimiter = "--#{boundary}"
|
|
643
|
-
body.split(delimiter).each do |segment|
|
|
644
|
-
next if segment.empty? || segment.start_with?("--")
|
|
645
|
-
|
|
646
|
-
segment = segment.sub(/\A\r\n/, "")
|
|
647
|
-
header_end = segment.index("\r\n\r\n")
|
|
648
|
-
next unless header_end
|
|
649
|
-
|
|
650
|
-
header_section = segment[0...header_end]
|
|
651
|
-
content = segment[(header_end + 4)..] || ""
|
|
652
|
-
content = content.sub(/\r\n\z/, "")
|
|
653
|
-
|
|
654
|
-
name = nil
|
|
655
|
-
filename = nil
|
|
656
|
-
type = "application/octet-stream"
|
|
657
|
-
header_section.split("\r\n").each do |line|
|
|
658
|
-
if line =~ /content-disposition/i
|
|
659
|
-
name = Regexp.last_match(1) if line =~ /name="([^"]*)"/
|
|
660
|
-
filename = Regexp.last_match(1) if line =~ /filename="([^"]*)"/
|
|
661
|
-
elsif line =~ /content-type:\s*(.+)/i
|
|
662
|
-
type = Regexp.last_match(1).strip
|
|
663
|
-
end
|
|
664
|
-
end
|
|
665
|
-
next if name.nil? || filename.nil?
|
|
666
|
-
|
|
667
|
-
bytes = content.dup.force_encoding("BINARY")
|
|
668
|
-
(files[name] ||= []) << {
|
|
669
|
-
filename: filename,
|
|
670
|
-
type: type,
|
|
671
|
-
content: bytes,
|
|
672
|
-
size: bytes.bytesize,
|
|
673
|
-
tempfile: StringIO.new(bytes)
|
|
674
|
-
}
|
|
675
|
-
end
|
|
676
|
-
files
|
|
677
|
-
end
|
|
678
641
|
end
|
|
679
642
|
end
|
data/lib/tina4/response.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
|
require "json"
|
|
3
9
|
require "uri"
|
|
4
10
|
|
|
@@ -59,11 +65,30 @@ module Tina4
|
|
|
59
65
|
|
|
60
66
|
attr_accessor :status_code, :headers, :body, :cookies
|
|
61
67
|
|
|
68
|
+
# ADR-0068: a header name must be an RFC 9110 token, and a header value,
|
|
69
|
+
# redirect location or cookie attribute may never carry CR, LF or NUL (a
|
|
70
|
+
# cookie attribute not ';' either). They are REFUSED where the developer
|
|
71
|
+
# sets them - never stripped - so the stack trace points at the line that
|
|
72
|
+
# built the value. The messages are identical in all four frameworks.
|
|
73
|
+
HEADER_TOKEN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/
|
|
74
|
+
UNSAFE_HEADER_VALUE = /[\r\n\0]/
|
|
75
|
+
UNSAFE_COOKIE_CONTENT = /[\r\n\0;]/
|
|
76
|
+
|
|
77
|
+
def self.check_header!(name, value)
|
|
78
|
+
unless name.to_s.match?(HEADER_TOKEN)
|
|
79
|
+
raise ArgumentError, "Header name must be a valid HTTP token [#{JSON.generate(name.to_s)}]"
|
|
80
|
+
end
|
|
81
|
+
return value unless value.to_s.match?(UNSAFE_HEADER_VALUE)
|
|
82
|
+
|
|
83
|
+
raise ArgumentError, "Invalid character in header content [#{JSON.generate(name.to_s)}]"
|
|
84
|
+
end
|
|
85
|
+
|
|
62
86
|
def initialize
|
|
63
87
|
@status_code = 200
|
|
64
88
|
@headers = { "content-type" => HTML_CONTENT_TYPE }
|
|
65
89
|
@body = ""
|
|
66
90
|
@cookies = nil # Lazy -- most responses have no cookies
|
|
91
|
+
@content_type_from_header = false
|
|
67
92
|
end
|
|
68
93
|
|
|
69
94
|
# Chainable status setter
|
|
@@ -79,16 +104,17 @@ module Tina4
|
|
|
79
104
|
# Callable response — auto-detects content type from data.
|
|
80
105
|
# Matches Python __call__ / PHP __invoke / Node response() pattern.
|
|
81
106
|
def call(data = nil, status_code = 200, content_type = nil)
|
|
107
|
+
Response.check_header!("Content-Type", content_type) if content_type
|
|
82
108
|
@status_code = status_code
|
|
83
109
|
data = jsonable(data)
|
|
84
110
|
if content_type
|
|
85
111
|
@headers["content-type"] = content_type
|
|
86
|
-
@body = data.to_s
|
|
112
|
+
@body = data.is_a?(Hash) || data.is_a?(Array) ? JSON.generate(data) : data.to_s
|
|
87
113
|
elsif data.is_a?(Hash) || data.is_a?(Array)
|
|
88
|
-
|
|
114
|
+
detected_content_type(JSON_CONTENT_TYPE)
|
|
89
115
|
@body = JSON.generate(data)
|
|
90
116
|
else
|
|
91
|
-
|
|
117
|
+
detected_content_type(HTML_CONTENT_TYPE)
|
|
92
118
|
@body = data.to_s
|
|
93
119
|
end
|
|
94
120
|
self
|
|
@@ -142,14 +168,16 @@ module Tina4
|
|
|
142
168
|
end
|
|
143
169
|
|
|
144
170
|
def csv(content, filename: "export.csv", status: 200)
|
|
171
|
+
disposition = Response.check_header!("Content-Disposition", "attachment; filename=\"#{filename}\"")
|
|
145
172
|
@status_code = status
|
|
146
173
|
@headers["content-type"] = "text/csv"
|
|
147
|
-
@headers["content-disposition"] =
|
|
174
|
+
@headers["content-disposition"] = disposition
|
|
148
175
|
@body = content.to_s
|
|
149
176
|
self
|
|
150
177
|
end
|
|
151
178
|
|
|
152
179
|
def redirect(url, status_or_opts = nil, status: nil)
|
|
180
|
+
Response.check_header!("Location", url)
|
|
153
181
|
@status_code = status || (status_or_opts.is_a?(Integer) ? status_or_opts : 302)
|
|
154
182
|
@headers["location"] = url
|
|
155
183
|
@body = ""
|
|
@@ -157,6 +185,7 @@ module Tina4
|
|
|
157
185
|
end
|
|
158
186
|
|
|
159
187
|
def file(path, content_type: nil, download: false, root: nil)
|
|
188
|
+
Response.check_header!("Content-Type", content_type) if content_type
|
|
160
189
|
# SECURITY: confine the read. The natural spelling of a download route,
|
|
161
190
|
#
|
|
162
191
|
# response.file("downloads/" + name) # name = "../secret.env"
|
|
@@ -206,7 +235,8 @@ module Tina4
|
|
|
206
235
|
ext = ::File.extname(path).downcase
|
|
207
236
|
@headers["content-type"] = content_type || MIME_TYPES[ext] || "application/octet-stream"
|
|
208
237
|
if download
|
|
209
|
-
@headers["content-disposition"] =
|
|
238
|
+
@headers["content-disposition"] =
|
|
239
|
+
Response.check_header!("Content-Disposition", "attachment; filename=\"#{::File.basename(path)}\"")
|
|
210
240
|
end
|
|
211
241
|
@body = ::File.binread(path)
|
|
212
242
|
self
|
|
@@ -279,12 +309,14 @@ module Tina4
|
|
|
279
309
|
{ error: true, code: code, message: message, status: status }
|
|
280
310
|
end
|
|
281
311
|
|
|
282
|
-
# Chainable header setter
|
|
312
|
+
# Chainable header setter. Content-Type (any case) is not a second header:
|
|
313
|
+
# it replaces the response's one content type, and call(data) keeps it
|
|
314
|
+
# instead of detecting one (ADR-0072, tina4-python#144).
|
|
283
315
|
def header(name, value = nil)
|
|
284
316
|
if value.nil?
|
|
285
|
-
@headers[name]
|
|
317
|
+
@headers[content_type_key?(name) ? "content-type" : name]
|
|
286
318
|
else
|
|
287
|
-
|
|
319
|
+
set_header(name, Response.check_header!(name, value))
|
|
288
320
|
self
|
|
289
321
|
end
|
|
290
322
|
end
|
|
@@ -294,7 +326,16 @@ module Tina4
|
|
|
294
326
|
set_cookie(name, value, opts)
|
|
295
327
|
end
|
|
296
328
|
|
|
329
|
+
# The value is percent-encoded on the way out, so it can never carry CR,
|
|
330
|
+
# LF, NUL or ';' to the wire and is not refused (ADR-0068 keeps encoding
|
|
331
|
+
# where a framework already did). The name and every attribute are refused.
|
|
297
332
|
def set_cookie(name, value, opts = {})
|
|
333
|
+
label = JSON.generate(name.to_s)
|
|
334
|
+
raise ArgumentError, "Cookie name must be a valid HTTP token [#{label}]" unless name.to_s.match?(HEADER_TOKEN)
|
|
335
|
+
if [opts[:path], opts[:same_site], opts[:max_age]].any? { |attribute| attribute.to_s.match?(UNSAFE_COOKIE_CONTENT) }
|
|
336
|
+
raise ArgumentError, "Invalid character in cookie content [#{label}]"
|
|
337
|
+
end
|
|
338
|
+
|
|
298
339
|
cookie_str = "#{name}=#{URI.encode_www_form_component(value)}"
|
|
299
340
|
cookie_str += "; Path=#{opts[:path] || '/'}"
|
|
300
341
|
cookie_str += "; HttpOnly" if opts.fetch(:http_only, true)
|
|
@@ -312,7 +353,7 @@ module Tina4
|
|
|
312
353
|
end
|
|
313
354
|
|
|
314
355
|
def add_header(key, value)
|
|
315
|
-
|
|
356
|
+
set_header(key, Response.check_header!(key, value))
|
|
316
357
|
self
|
|
317
358
|
end
|
|
318
359
|
|
|
@@ -353,6 +394,7 @@ module Tina4
|
|
|
353
394
|
# @yield [Enumerator::Yielder] Block receives a yielder to push chunks
|
|
354
395
|
# @return [self]
|
|
355
396
|
def stream(generator = nil, content_type: "text/event-stream", &block)
|
|
397
|
+
Response.check_header!("Content-Type", content_type)
|
|
356
398
|
@status_code = @status_code || 200
|
|
357
399
|
@headers["content-type"] = content_type
|
|
358
400
|
@headers["cache-control"] = "no-cache"
|
|
@@ -366,6 +408,7 @@ module Tina4
|
|
|
366
408
|
|
|
367
409
|
# Finalize and return the response — matches Python/Node API.
|
|
368
410
|
def send(data = nil, status_code: nil, content_type: nil)
|
|
411
|
+
Response.check_header!("Content-Type", content_type) if content_type
|
|
369
412
|
if data
|
|
370
413
|
if data.is_a?(Hash) || data.is_a?(Array)
|
|
371
414
|
return json(data, status_code || 200)
|
|
@@ -378,6 +421,25 @@ module Tina4
|
|
|
378
421
|
to_rack
|
|
379
422
|
end
|
|
380
423
|
|
|
424
|
+
def content_type_key?(name)
|
|
425
|
+
name.to_s.casecmp?("content-type")
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def set_header(name, value)
|
|
429
|
+
if content_type_key?(name)
|
|
430
|
+
@headers["content-type"] = value
|
|
431
|
+
@content_type_from_header = true
|
|
432
|
+
else
|
|
433
|
+
@headers[name] = value
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# A detected content type never replaces one the route set with header().
|
|
438
|
+
def detected_content_type(content_type)
|
|
439
|
+
@headers["content-type"] = content_type unless @content_type_from_header
|
|
440
|
+
end
|
|
441
|
+
private :content_type_key?, :set_header, :detected_content_type
|
|
442
|
+
|
|
381
443
|
def to_rack
|
|
382
444
|
final_headers = @headers.dup
|
|
383
445
|
final_headers["set-cookie"] = @cookies.join("\n") if @cookies && !@cookies.empty?
|
data/lib/tina4/response_cache.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
|
# Multi-backend response cache for GET requests.
|
data/lib/tina4/router.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 "fileutils"
|
|
4
10
|
require "json"
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
# Internal credential persistence: validate and read/write the same descriptor.
|
|
10
|
+
module SecretFile
|
|
11
|
+
def self.update(path)
|
|
12
|
+
begin
|
|
13
|
+
before = File.lstat(path)
|
|
14
|
+
raise IOError, "credential target must be an unlinked regular file" unless before.file? && before.nlink == 1
|
|
15
|
+
rescue Errno::ENOENT
|
|
16
|
+
# The O_CREAT open below creates the file with owner-only permissions.
|
|
17
|
+
end
|
|
18
|
+
flags = File::RDWR | File::CREAT
|
|
19
|
+
flags |= File::NOFOLLOW if defined?(File::NOFOLLOW)
|
|
20
|
+
flags |= File::NONBLOCK if defined?(File::NONBLOCK)
|
|
21
|
+
File.open(path, flags, 0o600) do |file|
|
|
22
|
+
file.flock(File::LOCK_EX)
|
|
23
|
+
stat = file.stat
|
|
24
|
+
raise IOError, "credential target must be an unlinked regular file" unless stat.file? && stat.nlink == 1
|
|
25
|
+
file.chmod(0o600)
|
|
26
|
+
content = yield file.read
|
|
27
|
+
file.rewind
|
|
28
|
+
file.truncate(0)
|
|
29
|
+
file.write(content)
|
|
30
|
+
file.flush
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/tina4/seeder.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 "securerandom"
|
|
4
10
|
require "json"
|
|
@@ -856,10 +862,10 @@ module Tina4
|
|
|
856
862
|
db = ref_class.get_db
|
|
857
863
|
next unless db
|
|
858
864
|
|
|
859
|
-
|
|
860
|
-
rows = db.fetch("SELECT #{
|
|
865
|
+
pk_column = ref_class.get_db_column(ref_class.primary_key_field || :id)
|
|
866
|
+
rows = db.fetch("SELECT #{pk_column} FROM #{ref_class.table_name}", [], limit: 100_000)
|
|
861
867
|
list = rows.respond_to?(:to_a) ? rows.to_a : Array(rows)
|
|
862
|
-
values = list.map { |r| r[
|
|
868
|
+
values = list.map { |r| r[pk_column] || r[pk_column.to_s] }.compact
|
|
863
869
|
pools[name] = values unless values.empty?
|
|
864
870
|
rescue => e
|
|
865
871
|
Tina4::Log.warning("Seeder: could not resolve FK pool for #{name}: #{e.message}")
|
data/lib/tina4/service.rb
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
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
|
# Tina4 — The Intelligent Native Application 4ramework
|
|
4
|
-
# Copyright
|
|
5
|
-
# License:
|
|
10
|
+
# Copyright (c) 2026 Code Infinity
|
|
11
|
+
# License: MPL-2.0 https://mozilla.org/MPL/2.0/
|
|
6
12
|
|
|
7
13
|
module Tina4
|
|
8
14
|
# Base class for class-based background services managed by
|
data/lib/tina4/service_runner.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
|
# Context object passed to each service handler, giving it control
|
data/lib/tina4/session.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
|
require "securerandom"
|
|
3
9
|
require "json"
|
|
4
10
|
|