tina4ruby 3.13.142 → 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 +4 -0
- data/lib/tina4/cli.rb +7 -2
- data/lib/tina4/crud.rb +56 -13
- data/lib/tina4/csp.rb +119 -0
- data/lib/tina4/error_overlay.rb +75 -37
- 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 +21 -8
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +34 -15
- data/lib/tina4/graphql.rb +9 -3
- data/lib/tina4/middleware.rb +16 -6
- 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/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.rb +1 -0
- metadata +3 -2
|
@@ -67,7 +67,7 @@ GALLERY_QUEUE_HTML = <<~'HTML'
|
|
|
67
67
|
<div class="card-body">
|
|
68
68
|
<div class="d-flex gap-2">
|
|
69
69
|
<input type="text" id="msgInput" class="form-control" placeholder="Enter a task message, e.g. send-email">
|
|
70
|
-
<button class="btn btn-primary"
|
|
70
|
+
<button class="btn btn-primary" id="produceBtn">Produce</button>
|
|
71
71
|
</div>
|
|
72
72
|
</div>
|
|
73
73
|
</div>
|
|
@@ -76,10 +76,10 @@ GALLERY_QUEUE_HTML = <<~'HTML'
|
|
|
76
76
|
<div class="card">
|
|
77
77
|
<div class="card-header">Actions</div>
|
|
78
78
|
<div class="card-body d-flex gap-2 flex-wrap">
|
|
79
|
-
<button class="btn btn-success"
|
|
80
|
-
<button class="btn btn-danger"
|
|
81
|
-
<button class="btn btn-warning"
|
|
82
|
-
<button class="btn btn-secondary"
|
|
79
|
+
<button class="btn btn-success" id="consumeBtn">Consume Next</button>
|
|
80
|
+
<button class="btn btn-danger" id="failBtn">Fail Next</button>
|
|
81
|
+
<button class="btn btn-warning" id="retryBtn">Retry Failed</button>
|
|
82
|
+
<button class="btn btn-secondary" id="refreshBtn">Refresh</button>
|
|
83
83
|
</div>
|
|
84
84
|
</div>
|
|
85
85
|
</div>
|
|
@@ -112,7 +112,7 @@ GALLERY_QUEUE_HTML = <<~'HTML'
|
|
|
112
112
|
</div>
|
|
113
113
|
</div>
|
|
114
114
|
|
|
115
|
-
<script>
|
|
115
|
+
<script nonce="__CSP_NONCE__">
|
|
116
116
|
function statusBadge(status) {
|
|
117
117
|
var colors = {pending:"primary", reserved:"warning", completed:"success", failed:"danger", dead:"secondary"};
|
|
118
118
|
var color = colors[status] || "secondary";
|
|
@@ -122,7 +122,7 @@ function statusBadge(status) {
|
|
|
122
122
|
function showAlert(msg, type) {
|
|
123
123
|
var area = document.getElementById("alertArea");
|
|
124
124
|
area.innerHTML = '<div class="alert alert-' + type + ' alert-dismissible">' + msg +
|
|
125
|
-
'<button type="button" class="btn-close
|
|
125
|
+
'<button type="button" class="btn-close js-alert-close"></button></div>';
|
|
126
126
|
setTimeout(function(){ area.innerHTML = ""; }, 3000);
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -196,6 +196,16 @@ async function retryFailed() {
|
|
|
196
196
|
refresh();
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
// CSP-clean wiring: no inline onclick — bind every control via addEventListener.
|
|
200
|
+
document.getElementById("produceBtn").addEventListener("click", produce);
|
|
201
|
+
document.getElementById("consumeBtn").addEventListener("click", consume);
|
|
202
|
+
document.getElementById("failBtn").addEventListener("click", failNext);
|
|
203
|
+
document.getElementById("retryBtn").addEventListener("click", retryFailed);
|
|
204
|
+
document.getElementById("refreshBtn").addEventListener("click", refresh);
|
|
205
|
+
document.getElementById("alertArea").addEventListener("click", function(e){
|
|
206
|
+
if (e.target.classList.contains("js-alert-close")) e.target.parentElement.remove();
|
|
207
|
+
});
|
|
208
|
+
|
|
199
209
|
refresh();
|
|
200
210
|
setInterval(refresh, 2000);
|
|
201
211
|
</script>
|
|
@@ -206,7 +216,10 @@ HTML
|
|
|
206
216
|
# ── Render the interactive HTML page ──────────────────────────
|
|
207
217
|
|
|
208
218
|
Tina4::Router.get("/gallery/queue") do |request, response|
|
|
209
|
-
response
|
|
219
|
+
# ADR-0088: stamp this response's CSP nonce on the inline <script> so it runs
|
|
220
|
+
# under the strict default policy (the HTML is a frozen, non-interpolated
|
|
221
|
+
# constant, so the nonce is substituted at serve time).
|
|
222
|
+
response.html(GALLERY_QUEUE_HTML.gsub("__CSP_NONCE__", response.csp_nonce))
|
|
210
223
|
end
|
|
211
224
|
|
|
212
225
|
# ── Produce — add a message to the queue ──────────────────────
|
|
@@ -5,10 +5,23 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>{{ title }}</title>
|
|
7
7
|
<link rel="stylesheet" href="/css/tina4.min.css">
|
|
8
|
-
<style>
|
|
8
|
+
<style nonce="{{ csp_nonce() }}">
|
|
9
9
|
.component-section { margin-bottom: 2.5rem; }
|
|
10
10
|
.component-section h3 { border-bottom: 2px solid var(--bs-primary, #0d6efd); padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
|
11
11
|
.code-preview { background: #1e293b; color: #4ade80; padding: 1rem; border-radius: 0.5rem; font-family: monospace; font-size: 0.85rem; overflow-x: auto; margin-top: 0.75rem; }
|
|
12
|
+
.progress-25 { width: 25%; }
|
|
13
|
+
.progress-50 { width: 50%; }
|
|
14
|
+
.progress-75 { width: 75%; }
|
|
15
|
+
.progress-100 { width: 100%; }
|
|
16
|
+
.build-card { background: #0f172a; border: 1px solid #334155; }
|
|
17
|
+
.build-heading { color: #e2e8f0; }
|
|
18
|
+
.build-code { background: #1e293b; border: 1px solid #334155; border-radius: 0.5rem; padding: 1.25rem; margin-top: 0.75rem; overflow-x: auto; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace; font-size: 0.9rem; line-height: 1.7; }
|
|
19
|
+
.build-note { color: #94a3b8; margin-top: 0.75rem; margin-bottom: 0; }
|
|
20
|
+
.tok-kw { color: #38bdf8; }
|
|
21
|
+
.tok-str { color: #4ade80; }
|
|
22
|
+
.tok-fn { color: #fbbf24; }
|
|
23
|
+
.tok-dec { color: #c084fc; }
|
|
24
|
+
.tok-comment { color: #94a3b8; }
|
|
12
25
|
</style>
|
|
13
26
|
</head>
|
|
14
27
|
<body>
|
|
@@ -29,7 +42,7 @@
|
|
|
29
42
|
|
|
30
43
|
<div class="alert alert-info alert-dismissible mb-4">
|
|
31
44
|
<strong>tina4css</strong> — Zero-dependency CSS framework (~24KB). Bootstrap-compatible class names, dark mode ready. No CDN needed — ships with every Tina4 project.
|
|
32
|
-
<button type="button" class="btn-close
|
|
45
|
+
<button type="button" class="btn-close js-dismiss"></button>
|
|
33
46
|
</div>
|
|
34
47
|
|
|
35
48
|
<!-- Buttons -->
|
|
@@ -217,10 +230,10 @@
|
|
|
217
230
|
<!-- Progress -->
|
|
218
231
|
<div class="component-section">
|
|
219
232
|
<h3>Progress Bars</h3>
|
|
220
|
-
<div class="progress mb-2"><div class="progress-bar bg-primary
|
|
221
|
-
<div class="progress mb-2"><div class="progress-bar bg-success
|
|
222
|
-
<div class="progress mb-2"><div class="progress-bar bg-warning
|
|
223
|
-
<div class="progress"><div class="progress-bar bg-danger
|
|
233
|
+
<div class="progress mb-2"><div class="progress-bar bg-primary progress-25">25%</div></div>
|
|
234
|
+
<div class="progress mb-2"><div class="progress-bar bg-success progress-50">50%</div></div>
|
|
235
|
+
<div class="progress mb-2"><div class="progress-bar bg-warning progress-75">75%</div></div>
|
|
236
|
+
<div class="progress"><div class="progress-bar bg-danger progress-100">100%</div></div>
|
|
224
237
|
</div>
|
|
225
238
|
|
|
226
239
|
<!-- List Group -->
|
|
@@ -235,23 +248,29 @@
|
|
|
235
248
|
</div>
|
|
236
249
|
|
|
237
250
|
<!-- How this page was built -->
|
|
238
|
-
<div class="card mt-4 mb-5
|
|
251
|
+
<div class="card mt-4 mb-5 build-card">
|
|
239
252
|
<div class="card-body">
|
|
240
|
-
<h5
|
|
241
|
-
<pre
|
|
253
|
+
<h5 class="build-heading">How this page was built</h5>
|
|
254
|
+
<pre class="build-code"><code><span class="tok-dec">Tina4::Router.get</span> <span class="tok-str">"/gallery/page"</span>, <span class="tok-fn">template:</span> <span class="tok-str">"gallery_page.twig"</span> <span class="tok-dec">do</span> |request, response|
|
|
242
255
|
response.call({
|
|
243
|
-
<span
|
|
244
|
-
<span
|
|
256
|
+
<span class="tok-fn">title:</span> <span class="tok-str">"tina4css Component Showcase"</span>,
|
|
257
|
+
<span class="tok-fn">items:</span> [...]
|
|
245
258
|
}, Tina4::HTTP_OK)
|
|
246
|
-
<span
|
|
247
|
-
<p
|
|
248
|
-
Rendered via <code
|
|
249
|
-
All components above use only <code
|
|
259
|
+
<span class="tok-dec">end</span></code></pre>
|
|
260
|
+
<p class="build-note">
|
|
261
|
+
Rendered via <code class="tok-dec">template:</code> keyword. Styled with <strong class="build-heading">tina4css</strong> — zero external CDN.
|
|
262
|
+
All components above use only <code class="tok-str"><link href="/css/tina4.min.css"></code>.
|
|
250
263
|
</p>
|
|
251
264
|
</div>
|
|
252
265
|
</div>
|
|
253
266
|
|
|
254
267
|
</div>
|
|
255
268
|
|
|
269
|
+
<script nonce="{{ csp_nonce() }}">
|
|
270
|
+
document.querySelectorAll('.js-dismiss').forEach(function(b){
|
|
271
|
+
b.addEventListener('click', function(){ this.parentElement.remove(); });
|
|
272
|
+
});
|
|
273
|
+
</script>
|
|
274
|
+
|
|
256
275
|
</body>
|
|
257
276
|
</html>
|
data/lib/tina4/graphql.rb
CHANGED
|
@@ -1205,19 +1205,25 @@ module Tina4
|
|
|
1205
1205
|
private
|
|
1206
1206
|
|
|
1207
1207
|
def graphiql_html(endpoint)
|
|
1208
|
+
# ADR-0088: no inline style= attribute, and the inline bootstrap <script>
|
|
1209
|
+
# carries this response's CSP nonce. The cross-origin GraphiQL CDN assets
|
|
1210
|
+
# still need TINA4_CSP to list unpkg.com (the default 'self' blocks them),
|
|
1211
|
+
# same as the swagger UI — this is a dev-only playground.
|
|
1212
|
+
nonce = Tina4::Csp.current_nonce
|
|
1208
1213
|
<<~HTML
|
|
1209
1214
|
<!DOCTYPE html>
|
|
1210
1215
|
<html>
|
|
1211
1216
|
<head>
|
|
1212
1217
|
<title>GraphiQL — Tina4 Ruby</title>
|
|
1213
1218
|
<link rel="stylesheet" href="https://unpkg.com/graphiql@3/graphiql.min.css" />
|
|
1219
|
+
<style nonce="#{nonce}">body{margin:0;height:100vh}#graphiql{height:100vh}</style>
|
|
1214
1220
|
</head>
|
|
1215
|
-
<body
|
|
1216
|
-
<div id="graphiql"
|
|
1221
|
+
<body>
|
|
1222
|
+
<div id="graphiql"></div>
|
|
1217
1223
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
1218
1224
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
1219
1225
|
<script crossorigin src="https://unpkg.com/graphiql@3/graphiql.min.js"></script>
|
|
1220
|
-
<script>
|
|
1226
|
+
<script nonce="#{nonce}">
|
|
1221
1227
|
const fetcher = GraphiQL.createFetcher({ url: '#{endpoint}' });
|
|
1222
1228
|
ReactDOM.createRoot(document.getElementById('graphiql'))
|
|
1223
1229
|
.render(React.createElement(GraphiQL, { fetcher }));
|
data/lib/tina4/middleware.rb
CHANGED
|
@@ -805,7 +805,14 @@ module Tina4
|
|
|
805
805
|
[
|
|
806
806
|
["X-Frame-Options", ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"],
|
|
807
807
|
["X-Content-Type-Options", "nosniff"],
|
|
808
|
-
|
|
808
|
+
# ADR-0088: always name a per-response nonce in style-src AND script-src
|
|
809
|
+
# so the framework's own inline <style>/<script> (and app templates using
|
|
810
|
+
# the Frond csp_nonce() global) run under the strict policy without ever
|
|
811
|
+
# adding 'unsafe-inline'. Derives style-src/script-src from default-src /
|
|
812
|
+
# 'self' when TINA4_CSP omits them. The value comes from the request
|
|
813
|
+
# thread-local so it matches the nonce the HTML body stamps on its inline
|
|
814
|
+
# elements.
|
|
815
|
+
["Content-Security-Policy", Tina4::Csp.resolve_csp_header(Tina4::Csp.current_nonce)],
|
|
809
816
|
["Referrer-Policy", ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"],
|
|
810
817
|
["X-XSS-Protection", "0"],
|
|
811
818
|
["Permissions-Policy", ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"]
|
|
@@ -847,11 +854,14 @@ module Tina4
|
|
|
847
854
|
|
|
848
855
|
@csp_default_warned = true
|
|
849
856
|
message = "TINA4_CSP is not set, so Tina4 is serving the default Content-Security-Policy " \
|
|
850
|
-
"\"default-src 'self'\" on every response.
|
|
851
|
-
"
|
|
852
|
-
"
|
|
853
|
-
"
|
|
854
|
-
"
|
|
857
|
+
"\"default-src 'self'\" on every response. The framework injects a per-response " \
|
|
858
|
+
"nonce into style-src and script-src, so its own inline <style>/<script> (and " \
|
|
859
|
+
"your templates using the csp_nonce() Frond global) work under this policy. The " \
|
|
860
|
+
"default still blocks cross-origin fonts/scripts/CDNs, data: URIs, and " \
|
|
861
|
+
"cross-origin WebSocket/XHR (e.g. a separate API or LiveKit host). If your app " \
|
|
862
|
+
"uses any of these, set TINA4_CSP to a policy that allows them (see " \
|
|
863
|
+
"https://tina4.com); to silence this notice without changing behaviour, set " \
|
|
864
|
+
"TINA4_CSP=\"default-src 'self'\"."
|
|
855
865
|
begin
|
|
856
866
|
Tina4::Log.warning(message)
|
|
857
867
|
rescue StandardError
|
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
|