* * @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\NextcloudAnnouncements\Cron; use OC\BackgroundJob\TimedJob; use OCA\NextcloudAnnouncements\Notification\Notifier; use OCP\AppFramework\Http; use OCP\Http\Client\IClientService; use OCP\IConfig; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; use OCP\Notification\IManager as INotificationManager; use phpseclib\File\X509; class Crawler extends TimedJob { const FEED_URL = 'https://pushfeed.nextcloud.com/feed'; /** @var string */ protected $appName; /** @var IConfig */ protected $config; /** @var IGroupManager */ protected $groupManager; /** @var INotificationManager */ protected $notificationManager; /** @var IClientService */ protected $clientService; /** @var string[] */ protected $notifyUsers = []; /** * @param string $appName * @param IConfig $config * @param IGroupManager $groupManager * @param INotificationManager $notificationManager * @param IClientService $clientService */ public function __construct($appName, IConfig $config, IGroupManager $groupManager, INotificationManager $notificationManager, IClientService $clientService) { $this->appName = $appName; $this->config = $config; $this->groupManager = $groupManager; $this->notificationManager = $notificationManager; $this->clientService = $clientService; // Run once per day $interval = 24 * 60 * 60; // Add random interval to spread load on server $interval += random_int(0, 60) * 60; $this->setInterval($interval); } protected function run($argument) { try { $feedBody = $this->loadFeed(); $rss = simplexml_load_string($feedBody); if ($rss === false) { throw new \Exception('Invalid XML feed'); } } catch (\Exception $e) { // Something is wrong 🙊 return; } $lastPubDate = $this->config->getAppValue($this->appName, 'pub_date', 'now'); if ($lastPubDate === 'now') { // First call, don't spam the user with old stuff... $this->config->setAppValue($this->appName, 'pub_date', $rss->channel->pubDate); return; } else if ($rss->channel->pubDate === $lastPubDate) { // Nothing new here... return; } $lastPubDateTime = new \DateTime($lastPubDate); foreach ($rss->channel->item as $item) { $id = md5((string) $item->guid); if ($this->config->getAppValue($this->appName, $id, '') === 'published') { continue; } $pubDate = new \DateTime((string) $item->pubDate); if ($pubDate <= $lastPubDateTime) { continue; } $notification = $this->notificationManager->createNotification(); $notification->setApp($this->appName) ->setDateTime($pubDate) ->setObject($this->appName, $id) ->setSubject(Notifier::SUBJECT, [(string) $item->title]) ->setLink((string) $item->link); foreach ($this->getUsersToNotify() as $uid) { $notification->setUser($uid); $this->notificationManager->notify($notification); } $this->config->getAppValue($this->appName, $id, 'published'); } $this->config->setAppValue($this->appName, 'pub_date', $rss->channel->pubDate); } /** * @return string * @throws \Exception */ protected function loadFeed() { $signature = $this->readFile('.signature'); if (!$signature) { throw new \Exception('Invalid signature fetched from the server'); } $certificate = new X509(); $certificate->loadCA(file_get_contents(\OC::$SERVERROOT . '/resources/codesigning/root.crt')); $loadedCertificate = $certificate->loadX509(file_get_contents(__DIR__ . '/../../appinfo/certificate.crt')); // Verify if the certificate has been revoked $crl = new X509(); $crl->loadCA(file_get_contents(\OC::$SERVERROOT . '/resources/codesigning/root.crt')); $crl->loadCRL(file_get_contents(\OC::$SERVERROOT . '/resources/codesigning/root.crl')); if ($crl->validateSignature() !== true) { throw new \Exception('Could not validate CRL signature'); } $csn = $loadedCertificate['tbsCertificate']['serialNumber']->toString(); $revoked = $crl->getRevoked($csn); if ($revoked !== false) { throw new \Exception('Certificate has been revoked'); } // Verify if the certificate has been issued by the Nextcloud Code Authority CA if($certificate->validateSignature() !== true) { throw new \Exception('App with id nextcloud_announcements has a certificate not issued by a trusted Code Signing Authority'); } // Verify if the certificate is issued for the requested app id $certInfo = openssl_x509_parse(file_get_contents(__DIR__ . '/../../appinfo/certificate.crt')); if(!isset($certInfo['subject']['CN'])) { throw new \Exception('App with id nextcloud_announcements has a cert with no CN'); } if($certInfo['subject']['CN'] !== 'nextcloud_announcements') { throw new \Exception(sprintf('App with id nextcloud_announcements has a cert issued to %s', $certInfo['subject']['CN'])); } $feedBody = $this->readFile('.rss'); // Check if the signature actually matches the downloaded content $certificate = openssl_get_publickey(file_get_contents(__DIR__ . '/../../appinfo/certificate.crt')); $verified = (bool)openssl_verify($feedBody, base64_decode($signature), $certificate, OPENSSL_ALGO_SHA512); openssl_free_key($certificate); if (!$verified) { // Signature does not match throw new \Exception('Feed has an invalid signature'); } return $feedBody; } /** * @param string $file * @return string * @throws \Exception */ protected function readFile($file) { $client = $this->clientService->newClient(); $response = $client->get(self::FEED_URL . $file); if ($response->getStatusCode() !== Http::STATUS_OK) { throw new \Exception('Could not load file'); } return $response->getBody(); } /** * Get the list of users to notify * @return string[] */ protected function getUsersToNotify() { if (!empty($this->notifyUsers)) { return array_keys($this->notifyUsers); } $groups = $this->config->getAppValue($this->appName, 'notification_groups', '["admin"]'); $groups = json_decode($groups, true); if ($groups === null) { return []; } foreach ($groups as $gid) { $group = $this->groupManager->get($gid); if (!($group instanceof IGroup)) { continue; } /** @var IUser[] $users */ $users = $group->getUsers(); foreach ($users as $user) { $uid = $user->getUID(); if (isset($this->notifyUsers[$uid])) { continue; } $this->notifyUsers[$uid] = true; } } return array_keys($this->notifyUsers); } }