rails_nexus 2.1.0 → 2.1.2

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +33 -6
  4. data/SECURITY.md +11 -0
  5. data/THIRD_PARTY_NOTICES.md +14 -0
  6. data/app/assets/config/rails_nexus_manifest.js +1 -1
  7. data/app/assets/javascripts/rails_nexus/application.js +55 -0
  8. data/app/controllers/rails_nexus/analytics_controller.rb +13 -26
  9. data/app/controllers/rails_nexus/application_controller.rb +6 -2
  10. data/app/controllers/rails_nexus/backup_controller.rb +1 -2
  11. data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -9
  12. data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
  13. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
  14. data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
  15. data/app/controllers/rails_nexus/settings_controller.rb +1 -39
  16. data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
  17. data/app/controllers/rails_nexus/stats_controller.rb +43 -43
  18. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
  19. data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
  20. data/app/javascript/rails_nexus/application.js +15 -3
  21. data/app/models/rails_nexus/backup_config.rb +31 -0
  22. data/app/models/rails_nexus/logged_exception.rb +27 -13
  23. data/app/services/rails_nexus/backup_service.rb +233 -98
  24. data/app/views/layouts/rails_nexus/application.html.erb +1 -1
  25. data/app/views/rails_nexus/stats/index.html.erb +1 -1
  26. data/lib/generators/rails_nexus/install_generator.rb +1 -1
  27. data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
  28. data/lib/generators/rails_nexus/templates/rails_nexus.rb +3 -0
  29. data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +1 -1
  30. data/lib/rails_nexus/configuration.rb +10 -0
  31. data/lib/rails_nexus/database_adapter.rb +53 -0
  32. data/lib/rails_nexus/engine.rb +0 -13
  33. data/lib/rails_nexus/logger.rb +1 -12
  34. data/lib/rails_nexus/notifications.rb +27 -20
  35. data/lib/rails_nexus/version.rb +1 -1
  36. data/lib/rails_nexus/webhook_client.rb +151 -0
  37. data/lib/rails_nexus.rb +37 -23
  38. data/rails_nexus.gemspec +3 -2
  39. metadata +8 -16
  40. data/config/importmap.rb +0 -15
@@ -2,8 +2,6 @@
2
2
 
3
3
  module RailsNexus
4
4
  class DatabaseHealthController < ApplicationController
5
- before_action :verify_access
6
-
7
5
  def index
8
6
  @period = params[:period]&.to_i || 7
9
7
  @period = [@period, 1].max
@@ -21,14 +19,6 @@ module RailsNexus
21
19
 
22
20
  private
23
21
 
24
- def verify_access
25
- config = RailsNexus.configuration
26
- return if config.auth_block.nil?
27
- unless config.auth_block&.call(self)
28
- render plain: "Forbidden", status: :forbidden
29
- end
30
- end
31
-
32
22
  def collect_pool_by_error(time_range)
33
23
  # Get all errors with system_health data in the time range
34
24
  exceptions = LoggedException.where(created_at: time_range)
@@ -2,8 +2,6 @@
2
2
 
3
3
  module RailsNexus
4
4
  class LoggedExceptionsController < ApplicationController
5
- before_action :rails_nexus_require_auth!
6
-
7
5
  RANSACK_FILTERS = %i[
8
6
  action_name_cont assigned_to_cont controller_name_cont created_at_gteq created_at_lteq
9
7
  exception_class_cont fingerprint_cont message_cont
@@ -1,7 +1,5 @@
1
1
  module RailsNexus
2
2
  class N1PatternsController < ApplicationController
3
- before_action :rails_nexus_require_auth!
4
-
5
3
  def index
6
4
  @patterns = RailsNexus::LoggedException.n_plus_one_patterns(limit: 50)
7
5
  @summary = RailsNexus::LoggedException.n_plus_one_summary
@@ -2,8 +2,6 @@
2
2
 
3
3
  module RailsNexus
4
4
  class SettingsController < ApplicationController
5
- before_action :verify_access
6
-
7
5
  def index
8
6
  @webhooks = RailsNexus.configuration.webhooks || []
9
7
  @webhook_headers = RailsNexus.configuration.webhook_headers || {}
@@ -43,14 +41,6 @@ module RailsNexus
43
41
 
44
42
  private
45
43
 
46
- def verify_access
47
- config = RailsNexus.configuration
48
- return if config.auth_block.nil?
49
- unless config.auth_block&.call(self)
50
- render plain: "Forbidden", status: :forbidden
51
- end
52
- end
53
-
54
44
  def build_test_payload
55
45
  {
56
46
  event: "webhook.test",
@@ -66,35 +56,7 @@ module RailsNexus
66
56
  end
67
57
 
68
58
  def deliver_test_webhook(url, payload)
69
- require "net/http"
70
- require "uri"
71
- require "json"
72
-
73
- uri = URI(url)
74
- http = Net::HTTP.new(uri.host, uri.port)
75
- http.use_ssl = uri.scheme == "https"
76
- http.open_timeout = 10
77
- http.read_timeout = 10
78
-
79
- request = Net::HTTP::Post.new(uri)
80
- request["Content-Type"] = "application/json"
81
- request.body = payload.to_json
82
-
83
- start_time = Time.now
84
- response = http.request(request)
85
- duration = Time.now - start_time
86
-
87
- {
88
- success: response.is_a?(Net::HTTPSuccess),
89
- status_code: response.code,
90
- body: response.body&.truncate(500),
91
- duration: duration.round(3)
92
- }
93
- rescue StandardError => e
94
- {
95
- success: false,
96
- error: e.message
97
- }
59
+ RailsNexus::WebhookClient.deliver(url: url, payload: payload, timeout: 10)
98
60
  end
99
61
  end
100
62
  end
@@ -7,27 +7,23 @@ module RailsNexus
7
7
  # GET /rails_nexus/source_code?file=...&line=...
8
8
  def show
9
9
  file_path = params[:file]
10
- line_number = params[:line]&.to_i
10
+ line_number = Integer(params[:line], exception: false)
11
11
 
12
- if file_path.blank? || line_number.blank?
12
+ if file_path.blank? || !line_number&.positive?
13
13
  render json: { error: "file and line parameters required" }, status: :bad_request
14
14
  return
15
15
  end
16
16
 
17
- # Security: only allow reading files within Rails.root
18
- begin
19
- real_path = File.realpath(file_path)
20
- unless real_path.start_with?(Rails.root.to_s)
21
- render json: { error: "Access denied" }, status: :forbidden
22
- return
23
- end
24
- rescue Errno::ENOENT
25
- render json: { error: "File not found" }, status: :not_found
17
+ resolved_path = resolve_source_path(file_path)
18
+ unless resolved_path
19
+ render json: { error: "File not found or access denied" }, status: :not_found
26
20
  return
27
21
  end
28
22
 
29
- source = read_source_snippet(file_path, line_number, context_lines: params[:context]&.to_i || 5)
30
- blame = git_blame_for_line(file_path, line_number)
23
+ requested_context = Integer(params[:context], exception: false) || 5
24
+ context_lines = requested_context.clamp(0, 50)
25
+ source = read_source_snippet(resolved_path, line_number, context_lines: context_lines)
26
+ blame = git_blame_for_line(resolved_path, line_number)
31
27
 
32
28
  if source
33
29
  render json: { source: source, blame: blame }
@@ -2,8 +2,6 @@
2
2
 
3
3
  module RailsNexus
4
4
  class StatsController < ApplicationController
5
- before_action :verify_access
6
-
7
5
  def index
8
6
  @system_stats = collect_system_stats
9
7
  @ruby_stats = collect_ruby_stats
@@ -19,14 +17,6 @@ module RailsNexus
19
17
 
20
18
  private
21
19
 
22
- def verify_access
23
- config = RailsNexus.configuration
24
- return if config.auth_block.nil?
25
- unless config.auth_block&.call(self)
26
- render plain: "Forbidden", status: :forbidden
27
- end
28
- end
29
-
30
20
  def collect_system_stats
31
21
  stats = {}
32
22
 
@@ -83,7 +73,7 @@ module RailsNexus
83
73
  process_stats[:thread_count] = "N/A"
84
74
  end
85
75
  begin
86
- process_stats[:open_files] = `ls /proc/#{pid}/fd 2>/dev/null | wc -l`.strip.to_i
76
+ process_stats[:open_files] = Dir.children("/proc/#{pid}/fd").size
87
77
  rescue StandardError
88
78
  process_stats[:open_files] = 0
89
79
  end
@@ -133,13 +123,14 @@ module RailsNexus
133
123
  }
134
124
  else
135
125
  # Fallback: use `df` command
136
- df_output = `df -h / 2>/dev/null`.split("\n").last&.split
126
+ stdout, _stderr, status = Open3.capture3("df", "-h", "/")
127
+ df_output = status.success? ? stdout.lines.last&.split : nil
137
128
  if df_output
138
129
  {
139
130
  total_gb: df_output[1],
140
131
  used_gb: df_output[2],
141
132
  available_gb: df_output[3],
142
- usage_percent: df_output[4]&.to_i || 0
133
+ usage_percent: df_output[4].to_i
143
134
  }
144
135
  else
145
136
  { total_gb: "N/A", used_gb: "N/A", available_gb: "N/A", usage_percent: 0 }
@@ -201,11 +192,7 @@ module RailsNexus
201
192
  stats[:pool_available] = pool.available? ? pool.connections.size : 0
202
193
 
203
194
  # Database version
204
- begin
205
- stats[:version] = ActiveRecord::Base.connection.select_value("SELECT version()")
206
- rescue StandardError
207
- stats[:version] = "N/A"
208
- end
195
+ stats[:version] = RailsNexus::DatabaseAdapter.database_version
209
196
 
210
197
  # Table stats (rails_nexus tables)
211
198
  stats[:tables] = collect_table_stats
@@ -308,24 +295,24 @@ module RailsNexus
308
295
  end
309
296
 
310
297
  # Check for pg_dump (PostgreSQL)
311
- if system("which pg_dump > /dev/null 2>&1")
298
+ if (version = executable_version("pg_dump"))
312
299
  stats[:detected] = true
313
300
  stats[:gem] ||= "pg_dump"
314
- stats[:details][:pg_dump] = `pg_dump --version 2>/dev/null`.strip
301
+ stats[:details][:pg_dump] = version
315
302
  end
316
303
 
317
304
  # Check for mysqldump (MySQL)
318
- if system("which mysqldump > /dev/null 2>&1")
305
+ if (version = executable_version("mysqldump"))
319
306
  stats[:detected] = true
320
307
  stats[:gem] ||= "mysqldump"
321
- stats[:details][:mysqldump] = `mysqldump --version 2>/dev/null`.strip
308
+ stats[:details][:mysqldump] = version
322
309
  end
323
310
 
324
311
  # Check for AWS CLI (RDS backups)
325
- if system("which aws > /dev/null 2>&1")
312
+ if (version = executable_version("aws"))
326
313
  stats[:detected] = true
327
314
  stats[:gem] ||= "aws-cli"
328
- stats[:details][:aws_cli] = `aws --version 2>/dev/null`.strip
315
+ stats[:details][:aws_cli] = version
329
316
 
330
317
  # Check for RDS snapshots if configured
331
318
  begin
@@ -339,16 +326,16 @@ module RailsNexus
339
326
  end
340
327
 
341
328
  # Check for docker backup solutions
342
- if File.exist?("/var/lib/docker")
329
+ if File.exist?("/var/lib/docker") && (version = executable_version("docker"))
343
330
  stats[:detected] = true
344
331
  stats[:gem] ||= "docker"
345
- stats[:details][:docker] = `docker --version 2>/dev/null`.strip
332
+ stats[:details][:docker] = version
346
333
  end
347
334
 
348
335
  # Check for crontab entries related to backups
349
336
  begin
350
- crontab = `crontab -l 2>/dev/null`
351
- if crontab.include?("backup") || crontab.include?("dump")
337
+ crontab, _stderr, status = Open3.capture3("crontab", "-l")
338
+ if status.success? && (crontab.include?("backup") || crontab.include?("dump"))
352
339
  stats[:details][:crontab_backup] = true
353
340
  stats[:status] = "scheduled" if stats[:status] == "unknown"
354
341
  end
@@ -391,11 +378,13 @@ module RailsNexus
391
378
  begin
392
379
  next unless ActiveRecord::Base.connection.table_exists?(table_name)
393
380
 
394
- count = ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM #{table_name}")
381
+ quoted_table = ActiveRecord::Base.connection.quote_table_name(table_name)
382
+ count = ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM #{quoted_table}").to_i
395
383
  tables << {
396
384
  name: table_name,
397
385
  row_count: count,
398
- size_mb: 0,
386
+ size_mb: nil,
387
+ size_supported: false,
399
388
  status: count > 0 ? "active" : "empty"
400
389
  }
401
390
  rescue StandardError
@@ -415,7 +404,7 @@ module RailsNexus
415
404
  SQL
416
405
  result.each do |row|
417
406
  table = tables.find { |t| t[:name] == row["table_name"] }
418
- table[:size_mb] = row["size_mb"] if table
407
+ table&.merge!(size_mb: row["size_mb"].to_f, size_supported: true)
419
408
  end
420
409
  elsif adapter.include?("postgresql")
421
410
  result = ActiveRecord::Base.connection.execute(<<~SQL)
@@ -427,7 +416,7 @@ module RailsNexus
427
416
  SQL
428
417
  result.each do |row|
429
418
  table = tables.find { |t| t[:name] == row["table_name"] }
430
- table[:size_mb] = (row["size_bytes"].to_f / 1024 / 1024).round(2) if table
419
+ table&.merge!(size_mb: (row["size_bytes"].to_f / 1024 / 1024).round(2), size_supported: true)
431
420
  end
432
421
  end
433
422
  rescue StandardError
@@ -437,6 +426,20 @@ module RailsNexus
437
426
  tables
438
427
  end
439
428
 
429
+ def executable_version(name)
430
+ executable = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
431
+ .map { |directory| File.join(directory, name) }
432
+ .find { |path| File.file?(path) && File.executable?(path) }
433
+ return nil unless executable
434
+
435
+ stdout, stderr, status = Open3.capture3(executable, "--version")
436
+ return nil unless status.success?
437
+
438
+ [stdout, stderr].find(&:present?)&.lines&.first&.strip
439
+ rescue Errno::ENOENT, Errno::EACCES
440
+ nil
441
+ end
442
+
440
443
  def collect_platform_stats
441
444
  {
442
445
  breakdown: LoggedException.platform_stats,
@@ -476,17 +479,14 @@ module RailsNexus
476
479
  end
477
480
 
478
481
  def process_uptime
479
- start_time = File.read("/proc/#{Process.pid}/stat").split[21].to_i rescue nil
480
- if start_time
481
- uptime_seconds = Time.now.to_i - (Process.clock_gettime(Process::CLOCK_MONOTONIC).to_i - start_time)
482
- seconds = Time.now.to_i - start_time
483
- days = seconds / 86400
484
- hours = (seconds % 86400) / 3600
485
- minutes = (seconds % 3600) / 60
486
- "#{days}d #{hours}h #{minutes}m"
487
- else
488
- "N/A"
489
- end
482
+ stdout, _stderr, status = Open3.capture3("ps", "-o", "etimes=", "-p", Process.pid.to_s)
483
+ return "N/A" unless status.success?
484
+
485
+ seconds = stdout.to_i
486
+ days = seconds / 86_400
487
+ hours = (seconds % 86_400) / 3600
488
+ minutes = (seconds % 3600) / 60
489
+ "#{days}d #{hours}h #{minutes}m"
490
490
  rescue StandardError
491
491
  "N/A"
492
492
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module RailsNexus
4
4
  module LoggedExceptionsHelper
5
+ include SourceCodeHelper
5
6
  def pretty_exception_date(exception)
6
7
  if RailsNexus.configuration.date_format
7
8
  return exception.created_at.strftime(RailsNexus.configuration.date_format)
@@ -186,58 +187,12 @@ module RailsNexus
186
187
 
187
188
  # Read source code snippet around a file:line
188
189
  def read_source_snippet(file_path, line_number, context: 5)
189
- return nil unless file_path.present? && line_number.present?
190
- return nil unless File.exist?(file_path)
191
-
192
- lines = File.readlines(file_path)
193
- start_line = [line_number - context, 0].max
194
- end_line = [line_number + context, lines.length - 1].min
195
-
196
- snippet_lines = (start_line..end_line).map do |i|
197
- {
198
- number: i + 1,
199
- content: lines[i]&.rstrip,
200
- highlighted: (i + 1) == line_number
201
- }
202
- end
203
-
204
- { lines: snippet_lines, start: start_line + 1, end: end_line + 1 }
205
- rescue StandardError
206
- nil
190
+ super(file_path, line_number, context_lines: context)
207
191
  end
208
192
 
209
193
  # Get git blame for a file:line range
210
- def git_blame_for_line(file_path, line_number, context: 2)
211
- return nil unless file_path.present? && line_number.present?
212
- return nil unless File.exist?(file_path)
213
-
214
- start_line = [line_number - context, 1].max
215
- end_line = line_number + context
216
-
217
- result = `git blame -L #{start_line},#{end_line} --porcelain #{file_path} 2>/dev/null`
218
- return nil unless $?.success?
219
-
220
- blame_data = {}
221
- current_sha = nil
222
-
223
- result.each_line do |line|
224
- if line =~ /^([0-9a-f]{40})\s+(\d+)\s+(\d+)/
225
- current_sha = $1
226
- line_num = $2.to_i
227
- blame_data[line_num] = { sha: current_sha&.slice(0, 7) } if line_num == line_number
228
- elsif line =~ /^author\s+(.+)/
229
- blame_data.each_value { |v| v[:author] = $1.strip if v[:sha] == current_sha&.slice(0, 7) && v[:author].nil? }
230
- elsif line =~ /^author-time\s+(\d+)/
231
- time = Time.at($1.to_i)
232
- blame_data.each_value { |v| v[:date] = time.strftime("%Y-%m-%d") if v[:sha] == current_sha&.slice(0, 7) && v[:date].nil? }
233
- elsif line =~ /^summary\s+(.+)/
234
- blame_data.each_value { |v| v[:message] = $1.strip if v[:sha] == current_sha&.slice(0, 7) && v[:message].nil? }
235
- end
236
- end
237
-
238
- blame_data[line_number]
239
- rescue StandardError
240
- nil
194
+ def git_blame_for_line(file_path, line_number, **_options)
195
+ super(file_path, line_number)
241
196
  end
242
197
 
243
198
  # Parse backtrace line into file path and line number
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+ require "pathname"
5
+
3
6
  module RailsNexus
4
7
  module SourceCodeHelper
5
8
  # Parse a backtrace line into components
@@ -20,16 +23,22 @@ module RailsNexus
20
23
  # Read source code snippet around a specific line
21
24
  # Returns { lines: Array, file: String, from_line: Integer, to_line: Integer, error_line: Integer }
22
25
  def read_source_snippet(file_path, error_line, context_lines: 5)
23
- return nil unless file_path.present? && File.exist?(file_path)
26
+ resolved_path = resolve_source_path(file_path)
27
+ return nil unless resolved_path
28
+
29
+ error_line = Integer(error_line, exception: false)
30
+ return nil unless error_line&.positive?
31
+
32
+ context_lines = (Integer(context_lines, exception: false) || 5).clamp(0, 50)
24
33
 
25
- total_lines = File.foreach(file_path).count
34
+ total_lines = File.foreach(resolved_path).count
26
35
  from_line = [error_line - context_lines, 1].max
27
36
  to_line = [error_line + context_lines, total_lines].min
28
37
 
29
38
  lines = []
30
39
  current_line = 0
31
40
 
32
- File.foreach(file_path) do |raw_line|
41
+ File.foreach(resolved_path) do |raw_line|
33
42
  current_line += 1
34
43
  break if current_line > to_line
35
44
  next if current_line < from_line
@@ -43,7 +52,7 @@ module RailsNexus
43
52
 
44
53
  {
45
54
  lines: lines,
46
- file: file_path,
55
+ file: resolved_path,
47
56
  from_line: from_line,
48
57
  to_line: to_line,
49
58
  error_line: error_line
@@ -53,13 +62,23 @@ module RailsNexus
53
62
  # Get git blame for a specific line
54
63
  # Returns { sha: String, author: String, date: String, message: String }
55
64
  def git_blame_for_line(file_path, line_number)
56
- return nil unless file_path.present? && line_number.present?
57
- return nil unless File.exist?(file_path)
58
- return nil unless system("git", "rev-parse", "--git-dir", File.dirname(file_path), [:out, :err] => File::NULL)
65
+ resolved_path = resolve_source_path(file_path)
66
+ line_number = Integer(line_number, exception: false)
67
+ return nil unless resolved_path && line_number&.positive?
68
+
69
+ directory = File.dirname(resolved_path)
70
+ repository_root, _stderr, status = Open3.capture3("git", "-C", directory, "rev-parse", "--show-toplevel")
71
+ return nil unless status.success?
72
+
73
+ repository_root = Pathname.new(repository_root.strip).realpath
74
+ relative_path = Pathname.new(resolved_path).relative_path_from(repository_root).to_s
75
+ return nil if relative_path == ".." || relative_path.start_with?("../")
59
76
 
60
77
  # Get blame for single line
61
- blame_output = `git -C "#{File.dirname(file_path)}" blame -L #{line_number},#{line_number} -p "#{File.basename(file_path)}" 2>/dev/null`
62
- return nil unless $?.success?
78
+ blame_output, _stderr, status = Open3.capture3(
79
+ "git", "-C", repository_root.to_s, "blame", "-L", "#{line_number},#{line_number}", "--porcelain", "--", relative_path
80
+ )
81
+ return nil unless status.success?
63
82
 
64
83
  # Parse porcelain blame output
65
84
  result = {}
@@ -80,6 +99,8 @@ module RailsNexus
80
99
  end
81
100
 
82
101
  result.presence
102
+ rescue ArgumentError, Errno::ENOENT, Errno::EACCES
103
+ nil
83
104
  end
84
105
 
85
106
  # Get source code with git blame for multiple backtrace lines
@@ -99,9 +120,27 @@ module RailsNexus
99
120
  parsed: parsed,
100
121
  source: source,
101
122
  blame: blame,
102
- is_app_code: parsed[:file]&.start_with?(Rails.root.to_s)
123
+ is_app_code: resolve_source_path(parsed[:file]).present?
103
124
  }
104
125
  end
105
126
  end
127
+
128
+ # Resolve a path and ensure its canonical location is a regular file inside Rails.root.
129
+ def resolve_source_path(file_path)
130
+ return nil if file_path.blank?
131
+
132
+ root = Pathname.new(Rails.root).realpath
133
+ candidate = Pathname.new(file_path.to_s)
134
+ candidate = root.join(candidate) unless candidate.absolute?
135
+ resolved = candidate.realpath
136
+ relative = resolved.relative_path_from(root).to_s
137
+
138
+ return nil if relative == ".." || relative.start_with?("../")
139
+ return nil unless resolved.file?
140
+
141
+ resolved.to_s
142
+ rescue ArgumentError, Errno::ENOENT, Errno::EACCES
143
+ nil
144
+ end
106
145
  end
107
146
  end
@@ -1,4 +1,3 @@
1
- import "@hotwired/turbo-rails"
2
1
  import { Application } from "@hotwired/stimulus"
3
2
 
4
3
  import RailsNexusController from "rails_nexus/controllers/rails_nexus"
@@ -6,8 +5,19 @@ import RailsNexusDetailController from "rails_nexus/controllers/rails_nexus_deta
6
5
  import RailsNexusSidebarController from "rails_nexus/controllers/rails_nexus_sidebar"
7
6
  import RailsNexusThemeController from "rails_nexus/controllers/rails_nexus_theme"
8
7
 
9
- // Keep the engine independent from the host application's Stimulus setup.
10
- const application = Application.start()
8
+ async function ensureTurbo() {
9
+ if (window.Turbo) return
10
+
11
+ const module = await import("@hotwired/turbo-rails")
12
+ window.Turbo = module.Turbo
13
+ }
14
+
15
+ // Reuse an explicitly exposed host Stimulus application when possible. If the
16
+ // host keeps it module-local, create one namespaced engine application only.
17
+ const application = window.Stimulus?.register
18
+ ? window.Stimulus
19
+ : (window.RailsNexusStimulus ||= Application.start())
20
+
11
21
  application.debug = false
12
22
 
13
23
  // Register engine controllers
@@ -15,3 +25,5 @@ application.register("rails_nexus", RailsNexusController)
15
25
  application.register("rails_nexus-theme", RailsNexusThemeController)
16
26
  application.register("rails_nexus-sidebar", RailsNexusSidebarController)
17
27
  application.register("rails_nexus-detail", RailsNexusDetailController)
28
+
29
+ ensureTurbo()
@@ -26,6 +26,10 @@ module RailsNexus
26
26
  attribute :split_chunks, :boolean, default: false
27
27
 
28
28
  validates :name, presence: true, uniqueness: true
29
+ validates :name, format: {
30
+ with: /\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/,
31
+ message: "may contain only letters, numbers, dots, underscores, dollar signs, and hyphens"
32
+ }
29
33
  validates :database_name, presence: true, if: -> { adapter != "redis" }
30
34
  validates :adapter, presence: true, inclusion: { in: ADAPTERS }
31
35
  validates :storage_path, presence: true
@@ -38,6 +42,11 @@ module RailsNexus
38
42
  validates :s3_secret_key, presence: true, if: :s3_enabled?
39
43
  validates :gpg_password, presence: true, if: :gpg_enabled?
40
44
  validates :email_to, presence: true, if: :email_notify?
45
+ validates :port, :rsync_port,
46
+ numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: 65_535 },
47
+ allow_nil: true
48
+ validate :database_identifier_is_safe
49
+ validate :remote_sync_values_are_safe
41
50
 
42
51
  scope :enabled, -> { where(enabled: true) }
43
52
  scope :disabled, -> { where(enabled: false) }
@@ -139,6 +148,28 @@ module RailsNexus
139
148
 
140
149
  private
141
150
 
151
+ def database_identifier_is_safe
152
+ return if database_name.blank? || sqlite?
153
+
154
+ unless database_name.match?(/\A[a-zA-Z0-9_$][a-zA-Z0-9_$.-]*\z/) && !database_name.include?("..")
155
+ errors.add(:database_name, "contains unsupported characters")
156
+ end
157
+ end
158
+
159
+ def remote_sync_values_are_safe
160
+ return unless rsync_enabled?
161
+
162
+ unless rsync_host.to_s.match?(/\A[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\z/)
163
+ errors.add(:rsync_host, "is not a valid hostname")
164
+ end
165
+ unless rsync_user.to_s.match?(/\A[a-zA-Z0-9_][a-zA-Z0-9_.-]*\z/)
166
+ errors.add(:rsync_user, "contains unsupported characters")
167
+ end
168
+ unless rsync_path.to_s.start_with?("/") && !rsync_path.to_s.match?(/[\0\r\n]/)
169
+ errors.add(:rsync_path, "must be an absolute remote path")
170
+ end
171
+ end
172
+
142
173
  def dump_extension
143
174
  parts = ["sql"]
144
175
  parts << "gz" if compress? && !bzip2_compress?