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
@@ -7,14 +7,19 @@
7
7
 
8
8
  require "json"
9
9
  require_relative "resp_client"
10
+ require_relative "resp_session_handler"
10
11
 
11
12
  module Tina4
12
13
  module SessionHandlers
13
14
  # Valkey-backed session handler. Valkey speaks the RESP protocol, so it works
14
15
  # through the `redis` gem when installed (parity with Python/Node); otherwise
15
16
  # it speaks raw RESP over a TCP socket via RespClient — zero dependencies.
16
- # Same as RedisHandler but reads VALKEY-prefixed configuration variables.
17
+ # Same behaviour as RedisHandler (read/write/destroy/cleanup and the
18
+ # gem-client build come from the shared RespSessionHandler) but reads
19
+ # VALKEY-prefixed configuration variables.
17
20
  class ValkeyHandler
21
+ include RespSessionHandler
22
+
18
23
  def initialize(options = {})
19
24
  @prefix = options[:prefix] || ENV["TINA4_SESSION_VALKEY_PREFIX"] || "tina4:session:"
20
25
  # TINA4_SESSION_VALKEY_TTL stays as the valkey-specific override, but it
@@ -29,56 +34,6 @@ module Tina4
29
34
  @redis = build_gem_client
30
35
  @resp = @redis ? nil : RespClient.new(host: @host, port: @port, password: @password, db: @db)
31
36
  end
32
-
33
- def read(session_id)
34
- key = "#{@prefix}#{session_id}"
35
- data = @redis ? @redis.get(key) : @resp.get(key)
36
- return nil unless data
37
- JSON.parse(data)
38
- rescue JSON::ParserError
39
- nil
40
- end
41
-
42
- # Write session data. A per-call +ttl+ WINS over the handler default, so
43
- # asking for a 60s session really gets 60s; 0 uses the default. Every
44
- # handler in every Tina4 framework takes this third argument -- Session#write
45
- # passes it, and a handler that did not accept it raised ArgumentError,
46
- # which safe_write swallowed into a silent STALE write.
47
- #
48
- # @param session_id [String] the session id
49
- # @param data [Hash] the payload to store
50
- # @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
51
- def write(session_id, data, ttl = 0)
52
- key = "#{@prefix}#{session_id}"
53
- payload = JSON.generate(data)
54
- effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
55
- if @redis
56
- @redis.setex(key, effective_ttl, payload)
57
- else
58
- @resp.setex(key, effective_ttl, payload)
59
- end
60
- end
61
-
62
- def destroy(session_id)
63
- key = "#{@prefix}#{session_id}"
64
- @redis ? @redis.del(key) : @resp.del(key)
65
- end
66
-
67
- def cleanup
68
- # Valkey handles TTL automatically (same as Redis)
69
- end
70
-
71
- private
72
-
73
- # Use the official `redis` gem (RESP-compatible with Valkey) only when the
74
- # REAL gem is loadable; otherwise nil so the caller falls back to raw RESP.
75
- def build_gem_client
76
- require "redis"
77
- return nil unless defined?(::Redis::VERSION)
78
- Redis.new(host: @host, port: @port, db: @db, password: @password)
79
- rescue LoadError
80
- nil
81
- end
82
37
  end
83
38
  end
84
39
  end
@@ -56,7 +56,11 @@ module Tina4
56
56
  end
57
57
 
58
58
  def globals
59
- @globals ||= {}
59
+ # csp_nonce() is a built-in template global (ADR-0088): a template can
60
+ # stamp the current response's CSP nonce on an inline <style>/<script>
61
+ # — <style nonce="{{ csp_nonce() }}">. Matches the Frond engine global
62
+ # and the value the security middleware names in the CSP header.
63
+ @globals ||= { "csp_nonce" => -> { Tina4::Csp.current_nonce } }
60
64
  end
61
65
 
62
66
  def add_global(key, value)
@@ -87,7 +91,11 @@ module Tina4
87
91
  error_dirs = TEMPLATE_DIRS.map { |d| File.join(Dir.pwd, d, "errors") }
88
92
  error_dirs << File.join(File.dirname(__FILE__), "templates", "errors")
89
93
 
90
- context = { "code" => code }.merge(data.transform_keys(&:to_s))
94
+ # csp_nonce() (ADR-0088) so the error twigs can carry a nonce on their
95
+ # inline <style>; render_error builds its own context and does not merge
96
+ # globals, so inject it here explicitly.
97
+ context = { "code" => code, "csp_nonce" => -> { Tina4::Csp.current_nonce } }
98
+ .merge(data.transform_keys(&:to_s))
91
99
 
92
100
  error_dirs.each do |dir|
93
101
  %w[.twig .html .erb].each do |ext|
@@ -152,7 +160,7 @@ module Tina4
152
160
 
153
161
  def error_overlay_css(color)
154
162
  <<~CSS
155
- <style>
163
+ <style nonce="#{Tina4::Csp.current_nonce}">
156
164
  * { box-sizing: border-box; margin: 0; padding: 0; }
157
165
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
158
166
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
@@ -9,6 +9,6 @@
9
9
  <div class="spinner"></div>
10
10
  <div class="error-title">Redirecting…</div>
11
11
  <div class="error-msg">You are being redirected to a new location.</div>
12
- <div class="error-path" style="color:var(--primary)">{{ redirect_url }}</div>
12
+ <div class="error-path c-primary">{{ redirect_url }}</div>
13
13
  <a href="{{ redirect_url }}" class="error-home">Click here if not redirected</a>
14
14
  {% endblock %}
@@ -1,9 +1,9 @@
1
1
  {% extends "errors/base.twig" %}
2
2
  {% block title %}401 — Unauthorized{% endblock %}
3
3
  {% block content %}
4
- <div class="error-code" style="color:var(--danger)">401</div>
4
+ <div class="error-code c-danger">401</div>
5
5
  <div class="error-title">Unauthorized</div>
6
6
  <div class="error-msg">You need to sign in to access this resource.</div>
7
- <div class="error-path" style="color:var(--danger)">{{ path }}</div>
7
+ <div class="error-path c-danger">{{ path }}</div>
8
8
  <a href="/" class="error-home">Go Home</a>
9
9
  {% endblock %}
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>403 — Forbidden</title>
7
- <style>
7
+ <style nonce="{{ csp_nonce() }}">
8
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
9
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
10
10
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>404 - Not Found</title>
7
- <style>
7
+ <style nonce="{{ csp_nonce() }}">
8
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
9
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
10
10
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>500 - Server Error</title>
7
- <style>
7
+ <style nonce="{{ csp_nonce() }}">
8
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
9
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
10
10
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: left; max-width: 700px; width: 90%; }
@@ -1,9 +1,9 @@
1
1
  {% extends "errors/base.twig" %}
2
2
  {% block title %}502 — Bad Gateway{% endblock %}
3
3
  {% block content %}
4
- <div class="error-code" style="color:var(--danger)">502</div>
4
+ <div class="error-code c-danger">502</div>
5
5
  <div class="error-title">Bad Gateway</div>
6
6
  <div class="error-msg">The upstream server returned an invalid response.</div>
7
- <div class="error-path" style="color:var(--danger)">{{ path }}</div>
7
+ <div class="error-path c-danger">{{ path }}</div>
8
8
  <a href="/" class="error-home">Go Home</a>
9
9
  {% endblock %}
@@ -4,9 +4,9 @@
4
4
  .error-code { color: var(--warn); }
5
5
  {% endblock %}
6
6
  {% block content %}
7
- <div class="error-code" style="color:var(--warn)">503</div>
7
+ <div class="error-code c-warn">503</div>
8
8
  <div class="error-title">Service Unavailable</div>
9
9
  <div class="error-msg">The server is temporarily unavailable. Please try again shortly.</div>
10
- <div class="error-path" style="color:var(--warn)">{{ path }}</div>
10
+ <div class="error-path c-warn">{{ path }}</div>
11
11
  <a href="/" class="error-home">Go Home</a>
12
12
  {% endblock %}
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  {% block meta %}{% endblock %}
7
7
  <title>{% block title %}Error{% endblock %}</title>
8
- <style>
8
+ <style nonce="{{ csp_nonce() }}">
9
9
  :root {
10
10
  --bg: #0f172a; --surface: #1e293b; --border: #334155;
11
11
  --text: #e2e8f0; --muted: #94a3b8; --primary: #3b82f6;
@@ -23,6 +23,9 @@ body { font-family: var(--font); background: var(--bg); color: var(--text); min-
23
23
  .error-home { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.5rem; background: var(--primary); color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.85rem; }
24
24
  .error-home:hover { opacity: 0.9; }
25
25
  .logo { width: 3rem; height: 3rem; margin-bottom: 1rem; opacity: 0.5; }
26
+ .c-danger { color: var(--danger); }
27
+ .c-warn { color: var(--warn); }
28
+ .c-primary { color: var(--primary); }
26
29
  {% block extra_styles %}{% endblock %}
27
30
  </style>
28
31
  </head>
data/lib/tina4/version.rb CHANGED
@@ -7,5 +7,5 @@
7
7
 
8
8
 
9
9
  module Tina4
10
- VERSION = "3.13.141"
10
+ VERSION = "3.13.143"
11
11
  end
@@ -126,20 +126,9 @@ module Tina4
126
126
  # drains in-flight requests (bounded by TINA4_SHUTDOWN_TIMEOUT).
127
127
  Tina4::Shutdown.setup(server: @server)
128
128
 
129
- # Test port (port + 1000) — stable, no-browser
130
- no_ai_port = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_AI_PORT", "").downcase)
131
- is_debug = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "").downcase)
132
-
133
- if is_debug && !no_ai_port
134
- ai_port = @port + 1000
135
- begin
136
- # Tagged so the pipeline suppresses live reload on this port.
137
- @server.listen(@host, ai_port, Tina4::AiPortRackApp.new(@app))
138
- puts " Test Port: http://localhost:#{ai_port} (stable — no hot-reload)"
139
- rescue Errno::EADDRINUSE
140
- puts " Test Port: SKIPPED (port #{ai_port} in use)"
141
- end
142
- end
129
+ # Test port (port + 1000) — stable, no-browser. Extracted so its bind
130
+ # rescue stays out of #start's cyclomatic complexity.
131
+ bind_auxiliary_ai_port
143
132
 
144
133
  @server.start
145
134
 
@@ -156,6 +145,33 @@ module Tina4
156
145
  Tina4::PortTakeover.remove_pidfile(@port)
157
146
  end
158
147
 
148
+ # Bind the auxiliary AI/test port (main port + 1000), a debug-only, stable,
149
+ # no-hot-reload sibling of the main port. It is a CONVENIENCE, never
150
+ # load-bearing: any bind failure here must DEGRADE to no-aux-port and leave
151
+ # the main server the operator asked for untouched. The rescue used to catch
152
+ # only Errno::EADDRINUSE, so any OTHER bind failure propagated out of #start
153
+ # and crashed the whole boot: a transient getaddrinfo/Socket::ResolutionError
154
+ # on a fresh host under process churn (a measured macOS artifact),
155
+ # Errno::EADDRNOTAVAIL, Errno::EACCES. Catch the broad bind-failure set
156
+ # (SocketError covers Socket::ResolutionError; SystemCallError covers every
157
+ # Errno::*), log LOUD so it is never silent, and carry on main-port only.
158
+ # Parity with Python (`except OSError`) and PHP (`\Throwable`).
159
+ def bind_auxiliary_ai_port
160
+ no_ai_port = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_AI_PORT", "").downcase)
161
+ is_debug = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "").downcase)
162
+ return unless is_debug && !no_ai_port
163
+
164
+ ai_port = @port + 1000
165
+ # Tagged so the pipeline suppresses live reload on this port.
166
+ @server.listen(@host, ai_port, Tina4::AiPortRackApp.new(@app))
167
+ puts " Test Port: http://localhost:#{ai_port} (stable — no hot-reload)"
168
+ rescue Errno::EADDRINUSE
169
+ puts " Test Port: SKIPPED (port #{ai_port} in use)"
170
+ rescue SocketError, SystemCallError => e
171
+ Tina4::Log.error("Test Port: SKIPPED — auxiliary port #{ai_port} bind failed (#{e.class}: #{e.message})")
172
+ puts " Test Port: SKIPPED (#{e.class})"
173
+ end
174
+
159
175
  # Dispatch a Rack-style env through the Tina4 app and return [status, headers, body].
160
176
  #
161
177
  # Useful for testing and embedding — does not require a running server.
@@ -691,6 +691,11 @@ module Tina4
691
691
 
692
692
  Thread.new do
693
693
  begin
694
+ # A large message (a pasted image in a chat, base64 inside JSON) arrives as a first frame with FIN clear,
695
+ # then CONTINUATION frames (RFC 6455 section 5.4); control frames may come in between. The first piece used
696
+ # to be emitted as the whole message and the rest ignored. Same design as the Python master (_handle_frame).
697
+ fragments = nil
698
+ fragment_opcode = nil
694
699
  loop do
695
700
  frame = connection.read_frame
696
701
  break unless frame
@@ -698,8 +703,26 @@ module Tina4
698
703
  connection.touch # mark activity for the idle reaper
699
704
 
700
705
  case frame[:opcode]
706
+ when 0x0 # Continuation - a stray one (no message started) is dropped
707
+ next unless fragments
708
+ fragments << frame[:data]
709
+ if frame[:fin]
710
+ emit(:message, connection, fragments) if fragment_opcode == 0x1
711
+ fragments = nil
712
+ fragment_opcode = nil
713
+ end
701
714
  when 0x1 # Text
702
- emit(:message, connection, frame[:data])
715
+ if frame[:fin]
716
+ emit(:message, connection, frame[:data])
717
+ else
718
+ fragments = frame[:data].dup
719
+ fragment_opcode = 0x1
720
+ end
721
+ when 0x2 # Binary - not delivered (as before), but its pieces are one message, never text
722
+ unless frame[:fin]
723
+ fragments = frame[:data].dup
724
+ fragment_opcode = 0x2
725
+ end
703
726
  when 0x8 # Close
704
727
  break
705
728
  when 0x9 # Ping
@@ -867,6 +890,7 @@ module Tina4
867
890
  first_byte = @socket.getbyte
868
891
  return nil unless first_byte
869
892
 
893
+ fin = (first_byte & 0x80) != 0
870
894
  opcode = first_byte & 0x0F
871
895
  second_byte = @socket.getbyte
872
896
  return nil unless second_byte
@@ -887,7 +911,7 @@ module Tina4
887
911
  data = data.bytes.each_with_index.map { |b, i| b ^ mask_key[i % 4] }.pack("C*")
888
912
  end
889
913
 
890
- { opcode: opcode, data: data }
914
+ { fin: fin, opcode: opcode, data: data }
891
915
  rescue IOError, EOFError
892
916
  nil
893
917
  end
@@ -175,25 +175,28 @@ module Tina4
175
175
 
176
176
  def subscribe(channel, &block)
177
177
  @mutex.synchronize do
178
- sid = @nats.subscribe(channel) do |msg|
178
+ # NATS::Client#subscribe returns a NATS::Subscription. The client's own
179
+ # #unsubscribe is private in nats-pure 2.x, so we tear a subscription
180
+ # down through the subscription object, not the client (see #unsubscribe).
181
+ subscription = @nats.subscribe(channel) do |msg|
179
182
  block.call(msg.data) if @running
180
183
  end
181
- @subs[channel] = sid
182
-
183
- # Run NATS event processing in a background thread
184
- @threads[channel] ||= Thread.new do
185
- loop do
186
- break unless @running
187
- sleep 0.01
188
- end
189
- end
184
+ @subs[channel] = subscription
185
+
186
+ # The NATS client dispatches subscription callbacks on its own reader
187
+ # thread, so this keeper thread only has to stay alive until unsubscribe
188
+ # or close kills it. It blocks on an empty queue - nothing is ever
189
+ # pushed - so it waits at zero CPU. Before, it woke a hundred times a
190
+ # second on `sleep 0.01` just to re-check @running, burning energy to do
191
+ # nothing (Carbonah E004).
192
+ @threads[channel] ||= Thread.new { Thread::Queue.new.pop }
190
193
  end
191
194
  end
192
195
 
193
196
  def unsubscribe(channel)
194
197
  @mutex.synchronize do
195
- sid = @subs.delete(channel)
196
- @nats.unsubscribe(sid) if sid
198
+ subscription = @subs.delete(channel)
199
+ subscription&.unsubscribe
197
200
  thread = @threads.delete(channel)
198
201
  thread&.kill
199
202
  end
@@ -202,7 +205,7 @@ module Tina4
202
205
  def close
203
206
  @running = false
204
207
  @mutex.synchronize do
205
- @subs.each_value { |sid| @nats.unsubscribe(sid) rescue nil }
208
+ @subs.each_value { |subscription| subscription.unsubscribe rescue nil }
206
209
  @subs.clear
207
210
  @threads.each_value { |t| t.kill }
208
211
  @threads.clear
data/lib/tina4.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "tina4/constants"
12
12
  require_relative "tina4/log"
13
13
  require_relative "tina4/debug" # backward compat alias
14
14
  require_relative "tina4/env"
15
+ require_relative "tina4/csp" # per-response CSP nonce (ADR-0088) — before response
15
16
  require_relative "tina4/sqlite_path_resolver"
16
17
  require_relative "tina4/row_symbolizer"
17
18
  require_relative "tina4/router"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.141
4
+ version: 3.13.143
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-29 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sqlite3
@@ -212,6 +212,7 @@ files:
212
212
  - lib/tina4/context/chunker.rb
213
213
  - lib/tina4/cors.rb
214
214
  - lib/tina4/crud.rb
215
+ - lib/tina4/csp.rb
215
216
  - lib/tina4/database.rb
216
217
  - lib/tina4/database/sqlite3_adapter.rb
217
218
  - lib/tina4/database_adapter.rb
@@ -311,6 +312,7 @@ files:
311
312
  - lib/tina4/queue_backends/lite_backend.rb
312
313
  - lib/tina4/queue_backends/mongo_backend.rb
313
314
  - lib/tina4/queue_backends/rabbitmq_backend.rb
315
+ - lib/tina4/queue_backends/republish_retry_policy.rb
314
316
  - lib/tina4/rack_app.rb
315
317
  - lib/tina4/rate_limiter.rb
316
318
  - lib/tina4/realtime.rb
@@ -340,6 +342,7 @@ files:
340
342
  - lib/tina4/session_handlers/mongo_wire_client.rb
341
343
  - lib/tina4/session_handlers/redis_handler.rb
342
344
  - lib/tina4/session_handlers/resp_client.rb
345
+ - lib/tina4/session_handlers/resp_session_handler.rb
343
346
  - lib/tina4/session_handlers/valkey_handler.rb
344
347
  - lib/tina4/shutdown.rb
345
348
  - lib/tina4/smtp_client.rb