pezza_action_push_web 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/MIT-LICENSE +20 -0
- data/README.md +299 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/action_push_web.js +424 -0
- data/app/assets/javascripts/components/action_push_web.js +7 -0
- data/app/assets/javascripts/components/denied.js +24 -0
- data/app/assets/javascripts/components/granted.js +86 -0
- data/app/assets/javascripts/components/request.js +55 -0
- data/app/assets/stylesheets/action_push_web/application.css +15 -0
- data/app/controllers/action_push_web/subscriptions_controller.rb +19 -0
- data/app/helpers/action_push_web/application_helper.rb +22 -0
- data/app/jobs/action_push_web/notification_job.rb +48 -0
- data/app/models/action_push_web/subscription.rb +13 -0
- data/db/migrate/20250907213606_create_action_push_web_subscriptions.rb +13 -0
- data/lib/action_push_web/engine.rb +36 -0
- data/lib/action_push_web/errors.rb +8 -0
- data/lib/action_push_web/notification.rb +63 -0
- data/lib/action_push_web/payload_encryption.rb +86 -0
- data/lib/action_push_web/pool.rb +46 -0
- data/lib/action_push_web/pusher.rb +85 -0
- data/lib/action_push_web/subscription_notification.rb +13 -0
- data/lib/action_push_web/vapid_key.rb +38 -0
- data/lib/action_push_web/vapid_key_generator.rb +19 -0
- data/lib/action_push_web/version.rb +3 -0
- data/lib/action_push_web.rb +37 -0
- data/lib/generators/action_push_web/install/install_generator.rb +51 -0
- data/lib/generators/action_push_web/install/templates/app/jobs/application_push_web_notification_job.rb.tt +7 -0
- data/lib/generators/action_push_web/install/templates/app/models/application_push_subscription.rb.tt +4 -0
- data/lib/generators/action_push_web/install/templates/app/models/application_push_web_notification.rb.tt +12 -0
- data/lib/generators/action_push_web/install/templates/app/views/pwa/service-worker.js +30 -0
- data/lib/generators/action_push_web/install/templates/config/push.yml.tt +22 -0
- data/lib/pezza_action_push_web.rb +4 -0
- data/lib/tasks/action_push_web_tasks.rake +4 -0
- metadata +160 -0
@@ -0,0 +1,48 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class NotificationJob < ActiveJob::Base
|
3
|
+
self.log_arguments = false
|
4
|
+
|
5
|
+
class_attribute :report_job_retries, default: false
|
6
|
+
|
7
|
+
discard_on ActiveJob::DeserializationError
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def retry_options
|
11
|
+
Rails.version >= "8.1" ? { report: report_job_retries } : {}
|
12
|
+
end
|
13
|
+
|
14
|
+
# Exponential backoff starting from a minimum of 1 minute, capped at 60m as suggested by FCM:
|
15
|
+
# https://firebase.google.com/docs/cloud-messaging/scale-fcm#errors
|
16
|
+
#
|
17
|
+
# | Executions | Delay (rounded minutes) |
|
18
|
+
# |------------|-------------------------|
|
19
|
+
# | 1 | 1 |
|
20
|
+
# | 2 | 2 |
|
21
|
+
# | 3 | 4 |
|
22
|
+
# | 4 | 8 |
|
23
|
+
# | 5 | 16 |
|
24
|
+
# | 6 | 32 |
|
25
|
+
# | 7 | 60 (cap) |
|
26
|
+
def exponential_backoff_delay(executions)
|
27
|
+
base_wait = 1.minute
|
28
|
+
delay = base_wait * (2**(executions - 1))
|
29
|
+
jitter = 0.15
|
30
|
+
jitter_delay = rand * delay * jitter
|
31
|
+
|
32
|
+
[ delay + jitter_delay, 60.minutes ].min
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
with_options retry_options do
|
37
|
+
retry_on PushServiceError, attempts: 20
|
38
|
+
end
|
39
|
+
|
40
|
+
with_options wait: ->(executions) { exponential_backoff_delay(executions) }, attempts: 6, **retry_options do
|
41
|
+
retry_on TooManyRequestsError, ResponseError
|
42
|
+
end
|
43
|
+
|
44
|
+
def perform(notification_class, notification_attributes, subscription)
|
45
|
+
notification_class.constantize.new(**notification_attributes).deliver_to(subscription)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class Subscription < ApplicationRecord
|
3
|
+
include ActiveSupport::Rescuable
|
4
|
+
|
5
|
+
rescue_from(TokenError) { destroy! }
|
6
|
+
|
7
|
+
belongs_to :owner, polymorphic: true, optional: true
|
8
|
+
|
9
|
+
def push(notification)
|
10
|
+
ActionPushWeb.push(SubscriptionNotification.new(notification:, subscription: self))
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class CreateActionPushWebSubscriptions < ActiveRecord::Migration[8.0]
|
2
|
+
def change
|
3
|
+
create_table :action_push_web_subscriptions do |t|
|
4
|
+
t.belongs_to :owner, polymorphic: true
|
5
|
+
t.string :endpoint, null: false
|
6
|
+
t.string :auth_key, null: false
|
7
|
+
t.string :p256dh_key, null: false
|
8
|
+
t.string :user_agent
|
9
|
+
|
10
|
+
t.timestamps
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class Engine < ::Rails::Engine
|
3
|
+
isolate_namespace ActionPushWeb
|
4
|
+
config.autoload_once_paths = %W[
|
5
|
+
#{root}/app/helpers
|
6
|
+
]
|
7
|
+
|
8
|
+
config.action_push_web = ActiveSupport::OrderedOptions.new
|
9
|
+
|
10
|
+
initializer "action_push_web.config" do |app|
|
11
|
+
app.paths.add "config/push", with: "config/push.yml"
|
12
|
+
end
|
13
|
+
|
14
|
+
initializer "action_push_web.pool" do |app|
|
15
|
+
at_exit { ActionPushWeb.pool.shutdown }
|
16
|
+
end
|
17
|
+
|
18
|
+
initializer "action_push_web.assets" do
|
19
|
+
if Rails.application.config.respond_to?(:assets)
|
20
|
+
Rails.application.config.assets.precompile += %w[action_push_web.js]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
initializer "action_push_web.importmap", before: "importmap" do |app|
|
25
|
+
if Rails.application.respond_to?(:importmap)
|
26
|
+
app.config.importmap.paths << Engine.root.join("config/importmap.rb")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
initializer "action_push_web.helpers", before: :load_config_initializers do
|
31
|
+
ActiveSupport.on_load(:action_controller_base) do
|
32
|
+
helper ActionPushWeb::Engine.helpers
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class TokenError < StandardError; end
|
3
|
+
class UnauthorizedError < StandardError; end
|
4
|
+
class PayloadTooLargeError < StandardError; end
|
5
|
+
class TooManyRequestsError < StandardError; end
|
6
|
+
class PushServiceError < StandardError; end
|
7
|
+
class ResponseError < StandardError; end
|
8
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class Notification
|
3
|
+
extend ActiveModel::Callbacks
|
4
|
+
|
5
|
+
attr_accessor :title, :body, :path, :context, :urgency, :silent, :badge
|
6
|
+
attr_writer :icon_path
|
7
|
+
|
8
|
+
define_model_callbacks :delivery
|
9
|
+
|
10
|
+
class_attribute :queue_name, default: ActiveJob::Base.default_queue_name
|
11
|
+
class_attribute :enabled, default: !Rails.env.test?
|
12
|
+
class_attribute :application
|
13
|
+
|
14
|
+
class << self
|
15
|
+
def queue_as(name)
|
16
|
+
self.queue_name = name
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(title:, path: nil, body: nil, icon_path: nil, urgency: nil, badge: nil, silent: nil, **context)
|
21
|
+
@title = title
|
22
|
+
@path = path
|
23
|
+
@body = body.to_s
|
24
|
+
@icon_path = icon_path
|
25
|
+
@urgency = urgency
|
26
|
+
@silent = silent
|
27
|
+
@badge = badge
|
28
|
+
@context = context
|
29
|
+
end
|
30
|
+
|
31
|
+
def deliver_to(subscription)
|
32
|
+
if enabled
|
33
|
+
run_callbacks(:delivery) { subscription.push(self) }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def deliver_later_to(subscriptions)
|
38
|
+
Array(subscriptions).each do |subscription|
|
39
|
+
ApplicationPushWebNotificationJob.set(queue: queue_name).
|
40
|
+
perform_later(self.class.name, self.as_json, subscription)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def icon_path
|
45
|
+
@icon_path.presence || config.fetch(:icon_path, nil)
|
46
|
+
end
|
47
|
+
|
48
|
+
def urgency
|
49
|
+
(@urgency.presence || config.fetch(:urgency, :normal)).to_s
|
50
|
+
end
|
51
|
+
|
52
|
+
def as_json
|
53
|
+
{ title:, body:, path:, icon_path:, urgency:,
|
54
|
+
silent:, badge:, **context }.compact
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def config
|
60
|
+
@config ||= ActionPushWeb.config_for(application)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class PayloadEncryption
|
3
|
+
def initialize(message:, p256dh_key:, auth_key:)
|
4
|
+
@message = message
|
5
|
+
@p256dh_key = p256dh_key
|
6
|
+
@auth_key = auth_key
|
7
|
+
end
|
8
|
+
|
9
|
+
def encrypt
|
10
|
+
serverkey16bn = [ server_public_key_bn.to_s(16) ].pack("H*")
|
11
|
+
|
12
|
+
rs = encrypted_payload.bytesize
|
13
|
+
raise ArgumentError, "encrypted payload is too big" if rs > 4096
|
14
|
+
|
15
|
+
aes128gcmheader = "#{salt}" + [ rs ].pack("N*") +
|
16
|
+
[ serverkey16bn.bytesize ].pack("C*") + serverkey16bn
|
17
|
+
|
18
|
+
aes128gcmheader + encrypted_payload
|
19
|
+
end
|
20
|
+
|
21
|
+
attr_reader :message, :p256dh_key, :auth_key
|
22
|
+
|
23
|
+
def group_name = "prime256v1"
|
24
|
+
def hash = "SHA256"
|
25
|
+
|
26
|
+
def salt
|
27
|
+
@salt ||= Random.new.bytes(16)
|
28
|
+
end
|
29
|
+
|
30
|
+
def server
|
31
|
+
@server ||= OpenSSL::PKey::EC.generate(group_name)
|
32
|
+
end
|
33
|
+
|
34
|
+
def server_public_key_bn = server.public_key.to_bn
|
35
|
+
|
36
|
+
def group
|
37
|
+
@group ||= OpenSSL::PKey::EC::Group.new(group_name)
|
38
|
+
end
|
39
|
+
|
40
|
+
def client_public_key_bn
|
41
|
+
@client_public_key_bn ||= OpenSSL::BN.new(Base64.urlsafe_decode64(p256dh_key), 2)
|
42
|
+
end
|
43
|
+
|
44
|
+
def client_public_key
|
45
|
+
@client_public_key ||= OpenSSL::PKey::EC::Point.new(group, client_public_key_bn)
|
46
|
+
end
|
47
|
+
|
48
|
+
def shared_secret
|
49
|
+
@shared_secret ||= server.dh_compute_key(client_public_key)
|
50
|
+
end
|
51
|
+
|
52
|
+
def prk
|
53
|
+
@prk ||= OpenSSL::KDF.hkdf(shared_secret,
|
54
|
+
salt: Base64.urlsafe_decode64(auth_key),
|
55
|
+
info: "WebPush: info\0" + client_public_key_bn.to_s(2) + server_public_key_bn.to_s(2),
|
56
|
+
hash:, length: 32)
|
57
|
+
end
|
58
|
+
|
59
|
+
def content_encryption_key
|
60
|
+
@content_encryption_key ||= OpenSSL::KDF.hkdf(
|
61
|
+
prk, salt:, info: "Content-Encoding: aes128gcm\0", hash:, length: 16
|
62
|
+
)
|
63
|
+
end
|
64
|
+
|
65
|
+
def nonce
|
66
|
+
@nonce ||= OpenSSL::KDF.hkdf(
|
67
|
+
prk, salt:, info: "Content-Encoding: nonce\0", hash:, length: 12
|
68
|
+
)
|
69
|
+
end
|
70
|
+
|
71
|
+
def encrypted_payload
|
72
|
+
@encrypted_payload ||= begin
|
73
|
+
cipher = OpenSSL::Cipher.new("aes-128-gcm")
|
74
|
+
cipher.encrypt
|
75
|
+
cipher.key = content_encryption_key
|
76
|
+
cipher.iv = nonce
|
77
|
+
text = cipher.update(message)
|
78
|
+
padding = cipher.update("\2\0")
|
79
|
+
e_text = text + padding + cipher.final
|
80
|
+
e_tag = cipher.auth_tag
|
81
|
+
|
82
|
+
e_text + e_tag
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class Pool
|
3
|
+
attr_reader :delivery_pool, :invalidation_pool, :connection
|
4
|
+
|
5
|
+
def initialize(delivery_pool: Concurrent::ThreadPoolExecutor.new(max_threads: 50, queue_size: 10000),
|
6
|
+
invalidation_pool: Concurrent::FixedThreadPool.new(1))
|
7
|
+
@delivery_pool = delivery_pool
|
8
|
+
@invalidation_pool = invalidation_pool
|
9
|
+
@connection = Net::HTTP::Persistent.new(name: "action_push_web", pool_size: 150)
|
10
|
+
end
|
11
|
+
|
12
|
+
def enqueue(notification, config:)
|
13
|
+
delivery_pool.post do
|
14
|
+
deliver(notification, config)
|
15
|
+
end
|
16
|
+
rescue Concurrent::RejectedExecutionError
|
17
|
+
end
|
18
|
+
|
19
|
+
def shutdown
|
20
|
+
connection.shutdown
|
21
|
+
shutdown_pool(delivery_pool)
|
22
|
+
shutdown_pool(invalidation_pool)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def deliver(notification, config)
|
28
|
+
Pusher.new(config, notification).push(connection:)
|
29
|
+
rescue TokenError => error
|
30
|
+
invalidate_subscription_later(notification.subscription, error)
|
31
|
+
end
|
32
|
+
|
33
|
+
def invalidate_subscription_later(subscription, error)
|
34
|
+
invalidation_pool.post do
|
35
|
+
subscription.rescue_with_handler(error)
|
36
|
+
rescue Exception => e
|
37
|
+
Rails.logger.error "Error in ActionPushWeb::Pool.invalidate_subscription_later: #{e.class} #{e.message}"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def shutdown_pool(pool)
|
42
|
+
pool.shutdown
|
43
|
+
pool.kill unless pool.wait_for_termination(1)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class Pusher
|
3
|
+
def initialize(config, notification)
|
4
|
+
@config = config
|
5
|
+
@notification = notification
|
6
|
+
end
|
7
|
+
|
8
|
+
def push(connection:)
|
9
|
+
request = Net::HTTP::Post.new(uri.request_uri, headers).tap do
|
10
|
+
it.body = payload
|
11
|
+
end
|
12
|
+
|
13
|
+
connection.request(uri, request).tap { handle_response(it) }
|
14
|
+
rescue OpenSSL::OpenSSLError
|
15
|
+
raise TokenError.new
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
attr_reader :config, :notification
|
21
|
+
|
22
|
+
delegate :title, :body, :icon_path, :path, :silent, :badge, :endpoint, :p256dh_key,
|
23
|
+
:auth_key, to: :notification
|
24
|
+
|
25
|
+
def message
|
26
|
+
JSON.generate title:, options: { body:, icon: icon_path, silent:, badge:, data: { path: } }
|
27
|
+
end
|
28
|
+
|
29
|
+
def payload
|
30
|
+
@payload ||= PayloadEncryption.new(message:, p256dh_key:, auth_key:).encrypt
|
31
|
+
end
|
32
|
+
|
33
|
+
def vapid_identification
|
34
|
+
config.slice(:public_key, :private_key).compact
|
35
|
+
end
|
36
|
+
|
37
|
+
def uri
|
38
|
+
@uri ||= URI.parse(endpoint)
|
39
|
+
end
|
40
|
+
|
41
|
+
def headers
|
42
|
+
headers = {}
|
43
|
+
headers["Content-Type"] = "application/octet-stream"
|
44
|
+
headers["Urgency"] = notification.urgency
|
45
|
+
headers["Ttl"] = config.fetch(:ttl, 60 * 60 * 24 * 7 * 4).to_s
|
46
|
+
headers["Content-Encoding"] = "aes128gcm"
|
47
|
+
headers["Content-Length"] = payload.length.to_s
|
48
|
+
headers["Authorization"] = vapid_authorization
|
49
|
+
|
50
|
+
headers
|
51
|
+
end
|
52
|
+
|
53
|
+
def vapid_authorization
|
54
|
+
vapid_key = VapidKey.new(config[:public_key], config[:private_key])
|
55
|
+
|
56
|
+
jwt = JWT.encode(jwt_payload, vapid_key.ec_key,
|
57
|
+
"ES256", { "typ": "JWT", "alg": "ES256" })
|
58
|
+
|
59
|
+
"vapid t=#{jwt},k=#{vapid_key.public_key_for_push_header}"
|
60
|
+
end
|
61
|
+
|
62
|
+
def jwt_payload
|
63
|
+
{ aud: uri.scheme + "://" + uri.host,
|
64
|
+
exp: Time.now.to_i + config.fetch(:expiration, 12 * 60 * 60),
|
65
|
+
sub: config.fetch(:subject, "mailto:sender@example.com") }
|
66
|
+
end
|
67
|
+
|
68
|
+
def handle_response(response)
|
69
|
+
if response.is_a?(Net::HTTPGone) || response.is_a?(Net::HTTPNotFound) # 410 || 404
|
70
|
+
raise TokenError.new
|
71
|
+
elsif response.is_a?(Net::HTTPUnauthorized) || response.is_a?(Net::HTTPForbidden) || # 401, 403
|
72
|
+
response.is_a?(Net::HTTPBadRequest) && response.message == "UnauthorizedRegistration" # 400, Google FCM
|
73
|
+
raise UnauthorizedError.new
|
74
|
+
elsif response.is_a?(Net::HTTPRequestEntityTooLarge) # 413
|
75
|
+
raise PayloadTooLargeError.new
|
76
|
+
elsif response.is_a?(Net::HTTPTooManyRequests) # 429, try again later!
|
77
|
+
raise TooManyRequestsError.new
|
78
|
+
elsif response.is_a?(Net::HTTPServerError) # 5xx
|
79
|
+
raise PushServiceError.new
|
80
|
+
elsif !response.is_a?(Net::HTTPSuccess) # unknown/unhandled response error
|
81
|
+
raise ResponseError.new
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class SubscriptionNotification
|
3
|
+
def initialize(notification:, subscription:)
|
4
|
+
@notification = notification
|
5
|
+
@subscription = subscription
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_reader :notification, :subscription
|
9
|
+
|
10
|
+
delegate_missing_to :notification
|
11
|
+
delegate :endpoint, :p256dh_key, :auth_key, to: :subscription
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class VapidKey
|
3
|
+
attr_reader :public_key, :private_key
|
4
|
+
|
5
|
+
def initialize(public_key, private_key)
|
6
|
+
@public_key = public_key
|
7
|
+
@private_key = private_key
|
8
|
+
end
|
9
|
+
|
10
|
+
# For request header (unpadded)
|
11
|
+
def public_key_for_push_header
|
12
|
+
public_key.delete("=")
|
13
|
+
end
|
14
|
+
|
15
|
+
def ec_key
|
16
|
+
@ec_key ||= begin
|
17
|
+
group = OpenSSL::PKey::EC::Group.new("prime256v1")
|
18
|
+
public_point = OpenSSL::PKey::EC::Point.new(group, decode_base64url_to_big_number(public_key))
|
19
|
+
priv_bn = decode_base64url_to_big_number(private_key)
|
20
|
+
|
21
|
+
asn1 = OpenSSL::ASN1::Sequence([
|
22
|
+
OpenSSL::ASN1::Integer(1),
|
23
|
+
OpenSSL::ASN1::OctetString(priv_bn.to_s(2)),
|
24
|
+
OpenSSL::ASN1::ObjectId("prime256v1", 0, :EXPLICIT),
|
25
|
+
OpenSSL::ASN1::BitString(public_point.to_octet_string(:uncompressed), 1, :EXPLICIT)
|
26
|
+
])
|
27
|
+
|
28
|
+
OpenSSL::PKey::EC.new(asn1.to_der)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def decode_base64url_to_big_number(str)
|
35
|
+
OpenSSL::BN.new(Base64.urlsafe_decode64(str), 2)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module ActionPushWeb
|
2
|
+
class VapidKeyGenerator
|
3
|
+
def initialize
|
4
|
+
@ec_key = OpenSSL::PKey::EC.generate("prime256v1")
|
5
|
+
end
|
6
|
+
|
7
|
+
def private_key
|
8
|
+
Base64.urlsafe_encode64(ec_key.private_key.to_s(2))
|
9
|
+
end
|
10
|
+
|
11
|
+
def public_key
|
12
|
+
Base64.urlsafe_encode64(ec_key.public_key.to_bn.to_s(2))
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
attr_reader :ec_key
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require "zeitwerk"
|
2
|
+
require "action_push_web/engine"
|
3
|
+
require "action_push_web/errors"
|
4
|
+
require "net/http"
|
5
|
+
require "net/http/persistent"
|
6
|
+
require "jwt"
|
7
|
+
|
8
|
+
loader= Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
|
9
|
+
loader.ignore("#{__dir__}/generators")
|
10
|
+
loader.ignore("#{__dir__}/action_push_web/errors.rb")
|
11
|
+
loader.setup
|
12
|
+
|
13
|
+
module ActionPushWeb
|
14
|
+
def self.pool
|
15
|
+
@pool ||= Pool.new
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.pool=(pool)
|
19
|
+
@pool = pool
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.config_for(application)
|
23
|
+
platform_config = Rails.application.config_for(:push)[:web]
|
24
|
+
raise "ActionPushWeb: 'web' platform is not configured" unless platform_config.present?
|
25
|
+
|
26
|
+
if application.present?
|
27
|
+
notification_config = platform_config.fetch(application.to_sym, {})
|
28
|
+
platform_config.fetch(:application, {}).merge(notification_config)
|
29
|
+
else
|
30
|
+
platform_config
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.push(notification)
|
35
|
+
pool.enqueue(notification, config: self.config_for(notification.application))
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
class ActionPushWeb::InstallGenerator < Rails::Generators::Base
|
2
|
+
source_root File.expand_path("templates", __dir__)
|
3
|
+
|
4
|
+
APPLICATION_LAYOUT_PATH = Rails.root.join("app/views/layouts/application.html.erb")
|
5
|
+
|
6
|
+
def copy_files
|
7
|
+
template "app/models/application_push_subscription.rb"
|
8
|
+
template "app/models/application_push_web_notification.rb"
|
9
|
+
template "app/jobs/application_push_web_notification_job.rb"
|
10
|
+
template "app/views/pwa/service-worker.js"
|
11
|
+
route "mount ActionPushWeb::Engine => \"/action_push_web\""
|
12
|
+
|
13
|
+
if Rails.root.join("config/push.yml").exist?
|
14
|
+
append_to_file "config/push.yml", File.read("#{self.class.source_root}/config/push.yml.tt").split("\n")[1..].join("\n").prepend("\n")
|
15
|
+
else
|
16
|
+
template "config/push.yml"
|
17
|
+
end
|
18
|
+
|
19
|
+
if Rails.root.join("app/javascript/application.js").exist?
|
20
|
+
append_to_file "app/javascript/application.js", %(import "action_push_web"\n)
|
21
|
+
end
|
22
|
+
|
23
|
+
if Rails.root.join("package.json").exist? && Rails.root.join("bun.config.js").exist?
|
24
|
+
# run "bun add action_push_web"
|
25
|
+
elsif Rails.root.join("package.json").exist?
|
26
|
+
# run "yarn add action_push_web"
|
27
|
+
end
|
28
|
+
|
29
|
+
if APPLICATION_LAYOUT_PATH.exist?
|
30
|
+
say "Add action push web meta tag in application layout"
|
31
|
+
insert_into_file APPLICATION_LAYOUT_PATH.to_s, "\n <%= action_push_web_key_tag %>", before: /\s*<\/head>/
|
32
|
+
else
|
33
|
+
say "Default application.html.erb is missing!", :red
|
34
|
+
say " Add <%= action_push_web_key_tag %> within the <head> tag in your custom layout."
|
35
|
+
end
|
36
|
+
|
37
|
+
rails_command "railties:install:migrations FROM=action_push_web",
|
38
|
+
inline: true
|
39
|
+
|
40
|
+
vapid_key = ActionPushWeb::VapidKeyGenerator.new
|
41
|
+
|
42
|
+
puts "\n"
|
43
|
+
puts <<~MSG
|
44
|
+
Add this entry to the credentials of the target environment:#{' '}
|
45
|
+
|
46
|
+
action_push_web:
|
47
|
+
public_key: #{vapid_key.public_key}
|
48
|
+
private_key: #{vapid_key.private_key}
|
49
|
+
MSG
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
class ApplicationPushWebNotification < ActionPushWeb::Notification
|
2
|
+
# Set a custom job queue_name
|
3
|
+
# queue_as :realtime
|
4
|
+
|
5
|
+
# Controls whether push notifications are enabled (default: !Rails.env.test?)
|
6
|
+
# self.enabled = Rails.env.production?
|
7
|
+
|
8
|
+
# Define a custom callback to modify or abort the notification before it is sent
|
9
|
+
# before_delivery do |notification|
|
10
|
+
# throw :abort if Notification.find(notification.context[:notification_id]).expired?
|
11
|
+
# end
|
12
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
self.addEventListener("push", async (event) => {
|
2
|
+
const data = await event.data.json()
|
3
|
+
event.waitUntil(Promise.all([ showNotification(data), updateBadgeCount(data.options) ]))
|
4
|
+
})
|
5
|
+
|
6
|
+
async function showNotification({ title, options }) {
|
7
|
+
return self.registration.showNotification(title, options)
|
8
|
+
}
|
9
|
+
|
10
|
+
async function updateBadgeCount({ data: { badge } }) {
|
11
|
+
return self.navigator.setAppBadge?.(badge || 0)
|
12
|
+
}
|
13
|
+
|
14
|
+
self.addEventListener("notificationclick", (event) => {
|
15
|
+
event.notification.close()
|
16
|
+
|
17
|
+
const url = new URL(event.notification.data.path, self.location.origin).href
|
18
|
+
event.waitUntil(openURL(url))
|
19
|
+
})
|
20
|
+
|
21
|
+
async function openURL(url) {
|
22
|
+
const clients = await self.clients.matchAll({ type: "window" })
|
23
|
+
const focused = clients.find((client) => client.focused)
|
24
|
+
|
25
|
+
if (focused) {
|
26
|
+
await focused.navigate(url)
|
27
|
+
} else {
|
28
|
+
await self.clients.openWindow(url)
|
29
|
+
}
|
30
|
+
}
|
@@ -0,0 +1,22 @@
|
|
1
|
+
shared:
|
2
|
+
web:
|
3
|
+
public_key: <%%= Rails.application.credentials.action_push_web.public_key %>
|
4
|
+
private_key: <%%= Rails.application.credentials.action_push_web.private_key %>
|
5
|
+
|
6
|
+
# Change the request timeout (default: 30).
|
7
|
+
# request_timeout: 60
|
8
|
+
|
9
|
+
# Change the ttl (default: 2419200).
|
10
|
+
# ttl: 60
|
11
|
+
|
12
|
+
# Change the expiration (default: 43200).
|
13
|
+
# expiration: 60
|
14
|
+
|
15
|
+
# Change the subject (default: mailto:sender@example.com).
|
16
|
+
# subject: mailto:support@my-domain.com
|
17
|
+
|
18
|
+
# Change the urgency (default: normal). You also choose to set this at the notification level.
|
19
|
+
# urgency: high
|
20
|
+
|
21
|
+
# Change the icon path (default: nil). You also choose to set this at the notification level.
|
22
|
+
# icon_path: https://example.com/icon.png
|