tina4ruby 3.13.93 → 3.13.96
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 +35 -43
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +458 -48
- 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/dev_mailbox.rb +5 -1
- 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 +25 -3
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/frond.rb +102 -10
- 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 +317 -82
- 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 +194 -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 +243 -29
- 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 +301 -38
- 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)
|
|
@@ -100,7 +180,7 @@ module Tina4
|
|
|
100
180
|
# a later save can retry. Returns true on a successful (or no-op) write.
|
|
101
181
|
def save
|
|
102
182
|
return true unless @modified
|
|
103
|
-
if safe_write(@id, @data)
|
|
183
|
+
if safe_write(@id, @data, @ttl)
|
|
104
184
|
@modified = false
|
|
105
185
|
true
|
|
106
186
|
else
|
|
@@ -115,9 +195,24 @@ module Tina4
|
|
|
115
195
|
@data = {}
|
|
116
196
|
end
|
|
117
197
|
|
|
118
|
-
# Get a session value with optional default
|
|
198
|
+
# Get a session value with optional default.
|
|
199
|
+
#
|
|
200
|
+
# The default is returned for an ABSENT key, never for a stored FALSE. This
|
|
201
|
+
# was `@data[key.to_s] || default`, which handed back the caller's default
|
|
202
|
+
# for any falsy stored value — so a feature flag stored as false read back
|
|
203
|
+
# as the caller's `true` default. Python (`dict.get`), PHP (`??`) and Node
|
|
204
|
+
# (`??`) all return the stored false, so Ruby was the 1-of-4 outlier.
|
|
205
|
+
#
|
|
206
|
+
# Deliberately the `nil?` form and NOT `@data.key?(k) ? @data[k] : default`:
|
|
207
|
+
# both fix the false case, but the key? form would ALSO flip a stored nil
|
|
208
|
+
# from the default to nil. Ruby currently agrees with PHP and Node there
|
|
209
|
+
# (stored nil -> default) and only Python disagrees (stored None -> None),
|
|
210
|
+
# so changing it is a cross-framework decision, not a side effect of this
|
|
211
|
+
# fix. This form is exactly PHP's `??` and Node's `??`. Same idiom as
|
|
212
|
+
# #get_flash below.
|
|
119
213
|
def get(key, default = nil)
|
|
120
|
-
@data[key.to_s]
|
|
214
|
+
value = @data[key.to_s]
|
|
215
|
+
value.nil? ? default : value
|
|
121
216
|
end
|
|
122
217
|
|
|
123
218
|
# Set a session value
|
|
@@ -171,18 +266,15 @@ module Tina4
|
|
|
171
266
|
@id
|
|
172
267
|
end
|
|
173
268
|
|
|
174
|
-
# Start or resume a session.
|
|
175
|
-
#
|
|
269
|
+
# Start or resume a session. Returns the session ID string.
|
|
270
|
+
#
|
|
271
|
+
# session_id is UNTRUSTED, so it goes through #adopt_or_mint: it is resumed
|
|
272
|
+
# only when it is a well-formed opaque id AND one the backend already holds a
|
|
273
|
+
# session under (strict mode). Otherwise a genuinely NEW session is started
|
|
274
|
+
# under a fresh SecureRandom.hex(32). A session already in flight keeps both
|
|
275
|
+
# its id and its data.
|
|
176
276
|
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
|
|
277
|
+
adopt_or_mint(session_id)
|
|
186
278
|
end
|
|
187
279
|
|
|
188
280
|
# Returns the current session ID string.
|
|
@@ -238,6 +330,9 @@ module Tina4
|
|
|
238
330
|
|
|
239
331
|
private
|
|
240
332
|
|
|
333
|
+
# The session id carried on the incoming cookie, or nil. A pure parser —
|
|
334
|
+
# every caller funnels through #adopt_or_mint, which is where the value is
|
|
335
|
+
# judged.
|
|
241
336
|
def extract_session_id(env)
|
|
242
337
|
cookie_str = env["HTTP_COOKIE"] || ""
|
|
243
338
|
cookie_str.split(";").each do |pair|
|
|
@@ -247,8 +342,58 @@ module Tina4
|
|
|
247
342
|
nil
|
|
248
343
|
end
|
|
249
344
|
|
|
250
|
-
|
|
251
|
-
|
|
345
|
+
# Adopt session_id, or mint a fresh one — the SINGLE decision both entry
|
|
346
|
+
# points (the constructor's cookie path and #start) go through, so neither
|
|
347
|
+
# can drift from the rule or skip it.
|
|
348
|
+
#
|
|
349
|
+
# STRICT SESSION MODE (OWASP; PHP's session.use_strict_mode=1). session_id is
|
|
350
|
+
# UNTRUSTED: it comes from the session cookie or a caller, both of which the
|
|
351
|
+
# client controls. It is adopted ONLY when it is BOTH
|
|
352
|
+
# (a) a well-formed opaque id (see valid_session_id?), and
|
|
353
|
+
# (b) an id the backend already holds a session under.
|
|
354
|
+
# Anything else — malformed, or well-formed but never issued — is DISCARDED
|
|
355
|
+
# and a fresh SecureRandom.hex(32) minted. Adopting either one is session
|
|
356
|
+
# fixation: an attacker plants a cookie, the victim logs in under it, and the
|
|
357
|
+
# attacker already holds the authenticated session id. A malformed id also
|
|
358
|
+
# used to steer a filesystem path.
|
|
359
|
+
#
|
|
360
|
+
# Sets @id/@data/@modified and returns the resolved id.
|
|
361
|
+
def adopt_or_mint(session_id)
|
|
362
|
+
session_id = nil unless self.class.valid_session_id?(session_id)
|
|
363
|
+
data = session_id.nil? ? nil : existing_session_data(session_id)
|
|
364
|
+
if data.nil?
|
|
365
|
+
session_id = SecureRandom.hex(32)
|
|
366
|
+
data = {}
|
|
367
|
+
end
|
|
368
|
+
@id = session_id
|
|
369
|
+
@data = data
|
|
370
|
+
@modified = false
|
|
371
|
+
@id
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# The stored data for session_id when the backend HOLDS a session under it,
|
|
375
|
+
# else nil — strict mode's signal to mint a fresh id rather than adopt one
|
|
376
|
+
# the client chose.
|
|
377
|
+
#
|
|
378
|
+
# nil AND empty both count as "no session": the handlers disagree (file,
|
|
379
|
+
# redis, valkey, mongo and database return nil for a missing session, while
|
|
380
|
+
# memcached returns {}), and strict mode must not silently no-op on one
|
|
381
|
+
# backend. An empty STORED session cannot arise anyway — #save is a no-op
|
|
382
|
+
# until something is actually written.
|
|
383
|
+
#
|
|
384
|
+
# A backend FAILURE is deliberately NOT "no session": it logs and returns {},
|
|
385
|
+
# so the id is still adopted. Reading an outage as "unknown" would rotate
|
|
386
|
+
# every id and log the entire userbase out on a single Redis blip — the
|
|
387
|
+
# opposite of the log-loud + degrade policy below.
|
|
388
|
+
def existing_session_data(session_id)
|
|
389
|
+
return nil if degraded?
|
|
390
|
+
|
|
391
|
+
data = @handler.read(session_id)
|
|
392
|
+
data.nil? || (data.respond_to?(:empty?) && data.empty?) ? nil : data
|
|
393
|
+
rescue StandardError => e
|
|
394
|
+
log_backend_error("read", e)
|
|
395
|
+
raise if @strict
|
|
396
|
+
{}
|
|
252
397
|
end
|
|
253
398
|
|
|
254
399
|
# ── Backend-failure policy (parity with Python's Session boundary) ──
|
|
@@ -263,7 +408,19 @@ module Tina4
|
|
|
263
408
|
# raising) is NOT a failure and logs nothing. TINA4_SESSION_STRICT=true
|
|
264
409
|
# re-raises instead of degrading.
|
|
265
410
|
|
|
411
|
+
# True when handler construction failed and this session is in-memory only
|
|
412
|
+
# (see #initialize). Every store operation below short-circuits to its
|
|
413
|
+
# degraded answer WITHOUT logging: the outage was already logged once, at
|
|
414
|
+
# the point it actually happened, and re-logging the same fact on every
|
|
415
|
+
# read/write would multiply one dead backend into a line per operation and
|
|
416
|
+
# bury it - the same blindness this whole policy exists to cure.
|
|
417
|
+
def degraded?
|
|
418
|
+
@handler.nil?
|
|
419
|
+
end
|
|
420
|
+
|
|
266
421
|
def safe_read(session_id)
|
|
422
|
+
return {} if degraded?
|
|
423
|
+
|
|
267
424
|
existing = @handler.read(session_id)
|
|
268
425
|
existing || {}
|
|
269
426
|
rescue StandardError => e
|
|
@@ -273,6 +430,8 @@ module Tina4
|
|
|
273
430
|
end
|
|
274
431
|
|
|
275
432
|
def safe_write(session_id, data, ttl = nil)
|
|
433
|
+
return false if degraded?
|
|
434
|
+
|
|
276
435
|
if ttl
|
|
277
436
|
@handler.write(session_id, data, ttl)
|
|
278
437
|
else
|
|
@@ -286,6 +445,8 @@ module Tina4
|
|
|
286
445
|
end
|
|
287
446
|
|
|
288
447
|
def safe_destroy(session_id)
|
|
448
|
+
return false if degraded?
|
|
449
|
+
|
|
289
450
|
@handler.destroy(session_id)
|
|
290
451
|
true
|
|
291
452
|
rescue StandardError => e
|
|
@@ -295,14 +456,48 @@ module Tina4
|
|
|
295
456
|
end
|
|
296
457
|
|
|
297
458
|
# Single source of the backend-failure log line. Names the operation and
|
|
298
|
-
# the concrete handler class so ops can see WHICH backend failed.
|
|
459
|
+
# the concrete handler class so ops can see WHICH backend failed. When
|
|
460
|
+
# CONSTRUCTION is what failed there is no handler yet, so it falls back to
|
|
461
|
+
# the CONFIGURED backend name - which is the thing the operator has to fix
|
|
462
|
+
# ("redsi", "database"), and strictly more useful than "NilClass".
|
|
299
463
|
def log_backend_error(operation, error)
|
|
300
|
-
handler_class = @handler.class.name
|
|
464
|
+
handler_class = @handler ? @handler.class.name : @options[:handler].to_s
|
|
301
465
|
Tina4::Log.error("Session #{operation} failed (#{handler_class}): #{error.message}")
|
|
302
466
|
rescue StandardError
|
|
303
467
|
warn("Session #{operation} failed: #{error.message}")
|
|
304
468
|
end
|
|
305
469
|
|
|
470
|
+
# Every accepted backend name, aliases included. Byte-identical membership in
|
|
471
|
+
# all four frameworks. Written once here so the case below and the error
|
|
472
|
+
# message can never disagree.
|
|
473
|
+
VALID_BACKENDS = %w[
|
|
474
|
+
file filesystem
|
|
475
|
+
redis
|
|
476
|
+
valkey
|
|
477
|
+
mongodb mongo
|
|
478
|
+
memcached memcache
|
|
479
|
+
database db
|
|
480
|
+
].freeze
|
|
481
|
+
|
|
482
|
+
# Canonical name of each backend, for the error message. Listing every alias
|
|
483
|
+
# would make it longer without making it clearer.
|
|
484
|
+
CANONICAL_BACKENDS = %w[file redis valkey mongodb memcached database].freeze
|
|
485
|
+
|
|
486
|
+
# Reject a backend name that is not a known backend.
|
|
487
|
+
#
|
|
488
|
+
# Never sees a blank name: create_handler normalises blank to "file" first,
|
|
489
|
+
# in one place. Blank must NOT be an error - an env var set to "" is a SET
|
|
490
|
+
# variable, so rejecting it would break every deployment that clears the var
|
|
491
|
+
# to fall back to the default.
|
|
492
|
+
def validate_backend!(name)
|
|
493
|
+
return if VALID_BACKENDS.include?(name)
|
|
494
|
+
|
|
495
|
+
raise ArgumentError,
|
|
496
|
+
"Unknown session backend \"#{name}\". " \
|
|
497
|
+
"Valid backends: #{CANONICAL_BACKENDS.join(', ')}. " \
|
|
498
|
+
"Leave TINA4_SESSION_BACKEND unset for the file default."
|
|
499
|
+
end
|
|
500
|
+
|
|
306
501
|
# The configured TINA4_SESSION_BACKEND name, or nil when unset/blank (so the
|
|
307
502
|
# caller keeps the DEFAULT_OPTIONS handler). The value is normalised at
|
|
308
503
|
# dispatch in #create_handler, not here.
|
|
@@ -313,13 +508,23 @@ module Tina4
|
|
|
313
508
|
|
|
314
509
|
# Build the storage handler for the resolved backend name.
|
|
315
510
|
#
|
|
316
|
-
# The accepted names
|
|
511
|
+
# The accepted names - and the aliases - mirror Python's
|
|
317
512
|
# 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
|
-
#
|
|
513
|
+
# mongodb|mongo, memcached|memcache, database|db. The name is normalised
|
|
514
|
+
# (downcase + strip) so "Redis" / " mongodb " from a .env line resolve.
|
|
515
|
+
#
|
|
516
|
+
# An UNKNOWN value RAISES. It used to fall back to the file handler
|
|
517
|
+
# silently, and the comment here described that as correct parity with
|
|
518
|
+
# Python - it was, and both were wrong. A typo in TINA4_SESSION_BACKEND
|
|
519
|
+
# ("redsi") produced a running app writing sessions to local disk while the
|
|
520
|
+
# operator believed they were in Redis: nothing logged, nothing failed, and
|
|
521
|
+
# the symptom surfaced later as users being logged out whenever a request
|
|
522
|
+
# landed on another instance.
|
|
321
523
|
def create_handler
|
|
322
|
-
|
|
524
|
+
name = @options[:handler].to_s.downcase.strip
|
|
525
|
+
name = "file" if name.empty?
|
|
526
|
+
validate_backend!(name)
|
|
527
|
+
case name.to_sym
|
|
323
528
|
when :file, :filesystem
|
|
324
529
|
Tina4::SessionHandlers::FileHandler.new(@options[:handler_options])
|
|
325
530
|
when :redis
|
|
@@ -328,6 +533,8 @@ module Tina4
|
|
|
328
533
|
Tina4::SessionHandlers::MongoHandler.new(@options[:handler_options])
|
|
329
534
|
when :valkey
|
|
330
535
|
Tina4::SessionHandlers::ValkeyHandler.new(@options[:handler_options])
|
|
536
|
+
when :memcached, :memcache
|
|
537
|
+
Tina4::SessionHandlers::MemcachedHandler.new(@options[:handler_options])
|
|
331
538
|
when :database, :db
|
|
332
539
|
# Parity with Python: the database backend "uses whatever DB is
|
|
333
540
|
# connected", so reuse the single ORM resolver (named binding → global
|
|
@@ -339,7 +546,14 @@ module Tina4
|
|
|
339
546
|
{ db: Tina4::ORM.db }.merge(@options[:handler_options] || {})
|
|
340
547
|
)
|
|
341
548
|
else
|
|
342
|
-
|
|
549
|
+
# Unreachable for a user's typo - validate_backend! already rejected it.
|
|
550
|
+
# Only a name that IS in VALID_BACKENDS but has no branch above can land
|
|
551
|
+
# here, which is a bug in this method rather than a configuration error,
|
|
552
|
+
# so it must not be swallowed into a file handler either.
|
|
553
|
+
raise ArgumentError,
|
|
554
|
+
"Session backend #{name.inspect} is listed in VALID_BACKENDS but " \
|
|
555
|
+
"has no handler branch. This is a framework bug, not a " \
|
|
556
|
+
"configuration error."
|
|
343
557
|
end
|
|
344
558
|
end
|
|
345
559
|
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
|