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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/lib/tina4/cli.rb +7 -2
  4. data/lib/tina4/crud.rb +61 -20
  5. data/lib/tina4/csp.rb +119 -0
  6. data/lib/tina4/error_overlay.rb +79 -43
  7. data/lib/tina4/frond.rb +7 -0
  8. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +25 -11
  9. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +30 -10
  10. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +34 -15
  11. data/lib/tina4/graphql.rb +9 -3
  12. data/lib/tina4/job.rb +17 -0
  13. data/lib/tina4/middleware.rb +16 -6
  14. data/lib/tina4/migration.rb +49 -41
  15. data/lib/tina4/queue_backends/kafka_backend.rb +12 -31
  16. data/lib/tina4/queue_backends/rabbitmq_backend.rb +15 -37
  17. data/lib/tina4/queue_backends/republish_retry_policy.rb +35 -0
  18. data/lib/tina4/rack_app.rb +70 -23
  19. data/lib/tina4/response.rb +6 -1
  20. data/lib/tina4/session.rb +145 -10
  21. data/lib/tina4/session_handlers/redis_handler.rb +7 -51
  22. data/lib/tina4/session_handlers/resp_session_handler.rb +75 -0
  23. data/lib/tina4/session_handlers/valkey_handler.rb +6 -51
  24. data/lib/tina4/template.rb +11 -3
  25. data/lib/tina4/templates/errors/302.twig +1 -1
  26. data/lib/tina4/templates/errors/401.twig +2 -2
  27. data/lib/tina4/templates/errors/403.twig +1 -1
  28. data/lib/tina4/templates/errors/404.twig +1 -1
  29. data/lib/tina4/templates/errors/500.twig +1 -1
  30. data/lib/tina4/templates/errors/502.twig +2 -2
  31. data/lib/tina4/templates/errors/503.twig +2 -2
  32. data/lib/tina4/templates/errors/base.twig +4 -1
  33. data/lib/tina4/version.rb +1 -1
  34. data/lib/tina4/webserver.rb +30 -14
  35. data/lib/tina4/websocket.rb +26 -2
  36. data/lib/tina4/websocket_backplane.rb +16 -13
  37. data/lib/tina4.rb +1 -0
  38. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0aa7d16e6192a9b815d104ed73e28d9dcfc0059017c8980cd2fa6593f23f3c95
4
- data.tar.gz: c9eb3c6c4141f58c7539fc65ad1d7596dca9602968b1094d6c6a3b236492aa6b
3
+ metadata.gz: 641d95c3ab74eeac0ce2b273ee7c6c44ca8aaab5e7e2a06d8e668eacffc5fb28
4
+ data.tar.gz: f8ad58627e7263bf560b499dc40fbd249d3267516d61b2c31696051f80b11852
5
5
  SHA512:
6
- metadata.gz: 11b4e161b6412ca687ff1022e1575336f5aeb6b17c54ee72d05db86d045cba8b5e4e5aa52c126fe7f761a234fcfec5215b8cd8a4ae5284c5738f84a4dea91b13
7
- data.tar.gz: b80d9c080a764c3ecd5912f6a7081e61f5196e1f751a86a52da62612f46223bea3148c203dbc549846ba916aadda20c831a1310472179dcd3f888da016624323
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
- <div class="container mt-4" style="max-width:400px">
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
- <div class="container mt-4" style="max-width:400px">
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\" onclick=\"crudSave('#{table_name}', '#{pk_value}')\">Save</button>"
130
- html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudDelete('#{table_name}', '#{pk_value}')\">Delete</button>"
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
+ # & < > " ' -> &amp; &lt; &gt; &quot; &#39;) 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.to_s
413
- .gsub("&", "&amp;")
414
- .gsub("<", "&lt;")
415
- .gsub(">", "&gt;")
416
- .gsub('"', "&quot;")
417
- .gsub("'", "&#39;")
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" onclick="crudShowCreate()">+ New</button>
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\" onclick=\"crudShowEdit(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Edit</button>"
532
- html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudShowDelete(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Delete</button>"
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" onclick="crudCloseModal('delete')">Cancel</button>
573
- <button class="btn btn-danger" onclick="crudConfirmDelete()">Delete</button>
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}\" onsubmit=\"return false;\">"
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\" onclick=\"crudCloseModal('#{id}')\">Cancel</button>"
605
- html += "<button type=\"button\" class=\"btn btn-primary\" onclick=\"crudSave#{edit ? 'Edit' : 'Create'}()\">Save</button>"
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
@@ -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 style=\"color:#{SUBTEXT};padding:8px 0;font-size:13px;\">" \
89
+ frames_html << "<div class=\"eo-hidden-frames\">" \
90
90
  "&#8230; #{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
- *{margin:0;padding:0;box-sizing:border-box;}
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 style="max-width:960px;margin:0 auto;">
138
- <div style="margin-bottom:24px;">
139
- <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
140
- <span style="background:#{RED};color:#{BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
141
- <span style="color:#{SUBTEXT};font-size:14px;">Tina4 Debug Overlay</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 style="color:#{RED};font-size:28px;font-weight:700;margin-bottom:8px;">#{esc(exc_type)}</h1>
144
- <p style="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};">#{esc(exc_msg)}</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 style="margin-top:32px;padding-top:16px;border-top:1px solid #{OVERLAY_COLOR};color:#{SUBTEXT};font-size:12px;">
152
+ <div class="eo-footer">
150
153
  Tina4 Debug Overlay &mdash; 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 & < > " ' -> &amp; &lt; &gt; &quot; &#39;)
216
+ # rather than keeping a private copy of the escape table here.
172
217
  def esc(text)
173
- text.to_s
174
- .gsub("&", "&amp;")
175
- .gsub("<", "&lt;")
176
- .gsub(">", "&gt;")
177
- .gsub('"', "&quot;")
178
- .gsub("'", "&#39;")
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
- bg = is_error ? "background:#{ERROR_LINE_BG};" : ""
250
+ row_class = is_error ? "eo-line eo-line-err" : "eo-line"
211
251
  marker = is_error ? "&#x25b6;" : " "
212
- "<div style=\"#{bg}display:flex;padding:1px 0;\">" \
213
- "<span style=\"color:#{YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;\">#{num}</span>" \
214
- "<span style=\"color:#{RED};width:1.2em;user-select:none;\">#{marker}</span>" \
215
- "<span style=\"color:#{TEXT_COLOR};white-space:pre-wrap;tab-size:4;\">#{esc(text)}</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 style=\"background:#{SURFACE};border-radius:6px;padding:12px;overflow-x:auto;" \
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 style=\"margin-bottom:16px;\">" \
228
- "<div style=\"margin-bottom:4px;\">" \
229
- "<span style=\"color:#{BLUE};\">#{esc(filename.to_s)}</span>" \
230
- "<span style=\"color:#{SUBTEXT};\"> : </span>" \
231
- "<span style=\"color:#{YELLOW};\">#{lineno}</span>" \
232
- "<span style=\"color:#{SUBTEXT};\"> in </span>" \
233
- "<span style=\"color:#{GREEN};\">#{esc(func_name.to_s)}</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 style=\"background:#{PEACH};color:#{BG};padding:1px 8px;" \
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 &mdash; 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 style=\"margin-top:16px;\"#{open_attr}>" \
263
- "<summary style=\"cursor:pointer;color:#{LAVENDER};font-weight:600;font-size:15px;" \
264
- "padding:8px 0;user-select:none;\">#{esc(title)}</summary>" \
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 style=\"color:#{SUBTEXT};\">None</span>" if pairs.empty?
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 style=\"color:#{PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;\">#{esc(key)}</td>" \
275
- "<td style=\"color:#{TEXT_COLOR};padding:4px 0;word-break:break-all;\">#{esc(val)}</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 style=\"border-collapse:collapse;width:100%;\">#{rows}</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.