tina4ruby 3.13.141 → 3.13.143
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 +22 -0
- data/lib/tina4/cli.rb +7 -2
- data/lib/tina4/crud.rb +61 -20
- data/lib/tina4/csp.rb +119 -0
- data/lib/tina4/error_overlay.rb +79 -43
- data/lib/tina4/frond.rb +7 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +25 -11
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +30 -10
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +34 -15
- data/lib/tina4/graphql.rb +9 -3
- data/lib/tina4/job.rb +17 -0
- data/lib/tina4/middleware.rb +16 -6
- data/lib/tina4/migration.rb +49 -41
- data/lib/tina4/queue_backends/kafka_backend.rb +12 -31
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +15 -37
- data/lib/tina4/queue_backends/republish_retry_policy.rb +35 -0
- data/lib/tina4/rack_app.rb +70 -23
- data/lib/tina4/response.rb +6 -1
- data/lib/tina4/session.rb +145 -10
- data/lib/tina4/session_handlers/redis_handler.rb +7 -51
- data/lib/tina4/session_handlers/resp_session_handler.rb +75 -0
- data/lib/tina4/session_handlers/valkey_handler.rb +6 -51
- data/lib/tina4/template.rb +11 -3
- data/lib/tina4/templates/errors/302.twig +1 -1
- data/lib/tina4/templates/errors/401.twig +2 -2
- data/lib/tina4/templates/errors/403.twig +1 -1
- data/lib/tina4/templates/errors/404.twig +1 -1
- data/lib/tina4/templates/errors/500.twig +1 -1
- data/lib/tina4/templates/errors/502.twig +2 -2
- data/lib/tina4/templates/errors/503.twig +2 -2
- data/lib/tina4/templates/errors/base.twig +4 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +30 -14
- data/lib/tina4/websocket.rb +26 -2
- data/lib/tina4/websocket_backplane.rb +16 -13
- data/lib/tina4.rb +1 -0
- metadata +5 -2
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
module QueueBackends
|
|
10
|
+
# The at-least-once fail policy (ADR-0022) shared by the broker backends
|
|
11
|
+
# whose protocol carries no delivery counter (kafka, rabbitmq). A Kafka
|
|
12
|
+
# record and an AMQP body are both immutable, so recording a failed attempt
|
|
13
|
+
# means RE-PUBLISHING a body that carries the incremented count rather than
|
|
14
|
+
# mutating the message in place.
|
|
15
|
+
#
|
|
16
|
+
# Increment attempts, record the error, dead-letter at the retry limit else
|
|
17
|
+
# re-enqueue, then ack LAST -- so a crash between the re-publish and the ack
|
|
18
|
+
# redelivers the job rather than losing it, which is the contract.
|
|
19
|
+
#
|
|
20
|
+
# The host backend must define +enqueue+, +dead_letter+, +complete+ (its
|
|
21
|
+
# terminal ack) and an +@max_retries+.
|
|
22
|
+
module RepublishRetryPolicy
|
|
23
|
+
def fail(job, error = "")
|
|
24
|
+
job.attempts += 1
|
|
25
|
+
job.error = error
|
|
26
|
+
if job.attempts >= @max_retries
|
|
27
|
+
dead_letter(job)
|
|
28
|
+
else
|
|
29
|
+
enqueue(job)
|
|
30
|
+
end
|
|
31
|
+
complete(job)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -103,6 +103,11 @@ module Tina4
|
|
|
103
103
|
# on the response whatever outcome the pipeline produced (200/404/500/413).
|
|
104
104
|
request_id = Tina4::Log.sanitize_request_id(env["HTTP_X_REQUEST_ID"]) || SecureRandom.hex(4)
|
|
105
105
|
Tina4::Log.set_request_id(request_id)
|
|
106
|
+
# One CSP nonce per request, published on the thread-local (ADR-0088) so the
|
|
107
|
+
# inline HTML body and the security middleware's CSP header name the SAME
|
|
108
|
+
# value. Cleared in `ensure`, exactly like the request id (an overlapping
|
|
109
|
+
# request on another thread never shares it).
|
|
110
|
+
Tina4::Csp.set_current_nonce(Tina4::Csp.generate_nonce)
|
|
106
111
|
|
|
107
112
|
begin
|
|
108
113
|
result = dispatch_pipeline(env)
|
|
@@ -112,8 +117,10 @@ module Tina4
|
|
|
112
117
|
# The request pipeline installs the id before its first log and
|
|
113
118
|
# clears it in `finally`/`ensure` after its last (Decision 12 /
|
|
114
119
|
# LOG-Q03), so an overlapping request on another thread can never
|
|
115
|
-
# observe a stale id from a request that already finished.
|
|
120
|
+
# observe a stale id from a request that already finished. The CSP
|
|
121
|
+
# nonce has the same lifetime.
|
|
116
122
|
Tina4::Log.clear_request_id
|
|
123
|
+
Tina4::Csp.clear_current_nonce
|
|
117
124
|
end
|
|
118
125
|
end
|
|
119
126
|
|
|
@@ -374,6 +381,10 @@ module Tina4
|
|
|
374
381
|
# zero-dependency — no vendored ~1.4MB swagger-ui-dist). Air-gapped
|
|
375
382
|
# deployments point TINA4_SWAGGER_UI_CDN at a self-hosted mirror.
|
|
376
383
|
cdn = (ENV["TINA4_SWAGGER_UI_CDN"] || "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5").sub(%r{/+\z}, "")
|
|
384
|
+
# ADR-0088: nonce the inline bootstrap <script> so it runs under the strict
|
|
385
|
+
# default CSP. The external <script src>/<link> need none ('self' allows
|
|
386
|
+
# same-origin; a CDN still needs TINA4_CSP to list its host).
|
|
387
|
+
nonce = Tina4::Csp.current_nonce
|
|
377
388
|
html = <<~HTML
|
|
378
389
|
<!DOCTYPE html>
|
|
379
390
|
<html lang="en">
|
|
@@ -385,7 +396,7 @@ module Tina4
|
|
|
385
396
|
<body>
|
|
386
397
|
<div id="swagger-ui"></div>
|
|
387
398
|
<script src="#{cdn}/swagger-ui-bundle.js"></script>
|
|
388
|
-
<script>
|
|
399
|
+
<script nonce="#{nonce}">
|
|
389
400
|
SwaggerUIBundle({ url: '/swagger/openapi.json', dom_id: '#swagger-ui' });
|
|
390
401
|
</script>
|
|
391
402
|
</body>
|
|
@@ -554,6 +565,12 @@ module Tina4
|
|
|
554
565
|
|
|
555
566
|
def render_landing_page
|
|
556
567
|
port = ENV["PORT"] || "7145"
|
|
568
|
+
# One CSP nonce for this response (ADR-0088): the <style>/<script> below
|
|
569
|
+
# carry it, and the security middleware names the SAME value in the CSP
|
|
570
|
+
# header. No inline style=/onclick= — a nonce covers a <style>/<script>
|
|
571
|
+
# ELEMENT but never a style or event-handler ATTRIBUTE, so every style
|
|
572
|
+
# lives in a class and every handler is bound with addEventListener.
|
|
573
|
+
nonce = Tina4::Csp.current_nonce
|
|
557
574
|
|
|
558
575
|
# Check deployed state for each gallery item
|
|
559
576
|
project_src = File.join(@root_dir, "src")
|
|
@@ -569,13 +586,13 @@ module Tina4
|
|
|
569
586
|
|
|
570
587
|
gallery_cards = gallery_items.map do |item|
|
|
571
588
|
deployed = File.file?(File.join(project_src, item[:file_check]))
|
|
572
|
-
deployed_badge = deployed ? '<span
|
|
589
|
+
deployed_badge = deployed ? '<span class="deployed-badge">DEPLOYED</span>' : ''
|
|
573
590
|
try_btn = if deployed
|
|
574
591
|
%(<a href="#{item[:try_url]}" class="gbtn gbtn-try" target="_blank">Try It</a>)
|
|
575
592
|
else
|
|
576
|
-
%(<button class="gbtn gbtn-deploy"
|
|
593
|
+
%(<button class="gbtn gbtn-deploy" data-gallery-deploy data-id="#{item[:id]}" data-try-url="#{item[:try_url]}">Deploy & Try</button>)
|
|
577
594
|
end
|
|
578
|
-
view_btn = %(<button class="gbtn gbtn-view"
|
|
595
|
+
view_btn = %(<button class="gbtn gbtn-view" data-gallery-view data-id="#{item[:id]}">View</button>)
|
|
579
596
|
|
|
580
597
|
<<~CARD
|
|
581
598
|
<div class="gallery-card">
|
|
@@ -584,7 +601,7 @@ module Tina4
|
|
|
584
601
|
<div class="icon">#{item[:icon]}</div>
|
|
585
602
|
<h3>#{item[:name]}</h3>
|
|
586
603
|
<p>#{item[:desc]}</p>
|
|
587
|
-
<div
|
|
604
|
+
<div class="gallery-card-actions">#{try_btn}#{view_btn}</div>
|
|
588
605
|
</div>
|
|
589
606
|
CARD
|
|
590
607
|
end.join
|
|
@@ -596,7 +613,7 @@ module Tina4
|
|
|
596
613
|
<meta charset="utf-8">
|
|
597
614
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
598
615
|
<title>Tina4Ruby</title>
|
|
599
|
-
<style>
|
|
616
|
+
<style nonce="#{nonce}">
|
|
600
617
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
601
618
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;position:relative}
|
|
602
619
|
.bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
|
|
@@ -638,6 +655,21 @@ module Tina4
|
|
|
638
655
|
.view-modal-close:hover{color:#e2e8f0}
|
|
639
656
|
@keyframes wiggle{0%{transform:rotate(0deg)}15%{transform:rotate(14deg)}30%{transform:rotate(-10deg)}45%{transform:rotate(8deg)}60%{transform:rotate(-4deg)}75%{transform:rotate(2deg)}100%{transform:rotate(0deg)}}
|
|
640
657
|
.star-wiggle{display:inline-block;transform-origin:center}
|
|
658
|
+
.tagline-sub{font-size:0.95rem;margin-top:-1rem}
|
|
659
|
+
.deployed-badge{position:absolute;top:0.75rem;right:0.75rem;background:#22c55e;color:#fff;font-size:0.65rem;padding:0.15rem 0.5rem;border-radius:0.25rem;font-weight:600}
|
|
660
|
+
.gallery-card-actions{display:flex;gap:0.5rem;margin-top:0.75rem}
|
|
661
|
+
.gallery-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem}
|
|
662
|
+
.view-modal-title{margin-bottom:1rem;color:#e2e8f0}
|
|
663
|
+
.vm-intro{color:#94a3b8;margin-bottom:1rem}
|
|
664
|
+
.vm-file-list{list-style:none;padding:0}
|
|
665
|
+
.vm-file{padding:0.25rem 0;color:#4ade80;font-family:monospace;font-size:0.85rem}
|
|
666
|
+
.vm-tryurl{color:#94a3b8;margin-top:1rem}
|
|
667
|
+
.vm-code{color:#38bdf8}
|
|
668
|
+
.tok-comment{color:#64748b}
|
|
669
|
+
.tok-kw{color:#c084fc}
|
|
670
|
+
.tok-str{color:#4ade80}
|
|
671
|
+
.tok-fn{color:#38bdf8}
|
|
672
|
+
.tok-dec{color:#fbbf24}
|
|
641
673
|
</style>
|
|
642
674
|
</head>
|
|
643
675
|
<body>
|
|
@@ -646,7 +678,7 @@ module Tina4
|
|
|
646
678
|
<img src="/images/tina4-logo-icon.webp" class="logo" alt="Tina4">
|
|
647
679
|
<h1>Tina4Ruby</h1>
|
|
648
680
|
<p class="tagline">The Intelligent Native Application 4ramework</p>
|
|
649
|
-
<p class="tagline
|
|
681
|
+
<p class="tagline tagline-sub">Simple. Fast. Human. | Built for AI. Built for you.</p>
|
|
650
682
|
<div class="actions">
|
|
651
683
|
<a href="https://tina4.com/ruby" class="btn" target="_blank">Website</a>
|
|
652
684
|
<a href="/__dev" class="btn">Dev Admin</a>
|
|
@@ -664,33 +696,32 @@ module Tina4
|
|
|
664
696
|
<div class="section">
|
|
665
697
|
<div class="card">
|
|
666
698
|
<h2>Getting Started</h2>
|
|
667
|
-
<pre class="code-block"><code><span
|
|
668
|
-
<span
|
|
699
|
+
<pre class="code-block"><code><span class="tok-comment"># app.rb</span>
|
|
700
|
+
<span class="tok-kw">require</span> <span class="tok-str">"tina4"</span>
|
|
669
701
|
|
|
670
|
-
Tina4::Router.<span
|
|
671
|
-
response.<span
|
|
672
|
-
<span
|
|
702
|
+
Tina4::Router.<span class="tok-fn">get</span>(<span class="tok-str">"/hello"</span>) <span class="tok-kw">do</span> |request, response|
|
|
703
|
+
response.<span class="tok-fn">json</span>({ <span class="tok-dec">message:</span> <span class="tok-str">"Hello World!"</span> })
|
|
704
|
+
<span class="tok-kw">end</span>
|
|
673
705
|
|
|
674
|
-
Tina4::WebServer.new(<span
|
|
706
|
+
Tina4::WebServer.new(<span class="tok-dec">port:</span> <span class="tok-fn">7145</span>).start <span class="tok-comment"># starts on port 7145</span></code></pre>
|
|
675
707
|
</div>
|
|
676
708
|
</div>
|
|
677
709
|
<div class="gallery">
|
|
678
710
|
<h2 id="gallery">Gallery</h2>
|
|
679
|
-
<div
|
|
711
|
+
<div class="gallery-grid">
|
|
680
712
|
#{gallery_cards}
|
|
681
713
|
</div>
|
|
682
714
|
</div>
|
|
683
715
|
<div class="view-modal" id="viewModal">
|
|
684
716
|
<div class="view-modal-content">
|
|
685
|
-
<button class="view-modal-close"
|
|
686
|
-
<h3 id="viewModalTitle"
|
|
717
|
+
<button class="view-modal-close" data-modal-close>×</button>
|
|
718
|
+
<h3 id="viewModalTitle" class="view-modal-title"></h3>
|
|
687
719
|
<div id="viewModalBody"></div>
|
|
688
720
|
</div>
|
|
689
721
|
</div>
|
|
690
|
-
<script>
|
|
691
|
-
function deployGallery(name, tryUrl) {
|
|
722
|
+
<script nonce="#{nonce}">
|
|
723
|
+
function deployGallery(btn, name, tryUrl) {
|
|
692
724
|
if (!confirm('Deploy the "' + name + '" gallery example into your project?')) return;
|
|
693
|
-
var btn = event.target;
|
|
694
725
|
btn.disabled = true;
|
|
695
726
|
btn.textContent = 'Deploying...';
|
|
696
727
|
fetch('/__dev/api/gallery/deploy', {
|
|
@@ -733,18 +764,34 @@ module Tina4
|
|
|
733
764
|
var title = document.getElementById('viewModalTitle');
|
|
734
765
|
var body = document.getElementById('viewModalBody');
|
|
735
766
|
title.textContent = item.name + ' — ' + item.description;
|
|
736
|
-
var html = '<p
|
|
767
|
+
var html = '<p class="vm-intro">Files that will be deployed:</p><ul class="vm-file-list">';
|
|
737
768
|
(item.files || []).forEach(function(f) {
|
|
738
|
-
html += '<li
|
|
769
|
+
html += '<li class="vm-file">src/' + f + '</li>';
|
|
739
770
|
});
|
|
740
771
|
html += '</ul>';
|
|
741
772
|
if (item.try_url) {
|
|
742
|
-
html += '<p
|
|
773
|
+
html += '<p class="vm-tryurl">Try URL: <code class="vm-code">' + item.try_url + '</code></p>';
|
|
743
774
|
}
|
|
744
775
|
body.innerHTML = html;
|
|
745
776
|
document.getElementById('viewModal').classList.add('active');
|
|
746
777
|
});
|
|
747
778
|
}
|
|
779
|
+
// CSP-clean wiring: no inline onclick handlers — bind every gallery
|
|
780
|
+
// button from its data-* attributes (a nonce covers script elements, not
|
|
781
|
+
// event-handler attributes).
|
|
782
|
+
document.querySelectorAll('[data-gallery-deploy]').forEach(function(btn){
|
|
783
|
+
btn.addEventListener('click', function(){
|
|
784
|
+
deployGallery(btn, btn.dataset.id, btn.dataset.tryUrl);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
document.querySelectorAll('[data-gallery-view]').forEach(function(btn){
|
|
788
|
+
btn.addEventListener('click', function(){ viewGallery(btn.dataset.id); });
|
|
789
|
+
});
|
|
790
|
+
document.querySelectorAll('[data-modal-close]').forEach(function(btn){
|
|
791
|
+
btn.addEventListener('click', function(){
|
|
792
|
+
document.getElementById('viewModal').classList.remove('active');
|
|
793
|
+
});
|
|
794
|
+
});
|
|
748
795
|
document.getElementById('viewModal').addEventListener('click', function(e) {
|
|
749
796
|
if (e.target === this) this.classList.remove('active');
|
|
750
797
|
});
|
data/lib/tina4/response.rb
CHANGED
|
@@ -63,7 +63,7 @@ module Tina4
|
|
|
63
63
|
TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
64
64
|
XML_CONTENT_TYPE = "application/xml; charset=utf-8"
|
|
65
65
|
|
|
66
|
-
attr_accessor :status_code, :headers, :body, :cookies
|
|
66
|
+
attr_accessor :status_code, :headers, :body, :cookies, :csp_nonce
|
|
67
67
|
|
|
68
68
|
# ADR-0068: a header name must be an RFC 9110 token, and a header value,
|
|
69
69
|
# redirect location or cookie attribute may never carry CR, LF or NUL (a
|
|
@@ -89,6 +89,11 @@ module Tina4
|
|
|
89
89
|
@body = ""
|
|
90
90
|
@cookies = nil # Lazy -- most responses have no cookies
|
|
91
91
|
@content_type_from_header = false
|
|
92
|
+
# Per-response CSP nonce (ADR-0088). RackApp#call mints one per request on
|
|
93
|
+
# the thread-local, so every Response built during that request reads the
|
|
94
|
+
# SAME value the security middleware names in the Content-Security-Policy
|
|
95
|
+
# header and the framework's inline <style>/<script> stamp on themselves.
|
|
96
|
+
@csp_nonce = Tina4::Csp.current_nonce
|
|
92
97
|
end
|
|
93
98
|
|
|
94
99
|
# Chainable status setter
|
data/lib/tina4/session.rb
CHANGED
|
@@ -175,23 +175,72 @@ module Tina4
|
|
|
175
175
|
def clear
|
|
176
176
|
@data = {}
|
|
177
177
|
@modified = true
|
|
178
|
+
@cleared = true
|
|
178
179
|
end
|
|
179
180
|
|
|
180
181
|
def to_hash
|
|
181
182
|
@data.dup
|
|
182
183
|
end
|
|
183
184
|
|
|
184
|
-
# Persist
|
|
185
|
-
#
|
|
186
|
-
#
|
|
185
|
+
# Persist this request's session. On a backend failure the error is logged
|
|
186
|
+
# and false is returned — the @modified (dirty) flag is RETAINED so a later
|
|
187
|
+
# save can retry. Returns true on a successful (or no-op) write.
|
|
187
188
|
#
|
|
188
189
|
# A cleared id (@id nil, e.g. after #destroy) is a no-op: there is nothing
|
|
189
|
-
# to persist, and a write would re-create the just-destroyed record.
|
|
190
|
-
#
|
|
190
|
+
# to persist, and a write would re-create the just-destroyed record.
|
|
191
|
+
#
|
|
192
|
+
# Another request may have changed or ended this session since this one
|
|
193
|
+
# loaded it: a logout, a #regenerate, a set that took a privilege away.
|
|
194
|
+
# Writing back the whole snapshot loaded at the start undid all of those, so
|
|
195
|
+
# a request in flight across a logout logged the user straight back in. The
|
|
196
|
+
# save therefore re-reads the stored record and writes only what THIS
|
|
197
|
+
# request changed onto it, and never re-creates a record another request
|
|
198
|
+
# removed: it ends the session for this request instead, so no cookie goes
|
|
199
|
+
# out for it.
|
|
200
|
+
#
|
|
201
|
+
# SLIDING EXPIRY (ADR-0087): a started session the store already holds is
|
|
202
|
+
# re-written on EVERY save, even when this request changed nothing. That
|
|
203
|
+
# write re-stamps the backend deadline to now + TINA4_SESSION_TTL, so expiry
|
|
204
|
+
# is measured from the last request that TOUCHED the session, not from its
|
|
205
|
+
# last change — a user actively reading pages stays logged in. The re-write
|
|
206
|
+
# is the SAME re-read, merged record the concurrent-save contract computes,
|
|
207
|
+
# so sliding never clobbers a concurrent change. PHP is the reference. The
|
|
208
|
+
# guards above take precedence over the slide: no id → nothing to slide;
|
|
209
|
+
# a record another request ended → forget + end, never re-created; a failed
|
|
210
|
+
# read → deadline untouched, dirty kept for a retry; TINA4_SESSION_TTL=0 →
|
|
211
|
+
# the slide re-stamps a zero (immortal) deadline (ADR-0027).
|
|
212
|
+
#
|
|
213
|
+
# The slide is scoped to @stored — an id the STORE actually holds (adopted,
|
|
214
|
+
# not freshly minted). ADR-0087's own guard: "a request with no session (no
|
|
215
|
+
# id issued, OR THE ID WAS NEVER ADOPTED) writes nothing — there is no
|
|
216
|
+
# deadline to move." A brand-new, unmodified session (minted id, @stored
|
|
217
|
+
# false) therefore still no-ops, exactly as before; a modified session of
|
|
218
|
+
# any kind still writes.
|
|
191
219
|
def save
|
|
192
|
-
return true unless @id && @modified
|
|
193
|
-
|
|
220
|
+
return true unless @id && (@modified || @stored)
|
|
221
|
+
|
|
222
|
+
record = @data
|
|
223
|
+
if @stored
|
|
224
|
+
current, failed = current_record
|
|
225
|
+
# Whether the record still exists is unknown, so nothing is written;
|
|
226
|
+
# the dirty flag is kept for a later retry.
|
|
227
|
+
return false if failed
|
|
228
|
+
|
|
229
|
+
if current.nil?
|
|
230
|
+
forget
|
|
231
|
+
@ended = true
|
|
232
|
+
return true
|
|
233
|
+
end
|
|
234
|
+
record = merged(current) unless @cleared
|
|
235
|
+
end
|
|
236
|
+
if safe_write(@id, record, @ttl)
|
|
194
237
|
@modified = false
|
|
238
|
+
# An empty record is no session (#start never adopts one), so the next
|
|
239
|
+
# save writes whole rather than take this request's own empty write for
|
|
240
|
+
# a logout.
|
|
241
|
+
@stored = !record.empty?
|
|
242
|
+
@cleared = false
|
|
243
|
+
@loaded = fingerprints(@data)
|
|
195
244
|
true
|
|
196
245
|
else
|
|
197
246
|
false # dirty flag retained for retry
|
|
@@ -205,9 +254,7 @@ module Tina4
|
|
|
205
254
|
# (nulls sessionId). A fresh session needs a new #start, which mints a new id.
|
|
206
255
|
def destroy
|
|
207
256
|
safe_destroy(@id) if @id
|
|
208
|
-
|
|
209
|
-
@data = {}
|
|
210
|
-
@modified = false
|
|
257
|
+
forget
|
|
211
258
|
end
|
|
212
259
|
|
|
213
260
|
# Get a session value with optional default.
|
|
@@ -272,11 +319,32 @@ module Tina4
|
|
|
272
319
|
# session fixation (a pre-auth session ID must not survive into the
|
|
273
320
|
# authenticated session). Destroys the old backend record (best-effort)
|
|
274
321
|
# and persists under the new ID.
|
|
322
|
+
#
|
|
323
|
+
# What is carried is the session as it is stored now with this request's
|
|
324
|
+
# own changes applied, the same merge #save does. If another request ended
|
|
325
|
+
# the session after this one loaded it (a logout, or a regenerate of its
|
|
326
|
+
# own), it stays ended: nothing is carried, no id is minted and nil is
|
|
327
|
+
# returned, so no cookie goes out to replace the one that request sent.
|
|
275
328
|
def regenerate
|
|
329
|
+
return nil if @ended
|
|
330
|
+
|
|
276
331
|
old_id = @id
|
|
332
|
+
if old_id && @stored
|
|
333
|
+
current, failed = current_record
|
|
334
|
+
unless failed
|
|
335
|
+
if current.nil?
|
|
336
|
+
forget
|
|
337
|
+
@ended = true
|
|
338
|
+
return nil
|
|
339
|
+
end
|
|
340
|
+
@data.replace(merged(current)) unless @cleared
|
|
341
|
+
end
|
|
342
|
+
end
|
|
277
343
|
@id = SecureRandom.hex(32)
|
|
278
344
|
safe_destroy(old_id)
|
|
279
345
|
@modified = true
|
|
346
|
+
# Nothing is stored under the new id yet, so the save writes it whole.
|
|
347
|
+
@stored = false
|
|
280
348
|
save
|
|
281
349
|
@id
|
|
282
350
|
end
|
|
@@ -383,9 +451,76 @@ module Tina4
|
|
|
383
451
|
@id = session_id
|
|
384
452
|
@data = data
|
|
385
453
|
@modified = false
|
|
454
|
+
# @stored: this request is working on a record the store holds, so a save
|
|
455
|
+
# must never re-create it once it has gone. An empty record does not
|
|
456
|
+
# count: it is no session. A failed read adopts the id with {}
|
|
457
|
+
# (#existing_session_data): not a record this request has seen, so its
|
|
458
|
+
# first save writes whole.
|
|
459
|
+
@stored = !data.empty?
|
|
460
|
+
# @loaded: one fingerprint per key of what this request last saw stored;
|
|
461
|
+
# #save compares the live data against it to find what THIS request
|
|
462
|
+
# changed. @cleared: #clear ran since the last save, so the save replaces
|
|
463
|
+
# the stored record instead of merging into it.
|
|
464
|
+
@loaded = @stored ? fingerprints(@data) : {}
|
|
465
|
+
@cleared = false
|
|
466
|
+
# @ended: this request found its session ended by another request (the
|
|
467
|
+
# record it loaded is gone). It stays ended for the rest of the request:
|
|
468
|
+
# #regenerate mints nothing either.
|
|
469
|
+
@ended = false
|
|
386
470
|
@id
|
|
387
471
|
end
|
|
388
472
|
|
|
473
|
+
# Forget the session in memory: no data, no id, nothing left to save. What
|
|
474
|
+
# #destroy does after removing the record, and what #save does when it
|
|
475
|
+
# finds another request already removed it.
|
|
476
|
+
def forget
|
|
477
|
+
@id = nil
|
|
478
|
+
@data = {}
|
|
479
|
+
@modified = false
|
|
480
|
+
@stored = false
|
|
481
|
+
@cleared = false
|
|
482
|
+
@loaded = {}
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# The record as stored right now, as [data, failed]. nil and empty both mean
|
|
486
|
+
# "no session", exactly as in #existing_session_data.
|
|
487
|
+
def current_record
|
|
488
|
+
return [nil, true] if degraded?
|
|
489
|
+
|
|
490
|
+
data = @handler.read(@id)
|
|
491
|
+
[data.nil? || (data.respond_to?(:empty?) && data.empty?) ? nil : data, false]
|
|
492
|
+
rescue StandardError => e
|
|
493
|
+
log_backend_error("read", e)
|
|
494
|
+
raise if @strict
|
|
495
|
+
[nil, true]
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# The stored record with only this request's own changes applied to it: the
|
|
499
|
+
# keys it set or changed since it last loaded or saved, minus the keys it
|
|
500
|
+
# removed. Every other key keeps the value the store holds now.
|
|
501
|
+
def merged(current)
|
|
502
|
+
record = current.dup
|
|
503
|
+
@data.each do |key, value|
|
|
504
|
+
now = fingerprint(value)
|
|
505
|
+
record[key] = value if now.nil? || @loaded[key] != now
|
|
506
|
+
end
|
|
507
|
+
@loaded.each_key { |key| record.delete(key) unless @data.key?(key) }
|
|
508
|
+
record
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def fingerprints(data)
|
|
512
|
+
data.each_with_object({}) { |(key, value), prints| prints[key] = fingerprint(value) }
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# The stored form of a value, for telling whether a request changed it.
|
|
516
|
+
# nil when it cannot be serialised: that always counts as changed, because
|
|
517
|
+
# writing a value again is harmless and missing a change is not.
|
|
518
|
+
def fingerprint(value)
|
|
519
|
+
JSON.generate(value)
|
|
520
|
+
rescue StandardError
|
|
521
|
+
nil
|
|
522
|
+
end
|
|
523
|
+
|
|
389
524
|
# The stored data for session_id when the backend HOLDS a session under it,
|
|
390
525
|
# else nil — strict mode's signal to mint a fresh id rather than adopt one
|
|
391
526
|
# the client chose.
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
require "json"
|
|
9
9
|
require_relative "resp_client"
|
|
10
|
+
require_relative "resp_session_handler"
|
|
10
11
|
|
|
11
12
|
module Tina4
|
|
12
13
|
module SessionHandlers
|
|
@@ -16,7 +17,13 @@ module Tina4
|
|
|
16
17
|
# backend did the latter and was retired 2026-07-31 as drift); otherwise
|
|
17
18
|
# speaks raw RESP over a TCP socket via RespClient — zero dependencies, so a
|
|
18
19
|
# Tina4 app stores sessions in Redis with no extra gem.
|
|
20
|
+
#
|
|
21
|
+
# read/write/destroy/cleanup and the gem-client build come from
|
|
22
|
+
# RespSessionHandler (shared byte-for-byte with ValkeyHandler); only the
|
|
23
|
+
# env-var namespace below differs.
|
|
19
24
|
class RedisHandler
|
|
25
|
+
include RespSessionHandler
|
|
26
|
+
|
|
20
27
|
# Connection is configured from TINA4_SESSION_REDIS_* env vars (parity with
|
|
21
28
|
# Python's RedisSessionHandler and the Ruby ValkeyHandler shape) so
|
|
22
29
|
# TINA4_SESSION_BACKEND=redis can actually be pointed at a server by env.
|
|
@@ -33,57 +40,6 @@ module Tina4
|
|
|
33
40
|
@redis = build_gem_client
|
|
34
41
|
@resp = @redis ? nil : RespClient.new(host: @host, port: @port, password: @password, db: @db)
|
|
35
42
|
end
|
|
36
|
-
|
|
37
|
-
def read(session_id)
|
|
38
|
-
key = "#{@prefix}#{session_id}"
|
|
39
|
-
data = @redis ? @redis.get(key) : @resp.get(key)
|
|
40
|
-
return nil unless data
|
|
41
|
-
JSON.parse(data)
|
|
42
|
-
rescue JSON::ParserError
|
|
43
|
-
nil
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Write session data. A per-call +ttl+ WINS over the handler default, so
|
|
47
|
-
# asking for a 60s session really gets 60s; 0 uses the default. Every
|
|
48
|
-
# handler in every Tina4 framework takes this third argument -- Session#write
|
|
49
|
-
# passes it, and a handler that did not accept it raised ArgumentError,
|
|
50
|
-
# which safe_write swallowed into a silent STALE write.
|
|
51
|
-
#
|
|
52
|
-
# @param session_id [String] the session id
|
|
53
|
-
# @param data [Hash] the payload to store
|
|
54
|
-
# @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
|
|
55
|
-
def write(session_id, data, ttl = 0)
|
|
56
|
-
key = "#{@prefix}#{session_id}"
|
|
57
|
-
payload = JSON.generate(data)
|
|
58
|
-
effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
|
|
59
|
-
if @redis
|
|
60
|
-
@redis.setex(key, effective_ttl, payload)
|
|
61
|
-
else
|
|
62
|
-
@resp.setex(key, effective_ttl, payload)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def destroy(session_id)
|
|
67
|
-
key = "#{@prefix}#{session_id}"
|
|
68
|
-
@redis ? @redis.del(key) : @resp.del(key)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def cleanup
|
|
72
|
-
# Redis handles TTL automatically
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
private
|
|
76
|
-
|
|
77
|
-
# Use the official `redis` gem only when the REAL gem is loadable (guard
|
|
78
|
-
# against an in-test fake `Redis` constant that has no VERSION). Returns nil
|
|
79
|
-
# when the gem is absent so the caller falls back to the raw RESP client.
|
|
80
|
-
def build_gem_client
|
|
81
|
-
require "redis"
|
|
82
|
-
return nil unless defined?(::Redis::VERSION)
|
|
83
|
-
Redis.new(host: @host, port: @port, db: @db, password: @password)
|
|
84
|
-
rescue LoadError
|
|
85
|
-
nil
|
|
86
|
-
end
|
|
87
43
|
end
|
|
88
44
|
end
|
|
89
45
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
require "json"
|
|
9
|
+
|
|
10
|
+
module Tina4
|
|
11
|
+
module SessionHandlers
|
|
12
|
+
# Shared RESP-protocol session storage. Redis and Valkey speak the same RESP
|
|
13
|
+
# wire protocol, so reading, writing, destroying and cleaning up a session --
|
|
14
|
+
# and building the optional `redis`-gem client -- are byte-identical between
|
|
15
|
+
# them. Only the env-var namespace differs (TINA4_SESSION_REDIS_* vs
|
|
16
|
+
# TINA4_SESSION_VALKEY_*), which each handler sets in its own initialize.
|
|
17
|
+
#
|
|
18
|
+
# A handler includes this and, in initialize, sets @prefix, @ttl, @host,
|
|
19
|
+
# @port, @db, @password, @redis (the gem client or nil) and @resp (the raw
|
|
20
|
+
# RespClient fallback, used when @redis is nil).
|
|
21
|
+
module RespSessionHandler
|
|
22
|
+
def read(session_id)
|
|
23
|
+
key = "#{@prefix}#{session_id}"
|
|
24
|
+
data = @redis ? @redis.get(key) : @resp.get(key)
|
|
25
|
+
return nil unless data
|
|
26
|
+
JSON.parse(data)
|
|
27
|
+
rescue JSON::ParserError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Write session data. A per-call +ttl+ WINS over the handler default, so
|
|
32
|
+
# asking for a 60s session really gets 60s; 0 uses the default. Every
|
|
33
|
+
# handler in every Tina4 framework takes this third argument -- Session#write
|
|
34
|
+
# passes it, and a handler that did not accept it raised ArgumentError,
|
|
35
|
+
# which safe_write swallowed into a silent STALE write.
|
|
36
|
+
#
|
|
37
|
+
# @param session_id [String] the session id
|
|
38
|
+
# @param data [Hash] the payload to store
|
|
39
|
+
# @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
|
|
40
|
+
def write(session_id, data, ttl = 0)
|
|
41
|
+
key = "#{@prefix}#{session_id}"
|
|
42
|
+
payload = JSON.generate(data)
|
|
43
|
+
effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
|
|
44
|
+
if @redis
|
|
45
|
+
@redis.setex(key, effective_ttl, payload)
|
|
46
|
+
else
|
|
47
|
+
@resp.setex(key, effective_ttl, payload)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def destroy(session_id)
|
|
52
|
+
key = "#{@prefix}#{session_id}"
|
|
53
|
+
@redis ? @redis.del(key) : @resp.del(key)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cleanup
|
|
57
|
+
# Redis/Valkey expire keys by TTL automatically -- nothing to sweep.
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Use the official `redis` gem (RESP-compatible with both Redis and
|
|
63
|
+
# Valkey) only when the REAL gem is loadable (guard against an in-test fake
|
|
64
|
+
# `Redis` constant that has no VERSION). Returns nil when the gem is absent
|
|
65
|
+
# so the caller falls back to the raw RESP client.
|
|
66
|
+
def build_gem_client
|
|
67
|
+
require "redis"
|
|
68
|
+
return nil unless defined?(::Redis::VERSION)
|
|
69
|
+
Redis.new(host: @host, port: @port, db: @db, password: @password)
|
|
70
|
+
rescue LoadError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|