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.
Files changed (56) 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/assets/javascripts/rails_nexus/controllers.js +82 -5
  9. data/app/assets/stylesheets/rails_nexus/application.css +170 -60
  10. data/app/controllers/rails_nexus/analytics_controller.rb +13 -26
  11. data/app/controllers/rails_nexus/application_controller.rb +6 -2
  12. data/app/controllers/rails_nexus/backup_controller.rb +22 -19
  13. data/app/controllers/rails_nexus/cron_jobs_controller.rb +1 -9
  14. data/app/controllers/rails_nexus/database_health_controller.rb +0 -10
  15. data/app/controllers/rails_nexus/logged_exceptions_controller.rb +0 -2
  16. data/app/controllers/rails_nexus/n1_patterns_controller.rb +0 -2
  17. data/app/controllers/rails_nexus/settings_controller.rb +1 -39
  18. data/app/controllers/rails_nexus/source_code_controller.rb +9 -13
  19. data/app/controllers/rails_nexus/stats_controller.rb +43 -43
  20. data/app/helpers/rails_nexus/application_helper.rb +5 -0
  21. data/app/helpers/rails_nexus/logged_exceptions_helper.rb +4 -49
  22. data/app/helpers/rails_nexus/source_code_helper.rb +49 -10
  23. data/app/javascript/controllers/rails_nexus_controller.js +6 -2
  24. data/app/javascript/controllers/rails_nexus_sidebar_controller.js +71 -3
  25. data/app/javascript/rails_nexus/application.js +15 -3
  26. data/app/jobs/rails_nexus/backup_job.rb +18 -0
  27. data/app/models/rails_nexus/backup.rb +11 -11
  28. data/app/models/rails_nexus/backup_config.rb +38 -2
  29. data/app/models/rails_nexus/logged_exception.rb +27 -13
  30. data/app/services/rails_nexus/backup_service.rb +342 -120
  31. data/app/views/layouts/rails_nexus/_sidebar_navigation.html.erb +40 -10
  32. data/app/views/layouts/rails_nexus/application.html.erb +72 -14
  33. data/app/views/rails_nexus/backup/_form.html.erb +3 -0
  34. data/app/views/rails_nexus/backup/history.html.erb +17 -2
  35. data/app/views/rails_nexus/backup/index.html.erb +29 -72
  36. data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +12 -44
  37. data/app/views/rails_nexus/logged_exceptions/index.html.erb +21 -99
  38. data/app/views/rails_nexus/stats/index.html.erb +1 -1
  39. data/db/seeds/backup_configs.rb +5 -4
  40. data/lib/generators/rails_nexus/install_generator.rb +1 -1
  41. data/lib/generators/rails_nexus/templates/INSTALL.md +1 -0
  42. data/lib/generators/rails_nexus/templates/migration.rb +8 -2
  43. data/lib/generators/rails_nexus/templates/rails_nexus.rb +3 -0
  44. data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +1 -1
  45. data/lib/rails_nexus/configuration.rb +10 -0
  46. data/lib/rails_nexus/database_adapter.rb +53 -0
  47. data/lib/rails_nexus/engine.rb +0 -13
  48. data/lib/rails_nexus/logger.rb +1 -12
  49. data/lib/rails_nexus/notifications.rb +27 -20
  50. data/lib/rails_nexus/version.rb +1 -1
  51. data/lib/rails_nexus/webhook_client.rb +151 -0
  52. data/lib/rails_nexus.rb +37 -23
  53. data/lib/tasks/rails_nexus.rake +4 -4
  54. data/rails_nexus.gemspec +3 -2
  55. metadata +9 -16
  56. data/config/importmap.rb +0 -15
@@ -13,103 +13,23 @@
13
13
  <% end %>
14
14
  </nav>
15
15
 
16
- <%# ─── Stats Overview ──────────────────────────────────────── %>
17
- <% if @stats %>
18
- <div class="rn-stats">
19
- <div class="rn-stat">
20
- <span class="rn-stat-label">Total Exceptions</span>
21
- <span class="rn-stat-value"><%= number_with_delimiter(@stats[:total]) %></span>
22
- <span class="rn-stat-change">All time</span>
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-xs font-semibold uppercase tracking-wider" style="color: var(--rn-text-muted)">Filters</span>
108
- <% if filtered? %>
109
- <%= link_to "Clear all", logged_exceptions_path(per_page: params[:per_page]), class: "ml-auto text-xs font-medium", style: "color: var(--rn-primary)" %>
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
- </div>
112
- </div>
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
- </div>
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">
@@ -339,7 +339,7 @@
339
339
  </td>
340
340
  <td>
341
341
  <span class="text-sm" style="color: var(--rn-text-muted)">
342
- <%= table[:size_mb] > 0 ? "#{table[:size_mb]} MB" : "—" %>
342
+ <%= table[:size_mb].to_f > 0 ? "#{table[:size_mb]} MB" : "—" %>
343
343
  </span>
344
344
  </td>
345
345
  <td>
@@ -18,8 +18,8 @@ configs = [
18
18
  database_name: "tti_erp",
19
19
  host: "localhost",
20
20
  port: 3306,
21
- username: "root",
22
- # password: "36B042D8948C4086F37FA@root", # stored in mysql-config/cnf — set via env or UI
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: "root",
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: "root",
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 Exception, with: :log_exception_handler\n"
38
+ "\n rescue_from StandardError, with: :log_exception_handler\n"
39
39
  end
40
40
  end
41
41
  end
@@ -7,3 +7,4 @@ Next steps:
7
7
  3. Visit `/rails_nexus` in the host application.
8
8
 
9
9
  The dashboard denies access until authentication is configured.
10
+ The generator captures `StandardError` application failures and deliberately leaves fatal process and VM exceptions to Rails and Ruby.
@@ -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 :model_name, null: false
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, [:model_name, :started_at]
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
- <%= javascript_importmap_tags "rails_nexus/application" %>
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
@@ -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
@@ -170,18 +170,7 @@ module RailsNexus
170
170
  end
171
171
 
172
172
  def filter_params(params)
173
- return {} unless params.is_a?(Hash)
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 for #{url}: #{e.message}")
32
- log_webhook_delivery(url: url, payload: payload, error: e.message, event_type: "exception.logged")
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
- uri = URI(url)
38
-
39
- http = Net::HTTP.new(uri.host, uri.port)
40
- http.use_ssl = uri.scheme == "https"
41
- http.open_timeout = config.webhook_timeout
42
- http.read_timeout = config.webhook_timeout
43
-
44
- request = Net::HTTP::Post.new(uri)
45
- request["Content-Type"] = "application/json"
46
- config.webhook_headers.each { |k, v| request[k] = v }
47
- request.body = payload.to_json
48
-
49
- response = http.request(request)
50
-
51
- unless response.is_a?(Net::HTTPSuccess)
52
- Rails.logger.error("[RailsNexus] Webhook #{url} returned #{response.code}")
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
 
@@ -1,3 +1,3 @@
1
1
  module RailsNexus
2
- VERSION = "2.0.6"
2
+ VERSION = "2.1.2"
3
3
  end
@@ -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