Version 4.6.0
This commit is contained in:
1 parent
f79dcf067a
commit
517a5e1f70
2036 files changed
+110041
-26162
No files matched your search
@@ -26,7 +26,80 @@ class _Notification
|
||||
* @brief Default Configuration
|
||||
*/
|
||||
protected static $defaultConfiguration = NULL;
|
||||
|
||||
|
||||
/**
|
||||
* @brief Cache of vapid headers to save processing time
|
||||
*/
|
||||
protected static $vapidHeaderCache = array();
|
||||
|
||||
/**
|
||||
* @brief Pre-defined web push TTL values
|
||||
*/
|
||||
const TTL_IMMEDIATE = 0;
|
||||
const TTL_SHORT = 120; // 2 minutes
|
||||
const TTL_MEDIUM = 21600; // 6 hours
|
||||
const TTL_LONG = 86400; // 1 day
|
||||
|
||||
/**
|
||||
* @brief Urgency values
|
||||
*/
|
||||
const URGENCY_VERY_LOW = 'very-low';
|
||||
const URGENCY_LOW = 'low';
|
||||
const URGENCY_NORMAL = 'normal';
|
||||
const URGENCY_HIGH = 'high';
|
||||
|
||||
/**
|
||||
* @brief Allow notifications to be programatically silenced
|
||||
*/
|
||||
protected static $silenced = FALSE;
|
||||
|
||||
/**
|
||||
* Silence notifications programatically
|
||||
* Useful when performing batch operations that would usually send a notification
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function silence()
|
||||
{
|
||||
static::$silenced = TRUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* If you don't possess a dictionary, this is the oppopsite of silence.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function unsilence()
|
||||
{
|
||||
static::$silenced = FALSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do we have the server things we need for web push?
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public static function canUseWebPush(): bool
|
||||
{
|
||||
if ( \IPS\IN_DEV or \IPS\CIC2 )
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
return \function_exists('gmp_init');
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this site have web push notifications enabled
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public static function webPushEnabled(): bool
|
||||
{
|
||||
return static::canUseWebPush() and ( ( isset( \IPS\Settings::i()->vapid_public_key ) && !empty( \IPS\Settings::i()->vapid_public_key ) &&
|
||||
isset( \IPS\Settings::i()->vapid_private_key ) && !empty( \IPS\Settings::i()->vapid_private_key ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration
|
||||
*
|
||||
@@ -143,7 +216,7 @@ class _Notification
|
||||
{
|
||||
$options = array();
|
||||
$availableTypes = array('inline');
|
||||
if ( \IPS\Settings::i()->mobile_app_id and $member->members_bitoptions['mobile_notifications'] )
|
||||
if ( ( \IPS\Settings::i()->mobile_app_id and $member->members_bitoptions['mobile_notifications'] ) || ( static::webPushEnabled() && \count( $member->getPwaAuths() ) ) )
|
||||
{
|
||||
$availableTypes[] = 'push';
|
||||
}
|
||||
@@ -354,6 +427,8 @@ class _Notification
|
||||
'notification_key' => $notificationKey,
|
||||
'preference' => implode( ',', $value )
|
||||
), TRUE );
|
||||
|
||||
$member->notificationsConfiguration[ $notificationKey ] = $value;
|
||||
}
|
||||
}
|
||||
elseif ( $details['type'] === 'custom' and ( !isset( $details['adminOnly'] ) or !$details['adminOnly'] ) )
|
||||
@@ -465,6 +540,11 @@ class _Notification
|
||||
*/
|
||||
public function send( $sentTo = array() )
|
||||
{
|
||||
if ( static::$silenced === TRUE )
|
||||
{
|
||||
return array();
|
||||
}
|
||||
|
||||
/* Make a placeholder for emails - we'll need to generate one per language */
|
||||
$emails = array();
|
||||
$emailRecipients = array();
|
||||
@@ -747,15 +827,14 @@ class _Notification
|
||||
}
|
||||
}
|
||||
|
||||
$notification->extra = $this->inlineExtra;
|
||||
|
||||
$notification->extra = $this->inlineExtra;
|
||||
$notification->save();
|
||||
|
||||
$sentTo[ $member->member_id ][] = 'inline';
|
||||
}
|
||||
|
||||
/* They want to receive a mobile app push notification (we don't send until the end once we've collated all the notifications to send) */
|
||||
if ( \IPS\Settings::i()->mobile_app_id and $member->members_bitoptions['mobile_notifications'] and isset( $notificationPreferences[ $keyToCheck ] ) AND \in_array( 'push', $notificationPreferences[ $keyToCheck ] ) and ( !isset( $sentTo[ $member->member_id ] ) or !\in_array( 'push', $sentTo[ $member->member_id ] ) ) and $licenseKey = \IPS\IPS::licenseKey() and $licenseKey['active'] )
|
||||
/* They want to receive a push notification (we don't send until the end once we've collated all the notifications to send) */
|
||||
if ( ( ( \IPS\Settings::i()->mobile_app_id and $member->members_bitoptions['mobile_notifications'] ) || ( static::webPushEnabled() && \count( $member->getPwaAuths() ) ) ) and isset( $notificationPreferences[ $keyToCheck ] ) AND \in_array( 'push', $notificationPreferences[ $keyToCheck ] ) and ( !isset( $sentTo[ $member->member_id ] ) or !\in_array( 'push', $sentTo[ $member->member_id ] ) ) and $licenseKey = \IPS\IPS::licenseKey() and $licenseKey['active'] )
|
||||
{
|
||||
$language = $member->language();
|
||||
if ( !isset( $pushNotifications[ $language->id ] ) )
|
||||
@@ -856,6 +935,52 @@ class _Notification
|
||||
*/
|
||||
protected function sendPushNotifications( $pushNotifications )
|
||||
{
|
||||
/* Send PWA notifications */
|
||||
$pwaPushNotifications = [];
|
||||
|
||||
foreach ( $pushNotifications as $memberId => $data )
|
||||
{
|
||||
$data = json_encode( array(
|
||||
'member' => $memberId,
|
||||
'title' => static::textForPushNotification( $data['title'] ?? \IPS\Settings::i()->board_name ),
|
||||
'body' => static::textForPushNotification( $data['body'] ),
|
||||
'url' => (string) $data['data']['url'],
|
||||
'icon' => $data['icon'] ?? NULL,
|
||||
'image' => $data['image'] ?? NULL,
|
||||
'tag' => $data['tag'] ?? NULL,
|
||||
'grouped' => static::textForPushNotification( $data['data']['grouped'] ) ?? NULL,
|
||||
'groupedTitle' => static::textForPushNotification( $data['data']['groupedTitle'] ) ?? NULL,
|
||||
'groupedUrl' => isset( $data['data']['groupedUrl'] ) ? (string) $data['data']['groupedUrl'] : NULL
|
||||
) );
|
||||
|
||||
$notificationId = \IPS\Db::i()->insert( 'core_notifications_pwa_queue', array( 'notification_data' => $data, 'expiration' => \IPS\DateTime::ts( time() )->add( new \DateInterval('P1D') )->getTimestamp() ) );
|
||||
|
||||
$pwaPushNotifications[ $memberId ] = [
|
||||
'member' => $memberId,
|
||||
'data' => array(
|
||||
'id' => $notificationId
|
||||
),
|
||||
'TTL' => $data['TTL'] ?? static::TTL_SHORT,
|
||||
'urgency' => $data['urgency'] ?? static::URGENCY_NORMAL
|
||||
];
|
||||
}
|
||||
|
||||
$memberIds = \array_keys( $pwaPushNotifications );
|
||||
$count = \IPS\Db::i()->select( 'COUNT(*)', 'core_notifications_pwa_keys', \IPS\Db::i()->in( '`member`', $memberIds ) )->first();
|
||||
|
||||
if ( $count )
|
||||
{
|
||||
if ( $count == 1 )
|
||||
{
|
||||
static::sendPWANotifications( $pwaPushNotifications );
|
||||
}
|
||||
else
|
||||
{
|
||||
\IPS\Task::queue( 'core', 'PWANotifications', $pwaPushNotifications, 2 );
|
||||
}
|
||||
}
|
||||
|
||||
/* Send mobile app notifications (send in batches of 100 to prevent a single massive request) */
|
||||
$limit = 100;
|
||||
$offset = 0;
|
||||
while ( $slice = \array_slice( $pushNotifications, $offset, $limit ) )
|
||||
@@ -890,7 +1015,125 @@ class _Notification
|
||||
$offset += $limit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a VAPID key pair for web push notification support
|
||||
*
|
||||
* @return array Contains public and private keys
|
||||
*/
|
||||
public static function generateVapidKeys(): array
|
||||
{
|
||||
\IPS\IPS::$PSR0Namespaces['Jose'] = \IPS\ROOT_PATH . '/system/3rd_party/JwtFramework/src';
|
||||
\IPS\IPS::$PSR0Namespaces['Minishlink'] = \IPS\ROOT_PATH . '/system/3rd_party/Minishlink';
|
||||
\IPS\IPS::$PSR0Namespaces['Base64Url'] = \IPS\ROOT_PATH .'/system/3rd_party/Base64Url';
|
||||
\IPS\IPS::$PSR0Namespaces['Brick'] = \IPS\ROOT_PATH . '/system/3rd_party/Brick';
|
||||
|
||||
return \Minishlink\WebPush\VAPID::createVapidKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send PWA notifications
|
||||
*
|
||||
* @param array $data PWA notifications to send
|
||||
* @return void
|
||||
*/
|
||||
public static function sendPWANotifications( $data )
|
||||
{
|
||||
\IPS\IPS::$PSR0Namespaces['Jose'] = \IPS\ROOT_PATH . '/system/3rd_party/JwtFramework/src';
|
||||
\IPS\IPS::$PSR0Namespaces['Minishlink'] = \IPS\ROOT_PATH . '/system/3rd_party/Minishlink';
|
||||
\IPS\IPS::$PSR0Namespaces['Base64Url'] = \IPS\ROOT_PATH .'/system/3rd_party/Base64Url';
|
||||
\IPS\IPS::$PSR0Namespaces['Brick'] = \IPS\ROOT_PATH . '/system/3rd_party/Brick';
|
||||
|
||||
// Step 1: Validate VAPID details
|
||||
$vapid = \Minishlink\WebPush\VAPID::validate( array(
|
||||
'subject' => "mailto:" . \IPS\Settings::i()->email_in,
|
||||
'publicKey' => \IPS\Settings::i()->vapid_public_key,
|
||||
'privateKey' => \IPS\Settings::i()->vapid_private_key
|
||||
) );
|
||||
|
||||
foreach ( \IPS\Db::i()->select( '*', 'core_notifications_pwa_keys', \IPS\Db::i()->in( '`member`', array_keys( $data ) ) ) as $auth )
|
||||
{
|
||||
/* Check device is logged in */
|
||||
try
|
||||
{
|
||||
$device = \IPS\Member\Device::load( $auth['device'] );
|
||||
if ( !$device->login_key )
|
||||
{
|
||||
/* Is not, continue */
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch( \OutOfRangeException $e )
|
||||
{
|
||||
/* Device does not exist so delete & skip */
|
||||
\IPS\Db::i()->delete( 'core_notifications_pwa_keys', array( 'device=?', $auth['device'] ) );
|
||||
continue;
|
||||
}
|
||||
|
||||
$endpoint = \IPS\Http\Url::external( $auth['endpoint'] );
|
||||
|
||||
// Step 2: Content encoding (provided by browser)
|
||||
$contentEncoding = $auth['encoding'];
|
||||
|
||||
// Step 3: Get and pad the payload
|
||||
$payload = json_encode( $data[ $auth['member'] ]['data'] );
|
||||
$payload = \Minishlink\WebPush\Encryption::padPayload( $payload, \Minishlink\WebPush\Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH, $contentEncoding );
|
||||
|
||||
// Step 4: Build Vapid headers
|
||||
$audience = $endpoint->data[ \IPS\Http\Url::COMPONENT_SCHEME ] . "://" . $endpoint->data[ \IPS\Http\Url::COMPONENT_HOST ];
|
||||
$cacheKey = implode( '#', array( $audience, $contentEncoding, md5(json_encode($vapid)) ) );
|
||||
|
||||
if( isset( static::$vapidHeaderCache[ $cacheKey ] ) )
|
||||
{
|
||||
$vapidHeaders = static::$vapidHeaderCache[ $cacheKey ];
|
||||
}
|
||||
else
|
||||
{
|
||||
$vapidHeaders = \Minishlink\WebPush\VAPID::getVapidHeaders( $audience, $vapid['subject'], $vapid['publicKey'], $vapid['privateKey'], $contentEncoding );
|
||||
static::$vapidHeaderCache[ $cacheKey ] = $vapidHeaders;
|
||||
}
|
||||
|
||||
// Step 5: Encrypt the payload with user's keys
|
||||
$encryptedPayload = \Minishlink\WebPush\Encryption::encrypt( $payload, $auth['p256dh'], $auth['auth'], $contentEncoding );
|
||||
$salt = $encryptedPayload['salt'];
|
||||
$localPublicKey = $encryptedPayload['localPublicKey'];
|
||||
$cipherText = $encryptedPayload['cipherText'];
|
||||
|
||||
// Step 6: Get the content coding header and prepend it to the content
|
||||
$encryptionContentCodingHeader = \Minishlink\WebPush\Encryption::getContentCodingHeader($salt, $localPublicKey, $contentEncoding);
|
||||
$content = $encryptionContentCodingHeader.$cipherText;
|
||||
|
||||
// Step 7: Set headers
|
||||
$headers = array();
|
||||
$headers['Content-Type'] = 'application/octet-stream';
|
||||
$headers['Content-Encoding'] = $contentEncoding;
|
||||
$headers['TTL'] = $data['TTL'] ?? static::TTL_MEDIUM;
|
||||
|
||||
if( isset( $data['urgency'] ) )
|
||||
{
|
||||
$headers['urgency'] = $data['urgency'];
|
||||
}
|
||||
|
||||
if ( $contentEncoding === "aesgcm" )
|
||||
{
|
||||
$headers['Encryption'] = 'salt=' . \Base64Url\Base64Url::encode($salt);
|
||||
$headers['Crypto-Key'] = 'dh=' . \Base64Url\Base64Url::encode($localPublicKey) . ';' . $vapidHeaders['Crypto-Key'];
|
||||
}
|
||||
|
||||
$headers['Content-Length'] = \Minishlink\WebPush\Utils::safeStrlen($content);
|
||||
$headers['Authorization'] = $vapidHeaders['Authorization'];
|
||||
|
||||
// Step 8: Send the damn thing
|
||||
$response = $endpoint->request()->setHeaders( $headers )->post( $content );
|
||||
|
||||
// Step 9: Check the response - 404/410 indicates a permanent problem, so delete that key
|
||||
if( \in_array( $response->httpResponseCode, array( 404, 410 ) ) )
|
||||
{
|
||||
\IPS\Db::i()->delete( 'core_notifications_pwa_keys', array('id = ?', $auth['id'] ) );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HTML to plaintext for use in notifications
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user