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
This commit is contained in:
		
				
					committed by
					
						 Eugen Rochko
						Eugen Rochko
					
				
			
			
				
	
			
			
			
						parent
						
							c2753fdfb4
						
					
				
				
					commit
					0c7c188c45
				
			| @@ -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 | ||||
|   | ||||
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -21,6 +21,7 @@ public/system | ||||
| public/assets | ||||
| public/packs | ||||
| public/packs-test | ||||
| public/sw.js | ||||
| .env | ||||
| .env.production | ||||
| node_modules/ | ||||
|   | ||||
							
								
								
									
										1
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								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' | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										39
									
								
								app/controllers/api/web/push_subscriptions_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/controllers/api/web/push_subscriptions_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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), | ||||
|   | ||||
							
								
								
									
										52
									
								
								app/javascript/mastodon/actions/push_notifications.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								app/javascript/mastodon/actions/push_notifications.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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, | ||||
|       }, | ||||
|     }); | ||||
|   }; | ||||
| } | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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> | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -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 () { | ||||
|   | ||||
| @@ -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()'); | ||||
|   }); | ||||
| } | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
							
								
								
									
										51
									
								
								app/javascript/mastodon/reducers/push_notifications.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								app/javascript/mastodon/reducers/push_notifications.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										1
									
								
								app/javascript/mastodon/service_worker/entry.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/javascript/mastodon/service_worker/entry.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| import './web_push_notifications'; | ||||
| @@ -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); | ||||
							
								
								
									
										109
									
								
								app/javascript/mastodon/web_push_subscription.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								app/javascript/mastodon/web_push_subscription.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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.'); | ||||
|   } | ||||
| } | ||||
| @@ -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%); | ||||
|   | ||||
| @@ -45,6 +45,10 @@ body.rtl { | ||||
|     margin-right: 8px; | ||||
|   } | ||||
|  | ||||
|   .setting-meta__label { | ||||
|     float: left; | ||||
|   } | ||||
|  | ||||
|   .status__avatar { | ||||
|     left: auto; | ||||
|     right: 10px; | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										190
									
								
								app/models/web/push_subscription.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								app/models/web/push_subscription.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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 | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|  | ||||
| class InitialStateSerializer < ActiveModel::Serializer | ||||
|   attributes :meta, :compose, :accounts, | ||||
|              :media_attachments, :settings | ||||
|              :media_attachments, :settings, :push_subscription | ||||
|  | ||||
|   def meta | ||||
|     store = { | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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' | ||||
|   | ||||
							
								
								
									
										27
									
								
								app/workers/web_push_notification_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/workers/web_push_notification_worker.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										17
									
								
								config/initializers/vapid.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								config/initializers/vapid.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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, | ||||
|       }, | ||||
|     }), | ||||
|   ], | ||||
| }); | ||||
|   | ||||
							
								
								
									
										12
									
								
								db/migrate/20170713175513_create_web_push_subscriptions.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								db/migrate/20170713175513_create_web_push_subscriptions.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -0,0 +1,5 @@ | ||||
| class AddWebPushSubscriptionToSessionActivations < ActiveRecord::Migration[5.1] | ||||
|   def change | ||||
|     add_column :session_activations, :web_push_subscription_id, :integer | ||||
|   end | ||||
| end | ||||
							
								
								
									
										12
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								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" | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								public/badge.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/badge.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 30 KiB | 
| @@ -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 | ||||
							
								
								
									
										5
									
								
								spec/fabricators/web_push_subscription_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								spec/fabricators/web_push_subscription_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| Fabricator(:web_push_subscription) do | ||||
|   endpoint   Faker::Internet.url | ||||
|   key_p256dh Faker::Internet.password | ||||
|   key_auth   Faker::Internet.password | ||||
| end | ||||
							
								
								
									
										28
									
								
								spec/models/web/push_subscription_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								spec/models/web/push_subscription_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										25
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								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" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user