Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebPush delivery #297

Closed
wants to merge 13 commits into from
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
### Unreleased

* WebPush delivery method
* Support html safe translations for Rails 7+

### 1.6.3
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Currently, we support these notification delivery methods out of the box:
* Vonage / Nexmo (SMS)
* iOS Apple Push Notifications
* Firebase Cloud Messaging (Android and more)
* WebPush

And you can easily add new notification types for any other delivery methods.

Expand Down Expand Up @@ -252,6 +253,7 @@ For example, emails will require a subject, body, and email address while an SMS
* [Twilio](docs/delivery_methods/twilio.md)
* [Vonage](docs/delivery_methods/vonage.md)
* [Firebase Cloud Messaging](docs/delivery_methods/fcm.md)
* [WebPush](docs/delivery_methods/web_push.md)

### Fallback Notifications

Expand Down
25 changes: 25 additions & 0 deletions docs/delivery_methods/web_push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# WebPush delivery Method

Sends a browser based notification via WebPush.

`deliver_by :web_push, data_method: :web_push_data`

## Options

- `data_method` - _Optional_

The method called on the notification for the data hash. Defaults to `:web_push_data`

## Setup

`rails generate noticed:web_push` will copy over sample javascript files and import them into `application.js`. A sample `service_worker.js` will be copied into the `public/` folder. This generator will also generate VAPID credentials which you will want to copy (`rails credentials:edit`).

### Service Worker Note

Service workers can only be used in the path they are located at and subpaths ie serving `assets/service_worker.js` will only work within the `assets` folder. Not ideal!

There is a header you can set to avoid this, but it isn't supported everywhere. To keep it simple, I've just included it into the public folder. It is likely you want to handle this differently, but this is a simple starting place.

## Handling Failures

When a subscription fails (expired or unauthorized), it is deleted.
8 changes: 8 additions & 0 deletions lib/generators/noticed/templates/WEB_PUSH_README
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Next Steps:

1. Add a trigger to prompt for push notifications: `<button id="enable_notifications" style="display: none">Enable Notifications</button>`
2. Customize `app/assets/webmanifests/app.webmanifest` for your your application
3. Install certificate on mobile device
1. Download and add localhost.crt onto your mobile device
2. Install and trust [iOS](https://support.apple.com/en-us/HT204477) [Android](https://proxyman.io/posts/2020-09-29-Install-And-Trust-Self-Signed-Certificate-On-Android-11)
3. Skipping this will cause the notification prompt to always be denied
13 changes: 13 additions & 0 deletions lib/generators/noticed/templates/app.webmanifest.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "FULL NAME",
"short_name": "HOME SCREEN LABEL",
"start_url": "/sign_in",
"display": "standalone",
"icons": [
{
"src": "LOGO.PNG",
"type": "image/png",
"sizes": "512x512"
}
]
}
11 changes: 11 additions & 0 deletions lib/generators/noticed/templates/notification.rb.tt
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,23 @@ class <%= class_name %> < Noticed::Base
# deliver_by :database
# deliver_by :email, mailer: "UserMailer"
# deliver_by :slack
# deliver_by :web_push, data_method: :web_push_data
# deliver_by :custom, class: "MyDeliveryMethod"

# Add required params
#
# param :post

# Add WebPush data
#
# def web_push_data
# {
# title: "<%= class_name.titleize %>",
# body: "",
# url: url,
# }
# end

# Define helper methods to make rendering easier.
#
# def message
Expand Down
31 changes: 31 additions & 0 deletions lib/generators/noticed/templates/service_worker.js.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
self.addEventListener('push', event => {
const data = event.data?.json() || {}
console.log("Received push", data)

event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
data: data,
})
)
})

self.addEventListener("notificationclick", event => {
event.notification.close()
console.log(`opening event.notification.data.url`)

event.waitUntil(
self.clients.openWindow(event.notification.data.url)
)
})

self.addEventListener('pushsubscriptionchange', async (event) => {
const subscription = await self.registration.pushManager.getSubscription()
await fetch("/web_push_subscriptions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription),
})
})
65 changes: 65 additions & 0 deletions lib/generators/noticed/templates/web_push.js.tt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
document.addEventListener("turbo:load", () => {
switch (Notification.permission) {
case "granted":
saveSubscription()
return
case "denied":
// do nothing?
return
default:
promptForNotifications()
}
})

function promptForNotifications() {
const notificationsButton = document.getElementById("enable_notifications")
if (!notificationsButton) return

notificationsButton.style.display = null
notificationsButton.addEventListener("click", event => {
event.preventDefault()
Notification.requestPermission()
.then((permission) => {
if (permission === "granted") {
setupSubscription()
} else {
alert("Notifications declined")
}
})
.catch(error => console.log("Notifications error", error))
.finally(() => notificationsButton.style.display = "none")
})
}

async function setupSubscription() {
if (Notification.permission !== "granted") return
if (!navigator.serviceWorker) return

let vapid = new Uint8Array(JSON.parse(document.querySelector("meta[name=web_push_public]")?.content))

await navigator.serviceWorker.register("/service_worker.js")
const registration = await navigator.serviceWorker.ready
await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapid
})

await saveSubscription()
}

async function saveSubscription() {
if (Notification.permission !== "granted") return
if (!navigator.serviceWorker) return

const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return

await fetch("/web_push_subscriptions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(subscription)
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

class WebPushSubscriptionsController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :require_login

def create
WebPushSubscription.find_or_create_by!(user: current_user, endpoint: params[:endpoint], auth_key: params[:keys][:auth], p256dh_key: params[:keys][:p256dh])

head :ok
end
end
103 changes: 103 additions & 0 deletions lib/generators/noticed/web_push_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# frozen_string_literal: true

require "rails/generators/named_base"

module Noticed
module Generators
class WebPushGenerator < Rails::Generators::NamedBase
include Rails::Generators::ResourceHelpers

source_root File.expand_path("../templates", __FILE__)

desc "Generates a Notification model for storing notifications."

argument :name, type: :string, default: "WebPushSubscription"
argument :attributes, type: :array, default: [], banner: "field:type field:type"
class_option :"encrypt-keys", type: :boolean, default: true, description: "use Active Record Encryption on key fields"

def add_manifest
inject_into_file File.join("app", "assets", "config", "manifest.js"), "\n//= link_tree ../webmanifests\n"
inject_into_file File.join("app", "views", "layouts", "application.html.erb"), before: "</head>" do
<<~LINK.chomp
\t<%= tag.link rel: "manifest", href: asset_path("app.webmanifest"), "data-turbo-track": "reload" %>\n\t
LINK
end
template "app.webmanifest.tt", "app/assets/webmanifests/app.webmanifest"
end

def add_web_push
gem "web-push", "~> 3.0"
run "bundle install" # needed in generate_vapid_keys
end

def generate_web_push_subscription_model
generate :model, name, "user:references endpoint auth_key p256dh_key", *attributes

inject_into_file File.join("app", "models", "web_push_subscription.rb"), after: "belongs_to :user" do
<<~PUBLISH


def publish(data)
WebPush.payload_send(
message: data.to_json,
endpoint: endpoint,
p256dh: p256dh_key,
auth: auth_key,
vapid: {
private_key: Rails.application.credentials.dig(:web_push, :private_key),
public_key: Rails.application.credentials.dig(:web_push, :public_key)
}
)
end
PUBLISH
end

if options[:"encrypt-keys"]
inject_into_file File.join("app", "models", "web_push_subscription.rb"), after: "belongs_to :user" do
<<~ENCRYPT_KEYS

encrypts :endpoint, deterministic: true
encrypts :auth_key, deterministic: true
encrypts :p256dh_key, deterministic: true
ENCRYPT_KEYS
end
end
end

def add_to_user
inject_into_class File.join("app", "models", "user.rb"), "User", " has_many :web_push_subscriptions, dependent: :destroy\n"
end

def add_controller
template "web_push_subscriptions_controller.rb", "app/controllers/web_push_subscriptions_controller.rb"
route "resources :web_push_subscriptions, only: :create"
end

def add_layout_header
inject_into_file File.join("app", "views", "layouts", "application.html.erb"), before: "</head>" do
"\n <meta name=\"web_push_public\" content=\"<%= Base64.urlsafe_decode64(Rails.application.credentials.dig(:web_push, :public_key)).bytes %>\" />\n "
end
end

def setup_javscript
template "web_push.js", "app/javascript/src/web_push.js"
template "service_worker.js", "public/service_worker.js"

append_to_file File.join("app", "javascript", "application.js") do
"\nimport \"./src/web_push\"\n"
end
end

def done
readme "WEB_PUSH_README" if behavior == :invoke
generate "noticed:web_push_vapid_keys" # shelling out so that it's loaded
end

private

def model_path
@model_path ||= File.join("app", "models", "#{file_path}.rb")
end
end
end
end
25 changes: 25 additions & 0 deletions lib/generators/noticed/web_push_vapid_keys_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

require "rails/generators/named_base"

module Noticed
module Generators
class WebPushVapidKeysGenerator < Rails::Generators::Base
def generate_vapid_keys
puts <<~KEYS
Add the following to your credentials (rails credentials:edit):"

web_push:
public_key: "#{vapid_key.public_key}"
private_key: "#{vapid_key.private_key}"
KEYS
end

private

def vapid_key
@vapid_key ||= WebPush.generate_key
end
end
end
end
1 change: 1 addition & 0 deletions lib/noticed.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ module DeliveryMethods
autoload :Test, "noticed/delivery_methods/test"
autoload :Twilio, "noticed/delivery_methods/twilio"
autoload :Vonage, "noticed/delivery_methods/vonage"
autoload :WebPush, "noticed/delivery_methods/web_push"
end

mattr_accessor :parent_class
Expand Down
25 changes: 25 additions & 0 deletions lib/noticed/delivery_methods/web_push.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
module Noticed
module DeliveryMethods
class WebPush < Noticed::DeliveryMethods::Base
option :data_method

def deliver
recipient.web_push_subscriptions.each do |subscription|
subscription.publish(data)
rescue ::WebPush::ExpiredSubscription
Rails.logger.info "Removing expired WebPush subscription"
subscription.destroy
rescue ::WebPush::Unauthorized
Rails.logger.info "Removing unauthorized WebPush subscription"
subscription.destroy
end
end

private

def data
notification.send(options.fetch(:data_method, :web_push_data))
end
end
end
end