pgbus 0.2.2 → 0.2.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11fc5e1aa1196d1332b426e72aab0364cc548925fe960ff6626a6cd8090b2de5
4
- data.tar.gz: 52fd690b0a3afda834850f5fc0eb3a111fd18062af6b3ca7097fadc085abf1b9
3
+ metadata.gz: daac3606ba66624f54b5b6cf8db0830827c2ce0492b7d6d38ce347d1e0f3aeb2
4
+ data.tar.gz: ad7eb6ea7beae1e07a813cac5cac37980671f8106f2ac7444a127a81d6f7a2e3
5
5
  SHA512:
6
- metadata.gz: f232df26f7a0122db40119b43bf0d03290ea583eaf889ac58c95bb81850c5f745603fa81b848be437f4298ffff5ed65c82c64b890da64bc663ef600a4d05fe0c
7
- data.tar.gz: c2d5f381fa2e4cf4943fe9bd27815459faaf9f92c7bf40d97bb2ea93b5622dcb5a197d8ccdb63249fd2ee681d4c10961070e7016093bacda2ba81010939cd7aa
6
+ metadata.gz: f7a513fd3e2f7aac5a3c0db040a3e7b6b11b6c4c0f619f6f0d37ebbf3e2cd6336737af20e4a7a00da19a1937d5be2b8e24d666483bf661005422c04bbac1805c
7
+ data.tar.gz: 255c0382fbc4f4b797577757ebe5bfa9611f350456292d78e41678d6ebe834c52a5588e2f902159b0c5467becb076f0b724b8dc782b21dfb0d423404dc31d178
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Breaking Changes
4
+
5
+ - **Queue names must be alphanumeric and underscores only.** Queue names containing dashes (e.g., `my-app-queue`) will now raise `ArgumentError`. Rename to underscored form (e.g., `my_app_queue`) before upgrading. This restriction prevents SQL injection via PGMQ queue identifiers, which are interpolated into table names and cannot be parameterized.
6
+
7
+ ### Security
8
+
9
+ - Add `bundler-audit` to CI for dependency vulnerability scanning
10
+ - Add `QueueNameValidator` to enforce strict queue name validation (alphanumeric + underscores, 61 char max)
11
+ - Add `config.allowed_global_id_models` to restrict which models can be deserialized from event payloads
12
+ - Add security headers to dashboard (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
13
+ - Warn when dashboard `web_auth` is unconfigured
14
+ - Add `globalid` as an explicit runtime dependency (was used but only transitively available via activejob)
15
+
3
16
  ## [0.1.0] - 2026-03-30
4
17
 
5
18
  - Initial release
data/Rakefile CHANGED
@@ -50,15 +50,24 @@ task :build do
50
50
  sh("rm -rf /tmp/gem-verify #{gem_file}")
51
51
  end
52
52
 
53
- desc "Release a new version (rake release[1.2.3] or rake release[pre])"
54
- task :release, [:version] do |_t, args|
53
+ desc "Release a new version (rake release[1.2.3] or rake release[pre] or rake release[1.2.3,force])"
54
+ task :release, %i[version force] do |_t, args|
55
55
  require_relative "lib/pgbus/version"
56
56
 
57
+ def info(msg) = puts "\e[34m→\e[0m #{msg}"
58
+ def success(msg) = puts "\e[32m✓\e[0m #{msg}"
59
+ def skip(msg) = puts "\e[33m⊘\e[0m #{msg} \e[33m(skipped)\e[0m"
60
+ def warn(msg) = puts "\e[33m⚠\e[0m #{msg}"
61
+ def error(msg) = puts "\e[31m✗\e[0m #{msg}"
62
+ def header(msg) = puts "\n\e[1;36m#{msg}\e[0m\n#{"─" * msg.length}"
63
+
57
64
  new_version = args[:version]
58
- abort "Usage: rake release[X.Y.Z] or rake release[pre]" unless new_version
65
+ abort "\e[31mUsage: rake release[X.Y.Z] or rake release[X.Y.Z,force]\e[0m" unless new_version
66
+
67
+ force = args[:force]&.to_s&.downcase == "force"
59
68
 
60
69
  dirty = `git status --porcelain`.strip
61
- abort "Aborting: working directory is not clean.\n#{dirty}" unless dirty.empty?
70
+ abort "\e[31mAborting: working directory is not clean.\e[0m\n#{dirty}" unless dirty.empty?
62
71
 
63
72
  current = Pgbus::VERSION
64
73
  prerelease = new_version.match?(/alpha|beta|rc|pre/) || new_version == "pre"
@@ -69,43 +78,97 @@ task :release, [:version] do |_t, args|
69
78
  end
70
79
 
71
80
  tag = "v#{new_version}"
81
+ version_file = "lib/pgbus/version.rb"
72
82
 
73
- puts "Current version: #{current}"
74
- puts "New version: #{new_version}"
75
- puts "Tag: #{tag}"
76
- puts "Pre-release: #{prerelease}"
77
- puts ""
83
+ title = "Release #{tag}"
84
+ title += " (force)" if force
85
+ header title
86
+ info "Current version: #{current}"
87
+ info "New version: #{new_version}"
88
+ info "Pre-release: #{prerelease}"
89
+
90
+ # Step 0: Force cleanup — delete existing release and tag
91
+ if force
92
+ header "Force cleanup"
93
+ if system("gh release view #{tag} >/dev/null 2>&1")
94
+ sh("gh release delete #{tag} --yes --cleanup-tag")
95
+ success "Deleted release and remote tag #{tag}"
96
+ else
97
+ skip "No release #{tag} to delete"
98
+ end
99
+
100
+ if system("git rev-parse #{tag} >/dev/null 2>&1")
101
+ sh("git tag -d #{tag}")
102
+ success "Deleted local tag #{tag}"
103
+ else
104
+ skip "No local tag #{tag} to delete"
105
+ end
106
+ end
78
107
 
79
- # Update version file if needed
80
- version_file = "lib/pgbus/version.rb"
81
- if new_version != current
108
+ # Step 1: Update version file
109
+ header "Version"
110
+ if new_version == current
111
+ skip "Version already #{new_version}"
112
+ else
82
113
  content = File.read(version_file)
83
114
  content.sub!(/VERSION = ".*"/, "VERSION = \"#{new_version}\"")
84
115
  File.write(version_file, content)
85
- puts "Updated #{version_file}"
116
+ success "Updated #{version_file}"
86
117
  end
87
118
 
88
- # Verify gem builds cleanly
119
+ # Step 2: Verify gem builds cleanly
120
+ header "Build verification"
89
121
  sh("gem build pgbus.gemspec --strict")
90
122
  sh("rm -f pgbus-*.gem")
123
+ success "Gem builds cleanly"
91
124
 
92
- # Commit, push, and create release
93
- if new_version != current
125
+ # Step 3: Commit version bump
126
+ header "Git commit"
127
+ version_changed = !`git diff #{version_file}`.strip.empty? || !`git diff --cached #{version_file}`.strip.empty?
128
+ if version_changed
94
129
  sh("git add #{version_file}")
95
130
  sh("git commit -m 'chore: bump version to #{new_version}'")
131
+ success "Committed version bump"
132
+ else
133
+ skip "No version change to commit"
134
+ end
135
+
136
+ # Step 4: Push to origin
137
+ header "Git push"
138
+ local_sha = `git rev-parse HEAD`.strip
139
+ remote_sha = `git rev-parse origin/main 2>/dev/null`.strip
140
+ if local_sha == remote_sha
141
+ skip "origin/main already at #{local_sha[0..6]}"
142
+ else
143
+ sh("git push origin main")
144
+ success "Pushed to origin/main"
96
145
  end
97
- sh("git push origin main")
98
146
 
99
- pre_flag = prerelease ? "--prerelease" : ""
100
- sh("gh release create #{tag} --generate-notes --target main #{pre_flag}".strip)
147
+ # Step 5: Create release
148
+ header "Release"
149
+ tag_exists = system("git rev-parse #{tag} >/dev/null 2>&1")
150
+ release_exists = system("gh release view #{tag} >/dev/null 2>&1")
151
+
152
+ if release_exists
153
+ skip "Release #{tag} already exists (use force to re-create)"
154
+ elsif tag_exists
155
+ info "Tag #{tag} exists, creating release from it"
156
+ pre_flag = prerelease ? "--prerelease" : ""
157
+ sh("gh release create #{tag} --generate-notes #{pre_flag}".strip)
158
+ success "Release #{tag} created from existing tag"
159
+ else
160
+ pre_flag = prerelease ? "--prerelease" : ""
161
+ sh("gh release create #{tag} --generate-notes --target main #{pre_flag}".strip)
162
+ success "Release #{tag} created"
163
+ end
101
164
 
102
165
  puts ""
103
- puts "Release #{tag} created! CI will handle the rest:"
104
- puts " - Run tests"
105
- puts " - Build + verify gem"
106
- puts " - Sign with Sigstore"
107
- puts " - Publish to RubyGems"
108
- puts " - Upload assets to the release"
166
+ success "\e[1mRelease #{tag} complete!\e[0m CI will handle the rest:"
167
+ puts " Run tests"
168
+ puts " Build + verify gem"
169
+ puts " Sign with Sigstore"
170
+ puts " Publish to RubyGems"
171
+ puts " Upload assets to the release"
109
172
  end
110
173
 
111
174
  task default: %i[spec rubocop]
@@ -6,6 +6,7 @@ module Pgbus
6
6
 
7
7
  protect_from_forgery with: :exception
8
8
  before_action :set_locale
9
+ after_action :set_security_headers
9
10
 
10
11
  layout "pgbus/application"
11
12
 
@@ -21,6 +22,14 @@ module Pgbus
21
22
 
22
23
  private
23
24
 
25
+ def set_security_headers
26
+ response.headers["X-Frame-Options"] = "SAMEORIGIN"
27
+ response.headers["X-Content-Type-Options"] = "nosniff"
28
+ response.headers["X-XSS-Protection"] = "0"
29
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
30
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
31
+ end
32
+
24
33
  def set_locale
25
34
  I18n.locale = extract_locale || I18n.default_locale
26
35
  end
@@ -51,6 +60,7 @@ module Pgbus
51
60
  @available_locales ||= Dir[Pgbus::Engine.root.join("config", "locales", "*.yml")]
52
61
  .map { |f| File.basename(f, ".yml").to_sym }
53
62
  end
63
+ helper_method :available_locales
54
64
 
55
65
  def data_source
56
66
  @data_source ||= Pgbus.configuration.web_data_source || Web::DataSource.new
@@ -67,9 +77,8 @@ module Pgbus
67
77
  def insights_minutes
68
78
  config = Pgbus.configuration
69
79
  default = config.insights_default_minutes.to_i
70
- max = config.stats_retention.to_i / 60
71
80
  value = (params[:minutes] || default).to_i
72
- value.clamp(1, [max, 1].max)
81
+ value.clamp(1, [default, 43_200].max)
73
82
  end
74
83
 
75
84
  def turbo_frame_request?
@@ -4,6 +4,12 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title><%= t("pgbus.layout.title") %></title>
7
+ <style>
8
+ /* Prevent white flash during navigation in dark mode.
9
+ Applied before Tailwind CDN loads so the background is correct immediately. */
10
+ html.dark { background-color: #030712; } /* gray-950 */
11
+ html.dark body { background-color: #030712; }
12
+ </style>
7
13
  <script src="https://cdn.tailwindcss.com"></script>
8
14
  <script>
9
15
  tailwind.config = { darkMode: 'class' };
@@ -17,24 +23,17 @@
17
23
  var isDark = document.documentElement.classList.toggle('dark');
18
24
  localStorage.setItem('pgbus-dark', isDark);
19
25
  }
20
- // Locale dropdown toggle
26
+ // Close locale dropdown when clicking outside
21
27
  document.addEventListener('click', function(e) {
22
- var btn = e.target.closest('[data-action="click->dropdown#toggle"]');
23
- var menu = btn && btn.parentElement.querySelector('[data-dropdown-target="menu"]');
24
- // Close all menus first
25
- document.querySelectorAll('[data-dropdown-target="menu"]').forEach(function(m) {
26
- if (m !== menu) m.classList.add('hidden');
27
- });
28
- if (menu) { menu.classList.toggle('hidden'); e.stopPropagation(); }
29
- });
30
- document.addEventListener('click', function() {
31
- document.querySelectorAll('[data-dropdown-target="menu"]').forEach(function(m) {
32
- m.classList.add('hidden');
33
- });
28
+ var switcher = document.getElementById('pgbus-locale-switcher');
29
+ var menu = document.getElementById('pgbus-locale-menu');
30
+ if (menu && switcher && !switcher.contains(e.target)) {
31
+ menu.classList.add('hidden');
32
+ }
34
33
  });
35
34
  </script>
36
35
  <script type="module">
37
- import * as Turbo from "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8/dist/turbo.es2017.esm.js";
36
+ import * as Turbo from "https://esm.sh/@hotwired/turbo@8";
38
37
 
39
38
  <% if Pgbus.configuration.web_live_updates %>
40
39
  const interval = <%= Pgbus.configuration.web_refresh_interval %>;
@@ -43,7 +42,10 @@
43
42
  function refreshFrames() {
44
43
  if (document.hidden) return;
45
44
  document.querySelectorAll("turbo-frame[data-auto-refresh]")
46
- .forEach(frame => frame.reload());
45
+ .forEach(frame => {
46
+ if (!frame.src && frame.dataset.src) frame.src = frame.dataset.src;
47
+ if (frame.src) frame.reload();
48
+ });
47
49
  }
48
50
  function start() { timer = setInterval(refreshFrames, interval); }
49
51
  function stop() { clearInterval(timer); }
@@ -81,19 +83,16 @@
81
83
 
82
84
  <div class="flex items-center space-x-2">
83
85
  <!-- Locale switcher -->
84
- <div class="relative" data-controller="dropdown">
85
- <button type="button" data-action="click->dropdown#toggle" class="rounded-md px-2 py-1.5 text-sm text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500">
86
+ <div class="relative" id="pgbus-locale-switcher">
87
+ <button type="button" onclick="document.getElementById('pgbus-locale-menu').classList.toggle('hidden')" class="rounded-md px-2 py-1 text-sm text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500">
86
88
  <%= pgbus_locale_flag(I18n.locale) %> <%= I18n.locale.to_s.upcase %>
87
89
  </button>
88
- <div data-dropdown-target="menu" class="hidden absolute right-0 z-50 mt-1 w-44 origin-top-right rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10">
90
+ <div id="pgbus-locale-menu" class="hidden absolute right-0 z-50 mt-1 w-44 origin-top-right rounded-md bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10">
89
91
  <div class="py-1" role="menu">
90
- <% controller.send(:available_locales).sort.each do |loc| %>
91
- <%= form_tag pgbus.set_locale_path, method: :post, class: "contents" do %>
92
- <%= hidden_field_tag :locale, loc %>
93
- <button type="submit" role="menuitem" class="w-full text-left px-3 py-1.5 text-sm <%= loc == I18n.locale ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 font-medium' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %>">
94
- <%= pgbus_locale_flag(loc) %> <%= pgbus_locale_name(loc) %>
95
- </button>
96
- <% end %>
92
+ <% available_locales.sort.each do |loc| %>
93
+ <a href="<%= pgbus.set_locale_path(locale: loc) %>" role="menuitem" class="block px-3 py-1 text-sm no-underline <%= loc == I18n.locale ? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 font-medium' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %>">
94
+ <%= pgbus_locale_flag(loc) %> <%= pgbus_locale_name(loc) %>
95
+ </a>
97
96
  <% end %>
98
97
  </div>
99
98
  </div>
@@ -1,4 +1,4 @@
1
- <turbo-frame id="dashboard-processes" data-auto-refresh src="<%= pgbus.root_path(frame: 'processes') %>">
1
+ <turbo-frame id="dashboard-processes" data-auto-refresh data-src="<%= pgbus.root_path(frame: 'processes') %>">
2
2
  <div>
3
3
  <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3"><%= t("pgbus.dashboard.processes_table.title") %></h2>
4
4
  <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
@@ -1,4 +1,4 @@
1
- <turbo-frame id="dashboard-queues" data-auto-refresh src="<%= pgbus.root_path(frame: 'queues') %>">
1
+ <turbo-frame id="dashboard-queues" data-auto-refresh data-src="<%= pgbus.root_path(frame: 'queues') %>">
2
2
  <div class="mb-8">
3
3
  <div class="flex items-center justify-between mb-3">
4
4
  <h2 class="text-lg font-semibold text-gray-900 dark:text-white"><%= t("pgbus.dashboard.queues_table.title") %></h2>
@@ -1,4 +1,4 @@
1
- <turbo-frame id="dashboard-failures" data-auto-refresh src="<%= pgbus.root_path(frame: 'failures') %>">
1
+ <turbo-frame id="dashboard-failures" data-auto-refresh data-src="<%= pgbus.root_path(frame: 'failures') %>">
2
2
  <div>
3
3
  <div class="flex items-center justify-between mb-3">
4
4
  <h2 class="text-lg font-semibold text-gray-900 dark:text-white"><%= t("pgbus.dashboard.recent_failures.title") %></h2>
@@ -1,4 +1,4 @@
1
- <turbo-frame id="dashboard-stats" data-auto-refresh src="<%= pgbus.root_path(frame: 'stats') %>">
1
+ <turbo-frame id="dashboard-stats" data-auto-refresh data-src="<%= pgbus.root_path(frame: 'stats') %>">
2
2
  <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6 mb-8">
3
3
  <div class="rounded-lg bg-white dark:bg-gray-800 p-5 shadow ring-1 ring-gray-200 dark:ring-gray-700">
4
4
  <p class="text-sm font-medium text-gray-500 dark:text-gray-400"><%= t("pgbus.dashboard.stats_cards.queues") %></p>
@@ -1,4 +1,4 @@
1
- <turbo-frame id="dlq-messages" data-auto-refresh src="<%= pgbus.dead_letter_index_path(frame: 'list') %>">
1
+ <turbo-frame id="dlq-messages" data-auto-refresh data-src="<%= pgbus.dead_letter_index_path(frame: 'list') %>">
2
2
  <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
3
3
  <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
4
4
  <thead class="bg-gray-50 dark:bg-gray-900">
@@ -1,4 +1,4 @@
1
- <turbo-frame id="jobs-enqueued" data-auto-refresh src="<%= pgbus.jobs_path(request.query_parameters.merge(frame: 'enqueued')) %>">
1
+ <turbo-frame id="jobs-enqueued" data-auto-refresh data-src="<%= pgbus.jobs_path(request.query_parameters.merge(frame: 'enqueued')) %>">
2
2
  <div>
3
3
  <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3"><%= t("pgbus.jobs.enqueued_table.title") %></h2>
4
4
  <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
@@ -1,4 +1,4 @@
1
- <turbo-frame id="jobs-failed" data-auto-refresh src="<%= pgbus.jobs_path(frame: 'failed') %>">
1
+ <turbo-frame id="jobs-failed" data-auto-refresh data-src="<%= pgbus.jobs_path(frame: 'failed') %>">
2
2
  <div class="mb-8">
3
3
  <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3"><%= t("pgbus.jobs.failed_table.title") %></h2>
4
4
  <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
@@ -1,4 +1,4 @@
1
- <turbo-frame id="processes-list" data-auto-refresh src="<%= pgbus.processes_path(frame: 'list') %>">
1
+ <turbo-frame id="processes-list" data-auto-refresh data-src="<%= pgbus.processes_path(frame: 'list') %>">
2
2
  <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
3
3
  <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
4
4
  <thead class="bg-gray-50 dark:bg-gray-900">
@@ -1,4 +1,4 @@
1
- <turbo-frame id="queues-list" data-auto-refresh src="<%= pgbus.queues_path(frame: 'list') %>">
1
+ <turbo-frame id="queues-list" data-auto-refresh data-src="<%= pgbus.queues_path(frame: 'list') %>">
2
2
  <div class="overflow-hidden rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
3
3
  <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
4
4
  <thead class="bg-gray-50 dark:bg-gray-900">
@@ -1,4 +1,4 @@
1
- <turbo-frame id="recurring-tasks" data-auto-refresh src="<%= pgbus.recurring_tasks_path(frame: 'recurring_tasks') %>">
1
+ <turbo-frame id="recurring-tasks" data-auto-refresh data-src="<%= pgbus.recurring_tasks_path(frame: 'recurring_tasks') %>">
2
2
  <div class="rounded-lg bg-white dark:bg-gray-800 shadow ring-1 ring-gray-200 dark:ring-gray-700">
3
3
  <% if @recurring_tasks.empty? %>
4
4
  <div class="p-8 text-center text-gray-500">
data/config/routes.rb CHANGED
@@ -52,7 +52,7 @@ Pgbus::Engine.routes.draw do
52
52
  resources :locks, only: [:index]
53
53
  resource :insights, only: [:show], controller: "insights"
54
54
 
55
- post :set_locale, to: "locale#update"
55
+ get :set_locale, to: "locale#update"
56
56
 
57
57
  namespace :api do
58
58
  get :stats, to: "stats#show"
@@ -19,6 +19,13 @@ module ActiveJob
19
19
  true
20
20
  end
21
21
 
22
+ # Called by ActiveJob::Continuation (Rails 8.1+) at each checkpoint.
23
+ # When true, continuable jobs save their cursor and re-enqueue
24
+ # themselves so the worker can shut down gracefully.
25
+ def stopping?
26
+ Pgbus.stopping
27
+ end
28
+
22
29
  private
23
30
 
24
31
  def adapter
@@ -80,7 +80,7 @@ module Pgbus
80
80
  private
81
81
 
82
82
  def execute_job(job)
83
- if defined?(Rails) && Rails.application
83
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
84
84
  Rails.application.executor.wrap { job.perform_now }
85
85
  else
86
86
  job.perform_now
data/lib/pgbus/client.rb CHANGED
@@ -216,7 +216,7 @@ module Pgbus
216
216
 
217
217
  def purge_archive(queue_name, older_than:, batch_size: 1000)
218
218
  full_name = config.queue_name(queue_name)
219
- sanitized = full_name.gsub(/[^a-zA-Z0-9_]/, "")
219
+ sanitized = QueueNameValidator.sanitize!(full_name)
220
220
  total = 0
221
221
 
222
222
  sql = "DELETE FROM pgmq.a_#{sanitized} " \
@@ -8,7 +8,7 @@ module Pgbus
8
8
  module_function
9
9
 
10
10
  def load(path, env: nil)
11
- env ||= defined?(Rails) ? Rails.env : ENV.fetch("PGBUS_ENV", "development")
11
+ env ||= (defined?(Rails) && Rails.respond_to?(:env) && Rails.env) || ENV.fetch("PGBUS_ENV", "development")
12
12
  raw = File.read(path)
13
13
  parsed = YAML.safe_load(ERB.new(raw).result, permitted_classes: [Symbol], aliases: true)
14
14
  config_hash = parsed.fetch(env, parsed)
@@ -36,7 +36,7 @@ module Pgbus
36
36
  attr_accessor :outbox_enabled, :outbox_poll_interval, :outbox_batch_size, :outbox_retention
37
37
 
38
38
  # Event bus
39
- attr_accessor :idempotency_ttl
39
+ attr_accessor :idempotency_ttl, :allowed_global_id_models
40
40
 
41
41
  # Logging
42
42
  attr_accessor :logger
@@ -108,8 +108,9 @@ module Pgbus
108
108
  @outbox_retention = 24 * 3600 # 1 day
109
109
 
110
110
  @idempotency_ttl = 7 * 24 * 3600 # 7 days
111
+ @allowed_global_id_models = nil # nil = allow all (for backwards compat)
111
112
 
112
- @logger = defined?(Rails) ? Rails.logger : Logger.new($stdout)
113
+ @logger = (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) || Logger.new($stdout)
113
114
 
114
115
  @listen_notify = true
115
116
  @notify_throttle_ms = 250
@@ -138,7 +139,9 @@ module Pgbus
138
139
  end
139
140
 
140
141
  def queue_name(name)
141
- "#{queue_prefix}_#{name}"
142
+ full = "#{queue_prefix}_#{name}"
143
+ QueueNameValidator.validate!(full)
144
+ full
142
145
  end
143
146
 
144
147
  def dead_letter_queue_name(name)
@@ -36,7 +36,7 @@ module Pgbus
36
36
 
37
37
  def build_event(raw)
38
38
  payload = raw["payload"]
39
- payload = GlobalID::Locator.locate(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
39
+ payload = Serializer.locate_global_id(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
40
40
 
41
41
  Event.new(
42
42
  event_id: raw["event_id"],
@@ -143,7 +143,7 @@ module Pgbus
143
143
  return true if config.recurring_tasks_file && File.exist?(config.recurring_tasks_file.to_s)
144
144
 
145
145
  # Check default location
146
- if defined?(Rails)
146
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
147
147
  default_path = Rails.root.join("config", "recurring.yml")
148
148
  return File.exist?(default_path.to_s)
149
149
  end
@@ -155,7 +155,7 @@ module Pgbus
155
155
  return if config.recurring_tasks&.any?
156
156
 
157
157
  path = config.recurring_tasks_file
158
- path ||= defined?(Rails) ? Rails.root.join("config", "recurring.yml") : nil
158
+ path ||= defined?(Rails) && Rails.respond_to?(:root) && Rails.root ? Rails.root.join("config", "recurring.yml") : nil
159
159
  return unless path && File.exist?(path.to_s)
160
160
 
161
161
  config.recurring_tasks = Recurring::ConfigLoader.load(path)
@@ -289,7 +289,7 @@ module Pgbus
289
289
  end
290
290
 
291
291
  def load_rails_app
292
- return unless defined?(Rails)
292
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
293
293
 
294
294
  Rails.application.eager_load! if Rails.application.respond_to?(:eager_load!)
295
295
  end
@@ -66,12 +66,14 @@ module Pgbus
66
66
 
67
67
  def graceful_shutdown
68
68
  Pgbus.logger.info { "[Pgbus] Worker shutting down gracefully..." }
69
+ Pgbus.stopping = true
69
70
  @lifecycle.transition_to(:draining)
70
71
  @wake_signal.notify!
71
72
  end
72
73
 
73
74
  def immediate_shutdown
74
75
  Pgbus.logger.warn { "[Pgbus] Worker shutting down immediately!" }
76
+ Pgbus.stopping = true
75
77
  @lifecycle.transition_to!(:stopped)
76
78
  @wake_signal.notify!
77
79
  @pool.kill
@@ -210,6 +212,7 @@ module Pgbus
210
212
  def check_recycle
211
213
  return unless @lifecycle.running? && recycle_needed?
212
214
 
215
+ Pgbus.stopping = true
213
216
  @lifecycle.transition_to(:draining)
214
217
  @wake_signal.notify!
215
218
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pgbus
4
+ # Validates and sanitizes PGMQ queue names for safe use in SQL identifiers.
5
+ #
6
+ # PGMQ queue names are interpolated into SQL as table/sequence names
7
+ # (e.g., pgmq.q_<name>, pgmq.a_<name>). This module enforces strict
8
+ # validation to prevent SQL injection via crafted queue names.
9
+ module QueueNameValidator
10
+ # PostgreSQL identifier limit is 63 bytes (NAMEDATALEN - 1).
11
+ # PGMQ prefixes with "q_" or "a_" (2 chars), so limit the name itself.
12
+ MAX_QUEUE_NAME_LENGTH = 61
13
+
14
+ # Only alphanumeric characters and underscores are allowed.
15
+ VALID_QUEUE_NAME_PATTERN = /\A[a-zA-Z0-9_]+\z/
16
+
17
+ module_function
18
+
19
+ # Validates a queue name for safe SQL identifier use.
20
+ # Returns the name if valid, raises ArgumentError if not.
21
+ def validate!(name)
22
+ name = name.to_s
23
+ raise ArgumentError, "Queue name cannot be blank" if name.empty?
24
+ if name.length > MAX_QUEUE_NAME_LENGTH
25
+ raise ArgumentError,
26
+ "Queue name too long (#{name.length} chars, max #{MAX_QUEUE_NAME_LENGTH}): #{name.inspect}"
27
+ end
28
+
29
+ unless VALID_QUEUE_NAME_PATTERN.match?(name)
30
+ raise ArgumentError,
31
+ "Invalid queue name: #{name.inspect}. Only alphanumeric characters and underscores are allowed."
32
+ end
33
+
34
+ name
35
+ end
36
+
37
+ # Sanitizes a queue name by removing invalid characters, then validates.
38
+ # Use this for names from untrusted sources (e.g., URL params).
39
+ def sanitize!(name)
40
+ sanitized = name.to_s.gsub(/[^a-zA-Z0-9_]/, "")
41
+ validate!(sanitized)
42
+ sanitized
43
+ end
44
+ end
45
+ end
@@ -24,7 +24,7 @@ module Pgbus
24
24
  end
25
25
 
26
26
  def detect_env
27
- if defined?(Rails)
27
+ if defined?(Rails) && Rails.respond_to?(:env) && Rails.env
28
28
  Rails.env.to_s
29
29
  else
30
30
  ENV.fetch("PGBUS_ENV", "development")
@@ -41,7 +41,7 @@ module Pgbus
41
41
  data = JSON.parse(json_string)
42
42
  payload = data["payload"]
43
43
 
44
- data["payload"] = GlobalID::Locator.locate(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
44
+ data["payload"] = locate_global_id(payload["_global_id"]) if payload.is_a?(Hash) && payload["_global_id"]
45
45
 
46
46
  Event.new(
47
47
  event_id: data["event_id"],
@@ -49,5 +49,32 @@ module Pgbus
49
49
  published_at: Time.parse(data["published_at"])
50
50
  )
51
51
  end
52
+
53
+ # Locate a GlobalID with optional type restriction.
54
+ # When allowed_global_id_models is configured, only those model classes
55
+ # can be resolved — prevents loading arbitrary objects from crafted payloads.
56
+ def locate_global_id(gid_string)
57
+ gid = GlobalID.parse(gid_string)
58
+ raise ArgumentError, "Invalid GlobalID: #{gid_string.inspect}" unless gid
59
+
60
+ allowed = Pgbus.configuration.allowed_global_id_models
61
+ if allowed&.empty?
62
+ raise ArgumentError,
63
+ "GlobalID deserialization is disabled (allowed_global_id_models is empty). " \
64
+ "Set to nil to allow all models, or add permitted classes."
65
+ end
66
+ if allowed&.any? { |entry| !entry.is_a?(Class) && !entry.is_a?(Module) }
67
+ raise ArgumentError,
68
+ "allowed_global_id_models must contain Class/Module objects, " \
69
+ "got: #{allowed.map(&:class).uniq.join(", ")}"
70
+ end
71
+ if allowed&.none? { |klass| gid.model_class <= klass }
72
+ raise ArgumentError,
73
+ "GlobalID model #{gid.model_class} is not in allowed_global_id_models. " \
74
+ "Add it to Pgbus.configuration.allowed_global_id_models to permit deserialization."
75
+ end
76
+
77
+ GlobalID::Locator.locate(gid)
78
+ end
52
79
  end
53
80
  end
data/lib/pgbus/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pgbus
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.4"
5
5
  end
@@ -9,16 +9,35 @@ module Pgbus
9
9
  before_action :authenticate_pgbus!
10
10
  end
11
11
 
12
+ class << self
13
+ attr_accessor :auth_warned
14
+ end
15
+
12
16
  private
13
17
 
14
18
  def authenticate_pgbus!
15
19
  auth_block = Pgbus.configuration.web_auth
16
- return if auth_block.nil?
20
+
21
+ if auth_block.nil?
22
+ warn_unauthenticated_dashboard
23
+ return
24
+ end
17
25
 
18
26
  return if auth_block.respond_to?(:call) && auth_block.call(request)
19
27
 
20
28
  head :unauthorized
21
29
  end
30
+
31
+ def warn_unauthenticated_dashboard
32
+ return if Pgbus::Web::Authentication.auth_warned
33
+
34
+ Pgbus.logger.warn do
35
+ "[Pgbus] Dashboard is accessible without authentication. " \
36
+ "Configure Pgbus.configuration.web_auth to restrict access. " \
37
+ "See: https://github.com/mhenrixon/pgbus#dashboard-authentication"
38
+ end
39
+ Pgbus::Web::Authentication.auth_warned = true
40
+ end
22
41
  end
23
42
  end
24
43
  end
@@ -662,10 +662,7 @@ module Pgbus
662
662
  end
663
663
 
664
664
  def sanitize_name(name)
665
- sanitized = name.gsub(/[^a-zA-Z0-9_]/, "")
666
- raise ArgumentError, "Invalid queue name: #{name.inspect}" if sanitized.empty?
667
-
668
- sanitized
665
+ QueueNameValidator.sanitize!(name)
669
666
  end
670
667
 
671
668
  def compute_throughput(queues)
data/lib/pgbus.rb CHANGED
@@ -13,6 +13,14 @@ module Pgbus
13
13
  class SchemaNotReady < Error; end
14
14
 
15
15
  class << self
16
+ # Process-global flag set by Worker#graceful_shutdown so the adapter
17
+ # can report stopping? to ActiveJob::Continuation (Rails 8.1+).
18
+ attr_writer :stopping
19
+
20
+ def stopping
21
+ @stopping || false
22
+ end
23
+
16
24
  def loader
17
25
  @loader ||= begin
18
26
  loader = Zeitwerk::Loader.for_gem
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgbus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -43,6 +43,20 @@ dependencies:
43
43
  - - ">="
44
44
  - !ruby/object:Gem::Version
45
45
  version: 1.11.1
46
+ - !ruby/object:Gem::Dependency
47
+ name: globalid
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '1.0'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '1.0'
46
60
  - !ruby/object:Gem::Dependency
47
61
  name: pgmq-ruby
48
62
  requirement: !ruby/object:Gem::Requirement
@@ -228,6 +242,7 @@ files:
228
242
  - lib/pgbus/process/wake_signal.rb
229
243
  - lib/pgbus/process/worker.rb
230
244
  - lib/pgbus/queue_factory.rb
245
+ - lib/pgbus/queue_name_validator.rb
231
246
  - lib/pgbus/rate_counter.rb
232
247
  - lib/pgbus/recurring/already_recorded.rb
233
248
  - lib/pgbus/recurring/command_job.rb