From 0c7c188c459117770ac1f74f70a9e65ed2be606f Mon Sep 17 00:00:00 2001 From: Sorin Davidoi <sorin.davidoi@gmail.com> Date: Thu, 13 Jul 2017 22:15:32 +0200 Subject: [PATCH] Web Push Notifications (#3243) * feat: Register push subscription * feat: Notify when mentioned * feat: Boost, favourite, reply, follow, follow request * feat: Notification interaction * feat: Handle change of public key * feat: Unsubscribe if things go wrong * feat: Do not send normal notifications if push is enabled * feat: Focus client if open * refactor: Move push logic to WebPushSubscription * feat: Better title and body * feat: Localize messages * chore: Fix lint errors * feat: Settings * refactor: Lazy load * fix: Check if push settings exist * feat: Device-based preferences * refactor: Simplify logic * refactor: Pull request feedback * refactor: Pull request feedback * refactor: Create /api/web/push_subscriptions endpoint * feat: Spec PushSubscriptionController * refactor: WebPushSubscription => Web::PushSubscription * feat: Spec Web::PushSubscription * feat: Display first media attachment * feat: Support direction * fix: Stuff broken while rebasing * refactor: Integration with session activations * refactor: Cleanup * refactor: Simplify implementation * feat: Set VAPID keys via environment * chore: Comments * fix: Crash when no alerts * fix: Set VAPID keys in testing environment * fix: Follow link * feat: Notification actions * fix: Delete previous subscription * chore: Temporary logs * refactor: Move migration to a later date * fix: Fetch the correct session activation and misc bugs * refactor: Move migration to a later date * fix: Remove follow request (no notifications) * feat: Send administrator contact to push service * feat: Set time-to-live * fix: Do not show sensitive images * fix: Reducer crash in error handling * feat: Add badge * chore: Fix lint error * fix: Checkbox label overlap * fix: Check for payload support * fix: Rename action "type" (crash in latest Chrome) * feat: Action to expand notification * fix: Lint errors * fix: Unescape notification body * fix: Do not allow boosting if the status is hidden * feat: Add VAPID keys to the production sample environment * fix: Strip HTML tags from status * refactor: Better error messages * refactor: Handle browser not implementing the VAPID protocol (Samsung Internet) * fix: Error when target_status is nil * fix: Handle lack of image * fix: Delete reference to invalid subscriptions * feat: Better error handling * fix: Unescape HTML characters after tags are striped * refactor: Simpify code * fix: Modify to work with #4091 * Sort strings alphabetically * i18n: Updated Polish translation it annoys me that it's not fully localized :P * refactor: Use current_session in PushSubscriptionController * fix: Rebase mistake * fix: Set cacheName to mastodon * refactor: Pull request feedback * refactor: Remove logging statements * chore(yarn): Fix conflicts with master * chore(yarn): Copy latest from master * chore(yarn): Readd offline-plugin * refactor: Use save! and update! * refactor: Send notifications async * fix: Allow retry when push fails * fix: Save track for failed pushes * fix: Minify sw.js * fix: Remove account_id from fabricator --- .env.production.sample | 11 + .gitignore | 1 + Gemfile | 1 + Gemfile.lock | 6 + .../api/web/push_subscriptions_controller.rb | 39 ++++ app/controllers/home_controller.rb | 1 + .../mastodon/actions/push_notifications.js | 52 +++++ .../components/column_settings.js | 23 ++- .../components/setting_toggle.js | 4 +- .../containers/column_settings_container.js | 9 +- app/javascript/mastodon/main.js | 8 + app/javascript/mastodon/reducers/index.js | 2 + .../mastodon/reducers/push_notifications.js | 51 +++++ .../mastodon/service_worker/entry.js | 1 + .../service_worker/web_push_notifications.js | 86 ++++++++ .../mastodon/web_push_subscription.js | 109 ++++++++++ app/javascript/styles/components.scss | 8 +- app/javascript/styles/rtl.scss | 4 + app/models/session_activation.rb | 12 ++ app/models/user.rb | 4 + app/models/web/push_subscription.rb | 190 ++++++++++++++++++ app/presenters/initial_state_presenter.rb | 2 +- app/serializers/initial_state_serializer.rb | 2 +- app/services/notify_service.rb | 5 + app/views/home/index.html.haml | 1 + app/workers/web_push_notification_worker.rb | 27 +++ config/environments/development.rb | 5 + config/environments/test.rb | 5 + config/initializers/vapid.rb | 17 ++ config/locales/en.yml | 15 ++ config/locales/pl.yml | 15 ++ config/routes.rb | 5 + config/webpack/production.js | 14 ++ ...713175513_create_web_push_subscriptions.rb | 12 ++ ...ush_subscription_to_session_activations.rb | 5 + db/schema.rb | 12 +- package.json | 1 + public/badge.png | Bin 0 -> 31156 bytes .../web/push_subscriptions_controller_spec.rb | 81 ++++++++ .../web_push_subscription_fabricator.rb | 5 + spec/models/web/push_subscription_spec.rb | 28 +++ yarn.lock | 25 ++- 42 files changed, 890 insertions(+), 14 deletions(-) create mode 100644 app/controllers/api/web/push_subscriptions_controller.rb create mode 100644 app/javascript/mastodon/actions/push_notifications.js create mode 100644 app/javascript/mastodon/reducers/push_notifications.js create mode 100644 app/javascript/mastodon/service_worker/entry.js create mode 100644 app/javascript/mastodon/service_worker/web_push_notifications.js create mode 100644 app/javascript/mastodon/web_push_subscription.js create mode 100644 app/models/web/push_subscription.rb create mode 100644 app/workers/web_push_notification_worker.rb create mode 100644 config/initializers/vapid.rb create mode 100644 db/migrate/20170713175513_create_web_push_subscriptions.rb create mode 100644 db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb create mode 100644 public/badge.png create mode 100644 spec/controllers/api/web/push_subscriptions_controller_spec.rb create mode 100644 spec/fabricators/web_push_subscription_fabricator.rb create mode 100644 spec/models/web/push_subscription_spec.rb diff --git a/.env.production.sample b/.env.production.sample index 394cdedfe..faefa2482 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -31,6 +31,17 @@ PAPERCLIP_SECRET= SECRET_KEY_BASE= OTP_SECRET= +# VAPID keys (used for push notifications +# You can generate the keys using the following command (first is the private key, second is the public one) +# You should only generate this once per instance. If you later decide to change it, all push subscription will +# be invalidated, requiring the users to access the website again to resubscribe. +# +# ruby -e "require 'webpush'; vapid_key = Webpush.generate_key; puts vapid_key.private_key; puts vapid_key.public_key;" +# +# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html +VAPID_PRIVATE_KEY= +VAPID_PUBLIC_KEY= + # Registrations # Single user mode will disable registrations and redirect frontpage to the first profile # SINGLE_USER_MODE=true diff --git a/.gitignore b/.gitignore index 38ebc934f..868a84368 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ public/system public/assets public/packs public/packs-test +public/sw.js .env .env.production node_modules/ diff --git a/Gemfile b/Gemfile index b52685cba..988b4d6b9 100644 --- a/Gemfile +++ b/Gemfile @@ -64,6 +64,7 @@ gem 'statsd-instrument', '~> 2.1' gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2017' gem 'webpacker', '~> 2.0' +gem 'webpush' group :development, :test do gem 'fabrication', '~> 2.16' diff --git a/Gemfile.lock b/Gemfile.lock index de0d6a107..5599e1db1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -181,6 +181,7 @@ GEM hashdiff (0.3.4) highline (1.7.8) hiredis (0.6.1) + hkdf (0.3.0) htmlentities (4.3.4) http (2.2.2) addressable (~> 2.3) @@ -209,6 +210,7 @@ GEM jmespath (1.3.1) json (2.1.0) jsonapi-renderer (0.1.2) + jwt (1.5.6) kaminari (1.0.1) activesupport (>= 4.1.0) kaminari-actionview (= 1.0.1) @@ -475,6 +477,9 @@ GEM activesupport (>= 4.2) multi_json (~> 1.2) railties (>= 4.2) + webpush (0.3.2) + hkdf (~> 0.2) + jwt websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.2) @@ -573,6 +578,7 @@ DEPENDENCIES uglifier (~> 3.2) webmock (~> 3.0) webpacker (~> 2.0) + webpush RUBY VERSION ruby 2.4.1p111 diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb new file mode 100644 index 000000000..8425db7b4 --- /dev/null +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Api::Web::PushSubscriptionsController < Api::BaseController + respond_to :json + + before_action :require_user! + + def create + params.require(:data).require(:endpoint) + params.require(:data).require(:keys).require([:auth, :p256dh]) + + active_session = current_session + + unless active_session.web_push_subscription.nil? + active_session.web_push_subscription.destroy! + active_session.update!(web_push_subscription: nil) + end + + web_subscription = ::Web::PushSubscription.create!( + endpoint: params[:data][:endpoint], + key_p256dh: params[:data][:keys][:p256dh], + key_auth: params[:data][:keys][:auth] + ) + + active_session.update!(web_push_subscription: web_subscription) + + render json: web_subscription.as_payload + end + + def update + params.require([:id, :data]) + + web_subscription = ::Web::PushSubscription.find(params[:id]) + + web_subscription.update!(data: params[:data]) + + render json: web_subscription.as_payload + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 8a8b9ec76..1585bc810 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -22,6 +22,7 @@ class HomeController < ApplicationController def initial_state_params { settings: Web::Setting.find_by(user: current_user)&.data || {}, + push_subscription: current_account.user.web_push_subscription(current_session), current_account: current_account, token: current_session.token, admin: Account.find_local(Setting.site_contact_username), diff --git a/app/javascript/mastodon/actions/push_notifications.js b/app/javascript/mastodon/actions/push_notifications.js new file mode 100644 index 000000000..55661d2b0 --- /dev/null +++ b/app/javascript/mastodon/actions/push_notifications.js @@ -0,0 +1,52 @@ +import axios from 'axios'; + +export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; +export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; +export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE'; + +export function setBrowserSupport (value) { + return { + type: SET_BROWSER_SUPPORT, + value, + }; +} + +export function setSubscription (subscription) { + return { + type: SET_SUBSCRIPTION, + subscription, + }; +} + +export function clearSubscription () { + return { + type: CLEAR_SUBSCRIPTION, + }; +} + +export function changeAlerts(key, value) { + return dispatch => { + dispatch({ + type: ALERTS_CHANGE, + key, + value, + }); + + dispatch(saveSettings()); + }; +} + +export function saveSettings() { + return (_, getState) => { + const state = getState().get('push_notifications'); + const subscription = state.get('subscription'); + const alerts = state.get('alerts'); + + axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, { + data: { + alerts, + }, + }); + }; +} diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 260594894..31cac5bc7 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -9,18 +9,27 @@ export default class ColumnSettings extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, + pushSettings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, }; + onPushChange = (key, checked) => { + this.props.onChange(['push', ...key], checked); + } + render () { - const { settings, onChange, onClear } = this.props; + const { settings, pushSettings, onChange, onClear } = this.props; const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />; const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />; const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; + const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); + const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />; + const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />; + return ( <div> <div className='column-settings__row'> @@ -30,7 +39,8 @@ export default class ColumnSettings extends React.PureComponent { <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> </div> @@ -38,7 +48,8 @@ export default class ColumnSettings extends React.PureComponent { <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> </div> @@ -46,7 +57,8 @@ export default class ColumnSettings extends React.PureComponent { <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> </div> @@ -54,7 +66,8 @@ export default class ColumnSettings extends React.PureComponent { <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> </div> diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js index 510820358..be1ff91d6 100644 --- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -10,6 +10,7 @@ export default class SettingToggle extends React.PureComponent { settings: ImmutablePropTypes.map.isRequired, settingKey: PropTypes.array.isRequired, label: PropTypes.node.isRequired, + meta: PropTypes.node, onChange: PropTypes.func.isRequired, } @@ -18,13 +19,14 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingKey, label } = this.props; + const { prefix, settings, settingKey, label, meta } = this.props; const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); return ( <div className='setting-toggle'> <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} /> <label htmlFor={id} className='setting-toggle__label'>{label}</label> + {meta && <span className='setting-meta__label'>{meta}</span>} </div> ); } diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index b139d4615..d4ead7881 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -3,6 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting, saveSettings } from '../../../actions/settings'; import { clearNotifications } from '../../../actions/notifications'; +import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications'; import { openModal } from '../../../actions/modal'; const messages = defineMessages({ @@ -12,16 +13,22 @@ const messages = defineMessages({ const mapStateToProps = state => ({ settings: state.getIn(['settings', 'notifications']), + pushSettings: state.get('push_notifications'), }); const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (key, checked) { - dispatch(changeSetting(['notifications', ...key], checked)); + if (key[0] === 'push') { + dispatch(changePushNotifications(key.slice(1), checked)); + } else { + dispatch(changeSetting(['notifications', ...key], checked)); + } }, onSave () { dispatch(saveSettings()); + dispatch(savePushNotificationSettings()); }, onClear () { diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js index d7ffa8ea6..d2c9d1c94 100644 --- a/app/javascript/mastodon/main.js +++ b/app/javascript/mastodon/main.js @@ -29,6 +29,14 @@ function main() { const props = JSON.parse(mountNode.getAttribute('data-props')); ReactDOM.render(<Mastodon {...props} />, mountNode); + if (process.env.NODE_ENV === 'production') { + // avoid offline in dev mode because it's harder to debug + const OfflinePluginRuntime = require('offline-plugin/runtime'); + const WebPushSubscription = require('./web_push_subscription'); + + OfflinePluginRuntime.install(); + WebPushSubscription.register(); + } perf.stop('main()'); }); } diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 919345f16..3aaf259c2 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -10,6 +10,7 @@ import accounts_counters from './accounts_counters'; import statuses from './statuses'; import relationships from './relationships'; import settings from './settings'; +import push_notifications from './push_notifications'; import status_lists from './status_lists'; import cards from './cards'; import reports from './reports'; @@ -32,6 +33,7 @@ const reducers = { statuses, relationships, settings, + push_notifications, cards, reports, contexts, diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js new file mode 100644 index 000000000..31a40d246 --- /dev/null +++ b/app/javascript/mastodon/reducers/push_notifications.js @@ -0,0 +1,51 @@ +import { STORE_HYDRATE } from '../actions/store'; +import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from '../actions/push_notifications'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + subscription: null, + alerts: new Immutable.Map({ + follow: false, + favourite: false, + reblog: false, + mention: false, + }), + isSubscribed: false, + browserSupport: false, +}); + +export default function push_subscriptions(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: { + const push_subscription = action.state.get('push_subscription'); + + if (push_subscription) { + return state + .set('subscription', new Immutable.Map({ + id: push_subscription.get('id'), + endpoint: push_subscription.get('endpoint'), + })) + .set('alerts', push_subscription.get('alerts') || initialState.get('alerts')) + .set('isSubscribed', true); + } + + return state; + } + case SET_SUBSCRIPTION: + return state + .set('subscription', new Immutable.Map({ + id: action.subscription.id, + endpoint: action.subscription.endpoint, + })) + .set('alerts', new Immutable.Map(action.subscription.alerts)) + .set('isSubscribed', true); + case SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case CLEAR_SUBSCRIPTION: + return initialState; + case ALERTS_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js new file mode 100644 index 000000000..364b67066 --- /dev/null +++ b/app/javascript/mastodon/service_worker/entry.js @@ -0,0 +1 @@ +import './web_push_notifications'; diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js new file mode 100644 index 000000000..1708aa9f7 --- /dev/null +++ b/app/javascript/mastodon/service_worker/web_push_notifications.js @@ -0,0 +1,86 @@ +const handlePush = (event) => { + const options = event.data.json(); + + options.body = options.data.nsfw || options.data.content; + options.image = options.image || undefined; // Null results in a network request (404) + options.timestamp = options.timestamp && new Date(options.timestamp); + + const expandAction = options.data.actions.find(action => action.todo === 'expand'); + + if (expandAction) { + options.actions = [expandAction]; + options.hiddenActions = options.data.actions.filter(action => action !== expandAction); + + options.data.hiddenImage = options.image; + options.image = undefined; + } else { + options.actions = options.data.actions; + } + + event.waitUntil(self.registration.showNotification(options.title, options)); +}; + +const cloneNotification = (notification) => { + const clone = { }; + + for(var k in notification) { + clone[k] = notification[k]; + } + + return clone; +}; + +const expandNotification = (notification) => { + const nextNotification = cloneNotification(notification); + + nextNotification.body = notification.data.content; + nextNotification.image = notification.data.hiddenImage; + nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand'); + + return self.registration.showNotification(nextNotification.title, nextNotification); +}; + +const makeRequest = (notification, action) => + fetch(action.action, { + headers: { + 'Authorization': `Bearer ${notification.data.access_token}`, + 'Content-Type': 'application/json', + }, + method: action.method, + credentials: 'include', + }); + +const removeActionFromNotification = (notification, action) => { + const actions = notification.actions.filter(act => act.action !== action.action); + + const nextNotification = cloneNotification(notification); + + nextNotification.actions = actions; + + return self.registration.showNotification(nextNotification.title, nextNotification); +}; + +const handleNotificationClick = (event) => { + const reactToNotificationClick = new Promise((resolve, reject) => { + if (event.action) { + const action = event.notification.data.actions.find(({ action }) => action === event.action); + + if (action.todo === 'expand') { + resolve(expandNotification(event.notification)); + } else if (action.todo === 'request') { + resolve(makeRequest(event.notification, action) + .then(() => removeActionFromNotification(event.notification, action))); + } else { + reject(`Unknown action: ${action.todo}`); + } + } else { + event.notification.close(); + resolve(self.clients.openWindow(event.notification.data.url)); + } + }); + + event.waitUntil(reactToNotificationClick); +}; + +self.addEventListener('push', handlePush); +self.addEventListener('notificationclick', handleNotificationClick); diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js new file mode 100644 index 000000000..391d3bcec --- /dev/null +++ b/app/javascript/mastodon/web_push_subscription.js @@ -0,0 +1,109 @@ +import axios from 'axios'; +import { store } from './containers/mastodon'; +import { setBrowserSupport, setSubscription, clearSubscription } from './actions/push_notifications'; + +// Taken from https://www.npmjs.com/package/web-push +const urlBase64ToUint8Array = (base64String) => { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; + +const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content'); + +const getRegistration = () => navigator.serviceWorker.ready; + +const getPushSubscription = (registration) => + registration.pushManager.getSubscription() + .then(subscription => ({ registration, subscription })); + +const subscribe = (registration) => + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()), + }); + +const unsubscribe = ({ registration, subscription }) => + subscription ? subscription.unsubscribe().then(() => registration) : registration; + +const sendSubscriptionToBackend = (subscription) => + axios.post('/api/web/push_subscriptions', { + data: subscription, + }).then(response => response.data); + +// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload +const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); + +export function register () { + store.dispatch(setBrowserSupport(supportsPushNotifications)); + + if (supportsPushNotifications) { + if (!getApplicationServerKey()) { + // eslint-disable-next-line no-console + console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.'); + return; + } + + getRegistration() + .then(getPushSubscription) + .then(({ registration, subscription }) => { + if (subscription !== null) { + // We have a subscription, check if it is still valid + const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString(); + const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString(); + const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']); + + // If the VAPID public key did not change and the endpoint corresponds + // to the endpoint saved in the backend, the subscription is valid + if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) { + return subscription; + } else { + // Something went wrong, try to subscribe again + return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend); + } + } + + // No subscription, try to subscribe + return subscribe(registration).then(sendSubscriptionToBackend); + }) + .then(subscription => { + // If we got a PushSubscription (and not a subscription object from the backend) + // it means that the backend subscription is valid (and was set during hydration) + if (!(subscription instanceof PushSubscription)) { + store.dispatch(setSubscription(subscription)); + } + }) + .catch(error => { + if (error.code === 20 && error.name === 'AbortError') { + // eslint-disable-next-line no-console + console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.'); + } else if (error.code === 5 && error.name === 'InvalidCharacterError') { + // eslint-disable-next-line no-console + console.error('The VAPID public key seems to be invalid:', getApplicationServerKey()); + } + + // Clear alerts and hide UI settings + store.dispatch(clearSubscription()); + + try { + getRegistration() + .then(getPushSubscription) + .then(unsubscribe); + } catch (e) { + + } + }); + } else { + // eslint-disable-next-line no-console + console.warn('Your browser does not support Web Push Notifications.'); + } +} diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 45dd9f914..02602afa4 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -2352,7 +2352,8 @@ button.icon-button.active i.fa-retweet { line-height: 24px; } -.setting-toggle__label { +.setting-toggle__label, +.setting-meta__label { color: $ui-primary-color; display: inline-block; margin-bottom: 14px; @@ -2360,6 +2361,11 @@ button.icon-button.active i.fa-retweet { vertical-align: middle; } +.setting-meta__label { + color: $ui-primary-color; + float: right; +} + .empty-column-indicator, .error-column { color: lighten($ui-base-color, 20%); diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss index a91d0d72a..4966fbc21 100644 --- a/app/javascript/styles/rtl.scss +++ b/app/javascript/styles/rtl.scss @@ -45,6 +45,10 @@ body.rtl { margin-right: 8px; } + .setting-meta__label { + float: left; + } + .status__avatar { left: auto; right: 10px; diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb index 887e3e3bd..7eb16af8f 100644 --- a/app/models/session_activation.rb +++ b/app/models/session_activation.rb @@ -3,6 +3,17 @@ # # Table name: session_activations # +# id :integer not null, primary key +# user_id :integer not null +# session_id :string not null +# created_at :datetime not null +# updated_at :datetime not null +# user_agent :string default(""), not null +# ip :inet +# access_token_id :integer +# web_push_subscription_id :integer +# + # id :integer not null, primary key # user_id :integer not null # session_id :string not null @@ -15,6 +26,7 @@ class SessionActivation < ApplicationRecord belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy + belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy delegate :token, to: :access_token, diff --git a/app/models/user.rb b/app/models/user.rb index 86e578225..a63b1da7f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -113,6 +113,10 @@ class User < ApplicationRecord session_activations.active? id end + def web_push_subscription(session) + session.web_push_subscription.nil? ? nil : session.web_push_subscription.as_payload + end + protected def send_devise_notification(notification, *args) diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb new file mode 100644 index 000000000..4440706a6 --- /dev/null +++ b/app/models/web/push_subscription.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: web_push_subscriptions +# +# id :integer not null, primary key +# endpoint :string not null +# key_p256dh :string not null +# key_auth :string not null +# data :json +# created_at :datetime not null +# updated_at :datetime not null +# + +class Web::PushSubscription < ApplicationRecord + include RoutingHelper + include StreamEntriesHelper + include ActionView::Helpers::TranslationHelper + include ActionView::Helpers::SanitizeHelper + + has_one :session_activation + + before_create :send_welcome_notification + + def push(notification) + return unless pushable? notification + + name = display_name notification.from_account + title = title_str(name, notification) + body = body_str notification + dir = dir_str body + url = url_str notification + image = image_str notification + actions = actions_arr notification + + access_token = actions.empty? ? nil : find_or_create_access_token(notification).token + nsfw = notification.target_status.nil? || notification.target_status.spoiler_text.empty? ? nil : notification.target_status.spoiler_text + + # TODO: Make sure that the payload does not exceed 4KB - Webpush::PayloadTooLarge + # TODO: Queue the requests - Webpush::TooManyRequests + Webpush.payload_send( + message: JSON.generate( + title: title, + dir: dir, + image: image, + badge: full_asset_url('badge.png'), + tag: notification.id, + timestamp: notification.created_at, + icon: notification.from_account.avatar_static_url, + data: { + content: decoder.decode(strip_tags(body)), + nsfw: nsfw.nil? ? nil : decoder.decode(strip_tags(nsfw)), + url: url, + actions: actions, + access_token: access_token, + } + ), + endpoint: endpoint, + p256dh: key_p256dh, + auth: key_auth, + vapid: { + # subject: "mailto:#{Setting.site_contact_email}", + private_key: Rails.configuration.x.vapid_private_key, + public_key: Rails.configuration.x.vapid_public_key, + }, + ttl: 40 * 60 * 60 # 48 hours + ) + end + + def as_payload + payload = { + id: id, + endpoint: endpoint, + } + + payload[:alerts] = data['alerts'] if data && data.key?('alerts') + + payload + end + + private + + def title_str(name, notification) + case notification.type + when :mention then translate('push_notifications.mention.title', name: name) + when :follow then translate('push_notifications.follow.title', name: name) + when :favourite then translate('push_notifications.favourite.title', name: name) + when :reblog then translate('push_notifications.reblog.title', name: name) + end + end + + def body_str(notification) + case notification.type + when :mention then notification.target_status.text + when :follow then notification.from_account.note + when :favourite then notification.target_status.text + when :reblog then notification.target_status.text + end + end + + def url_str(notification) + case notification.type + when :mention then web_url("statuses/#{notification.target_status.id}") + when :follow then web_url("accounts/#{notification.from_account.id}") + when :favourite then web_url("statuses/#{notification.target_status.id}") + when :reblog then web_url("statuses/#{notification.target_status.id}") + end + end + + def actions_arr(notification) + actions = + case notification.type + when :mention then [ + { + title: translate('push_notifications.mention.action_favourite'), + icon: full_asset_url('emoji/2764.png'), + todo: 'request', + method: 'POST', + action: "/api/v1/statuses/#{notification.target_status.id}/favourite", + }, + ] + else [] + end + + should_hide = notification.type.equal?(:mention) && !notification.target_status.nil? && (notification.target_status.sensitive || !notification.target_status.spoiler_text.empty?) + can_boost = notification.type.equal?(:mention) && !notification.target_status.nil? && !notification.target_status.hidden? + + if should_hide + actions.insert(0, title: translate('push_notifications.mention.action_expand'), icon: full_asset_url('emoji/1f441.png'), todo: 'expand', action: 'expand') + end + + if can_boost + actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('emoji/1f504.png'), todo: 'request', method: 'POST', action: "/api/v1/statuses/#{notification.target_status.id}/reblog" } + end + + actions + end + + def image_str(notification) + return nil if notification.target_status.nil? || notification.target_status.media_attachments.empty? + + full_asset_url(notification.target_status.media_attachments.first.file.url(:small)) + end + + def dir_str(body) + rtl?(body) ? 'rtl' : 'ltr' + end + + def pushable?(notification) + data && data.key?('alerts') && data['alerts'][notification.type.to_s] + end + + def send_welcome_notification + Webpush.payload_send( + message: JSON.generate( + title: translate('push_notifications.subscribed.title'), + icon: full_asset_url('android-chrome-192x192.png'), + badge: full_asset_url('badge.png'), + data: { + content: translate('push_notifications.subscribed.body'), + actions: [], + url: web_url('notifications'), + } + ), + endpoint: endpoint, + p256dh: key_p256dh, + auth: key_auth, + vapid: { + # subject: "mailto:#{Setting.site_contact_email}", + private_key: Rails.configuration.x.vapid_private_key, + public_key: Rails.configuration.x.vapid_public_key, + }, + ttl: 5 * 60 # 5 minutes + ) + end + + def find_or_create_access_token(notification) + Doorkeeper::AccessToken.find_or_create_for( + Doorkeeper::Application.find_by(superapp: true), + notification.account.user.id, + Doorkeeper::OAuth::Scopes.from_string('read write follow'), + Doorkeeper.configuration.access_token_expires_in, + Doorkeeper.configuration.refresh_token_enabled? + ) + end + + def decoder + @decoder ||= HTMLEntities.new + end +end diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb index 75fef28a8..9507aad4a 100644 --- a/app/presenters/initial_state_presenter.rb +++ b/app/presenters/initial_state_presenter.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class InitialStatePresenter < ActiveModelSerializers::Model - attributes :settings, :token, :current_account, :admin + attributes :settings, :push_subscription, :token, :current_account, :admin end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 6751c9411..704d29a57 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -2,7 +2,7 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :accounts, - :media_attachments, :settings + :media_attachments, :settings, :push_subscription def meta store = { diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 407d385ea..0ab61b634 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -61,6 +61,11 @@ class NotifyService < BaseService @notification.save! return unless @notification.browserable? Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification))) + send_push_notifications + end + + def send_push_notifications + WebPushNotificationWorker.perform_async(@recipient.id, @notification.id) end def send_email diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 71dcb54c6..13ca9ea79 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,4 +1,5 @@ - content_for :header_tags do + %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key} %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) = javascript_pack_tag 'application', integrity: true, crossorigin: 'anonymous' diff --git a/app/workers/web_push_notification_worker.rb b/app/workers/web_push_notification_worker.rb new file mode 100644 index 000000000..0568a3e02 --- /dev/null +++ b/app/workers/web_push_notification_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class WebPushNotificationWorker + include Sidekiq::Worker + + sidekiq_options backtrace: true + + def perform(recipient_id, notification_id) + recipient = Account.find(recipient_id) + notification = Notification.find(notification_id) + + sessions_with_subscriptions = recipient.user.session_activations.reject { |session| session.web_push_subscription.nil? } + + sessions_with_subscriptions.each do |session| + begin + session.web_push_subscription.push(notification) + rescue Webpush::InvalidSubscription, Webpush::ExpiredSubscription + # Subscription expiration is not currently implemented in any browser + session.web_push_subscription.destroy! + session.web_push_subscription = nil + session.save! + rescue Webpush::PayloadTooLarge => e + Rails.logger.error(e) + end + end + end +end diff --git a/config/environments/development.rb b/config/environments/development.rb index 406fa970b..4c60965c8 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -31,6 +31,11 @@ Rails.application.configure do config.logger = ActiveSupport::TaggedLogging.new(logger) end + # Generate random VAPID keys + vapid_key = Webpush.generate_key + config.x.vapid_private_key = vapid_key.private_key + config.x.vapid_public_key = vapid_key.public_key + # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false diff --git a/config/environments/test.rb b/config/environments/test.rb index bde69eba1..e68cb156d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -40,6 +40,11 @@ Rails.application.configure do # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr + # Generate random VAPID keys + vapid_key = Webpush.generate_key + config.x.vapid_private_key = vapid_key.private_key + config.x.vapid_public_key = vapid_key.public_key + # Raises error for missing translations # config.action_view.raise_on_missing_translations = true end diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb new file mode 100644 index 000000000..74e07377c --- /dev/null +++ b/config/initializers/vapid.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Rails.application.configure do + + # You can generate the keys using the following command (first is the private key, second is the public one) + # You should only generate this once per instance. If you later decide to change it, all push subscription will + # be invalidated, requiring the users to access the website again to resubscribe. + # + # ruby -e "require 'webpush'; vapid_key = Webpush.generate_key; puts vapid_key.private_key; puts vapid_key.public_key;" + # + # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html + + if Rails.env.production? + config.x.vapid_private_key = ENV['VAPID_PRIVATE_KEY'] + config.x.vapid_public_key = ENV['VAPID_PUBLIC_KEY'] + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index c9b5d9ab8..79efddfad 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -335,6 +335,21 @@ en: next: Next prev: Prev truncate: "…" + push_notifications: + favourite: + title: "%{name} favourited your status" + follow: + title: "%{name} is now following you" + mention: + action_boost: 'Boost' + action_expand: 'Show more' + action_favourite: 'Favourite' + title: "%{name} mentioned you" + reblog: + title: "%{name} boosted your status" + subscribed: + body: "You can now receive push notifications." + title: "Subscription registered!" remote_follow: acct: Enter your username@domain you want to follow from missing_resource: Could not find the required redirect URL for your account diff --git a/config/locales/pl.yml b/config/locales/pl.yml index dc5aa716b..f9d69745f 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -339,6 +339,21 @@ pl: next: Następna prev: Poprzednia truncate: "…" + push_notifications: + favourite: + title: "%{name} dodał Twój status do ulubionych" + follow: + title: "%{name} zaczął Cię śledzić" + mention: + action_boost: 'Podbij' + action_expand: 'Pokaż więcej' + action_favourite: 'Dodaj do ulubionych' + title: "%{name} wspomniał o Tobie" + reblog: + title: "%{name} podbił Twój status" + subscribed: + body: "Otrzymujesz teraz powiadomienia push." + title: "Zarejestrowano subskrypcję!" remote_follow: acct: Podaj swój adres (nazwa@domena), z którego chcesz śledzić missing_resource: Nie udało się znaleźć adresu przekierowania z Twojej domeny diff --git a/config/routes.rb b/config/routes.rb index 963fedcb4..9171d02d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -206,6 +206,11 @@ Rails.application.routes.draw do namespace :web do resource :settings, only: [:update] + resources :push_subscriptions, only: [:create] do + member do + put :update + end + end end end diff --git a/config/webpack/production.js b/config/webpack/production.js index 303fca81b..4592db89e 100644 --- a/config/webpack/production.js +++ b/config/webpack/production.js @@ -5,6 +5,9 @@ const merge = require('webpack-merge'); const CompressionPlugin = require('compression-webpack-plugin'); const sharedConfig = require('./shared.js'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; +const OfflinePlugin = require('offline-plugin'); +const { publicPath } = require('./configuration.js'); +const path = require('path'); module.exports = merge(sharedConfig, { output: { filename: '[name]-[chunkhash].js' }, @@ -39,5 +42,16 @@ module.exports = merge(sharedConfig, { openAnalyzer: false, logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout }), + new OfflinePlugin({ + publicPath: publicPath, // sw.js must be served from the root to avoid scope issues + caches: { }, // do not cache things, we only use it for push notifications for now + ServiceWorker: { + entry: path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'), + cacheName: 'mastodon', + output: '../sw.js', + publicPath: '/sw.js', + minify: true, + }, + }), ], }); diff --git a/db/migrate/20170713175513_create_web_push_subscriptions.rb b/db/migrate/20170713175513_create_web_push_subscriptions.rb new file mode 100644 index 000000000..4e5c2ba00 --- /dev/null +++ b/db/migrate/20170713175513_create_web_push_subscriptions.rb @@ -0,0 +1,12 @@ +class CreateWebPushSubscriptions < ActiveRecord::Migration[5.1] + def change + create_table :web_push_subscriptions do |t| + t.string :endpoint, null: false + t.string :key_p256dh, null: false + t.string :key_auth, null: false + t.json :data + + t.timestamps + end + end +end diff --git a/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb b/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb new file mode 100644 index 000000000..d69cdfa50 --- /dev/null +++ b/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb @@ -0,0 +1,5 @@ +class AddWebPushSubscriptionToSessionActivations < ActiveRecord::Migration[5.1] + def change + add_column :session_activations, :web_push_subscription_id, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index d6e572703..b2c59a0f6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170713112503) do +ActiveRecord::Schema.define(version: 20170713190709) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -258,6 +258,7 @@ ActiveRecord::Schema.define(version: 20170713112503) do t.string "user_agent", default: "", null: false t.inet "ip" t.integer "access_token_id" + t.integer "web_push_subscription_id" t.index ["session_id"], name: "index_session_activations_on_session_id", unique: true t.index ["user_id"], name: "index_session_activations_on_user_id" end @@ -371,6 +372,15 @@ ActiveRecord::Schema.define(version: 20170713112503) do t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + create_table "web_push_subscriptions", force: :cascade do |t| + t.string "endpoint", null: false + t.string "key_p256dh", null: false + t.string "key_auth", null: false + t.json "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "web_settings", id: :serial, force: :cascade do |t| t.integer "user_id" t.json "data" diff --git a/package.json b/package.json index 004c4d1f5..1aaa243c8 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "node-sass": "^4.5.2", "npmlog": "^4.1.2", "object-assign": "^4.1.1", + "offline-plugin": "^4.8.3", "path-complete-extname": "^0.1.0", "pg": "^6.4.0", "postcss-loader": "^2.0.6", diff --git a/public/badge.png b/public/badge.png new file mode 100644 index 0000000000000000000000000000000000000000..fc1f42dca135ab028fbf3194a74329eb7c5103ee GIT binary patch literal 31156 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 XmIvNiJ9t$G00000NkvXXu0mjfv<>6{ literal 0 HcmV?d00001 diff --git a/spec/controllers/api/web/push_subscriptions_controller_spec.rb b/spec/controllers/api/web/push_subscriptions_controller_spec.rb new file mode 100644 index 000000000..871176a07 --- /dev/null +++ b/spec/controllers/api/web/push_subscriptions_controller_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::Web::PushSubscriptionsController do + render_views + + let(:user) { Fabricate(:user) } + + let(:create_payload) do + { + data: { + endpoint: 'https://fcm.googleapis.com/fcm/send/fiuH06a27qE:APA91bHnSiGcLwdaxdyqVXNDR9w1NlztsHb6lyt5WDKOC_Z_Q8BlFxQoR8tWFSXUIDdkyw0EdvxTu63iqamSaqVSevW5LfoFwojws8XYDXv_NRRLH6vo2CdgiN4jgHv5VLt2A8ah6lUX', + keys: { + p256dh: 'BEm_a0bdPDhf0SOsrnB2-ategf1hHoCnpXgQsFj5JCkcoMrMt2WHoPfEYOYPzOIs9mZE8ZUaD7VA5vouy0kEkr8=', + auth: 'eH_C8rq2raXqlcBVDa1gLg==', + }, + } + } + end + + let(:alerts_payload) do + { + data: { + alerts: { + follow: true, + favourite: false, + reblog: true, + mention: false, + } + } + } + end + + describe 'POST #create' do + it 'saves push subscriptions' do + sign_in(user) + + stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200) + + post :create, format: :json, params: create_payload + + user.reload + + push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]) + + expect(push_subscription['endpoint']).to eq(create_payload[:data][:endpoint]) + expect(push_subscription['key_p256dh']).to eq(create_payload[:data][:keys][:p256dh]) + expect(push_subscription['key_auth']).to eq(create_payload[:data][:keys][:auth]) + end + + it 'sends welcome notification' do + sign_in(user) + + stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200) + + post :create, format: :json, params: create_payload + end + end + + describe 'PUT #update' do + it 'changes alert settings' do + sign_in(user) + + stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200) + + post :create, format: :json, params: create_payload + + alerts_payload[:id] = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]).id + + put :update, format: :json, params: alerts_payload + + push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]) + + expect(push_subscription.data['follow']).to eq(alerts_payload[:data][:follow]) + expect(push_subscription.data['favourite']).to eq(alerts_payload[:data][:favourite]) + expect(push_subscription.data['reblog']).to eq(alerts_payload[:data][:reblog]) + expect(push_subscription.data['mention']).to eq(alerts_payload[:data][:mention]) + end + end +end diff --git a/spec/fabricators/web_push_subscription_fabricator.rb b/spec/fabricators/web_push_subscription_fabricator.rb new file mode 100644 index 000000000..72d11b77c --- /dev/null +++ b/spec/fabricators/web_push_subscription_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:web_push_subscription) do + endpoint Faker::Internet.url + key_p256dh Faker::Internet.password + key_auth Faker::Internet.password +end diff --git a/spec/models/web/push_subscription_spec.rb b/spec/models/web/push_subscription_spec.rb new file mode 100644 index 000000000..574da55ac --- /dev/null +++ b/spec/models/web/push_subscription_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe Web::PushSubscription, type: :model do + let(:alerts) { { mention: true, reblog: false, follow: true, follow_request: false, favourite: true } } + let(:payload_no_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd').as_payload } + let(:payload_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd', data: { alerts: alerts }).as_payload } + let(:push_subscription) { Web::PushSubscription.new(data: { alerts: alerts }) } + + describe '#as_payload' do + it 'only returns id and endpoint' do + expect(payload_no_alerts.keys).to eq [:id, :endpoint] + end + + it 'returns alerts if set' do + expect(payload_alerts.keys).to eq [:id, :endpoint, :alerts] + end + end + + describe '#pushable?' do + it 'obeys alert settings' do + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Mention'))).to eq true + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Status'))).to eq false + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Follow'))).to eq true + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'FollowRequest'))).to eq false + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Favourite'))).to eq true + end + end +end diff --git a/yarn.lock b/yarn.lock index 13c3f4951..812a0721a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2209,7 +2209,7 @@ deep-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" -deep-extend@~0.4.0: +deep-extend@^0.4.0, deep-extend@~0.4.0: version "0.4.2" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f" @@ -2416,7 +2416,7 @@ ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" -ejs@^2.5.6: +ejs@^2.3.4, ejs@^2.5.6: version "2.5.6" resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.6.tgz#479636bfa3fe3b1debd52087f0acb204b4f19c88" @@ -4059,6 +4059,15 @@ loader-runner@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" +loader-utils@0.2.x: + version "0.2.17" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.17.tgz#f86e6374d43205a6e6c60e9196f17c0299bfb348" + dependencies: + big.js "^3.1.3" + emojis-list "^2.0.0" + json5 "^0.5.0" + object-assign "^4.0.1" + loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" @@ -4419,7 +4428,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" -minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2: +minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" dependencies: @@ -4760,6 +4769,16 @@ obuf@^1.0.0, obuf@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.1.tgz#104124b6c602c6796881a042541d36db43a5264e" +offline-plugin@^4.8.3: + version "4.8.3" + resolved "https://registry.yarnpkg.com/offline-plugin/-/offline-plugin-4.8.3.tgz#9e95bd342ea2ac836b001b81f204c40638694d6c" + dependencies: + deep-extend "^0.4.0" + ejs "^2.3.4" + loader-utils "0.2.x" + minimatch "^3.0.3" + slash "^1.0.0" + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"