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
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,28 @@ 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
|
+
|
|
13
|
+
## 3.13.142 — 2026-09-30
|
|
14
|
+
|
|
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.
|
|
16
|
+
|
|
17
|
+
### Fixes
|
|
18
|
+
- WebSocket now delivers a fragmented message whole. A client that splits a payload across continuation frames (RFC 6455 §5.4) used to have each fragment handed over on its own, so the handler saw pieces instead of the message. The frames are reassembled and delivered once, complete (#87).
|
|
19
|
+
- `session.regenerate` mid-request re-emits the session cookie, so a login that rotates the id keeps the browser in step instead of dropping it on the next request. Parity with the same fix in the php framework (tina4-php#253) (#90).
|
|
20
|
+
- The gallery queue reads a bounded batch instead of draining without limit, and the NATS backplane keeper stops busy-polling and blocks for work instead. Less wheel-spinning, the same behaviour (#89).
|
|
21
|
+
- A flaky serve-debug spec now polls `/__dev` for real readiness and retries a transient boot, so the suite stops tripping over its own startup timing (#91).
|
|
22
|
+
|
|
23
|
+
### Refactors
|
|
24
|
+
- Cyclomatic complexity comes down across the flagged modules, and all six cross-file duplicate blocks in `lib/tina4` are gone, each one characterised first so the behaviour cannot shift (#86, #88).
|
|
25
|
+
|
|
26
|
+
### Chore
|
|
27
|
+
- `TINA4_TEST_NATS_URL` joins the canonical test-env set (ADR-0038), so the NATS URL is named the same way everywhere.
|
|
28
|
+
- The nats-pure dependency and its transitive gems were licence-reviewed, unblocking the release-package workflow.
|
|
29
|
+
- CI wires the `tina4 metrics --fail-on-regression` ratchet gate with a committed baseline (ADR-0002), so a new complexity offender fails the build instead of drifting in unseen (#92).
|
|
30
|
+
|
|
9
31
|
## 3.13.141 — 2026-09-29
|
|
10
32
|
|
|
11
33
|
A hardening release. The sibling php framework shipped committed absolute symlinks that broke `composer install` on Windows, so every framework now refuses a committed symlink up front. Ruby carries no such link today, so this guard is preventive parity: it keeps the door shut before anything can wander in. 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>"
|
|
@@ -407,14 +407,12 @@ module Tina4
|
|
|
407
407
|
}
|
|
408
408
|
end
|
|
409
409
|
|
|
410
|
-
# Escape HTML special characters
|
|
410
|
+
# Escape HTML special characters. Delegates to the framework's one
|
|
411
|
+
# canonical escaper (Frond.escape_html, single-pass, byte-identical
|
|
412
|
+
# & < > " ' -> & < > " ') so CRUD and the error
|
|
413
|
+
# overlay never carry their own private copy of the HTML escape table.
|
|
411
414
|
def h(text)
|
|
412
|
-
text
|
|
413
|
-
.gsub("&", "&")
|
|
414
|
-
.gsub("<", "<")
|
|
415
|
-
.gsub(">", ">")
|
|
416
|
-
.gsub('"', """)
|
|
417
|
-
.gsub("'", "'")
|
|
415
|
+
Tina4::Frond.escape_html(text)
|
|
418
416
|
end
|
|
419
417
|
|
|
420
418
|
# Pretty label from a column name: "user_name" => "User Name"
|
|
@@ -459,7 +457,7 @@ module Tina4
|
|
|
459
457
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
460
458
|
<title>#{h(title)}</title>
|
|
461
459
|
<link rel="stylesheet" href="/css/tina4.min.css">
|
|
462
|
-
<style>
|
|
460
|
+
<style nonce="#{Tina4::Csp.current_nonce}">
|
|
463
461
|
.crud-container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
|
|
464
462
|
.crud-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
|
|
465
463
|
.crud-search { max-width: 300px; }
|
|
@@ -493,7 +491,7 @@ module Tina4
|
|
|
493
491
|
class="form-control crud-search">
|
|
494
492
|
<button type="submit" class="btn btn-secondary">Search</button>
|
|
495
493
|
</form>
|
|
496
|
-
<button class="btn btn-primary"
|
|
494
|
+
<button class="btn btn-primary" data-crud-action="create">+ New</button>
|
|
497
495
|
</div>
|
|
498
496
|
</div>
|
|
499
497
|
<div class="crud-info">
|
|
@@ -528,8 +526,8 @@ module Tina4
|
|
|
528
526
|
html += "<td>#{h(value)}</td>"
|
|
529
527
|
end
|
|
530
528
|
html += "<td>"
|
|
531
|
-
html += "<button class=\"btn btn-sm btn-primary me-1\"
|
|
532
|
-
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>"
|
|
533
531
|
html += "</td></tr>"
|
|
534
532
|
end
|
|
535
533
|
end
|
|
@@ -569,8 +567,8 @@ module Tina4
|
|
|
569
567
|
<p>Are you sure you want to delete this record? This action cannot be undone.</p>
|
|
570
568
|
<input type="hidden" id="delete-pk-value">
|
|
571
569
|
<div class="modal-footer">
|
|
572
|
-
<button class="btn btn-secondary"
|
|
573
|
-
<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>
|
|
574
572
|
</div>
|
|
575
573
|
</div>
|
|
576
574
|
</div>
|
|
@@ -588,7 +586,7 @@ module Tina4
|
|
|
588
586
|
html = "<div class=\"modal-overlay\" id=\"modal-#{id}\">"
|
|
589
587
|
html += "<div class=\"modal-box\">"
|
|
590
588
|
html += "<h3>#{h(title)}</h3>"
|
|
591
|
-
html += "<form id=\"form-#{id}\"
|
|
589
|
+
html += "<form id=\"form-#{id}\" data-crud-form=\"1\">"
|
|
592
590
|
html += "<input type=\"hidden\" id=\"#{id}-pk-value\" name=\"#{pk}\">" if edit
|
|
593
591
|
|
|
594
592
|
columns.each do |col|
|
|
@@ -601,8 +599,8 @@ module Tina4
|
|
|
601
599
|
end
|
|
602
600
|
|
|
603
601
|
html += "<div class=\"modal-footer\">"
|
|
604
|
-
html += "<button type=\"button\" class=\"btn btn-secondary\"
|
|
605
|
-
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>"
|
|
606
604
|
html += "</div></form></div></div>"
|
|
607
605
|
html
|
|
608
606
|
end
|
|
@@ -611,7 +609,7 @@ module Tina4
|
|
|
611
609
|
def build_crud_javascript(api_path, pk, columns, request_path)
|
|
612
610
|
columns_json = JSON.generate(columns.map(&:to_s))
|
|
613
611
|
<<~HTML
|
|
614
|
-
<script>
|
|
612
|
+
<script nonce="#{Tina4::Csp.current_nonce}">
|
|
615
613
|
var CRUD_API = '#{api_path}';
|
|
616
614
|
var CRUD_PK = '#{pk}';
|
|
617
615
|
var CRUD_COLUMNS = #{columns_json};
|
|
@@ -710,6 +708,37 @@ module Tina4
|
|
|
710
708
|
.catch(function(e) { crudShowAlert('Failed to delete: ' + e, 'danger'); });
|
|
711
709
|
}
|
|
712
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
|
+
|
|
713
742
|
// Close modal on overlay click
|
|
714
743
|
document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
|
|
715
744
|
overlay.addEventListener('click', function(e) {
|
|
@@ -731,7 +760,7 @@ module Tina4
|
|
|
731
760
|
|
|
732
761
|
def inline_crud_javascript(table_name)
|
|
733
762
|
<<~JS
|
|
734
|
-
<script>
|
|
763
|
+
<script nonce="#{Tina4::Csp.current_nonce}">
|
|
735
764
|
function crudSave(table, id) {
|
|
736
765
|
const row = document.querySelector(`tr[data-id="${id}"]`);
|
|
737
766
|
const cells = row.querySelectorAll('td[data-field]');
|
|
@@ -750,6 +779,18 @@ module Tina4
|
|
|
750
779
|
.then(d => { document.querySelector(`tr[data-id="${id}"]`).remove(); })
|
|
751
780
|
.catch(e => alert('Error: ' + e));
|
|
752
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
|
+
});
|
|
753
794
|
</script>
|
|
754
795
|
JS
|
|
755
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", ""))
|
|
@@ -169,13 +211,11 @@ module Tina4
|
|
|
169
211
|
key.to_s.match?(SENSITIVE_KEY_RE) ? REDACTED : value
|
|
170
212
|
end
|
|
171
213
|
|
|
214
|
+
# Delegates to the framework's one canonical escaper (Frond.escape_html,
|
|
215
|
+
# single-pass, byte-identical & < > " ' -> & < > " ')
|
|
216
|
+
# rather than keeping a private copy of the escape table here.
|
|
172
217
|
def esc(text)
|
|
173
|
-
text
|
|
174
|
-
.gsub("&", "&")
|
|
175
|
-
.gsub("<", "<")
|
|
176
|
-
.gsub(">", ">")
|
|
177
|
-
.gsub('"', """)
|
|
178
|
-
.gsub("'", "'")
|
|
218
|
+
Tina4::Frond.escape_html(text)
|
|
179
219
|
end
|
|
180
220
|
|
|
181
221
|
def parse_backtrace_line(line)
|
|
@@ -207,30 +247,28 @@ module Tina4
|
|
|
207
247
|
return "" if lines.empty?
|
|
208
248
|
|
|
209
249
|
rows = lines.map do |num, text, is_error|
|
|
210
|
-
|
|
250
|
+
row_class = is_error ? "eo-line eo-line-err" : "eo-line"
|
|
211
251
|
marker = is_error ? "▶" : " "
|
|
212
|
-
"<div
|
|
213
|
-
"<span
|
|
214
|
-
"<span
|
|
215
|
-
"<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>" \
|
|
216
256
|
"</div>"
|
|
217
257
|
end.join("\n")
|
|
218
258
|
|
|
219
|
-
"<div
|
|
220
|
-
"font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;\">" \
|
|
221
|
-
"#{rows}</div>"
|
|
259
|
+
"<div class=\"eo-source\">#{rows}</div>"
|
|
222
260
|
end
|
|
223
261
|
|
|
224
262
|
def format_frame(filename, lineno, func_name, captured_at: 0.0)
|
|
225
263
|
source = (filename && lineno.positive?) ? format_source_block(filename, lineno) : ""
|
|
226
264
|
stale_badge = stale_file_badge(filename, captured_at)
|
|
227
|
-
"<div
|
|
228
|
-
"<div
|
|
229
|
-
"<span
|
|
230
|
-
"<span
|
|
231
|
-
"<span
|
|
232
|
-
"<span
|
|
233
|
-
"<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>" \
|
|
234
272
|
"#{stale_badge}" \
|
|
235
273
|
"</div>" \
|
|
236
274
|
"#{source}" \
|
|
@@ -249,8 +287,7 @@ module Tina4
|
|
|
249
287
|
return "" if mtime <= captured_at + 0.5
|
|
250
288
|
|
|
251
289
|
mtime_str = Time.at(mtime).utc.strftime("%H:%M:%S")
|
|
252
|
-
"<span
|
|
253
|
-
"border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;\">" \
|
|
290
|
+
"<span class=\"eo-stale\">" \
|
|
254
291
|
"FILE MODIFIED @ #{mtime_str} UTC — source may not match what failed" \
|
|
255
292
|
"</span>"
|
|
256
293
|
rescue StandardError
|
|
@@ -259,23 +296,22 @@ module Tina4
|
|
|
259
296
|
|
|
260
297
|
def collapsible(title, content, open_by_default: false)
|
|
261
298
|
open_attr = open_by_default ? " open" : ""
|
|
262
|
-
"<details
|
|
263
|
-
"<summary
|
|
264
|
-
"
|
|
265
|
-
"<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>" \
|
|
266
302
|
"</details>"
|
|
267
303
|
end
|
|
268
304
|
|
|
269
305
|
def table(pairs)
|
|
270
|
-
return "<span
|
|
306
|
+
return "<span class=\"eo-none\">None</span>" if pairs.empty?
|
|
271
307
|
|
|
272
308
|
rows = pairs.map do |key, val|
|
|
273
309
|
"<tr>" \
|
|
274
|
-
"<td
|
|
275
|
-
"<td
|
|
310
|
+
"<td class=\"eo-key\">#{esc(key)}</td>" \
|
|
311
|
+
"<td class=\"eo-val\">#{esc(val)}</td>" \
|
|
276
312
|
"</tr>"
|
|
277
313
|
end.join
|
|
278
|
-
"<table
|
|
314
|
+
"<table class=\"eo-table\">#{rows}</table>"
|
|
279
315
|
end
|
|
280
316
|
end
|
|
281
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.
|