rails_nexus 2.1.0 → 2.1.3

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +68 -13
  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/cron_jobs_controller.rb +1 -9
  11. data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
  12. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
  13. data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
  14. data/app/controllers/rails_nexus/settings_controller.rb +1 -39
  15. data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
  16. data/app/controllers/rails_nexus/stats_controller.rb +33 -159
  17. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
  18. data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
  19. data/app/javascript/rails_nexus/application.js +15 -3
  20. data/app/models/rails_nexus/logged_exception.rb +27 -13
  21. data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +0 -11
  22. data/app/views/layouts/rails_nexus/application.html.erb +2 -13
  23. data/app/views/rails_nexus/stats/index.html.erb +1 -102
  24. data/config/routes.rb +0 -11
  25. data/lib/generators/rails_nexus/install_generator.rb +1 -1
  26. data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
  27. data/lib/generators/rails_nexus/templates/migration.rb +0 -82
  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 -45
  31. data/lib/rails_nexus/database_adapter.rb +53 -0
  32. data/lib/rails_nexus/engine.rb +6 -17
  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/lib/tasks/rails_nexus.rake +0 -80
  39. data/rails_nexus.gemspec +4 -5
  40. metadata +12 -41
  41. data/app/controllers/rails_nexus/backup_controller.rb +0 -120
  42. data/app/jobs/rails_nexus/backup_job.rb +0 -18
  43. data/app/models/rails_nexus/backup.rb +0 -107
  44. data/app/models/rails_nexus/backup_config.rb +0 -158
  45. data/app/services/rails_nexus/backup_runner.rb +0 -62
  46. data/app/services/rails_nexus/backup_service.rb +0 -682
  47. data/app/views/rails_nexus/backup/_form.html.erb +0 -334
  48. data/app/views/rails_nexus/backup/config_history.html.erb +0 -67
  49. data/app/views/rails_nexus/backup/edit.html.erb +0 -15
  50. data/app/views/rails_nexus/backup/history.html.erb +0 -75
  51. data/app/views/rails_nexus/backup/index.html.erb +0 -112
  52. data/app/views/rails_nexus/backup/new.html.erb +0 -15
  53. data/config/importmap.rb +0 -15
  54. data/db/seeds/backup_configs.rb +0 -140
  55. data/lib/generators/rails_nexus/backup_generator.rb +0 -70
  56. data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +0 -152
  57. data/lib/generators/rails_nexus/templates/backup/config.rb +0 -27
  58. data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +0 -24
  59. data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +0 -35
  60. data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +0 -43
  61. data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +0 -15
  62. data/lib/generators/rails_nexus/templates/backup/schedule.rb +0 -28
@@ -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
@@ -13,20 +11,11 @@ module RailsNexus
13
11
  @exception_stats = collect_exception_stats
14
12
  @platform_stats = collect_platform_stats
15
13
  @database_stats = collect_database_stats
16
- @backup_stats = collect_backup_stats
17
14
  @storm_stats = collect_storm_stats if defined?(RailsNexus::StormProtection)
18
15
  end
19
16
 
20
17
  private
21
18
 
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
19
  def collect_system_stats
31
20
  stats = {}
32
21
 
@@ -83,7 +72,7 @@ module RailsNexus
83
72
  process_stats[:thread_count] = "N/A"
84
73
  end
85
74
  begin
86
- process_stats[:open_files] = `ls /proc/#{pid}/fd 2>/dev/null | wc -l`.strip.to_i
75
+ process_stats[:open_files] = Dir.children("/proc/#{pid}/fd").size
87
76
  rescue StandardError
88
77
  process_stats[:open_files] = 0
89
78
  end
@@ -133,13 +122,14 @@ module RailsNexus
133
122
  }
134
123
  else
135
124
  # Fallback: use `df` command
136
- df_output = `df -h / 2>/dev/null`.split("\n").last&.split
125
+ stdout, _stderr, status = Open3.capture3("df", "-h", "/")
126
+ df_output = status.success? ? stdout.lines.last&.split : nil
137
127
  if df_output
138
128
  {
139
129
  total_gb: df_output[1],
140
130
  used_gb: df_output[2],
141
131
  available_gb: df_output[3],
142
- usage_percent: df_output[4]&.to_i || 0
132
+ usage_percent: df_output[4].to_i
143
133
  }
144
134
  else
145
135
  { total_gb: "N/A", used_gb: "N/A", available_gb: "N/A", usage_percent: 0 }
@@ -201,11 +191,7 @@ module RailsNexus
201
191
  stats[:pool_available] = pool.available? ? pool.connections.size : 0
202
192
 
203
193
  # Database version
204
- begin
205
- stats[:version] = ActiveRecord::Base.connection.select_value("SELECT version()")
206
- rescue StandardError
207
- stats[:version] = "N/A"
208
- end
194
+ stats[:version] = RailsNexus::DatabaseAdapter.database_version
209
195
 
210
196
  # Table stats (rails_nexus tables)
211
197
  stats[:tables] = collect_table_stats
@@ -256,131 +242,6 @@ module RailsNexus
256
242
  { error: e.message }
257
243
  end
258
244
 
259
- def collect_backup_stats
260
- stats = { detected: false, gem: nil, status: "unknown", last_backup: nil, details: {} }
261
-
262
- # Check for backup gem
263
- if defined?(Backup)
264
- stats[:detected] = true
265
- stats[:gem] = "backup"
266
- stats[:version] = Backup::VERSION if Backup.const_defined?(:VERSION)
267
-
268
- begin
269
- # Check for backup configuration
270
- config_path = Rails.root.join("config", "backup")
271
- if config_path.exist?
272
- stats[:details][:config_path] = config_path.to_s
273
- stats[:status] = "configured"
274
- end
275
-
276
- # Check for backup logs
277
- log_path = Rails.root.join("log", "backup")
278
- if log_path.exist?
279
- log_files = log_path.glob("*.log").sort_by(&:mtime).reverse
280
- if log_files.any?
281
- last_log = log_files.first
282
- stats[:details][:log_file] = last_log.to_s
283
- stats[:details][:log_size] = (last_log.size / 1024.0).round(1)
284
-
285
- # Parse last backup time from log
286
- log_content = last_log.read(1000) # Read first 1KB
287
- if log_content.match(/started at (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/)
288
- stats[:last_backup] = Time.parse($1)
289
- end
290
- end
291
- end
292
-
293
- # Check for backup models
294
- models = Backup::Model.all rescue []
295
- if models.any?
296
- stats[:details][:models] = models.map do |m|
297
- {
298
- name: m.name,
299
- schedule: m.schedule&.to_s,
300
- triggers: m.triggers.map(&:name)
301
- }
302
- end
303
- stats[:status] = "configured"
304
- end
305
- rescue StandardError => e
306
- stats[:details][:error] = e.message
307
- end
308
- end
309
-
310
- # Check for pg_dump (PostgreSQL)
311
- if system("which pg_dump > /dev/null 2>&1")
312
- stats[:detected] = true
313
- stats[:gem] ||= "pg_dump"
314
- stats[:details][:pg_dump] = `pg_dump --version 2>/dev/null`.strip
315
- end
316
-
317
- # Check for mysqldump (MySQL)
318
- if system("which mysqldump > /dev/null 2>&1")
319
- stats[:detected] = true
320
- stats[:gem] ||= "mysqldump"
321
- stats[:details][:mysqldump] = `mysqldump --version 2>/dev/null`.strip
322
- end
323
-
324
- # Check for AWS CLI (RDS backups)
325
- if system("which aws > /dev/null 2>&1")
326
- stats[:detected] = true
327
- stats[:gem] ||= "aws-cli"
328
- stats[:details][:aws_cli] = `aws --version 2>/dev/null`.strip
329
-
330
- # Check for RDS snapshots if configured
331
- begin
332
- rds_client = Aws::RDS::Client.new
333
- snapshots = rds_client.describe_db_snapshots(db_snapshot_identifier: "automated-*")
334
- stats[:details][:rds_snapshots] = snapshots.db_snapshots.count
335
- stats[:status] = "configured" if snapshots.db_snapshots.any?
336
- rescue StandardError
337
- # AWS not configured or no access
338
- end
339
- end
340
-
341
- # Check for docker backup solutions
342
- if File.exist?("/var/lib/docker")
343
- stats[:detected] = true
344
- stats[:gem] ||= "docker"
345
- stats[:details][:docker] = `docker --version 2>/dev/null`.strip
346
- end
347
-
348
- # Check for crontab entries related to backups
349
- begin
350
- crontab = `crontab -l 2>/dev/null`
351
- if crontab.include?("backup") || crontab.include?("dump")
352
- stats[:details][:crontab_backup] = true
353
- stats[:status] = "scheduled" if stats[:status] == "unknown"
354
- end
355
- rescue StandardError
356
- # Cannot read crontab
357
- end
358
-
359
- # Check for backup-related environment variables
360
- backup_env_vars = %w[BACKUP_DIR BACKUP_S3_BUCKET BACKUP_GCS_BUCKET DATABASE_BACKUP_URL]
361
- found_env = backup_env_vars.select { |var| ENV[var] }
362
- if found_env.any?
363
- stats[:detected] = true
364
- stats[:details][:env_vars] = found_env
365
- end
366
-
367
- # Determine overall status
368
- if stats[:last_backup]
369
- hours_since = (Time.now - stats[:last_backup]) / 3600
370
- if hours_since < 24
371
- stats[:status] = "healthy"
372
- elsif hours_since < 168 # 7 days
373
- stats[:status] = "warning"
374
- else
375
- stats[:status] = "stale"
376
- end
377
- elsif stats[:status] == "unknown"
378
- stats[:status] = "not_configured"
379
- end
380
-
381
- stats
382
- end
383
-
384
245
  def collect_table_stats
385
246
  tables = []
386
247
 
@@ -391,11 +252,13 @@ module RailsNexus
391
252
  begin
392
253
  next unless ActiveRecord::Base.connection.table_exists?(table_name)
393
254
 
394
- count = ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM #{table_name}")
255
+ quoted_table = ActiveRecord::Base.connection.quote_table_name(table_name)
256
+ count = ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM #{quoted_table}").to_i
395
257
  tables << {
396
258
  name: table_name,
397
259
  row_count: count,
398
- size_mb: 0,
260
+ size_mb: nil,
261
+ size_supported: false,
399
262
  status: count > 0 ? "active" : "empty"
400
263
  }
401
264
  rescue StandardError
@@ -415,7 +278,7 @@ module RailsNexus
415
278
  SQL
416
279
  result.each do |row|
417
280
  table = tables.find { |t| t[:name] == row["table_name"] }
418
- table[:size_mb] = row["size_mb"] if table
281
+ table&.merge!(size_mb: row["size_mb"].to_f, size_supported: true)
419
282
  end
420
283
  elsif adapter.include?("postgresql")
421
284
  result = ActiveRecord::Base.connection.execute(<<~SQL)
@@ -427,7 +290,7 @@ module RailsNexus
427
290
  SQL
428
291
  result.each do |row|
429
292
  table = tables.find { |t| t[:name] == row["table_name"] }
430
- table[:size_mb] = (row["size_bytes"].to_f / 1024 / 1024).round(2) if table
293
+ table&.merge!(size_mb: (row["size_bytes"].to_f / 1024 / 1024).round(2), size_supported: true)
431
294
  end
432
295
  end
433
296
  rescue StandardError
@@ -437,6 +300,20 @@ module RailsNexus
437
300
  tables
438
301
  end
439
302
 
303
+ def executable_version(name)
304
+ executable = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
305
+ .map { |directory| File.join(directory, name) }
306
+ .find { |path| File.file?(path) && File.executable?(path) }
307
+ return nil unless executable
308
+
309
+ stdout, stderr, status = Open3.capture3(executable, "--version")
310
+ return nil unless status.success?
311
+
312
+ [stdout, stderr].find(&:present?)&.lines&.first&.strip
313
+ rescue Errno::ENOENT, Errno::EACCES
314
+ nil
315
+ end
316
+
440
317
  def collect_platform_stats
441
318
  {
442
319
  breakdown: LoggedException.platform_stats,
@@ -476,17 +353,14 @@ module RailsNexus
476
353
  end
477
354
 
478
355
  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
356
+ stdout, _stderr, status = Open3.capture3("ps", "-o", "etimes=", "-p", Process.pid.to_s)
357
+ return "N/A" unless status.success?
358
+
359
+ seconds = stdout.to_i
360
+ days = seconds / 86_400
361
+ hours = (seconds % 86_400) / 3600
362
+ minutes = (seconds % 3600) / 60
363
+ "#{days}d #{hours}h #{minutes}m"
490
364
  rescue StandardError
491
365
  "N/A"
492
366
  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()