You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
368 lines
12 KiB
368 lines
12 KiB
<?php |
|
/** |
|
* @copyright Copyright (c) 2017 Joas Schilling <coding@schilljs.com> |
|
* |
|
* @license GNU AGPL version 3 or any later version |
|
* |
|
* This program is free software: you can redistribute it and/or modify |
|
* it under the terms of the GNU Affero General Public License as |
|
* published by the Free Software Foundation, either version 3 of the |
|
* License, or (at your option) any later version. |
|
* |
|
* This program is distributed in the hope that it will be useful, |
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
* GNU Affero General Public License for more details. |
|
* |
|
* You should have received a copy of the GNU Affero General Public License |
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
* |
|
*/ |
|
|
|
namespace OCA\Notifications; |
|
|
|
|
|
use OC\Authentication\Exceptions\InvalidTokenException; |
|
use OC\Authentication\Token\IProvider; |
|
use OC\Security\IdentityProof\Key; |
|
use OC\Security\IdentityProof\Manager; |
|
use OCP\AppFramework\Http; |
|
use OCP\DB\QueryBuilder\IQueryBuilder; |
|
use OCP\Http\Client\IClientService; |
|
use OCP\IConfig; |
|
use OCP\IDBConnection; |
|
use OCP\ILogger; |
|
use OCP\IUser; |
|
use OCP\IUserManager; |
|
use OCP\Notification\IManager as INotificationManager; |
|
use OCP\Notification\INotification; |
|
|
|
class Push { |
|
/** @var IDBConnection */ |
|
protected $db; |
|
/** @var INotificationManager */ |
|
protected $notificationManager; |
|
/** @var IConfig */ |
|
protected $config; |
|
/** @var IProvider */ |
|
protected $tokenProvider; |
|
/** @var Manager */ |
|
private $keyManager; |
|
/** @var IUserManager */ |
|
private $userManager; |
|
/** @var IClientService */ |
|
protected $clientService; |
|
/** @var ILogger */ |
|
protected $log; |
|
|
|
public function __construct(IDBConnection $connection, INotificationManager $notificationManager, IConfig $config, IProvider $tokenProvider, Manager $keyManager, IUserManager $userManager, IClientService $clientService, ILogger $log) { |
|
$this->db = $connection; |
|
$this->notificationManager = $notificationManager; |
|
$this->config = $config; |
|
$this->tokenProvider = $tokenProvider; |
|
$this->keyManager = $keyManager; |
|
$this->userManager = $userManager; |
|
$this->clientService = $clientService; |
|
$this->log = $log; |
|
} |
|
|
|
public function pushToDevice(int $id, INotification $notification): void { |
|
$user = $this->userManager->get($notification->getUser()); |
|
if (!($user instanceof IUser)) { |
|
return; |
|
} |
|
|
|
$devices = $this->getDevicesForUser($notification->getUser()); |
|
if (empty($devices)) { |
|
return; |
|
} |
|
|
|
$language = $this->config->getSystemValue('force_language', false); |
|
$language = \is_string($language) ? $language : $this->config->getUserValue($notification->getUser(), 'core', 'lang', null); |
|
$language = $language ?? $this->config->getSystemValue('default_language', 'en'); |
|
try { |
|
$this->notificationManager->setPreparingPushNotification(true); |
|
$notification = $this->notificationManager->prepare($notification, $language); |
|
} catch (\InvalidArgumentException $e) { |
|
return; |
|
} finally { |
|
$this->notificationManager->setPreparingPushNotification(false); |
|
} |
|
|
|
$userKey = $this->keyManager->getKey($user); |
|
|
|
$isTalkNotification = \in_array($notification->getApp(), ['spreed', 'talk'], true); |
|
$talkApps = array_filter($devices, function($device) { |
|
return $device['apptype'] === 'talk'; |
|
}); |
|
$hasTalkApps = !empty($talkApps); |
|
|
|
$pushNotifications = []; |
|
foreach ($devices as $device) { |
|
if (!$isTalkNotification && $device['apptype'] === 'talk') { |
|
// The iOS app can not kill notifications, |
|
// therefor we should only send relevant notifications to the Talk |
|
// app, so it does not pollute the notifications bar with useless |
|
// notifications, especially when the Sync client app is also installed. |
|
continue; |
|
} |
|
if ($isTalkNotification && $hasTalkApps && $device['apptype'] !== 'talk') { |
|
// Similar to the previous case, we also don't send Talk notifications |
|
// to the Sync client app, when there is a Talk app installed. We only |
|
// do this, when you don't have a Talk app on your device, so you still |
|
// get the push notification. |
|
continue; |
|
} |
|
|
|
try { |
|
$payload = json_encode($this->encryptAndSign($userKey, $device, $id, $notification, $isTalkNotification)); |
|
|
|
$proxyServer = rtrim($device['proxyserver'], '/'); |
|
if (!isset($pushNotifications[$proxyServer])) { |
|
$pushNotifications[$proxyServer] = []; |
|
} |
|
$pushNotifications[$proxyServer][] = $payload; |
|
} catch (InvalidTokenException $e) { |
|
// Token does not exist anymore, should drop the push device entry |
|
$this->deletePushToken($device['token']); |
|
} catch (\InvalidArgumentException $e) { |
|
// Failed to encrypt message for device: public key is invalid |
|
$this->deletePushToken($device['token']); |
|
} |
|
} |
|
|
|
if (empty($pushNotifications)) { |
|
return; |
|
} |
|
|
|
$client = $this->clientService->newClient(); |
|
foreach ($pushNotifications as $proxyServer => $notifications) { |
|
try { |
|
$response = $client->post($proxyServer . '/notifications', [ |
|
'body' => [ |
|
'notifications' => $notifications, |
|
], |
|
]); |
|
} catch (\Exception $e) { |
|
$this->log->logException($e, [ |
|
'app' => 'notifications', |
|
'level' => $e->getCode() === Http::STATUS_BAD_REQUEST ? ILogger::INFO : ILogger::WARN, |
|
]); |
|
continue; |
|
} |
|
|
|
$status = $response->getStatusCode(); |
|
if ($status !== Http::STATUS_OK && $status !== Http::STATUS_SERVICE_UNAVAILABLE) { |
|
$body = $response->getBody(); |
|
$this->log->error('Could not send notification to push server [{url}]: {error}',[ |
|
'error' => \is_string($body) ? $body : 'no reason given', |
|
'url' => $proxyServer, |
|
'app' => 'notifications', |
|
]); |
|
} else if ($status === Http::STATUS_SERVICE_UNAVAILABLE && $this->config->getSystemValue('debug', false)) { |
|
$body = $response->getBody(); |
|
$this->log->debug('Could not send notification to push server [{url}]: {error}',[ |
|
'error' => \is_string($body) ? $body : 'no reason given', |
|
'url' => $proxyServer, |
|
'app' => 'notifications', |
|
]); |
|
} |
|
} |
|
} |
|
|
|
public function pushDeleteToDevice(string $userId, int $notificationId): void { |
|
$user = $this->userManager->get($userId); |
|
if (!($user instanceof IUser)) { |
|
return; |
|
} |
|
|
|
$devices = $this->getDevicesForUser($userId); |
|
if (empty($devices)) { |
|
return; |
|
} |
|
|
|
$userKey = $this->keyManager->getKey($user); |
|
$pushNotifications = []; |
|
foreach ($devices as $device) { |
|
try { |
|
$payload = json_encode($this->encryptAndSignDelete($userKey, $device, $notificationId)); |
|
|
|
$proxyServer = rtrim($device['proxyserver'], '/'); |
|
if (!isset($pushNotifications[$proxyServer])) { |
|
$pushNotifications[$proxyServer] = []; |
|
} |
|
$pushNotifications[$proxyServer][] = $payload; |
|
} catch (InvalidTokenException $e) { |
|
// Token does not exist anymore, should drop the push device entry |
|
$this->deletePushToken($device['token']); |
|
} catch (\InvalidArgumentException $e) { |
|
// Failed to encrypt message for device: public key is invalid |
|
$this->deletePushToken($device['token']); |
|
} |
|
} |
|
|
|
if (empty($pushNotifications)) { |
|
return; |
|
} |
|
|
|
$client = $this->clientService->newClient(); |
|
foreach ($pushNotifications as $proxyServer => $notifications) { |
|
try { |
|
$response = $client->post($proxyServer . '/notifications', [ |
|
'body' => [ |
|
'notifications' => $notifications, |
|
], |
|
]); |
|
} catch (\Exception $e) { |
|
$this->log->logException($e, [ |
|
'app' => 'notifications', |
|
'level' => $e->getCode() === Http::STATUS_BAD_REQUEST ? ILogger::INFO : ILogger::WARN, |
|
]); |
|
continue; |
|
} |
|
|
|
$status = $response->getStatusCode(); |
|
if ($status !== Http::STATUS_OK && $status !== Http::STATUS_SERVICE_UNAVAILABLE) { |
|
$body = $response->getBody(); |
|
$this->log->error('Could not send notification to push server [{url}]: {error}',[ |
|
'error' => \is_string($body) ? $body : 'no reason given', |
|
'url' => $proxyServer, |
|
'app' => 'notifications', |
|
]); |
|
} else if ($status === Http::STATUS_SERVICE_UNAVAILABLE && $this->config->getSystemValue('debug', false)) { |
|
$body = $response->getBody(); |
|
$this->log->debug('Could not send notification to push server [{url}]: {error}',[ |
|
'error' => \is_string($body) ? $body : 'no reason given', |
|
'url' => $proxyServer, |
|
'app' => 'notifications', |
|
]); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* @param Key $userKey |
|
* @param array $device |
|
* @param int $id |
|
* @param INotification $notification |
|
* @param bool $isTalkNotification |
|
* @return array |
|
* @throws InvalidTokenException |
|
* @throws \InvalidArgumentException |
|
*/ |
|
protected function encryptAndSign(Key $userKey, array $device, int $id, INotification $notification, bool $isTalkNotification): array { |
|
// Check if the token is still valid... |
|
$this->tokenProvider->getTokenById($device['token']); |
|
|
|
$data = [ |
|
'nid' => $id, |
|
'app' => $notification->getApp(), |
|
'subject' => '', |
|
'type' => $notification->getObjectType(), |
|
'id' => $notification->getObjectId(), |
|
]; |
|
|
|
// Max length of encryption is 255, so we need to shorten the subject to be shorter |
|
$subject = $notification->getParsedSubject(); |
|
// Half the length for multibyte characters like Russian, Chinese, Japanese, Emojis, … |
|
$dataLength = floor((245 - strlen(json_encode($data))) / 2) - 1; |
|
if (strlen($subject) > $dataLength) { |
|
$data['subject'] = mb_substr($subject, 0, $dataLength) . '…'; |
|
} else { |
|
$data['subject'] = $subject; |
|
} |
|
|
|
if ($isTalkNotification) { |
|
$priority = 'high'; |
|
} else { |
|
$priority = 'normal'; |
|
} |
|
|
|
if (!openssl_public_encrypt(json_encode($data), $encryptedSubject, $device['devicepublickey'], OPENSSL_PKCS1_PADDING)) { |
|
$this->log->error(openssl_error_string(), ['app' => 'notifications']); |
|
throw new \InvalidArgumentException('Failed to encrypt message for device'); |
|
} |
|
|
|
openssl_sign($encryptedSubject, $signature, $userKey->getPrivate(), OPENSSL_ALGO_SHA512); |
|
$base64EncryptedSubject = base64_encode($encryptedSubject); |
|
$base64Signature = base64_encode($signature); |
|
|
|
return [ |
|
'deviceIdentifier' => $device['deviceidentifier'], |
|
'pushTokenHash' => $device['pushtokenhash'], |
|
'subject' => $base64EncryptedSubject, |
|
'signature' => $base64Signature, |
|
'priority' => $priority, |
|
]; |
|
} |
|
|
|
/** |
|
* @param Key $userKey |
|
* @param array $device |
|
* @param int $id |
|
* @return array |
|
* @throws InvalidTokenException |
|
* @throws \InvalidArgumentException |
|
*/ |
|
protected function encryptAndSignDelete(Key $userKey, array $device, int $id): array { |
|
// Check if the token is still valid... |
|
$this->tokenProvider->getTokenById($device['token']); |
|
|
|
if ($id === 0) { |
|
$data = [ |
|
'delete-all' => true, |
|
]; |
|
} else { |
|
$data = [ |
|
'nid' => $id, |
|
'delete' => true, |
|
]; |
|
} |
|
|
|
if (!openssl_public_encrypt(json_encode($data), $encryptedSubject, $device['devicepublickey'], OPENSSL_PKCS1_PADDING)) { |
|
$this->log->error(openssl_error_string(), ['app' => 'notifications']); |
|
throw new \InvalidArgumentException('Failed to encrypt message for device'); |
|
} |
|
|
|
openssl_sign($encryptedSubject, $signature, $userKey->getPrivate(), OPENSSL_ALGO_SHA512); |
|
$base64EncryptedSubject = base64_encode($encryptedSubject); |
|
$base64Signature = base64_encode($signature); |
|
|
|
return [ |
|
'deviceIdentifier' => $device['deviceidentifier'], |
|
'pushTokenHash' => $device['pushtokenhash'], |
|
'subject' => $base64EncryptedSubject, |
|
'signature' => $base64Signature, |
|
'priority' => 'normal', |
|
]; |
|
} |
|
|
|
/** |
|
* @param string $uid |
|
* @return array[] |
|
*/ |
|
protected function getDevicesForUser(string $uid): array { |
|
$query = $this->db->getQueryBuilder(); |
|
$query->select('*') |
|
->from('notifications_pushtokens') |
|
->where($query->expr()->eq('uid', $query->createNamedParameter($uid))); |
|
|
|
$result = $query->execute(); |
|
$devices = $result->fetchAll(); |
|
$result->closeCursor(); |
|
|
|
return $devices; |
|
} |
|
|
|
/** |
|
* @param int $tokenId |
|
* @return bool |
|
*/ |
|
protected function deletePushToken(int $tokenId): bool { |
|
$query = $this->db->getQueryBuilder(); |
|
$query->delete('notifications_pushtokens') |
|
->where($query->expr()->eq('token', $query->createNamedParameter($tokenId, IQueryBuilder::PARAM_INT))); |
|
|
|
return $query->execute() !== 0; |
|
} |
|
}
|
|
|