tina4ruby 3.13.148 → 3.13.149

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.
@@ -8,6 +8,7 @@
8
8
 
9
9
  require "json"
10
10
  require "digest"
11
+ require "ipaddr"
11
12
  require "tmpdir"
12
13
  require "net/http"
13
14
  require "uri"
@@ -2134,6 +2135,11 @@ module Tina4
2134
2135
  # (with_mcp_gate -> 404), so the REST loopback arm skips these prefixes and
2135
2136
  # lets the MCP gate govern (keeps a disallowed mcp/call a 404, not a 403).
2136
2137
  DEV_MCP_PREFIXES = ["/__dev/api/mcp", "/__dev/mcp"].freeze
2138
+
2139
+ # Static dev-toolbar assets that carry no secrets and expose no actions, so
2140
+ # the peer gate does not cover them: wherever the toolbar is injected its
2141
+ # stylesheet and script must load (issue #279). Host + same-origin still apply.
2142
+ DEV_PUBLIC_ASSETS = ["/__dev/toolbar.css", "/__dev/toolbar.js"].freeze
2137
2143
  # Private-key / credential basenames the file endpoints must never serve.
2138
2144
  DEV_SECRET_BASENAMES = %w[.env .envrc id_rsa id_dsa id_ecdsa id_ed25519].freeze
2139
2145
  DEV_SECRET_SUFFIXES = %w[.pem .key .pfx .p12 .keystore .jks].freeze
@@ -2186,9 +2192,14 @@ module Tina4
2186
2192
  return dev_refused("dev-admin: refused (host not allowed)") unless dev_host_allowed?(env)
2187
2193
  return dev_refused("dev-admin: refused (cross-origin request)") unless dev_same_origin_ok?(env)
2188
2194
 
2195
+ # The static toolbar assets are exempt from the peer gate (#279): they
2196
+ # carry no secrets and the toolbar needs them wherever it is injected.
2197
+ # Host + same-origin above still apply.
2198
+ return nil if DEV_PUBLIC_ASSETS.include?(path)
2199
+
2189
2200
  unless path.start_with?(*DEV_MCP_PREFIXES)
2190
2201
  remote_ip = (env["REMOTE_ADDR"] || "").to_s
2191
- unless Tina4.is_loopback?(remote_ip) || mcp_token_ok?(env, dedicated: true)
2202
+ unless dev_peer_allowed?(remote_ip) || mcp_token_ok?(env, dedicated: true)
2192
2203
  return dev_refused("dev-admin: refused (non-loopback peer)")
2193
2204
  end
2194
2205
  end
@@ -2216,6 +2227,47 @@ module Tina4
2216
2227
  end
2217
2228
  public :dev_host_allowed?
2218
2229
 
2230
+ # True when raw peer +ip+ falls inside +entry+ (a bare IP or a CIDR). IPv4
2231
+ # and IPv6; an IPv4-mapped IPv6 peer (::ffff:a.b.c.d) is matched as IPv4.
2232
+ def ip_in_cidr?(ip, entry)
2233
+ ip = ip.to_s.strip
2234
+ ip = ip[7..] if ip.downcase.start_with?("::ffff:") # mapped -> compare as IPv4
2235
+ net = IPAddr.new(entry.to_s.strip)
2236
+ addr = IPAddr.new(ip)
2237
+ return false unless net.family == addr.family # v4 peer never matches a v6 CIDR
2238
+ net.include?(addr)
2239
+ rescue IPAddr::Error, ArgumentError
2240
+ false
2241
+ end
2242
+ public :ip_in_cidr?
2243
+
2244
+ # True when the RAW socket peer may reach /__dev: loopback always, plus any
2245
+ # IP/CIDR in TINA4_DEV_ALLOWED_PEERS (comma-separated, opt-in, default none).
2246
+ # Reads the real socket peer ONLY, never a forwarded header, so it cannot be
2247
+ # spoofed by X-Forwarded-For. The documented way to reach the dev dashboard
2248
+ # from a Docker dev box, where requests arrive from the container-network
2249
+ # gateway rather than loopback (issue #279).
2250
+ def dev_peer_allowed?(remote_ip)
2251
+ return true if Tina4.is_loopback?(remote_ip)
2252
+
2253
+ configured = ENV["TINA4_DEV_ALLOWED_PEERS"].to_s.strip
2254
+ return false if configured.empty?
2255
+
2256
+ configured.split(",").any? { |entry| !entry.strip.empty? && ip_in_cidr?(remote_ip, entry) }
2257
+ end
2258
+ public :dev_peer_allowed?
2259
+
2260
+ # True when the toolbar may be injected for this viewer - the Host allow-list
2261
+ # and the raw-peer gate both pass. A viewer the /__dev gate would refuse gets
2262
+ # NO toolbar markup, so its stylesheet and script are never requested only to
2263
+ # 403 (issue #279).
2264
+ def dev_toolbar_allowed?(env)
2265
+ return false unless dev_host_allowed?(env)
2266
+
2267
+ dev_peer_allowed?((env["REMOTE_ADDR"] || "").to_s)
2268
+ end
2269
+ public :dev_toolbar_allowed?
2270
+
2219
2271
  def dev_refused(message)
2220
2272
  [403, { "content-type" => "application/json; charset=utf-8" },
2221
2273
  [JSON.generate({ "ok" => false, "error" => message })]]
@@ -587,6 +587,9 @@ module Tina4
587
587
  status, headers, body_parts = response
588
588
  content_type = headers["content-type"] || ""
589
589
  return nil unless content_type.include?("text/html")
590
+ # Do not inject the toolbar for a viewer the /__dev gate would refuse (#279):
591
+ # otherwise the page carries toolbar markup whose stylesheet and script 403.
592
+ return nil unless Tina4::DevAdmin.dev_toolbar_allowed?(ctx.env)
590
593
 
591
594
  request_info = {
592
595
  method: ctx.method,
@@ -90,14 +90,15 @@ module Tina4
90
90
 
91
91
  attr_reader :host, :port, :username, :from_address, :from_name,
92
92
  :imap_host, :imap_port, :use_tls, :encryption,
93
- :imap_encryption, :imap_use_tls, :imap_username, :imap_password
93
+ :imap_encryption, :imap_use_tls, :imap_username, :imap_password,
94
+ :timeout
94
95
 
95
96
  # Initialize with SMTP config.
96
97
  # Priority: constructor params > ENV (TINA4_MAIL_*) > sensible defaults
97
98
  def initialize(host: nil, port: nil, username: nil, password: nil,
98
99
  from_address: nil, from_name: nil, encryption: nil, use_tls: nil,
99
100
  imap_host: nil, imap_port: nil, imap_encryption: nil,
100
- imap_username: nil, imap_password: nil)
101
+ imap_username: nil, imap_password: nil, timeout: nil)
101
102
  # Whether a host was actually CONFIGURED, which is not the same as @host being
102
103
  # set: it falls back to "localhost", so it is never nil and cannot answer
103
104
  # "can this messenger send?". The capture gate needs that answer, so record it
@@ -143,7 +144,55 @@ module Tina4
143
144
  # account read the wrong mailbox. Explicit constructor args win (ADR-0041).
144
145
  @imap_username = imap_username || ENV["TINA4_MAIL_IMAP_USERNAME"] || @username
145
146
  @imap_password = imap_password || ENV["TINA4_MAIL_IMAP_PASSWORD"] || @password
147
+
148
+ # SMTP send timeout (seconds): constructor > TINA4_MAIL_TIMEOUT > 30.
149
+ # Whole seconds, at least 1. See resolve_timeout. Parity with the PHP
150
+ # master (Messenger::resolveTimeout, tina4-php#286).
151
+ @timeout = resolve_timeout(timeout, ENV["TINA4_MAIL_TIMEOUT"])
152
+ end
153
+
154
+ #: Default socket timeout, in seconds - the value this class has always used.
155
+ TIMEOUT_DEFAULT = 30
156
+ #: Raw TINA4_MAIL_TIMEOUT values already warned about, so a bad one is said once.
157
+ @timeout_warned = {}
158
+ class << self
159
+ attr_reader :timeout_warned
160
+ end
161
+
162
+ # Resolve the SMTP send timeout to whole seconds (issue #278).
163
+ #
164
+ # An explicit constructor value is the caller's own instruction, so one below
165
+ # a second is a programming error and raises. A bad env value is a
166
+ # misconfiguration: warn once and use the default rather than guess. Zero and
167
+ # negative are garbage, not an opt-out - to the socket 0 means "do not wait at
168
+ # all", so no send could succeed.
169
+ def resolve_timeout(explicit, raw)
170
+ unless explicit.nil?
171
+ seconds = Integer(explicit)
172
+ raise ArgumentError, "Messenger timeout must be at least 1 second, got #{seconds}." if seconds < 1
173
+
174
+ return seconds
175
+ end
176
+ return TIMEOUT_DEFAULT if raw.nil? || raw.to_s.strip.empty?
177
+
178
+ seconds = begin
179
+ Integer(raw.to_s.strip)
180
+ rescue ArgumentError
181
+ nil
182
+ end
183
+ if seconds.nil? || seconds < 1
184
+ unless self.class.timeout_warned.key?(raw)
185
+ self.class.timeout_warned[raw] = true
186
+ Tina4::Log.warning(
187
+ "TINA4_MAIL_TIMEOUT must be a whole number of seconds and at least 1, got " \
188
+ "#{raw.inspect} - using the default of #{TIMEOUT_DEFAULT} seconds"
189
+ )
190
+ end
191
+ return TIMEOUT_DEFAULT
192
+ end
193
+ seconds
146
194
  end
195
+ private :resolve_timeout
147
196
 
148
197
  def normalize_encryption(value, label)
149
198
  normalized = value.to_s.strip.downcase
@@ -486,6 +535,7 @@ module Tina4
486
535
  starttls: @use_tls,
487
536
  username: authenticate ? @username : nil,
488
537
  password: authenticate ? @password : nil,
538
+ timeout: @timeout,
489
539
  &block
490
540
  )
491
541
  end
@@ -6,42 +6,51 @@
6
6
  # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
 
8
8
  require "fileutils"
9
+ require "digest"
10
+ require "tmpdir"
9
11
 
10
12
  module Tina4
11
13
  class Migration
12
14
  TRACKING_TABLE = "tina4_migration"
15
+ # Lock name shared by GET_LOCK (MySQL) and sp_getapplock (MSSQL).
16
+ LOCK_NAME = "tina4_migration_lock"
13
17
 
14
18
  attr_reader :db, :migrations_dir
15
19
 
16
20
  def initialize(db, migrations_dir: nil)
17
21
  @db = db
18
22
  @migrations_dir = migrations_dir || resolve_migrations_dir
19
- ensure_tracking_table
23
+ # Ruby boots once per process (no per-request re-entry like PHP's php -S),
24
+ # so there is no concurrent CONSTRUCT race here — the table is ensured
25
+ # eagerly as before. migrate also re-ensures it UNDER the run-wide lock
26
+ # (idempotent), which is where the #277 apply race is actually serialized.
27
+ ensure_tracking_table if @db
20
28
  end
21
29
 
22
- # Run all pending migrations
30
+ # Run all pending migrations under a cross-process lock.
31
+ #
32
+ # The lock serializes concurrent startup migrations (#277): the hook runs per
33
+ # request under php -S-style servers and once per process on a long-lived
34
+ # server, so several workers could reach a fresh database at once and apply
35
+ # the same migration more than once (a data migration then inserted its rows
36
+ # twice). Now the winner migrates while the rest block, then re-read the
37
+ # applied set and find nothing pending. Every backend auto-releases the lock
38
+ # when the holder's session/process dies, so a crash never deadlocks boot.
23
39
  def migrate
24
- pending = pending_migrations
25
- if pending.empty?
26
- Tina4::Log.info("No pending migrations")
27
- return []
28
- end
29
-
30
- batch = next_batch_number
31
- results = []
32
- pending.each do |file|
33
- result = run_migration(file, batch)
34
- results << result
35
- # Stop on failure
36
- break if result[:status] == "failed"
40
+ lock = acquire_migration_lock
41
+ begin
42
+ ensure_tracking_table
43
+ run_pending
44
+ ensure
45
+ release_migration_lock(lock)
37
46
  end
38
- results
39
47
  end
40
48
 
41
49
  alias run migrate
42
50
 
43
51
  # Rollback last batch (or N steps)
44
52
  def rollback(steps = 1)
53
+ ensure_tracking_table
45
54
  completed = completed_migrations_with_batch
46
55
  return [] if completed.empty?
47
56
 
@@ -60,6 +69,7 @@ module Tina4
60
69
  end
61
70
 
62
71
  def status
72
+ ensure_tracking_table
63
73
  {
64
74
  completed: completed_migrations,
65
75
  pending: pending_migrations.map { |f| File.basename(f) }
@@ -178,6 +188,112 @@ module Tina4
178
188
 
179
189
  private
180
190
 
191
+ # Apply every pending migration, in order. Caller holds the run-wide lock.
192
+ def run_pending
193
+ pending = pending_migrations
194
+ if pending.empty?
195
+ Tina4::Log.info("No pending migrations")
196
+ return []
197
+ end
198
+
199
+ batch = next_batch_number
200
+ results = []
201
+ pending.each do |file|
202
+ result = run_migration(file, batch)
203
+ results << result
204
+ # Stop on failure
205
+ break if result[:status] == "failed"
206
+ end
207
+ results
208
+ end
209
+
210
+ # A stable signed-64-bit key for PostgreSQL's pg_advisory_lock, derived from
211
+ # the lock name so it cannot collide with an application's own advisory-lock
212
+ # keys. It is a constant (not user input), so inlining it in the SQL is
213
+ # injection-safe.
214
+ def pg_advisory_key
215
+ Digest::SHA256.digest(LOCK_NAME)[0, 8].unpack1("q")
216
+ end
217
+
218
+ # Take the run-wide migration lock, blocking until it is held.
219
+ #
220
+ # PostgreSQL/MySQL/MSSQL use a native session-scoped advisory lock; SQLite,
221
+ # Firebird and anything else fall back to an OS advisory file lock on a
222
+ # sidecar in the migrations folder. A backend that cannot lock degrades to the
223
+ # file lock, and finally to running unlocked (the pre-#277 behaviour) rather
224
+ # than blocking boot. Returns [kind, resource] for release_migration_lock.
225
+ def acquire_migration_lock
226
+ engine = (@db.respond_to?(:get_database_type) ? @db.get_database_type : "").to_s.downcase
227
+
228
+ begin
229
+ if engine.start_with?("postgres")
230
+ @db.fetch("SELECT pg_advisory_lock(#{pg_advisory_key}) AS locked")
231
+ return [:postgres, nil]
232
+ elsif engine.start_with?("mysql")
233
+ # -1 = wait indefinitely; GET_LOCK is connection-scoped.
234
+ @db.fetch("SELECT GET_LOCK('#{LOCK_NAME}', -1) AS locked")
235
+ return [:mysql, nil]
236
+ elsif engine == "mssql" || engine == "sqlserver"
237
+ @db.execute(
238
+ "DECLARE @res INT; EXEC @res = sp_getapplock @Resource = '#{LOCK_NAME}', " \
239
+ "@LockMode = 'Exclusive', @LockOwner = 'Session', @LockTimeout = -1"
240
+ )
241
+ return [:mssql, nil]
242
+ end
243
+ rescue => e
244
+ Tina4::Log.debug("DB migration lock unavailable (#{engine}): #{e.message}; using a file lock")
245
+ end
246
+
247
+ acquire_file_lock
248
+ end
249
+
250
+ # OS advisory file lock on a sidecar — the portable fallback (crash-safe: the
251
+ # kernel drops it when the process exits).
252
+ def acquire_file_lock
253
+ handle = File.open(file_lock_path, File::RDWR | File::CREAT, 0o644)
254
+ handle.flock(File::LOCK_EX)
255
+ [:file, handle]
256
+ rescue => e
257
+ Tina4::Log.debug("file migration lock unavailable: #{e.message}; running unlocked")
258
+ [:none, nil]
259
+ end
260
+
261
+ # Where the advisory lock file lives: the system temp directory, NOT the
262
+ # migrations folder. The lock is a runtime artifact, not a migration — a
263
+ # dotfile left in the tracked migrations/ directory gets committed by accident
264
+ # and blocks a plain rmdir of the folder. The name is derived from the ABSOLUTE
265
+ # migrations directory, so every worker of the SAME app lands on the SAME file
266
+ # and flock() serializes them, while two different apps get two different locks.
267
+ # This keeps the lock scope identical to before; only the file's location
268
+ # changed. Mirrors the PHP reference's fileLockPath() (issue #277).
269
+ def file_lock_path
270
+ key = begin
271
+ File.realpath(@migrations_dir)
272
+ rescue StandardError
273
+ @migrations_dir
274
+ end
275
+ File.join(Dir.tmpdir, "tina4-migration-#{Digest::SHA256.hexdigest(key)}.lock")
276
+ end
277
+
278
+ def release_migration_lock(handle)
279
+ kind, resource = handle
280
+ case kind
281
+ when :postgres
282
+ @db.fetch("SELECT pg_advisory_unlock(#{pg_advisory_key}) AS released")
283
+ when :mysql
284
+ @db.fetch("SELECT RELEASE_LOCK('#{LOCK_NAME}') AS released")
285
+ when :mssql
286
+ @db.execute("EXEC sp_releaseapplock @Resource = '#{LOCK_NAME}', @LockOwner = 'Session'")
287
+ when :file
288
+ if resource
289
+ resource.flock(File::LOCK_UN)
290
+ resource.close
291
+ end
292
+ end
293
+ rescue => e
294
+ Tina4::Log.debug("migration lock release failed (#{handle&.first}): #{e.message}")
295
+ end
296
+
181
297
  # Resolve migrations directory: prefer src/migrations, fall back to migrations/
182
298
  def resolve_migrations_dir
183
299
  # Canonical location is migrations/ (project root) — matches the Python reference, the CLI,
@@ -0,0 +1,26 @@
1
+ {#
2
+ crud/form.twig — the create/edit field inputs (and the public generate_form).
3
+
4
+ Override: drop your own templates/crud/form.twig to change how inputs render.
5
+
6
+ fields - one per input: { id, name, label, value, placeholder, required_attr,
7
+ and exactly ONE control flag: input (with type) | textarea | checkbox
8
+ (checked_attr) | select (options_html) }.
9
+ wrap - render the <form> wrapper; form_id_attr/action/form_method set it
10
+ edit - add the hidden primary-key input (edit modal)
11
+ modal_footer- render the Cancel/Save modal buttons; submit_button - a plain Submit
12
+
13
+ No inline styles or on*= handlers: the modal buttons carry data-crud-action and
14
+ are wired by the page script's delegated listener (ADR-0088).
15
+ #}
16
+ {% if wrap %}<form{{ form_id_attr|raw }} action="{{ action }}" method="{{ form_method }}" class="needs-validation" data-crud-form="1">{% endif %}
17
+ {% if method_override %}<input type="hidden" name="_method" value="{{ method_override }}">{% endif %}
18
+ {% if edit %}<input type="hidden" id="{{ mode }}-pk-value" name="{{ pk }}">{% endif %}
19
+ {% if modal_footer %}<div class="crud-errors" data-crud-errors hidden></div>{% endif %}
20
+ {% for field in fields %}<div class="mb-3">
21
+ <label for="{{ field.id }}" class="form-label">{{ field.label }}</label>
22
+ {% if field.textarea %}<textarea class="form-control" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ field.value }}</textarea>{% endif %}{% if field.checkbox %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ field.id }}" name="{{ field.name }}"{{ field.checked_attr|raw }}></div>{% endif %}{% if field.select %}<select class="form-select" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ field.options_html|raw }}</select>{% endif %}{% if field.input %}<input type="{{ field.type }}" class="form-control" id="{{ field.id }}" name="{{ field.name }}" value="{{ field.value }}" placeholder="{{ field.placeholder }}"{{ field.required_attr|raw }}>{% endif %}
23
+ </div>
24
+ {% endfor %}
25
+ {% if modal_footer %}<div class="modal-footer"><button type="button" class="btn btn-secondary" data-crud-action="close" data-crud-modal="{{ mode }}">Cancel</button><button type="button" class="btn btn-primary" data-crud-action="save" data-crud-mode="{{ mode }}">Save</button></div>{% endif %}{% if submit_button %}<button type="submit" class="btn btn-primary">Submit</button>{% endif %}
26
+ {% if wrap %}</form>{% endif %}
@@ -0,0 +1,31 @@
1
+ {#
2
+ crud/modals.twig — the create, edit and delete-confirm dialogs.
3
+
4
+ Override: drop your own templates/crud/modals.twig to restyle the dialogs.
5
+ create_form / edit_form are the crud/form.twig renders injected here. Buttons
6
+ carry data-crud-action (close / confirm-delete) and are wired by the page
7
+ script — no inline on*= handlers (ADR-0088).
8
+ #}
9
+ <div class="modal-overlay" id="modal-create">
10
+ <div class="modal-box">
11
+ <h3>Create New Record</h3>
12
+ {{ create_form|raw }}
13
+ </div>
14
+ </div>
15
+ <div class="modal-overlay" id="modal-edit">
16
+ <div class="modal-box">
17
+ <h3>Edit Record</h3>
18
+ {{ edit_form|raw }}
19
+ </div>
20
+ </div>
21
+ <div class="modal-overlay" id="modal-delete">
22
+ <div class="modal-box">
23
+ <h3>Confirm Delete</h3>
24
+ <p>Are you sure you want to delete this record? This action cannot be undone.</p>
25
+ <input type="hidden" id="delete-pk-value">
26
+ <div class="modal-footer">
27
+ <button class="btn btn-secondary" data-crud-action="close" data-crud-modal="delete">Cancel</button>
28
+ <button class="btn btn-danger" data-crud-action="confirm-delete">Delete</button>
29
+ </div>
30
+ </div>
31
+ </div>