diff --git a/css/admin.scss b/css/admin.scss index a28b833a73..d95e55e11a 100644 --- a/css/admin.scss +++ b/css/admin.scss @@ -1,4 +1,4 @@ -@use "sass:math"; +@use 'sass:math'; .rd-settings-documentation { max-width: 50em; diff --git a/css/templatePicker.scss b/css/templatePicker.scss index ee07186388..b432d9a81d 100644 --- a/css/templatePicker.scss +++ b/css/templatePicker.scss @@ -20,7 +20,7 @@ * */ - @use "sass:math"; +@use 'sass:math'; #template-picker { .template-container:not(.hidden) { diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 19fd8e641a..c61b92ad6f 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -27,6 +27,7 @@ use OCA\Files_Sharing\Event\ShareLinkAccessedEvent; use OCA\Richdocuments\AppConfig; use OCA\Richdocuments\Capabilities; +use OCA\Richdocuments\Listener\BeforeFetchPreviewListener; use OCA\Richdocuments\Listener\CSPListener; use OCA\Richdocuments\Listener\LoadViewerListener; use OCA\Richdocuments\Listener\ShareLinkListener; @@ -52,6 +53,7 @@ use OCP\IConfig; use OCP\IL10N; use OCP\IPreview; +use OCP\Preview\BeforeFetchPreviewEvent; use OCP\Security\CSP\AddContentSecurityPolicyEvent; class Application extends App implements IBootstrap { @@ -70,6 +72,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class); $context->registerEventListener(LoadViewer::class, LoadViewerListener::class); $context->registerEventListener(ShareLinkAccessedEvent::class, ShareLinkListener::class); + $context->registerEventListener(BeforeFetchPreviewEvent::class, BeforeFetchPreviewListener::class); } public function boot(IBootContext $context): void { diff --git a/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 5121004467..f9d99a7327 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -237,6 +237,7 @@ public function updateWatermarkSettings($settings = []) { 'watermark_enabled', 'watermark_shareAll', 'watermark_shareRead', + 'watermark_shareDisabledDownload', 'watermark_linkSecure', 'watermark_linkRead', 'watermark_linkAll', diff --git a/lib/Controller/WopiController.php b/lib/Controller/WopiController.php index 478a784cd0..62bb5e02ab 100644 --- a/lib/Controller/WopiController.php +++ b/lib/Controller/WopiController.php @@ -67,6 +67,7 @@ use OCP\PreConditionNotMetException; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager as IShareManager; +use OCP\Share\IShare; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; @@ -234,7 +235,8 @@ public function checkFileInfo($fileId, $access_token) { $response['TemplateSaveAs'] = $file->getName(); } - if ($this->shouldWatermark($isPublic, $wopi->getEditorUid(), $fileId, $wopi)) { + $share = $this->getShareForWopiToken($wopi); + if ($this->permissionManager->shouldWatermark($file, $wopi->getEditorUid(), $share)) { $email = $user !== null && !$isPublic ? $user->getEMailAddress() : ""; $replacements = [ 'userId' => $wopi->getEditorUid(), @@ -318,62 +320,6 @@ private function setFederationFileInfo(Wopi $wopi, $response) { return $response; } - private function shouldWatermark($isPublic, $userId, $fileId, Wopi $wopi) { - if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_enabled', 'no') === 'no') { - return false; - } - - if ($isPublic) { - if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_linkAll', 'no') === 'yes') { - return true; - } - if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_linkRead', 'no') === 'yes' && !$wopi->getCanwrite()) { - return true; - } - if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_linkSecure', 'no') === 'yes' && $wopi->getHideDownload()) { - return true; - } - if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_linkTags', 'no') === 'yes') { - $tags = $this->appConfig->getAppValueArray('watermark_linkTagsList'); - $fileTags = \OC::$server->getSystemTagObjectMapper()->getTagIdsForObjects([$fileId], 'files')[$fileId]; - foreach ($fileTags as $tagId) { - if (in_array($tagId, $tags, true)) { - return true; - } - } - } - } else { - if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_shareAll', 'no') === 'yes') { - $files = $this->rootFolder->getUserFolder($userId)->getById($fileId); - if (count($files) !== 0 && $files[0]->getOwner()->getUID() !== $userId) { - return true; - } - } - if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_shareRead', 'no') === 'yes' && !$wopi->getCanwrite()) { - return true; - } - } - if ($userId !== null && $this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_allGroups', 'no') === 'yes') { - $groups = $this->appConfig->getAppValueArray('watermark_allGroupsList'); - foreach ($groups as $group) { - if (\OC::$server->getGroupManager()->isInGroup($userId, $group)) { - return true; - } - } - } - if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_allTags', 'no') === 'yes') { - $tags = $this->appConfig->getAppValueArray('watermark_allTagsList'); - $fileTags = \OC::$server->getSystemTagObjectMapper()->getTagIdsForObjects([$fileId], 'files')[$fileId]; - foreach ($fileTags as $tagId) { - if (in_array($tagId, $tags, true)) { - return true; - } - } - } - - return false; - } - /** * Given an access token and a fileId, returns the contents of the file. * Expects a valid token in access_token parameter. @@ -842,6 +788,7 @@ private function retryOperation(callable $operation) { * @throws ShareNotFound */ private function getFileForWopiToken(Wopi $wopi) { + $this->userScopeService->setUserScope($wopi->getEditorUid()); if (!empty($wopi->getShare())) { $share = $this->shareManager->getShareByToken($wopi->getShare()); $node = $share->getNode(); @@ -875,6 +822,15 @@ private function getFileForWopiToken(Wopi $wopi) { return array_shift($files); } + private function getShareForWopiToken(Wopi $wopi): ?IShare { + try { + return $wopi->getShare() ? $this->shareManager->getShareByToken($wopi->getShare()) : null; + } catch (ShareNotFound $e) { + } + + return null; + } + /** * Endpoint to return the template file that is requested by collabora to create a new document * diff --git a/lib/Listener/BeforeFetchPreviewListener.php b/lib/Listener/BeforeFetchPreviewListener.php new file mode 100644 index 0000000000..20163f15cd --- /dev/null +++ b/lib/Listener/BeforeFetchPreviewListener.php @@ -0,0 +1,82 @@ + + * + * @author Julius Härtl + * + * @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\Richdocuments\Listener; + +use OCA\Files_Sharing\SharedStorage; +use OCA\Richdocuments\PermissionManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\NotFoundException; +use OCP\IRequest; +use OCP\IUserSession; +use OCP\Preview\BeforeFetchPreviewEvent; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IManager; +use OCP\Share\IShare; + +class BeforeFetchPreviewListener implements IEventListener { + private PermissionManager $permissionManager; + private IUserSession $userSession; + private IRequest $request; + private IManager $shareManager; + + public function __construct(PermissionManager $permissionManager, IUserSession $userSession, IRequest $request, IManager $shareManager) { + $this->permissionManager = $permissionManager; + $this->userSession = $userSession; + $this->request = $request; + $this->shareManager = $shareManager; + } + + public function handle(Event $event): void { + if (!$event instanceof BeforeFetchPreviewEvent) { + return; + } + $shareToken = $this->request->getParam('token'); + + $share = null; + + // Get share for internal shares + $storage = $event->getNode()->getStorage(); + if (!$shareToken && $storage->instanceOfStorage(SharedStorage::class)) { + if (method_exists(IShare::class, 'getAttributes')) { + /** @var SharedStorage $storage */ + $share = $storage->getShare(); + } + } + + // Get different share for public previews as the share from the node is only set for mounted shares + try { + $share = $shareToken ? $this->shareManager->getShareByToken($shareToken) : $share; + } catch (ShareNotFound $e) { + } + + $userId = $this->userSession->getUser() ? $this->userSession->getUser()->getUID() : null; + if ($this->permissionManager->shouldWatermark($event->getNode(), $userId, $share)) { + throw new NotFoundException(); + } + } +} diff --git a/lib/PermissionManager.php b/lib/PermissionManager.php index a509dc817e..752a361860 100644 --- a/lib/PermissionManager.php +++ b/lib/PermissionManager.php @@ -23,26 +23,38 @@ namespace OCA\Richdocuments; +use OCP\Constants; +use OCP\Files\Node; +use OCP\IConfig; use OCP\IGroupManager; use OCP\IUserManager; use OCP\IUserSession; +use OCP\Share\IAttributes; +use OCP\Share\IShare; +use OCP\SystemTag\ISystemTagObjectMapper; class PermissionManager { - private AppConfig $config; + private AppConfig $appConfig; + private IConfig $config; private IGroupManager $groupManager; private IUserManager $userManager; private IUserSession $userSession; + private ISystemTagObjectMapper $systemTagObjectMapper; public function __construct( - AppConfig $config, - IGroupManager $groupManager, - IUserManager $userManager, - IUserSession $userSession + AppConfig $appConfig, + IConfig $config, + IGroupManager $groupManager, + IUserManager $userManager, + IUserSession $userSession, + ISystemTagObjectMapper $systemTagObjectMapper ) { + $this->appConfig = $appConfig; $this->config = $config; $this->groupManager = $groupManager; $this->userManager = $userManager; $this->userSession = $userSession; + $this->systemTagObjectMapper = $systemTagObjectMapper; } private function userMatchesGroupList(?string $userId = null, ?array $groupList = []): bool { @@ -75,7 +87,7 @@ private function userMatchesGroupList(?string $userId = null, ?array $groupList } public function isEnabledForUser(string $userId = null): bool { - if ($this->userMatchesGroupList($userId, $this->config->getUseGroups())) { + if ($this->userMatchesGroupList($userId, $this->appConfig->getUseGroups())) { return true; } @@ -83,7 +95,7 @@ public function isEnabledForUser(string $userId = null): bool { } public function userCanEdit(string $userId = null): bool { - if ($this->userMatchesGroupList($userId, $this->config->getEditGroups())) { + if ($this->userMatchesGroupList($userId, $this->appConfig->getEditGroups())) { return true; } @@ -91,10 +103,80 @@ public function userCanEdit(string $userId = null): bool { } public function userIsFeatureLocked(string $userId = null): bool { - if ($this->config->isReadOnlyFeatureLocked() && !$this->userCanEdit($userId)) { + if ($this->appConfig->isReadOnlyFeatureLocked() && !$this->userCanEdit($userId)) { return true; } return false; } + + public function shouldWatermark(Node $node, ?string $userId = null, ?IShare $share = null): bool { + if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_enabled', 'no') === 'no') { + return false; + } + + $fileId = $node->getId(); + + $isUpdatable = $node->isUpdateable() && (!$share || $share->getPermissions() & Constants::PERMISSION_UPDATE); + + $hasShareAttributes = $share && method_exists($share, 'getAttributes') && $share->getAttributes() instanceof IAttributes; + $isDisabledDownload = $hasShareAttributes && $share->getAttributes()->getAttribute('permissions', 'download') === false; + $isHideDownload = $share && $share->getHideDownload(); + $isSecureView = $isDisabledDownload || $isHideDownload; + + if ($share && $share->getShareType() === IShare::TYPE_LINK) { + if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_linkAll', 'no') === 'yes') { + return true; + } + if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_linkRead', 'no') === 'yes' && !$isUpdatable) { + return true; + } + if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_linkSecure', 'no') === 'yes' && $isSecureView) { + return true; + } + if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_linkTags', 'no') === 'yes') { + $tags = $this->appConfig->getAppValueArray('watermark_linkTagsList'); + $fileTags = $this->systemTagObjectMapper->getTagIdsForObjects([$fileId], 'files')[$fileId]; + foreach ($fileTags as $tagId) { + if (in_array($tagId, $tags, true)) { + return true; + } + } + } + } + + if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_shareAll', 'no') === 'yes') { + if ($node->getOwner()->getUID() !== $userId) { + return true; + } + } + + if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_shareRead', 'no') === 'yes' && !$isUpdatable) { + return true; + } + + if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_shareDisabledDownload', 'no') === 'yes' && $isDisabledDownload) { + return true; + } + + if ($userId !== null && $this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_allGroups', 'no') === 'yes') { + $groups = $this->appConfig->getAppValueArray('watermark_allGroupsList'); + foreach ($groups as $group) { + if ($this->groupManager->isInGroup($userId, $group)) { + return true; + } + } + } + if ($this->config->getAppValue(AppConfig::WATERMARK_APP_NAMESPACE, 'watermark_allTags', 'no') === 'yes') { + $tags = $this->appConfig->getAppValueArray('watermark_allTagsList'); + $fileTags = $this->systemTagObjectMapper->getTagIdsForObjects([$fileId], 'files')[$fileId]; + foreach ($fileTags as $tagId) { + if (in_array($tagId, $tags, true)) { + return true; + } + } + } + + return false; + } } diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue index 58bb874247..d7ecb3c498 100644 --- a/src/components/AdminSettings.vue +++ b/src/components/AdminSettings.vue @@ -298,6 +298,12 @@

{{ t('richdocuments', 'Secure view settings') }}

{{ t('richdocuments', 'Secure view enables you to secure documents by embedding a watermark') }}

+
    +
  • {{ t('richdocuments', 'The settings only apply to compatible office files that are opened in Nextcloud Office') }}
  • +
  • {{ t('richdocuments', 'The following options within Nextcloud Office will be disabled: Copy, Download, Export, Print') }}
  • +
  • {{ t('richdocuments', 'Files may still be downloadable through Nextcloud unless restricted otherwise through sharing or access control settings') }}
  • +
  • {{ t('richdocuments', 'Previews will be blocked for watermarked files to not leak the first page of documents') }}
  • +
+

Link shares

config = $this->createMock(AppConfig::class); + $this->appConfig = $this->createMock(AppConfig::class); + $this->config = $this->createMock(IConfig::class); $this->groupManager = $this->createMock(IGroupManager::class); $this->userManager = $this->createMock(IUserManager::class); $this->userSession = $this->createMock(IUserSession::class); - $this->permissionManager = new PermissionManager($this->config, $this->groupManager, $this->userManager, $this->userSession); + $this->systemTagMapper = $this->createMock(ISystemTagObjectMapper::class); + $this->permissionManager = new PermissionManager($this->appConfig, $this->config, $this->groupManager, $this->userManager, $this->userSession, $this->systemTagMapper); } public function testIsEnabledForUserEnabledNoRestrictions(): void { - $this->config + $this->appConfig ->expects($this->once()) ->method('getUseGroups') ->willReturn(null); @@ -77,7 +82,7 @@ public function dataGroupMatchGroups(): array { /** @dataProvider dataGroupMatchGroups */ public function testEditGroups($editGroups, $userGroups, $result): void { $userMock = $this->createMock(IUser::class); - $this->config->expects($this->any()) + $this->appConfig->expects($this->any()) ->method('getEditGroups') ->willReturn($editGroups); $this->userManager->expects($this->any()) @@ -93,7 +98,7 @@ public function testEditGroups($editGroups, $userGroups, $result): void { /** @dataProvider dataGroupMatchGroups */ public function testUseGroups($editGroups, $userGroups, $result): void { $userMock = $this->createMock(IUser::class); - $this->config->expects($this->any()) + $this->appConfig->expects($this->any()) ->method('getUseGroups') ->willReturn($editGroups); $this->userManager->expects($this->any()) @@ -109,10 +114,10 @@ public function testUseGroups($editGroups, $userGroups, $result): void { /** @dataProvider dataGroupMatchGroups */ public function testFeatureLock($editGroups, $userGroups, $result): void { $userMock = $this->createMock(IUser::class); - $this->config->expects($this->any()) + $this->appConfig->expects($this->any()) ->method('getEditGroups') ->willReturn($editGroups); - $this->config->expects($this->any()) + $this->appConfig->expects($this->any()) ->method('isReadOnlyFeatureLocked') ->willReturn(true); $this->userManager->expects($this->any()) diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 87c7e3dd35..bf7f35c24b 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -22,6 +22,9 @@ OpenDocument Pdf + + BeforeFetchPreviewEvent + @@ -76,24 +79,16 @@ $item->getId() $node->getId() - - $this->rootFolder + $this->rootFolder $this->rootFolder $this->rootFolder - BeforeFederationRedirectEvent IRootFolder IRootFolder $app !== '' - - \OCA\Files_Sharing\External\Storage - - - getRemote - @@ -129,9 +124,7 @@ - - $this->rootFolder - $this->rootFolder + $this->rootFolder $this->rootFolder $this->rootFolder @@ -159,6 +152,16 @@ $time + + + BeforeFetchPreviewEvent + + + + + $share && method_exists($share, 'getAttributes') + + Office diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 8b53b34d2e..e58a0f07bb 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -3,31 +3,40 @@ declare(strict_types=1); namespace OCA\Federation { - class TrustedServers { - public function getServers() {} - public function isTrustedServer($domainWithPort) {} - } + class TrustedServers { + public function getServers() { + } + public function isTrustedServer($domainWithPort) { + } + } } namespace OCA\Viewer\Event { - class LoadViewer extends \OCP\EventDispatcher\Event {} + class LoadViewer extends \OCP\EventDispatcher\Event { + } } namespace Doctrine\DBAL\Platforms { - class SqlitePlatform {} + class SqlitePlatform { + } } namespace OCA\Files_Sharing { + use OCP\Files\Storage\IStorage; use \OCP\Share\IShare; - class SharedStorage { - public function getShare(): IShare {} + + abstract class SharedStorage implements IStorage { + public function getShare(): IShare { + } } } namespace OCA\Files_Sharing\Event { use \OCP\Share\IShare; + class ShareLinkAccessedEvent extends \OCP\EventDispatcher\Event { - public function __construct(IShare $share, string $step = '', int $errorCode = 200, string $errorMessage = '') {} + public function __construct(IShare $share, string $step = '', int $errorCode = 200, string $errorMessage = '') { + } public function getShare(): IShare { } @@ -44,5 +53,6 @@ namespace OCA\Files_Sharing\Event { } class OC_Helper { - public static function getFileTemplateManager() {} + public static function getFileTemplateManager() { + } }