Catrope has uploaded a new change for review. ( 
https://gerrit.wikimedia.org/r/371615 )

Change subject: [WIP] Push notifications
......................................................................

[WIP] Push notifications

TODO:
* Proper UX for subscribing and unsubscribing
* Respect push notif config being disabled (or key not configured)
* Update code for 'push' as a 3rd type everywhere
* Move away from WebPush package to something else
  (legoktm says WebPush is not suitable for production)
* Better way of finding OOUI icons
* Add a ServiceWorker module to MW core that gathers
  SW code and takes care of registration

Bug: T113125
Change-Id: Ied7b878291a63989d716a0f233be76b21453ba3c
---
M Hooks.php
M composer.json
A db_patches/echo_push_subscription.sql
M echo.sql
M extension.json
M i18n/en.json
M includes/AttributeManager.php
M includes/Notifier.php
A includes/api/ApiEchoPushSubscribe.php
M includes/formatters/EchoIcon.php
M modules/ext.echo.init.js
A modules/ext.echo.serviceWorker.js
12 files changed, 262 insertions(+), 14 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/Echo 
refs/changes/15/371615/1

diff --git a/Hooks.php b/Hooks.php
index b3bd910..d650614 100644
--- a/Hooks.php
+++ b/Hooks.php
@@ -229,6 +229,7 @@
                $updater->addExtensionIndex( 'echo_target_page', 
'echo_target_page_page_event', "$dir/db_patches/patch-add-page_event-index.sql" 
);
                $updater->addExtensionIndex( 'echo_event', 
'echo_event_page_id', "$dir/db_patches/patch-add-event_page_id-index.sql" );
                $updater->dropExtensionIndex( 'echo_notification', 
'user_event', "$dir/db_patches/patch-notification-pk.sql" );
+               $updater->addExtensionTable( 'echo_push_subscription', 
"$dir/db_patches/echo_push_subscription.sql" );
        }
 
        /**
@@ -1387,10 +1388,13 @@
        }
 
        public static function onResourceLoaderGetConfigVars( &$vars ) {
-               global $wgEchoFooterNoticeURL;
+               global $wgEchoFooterNoticeURL, $wgEchoEnablePushNotifications, 
$wgEchoVAPIDKey;
 
                $vars['wgEchoMaxNotificationCount'] = 
MWEchoNotifUser::MAX_BADGE_COUNT;
                $vars['wgEchoFooterNoticeURL'] = $wgEchoFooterNoticeURL;
+               if ( $wgEchoEnablePushNotifications ) {
+                       $vars['wgEchoVAPIDPublicKey'] = 
$wgEchoVAPIDKey['publicKey'];
+               }
 
                return true;
        }
diff --git a/composer.json b/composer.json
index 55507be..aba2c0b 100644
--- a/composer.json
+++ b/composer.json
@@ -1,4 +1,7 @@
 {
+       "require": {
+               "minishlink/web-push": "1.4.3"
+       },
        "require-dev": {
                "jakub-onderka/php-parallel-lint": "0.9.2",
                "mediawiki/mediawiki-codesniffer": "0.10.1",
diff --git a/db_patches/echo_push_subscription.sql 
b/db_patches/echo_push_subscription.sql
new file mode 100644
index 0000000..3bd21cc
--- /dev/null
+++ b/db_patches/echo_push_subscription.sql
@@ -0,0 +1,7 @@
+CREATE TABLE /*_*/echo_push_subscription (
+       eps_id int unsigned not null primary key auto_increment,
+       eps_user int unsigned not null,
+       eps_subscription BLOB not null
+) /*$wgDBTableOptions*/;
+
+CREATE INDEX /*i*/echo_push_subscription_user_id ON 
/*_*/echo_push_subscription (eps_user);
diff --git a/echo.sql b/echo.sql
index 9736c92..50c06a1 100644
--- a/echo.sql
+++ b/echo.sql
@@ -54,3 +54,11 @@
 
 CREATE INDEX /*i*/echo_target_page_event ON /*_*/echo_target_page (etp_event);
 CREATE INDEX /*i*/echo_target_page_page_event ON /*_*/echo_target_page 
(etp_page, etp_event);
+
+CREATE TABLE /*_*/echo_push_subscription (
+       eps_id int unsigned not null primary key auto_increment,
+       eps_user int unsigned not null,
+       eps_subscription BLOB not null
+) /*$wgDBTableOptions*/;
+
+CREATE INDEX /*i*/echo_push_subscription_user_id ON 
/*_*/echo_push_subscription (eps_user);
diff --git a/extension.json b/extension.json
index 39606db..509f141 100644
--- a/extension.json
+++ b/extension.json
@@ -24,7 +24,8 @@
        "APIModules": {
                "echomarkread": "ApiEchoMarkRead",
                "echomarkseen": "ApiEchoMarkSeen",
-               "echoarticlereminder": "ApiEchoArticleReminder"
+               "echoarticlereminder": "ApiEchoArticleReminder",
+               "echopushsubscribe": "ApiEchoPushSubscribe"
        },
        "DefaultUserOptions": {
                "echo-show-alert": true,
@@ -510,7 +511,8 @@
                "DefaultNotifyTypeAvailability": {
                        "value": {
                                "web": true,
-                               "email": true
+                               "email": true,
+                               "push": true
                        }
                },
                "NotifyTypeAvailabilityByCategory": {
@@ -539,6 +541,10 @@
                                "email": [
                                        "EchoNotifier",
                                        "notifyWithEmail"
+                               ],
+                               "push": [
+                                       "EchoNotifier",
+                                       "notifyWithPush"
                                ]
                        }
                },
@@ -898,6 +904,18 @@
                "AllowArticleReminderNotification": {
                        "value": false,
                        "description": "This is a feature flag to the Article 
Reminder notification"
+               },
+               "EchoEnablePushNotifications": {
+                       "value": false,
+                       "description": "Enable support for push notifications. 
Also requires EchoVAPIDKey to be set."
+               },
+               "EchoVAPIDKey": {
+                       "value": {
+                               "subject": null,
+                               "publicKey": null,
+                               "privateKey": null
+                       },
+                       "description": "VAPID public key and private key (in 
base64 format), and contact URL (https or mailto) in the subject field."
                }
        },
        "manifest_version": 2,
@@ -909,6 +927,7 @@
                "ApiEchoMarkSeen": "includes/api/ApiEchoMarkSeen.php",
                "ApiEchoNotifications": "includes/api/ApiEchoNotifications.php",
                "ApiEchoNotificationsTest": 
"tests/phpunit/api/ApiEchoNotificationsTest.php",
+               "ApiEchoPushSubscribe": "includes/api/ApiEchoPushSubscribe.php",
                "ApiEchoUnreadNotificationPages": 
"includes/api/ApiEchoUnreadNotificationPages.php",
                "BackfillReadBundles": "maintenance/backfillReadBundles.php",
                "BackfillUnreadWikis": "maintenance/backfillUnreadWikis.php",
@@ -1020,5 +1039,6 @@
                "SuppressionMaintenanceTest": 
"tests/phpunit/maintenance/SupressionMaintenanceTest.php",
                "TestDiscussionParser": "maintenance/testDiscussionParser.php",
                "UpdateEchoSchemaForSuppression": 
"maintenance/updateEchoSchemaForSuppression.php"
-       }
+       },
+       "load_composer_autoloader": true
 }
diff --git a/i18n/en.json b/i18n/en.json
index dd14a77..161e053 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -48,6 +48,7 @@
        "echo-pref-email-format": "Email format:",
        "echo-pref-web": "Web",
        "echo-pref-email": "Email",
+       "echo-pref-push": "Push notifications",
        "echo-pref-email-frequency-never": "Do not send me any email 
notifications",
        "echo-pref-email-frequency-immediately": "Individual notifications as 
they come in",
        "echo-pref-email-frequency-daily": "A daily summary of notifications",
diff --git a/includes/AttributeManager.php b/includes/AttributeManager.php
index 7c9fb4c..3418a5c 100644
--- a/includes/AttributeManager.php
+++ b/includes/AttributeManager.php
@@ -134,7 +134,7 @@
         * Get the enabled events for a user, which excludes user-dismissed 
events
         * from the general enabled events
         * @param User $user
-        * @param string $notifyType Either "web" or "email".
+        * @param string $notifyType Either "web", "email" or "push".
         * @return string[]
         */
        public function getUserEnabledEvents( User $user, $notifyType ) {
@@ -157,7 +157,7 @@
        /**
         * Get the user enabled events for the specified sections
         * @param User $user
-        * @param string $notifyType Either "web" or "email".
+        * @param string $notifyType Either "web", "email" or "push".
         * @param string[] $sections
         * @return string[]
         */
@@ -321,7 +321,7 @@
         * on, regardless of the default or a particular user's preferences.
         *
         * @param string $category Category name
-        * @param string $notifyType notify type, e.g. email/web.
+        * @param string $notifyType notify type (web/email/push)
         */
        public function isNotifyTypeAvailableForCategory( $category, 
$notifyType ) {
                if ( isset( 
$this->notifyTypeAvailabilityByCategory[$category][$notifyType] ) ) {
@@ -351,7 +351,7 @@
         * for this category and format.
         *
         * @param string $category Name of category
-        * @param string $notifyType notify type, e.g. email/web.
+        * @param string $notifyType notify type (web/email/push)
         */
        public function isNotifyTypeDismissableForCategory( $category, 
$notifyType ) {
                return !(
diff --git a/includes/Notifier.php b/includes/Notifier.php
index 8a49efc..298c7a1 100644
--- a/includes/Notifier.php
+++ b/includes/Notifier.php
@@ -1,6 +1,7 @@
 <?php
 
-// @todo Fill in
+use Minishlink\WebPush\WebPush;
+
 class EchoNotifier {
        /**
         * Record an EchoNotification for an EchoEvent
@@ -23,6 +24,64 @@
                MWEchoEventLogging::logSchemaEcho( $user, $event, 'web' );
        }
 
+       public static function notifyWithPush( $user, $event ) {
+               // TODO disable this when push notifs are disabled, or VAPID 
key is not set
+               // TODO support PEM file
+               global $wgEchoVAPIDKey;
+               $webPush = new WebPush( [
+                       'VAPID' => $wgEchoVAPIDKey
+               ] );
+               $lang = wfGetLangObj( $user->getOption( 'language' ) );
+               $formatter = new EchoModelFormatter( $user, $lang );
+               $notifData = $formatter->format( $event );
+               $payload = [
+                       'title' => Sanitizer::stripAllTags( 
$notifData['header'] ),
+                       'body' => Sanitizer::stripAllTags( $notifData['body'] ),
+                       'url' => $notifData['links']['primary']['url'],
+                       'icon' => wfExpandUrl(
+                               EchoIcon::getRasterizedUrl( $notifData['icon'], 
$lang->getCode() )
+                       ),
+                       'badge' => wfExpandUrl(
+                               EchoIcon::getRasterizedUrl( 'site', 
$lang->getCode() )
+                       ),
+                       'actions' => [],
+               ];
+               foreach ( $notifData['links']['secondary'] as $index => $link ) 
{
+                       $iconUrl = wfExpandUrl(
+                               EchoIcon::getRasterizedSecondaryIconUrl( 
$link['icon'], $lang->getCode() ),
+                               PROTO_CANONICAL
+                       );
+                       $payload['actions'][] = [
+                               'action' => $index,
+                               'title' => $link['label'],
+                               'icon' => $iconUrl,
+                               'url' => $link['url']
+                       ];
+               }
+               $payloadJSON = FormatJson::encode( $payload );
+               wfDebugLog( __METHOD__, 'payload ' . $payloadJSON );
+
+               // TODO factor out into a class or something
+               $dbFactory = MWEchoDbFactory::newFromDefault();
+               $dbr = $dbFactory->getEchoDb( DB_REPLICA );
+               $subscriptions = $dbr->selectFieldValues(
+                       'echo_push_subscription',
+                       'eps_subscription',
+                       [ 'eps_user' => $user->getId() ]
+               );
+               foreach ( $subscriptions as $subscriptionJSON ) {
+                       $subscription = FormatJson::decode( $subscriptionJSON );
+                       $webPush->sendNotification(
+                               $subscription->endpoint,
+                               FormatJson::encode( $payload ),
+                               $subscription->keys->p256dh,
+                               $subscription->keys->auth,
+                               true
+                       );
+               }
+               $webPush->flush();
+       }
+
        /**
         * Send a Notification to a user by email
         *
diff --git a/includes/api/ApiEchoPushSubscribe.php 
b/includes/api/ApiEchoPushSubscribe.php
new file mode 100644
index 0000000..447d35f
--- /dev/null
+++ b/includes/api/ApiEchoPushSubscribe.php
@@ -0,0 +1,60 @@
+<?php
+
+class ApiEchoPushSubscribe extends ApiBase {
+
+       public function execute() {
+               $user = $this->getUser();
+               if ( $user->isAnon() ) {
+                       $this->dieWithError( 'apierror-mustbeloggedin-generic', 
'login-required' );
+               }
+
+               $params = $this->extractRequestParams();
+               $subscription = FormatJson::decode( $params['subscriptionJSON'] 
);
+               if ( !$subscription ) {
+                       $this->dieWithError( 
'apierror-echo-invalid-subscription-json', 'invalid-json' );
+               }
+
+               $dbFactory = MWEchoDbFactory::newFromDefault();
+               $dbw = $dbFactory->getEchoDb( DB_MASTER );
+               $dbw->insert(
+                       'echo_push_subscription',
+                       [
+                               'eps_user' => $user->getId(),
+                               'eps_subscription' => FormatJson::encode( 
$subscription )
+                       ]
+               );
+
+               $this->getResult()->addValue( $this->getModuleName(), null, [ 
'result' => 'Success', 'id' => $dbw->insertId() ] );
+       }
+
+       public function getAllowedParams() {
+               return [
+                       'subscriptionJSON' => [
+                               ApiBase::PARAM_REQUIRED => true,
+                       ],
+                       'token' => [
+                               ApiBase::PARAM_REQUIRED => true,
+                       ],
+               ];
+       }
+
+       public function needsToken() {
+               return 'csrf';
+       }
+
+       public function getTokenSalt() {
+               return '';
+       }
+
+       public function mustBePosted() {
+               return true;
+       }
+
+       public function isWriteMode() {
+               return true;
+       }
+
+       public function getHelpUrls() {
+               return 
'https://www.mediawiki.org/wiki/Echo_(Notifications)/API';
+       }
+}
diff --git a/includes/formatters/EchoIcon.php b/includes/formatters/EchoIcon.php
index 74b90f6..2e3fa4d 100644
--- a/includes/formatters/EchoIcon.php
+++ b/includes/formatters/EchoIcon.php
@@ -72,11 +72,11 @@
                // rasterizing module
                if ( $url === false || $url === null ) {
                        $iconUrl = wfScript( 'load' ) . '?' . wfArrayToCgi( [
-                                       'modules' => 'ext.echo.emailicons',
-                                       'image' => $icon,
-                                       'lang' => $lang,
-                                       'format' => 'rasterized'
-                               ] );
+                               'modules' => 'ext.echo.emailicons',
+                               'image' => $icon,
+                               'lang' => $lang,
+                               'format' => 'rasterized'
+                       ] );
                } else {
                        // For icons that are defined by URL
                        $iconUrl = $wgEchoNotificationIcons[ $icon ][ 'url' ];
@@ -85,4 +85,18 @@
                return $iconUrl;
        }
 
+       public static function getRasterizedSecondaryIconUrl( $icon, $lang ) {
+               global $wgEchoSecondaryIcons;
+               if ( isset( $wgEchoSecondaryIcons[ $icon ] ) ) {
+                       $module = 'ext.echo.secondaryicons';
+               } else {
+                       $module = 'oojs-ui.styles.icons-user'; // HACK HACK HACK
+               }
+               return wfScript( 'load' ) . '?' . wfArrayToCgi( [
+                       'modules' => $module,
+                       'image' => $icon,
+                       'lang' => $lang,
+                       'format' => 'rasterized'
+               ] );
+       }
 }
diff --git a/modules/ext.echo.init.js b/modules/ext.echo.init.js
index b4b82f9..815cea5 100644
--- a/modules/ext.echo.init.js
+++ b/modules/ext.echo.init.js
@@ -132,4 +132,50 @@
                } );
        } );
 
+       // TODO clean up
+       function urlBase64ToUint8Array( base64String ) {
+               var i,
+                       padding = '='.repeat( ( 4 - base64String.length % 4 ) % 
4 ),
+                       base64 = ( base64String + padding )
+                               .replace( /\-/g, '+' )
+                               .replace( /_/g, '/' ),
+                       rawData = window.atob( base64 ),
+                       /* global Uint8Array:false */
+                       outputArray = new Uint8Array( rawData.length );
+
+               for ( i = 0; i < rawData.length; i++ ) {
+                       outputArray[ i ] = rawData.charCodeAt( i );
+               }
+               return outputArray;
+       }
+
+       // Set up push notification stuff
+       if ( !( 'serviceWorker' in navigator ) || !( 'PushManager' in window ) 
) {
+               return;
+       }
+
+       navigator.serviceWorker.register( mw.config.get( 
'wgExtensionAssetsPath' ) + '/Echo/modules/ext.echo.serviceWorker.js' )
+               .then( function ( registration ) {
+                       // TODO fall back to Notifications.permission if needed
+                       return navigator.permissions.query( { name: 
'notifications' } )
+                               .then( function ( result ) {
+                                       if ( result.state !== 'prompt' ) {
+                                               return null;
+                                       }
+                                       return 
registration.pushManager.subscribe( {
+                                               userVisibleOnly: true,
+                                               applicationServerKey: 
urlBase64ToUint8Array( mw.config.get( 'wgEchoVAPIDPublicKey' ) )
+                                       } );
+                               } );
+               } )
+               .then( function ( subscription ) {
+                       if ( !subscription ) {
+                               return null;
+                       }
+                       return new mw.Api().postWithToken( 'csrf', {
+                               action: 'echopushsubscribe',
+                               subscriptionJSON: JSON.stringify( subscription )
+                       } );
+               } );
+
 }( mediaWiki, jQuery ) );
diff --git a/modules/ext.echo.serviceWorker.js 
b/modules/ext.echo.serviceWorker.js
new file mode 100644
index 0000000..2d47132
--- /dev/null
+++ b/modules/ext.echo.serviceWorker.js
@@ -0,0 +1,26 @@
+/* global clients:false */
+self.addEventListener( 'push', function ( event ) {
+       var data = event.data.json();
+       event.waitUntil(
+               // TODO have server serve stuff in the right format
+               self.registration.showNotification( data.title, {
+                       data: data,
+                       body: data.body,
+                       icon: data.icon,
+                       badge: data.badge,
+                       actions: data.actions
+               } )
+       );
+} );
+
+self.addEventListener( 'notificationclick', function ( event ) {
+       var data = event.notification.data,
+               url = data.url;
+       if ( event.action in data.actions ) {
+               url = data.actions[ event.action ].url;
+       }
+       event.notification.close();
+       event.waitUntil(
+               clients.openWindow( url )
+       );
+} );

-- 
To view, visit https://gerrit.wikimedia.org/r/371615
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: Ied7b878291a63989d716a0f233be76b21453ba3c
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/Echo
Gerrit-Branch: master
Gerrit-Owner: Catrope <r...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to