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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 641d95c3ab74eeac0ce2b273ee7c6c44ca8aaab5e7e2a06d8e668eacffc5fb28
|
|
4
|
+
data.tar.gz: f8ad58627e7263bf560b499dc40fbd249d3267516d61b2c31696051f80b11852
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2354cb021a1136116236d2a94f6c5be38331823eda3e4ea4b37483ac395e3324af69b8a88d55e856039fe1951bb74be6e4918505bcbd6f6b70075f9b2ef0f232
|
|
7
|
+
data.tar.gz: f3c5de4a797f61db477db42c3a46e0f47dd132e7e2522b05d6127ee6c6d84b631215808fcb88953467308291a9754b39811f1ab8604f02fca93921b39b2ea67b
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,10 @@ number means the same thing everywhere.
|
|
|
6
6
|
**The authoritative release notes for every shipped version live in the documentation:**
|
|
7
7
|
https://tina4.com/ruby/36-releases
|
|
8
8
|
|
|
9
|
+
## 3.13.143 — 2026-10-01
|
|
10
|
+
|
|
11
|
+
A fresh project's own styling now loads under the strict default Content-Security-Policy. The framework serves `default-src 'self'`, which a browser enforces by refusing every inline `<style>` and `<script>` -- so a new project rendered unstyled with its scripts blocked. The framework now mints one nonce per response, injects it into the `style-src` and `script-src` directives, and stamps it on every inline block it emits, de-inlining the `style=` and `on*=` attributes it used to rely on; a template that needs an inline block stamps `csp_nonce()` on it (ADR-0088, #96). A session's expiry now slides on activity: a read-only request that touched a stored session re-writes the merged record to move the deadline forward, so a request in flight next to a logout no longer loses the wrong state (ADR-0087, #94). Internally, the serve-debug readiness probe is identity-guarded so a foreign server on a reused ephemeral port can no longer be mistaken for the child under test -- a continuous-integration flake fix with no runtime change (#95). The framework still has no required runtime dependencies.
|
|
12
|
+
|
|
9
13
|
## 3.13.142 — 2026-09-30
|
|
10
14
|
|
|
11
15
|
A release led by real fixes for anyone on WebSockets, sessions, or the gallery queue, then a round of internal tidying and a new metrics gate that holds the line on complexity. The framework still carries no required runtime dependencies.
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -2749,7 +2749,11 @@ module Tina4
|
|
|
2749
2749
|
{% extends "base.twig" %}
|
|
2750
2750
|
{% block title %}Login{% endblock %}
|
|
2751
2751
|
{% block content %}
|
|
2752
|
-
|
|
2752
|
+
{# No inline style attribute: a nonce lets this style block run under the
|
|
2753
|
+
strict default Content-Security-Policy. csp_nonce() is a built-in
|
|
2754
|
+
Frond global that returns this response's nonce. #}
|
|
2755
|
+
<style nonce="{{ csp_nonce() }}">.auth-form{max-width:400px;margin:0 auto}</style>
|
|
2756
|
+
<div class="container mt-4 auth-form">
|
|
2753
2757
|
<h1>Login</h1>
|
|
2754
2758
|
<form method="post" action="/api/auth/login">
|
|
2755
2759
|
{{ form_token() }}
|
|
@@ -2777,7 +2781,8 @@ module Tina4
|
|
|
2777
2781
|
{% extends "base.twig" %}
|
|
2778
2782
|
{% block title %}Register{% endblock %}
|
|
2779
2783
|
{% block content %}
|
|
2780
|
-
<
|
|
2784
|
+
<style nonce="{{ csp_nonce() }}">.auth-form{max-width:400px;margin:0 auto}</style>
|
|
2785
|
+
<div class="container mt-4 auth-form">
|
|
2781
2786
|
<h1>Register</h1>
|
|
2782
2787
|
<form method="post" action="/api/auth/register">
|
|
2783
2788
|
{{ form_token() }}
|
data/lib/tina4/crud.rb
CHANGED
|
@@ -126,8 +126,8 @@ module Tina4
|
|
|
126
126
|
end
|
|
127
127
|
if editable
|
|
128
128
|
html += "<td>"
|
|
129
|
-
html += "<button class=\"btn btn-sm btn-primary me-1\"
|
|
130
|
-
html += "<button class=\"btn btn-sm btn-danger\"
|
|
129
|
+
html += "<button class=\"btn btn-sm btn-primary me-1\" data-crud-inline=\"save\" data-table=\"#{h(table_name)}\" data-id=\"#{h(pk_value)}\">Save</button>"
|
|
130
|
+
html += "<button class=\"btn btn-sm btn-danger\" data-crud-inline=\"delete\" data-table=\"#{h(table_name)}\" data-id=\"#{h(pk_value)}\">Delete</button>"
|
|
131
131
|
html += "</td>"
|
|
132
132
|
end
|
|
133
133
|
html += "</tr>"
|
|
@@ -457,7 +457,7 @@ module Tina4
|
|
|
457
457
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
458
458
|
<title>#{h(title)}</title>
|
|
459
459
|
<link rel="stylesheet" href="/css/tina4.min.css">
|
|
460
|
-
<style>
|
|
460
|
+
<style nonce="#{Tina4::Csp.current_nonce}">
|
|
461
461
|
.crud-container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
|
|
462
462
|
.crud-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
|
463
463
|
.crud-search { max-width: 300px; }
|
|
@@ -491,7 +491,7 @@ module Tina4
|
|
|
491
491
|
class="form-control crud-search">
|
|
492
492
|
<button type="submit" class="btn btn-secondary">Search</button>
|
|
493
493
|
</form>
|
|
494
|
-
<button class="btn btn-primary"
|
|
494
|
+
<button class="btn btn-primary" data-crud-action="create">+ New</button>
|
|
495
495
|
</div>
|
|
496
496
|
</div>
|
|
497
497
|
<div class="crud-info">
|
|
@@ -526,8 +526,8 @@ module Tina4
|
|
|
526
526
|
html += "<td>#{h(value)}</td>"
|
|
527
527
|
end
|
|
528
528
|
html += "<td>"
|
|
529
|
-
html += "<button class=\"btn btn-sm btn-primary me-1\"
|
|
530
|
-
html += "<button class=\"btn btn-sm btn-danger\"
|
|
529
|
+
html += "<button class=\"btn btn-sm btn-primary me-1\" data-crud-action=\"edit\" data-id=\"#{h(pk_value)}\">Edit</button>"
|
|
530
|
+
html += "<button class=\"btn btn-sm btn-danger\" data-crud-action=\"delete\" data-id=\"#{h(pk_value)}\">Delete</button>"
|
|
531
531
|
html += "</td></tr>"
|
|
532
532
|
end
|
|
533
533
|
end
|
|
@@ -567,8 +567,8 @@ module Tina4
|
|
|
567
567
|
<p>Are you sure you want to delete this record? This action cannot be undone.</p>
|
|
568
568
|
<input type="hidden" id="delete-pk-value">
|
|
569
569
|
<div class="modal-footer">
|
|
570
|
-
<button class="btn btn-secondary"
|
|
571
|
-
<button class="btn btn-danger"
|
|
570
|
+
<button class="btn btn-secondary" data-crud-action="close" data-crud-modal="delete">Cancel</button>
|
|
571
|
+
<button class="btn btn-danger" data-crud-action="confirm-delete">Delete</button>
|
|
572
572
|
</div>
|
|
573
573
|
</div>
|
|
574
574
|
</div>
|
|
@@ -586,7 +586,7 @@ module Tina4
|
|
|
586
586
|
html = "<div class=\"modal-overlay\" id=\"modal-#{id}\">"
|
|
587
587
|
html += "<div class=\"modal-box\">"
|
|
588
588
|
html += "<h3>#{h(title)}</h3>"
|
|
589
|
-
html += "<form id=\"form-#{id}\"
|
|
589
|
+
html += "<form id=\"form-#{id}\" data-crud-form=\"1\">"
|
|
590
590
|
html += "<input type=\"hidden\" id=\"#{id}-pk-value\" name=\"#{pk}\">" if edit
|
|
591
591
|
|
|
592
592
|
columns.each do |col|
|
|
@@ -599,8 +599,8 @@ module Tina4
|
|
|
599
599
|
end
|
|
600
600
|
|
|
601
601
|
html += "<div class=\"modal-footer\">"
|
|
602
|
-
html += "<button type=\"button\" class=\"btn btn-secondary\"
|
|
603
|
-
html += "<button type=\"button\" class=\"btn btn-primary\"
|
|
602
|
+
html += "<button type=\"button\" class=\"btn btn-secondary\" data-crud-action=\"close\" data-crud-modal=\"#{h(id)}\">Cancel</button>"
|
|
603
|
+
html += "<button type=\"button\" class=\"btn btn-primary\" data-crud-action=\"save\" data-crud-mode=\"#{edit ? 'edit' : 'create'}\">Save</button>"
|
|
604
604
|
html += "</div></form></div></div>"
|
|
605
605
|
html
|
|
606
606
|
end
|
|
@@ -609,7 +609,7 @@ module Tina4
|
|
|
609
609
|
def build_crud_javascript(api_path, pk, columns, request_path)
|
|
610
610
|
columns_json = JSON.generate(columns.map(&:to_s))
|
|
611
611
|
<<~HTML
|
|
612
|
-
<script>
|
|
612
|
+
<script nonce="#{Tina4::Csp.current_nonce}">
|
|
613
613
|
var CRUD_API = '#{api_path}';
|
|
614
614
|
var CRUD_PK = '#{pk}';
|
|
615
615
|
var CRUD_COLUMNS = #{columns_json};
|
|
@@ -708,6 +708,37 @@ module Tina4
|
|
|
708
708
|
.catch(function(e) { crudShowAlert('Failed to delete: ' + e, 'danger'); });
|
|
709
709
|
}
|
|
710
710
|
|
|
711
|
+
// Replaces the modal forms' inline submit handler (a CSP-blocked on*=
|
|
712
|
+
// attribute): they post over fetch(), so stop the native submit here.
|
|
713
|
+
document.addEventListener('submit', function (event) {
|
|
714
|
+
if (event.target.closest('form[data-crud-form]')) {
|
|
715
|
+
event.preventDefault();
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// CSP-clean action wiring: a nonce authorises this <script> element
|
|
720
|
+
// but never an inline on*= attribute, so every button carries
|
|
721
|
+
// data-crud-action and binds through one delegated listener.
|
|
722
|
+
document.addEventListener('click', function (event) {
|
|
723
|
+
var button = event.target.closest('[data-crud-action]');
|
|
724
|
+
if (!button) return;
|
|
725
|
+
var action = button.dataset.crudAction;
|
|
726
|
+
if (action === 'create') {
|
|
727
|
+
crudShowCreate();
|
|
728
|
+
} else if (action === 'edit') {
|
|
729
|
+
crudShowEdit(button.dataset.id);
|
|
730
|
+
} else if (action === 'delete') {
|
|
731
|
+
crudShowDelete(button.dataset.id);
|
|
732
|
+
} else if (action === 'close') {
|
|
733
|
+
crudCloseModal(button.dataset.crudModal);
|
|
734
|
+
} else if (action === 'confirm-delete') {
|
|
735
|
+
crudConfirmDelete();
|
|
736
|
+
} else if (action === 'save') {
|
|
737
|
+
if (button.dataset.crudMode === 'edit') { crudSaveEdit(); }
|
|
738
|
+
else { crudSaveCreate(); }
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
|
|
711
742
|
// Close modal on overlay click
|
|
712
743
|
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
|
|
713
744
|
overlay.addEventListener('click', function(e) {
|
|
@@ -729,7 +760,7 @@ module Tina4
|
|
|
729
760
|
|
|
730
761
|
def inline_crud_javascript(table_name)
|
|
731
762
|
<<~JS
|
|
732
|
-
<script>
|
|
763
|
+
<script nonce="#{Tina4::Csp.current_nonce}">
|
|
733
764
|
function crudSave(table, id) {
|
|
734
765
|
const row = document.querySelector(`tr[data-id="${id}"]`);
|
|
735
766
|
const cells = row.querySelectorAll('td[data-field]');
|
|
@@ -748,6 +779,18 @@ module Tina4
|
|
|
748
779
|
.then(d => { document.querySelector(`tr[data-id="${id}"]`).remove(); })
|
|
749
780
|
.catch(e => alert('Error: ' + e));
|
|
750
781
|
}
|
|
782
|
+
// CSP-clean wiring: a nonce authorises this <script> element but never
|
|
783
|
+
// an inline on*= attribute, so the Save/Delete buttons carry
|
|
784
|
+
// data-crud-inline + data-* and bind through one delegated listener.
|
|
785
|
+
document.addEventListener('click', function (event) {
|
|
786
|
+
var button = event.target.closest('[data-crud-inline]');
|
|
787
|
+
if (!button) return;
|
|
788
|
+
if (button.dataset.crudInline === 'save') {
|
|
789
|
+
crudSave(button.dataset.table, button.dataset.id);
|
|
790
|
+
} else if (button.dataset.crudInline === 'delete') {
|
|
791
|
+
crudDelete(button.dataset.table, button.dataset.id);
|
|
792
|
+
}
|
|
793
|
+
});
|
|
751
794
|
</script>
|
|
752
795
|
JS
|
|
753
796
|
end
|
data/lib/tina4/csp.rb
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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 "securerandom"
|
|
9
|
+
|
|
10
|
+
module Tina4
|
|
11
|
+
# Per-response Content-Security-Policy nonce (ADR-0088).
|
|
12
|
+
#
|
|
13
|
+
# The framework serves a strict default CSP (`default-src 'self'`). A browser
|
|
14
|
+
# refuses every inline `<style>` and `<script>` under that policy unless the
|
|
15
|
+
# element carries a nonce that the `Content-Security-Policy` header also names.
|
|
16
|
+
# So the framework mints one cryptographically-random nonce per response, stamps
|
|
17
|
+
# it on every inline `<style>`/`<script>` it emits, and injects the matching
|
|
18
|
+
# `'nonce-<X>'` into the `style-src` and `script-src` directives of the CSP
|
|
19
|
+
# header. The same value reaches user templates through the Frond global
|
|
20
|
+
# `csp_nonce()`.
|
|
21
|
+
#
|
|
22
|
+
# The nonce lives in a thread-local, so each request (one Puma worker thread)
|
|
23
|
+
# gets its own value and concurrent requests never see each other's.
|
|
24
|
+
# `RackApp#call` sets a fresh nonce at the start of every request and clears it
|
|
25
|
+
# in `ensure`, exactly as it does the request id. Whoever touches the nonce
|
|
26
|
+
# first in a request — the body emitter rendering inline content, or the
|
|
27
|
+
# security middleware building the header — gets the same value, because
|
|
28
|
+
# {current_nonce} generates one on first access and caches it on the thread for
|
|
29
|
+
# the rest of the request.
|
|
30
|
+
module Csp
|
|
31
|
+
THREAD_KEY = :tina4_csp_nonce
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Return a fresh cryptographically-random nonce (128 bits, base64).
|
|
36
|
+
# SecureRandom.base64 is stdlib (no `base64` gem), encodes 16 random bytes.
|
|
37
|
+
def generate_nonce
|
|
38
|
+
SecureRandom.base64(16)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Set the nonce for the current request (thread) context.
|
|
42
|
+
def set_current_nonce(value)
|
|
43
|
+
Thread.current[THREAD_KEY] = value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Clear the nonce at the end of a request (mirrors Log.clear_request_id).
|
|
47
|
+
def clear_current_nonce
|
|
48
|
+
Thread.current[THREAD_KEY] = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Return the current request's nonce, minting one on first access.
|
|
52
|
+
#
|
|
53
|
+
# Generate-on-first-access makes the value independent of ordering: whether
|
|
54
|
+
# the inline body or the CSP header is built first, both read the same nonce.
|
|
55
|
+
def current_nonce
|
|
56
|
+
value = Thread.current[THREAD_KEY]
|
|
57
|
+
if value.nil? || value.empty?
|
|
58
|
+
value = generate_nonce
|
|
59
|
+
Thread.current[THREAD_KEY] = value
|
|
60
|
+
end
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Frond / template global: the current response's CSP nonce.
|
|
65
|
+
#
|
|
66
|
+
# <style nonce="{{ csp_nonce() }}"> ... </style>
|
|
67
|
+
# <script nonce="{{ csp_nonce() }}"> ... </script>
|
|
68
|
+
def csp_nonce
|
|
69
|
+
current_nonce
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Return +csp+ with `'nonce-<nonce>'` present in style-src AND script-src.
|
|
73
|
+
#
|
|
74
|
+
# For the default `default-src 'self'` this yields
|
|
75
|
+
# `default-src 'self'; style-src 'self' 'nonce-X'; script-src 'self' 'nonce-X'`.
|
|
76
|
+
# When a directive is absent it is derived from `default-src` (falling back to
|
|
77
|
+
# `'self'`) so the framework's own nonce'd content always works; when present
|
|
78
|
+
# the nonce is appended (idempotently). Every other directive is kept, in order.
|
|
79
|
+
def inject_nonce_into_csp(csp, nonce)
|
|
80
|
+
token = "'nonce-#{nonce}'"
|
|
81
|
+
directives = []
|
|
82
|
+
csp.to_s.split(";").each do |part|
|
|
83
|
+
part = part.strip
|
|
84
|
+
next if part.empty?
|
|
85
|
+
|
|
86
|
+
bits = part.split(/\s+/, 2)
|
|
87
|
+
name = bits[0].downcase
|
|
88
|
+
value = bits.length > 1 ? bits[1].strip : ""
|
|
89
|
+
directives << [name, value]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
default_pair = directives.find { |name, _| name == "default-src" }
|
|
93
|
+
default_value = default_pair ? default_pair[1] : "'self'"
|
|
94
|
+
|
|
95
|
+
%w[style-src script-src].each do |directive|
|
|
96
|
+
existing = directives.find { |name, _| name == directive }
|
|
97
|
+
if existing
|
|
98
|
+
existing[1] = "#{existing[1]} #{token}".strip unless existing[1].split(/\s+/).include?(token)
|
|
99
|
+
else
|
|
100
|
+
base = default_value.empty? ? "'self'" : default_value
|
|
101
|
+
directives << [directive, "#{base} #{token}".strip]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
directives.map { |name, value| value.empty? ? name : "#{name} #{value}" }.join("; ")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Build the CSP header value for +nonce+ from the environment.
|
|
109
|
+
#
|
|
110
|
+
# Honours TINA4_CSP (default `default-src 'self'`) and always injects the
|
|
111
|
+
# nonce into style-src and script-src. A blank TINA4_CSP is treated as the
|
|
112
|
+
# default policy (the empty-string / unset behaviour is unchanged elsewhere).
|
|
113
|
+
def resolve_csp_header(nonce)
|
|
114
|
+
csp = ENV["TINA4_CSP"]
|
|
115
|
+
csp = "default-src 'self'" if csp.nil? || csp.empty?
|
|
116
|
+
inject_nonce_into_csp(csp, nonce)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
data/lib/tina4/error_overlay.rb
CHANGED
|
@@ -86,7 +86,7 @@ module Tina4
|
|
|
86
86
|
end
|
|
87
87
|
hidden = backtrace.length - [backtrace.length, MAX_FRAMES].min
|
|
88
88
|
if hidden.positive?
|
|
89
|
-
frames_html << "<div
|
|
89
|
+
frames_html << "<div class=\"eo-hidden-frames\">" \
|
|
90
90
|
"… #{hidden} more stack frames hidden (truncated at #{MAX_FRAMES})</div>"
|
|
91
91
|
end
|
|
92
92
|
|
|
@@ -121,6 +121,10 @@ module Tina4
|
|
|
121
121
|
env_section = collapsible("Environment", table(env_pairs))
|
|
122
122
|
stack_section = collapsible("Stack Trace", frames_html, open_by_default: true)
|
|
123
123
|
|
|
124
|
+
# ADR-0088: one nonce'd <style> holds the whole overlay stylesheet; no
|
|
125
|
+
# element carries a style= attribute, because a nonce covers a <style>
|
|
126
|
+
# ELEMENT but never a style attribute. The value matches the CSP header.
|
|
127
|
+
nonce = Tina4::Csp.current_nonce
|
|
124
128
|
<<~HTML
|
|
125
129
|
<!DOCTYPE html>
|
|
126
130
|
<html lang="en">
|
|
@@ -128,25 +132,24 @@ module Tina4
|
|
|
128
132
|
<meta charset="utf-8">
|
|
129
133
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
130
134
|
<title>Tina4 Error — #{esc(exc_type)}</title>
|
|
131
|
-
<style>
|
|
132
|
-
|
|
133
|
-
body{background:#{BG};color:#{TEXT_COLOR};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
|
|
135
|
+
<style nonce="#{nonce}">
|
|
136
|
+
#{overlay_stylesheet}
|
|
134
137
|
</style>
|
|
135
138
|
</head>
|
|
136
139
|
<body>
|
|
137
|
-
<div
|
|
138
|
-
<div
|
|
139
|
-
<div
|
|
140
|
-
<span
|
|
141
|
-
<span
|
|
140
|
+
<div class="eo-wrap">
|
|
141
|
+
<div class="eo-header">
|
|
142
|
+
<div class="eo-badge-row">
|
|
143
|
+
<span class="eo-badge">Error</span>
|
|
144
|
+
<span class="eo-sub">Tina4 Debug Overlay</span>
|
|
142
145
|
</div>
|
|
143
|
-
<h1
|
|
144
|
-
<p
|
|
146
|
+
<h1 class="eo-type">#{esc(exc_type)}</h1>
|
|
147
|
+
<p class="eo-msg">#{esc(exc_msg)}</p>
|
|
145
148
|
</div>
|
|
146
149
|
#{stack_section}
|
|
147
150
|
#{request_section}
|
|
148
151
|
#{env_section}
|
|
149
|
-
<div
|
|
152
|
+
<div class="eo-footer">
|
|
150
153
|
Tina4 Debug Overlay — This page is only shown in debug mode. Set TINA4_DEBUG=false in production.
|
|
151
154
|
</div>
|
|
152
155
|
</div>
|
|
@@ -155,6 +158,45 @@ module Tina4
|
|
|
155
158
|
HTML
|
|
156
159
|
end
|
|
157
160
|
|
|
161
|
+
# The overlay's full stylesheet, served inside one nonce'd <style> block.
|
|
162
|
+
# Keeping the rules here (not on the elements) is what makes the overlay
|
|
163
|
+
# CSP-clean: no framework page emits a style="..." attribute.
|
|
164
|
+
def overlay_stylesheet
|
|
165
|
+
<<~CSS
|
|
166
|
+
*{margin:0;padding:0;box-sizing:border-box;}
|
|
167
|
+
body{background:#{BG};color:#{TEXT_COLOR};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
|
|
168
|
+
.eo-wrap{max-width:960px;margin:0 auto;}
|
|
169
|
+
.eo-header{margin-bottom:24px;}
|
|
170
|
+
.eo-badge-row{display:flex;align-items:center;gap:12px;margin-bottom:12px;}
|
|
171
|
+
.eo-badge{background:#{RED};color:#{BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;}
|
|
172
|
+
.eo-sub{color:#{SUBTEXT};font-size:14px;}
|
|
173
|
+
.eo-type{color:#{RED};font-size:28px;font-weight:700;margin-bottom:8px;}
|
|
174
|
+
.eo-msg{color:#{TEXT_COLOR};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:#{SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid #{RED};}
|
|
175
|
+
.eo-footer{margin-top:32px;padding-top:16px;border-top:1px solid #{OVERLAY_COLOR};color:#{SUBTEXT};font-size:12px;}
|
|
176
|
+
.eo-source{background:#{SURFACE};border-radius:6px;padding:12px;overflow-x:auto;font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;}
|
|
177
|
+
.eo-line{display:flex;padding:1px 0;}
|
|
178
|
+
.eo-line-err{background:#{ERROR_LINE_BG};}
|
|
179
|
+
.eo-ln{color:#{YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;}
|
|
180
|
+
.eo-marker{color:#{RED};width:1.2em;user-select:none;}
|
|
181
|
+
.eo-code{color:#{TEXT_COLOR};white-space:pre-wrap;tab-size:4;}
|
|
182
|
+
.eo-frame{margin-bottom:16px;}
|
|
183
|
+
.eo-frame-head{margin-bottom:4px;}
|
|
184
|
+
.eo-file{color:#{BLUE};}
|
|
185
|
+
.eo-sep{color:#{SUBTEXT};}
|
|
186
|
+
.eo-lineno{color:#{YELLOW};}
|
|
187
|
+
.eo-fn{color:#{GREEN};}
|
|
188
|
+
.eo-stale{background:#{PEACH};color:#{BG};padding:1px 8px;border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;}
|
|
189
|
+
.eo-details{margin-top:16px;}
|
|
190
|
+
.eo-summary{cursor:pointer;color:#{LAVENDER};font-weight:600;font-size:15px;padding:8px 0;user-select:none;}
|
|
191
|
+
.eo-details-body{padding:8px 0;}
|
|
192
|
+
.eo-table{border-collapse:collapse;width:100%;}
|
|
193
|
+
.eo-key{color:#{PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;}
|
|
194
|
+
.eo-val{color:#{TEXT_COLOR};padding:4px 0;word-break:break-all;}
|
|
195
|
+
.eo-none{color:#{SUBTEXT};}
|
|
196
|
+
.eo-hidden-frames{color:#{SUBTEXT};padding:8px 0;font-size:13px;}
|
|
197
|
+
CSS
|
|
198
|
+
end
|
|
199
|
+
|
|
158
200
|
# Return true if TINA4_DEBUG is enabled.
|
|
159
201
|
def is_debug_mode
|
|
160
202
|
Tina4::Env.is_truthy(ENV.fetch("TINA4_DEBUG", ""))
|
|
@@ -205,30 +247,28 @@ module Tina4
|
|
|
205
247
|
return "" if lines.empty?
|
|
206
248
|
|
|
207
249
|
rows = lines.map do |num, text, is_error|
|
|
208
|
-
|
|
250
|
+
row_class = is_error ? "eo-line eo-line-err" : "eo-line"
|
|
209
251
|
marker = is_error ? "▶" : " "
|
|
210
|
-
"<div
|
|
211
|
-
"<span
|
|
212
|
-
"<span
|
|
213
|
-
"<span
|
|
252
|
+
"<div class=\"#{row_class}\">" \
|
|
253
|
+
"<span class=\"eo-ln\">#{num}</span>" \
|
|
254
|
+
"<span class=\"eo-marker\">#{marker}</span>" \
|
|
255
|
+
"<span class=\"eo-code\">#{esc(text)}</span>" \
|
|
214
256
|
"</div>"
|
|
215
257
|
end.join("\n")
|
|
216
258
|
|
|
217
|
-
"<div
|
|
218
|
-
"font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;\">" \
|
|
219
|
-
"#{rows}</div>"
|
|
259
|
+
"<div class=\"eo-source\">#{rows}</div>"
|
|
220
260
|
end
|
|
221
261
|
|
|
222
262
|
def format_frame(filename, lineno, func_name, captured_at: 0.0)
|
|
223
263
|
source = (filename && lineno.positive?) ? format_source_block(filename, lineno) : ""
|
|
224
264
|
stale_badge = stale_file_badge(filename, captured_at)
|
|
225
|
-
"<div
|
|
226
|
-
"<div
|
|
227
|
-
"<span
|
|
228
|
-
"<span
|
|
229
|
-
"<span
|
|
230
|
-
"<span
|
|
231
|
-
"<span
|
|
265
|
+
"<div class=\"eo-frame\">" \
|
|
266
|
+
"<div class=\"eo-frame-head\">" \
|
|
267
|
+
"<span class=\"eo-file\">#{esc(filename.to_s)}</span>" \
|
|
268
|
+
"<span class=\"eo-sep\"> : </span>" \
|
|
269
|
+
"<span class=\"eo-lineno\">#{lineno}</span>" \
|
|
270
|
+
"<span class=\"eo-sep\"> in </span>" \
|
|
271
|
+
"<span class=\"eo-fn\">#{esc(func_name.to_s)}</span>" \
|
|
232
272
|
"#{stale_badge}" \
|
|
233
273
|
"</div>" \
|
|
234
274
|
"#{source}" \
|
|
@@ -247,8 +287,7 @@ module Tina4
|
|
|
247
287
|
return "" if mtime <= captured_at + 0.5
|
|
248
288
|
|
|
249
289
|
mtime_str = Time.at(mtime).utc.strftime("%H:%M:%S")
|
|
250
|
-
"<span
|
|
251
|
-
"border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;\">" \
|
|
290
|
+
"<span class=\"eo-stale\">" \
|
|
252
291
|
"FILE MODIFIED @ #{mtime_str} UTC — source may not match what failed" \
|
|
253
292
|
"</span>"
|
|
254
293
|
rescue StandardError
|
|
@@ -257,23 +296,22 @@ module Tina4
|
|
|
257
296
|
|
|
258
297
|
def collapsible(title, content, open_by_default: false)
|
|
259
298
|
open_attr = open_by_default ? " open" : ""
|
|
260
|
-
"<details
|
|
261
|
-
"<summary
|
|
262
|
-
"
|
|
263
|
-
"<div style=\"padding:8px 0;\">#{content}</div>" \
|
|
299
|
+
"<details class=\"eo-details\"#{open_attr}>" \
|
|
300
|
+
"<summary class=\"eo-summary\">#{esc(title)}</summary>" \
|
|
301
|
+
"<div class=\"eo-details-body\">#{content}</div>" \
|
|
264
302
|
"</details>"
|
|
265
303
|
end
|
|
266
304
|
|
|
267
305
|
def table(pairs)
|
|
268
|
-
return "<span
|
|
306
|
+
return "<span class=\"eo-none\">None</span>" if pairs.empty?
|
|
269
307
|
|
|
270
308
|
rows = pairs.map do |key, val|
|
|
271
309
|
"<tr>" \
|
|
272
|
-
"<td
|
|
273
|
-
"<td
|
|
310
|
+
"<td class=\"eo-key\">#{esc(key)}</td>" \
|
|
311
|
+
"<td class=\"eo-val\">#{esc(val)}</td>" \
|
|
274
312
|
"</tr>"
|
|
275
313
|
end.join
|
|
276
|
-
"<table
|
|
314
|
+
"<table class=\"eo-table\">#{rows}</table>"
|
|
277
315
|
end
|
|
278
316
|
end
|
|
279
317
|
end
|
data/lib/tina4/frond.rb
CHANGED
|
@@ -3184,6 +3184,13 @@ module Tina4
|
|
|
3184
3184
|
# Both this global and the |dump filter call Frond.render_dump which
|
|
3185
3185
|
# returns an empty SafeString in production so dump never leaks state.
|
|
3186
3186
|
@globals["dump"] = ->(value = nil) { Frond.render_dump(value) }
|
|
3187
|
+
|
|
3188
|
+
# CSP nonce: {{ csp_nonce() }} returns the current response's nonce so a
|
|
3189
|
+
# template can serve an inline <style>/<script> under the strict default
|
|
3190
|
+
# Content-Security-Policy — <style nonce="{{ csp_nonce() }}">. The value
|
|
3191
|
+
# matches the 'nonce-X' the security middleware puts in the CSP header
|
|
3192
|
+
# (ADR-0088).
|
|
3193
|
+
@globals["csp_nonce"] = -> { Tina4::Csp.current_nonce }
|
|
3187
3194
|
end
|
|
3188
3195
|
|
|
3189
3196
|
# Render a value as a pre-formatted inspect() wrapped in <pre> tags.
|
|
@@ -7,15 +7,27 @@
|
|
|
7
7
|
# Gallery: Auth — JWT login with a visual demo page.
|
|
8
8
|
|
|
9
9
|
Tina4::Router.get("/gallery/auth") do |request, response|
|
|
10
|
+
# ADR-0088: this page's inline <style>/<script> carry the response's CSP nonce
|
|
11
|
+
# so they run under the strict default policy; no inline style=/onclick=.
|
|
12
|
+
nonce = response.csp_nonce
|
|
10
13
|
html = <<~HTML
|
|
11
14
|
<!DOCTYPE html>
|
|
12
15
|
<html lang="en">
|
|
13
16
|
<head>
|
|
14
17
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
15
18
|
<title>Auth Demo</title><link rel="stylesheet" href="/css/tina4.min.css">
|
|
19
|
+
<style nonce="#{nonce}">
|
|
20
|
+
.auth-demo{max-width:600px}
|
|
21
|
+
.is-hidden{display:none}
|
|
22
|
+
.token-box{word-break:break-all;white-space:pre-wrap;color:#4ade80;background:#1e293b;padding:1rem;border-radius:0.5rem}
|
|
23
|
+
.payload-box{color:#38bdf8;background:#1e293b;padding:1rem;border-radius:0.5rem}
|
|
24
|
+
.howto-card{border:1px solid #334155}
|
|
25
|
+
.howto-heading{color:#e2e8f0}
|
|
26
|
+
.howto-code{background:#0f172a;color:#4ade80;padding:1rem;border-radius:0.5rem;font-size:0.8rem}
|
|
27
|
+
</style>
|
|
16
28
|
</head>
|
|
17
29
|
<body class="bg-dark text-light">
|
|
18
|
-
<div class="container mt-5
|
|
30
|
+
<div class="container mt-5 auth-demo">
|
|
19
31
|
<h2 class="mb-4">JWT Authentication Demo</h2>
|
|
20
32
|
<div class="card mb-4">
|
|
21
33
|
<div class="card-header bg-primary text-white">Login</div>
|
|
@@ -28,35 +40,35 @@ Tina4::Router.get("/gallery/auth") do |request, response|
|
|
|
28
40
|
<label class="form-label">Password</label>
|
|
29
41
|
<input type="password" id="password" class="form-control" placeholder="secret" value="secret">
|
|
30
42
|
</div>
|
|
31
|
-
<button class="btn btn-primary"
|
|
43
|
+
<button class="btn btn-primary" id="loginBtn">Login</button>
|
|
32
44
|
</div>
|
|
33
45
|
</div>
|
|
34
|
-
<div id="result"
|
|
46
|
+
<div id="result" class="is-hidden">
|
|
35
47
|
<div class="card mb-3">
|
|
36
48
|
<div class="card-header bg-success text-white">Token Received</div>
|
|
37
49
|
<div class="card-body">
|
|
38
|
-
<pre id="token"
|
|
50
|
+
<pre id="token" class="token-box"></pre>
|
|
39
51
|
</div>
|
|
40
52
|
</div>
|
|
41
53
|
<div class="card mb-3">
|
|
42
54
|
<div class="card-header">Token Payload (decoded)</div>
|
|
43
55
|
<div class="card-body">
|
|
44
|
-
<pre id="payload"
|
|
56
|
+
<pre id="payload" class="payload-box"></pre>
|
|
45
57
|
</div>
|
|
46
58
|
</div>
|
|
47
|
-
<button class="btn btn-outline-info"
|
|
59
|
+
<button class="btn btn-outline-info" id="verifyBtn">Verify Token</button>
|
|
48
60
|
<span id="verify-result" class="ms-2"></span>
|
|
49
61
|
</div>
|
|
50
|
-
<div class="card bg-dark mt-4
|
|
62
|
+
<div class="card bg-dark mt-4 howto-card">
|
|
51
63
|
<div class="card-body">
|
|
52
|
-
<h6
|
|
53
|
-
<pre
|
|
64
|
+
<h6 class="howto-heading">How it works</h6>
|
|
65
|
+
<pre class="howto-code"><code>token = Tina4::Auth.create_token({ username: "admin" })
|
|
54
66
|
payload = Tina4::Auth.get_payload(token)
|
|
55
67
|
result = Tina4::Auth.validate_token(token)</code></pre>
|
|
56
68
|
</div>
|
|
57
69
|
</div>
|
|
58
70
|
</div>
|
|
59
|
-
<script>
|
|
71
|
+
<script nonce="#{nonce}">
|
|
60
72
|
var currentToken = '';
|
|
61
73
|
function doLogin() {
|
|
62
74
|
fetch('/api/gallery/auth/login', {
|
|
@@ -77,7 +89,7 @@ Tina4::Router.get("/gallery/auth") do |request, response|
|
|
|
77
89
|
} catch(e) {
|
|
78
90
|
document.getElementById('payload').textContent = 'Could not decode';
|
|
79
91
|
}
|
|
80
|
-
document.getElementById('result').
|
|
92
|
+
document.getElementById('result').classList.remove('is-hidden');
|
|
81
93
|
document.getElementById('verify-result').textContent = '';
|
|
82
94
|
} else {
|
|
83
95
|
alert(d.error || 'Login failed');
|
|
@@ -95,6 +107,8 @@ Tina4::Router.get("/gallery/auth") do |request, response|
|
|
|
95
107
|
}
|
|
96
108
|
});
|
|
97
109
|
}
|
|
110
|
+
document.getElementById('loginBtn').addEventListener('click', doLogin);
|
|
111
|
+
document.getElementById('verifyBtn').addEventListener('click', verifyToken);
|
|
98
112
|
</script>
|
|
99
113
|
</body></html>
|
|
100
114
|
HTML
|