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
pull/4182/head
Sorin Davidoi 2017-07-13 22:15:32 +02:00 committed by Eugen Rochko
parent c2753fdfb4
commit 0c7c188c45
42 changed files with 890 additions and 14 deletions

View File

@ -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
View File

@ -21,6 +21,7 @@ public/system
public/assets
public/packs
public/packs-test
public/sw.js
.env
.env.production
node_modules/

View File

@ -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'

View File

@ -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

View 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

View File

@ -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),

View 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,
},
});
};
}

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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) {
if (key[0] === 'push') {
dispatch(changePushNotifications(key.slice(1), checked));
} else {
dispatch(changeSetting(['notifications', ...key], checked));
}
},
onSave () {
dispatch(saveSettings());
dispatch(savePushNotificationSettings());
},
onClear () {

View File

@ -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()');
});
}

View File

@ -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,

View 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;
}
};

View File

@ -0,0 +1 @@
import './web_push_notifications';

View File

@ -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);

View 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.');
}
}

View File

@ -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%);

View File

@ -45,6 +45,10 @@ body.rtl {
margin-right: 8px;
}
.setting-meta__label {
float: left;
}
.status__avatar {
left: auto;
right: 10px;

View File

@ -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,

View File

@ -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)

View 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

View File

@ -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

View File

@ -2,7 +2,7 @@
class InitialStateSerializer < ActiveModel::Serializer
attributes :meta, :compose, :accounts,
:media_attachments, :settings
:media_attachments, :settings, :push_subscription
def meta
store = {

View File

@ -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

View File

@ -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'

View 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

View File

@ -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

View File

@ -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

View 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

View File

@ -335,6 +335,21 @@ en:
next: Next
prev: Prev
truncate: "&hellip;"
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

View File

@ -339,6 +339,21 @@ pl:
next: Następna
prev: Poprzednia
truncate: "&hellip;"
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

View File

@ -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

View File

@ -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,
},
}),
],
});

View 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

View File

@ -0,0 +1,5 @@
class AddWebPushSubscriptionToSessionActivations < ActiveRecord::Migration[5.1]
def change
add_column :session_activations, :web_push_subscription_id, :integer
end
end

View File

@ -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"

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -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

View 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

View 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

View File

@ -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"