*
* @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 .
*
*/
namespace OCA\Notifications;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
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\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\L10N\IFactory;
use OCP\Notification\IManager as INotificationManager;
use OCP\Notification\INotification;
use OCP\UserStatus\IManager as IUserStatusManager;
use OCP\UserStatus\IUserStatus;
use OCP\Util;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Push {
/** @var IDBConnection */
protected $db;
/** @var INotificationManager */
protected $notificationManager;
/** @var IConfig */
protected $config;
/** @var IProvider */
protected $tokenProvider;
/** @var Manager */
private $keyManager;
/** @var IClientService */
protected $clientService;
/** @var ICache */
protected $cache;
/** @var IUserStatusManager */
protected $userStatusManager;
/** @var IFactory */
protected $l10nFactory;
/** @var LoggerInterface */
protected $log;
/** @var OutputInterface */
protected $output;
/** @var array */
protected $payloadsToSend = [];
/** @var bool */
protected $deferPreparing = false;
/** @var bool */
protected $deferPayloads = false;
/** @var array[] */
protected $deletesToPush = [];
/** @var INotification[] */
protected $notificationsToPush = [];
/** @var null[]|IUserStatus[] */
protected $userStatuses = [];
/** @var array[] */
protected $userDevices = [];
/** @var string[] */
protected $loadDevicesForUsers = [];
/** @var string[] */
protected $loadStatusForUsers = [];
public function __construct(IDBConnection $connection,
INotificationManager $notificationManager,
IConfig $config,
IProvider $tokenProvider,
Manager $keyManager,
IClientService $clientService,
ICacheFactory $cacheFactory,
IUserStatusManager $userStatusManager,
IFactory $l10nFactory,
LoggerInterface $log) {
$this->db = $connection;
$this->notificationManager = $notificationManager;
$this->config = $config;
$this->tokenProvider = $tokenProvider;
$this->keyManager = $keyManager;
$this->clientService = $clientService;
$this->cache = $cacheFactory->createDistributed('pushtokens');
$this->userStatusManager = $userStatusManager;
$this->l10nFactory = $l10nFactory;
$this->log = $log;
}
public function setOutput(OutputInterface $output): void {
$this->output = $output;
}
protected function printInfo(string $message): void {
if ($this->output) {
$this->output->writeln($message);
}
}
public function isDeferring(): bool {
return $this->deferPayloads;
}
public function deferPayloads(): void {
$this->deferPreparing = true;
$this->deferPayloads = true;
}
public function flushPayloads(): void {
$this->deferPreparing = false;
if (!empty($this->loadDevicesForUsers)) {
$this->loadDevicesForUsers = array_unique($this->loadDevicesForUsers);
$missingDevicesFor = array_diff($this->loadDevicesForUsers, array_keys($this->userDevices));
$newUserDevices = $this->getDevicesForUsers($missingDevicesFor);
foreach ($missingDevicesFor as $userId) {
$this->userDevices[$userId] = $newUserDevices[$userId] ?? [];
}
$this->loadDevicesForUsers = [];
}
if (!empty($this->loadStatusForUsers)) {
$this->loadStatusForUsers = array_unique($this->loadStatusForUsers);
$missingStatusFor = array_diff($this->loadStatusForUsers, array_keys($this->userStatuses));
$newUserStatuses = $this->userStatusManager->getUserStatuses($missingStatusFor);
foreach ($missingStatusFor as $userId) {
$this->userStatuses[$userId] = $newUserStatuses[$userId] ?? null;
}
$this->loadStatusForUsers = [];
}
if (!empty($this->notificationsToPush)) {
foreach ($this->notificationsToPush as $id => $notification) {
$this->pushToDevice($id, $notification);
}
$this->notificationsToPush = [];
}
if (!empty($this->deletesToPush)) {
foreach ($this->deletesToPush as $id => $data) {
$this->pushDeleteToDevice($data['userId'], $id, $data['app']);
}
$this->deletesToPush = [];
}
$this->deferPayloads = false;
$this->sendNotificationsToProxies();
}
public function filterDeviceList(array $devices, string $app): array {
$isTalkNotification = \in_array($app, ['spreed', 'talk', 'admin_notification_talk'], true);
$talkDevices = array_filter($devices, static function ($device) {
return $device['apptype'] === 'talk';
});
$otherDevices = array_filter($devices, static function ($device) {
return $device['apptype'] !== 'talk';
});
$this->printInfo('Identified ' . count($talkDevices) . ' Talk devices and ' . count($otherDevices) . ' others.');
if (!$isTalkNotification) {
if (empty($otherDevices)) {
// We only send file notifications to the files app.
// If you don't have such a device, bye!
return [];
}
return $otherDevices;
}
if (empty($talkDevices)) {
// If you don't have a talk device,
// we fall back to the files app.
return $otherDevices;
}
return $talkDevices;
}
public function pushToDevice(int $id, INotification $notification, ?OutputInterface $output = null): void {
if (!$this->config->getSystemValueBool('has_internet_connection', true)) {
return;
}
if ($this->deferPreparing) {
$this->notificationsToPush[$id] = clone $notification;
$this->loadDevicesForUsers[] = $notification->getUser();
$this->loadStatusForUsers[] = $notification->getUser();
return;
}
$user = $this->createFakeUserObject($notification->getUser());
if (!array_key_exists($notification->getUser(), $this->userStatuses)) {
$userStatus = $this->userStatusManager->getUserStatuses([
$notification->getUser(),
]);
$this->userStatuses[$notification->getUser()] = $userStatus[$notification->getUser()] ?? null;
}
if (isset($this->userStatuses[$notification->getUser()])) {
$userStatus = $this->userStatuses[$notification->getUser()];
if ($userStatus->getStatus() === IUserStatus::DND) {
$this->printInfo('User status is set to DND - no push notifications will be sent');
return;
}
}
if (!array_key_exists($notification->getUser(), $this->userDevices)) {
$devices = $this->getDevicesForUser($notification->getUser());
$this->userDevices[$notification->getUser()] = $devices;
} else {
$devices = $this->userDevices[$notification->getUser()];
}
if (empty($devices)) {
$this->printInfo('No devices found for user');
return;
}
$this->printInfo('Trying to push to ' . count($devices) . ' devices');
$this->printInfo('');
$language = $this->l10nFactory->getUserLanguage($user);
$this->printInfo('Language is set to ' . $language);
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);
$this->printInfo('Private user key size: ' . strlen($userKey->getPrivate()));
$this->printInfo('Public user key size: ' . strlen($userKey->getPublic()));
$isTalkNotification = \in_array($notification->getApp(), ['spreed', 'talk', 'admin_notification_talk'], true);
$devices = $this->filterDeviceList($devices, $notification->getApp());
if (empty($devices)) {
return;
}
// We don't push to devices that are older than 60 days
$maxAge = time() - 60 * 24 * 60 * 60;
foreach ($devices as $device) {
$device['token'] = (int) $device['token'];
$this->printInfo('');
$this->printInfo('Device token:' . $device['token']);
if (!$this->validateToken($device['token'], $maxAge)) {
// Token does not exist anymore
continue;
}
try {
$payload = json_encode($this->encryptAndSign($userKey, $device, $id, $notification, $isTalkNotification));
$proxyServer = rtrim($device['proxyserver'], '/');
if (!isset($this->payloadsToSend[$proxyServer])) {
$this->payloadsToSend[$proxyServer] = [];
}
$this->payloadsToSend[$proxyServer][] = $payload;
} catch (\InvalidArgumentException $e) {
// Failed to encrypt message for device: public key is invalid
$this->deletePushToken($device['token']);
}
}
if (!$this->deferPayloads) {
$this->sendNotificationsToProxies();
}
}
public function pushDeleteToDevice(string $userId, int $notificationId, string $app = ''): void {
if (!$this->config->getSystemValueBool('has_internet_connection', true)) {
return;
}
if ($this->deferPreparing) {
$this->deletesToPush[$notificationId] = ['userId' => $userId, 'app' => $app];
$this->loadDevicesForUsers[] = $userId;
return;
}
$user = $this->createFakeUserObject($userId);
if (!array_key_exists($userId, $this->userDevices)) {
$devices = $this->getDevicesForUser($userId);
$this->userDevices[$userId] = $devices;
} else {
$devices = $this->userDevices[$userId];
}
if ($notificationId !== 0 && $app !== '') {
// Only filter when it's not a single delete
$devices = $this->filterDeviceList($devices, $app);
}
if (empty($devices)) {
return;
}
// We don't push to devices that are older than 60 days
$maxAge = time() - 60 * 24 * 60 * 60;
$userKey = $this->keyManager->getKey($user);
foreach ($devices as $device) {
$device['token'] = (int) $device['token'];
if (!$this->validateToken($device['token'], $maxAge)) {
// Token does not exist anymore
continue;
}
try {
$payload = json_encode($this->encryptAndSignDelete($userKey, $device, $notificationId));
$proxyServer = rtrim($device['proxyserver'], '/');
if (!isset($this->payloadsToSend[$proxyServer])) {
$this->payloadsToSend[$proxyServer] = [];
}
$this->payloadsToSend[$proxyServer][] = $payload;
} catch (\InvalidArgumentException $e) {
// Failed to encrypt message for device: public key is invalid
$this->deletePushToken($device['token']);
}
}
if (!$this->deferPayloads) {
$this->sendNotificationsToProxies();
}
}
protected function sendNotificationsToProxies(): void {
$pushNotifications = $this->payloadsToSend;
$this->payloadsToSend = [];
if (empty($pushNotifications)) {
return;
}
if (!$this->notificationManager->isFairUseOfFreePushService()) {
/**
* We want to keep offering our push notification service for free, but large
* users overload our infrastructure. For this reason we have to rate-limit the
* use of push notifications. If you need this feature, consider using Nextcloud Enterprise.
*/
return;
}
$client = $this->clientService->newClient();
foreach ($pushNotifications as $proxyServer => $notifications) {
try {
$response = $client->post($proxyServer . '/notifications', [
'body' => [
'notifications' => $notifications,
],
]);
$status = $response->getStatusCode();
$body = $response->getBody();
$bodyData = json_decode($body, true);
} catch (ClientException $e) {
// Server responded with 4xx (400 Bad Request mostlikely)
$response = $e->getResponse();
$status = $response->getStatusCode();
$body = $response->getBody()->getContents();
$bodyData = json_decode($body, true);
} catch (ServerException $e) {
// Server responded with 5xx
$response = $e->getResponse();
$body = $response->getBody()->getContents();
$error = \is_string($body) ? $body : ('no reason given (' . $response->getStatusCode() . ')');
$this->log->debug('Could not send notification to push server [{url}]: {error}', [
'error' => $error,
'url' => $proxyServer,
'app' => 'notifications',
]);
$this->printInfo('Could not send notification to push server [' . $proxyServer . ']: ' . $error);
continue;
} catch (\Exception $e) {
$this->log->error($e->getMessage(), [
'exception' => $e,
]);
$error = $e->getMessage() ?: 'no reason given';
$this->printInfo('Could not send notification to push server [' . get_class($e) . ']: ' . $error);
continue;
}
if (is_array($bodyData) && array_key_exists('unknown', $bodyData) && array_key_exists('failed', $bodyData)) {
if (is_array($bodyData['unknown'])) {
// Proxy returns null when the array is empty
foreach ($bodyData['unknown'] as $unknownDevice) {
$this->printInfo('Deleting device because it is unknown by the push server: ' . $unknownDevice);
$this->deletePushTokenByDeviceIdentifier($unknownDevice);
}
}
if ($bodyData['failed'] !== 0) {
$this->printInfo('Push notification sent, but ' . $bodyData['failed'] . ' failed');
} else {
$this->printInfo('Push notification sent successfully');
}
} elseif ($status !== Http::STATUS_OK) {
$error = $body && $bodyData === null ? $body : 'no reason given';
$this->printInfo('Could not send notification to push server [' . $proxyServer . ']: ' . $error);
$this->log->warning('Could not send notification to push server [{url}]: {error}', [
'error' => $error,
'url' => $proxyServer,
'app' => 'notifications',
]);
} else {
$error = $body && $bodyData === null ? $body : 'no reason given';
$this->printInfo('Push notification sent but response was not parsable, using an outdated push proxy? [' . $proxyServer . ']: ' . $error);
$this->log->info('Push notification sent but response was not parsable, using an outdated push proxy? [{url}]: {error}', [
'error' => $error,
'url' => $proxyServer,
'app' => 'notifications',
]);
}
}
}
protected function validateToken(int $tokenId, int $maxAge): bool {
$age = $this->cache->get('t' . $tokenId);
if ($age !== null) {
return $age > $maxAge;
}
try {
// Check if the token is still valid...
$token = $this->tokenProvider->getTokenById($tokenId);
$this->cache->set('t' . $tokenId, $token->getLastCheck(), 600);
if ($token->getLastCheck() > $maxAge) {
$this->printInfo('Device token is valid');
} else {
$this->printInfo('Device token "last checked" is older than 60 days: ' . $token->getLastCheck());
}
return $token->getLastCheck() > $maxAge;
} catch (InvalidTokenException $e) {
// Token does not exist anymore, should drop the push device entry
$this->printInfo('InvalidTokenException is thrown');
$this->deletePushToken($tokenId);
$this->cache->set('t' . $tokenId, 0, 600);
return false;
}
}
/**
* @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 {
$data = [
'nid' => $id,
'app' => $notification->getApp(),
'subject' => '',
'type' => $notification->getObjectType(),
'id' => $notification->getObjectId(),
];
// Max length of encryption is ~240, so we need to make sure the subject is shorter.
// Also, subtract two for encapsulating quotes will be added.
$maxDataLength = 200 - strlen(json_encode($data)) - 2;
$data['subject'] = Util::shortenMultibyteString($notification->getParsedSubject(), $maxDataLength);
if ($notification->getParsedSubject() !== $data['subject']) {
$data['subject'] .= '…';
}
if ($isTalkNotification) {
$priority = 'high';
$type = $data['type'] === 'call' ? 'voip' : 'alert';
} elseif ($data['app'] === 'twofactor_nextcloud_notification' || $data['app'] === 'phonetrack') {
$priority = 'high';
$type = 'alert';
} else {
$priority = 'normal';
$type = 'alert';
}
$this->printInfo('Device public key size: ' . strlen($device['devicepublickey']));
$this->printInfo('Data to encrypt is: ' . json_encode($data));
if (!openssl_public_encrypt(json_encode($data), $encryptedSubject, $device['devicepublickey'], OPENSSL_PKCS1_PADDING)) {
$error = openssl_error_string();
$this->log->error($error, ['app' => 'notifications']);
$this->printInfo('Error while encrypting data: "' . $error . '"');
throw new \InvalidArgumentException('Failed to encrypt message for device');
}
if (openssl_sign($encryptedSubject, $signature, $userKey->getPrivate(), OPENSSL_ALGO_SHA512)) {
$this->printInfo('Signed encrypted push subject');
} else {
$this->printInfo('Failed to signed encrypted push subject');
}
$base64EncryptedSubject = base64_encode($encryptedSubject);
$base64Signature = base64_encode($signature);
return [
'deviceIdentifier' => $device['deviceidentifier'],
'pushTokenHash' => $device['pushtokenhash'],
'subject' => $base64EncryptedSubject,
'signature' => $base64Signature,
'priority' => $priority,
'type' => $type,
];
}
/**
* @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 {
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',
'type' => 'background',
];
}
/**
* @param string $uid
* @return array[]
*/
protected function getDevicesForUser(string $uid): array {
$query = $this->db->getQueryBuilder();
$query->select('*')
->from('notifications_pushhash')
->where($query->expr()->eq('uid', $query->createNamedParameter($uid)));
$result = $query->executeQuery();
$devices = $result->fetchAll();
$result->closeCursor();
return $devices;
}
/**
* @param string[] $userIds
* @return array[]
*/
protected function getDevicesForUsers(array $userIds): array {
$query = $this->db->getQueryBuilder();
$query->select('*')
->from('notifications_pushhash')
->where($query->expr()->in('uid', $query->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY)));
$devices = [];
$result = $query->executeQuery();
while ($row = $result->fetch()) {
if (!isset($devices[$row['uid']])) {
$devices[$row['uid']] = [];
}
$devices[$row['uid']][] = $row;
}
$result->closeCursor();
return $devices;
}
/**
* @param int $tokenId
* @return bool
*/
protected function deletePushToken(int $tokenId): bool {
$query = $this->db->getQueryBuilder();
$query->delete('notifications_pushhash')
->where($query->expr()->eq('token', $query->createNamedParameter($tokenId, IQueryBuilder::PARAM_INT)));
return $query->executeStatement() !== 0;
}
/**
* @param string $deviceIdentifier
* @return bool
*/
protected function deletePushTokenByDeviceIdentifier(string $deviceIdentifier): bool {
$query = $this->db->getQueryBuilder();
$query->delete('notifications_pushhash')
->where($query->expr()->eq('deviceidentifier', $query->createNamedParameter($deviceIdentifier)));
return $query->executeStatement() !== 0;
}
protected function createFakeUserObject(string $userId): IUser {
return new FakeUser($userId);
}
}