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
|
@@ -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
|
|
@@ -39,6 +39,13 @@ end
|
|
|
39
39
|
|
|
40
40
|
GALLERY_QUEUE_MAX_RETRIES = 3
|
|
41
41
|
|
|
42
|
+
# The next message to consume or fail: the highest-priority pending one whose
|
|
43
|
+
# delay has passed. LIMIT 1 keeps the database from sorting and returning every
|
|
44
|
+
# pending row when the demo only ever wants the first (Carbonah E003).
|
|
45
|
+
GALLERY_QUEUE_NEXT_PENDING_SQL =
|
|
46
|
+
"SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' " \
|
|
47
|
+
"AND available_at <= ? ORDER BY priority DESC, id ASC LIMIT 1"
|
|
48
|
+
|
|
42
49
|
GALLERY_QUEUE_HTML = <<~'HTML'
|
|
43
50
|
<!DOCTYPE html>
|
|
44
51
|
<html lang="en">
|
|
@@ -60,7 +67,7 @@ GALLERY_QUEUE_HTML = <<~'HTML'
|
|
|
60
67
|
<div class="card-body">
|
|
61
68
|
<div class="d-flex gap-2">
|
|
62
69
|
<input type="text" id="msgInput" class="form-control" placeholder="Enter a task message, e.g. send-email">
|
|
63
|
-
<button class="btn btn-primary"
|
|
70
|
+
<button class="btn btn-primary" id="produceBtn">Produce</button>
|
|
64
71
|
</div>
|
|
65
72
|
</div>
|
|
66
73
|
</div>
|
|
@@ -69,10 +76,10 @@ GALLERY_QUEUE_HTML = <<~'HTML'
|
|
|
69
76
|
<div class="card">
|
|
70
77
|
<div class="card-header">Actions</div>
|
|
71
78
|
<div class="card-body d-flex gap-2 flex-wrap">
|
|
72
|
-
<button class="btn btn-success"
|
|
73
|
-
<button class="btn btn-danger"
|
|
74
|
-
<button class="btn btn-warning"
|
|
75
|
-
<button class="btn btn-secondary"
|
|
79
|
+
<button class="btn btn-success" id="consumeBtn">Consume Next</button>
|
|
80
|
+
<button class="btn btn-danger" id="failBtn">Fail Next</button>
|
|
81
|
+
<button class="btn btn-warning" id="retryBtn">Retry Failed</button>
|
|
82
|
+
<button class="btn btn-secondary" id="refreshBtn">Refresh</button>
|
|
76
83
|
</div>
|
|
77
84
|
</div>
|
|
78
85
|
</div>
|
|
@@ -105,7 +112,7 @@ GALLERY_QUEUE_HTML = <<~'HTML'
|
|
|
105
112
|
</div>
|
|
106
113
|
</div>
|
|
107
114
|
|
|
108
|
-
<script>
|
|
115
|
+
<script nonce="__CSP_NONCE__">
|
|
109
116
|
function statusBadge(status) {
|
|
110
117
|
var colors = {pending:"primary", reserved:"warning", completed:"success", failed:"danger", dead:"secondary"};
|
|
111
118
|
var color = colors[status] || "secondary";
|
|
@@ -115,7 +122,7 @@ function statusBadge(status) {
|
|
|
115
122
|
function showAlert(msg, type) {
|
|
116
123
|
var area = document.getElementById("alertArea");
|
|
117
124
|
area.innerHTML = '<div class="alert alert-' + type + ' alert-dismissible">' + msg +
|
|
118
|
-
'<button type="button" class="btn-close
|
|
125
|
+
'<button type="button" class="btn-close js-alert-close"></button></div>';
|
|
119
126
|
setTimeout(function(){ area.innerHTML = ""; }, 3000);
|
|
120
127
|
}
|
|
121
128
|
|
|
@@ -189,6 +196,16 @@ async function retryFailed() {
|
|
|
189
196
|
refresh();
|
|
190
197
|
}
|
|
191
198
|
|
|
199
|
+
// CSP-clean wiring: no inline onclick — bind every control via addEventListener.
|
|
200
|
+
document.getElementById("produceBtn").addEventListener("click", produce);
|
|
201
|
+
document.getElementById("consumeBtn").addEventListener("click", consume);
|
|
202
|
+
document.getElementById("failBtn").addEventListener("click", failNext);
|
|
203
|
+
document.getElementById("retryBtn").addEventListener("click", retryFailed);
|
|
204
|
+
document.getElementById("refreshBtn").addEventListener("click", refresh);
|
|
205
|
+
document.getElementById("alertArea").addEventListener("click", function(e){
|
|
206
|
+
if (e.target.classList.contains("js-alert-close")) e.target.parentElement.remove();
|
|
207
|
+
});
|
|
208
|
+
|
|
192
209
|
refresh();
|
|
193
210
|
setInterval(refresh, 2000);
|
|
194
211
|
</script>
|
|
@@ -199,7 +216,10 @@ HTML
|
|
|
199
216
|
# ── Render the interactive HTML page ──────────────────────────
|
|
200
217
|
|
|
201
218
|
Tina4::Router.get("/gallery/queue") do |request, response|
|
|
202
|
-
response
|
|
219
|
+
# ADR-0088: stamp this response's CSP nonce on the inline <script> so it runs
|
|
220
|
+
# under the strict default policy (the HTML is a frozen, non-interpolated
|
|
221
|
+
# constant, so the nonce is substituted at serve time).
|
|
222
|
+
response.html(GALLERY_QUEUE_HTML.gsub("__CSP_NONCE__", response.csp_nonce))
|
|
203
223
|
end
|
|
204
224
|
|
|
205
225
|
# ── Produce — add a message to the queue ──────────────────────
|
|
@@ -273,7 +293,7 @@ Tina4::Router.post("/api/gallery/queue/consume") do |request, response|
|
|
|
273
293
|
now = _gallery_queue_now
|
|
274
294
|
|
|
275
295
|
row = db.fetch_one(
|
|
276
|
-
|
|
296
|
+
GALLERY_QUEUE_NEXT_PENDING_SQL,
|
|
277
297
|
["gallery-tasks", now]
|
|
278
298
|
)
|
|
279
299
|
|
|
@@ -295,7 +315,7 @@ Tina4::Router.post("/api/gallery/queue/fail") do |request, response|
|
|
|
295
315
|
now = _gallery_queue_now
|
|
296
316
|
|
|
297
317
|
row = db.fetch_one(
|
|
298
|
-
|
|
318
|
+
GALLERY_QUEUE_NEXT_PENDING_SQL,
|
|
299
319
|
["gallery-tasks", now]
|
|
300
320
|
)
|
|
301
321
|
|
|
@@ -5,10 +5,23 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>{{ title }}</title>
|
|
7
7
|
<link rel="stylesheet" href="/css/tina4.min.css">
|
|
8
|
-
<style>
|
|
8
|
+
<style nonce="{{ csp_nonce() }}">
|
|
9
9
|
.component-section { margin-bottom: 2.5rem; }
|
|
10
10
|
.component-section h3 { border-bottom: 2px solid var(--bs-primary, #0d6efd); padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
|
11
11
|
.code-preview { background: #1e293b; color: #4ade80; padding: 1rem; border-radius: 0.5rem; font-family: monospace; font-size: 0.85rem; overflow-x: auto; margin-top: 0.75rem; }
|
|
12
|
+
.progress-25 { width: 25%; }
|
|
13
|
+
.progress-50 { width: 50%; }
|
|
14
|
+
.progress-75 { width: 75%; }
|
|
15
|
+
.progress-100 { width: 100%; }
|
|
16
|
+
.build-card { background: #0f172a; border: 1px solid #334155; }
|
|
17
|
+
.build-heading { color: #e2e8f0; }
|
|
18
|
+
.build-code { background: #1e293b; border: 1px solid #334155; border-radius: 0.5rem; padding: 1.25rem; margin-top: 0.75rem; overflow-x: auto; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace; font-size: 0.9rem; line-height: 1.7; }
|
|
19
|
+
.build-note { color: #94a3b8; margin-top: 0.75rem; margin-bottom: 0; }
|
|
20
|
+
.tok-kw { color: #38bdf8; }
|
|
21
|
+
.tok-str { color: #4ade80; }
|
|
22
|
+
.tok-fn { color: #fbbf24; }
|
|
23
|
+
.tok-dec { color: #c084fc; }
|
|
24
|
+
.tok-comment { color: #94a3b8; }
|
|
12
25
|
</style>
|
|
13
26
|
</head>
|
|
14
27
|
<body>
|
|
@@ -29,7 +42,7 @@
|
|
|
29
42
|
|
|
30
43
|
<div class="alert alert-info alert-dismissible mb-4">
|
|
31
44
|
<strong>tina4css</strong> — Zero-dependency CSS framework (~24KB). Bootstrap-compatible class names, dark mode ready. No CDN needed — ships with every Tina4 project.
|
|
32
|
-
<button type="button" class="btn-close
|
|
45
|
+
<button type="button" class="btn-close js-dismiss"></button>
|
|
33
46
|
</div>
|
|
34
47
|
|
|
35
48
|
<!-- Buttons -->
|
|
@@ -217,10 +230,10 @@
|
|
|
217
230
|
<!-- Progress -->
|
|
218
231
|
<div class="component-section">
|
|
219
232
|
<h3>Progress Bars</h3>
|
|
220
|
-
<div class="progress mb-2"><div class="progress-bar bg-primary
|
|
221
|
-
<div class="progress mb-2"><div class="progress-bar bg-success
|
|
222
|
-
<div class="progress mb-2"><div class="progress-bar bg-warning
|
|
223
|
-
<div class="progress"><div class="progress-bar bg-danger
|
|
233
|
+
<div class="progress mb-2"><div class="progress-bar bg-primary progress-25">25%</div></div>
|
|
234
|
+
<div class="progress mb-2"><div class="progress-bar bg-success progress-50">50%</div></div>
|
|
235
|
+
<div class="progress mb-2"><div class="progress-bar bg-warning progress-75">75%</div></div>
|
|
236
|
+
<div class="progress"><div class="progress-bar bg-danger progress-100">100%</div></div>
|
|
224
237
|
</div>
|
|
225
238
|
|
|
226
239
|
<!-- List Group -->
|
|
@@ -235,23 +248,29 @@
|
|
|
235
248
|
</div>
|
|
236
249
|
|
|
237
250
|
<!-- How this page was built -->
|
|
238
|
-
<div class="card mt-4 mb-5
|
|
251
|
+
<div class="card mt-4 mb-5 build-card">
|
|
239
252
|
<div class="card-body">
|
|
240
|
-
<h5
|
|
241
|
-
<pre
|
|
253
|
+
<h5 class="build-heading">How this page was built</h5>
|
|
254
|
+
<pre class="build-code"><code><span class="tok-dec">Tina4::Router.get</span> <span class="tok-str">"/gallery/page"</span>, <span class="tok-fn">template:</span> <span class="tok-str">"gallery_page.twig"</span> <span class="tok-dec">do</span> |request, response|
|
|
242
255
|
response.call({
|
|
243
|
-
<span
|
|
244
|
-
<span
|
|
256
|
+
<span class="tok-fn">title:</span> <span class="tok-str">"tina4css Component Showcase"</span>,
|
|
257
|
+
<span class="tok-fn">items:</span> [...]
|
|
245
258
|
}, Tina4::HTTP_OK)
|
|
246
|
-
<span
|
|
247
|
-
<p
|
|
248
|
-
Rendered via <code
|
|
249
|
-
All components above use only <code
|
|
259
|
+
<span class="tok-dec">end</span></code></pre>
|
|
260
|
+
<p class="build-note">
|
|
261
|
+
Rendered via <code class="tok-dec">template:</code> keyword. Styled with <strong class="build-heading">tina4css</strong> — zero external CDN.
|
|
262
|
+
All components above use only <code class="tok-str"><link href="/css/tina4.min.css"></code>.
|
|
250
263
|
</p>
|
|
251
264
|
</div>
|
|
252
265
|
</div>
|
|
253
266
|
|
|
254
267
|
</div>
|
|
255
268
|
|
|
269
|
+
<script nonce="{{ csp_nonce() }}">
|
|
270
|
+
document.querySelectorAll('.js-dismiss').forEach(function(b){
|
|
271
|
+
b.addEventListener('click', function(){ this.parentElement.remove(); });
|
|
272
|
+
});
|
|
273
|
+
</script>
|
|
274
|
+
|
|
256
275
|
</body>
|
|
257
276
|
</html>
|
data/lib/tina4/graphql.rb
CHANGED
|
@@ -1205,19 +1205,25 @@ module Tina4
|
|
|
1205
1205
|
private
|
|
1206
1206
|
|
|
1207
1207
|
def graphiql_html(endpoint)
|
|
1208
|
+
# ADR-0088: no inline style= attribute, and the inline bootstrap <script>
|
|
1209
|
+
# carries this response's CSP nonce. The cross-origin GraphiQL CDN assets
|
|
1210
|
+
# still need TINA4_CSP to list unpkg.com (the default 'self' blocks them),
|
|
1211
|
+
# same as the swagger UI — this is a dev-only playground.
|
|
1212
|
+
nonce = Tina4::Csp.current_nonce
|
|
1208
1213
|
<<~HTML
|
|
1209
1214
|
<!DOCTYPE html>
|
|
1210
1215
|
<html>
|
|
1211
1216
|
<head>
|
|
1212
1217
|
<title>GraphiQL — Tina4 Ruby</title>
|
|
1213
1218
|
<link rel="stylesheet" href="https://unpkg.com/graphiql@3/graphiql.min.css" />
|
|
1219
|
+
<style nonce="#{nonce}">body{margin:0;height:100vh}#graphiql{height:100vh}</style>
|
|
1214
1220
|
</head>
|
|
1215
|
-
<body
|
|
1216
|
-
<div id="graphiql"
|
|
1221
|
+
<body>
|
|
1222
|
+
<div id="graphiql"></div>
|
|
1217
1223
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
1218
1224
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
1219
1225
|
<script crossorigin src="https://unpkg.com/graphiql@3/graphiql.min.js"></script>
|
|
1220
|
-
<script>
|
|
1226
|
+
<script nonce="#{nonce}">
|
|
1221
1227
|
const fetcher = GraphiQL.createFetcher({ url: '#{endpoint}' });
|
|
1222
1228
|
ReactDOM.createRoot(document.getElementById('graphiql'))
|
|
1223
1229
|
.render(React.createElement(GraphiQL, { fetcher }));
|
data/lib/tina4/job.rb
CHANGED
|
@@ -13,6 +13,23 @@ module Tina4
|
|
|
13
13
|
attr_reader :id, :topic, :payload, :created_at, :priority, :available_at
|
|
14
14
|
attr_accessor :status, :attempts, :error, :queue
|
|
15
15
|
|
|
16
|
+
# Rebuild a Job from a parsed queue payload (a Hash with string keys, as
|
|
17
|
+
# produced by #to_json and read back off the wire). attempts and error MUST
|
|
18
|
+
# ride along: rebuilding from topic/payload/id alone reset attempts to 0 on
|
|
19
|
+
# every redelivery, so fail()'s attempts >= max_retries check could never
|
|
20
|
+
# trip and a poison job would be retried forever instead of dead-lettering.
|
|
21
|
+
# Shared by the broker backends (kafka, rabbitmq) whose wire format is this
|
|
22
|
+
# JSON.
|
|
23
|
+
def self.from_payload(data)
|
|
24
|
+
new(
|
|
25
|
+
topic: data["topic"],
|
|
26
|
+
payload: data["payload"],
|
|
27
|
+
id: data["id"],
|
|
28
|
+
attempts: data["attempts"] || 0,
|
|
29
|
+
error: data["error"]
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
16
33
|
def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil,
|
|
17
34
|
attempts: 0, created_at: nil, error: nil, queue: nil)
|
|
18
35
|
@id = id || SecureRandom.uuid
|
data/lib/tina4/middleware.rb
CHANGED
|
@@ -805,7 +805,14 @@ module Tina4
|
|
|
805
805
|
[
|
|
806
806
|
["X-Frame-Options", ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"],
|
|
807
807
|
["X-Content-Type-Options", "nosniff"],
|
|
808
|
-
|
|
808
|
+
# ADR-0088: always name a per-response nonce in style-src AND script-src
|
|
809
|
+
# so the framework's own inline <style>/<script> (and app templates using
|
|
810
|
+
# the Frond csp_nonce() global) run under the strict policy without ever
|
|
811
|
+
# adding 'unsafe-inline'. Derives style-src/script-src from default-src /
|
|
812
|
+
# 'self' when TINA4_CSP omits them. The value comes from the request
|
|
813
|
+
# thread-local so it matches the nonce the HTML body stamps on its inline
|
|
814
|
+
# elements.
|
|
815
|
+
["Content-Security-Policy", Tina4::Csp.resolve_csp_header(Tina4::Csp.current_nonce)],
|
|
809
816
|
["Referrer-Policy", ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"],
|
|
810
817
|
["X-XSS-Protection", "0"],
|
|
811
818
|
["Permissions-Policy", ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"]
|
|
@@ -847,11 +854,14 @@ module Tina4
|
|
|
847
854
|
|
|
848
855
|
@csp_default_warned = true
|
|
849
856
|
message = "TINA4_CSP is not set, so Tina4 is serving the default Content-Security-Policy " \
|
|
850
|
-
"\"default-src 'self'\" on every response.
|
|
851
|
-
"
|
|
852
|
-
"
|
|
853
|
-
"
|
|
854
|
-
"
|
|
857
|
+
"\"default-src 'self'\" on every response. The framework injects a per-response " \
|
|
858
|
+
"nonce into style-src and script-src, so its own inline <style>/<script> (and " \
|
|
859
|
+
"your templates using the csp_nonce() Frond global) work under this policy. The " \
|
|
860
|
+
"default still blocks cross-origin fonts/scripts/CDNs, data: URIs, and " \
|
|
861
|
+
"cross-origin WebSocket/XHR (e.g. a separate API or LiveKit host). If your app " \
|
|
862
|
+
"uses any of these, set TINA4_CSP to a policy that allows them (see " \
|
|
863
|
+
"https://tina4.com); to silence this notice without changing behaviour, set " \
|
|
864
|
+
"TINA4_CSP=\"default-src 'self'\"."
|
|
855
865
|
begin
|
|
856
866
|
Tina4::Log.warning(message)
|
|
857
867
|
rescue StandardError
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -492,56 +492,23 @@ module Tina4
|
|
|
492
492
|
|
|
493
493
|
# Block comment /* … */ — stripped.
|
|
494
494
|
if ch == "/" && i + 1 < n && sql[i + 1] == "*"
|
|
495
|
-
|
|
496
|
-
i = endpos ? endpos + 2 : n
|
|
495
|
+
i = skip_block_comment(sql, i, n)
|
|
497
496
|
next
|
|
498
497
|
end
|
|
499
498
|
|
|
500
499
|
# Line comment -- … — stripped to end of line; the newline is left for the
|
|
501
500
|
# next iteration so line structure (and NEXT-line boundaries) survive.
|
|
502
501
|
if ch == "-" && i + 1 < n && sql[i + 1] == "-"
|
|
503
|
-
|
|
504
|
-
i = endpos || n
|
|
502
|
+
i = skip_line_comment(sql, i, n)
|
|
505
503
|
next
|
|
506
504
|
end
|
|
507
505
|
|
|
508
|
-
#
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
current << "''"
|
|
515
|
-
i += 2
|
|
516
|
-
elsif sql[i] == "'"
|
|
517
|
-
current << "'"
|
|
518
|
-
i += 1
|
|
519
|
-
break
|
|
520
|
-
else
|
|
521
|
-
current << sql[i]
|
|
522
|
-
i += 1
|
|
523
|
-
end
|
|
524
|
-
end
|
|
525
|
-
next
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
# Double-quoted identifier — "" escapes a quote. Same verbatim handling.
|
|
529
|
-
if ch == '"'
|
|
530
|
-
current << '"'
|
|
531
|
-
i += 1
|
|
532
|
-
while i < n
|
|
533
|
-
if sql[i] == '"' && i + 1 < n && sql[i + 1] == '"'
|
|
534
|
-
current << '""'
|
|
535
|
-
i += 2
|
|
536
|
-
elsif sql[i] == '"'
|
|
537
|
-
current << '"'
|
|
538
|
-
i += 1
|
|
539
|
-
break
|
|
540
|
-
else
|
|
541
|
-
current << sql[i]
|
|
542
|
-
i += 1
|
|
543
|
-
end
|
|
544
|
-
end
|
|
506
|
+
# '…' single-quoted string or "…" double-quoted identifier — copied
|
|
507
|
+
# verbatim, honouring the SQL doubled-quote escape ('' / ""). A ;, -- or
|
|
508
|
+
# /* inside a literal is data, not a delimiter or comment.
|
|
509
|
+
if ch == "'" || ch == '"'
|
|
510
|
+
literal, i = read_quoted_literal(sql, i, ch)
|
|
511
|
+
current << literal
|
|
545
512
|
next
|
|
546
513
|
end
|
|
547
514
|
|
|
@@ -592,6 +559,47 @@ module Tina4
|
|
|
592
559
|
m && m[1]
|
|
593
560
|
end
|
|
594
561
|
|
|
562
|
+
# Skip a /* … */ block comment that opens at +start_index+. Returns the index
|
|
563
|
+
# just past the closing "*/", or +length+ when the comment is unterminated.
|
|
564
|
+
def skip_block_comment(sql, start_index, length)
|
|
565
|
+
endpos = sql.index("*/", start_index + 2)
|
|
566
|
+
endpos ? endpos + 2 : length
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Skip a -- … line comment that opens at +start_index+. Returns the index of
|
|
570
|
+
# the terminating newline (left for the next iteration so line structure
|
|
571
|
+
# survives), or +length+ when the comment runs to the end of the script.
|
|
572
|
+
def skip_line_comment(sql, start_index, length)
|
|
573
|
+
sql.index("\n", start_index + 2) || length
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
# Read a '…' string literal or "…" quoted identifier that opens at the
|
|
577
|
+
# +quote+ at +start_index+. A doubled quote ('' or "") is an escaped quote,
|
|
578
|
+
# not a terminator, so it is copied and scanning continues. An unterminated
|
|
579
|
+
# literal is read to the end of the script (matching the raw scan it replaces).
|
|
580
|
+
#
|
|
581
|
+
# @return [Array(String, Integer)] the verbatim literal (both quotes included)
|
|
582
|
+
# and the index just past the closing quote.
|
|
583
|
+
def read_quoted_literal(sql, start_index, quote)
|
|
584
|
+
n = sql.length
|
|
585
|
+
literal = quote.dup
|
|
586
|
+
i = start_index + 1
|
|
587
|
+
while i < n
|
|
588
|
+
if sql[i] == quote && i + 1 < n && sql[i + 1] == quote
|
|
589
|
+
literal << quote << quote
|
|
590
|
+
i += 2
|
|
591
|
+
elsif sql[i] == quote
|
|
592
|
+
literal << quote
|
|
593
|
+
i += 1
|
|
594
|
+
break
|
|
595
|
+
else
|
|
596
|
+
literal << sql[i]
|
|
597
|
+
i += 1
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
[literal, i]
|
|
601
|
+
end
|
|
602
|
+
|
|
595
603
|
def execute_sql_file(file)
|
|
596
604
|
sql = File.read(file)
|
|
597
605
|
statements = split_sql_statements(sql)
|
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
require_relative "../parse_json"
|
|
10
|
+
require_relative "republish_retry_policy"
|
|
10
11
|
|
|
11
12
|
module Tina4
|
|
12
13
|
module QueueBackends
|
|
13
14
|
class KafkaBackend
|
|
15
|
+
# Shared at-least-once fail() (ADR-0022): re-publish carrying the new
|
|
16
|
+
# attempt count, dead-letter at the limit, ack last. See RabbitmqBackend.
|
|
17
|
+
include RepublishRetryPolicy
|
|
18
|
+
|
|
14
19
|
def initialize(options = {})
|
|
15
20
|
require "rdkafka"
|
|
16
21
|
@brokers = options[:brokers] || "localhost:9092"
|
|
@@ -131,17 +136,10 @@ module Tina4
|
|
|
131
136
|
data = Tina4.parse_json(msg.payload)
|
|
132
137
|
@last_message = msg
|
|
133
138
|
|
|
134
|
-
# attempts and error MUST be carried back.
|
|
135
|
-
# topic/payload/id alone reset attempts to 0 on every
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
Tina4::Job.new(
|
|
139
|
-
topic: data["topic"],
|
|
140
|
-
payload: data["payload"],
|
|
141
|
-
id: data["id"],
|
|
142
|
-
attempts: data["attempts"] || 0,
|
|
143
|
-
error: data["error"]
|
|
144
|
-
)
|
|
139
|
+
# attempts and error MUST be carried back (Job.from_payload does this);
|
|
140
|
+
# rebuilding from topic/payload/id alone reset attempts to 0 on every
|
|
141
|
+
# redelivery, so a poison record would be re-produced forever.
|
|
142
|
+
Tina4::Job.from_payload(data)
|
|
145
143
|
rescue Rdkafka::RdkafkaError
|
|
146
144
|
nil
|
|
147
145
|
end
|
|
@@ -186,26 +184,9 @@ module Tina4
|
|
|
186
184
|
acknowledge(message)
|
|
187
185
|
end
|
|
188
186
|
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
#
|
|
192
|
-
# degraded to in-memory bookkeeping, so job.fail() NEVER reached Kafka:
|
|
193
|
-
# the offset was never committed and no dead letter was produced.
|
|
194
|
-
#
|
|
195
|
-
# A retry RE-PRODUCES a record carrying the new count, because a Kafka
|
|
196
|
-
# record is immutable and carries no delivery counter. The offset is
|
|
197
|
-
# committed LAST so a crash in between redelivers rather than loses -
|
|
198
|
-
# at-least-once, which is the contract.
|
|
199
|
-
def fail(job, error = "")
|
|
200
|
-
job.attempts += 1
|
|
201
|
-
job.error = error
|
|
202
|
-
if job.attempts >= @max_retries
|
|
203
|
-
dead_letter(job)
|
|
204
|
-
else
|
|
205
|
-
enqueue(job)
|
|
206
|
-
end
|
|
207
|
-
acknowledge(job)
|
|
208
|
-
end
|
|
187
|
+
# fail() comes from RepublishRetryPolicy (re-produce carrying the new
|
|
188
|
+
# count, dead-letter at the limit, commit the offset LAST). Kafka's
|
|
189
|
+
# +complete+ is +acknowledge+, so the terminal ack commits the offset.
|
|
209
190
|
|
|
210
191
|
def retry(job, delay_seconds: 0)
|
|
211
192
|
if delay_seconds.to_f > 0
|
|
@@ -7,10 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
require_relative "../parse_json"
|
|
10
|
+
require_relative "republish_retry_policy"
|
|
10
11
|
|
|
11
12
|
module Tina4
|
|
12
13
|
module QueueBackends
|
|
13
14
|
class RabbitmqBackend
|
|
15
|
+
# Shared at-least-once fail() (ADR-0022): re-publish carrying the new
|
|
16
|
+
# attempt count, dead-letter at the limit, ack last. See KafkaBackend.
|
|
17
|
+
include RepublishRetryPolicy
|
|
18
|
+
|
|
14
19
|
def initialize(options = {})
|
|
15
20
|
require "bunny"
|
|
16
21
|
@connection = Bunny.new(
|
|
@@ -71,17 +76,10 @@ module Tina4
|
|
|
71
76
|
return nil unless payload
|
|
72
77
|
|
|
73
78
|
data = Tina4.parse_json(payload)
|
|
74
|
-
# attempts and error MUST be carried back.
|
|
75
|
-
# topic/payload/id alone reset attempts to 0 on every
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
msg = Tina4::Job.new(
|
|
79
|
-
topic: data["topic"],
|
|
80
|
-
payload: data["payload"],
|
|
81
|
-
id: data["id"],
|
|
82
|
-
attempts: data["attempts"] || 0,
|
|
83
|
-
error: data["error"]
|
|
84
|
-
)
|
|
79
|
+
# attempts and error MUST be carried back (Job.from_payload does this);
|
|
80
|
+
# rebuilding from topic/payload/id alone reset attempts to 0 on every
|
|
81
|
+
# redelivery, so a poison job would be retried forever.
|
|
82
|
+
msg = Tina4::Job.from_payload(data)
|
|
85
83
|
@last_delivery_tag = delivery_info.delivery_tag
|
|
86
84
|
msg
|
|
87
85
|
end
|
|
@@ -108,32 +106,12 @@ module Tina4
|
|
|
108
106
|
dlq.publish(message.to_json, persistent: true)
|
|
109
107
|
end
|
|
110
108
|
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
# far as the application could see.
|
|
118
|
-
#
|
|
119
|
-
# AMQP basic.nack(requeue=true) returns the ORIGINAL body unmodified --
|
|
120
|
-
# the protocol carries no delivery counter -- so a retry RE-PUBLISHES a
|
|
121
|
-
# body carrying the new count instead. That is what every AMQP client
|
|
122
|
-
# that counts attempts does (Celery, Spring AMQP's
|
|
123
|
-
# RepublishMessageRecoverer, laravel-queue-rabbitmq).
|
|
124
|
-
def fail(job, error = "")
|
|
125
|
-
job.attempts += 1
|
|
126
|
-
job.error = error
|
|
127
|
-
if job.attempts >= @max_retries
|
|
128
|
-
dead_letter(job)
|
|
129
|
-
else
|
|
130
|
-
enqueue(job)
|
|
131
|
-
end
|
|
132
|
-
# Ack LAST: the re-publish (or dead-letter) is durable before the
|
|
133
|
-
# original leaves the queue, so a crash in between redelivers rather
|
|
134
|
-
# than loses. That is at-least-once, which is the contract.
|
|
135
|
-
complete(job)
|
|
136
|
-
end
|
|
109
|
+
# fail() comes from RepublishRetryPolicy. AMQP basic.nack(requeue=true)
|
|
110
|
+
# returns the ORIGINAL body unmodified -- the protocol carries no delivery
|
|
111
|
+
# counter -- so the shared policy RE-PUBLISHES a body carrying the new
|
|
112
|
+
# count, then complete() acks LAST. That is what every AMQP client that
|
|
113
|
+
# counts attempts does (Celery, Spring AMQP's RepublishMessageRecoverer,
|
|
114
|
+
# laravel-queue-rabbitmq).
|
|
137
115
|
|
|
138
116
|
# Explicit re-queue requested by the caller (job.retry). Always
|
|
139
117
|
# re-enqueues regardless of the retry limit -- a manual override,
|