rails_nexus 2.0.6 → 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/assets/javascripts/rails_nexus/controllers.js +82 -5
- data/app/assets/stylesheets/rails_nexus/application.css +170 -60
- 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 +22 -19
- 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/application_helper.rb +5 -0
- 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/controllers/rails_nexus_controller.js +6 -2
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +71 -3
- data/app/javascript/rails_nexus/application.js +15 -3
- data/app/jobs/rails_nexus/backup_job.rb +18 -0
- data/app/models/rails_nexus/backup.rb +11 -11
- data/app/models/rails_nexus/backup_config.rb +38 -2
- data/app/models/rails_nexus/logged_exception.rb +27 -13
- data/app/services/rails_nexus/backup_service.rb +342 -120
- data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +40 -10
- data/app/views/layouts/rails_nexus/application.html.erb +72 -14
- data/app/views/rails_nexus/backup/_form.html.erb +3 -0
- data/app/views/rails_nexus/backup/history.html.erb +17 -2
- data/app/views/rails_nexus/backup/index.html.erb +29 -72
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +12 -44
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +21 -99
- data/app/views/rails_nexus/stats/index.html.erb +1 -1
- data/db/seeds/backup_configs.rb +5 -4
- 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/migration.rb +8 -2
- 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/lib/tasks/rails_nexus.rake +4 -4
- data/rails_nexus.gemspec +3 -2
- metadata +9 -16
- data/config/importmap.rb +0 -15
|
@@ -13,103 +13,23 @@
|
|
|
13
13
|
<% end %>
|
|
14
14
|
</nav>
|
|
15
15
|
|
|
16
|
-
<%# ───
|
|
17
|
-
<%
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
</div>
|
|
24
|
-
<div class="rn-stat">
|
|
25
|
-
<span class="rn-stat-label">This Week</span>
|
|
26
|
-
<span class="rn-stat-value <%= 'danger' if @stats[:this_week] > 10 %>"><%= @stats[:this_week] %></span>
|
|
27
|
-
<span class="rn-stat-change">Last 7 days</span>
|
|
28
|
-
</div>
|
|
29
|
-
<div class="rn-stat">
|
|
30
|
-
<span class="rn-stat-label">Today</span>
|
|
31
|
-
<span class="rn-stat-value <%= 'danger' if @stats[:today] > 5 %>"><%= @stats[:today] %></span>
|
|
32
|
-
<span class="rn-stat-change <%= 'up' if @stats[:today] > @stats[:yesterday] %>">
|
|
33
|
-
<% if @stats[:yesterday] > 0 %>
|
|
34
|
-
<%= ((@stats[:today].to_f / [@stats[:yesterday], 1].max - 1) * 100).round %>% vs yesterday
|
|
35
|
-
<% else %>
|
|
36
|
-
vs yesterday
|
|
37
|
-
<% end %>
|
|
38
|
-
</span>
|
|
39
|
-
</div>
|
|
40
|
-
<div class="rn-stat">
|
|
41
|
-
<span class="rn-stat-label">Unique Classes</span>
|
|
42
|
-
<span class="rn-stat-value"><%= @stats[:top_classes]&.size || 0 %></span>
|
|
43
|
-
<span class="rn-stat-change">Exception types</span>
|
|
44
|
-
</div>
|
|
45
|
-
</div>
|
|
46
|
-
<% end %>
|
|
47
|
-
|
|
48
|
-
<%# ─── User Impact Ranking ───────────────────────────────── %>
|
|
49
|
-
<% begin %>
|
|
50
|
-
<% if RailsNexus.configuration.user_impact_tracking %>
|
|
51
|
-
<% impact_data = RailsNexus::LoggedException.user_impact_ranking(limit: 5) %>
|
|
52
|
-
<% if impact_data.any? %>
|
|
53
|
-
<div class="rn-card">
|
|
54
|
-
<div class="p-4" style="border-bottom: 1px solid var(--rn-border-light)">
|
|
55
|
-
<div class="flex items-center gap-2">
|
|
56
|
-
<svg class="w-4 h-4" style="color: var(--rn-danger)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
57
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
|
58
|
-
</svg>
|
|
59
|
-
<h2 class="text-sm font-semibold" style="color: var(--rn-text)">User Impact (Top 5)</h2>
|
|
60
|
-
</div>
|
|
61
|
-
<p class="text-xs mt-1" style="color: var(--rn-text-muted)">Errors ranked by unique users affected</p>
|
|
62
|
-
</div>
|
|
63
|
-
<div class="rn-table-wrap">
|
|
64
|
-
<table class="rn-table">
|
|
65
|
-
<thead>
|
|
66
|
-
<tr>
|
|
67
|
-
<th>Exception</th>
|
|
68
|
-
<th>Users Affected</th>
|
|
69
|
-
<th>Occurrences</th>
|
|
70
|
-
<th>Last Seen</th>
|
|
71
|
-
</tr>
|
|
72
|
-
</thead>
|
|
73
|
-
<tbody>
|
|
74
|
-
<% impact_data.each do |row| %>
|
|
75
|
-
<tr>
|
|
76
|
-
<td>
|
|
77
|
-
<span class="rn-badge rn-badge-danger"><%= row.exception_class %></span>
|
|
78
|
-
<span class="text-xs ml-1" style="color: var(--rn-text-muted)"><%= row.controller_name %>#<%= row.action_name %></span>
|
|
79
|
-
</td>
|
|
80
|
-
<td>
|
|
81
|
-
<span class="font-bold" style="color: var(--rn-danger)"><%= row.unique_users %></span>
|
|
82
|
-
</td>
|
|
83
|
-
<td>
|
|
84
|
-
<span style="color: var(--rn-text-secondary)"><%= row.total_occurrences %></span>
|
|
85
|
-
</td>
|
|
86
|
-
<td>
|
|
87
|
-
<span class="text-xs" style="color: var(--rn-text-muted)"><%= row.last_seen&.strftime("%b %d %H:%M") %></span>
|
|
88
|
-
</td>
|
|
89
|
-
</tr>
|
|
90
|
-
<% end %>
|
|
91
|
-
</tbody>
|
|
92
|
-
</table>
|
|
93
|
-
</div>
|
|
94
|
-
</div>
|
|
95
|
-
<% end %>
|
|
96
|
-
<% end %>
|
|
97
|
-
<% rescue => e %>
|
|
98
|
-
<%# Silently ignore if user_id column doesn't exist yet %>
|
|
99
|
-
<% end %>
|
|
100
|
-
|
|
101
|
-
<%# ─── Filters Card ────────────────────────────────────────── %>
|
|
102
|
-
<div class="rn-card">
|
|
103
|
-
<%# Filter header with icon %>
|
|
104
|
-
<div class="rn-filters" style="padding-bottom: 0.5rem">
|
|
105
|
-
<div class="flex items-center gap-2">
|
|
16
|
+
<%# ─── Collapsible Filters ─────────────────────────────────── %>
|
|
17
|
+
<% pills = active_filter_pills %>
|
|
18
|
+
<section class="rn-card rn-exceptions-panel">
|
|
19
|
+
<div class="rn-exceptions-main" style="min-height: 400px">
|
|
20
|
+
<details class="rn-filter-disclosure" open>
|
|
21
|
+
<summary class="rn-filter-summary">
|
|
22
|
+
<span class="flex items-center gap-2">
|
|
106
23
|
<svg class="w-4 h-4" style="color: var(--rn-text-muted)" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" /></svg>
|
|
107
|
-
<span class="text-
|
|
108
|
-
<% if
|
|
109
|
-
|
|
24
|
+
<span class="text-sm font-semibold" style="color: var(--rn-text)">Filters</span>
|
|
25
|
+
<% if pills.any? %>
|
|
26
|
+
<span class="rn-filter-count"><%= pills.size %> active</span>
|
|
27
|
+
<% else %>
|
|
28
|
+
<span class="text-xs" style="color: var(--rn-text-muted)">Search and narrow exceptions</span>
|
|
110
29
|
<% end %>
|
|
111
|
-
</
|
|
112
|
-
|
|
30
|
+
</span>
|
|
31
|
+
<svg class="rn-filter-chevron w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25L12 15.75 4.5 8.25" /></svg>
|
|
32
|
+
</summary>
|
|
113
33
|
|
|
114
34
|
<%# Filter form %>
|
|
115
35
|
<%= search_form_for @q, url: query_logged_exceptions_path, data: { turbo_frame: "exceptions" } do |f| %>
|
|
@@ -215,11 +135,13 @@
|
|
|
215
135
|
|
|
216
136
|
<div class="flex items-center gap-2">
|
|
217
137
|
<%= f.submit "Apply", class: "rn-btn rn-btn-primary" %>
|
|
138
|
+
<% if filtered? %>
|
|
139
|
+
<%= link_to "Clear", logged_exceptions_path(per_page: params[:per_page]), class: "rn-btn rn-btn-ghost" %>
|
|
140
|
+
<% end %>
|
|
218
141
|
</div>
|
|
219
142
|
</div>
|
|
220
143
|
|
|
221
144
|
<%# Active filter pills %>
|
|
222
|
-
<% pills = active_filter_pills %>
|
|
223
145
|
<% if pills.any? %>
|
|
224
146
|
<div class="rn-filter-pills">
|
|
225
147
|
<% pills.each do |pill| %>
|
|
@@ -231,11 +153,9 @@
|
|
|
231
153
|
</div>
|
|
232
154
|
<% end %>
|
|
233
155
|
<% end %>
|
|
234
|
-
</
|
|
156
|
+
</details>
|
|
235
157
|
|
|
236
158
|
<%# ─── Content Area ────────────────────────────────────────── %>
|
|
237
|
-
<div class="flex gap-4" style="min-height: 400px">
|
|
238
|
-
|
|
239
159
|
<%# ─── Exception List ──────────────────────────────────── %>
|
|
240
160
|
<div class="flex-1 min-w-0">
|
|
241
161
|
|
|
@@ -270,6 +190,8 @@
|
|
|
270
190
|
</div>
|
|
271
191
|
</div>
|
|
272
192
|
|
|
193
|
+
</section>
|
|
194
|
+
|
|
273
195
|
<%# ─── Keyboard Shortcuts Dialog ───────────────────────────── %>
|
|
274
196
|
<% if RailsNexus.configuration.keyboard_shortcuts %>
|
|
275
197
|
<div class="rn-shortcuts-overlay" data-rails_nexus-target="shortcutsDialog" role="dialog" aria-modal="true" aria-labelledby="rn-shortcuts-title">
|
data/db/seeds/backup_configs.rb
CHANGED
|
@@ -18,8 +18,8 @@ configs = [
|
|
|
18
18
|
database_name: "tti_erp",
|
|
19
19
|
host: "localhost",
|
|
20
20
|
port: 3306,
|
|
21
|
-
username: "
|
|
22
|
-
|
|
21
|
+
username: "ENV[DB_USERNAME]",
|
|
22
|
+
password: "ENV[DB_PASSWORD]",
|
|
23
23
|
skip_tables: "tti_erp.logged_exceptions,tti_erp.sessions,tti_erp.versions",
|
|
24
24
|
|
|
25
25
|
storage_path: "~/dumps",
|
|
@@ -58,7 +58,8 @@ configs = [
|
|
|
58
58
|
database_name: "tti_erp",
|
|
59
59
|
host: "localhost",
|
|
60
60
|
port: 3306,
|
|
61
|
-
username: "
|
|
61
|
+
username: "ENV[DB_USERNAME]",
|
|
62
|
+
password: "ENV[DB_PASSWORD]",
|
|
62
63
|
skip_tables: "",
|
|
63
64
|
|
|
64
65
|
storage_path: "~/dumps",
|
|
@@ -92,7 +93,7 @@ configs = [
|
|
|
92
93
|
database_name: "tti_erp",
|
|
93
94
|
host: "localhost",
|
|
94
95
|
port: 3306,
|
|
95
|
-
username: "
|
|
96
|
+
username: "ENV[DB_USERNAME]",
|
|
96
97
|
skip_tables: "tti_erp.logged_exceptions,tti_erp.sessions,tti_erp.versions",
|
|
97
98
|
|
|
98
99
|
storage_path: "~/dumps/sync",
|
|
@@ -35,7 +35,7 @@ module RailsNexus
|
|
|
35
35
|
content = File.read(destination_controller)
|
|
36
36
|
unless content.include?("log_exception_handler")
|
|
37
37
|
inject_into_class app_controller, "ApplicationController" do
|
|
38
|
-
"\n rescue_from
|
|
38
|
+
"\n rescue_from StandardError, with: :log_exception_handler\n"
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
end
|
|
@@ -114,7 +114,7 @@ class CreateRailsNexusTables < ActiveRecord::Migration[<%= "[#{ActiveRecord::Mig
|
|
|
114
114
|
|
|
115
115
|
# ─── rails_nexus_backups ─────────────────────────────────────────
|
|
116
116
|
create_table :rails_nexus_backups do |t|
|
|
117
|
-
t.string :
|
|
117
|
+
t.string :config_name, null: false
|
|
118
118
|
t.string :status, null: false
|
|
119
119
|
t.text :file_path
|
|
120
120
|
t.bigint :file_size
|
|
@@ -125,7 +125,7 @@ class CreateRailsNexusTables < ActiveRecord::Migration[<%= "[#{ActiveRecord::Mig
|
|
|
125
125
|
t.datetime :completed_at
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
-
add_index :rails_nexus_backups, [:
|
|
128
|
+
add_index :rails_nexus_backups, [:config_name, :started_at]
|
|
129
129
|
add_index :rails_nexus_backups, :status
|
|
130
130
|
add_index :rails_nexus_backups, :started_at
|
|
131
131
|
|
|
@@ -148,6 +148,7 @@ class CreateRailsNexusTables < ActiveRecord::Migration[<%= "[#{ActiveRecord::Mig
|
|
|
148
148
|
t.boolean :bzip2_compress, default: false
|
|
149
149
|
t.boolean :encrypted, default: false
|
|
150
150
|
t.string :encryption_password
|
|
151
|
+
t.boolean :encrypt_base64, default: false
|
|
151
152
|
t.boolean :gpg_enabled, default: false
|
|
152
153
|
t.string :gpg_password
|
|
153
154
|
|
|
@@ -157,6 +158,11 @@ class CreateRailsNexusTables < ActiveRecord::Migration[<%= "[#{ActiveRecord::Mig
|
|
|
157
158
|
t.string :rsync_user
|
|
158
159
|
t.string :rsync_path
|
|
159
160
|
t.boolean :rsync_mirror, default: false
|
|
161
|
+
t.boolean :rsync_archive, default: true
|
|
162
|
+
t.string :rsync_directories
|
|
163
|
+
t.string :rsync_excludes
|
|
164
|
+
|
|
165
|
+
t.string :mysql_additional_options
|
|
160
166
|
|
|
161
167
|
# S3 storage
|
|
162
168
|
t.boolean :s3_enabled, default: false
|
|
@@ -34,6 +34,9 @@ Rails.application.config.to_prepare do
|
|
|
34
34
|
# config.webhooks = ["https://hooks.slack.com/services/xxx"]
|
|
35
35
|
# config.webhook_timeout = 5
|
|
36
36
|
# config.webhook_headers = { "Authorization" => "Bearer token" }
|
|
37
|
+
# config.webhook_allowed_hosts = ["hooks.example.com", "*.example.org"]
|
|
38
|
+
# config.webhook_denied_hosts = ["blocked.example.org"]
|
|
39
|
+
# config.webhook_allow_http_in_development = false
|
|
37
40
|
|
|
38
41
|
# ─── Cleanup ─────────────────────────────────────────────
|
|
39
42
|
# Auto-delete exceptions older than N days
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<title><%= content_for?(:title) ? yield(:title) : RailsNexus.configuration.application_name || "RailsNexus" %></title>
|
|
8
8
|
|
|
9
9
|
<%= stylesheet_link_tag "rails_nexus/application", "data-turbo-track": "reload" %>
|
|
10
|
-
<%=
|
|
10
|
+
<%= javascript_include_tag "rails_nexus/application", defer: true, "data-turbo-track": "reload" %>
|
|
11
11
|
|
|
12
12
|
<% if respond_to?(:turbo_refreshes_with) %>
|
|
13
13
|
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
|
|
@@ -49,6 +49,13 @@ module RailsNexus
|
|
|
49
49
|
# config.webhook_headers = { "Authorization" => "Bearer token123" }
|
|
50
50
|
attr_accessor :webhook_headers
|
|
51
51
|
|
|
52
|
+
# Host allow/deny policies. Wildcards such as "*.example.com" are accepted.
|
|
53
|
+
attr_accessor :webhook_allowed_hosts
|
|
54
|
+
attr_accessor :webhook_denied_hosts
|
|
55
|
+
|
|
56
|
+
# HTTP remains disabled except when this is true and Rails.env is development.
|
|
57
|
+
attr_accessor :webhook_allow_http_in_development
|
|
58
|
+
|
|
52
59
|
# ─── Logging ───────────────────────────────────────────────
|
|
53
60
|
|
|
54
61
|
# Enable structured logging (default: true).
|
|
@@ -209,6 +216,9 @@ module RailsNexus
|
|
|
209
216
|
@webhooks = []
|
|
210
217
|
@webhook_timeout = 5
|
|
211
218
|
@webhook_headers = {}
|
|
219
|
+
@webhook_allowed_hosts = []
|
|
220
|
+
@webhook_denied_hosts = []
|
|
221
|
+
@webhook_allow_http_in_development = false
|
|
212
222
|
|
|
213
223
|
# Logging
|
|
214
224
|
@logging_enabled = true
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
module DatabaseAdapter
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def family(connection = ActiveRecord::Base.connection)
|
|
8
|
+
name = connection.adapter_name.to_s.downcase
|
|
9
|
+
return :postgresql if name.include?("postgres")
|
|
10
|
+
return :mysql if name.include?("mysql") || name.include?("trilogy")
|
|
11
|
+
return :sqlite if name.include?("sqlite")
|
|
12
|
+
|
|
13
|
+
:unsupported
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def time_bucket_expression(column: "created_at", period: :hour, connection: ActiveRecord::Base.connection)
|
|
17
|
+
column = connection.quote_column_name(column)
|
|
18
|
+
|
|
19
|
+
case [family(connection), period.to_sym]
|
|
20
|
+
when [:postgresql, :hour] then "TO_CHAR(DATE_TRUNC('hour', #{column}), 'YYYY-MM-DD HH24:00')"
|
|
21
|
+
when [:mysql, :hour] then "DATE_FORMAT(#{column}, '%Y-%m-%d %H:00')"
|
|
22
|
+
when [:sqlite, :hour] then "strftime('%Y-%m-%d %H:00', #{column})"
|
|
23
|
+
else
|
|
24
|
+
raise NotImplementedError, "Time grouping is not supported by this database adapter"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def time_component_expression(column: "created_at", component:, connection: ActiveRecord::Base.connection)
|
|
29
|
+
column = connection.quote_column_name(column)
|
|
30
|
+
|
|
31
|
+
case [family(connection), component.to_sym]
|
|
32
|
+
when [:postgresql, :hour] then "CAST(EXTRACT(HOUR FROM #{column}) AS INTEGER)"
|
|
33
|
+
when [:mysql, :hour] then "HOUR(#{column})"
|
|
34
|
+
when [:sqlite, :hour] then "CAST(strftime('%H', #{column}) AS INTEGER)"
|
|
35
|
+
when [:postgresql, :weekday] then "CAST(EXTRACT(DOW FROM #{column}) AS INTEGER)"
|
|
36
|
+
when [:mysql, :weekday] then "DAYOFWEEK(#{column}) - 1"
|
|
37
|
+
when [:sqlite, :weekday] then "CAST(strftime('%w', #{column}) AS INTEGER)"
|
|
38
|
+
else
|
|
39
|
+
raise NotImplementedError, "Time component grouping is not supported by this database adapter"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def database_version(connection = ActiveRecord::Base.connection)
|
|
44
|
+
case family(connection)
|
|
45
|
+
when :sqlite then connection.select_value("SELECT sqlite_version()")
|
|
46
|
+
when :postgresql, :mysql then connection.select_value("SELECT version()")
|
|
47
|
+
else "N/A"
|
|
48
|
+
end
|
|
49
|
+
rescue ActiveRecord::StatementInvalid
|
|
50
|
+
"N/A"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/rails_nexus/engine.rb
CHANGED
|
@@ -11,8 +11,6 @@ module RailsNexus
|
|
|
11
11
|
initializer "rails_nexus.assets", before: "propshaft.assets_middleware", group: :all do |app|
|
|
12
12
|
next unless app.config.respond_to?(:assets)
|
|
13
13
|
|
|
14
|
-
app.config.assets.paths << root.join("app/javascript")
|
|
15
|
-
|
|
16
14
|
# Sprockets 4 only compiles assets listed in a manifest or in the
|
|
17
15
|
# precompile list. Register these here so mounting applications do not
|
|
18
16
|
# need to modify their own asset manifest.
|
|
@@ -20,21 +18,10 @@ module RailsNexus
|
|
|
20
18
|
app.config.assets.precompile += %w[
|
|
21
19
|
rails_nexus/application.css
|
|
22
20
|
rails_nexus/application.js
|
|
23
|
-
controllers/rails_nexus_controller.js
|
|
24
|
-
controllers/rails_nexus_detail_controller.js
|
|
25
|
-
controllers/rails_nexus_sidebar_controller.js
|
|
26
|
-
controllers/rails_nexus_theme_controller.js
|
|
27
21
|
]
|
|
28
22
|
end
|
|
29
23
|
end
|
|
30
24
|
|
|
31
|
-
# ─── Importmap support (for importmap-rails users) ────────────
|
|
32
|
-
|
|
33
|
-
initializer "rails_nexus.importmap", before: "importmap" do |app|
|
|
34
|
-
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
35
|
-
app.config.importmap.cache_sweepers << root.join("app/javascript")
|
|
36
|
-
end
|
|
37
|
-
|
|
38
25
|
# ─── Database connection (separate DB support) ─────────────────
|
|
39
26
|
|
|
40
27
|
initializer "rails_nexus.database" do
|
data/lib/rails_nexus/logger.rb
CHANGED
|
@@ -170,18 +170,7 @@ module RailsNexus
|
|
|
170
170
|
end
|
|
171
171
|
|
|
172
172
|
def filter_params(params)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
rails_filters = Rails.application.config.filter_parameters
|
|
176
|
-
params.each_with_object({}) do |(key, value), result|
|
|
177
|
-
if rails_filters.any? { |f| key.to_s.include?(f.to_s) }
|
|
178
|
-
result[key] = "[FILTERED]"
|
|
179
|
-
elsif value.is_a?(Hash)
|
|
180
|
-
result[key] = filter_params(value)
|
|
181
|
-
else
|
|
182
|
-
result[key] = value
|
|
183
|
-
end
|
|
184
|
-
end
|
|
173
|
+
RailsNexus.filter_sensitive_data(params)
|
|
185
174
|
end
|
|
186
175
|
end
|
|
187
176
|
end
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "net/http"
|
|
4
|
-
require "uri"
|
|
5
3
|
require "json"
|
|
4
|
+
require "rails_nexus/webhook_client"
|
|
6
5
|
|
|
7
6
|
module RailsNexus
|
|
8
7
|
module Notifications
|
|
@@ -28,28 +27,36 @@ module RailsNexus
|
|
|
28
27
|
config.webhooks.each do |url|
|
|
29
28
|
deliver_webhook(url, payload, config)
|
|
30
29
|
rescue StandardError => e
|
|
31
|
-
Rails.logger.error("[RailsNexus] Webhook failed
|
|
32
|
-
log_webhook_delivery(
|
|
30
|
+
Rails.logger.error("[RailsNexus] Webhook delivery failed: #{e.class}")
|
|
31
|
+
log_webhook_delivery(
|
|
32
|
+
url: RailsNexus::WebhookClient.redacted_url(url),
|
|
33
|
+
payload: payload,
|
|
34
|
+
error: "Webhook delivery failed",
|
|
35
|
+
event_type: "exception.logged"
|
|
36
|
+
)
|
|
33
37
|
end
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def deliver_webhook(url, payload, config)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
logged_url = RailsNexus::WebhookClient.redacted_url(url)
|
|
42
|
+
result = RailsNexus::WebhookClient.deliver(
|
|
43
|
+
url: url,
|
|
44
|
+
payload: payload,
|
|
45
|
+
headers: config.webhook_headers,
|
|
46
|
+
timeout: config.webhook_timeout
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if result[:success]
|
|
50
|
+
log_webhook_delivery(
|
|
51
|
+
url: logged_url,
|
|
52
|
+
payload: payload,
|
|
53
|
+
status_code: result[:status_code],
|
|
54
|
+
response_body: result[:body],
|
|
55
|
+
event_type: "exception.logged"
|
|
56
|
+
)
|
|
57
|
+
else
|
|
58
|
+
Rails.logger.error("[RailsNexus] Webhook delivery failed: #{result[:error]}")
|
|
59
|
+
log_webhook_delivery(url: logged_url, payload: payload, error: result[:error], event_type: "exception.logged")
|
|
53
60
|
end
|
|
54
61
|
end
|
|
55
62
|
|
data/lib/rails_nexus/version.rb
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "socket"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module RailsNexus
|
|
11
|
+
class WebhookClient
|
|
12
|
+
class ValidationError < StandardError; end
|
|
13
|
+
|
|
14
|
+
BLOCKED_NETWORKS = %w[
|
|
15
|
+
0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
|
|
16
|
+
172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.168.0.0/16
|
|
17
|
+
198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4
|
|
18
|
+
::/128 ::1/128 ::ffff:0:0/96 100::/64 2001:db8::/32 fc00::/7 fe80::/10 ff00::/8
|
|
19
|
+
].map { |network| IPAddr.new(network) }.freeze
|
|
20
|
+
|
|
21
|
+
def self.deliver(url:, payload:, headers: {}, timeout: nil)
|
|
22
|
+
new(url: url, payload: payload, headers: headers, timeout: timeout).deliver
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.redacted_url(url)
|
|
26
|
+
uri = URI.parse(url.to_s)
|
|
27
|
+
return "invalid-webhook-url" if uri.host.blank?
|
|
28
|
+
|
|
29
|
+
port = uri.port && uri.port != uri.default_port ? ":#{uri.port}" : ""
|
|
30
|
+
"#{uri.scheme}://#{uri.host}#{port}/[REDACTED]"
|
|
31
|
+
rescue URI::InvalidURIError
|
|
32
|
+
"invalid-webhook-url"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(url:, payload:, headers: {}, timeout: nil, resolver: nil, http_factory: nil)
|
|
36
|
+
@url = url.to_s
|
|
37
|
+
@payload = payload
|
|
38
|
+
@headers = headers || {}
|
|
39
|
+
@timeout = Integer(timeout || RailsNexus.configuration.webhook_timeout || 5)
|
|
40
|
+
@resolver = resolver || method(:resolve_addresses)
|
|
41
|
+
@http_factory = http_factory || ->(host, port) { Net::HTTP.new(host, port) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def deliver
|
|
45
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
46
|
+
uri, address = validated_destination
|
|
47
|
+
http = build_http(uri, address)
|
|
48
|
+
response = http.request(build_request(uri))
|
|
49
|
+
status = response.code.to_i
|
|
50
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
51
|
+
|
|
52
|
+
if status.between?(300, 399)
|
|
53
|
+
return failure("Webhook redirects are not allowed", status_code: response.code, duration: duration)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
success: status.between?(200, 299),
|
|
58
|
+
status_code: response.code,
|
|
59
|
+
body: response.body.to_s.truncate(500),
|
|
60
|
+
duration: duration.round(3),
|
|
61
|
+
error: status.between?(200, 299) ? nil : "Webhook returned HTTP #{status}"
|
|
62
|
+
}
|
|
63
|
+
rescue ValidationError => error
|
|
64
|
+
failure(error.message)
|
|
65
|
+
rescue StandardError
|
|
66
|
+
failure("Webhook delivery failed")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def validated_destination
|
|
72
|
+
uri = URI.parse(@url)
|
|
73
|
+
allowed_schemes = ["https"]
|
|
74
|
+
if Rails.env.development? && RailsNexus.configuration.webhook_allow_http_in_development
|
|
75
|
+
allowed_schemes << "http"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raise ValidationError, "Webhook URL must use HTTPS" unless allowed_schemes.include?(uri.scheme)
|
|
79
|
+
raise ValidationError, "Webhook URL must include a hostname" if uri.host.blank?
|
|
80
|
+
raise ValidationError, "Webhook URL credentials are not allowed" if uri.userinfo.present?
|
|
81
|
+
validate_host_policy!(uri.host)
|
|
82
|
+
|
|
83
|
+
addresses = Array(@resolver.call(uri.host)).map { |address| IPAddr.new(address.to_s) }.uniq
|
|
84
|
+
raise ValidationError, "Webhook host could not be resolved" if addresses.empty?
|
|
85
|
+
raise ValidationError, "Webhook destination is not publicly routable" if addresses.any? { |address| blocked_address?(address) }
|
|
86
|
+
|
|
87
|
+
[uri, addresses.first.to_s]
|
|
88
|
+
rescue URI::InvalidURIError, IPAddr::InvalidAddressError
|
|
89
|
+
raise ValidationError, "Webhook URL is invalid"
|
|
90
|
+
rescue SocketError
|
|
91
|
+
raise ValidationError, "Webhook host could not be resolved"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def resolve_addresses(host)
|
|
95
|
+
Addrinfo.getaddrinfo(host, nil, :UNSPEC, :STREAM).map(&:ip_address)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def blocked_address?(address)
|
|
99
|
+
BLOCKED_NETWORKS.any? { |network| network.include?(address) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_host_policy!(host)
|
|
103
|
+
denied = Array(RailsNexus.configuration.webhook_denied_hosts)
|
|
104
|
+
allowed = Array(RailsNexus.configuration.webhook_allowed_hosts)
|
|
105
|
+
|
|
106
|
+
raise ValidationError, "Webhook hostname is denied" if denied.any? { |pattern| host_matches?(host, pattern) }
|
|
107
|
+
if allowed.any? && allowed.none? { |pattern| host_matches?(host, pattern) }
|
|
108
|
+
raise ValidationError, "Webhook hostname is not allowed"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def host_matches?(host, pattern)
|
|
113
|
+
candidate = pattern.to_s.downcase
|
|
114
|
+
actual = host.to_s.downcase
|
|
115
|
+
return actual.end_with?(candidate.delete_prefix("*")) if candidate.start_with?("*.")
|
|
116
|
+
actual == candidate
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_http(uri, address)
|
|
120
|
+
http = @http_factory.call(uri.host, uri.port)
|
|
121
|
+
http.ipaddr = address
|
|
122
|
+
http.use_ssl = uri.scheme == "https"
|
|
123
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
124
|
+
http.open_timeout = @timeout
|
|
125
|
+
http.read_timeout = @timeout
|
|
126
|
+
http
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_request(uri)
|
|
130
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
131
|
+
request["Host"] = uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
|
|
132
|
+
request["Content-Type"] = "application/json"
|
|
133
|
+
@headers.each do |name, value|
|
|
134
|
+
validate_header!(name, value)
|
|
135
|
+
request[name.to_s] = value.to_s
|
|
136
|
+
end
|
|
137
|
+
request.body = @payload.to_json
|
|
138
|
+
request
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def validate_header!(name, value)
|
|
142
|
+
if name.to_s.match?(/[\r\n]/) || value.to_s.match?(/[\r\n]/)
|
|
143
|
+
raise ValidationError, "Webhook header is invalid"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def failure(message, status_code: nil, duration: nil)
|
|
148
|
+
{ success: false, error: message, status_code: status_code, duration: duration&.round(3) }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|