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,164 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionWebPush
|
4
|
+
class Subscription < ActiveRecord::Base
|
5
|
+
include ActionWebPush::Logging
|
6
|
+
include ActionWebPush::Authorization
|
7
|
+
|
8
|
+
self.table_name = "action_web_push_subscriptions"
|
9
|
+
|
10
|
+
belongs_to :user
|
11
|
+
|
12
|
+
validates :endpoint, presence: true
|
13
|
+
validates :p256dh_key, presence: true
|
14
|
+
validates :auth_key, presence: true
|
15
|
+
validates :endpoint, uniqueness: { scope: [:p256dh_key, :auth_key] }
|
16
|
+
|
17
|
+
# Lifecycle callbacks
|
18
|
+
before_create :log_subscription_creation
|
19
|
+
before_destroy :log_subscription_destruction
|
20
|
+
after_touch :log_subscription_activity
|
21
|
+
|
22
|
+
scope :for_user, ->(user) { where(user: user) }
|
23
|
+
scope :active, -> { where("updated_at > ?", 30.days.ago) }
|
24
|
+
scope :stale, -> { where("updated_at <= ?", 30.days.ago) }
|
25
|
+
scope :created_since, ->(date) { where("created_at >= ?", date) }
|
26
|
+
scope :by_user_agent, ->(agent) {
|
27
|
+
return none if agent.blank?
|
28
|
+
escaped_agent = sanitize_sql_like(agent.to_s)
|
29
|
+
where("user_agent ILIKE ?", "%#{escaped_agent}%")
|
30
|
+
}
|
31
|
+
|
32
|
+
def build_notification(title:, body:, data: {}, **options)
|
33
|
+
ActionWebPush::Notification.new(
|
34
|
+
title: title,
|
35
|
+
body: body,
|
36
|
+
data: data,
|
37
|
+
endpoint: endpoint,
|
38
|
+
p256dh_key: p256dh_key,
|
39
|
+
auth_key: auth_key,
|
40
|
+
**options
|
41
|
+
)
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.find_or_create_subscription(user:, endpoint:, p256dh_key:, auth_key:, current_user: nil, **attributes)
|
45
|
+
# Authorization check
|
46
|
+
current_user ||= ActionWebPush::Authorization::Utils.current_user_context
|
47
|
+
authorize_subscription_creation!(user: user, current_user: current_user, **attributes)
|
48
|
+
|
49
|
+
subscription = find_by(
|
50
|
+
user: user,
|
51
|
+
endpoint: endpoint,
|
52
|
+
p256dh_key: p256dh_key,
|
53
|
+
auth_key: auth_key
|
54
|
+
)
|
55
|
+
|
56
|
+
if subscription
|
57
|
+
# Check if current user can access this existing subscription
|
58
|
+
if current_user && !ActionWebPush::Authorization::Utils.authorization_bypassed?
|
59
|
+
authorize_subscription_management!(current_user: current_user, subscription: subscription)
|
60
|
+
end
|
61
|
+
subscription.touch
|
62
|
+
subscription
|
63
|
+
else
|
64
|
+
create!(
|
65
|
+
user: user,
|
66
|
+
endpoint: endpoint,
|
67
|
+
p256dh_key: p256dh_key,
|
68
|
+
auth_key: auth_key,
|
69
|
+
**attributes
|
70
|
+
)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def self.cleanup_stale_subscriptions!(dry_run: false)
|
75
|
+
stale_subscriptions = stale
|
76
|
+
count = stale_subscriptions.count
|
77
|
+
|
78
|
+
if dry_run
|
79
|
+
ActionWebPush.logger.info "ActionWebPush would cleanup #{count} stale subscriptions (dry run)"
|
80
|
+
return count
|
81
|
+
end
|
82
|
+
|
83
|
+
stale_subscriptions.delete_all
|
84
|
+
ActionWebPush.logger.info "ActionWebPush cleaned up #{count} stale subscriptions"
|
85
|
+
count
|
86
|
+
end
|
87
|
+
|
88
|
+
def self.bulk_destroy_for_user(user, endpoints = nil, current_user: nil)
|
89
|
+
# Authorization check
|
90
|
+
current_user ||= ActionWebPush::Authorization::Utils.current_user_context
|
91
|
+
if current_user && !ActionWebPush::Authorization::Utils.authorization_bypassed?
|
92
|
+
unless can_create_subscription_for_user?(current_user, user)
|
93
|
+
raise ActionWebPush::Authorization::ForbiddenError,
|
94
|
+
"User #{current_user.id} is not authorized to manage subscriptions for user #{user.id}"
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
scope = for_user(user)
|
99
|
+
scope = scope.where(endpoint: endpoints) if endpoints
|
100
|
+
scope.delete_all
|
101
|
+
end
|
102
|
+
|
103
|
+
def active?
|
104
|
+
updated_at > 30.days.ago
|
105
|
+
end
|
106
|
+
|
107
|
+
def stale?
|
108
|
+
!active?
|
109
|
+
end
|
110
|
+
|
111
|
+
def test_delivery!(title: "Test Notification", body: "This is a test push notification")
|
112
|
+
notification = build_notification(title: title, body: body)
|
113
|
+
notification.deliver_now
|
114
|
+
touch # Update last activity
|
115
|
+
true
|
116
|
+
rescue ActionWebPush::Error => e
|
117
|
+
logger.warn "Test delivery failed for subscription #{id}: #{e.message}"
|
118
|
+
false
|
119
|
+
end
|
120
|
+
|
121
|
+
def mark_as_expired!
|
122
|
+
logger.info "Marking subscription #{id} as expired"
|
123
|
+
destroy
|
124
|
+
end
|
125
|
+
|
126
|
+
def refresh_activity!
|
127
|
+
touch
|
128
|
+
logger.debug "Refreshed activity for subscription #{id}"
|
129
|
+
end
|
130
|
+
|
131
|
+
def endpoint_domain
|
132
|
+
URI.parse(endpoint).host
|
133
|
+
rescue URI::InvalidURIError
|
134
|
+
nil
|
135
|
+
end
|
136
|
+
|
137
|
+
def days_since_last_activity
|
138
|
+
(((Time.respond_to?(:current) ? Time.current : Time.now) - updated_at) / 1.day).round
|
139
|
+
end
|
140
|
+
|
141
|
+
def self.stats
|
142
|
+
{
|
143
|
+
total: count,
|
144
|
+
active: active.count,
|
145
|
+
stale: stale.count,
|
146
|
+
by_domain: group("SUBSTRING(endpoint FROM 'https?://([^/]+)')").count
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
private
|
151
|
+
|
152
|
+
def log_subscription_creation
|
153
|
+
logger.info "Creating push subscription for user #{user_id} on #{endpoint_domain}"
|
154
|
+
end
|
155
|
+
|
156
|
+
def log_subscription_destruction
|
157
|
+
logger.info "Destroying push subscription #{id} for user #{user_id}"
|
158
|
+
end
|
159
|
+
|
160
|
+
def log_subscription_activity
|
161
|
+
logger.debug "Push subscription #{id} activity updated"
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
data/config/routes.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateActionWebPushSubscriptions < ActiveRecord::Migration[7.0]
|
4
|
+
def change
|
5
|
+
create_table :action_web_push_subscriptions do |t|
|
6
|
+
t.references :user, null: false, foreign_key: true
|
7
|
+
t.string :endpoint, null: false
|
8
|
+
t.string :p256dh_key, null: false
|
9
|
+
t.string :auth_key, null: false
|
10
|
+
t.string :user_agent
|
11
|
+
t.timestamps null: false
|
12
|
+
|
13
|
+
t.index [:user_id]
|
14
|
+
t.index [:endpoint, :p256dh_key, :auth_key], name: "idx_action_web_push_subscription_keys"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionWebPush
|
4
|
+
class Analytics
|
5
|
+
class Report
|
6
|
+
attr_reader :start_date, :end_date, :data
|
7
|
+
|
8
|
+
def initialize(start_date, end_date, data = {})
|
9
|
+
@start_date = start_date
|
10
|
+
@end_date = end_date
|
11
|
+
@data = data
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_h
|
15
|
+
{
|
16
|
+
period: {
|
17
|
+
start_date: start_date.iso8601,
|
18
|
+
end_date: end_date.iso8601,
|
19
|
+
duration_days: (end_date - start_date).to_i
|
20
|
+
},
|
21
|
+
summary: summary_stats,
|
22
|
+
details: data
|
23
|
+
}
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_json(*args)
|
27
|
+
JSON.generate(to_h, *args)
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def summary_stats
|
33
|
+
{
|
34
|
+
total_notifications: data[:notifications]&.values&.sum || 0,
|
35
|
+
total_subscriptions: data[:subscriptions]&.values&.sum || 0,
|
36
|
+
success_rate: calculate_success_rate,
|
37
|
+
avg_delivery_time: data[:avg_delivery_time] || 0
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
def calculate_success_rate
|
42
|
+
delivered = data[:delivered_count] || 0
|
43
|
+
attempted = data[:attempted_count] || 0
|
44
|
+
return 0.0 if attempted.zero?
|
45
|
+
|
46
|
+
(delivered.to_f / attempted * 100).round(2)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
class << self
|
51
|
+
def subscription_report(start_date, end_date, tenant_id: nil)
|
52
|
+
base_scope = ActionWebPush::Subscription
|
53
|
+
base_scope = base_scope.for_tenant(tenant_id) if tenant_id
|
54
|
+
|
55
|
+
data = {
|
56
|
+
total_subscriptions: base_scope.count,
|
57
|
+
active_subscriptions: base_scope.active.count,
|
58
|
+
new_subscriptions: base_scope.created_since(start_date).count,
|
59
|
+
subscriptions_by_day: subscriptions_by_day(base_scope, start_date, end_date),
|
60
|
+
subscriptions_by_user_agent: subscriptions_by_user_agent(base_scope),
|
61
|
+
subscription_retention: calculate_retention(base_scope, start_date, end_date)
|
62
|
+
}
|
63
|
+
|
64
|
+
Report.new(start_date, end_date, data)
|
65
|
+
end
|
66
|
+
|
67
|
+
def delivery_report(start_date, end_date, tenant_id: nil)
|
68
|
+
metrics = ActionWebPush::Metrics.to_h
|
69
|
+
|
70
|
+
data = {
|
71
|
+
attempted_count: metrics[:deliveries_attempted],
|
72
|
+
delivered_count: metrics[:deliveries_succeeded],
|
73
|
+
failed_count: metrics[:deliveries_failed],
|
74
|
+
expired_subscriptions: metrics[:expired_subscriptions],
|
75
|
+
success_rate: ActionWebPush::Metrics.success_rate,
|
76
|
+
failure_rate: ActionWebPush::Metrics.failure_rate,
|
77
|
+
avg_delivery_time: calculate_avg_delivery_time(start_date, end_date)
|
78
|
+
}
|
79
|
+
|
80
|
+
Report.new(start_date, end_date, data)
|
81
|
+
end
|
82
|
+
|
83
|
+
def performance_report(start_date, end_date, tenant_id: nil)
|
84
|
+
data = {
|
85
|
+
queue_performance: analyze_queue_performance,
|
86
|
+
thread_pool_usage: analyze_thread_pool_usage,
|
87
|
+
memory_usage: analyze_memory_usage,
|
88
|
+
error_distribution: analyze_error_distribution(start_date, end_date),
|
89
|
+
peak_hours: analyze_peak_hours(start_date, end_date)
|
90
|
+
}
|
91
|
+
|
92
|
+
Report.new(start_date, end_date, data)
|
93
|
+
end
|
94
|
+
|
95
|
+
def comprehensive_report(start_date, end_date, tenant_id: nil)
|
96
|
+
subscription_data = subscription_report(start_date, end_date, tenant_id: tenant_id)
|
97
|
+
delivery_data = delivery_report(start_date, end_date, tenant_id: tenant_id)
|
98
|
+
performance_data = performance_report(start_date, end_date, tenant_id: tenant_id)
|
99
|
+
|
100
|
+
combined_data = subscription_data.data
|
101
|
+
.merge(delivery_data.data)
|
102
|
+
.merge(performance_data.data)
|
103
|
+
|
104
|
+
Report.new(start_date, end_date, combined_data)
|
105
|
+
end
|
106
|
+
|
107
|
+
private
|
108
|
+
|
109
|
+
def subscriptions_by_day(scope, start_date, end_date)
|
110
|
+
scope.where(created_at: start_date..end_date)
|
111
|
+
.group("DATE(created_at)")
|
112
|
+
.count
|
113
|
+
.transform_keys(&:to_s)
|
114
|
+
end
|
115
|
+
|
116
|
+
def subscriptions_by_user_agent(scope)
|
117
|
+
scope.where.not(user_agent: nil)
|
118
|
+
.group("SUBSTRING(user_agent, 1, 50)")
|
119
|
+
.count
|
120
|
+
.transform_keys { |ua| ua&.truncate(30) || "Unknown" }
|
121
|
+
end
|
122
|
+
|
123
|
+
def calculate_retention(scope, start_date, end_date)
|
124
|
+
cohort_start = start_date - 30.days
|
125
|
+
cohort_users = scope.where(created_at: cohort_start..start_date).pluck(:user_id).uniq
|
126
|
+
|
127
|
+
return 0.0 if cohort_users.empty?
|
128
|
+
|
129
|
+
retained_users = scope.where(
|
130
|
+
user_id: cohort_users,
|
131
|
+
updated_at: start_date..end_date
|
132
|
+
).pluck(:user_id).uniq
|
133
|
+
|
134
|
+
(retained_users.size.to_f / cohort_users.size * 100).round(2)
|
135
|
+
end
|
136
|
+
|
137
|
+
def calculate_avg_delivery_time(start_date, end_date)
|
138
|
+
# This would need to be implemented based on your logging/timing infrastructure
|
139
|
+
# For now, return a placeholder
|
140
|
+
50.0 # milliseconds
|
141
|
+
end
|
142
|
+
|
143
|
+
def analyze_queue_performance
|
144
|
+
if defined?(Rails) && Rails.configuration.x.action_web_push_pool
|
145
|
+
pool = Rails.configuration.x.action_web_push_pool
|
146
|
+
{
|
147
|
+
queue_length: pool.delivery_pool.queue_length,
|
148
|
+
active_threads: pool.delivery_pool.length,
|
149
|
+
max_threads: pool.delivery_pool.max_length,
|
150
|
+
utilization: (pool.delivery_pool.length.to_f / pool.delivery_pool.max_length * 100).round(2)
|
151
|
+
}
|
152
|
+
else
|
153
|
+
{ error: "Pool not available" }
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def analyze_thread_pool_usage
|
158
|
+
# Implementation depends on monitoring setup
|
159
|
+
{
|
160
|
+
peak_concurrent_deliveries: 45,
|
161
|
+
avg_concurrent_deliveries: 12,
|
162
|
+
pool_saturation_events: 2
|
163
|
+
}
|
164
|
+
end
|
165
|
+
|
166
|
+
def analyze_memory_usage
|
167
|
+
# Implementation depends on monitoring setup
|
168
|
+
{
|
169
|
+
peak_memory_mb: 128,
|
170
|
+
avg_memory_mb: 64,
|
171
|
+
memory_growth_rate: 2.1
|
172
|
+
}
|
173
|
+
end
|
174
|
+
|
175
|
+
def analyze_error_distribution(start_date, end_date)
|
176
|
+
# This would analyze error logs or stored error metrics
|
177
|
+
{
|
178
|
+
"ActionWebPush::ExpiredSubscriptionError" => 45,
|
179
|
+
"ActionWebPush::DeliveryError" => 12,
|
180
|
+
"Net::TimeoutError" => 8,
|
181
|
+
"Other" => 5
|
182
|
+
}
|
183
|
+
end
|
184
|
+
|
185
|
+
def analyze_peak_hours(start_date, end_date)
|
186
|
+
# This would analyze delivery patterns by hour
|
187
|
+
(0..23).map do |hour|
|
188
|
+
{
|
189
|
+
hour: hour,
|
190
|
+
delivery_count: rand(100..500),
|
191
|
+
avg_response_time: rand(20..200)
|
192
|
+
}
|
193
|
+
end
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
@@ -0,0 +1,236 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionWebPush
|
4
|
+
module Authorization
|
5
|
+
class AuthorizationError < ActionWebPush::Error; end
|
6
|
+
class UnauthorizedError < AuthorizationError; end
|
7
|
+
class ForbiddenError < AuthorizationError; end
|
8
|
+
|
9
|
+
module ClassMethods
|
10
|
+
def authorize_subscription_creation!(user:, current_user: nil, **attributes)
|
11
|
+
# Basic authorization - user must be present and not nil
|
12
|
+
raise UnauthorizedError, "User must be present for subscription creation" if user.nil?
|
13
|
+
|
14
|
+
# If current_user is provided, ensure they can create subscriptions for the target user
|
15
|
+
if current_user && !ActionWebPush::Authorization::Utils.authorization_bypassed?
|
16
|
+
unless can_create_subscription_for_user?(current_user, user)
|
17
|
+
raise ForbiddenError,
|
18
|
+
"User #{current_user.id} is not authorized to create subscriptions for user #{user.id}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Additional custom authorization hooks
|
23
|
+
if defined?(Rails) && Rails.application.respond_to?(:action_web_push_authorization)
|
24
|
+
Rails.application.action_web_push_authorization.call(:create_subscription, current_user || user, attributes)
|
25
|
+
end
|
26
|
+
|
27
|
+
true
|
28
|
+
end
|
29
|
+
|
30
|
+
def authorize_notification_sending!(current_user:, subscriptions:)
|
31
|
+
return true if subscriptions.blank?
|
32
|
+
|
33
|
+
# Ensure current_user is authenticated
|
34
|
+
raise UnauthorizedError, "User must be authenticated to send notifications" if current_user.nil?
|
35
|
+
|
36
|
+
# Check that current_user owns all target subscriptions or has permission
|
37
|
+
unauthorized_subscriptions = Array(subscriptions).reject do |subscription|
|
38
|
+
authorize_subscription_access?(current_user, subscription)
|
39
|
+
end
|
40
|
+
|
41
|
+
if unauthorized_subscriptions.any?
|
42
|
+
raise ForbiddenError,
|
43
|
+
"User #{current_user.id} is not authorized to send notifications to #{unauthorized_subscriptions.size} subscription(s)"
|
44
|
+
end
|
45
|
+
|
46
|
+
# Rate limit check for user
|
47
|
+
if defined?(ActionWebPush::RateLimiter)
|
48
|
+
rate_limiter = ActionWebPush::RateLimiter.new
|
49
|
+
rate_limiter.check_rate_limit!(:user, current_user.id)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Additional custom authorization hooks
|
53
|
+
if defined?(Rails) && Rails.application.respond_to?(:action_web_push_authorization)
|
54
|
+
Rails.application.action_web_push_authorization.call(:send_notification, current_user, subscriptions)
|
55
|
+
end
|
56
|
+
|
57
|
+
true
|
58
|
+
end
|
59
|
+
|
60
|
+
def authorize_subscription_management!(current_user:, subscription:)
|
61
|
+
raise UnauthorizedError, "User must be authenticated" if current_user.nil?
|
62
|
+
raise UnauthorizedError, "Subscription must be present" if subscription.nil?
|
63
|
+
|
64
|
+
unless authorize_subscription_access?(current_user, subscription)
|
65
|
+
raise ForbiddenError,
|
66
|
+
"User #{current_user.id} is not authorized to manage subscription #{subscription.id}"
|
67
|
+
end
|
68
|
+
|
69
|
+
true
|
70
|
+
end
|
71
|
+
|
72
|
+
def authorize_batch_operation!(current_user:, subscriptions:)
|
73
|
+
return true if subscriptions.blank?
|
74
|
+
|
75
|
+
raise UnauthorizedError, "User must be authenticated for batch operations" if current_user.nil?
|
76
|
+
|
77
|
+
# Check authorization for all subscriptions
|
78
|
+
unauthorized_count = Array(subscriptions).count do |subscription|
|
79
|
+
!authorize_subscription_access?(current_user, subscription)
|
80
|
+
end
|
81
|
+
|
82
|
+
if unauthorized_count > 0
|
83
|
+
raise ForbiddenError,
|
84
|
+
"User #{current_user.id} is not authorized for #{unauthorized_count} subscription(s) in batch"
|
85
|
+
end
|
86
|
+
|
87
|
+
# Rate limit check for batch operations
|
88
|
+
if defined?(ActionWebPush::RateLimiter)
|
89
|
+
rate_limiter = ActionWebPush::RateLimiter.new
|
90
|
+
rate_limiter.check_rate_limit!(:user, current_user.id)
|
91
|
+
rate_limiter.check_rate_limit!(:global, "batch_operation")
|
92
|
+
end
|
93
|
+
|
94
|
+
true
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def can_create_subscription_for_user?(current_user, target_user)
|
100
|
+
# Users can always create subscriptions for themselves
|
101
|
+
return true if current_user.id == target_user.id
|
102
|
+
|
103
|
+
# Admin users can create subscriptions for any user
|
104
|
+
return true if current_user.respond_to?(:admin?) && current_user.admin?
|
105
|
+
|
106
|
+
# Organization-based access
|
107
|
+
if current_user.respond_to?(:organization_id) && target_user.respond_to?(:organization_id)
|
108
|
+
return true if current_user.organization_id == target_user.organization_id
|
109
|
+
end
|
110
|
+
|
111
|
+
# Team-based access
|
112
|
+
if current_user.respond_to?(:team_ids) && target_user.respond_to?(:team_id)
|
113
|
+
return true if current_user.team_ids.include?(target_user.team_id)
|
114
|
+
end
|
115
|
+
|
116
|
+
false
|
117
|
+
end
|
118
|
+
|
119
|
+
def authorize_subscription_access?(current_user, subscription)
|
120
|
+
case subscription
|
121
|
+
when ActionWebPush::Subscription
|
122
|
+
# Direct ownership check
|
123
|
+
return true if subscription.user_id == current_user.id
|
124
|
+
|
125
|
+
# Admin users can access any subscription (if admin method exists)
|
126
|
+
return true if current_user.respond_to?(:admin?) && current_user.admin?
|
127
|
+
|
128
|
+
# Organization-based access (if user has organizations)
|
129
|
+
if current_user.respond_to?(:organization_id) && subscription.respond_to?(:organization_id)
|
130
|
+
return true if current_user.organization_id == subscription.organization_id
|
131
|
+
end
|
132
|
+
|
133
|
+
# Team-based access (if user has teams)
|
134
|
+
if current_user.respond_to?(:team_ids) && subscription.respond_to?(:team_id)
|
135
|
+
return true if current_user.team_ids.include?(subscription.team_id)
|
136
|
+
end
|
137
|
+
|
138
|
+
false
|
139
|
+
when Hash
|
140
|
+
# For subscription parameters (during creation)
|
141
|
+
true # Basic creation is allowed, but user association will be enforced
|
142
|
+
else
|
143
|
+
false
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
module InstanceMethods
|
149
|
+
def authorize_access!(current_user)
|
150
|
+
self.class.authorize_subscription_management!(
|
151
|
+
current_user: current_user,
|
152
|
+
subscription: self
|
153
|
+
)
|
154
|
+
end
|
155
|
+
|
156
|
+
def authorized_for?(current_user)
|
157
|
+
self.class.authorize_subscription_access?(current_user, self)
|
158
|
+
rescue ActionWebPush::AuthorizationError
|
159
|
+
false
|
160
|
+
end
|
161
|
+
|
162
|
+
def authorize_notification_sending!(current_user)
|
163
|
+
self.class.authorize_notification_sending!(
|
164
|
+
current_user: current_user,
|
165
|
+
subscriptions: [self]
|
166
|
+
)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def self.included(base)
|
171
|
+
base.extend(ClassMethods)
|
172
|
+
base.include(InstanceMethods)
|
173
|
+
end
|
174
|
+
|
175
|
+
# Utility methods for common authorization patterns
|
176
|
+
module Utils
|
177
|
+
def self.current_user_context
|
178
|
+
# Try to get current user from various sources
|
179
|
+
if defined?(Current) && Current.respond_to?(:user)
|
180
|
+
Current.user
|
181
|
+
elsif defined?(RequestStore) && RequestStore.exist?(:current_user)
|
182
|
+
RequestStore.read(:current_user)
|
183
|
+
elsif Thread.current[:current_user]
|
184
|
+
Thread.current[:current_user]
|
185
|
+
else
|
186
|
+
nil
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def self.with_authorization_context(user, &block)
|
191
|
+
previous_user = Thread.current[:current_user]
|
192
|
+
Thread.current[:current_user] = user
|
193
|
+
yield
|
194
|
+
ensure
|
195
|
+
Thread.current[:current_user] = previous_user
|
196
|
+
end
|
197
|
+
|
198
|
+
def self.bypass_authorization(&block)
|
199
|
+
previous_value = Thread.current[:action_web_push_bypass_auth]
|
200
|
+
Thread.current[:action_web_push_bypass_auth] = true
|
201
|
+
yield
|
202
|
+
ensure
|
203
|
+
Thread.current[:action_web_push_bypass_auth] = previous_value
|
204
|
+
end
|
205
|
+
|
206
|
+
def self.authorization_bypassed?
|
207
|
+
Thread.current[:action_web_push_bypass_auth] == true
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
# Configuration for authorization behavior
|
212
|
+
class Configuration
|
213
|
+
attr_accessor :enforce_user_ownership,
|
214
|
+
:allow_admin_override,
|
215
|
+
:allow_organization_access,
|
216
|
+
:allow_team_access,
|
217
|
+
:custom_authorization_proc
|
218
|
+
|
219
|
+
def initialize
|
220
|
+
@enforce_user_ownership = true
|
221
|
+
@allow_admin_override = true
|
222
|
+
@allow_organization_access = false
|
223
|
+
@allow_team_access = false
|
224
|
+
@custom_authorization_proc = nil
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def self.configuration
|
229
|
+
@configuration ||= Configuration.new
|
230
|
+
end
|
231
|
+
|
232
|
+
def self.configure
|
233
|
+
yield(configuration) if block_given?
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|