From 0bb8dedaaeb8ed07feca4a4b86624f2c0a19bb20 Mon Sep 17 00:00:00 2001 From: Vincent Petry Date: Wed, 18 May 2022 14:54:27 +0200 Subject: [PATCH] Add share attributes + prevent download permission Makes it possible to store download permission Signed-off-by: Vincent Petry --- .../dav/lib/Connector/Sabre/ServerFactory.php | 5 + apps/dav/lib/DAV/ViewOnlyPlugin.php | 116 +++++++++++++ apps/dav/lib/Server.php | 6 + .../dav/tests/unit/DAV/ViewOnlyPluginTest.php | 128 +++++++++++++++ apps/files/js/fileinfomodel.js | 9 + .../files_sharing/lib/AppInfo/Application.php | 59 +++++++ .../lib/Controller/ShareAPIController.php | 31 ++++ apps/files_sharing/lib/MountProvider.php | 29 +++- apps/files_sharing/lib/ViewOnly.php | 116 +++++++++++++ apps/files_sharing/tests/ApiTest.php | 7 +- .../Controller/ShareAPIControllerTest.php | 49 +++++- .../files_sharing/tests/MountProviderTest.php | 97 +++++++---- core/Migrations/Version20181220085457.php | 50 ++++++ lib/private/Share20/DefaultShareProvider.php | 66 ++++++++ lib/private/Share20/Manager.php | 15 +- lib/private/Share20/Share.php | 27 ++- lib/private/Share20/ShareAttributes.php | 73 +++++++++ lib/private/legacy/OC_Files.php | 29 +++- lib/public/Share/IAttributes.php | 68 ++++++++ lib/public/Share/IShare.php | 27 ++- .../lib/Share20/DefaultShareProviderTest.php | 32 ++++ tests/lib/Share20/LegacyHooksTest.php | 154 ++++++++++++++++++ tests/lib/Share20/ManagerTest.php | 6 +- 23 files changed, 1150 insertions(+), 49 deletions(-) create mode 100644 apps/dav/lib/DAV/ViewOnlyPlugin.php create mode 100644 apps/dav/tests/unit/DAV/ViewOnlyPluginTest.php create mode 100644 apps/files_sharing/lib/ViewOnly.php create mode 100644 core/Migrations/Version20181220085457.php create mode 100644 lib/private/Share20/ShareAttributes.php create mode 100644 lib/public/Share/IAttributes.php diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index 635645ed99275..12f4640eaa593 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -171,6 +171,11 @@ public function createServer($baseUri, $server->addPlugin(new \OCA\DAV\Connector\Sabre\QuotaPlugin($view, true)); $server->addPlugin(new \OCA\DAV\Connector\Sabre\ChecksumUpdatePlugin()); + // Allow view-only plugin for webdav requests + $server->addPlugin(new ViewOnlyPlugin( + \OC::$server->getLogger() + )); + if ($this->userSession->isLoggedIn()) { $server->addPlugin(new \OCA\DAV\Connector\Sabre\TagsPlugin($objectTree, $this->tagManager)); $server->addPlugin(new \OCA\DAV\Connector\Sabre\SharesPlugin( diff --git a/apps/dav/lib/DAV/ViewOnlyPlugin.php b/apps/dav/lib/DAV/ViewOnlyPlugin.php new file mode 100644 index 0000000000000..7bfa1dc700f4c --- /dev/null +++ b/apps/dav/lib/DAV/ViewOnlyPlugin.php @@ -0,0 +1,116 @@ + + * + */ + +namespace OCA\DAV\DAV; + +use OCA\DAV\Connector\Sabre\Exception\Forbidden; +use OCA\DAV\Connector\Sabre\File as DavFile; +use OCA\DAV\Meta\MetaFile; +use OCP\Files\FileInfo; +use OCP\Files\NotFoundException; +use OCP\ILogger; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\DAV\Exception\NotFound; + +/** + * Sabre plugin for restricting file share receiver download: + */ +class ViewOnlyPlugin extends ServerPlugin { + + /** @var Server $server */ + private $server; + + /** @var ILogger $logger */ + private $logger; + + /** + * @param ILogger $logger + */ + public function __construct(ILogger $logger) { + $this->logger = $logger; + } + + /** + * This initializes the plugin. + * + * This function is called by Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param Server $server + * @return void + */ + public function initialize(Server $server) { + $this->server = $server; + //priority 90 to make sure the plugin is called before + //Sabre\DAV\CorePlugin::httpGet + $this->server->on('method:GET', [$this, 'checkViewOnly'], 90); + } + + /** + * Disallow download via DAV Api in case file being received share + * and having special permission + * + * @param RequestInterface $request request object + * @return boolean + * @throws Forbidden + * @throws NotFoundException + */ + public function checkViewOnly( + RequestInterface $request + ) { + $path = $request->getPath(); + + try { + $davNode = $this->server->tree->getNodeForPath($path); + if (!($davNode instanceof DavFile || $davNode instanceof MetaFile)) { + return true; + } + // Restrict view-only to nodes which are shared + $node = $davNode->getNode(); + if (!$node instanceof FileInfo) { + return true; + } + + $storage = $node->getStorage(); + // using string as we have no guarantee that "files_sharing" app is loaded + if (!$storage->instanceOfStorage('OCA\Files_Sharing\SharedStorage')) { + return true; + } + // Extract extra permissions + /** @var \OCA\Files_Sharing\SharedStorage $storage */ + $share = $storage->getShare(); + + // Check if read-only and on whether permission can download is both set and disabled. + $canDownload = $share->getAttributes()->getAttribute('permissions', 'download'); + if ($canDownload !== null && !$canDownload) { + throw new Forbidden('Access to this resource has been denied because it is in view-only mode.'); + } + } catch (NotFound $e) { + $this->logger->warning($e->getMessage()); + } + + return true; + } +} diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 9791bc6b334e2..a25248677918d 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -62,6 +62,7 @@ use OCA\DAV\Connector\Sabre\TagsPlugin; use OCA\DAV\DAV\CustomPropertiesBackend; use OCA\DAV\DAV\PublicAuth; +use OCA\DAV\DAV\ViewOnlyPlugin; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\DAV\Files\BrowserErrorPagePlugin; use OCA\DAV\Files\LazySearchBackend; @@ -229,6 +230,11 @@ public function __construct(IRequest $request, string $baseUri) { $this->server->addPlugin(new FakeLockerPlugin()); } + // Allow view-only plugin for webdav requests + $this->server->addPlugin(new ViewOnlyPlugin( + \OC::$server->getLogger() + )); + if (BrowserErrorPagePlugin::isBrowserRequest($request)) { $this->server->addPlugin(new BrowserErrorPagePlugin()); } diff --git a/apps/dav/tests/unit/DAV/ViewOnlyPluginTest.php b/apps/dav/tests/unit/DAV/ViewOnlyPluginTest.php new file mode 100644 index 0000000000000..577935be7b071 --- /dev/null +++ b/apps/dav/tests/unit/DAV/ViewOnlyPluginTest.php @@ -0,0 +1,128 @@ + + * + */ +namespace OCA\DAV\Tests\unit\DAV; + +use OCA\DAV\DAV\ViewOnlyPlugin; +use OCA\Files_Sharing\SharedStorage; +use OCA\DAV\Connector\Sabre\File as DavFile; +use OCP\Files\FileInfo; +use OCP\Files\Storage\IStorage; +use OCP\ILogger; +use OCP\Share\IAttributes; +use OCP\Share\IShare; +use Sabre\DAV\Server; +use Sabre\DAV\Tree; +use Test\TestCase; +use Sabre\HTTP\RequestInterface; +use OCA\DAV\Connector\Sabre\Exception\Forbidden; + +class ViewOnlyPluginTest extends TestCase { + + /** @var ViewOnlyPlugin */ + private $plugin; + /** @var Tree | \PHPUnit\Framework\MockObject\MockObject */ + private $tree; + /** @var RequestInterface | \PHPUnit\Framework\MockObject\MockObject */ + private $request; + + public function setUp() { + $this->plugin = new ViewOnlyPlugin( + $this->createMock(ILogger::class) + ); + $this->request = $this->createMock(RequestInterface::class); + $this->tree = $this->createMock(Tree::class); + + $server = $this->createMock(Server::class); + $server->tree = $this->tree; + + $this->plugin->initialize($server); + } + + public function testCanGetNonDav() { + $this->request->expects($this->once())->method('getPath')->willReturn('files/test/target'); + $this->tree->method('getNodeForPath')->willReturn(null); + + $this->assertTrue($this->plugin->checkViewOnly($this->request)); + } + + public function testCanGetNonFileInfo() { + $this->request->expects($this->once())->method('getPath')->willReturn('files/test/target'); + $davNode = $this->createMock(DavFile::class); + $this->tree->method('getNodeForPath')->willReturn($davNode); + + $davNode->method('getNode')->willReturn(null); + + $this->assertTrue($this->plugin->checkViewOnly($this->request)); + } + + public function testCanGetNonShared() { + $this->request->expects($this->once())->method('getPath')->willReturn('files/test/target'); + $davNode = $this->createMock(DavFile::class); + $this->tree->method('getNodeForPath')->willReturn($davNode); + + $fileInfo = $this->createMock(FileInfo::class); + $davNode->method('getNode')->willReturn($fileInfo); + + $storage = $this->createMock(IStorage::class); + $fileInfo->method('getStorage')->willReturn($storage); + $storage->method('instanceOfStorage')->with(SharedStorage::class)->willReturn(false); + + $this->assertTrue($this->plugin->checkViewOnly($this->request)); + } + + public function providesDataForCanGet() { + return [ + // has attribute permissions-download enabled - can get file + [ $this->createMock(FileInfo::class), true, true], + // has no attribute permissions-download - can get file + [ $this->createMock(FileInfo::class), null, true], + // has attribute permissions-download disabled- cannot get the file + [ $this->createMock(FileInfo::class), false, false], + ]; + } + + /** + * @dataProvider providesDataForCanGet + */ + public function testCanGet($nodeInfo, $attrEnabled, $expectCanDownloadFile) { + $this->request->expects($this->once())->method('getPath')->willReturn('files/test/target'); + + $davNode = $this->createMock(DavFile::class); + $this->tree->method('getNodeForPath')->willReturn($davNode); + + $davNode->method('getNode')->willReturn($nodeInfo); + + $storage = $this->createMock(SharedStorage::class); + $share = $this->createMock(IShare::class); + $nodeInfo->method('getStorage')->willReturn($storage); + $storage->method('instanceOfStorage')->with(SharedStorage::class)->willReturn(true); + $storage->method('getShare')->willReturn($share); + + $extAttr = $this->createMock(IAttributes::class); + $share->method('getAttributes')->willReturn($extAttr); + $extAttr->method('getAttribute')->with('permissions', 'download')->willReturn($attrEnabled); + + if (!$expectCanDownloadFile) { + $this->expectException(Forbidden::class); + } + $this->plugin->checkViewOnly($this->request); + } +} diff --git a/apps/files/js/fileinfomodel.js b/apps/files/js/fileinfomodel.js index 8e7b399544ce3..83a8c62592b21 100644 --- a/apps/files/js/fileinfomodel.js +++ b/apps/files/js/fileinfomodel.js @@ -83,6 +83,15 @@ return OC.joinPaths(this.get('path'), this.get('name')); }, + /** + * Returns the mimetype of the file + * + * @return {string} mimetype + */ + getMimeType: function() { + return this.get('mimetype'); + }, + /** * Reloads missing properties from server and set them in the model. * @param properties array of properties to be reloaded diff --git a/apps/files_sharing/lib/AppInfo/Application.php b/apps/files_sharing/lib/AppInfo/Application.php index 2539247b561bc..a27b2f20fe440 100644 --- a/apps/files_sharing/lib/AppInfo/Application.php +++ b/apps/files_sharing/lib/AppInfo/Application.php @@ -106,6 +106,7 @@ function () use ($c) { public function boot(IBootContext $context): void { $context->injectFn([$this, 'registerMountProviders']); $context->injectFn([$this, 'registerEventsScripts']); + $context->injectFn([$this, 'registerDownloadEvents']); $context->injectFn([$this, 'setupSharingMenus']); Helper::registerHooks(); @@ -150,6 +151,64 @@ public function registerEventsScripts(IEventDispatcher $dispatcher, EventDispatc }); } + public function registerDownloadEvents( + EventDispatcherInterface $oldDispatcher, + ?IUserSession $userSession, + IRootFolder $rootFolder + ) { + + $oldDispatcher->addListener( + 'file.beforeGetDirect', + function (GenericEvent $event) use ($userSession, $rootFolder) { + $pathsToCheck[] = $event->getArgument('path'); + + // Check only for user/group shares. Don't restrict e.g. share links + if ($userSession && $userSession->isLoggedIn()) { + $uid = $userSession->getUser()->getUID(); + $viewOnlyHandler = new ViewOnly( + $rootFolder->getUserFolder($uid) + ); + if (!$viewOnlyHandler->check($pathsToCheck)) { + $event->setArgument('errorMessage', 'Access to this resource or one of its sub-items has been denied.'); + } + } + } + ); + + $oldDispatcher->addListener( + 'file.beforeCreateZip', + function (GenericEvent $event) use ($userSession, $rootFolder) { + $dir = $event->getArgument('dir'); + $files = $event->getArgument('files'); + + $pathsToCheck = []; + if (\is_array($files)) { + foreach ($files as $file) { + $pathsToCheck[] = $dir . '/' . $file; + } + } elseif (\is_string($files)) { + $pathsToCheck[] = $dir . '/' . $files; + } + + // Check only for user/group shares. Don't restrict e.g. share links + if ($userSession && $userSession->isLoggedIn()) { + $uid = $userSession->getUser()->getUID(); + $viewOnlyHandler = new ViewOnly( + $rootFolder->getUserFolder($uid) + ); + if (!$viewOnlyHandler->check($pathsToCheck)) { + $event->setArgument('errorMessage', 'Access to this resource or one of its sub-items has been denied.'); + $event->setArgument('run', false); + } else { + $event->setArgument('run', true); + } + } else { + $event->setArgument('run', true); + } + } + ); + } + public function setupSharingMenus(IManager $shareManager, IFactory $l10nFactory, IUserSession $userSession) { if (!$shareManager->shareApiEnabled() || !class_exists('\OCA\Files\App')) { return; diff --git a/apps/files_sharing/lib/Controller/ShareAPIController.php b/apps/files_sharing/lib/Controller/ShareAPIController.php index c044148513261..617802cde05ae 100644 --- a/apps/files_sharing/lib/Controller/ShareAPIController.php +++ b/apps/files_sharing/lib/Controller/ShareAPIController.php @@ -45,6 +45,7 @@ namespace OCA\Files_Sharing\Controller; use OC\Files\FileInfo; +use OCA\DAV\DAV\ViewOnlyPlugin; use OCA\Files_Sharing\Exceptions\SharingRightsException; use OCA\Files_Sharing\External\Storage; use OCA\Files\Helper; @@ -324,6 +325,11 @@ protected function formatShare(IShare $share, Node $recipientNode = null): array $result['mail_send'] = $share->getMailSend() ? 1 : 0; $result['hide_download'] = $share->getHideDownload() ? 1 : 0; + $result['attributes'] = null; + if ($attributes = $share->getAttributes()) { + $result['attributes'] = \json_encode($attributes->toArray()); + } + return $result; } @@ -674,6 +680,8 @@ public function createShare( $share->setNote($note); } + $share = $this->setShareAttributes($share, $this->request->getParam('attributes', null)); + try { $share = $this->shareManager->createShare($share); } catch (GenericShareException $e) { @@ -1216,6 +1224,8 @@ public function updateShare( } } + $share = $this->setShareAttributes($share, $this->request->getParam('attributes', null)); + try { $share = $this->shareManager->updateShare($share); } catch (GenericShareException $e) { @@ -1832,4 +1842,25 @@ private function mergeFormattedShares(array &$shares, array $newShares) { } } } + + /** + * @param IShare $share + * @param string[][]|null $formattedShareAttributes + * @return IShare modified share + */ + private function setShareAttributes(IShare $share, $formattedShareAttributes) { + $newShareAttributes = $this->shareManager->newShare()->newAttributes(); + if ($formattedShareAttributes !== null) { + foreach ($formattedShareAttributes as $formattedAttr) { + $newShareAttributes->setAttribute( + $formattedAttr['scope'], + $formattedAttr['key'], + (bool) \json_decode($formattedAttr['enabled']) + ); + } + } + $share->setAttributes($newShareAttributes); + + return $share; + } } diff --git a/apps/files_sharing/lib/MountProvider.php b/apps/files_sharing/lib/MountProvider.php index d27f9e5e0da34..9af0b33858a75 100644 --- a/apps/files_sharing/lib/MountProvider.php +++ b/apps/files_sharing/lib/MountProvider.php @@ -38,6 +38,7 @@ use OCP\IConfig; use OCP\ILogger; use OCP\IUser; +use OCP\Share\IAttributes; use OCP\Share\IManager; use OCP\Share\IShare; @@ -229,14 +230,31 @@ private function buildSuperShares(array $allShares, \OCP\IUser $user) { ->setTarget($shares[0]->getTarget()); // use most permissive permissions - $permissions = 0; + // this covers the case where there are multiple shares for the same + // file e.g. from different groups and different permissions + $superPermissions = 0; + $superAttributes = $this->shareManager->newShare()->newAttributes(); $status = IShare::STATUS_PENDING; foreach ($shares as $share) { - $permissions |= $share->getPermissions(); + $superPermissions |= $share->getPermissions(); $status = max($status, $share->getStatus()); + // update permissions + $superPermissions |= $share->getPermissions(); + + // update share permission attributes + if ($share->getAttributes() !== null) { + foreach ($share->getAttributes()->toArray() as $attribute) { + if ($superAttributes->getAttribute($attribute['scope'], $attribute['key']) === true) { + // if super share attribute is already enabled, it is most permissive + continue; + } + // update supershare attributes with subshare attribute + $superAttributes->setAttribute($attribute['scope'], $attribute['key'], $attribute['enabled']); + } + } + // adjust target, for database consistency if needed if ($share->getTarget() !== $superShare->getTarget()) { - // adjust target, for database consistency $share->setTarget($superShare->getTarget()); try { $this->shareManager->moveShare($share, $user->getUID()); @@ -261,8 +279,9 @@ private function buildSuperShares(array $allShares, \OCP\IUser $user) { } } - $superShare->setPermissions($permissions) - ->setStatus($status); + $superShare->setPermissions($superPermissions); + $superShare->setStatus($status); + $superShare->setAttributes($superAttributes); $result[] = [$superShare, $shares]; } diff --git a/apps/files_sharing/lib/ViewOnly.php b/apps/files_sharing/lib/ViewOnly.php new file mode 100644 index 0000000000000..58ff2e7f2b47d --- /dev/null +++ b/apps/files_sharing/lib/ViewOnly.php @@ -0,0 +1,116 @@ + + * + */ + +namespace OCA\Files_Sharing; + +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; + +/** + * Handles restricting for download of files + */ +class ViewOnly { + + /** @var Folder */ + private $userFolder; + + public function __construct(Folder $userFolder) { + $this->userFolder = $userFolder; + } + + /** + * @param string[] $pathsToCheck + * @return bool + */ + public function check($pathsToCheck) { + // If any of elements cannot be downloaded, prevent whole download + foreach ($pathsToCheck as $file) { + try { + $info = $this->userFolder->get($file); + if ($info instanceof File) { + // access to filecache is expensive in the loop + if (!$this->checkFileInfo($info)) { + return false; + } + } elseif ($info instanceof Folder) { + // get directory content is rather cheap query + if (!$this->dirRecursiveCheck($info)) { + return false; + } + } + } catch (NotFoundException $e) { + continue; + } + } + return true; + } + + /** + * @param Folder $dirInfo + * @return bool + * @throws NotFoundException + */ + private function dirRecursiveCheck(Folder $dirInfo) { + if (!$this->checkFileInfo($dirInfo)) { + return false; + } + // If any of elements cannot be downloaded, prevent whole download + $files = $dirInfo->getDirectoryListing(); + foreach ($files as $file) { + if ($file instanceof File) { + if (!$this->checkFileInfo($file)) { + return false; + } + } elseif ($file instanceof Folder) { + return $this->dirRecursiveCheck($file); + } + } + + return true; + } + + /** + * @param Node $fileInfo + * @return bool + * @throws NotFoundException + */ + private function checkFileInfo(Node $fileInfo) { + // Restrict view-only to nodes which are shared + $storage = $fileInfo->getStorage(); + if (!$storage->instanceOfStorage(SharedStorage::class)) { + return true; + } + + // Extract extra permissions + /** @var \OCA\Files_Sharing\SharedStorage $storage */ + $share = $storage->getShare(); + + // Check if read-only and on whether permission can download is both set and disabled. + + $canDownload = $share->getAttributes()->getAttribute('permissions', 'download'); + if ($canDownload !== null && !$canDownload) { + return false; + } + return true; + } +} diff --git a/apps/files_sharing/tests/ApiTest.php b/apps/files_sharing/tests/ApiTest.php index c7159e82163e2..7bfe3adc6f646 100644 --- a/apps/files_sharing/tests/ApiTest.php +++ b/apps/files_sharing/tests/ApiTest.php @@ -948,8 +948,11 @@ public function testUpdateShare() { ->setSharedBy(self::TEST_FILES_SHARING_API_USER1) ->setSharedWith(self::TEST_FILES_SHARING_API_USER2) ->setShareType(IShare::TYPE_USER) - ->setPermissions(19); + ->setPermissions(19) + ->setAttributes($this->shareManager->newShare()->newAttributes()); $share1 = $this->shareManager->createShare($share1); + $this->assertEquals(19, $share1->getPermissions()); + $this->assertEquals(null, $share1->getAttributes()); $share2 = $this->shareManager->newShare(); $share2->setNode($node1) @@ -957,6 +960,7 @@ public function testUpdateShare() { ->setShareType(IShare::TYPE_LINK) ->setPermissions(1); $share2 = $this->shareManager->createShare($share2); + $this->assertEquals(1, $share2->getPermissions()); // update permissions $ocs = $this->createOCS(self::TEST_FILES_SHARING_API_USER1); @@ -965,6 +969,7 @@ public function testUpdateShare() { $share1 = $this->shareManager->getShareById('ocinternal:' . $share1->getId()); $this->assertEquals(1, $share1->getPermissions()); + $this->assertEquals(true, $share1->getAttributes()->getAttribute('app1', 'attr1')); // update password for link share $this->assertNull($share2->getPassword()); diff --git a/apps/files_sharing/tests/Controller/ShareAPIControllerTest.php b/apps/files_sharing/tests/Controller/ShareAPIControllerTest.php index 5555aef14256b..3b3ba74ef8d99 100644 --- a/apps/files_sharing/tests/Controller/ShareAPIControllerTest.php +++ b/apps/files_sharing/tests/Controller/ShareAPIControllerTest.php @@ -124,7 +124,11 @@ protected function setUp(): void { ->willReturn(true); $this->shareManager ->expects($this->any()) - ->method('shareProviderExists')->willReturn(true); + ->method('shareProviderExists')->willReturn(true); + $this->shareManager + ->expects($this->any()) + ->method('newShare') + ->willReturn($this->newShare()); $this->groupManager = $this->createMock(IGroupManager::class); $this->userManager = $this->createMock(IUserManager::class); $this->request = $this->createMock(IRequest::class); @@ -194,6 +198,25 @@ private function newShare() { } + private function mockShareAttributes() { + $formattedShareAttributes = [ + [ + [ + 'scope' => 'permissions', + 'key' => 'download', + 'enabled' => true + ] + ] + ]; + + $shareAttributes = $this->createMock(IShareAttributes::class); + $shareAttributes->method('toArray')->willReturn($formattedShareAttributes); + $shareAttributes->method('getAttribute')->with('permissions', 'download')->willReturn(true); + + // send both IShare attributes class and expected json string + return [$shareAttributes, \json_encode($formattedShareAttributes)]; + } + public function testDeleteShareShareNotFound() { $this->expectException(\OCP\AppFramework\OCS\OCSNotFoundException::class); $this->expectExceptionMessage('Wrong share ID, share doesn\'t exist'); @@ -505,7 +528,7 @@ public function testGetGetShareNotExists() { public function createShare($id, $shareType, $sharedWith, $sharedBy, $shareOwner, $path, $permissions, $shareTime, $expiration, $parent, $target, $mail_send, $note = '', $token = null, - $password = null, $label = '') { + $password = null, $label = '', $attributes = null) { $share = $this->getMockBuilder(IShare::class)->getMock(); $share->method('getId')->willReturn($id); $share->method('getShareType')->willReturn($shareType); @@ -516,6 +539,7 @@ public function createShare($id, $shareType, $sharedWith, $sharedBy, $shareOwner $share->method('getPermissions')->willReturn($permissions); $share->method('getNote')->willReturn($note); $share->method('getLabel')->willReturn($label); + $share->method('getAttributes')->willReturn($attributes); $time = new \DateTime(); $time->setTimestamp($shareTime); $share->method('getShareTime')->willReturn($time); @@ -565,6 +589,8 @@ public function dataGetShare() { $folder->method('getParent')->willReturn($parentFolder); $folder->method('getMimeType')->willReturn('myFolderMimeType'); + [$shareAttributes, $shareAttributesReturnJson] = $this->mockShareAttributes(); + // File shared with user $share = $this->createShare( 100, @@ -579,7 +605,8 @@ public function dataGetShare() { 6, 'target', 0, - 'personal note' + 'personal note', + $shareAttributes, ); $expected = [ 'id' => 100, @@ -597,6 +624,7 @@ public function dataGetShare() { 'token' => null, 'expiration' => null, 'permissions' => 4, + 'attributes' => $shareAttributesReturnJson, 'stime' => 5, 'parent' => null, 'storage_id' => 'STORAGE', @@ -630,7 +658,8 @@ public function dataGetShare() { 6, 'target', 0, - 'personal note' + 'personal note', + $shareAttributes, ); $expected = [ 'id' => 101, @@ -647,6 +676,7 @@ public function dataGetShare() { 'token' => null, 'expiration' => null, 'permissions' => 4, + 'attributes' => $shareAttributesReturnJson, 'stime' => 5, 'parent' => null, 'storage_id' => 'STORAGE', @@ -702,6 +732,7 @@ public function dataGetShare() { 'token' => 'token', 'expiration' => '2000-01-02 00:00:00', 'permissions' => 4, + 'attributes' => null, 'stime' => 5, 'parent' => null, 'storage_id' => 'STORAGE', @@ -3725,7 +3756,7 @@ public function dataFormatShare() { $recipient = $this->getMockBuilder(IUser::class)->getMock(); $recipient->method('getDisplayName')->willReturn('recipientDN'); $recipient->method('getSystemEMailAddress')->willReturn('recipient'); - + [$shareAttributes, $shareAttributesReturnJson] = $this->mockShareAttributes(); $result = []; @@ -3735,6 +3766,7 @@ public function dataFormatShare() { ->setSharedBy('initiator') ->setShareOwner('owner') ->setPermissions(\OCP\Constants::PERMISSION_READ) + ->setAttributes($shareAttributes) ->setNode($file) ->setShareTime(new \DateTime('2000-01-01T00:01:02')) ->setTarget('myTarget') @@ -3749,6 +3781,7 @@ public function dataFormatShare() { 'uid_owner' => 'initiator', 'displayname_owner' => 'initiator', 'permissions' => 1, + 'attributes' => $shareAttributesReturnJson, 'stime' => 946684862, 'parent' => null, 'expiration' => null, @@ -3785,6 +3818,7 @@ public function dataFormatShare() { 'uid_owner' => 'initiator', 'displayname_owner' => 'initiatorDN', 'permissions' => 1, + 'attributes' => $shareAttributesReturnJson, 'stime' => 946684862, 'parent' => null, 'expiration' => null, @@ -3837,6 +3871,7 @@ public function dataFormatShare() { 'uid_owner' => 'initiator', 'displayname_owner' => 'initiator', 'permissions' => 1, + 'attributes' => null, 'stime' => 946684862, 'parent' => null, 'expiration' => null, @@ -3885,6 +3920,7 @@ public function dataFormatShare() { 'uid_owner' => 'initiator', 'displayname_owner' => 'initiator', 'permissions' => 1, + 'attributes' => null, 'stime' => 946684862, 'parent' => null, 'expiration' => null, @@ -3935,6 +3971,7 @@ public function dataFormatShare() { 'uid_owner' => 'initiator', 'displayname_owner' => 'initiator', 'permissions' => 1, + 'attributes' => null, 'stime' => 946684862, 'parent' => null, 'expiration' => null, @@ -4030,6 +4067,7 @@ public function dataFormatShare() { 'uid_owner' => 'initiator', 'displayname_owner' => 'initiator', 'permissions' => 1, + 'attributes' => null, 'stime' => 946684862, 'parent' => null, 'expiration' => '2001-01-02 00:00:00', @@ -4228,6 +4266,7 @@ public function dataFormatShare() { 'uid_owner' => 'initiator', 'displayname_owner' => 'initiator', 'permissions' => 1, + 'attributes' => null, 'stime' => 946684862, 'parent' => null, 'expiration' => null, diff --git a/apps/files_sharing/tests/MountProviderTest.php b/apps/files_sharing/tests/MountProviderTest.php index 00ae847eaac85..40512b0df91bb 100644 --- a/apps/files_sharing/tests/MountProviderTest.php +++ b/apps/files_sharing/tests/MountProviderTest.php @@ -81,11 +81,35 @@ protected function setUp(): void { $this->provider = new MountProvider($this->config, $this->shareManager, $this->logger, $eventDispatcher, $cacheFactory); } - private function makeMockShare($id, $nodeId, $owner = 'user2', $target = null, $permissions = 31) { + private function makeMockShareAttributes($attrs) { + if ($attrs === null) { + return null; + } + + $shareAttributes = $this->createMock(IShareAttributes::class); + $shareAttributes->method('toArray')->willReturn($attrs); + $shareAttributes->method('getAttribute')->will( + $this->returnCallback(function ($scope, $key) use ($attrs) { + $result = null; + foreach ($attrs as $attr) { + if ($attr['key'] === $key && $attr['scope'] === $scope) { + $result = $attr['enabled']; + } + } + return $result; + }) + ); + return $shareAttributes; + } + + private function makeMockShare($id, $nodeId, $owner = 'user2', $target = null, $permissions = 31, $attributes) { $share = $this->createMock(IShare::class); $share->expects($this->any()) ->method('getPermissions') ->willReturn($permissions); + $share->expects($this->any()) + ->method('getAttributes') + ->will($this->returnValue($this->makeMockShareAttributes($attributes))); $share->expects($this->any()) ->method('getShareOwner') ->willReturn($owner); @@ -115,14 +139,16 @@ private function makeMockShare($id, $nodeId, $owner = 'user2', $target = null, $ public function testExcludeShares() { $rootFolder = $this->createMock(IRootFolder::class); $userManager = $this->createMock(IUserManager::class); + $attr1 = []; + $attr2 = [['scope' => 'permission', 'key' => 'download', 'enabled' => true]]; $userShares = [ - $this->makeMockShare(1, 100, 'user2', '/share2', 0), - $this->makeMockShare(2, 100, 'user2', '/share2', 31), + $this->makeMockShare(1, 100, 'user2', '/share2', 0, $attr1), + $this->makeMockShare(2, 100, 'user2', '/share2', 31, $attr2), ]; $groupShares = [ - $this->makeMockShare(3, 100, 'user2', '/share2', 0), - $this->makeMockShare(4, 101, 'user2', '/share4', 31), - $this->makeMockShare(5, 100, 'user1', '/share4', 31), + $this->makeMockShare(3, 100, 'user2', '/share2', 0, $attr1), + $this->makeMockShare(4, 101, 'user2', '/share4', 31, $attr2), + $this->makeMockShare(5, 100, 'user1', '/share4', 31, $attr2), ]; $roomShares = [ $this->makeMockShare(6, 102, 'user2', '/share6', 0), @@ -178,12 +204,14 @@ public function testExcludeShares() { $this->assertEquals(100, $mountedShare1->getNodeId()); $this->assertEquals('/share2', $mountedShare1->getTarget()); $this->assertEquals(31, $mountedShare1->getPermissions()); + $this->assertEquals(true, $mountedShare1->getAttributes()->getAttribute('permission', 'download')); $mountedShare2 = $mounts[1]->getShare(); $this->assertEquals('4', $mountedShare2->getId()); $this->assertEquals('user2', $mountedShare2->getShareOwner()); $this->assertEquals(101, $mountedShare2->getNodeId()); $this->assertEquals('/share4', $mountedShare2->getTarget()); $this->assertEquals(31, $mountedShare2->getPermissions()); + $this->assertEquals(true, $mountedShare2->getAttributes()->getAttribute('permission', 'download')); $mountedShare3 = $mounts[2]->getShare(); $this->assertEquals('8', $mountedShare3->getId()); $this->assertEquals('user2', $mountedShare3->getShareOwner()); @@ -205,27 +233,27 @@ public function mergeSharesDataProvider() { // #0: share as outsider with "group1" and "user1" with same permissions [ [ - [1, 100, 'user2', '/share2', 31], + [1, 100, 'user2', '/share2', 31, null], ], [ - [2, 100, 'user2', '/share2', 31], + [2, 100, 'user2', '/share2', 31, null], ], [ // combined, user share has higher priority - ['1', 100, 'user2', '/share2', 31], + ['1', 100, 'user2', '/share2', 31, []], ], ], // #1: share as outsider with "group1" and "user1" with different permissions [ [ - [1, 100, 'user2', '/share', 31], + [1, 100, 'user2', '/share', 31, [['scope' => 'permission', 'key' => 'download', 'enabled' => true], ['scope' => 'app', 'key' => 'attribute1', 'enabled' => true]]], ], [ - [2, 100, 'user2', '/share', 15], + [2, 100, 'user2', '/share', 15, [['scope' => 'permission', 'key' => 'download', 'enabled' => false], ['scope' => 'app', 'key' => 'attribute2', 'enabled' => false]]], ], [ // use highest permissions - ['1', 100, 'user2', '/share', 31], + ['1', 100, 'user2', '/share', 31, [['scope' => 'permission', 'key' => 'download', 'enabled' => true], ['scope' => 'app', 'key' => 'attribute1', 'enabled' => true], ['scope' => 'app', 'key' => 'attribute2', 'enabled' => false]]], ], ], // #2: share as outsider with "group1" and "group2" with same permissions @@ -233,12 +261,12 @@ public function mergeSharesDataProvider() { [ ], [ - [1, 100, 'user2', '/share', 31], - [2, 100, 'user2', '/share', 31], + [1, 100, 'user2', '/share', 31, null], + [2, 100, 'user2', '/share', 31, []], ], [ // combined, first group share has higher priority - ['1', 100, 'user2', '/share', 31], + ['1', 100, 'user2', '/share', 31, []], ], ], // #3: share as outsider with "group1" and "group2" with different permissions @@ -246,12 +274,12 @@ public function mergeSharesDataProvider() { [ ], [ - [1, 100, 'user2', '/share', 31], - [2, 100, 'user2', '/share', 15], + [1, 100, 'user2', '/share', 31, [['scope' => 'permission', 'key' => 'download', 'enabled' => false]]], + [2, 100, 'user2', '/share', 15, [['scope' => 'permission', 'key' => 'download', 'enabled' => true]]], ], [ - // use higher permissions - ['1', 100, 'user2', '/share', 31], + // use higher permissions (most permissive) + ['1', 100, 'user2', '/share', 31, [['scope' => 'permission', 'key' => 'download', 'enabled' => true]]], ], ], // #4: share as insider with "group1" @@ -259,7 +287,7 @@ public function mergeSharesDataProvider() { [ ], [ - [1, 100, 'user1', '/share', 31], + [1, 100, 'user1', '/share', 31, []], ], [ // no received share since "user1" is the sharer/owner @@ -270,8 +298,8 @@ public function mergeSharesDataProvider() { [ ], [ - [1, 100, 'user1', '/share', 31], - [2, 100, 'user1', '/share', 15], + [1, 100, 'user1', '/share', 31, [['scope' => 'permission', 'key' => 'download', 'enabled' => true]]], + [2, 100, 'user1', '/share', 15, [['scope' => 'permission', 'key' => 'download', 'enabled' => false]]], ], [ // no received share since "user1" is the sharer/owner @@ -282,7 +310,7 @@ public function mergeSharesDataProvider() { [ ], [ - [1, 100, 'user2', '/share', 0], + [1, 100, 'user2', '/share', 0, []], ], [ // no received share since "user1" opted out @@ -291,40 +319,40 @@ public function mergeSharesDataProvider() { // #7: share as outsider with "group1" and "user1" where recipient renamed in between [ [ - [1, 100, 'user2', '/share2-renamed', 31], + [1, 100, 'user2', '/share2-renamed', 31, []], ], [ - [2, 100, 'user2', '/share2', 31], + [2, 100, 'user2', '/share2', 31, []], ], [ // use target of least recent share - ['1', 100, 'user2', '/share2-renamed', 31], + ['1', 100, 'user2', '/share2-renamed', 31, []], ], ], // #8: share as outsider with "group1" and "user1" where recipient renamed in between [ [ - [2, 100, 'user2', '/share2', 31], + [2, 100, 'user2', '/share2', 31, []], ], [ - [1, 100, 'user2', '/share2-renamed', 31], + [1, 100, 'user2', '/share2-renamed', 31, []], ], [ // use target of least recent share - ['1', 100, 'user2', '/share2-renamed', 31], + ['1', 100, 'user2', '/share2-renamed', 31, []], ], ], // #9: share as outsider with "nullgroup" and "user1" where recipient renamed in between [ [ - [2, 100, 'user2', '/share2', 31], + [2, 100, 'user2', '/share2', 31, []], ], [ - [1, 100, 'nullgroup', '/share2-renamed', 31], + [1, 100, 'nullgroup', '/share2-renamed', 31, []], ], [ // use target of least recent share - ['1', 100, 'nullgroup', '/share2-renamed', 31], + ['1', 100, 'nullgroup', '/share2-renamed', 31, []], ], true ], @@ -410,6 +438,11 @@ public function testMergeShares($userShares, $groupShares, $expectedShares, $mov $this->assertEquals($expectedShare[2], $share->getShareOwner()); $this->assertEquals($expectedShare[3], $share->getTarget()); $this->assertEquals($expectedShare[4], $share->getPermissions()); + if ($expectedShare[5] === null) { + $this->assertNull($share->getAttributes()); + } else { + $this->assertEquals($expectedShare[5], $share->getAttributes()->toArray()); + } } } } diff --git a/core/Migrations/Version20181220085457.php b/core/Migrations/Version20181220085457.php new file mode 100644 index 0000000000000..af1ed61910d6d --- /dev/null +++ b/core/Migrations/Version20181220085457.php @@ -0,0 +1,50 @@ + + * + */ + +namespace OC\Migrations; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Types\Type; +use OCP\Migration\ISchemaMigration; + +/** + * Auto-generated migration step: Please modify to your needs! + */ +class Version20181220085457 implements ISchemaMigration { + public function changeSchema(Schema $schema, array $options) { + $prefix = $options['tablePrefix']; + + if ($schema->hasTable("${prefix}share")) { + $shareTable = $schema->getTable("${prefix}share"); + + if (!$shareTable->hasColumn('attributes')) { + $shareTable->addColumn( + 'attributes', + Type::JSON, + [ + 'default' => null, + 'notnull' => false + ] + ); + } + } + } +} diff --git a/lib/private/Share20/DefaultShareProvider.php b/lib/private/Share20/DefaultShareProvider.php index 520bd17d3cf08..f1551a5a5ed94 100644 --- a/lib/private/Share20/DefaultShareProvider.php +++ b/lib/private/Share20/DefaultShareProvider.php @@ -52,6 +52,7 @@ use OCP\L10N\IFactory; use OCP\Mail\IMailer; use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IAttributes; use OCP\Share\IShare; use OCP\Share\IShareProvider; @@ -193,6 +194,12 @@ public function create(\OCP\Share\IShare $share) { // set the permissions $qb->setValue('permissions', $qb->createNamedParameter($share->getPermissions())); + // set share attributes + $shareAttributes = $this->formatShareAttributes( + $share->getAttributes() + ); + $qb->setValue('attributes', $qb->createNamedParameter($shareAttributes)); + // Set who created this share $qb->setValue('uid_initiator', $qb->createNamedParameter($share->getSharedBy())); @@ -248,6 +255,8 @@ public function create(\OCP\Share\IShare $share) { public function update(\OCP\Share\IShare $share) { $originalShare = $this->getShareById($share->getId()); + $shareAttributes = $this->formatShareAttributes($share->getAttributes()); + if ($share->getShareType() === IShare::TYPE_USER) { /* * We allow updating the recipient on user shares. @@ -259,6 +268,7 @@ public function update(\OCP\Share\IShare $share) { ->set('uid_owner', $qb->createNamedParameter($share->getShareOwner())) ->set('uid_initiator', $qb->createNamedParameter($share->getSharedBy())) ->set('permissions', $qb->createNamedParameter($share->getPermissions())) + ->set('attributes', $qb->createNamedParameter($shareAttributes)) ->set('item_source', $qb->createNamedParameter($share->getNode()->getId())) ->set('file_source', $qb->createNamedParameter($share->getNode()->getId())) ->set('expiration', $qb->createNamedParameter($share->getExpirationDate(), IQueryBuilder::PARAM_DATE)) @@ -272,6 +282,7 @@ public function update(\OCP\Share\IShare $share) { ->set('uid_owner', $qb->createNamedParameter($share->getShareOwner())) ->set('uid_initiator', $qb->createNamedParameter($share->getSharedBy())) ->set('permissions', $qb->createNamedParameter($share->getPermissions())) + ->set('attributes', $qb->createNamedParameter($shareAttributes)) ->set('item_source', $qb->createNamedParameter($share->getNode()->getId())) ->set('file_source', $qb->createNamedParameter($share->getNode()->getId())) ->set('expiration', $qb->createNamedParameter($share->getExpirationDate(), IQueryBuilder::PARAM_DATE)) @@ -301,6 +312,7 @@ public function update(\OCP\Share\IShare $share) { ->where($qb->expr()->eq('parent', $qb->createNamedParameter($share->getId()))) ->andWhere($qb->expr()->neq('permissions', $qb->createNamedParameter(0))) ->set('permissions', $qb->createNamedParameter($share->getPermissions())) + ->set('attributes', $qb->createNamedParameter($shareAttributes)) ->execute(); } elseif ($share->getShareType() === IShare::TYPE_LINK) { $qb = $this->dbConn->getQueryBuilder(); @@ -311,6 +323,7 @@ public function update(\OCP\Share\IShare $share) { ->set('uid_owner', $qb->createNamedParameter($share->getShareOwner())) ->set('uid_initiator', $qb->createNamedParameter($share->getSharedBy())) ->set('permissions', $qb->createNamedParameter($share->getPermissions())) + ->set('attributes', $qb->createNamedParameter($shareAttributes)) ->set('item_source', $qb->createNamedParameter($share->getNode()->getId())) ->set('file_source', $qb->createNamedParameter($share->getNode()->getId())) ->set('token', $qb->createNamedParameter($share->getToken())) @@ -611,6 +624,10 @@ public function move(\OCP\Share\IShare $share, $recipient) { $data = $stmt->fetch(); $stmt->closeCursor(); + $shareAttributes = $this->formatShareAttributes( + $share->getAttributes() + ); + if ($data === false) { // No usergroup share yet. Create one. $qb = $this->dbConn->getQueryBuilder(); @@ -626,6 +643,7 @@ public function move(\OCP\Share\IShare $share, $recipient) { 'file_source' => $qb->createNamedParameter($share->getNodeId()), 'file_target' => $qb->createNamedParameter($share->getTarget()), 'permissions' => $qb->createNamedParameter($share->getPermissions()), + 'attributes' => $qb->createNamedParameter($shareAttributes), 'stime' => $qb->createNamedParameter($share->getShareTime()->getTimestamp()), ])->execute(); } else { @@ -1061,6 +1079,8 @@ private function createShare($data) { $share->setToken($data['token']); } + $share = $this->updateShareAttributes($share, $data['attributes']); + $share->setSharedBy($data['uid_initiator']); $share->setShareOwner($data['uid_owner']); @@ -1528,4 +1548,50 @@ public function getAllShares(): iterable { } $cursor->closeCursor(); } + + /** + * Load from database format (JSON string) to IAttributes + * + * @param IShare $share + * @param string|null $data + * @return IShare modified share + */ + private function updateShareAttributes(IShare $share, $data) { + if ($data !== null) { + $attributes = new ShareAttributes(); + $compressedAttributes = \json_decode($data, true); + foreach ($compressedAttributes as $compressedAttribute) { + $attributes->setAttribute( + $compressedAttribute[0], + $compressedAttribute[1], + $compressedAttribute[2] + ); + } + $share->setAttributes($attributes); + } + + return $share; + } + + /** + * Format IAttributes to database format (JSON string) + * + * @param IAttributes|null $attributes + * @return string|null + */ + private function formatShareAttributes($attributes) { + if ($attributes === null || empty($attributes->toArray())) { + return null; + } + + $compressedAttributes = []; + foreach ($attributes->toArray() as $attribute) { + $compressedAttributes[] = [ + 0 => $attribute['scope'], + 1 => $attribute['key'], + 2 => $attribute['enabled'] + ]; + } + return \json_encode($compressedAttributes); + } } diff --git a/lib/private/Share20/Manager.php b/lib/private/Share20/Manager.php index eed86bb41c385..207b5ce277cba 100644 --- a/lib/private/Share20/Manager.php +++ b/lib/private/Share20/Manager.php @@ -1093,6 +1093,7 @@ public function updateShare(IShare $share) { 'shareWith' => $share->getSharedWith(), 'uidOwner' => $share->getSharedBy(), 'permissions' => $share->getPermissions(), + 'attributes' => $share->getAttributes(), 'path' => $userFolder->getRelativePath($share->getNode()->getPath()), ]); } @@ -1777,7 +1778,7 @@ public function getAccessList(\OCP\Files\Node $path, $recursive = true, $current * @return IShare */ public function newShare() { - return new \OC\Share20\Share($this->rootFolder, $this->userManager); + return new Share($this->rootFolder, $this->userManager); } /** @@ -2087,4 +2088,16 @@ public function getAllShares(): iterable { yield from $provider->getAllShares(); } } + + /** + * @param IAttributes|null $perms + * @return string + */ + private function hashAttributes($perms) { + if ($perms === null || empty($perms->toArray())) { + return ""; + } + + return \md5(\json_encode($perms->toArray())); + } } diff --git a/lib/private/Share20/Share.php b/lib/private/Share20/Share.php index 7ed03832e4c71..ccf751f09480b 100644 --- a/lib/private/Share20/Share.php +++ b/lib/private/Share20/Share.php @@ -37,6 +37,7 @@ use OCP\Files\NotFoundException; use OCP\IUserManager; use OCP\Share\Exceptions\IllegalIDChangeException; +use OCP\Share\IAttributes; use OCP\Share\IShare; class Share implements IShare { @@ -65,6 +66,8 @@ class Share implements IShare { private $shareOwner; /** @var int */ private $permissions; + /** @var IAttributes */ + private $attributes; /** @var int */ private $status; /** @var string */ @@ -332,6 +335,28 @@ public function getPermissions() { return $this->permissions; } + /** + * @inheritdoc + */ + public function newAttributes() { + return new ShareAttributes(); + } + + /** + * @inheritdoc + */ + public function setAttributes(IAttributes $attributes) { + $this->attributes = $attributes; + return $this; + } + + /** + * @inheritdoc + */ + public function getAttributes() { + return $this->attributes; + } + /** * @inheritdoc */ @@ -511,7 +536,7 @@ public function getToken() { * Set the parent of this share * * @param int parent - * @return \OCP\Share\IShare + * @return IShare * @deprecated The new shares do not have parents. This is just here for legacy reasons. */ public function setParent($parent) { diff --git a/lib/private/Share20/ShareAttributes.php b/lib/private/Share20/ShareAttributes.php new file mode 100644 index 0000000000000..92f034e6783a5 --- /dev/null +++ b/lib/private/Share20/ShareAttributes.php @@ -0,0 +1,73 @@ + + * + * @copyright Copyright (c) 2019, ownCloud GmbH + * @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 OC\Share20; + +use OCP\Share\IAttributes; + +class ShareAttributes implements IAttributes { + + /** @var array */ + private $attributes; + + public function __construct() { + $this->attributes = []; + } + + /** + * @inheritdoc + */ + public function setAttribute($scope, $key, $enabled) { + if (!\array_key_exists($scope, $this->attributes)) { + $this->attributes[$scope] = []; + } + $this->attributes[$scope][$key] = $enabled; + return $this; + } + + /** + * @inheritdoc + */ + public function getAttribute($scope, $key) { + if (\array_key_exists($scope, $this->attributes) && + \array_key_exists($key, $this->attributes[$scope])) { + return $this->attributes[$scope][$key]; + } + return null; + } + + /** + * @inheritdoc + */ + public function toArray() { + $result = []; + foreach ($this->attributes as $scope => $keys) { + foreach ($keys as $key => $enabled) { + $result[] = [ + "scope" => $scope, + "key" => $key, + "enabled" => $enabled + ]; + } + } + + return $result; + } +} diff --git a/lib/private/legacy/OC_Files.php b/lib/private/legacy/OC_Files.php index 02e15fd08d545..fc45535d5c44b 100644 --- a/lib/private/legacy/OC_Files.php +++ b/lib/private/legacy/OC_Files.php @@ -167,6 +167,13 @@ public static function get($dir, $files, $params = null) { } } + //Dispatch an event to see if any apps have problem with download + $event = new \Symfony\Component\EventDispatcher\GenericEvent(null, ['dir' => $dir, 'files' => $files, 'run' => true]); + OC::$server->getEventDispatcher()->dispatch('file.beforeCreateZip', $event); + if (($event->getArgument('run') === false) or ($event->hasArgument('errorMessage'))) { ++ throw new \OC\ForbiddenException($event->getArgument('errorMessage')); + } + $streamer = new Streamer(\OC::$server->getRequest(), $fileSize, $numberOfFiles); OC_Util::obEnd(); @@ -212,6 +219,8 @@ public static function get($dir, $files, $params = null) { $streamer->finalize(); set_time_limit($executionTime); self::unlockAllTheFiles($dir, $files, $getType, $view, $filename); + $event = new \Symfony\Component\EventDispatcher\GenericEvent(null, ['result' => 'success', 'dir' => $dir, 'files' => $files]); + OC::$server->getEventDispatcher()->dispatch('file.afterCreateZip', $event); } catch (\OCP\Lock\LockedException $ex) { self::unlockAllTheFiles($dir, $files, $getType, $view, $filename); OC::$server->getLogger()->logException($ex); @@ -222,13 +231,16 @@ public static function get($dir, $files, $params = null) { self::unlockAllTheFiles($dir, $files, $getType, $view, $filename); OC::$server->getLogger()->logException($ex); $l = \OC::$server->getL10N('lib'); - \OC_Template::printErrorPage($l->t('Cannot read file'), $ex->getMessage(), 200); + \OC_Template::printErrorPage($l->t('Cannot download file'), $ex->getMessage(), 200); } catch (\Exception $ex) { self::unlockAllTheFiles($dir, $files, $getType, $view, $filename); OC::$server->getLogger()->logException($ex); $l = \OC::$server->getL10N('lib'); $hint = method_exists($ex, 'getHint') ? $ex->getHint() : ''; - \OC_Template::printErrorPage($l->t('Cannot read file'), $hint, 200); + if ($event && $event->hasArgument('message')) { + $hint .= ' ' . $event->getArgument('message'); + } + \OC_Template::printErrorPage($l->t('Cannot download file'), $hint, 200); } } @@ -287,6 +299,7 @@ private static function parseHttpRangeHeader($rangeHeaderPos, $fileSize) { * @param string $name * @param string $dir * @param array $params ; 'head' boolean to only send header of the request ; 'range' http range header + * @throws \OC\ForbiddenException */ private static function getSingleFile($view, $dir, $name, $params) { $filename = $dir . '/' . $name; @@ -322,6 +335,18 @@ private static function getSingleFile($view, $dir, $name, $params) { $rangeArray = self::parseHttpRangeHeader(substr($params['range'], 6), $fileSize); } + $event = new \Symfony\Component\EventDispatcher\GenericEvent(null, ['path' => $filename]); + OC::$server->getEventDispatcher()->dispatch('file.beforeGetDirect', $event); + + if (!\OC\Files\Filesystem::isReadable($filename) || $event->hasArgument('errorMessage')) { + if (!$event->hasArgument('errorMessage')) { + $msg = $event->getArgument('errorMessage'); + } else { + $msg = 'Access denied'; + } + throw new \OC\ForbiddenException($msg); + } + self::sendHeaders($filename, $name, $rangeArray); if (isset($params['head']) && $params['head']) { diff --git a/lib/public/Share/IAttributes.php b/lib/public/Share/IAttributes.php new file mode 100644 index 0000000000000..9f2556e4005b1 --- /dev/null +++ b/lib/public/Share/IAttributes.php @@ -0,0 +1,68 @@ + + * + * @copyright Copyright (c) 2019, ownCloud GmbH + * @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 OCP\Share; + +/** + * Interface IAttributes + * + * @package OCP\Share + * @since 10.2.0 + */ +interface IAttributes { + + /** + * Sets an attribute enabled/disabled. If the key did not exist before it will be created. + * + * @param string $scope scope + * @param string $key key + * @param bool $enabled enabled + * @return IAttributes The modified object + * @since 10.2.0 + */ + public function setAttribute($scope, $key, $enabled); + + /** + * Returns if attribute is enabled/disabled for given scope id and key. + * If attribute does not exist, returns null + * + * @param string $scope scope + * @param string $key key + * @return bool|null + * @since 10.2.0 + */ + public function getAttribute($scope, $key); + + /** + * Formats the IAttributes object to array with the following format: + * [ + * 0 => [ + * "scope" => , + * "key" => , + * "enabled" => + * ], + * ... + * ] + * + * @return array formatted IAttributes + * @since 10.2.0 + */ + public function toArray(); +} diff --git a/lib/public/Share/IShare.php b/lib/public/Share/IShare.php index 1d3cf9bbbdff7..d992fe2ccd72c 100644 --- a/lib/public/Share/IShare.php +++ b/lib/public/Share/IShare.php @@ -300,7 +300,7 @@ public function getSharedWithAvatar(); * See \OCP\Constants::PERMISSION_* * * @param int $permissions - * @return \OCP\Share\IShare The modified object + * @return IShare The modified object * @since 9.0.0 */ public function setPermissions($permissions); @@ -314,6 +314,31 @@ public function setPermissions($permissions); */ public function getPermissions(); + /** + * Create share attributes object + * + * @since 25.0.0 + * @return IAttributes; + */ + public function newAttributes(); + + /** + * Set share attributes + * + * @param IAttributes $attributes + * @since 25.0.0 + * @return IShare The modified object + */ + public function setAttributes(IAttributes $attributes); + + /** + * Get share attributes + * + * @since 25.0.0 + * @return IAttributes + */ + public function getAttributes(); + /** * Set the accepted status * See self::STATUS_* diff --git a/tests/lib/Share20/DefaultShareProviderTest.php b/tests/lib/Share20/DefaultShareProviderTest.php index 03e1bdb4346b8..f010862b7bb9a 100644 --- a/tests/lib/Share20/DefaultShareProviderTest.php +++ b/tests/lib/Share20/DefaultShareProviderTest.php @@ -23,6 +23,8 @@ namespace Test\Share20; use OC\Share20\DefaultShareProvider; +use OC\Share20\ShareAttributes; +use OCP\Share\IAttributes as IShareAttributes; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Defaults; use OCP\Files\File; @@ -703,6 +705,11 @@ public function testCreateUserShare() { $share->setSharedWithDisplayName('Displayed Name'); $share->setSharedWithAvatar('/path/to/image.svg'); $share->setPermissions(1); + + $attrs = new ShareAttributes(); + $attrs->setAttribute('permissions', 'download', true); + $share->setAttributes($attrs); + $share->setTarget('/target'); $share2 = $this->provider->create($share); @@ -723,6 +730,17 @@ public function testCreateUserShare() { $this->assertSame('/path/to/image.svg', $share->getSharedWithAvatar()); $this->assertSame(null, $share2->getSharedWithDisplayName()); $this->assertSame(null, $share2->getSharedWithAvatar()); + + $this->assertSame( + [ + [ + 'scope' => 'permissions', + 'key' => 'download', + 'enabled' => true + ] + ], + $share->getAttributes()->toArray() + ); } public function testCreateGroupShare() { @@ -760,6 +778,9 @@ public function testCreateGroupShare() { $share->setSharedWithDisplayName('Displayed Name'); $share->setSharedWithAvatar('/path/to/image.svg'); $share->setTarget('/target'); + $attrs = new ShareAttributes(); + $attrs->setAttribute('permissions', 'download', true); + $share->setAttributes($attrs); $share2 = $this->provider->create($share); @@ -779,6 +800,17 @@ public function testCreateGroupShare() { $this->assertSame('/path/to/image.svg', $share->getSharedWithAvatar()); $this->assertSame(null, $share2->getSharedWithDisplayName()); $this->assertSame(null, $share2->getSharedWithAvatar()); + + $this->assertSame( + [ + [ + 'scope' => 'permissions', + 'key' => 'download', + 'enabled' => true + ] + ], + $share->getAttributes()->toArray() + ); } public function testCreateLinkShare() { diff --git a/tests/lib/Share20/LegacyHooksTest.php b/tests/lib/Share20/LegacyHooksTest.php index 5afee588c34db..00f061ae8d257 100644 --- a/tests/lib/Share20/LegacyHooksTest.php +++ b/tests/lib/Share20/LegacyHooksTest.php @@ -353,4 +353,158 @@ public function testPostShare() { $event = new GenericEvent($share); $this->eventDispatcher->dispatch('OCP\Share::postShare', $event); } + + public function providesDataForCanGet() { + // normal file (sender) - can download directly + $senderFileStorage = $this->createMock(IStorage::class); + $senderFileStorage->method('instanceOfStorage')->with(SharedStorage::class)->willReturn(false); + $senderFile = $this->createMock(File::class); + $senderFile->method('getStorage')->willReturn($senderFileStorage); + $senderUserFolder = $this->createMock(Folder::class); + $senderUserFolder->method('get')->willReturn($senderFile); + + $result[] = [ '/bar.txt', $senderUserFolder, true ]; + + // shared file (receiver) with attribute secure-view-enabled set false - + // can download directly + $receiverFileShareAttributes = $this->createMock(IAttributes::class); + $receiverFileShareAttributes->method('getAttribute')->with('permissions', 'download')->willReturn(true); + $receiverFileShare = $this->createMock(IShare::class); + $receiverFileShare->method('getAttributes')->willReturn($receiverFileShareAttributes); + $receiverFileStorage = $this->createMock(SharedStorage::class); + $receiverFileStorage->method('instanceOfStorage')->with(SharedStorage::class)->willReturn(true); + $receiverFileStorage->method('getShare')->willReturn($receiverFileShare); + $receiverFile = $this->createMock(File::class); + $receiverFile->method('getStorage')->willReturn($receiverFileStorage); + $receiverUserFolder = $this->createMock(Folder::class); + $receiverUserFolder->method('get')->willReturn($receiverFile); + + $result[] = [ '/share-bar.txt', $receiverUserFolder, true ]; + + // shared file (receiver) with attribute secure-view-enabled set true - + // cannot download directly + $secureReceiverFileShareAttributes = $this->createMock(IAttributes::class); + $secureReceiverFileShareAttributes->method('getAttribute')->with('permissions', 'download')->willReturn(false); + $secureReceiverFileShare = $this->createMock(IShare::class); + $secureReceiverFileShare->method('getAttributes')->willReturn($secureReceiverFileShareAttributes); + $secureReceiverFileStorage = $this->createMock(SharedStorage::class); + $secureReceiverFileStorage->method('instanceOfStorage')->with(SharedStorage::class)->willReturn(true); + $secureReceiverFileStorage->method('getShare')->willReturn($secureReceiverFileShare); + $secureReceiverFile = $this->createMock(File::class); + $secureReceiverFile->method('getStorage')->willReturn($secureReceiverFileStorage); + $secureReceiverUserFolder = $this->createMock(Folder::class); + $secureReceiverUserFolder->method('get')->willReturn($secureReceiverFile); + + $result[] = [ '/secure-share-bar.txt', $secureReceiverUserFolder, false ]; + + return $result; + } + + /** + * @dataProvider providesDataForCanGet + */ + public function testCheckDirectCanBeDownloaded($path, $userFolder, $run) { + $user = $this->createMock(IUser::class); + $user->method("getUID")->willReturn("test"); + $this->userSession->method("getUser")->willReturn($user); + $this->userSession->method("isLoggedIn")->willReturn(true); + $this->rootFolder->method('getUserFolder')->willReturn($userFolder); + + // Simulate direct download of file + $event = new GenericEvent(null, [ 'path' => $path ]); + $this->eventDispatcher->dispatch('file.beforeGetDirect', $event); + + $this->assertEquals($run, !$event->hasArgument('errorMessage')); + } + + public function providesDataForCanZip() { + // Mock: Normal file/folder storage + $nonSharedStorage = $this->createMock(IStorage::class); + $nonSharedStorage->method('instanceOfStorage')->with(SharedStorage::class)->willReturn(false); + + // Mock: Secure-view file/folder shared storage + $secureReceiverFileShareAttributes = $this->createMock(IAttributes::class); + $secureReceiverFileShareAttributes->method('getAttribute')->with('permissions', 'download')->willReturn(false); + $secureReceiverFileShare = $this->createMock(IShare::class); + $secureReceiverFileShare->method('getAttributes')->willReturn($secureReceiverFileShareAttributes); + $secureSharedStorage = $this->createMock(SharedStorage::class); + $secureSharedStorage->method('instanceOfStorage')->with(SharedStorage::class)->willReturn(true); + $secureSharedStorage->method('getShare')->willReturn($secureReceiverFileShare); + + // 1. can download zipped 2 non-shared files inside non-shared folder + // 2. can download zipped non-shared folder + $sender1File = $this->createMock(File::class); + $sender1File->method('getStorage')->willReturn($nonSharedStorage); + $sender1Folder = $this->createMock(Folder::class); + $sender1Folder->method('getStorage')->willReturn($nonSharedStorage); + $sender1Folder->method('getDirectoryListing')->willReturn([$sender1File, $sender1File]); + $sender1RootFolder = $this->createMock(Folder::class); + $sender1RootFolder->method('getStorage')->willReturn($nonSharedStorage); + $sender1RootFolder->method('getDirectoryListing')->willReturn([$sender1Folder]); + $sender1UserFolder = $this->createMock(Folder::class); + $sender1UserFolder->method('get')->willReturn($sender1RootFolder); + + $return[] = [ '/folder', ['bar1.txt', 'bar2.txt'], $sender1UserFolder, true ]; + $return[] = [ '/', 'folder', $sender1UserFolder, true ]; + + // 3. cannot download zipped 1 non-shared file and 1 secure-shared inside non-shared folder + $receiver1File = $this->createMock(File::class); + $receiver1File->method('getStorage')->willReturn($nonSharedStorage); + $receiver1SecureFile = $this->createMock(File::class); + $receiver1SecureFile->method('getStorage')->willReturn($secureSharedStorage); + $receiver1Folder = $this->createMock(Folder::class); + $receiver1Folder->method('getStorage')->willReturn($nonSharedStorage); + $receiver1Folder->method('getDirectoryListing')->willReturn([$receiver1File, $receiver1SecureFile]); + $receiver1RootFolder = $this->createMock(Folder::class); + $receiver1RootFolder->method('getStorage')->willReturn($nonSharedStorage); + $receiver1RootFolder->method('getDirectoryListing')->willReturn([$receiver1Folder]); + $receiver1UserFolder = $this->createMock(Folder::class); + $receiver1UserFolder->method('get')->willReturn($receiver1RootFolder); + + $return[] = [ '/folder', ['secured-bar1.txt', 'bar2.txt'], $receiver1UserFolder, false ]; + + // 4. cannot download zipped secure-shared folder + $receiver2Folder = $this->createMock(Folder::class); + $receiver2Folder->method('getStorage')->willReturn($secureSharedStorage); + $receiver2RootFolder = $this->createMock(Folder::class); + $receiver2RootFolder->method('getStorage')->willReturn($nonSharedStorage); + $receiver2RootFolder->method('getDirectoryListing')->willReturn([$receiver2Folder]); + $receiver2UserFolder = $this->createMock(Folder::class); + $receiver2UserFolder->method('get')->willReturn($receiver2RootFolder); + + $return[] = [ '/', 'secured-folder', $receiver2UserFolder, false ]; + + return $return; + } + + /** + * @dataProvider providesDataForCanZip + */ + public function testCheckZipCanBeDownloaded($dir, $files, $userFolder, $run) { + $user = $this->createMock(IUser::class); + $user->method("getUID")->willReturn("test"); + $this->userSession->method("getUser")->willReturn($user); + $this->userSession->method("isLoggedIn")->willReturn(true); + + $this->rootFolder->method('getUserFolder')->with("test")->willReturn($userFolder); + + // Simulate zip download of folder folder + $event = new GenericEvent(null, ['dir' => $dir, 'files' => $files, 'run' => true]); + $this->eventDispatcher->dispatch('file.beforeCreateZip', $event); + + $this->assertEquals($run, $event->getArgument('run')); + $this->assertEquals($run, !$event->hasArgument('errorMessage')); + } + + public function testCheckFileUserNotFound() { + $this->userSession->method("isLoggedIn")->willReturn(false); + + // Simulate zip download of folder folder + $event = new GenericEvent(null, ['dir' => '/test', 'files' => ['test.txt'], 'run' => true]); + $this->eventDispatcher->dispatch('file.beforeCreateZip', $event); + + // It should run as this would restrict e.g. share links otherwise + $this->assertTrue($event->getArgument('run')); + $this->assertFalse($event->hasArgument('errorMessage')); + } } diff --git a/tests/lib/Share20/ManagerTest.php b/tests/lib/Share20/ManagerTest.php index 2ed99519df6e7..797a5ebf68387 100644 --- a/tests/lib/Share20/ManagerTest.php +++ b/tests/lib/Share20/ManagerTest.php @@ -593,7 +593,7 @@ public function testVerifyPasswordHookFails() { } public function createShare($id, $type, $path, $sharedWith, $sharedBy, $shareOwner, - $permissions, $expireDate = null, $password = null) { + $permissions, $expireDate = null, $password = null, $attributes = null) { $share = $this->createMock(IShare::class); $share->method('getShareType')->willReturn($type); @@ -602,6 +602,7 @@ public function createShare($id, $type, $path, $sharedWith, $sharedBy, $shareOwn $share->method('getShareOwner')->willReturn($shareOwner); $share->method('getNode')->willReturn($path); $share->method('getPermissions')->willReturn($permissions); + $share->method('getAttributes')->willReturn($attributes); $share->method('getExpirationDate')->willReturn($expireDate); $share->method('getPassword')->willReturn($password); @@ -3039,6 +3040,8 @@ public function testUpdateShareCantChangeShareType() { $manager->expects($this->once())->method('getShareById')->with('foo:42')->willReturn($originalShare); $share = $this->manager->newShare(); + $attrs = $this->manager->newShare()->newAttributes(); + $attrs->setAttribute('app1', 'perm1', true); $share->setProviderId('foo') ->setId('42') ->setShareType(IShare::TYPE_USER); @@ -3136,6 +3139,7 @@ public function testUpdateShareUser() { ->setShareOwner('newUser') ->setSharedBy('sharer') ->setPermissions(31) + ->setAttributes($attrs) ->setNode($node); $this->defaultProvider->expects($this->once())