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/session.rb
CHANGED
|
@@ -8,11 +8,47 @@ module Tina4
|
|
|
8
8
|
secret: nil,
|
|
9
9
|
max_age: 3600,
|
|
10
10
|
handler: :file,
|
|
11
|
-
handler_options: {}
|
|
11
|
+
handler_options: {},
|
|
12
|
+
# Opt-in, and OFF for every direct caller. See the construction guard in
|
|
13
|
+
# #initialize: only the live REQUEST PATH degrades when the storage
|
|
14
|
+
# handler cannot be built; boot, the CLI, a spec and app code that call
|
|
15
|
+
# Session.new themselves still get the loud raise they rely on.
|
|
16
|
+
degrade_on_backend_failure: false
|
|
12
17
|
}.freeze
|
|
13
18
|
|
|
14
19
|
attr_reader :id, :data
|
|
15
20
|
|
|
21
|
+
# A session id is OPAQUE — an unguessable lookup token and nothing else. It
|
|
22
|
+
# is never a filename, a path, a SQL fragment or a Redis key fragment, so the
|
|
23
|
+
# only characters it may contain are the ones every backend treats as inert.
|
|
24
|
+
#
|
|
25
|
+
# The alphabet is the RFC 4648 base64url set, which is exactly what all four
|
|
26
|
+
# frameworks already mint: Ruby SecureRandom.hex(32), Python
|
|
27
|
+
# secrets.token_urlsafe(32), PHP/Node hex(16). Validation is therefore
|
|
28
|
+
# non-breaking for every id the family has ever issued, while rejecting the
|
|
29
|
+
# "." and "/" that turn a cookie into a path traversal.
|
|
30
|
+
#
|
|
31
|
+
# The constraint is the ALPHABET, not the length. There is deliberately NO
|
|
32
|
+
# entropy floor: unguessability comes from the framework MINTING the id
|
|
33
|
+
# (SecureRandom.hex(32)), never from inspecting one an app passed on purpose,
|
|
34
|
+
# so a floor would close no attack while breaking trusted callers that manage
|
|
35
|
+
# their own short programmatic ids (start("my-session-id")). An
|
|
36
|
+
# attacker-supplied id is stopped by strict mode (see #adopt_or_mint), not by
|
|
37
|
+
# its length. The 128-character ceiling just bounds what can be pushed
|
|
38
|
+
# through a backend key.
|
|
39
|
+
#
|
|
40
|
+
# \A and \z, NEVER ^ and $: Ruby's ^/$ match LINE boundaries, so a "^...$"
|
|
41
|
+
# anchor would accept "legitimate_looking_id\n../../etc/passwd".
|
|
42
|
+
SESSION_ID_PATTERN = /\A[A-Za-z0-9_-]{1,128}\z/
|
|
43
|
+
|
|
44
|
+
# True when session_id is a well-formed opaque session identifier.
|
|
45
|
+
#
|
|
46
|
+
# Callers pass UNTRUSTED input here (the session cookie is attacker-chosen),
|
|
47
|
+
# so anything that is not a String of the opaque alphabet is rejected.
|
|
48
|
+
def self.valid_session_id?(session_id)
|
|
49
|
+
session_id.is_a?(String) && SESSION_ID_PATTERN.match?(session_id)
|
|
50
|
+
end
|
|
51
|
+
|
|
16
52
|
# The session cookie name — the SINGLE source of truth shared by the WRITE
|
|
17
53
|
# side (#cookie_header) and the READ side (#extract_session_id AND RackApp's
|
|
18
54
|
# incoming-cookie parse), so a cookie written under a renamed name is read
|
|
@@ -49,6 +85,13 @@ module Tina4
|
|
|
49
85
|
ttl_env = ENV["TINA4_SESSION_TTL"]
|
|
50
86
|
@options[:max_age] = Integer(ttl_env) if ttl_env && !ttl_env.strip.empty?
|
|
51
87
|
end
|
|
88
|
+
# The BACKEND lifetime, resolved once and forwarded to handler#write on
|
|
89
|
+
# every save (parity with Python's Session._ttl, which flows the same way).
|
|
90
|
+
# #save used to call safe_write(@id, @data) with NO ttl, so the cookie said
|
|
91
|
+
# Max-Age=900 while the stored record lived for the handler's own default -
|
|
92
|
+
# a silent disagreement between what the browser was told and what the
|
|
93
|
+
# store actually did. One resolver, both directions.
|
|
94
|
+
@ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
|
|
52
95
|
# TINA4_SESSION_BACKEND — selects the storage handler unless the caller
|
|
53
96
|
# explicitly passed :handler (same precedence as :cookie_name above; an
|
|
54
97
|
# explicit option always wins over the environment). Without this the
|
|
@@ -66,10 +109,47 @@ module Tina4
|
|
|
66
109
|
# session cookie for an encrypted request must never be sent in the clear.
|
|
67
110
|
# Uses the SAME detector as Request#url so the two never disagree (issue #31).
|
|
68
111
|
@request_secure = Tina4::Request.secure_scheme?(env || {})
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
112
|
+
# LOG LOUD, THEN DEGRADE (ADR-0021) - for handler CONSTRUCTION too.
|
|
113
|
+
#
|
|
114
|
+
# The read/write/destroy/gc policy further down has always been right, but
|
|
115
|
+
# it sat BELOW this line: create_handler ran bare. A handler whose
|
|
116
|
+
# constructor touches the network - the database backend opens its
|
|
117
|
+
# connection and issues DDL in #initialize - raised straight out of
|
|
118
|
+
# Session.new, out of Request#session, and into RackApp's 500 handler, so
|
|
119
|
+
# an unreachable backend took the whole REQUEST down instead of degrading
|
|
120
|
+
# it, and TINA4_SESSION_STRICT was INERT because the non-strict path
|
|
121
|
+
# already produced the identical 500.
|
|
122
|
+
#
|
|
123
|
+
# WHO DEGRADES, AND WHO STILL RAISES. Only the caller that opts in, which
|
|
124
|
+
# is the live request path (Request#session, RackApp.enforce_route_auth).
|
|
125
|
+
# Every other caller keeps the loud raise, deliberately: an unknown
|
|
126
|
+
# TINA4_SESSION_BACKEND is a CONFIGURATION error, not an outage, and the
|
|
127
|
+
# owner decision of 2026-07-31 (session_backend_validation_spec.rb, all
|
|
128
|
+
# four frameworks) is that it must fail fast where a human can fix it
|
|
129
|
+
# rather than serve on the wrong storage. This is the same split the
|
|
130
|
+
# Python master makes: its Session raises, and core/server.py's request
|
|
131
|
+
# path is what logs and degrades.
|
|
132
|
+
#
|
|
133
|
+
# A DEGRADED SESSION IS AN IN-MEMORY-ONLY SESSION: no handler, so nothing
|
|
134
|
+
# is read from or written to any store. The route still receives a working
|
|
135
|
+
# Session object - Ruby cannot hand back Python's `request.session = None`
|
|
136
|
+
# without turning every `session[...]` into a NoMethodError, which would
|
|
137
|
+
# 500 the very request this is saving - so reads yield an empty session
|
|
138
|
+
# and #save returns false, which is exactly the contract. The failure is
|
|
139
|
+
# logged ONCE, here, where it happened; see #degraded? for why not again.
|
|
140
|
+
@handler = nil
|
|
141
|
+
begin
|
|
142
|
+
@handler = create_handler
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
raise unless @options[:degrade_on_backend_failure]
|
|
145
|
+
|
|
146
|
+
log_backend_error("handler construction", e)
|
|
147
|
+
raise if @strict
|
|
148
|
+
end
|
|
149
|
+
# The cookie is the live server's session-id source and is fully
|
|
150
|
+
# attacker-controlled, so it goes through the same strict-mode funnel as
|
|
151
|
+
# an explicit #start.
|
|
152
|
+
adopt_or_mint(extract_session_id(env))
|
|
73
153
|
end
|
|
74
154
|
|
|
75
155
|
def [](key)
|
|
@@ -98,9 +178,13 @@ module Tina4
|
|
|
98
178
|
# Persist the session if dirty. On a backend write failure the error is
|
|
99
179
|
# logged and false is returned — the @modified (dirty) flag is RETAINED so
|
|
100
180
|
# a later save can retry. Returns true on a successful (or no-op) write.
|
|
181
|
+
#
|
|
182
|
+
# A cleared id (@id nil, e.g. after #destroy) is a no-op: there is nothing
|
|
183
|
+
# to persist, and a write would re-create the just-destroyed record. Mirrors
|
|
184
|
+
# the Python master's `if self._session_id and self._dirty`.
|
|
101
185
|
def save
|
|
102
|
-
return true unless @modified
|
|
103
|
-
if safe_write(@id, @data)
|
|
186
|
+
return true unless @id && @modified
|
|
187
|
+
if safe_write(@id, @data, @ttl)
|
|
104
188
|
@modified = false
|
|
105
189
|
true
|
|
106
190
|
else
|
|
@@ -108,16 +192,36 @@ module Tina4
|
|
|
108
192
|
end
|
|
109
193
|
end
|
|
110
194
|
|
|
111
|
-
# Destroy the current session.
|
|
112
|
-
#
|
|
195
|
+
# Destroy the current session. ENDS the session: the stored record is
|
|
196
|
+
# removed and the id is CLEARED (@id = nil), so a later set()+save() with no
|
|
197
|
+
# new #start has no id to persist under and cannot RESURRECT the just-
|
|
198
|
+
# destroyed record. Mirrors the Python master (nulls _session_id) and Node
|
|
199
|
+
# (nulls sessionId). A fresh session needs a new #start, which mints a new id.
|
|
113
200
|
def destroy
|
|
114
|
-
safe_destroy(@id)
|
|
201
|
+
safe_destroy(@id) if @id
|
|
202
|
+
@id = nil
|
|
115
203
|
@data = {}
|
|
204
|
+
@modified = false
|
|
116
205
|
end
|
|
117
206
|
|
|
118
|
-
# Get a session value with optional default
|
|
207
|
+
# Get a session value with optional default.
|
|
208
|
+
#
|
|
209
|
+
# The default is returned for an ABSENT key, never for a stored FALSE. This
|
|
210
|
+
# was `@data[key.to_s] || default`, which handed back the caller's default
|
|
211
|
+
# for any falsy stored value — so a feature flag stored as false read back
|
|
212
|
+
# as the caller's `true` default. Python (`dict.get`), PHP (`??`) and Node
|
|
213
|
+
# (`??`) all return the stored false, so Ruby was the 1-of-4 outlier.
|
|
214
|
+
#
|
|
215
|
+
# Deliberately the `nil?` form and NOT `@data.key?(k) ? @data[k] : default`:
|
|
216
|
+
# both fix the false case, but the key? form would ALSO flip a stored nil
|
|
217
|
+
# from the default to nil. Ruby currently agrees with PHP and Node there
|
|
218
|
+
# (stored nil -> default) and only Python disagrees (stored None -> None),
|
|
219
|
+
# so changing it is a cross-framework decision, not a side effect of this
|
|
220
|
+
# fix. This form is exactly PHP's `??` and Node's `??`. Same idiom as
|
|
221
|
+
# #get_flash below.
|
|
119
222
|
def get(key, default = nil)
|
|
120
|
-
@data[key.to_s]
|
|
223
|
+
value = @data[key.to_s]
|
|
224
|
+
value.nil? ? default : value
|
|
121
225
|
end
|
|
122
226
|
|
|
123
227
|
# Set a session value
|
|
@@ -171,18 +275,15 @@ module Tina4
|
|
|
171
275
|
@id
|
|
172
276
|
end
|
|
173
277
|
|
|
174
|
-
# Start or resume a session.
|
|
175
|
-
#
|
|
278
|
+
# Start or resume a session. Returns the session ID string.
|
|
279
|
+
#
|
|
280
|
+
# session_id is UNTRUSTED, so it goes through #adopt_or_mint: it is resumed
|
|
281
|
+
# only when it is a well-formed opaque id AND one the backend already holds a
|
|
282
|
+
# session under (strict mode). Otherwise a genuinely NEW session is started
|
|
283
|
+
# under a fresh SecureRandom.hex(32). A session already in flight keeps both
|
|
284
|
+
# its id and its data.
|
|
176
285
|
def start(session_id = nil)
|
|
177
|
-
|
|
178
|
-
@id = session_id
|
|
179
|
-
@data = load_session
|
|
180
|
-
else
|
|
181
|
-
@id = SecureRandom.hex(32)
|
|
182
|
-
@data = {}
|
|
183
|
-
end
|
|
184
|
-
@modified = false
|
|
185
|
-
@id
|
|
286
|
+
adopt_or_mint(session_id)
|
|
186
287
|
end
|
|
187
288
|
|
|
188
289
|
# Returns the current session ID string.
|
|
@@ -238,6 +339,9 @@ module Tina4
|
|
|
238
339
|
|
|
239
340
|
private
|
|
240
341
|
|
|
342
|
+
# The session id carried on the incoming cookie, or nil. A pure parser —
|
|
343
|
+
# every caller funnels through #adopt_or_mint, which is where the value is
|
|
344
|
+
# judged.
|
|
241
345
|
def extract_session_id(env)
|
|
242
346
|
cookie_str = env["HTTP_COOKIE"] || ""
|
|
243
347
|
cookie_str.split(";").each do |pair|
|
|
@@ -247,8 +351,58 @@ module Tina4
|
|
|
247
351
|
nil
|
|
248
352
|
end
|
|
249
353
|
|
|
250
|
-
|
|
251
|
-
|
|
354
|
+
# Adopt session_id, or mint a fresh one — the SINGLE decision both entry
|
|
355
|
+
# points (the constructor's cookie path and #start) go through, so neither
|
|
356
|
+
# can drift from the rule or skip it.
|
|
357
|
+
#
|
|
358
|
+
# STRICT SESSION MODE (OWASP; PHP's session.use_strict_mode=1). session_id is
|
|
359
|
+
# UNTRUSTED: it comes from the session cookie or a caller, both of which the
|
|
360
|
+
# client controls. It is adopted ONLY when it is BOTH
|
|
361
|
+
# (a) a well-formed opaque id (see valid_session_id?), and
|
|
362
|
+
# (b) an id the backend already holds a session under.
|
|
363
|
+
# Anything else — malformed, or well-formed but never issued — is DISCARDED
|
|
364
|
+
# and a fresh SecureRandom.hex(32) minted. Adopting either one is session
|
|
365
|
+
# fixation: an attacker plants a cookie, the victim logs in under it, and the
|
|
366
|
+
# attacker already holds the authenticated session id. A malformed id also
|
|
367
|
+
# used to steer a filesystem path.
|
|
368
|
+
#
|
|
369
|
+
# Sets @id/@data/@modified and returns the resolved id.
|
|
370
|
+
def adopt_or_mint(session_id)
|
|
371
|
+
session_id = nil unless self.class.valid_session_id?(session_id)
|
|
372
|
+
data = session_id.nil? ? nil : existing_session_data(session_id)
|
|
373
|
+
if data.nil?
|
|
374
|
+
session_id = SecureRandom.hex(32)
|
|
375
|
+
data = {}
|
|
376
|
+
end
|
|
377
|
+
@id = session_id
|
|
378
|
+
@data = data
|
|
379
|
+
@modified = false
|
|
380
|
+
@id
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# The stored data for session_id when the backend HOLDS a session under it,
|
|
384
|
+
# else nil — strict mode's signal to mint a fresh id rather than adopt one
|
|
385
|
+
# the client chose.
|
|
386
|
+
#
|
|
387
|
+
# nil AND empty both count as "no session": the handlers disagree (file,
|
|
388
|
+
# redis, valkey, mongo and database return nil for a missing session, while
|
|
389
|
+
# memcached returns {}), and strict mode must not silently no-op on one
|
|
390
|
+
# backend. An empty STORED session cannot arise anyway — #save is a no-op
|
|
391
|
+
# until something is actually written.
|
|
392
|
+
#
|
|
393
|
+
# A backend FAILURE is deliberately NOT "no session": it logs and returns {},
|
|
394
|
+
# so the id is still adopted. Reading an outage as "unknown" would rotate
|
|
395
|
+
# every id and log the entire userbase out on a single Redis blip — the
|
|
396
|
+
# opposite of the log-loud + degrade policy below.
|
|
397
|
+
def existing_session_data(session_id)
|
|
398
|
+
return nil if degraded?
|
|
399
|
+
|
|
400
|
+
data = @handler.read(session_id)
|
|
401
|
+
data.nil? || (data.respond_to?(:empty?) && data.empty?) ? nil : data
|
|
402
|
+
rescue StandardError => e
|
|
403
|
+
log_backend_error("read", e)
|
|
404
|
+
raise if @strict
|
|
405
|
+
{}
|
|
252
406
|
end
|
|
253
407
|
|
|
254
408
|
# ── Backend-failure policy (parity with Python's Session boundary) ──
|
|
@@ -263,7 +417,19 @@ module Tina4
|
|
|
263
417
|
# raising) is NOT a failure and logs nothing. TINA4_SESSION_STRICT=true
|
|
264
418
|
# re-raises instead of degrading.
|
|
265
419
|
|
|
420
|
+
# True when handler construction failed and this session is in-memory only
|
|
421
|
+
# (see #initialize). Every store operation below short-circuits to its
|
|
422
|
+
# degraded answer WITHOUT logging: the outage was already logged once, at
|
|
423
|
+
# the point it actually happened, and re-logging the same fact on every
|
|
424
|
+
# read/write would multiply one dead backend into a line per operation and
|
|
425
|
+
# bury it - the same blindness this whole policy exists to cure.
|
|
426
|
+
def degraded?
|
|
427
|
+
@handler.nil?
|
|
428
|
+
end
|
|
429
|
+
|
|
266
430
|
def safe_read(session_id)
|
|
431
|
+
return {} if degraded?
|
|
432
|
+
|
|
267
433
|
existing = @handler.read(session_id)
|
|
268
434
|
existing || {}
|
|
269
435
|
rescue StandardError => e
|
|
@@ -273,6 +439,8 @@ module Tina4
|
|
|
273
439
|
end
|
|
274
440
|
|
|
275
441
|
def safe_write(session_id, data, ttl = nil)
|
|
442
|
+
return false if degraded?
|
|
443
|
+
|
|
276
444
|
if ttl
|
|
277
445
|
@handler.write(session_id, data, ttl)
|
|
278
446
|
else
|
|
@@ -286,6 +454,8 @@ module Tina4
|
|
|
286
454
|
end
|
|
287
455
|
|
|
288
456
|
def safe_destroy(session_id)
|
|
457
|
+
return false if degraded?
|
|
458
|
+
|
|
289
459
|
@handler.destroy(session_id)
|
|
290
460
|
true
|
|
291
461
|
rescue StandardError => e
|
|
@@ -295,14 +465,48 @@ module Tina4
|
|
|
295
465
|
end
|
|
296
466
|
|
|
297
467
|
# Single source of the backend-failure log line. Names the operation and
|
|
298
|
-
# the concrete handler class so ops can see WHICH backend failed.
|
|
468
|
+
# the concrete handler class so ops can see WHICH backend failed. When
|
|
469
|
+
# CONSTRUCTION is what failed there is no handler yet, so it falls back to
|
|
470
|
+
# the CONFIGURED backend name - which is the thing the operator has to fix
|
|
471
|
+
# ("redsi", "database"), and strictly more useful than "NilClass".
|
|
299
472
|
def log_backend_error(operation, error)
|
|
300
|
-
handler_class = @handler.class.name
|
|
473
|
+
handler_class = @handler ? @handler.class.name : @options[:handler].to_s
|
|
301
474
|
Tina4::Log.error("Session #{operation} failed (#{handler_class}): #{error.message}")
|
|
302
475
|
rescue StandardError
|
|
303
476
|
warn("Session #{operation} failed: #{error.message}")
|
|
304
477
|
end
|
|
305
478
|
|
|
479
|
+
# Every accepted backend name, aliases included. Byte-identical membership in
|
|
480
|
+
# all four frameworks. Written once here so the case below and the error
|
|
481
|
+
# message can never disagree.
|
|
482
|
+
VALID_BACKENDS = %w[
|
|
483
|
+
file filesystem
|
|
484
|
+
redis
|
|
485
|
+
valkey
|
|
486
|
+
mongodb mongo
|
|
487
|
+
memcached memcache
|
|
488
|
+
database db
|
|
489
|
+
].freeze
|
|
490
|
+
|
|
491
|
+
# Canonical name of each backend, for the error message. Listing every alias
|
|
492
|
+
# would make it longer without making it clearer.
|
|
493
|
+
CANONICAL_BACKENDS = %w[file redis valkey mongodb memcached database].freeze
|
|
494
|
+
|
|
495
|
+
# Reject a backend name that is not a known backend.
|
|
496
|
+
#
|
|
497
|
+
# Never sees a blank name: create_handler normalises blank to "file" first,
|
|
498
|
+
# in one place. Blank must NOT be an error - an env var set to "" is a SET
|
|
499
|
+
# variable, so rejecting it would break every deployment that clears the var
|
|
500
|
+
# to fall back to the default.
|
|
501
|
+
def validate_backend!(name)
|
|
502
|
+
return if VALID_BACKENDS.include?(name)
|
|
503
|
+
|
|
504
|
+
raise ArgumentError,
|
|
505
|
+
"Unknown session backend \"#{name}\". " \
|
|
506
|
+
"Valid backends: #{CANONICAL_BACKENDS.join(', ')}. " \
|
|
507
|
+
"Leave TINA4_SESSION_BACKEND unset for the file default."
|
|
508
|
+
end
|
|
509
|
+
|
|
306
510
|
# The configured TINA4_SESSION_BACKEND name, or nil when unset/blank (so the
|
|
307
511
|
# caller keeps the DEFAULT_OPTIONS handler). The value is normalised at
|
|
308
512
|
# dispatch in #create_handler, not here.
|
|
@@ -313,13 +517,23 @@ module Tina4
|
|
|
313
517
|
|
|
314
518
|
# Build the storage handler for the resolved backend name.
|
|
315
519
|
#
|
|
316
|
-
# The accepted names
|
|
520
|
+
# The accepted names - and the aliases - mirror Python's
|
|
317
521
|
# Session._resolve_handler exactly: file|filesystem, redis, valkey,
|
|
318
|
-
# mongodb|mongo, database|db. The name is normalised
|
|
319
|
-
# "Redis" / " mongodb " from a .env line resolve
|
|
320
|
-
#
|
|
522
|
+
# mongodb|mongo, memcached|memcache, database|db. The name is normalised
|
|
523
|
+
# (downcase + strip) so "Redis" / " mongodb " from a .env line resolve.
|
|
524
|
+
#
|
|
525
|
+
# An UNKNOWN value RAISES. It used to fall back to the file handler
|
|
526
|
+
# silently, and the comment here described that as correct parity with
|
|
527
|
+
# Python - it was, and both were wrong. A typo in TINA4_SESSION_BACKEND
|
|
528
|
+
# ("redsi") produced a running app writing sessions to local disk while the
|
|
529
|
+
# operator believed they were in Redis: nothing logged, nothing failed, and
|
|
530
|
+
# the symptom surfaced later as users being logged out whenever a request
|
|
531
|
+
# landed on another instance.
|
|
321
532
|
def create_handler
|
|
322
|
-
|
|
533
|
+
name = @options[:handler].to_s.downcase.strip
|
|
534
|
+
name = "file" if name.empty?
|
|
535
|
+
validate_backend!(name)
|
|
536
|
+
case name.to_sym
|
|
323
537
|
when :file, :filesystem
|
|
324
538
|
Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
|
|
325
539
|
when :redis
|
|
@@ -328,6 +542,8 @@ module Tina4
|
|
|
328
542
|
Tina4::SessionHandlers::MongoHandler.new(@options[:handler_options])
|
|
329
543
|
when :valkey
|
|
330
544
|
Tina4::SessionHandlers::ValkeyHandler.new(@options[:handler_options])
|
|
545
|
+
when :memcached, :memcache
|
|
546
|
+
Tina4::SessionHandlers::MemcachedHandler.new(@options[:handler_options])
|
|
331
547
|
when :database, :db
|
|
332
548
|
# Parity with Python: the database backend "uses whatever DB is
|
|
333
549
|
# connected", so reuse the single ORM resolver (named binding → global
|
|
@@ -339,7 +555,14 @@ module Tina4
|
|
|
339
555
|
{ db: Tina4::ORM.db }.merge(@options[:handler_options] || {})
|
|
340
556
|
)
|
|
341
557
|
else
|
|
342
|
-
|
|
558
|
+
# Unreachable for a user's typo - validate_backend! already rejected it.
|
|
559
|
+
# Only a name that IS in VALID_BACKENDS but has no branch above can land
|
|
560
|
+
# here, which is a bug in this method rather than a configuration error,
|
|
561
|
+
# so it must not be swallowed into a file handler either.
|
|
562
|
+
raise ArgumentError,
|
|
563
|
+
"Session backend #{name.inspect} is listed in VALID_BACKENDS but " \
|
|
564
|
+
"has no handler branch. This is a framework bug, not a " \
|
|
565
|
+
"configuration error."
|
|
343
566
|
end
|
|
344
567
|
end
|
|
345
568
|
end
|
|
@@ -7,22 +7,120 @@ module Tina4
|
|
|
7
7
|
class DatabaseHandler
|
|
8
8
|
TABLE_NAME = "tina4_session"
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
# CREATE TABLE per engine. This is the ONLY genuinely per-engine SQL in
|
|
11
|
+
# this file - every other statement is written once with `?` placeholders
|
|
12
|
+
# and rewritten for the driver by the Database layer.
|
|
13
|
+
#
|
|
14
|
+
# The COLUMNS are identical on every engine (session_id, data,
|
|
15
|
+
# expires_at) because the table is a CROSS-FRAMEWORK CONTRACT: a
|
|
16
|
+
# tina4_session table written by tina4-python must be readable by
|
|
17
|
+
# tina4-ruby. Only the type spellings and the "create it only if absent"
|
|
18
|
+
# idiom differ. Ported from the Node reference under ADR-0004
|
|
19
|
+
# (tina4-nodejs/packages/core/src/sessionHandlers/databaseHandler.ts).
|
|
20
|
+
#
|
|
21
|
+
# WHAT WAS WRONG. This used to be ONE generic string for every engine,
|
|
22
|
+
# opening with CREATE TABLE IF NOT EXISTS. That clause is not T-SQL, so
|
|
23
|
+
# the whole statement was a SYNTAX ERROR on SQL Server and the database
|
|
24
|
+
# session backend did not work on MSSQL AT ALL - the identical defect
|
|
25
|
+
# measured and fixed in PHP. Firebird rejected the same string twice
|
|
26
|
+
# over: it has no IF NOT EXISTS clause AND no TEXT type.
|
|
27
|
+
CREATE_TABLE_SQL = {
|
|
28
|
+
# Behaviour-identical to the generic DDL this replaces, so an EXISTING
|
|
29
|
+
# deployment is untouched. IF NOT EXISTS makes the statement a no-op
|
|
30
|
+
# against a table that is already there, so a live tina4_session keeps
|
|
31
|
+
# exactly the columns it was created with; and on a FRESH database the
|
|
32
|
+
# storage classes are the same ones the old spelling produced, because
|
|
33
|
+
# SQLite resolves VARCHAR(255) to TEXT affinity and DOUBLE PRECISION to
|
|
34
|
+
# REAL affinity. Only the declared type string in sqlite_master
|
|
35
|
+
# changes, and nothing reads that.
|
|
36
|
+
"sqlite" => <<~SQL,
|
|
37
|
+
CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
|
|
38
|
+
session_id TEXT PRIMARY KEY,
|
|
39
|
+
data TEXT NOT NULL,
|
|
40
|
+
expires_at REAL NOT NULL
|
|
41
|
+
)
|
|
42
|
+
SQL
|
|
43
|
+
"postgres" => <<~SQL,
|
|
44
|
+
CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
|
|
45
|
+
session_id VARCHAR(255) PRIMARY KEY,
|
|
46
|
+
data TEXT NOT NULL,
|
|
47
|
+
expires_at DOUBLE PRECISION NOT NULL
|
|
48
|
+
)
|
|
49
|
+
SQL
|
|
50
|
+
"mysql" => <<~SQL,
|
|
51
|
+
CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
|
|
52
|
+
session_id VARCHAR(255) PRIMARY KEY,
|
|
53
|
+
data TEXT NOT NULL,
|
|
54
|
+
expires_at DOUBLE NOT NULL
|
|
55
|
+
)
|
|
56
|
+
SQL
|
|
57
|
+
# T-SQL has no CREATE TABLE IF NOT EXISTS. Node guards this statement
|
|
58
|
+
# with `IF OBJECT_ID(N'tina4_session', N'U') IS NULL`; that guard is
|
|
59
|
+
# deliberately NOT carried over, because it is CHECK-THEN-ACT and has a
|
|
60
|
+
# window - two connections can both see NULL and both CREATE, and the
|
|
61
|
+
# loser gets `Msg 2714: There is already an object named
|
|
62
|
+
# 'tina4_session'`, measured on live SQL Server. This takes Node's
|
|
63
|
+
# TYPES and pairs them with PHP's RESCUE in ensure_table below, which
|
|
64
|
+
# is strictly better than either alone: Node's types make the statement
|
|
65
|
+
# legal on this engine, and PHP's rescue closes the window Node's
|
|
66
|
+
# catalog check leaves open.
|
|
67
|
+
"mssql" => <<~SQL,
|
|
68
|
+
CREATE TABLE #{TABLE_NAME} (
|
|
69
|
+
session_id NVARCHAR(255) NOT NULL PRIMARY KEY,
|
|
70
|
+
data NVARCHAR(MAX) NOT NULL,
|
|
71
|
+
expires_at FLOAT NOT NULL
|
|
72
|
+
)
|
|
73
|
+
SQL
|
|
74
|
+
# Firebird has neither IF NOT EXISTS nor a TEXT type, so the catalog
|
|
75
|
+
# check goes in an EXECUTE BLOCK and the payload is a VARCHAR. That
|
|
76
|
+
# caps a session payload at 8191 characters ON THIS ENGINE ALONE, which
|
|
77
|
+
# is the deliberate price of not using BLOB SUB_TYPE TEXT: a driver
|
|
78
|
+
# hands a blob back as a reader rather than a string, which the read
|
|
79
|
+
# path here would not understand.
|
|
80
|
+
#
|
|
81
|
+
# VERIFIED AT THE SQL LEVEL ONLY (Firebird 5.0.4, via isql, measured on
|
|
82
|
+
# the lab container): CREATE TABLE IF NOT EXISTS fails -104 "Token
|
|
83
|
+
# unknown ... NOT", a TEXT column fails -607 "Specified domain or
|
|
84
|
+
# source column TEXT does not exist", DOUBLE PRECISION is accepted, and
|
|
85
|
+
# this EXECUTE BLOCK creates the table and is idempotent on a second
|
|
86
|
+
# run. That idempotence is check-then-act inside one block, so it is
|
|
87
|
+
# NOT a race guard - a bare CREATE with the table present gives
|
|
88
|
+
# SQLSTATE 42S01, so the rescue below is required here exactly as on
|
|
89
|
+
# mssql.
|
|
90
|
+
"firebird" => <<~SQL
|
|
91
|
+
EXECUTE BLOCK AS BEGIN
|
|
92
|
+
IF (NOT EXISTS(SELECT 1 FROM RDB$RELATIONS WHERE RDB$RELATION_NAME = '#{TABLE_NAME.upcase}')) THEN
|
|
93
|
+
EXECUTE STATEMENT 'CREATE TABLE #{TABLE_NAME.upcase} (SESSION_ID VARCHAR(255) NOT NULL PRIMARY KEY, DATA VARCHAR(8191) NOT NULL, EXPIRES_AT DOUBLE PRECISION NOT NULL)';
|
|
94
|
+
END
|
|
95
|
+
SQL
|
|
96
|
+
}.freeze
|
|
17
97
|
|
|
98
|
+
# NO NETWORK I/O IN A CONSTRUCTOR (ADR-0021, session_contract.json #4).
|
|
99
|
+
# Both lines this constructor used to run were real traffic:
|
|
100
|
+
#
|
|
101
|
+
# Tina4::Database.new(...) - #initialize ends in `connect` for a
|
|
102
|
+
# single-connection database (database.rb:405), so the driver DIALS.
|
|
103
|
+
# MEASURED against a real counting TCP listener: 1 accepted connection.
|
|
104
|
+
# ensure_table - a CREATE TABLE IF NOT EXISTS, real DDL on
|
|
105
|
+
# that connection.
|
|
106
|
+
#
|
|
107
|
+
# And because the request path builds a Session per request, that ran on
|
|
108
|
+
# EVERY request. Both sat OUTSIDE the log-loud-and-degrade policy, so an
|
|
109
|
+
# unreachable database took the app down at construction instead of
|
|
110
|
+
# degrading per request as designed. Connection and table are now resolved
|
|
111
|
+
# on FIRST USE, inside that policy.
|
|
18
112
|
def initialize(options = {})
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
113
|
+
# TINA4_SESSION_TTL reaches every backend (ADR-0024); was a hard-coded
|
|
114
|
+
# 86400. 3600 matches Python (the master), PHP and Node.
|
|
115
|
+
@ttl = (options[:ttl] || ENV["TINA4_SESSION_TTL"] || 3600).to_i
|
|
116
|
+
@db_option = options[:db]
|
|
117
|
+
@db = nil
|
|
118
|
+
@table_ready = false
|
|
22
119
|
end
|
|
23
120
|
|
|
24
121
|
def read(session_id)
|
|
25
|
-
|
|
122
|
+
ensure_table
|
|
123
|
+
row = db.fetch_one("SELECT data, expires_at FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
|
|
26
124
|
return nil unless row
|
|
27
125
|
|
|
28
126
|
expires_at = (row[:expires_at] || row["expires_at"]).to_f
|
|
@@ -36,36 +134,103 @@ module Tina4
|
|
|
36
134
|
nil
|
|
37
135
|
end
|
|
38
136
|
|
|
39
|
-
|
|
40
|
-
|
|
137
|
+
# Write session data. A per-call +ttl+ WINS over the handler default; 0 means
|
|
138
|
+
# never expires and is stored as the 0 that read guards out.
|
|
139
|
+
#
|
|
140
|
+
# @param session_id [String] the session id
|
|
141
|
+
# @param data [Hash] the payload to store
|
|
142
|
+
# @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
|
|
143
|
+
def write(session_id, data, ttl = 0)
|
|
144
|
+
ensure_table
|
|
145
|
+
effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
|
|
146
|
+
expires_at = effective_ttl.positive? ? Time.now.to_f + effective_ttl : 0.0
|
|
41
147
|
json_data = JSON.generate(data)
|
|
42
148
|
|
|
43
|
-
existing =
|
|
149
|
+
existing = db.fetch_one("SELECT session_id FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
|
|
44
150
|
if existing
|
|
45
|
-
|
|
151
|
+
db.execute("UPDATE #{TABLE_NAME} SET data = ?, expires_at = ? WHERE session_id = ?", [json_data, expires_at, session_id])
|
|
46
152
|
else
|
|
47
|
-
|
|
153
|
+
db.execute("INSERT INTO #{TABLE_NAME} (session_id, data, expires_at) VALUES (?, ?, ?)", [session_id, json_data, expires_at])
|
|
48
154
|
end
|
|
49
155
|
end
|
|
50
156
|
|
|
51
157
|
def destroy(session_id)
|
|
52
|
-
|
|
158
|
+
ensure_table
|
|
159
|
+
db.execute("DELETE FROM #{TABLE_NAME} WHERE session_id = ?", [session_id])
|
|
53
160
|
end
|
|
54
161
|
|
|
55
162
|
def cleanup
|
|
56
|
-
|
|
163
|
+
ensure_table
|
|
164
|
+
db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f])
|
|
57
165
|
end
|
|
58
166
|
|
|
59
167
|
# Garbage-collect expired sessions. Matches the Python interface.
|
|
60
168
|
# @param max_age [Integer] maximum session age in seconds (unused — expiry is absolute)
|
|
61
169
|
def gc(max_age)
|
|
62
|
-
|
|
170
|
+
ensure_table
|
|
171
|
+
db.execute("DELETE FROM #{TABLE_NAME} WHERE expires_at > 0 AND expires_at < ?", [Time.now.to_f])
|
|
63
172
|
end
|
|
64
173
|
|
|
65
174
|
private
|
|
66
175
|
|
|
176
|
+
# The database connection, resolved on FIRST USE. An explicit :db option
|
|
177
|
+
# still wins; only the env-derived fallback has to be built, and building
|
|
178
|
+
# it CONNECTS (see #initialize), which is why it cannot happen earlier.
|
|
179
|
+
def db
|
|
180
|
+
@db ||= (@db_option || Tina4::Database.new(ENV["TINA4_DATABASE_URL"]))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The CREATE TABLE this connection's engine understands.
|
|
184
|
+
#
|
|
185
|
+
# Tina4::Database#driver_name is the public accessor and it already holds
|
|
186
|
+
# the ALIAS-NORMALISED engine key ("postgres", not "postgresql"), the same
|
|
187
|
+
# value used to pick the driver class, so nothing here re-parses the
|
|
188
|
+
# connection string. An injected :db is an application-supplied object and
|
|
189
|
+
# therefore a trust boundary: one that cannot name its engine falls back
|
|
190
|
+
# to the generic ANSI shape, which is byte-for-byte what every engine used
|
|
191
|
+
# to get, so an app that duck-types a database is no worse off than before.
|
|
192
|
+
def create_table_sql
|
|
193
|
+
engine = db.respond_to?(:driver_name) ? db.driver_name.to_s : ""
|
|
194
|
+
CREATE_TABLE_SQL.fetch(engine, CREATE_TABLE_SQL["postgres"])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Create the session table once, on first use rather than at construction.
|
|
198
|
+
# The flag is set BEFORE the DDL so an unreachable database is not
|
|
199
|
+
# re-probed on every call - the same ordering the Python master uses for
|
|
200
|
+
# _table_ready.
|
|
201
|
+
#
|
|
202
|
+
# THE CONCURRENT FIRST-USE RACE. Two workers booting together both reach
|
|
203
|
+
# this line and both issue the CREATE; one of them loses. IF NOT EXISTS
|
|
204
|
+
# settles that ENGINE-SIDE on sqlite, postgres and mysql, but T-SQL has no
|
|
205
|
+
# such clause and Firebird has none either, so on those two the loser
|
|
206
|
+
# raises and would take down the subsystem that decides whether anyone is
|
|
207
|
+
# logged in.
|
|
208
|
+
#
|
|
209
|
+
# The rescue below is the guard, ported from PHP. It RE-CHECKS whether the
|
|
210
|
+
# table exists rather than parsing the error message, because every engine
|
|
211
|
+
# spells "already exists" differently ("There is already an object named",
|
|
212
|
+
# "already exists", SQLSTATE 42S01) and a string match would rot the first
|
|
213
|
+
# time an engine reworded itself or ran under another locale.
|
|
67
214
|
def ensure_table
|
|
68
|
-
@
|
|
215
|
+
return if @table_ready
|
|
216
|
+
|
|
217
|
+
@table_ready = true
|
|
218
|
+
begin
|
|
219
|
+
db.execute(create_table_sql)
|
|
220
|
+
rescue StandardError
|
|
221
|
+
# A failed statement leaves PostgreSQL's transaction ABORTED, so
|
|
222
|
+
# without this the re-check would fail for the wrong reason and report
|
|
223
|
+
# a missing table that is right there. Best effort: an engine with no
|
|
224
|
+
# open transaction is entitled to object to being rolled back.
|
|
225
|
+
begin
|
|
226
|
+
db.rollback
|
|
227
|
+
rescue StandardError
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
# Somebody else created it - that is the race, and it is a success.
|
|
231
|
+
# Anything else is a real failure and is re-raised untouched.
|
|
232
|
+
raise unless db.table_exists?(TABLE_NAME)
|
|
233
|
+
end
|
|
69
234
|
end
|
|
70
235
|
end
|
|
71
236
|
end
|