actionwebpush 0.1.0
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 +7 -0
- data/.ruby-version +1 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/CONTRIBUTING.md +256 -0
- data/LICENSE.txt +21 -0
- data/README.md +569 -0
- data/Rakefile +17 -0
- data/app/controllers/actionwebpush/subscriptions_controller.rb +60 -0
- data/app/models/actionwebpush/subscription.rb +164 -0
- data/config/routes.rb +5 -0
- data/db/migrate/001_create_action_web_push_subscriptions.rb +17 -0
- data/lib/actionwebpush/analytics.rb +197 -0
- data/lib/actionwebpush/authorization.rb +236 -0
- data/lib/actionwebpush/base.rb +107 -0
- data/lib/actionwebpush/batch_delivery.rb +92 -0
- data/lib/actionwebpush/configuration.rb +91 -0
- data/lib/actionwebpush/delivery_job.rb +52 -0
- data/lib/actionwebpush/delivery_methods/base.rb +19 -0
- data/lib/actionwebpush/delivery_methods/test.rb +36 -0
- data/lib/actionwebpush/delivery_methods/web_push.rb +74 -0
- data/lib/actionwebpush/engine.rb +20 -0
- data/lib/actionwebpush/error_handler.rb +99 -0
- data/lib/actionwebpush/generators/campfire_migration_generator.rb +69 -0
- data/lib/actionwebpush/generators/install_generator.rb +47 -0
- data/lib/actionwebpush/generators/templates/campfire_compatibility.rb +173 -0
- data/lib/actionwebpush/generators/templates/campfire_data_migration.rb +98 -0
- data/lib/actionwebpush/generators/templates/create_action_web_push_subscriptions.rb +17 -0
- data/lib/actionwebpush/generators/templates/initializer.rb +16 -0
- data/lib/actionwebpush/generators/vapid_keys_generator.rb +38 -0
- data/lib/actionwebpush/instrumentation.rb +31 -0
- data/lib/actionwebpush/logging.rb +38 -0
- data/lib/actionwebpush/metrics.rb +67 -0
- data/lib/actionwebpush/notification.rb +97 -0
- data/lib/actionwebpush/pool.rb +167 -0
- data/lib/actionwebpush/railtie.rb +48 -0
- data/lib/actionwebpush/rate_limiter.rb +167 -0
- data/lib/actionwebpush/sentry_integration.rb +104 -0
- data/lib/actionwebpush/status_broadcaster.rb +62 -0
- data/lib/actionwebpush/status_channel.rb +21 -0
- data/lib/actionwebpush/tenant_configuration.rb +106 -0
- data/lib/actionwebpush/test_helper.rb +68 -0
- data/lib/actionwebpush/version.rb +5 -0
- data/lib/actionwebpush.rb +78 -0
- data/sig/actionwebpush.rbs +4 -0
- metadata +212 -0
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionWebPush
|
4
|
+
class Base
|
5
|
+
include ActionWebPush::Authorization
|
6
|
+
def self.default_params
|
7
|
+
@default_params ||= {}
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.default_params=(value)
|
11
|
+
@default_params = value.dup.freeze
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.inherited(subclass)
|
15
|
+
# Each subclass gets its own copy of default_params
|
16
|
+
subclass.instance_variable_set(:@default_params, default_params.dup)
|
17
|
+
super
|
18
|
+
end
|
19
|
+
|
20
|
+
attr_reader :params
|
21
|
+
|
22
|
+
def initialize(**params)
|
23
|
+
@params = self.class.default_params.merge(params)
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.push(subscriptions, **notification_params)
|
27
|
+
new(**notification_params).push(subscriptions)
|
28
|
+
end
|
29
|
+
|
30
|
+
def push(subscriptions, current_user: nil, **notification_params)
|
31
|
+
current_user ||= ActionWebPush::Authorization::Utils.current_user_context
|
32
|
+
|
33
|
+
# Authorization check
|
34
|
+
if current_user && !ActionWebPush::Authorization::Utils.authorization_bypassed?
|
35
|
+
authorize_notification_sending!(
|
36
|
+
current_user: current_user,
|
37
|
+
subscriptions: subscriptions
|
38
|
+
)
|
39
|
+
end
|
40
|
+
|
41
|
+
if notification_params.empty?
|
42
|
+
# Called with just subscriptions, use stored params
|
43
|
+
notifications = build_notifications(subscriptions, **@params)
|
44
|
+
else
|
45
|
+
# Called with both subscriptions and notification params
|
46
|
+
notifications = build_notifications(subscriptions, **notification_params)
|
47
|
+
end
|
48
|
+
|
49
|
+
deliver_notifications(notifications)
|
50
|
+
notifications.first # Return first notification for compatibility
|
51
|
+
end
|
52
|
+
|
53
|
+
def deliver_now(subscriptions)
|
54
|
+
push(subscriptions)
|
55
|
+
end
|
56
|
+
|
57
|
+
def deliver_later(subscriptions, wait: nil, wait_until: nil, queue: nil, priority: nil)
|
58
|
+
subscriptions = Array(subscriptions)
|
59
|
+
|
60
|
+
subscriptions.each do |subscription|
|
61
|
+
job = ActionWebPush::DeliveryJob.set(
|
62
|
+
wait: wait,
|
63
|
+
wait_until: wait_until,
|
64
|
+
queue: queue || :action_web_push,
|
65
|
+
priority: priority
|
66
|
+
)
|
67
|
+
|
68
|
+
job.perform_later(params, { id: subscription.id })
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def build_notifications(subscriptions, **notification_params)
|
75
|
+
subscriptions = subscriptions.is_a?(Array) ? subscriptions : [subscriptions]
|
76
|
+
subscriptions.map do |subscription|
|
77
|
+
if subscription.respond_to?(:build_notification)
|
78
|
+
subscription.build_notification(**notification_params)
|
79
|
+
else
|
80
|
+
# Handle hash-based subscription data
|
81
|
+
ActionWebPush::Notification.new(
|
82
|
+
endpoint: subscription.is_a?(Hash) ? subscription[:endpoint] : subscription.endpoint,
|
83
|
+
p256dh_key: subscription.is_a?(Hash) ? subscription[:p256dh_key] : subscription.p256dh_key,
|
84
|
+
auth_key: subscription.is_a?(Hash) ? subscription[:auth_key] : subscription.auth_key,
|
85
|
+
**notification_params
|
86
|
+
)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def deliver_notifications(notifications)
|
92
|
+
if defined?(Rails) && Rails.respond_to?(:configuration) && Rails.configuration.respond_to?(:x) && Rails.configuration.x.respond_to?(:action_web_push_pool)
|
93
|
+
Rails.configuration.x.action_web_push_pool.queue(notifications)
|
94
|
+
else
|
95
|
+
# Fallback to direct delivery
|
96
|
+
delivery_method = ActionWebPush::DeliveryMethods.for(ActionWebPush.config.delivery_method)
|
97
|
+
notifications.each { |notification| delivery_method.deliver!(notification) }
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def self.default(**params)
|
102
|
+
# Create a new merged hash without mutating existing params
|
103
|
+
merged_params = default_params.merge(params)
|
104
|
+
self.default_params = merged_params
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionWebPush
|
4
|
+
class BatchDelivery
|
5
|
+
include ActionWebPush::Logging
|
6
|
+
include ActionWebPush::Authorization
|
7
|
+
attr_reader :notifications, :pool, :batch_size, :current_user
|
8
|
+
|
9
|
+
def initialize(notifications, pool: nil, batch_size: nil, current_user: nil)
|
10
|
+
@notifications = Array(notifications)
|
11
|
+
@pool = pool || (defined?(Rails) ? Rails.configuration.x.action_web_push_pool : nil)
|
12
|
+
@batch_size = batch_size || ActionWebPush.config.batch_size || 100
|
13
|
+
@current_user = current_user || ActionWebPush::Authorization::Utils.current_user_context
|
14
|
+
|
15
|
+
# Authorization check for batch operations
|
16
|
+
if @current_user && !ActionWebPush::Authorization::Utils.authorization_bypassed?
|
17
|
+
# Extract subscriptions from notifications for authorization check
|
18
|
+
subscriptions = extract_subscriptions_from_notifications(@notifications)
|
19
|
+
authorize_batch_operation!(
|
20
|
+
current_user: @current_user,
|
21
|
+
subscriptions: subscriptions
|
22
|
+
)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def deliver_all
|
27
|
+
# Process notifications in batches to avoid overwhelming the system
|
28
|
+
notifications.each_slice(batch_size) do |batch|
|
29
|
+
if pool
|
30
|
+
batch_deliver_with_pool(batch)
|
31
|
+
else
|
32
|
+
direct_batch_deliver(batch)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.deliver(notifications, **options)
|
38
|
+
new(notifications, **options).deliver_all
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def batch_deliver_with_pool(batch_notifications)
|
44
|
+
# Group notifications by endpoint to avoid overwhelming single endpoints
|
45
|
+
grouped = batch_notifications.group_by(&:endpoint)
|
46
|
+
|
47
|
+
grouped.each do |endpoint, endpoint_notifications|
|
48
|
+
# Stagger delivery to same endpoint to avoid rate limiting
|
49
|
+
endpoint_notifications.each_with_index do |notification, index|
|
50
|
+
pool.delivery_pool.post do
|
51
|
+
sleep(index * 0.01) if index > 0 # Small delay between same endpoint
|
52
|
+
deliver_single(notification)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def direct_batch_deliver(batch_notifications)
|
59
|
+
batch_notifications.each { |notification| deliver_single(notification) }
|
60
|
+
end
|
61
|
+
|
62
|
+
def deliver_single(notification)
|
63
|
+
notification.deliver_now
|
64
|
+
rescue ActionWebPush::ExpiredSubscriptionError => e
|
65
|
+
# Handle expired subscription cleanup if we can identify the subscription
|
66
|
+
handle_expired_subscription(notification)
|
67
|
+
rescue StandardError => e
|
68
|
+
logger.error "ActionWebPush batch delivery failed: #{e.class} #{e.message}"
|
69
|
+
end
|
70
|
+
|
71
|
+
def handle_expired_subscription(notification)
|
72
|
+
# Try to find and clean up the expired subscription
|
73
|
+
subscription = ActionWebPush::Subscription.find_by(endpoint: notification.endpoint)
|
74
|
+
subscription&.destroy
|
75
|
+
rescue StandardError => e
|
76
|
+
logger.warn "Failed to cleanup expired subscription: #{e.message}"
|
77
|
+
end
|
78
|
+
|
79
|
+
def extract_subscriptions_from_notifications(notifications)
|
80
|
+
# Extract subscription information from notifications for authorization
|
81
|
+
notifications.map do |notification|
|
82
|
+
if notification.respond_to?(:endpoint)
|
83
|
+
# Try to find the subscription by endpoint
|
84
|
+
ActionWebPush::Subscription.find_by(endpoint: notification.endpoint)
|
85
|
+
else
|
86
|
+
nil
|
87
|
+
end
|
88
|
+
end.compact
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionWebPush
|
4
|
+
class Configuration
|
5
|
+
attr_accessor :vapid_public_key, :vapid_private_key, :vapid_subject
|
6
|
+
attr_accessor :pool_size, :queue_size, :delivery_method, :connection_pool_size, :batch_size
|
7
|
+
attr_accessor :logger, :timeout, :max_retries, :async
|
8
|
+
attr_reader :delivery_methods
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@pool_size = 50
|
12
|
+
@queue_size = 10000
|
13
|
+
@connection_pool_size = 150
|
14
|
+
@batch_size = 100
|
15
|
+
@delivery_method = :web_push
|
16
|
+
@vapid_subject = "mailto:support@example.com"
|
17
|
+
@logger = nil
|
18
|
+
@timeout = 30
|
19
|
+
@max_retries = 3
|
20
|
+
@async = false
|
21
|
+
@delivery_methods = {
|
22
|
+
web_push: ActionWebPush::DeliveryMethods::WebPush,
|
23
|
+
test: ActionWebPush::DeliveryMethods::Test
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
def vapid_keys
|
28
|
+
{
|
29
|
+
public_key: vapid_public_key,
|
30
|
+
private_key: vapid_private_key
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
def valid?
|
35
|
+
validate!
|
36
|
+
true
|
37
|
+
rescue ConfigurationError
|
38
|
+
false
|
39
|
+
end
|
40
|
+
|
41
|
+
def validate!
|
42
|
+
errors = []
|
43
|
+
|
44
|
+
# VAPID keys validation
|
45
|
+
errors << "vapid_public_key is required" if vapid_public_key.blank?
|
46
|
+
errors << "vapid_private_key is required" if vapid_private_key.blank?
|
47
|
+
|
48
|
+
if vapid_public_key.present? && vapid_public_key.length != 87
|
49
|
+
errors << "vapid_public_key must be 87 characters long (Base64 encoded)"
|
50
|
+
end
|
51
|
+
|
52
|
+
if vapid_private_key.present? && vapid_private_key.length != 43
|
53
|
+
errors << "vapid_private_key must be 43 characters long (Base64 encoded)"
|
54
|
+
end
|
55
|
+
|
56
|
+
# Email validation for vapid_subject
|
57
|
+
if vapid_subject.present? && !vapid_subject.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\z/i)
|
58
|
+
errors << "vapid_subject must be a valid email address (format: mailto:email@domain.com)"
|
59
|
+
end
|
60
|
+
|
61
|
+
# Numeric validations with reasonable bounds
|
62
|
+
errors << "pool_size must be between 1 and 1000" unless pool_size.is_a?(Integer) && pool_size.between?(1, 1000)
|
63
|
+
errors << "queue_size must be between 1 and 100000" unless queue_size.is_a?(Integer) && queue_size.between?(1, 100000)
|
64
|
+
errors << "connection_pool_size must be between 1 and 1000" unless connection_pool_size.is_a?(Integer) && connection_pool_size.between?(1, 1000)
|
65
|
+
errors << "batch_size must be between 1 and 10000" unless batch_size.is_a?(Integer) && batch_size.between?(1, 10000)
|
66
|
+
errors << "timeout must be between 1 and 300 seconds" unless timeout.is_a?(Integer) && timeout.between?(1, 300)
|
67
|
+
errors << "max_retries must be between 0 and 10" unless max_retries.is_a?(Integer) && max_retries.between?(0, 10)
|
68
|
+
|
69
|
+
# Delivery method validation
|
70
|
+
unless delivery_methods.key?(delivery_method)
|
71
|
+
available = delivery_methods.keys.join(", ")
|
72
|
+
errors << "delivery_method must be one of: #{available}"
|
73
|
+
end
|
74
|
+
|
75
|
+
# Boolean validation
|
76
|
+
unless [true, false].include?(async)
|
77
|
+
errors << "async must be true or false"
|
78
|
+
end
|
79
|
+
|
80
|
+
raise ConfigurationError, "Configuration errors: #{errors.join('; ')}" if errors.any?
|
81
|
+
end
|
82
|
+
|
83
|
+
def delivery_method_class
|
84
|
+
delivery_methods[delivery_method] || raise(ConfigurationError, "Unknown delivery method: #{delivery_method}")
|
85
|
+
end
|
86
|
+
|
87
|
+
def add_delivery_method(name, klass)
|
88
|
+
delivery_methods[name] = klass
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionWebPush
|
4
|
+
class DeliveryJob < ActiveJob::Base
|
5
|
+
queue_as :action_web_push
|
6
|
+
|
7
|
+
# Retry on transient failures
|
8
|
+
retry_on ActionWebPush::DeliveryError, wait: :polynomially_longer, attempts: 5
|
9
|
+
retry_on Net::TimeoutError, wait: :exponentially_longer, attempts: 3
|
10
|
+
retry_on Errno::ECONNREFUSED, wait: :exponentially_longer, attempts: 3
|
11
|
+
|
12
|
+
# Don't retry on permanent failures
|
13
|
+
discard_on ActionWebPush::ExpiredSubscriptionError
|
14
|
+
discard_on ActiveRecord::RecordNotFound
|
15
|
+
|
16
|
+
def perform(notification_params, subscription_params = nil)
|
17
|
+
if subscription_params
|
18
|
+
# Single subscription with notification data
|
19
|
+
subscription = ActionWebPush::Subscription.find(subscription_params[:id])
|
20
|
+
notification = subscription.build_notification(**notification_params)
|
21
|
+
deliver_with_error_handling(notification, subscription)
|
22
|
+
else
|
23
|
+
# Direct notification delivery
|
24
|
+
notification = ActionWebPush::Notification.new(**notification_params)
|
25
|
+
deliver_with_error_handling(notification)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def deliver_with_error_handling(notification, subscription = nil)
|
32
|
+
notification.deliver_now
|
33
|
+
|
34
|
+
logger.info "ActionWebPush delivered: #{notification.title} to #{notification.endpoint[0..50]}..."
|
35
|
+
rescue ActionWebPush::ExpiredSubscriptionError => e
|
36
|
+
# Handle expired subscription
|
37
|
+
if subscription
|
38
|
+
subscription.destroy
|
39
|
+
logger.info "ActionWebPush destroyed expired subscription: #{subscription.id}"
|
40
|
+
end
|
41
|
+
raise # Re-raise to trigger discard_on
|
42
|
+
rescue ActionWebPush::DeliveryError => e
|
43
|
+
# Log delivery error and re-raise for retry
|
44
|
+
logger.warn "ActionWebPush delivery failed (will retry): #{e.message}"
|
45
|
+
raise
|
46
|
+
rescue StandardError => e
|
47
|
+
# Catch-all for unexpected errors
|
48
|
+
logger.error "ActionWebPush unexpected error: #{e.class} #{e.message}"
|
49
|
+
raise ActionWebPush::DeliveryError, "Unexpected delivery failure: #{e.message}"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionWebPush
|
4
|
+
module DeliveryMethods
|
5
|
+
class Base
|
6
|
+
include ActionWebPush::Logging
|
7
|
+
|
8
|
+
attr_reader :settings
|
9
|
+
|
10
|
+
def initialize(settings = {})
|
11
|
+
@settings = settings
|
12
|
+
end
|
13
|
+
|
14
|
+
def deliver!(notification, connection: nil)
|
15
|
+
raise NotImplementedError, "Subclasses must implement deliver!"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionWebPush
|
4
|
+
module DeliveryMethods
|
5
|
+
class Test < Base
|
6
|
+
@@deliveries = []
|
7
|
+
|
8
|
+
def self.deliveries
|
9
|
+
@@deliveries
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.deliveries=(value)
|
13
|
+
@@deliveries = value
|
14
|
+
end
|
15
|
+
|
16
|
+
def deliver!(notification, connection: nil)
|
17
|
+
self.class.deliveries << {
|
18
|
+
title: notification.title,
|
19
|
+
body: notification.body,
|
20
|
+
data: notification.data,
|
21
|
+
endpoint: notification.endpoint,
|
22
|
+
p256dh_key: notification.p256dh_key,
|
23
|
+
auth_key: notification.auth_key,
|
24
|
+
options: notification.options,
|
25
|
+
delivered_at: Time.now
|
26
|
+
}
|
27
|
+
|
28
|
+
logger.info "ActionWebPush::Test delivered: #{notification.title}"
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.clear_deliveries!
|
32
|
+
deliveries.clear
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "web-push"
|
4
|
+
|
5
|
+
module ActionWebPush
|
6
|
+
module DeliveryMethods
|
7
|
+
class WebPush < Base
|
8
|
+
def deliver!(notification, connection: nil)
|
9
|
+
ActionWebPush::Instrumentation.instrument("notification_delivery",
|
10
|
+
endpoint: notification.endpoint,
|
11
|
+
title: notification.title,
|
12
|
+
urgency: notification.options[:urgency] || "high"
|
13
|
+
) do |payload|
|
14
|
+
response = ::WebPush.payload_send(
|
15
|
+
message: encoded_message(notification),
|
16
|
+
endpoint: notification.endpoint,
|
17
|
+
p256dh: notification.p256dh_key,
|
18
|
+
auth: notification.auth_key,
|
19
|
+
vapid: vapid_identification,
|
20
|
+
connection: connection,
|
21
|
+
urgency: notification.options[:urgency] || "high"
|
22
|
+
)
|
23
|
+
|
24
|
+
payload[:success] = response.success?
|
25
|
+
payload[:response_code] = response.code if response.respond_to?(:code)
|
26
|
+
|
27
|
+
response.success?
|
28
|
+
end
|
29
|
+
rescue ::WebPush::ExpiredSubscription => e
|
30
|
+
context = {
|
31
|
+
endpoint: notification.endpoint,
|
32
|
+
subscription_id: notification.respond_to?(:subscription_id) ? notification.subscription_id : nil
|
33
|
+
}
|
34
|
+
raise ActionWebPush::ErrorHandler.handle_expired_subscription_error(
|
35
|
+
ActionWebPush::ExpiredSubscriptionError.new(e.message), context
|
36
|
+
)
|
37
|
+
rescue StandardError => e
|
38
|
+
context = {
|
39
|
+
endpoint: notification.endpoint,
|
40
|
+
title: notification.title,
|
41
|
+
retry_count: 0
|
42
|
+
}
|
43
|
+
raise ActionWebPush::ErrorHandler.handle_delivery_failure(
|
44
|
+
ActionWebPush::DeliveryError.new("Failed to deliver push notification: #{e.message}"), context
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def vapid_identification
|
51
|
+
config = ActionWebPush.config
|
52
|
+
{
|
53
|
+
subject: config.vapid_subject,
|
54
|
+
public_key: config.vapid_public_key,
|
55
|
+
private_key: config.vapid_private_key
|
56
|
+
}
|
57
|
+
end
|
58
|
+
|
59
|
+
def encoded_message(notification)
|
60
|
+
payload = {
|
61
|
+
title: notification.title,
|
62
|
+
options: {
|
63
|
+
body: notification.body,
|
64
|
+
icon: notification.options[:icon],
|
65
|
+
badge: notification.options[:badge],
|
66
|
+
data: notification.data
|
67
|
+
}
|
68
|
+
}
|
69
|
+
|
70
|
+
JSON.generate(payload)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/engine"
|
4
|
+
|
5
|
+
module ActionWebPush
|
6
|
+
class Engine < ::Rails::Engine
|
7
|
+
isolate_namespace ActionWebPush
|
8
|
+
|
9
|
+
config.generators do |g|
|
10
|
+
g.test_framework :minitest
|
11
|
+
g.orm :active_record
|
12
|
+
end
|
13
|
+
|
14
|
+
initializer "action_web_push.routes" do |app|
|
15
|
+
app.routes.prepend do
|
16
|
+
mount ActionWebPush::Engine => "/action_web_push"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionWebPush
|
4
|
+
module ErrorHandler
|
5
|
+
def self.handle_delivery_error(error, context = {})
|
6
|
+
case error
|
7
|
+
when ActionWebPush::ExpiredSubscriptionError
|
8
|
+
handle_expired_subscription_error(error, context)
|
9
|
+
when ActionWebPush::RateLimitExceeded
|
10
|
+
handle_rate_limit_error(error, context)
|
11
|
+
when ActionWebPush::DeliveryError
|
12
|
+
handle_delivery_failure(error, context)
|
13
|
+
when ActionWebPush::ConfigurationError
|
14
|
+
handle_configuration_error(error, context)
|
15
|
+
else
|
16
|
+
handle_unexpected_error(error, context)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.handle_expired_subscription_error(error, context)
|
21
|
+
ActionWebPush::Instrumentation.publish("subscription_expired",
|
22
|
+
error: error.message,
|
23
|
+
endpoint: context[:endpoint],
|
24
|
+
subscription_id: context[:subscription_id]
|
25
|
+
)
|
26
|
+
|
27
|
+
ActionWebPush.logger.info "Subscription expired: #{error.message}"
|
28
|
+
|
29
|
+
# Cleanup subscription if possible
|
30
|
+
cleanup_expired_subscription(context[:subscription_id]) if context[:subscription_id]
|
31
|
+
|
32
|
+
error
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.handle_rate_limit_error(error, context)
|
36
|
+
ActionWebPush::Instrumentation.publish("rate_limit_exceeded",
|
37
|
+
error: error.message,
|
38
|
+
resource_type: context[:resource_type],
|
39
|
+
resource_id: context[:resource_id]
|
40
|
+
)
|
41
|
+
|
42
|
+
ActionWebPush.logger.warn "Rate limit exceeded: #{error.message}"
|
43
|
+
error
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.handle_delivery_failure(error, context)
|
47
|
+
ActionWebPush::Instrumentation.publish("notification_delivery_failed",
|
48
|
+
error: error.message,
|
49
|
+
error_class: error.class.name,
|
50
|
+
endpoint: context[:endpoint],
|
51
|
+
notification_title: context[:title],
|
52
|
+
retry_count: context[:retry_count] || 0
|
53
|
+
)
|
54
|
+
|
55
|
+
retry_count = context[:retry_count]
|
56
|
+
log_level = (retry_count.is_a?(Integer) && retry_count > 2) ? :error : :warn
|
57
|
+
ActionWebPush.logger.send(log_level, "Delivery failed: #{error.message}")
|
58
|
+
|
59
|
+
error
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.handle_configuration_error(error, context)
|
63
|
+
ActionWebPush::Instrumentation.publish("configuration_error",
|
64
|
+
error: error.message,
|
65
|
+
context: context
|
66
|
+
)
|
67
|
+
|
68
|
+
ActionWebPush.logger.error "Configuration error: #{error.message}"
|
69
|
+
error
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.handle_unexpected_error(error, context)
|
73
|
+
ActionWebPush::Instrumentation.publish("unexpected_error",
|
74
|
+
error: error.message,
|
75
|
+
error_class: error.class.name,
|
76
|
+
backtrace: error.backtrace&.first(5),
|
77
|
+
context: context
|
78
|
+
)
|
79
|
+
|
80
|
+
ActionWebPush.logger.error "Unexpected error in ActionWebPush: #{error.class} #{error.message}"
|
81
|
+
|
82
|
+
# Wrap in ActionWebPush error for consistency
|
83
|
+
ActionWebPush::DeliveryError.new("Unexpected error: #{error.message}")
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def self.cleanup_expired_subscription(subscription_id)
|
89
|
+
return unless defined?(ActionWebPush::Subscription)
|
90
|
+
|
91
|
+
subscription = ActionWebPush::Subscription.find_by(id: subscription_id)
|
92
|
+
subscription&.destroy
|
93
|
+
|
94
|
+
ActionWebPush.logger.debug "Cleaned up expired subscription #{subscription_id}"
|
95
|
+
rescue StandardError => e
|
96
|
+
ActionWebPush.logger.warn "Failed to cleanup expired subscription #{subscription_id}: #{e.message}"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/base"
|
4
|
+
|
5
|
+
module ActionWebPush
|
6
|
+
module Generators
|
7
|
+
class CampfireMigrationGenerator < Rails::Generators::Base
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
9
|
+
|
10
|
+
desc "Generate migration from Campfire's existing push_subscriptions to ActionWebPush"
|
11
|
+
|
12
|
+
class_option :table_name, type: :string, default: "push_subscriptions",
|
13
|
+
desc: "Name of existing Campfire table"
|
14
|
+
|
15
|
+
class_option :preserve_data, type: :boolean, default: true,
|
16
|
+
desc: "Whether to preserve existing subscription data"
|
17
|
+
|
18
|
+
def create_data_migration
|
19
|
+
template(
|
20
|
+
"campfire_data_migration.rb",
|
21
|
+
"db/migrate/#{timestamp}_migrate_campfire_push_subscriptions_to_action_web_push.rb",
|
22
|
+
migration_version: migration_version
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
def create_compatibility_module
|
27
|
+
template(
|
28
|
+
"campfire_compatibility.rb",
|
29
|
+
"lib/action_web_push/campfire_compatibility.rb"
|
30
|
+
)
|
31
|
+
end
|
32
|
+
|
33
|
+
def show_migration_instructions
|
34
|
+
say "\n" + "="*70
|
35
|
+
say "Campfire to ActionWebPush Migration Generated!"
|
36
|
+
say "="*70
|
37
|
+
say "\nNext steps:"
|
38
|
+
say "1. Review the generated migration file"
|
39
|
+
say "2. Backup your existing push_subscriptions data"
|
40
|
+
say "3. Run: rails db:migrate"
|
41
|
+
say "4. Update your Campfire code to use ActionWebPush"
|
42
|
+
say "5. Test thoroughly in staging environment"
|
43
|
+
say "\nCompatibility module created at: lib/action_web_push/campfire_compatibility.rb"
|
44
|
+
say "This provides backward compatibility during the transition period."
|
45
|
+
say "\nIMPORTANT: Test this migration on a copy of production data first!"
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def timestamp
|
51
|
+
Time.current.strftime("%Y%m%d%H%M%S")
|
52
|
+
end
|
53
|
+
|
54
|
+
def migration_version
|
55
|
+
if Rails::VERSION::MAJOR >= 5
|
56
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def old_table_name
|
61
|
+
options[:table_name]
|
62
|
+
end
|
63
|
+
|
64
|
+
def preserve_data?
|
65
|
+
options[:preserve_data]
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|