From ec247e63ee6371ab2baaf1e4361d395567dff530 Mon Sep 17 00:00:00 2001 From: Joachim Bauch Date: Tue, 9 May 2023 12:08:05 +0200 Subject: [PATCH] Add background job to verify documents. --- appinfo/info.xml | 1 + lib/BackgroundJob/BackgroundVerify.php | 206 ++++++++++++++++++ lib/Config.php | 4 + lib/Settings/Admin/AdminSettings.php | 1 + .../AdminSettings/InstanceSettings.vue | 27 +++ tests/psalm-baseline.xml | 8 + 6 files changed, 247 insertions(+) create mode 100644 lib/BackgroundJob/BackgroundVerify.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 8391aefc..9a0072ac 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -21,6 +21,7 @@ + OCA\Esig\BackgroundJob\BackgroundVerify OCA\Esig\BackgroundJob\DeleteCompleted OCA\Esig\BackgroundJob\FetchSigned OCA\Esig\BackgroundJob\ResendMails diff --git a/lib/BackgroundJob/BackgroundVerify.php b/lib/BackgroundJob/BackgroundVerify.php new file mode 100644 index 00000000..22874ef0 --- /dev/null +++ b/lib/BackgroundJob/BackgroundVerify.php @@ -0,0 +1,206 @@ + + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * 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, version 3, + * along with this program. If not, see + * + */ +namespace OCA\Esig\BackgroundJob; + +use GuzzleHttp\Exception\BadResponseException; +use GuzzleHttp\Exception\ConnectException; +use OCA\Esig\Client; +use OCA\Esig\Config; +use OCA\Esig\Verify; +use OCP\AppFramework\Http; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\IJob; +use OCP\BackgroundJob\TimedJob; +use OCP\DB\IResult; +use OCP\Files\File; +use OCP\Files\IMimeTypeLoader; +use OCP\Files\IRootFolder; +use OCP\IDBConnection; +use OCP\IUser; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class BackgroundVerify extends TimedJob { + private LoggerInterface $logger; + private IUserManager $userManager; + private IDBConnection $db; + private IRootFolder $rootFolder; + private IMimeTypeLoader $mimeTypeLoader; + private Config $config; + private Client $client; + private Verify $verify; + + public function __construct(ITimeFactory $timeFactory, + LoggerInterface $logger, + IUserManager $userManager, + IDBConnection $db, + IRootFolder $rootFolder, + IMimeTypeLoader $mimeTypeLoader, + Config $config, + Client $client, + Verify $verify) { + parent::__construct($timeFactory); + + // Every 5 minutes + $this->setInterval(60 * 5); + $this->setTimeSensitivity(IJob::TIME_INSENSITIVE); + + $this->logger = $logger; + $this->userManager = $userManager; + $this->db = $db; + $this->rootFolder = $rootFolder; + $this->mimeTypeLoader = $mimeTypeLoader; + $this->config = $config; + $this->client = $client; + $this->verify = $verify; + } + + protected function run($argument): void { + if (!$this->config->isBackgroundVerifyEnabled()) { + $this->logger->info('Background verification disabled'); + return; + } + + $account = $this->config->getAccount(); + if (!$account['id'] || !$account['secret']) { + $this->logger->info('No account configured'); + return; + } + + $this->logger->debug('Starting background verification'); + + try { + $result = $this->getPendingFiles(); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return; + } + + $batchSize = $this->getBatchSize(); + $server = $this->config->getServer(); + $cnt = 0; + while (($row = $result->fetch()) && $cnt < $batchSize) { + try { + $fileId = $row['fileid']; + $users = $this->getUserWithAccessToStorage((int)$row['storage']); + + foreach ($users as $user) { + /** @var IUser $owner */ + $owner = $this->userManager->get($user['user_id']); + if (!$owner instanceof IUser) { + continue; + } + + $userFolder = $this->rootFolder->getUserFolder($owner->getUID()); + $files = $userFolder->getById($fileId); + if (empty($files)) { + continue; + } + + $file = array_pop($files); + if (!$file instanceof File) { + $this->logger->error('Tried to verify non file at ' . $file->getPath()); + break; + } + + if (!$userFolder->nodeExists($userFolder->getRelativePath($file->getPath()))) { + $this->logger->error('Tried to verify non-existing file at ' . $file->getPath()); + break; + } + + $this->verifyFile($file, $account, $server); + $cnt++; + break; + } + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + } + } + + private function getBatchSize(): int { + // TODO: Make this configurable? + return 10; + } + + protected function getUserWithAccessToStorage(int $storageId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('user_id') + ->from('mounts') + ->where($qb->expr()->eq('storage_id', $qb->createNamedParameter($storageId))); + + $cursor = $qb->executeQuery(); + $data = $cursor->fetchAll(); + $cursor->closeCursor(); + return $data; + } + + private function getPendingFiles(): IResult { + $pdfMimeTypeId = $this->mimeTypeLoader->getId('application/pdf'); + + $query = $this->db->getQueryBuilder(); + $query->select('fc.fileid', 'storage') + ->from('filecache', 'fc') + ->leftJoin('fc', 'esig_file_signatures', 'fs', $query->expr()->eq('fc.fileid', 'fs.file_id')) + ->where($query->expr()->isNull('fs.file_id')) + ->andWhere($query->expr()->eq('mimetype', $query->expr()->literal($pdfMimeTypeId))) + ->andWhere($query->expr()->like('path', $query->expr()->literal('files/%'))) + ->setMaxResults($this->getBatchSize() * 10); + + return $query->executeQuery(); + } + + private function verifyFile(File $file, array $account, string $server) { + $this->logger->debug('Verifying file ' . $file->getPath()); + + try { + $signatures = $this->client->verifySignatures($file, $account, $server); + } catch (ConnectException $e) { + $this->logger->error('Error connecting to ' . $server . ' for ' . $file->getPath(), [ + 'exception' => $e, + ]); + return; + } catch (\Exception $e) { + switch ($e->getCode()) { + case Http::STATUS_NOT_FOUND: + /** @var BadResponseException $e */ + $response = $e->getResponse(); + $body = (string) $response->getBody(); + $signatures = json_decode($body, true); + if ($signatures) { + $this->verify->storeFileSignatures($file, $signatures); + } + return; + } + + $this->logger->error('Error sending request to ' . $server . ' for ' . $file->getPath(), [ + 'exception' => $e, + ]); + return; + } + + $this->verify->storeFileSignatures($file, $signatures); + } +} diff --git a/lib/Config.php b/lib/Config.php index 848f51f6..48dc0e1b 100644 --- a/lib/Config.php +++ b/lib/Config.php @@ -81,6 +81,10 @@ public function insecureSkipVerify(): bool { return $this->config->getAppValue('esig', 'insecure_skip_verify', 'false') === 'true'; } + public function isBackgroundVerifyEnabled(): bool { + return $this->config->getAppValue('esig', 'background_verify', 'true') === 'true'; + } + public function getSignatureImage(IUser $user): ?ISimpleFile { try { $folder = $this->appData->getFolder($user->getUID()); diff --git a/lib/Settings/Admin/AdminSettings.php b/lib/Settings/Admin/AdminSettings.php index e7a4994b..83230b36 100644 --- a/lib/Settings/Admin/AdminSettings.php +++ b/lib/Settings/Admin/AdminSettings.php @@ -72,6 +72,7 @@ public function getForm(): TemplateResponse { 'signed_save_mode' => $this->config->getSignedSaveMode(), 'intranet_instance' => $this->config->isIntranetInstance(), 'insecure_skip_verify' => $this->config->insecureSkipVerify(), + 'background_verify' => $this->config->isBackgroundVerifyEnabled(), 'delete_max_age' => $this->config->getDeleteMaxAge(), ]); diff --git a/src/components/AdminSettings/InstanceSettings.vue b/src/components/AdminSettings/InstanceSettings.vue index 41ac29e8..69decb44 100644 --- a/src/components/AdminSettings/InstanceSettings.vue +++ b/src/components/AdminSettings/InstanceSettings.vue @@ -37,6 +37,13 @@ {{ t('esig', 'This is potentially insecure and should only be enabled during development (if necessary).') }} +
+ + {{ t('esig', 'Verify document signatures in the background.') }} + +
@@ -106,6 +113,26 @@ export default { }, }) }, + + debounceUpdateBackgroundVerify: debounce(function() { + this.updateBackgroundVerify() + }, 500), + + updateBackgroundVerify() { + this.loading = true + + const self = this + OCP.AppConfig.setValue('esig', 'background_verify', this.settings.background_verify, { + success() { + showSuccess(t('esig', 'Settings saved')) + self.loading = false + }, + error() { + showError(t('esig', 'Could not save settings')) + self.loading = false + }, + }) + }, }, } diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 231af657..83c5f1b2 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -1,5 +1,13 @@ + + + IRootFolder + + + BadResponseException + + $e