tina4ruby 3.13.94 → 3.13.97
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 +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +248 -36
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +256 -33
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +289 -37
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/cors.rb
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tina4
|
|
4
|
+
# CORS policy — reads config from env, computes the response headers.
|
|
5
|
+
#
|
|
6
|
+
# DENY BY DEFAULT (ADR-0018). With TINA4_CORS_ORIGINS unset, NO
|
|
7
|
+
# Access-Control-Allow-Origin is emitted and the browser's own CORS check
|
|
8
|
+
# blocks the cross-origin request. "*" still works, it just has to be asked
|
|
9
|
+
# for. Breaking change from the old permissive default.
|
|
10
|
+
#
|
|
11
|
+
# CREDENTIALS AND THE WILDCARD ARE MUTUALLY EXCLUSIVE. The Fetch Standard's
|
|
12
|
+
# CORS check treats "*" as a literal (not a wildcard) once the request's
|
|
13
|
+
# credentials mode is "include", so Access-Control-Allow-Origin: * together
|
|
14
|
+
# with Access-Control-Allow-Credentials: true is rejected by every browser.
|
|
15
|
+
# Ruby emitted exactly that pair before 2026-07-31 (measured through the real
|
|
16
|
+
# Rack app): the credentials header was written unconditionally from config,
|
|
17
|
+
# with no wildcard guard, while Python, PHP and Node all guarded it.
|
|
18
|
+
#
|
|
19
|
+
# VARY: ORIGIN whenever the ACAO value is COMPUTED from the request's Origin,
|
|
20
|
+
# i.e. whenever an allow-list is configured, on a MISS as well as a match.
|
|
21
|
+
# RFC 9110 s12.5.5: a Vary field name list tells cache recipients they "MUST
|
|
22
|
+
# NOT use this response to satisfy a later request unless the later request
|
|
23
|
+
# has the same values for the listed header fields as the original request".
|
|
24
|
+
# A constant "*" genuinely does not vary and gets no Vary, which would only
|
|
25
|
+
# fragment a CDN's cache per-origin for a response identical to everyone.
|
|
26
|
+
# Access-Control-Allow-Methods / -Allow-Headers are static configured lists
|
|
27
|
+
# here, never derived from the request's Access-Control-Request-* headers, so
|
|
28
|
+
# those field names do NOT belong in Vary.
|
|
4
29
|
module CorsMiddleware
|
|
5
30
|
class << self
|
|
6
31
|
def config
|
|
@@ -9,48 +34,128 @@ module Tina4
|
|
|
9
34
|
|
|
10
35
|
def reset!
|
|
11
36
|
@config = nil
|
|
37
|
+
@warned = nil
|
|
12
38
|
end
|
|
13
39
|
|
|
14
|
-
#
|
|
15
|
-
def
|
|
40
|
+
# The configured origins, split and emptied of blanks.
|
|
41
|
+
def allowed_origins
|
|
42
|
+
config[:origins].split(",").map(&:strip).reject(&:empty?)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Whether an operator has actually declared a CORS policy.
|
|
46
|
+
def configured?
|
|
47
|
+
!allowed_origins.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The CORS policy headers for a request environment.
|
|
51
|
+
#
|
|
52
|
+
# This is the ONE place the policy is computed. preflight_response and
|
|
53
|
+
# apply_headers both call it, so the preflight path and the normal
|
|
54
|
+
# response path can never drift apart the way two implementations do.
|
|
55
|
+
def policy_headers(env = {})
|
|
56
|
+
allowed = allowed_origins
|
|
57
|
+
request_origin = env["HTTP_ORIGIN"]
|
|
58
|
+
|
|
59
|
+
if allowed.empty?
|
|
60
|
+
warn_once(:unconfigured, request_origin) if present?(request_origin)
|
|
61
|
+
return {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
headers = {}
|
|
65
|
+
wildcard = allowed.include?("*")
|
|
66
|
+
# An allow-list decision reads the request Origin, so the response
|
|
67
|
+
# varies by it — on a MISS too, or a shared cache can serve this
|
|
68
|
+
# no-ACAO response to an origin that should have been allowed.
|
|
69
|
+
headers["vary"] = "Origin" unless wildcard
|
|
70
|
+
|
|
16
71
|
origin = resolve_origin(env)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
72
|
+
if origin.nil?
|
|
73
|
+
warn_once(:denied, request_origin) if present?(request_origin)
|
|
74
|
+
return headers
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
headers["access-control-allow-origin"] = origin
|
|
78
|
+
headers["access-control-allow-methods"] = config[:methods]
|
|
79
|
+
headers["access-control-allow-headers"] = config[:headers]
|
|
80
|
+
headers["access-control-max-age"] = config[:max_age]
|
|
81
|
+
|
|
82
|
+
if credentials?
|
|
83
|
+
if origin == "*"
|
|
84
|
+
warn_once(:wildcard_credentials, nil)
|
|
85
|
+
else
|
|
86
|
+
headers["access-control-allow-credentials"] = "true"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
headers
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Handle a CORS preflight request, returns a Rack response array.
|
|
94
|
+
#
|
|
95
|
+
# `allow` is the resource's REAL method set, and it is emitted as the
|
|
96
|
+
# `Allow` header alongside the CORS headers. RFC 9110 s9.3.7 says a
|
|
97
|
+
# successful OPTIONS response SHOULD carry Allow, and a preflight is an
|
|
98
|
+
# OPTIONS response - dropping it means a bare OPTIONS and a preflight to
|
|
99
|
+
# the same path answer two different questions.
|
|
100
|
+
#
|
|
101
|
+
# This is CONFORMANCE, not a deviation. The frameworks' own OPTIONS
|
|
102
|
+
# handlers already do it - Django's View.options() sets Allow from
|
|
103
|
+
# _allowed_methods(), Express's router auto-answers OPTIONS with Allow.
|
|
104
|
+
# The add-on CORS libraries (cors npm, django-cors-headers, rack-cors,
|
|
105
|
+
# stack-cors, ASP.NET CORS) omit it, but that is a LAYERING artifact:
|
|
106
|
+
# each sits ahead of the framework, so short-circuiting the preflight
|
|
107
|
+
# also skips the framework's OPTIONS handler and the header it would
|
|
108
|
+
# have produced. Tina4 owns both paths, so it costs one header to answer
|
|
109
|
+
# both questions at once. See ADR-0013.
|
|
110
|
+
#
|
|
111
|
+
# Note Allow and Access-Control-Allow-Methods are NOT the same thing and
|
|
112
|
+
# are not interchangeable: Allow is what the resource supports, ACAM is
|
|
113
|
+
# what the CORS policy permits cross-origin. A policy allowing DELETE on
|
|
114
|
+
# a GET-only route is still a 405.
|
|
115
|
+
#
|
|
116
|
+
# The status is 204 whether the origin was allowed or denied — the
|
|
117
|
+
# browser does the blocking, and inventing a 403 here would be a second
|
|
118
|
+
# behaviour change for no gain.
|
|
119
|
+
def preflight_response(env = {}, allow: nil)
|
|
120
|
+
headers = policy_headers(env)
|
|
121
|
+
# An unknown path yields "" - the same shape the bare-OPTIONS branch
|
|
122
|
+
# uses - so a client can tell "nothing here" from "not told".
|
|
123
|
+
headers["allow"] = Array(allow).join(", ") unless allow.nil?
|
|
124
|
+
[204, headers, [""]]
|
|
28
125
|
end
|
|
29
126
|
|
|
30
|
-
# Apply CORS headers to a response headers hash
|
|
127
|
+
# Apply CORS headers to a response headers hash, merging Vary rather than
|
|
128
|
+
# clobbering a value another layer already set.
|
|
31
129
|
def apply_headers(response_headers, env = {})
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
130
|
+
policy_headers(env).each do |name, value|
|
|
131
|
+
response_headers[name] = if name == "vary"
|
|
132
|
+
merge_vary(response_headers["vary"], value)
|
|
133
|
+
else
|
|
134
|
+
value
|
|
135
|
+
end
|
|
136
|
+
end
|
|
38
137
|
response_headers
|
|
39
138
|
end
|
|
40
139
|
|
|
41
|
-
# Check if a given origin is allowed
|
|
140
|
+
# Check if a given origin is allowed by the configured policy.
|
|
42
141
|
def origin_allowed?(origin)
|
|
43
|
-
|
|
142
|
+
allowed = allowed_origins
|
|
143
|
+
return false if allowed.empty?
|
|
144
|
+
return true if allowed.include?("*")
|
|
44
145
|
|
|
45
|
-
allowed = config[:origins].split(",").map(&:strip)
|
|
46
146
|
allowed.include?(origin)
|
|
47
147
|
end
|
|
48
148
|
|
|
149
|
+
def credentials?
|
|
150
|
+
%w[true 1 yes].include?(config[:credentials].to_s.downcase)
|
|
151
|
+
end
|
|
152
|
+
|
|
49
153
|
private
|
|
50
154
|
|
|
51
155
|
def load_config
|
|
52
156
|
{
|
|
53
|
-
|
|
157
|
+
# Default is EMPTY, not "*" — deny by default (ADR-0018).
|
|
158
|
+
origins: ENV["TINA4_CORS_ORIGINS"] || "",
|
|
54
159
|
methods: ENV["TINA4_CORS_METHODS"] || "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
55
160
|
headers: ENV["TINA4_CORS_HEADERS"] || "Content-Type,Authorization,X-Request-ID",
|
|
56
161
|
max_age: ENV["TINA4_CORS_MAX_AGE"] || "86400",
|
|
@@ -58,15 +163,66 @@ module Tina4
|
|
|
58
163
|
}.freeze
|
|
59
164
|
end
|
|
60
165
|
|
|
166
|
+
# Resolve the origin to send, or nil for none.
|
|
167
|
+
#
|
|
168
|
+
# Reads the Origin header ONLY. The Referer used to be a fallback, which
|
|
169
|
+
# was wrong twice over: a Referer is a full URL, not an origin, and the
|
|
170
|
+
# CORS protocol is defined entirely in terms of the Origin header. A
|
|
171
|
+
# plain same-site navigation carries a Referer and no Origin, and used to
|
|
172
|
+
# get CORS headers stamped on it for no reason.
|
|
61
173
|
def resolve_origin(env)
|
|
62
|
-
|
|
174
|
+
allowed = allowed_origins
|
|
175
|
+
return nil if allowed.empty?
|
|
176
|
+
return "*" if allowed.include?("*")
|
|
177
|
+
|
|
178
|
+
request_origin = env["HTTP_ORIGIN"]
|
|
179
|
+
return nil unless present?(request_origin)
|
|
180
|
+
|
|
181
|
+
clean = request_origin.chomp("/")
|
|
182
|
+
allowed.include?(clean) ? clean : nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def present?(value)
|
|
186
|
+
!value.nil? && !value.to_s.empty?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def merge_vary(current, field_name)
|
|
190
|
+
parts = current.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
191
|
+
return parts.join(", ") if parts.any? { |p| p.casecmp?(field_name) }
|
|
192
|
+
|
|
193
|
+
(parts + [field_name]).join(", ")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Log an actionable warning at most once per reason per process.
|
|
197
|
+
#
|
|
198
|
+
# A rejected cross-origin request is otherwise invisible: the browser
|
|
199
|
+
# reports a generic CORS failure and the server log says nothing, so the
|
|
200
|
+
# operator has to read the framework source to find the env var to set.
|
|
201
|
+
def warn_once(reason, request_origin)
|
|
202
|
+
@warned ||= {}
|
|
203
|
+
key = reason == :denied ? "denied:#{request_origin}" : reason
|
|
204
|
+
return if @warned[key]
|
|
205
|
+
|
|
206
|
+
@warned[key] = true
|
|
207
|
+
Tina4::Log.warning(warning_message(reason, request_origin))
|
|
208
|
+
rescue StandardError
|
|
209
|
+
nil # logging must never break a request
|
|
210
|
+
end
|
|
63
211
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
request_origin.
|
|
212
|
+
def warning_message(reason, request_origin)
|
|
213
|
+
case reason
|
|
214
|
+
when :unconfigured
|
|
215
|
+
"CORS: refused cross-origin request from #{request_origin} - no policy is configured. " \
|
|
216
|
+
"Set TINA4_CORS_ORIGINS to the origins you want to allow, e.g. " \
|
|
217
|
+
"TINA4_CORS_ORIGINS=https://app.example.com (or '*' to allow any origin)."
|
|
218
|
+
when :denied
|
|
219
|
+
"CORS: origin #{request_origin} is not in TINA4_CORS_ORIGINS (#{config[:origins]}) " \
|
|
220
|
+
"- the browser will block this response."
|
|
68
221
|
else
|
|
69
|
-
""
|
|
222
|
+
"CORS: TINA4_CORS_CREDENTIALS is true but TINA4_CORS_ORIGINS is '*'. The Fetch Standard " \
|
|
223
|
+
"forbids Access-Control-Allow-Origin: * with credentials, so credentials are NOT being " \
|
|
224
|
+
"sent. Credentialed CORS requires an explicit origin list, e.g. " \
|
|
225
|
+
"TINA4_CORS_ORIGINS=https://app.example.com."
|
|
70
226
|
end
|
|
71
227
|
end
|
|
72
228
|
end
|
|
@@ -149,8 +149,11 @@ module Tina4
|
|
|
149
149
|
(["?"] * count).join(", ")
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
+
# NEW LINE, not a space — appended inline the clause lands inside a
|
|
153
|
+
# trailing `-- comment` and the engine ignores it (see the note on
|
|
154
|
+
# Drivers::SqliteDriver#apply_limit).
|
|
152
155
|
def apply_limit(sql, limit, offset = 0)
|
|
153
|
-
"#{sql}
|
|
156
|
+
"#{sql}\nLIMIT #{limit} OFFSET #{offset}"
|
|
154
157
|
end
|
|
155
158
|
|
|
156
159
|
private
|