Version 4.6.0

This commit is contained in:
Neo committed 2025-12-19 05:55:31 -08:00
1 parent f79dcf067a
commit 517a5e1f70
2036 files changed
+110041 -26162

No files matched your search

+249 -6
View File
@@ -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
*