From 121603a44b41a23a746a35ce1208d7ae5a197db6 Mon Sep 17 00:00:00 2001 From: Fernando Valverde Date: Wed, 5 May 2021 08:37:20 -0600 Subject: [PATCH] =?UTF-8?q?Replace=20Pusher=20Beams=20with=20Rpush=20PN=20?= =?UTF-8?q?delivery=20=E2=9C=82=EF=B8=8F=20=E2=9C=82=EF=B8=8F=20=20(#13588?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove feature flag * Remove Pusher Beams implementation * Remove Pusher Beams require from initializer * Adds consumer apps to sidebar + consider :admin_restructure * Fix routes + PN target URL * Remove old file that sneaked in * Adds docs * docs tweaks * Apply suggestions from code review Co-authored-by: rhymes Co-authored-by: rhymes --- .env_sample | 2 - Gemfile | 1 - Gemfile.lock | 4 -- app/models/admin_menu.rb | 1 + .../notifications/new_comment/send.rb | 64 +++-------------- app/services/push_notifications/send.rb | 2 - app/views/admin/consumer_apps/index.html.erb | 5 +- config/initializers/pusher.rb | 7 -- config/routes.rb | 6 +- config/routes/admin.rb | 2 +- docs/backend/push-notifications.md | 27 ++++++++ docs/backend/pusher.md | 65 ------------------ docs/backend/readme.md | 1 + spec/requests/devices_spec.rb | 1 - spec/routing/devices_routes_spec.rb | 42 ----------- .../notifications/new_comment/send_spec.rb | 18 ----- spec/services/push_notifications/send_spec.rb | 12 ---- .../cache/pusher-push-notifications-2.0.1.gem | Bin 14336 -> 0 bytes 18 files changed, 46 insertions(+), 214 deletions(-) create mode 100644 docs/backend/push-notifications.md delete mode 100644 spec/routing/devices_routes_spec.rb delete mode 100644 vendor/cache/pusher-push-notifications-2.0.1.gem diff --git a/.env_sample b/.env_sample index 4ed89fac4..8723203f1 100644 --- a/.env_sample +++ b/.env_sample @@ -120,8 +120,6 @@ PUSHER_APP_ID= PUSHER_CLUSTER= PUSHER_KEY= PUSHER_SECRET= -PUSHER_BEAMS_ID= -PUSHER_BEAMS_KEY= # Google recaptcha (Optional) # (https://developers.google.com/recaptcha/intro) diff --git a/Gemfile b/Gemfile index 9197f872c..99f06497b 100644 --- a/Gemfile +++ b/Gemfile @@ -70,7 +70,6 @@ gem "pg_search", "~> 2.3.5" # PgSearch builds Active Record named scopes that ta gem "puma", "~> 5.2.2" # Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server gem "pundit", "~> 2.1" # Object oriented authorization for Rails applications gem "pusher", "~> 2.0" # Ruby library for Pusher Channels HTTP API -gem "pusher-push-notifications", "~> 2.0" # Pusher Push Notifications Ruby server SDK gem "rack-attack", "~> 6.5.0" # Used to throttle requests to prevent brute force attacks gem "rack-cors", "~> 1.1" # Middleware that will make Rack-based apps CORS compatible gem "rack-timeout", "~> 0.6" # Rack middleware which aborts requests that have been running for longer than a specified timeout diff --git a/Gemfile.lock b/Gemfile.lock index a79078ca4..3c22f5200 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -526,9 +526,6 @@ GEM httpclient (~> 2.8) multi_json (~> 1.15) pusher-signature (~> 0.1.8) - pusher-push-notifications (2.0.1) - jwt (~> 2.1, >= 2.1.0) - rest-client (~> 2.0, >= 2.0.2) pusher-signature (0.1.8) raabro (1.4.0) racc (1.5.2) @@ -933,7 +930,6 @@ DEPENDENCIES pundit (~> 2.1) pundit-matchers (~> 1.6) pusher (~> 2.0) - pusher-push-notifications (~> 2.0) rack-attack (~> 6.5.0) rack-cors (~> 1.1) rack-host-redirect (~> 1.3) diff --git a/app/models/admin_menu.rb b/app/models/admin_menu.rb index 85d1d95c4..b5941a719 100644 --- a/app/models/admin_menu.rb +++ b/app/models/admin_menu.rb @@ -54,6 +54,7 @@ class AdminMenu scope :apps, "palette-line", [ item(name: "chat channels"), + item(name: "consumer apps", controller: "consumer_apps"), item(name: "events"), item(name: "listings"), item(name: "welcome"), diff --git a/app/services/notifications/new_comment/send.rb b/app/services/notifications/new_comment/send.rb index 4a96def62..38fac21a0 100644 --- a/app/services/notifications/new_comment/send.rb +++ b/app/services/notifications/new_comment/send.rb @@ -34,27 +34,17 @@ module Notifications ) end + # Send PNs using Rpush - respecting users' notificaton delivery settings targets = User.where(id: user_ids, mobile_comment_notifications: true).ids - - # Pusher Beams uses named Pub/Sub channels instead of raw user_ids - target_channels = targets.map { |id| "user-notifications-#{id}" } - # Sends the push notification to Pusher Beams channels. - # Batch is in place to respect Pusher 100 channel limit. - target_channels.each_slice(100) { |batch| send_push_notifications(batch) } - - if FeatureFlag.enabled?(:mobile_notifications) - # Send PNs using Rpush - url_path = Rails.application.routes.url_helpers.notifications_path(:comments) - PushNotifications::Send.call( - user_ids: targets, - title: "@#{comment.user.username}", - body: "Re: #{comment.parent_or_root_article.title.strip}", - payload: { - url: URL.url(url_path), - type: "new comment" - }, - ) - end + PushNotifications::Send.call( + user_ids: targets, + title: "@#{comment.user.username}", + body: "Re: #{comment.parent_or_root_article.title.strip}", + payload: { + url: URL.url(comment.path), + type: "new comment" + }, + ) return unless comment.commentable.organization_id @@ -97,40 +87,6 @@ module Notifications user_ids_for("only_author_comments") end - - def send_push_notifications(channels) - return unless ApplicationConfig["PUSHER_BEAMS_KEY"] && ApplicationConfig["PUSHER_BEAMS_KEY"].size == 64 - - Pusher::PushNotifications.publish_to_interests( - interests: channels, - payload: push_notification_payload, - ) - end - - def push_notification_payload - title = "@#{comment.user.username}" - subtitle = "re: #{comment.parent_or_root_article.title.strip}" - data_payload = { url: URL.url("/notifications/comments") } - { - apns: { - aps: { - alert: { - title: title, - subtitle: subtitle, - body: CGI.unescapeHTML(comment.title.strip) - } - }, - data: data_payload - }, - fcm: { - notification: { - title: title, - body: subtitle - }, - data: data_payload - } - } - end end end end diff --git a/app/services/push_notifications/send.rb b/app/services/push_notifications/send.rb index 036310b00..d9e4b0a73 100644 --- a/app/services/push_notifications/send.rb +++ b/app/services/push_notifications/send.rb @@ -12,8 +12,6 @@ module PushNotifications end def call - return unless FeatureFlag.enabled?(:mobile_notifications) - Device.where(user_id: @user_ids).find_each do |device| device.create_notification(@title, @body, @payload) end diff --git a/app/views/admin/consumer_apps/index.html.erb b/app/views/admin/consumer_apps/index.html.erb index d9f0ea026..e693f8f03 100644 --- a/app/views/admin/consumer_apps/index.html.erb +++ b/app/views/admin/consumer_apps/index.html.erb @@ -4,7 +4,10 @@

Consumer Apps

- The following are apps that will be able to receive Push Notifications from <%= SiteConfig.community_name %>. + Consumer apps are standalone mobile apps that users can use to browse <%= SiteConfig.community_name %> as an alternative to web browsers. +

+

+ If the app is listed as operational, it means <%= SiteConfig.community_name %> will be able to deliver push notifications directly to users' devices.

diff --git a/config/initializers/pusher.rb b/config/initializers/pusher.rb index 7b0dfbf9a..4577408a6 100644 --- a/config/initializers/pusher.rb +++ b/config/initializers/pusher.rb @@ -1,5 +1,3 @@ -require "pusher/push_notifications" - if ApplicationConfig["PUSHER_APP_ID"].present? Pusher.app_id = ApplicationConfig["PUSHER_APP_ID"] Pusher.key = ApplicationConfig["PUSHER_KEY"] @@ -7,9 +5,4 @@ if ApplicationConfig["PUSHER_APP_ID"].present? Pusher.cluster = ApplicationConfig["PUSHER_CLUSTER"] Pusher.logger = Rails.logger Pusher.encrypted = true - - Pusher::PushNotifications.configure do |config| - config.instance_id = ApplicationConfig["PUSHER_BEAMS_ID"] - config.secret_key = ApplicationConfig["PUSHER_BEAMS_KEY"] - end end diff --git a/config/routes.rb b/config/routes.rb index 5095553f4..ad8f4c6e9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -134,10 +134,8 @@ Rails.application.routes.draw do end resources :comment_mutes, only: %i[update] resources :users, only: %i[index], defaults: { format: :json } do # internal API - constraints(-> { FeatureFlag.enabled?(:mobile_notifications) }) do - collection do - resources :devices, only: %i[create destroy] - end + collection do + resources :devices, only: %i[create destroy] end end resources :users, only: %i[update] diff --git a/config/routes/admin.rb b/config/routes/admin.rb index f54c0defb..7d2549595 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -14,7 +14,6 @@ namespace :admin do resources :organization_memberships, only: %i[update destroy create] resources :permissions, only: %i[index] resources :reactions, only: [:update] - resources :consumer_apps, only: %i[index new create edit update destroy] namespace :settings do resources :authentications, only: [:create] resources :campaigns, only: [:create] @@ -123,6 +122,7 @@ namespace :admin do delete :remove_user end end + resources :consumer_apps, only: %i[index new create edit update destroy] resources :events, only: %i[index create update new edit] resources :listings, only: %i[index edit update destroy] resources :listing_categories, only: %i[index edit update new create diff --git a/docs/backend/push-notifications.md b/docs/backend/push-notifications.md new file mode 100644 index 000000000..969dce15f --- /dev/null +++ b/docs/backend/push-notifications.md @@ -0,0 +1,27 @@ +--- +title: Push Notifications +--- + +# Push Notifications Delivery + +Forem instances rely on [Rpush](https://github.com/rpush/rpush) to deliver push +notifications. This decision was heavily influenced by the desire to +provide as much flexibility as possible to Creators. In order to do this, Forem +instances can register and configure a `ConsumerApp`. + +These consumer apps represent mobile applications that users use to browse and consume content +on a Forem. Authenticated users of a specific Forem instance can register a `Device` +associated to a `ConsumerApp`. With these pieces we are able to deliver +push notifications to users devices. + +The `ConsumerApp` configuration page can be found at +`/admin/apps/consumer_apps`. The official Forem apps are supported by default +and require their secret credential to be provided via ENV variable. + +## Rpush Implementation + +We use Rpush's `rpush-redis` implementation (read +[this thread](https://github.com/forem/forem/pull/12419/files#r564660917) for +the reasons why), hence all `Rpush` models are persisted in Redis. More +details about how this works +[here](https://github.com/rpush/rpush/wiki/Using-Redis). diff --git a/docs/backend/pusher.md b/docs/backend/pusher.md index 5d67b465a..4b066f96e 100644 --- a/docs/backend/pusher.md +++ b/docs/backend/pusher.md @@ -40,68 +40,3 @@ you'll need to provide those keys to the Rails application. ![pusher-3](https://user-images.githubusercontent.com/22895284/51086058-0626d780-1742-11e9-9c2a-26b9b10fa77f.png) 5. Done. - -## Mobile Push Notifications - -These steps are required only when working with the native Apps. In order to -setup Push Notifications to mobile devices you need to create a Pusher Beams -instance and retrieve its credentials - -1. [Sign up](https://dashboard.pusher.com/accounts/sign_up) or - [sign in](https://dashboard.pusher.com/) to your Pusher account. - -2. Once signed in, go in the "BEAMS" section on the left sidebar and fill in the - prompt to create a new Pusher Beams instance. - -![pusher-beams-1](https://user-images.githubusercontent.com/6045239/78602158-2e9d6480-7813-11ea-9da1-4f1310776570.png) - -3. In your new Pusher Beams instance, click the "Credentials" tab. - - ![pusher-beams-2](https://user-images.githubusercontent.com/6045239/78602934-82f51400-7814-11ea-8cc8-6933c43079bc.png) - -4. Change your keys accordingly (name of Pusher key -> name of our application - key): - -```text -Instance ID -> PUSHER_BEAMS_ID -Instance Key -> PUSHER_BEAMS_KEY -``` - -5. Done. You now have your server configured to use Pusher Beams. - -However, in order to send Push Notifications to devices you'll need to do some -platform specific configuration as well. - -### Firebase for Android Push Notifications - -1. Sign up or Sign in to [Firebase](https://firebase.google.com/) - -2. Add a new project. Google Analytics doesn't need to be configured for this - Push Notifications to work. - -![fcm-1](https://user-images.githubusercontent.com/6045239/78604287-cfd9ea00-7816-11ea-9b47-b374b4e60faf.png) - -![fcm-2](https://user-images.githubusercontent.com/6045239/78609833-d2d9d800-7820-11ea-9d78-53dfe823bd3f.png) - -3. Add Android support for your iOS app - -![fcm-3](https://user-images.githubusercontent.com/6045239/78609955-1b919100-7821-11ea-8a2f-9f96cf26185c.png) - -![fcm-4](https://user-images.githubusercontent.com/6045239/78612418-885b5a00-7826-11ea-91b8-125981367068.png) - -4. Download the `google-services.json` file (to be used in the Android app) - -![fcm-5](https://user-images.githubusercontent.com/6045239/78612412-85f90000-7826-11ea-8996-dcb568e7b037.png) - -5. Finish off the next steps (not required at this moment). Now head over to - your Firebase project's settings page and copy the Server key from the "Cloud - Messaging" tab - -![fcm-6](https://user-images.githubusercontent.com/6045239/78615948-11c35a00-7830-11ea-8470-675488a0ecc1.png) - -6. Now back in your Pusher Beams page account add the Server key from the - previous step under the "Settings" tab - -![fcm-7](https://user-images.githubusercontent.com/6045239/78616350-353ad480-7831-11ea-8d63-2f183c2f233b.png) - -7. Done. diff --git a/docs/backend/readme.md b/docs/backend/readme.md index 553924150..d09a2e2d7 100644 --- a/docs/backend/readme.md +++ b/docs/backend/readme.md @@ -19,6 +19,7 @@ items: - notification.md - scheduled-jobs.md - metrics.md + - push-notifications.md - tracking.md - service-objects.md --- diff --git a/spec/requests/devices_spec.rb b/spec/requests/devices_spec.rb index 1fd390a38..69fd04850 100644 --- a/spec/requests/devices_spec.rb +++ b/spec/requests/devices_spec.rb @@ -5,7 +5,6 @@ RSpec.describe "Devices", type: :request do let(:consumer_app) { create(:consumer_app) } before do - allow(FeatureFlag).to receive(:enabled?).with(:mobile_notifications).and_return(true) sign_in user end diff --git a/spec/routing/devices_routes_spec.rb b/spec/routing/devices_routes_spec.rb deleted file mode 100644 index 6e67f1c5c..000000000 --- a/spec/routing/devices_routes_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -require "rails_helper" - -RSpec.describe "User devices routes", type: :routing do - it "renders the user devices routes if the mobile_notifications feature flag is enabled", :aggregate_failures do - allow(FeatureFlag).to receive(:enabled?).twice.with(:mobile_notifications).and_return(true) - - expect(post: devices_path).to route_to( - controller: "devices", - action: "create", - format: :json, - locale: nil, - ) - - expect(delete: "#{devices_path}/1").to route_to( - controller: "devices", - action: "destroy", - id: "1", - format: :json, - locale: nil, - ) - end - - it "does not render the user devices routes if the mobile_notifications feature flag is disabled", - :aggregate_failures do - allow(FeatureFlag).to receive(:enabled?).at_least(:twice).with(:mobile_notifications).and_return(false) - - expect(post: devices_path).not_to route_to( - controller: "devices", - action: "create", - format: :json, - locale: nil, - ) - - expect(delete: "#{devices_path}/1").not_to route_to( - controller: "devices", - action: "destroy", - id: "1", - format: :json, - locale: nil, - ) - end -end diff --git a/spec/services/notifications/new_comment/send_spec.rb b/spec/services/notifications/new_comment/send_spec.rb index 528803160..8a930063c 100644 --- a/spec/services/notifications/new_comment/send_spec.rb +++ b/spec/services/notifications/new_comment/send_spec.rb @@ -97,22 +97,4 @@ RSpec.describe Notifications::NewComment::Send, type: :service do expect(Notification.where(notifiable_type: "Comment", notifiable_id: child_comment.id, organization_id: organization.id)).to be_any end - - it "sends Push Notifications using Pusher Beams when configured" do - allow(ApplicationConfig).to receive(:[]).with("PUSHER_BEAMS_KEY").and_return("x" * 64) - allow(ApplicationConfig).to receive(:[]).with("APP_PROTOCOL").and_return("http://") - allow(ApplicationConfig).to receive(:[]).with("APP_DOMAIN").and_return("localhost:3000") - - allow(Pusher::PushNotifications).to receive(:publish_to_interests) - - comment_sent = child_comment - described_class.call(comment_sent) - - channels = ["user-notifications-#{user2.id}", "user-notifications-#{user.id}"] - payload = described_class.new(comment_sent).__send__(:push_notification_payload) - expect(Pusher::PushNotifications).to have_received(:publish_to_interests) do |block| - expect(block[:interests]).to match_array(channels) - expect(block[:payload]).to eq(payload) - end - end end diff --git a/spec/services/push_notifications/send_spec.rb b/spec/services/push_notifications/send_spec.rb index fc8a31672..3f763e11a 100644 --- a/spec/services/push_notifications/send_spec.rb +++ b/spec/services/push_notifications/send_spec.rb @@ -20,18 +20,8 @@ RSpec.describe PushNotifications::Send, type: :service do } end - context "with feature flag set to false/off" do - before { allow(FeatureFlag).to receive(:enabled?).with(:mobile_notifications).and_return(false) } - - it "does nothing if the feature flag is disabled" do - expect { described_class.call(**params) } - .not_to change { Rpush::Client::Redis::Notification.all.count } - end - end - context "with no devices for user" do before do - allow(FeatureFlag).to receive(:enabled?).with(:mobile_notifications).and_return(true) user.devices.delete end @@ -44,7 +34,6 @@ RSpec.describe PushNotifications::Send, type: :service do context "with devices for one user" do before do - allow(FeatureFlag).to receive(:enabled?).with(:mobile_notifications).and_return(true) allow(ApplicationConfig).to receive(:[]).with("RPUSH_IOS_PEM").and_return("dGVzdGluZw==") allow(ApplicationConfig).to receive(:[]).with("COMMUNITY_NAME").and_return("Forem") create(:device, user: user) @@ -67,7 +56,6 @@ RSpec.describe PushNotifications::Send, type: :service do context "with devices for multiple users" do before do - allow(FeatureFlag).to receive(:enabled?).with(:mobile_notifications).and_return(true) allow(ApplicationConfig).to receive(:[]).with("RPUSH_IOS_PEM").and_return("dGVzdGluZw==") allow(ApplicationConfig).to receive(:[]).with("COMMUNITY_NAME").and_return("Forem") create(:device, user: user) diff --git a/vendor/cache/pusher-push-notifications-2.0.1.gem b/vendor/cache/pusher-push-notifications-2.0.1.gem deleted file mode 100644 index 10b8930079e7fed28767181b818fe3eef930c17a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14336 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