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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/README.md +33 -6
- data/SECURITY.md +11 -0
- data/THIRD_PARTY_NOTICES.md +14 -0
- data/app/assets/config/rails_nexus_manifest.js +1 -1
- data/app/assets/javascripts/rails_nexus/application.js +55 -0
- data/app/controllers/rails_nexus/analytics_controller.rb +13 -26
- data/app/controllers/rails_nexus/application_controller.rb +6 -2
- data/app/controllers/rails_nexus/backup_controller.rb +1 -2
- data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -9
- data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
- data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
- data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
- data/app/controllers/rails_nexus/settings_controller.rb +1 -39
- data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
- data/app/controllers/rails_nexus/stats_controller.rb +43 -43
- data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
- data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
- data/app/javascript/rails_nexus/application.js +15 -3
- data/app/models/rails_nexus/backup_config.rb +31 -0
- data/app/models/rails_nexus/logged_exception.rb +27 -13
- data/app/services/rails_nexus/backup_service.rb +233 -98
- data/app/views/layouts/rails_nexus/application.html.erb +1 -1
- data/app/views/rails_nexus/stats/index.html.erb +1 -1
- data/lib/generators/rails_nexus/install_generator.rb +1 -1
- data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
- data/lib/generators/rails_nexus/templates/rails_nexus.rb +3 -0
- data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +1 -1
- data/lib/rails_nexus/configuration.rb +10 -0
- data/lib/rails_nexus/database_adapter.rb +53 -0
- data/lib/rails_nexus/engine.rb +0 -13
- data/lib/rails_nexus/logger.rb +1 -12
- data/lib/rails_nexus/notifications.rb +27 -20
- data/lib/rails_nexus/version.rb +1 -1
- data/lib/rails_nexus/webhook_client.rb +151 -0
- data/lib/rails_nexus.rb +37 -23
- data/rails_nexus.gemspec +3 -2
- metadata +8 -16
- 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
|
|
@@ -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
|
-
|
|
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]
|
|
10
|
+
line_number = Integer(params[:line], exception: false)
|
|
11
11
|
|
|
12
|
-
if file_path.blank? || line_number
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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] =
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
|
298
|
+
if (version = executable_version("pg_dump"))
|
|
312
299
|
stats[:detected] = true
|
|
313
300
|
stats[:gem] ||= "pg_dump"
|
|
314
|
-
stats[:details][:pg_dump] =
|
|
301
|
+
stats[:details][:pg_dump] = version
|
|
315
302
|
end
|
|
316
303
|
|
|
317
304
|
# Check for mysqldump (MySQL)
|
|
318
|
-
if
|
|
305
|
+
if (version = executable_version("mysqldump"))
|
|
319
306
|
stats[:detected] = true
|
|
320
307
|
stats[:gem] ||= "mysqldump"
|
|
321
|
-
stats[:details][:mysqldump] =
|
|
308
|
+
stats[:details][:mysqldump] = version
|
|
322
309
|
end
|
|
323
310
|
|
|
324
311
|
# Check for AWS CLI (RDS backups)
|
|
325
|
-
if
|
|
312
|
+
if (version = executable_version("aws"))
|
|
326
313
|
stats[:detected] = true
|
|
327
314
|
stats[:gem] ||= "aws-cli"
|
|
328
|
-
stats[:details][:aws_cli] =
|
|
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] =
|
|
332
|
+
stats[:details][:docker] = version
|
|
346
333
|
end
|
|
347
334
|
|
|
348
335
|
# Check for crontab entries related to backups
|
|
349
336
|
begin
|
|
350
|
-
crontab =
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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,
|
|
211
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
return nil unless
|
|
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
|
|
62
|
-
|
|
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]
|
|
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
|
-
|
|
10
|
-
|
|
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?
|