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

Web Push Notifications #3243

Merged
merged 85 commits into from
Jul 13, 2017
Merged

Web Push Notifications #3243

merged 85 commits into from
Jul 13, 2017

Conversation

sorin-davidoi
Copy link
Contributor

@sorin-davidoi sorin-davidoi commented May 23, 2017

This PR introduces the ability to send Web Push Notifications. The basic idea is to save a push subscription to the backend, which can then be used to wake up the Service Worker and display a notification when something happened, even if Mastodon is not opened. Together with #3052 this can make Mastodon pretty much indistinguishable from a native application (at least on Android).

Features to implement:

  • Register Service Worker
  • Generate VAPID keys in the backend
  • Pass the public VAPID key to the frontend
  • Register push subscription to the backend
  • Delete invalid push subscriptions
  • Send push notifications
    • Mentions
    • New followers
      • Follow
      • Follow request
    • Favorites
    • Boosts
  • Localize messages
  • Handle notification interactions
  • Integrate in the UI - one should be able to control the settings for each individual subscription (e.g. for what type of events it gets triggered). This is not a per account preference, as I assume people want different settings for each device (e.g. only push for mentions on mobile, but push all notifications on desktop).
  • Handle backend changing the applicationServerKey
  • Handle RTL
  • Display attachment
  • Unregister push subscription when logging out - can not figure out how to associate a subscription with a session, such that when the user logs out the subscription is destroyed
  • Set VAPID keys via configuration
  • Move logic out of SettingsController - I could not figure out how to create a new endpoint for this, so I've hijacked this controller for adding and updating subscriptions.
  • Add actions to the notification
    • Favourite / boost status

Browser support: Chrome and Firefox. Cannot be polyfilled.

Closes #1122, #1039.

@sorin-davidoi sorin-davidoi changed the title Web Push Notifications [WIP] Web Push Notifications May 25, 2017
@nolanlawson
Copy link
Contributor

I was just about to ask how you managed to get Chrome to do push notifications without a GCM/FCM ID, but it looks like they've removed that requirement, which is going to make our lives a lot easier. Hooray! 🎉

@sorin-davidoi
Copy link
Contributor Author

I think this is ready for a first review. Updated the description with stuff that still needs to be done. Tested on Firefox and Chrome on Linux (KDE Plasma) and on Android.

photo_2017-05-27_00-31-24
image
photo_2017-05-27_00-30-57
photo_2017-05-27_00-31-16

@sorin-davidoi sorin-davidoi changed the title Web Push Notifications [REVIEW ME] Web Push Notifications May 26, 2017
Copy link
Contributor

@akihikodaki akihikodaki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some questions and found something suspicious, so please review them. I'm looking forward this feature.

current_account.web_push_subscriptions << web_subscription
current_account.save!

render json: web_subscription.as_payload, status: 200
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it need explicit status?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't think so.


try {
getRegistration()
.then(getPushSubscription())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose you mean getPushSubscription instead of getPushSubscription()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.

db/schema.rb Outdated
@@ -358,6 +358,16 @@
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree
end

create_table "web_push_subscriptions", force: :cascade do |t|
t.integer "account_id"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not index?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by the following command, so I cannot answer that 😄 How should it look like?

rails generate model WebPushSubscription account_id:integer endpoint:string:unique key_p256dh:string key_auth:string

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please look at other tables. Here is an example:

t.index ["account_id"], name: "index_reports_on_account_id", using: :btree

@@ -0,0 +1,20 @@
# frozen_string_literal: true
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec?

@@ -1,20 +1,49 @@
# frozen_string_literal: true

class Api::Web::SettingsController < ApiController
include ActionView::Helpers::TranslationHelper

respond_to :json

before_action :require_user!

def update
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec this, too.

private

def web_notification
@_web_push_notification ||= WebPushNotifications.where(user: current_user).first_or_initialize(user: current_user)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where WebPushNotifications comes from?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ruby magic 😄 ? Guess it is the database model?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ruby can sometimes do magic, but it should be in schema.rb anyway if it is a database model.

@@ -1,20 +1,49 @@
# frozen_string_literal: true

class Api::Web::SettingsController < ApiController
include ActionView::Helpers::TranslationHelper
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is it for?

@sorin-davidoi
Copy link
Contributor Author

sorin-davidoi commented May 28, 2017

Will try to see tomorrow how Rails tests work and maybe add some specs if I can figure things out.

Copy link
Contributor

@akihikodaki akihikodaki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found an error with CI.

@@ -1,20 +1,47 @@
# frozen_string_literal: true

class Api::Web::SettingsController < ApiController
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes the spec fail.

@sorin-davidoi
Copy link
Contributor Author

If this spec gets traction we might even be able to reply from the notification.

@sorin-davidoi sorin-davidoi changed the title [REVIEW ME] Web Push Notifications Web Push Notifications May 30, 2017
@Gargron
Copy link
Member

Gargron commented May 30, 2017

33 files change - I'm already scared :D Is this ready for review?

@sorin-davidoi
Copy link
Contributor Author

I still haven't figured out how to attach the subscription to the session, so that when the user logs out the subscription gets deleted. Also need to pass the VAPID keys via config files / environment (right now they are generated when the server starts).

@sorin-davidoi
Copy link
Contributor Author

sorin-davidoi commented May 30, 2017

@akihikodaki I explored a bit our options for clearing the subscriptions. We have the following in doorkeeper.rb:

  # Reuse access token for the same resource owner within an application (disabled by default)
  # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383
  reuse_access_token

What I understand from this is that we do not keep track of each user session (we use one token for all sessions), so there is no way to associate a push subscription with the session that created it. What is the reason for this? What would be the implications of issuing a new token for each login and deleting the token when the user logs out (attaching the push subscription to this token and deleting them together).

Another approach would be to manually delete the push subscription from the front-end when the user logs out, but that would create a huge security / privacy issue.

Don't really know how to proceed with this.

@Gargron
Copy link
Member

Gargron commented May 30, 2017

What is the reason for this?

The web UI uses the API like any other app would use the API, so it needs an access token. Reusing of the access token was implemented in response to #1681

@akihikodaki
Copy link
Contributor

Another approach would be to manually delete the push subscription from the front-end when the user logs out, but that would create a huge security / privacy issue.

Could you tell me about your security concern?

@sorin-davidoi
Copy link
Contributor Author

sorin-davidoi commented May 31, 2017

  1. Call /auth/sign_out -> success
  2. Call endpoint deleting the current subscription -> fail

Result: the user is logged out, but the browser keeps getting push notifications for that account. Also someone can mess with the code and skip the request to delete the subscription.

Signing out and deleting the subscription should be an atomic operation, handled by the back-end.

Ideally, there would be another model (Device or Session or something similar), with an account having multiple devices (places where the user is signed in). A device has a subscription. When the user logs out, the corresponding device gets deleted together with the attached subscription. This would also enable the user to see where they are logged in and log out remotely (kind of like Dropbox does it, see screenshot below).

image

@Gargron
Copy link
Member

Gargron commented May 31, 2017

@akihikodaki
Copy link
Contributor

@sorin-davidoi

the user is logged out, but the browser keeps getting push notifications for that account. Also someone can mess with the code and skip the request to delete the subscription.

Even now we have access_token, which remains valid after signing out.

before do
sign_in(user)

ActiveSession.deactivate warden.raw_session['auth_id']
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests fail because these two lines need to be executed after each call to sign_in (for all tests). Tried putting the following into spec_helper.rb but didn't help. Any pointers?

Devise.setup do |config|
  Warden::Manager.after_set_user except: :fetch do |user, auth, opts|
    ActiveSession.deactivate warden.raw_session['auth_id']
    warden.raw_session['auth_id'] = user.activate_session
  end
end

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is because Devise::Test::ControllerHelpers does not support configuration by Devise.setup.
https://github.com/plataformatec/devise/blob/9fe7040db90be1acb499d2534b077266dbf3b209/lib/devise/test/controller_helpers.rb#L63

Devise::Test::ControllerHelpers is included in rails_helper. You may hook sign_in there. (it's is a bit hacky, but it cannot be helped, I think.)
https://github.com/tootsuite/mastodon/blob/fd66f7cdc0931a67a2e24d887fb79f44fc42e2bc/spec/rails_helper.rb#L26

@Gargron
Copy link
Member

Gargron commented Jun 1, 2017

I suggest extracting Devise modifications into a separate PR to make reviewing easier

before do
sign_in(user)

ActiveSession.deactivate warden.raw_session['auth_id']
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is because Devise::Test::ControllerHelpers does not support configuration by Devise.setup.
https://github.com/plataformatec/devise/blob/9fe7040db90be1acb499d2534b077266dbf3b209/lib/devise/test/controller_helpers.rb#L63

Devise::Test::ControllerHelpers is included in rails_helper. You may hook sign_in there. (it's is a bit hacky, but it cannot be helped, I think.)
https://github.com/tootsuite/mastodon/blob/fd66f7cdc0931a67a2e24d887fb79f44fc42e2bc/spec/rails_helper.rb#L26

@sorin-davidoi
Copy link
Contributor Author

Will continue work once #3616 is merged.

config/routes.rb Outdated
@@ -206,6 +206,12 @@

namespace :web do
resource :settings, only: [:update]
resource :push_subscriptions, only: [:create] do
# Why doesn't PUT /api/web/push_subscriptions/:id work?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Gargron Do you have some insights into this? If I call axios.put('/api/web/push_subscriptions/2' I get this in the logs:

Started PUT "/api/web/push_subscriptions/2" for 127.0.0.1 at 2017-06-24 16:34:57 +0200
Processing by ApplicationController#raise_not_found as HTML

axios.put('/api/web/push_subscriptions') works fine though.

Cannot seem to figure it out.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"resource" means there's no index, show with no ID (e.g. there is always one item)

"resources" is what gives you both index and show with ID

@sorin-davidoi
Copy link
Contributor Author

Added actions when you are mentioned:

@sorin-davidoi
Copy link
Contributor Author

sorin-davidoi commented Jun 25, 2017

I think this is ready for review. The service worker is configured not to cache anything. The offline-plugin configuration is mostly copied from #3052 and is only active during in the production build. All the lint errors are logging statements that I've left in to aid testing / debugging.

For testing, after you see the "Subscription registered" notification, go to the settings notifications and:

  • disable desktop notifications
  • enable push notification

This will make it less confusing which type of notifications you are receiving. Would be nice if you could take a look at Chrome and Firefox on Android.

Copy link
Contributor

@beatrix-bitrot beatrix-bitrot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I, Beatrix Bitrot, being of sound mind and of my own volition APPROVE THESE CHANGES

@nightpool
Copy link
Member

nightpool commented Jul 12, 2017

@sorin-davidoi it's hard to see how user-side logging is really going to help in this case (it's difficult enough to get users to tell us when something goes wrong, much less get them to have the dev console open at the right time) but if you think it's important to keep it, I defer to your judgement

@beatrix-bitrot
Copy link
Contributor

ah, about the console log statements? i'm indifferent

for most users i don't think they be any use but on bleeding edge instances they may come in handy

maybe leave them in master for a bit and remove before the next tagged release? idk

another option is to leave them in here, then make another PR that removes them so that if anyone needs to enable them for testing all they need to do is revert the commit that removes them

idk

Copy link
Contributor

@akihikodaki akihikodaki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added many comments, but they are trivial and the logic itself looks fine. I hope they will be addressed soon and the change will be merged.

end

web_subscription = ::Web::PushSubscription.new(
endpoint: params[:data][:endpoint],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not params.require?

active_session.save!
end

web_subscription = ::Web::PushSubscription.new(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use create!.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, does not seem to work, as this is called from an endpoint.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the problem with calling create from an endpoint?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, think I misunderstood the comment. I took it as to change the name of the method from create to create!. Will update it after I fix the conflicts 🙄


web_subscription.save!

active_session.web_push_subscription = web_subscription
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use update!.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as create!.

if (action.todo === 'expand') {
return expandNotification(event.notification);
} else if (action.todo === 'request') {
return makeRequest(event.notification, action)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following statement would be enough. A promise as resolved value will be evaluated and expanded.

resolve(makeRequest(event.notification, action)
  .then(() => removeActionFromNotification(event.notification, action)));

.catch(reject);
}

return null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returned values does not really matter for promises.

}
});
} else {
// eslint-disable-next-line no-console
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are aware of, but here I note you should be consistent, whether you use eslint-disable-next-line or not. If you use, apply for all other console. If you do not, remove this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will remove the other logs.

@@ -0,0 +1,12 @@
class CreateWebPushSubscriptions < ActiveRecord::Migration[5.1]
def change
create_table :web_push_subscriptions do |t|
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the following columns cannot not be null, can they?

rescue Webpush::PayloadTooLarge, Webpush::TooManyRequests => e
Rails.logger.error(e)
rescue Webpush::Error => e
# Failing to send push notifications should not result in Internal Server Error
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failing to send push notifications should not result in Internal Server Error

Yes, but that is controllers' business. If it is just to eliminate Internal Server Error, rescue in controllers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really sure where to move the rescues then (this is called from a ton of places, all of them services - should each of them duplicate the rescues?).

Copy link
Member

@nightpool nightpool Jul 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sending notifications synchronously? that seems really bad.

If it's from sidekiq, then we can just let the error propagate so we retry the job.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest I don't really know - don't have a strong grasp on the whole sidekiq thing.

describe Api::Web::PushSubscriptionsController do
render_views

let!(:user) { Fabricate(:user) }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think let is fine for these cases.

@@ -0,0 +1,6 @@
Fabricator(:web_push_subscription) do
account_id 1
endpoint "MyString"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Faker gem.

Copy link
Contributor

@akihikodaki akihikodaki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All problems I have requested are addressed, but I found a few other suspicious things.

cacheName: 'mastodon',
output: '../sw.js',
publicPath: '/sw.js',
minify: false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't you minify?

@@ -0,0 +1,6 @@
Fabricator(:web_push_subscription) do
account_id 1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Web::PushSubscription does not have account_id column.

@Gargron Gargron dismissed akihikodaki’s stale review July 13, 2017 20:15

Seems to be addressed. There are some code style issues (from a human PoV, not code climate) that I intend to refactor in the future, but I think overall this is fine

@Gargron Gargron merged commit 0c7c188 into mastodon:master Jul 13, 2017
Gargron pushed a commit that referenced this pull request Jul 14, 2017
@sorin-davidoi
Copy link
Contributor Author

For things that were left unaddressed here see #4200.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants