diff options
Diffstat (limited to 'apps/files_sharing/lib/Controller')
10 files changed, 2914 insertions, 1343 deletions
diff --git a/apps/files_sharing/lib/Controller/AcceptController.php b/apps/files_sharing/lib/Controller/AcceptController.php new file mode 100644 index 00000000000..721ddec7d2b --- /dev/null +++ b/apps/files_sharing/lib/Controller/AcceptController.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Sharing\Controller; + +use OCA\Files_Sharing\AppInfo\Application; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IManager as ShareManager; + +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class AcceptController extends Controller { + + public function __construct( + IRequest $request, + private ShareManager $shareManager, + private IUserSession $userSession, + private IURLGenerator $urlGenerator, + ) { + parent::__construct(Application::APP_ID, $request); + } + + #[NoAdminRequired] + #[NoCSRFRequired] + public function accept(string $shareId): Response { + try { + $share = $this->shareManager->getShareById($shareId); + } catch (ShareNotFound $e) { + return new NotFoundResponse(); + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return new NotFoundResponse(); + } + + try { + $share = $this->shareManager->acceptShare($share, $user->getUID()); + } catch (\Exception $e) { + // Just ignore + } + + $url = $this->urlGenerator->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $share->getNode()->getId()]); + + return new RedirectResponse($url); + } +} diff --git a/apps/files_sharing/lib/Controller/DeletedShareAPIController.php b/apps/files_sharing/lib/Controller/DeletedShareAPIController.php new file mode 100644 index 00000000000..2fa4d7c668f --- /dev/null +++ b/apps/files_sharing/lib/Controller/DeletedShareAPIController.php @@ -0,0 +1,240 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Sharing\Controller; + +use OCA\Deck\Sharing\ShareAPIHelper; +use OCA\Files_Sharing\ResponseDefinitions; +use OCP\App\IAppManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCSController; +use OCP\AppFramework\QueryException; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUserManager; +use OCP\Server; +use OCP\Share\Exceptions\GenericShareException; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IManager as ShareManager; +use OCP\Share\IShare; + +/** + * @psalm-import-type Files_SharingDeletedShare from ResponseDefinitions + */ +class DeletedShareAPIController extends OCSController { + + public function __construct( + string $appName, + IRequest $request, + private ShareManager $shareManager, + private ?string $userId, + private IUserManager $userManager, + private IGroupManager $groupManager, + private IRootFolder $rootFolder, + private IAppManager $appManager, + ) { + parent::__construct($appName, $request); + } + + /** + * @suppress PhanUndeclaredClassMethod + * + * @return Files_SharingDeletedShare + */ + private function formatShare(IShare $share): array { + $result = [ + 'id' => $share->getFullId(), + 'share_type' => $share->getShareType(), + 'uid_owner' => $share->getSharedBy(), + 'displayname_owner' => $this->userManager->get($share->getSharedBy())->getDisplayName(), + 'permissions' => 0, + 'stime' => $share->getShareTime()->getTimestamp(), + 'parent' => null, + 'expiration' => null, + 'token' => null, + 'uid_file_owner' => $share->getShareOwner(), + 'displayname_file_owner' => $this->userManager->get($share->getShareOwner())->getDisplayName(), + 'path' => $share->getTarget(), + ]; + $userFolder = $this->rootFolder->getUserFolder($share->getSharedBy()); + $node = $userFolder->getFirstNodeById($share->getNodeId()); + if (!$node) { + // fallback to guessing the path + $node = $userFolder->get($share->getTarget()); + if ($node === null || $share->getTarget() === '') { + throw new NotFoundException(); + } + } + + $result['path'] = $userFolder->getRelativePath($node->getPath()); + if ($node instanceof Folder) { + $result['item_type'] = 'folder'; + } else { + $result['item_type'] = 'file'; + } + $result['mimetype'] = $node->getMimetype(); + $result['storage_id'] = $node->getStorage()->getId(); + $result['storage'] = $node->getStorage()->getCache()->getNumericStorageId(); + $result['item_source'] = $node->getId(); + $result['file_source'] = $node->getId(); + $result['file_parent'] = $node->getParent()->getId(); + $result['file_target'] = $share->getTarget(); + $result['item_size'] = $node->getSize(); + $result['item_mtime'] = $node->getMTime(); + + $expiration = $share->getExpirationDate(); + if ($expiration !== null) { + $result['expiration'] = $expiration->format('Y-m-d 00:00:00'); + } + + if ($share->getShareType() === IShare::TYPE_GROUP) { + $group = $this->groupManager->get($share->getSharedWith()); + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = $group !== null ? $group->getDisplayName() : $share->getSharedWith(); + } elseif ($share->getShareType() === IShare::TYPE_ROOM) { + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = ''; + + try { + $result = array_merge($result, $this->getRoomShareHelper()->formatShare($share)); + } catch (QueryException $e) { + } + } elseif ($share->getShareType() === IShare::TYPE_DECK) { + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = ''; + + try { + $result = array_merge($result, $this->getDeckShareHelper()->formatShare($share)); + } catch (QueryException $e) { + } + } elseif ($share->getShareType() === IShare::TYPE_SCIENCEMESH) { + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = ''; + + try { + $result = array_merge($result, $this->getSciencemeshShareHelper()->formatShare($share)); + } catch (QueryException $e) { + } + } + + return $result; + } + + /** + * Get a list of all deleted shares + * + * @return DataResponse<Http::STATUS_OK, list<Files_SharingDeletedShare>, array{}> + * + * 200: Deleted shares returned + */ + #[NoAdminRequired] + public function index(): DataResponse { + $groupShares = $this->shareManager->getDeletedSharedWith($this->userId, IShare::TYPE_GROUP, null, -1, 0); + $teamShares = $this->shareManager->getDeletedSharedWith($this->userId, IShare::TYPE_CIRCLE, null, -1, 0); + $roomShares = $this->shareManager->getDeletedSharedWith($this->userId, IShare::TYPE_ROOM, null, -1, 0); + $deckShares = $this->shareManager->getDeletedSharedWith($this->userId, IShare::TYPE_DECK, null, -1, 0); + $sciencemeshShares = $this->shareManager->getDeletedSharedWith($this->userId, IShare::TYPE_SCIENCEMESH, null, -1, 0); + + $shares = array_merge($groupShares, $teamShares, $roomShares, $deckShares, $sciencemeshShares); + + $shares = array_values(array_map(function (IShare $share) { + return $this->formatShare($share); + }, $shares)); + + return new DataResponse($shares); + } + + /** + * Undelete a deleted share + * + * @param string $id ID of the share + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSException + * @throws OCSNotFoundException Share not found + * + * 200: Share undeleted successfully + */ + #[NoAdminRequired] + public function undelete(string $id): DataResponse { + try { + $share = $this->shareManager->getShareById($id, $this->userId); + } catch (ShareNotFound $e) { + throw new OCSNotFoundException('Share not found'); + } + + if ($share->getPermissions() !== 0) { + throw new OCSNotFoundException('No deleted share found'); + } + + try { + $this->shareManager->restoreShare($share, $this->userId); + } catch (GenericShareException $e) { + throw new OCSException('Something went wrong'); + } + + return new DataResponse([]); + } + + /** + * Returns the helper of DeletedShareAPIController for room shares. + * + * If the Talk application is not enabled or the helper is not available + * a QueryException is thrown instead. + * + * @return \OCA\Talk\Share\Helper\DeletedShareAPIController + * @throws QueryException + */ + private function getRoomShareHelper() { + if (!$this->appManager->isEnabledForUser('spreed')) { + throw new QueryException(); + } + + return Server::get('\OCA\Talk\Share\Helper\DeletedShareAPIController'); + } + + /** + * Returns the helper of DeletedShareAPIHelper for deck shares. + * + * If the Deck application is not enabled or the helper is not available + * a QueryException is thrown instead. + * + * @return ShareAPIHelper + * @throws QueryException + */ + private function getDeckShareHelper() { + if (!$this->appManager->isEnabledForUser('deck')) { + throw new QueryException(); + } + + return Server::get('\OCA\Deck\Sharing\ShareAPIHelper'); + } + + /** + * Returns the helper of DeletedShareAPIHelper for sciencemesh shares. + * + * If the sciencemesh application is not enabled or the helper is not available + * a QueryException is thrown instead. + * + * @return ShareAPIHelper + * @throws QueryException + */ + private function getSciencemeshShareHelper() { + if (!$this->appManager->isEnabledForUser('sciencemesh')) { + throw new QueryException(); + } + + return Server::get('\OCA\ScienceMesh\Sharing\ShareAPIHelper'); + } +} diff --git a/apps/files_sharing/lib/Controller/ExternalSharesController.php b/apps/files_sharing/lib/Controller/ExternalSharesController.php index fe4c09dd195..fa828a9d2c2 100644 --- a/apps/files_sharing/lib/Controller/ExternalSharesController.php +++ b/apps/files_sharing/lib/Controller/ExternalSharesController.php @@ -1,36 +1,16 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\Files_Sharing\Controller; use OCP\AppFramework\Controller; -use OCP\IRequest; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; -use OCP\Http\Client\IClientService; -use OCP\AppFramework\Http\DataResponse; +use OCP\IRequest; /** * Class ExternalSharesController @@ -38,113 +18,45 @@ use OCP\AppFramework\Http\DataResponse; * @package OCA\Files_Sharing\Controller */ class ExternalSharesController extends Controller { - - /** @var \OCA\Files_Sharing\External\Manager */ - private $externalManager; - /** @var IClientService */ - private $clientService; - - /** - * @param string $appName - * @param IRequest $request - * @param \OCA\Files_Sharing\External\Manager $externalManager - * @param IClientService $clientService - */ - public function __construct($appName, - IRequest $request, - \OCA\Files_Sharing\External\Manager $externalManager, - IClientService $clientService) { + public function __construct( + string $appName, + IRequest $request, + private \OCA\Files_Sharing\External\Manager $externalManager, + ) { parent::__construct($appName, $request); - $this->externalManager = $externalManager; - $this->clientService = $clientService; } /** - * @NoAdminRequired * @NoOutgoingFederatedSharingRequired * * @return JSONResponse */ + #[NoAdminRequired] public function index() { return new JSONResponse($this->externalManager->getOpenShares()); } /** - * @NoAdminRequired * @NoOutgoingFederatedSharingRequired * * @param int $id * @return JSONResponse */ + #[NoAdminRequired] public function create($id) { $this->externalManager->acceptShare($id); return new JSONResponse(); } /** - * @NoAdminRequired * @NoOutgoingFederatedSharingRequired * * @param integer $id * @return JSONResponse */ + #[NoAdminRequired] public function destroy($id) { $this->externalManager->declineShare($id); return new JSONResponse(); } - - /** - * Test whether the specified remote is accessible - * - * @param string $remote - * @param bool $checkVersion - * @return bool - */ - protected function testUrl($remote, $checkVersion = false) { - try { - $client = $this->clientService->newClient(); - $response = json_decode($client->get( - $remote, - [ - 'timeout' => 3, - 'connect_timeout' => 3, - ] - )->getBody()); - - if ($checkVersion) { - return !empty($response->version) && version_compare($response->version, '7.0.0', '>='); - } else { - return is_object($response); - } - } catch (\Exception $e) { - return false; - } - } - - /** - * @PublicPage - * @NoOutgoingFederatedSharingRequired - * @NoIncomingFederatedSharingRequired - * - * @param string $remote - * @return DataResponse - */ - public function testRemote($remote) { - if ( - $this->testUrl('https://' . $remote . '/ocs-provider/') || - $this->testUrl('https://' . $remote . '/ocs-provider/index.php') || - $this->testUrl('https://' . $remote . '/status.php', true) - ) { - return new DataResponse('https'); - } elseif ( - $this->testUrl('http://' . $remote . '/ocs-provider/') || - $this->testUrl('http://' . $remote . '/ocs-provider/index.php') || - $this->testUrl('http://' . $remote . '/status.php', true) - ) { - return new DataResponse('http'); - } else { - return new DataResponse(false); - } - } - } diff --git a/apps/files_sharing/lib/Controller/PublicPreviewController.php b/apps/files_sharing/lib/Controller/PublicPreviewController.php index 0870995fc7b..d917f6e0ebb 100644 --- a/apps/files_sharing/lib/Controller/PublicPreviewController.php +++ b/apps/files_sharing/lib/Controller/PublicPreviewController.php @@ -1,83 +1,100 @@ <?php + /** - * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Sharing\Controller; -use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\PublicShareController; use OCP\Constants; use OCP\Files\Folder; use OCP\Files\NotFoundException; use OCP\IPreview; use OCP\IRequest; +use OCP\ISession; +use OCP\Preview\IMimeIconProvider; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager as ShareManager; +use OCP\Share\IShare; + +class PublicPreviewController extends PublicShareController { -class PublicPreviewController extends Controller { + /** @var IShare */ + private $share; - /** @var ShareManager */ - private $shareManager; + public function __construct( + string $appName, + IRequest $request, + private ShareManager $shareManager, + ISession $session, + private IPreview $previewManager, + private IMimeIconProvider $mimeIconProvider, + ) { + parent::__construct($appName, $request, $session); + } - /** @var IPreview */ - private $previewManager; + protected function getPasswordHash(): ?string { + return $this->share->getPassword(); + } - public function __construct($appName, - IRequest $request, - ShareManager $shareManger, - IPreview $previewManager) { - parent::__construct($appName, $request); + public function isValidToken(): bool { + try { + $this->share = $this->shareManager->getShareByToken($this->getToken()); + return true; + } catch (ShareNotFound $e) { + return false; + } + } - $this->shareManager = $shareManger; - $this->previewManager = $previewManager; + protected function isPasswordProtected(): bool { + return $this->share->getPassword() !== null; } + /** - * @PublicPage - * @NoCSRFRequired + * Get a preview for a shared file + * + * @param string $token Token of the share + * @param string $file File in the share + * @param int $x Width of the preview + * @param int $y Height of the preview + * @param bool $a Whether to not crop the preview + * @param bool $mimeFallback Whether to fallback to the mime icon if no preview is available + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}> * - * @param string $file - * @param int $x - * @param int $y - * @param string $t - * @param bool $a - * @return DataResponse|FileDisplayResponse + * 200: Preview returned + * 303: Redirect to the mime icon url if mimeFallback is true + * 400: Getting preview is not possible + * 403: Getting preview is not allowed + * 404: Share or preview not found */ + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] public function getPreview( - $file = '', - $x = 32, - $y = 32, - $t = '', - $a = false + string $token, + string $file = '', + int $x = 32, + int $y = 32, + $a = false, + bool $mimeFallback = false, ) { + $cacheForSeconds = 60 * 60 * 24; // 1 day - if ($t === '' || $x === 0 || $y === 0) { + if ($token === '' || $x === 0 || $y === 0) { return new DataResponse([], Http::STATUS_BAD_REQUEST); } try { - $share = $this->shareManager->getShareByToken($t); + $share = $this->shareManager->getShareByToken($token); } catch (ShareNotFound $e) { return new DataResponse([], Http::STATUS_NOT_FOUND); } @@ -86,6 +103,21 @@ class PublicPreviewController extends Controller { return new DataResponse([], Http::STATUS_FORBIDDEN); } + // Only explicitly set to false will forbid the download! + $downloadForbidden = !$share->canSeeContent(); + + // Is this header is set it means our UI is doing a preview for no-download shares + // we check a header so we at least prevent people from using the link directly (obfuscation) + $isPublicPreview = $this->request->getHeader('x-nc-preview') === 'true'; + + if ($isPublicPreview && $downloadForbidden) { + // Only cache for 15 minutes on public preview requests to quickly remove from cache + $cacheForSeconds = 15 * 60; + } elseif ($downloadForbidden) { + // This is not a public share preview so we only allow a preview if download permissions are granted + return new DataResponse([], Http::STATUS_FORBIDDEN); + } + try { $node = $share->getNode(); if ($node instanceof Folder) { @@ -95,8 +127,16 @@ class PublicPreviewController extends Controller { } $f = $this->previewManager->getPreview($file, $x, $y, !$a); - return new FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); + $response = new FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); + $response->cacheFor($cacheForSeconds); + return $response; } catch (NotFoundException $e) { + // If we have no preview enabled, we can redirect to the mime icon if any + if ($mimeFallback) { + if ($url = $this->mimeIconProvider->getMimeIconUrl($file->getMimeType())) { + return new RedirectResponse($url); + } + } return new DataResponse([], Http::STATUS_NOT_FOUND); } catch (\InvalidArgumentException $e) { return new DataResponse([], Http::STATUS_BAD_REQUEST); @@ -104,14 +144,22 @@ class PublicPreviewController extends Controller { } /** - * @PublicPage - * @NoCSRFRequired * @NoSameSiteCookieRequired * - * @param $token - * @return DataResponse|FileDisplayResponse + * Get a direct link preview for a shared file + * + * @param string $token Token of the share + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}> + * + * 200: Preview returned + * 400: Getting preview is not possible + * 403: Getting preview is not allowed + * 404: Share or preview not found */ - public function directLink($token) { + #[PublicPage] + #[NoCSRFRequired] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function directLink(string $token) { // No token no image if ($token === '') { return new DataResponse([], Http::STATUS_BAD_REQUEST); @@ -134,6 +182,10 @@ class PublicPreviewController extends Controller { return new DataResponse([], Http::STATUS_FORBIDDEN); } + if (!$share->canSeeContent()) { + return new DataResponse([], Http::STATUS_FORBIDDEN); + } + try { $node = $share->getNode(); if ($node instanceof Folder) { @@ -142,7 +194,9 @@ class PublicPreviewController extends Controller { } $f = $this->previewManager->getPreview($node, -1, -1, false); - return new FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); + $response = new FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); + $response->cacheFor(3600 * 24); + return $response; } catch (NotFoundException $e) { return new DataResponse([], Http::STATUS_NOT_FOUND); } catch (\InvalidArgumentException $e) { diff --git a/apps/files_sharing/lib/Controller/RemoteController.php b/apps/files_sharing/lib/Controller/RemoteController.php index d6206391180..8c15cd8463e 100644 --- a/apps/files_sharing/lib/Controller/RemoteController.php +++ b/apps/files_sharing/lib/Controller/RemoteController.php @@ -1,83 +1,66 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\Files_Sharing\Controller; +use OC\Files\View; use OCA\Files_Sharing\External\Manager; +use OCA\Files_Sharing\ResponseDefinitions; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; -use OCP\ILogger; use OCP\IRequest; +use Psr\Log\LoggerInterface; +/** + * @psalm-import-type Files_SharingRemoteShare from ResponseDefinitions + */ class RemoteController extends OCSController { - - /** @var Manager */ - private $externalManager; - - /** @var ILogger */ - private $logger; - /** - * @NoAdminRequired - * * Remote constructor. * * @param string $appName * @param IRequest $request * @param Manager $externalManager */ - public function __construct($appName, - IRequest $request, - Manager $externalManager, - ILogger $logger) { + public function __construct( + $appName, + IRequest $request, + private Manager $externalManager, + private LoggerInterface $logger, + ) { parent::__construct($appName, $request); - - $this->externalManager = $externalManager; - $this->logger = $logger; } /** - * @NoAdminRequired - * * Get list of pending remote shares * - * @return DataResponse + * @return DataResponse<Http::STATUS_OK, list<Files_SharingRemoteShare>, array{}> + * + * 200: Pending remote shares returned */ + #[NoAdminRequired] public function getOpenShares() { return new DataResponse($this->externalManager->getOpenShares()); } /** - * @NoAdminRequired - * * Accept a remote share * - * @param int $id - * @return DataResponse - * @throws OCSNotFoundException + * @param int $id ID of the share + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSNotFoundException Share not found + * + * 200: Share accepted successfully */ + #[NoAdminRequired] public function acceptShare($id) { if ($this->externalManager->acceptShare($id)) { return new DataResponse(); @@ -86,18 +69,19 @@ class RemoteController extends OCSController { $this->logger->error('Could not accept federated share with id: ' . $id, ['app' => 'files_sharing']); - throw new OCSNotFoundException('wrong share ID, share doesn\'t exist.'); + throw new OCSNotFoundException('wrong share ID, share does not exist.'); } /** - * @NoAdminRequired - * * Decline a remote share * - * @param int $id - * @return DataResponse - * @throws OCSNotFoundException + * @param int $id ID of the share + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSNotFoundException Share not found + * + * 200: Share declined successfully */ + #[NoAdminRequired] public function declineShare($id) { if ($this->externalManager->declineShare($id)) { return new DataResponse(); @@ -106,7 +90,7 @@ class RemoteController extends OCSController { // Make sure the user has no notification for something that does not exist anymore. $this->externalManager->processNotification($id); - throw new OCSNotFoundException('wrong share ID, share doesn\'t exist.'); + throw new OCSNotFoundException('wrong share ID, share does not exist.'); } /** @@ -114,9 +98,13 @@ class RemoteController extends OCSController { * @return array enriched share info with data from the filecache */ private static function extendShareInfo($share) { - $view = new \OC\Files\View('/' . \OC_User::getUser() . '/files/'); + $view = new View('/' . \OC_User::getUser() . '/files/'); $info = $view->getFileInfo($share['mountpoint']); + if ($info === false) { + return $share; + } + $share['mimetype'] = $info->getMimetype(); $share['mtime'] = $info->getMTime(); $share['permissions'] = $info->getPermissions(); @@ -127,28 +115,30 @@ class RemoteController extends OCSController { } /** - * @NoAdminRequired + * Get a list of accepted remote shares * - * List accepted remote shares + * @return DataResponse<Http::STATUS_OK, list<Files_SharingRemoteShare>, array{}> * - * @return DataResponse + * 200: Accepted remote shares returned */ + #[NoAdminRequired] public function getShares() { $shares = $this->externalManager->getAcceptedShares(); - $shares = array_map('self::extendShareInfo', $shares); + $shares = array_map(self::extendShareInfo(...), $shares); return new DataResponse($shares); } /** - * @NoAdminRequired - * * Get info of a remote share * - * @param int $id - * @return DataResponse - * @throws OCSNotFoundException + * @param int $id ID of the share + * @return DataResponse<Http::STATUS_OK, Files_SharingRemoteShare, array{}> + * @throws OCSNotFoundException Share not found + * + * 200: Share returned */ + #[NoAdminRequired] public function getShare($id) { $shareInfo = $this->externalManager->getShare($id); @@ -161,15 +151,16 @@ class RemoteController extends OCSController { } /** - * @NoAdminRequired - * * Unshare a remote share * - * @param int $id - * @return DataResponse - * @throws OCSNotFoundException - * @throws OCSForbiddenException + * @param int $id ID of the share + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSNotFoundException Share not found + * @throws OCSForbiddenException Unsharing is not possible + * + * 200: Share unshared successfully */ + #[NoAdminRequired] public function unshare($id) { $shareInfo = $this->externalManager->getShare($id); diff --git a/apps/files_sharing/lib/Controller/SettingsController.php b/apps/files_sharing/lib/Controller/SettingsController.php new file mode 100644 index 00000000000..67d9193be78 --- /dev/null +++ b/apps/files_sharing/lib/Controller/SettingsController.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Sharing\Controller; + +use OCA\Files_Sharing\AppInfo\Application; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IConfig; +use OCP\IRequest; + +class SettingsController extends Controller { + + public function __construct( + IRequest $request, + private IConfig $config, + private string $userId, + ) { + parent::__construct(Application::APP_ID, $request); + } + + #[NoAdminRequired] + public function setDefaultAccept(bool $accept): JSONResponse { + $this->config->setUserValue($this->userId, Application::APP_ID, 'default_accept', $accept ? 'yes' : 'no'); + return new JSONResponse(); + } + + #[NoAdminRequired] + public function setUserShareFolder(string $shareFolder): JSONResponse { + $this->config->setUserValue($this->userId, Application::APP_ID, 'share_folder', $shareFolder); + return new JSONResponse(); + } + + #[NoAdminRequired] + public function resetUserShareFolder(): JSONResponse { + $this->config->deleteUserValue($this->userId, Application::APP_ID, 'share_folder'); + return new JSONResponse(); + } +} diff --git a/apps/files_sharing/lib/Controller/ShareAPIController.php b/apps/files_sharing/lib/Controller/ShareAPIController.php index 10876e16568..095a8a75963 100644 --- a/apps/files_sharing/lib/Controller/ShareAPIController.php +++ b/apps/files_sharing/lib/Controller/ShareAPIController.php @@ -1,229 +1,385 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Maxence Lange <maxence@nextcloud.com> - * @author Michael Jobst <mjobst+github@tecratech.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @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 <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ + namespace OCA\Files_Sharing\Controller; +use Exception; +use OC\Core\AppInfo\ConfigLexicon; +use OC\Files\FileInfo; +use OC\Files\Storage\Wrapper\Wrapper; +use OCA\Circles\Api\v1\Circles; +use OCA\Deck\Sharing\ShareAPIHelper; +use OCA\Federation\TrustedServers; use OCA\Files\Helper; +use OCA\Files_Sharing\Exceptions\SharingRightsException; +use OCA\Files_Sharing\External\Storage; +use OCA\Files_Sharing\ResponseDefinitions; +use OCA\Files_Sharing\SharedStorage; +use OCA\GlobalSiteSelector\Service\SlaveService; +use OCP\App\IAppManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; +use OCP\AppFramework\QueryException; +use OCP\Constants; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; +use OCP\Files\Mount\IShareOwnerlessMount; use OCP\Files\Node; use OCP\Files\NotFoundException; +use OCP\HintException; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IDateTimeZone; use OCP\IGroupManager; use OCP\IL10N; -use OCP\IUserManager; +use OCP\IPreview; use OCP\IRequest; +use OCP\ITagManager; use OCP\IURLGenerator; -use OCP\Files\IRootFolder; +use OCP\IUserManager; +use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; -use OCP\Share\IManager; -use OCP\Share\Exceptions\ShareNotFound; +use OCP\Mail\IMailer; +use OCP\Server; use OCP\Share\Exceptions\GenericShareException; -use OCP\Lock\ILockingProvider; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\Exceptions\ShareTokenException; +use OCP\Share\IManager; +use OCP\Share\IProviderFactory; use OCP\Share\IShare; +use OCP\Share\IShareProviderWithNotification; +use OCP\UserStatus\IManager as IUserStatusManager; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; /** - * Class Share20OCS - * * @package OCA\Files_Sharing\API + * + * @psalm-import-type Files_SharingShare from ResponseDefinitions */ class ShareAPIController extends OCSController { - /** @var IManager */ - private $shareManager; - /** @var IGroupManager */ - private $groupManager; - /** @var IUserManager */ - private $userManager; - /** @var IRequest */ - protected $request; - /** @var IRootFolder */ - private $rootFolder; - /** @var IURLGenerator */ - private $urlGenerator; - /** @var string */ - private $currentUser; - /** @var IL10N */ - private $l; - /** @var \OCP\Files\Node */ - private $lockedNode; + private ?Node $lockedNode = null; + private array $trustedServerCache = []; /** * Share20OCS constructor. - * - * @param string $appName - * @param IRequest $request - * @param IManager $shareManager - * @param IGroupManager $groupManager - * @param IUserManager $userManager - * @param IRootFolder $rootFolder - * @param IURLGenerator $urlGenerator - * @param string $userId - * @param IL10N $l10n */ public function __construct( - $appName, + string $appName, IRequest $request, - IManager $shareManager, - IGroupManager $groupManager, - IUserManager $userManager, - IRootFolder $rootFolder, - IURLGenerator $urlGenerator, - $userId, - IL10N $l10n + private IManager $shareManager, + private IGroupManager $groupManager, + private IUserManager $userManager, + private IRootFolder $rootFolder, + private IURLGenerator $urlGenerator, + private IL10N $l, + private IConfig $config, + private IAppConfig $appConfig, + private IAppManager $appManager, + private ContainerInterface $serverContainer, + private IUserStatusManager $userStatusManager, + private IPreview $previewManager, + private IDateTimeZone $dateTimeZone, + private LoggerInterface $logger, + private IProviderFactory $factory, + private IMailer $mailer, + private ITagManager $tagManager, + private ?TrustedServers $trustedServers, + private ?string $userId = null, ) { parent::__construct($appName, $request); - - $this->shareManager = $shareManager; - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->request = $request; - $this->rootFolder = $rootFolder; - $this->urlGenerator = $urlGenerator; - $this->currentUser = $userId; - $this->l = $l10n; } /** * Convert an IShare to an array for OCS output * - * @param \OCP\Share\IShare $share + * @param IShare $share * @param Node|null $recipientNode - * @return array + * @return Files_SharingShare * @throws NotFoundException In case the node can't be resolved. + * + * @suppress PhanUndeclaredClassMethod */ - protected function formatShare(\OCP\Share\IShare $share, Node $recipientNode = null) { + protected function formatShare(IShare $share, ?Node $recipientNode = null): array { $sharedBy = $this->userManager->get($share->getSharedBy()); $shareOwner = $this->userManager->get($share->getShareOwner()); + $isOwnShare = false; + if ($shareOwner !== null) { + $isOwnShare = $shareOwner->getUID() === $this->userId; + } + $result = [ 'id' => $share->getId(), 'share_type' => $share->getShareType(), 'uid_owner' => $share->getSharedBy(), 'displayname_owner' => $sharedBy !== null ? $sharedBy->getDisplayName() : $share->getSharedBy(), + // recipient permissions 'permissions' => $share->getPermissions(), + // current user permissions on this share + 'can_edit' => $this->canEditShare($share), + 'can_delete' => $this->canDeleteShare($share), 'stime' => $share->getShareTime()->getTimestamp(), 'parent' => null, 'expiration' => null, 'token' => null, 'uid_file_owner' => $share->getShareOwner(), + 'note' => $share->getNote(), + 'label' => $share->getLabel(), 'displayname_file_owner' => $shareOwner !== null ? $shareOwner->getDisplayName() : $share->getShareOwner(), ]; - $userFolder = $this->rootFolder->getUserFolder($this->currentUser); + $userFolder = $this->rootFolder->getUserFolder($this->userId); if ($recipientNode) { $node = $recipientNode; } else { - $nodes = $userFolder->getById($share->getNodeId()); - - if (empty($nodes)) { + $node = $userFolder->getFirstNodeById($share->getNodeId()); + if (!$node) { // fallback to guessing the path $node = $userFolder->get($share->getTarget()); - if ($node === null) { + if ($node === null || $share->getTarget() === '') { throw new NotFoundException(); } - } else { - $node = $nodes[0]; } } $result['path'] = $userFolder->getRelativePath($node->getPath()); - if ($node instanceOf \OCP\Files\Folder) { + if ($node instanceof Folder) { $result['item_type'] = 'folder'; } else { $result['item_type'] = 'file'; } + + // Get the original node permission if the share owner is the current user + if ($isOwnShare) { + $result['item_permissions'] = $node->getPermissions(); + } + + // If we're on the recipient side, the node permissions + // are bound to the share permissions. So we need to + // adjust the permissions to the share permissions if necessary. + if (!$isOwnShare) { + $result['item_permissions'] = $share->getPermissions(); + + // For some reason, single files share are forbidden to have the delete permission + // since we have custom methods to check those, let's adjust straight away. + // DAV permissions does not have that issue though. + if ($this->canDeleteShare($share) || $this->canDeleteShareFromSelf($share)) { + $result['item_permissions'] |= Constants::PERMISSION_DELETE; + } + if ($this->canEditShare($share)) { + $result['item_permissions'] |= Constants::PERMISSION_UPDATE; + } + } + + // See MOUNT_ROOT_PROPERTYNAME dav property + $result['is-mount-root'] = $node->getInternalPath() === ''; + $result['mount-type'] = $node->getMountPoint()->getMountType(); + $result['mimetype'] = $node->getMimetype(); + $result['has_preview'] = $this->previewManager->isAvailable($node); $result['storage_id'] = $node->getStorage()->getId(); $result['storage'] = $node->getStorage()->getCache()->getNumericStorageId(); $result['item_source'] = $node->getId(); $result['file_source'] = $node->getId(); $result['file_parent'] = $node->getParent()->getId(); $result['file_target'] = $share->getTarget(); + $result['item_size'] = $node->getSize(); + $result['item_mtime'] = $node->getMTime(); + + if ($this->trustedServers !== null && in_array($share->getShareType(), [IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP], true)) { + $result['is_trusted_server'] = false; + $sharedWith = $share->getSharedWith(); + $remoteIdentifier = is_string($sharedWith) ? strrchr($sharedWith, '@') : false; + if ($remoteIdentifier !== false) { + $remote = substr($remoteIdentifier, 1); + + if (isset($this->trustedServerCache[$remote])) { + $result['is_trusted_server'] = $this->trustedServerCache[$remote]; + } else { + try { + $isTrusted = $this->trustedServers->isTrustedServer($remote); + $this->trustedServerCache[$remote] = $isTrusted; + $result['is_trusted_server'] = $isTrusted; + } catch (\Exception $e) { + // Server not found or other issue, we consider it not trusted + $this->trustedServerCache[$remote] = false; + $this->logger->error( + 'Error checking if remote server is trusted (treating as untrusted): ' . $e->getMessage(), + ['exception' => $e] + ); + } + } + } + } $expiration = $share->getExpirationDate(); if ($expiration !== null) { + $expiration->setTimezone($this->dateTimeZone->getTimeZone()); $result['expiration'] = $expiration->format('Y-m-d 00:00:00'); } - if ($share->getShareType() === \OCP\Share::SHARE_TYPE_USER) { + if ($share->getShareType() === IShare::TYPE_USER) { $sharedWith = $this->userManager->get($share->getSharedWith()); $result['share_with'] = $share->getSharedWith(); $result['share_with_displayname'] = $sharedWith !== null ? $sharedWith->getDisplayName() : $share->getSharedWith(); - } else if ($share->getShareType() === \OCP\Share::SHARE_TYPE_GROUP) { + $result['share_with_displayname_unique'] = $sharedWith !== null ? ( + !empty($sharedWith->getSystemEMailAddress()) ? $sharedWith->getSystemEMailAddress() : $sharedWith->getUID() + ) : $share->getSharedWith(); + + $userStatuses = $this->userStatusManager->getUserStatuses([$share->getSharedWith()]); + $userStatus = array_shift($userStatuses); + if ($userStatus) { + $result['status'] = [ + 'status' => $userStatus->getStatus(), + 'message' => $userStatus->getMessage(), + 'icon' => $userStatus->getIcon(), + 'clearAt' => $userStatus->getClearAt() + ? (int)$userStatus->getClearAt()->format('U') + : null, + ]; + } + } elseif ($share->getShareType() === IShare::TYPE_GROUP) { $group = $this->groupManager->get($share->getSharedWith()); $result['share_with'] = $share->getSharedWith(); $result['share_with_displayname'] = $group !== null ? $group->getDisplayName() : $share->getSharedWith(); - } else if ($share->getShareType() === \OCP\Share::SHARE_TYPE_LINK) { + } elseif ($share->getShareType() === IShare::TYPE_LINK) { + // "share_with" and "share_with_displayname" for passwords of link + // shares was deprecated in Nextcloud 15, use "password" instead. $result['share_with'] = $share->getPassword(); - $result['share_with_displayname'] = $share->getPassword(); + $result['share_with_displayname'] = '(' . $this->l->t('Shared link') . ')'; + + $result['password'] = $share->getPassword(); + + $result['send_password_by_talk'] = $share->getSendPasswordByTalk(); $result['token'] = $share->getToken(); $result['url'] = $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', ['token' => $share->getToken()]); - - } else if ($share->getShareType() === \OCP\Share::SHARE_TYPE_REMOTE) { + } elseif ($share->getShareType() === IShare::TYPE_REMOTE) { + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = $this->getCachedFederatedDisplayName($share->getSharedWith()); + $result['token'] = $share->getToken(); + } elseif ($share->getShareType() === IShare::TYPE_REMOTE_GROUP) { $result['share_with'] = $share->getSharedWith(); $result['share_with_displayname'] = $this->getDisplayNameFromAddressBook($share->getSharedWith(), 'CLOUD'); $result['token'] = $share->getToken(); - } else if ($share->getShareType() === \OCP\Share::SHARE_TYPE_EMAIL) { + } elseif ($share->getShareType() === IShare::TYPE_EMAIL) { $result['share_with'] = $share->getSharedWith(); $result['password'] = $share->getPassword(); + $result['password_expiration_time'] = $share->getPasswordExpirationTime() !== null ? $share->getPasswordExpirationTime()->format(\DateTime::ATOM) : null; + $result['send_password_by_talk'] = $share->getSendPasswordByTalk(); $result['share_with_displayname'] = $this->getDisplayNameFromAddressBook($share->getSharedWith(), 'EMAIL'); $result['token'] = $share->getToken(); - } else if ($share->getShareType() === \OCP\Share::SHARE_TYPE_CIRCLE) { - $result['share_with_displayname'] = $share->getSharedWith(); - $result['share_with'] = explode(' ', $share->getSharedWith(), 2)[0]; + } elseif ($share->getShareType() === IShare::TYPE_CIRCLE) { + // getSharedWith() returns either "name (type, owner)" or + // "name (type, owner) [id]", depending on the Teams app version. + $hasCircleId = (substr($share->getSharedWith(), -1) === ']'); + + $result['share_with_displayname'] = $share->getSharedWithDisplayName(); + if (empty($result['share_with_displayname'])) { + $displayNameLength = ($hasCircleId ? strrpos($share->getSharedWith(), ' ') : strlen($share->getSharedWith())); + $result['share_with_displayname'] = substr($share->getSharedWith(), 0, $displayNameLength); + } + + $result['share_with_avatar'] = $share->getSharedWithAvatar(); + + $shareWithStart = ($hasCircleId ? strrpos($share->getSharedWith(), '[') + 1 : 0); + $shareWithLength = ($hasCircleId ? -1 : strpos($share->getSharedWith(), ' ')); + if ($shareWithLength === false) { + $result['share_with'] = substr($share->getSharedWith(), $shareWithStart); + } else { + $result['share_with'] = substr($share->getSharedWith(), $shareWithStart, $shareWithLength); + } + } elseif ($share->getShareType() === IShare::TYPE_ROOM) { + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = ''; + + try { + /** @var array{share_with_displayname: string, share_with_link: string, share_with?: string, token?: string} $roomShare */ + $roomShare = $this->getRoomShareHelper()->formatShare($share); + $result = array_merge($result, $roomShare); + } catch (ContainerExceptionInterface $e) { + } + } elseif ($share->getShareType() === IShare::TYPE_DECK) { + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = ''; + + try { + /** @var array{share_with: string, share_with_displayname: string, share_with_link: string} $deckShare */ + $deckShare = $this->getDeckShareHelper()->formatShare($share); + $result = array_merge($result, $deckShare); + } catch (ContainerExceptionInterface $e) { + } + } elseif ($share->getShareType() === IShare::TYPE_SCIENCEMESH) { + $result['share_with'] = $share->getSharedWith(); + $result['share_with_displayname'] = ''; + + try { + /** @var array{share_with: string, share_with_displayname: string, token: string} $scienceMeshShare */ + $scienceMeshShare = $this->getSciencemeshShareHelper()->formatShare($share); + $result = array_merge($result, $scienceMeshShare); + } catch (ContainerExceptionInterface $e) { + } } $result['mail_send'] = $share->getMailSend() ? 1 : 0; + $result['hide_download'] = $share->getHideDownload() ? 1 : 0; + + $result['attributes'] = null; + if ($attributes = $share->getAttributes()) { + $result['attributes'] = (string)\json_encode($attributes->toArray()); + } return $result; } /** * Check if one of the users address books knows the exact property, if - * yes we return the full name. + * not we return the full name. * * @param string $query * @param string $property * @return string */ - private function getDisplayNameFromAddressBook($query, $property) { - // FIXME: If we inject the contacts manager it gets initialized bofore any address books are registered - $result = \OC::$server->getContactsManager()->search($query, [$property]); + private function getDisplayNameFromAddressBook(string $query, string $property): string { + // FIXME: If we inject the contacts manager it gets initialized before any address books are registered + try { + $result = Server::get(\OCP\Contacts\IManager::class)->search($query, [$property], [ + 'limit' => 1, + 'enumeration' => false, + 'strict_search' => true, + ]); + } catch (Exception $e) { + $this->logger->error( + $e->getMessage(), + ['exception' => $e] + ); + return $query; + } + foreach ($result as $r) { - foreach($r[$property] as $value) { - if ($value === $query) { + foreach ($r[$property] as $value) { + if ($value === $query && $r['FN']) { return $r['FN']; } } @@ -232,65 +388,178 @@ class ShareAPIController extends OCSController { return $query; } + + /** + * @param list<Files_SharingShare> $shares + * @param array<string, string>|null $updatedDisplayName + * + * @return list<Files_SharingShare> + */ + private function fixMissingDisplayName(array $shares, ?array $updatedDisplayName = null): array { + $userIds = $updated = []; + foreach ($shares as $share) { + // share is federated and share have no display name yet + if ($share['share_type'] === IShare::TYPE_REMOTE + && ($share['share_with'] ?? '') !== '' + && ($share['share_with_displayname'] ?? '') === '') { + $userIds[] = $userId = $share['share_with']; + + if ($updatedDisplayName !== null && array_key_exists($userId, $updatedDisplayName)) { + $share['share_with_displayname'] = $updatedDisplayName[$userId]; + } + } + + // prepping userIds with displayName to be updated + $updated[] = $share; + } + + // if $updatedDisplayName is not null, it means we should have already fixed displayNames of the shares + if ($updatedDisplayName !== null) { + return $updated; + } + + // get displayName for the generated list of userId with no displayName + $displayNames = $this->retrieveFederatedDisplayName($userIds); + + // if no displayName are updated, we exit + if (empty($displayNames)) { + return $updated; + } + + // let's fix missing display name and returns all shares + return $this->fixMissingDisplayName($shares, $displayNames); + } + + + /** + * get displayName of a list of userIds from the lookup-server; through the globalsiteselector app. + * returns an array with userIds as keys and displayName as values. + * + * @param array $userIds + * @param bool $cacheOnly - do not reach LUS, get data from cache. + * + * @return array + * @throws ContainerExceptionInterface + */ + private function retrieveFederatedDisplayName(array $userIds, bool $cacheOnly = false): array { + // check if gss is enabled and available + if (count($userIds) === 0 + || !$this->appManager->isEnabledForAnyone('globalsiteselector') + || !class_exists('\OCA\GlobalSiteSelector\Service\SlaveService')) { + return []; + } + + try { + $slaveService = Server::get(SlaveService::class); + } catch (\Throwable $e) { + $this->logger->error( + $e->getMessage(), + ['exception' => $e] + ); + return []; + } + + return $slaveService->getUsersDisplayName($userIds, $cacheOnly); + } + + + /** + * retrieve displayName from cache if available (should be used on federated shares) + * if not available in cache/lus, try for get from address-book, else returns empty string. + * + * @param string $userId + * @param bool $cacheOnly if true will not reach the lus but will only get data from cache + * + * @return string + */ + private function getCachedFederatedDisplayName(string $userId, bool $cacheOnly = true): string { + $details = $this->retrieveFederatedDisplayName([$userId], $cacheOnly); + if (array_key_exists($userId, $details)) { + return $details[$userId]; + } + + $displayName = $this->getDisplayNameFromAddressBook($userId, 'CLOUD'); + return ($displayName === $userId) ? '' : $displayName; + } + + + /** * Get a specific share by id * - * @NoAdminRequired + * @param string $id ID of the share + * @param bool $include_tags Include tags in the share + * @return DataResponse<Http::STATUS_OK, list<Files_SharingShare>, array{}> + * @throws OCSNotFoundException Share not found * - * @param string $id - * @return DataResponse - * @throws OCSNotFoundException + * 200: Share returned */ - public function getShare($id) { + #[NoAdminRequired] + public function getShare(string $id, bool $include_tags = false): DataResponse { try { $share = $this->getShareById($id); } catch (ShareNotFound $e) { - throw new OCSNotFoundException($this->l->t('Wrong share ID, share doesn\'t exist')); + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); } - if ($this->canAccessShare($share)) { - try { + try { + if ($this->canAccessShare($share)) { $share = $this->formatShare($share); - return new DataResponse([$share]); - } catch (NotFoundException $e) { - //Fall trough + + if ($include_tags) { + $share = $this->populateTags([$share]); + } else { + $share = [$share]; + } + + return new DataResponse($share); } + } catch (NotFoundException $e) { + // Fall through } - throw new OCSNotFoundException($this->l->t('Wrong share ID, share doesn\'t exist')); + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); } /** * Delete a share * - * @NoAdminRequired + * @param string $id ID of the share + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSNotFoundException Share not found + * @throws OCSForbiddenException Missing permissions to delete the share * - * @param string $id - * @return DataResponse - * @throws OCSNotFoundException + * 200: Share deleted successfully */ - public function deleteShare($id) { + #[NoAdminRequired] + public function deleteShare(string $id): DataResponse { try { $share = $this->getShareById($id); } catch (ShareNotFound $e) { - throw new OCSNotFoundException($this->l->t('Wrong share ID, share doesn\'t exist')); + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); } try { $this->lock($share->getNode()); } catch (LockedException $e) { - throw new OCSNotFoundException($this->l->t('could not delete share')); + throw new OCSNotFoundException($this->l->t('Could not delete share')); } if (!$this->canAccessShare($share)) { - throw new OCSNotFoundException($this->l->t('Could not delete share')); + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); } - if ($share->getShareType() === \OCP\Share::SHARE_TYPE_GROUP && - $share->getShareOwner() !== $this->currentUser && - $share->getSharedBy() !== $this->currentUser) { - $this->shareManager->deleteFromSelf($share, $this->currentUser); + // if it's a group share or a room share + // we don't delete the share, but only the + // mount point. Allowing it to be restored + // from the deleted shares + if ($this->canDeleteShareFromSelf($share)) { + $this->shareManager->deleteFromSelf($share, $this->userId); } else { + if (!$this->canDeleteShare($share)) { + throw new OCSForbiddenException($this->l->t('Could not delete share')); + } + $this->shareManager->deleteShare($share); } @@ -298,48 +567,75 @@ class ShareAPIController extends OCSController { } /** - * @NoAdminRequired + * Create a share * - * @param string $path - * @param int $permissions - * @param int $shareType - * @param string $shareWith - * @param string $publicUpload - * @param string $password - * @param string $expireDate + * @param string|null $path Path of the share + * @param int|null $permissions Permissions for the share + * @param int $shareType Type of the share + * @param ?string $shareWith The entity this should be shared with + * @param 'true'|'false'|null $publicUpload If public uploading is allowed (deprecated) + * @param string $password Password for the share + * @param string|null $sendPasswordByTalk Send the password for the share over Talk + * @param ?string $expireDate The expiry date of the share in the user's timezone at 00:00. + * If $expireDate is not supplied or set to `null`, the system default will be used. + * @param string $note Note for the share + * @param string $label Label for the share (only used in link and email) + * @param string|null $attributes Additional attributes for the share + * @param 'false'|'true'|null $sendMail Send a mail to the recipient * - * @return DataResponse - * @throws OCSNotFoundException - * @throws OCSForbiddenException - * @throws OCSBadRequestException + * @return DataResponse<Http::STATUS_OK, Files_SharingShare, array{}> + * @throws OCSBadRequestException Unknown share type * @throws OCSException - * + * @throws OCSForbiddenException Creating the share is not allowed + * @throws OCSNotFoundException Creating the share failed * @suppress PhanUndeclaredClassMethod + * + * 200: Share created */ + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 600)] public function createShare( - $path = null, - $permissions = \OCP\Constants::PERMISSION_ALL, - $shareType = -1, - $shareWith = null, - $publicUpload = 'false', - $password = '', - $expireDate = '' - ) { + ?string $path = null, + ?int $permissions = null, + int $shareType = -1, + ?string $shareWith = null, + ?string $publicUpload = null, + string $password = '', + ?string $sendPasswordByTalk = null, + ?string $expireDate = null, + string $note = '', + string $label = '', + ?string $attributes = null, + ?string $sendMail = null, + ): DataResponse { + assert($this->userId !== null); + $share = $this->shareManager->newShare(); + $hasPublicUpload = $this->getLegacyPublicUpload($publicUpload); // Verify path if ($path === null) { throw new OCSNotFoundException($this->l->t('Please specify a file or folder path')); } - $userFolder = $this->rootFolder->getUserFolder($this->currentUser); + $userFolder = $this->rootFolder->getUserFolder($this->userId); try { - $path = $userFolder->get($path); + /** @var \OC\Files\Node\Node $node */ + $node = $userFolder->get($path); } catch (NotFoundException $e) { - throw new OCSNotFoundException($this->l->t('Wrong path, file/folder doesn\'t exist')); + throw new OCSNotFoundException($this->l->t('Wrong path, file/folder does not exist')); + } + + // a user can have access to a file through different paths, with differing permissions + // combine all permissions to determine if the user can share this file + $nodes = $userFolder->getById($node->getId()); + foreach ($nodes as $nodeById) { + /** @var FileInfo $fileInfo */ + $fileInfo = $node->getFileInfo(); + $fileInfo['permissions'] |= $nodeById->getPermissions(); } - $share->setNode($path); + $share->setNode($node); try { $this->lock($share->getNode()); @@ -347,36 +643,80 @@ class ShareAPIController extends OCSController { throw new OCSNotFoundException($this->l->t('Could not create share')); } - if ($permissions < 0 || $permissions > \OCP\Constants::PERMISSION_ALL) { - throw new OCSNotFoundException($this->l->t('invalid permissions')); + // Set permissions + if ($shareType === IShare::TYPE_LINK || $shareType === IShare::TYPE_EMAIL) { + $permissions = $this->getLinkSharePermissions($permissions, $hasPublicUpload); + $this->validateLinkSharePermissions($node, $permissions, $hasPublicUpload); + } else { + // Use default permissions only for non-link shares to keep legacy behavior + if ($permissions === null) { + $permissions = (int)$this->config->getAppValue('core', 'shareapi_default_permissions', (string)Constants::PERMISSION_ALL); + } + // Non-link shares always require read permissions (link shares could be file drop) + $permissions |= Constants::PERMISSION_READ; } - // Shares always require read permissions - $permissions |= \OCP\Constants::PERMISSION_READ; - - if ($path instanceof \OCP\Files\File) { - // Single file shares should never have delete or create permissions - $permissions &= ~\OCP\Constants::PERMISSION_DELETE; - $permissions &= ~\OCP\Constants::PERMISSION_CREATE; + // For legacy reasons the API allows to pass PERMISSIONS_ALL even for single file shares (I look at you Talk) + if ($node instanceof File) { + // if this is a single file share we remove the DELETE and CREATE permissions + $permissions = $permissions & ~(Constants::PERMISSION_DELETE | Constants::PERMISSION_CREATE); } - /* + /** * Hack for https://github.com/owncloud/core/issues/22587 * We check the permissions via webdav. But the permissions of the mount point * do not equal the share permissions. Here we fix that for federated mounts. */ - if ($path->getStorage()->instanceOfStorage('OCA\Files_Sharing\External\Storage')) { - $permissions &= ~($permissions & ~$path->getPermissions()); + if ($node->getStorage()->instanceOfStorage(Storage::class)) { + $permissions &= ~($permissions & ~$node->getPermissions()); + } + + if ($attributes !== null) { + $share = $this->setShareAttributes($share, $attributes); + } + + // Expire date checks + // Normally, null means no expiration date but we still set the default for backwards compatibility + // If the client sends an empty string, we set noExpirationDate to true + if ($expireDate !== null) { + if ($expireDate !== '') { + try { + $expireDateTime = $this->parseDate($expireDate); + $share->setExpirationDate($expireDateTime); + } catch (\Exception $e) { + throw new OCSNotFoundException($e->getMessage(), $e); + } + } else { + // Client sent empty string for expire date. + // Set noExpirationDate to true so overwrite is prevented. + $share->setNoExpirationDate(true); + } } - if ($shareType === \OCP\Share::SHARE_TYPE_USER) { + $share->setSharedBy($this->userId); + + // Handle mail send + if (is_null($sendMail)) { + $allowSendMail = $this->config->getSystemValueBool('sharing.enable_share_mail', true); + if ($allowSendMail !== true || $shareType === IShare::TYPE_EMAIL) { + // Define a default behavior when sendMail is not provided + // For email shares with a valid recipient, the default is to send the mail + // For all other share types, the default is to not send the mail + $allowSendMail = ($shareType === IShare::TYPE_EMAIL && $shareWith !== null && $shareWith !== ''); + } + $share->setMailSend($allowSendMail); + } else { + $share->setMailSend($sendMail === 'true'); + } + + if ($shareType === IShare::TYPE_USER) { // Valid user is required to share if ($shareWith === null || !$this->userManager->userExists($shareWith)) { - throw new OCSNotFoundException($this->l->t('Please specify a valid user')); + throw new OCSNotFoundException($this->l->t('Please specify a valid account to share with')); } $share->setSharedWith($shareWith); $share->setPermissions($permissions); - } else if ($shareType === \OCP\Share::SHARE_TYPE_GROUP) { + } elseif ($shareType === IShare::TYPE_GROUP) { if (!$this->shareManager->allowGroupSharing()) { throw new OCSNotFoundException($this->l->t('Group sharing is disabled by the administrator')); } @@ -387,102 +727,122 @@ class ShareAPIController extends OCSController { } $share->setSharedWith($shareWith); $share->setPermissions($permissions); - } else if ($shareType === \OCP\Share::SHARE_TYPE_LINK) { - //Can we even share links? + } elseif ($shareType === IShare::TYPE_LINK + || $shareType === IShare::TYPE_EMAIL) { + + // Can we even share links? if (!$this->shareManager->shareApiAllowLinks()) { throw new OCSNotFoundException($this->l->t('Public link sharing is disabled by the administrator')); } - /* - * For now we only allow 1 link share. - * Return the existing link share if this is a duplicate - */ - $existingShares = $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_LINK, $path, false, 1, 0); - if (!empty($existingShares)) { - return new DataResponse($this->formatShare($existingShares[0])); - } - - if ($publicUpload === 'true') { - // Check if public upload is allowed - if (!$this->shareManager->shareApiLinkAllowPublicUpload()) { - throw new OCSForbiddenException($this->l->t('Public upload disabled by the administrator')); - } - - // Public upload can only be set for folders - if ($path instanceof \OCP\Files\File) { - throw new OCSNotFoundException($this->l->t('Public upload is only possible for publicly shared folders')); - } - - $share->setPermissions( - \OCP\Constants::PERMISSION_READ | - \OCP\Constants::PERMISSION_CREATE | - \OCP\Constants::PERMISSION_UPDATE | - \OCP\Constants::PERMISSION_DELETE - ); - } else { - $share->setPermissions(\OCP\Constants::PERMISSION_READ); - } + $this->validateLinkSharePermissions($node, $permissions, $hasPublicUpload); + $share->setPermissions($permissions); // Set password if ($password !== '') { $share->setPassword($password); } - //Expire date - if ($expireDate !== '') { - try { - $expireDate = $this->parseDate($expireDate); - $share->setExpirationDate($expireDate); - } catch (\Exception $e) { - throw new OCSNotFoundException($this->l->t('Invalid date, date format must be YYYY-MM-DD')); + // Only share by mail have a recipient + if (is_string($shareWith) && $shareType === IShare::TYPE_EMAIL) { + // If sending a mail have been requested, validate the mail address + if ($share->getMailSend() && !$this->mailer->validateMailAddress($shareWith)) { + throw new OCSNotFoundException($this->l->t('Please specify a valid email address')); + } + $share->setSharedWith($shareWith); + } + + // If we have a label, use it + if ($label !== '') { + if (strlen($label) > 255) { + throw new OCSBadRequestException('Maximum label length is 255'); } + $share->setLabel($label); } - } else if ($shareType === \OCP\Share::SHARE_TYPE_REMOTE) { + if ($sendPasswordByTalk === 'true') { + if (!$this->appManager->isEnabledForUser('spreed')) { + throw new OCSForbiddenException($this->l->t('Sharing %s sending the password by Nextcloud Talk failed because Nextcloud Talk is not enabled', [$node->getPath()])); + } + + $share->setSendPasswordByTalk(true); + } + } elseif ($shareType === IShare::TYPE_REMOTE) { if (!$this->shareManager->outgoingServer2ServerSharesAllowed()) { - throw new OCSForbiddenException($this->l->t('Sharing %s failed because the back end does not allow shares from type %s', [$path->getPath(), $shareType])); + throw new OCSForbiddenException($this->l->t('Sharing %1$s failed because the back end does not allow shares from type %2$s', [$node->getPath(), $shareType])); + } + + if ($shareWith === null) { + throw new OCSNotFoundException($this->l->t('Please specify a valid federated account ID')); } $share->setSharedWith($shareWith); $share->setPermissions($permissions); - } else if ($shareType === \OCP\Share::SHARE_TYPE_EMAIL) { - if ($share->getNodeType() === 'file') { - $share->setPermissions(\OCP\Constants::PERMISSION_READ); - } else { - $share->setPermissions( - \OCP\Constants::PERMISSION_READ | - \OCP\Constants::PERMISSION_CREATE | - \OCP\Constants::PERMISSION_UPDATE | - \OCP\Constants::PERMISSION_DELETE); + $share->setSharedWithDisplayName($this->getCachedFederatedDisplayName($shareWith, false)); + } elseif ($shareType === IShare::TYPE_REMOTE_GROUP) { + if (!$this->shareManager->outgoingServer2ServerGroupSharesAllowed()) { + throw new OCSForbiddenException($this->l->t('Sharing %1$s failed because the back end does not allow shares from type %2$s', [$node->getPath(), $shareType])); } + + if ($shareWith === null) { + throw new OCSNotFoundException($this->l->t('Please specify a valid federated group ID')); + } + $share->setSharedWith($shareWith); - } else if ($shareType === \OCP\Share::SHARE_TYPE_CIRCLE) { - if (!\OC::$server->getAppManager()->isEnabledForUser('circles') || !class_exists('\OCA\Circles\ShareByCircleProvider')) { - throw new OCSNotFoundException($this->l->t('You cannot share to a Circle if the app is not enabled')); + $share->setPermissions($permissions); + } elseif ($shareType === IShare::TYPE_CIRCLE) { + if (!Server::get(IAppManager::class)->isEnabledForUser('circles') || !class_exists('\OCA\Circles\ShareByCircleProvider')) { + throw new OCSNotFoundException($this->l->t('You cannot share to a Team if the app is not enabled')); } - $circle = \OCA\Circles\Api\v1\Circles::detailsCircle($shareWith); + $circle = Circles::detailsCircle($shareWith); - // Valid circle is required to share + // Valid team is required to share if ($circle === null) { - throw new OCSNotFoundException($this->l->t('Please specify a valid circle')); + throw new OCSNotFoundException($this->l->t('Please specify a valid team')); } $share->setSharedWith($shareWith); $share->setPermissions($permissions); + } elseif ($shareType === IShare::TYPE_ROOM) { + try { + $this->getRoomShareHelper()->createShare($share, $shareWith, $permissions, $expireDate ?? ''); + } catch (ContainerExceptionInterface $e) { + throw new OCSForbiddenException($this->l->t('Sharing %s failed because the back end does not support room shares', [$node->getPath()])); + } + } elseif ($shareType === IShare::TYPE_DECK) { + try { + $this->getDeckShareHelper()->createShare($share, $shareWith, $permissions, $expireDate ?? ''); + } catch (ContainerExceptionInterface $e) { + throw new OCSForbiddenException($this->l->t('Sharing %s failed because the back end does not support room shares', [$node->getPath()])); + } + } elseif ($shareType === IShare::TYPE_SCIENCEMESH) { + try { + $this->getSciencemeshShareHelper()->createShare($share, $shareWith, $permissions, $expireDate ?? ''); + } catch (ContainerExceptionInterface $e) { + throw new OCSForbiddenException($this->l->t('Sharing %s failed because the back end does not support ScienceMesh shares', [$node->getPath()])); + } } else { throw new OCSBadRequestException($this->l->t('Unknown share type')); } $share->setShareType($shareType); - $share->setSharedBy($this->currentUser); + $this->checkInheritedAttributes($share); + + if ($note !== '') { + $share->setNote($note); + } try { $share = $this->shareManager->createShare($share); - } catch (GenericShareException $e) { + } catch (HintException $e) { $code = $e->getCode() === 0 ? 403 : $e->getCode(); throw new OCSException($e->getHint(), $code); + } catch (GenericShareException|\InvalidArgumentException $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new OCSForbiddenException($e->getMessage(), $e); } catch (\Exception $e) { - throw new OCSForbiddenException($e->getMessage()); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new OCSForbiddenException('Failed to create share.', $e); } $output = $this->formatShare($share); @@ -491,24 +851,27 @@ class ShareAPIController extends OCSController { } /** - * @param \OCP\Files\File|\OCP\Files\Folder $node + * @param null|Node $node * @param boolean $includeTags - * @return DataResponse + * + * @return list<Files_SharingShare> */ - private function getSharedWithMe($node = null, $includeTags) { - - $userShares = $this->shareManager->getSharedWith($this->currentUser, \OCP\Share::SHARE_TYPE_USER, $node, -1, 0); - $groupShares = $this->shareManager->getSharedWith($this->currentUser, \OCP\Share::SHARE_TYPE_GROUP, $node, -1, 0); - $circleShares = $this->shareManager->getSharedWith($this->currentUser, \OCP\Share::SHARE_TYPE_CIRCLE, $node, -1, 0); - - $shares = array_merge($userShares, $groupShares, $circleShares); - - $shares = array_filter($shares, function (IShare $share) { - return $share->getShareOwner() !== $this->currentUser; + private function getSharedWithMe($node, bool $includeTags): array { + $userShares = $this->shareManager->getSharedWith($this->userId, IShare::TYPE_USER, $node, -1, 0); + $groupShares = $this->shareManager->getSharedWith($this->userId, IShare::TYPE_GROUP, $node, -1, 0); + $circleShares = $this->shareManager->getSharedWith($this->userId, IShare::TYPE_CIRCLE, $node, -1, 0); + $roomShares = $this->shareManager->getSharedWith($this->userId, IShare::TYPE_ROOM, $node, -1, 0); + $deckShares = $this->shareManager->getSharedWith($this->userId, IShare::TYPE_DECK, $node, -1, 0); + $sciencemeshShares = $this->shareManager->getSharedWith($this->userId, IShare::TYPE_SCIENCEMESH, $node, -1, 0); + + $shares = array_merge($userShares, $groupShares, $circleShares, $roomShares, $deckShares, $sciencemeshShares); + + $filteredShares = array_filter($shares, function (IShare $share) { + return $share->getShareOwner() !== $this->userId; }); $formatted = []; - foreach ($shares as $share) { + foreach ($filteredShares as $share) { if ($this->canAccessShare($share)) { try { $formatted[] = $this->formatShare($share); @@ -519,339 +882,838 @@ class ShareAPIController extends OCSController { } if ($includeTags) { - $formatted = Helper::populateTags($formatted, 'file_source', \OC::$server->getTagManager()); + $formatted = $this->populateTags($formatted); } - return new DataResponse($formatted); + return $formatted; } /** - * @param \OCP\Files\Folder $folder - * @return DataResponse + * @param Node $folder + * + * @return list<Files_SharingShare> * @throws OCSBadRequestException + * @throws NotFoundException */ - private function getSharesInDir($folder) { - if (!($folder instanceof \OCP\Files\Folder)) { + private function getSharesInDir(Node $folder): array { + if (!($folder instanceof Folder)) { throw new OCSBadRequestException($this->l->t('Not a directory')); } $nodes = $folder->getDirectoryListing(); - /** @var \OCP\Share\IShare[] $shares */ - $shares = []; - foreach ($nodes as $node) { - $shares = array_merge($shares, $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_USER, $node, false, -1, 0)); - $shares = array_merge($shares, $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_GROUP, $node, false, -1, 0)); - $shares = array_merge($shares, $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_LINK, $node, false, -1, 0)); - if($this->shareManager->shareProviderExists(\OCP\Share::SHARE_TYPE_EMAIL)) { - $shares = array_merge($shares, $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_EMAIL, $node, false, -1, 0)); - } - if ($this->shareManager->outgoingServer2ServerSharesAllowed()) { - $shares = array_merge($shares, $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_REMOTE, $node, false, -1, 0)); - } - } - $formatted = []; + /** @var IShare[] $shares */ + $shares = array_reduce($nodes, function ($carry, $node) { + $carry = array_merge($carry, $this->getAllShares($node, true)); + return $carry; + }, []); + + // filter out duplicate shares + $known = []; + + $formatted = $miniFormatted = []; + $resharingRight = false; + $known = []; foreach ($shares as $share) { + if (in_array($share->getId(), $known) || $share->getSharedWith() === $this->userId) { + continue; + } + try { - $formatted[] = $this->formatShare($share); - } catch (NotFoundException $e) { + $format = $this->formatShare($share); + + $known[] = $share->getId(); + $formatted[] = $format; + if ($share->getSharedBy() === $this->userId) { + $miniFormatted[] = $format; + } + if (!$resharingRight && $this->shareProviderResharingRights($this->userId, $share, $folder)) { + $resharingRight = true; + } + } catch (\Exception $e) { //Ignore this share } } - return new DataResponse($formatted); + if (!$resharingRight) { + $formatted = $miniFormatted; + } + + return $formatted; } /** - * The getShares function. - * - * @NoAdminRequired + * Get shares of the current user * - * @param string $shared_with_me - * @param string $reshares - * @param string $subfiles - * @param string $path + * @param string $shared_with_me Only get shares with the current user + * @param string $reshares Only get shares by the current user and reshares + * @param string $subfiles Only get all shares in a folder + * @param string $path Get shares for a specific path + * @param string $include_tags Include tags in the share * - * - Get shares by the current user - * - Get shares by the current user and reshares (?reshares=true) - * - Get shares with the current user (?shared_with_me=true) - * - Get shares for a specific path (?path=...) - * - Get all shares in a folder (?subfiles=true&path=..) + * @return DataResponse<Http::STATUS_OK, list<Files_SharingShare>, array{}> + * @throws OCSNotFoundException The folder was not found or is inaccessible * - * @return DataResponse - * @throws OCSNotFoundException + * 200: Shares returned */ + #[NoAdminRequired] public function getShares( - $shared_with_me = 'false', - $reshares = 'false', - $subfiles = 'false', - $path = null, - $include_tags = 'false' - ) { - - if ($path !== null) { - $userFolder = $this->rootFolder->getUserFolder($this->currentUser); + string $shared_with_me = 'false', + string $reshares = 'false', + string $subfiles = 'false', + string $path = '', + string $include_tags = 'false', + ): DataResponse { + $node = null; + if ($path !== '') { + $userFolder = $this->rootFolder->getUserFolder($this->userId); try { - $path = $userFolder->get($path); - $this->lock($path); - } catch (\OCP\Files\NotFoundException $e) { - throw new OCSNotFoundException($this->l->t('Wrong path, file/folder doesn\'t exist')); + $node = $userFolder->get($path); + $this->lock($node); + } catch (NotFoundException $e) { + throw new OCSNotFoundException( + $this->l->t('Wrong path, file/folder does not exist') + ); } catch (LockedException $e) { - throw new OCSNotFoundException($this->l->t('Could not lock path')); + throw new OCSNotFoundException($this->l->t('Could not lock node')); } } - if ($shared_with_me === 'true') { - $result = $this->getSharedWithMe($path, $include_tags); - return $result; + $shares = $this->getFormattedShares( + $this->userId, + $node, + ($shared_with_me === 'true'), + ($reshares === 'true'), + ($subfiles === 'true'), + ($include_tags === 'true') + ); + + return new DataResponse($shares); + } + + private function getLinkSharePermissions(?int $permissions, ?bool $legacyPublicUpload): int { + $permissions = $permissions ?? Constants::PERMISSION_READ; + + // Legacy option handling + if ($legacyPublicUpload !== null) { + $permissions = $legacyPublicUpload + ? (Constants::PERMISSION_READ | Constants::PERMISSION_CREATE | Constants::PERMISSION_UPDATE | Constants::PERMISSION_DELETE) + : Constants::PERMISSION_READ; } - if ($subfiles === 'true') { - $result = $this->getSharesInDir($path); - return $result; + if ($this->hasPermission($permissions, Constants::PERMISSION_READ) + && $this->shareManager->outgoingServer2ServerSharesAllowed() + && $this->appConfig->getValueBool('core', ConfigLexicon::SHAREAPI_ALLOW_FEDERATION_ON_PUBLIC_SHARES)) { + $permissions |= Constants::PERMISSION_SHARE; } - if ($reshares === 'true') { - $reshares = true; - } else { - $reshares = false; + return $permissions; + } + + /** + * Helper to check for legacy "publicUpload" handling. + * If the value is set to `true` or `false` then true or false are returned. + * Otherwise null is returned to indicate that the option was not (or wrong) set. + * + * @param null|string $legacyPublicUpload The value of `publicUpload` + */ + private function getLegacyPublicUpload(?string $legacyPublicUpload): ?bool { + if ($legacyPublicUpload === 'true') { + return true; + } elseif ($legacyPublicUpload === 'false') { + return false; } + // Not set at all + return null; + } - // Get all shares - $userShares = $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_USER, $path, $reshares, -1, 0); - $groupShares = $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_GROUP, $path, $reshares, -1, 0); - $linkShares = $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_LINK, $path, $reshares, -1, 0); - if ($this->shareManager->shareProviderExists(\OCP\Share::SHARE_TYPE_EMAIL)) { - $mailShares = $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_EMAIL, $path, $reshares, -1, 0); - } else { - $mailShares = []; + /** + * For link and email shares validate that only allowed combinations are set. + * + * @throw OCSBadRequestException If permission combination is invalid. + * @throw OCSForbiddenException If public upload was forbidden by the administrator. + */ + private function validateLinkSharePermissions(Node $node, int $permissions, ?bool $legacyPublicUpload): void { + if ($legacyPublicUpload && ($node instanceof File)) { + throw new OCSBadRequestException($this->l->t('Public upload is only possible for publicly shared folders')); } - if ($this->shareManager->shareProviderExists(\OCP\Share::SHARE_TYPE_CIRCLE)) { - $circleShares = $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_CIRCLE, $path, $reshares, -1, 0); - } else { - $circleShares = []; + + // We need at least READ or CREATE (file drop) + if (!$this->hasPermission($permissions, Constants::PERMISSION_READ) + && !$this->hasPermission($permissions, Constants::PERMISSION_CREATE)) { + throw new OCSBadRequestException($this->l->t('Share must at least have READ or CREATE permissions')); } - $shares = array_merge($userShares, $groupShares, $linkShares, $mailShares, $circleShares); + // UPDATE and DELETE require a READ permission + if (!$this->hasPermission($permissions, Constants::PERMISSION_READ) + && ($this->hasPermission($permissions, Constants::PERMISSION_UPDATE) || $this->hasPermission($permissions, Constants::PERMISSION_DELETE))) { + throw new OCSBadRequestException($this->l->t('Share must have READ permission if UPDATE or DELETE permission is set')); + } - if ($this->shareManager->outgoingServer2ServerSharesAllowed()) { - $federatedShares = $this->shareManager->getSharesBy($this->currentUser, \OCP\Share::SHARE_TYPE_REMOTE, $path, $reshares, -1, 0); - $shares = array_merge($shares, $federatedShares); + // Check if public uploading was disabled + if ($this->hasPermission($permissions, Constants::PERMISSION_CREATE) + && !$this->shareManager->shareApiLinkAllowPublicUpload()) { + throw new OCSForbiddenException($this->l->t('Public upload disabled by the administrator')); } + } - $formatted = []; + /** + * @param string $viewer + * @param Node $node + * @param bool $sharedWithMe + * @param bool $reShares + * @param bool $subFiles + * @param bool $includeTags + * + * @return list<Files_SharingShare> + * @throws NotFoundException + * @throws OCSBadRequestException + */ + private function getFormattedShares( + string $viewer, + $node = null, + bool $sharedWithMe = false, + bool $reShares = false, + bool $subFiles = false, + bool $includeTags = false, + ): array { + if ($sharedWithMe) { + return $this->getSharedWithMe($node, $includeTags); + } + + if ($subFiles) { + return $this->getSharesInDir($node); + } + + $shares = $this->getSharesFromNode($viewer, $node, $reShares); + + $known = $formatted = $miniFormatted = []; + $resharingRight = false; foreach ($shares as $share) { try { - $formatted[] = $this->formatShare($share, $path); + $share->getNode(); } catch (NotFoundException $e) { - //Ignore share + /* + * Ignore shares where we can't get the node + * For example deleted shares + */ + continue; + } + + if (in_array($share->getId(), $known) + || ($share->getSharedWith() === $this->userId && $share->getShareType() === IShare::TYPE_USER)) { + continue; + } + + $known[] = $share->getId(); + try { + /** @var IShare $share */ + $format = $this->formatShare($share, $node); + $formatted[] = $format; + + // let's also build a list of shares created + // by the current user only, in case + // there is no resharing rights + if ($share->getSharedBy() === $this->userId) { + $miniFormatted[] = $format; + } + + // check if one of those share is shared with me + // and if I have resharing rights on it + if (!$resharingRight && $this->shareProviderResharingRights($this->userId, $share, $node)) { + $resharingRight = true; + } + } catch (InvalidPathException|NotFoundException $e) { } } - if ($include_tags) { - $formatted = Helper::populateTags($formatted, 'file_source', \OC::$server->getTagManager()); + if (!$resharingRight) { + $formatted = $miniFormatted; } - return new DataResponse($formatted); + // fix eventual missing display name from federated shares + $formatted = $this->fixMissingDisplayName($formatted); + + if ($includeTags) { + $formatted = $this->populateTags($formatted); + } + + return $formatted; } + /** - * @NoAdminRequired + * Get all shares relative to a file, including parent folders shares rights * - * @param int $id - * @param int $permissions - * @param string $password - * @param string $publicUpload - * @param string $expireDate - * @return DataResponse - * @throws OCSNotFoundException - * @throws OCSBadRequestException - * @throws OCSForbiddenException + * @param string $path Path all shares will be relative to + * + * @return DataResponse<Http::STATUS_OK, list<Files_SharingShare>, array{}> + * @throws InvalidPathException + * @throws NotFoundException + * @throws OCSNotFoundException The given path is invalid + * @throws SharingRightsException + * + * 200: Shares returned + */ + #[NoAdminRequired] + public function getInheritedShares(string $path): DataResponse { + // get Node from (string) path. + $userFolder = $this->rootFolder->getUserFolder($this->userId); + try { + $node = $userFolder->get($path); + $this->lock($node); + } catch (NotFoundException $e) { + throw new OCSNotFoundException($this->l->t('Wrong path, file/folder does not exist')); + } catch (LockedException $e) { + throw new OCSNotFoundException($this->l->t('Could not lock path')); + } + + if (!($node->getPermissions() & Constants::PERMISSION_SHARE)) { + throw new SharingRightsException($this->l->t('no sharing rights on this item')); + } + + // The current top parent we have access to + $parent = $node; + + // initiate real owner. + $owner = $node->getOwner() + ->getUID(); + if (!$this->userManager->userExists($owner)) { + return new DataResponse([]); + } + + // get node based on the owner, fix owner in case of external storage + $userFolder = $this->rootFolder->getUserFolder($owner); + if ($node->getId() !== $userFolder->getId() && !$userFolder->isSubNode($node)) { + $owner = $node->getOwner() + ->getUID(); + $userFolder = $this->rootFolder->getUserFolder($owner); + $node = $userFolder->getFirstNodeById($node->getId()); + } + $basePath = $userFolder->getPath(); + + // generate node list for each parent folders + /** @var Node[] $nodes */ + $nodes = []; + while (true) { + $node = $node->getParent(); + if ($node->getPath() === $basePath) { + break; + } + $nodes[] = $node; + } + + // The user that is requesting this list + $currentUserFolder = $this->rootFolder->getUserFolder($this->userId); + + // for each nodes, retrieve shares. + $shares = []; + + foreach ($nodes as $node) { + $getShares = $this->getFormattedShares($owner, $node, false, true); + + $currentUserNode = $currentUserFolder->getFirstNodeById($node->getId()); + if ($currentUserNode) { + $parent = $currentUserNode; + } + + $subPath = $currentUserFolder->getRelativePath($parent->getPath()); + foreach ($getShares as &$share) { + $share['via_fileid'] = $parent->getId(); + $share['via_path'] = $subPath; + } + $this->mergeFormattedShares($shares, $getShares); + } + + return new DataResponse(array_values($shares)); + } + + /** + * Check whether a set of permissions contains the permissions to check. + */ + private function hasPermission(int $permissionsSet, int $permissionsToCheck): bool { + return ($permissionsSet & $permissionsToCheck) === $permissionsToCheck; + } + + /** + * Update a share + * + * @param string $id ID of the share + * @param int|null $permissions New permissions + * @param string|null $password New password + * @param string|null $sendPasswordByTalk New condition if the password should be send over Talk + * @param string|null $publicUpload New condition if public uploading is allowed + * @param string|null $expireDate New expiry date + * @param string|null $note New note + * @param string|null $label New label + * @param string|null $hideDownload New condition if the download should be hidden + * @param string|null $attributes New additional attributes + * @param string|null $sendMail if the share should be send by mail. + * Considering the share already exists, no mail will be send after the share is updated. + * You will have to use the sendMail action to send the mail. + * @param string|null $shareWith New recipient for email shares + * @param string|null $token New token + * @return DataResponse<Http::STATUS_OK, Files_SharingShare, array{}> + * @throws OCSBadRequestException Share could not be updated because the requested changes are invalid + * @throws OCSForbiddenException Missing permissions to update the share + * @throws OCSNotFoundException Share not found + * + * 200: Share updated successfully */ + #[NoAdminRequired] public function updateShare( - $id, - $permissions = null, - $password = null, - $publicUpload = null, - $expireDate = null - ) { + string $id, + ?int $permissions = null, + ?string $password = null, + ?string $sendPasswordByTalk = null, + ?string $publicUpload = null, + ?string $expireDate = null, + ?string $note = null, + ?string $label = null, + ?string $hideDownload = null, + ?string $attributes = null, + ?string $sendMail = null, + ?string $token = null, + ): DataResponse { try { $share = $this->getShareById($id); } catch (ShareNotFound $e) { - throw new OCSNotFoundException($this->l->t('Wrong share ID, share doesn\'t exist')); + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); } $this->lock($share->getNode()); if (!$this->canAccessShare($share, false)) { - throw new OCSNotFoundException($this->l->t('Wrong share ID, share doesn\'t exist')); + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); } - if ($permissions === null && $password === null && $publicUpload === null && $expireDate === null) { - throw new OCSBadRequestException($this->l->t('Wrong or no update parameter given')); + if (!$this->canEditShare($share)) { + throw new OCSForbiddenException($this->l->t('You are not allowed to edit incoming shares')); } - /* - * expirationdate, password and publicUpload only make sense for link shares - */ - if ($share->getShareType() === \OCP\Share::SHARE_TYPE_LINK) { - - $newPermissions = null; - if ($publicUpload === 'true') { - $newPermissions = \OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_CREATE | \OCP\Constants::PERMISSION_UPDATE | \OCP\Constants::PERMISSION_DELETE; - } else if ($publicUpload === 'false') { - $newPermissions = \OCP\Constants::PERMISSION_READ; - } + if ( + $permissions === null + && $password === null + && $sendPasswordByTalk === null + && $publicUpload === null + && $expireDate === null + && $note === null + && $label === null + && $hideDownload === null + && $attributes === null + && $sendMail === null + && $token === null + ) { + throw new OCSBadRequestException($this->l->t('Wrong or no update parameter given')); + } - if ($permissions !== null) { - $newPermissions = (int)$permissions; - $newPermissions = $newPermissions & ~\OCP\Constants::PERMISSION_SHARE; - } - - if ($newPermissions !== null && - !in_array($newPermissions, [ - \OCP\Constants::PERMISSION_READ, - \OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_CREATE | \OCP\Constants::PERMISSION_UPDATE, // legacy - \OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_CREATE | \OCP\Constants::PERMISSION_UPDATE | \OCP\Constants::PERMISSION_DELETE, // correct - \OCP\Constants::PERMISSION_CREATE, // hidden file list - \OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_UPDATE, // allow to edit single files - ]) - ) { - throw new OCSBadRequestException($this->l->t('Can\'t change permissions for public share links')); - } - - if ( - // legacy - $newPermissions === (\OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_CREATE | \OCP\Constants::PERMISSION_UPDATE) || - // correct - $newPermissions === (\OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_CREATE | \OCP\Constants::PERMISSION_UPDATE | \OCP\Constants::PERMISSION_DELETE) - ) { - if (!$this->shareManager->shareApiLinkAllowPublicUpload()) { - throw new OCSForbiddenException($this->l->t('Public upload disabled by the administrator')); - } + if ($note !== null) { + $share->setNote($note); + } - if (!($share->getNode() instanceof \OCP\Files\Folder)) { - throw new OCSBadRequestException($this->l->t('Public upload is only possible for publicly shared folders')); - } + if ($attributes !== null) { + $share = $this->setShareAttributes($share, $attributes); + } - // normalize to correct public upload permissions - $newPermissions = \OCP\Constants::PERMISSION_READ | \OCP\Constants::PERMISSION_CREATE | \OCP\Constants::PERMISSION_UPDATE | \OCP\Constants::PERMISSION_DELETE; - } + // Handle mail send + if ($sendMail === 'true' || $sendMail === 'false') { + $share->setMailSend($sendMail === 'true'); + } - if ($newPermissions !== null) { - $share->setPermissions($newPermissions); - $permissions = $newPermissions; + /** + * expiration date, password and publicUpload only make sense for link shares + */ + if ($share->getShareType() === IShare::TYPE_LINK + || $share->getShareType() === IShare::TYPE_EMAIL) { + + // Update hide download state + if ($hideDownload === 'true') { + $share->setHideDownload(true); + } elseif ($hideDownload === 'false') { + $share->setHideDownload(false); } - if ($expireDate === '') { - $share->setExpirationDate(null); - } else if ($expireDate !== null) { - try { - $expireDate = $this->parseDate($expireDate); - } catch (\Exception $e) { - throw new OCSBadRequestException($e->getMessage()); - } - $share->setExpirationDate($expireDate); + // If either manual permissions are specified or publicUpload + // then we need to also update the permissions of the share + if ($permissions !== null || $publicUpload !== null) { + $hasPublicUpload = $this->getLegacyPublicUpload($publicUpload); + $permissions = $this->getLinkSharePermissions($permissions ?? Constants::PERMISSION_READ, $hasPublicUpload); + $this->validateLinkSharePermissions($share->getNode(), $permissions, $hasPublicUpload); + $share->setPermissions($permissions); } if ($password === '') { $share->setPassword(null); - } else if ($password !== null) { + } elseif ($password !== null) { $share->setPassword($password); } - } else { - if ($permissions !== null) { - $permissions = (int)$permissions; - $share->setPermissions($permissions); + if ($label !== null) { + if (strlen($label) > 255) { + throw new OCSBadRequestException('Maximum label length is 255'); + } + $share->setLabel($label); } - if ($share->getShareType() === \OCP\Share::SHARE_TYPE_EMAIL) { - if ($password === '') { - $share->setPassword(null); - } else if ($password !== null) { - $share->setPassword($password); + if ($sendPasswordByTalk === 'true') { + if (!$this->appManager->isEnabledForUser('spreed')) { + throw new OCSForbiddenException($this->l->t('"Sending the password by Nextcloud Talk" for sharing a file or folder failed because Nextcloud Talk is not enabled.')); } + + $share->setSendPasswordByTalk(true); + } elseif ($sendPasswordByTalk !== null) { + $share->setSendPasswordByTalk(false); } - if ($expireDate === '') { - $share->setExpirationDate(null); - } else if ($expireDate !== null) { - try { - $expireDate = $this->parseDate($expireDate); - } catch (\Exception $e) { - throw new OCSBadRequestException($e->getMessage()); + if ($token !== null) { + if (!$this->shareManager->allowCustomTokens()) { + throw new OCSForbiddenException($this->l->t('Custom share link tokens have been disabled by the administrator')); + } + if (!$this->validateToken($token)) { + throw new OCSBadRequestException($this->l->t('Tokens must contain at least 1 character and may only contain letters, numbers, or a hyphen')); } - $share->setExpirationDate($expireDate); + $share->setToken($token); } + } + // NOT A LINK SHARE + else { + if ($permissions !== null) { + $share->setPermissions($permissions); + } + } + + if ($expireDate === '') { + $share->setExpirationDate(null); + } elseif ($expireDate !== null) { + try { + $expireDateTime = $this->parseDate($expireDate); + $share->setExpirationDate($expireDateTime); + } catch (\Exception $e) { + throw new OCSBadRequestException($e->getMessage(), $e); + } } - if ($permissions !== null && $share->getShareOwner() !== $this->currentUser) { - /* Check if this is an incomming share */ - $incomingShares = $this->shareManager->getSharedWith($this->currentUser, \OCP\Share::SHARE_TYPE_USER, $share->getNode(), -1, 0); - $incomingShares = array_merge($incomingShares, $this->shareManager->getSharedWith($this->currentUser, \OCP\Share::SHARE_TYPE_GROUP, $share->getNode(), -1, 0)); + try { + $this->checkInheritedAttributes($share); + $share = $this->shareManager->updateShare($share); + } catch (HintException $e) { + $code = $e->getCode() === 0 ? 403 : $e->getCode(); + throw new OCSException($e->getHint(), (int)$code); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new OCSBadRequestException('Failed to update share.', $e); + } + + return new DataResponse($this->formatShare($share)); + } + + private function validateToken(string $token): bool { + if (mb_strlen($token) === 0) { + return false; + } + if (!preg_match('/^[a-z0-9-]+$/i', $token)) { + return false; + } + return true; + } + + /** + * Get all shares that are still pending + * + * @return DataResponse<Http::STATUS_OK, list<Files_SharingShare>, array{}> + * + * 200: Pending shares returned + */ + #[NoAdminRequired] + public function pendingShares(): DataResponse { + $pendingShares = []; + + $shareTypes = [ + IShare::TYPE_USER, + IShare::TYPE_GROUP + ]; - /** @var \OCP\Share\IShare[] $incomingShares */ - if (!empty($incomingShares)) { - $maxPermissions = 0; - foreach ($incomingShares as $incomingShare) { - $maxPermissions |= $incomingShare->getPermissions(); + foreach ($shareTypes as $shareType) { + $shares = $this->shareManager->getSharedWith($this->userId, $shareType, null, -1, 0); + + foreach ($shares as $share) { + if ($share->getStatus() === IShare::STATUS_PENDING || $share->getStatus() === IShare::STATUS_REJECTED) { + $pendingShares[] = $share; } + } + } - if ($share->getPermissions() & ~$maxPermissions) { - throw new OCSNotFoundException($this->l->t('Cannot increase permissions')); + $result = array_values(array_filter(array_map(function (IShare $share) { + $userFolder = $this->rootFolder->getUserFolder($share->getSharedBy()); + $node = $userFolder->getFirstNodeById($share->getNodeId()); + if (!$node) { + // fallback to guessing the path + $node = $userFolder->get($share->getTarget()); + if ($node === null || $share->getTarget() === '') { + return null; } } + + try { + $formattedShare = $this->formatShare($share, $node); + $formattedShare['path'] = '/' . $share->getNode()->getName(); + $formattedShare['permissions'] = 0; + return $formattedShare; + } catch (NotFoundException $e) { + return null; + } + }, $pendingShares), function ($entry) { + return $entry !== null; + })); + + return new DataResponse($result); + } + + /** + * Accept a share + * + * @param string $id ID of the share + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * @throws OCSNotFoundException Share not found + * @throws OCSException + * @throws OCSBadRequestException Share could not be accepted + * + * 200: Share accepted successfully + */ + #[NoAdminRequired] + public function acceptShare(string $id): DataResponse { + try { + $share = $this->getShareById($id); + } catch (ShareNotFound $e) { + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); } + if (!$this->canAccessShare($share)) { + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); + } try { - $share = $this->shareManager->updateShare($share); + $this->shareManager->acceptShare($share, $this->userId); + } catch (HintException $e) { + $code = $e->getCode() === 0 ? 403 : $e->getCode(); + throw new OCSException($e->getHint(), (int)$code); } catch (\Exception $e) { - throw new OCSBadRequestException($e->getMessage()); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new OCSBadRequestException('Failed to accept share.', $e); } - return new DataResponse($this->formatShare($share)); + return new DataResponse(); } /** - * @param \OCP\Share\IShare $share - * @return bool + * Does the user have read permission on the share + * + * @param IShare $share the share to check + * @param boolean $checkGroups check groups as well? + * @return boolean + * @throws NotFoundException + * + * @suppress PhanUndeclaredClassMethod */ - protected function canAccessShare(\OCP\Share\IShare $share, $checkGroups = true) { + protected function canAccessShare(IShare $share, bool $checkGroups = true): bool { // A file with permissions 0 can't be accessed by us. So Don't show it if ($share->getPermissions() === 0) { return false; } // Owner of the file and the sharer of the file can always get share - if ($share->getShareOwner() === $this->currentUser || - $share->getSharedBy() === $this->currentUser - ) { + if ($share->getShareOwner() === $this->userId + || $share->getSharedBy() === $this->userId) { return true; } - // If the share is shared with you (or a group you are a member of) - if ($share->getShareType() === \OCP\Share::SHARE_TYPE_USER && - $share->getSharedWith() === $this->currentUser - ) { + // If the share is shared with you, you can access it! + if ($share->getShareType() === IShare::TYPE_USER + && $share->getSharedWith() === $this->userId) { return true; } - if ($checkGroups && $share->getShareType() === \OCP\Share::SHARE_TYPE_GROUP) { + // Have reshare rights on the shared file/folder ? + // Does the currentUser have access to the shared file? + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $file = $userFolder->getFirstNodeById($share->getNodeId()); + if ($file && $this->shareProviderResharingRights($this->userId, $share, $file)) { + return true; + } + + // If in the recipient group, you can see the share + if ($checkGroups && $share->getShareType() === IShare::TYPE_GROUP) { $sharedWith = $this->groupManager->get($share->getSharedWith()); - $user = $this->userManager->get($this->currentUser); + $user = $this->userManager->get($this->userId); if ($user !== null && $sharedWith !== null && $sharedWith->inGroup($user)) { return true; } } - if ($share->getShareType() === \OCP\Share::SHARE_TYPE_CIRCLE) { + if ($share->getShareType() === IShare::TYPE_CIRCLE) { // TODO: have a sanity check like above? return true; } + if ($share->getShareType() === IShare::TYPE_ROOM) { + try { + return $this->getRoomShareHelper()->canAccessShare($share, $this->userId); + } catch (ContainerExceptionInterface $e) { + return false; + } + } + + if ($share->getShareType() === IShare::TYPE_DECK) { + try { + return $this->getDeckShareHelper()->canAccessShare($share, $this->userId); + } catch (ContainerExceptionInterface $e) { + return false; + } + } + + if ($share->getShareType() === IShare::TYPE_SCIENCEMESH) { + try { + return $this->getSciencemeshShareHelper()->canAccessShare($share, $this->userId); + } catch (ContainerExceptionInterface $e) { + return false; + } + } + + return false; + } + + /** + * Does the user have edit permission on the share + * + * @param IShare $share the share to check + * @return boolean + */ + protected function canEditShare(IShare $share): bool { + // A file with permissions 0 can't be accessed by us. So Don't show it + if ($share->getPermissions() === 0) { + return false; + } + + // The owner of the file and the creator of the share + // can always edit the share + if ($share->getShareOwner() === $this->userId + || $share->getSharedBy() === $this->userId + ) { + return true; + } + + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $file = $userFolder->getFirstNodeById($share->getNodeId()); + if ($file?->getMountPoint() instanceof IShareOwnerlessMount && $this->shareProviderResharingRights($this->userId, $share, $file)) { + return true; + } + + //! we do NOT support some kind of `admin` in groups. + //! You cannot edit shares shared to a group you're + //! a member of if you're not the share owner or the file owner! + + return false; + } + + /** + * Does the user have delete permission on the share + * + * @param IShare $share the share to check + * @return boolean + */ + protected function canDeleteShare(IShare $share): bool { + // A file with permissions 0 can't be accessed by us. So Don't show it + if ($share->getPermissions() === 0) { + return false; + } + + // if the user is the recipient, i can unshare + // the share with self + if ($share->getShareType() === IShare::TYPE_USER + && $share->getSharedWith() === $this->userId + ) { + return true; + } + + // The owner of the file and the creator of the share + // can always delete the share + if ($share->getShareOwner() === $this->userId + || $share->getSharedBy() === $this->userId + ) { + return true; + } + + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $file = $userFolder->getFirstNodeById($share->getNodeId()); + if ($file?->getMountPoint() instanceof IShareOwnerlessMount && $this->shareProviderResharingRights($this->userId, $share, $file)) { + return true; + } + + return false; + } + + /** + * Does the user have delete permission on the share + * This differs from the canDeleteShare function as it only + * remove the share for the current user. It does NOT + * completely delete the share but only the mount point. + * It can then be restored from the deleted shares section. + * + * @param IShare $share the share to check + * @return boolean + * + * @suppress PhanUndeclaredClassMethod + */ + protected function canDeleteShareFromSelf(IShare $share): bool { + if ($share->getShareType() !== IShare::TYPE_GROUP + && $share->getShareType() !== IShare::TYPE_ROOM + && $share->getShareType() !== IShare::TYPE_DECK + && $share->getShareType() !== IShare::TYPE_SCIENCEMESH + ) { + return false; + } + + if ($share->getShareOwner() === $this->userId + || $share->getSharedBy() === $this->userId + ) { + // Delete the whole share, not just for self + return false; + } + + // If in the recipient group, you can delete the share from self + if ($share->getShareType() === IShare::TYPE_GROUP) { + $sharedWith = $this->groupManager->get($share->getSharedWith()); + $user = $this->userManager->get($this->userId); + if ($user !== null && $sharedWith !== null && $sharedWith->inGroup($user)) { + return true; + } + } + + if ($share->getShareType() === IShare::TYPE_ROOM) { + try { + return $this->getRoomShareHelper()->canAccessShare($share, $this->userId); + } catch (ContainerExceptionInterface $e) { + return false; + } + } + + if ($share->getShareType() === IShare::TYPE_DECK) { + try { + return $this->getDeckShareHelper()->canAccessShare($share, $this->userId); + } catch (ContainerExceptionInterface $e) { + return false; + } + } + + if ($share->getShareType() === IShare::TYPE_SCIENCEMESH) { + try { + return $this->getSciencemeshShareHelper()->canAccessShare($share, $this->userId); + } catch (ContainerExceptionInterface $e) { + return false; + } + } + return false; } @@ -865,19 +1727,15 @@ class ShareAPIController extends OCSController { * @throws \Exception * @return \DateTime */ - private function parseDate($expireDate) { + private function parseDate(string $expireDate): \DateTime { try { - $date = new \DateTime($expireDate); + $date = new \DateTime(trim($expireDate, '"'), $this->dateTimeZone->getTimeZone()); + // Make sure it expires at midnight in owner timezone + $date->setTime(0, 0, 0); } catch (\Exception $e) { - throw new \Exception('Invalid date. Format must be YYYY-MM-DD'); - } - - if ($date === false) { - throw new \Exception('Invalid date. Format must be YYYY-MM-DD'); + throw new \Exception($this->l->t('Invalid date. Format must be YYYY-MM-DD')); } - $date->setTime(0, 0, 0); - return $date; } @@ -886,15 +1744,15 @@ class ShareAPIController extends OCSController { * not support this we need to check all backends. * * @param string $id - * @return \OCP\Share\IShare + * @return IShare * @throws ShareNotFound */ - private function getShareById($id) { + private function getShareById(string $id): IShare { $share = null; // First check if it is an internal share. try { - $share = $this->shareManager->getShareById('ocinternal:' . $id); + $share = $this->shareManager->getShareById('ocinternal:' . $id, $this->userId); return $share; } catch (ShareNotFound $e) { // Do nothing, just try the other share type @@ -902,8 +1760,33 @@ class ShareAPIController extends OCSController { try { - if ($this->shareManager->shareProviderExists(\OCP\Share::SHARE_TYPE_CIRCLE)) { - $share = $this->shareManager->getShareById('ocCircleShare:' . $id); + if ($this->shareManager->shareProviderExists(IShare::TYPE_CIRCLE)) { + $share = $this->shareManager->getShareById('ocCircleShare:' . $id, $this->userId); + return $share; + } + } catch (ShareNotFound $e) { + // Do nothing, just try the other share type + } + + try { + if ($this->shareManager->shareProviderExists(IShare::TYPE_EMAIL)) { + $share = $this->shareManager->getShareById('ocMailShare:' . $id, $this->userId); + return $share; + } + } catch (ShareNotFound $e) { + // Do nothing, just try the other share type + } + + try { + $share = $this->shareManager->getShareById('ocRoomShare:' . $id, $this->userId); + return $share; + } catch (ShareNotFound $e) { + // Do nothing, just try the other share type + } + + try { + if ($this->shareManager->shareProviderExists(IShare::TYPE_DECK)) { + $share = $this->shareManager->getShareById('deck:' . $id, $this->userId); return $share; } } catch (ShareNotFound $e) { @@ -911,8 +1794,8 @@ class ShareAPIController extends OCSController { } try { - if ($this->shareManager->shareProviderExists(\OCP\Share::SHARE_TYPE_EMAIL)) { - $share = $this->shareManager->getShareById('ocMailShare:' . $id); + if ($this->shareManager->shareProviderExists(IShare::TYPE_SCIENCEMESH)) { + $share = $this->shareManager->getShareById('sciencemesh:' . $id, $this->userId); return $share; } } catch (ShareNotFound $e) { @@ -922,7 +1805,7 @@ class ShareAPIController extends OCSController { if (!$this->shareManager->outgoingServer2ServerSharesAllowed()) { throw new ShareNotFound(); } - $share = $this->shareManager->getShareById('ocFederatedSharing:' . $id); + $share = $this->shareManager->getShareById('ocFederatedSharing:' . $id, $this->userId); return $share; } @@ -930,19 +1813,483 @@ class ShareAPIController extends OCSController { /** * Lock a Node * - * @param \OCP\Files\Node $node + * @param Node $node + * @throws LockedException */ - private function lock(\OCP\Files\Node $node) { + private function lock(Node $node) { $node->lock(ILockingProvider::LOCK_SHARED); $this->lockedNode = $node; } /** * Cleanup the remaining locks + * @throws LockedException */ public function cleanup() { if ($this->lockedNode !== null) { $this->lockedNode->unlock(ILockingProvider::LOCK_SHARED); } } + + /** + * Returns the helper of ShareAPIController for room shares. + * + * If the Talk application is not enabled or the helper is not available + * a ContainerExceptionInterface is thrown instead. + * + * @return \OCA\Talk\Share\Helper\ShareAPIController + * @throws ContainerExceptionInterface + */ + private function getRoomShareHelper() { + if (!$this->appManager->isEnabledForUser('spreed')) { + throw new QueryException(); + } + + return $this->serverContainer->get('\OCA\Talk\Share\Helper\ShareAPIController'); + } + + /** + * Returns the helper of ShareAPIHelper for deck shares. + * + * If the Deck application is not enabled or the helper is not available + * a ContainerExceptionInterface is thrown instead. + * + * @return ShareAPIHelper + * @throws ContainerExceptionInterface + */ + private function getDeckShareHelper() { + if (!$this->appManager->isEnabledForUser('deck')) { + throw new QueryException(); + } + + return $this->serverContainer->get('\OCA\Deck\Sharing\ShareAPIHelper'); + } + + /** + * Returns the helper of ShareAPIHelper for sciencemesh shares. + * + * If the sciencemesh application is not enabled or the helper is not available + * a ContainerExceptionInterface is thrown instead. + * + * @return ShareAPIHelper + * @throws ContainerExceptionInterface + */ + private function getSciencemeshShareHelper() { + if (!$this->appManager->isEnabledForUser('sciencemesh')) { + throw new QueryException(); + } + + return $this->serverContainer->get('\OCA\ScienceMesh\Sharing\ShareAPIHelper'); + } + + /** + * @param string $viewer + * @param Node $node + * @param bool $reShares + * + * @return IShare[] + */ + private function getSharesFromNode(string $viewer, $node, bool $reShares): array { + $providers = [ + IShare::TYPE_USER, + IShare::TYPE_GROUP, + IShare::TYPE_LINK, + IShare::TYPE_EMAIL, + IShare::TYPE_CIRCLE, + IShare::TYPE_ROOM, + IShare::TYPE_DECK, + IShare::TYPE_SCIENCEMESH + ]; + + // Should we assume that the (currentUser) viewer is the owner of the node !? + $shares = []; + foreach ($providers as $provider) { + if (!$this->shareManager->shareProviderExists($provider)) { + continue; + } + + $providerShares + = $this->shareManager->getSharesBy($viewer, $provider, $node, $reShares, -1, 0); + $shares = array_merge($shares, $providerShares); + } + + if ($this->shareManager->outgoingServer2ServerSharesAllowed()) { + $federatedShares = $this->shareManager->getSharesBy( + $this->userId, IShare::TYPE_REMOTE, $node, $reShares, -1, 0 + ); + $shares = array_merge($shares, $federatedShares); + } + + if ($this->shareManager->outgoingServer2ServerGroupSharesAllowed()) { + $federatedShares = $this->shareManager->getSharesBy( + $this->userId, IShare::TYPE_REMOTE_GROUP, $node, $reShares, -1, 0 + ); + $shares = array_merge($shares, $federatedShares); + } + + return $shares; + } + + + /** + * @param Node $node + * + * @throws SharingRightsException + */ + private function confirmSharingRights(Node $node): void { + if (!$this->hasResharingRights($this->userId, $node)) { + throw new SharingRightsException($this->l->t('No sharing rights on this item')); + } + } + + + /** + * @param string $viewer + * @param Node $node + * + * @return bool + */ + private function hasResharingRights($viewer, $node): bool { + if ($viewer === $node->getOwner()->getUID()) { + return true; + } + + foreach ([$node, $node->getParent()] as $node) { + $shares = $this->getSharesFromNode($viewer, $node, true); + foreach ($shares as $share) { + try { + if ($this->shareProviderResharingRights($viewer, $share, $node)) { + return true; + } + } catch (InvalidPathException|NotFoundException $e) { + } + } + } + + return false; + } + + + /** + * Returns if we can find resharing rights in an IShare object for a specific user. + * + * @suppress PhanUndeclaredClassMethod + * + * @param string $userId + * @param IShare $share + * @param Node $node + * + * @return bool + * @throws NotFoundException + * @throws InvalidPathException + */ + private function shareProviderResharingRights(string $userId, IShare $share, $node): bool { + if ($share->getShareOwner() === $userId) { + return true; + } + + // we check that current user have parent resharing rights on the current file + if ($node !== null && ($node->getPermissions() & Constants::PERMISSION_SHARE) !== 0) { + return true; + } + + if ((Constants::PERMISSION_SHARE & $share->getPermissions()) === 0) { + return false; + } + + if ($share->getShareType() === IShare::TYPE_USER && $share->getSharedWith() === $userId) { + return true; + } + + if ($share->getShareType() === IShare::TYPE_GROUP && $this->groupManager->isInGroup($userId, $share->getSharedWith())) { + return true; + } + + if ($share->getShareType() === IShare::TYPE_CIRCLE && Server::get(IAppManager::class)->isEnabledForUser('circles') + && class_exists('\OCA\Circles\Api\v1\Circles')) { + $hasCircleId = (str_ends_with($share->getSharedWith(), ']')); + $shareWithStart = ($hasCircleId ? strrpos($share->getSharedWith(), '[') + 1 : 0); + $shareWithLength = ($hasCircleId ? -1 : strpos($share->getSharedWith(), ' ')); + if ($shareWithLength === false) { + $sharedWith = substr($share->getSharedWith(), $shareWithStart); + } else { + $sharedWith = substr($share->getSharedWith(), $shareWithStart, $shareWithLength); + } + try { + $member = Circles::getMember($sharedWith, $userId, 1); + if ($member->getLevel() >= 4) { + return true; + } + return false; + } catch (ContainerExceptionInterface $e) { + return false; + } + } + + return false; + } + + /** + * Get all the shares for the current user + * + * @param Node|null $path + * @param boolean $reshares + * @return IShare[] + */ + private function getAllShares(?Node $path = null, bool $reshares = false) { + // Get all shares + $userShares = $this->shareManager->getSharesBy($this->userId, IShare::TYPE_USER, $path, $reshares, -1, 0); + $groupShares = $this->shareManager->getSharesBy($this->userId, IShare::TYPE_GROUP, $path, $reshares, -1, 0); + $linkShares = $this->shareManager->getSharesBy($this->userId, IShare::TYPE_LINK, $path, $reshares, -1, 0); + + // EMAIL SHARES + $mailShares = $this->shareManager->getSharesBy($this->userId, IShare::TYPE_EMAIL, $path, $reshares, -1, 0); + + // TEAM SHARES + $circleShares = $this->shareManager->getSharesBy($this->userId, IShare::TYPE_CIRCLE, $path, $reshares, -1, 0); + + // TALK SHARES + $roomShares = $this->shareManager->getSharesBy($this->userId, IShare::TYPE_ROOM, $path, $reshares, -1, 0); + + // DECK SHARES + $deckShares = $this->shareManager->getSharesBy($this->userId, IShare::TYPE_DECK, $path, $reshares, -1, 0); + + // SCIENCEMESH SHARES + $sciencemeshShares = $this->shareManager->getSharesBy($this->userId, IShare::TYPE_SCIENCEMESH, $path, $reshares, -1, 0); + + // FEDERATION + if ($this->shareManager->outgoingServer2ServerSharesAllowed()) { + $federatedShares = $this->shareManager->getSharesBy($this->userId, IShare::TYPE_REMOTE, $path, $reshares, -1, 0); + } else { + $federatedShares = []; + } + if ($this->shareManager->outgoingServer2ServerGroupSharesAllowed()) { + $federatedGroupShares = $this->shareManager->getSharesBy($this->userId, IShare::TYPE_REMOTE_GROUP, $path, $reshares, -1, 0); + } else { + $federatedGroupShares = []; + } + + return array_merge($userShares, $groupShares, $linkShares, $mailShares, $circleShares, $roomShares, $deckShares, $sciencemeshShares, $federatedShares, $federatedGroupShares); + } + + + /** + * merging already formatted shares. + * We'll make an associative array to easily detect duplicate Ids. + * Keys _needs_ to be removed after all shares are retrieved and merged. + * + * @param array $shares + * @param array $newShares + */ + private function mergeFormattedShares(array &$shares, array $newShares) { + foreach ($newShares as $newShare) { + if (!array_key_exists($newShare['id'], $shares)) { + $shares[$newShare['id']] = $newShare; + } + } + } + + /** + * @param IShare $share + * @param string|null $attributesString + * @return IShare modified share + */ + private function setShareAttributes(IShare $share, ?string $attributesString) { + $newShareAttributes = null; + if ($attributesString !== null) { + $newShareAttributes = $this->shareManager->newShare()->newAttributes(); + $formattedShareAttributes = \json_decode($attributesString, true); + if (is_array($formattedShareAttributes)) { + foreach ($formattedShareAttributes as $formattedAttr) { + $newShareAttributes->setAttribute( + $formattedAttr['scope'], + $formattedAttr['key'], + $formattedAttr['value'], + ); + } + } else { + throw new OCSBadRequestException($this->l->t('Invalid share attributes provided: "%s"', [$attributesString])); + } + } + $share->setAttributes($newShareAttributes); + + return $share; + } + + private function checkInheritedAttributes(IShare $share): void { + if (!$share->getSharedBy()) { + return; // Probably in a test + } + + $canDownload = false; + $hideDownload = true; + + $userFolder = $this->rootFolder->getUserFolder($share->getSharedBy()); + $nodes = $userFolder->getById($share->getNodeId()); + foreach ($nodes as $node) { + // Owner always can download it - so allow it and break + if ($node->getOwner()?->getUID() === $share->getSharedBy()) { + $canDownload = true; + $hideDownload = false; + break; + } + + if ($node->getStorage()->instanceOfStorage(SharedStorage::class)) { + $storage = $node->getStorage(); + if ($storage instanceof Wrapper) { + $storage = $storage->getInstanceOfStorage(SharedStorage::class); + if ($storage === null) { + throw new \RuntimeException('Should not happen, instanceOfStorage but getInstanceOfStorage return null'); + } + } else { + throw new \RuntimeException('Should not happen, instanceOfStorage but not a wrapper'); + } + + /** @var SharedStorage $storage */ + $originalShare = $storage->getShare(); + $inheritedAttributes = $originalShare->getAttributes(); + // hide if hidden and also the current share enforces hide (can only be false if one share is false or user is owner) + $hideDownload = $hideDownload && $originalShare->getHideDownload(); + // allow download if already allowed by previous share or when the current share allows downloading + $canDownload = $canDownload || $inheritedAttributes === null || $inheritedAttributes->getAttribute('permissions', 'download') !== false; + } elseif ($node->getStorage()->instanceOfStorage(Storage::class)) { + $canDownload = true; // in case of federation storage, we can expect the download to be activated by default + } + } + + if ($hideDownload || !$canDownload) { + $share->setHideDownload(true); + + if (!$canDownload) { + $attributes = $share->getAttributes() ?? $share->newAttributes(); + $attributes->setAttribute('permissions', 'download', false); + $share->setAttributes($attributes); + } + } + } + + /** + * Send a mail notification again for a share. + * The mail_send option must be enabled for the given share. + * @param string $id the share ID + * @param string $password the password to check against. Necessary for password protected shares. + * @throws OCSNotFoundException Share not found + * @throws OCSForbiddenException You are not allowed to send mail notifications + * @throws OCSBadRequestException Invalid request or wrong password + * @throws OCSException Error while sending mail notification + * @return DataResponse<Http::STATUS_OK, list<empty>, array{}> + * + * 200: The email notification was sent successfully + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 10, period: 600)] + public function sendShareEmail(string $id, $password = ''): DataResponse { + try { + $share = $this->getShareById($id); + + if (!$this->canAccessShare($share, false)) { + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); + } + + if (!$this->canEditShare($share)) { + throw new OCSForbiddenException($this->l->t('You are not allowed to send mail notifications')); + } + + // For mail and link shares, the user must be + // the owner of the share, not only the file owner. + if ($share->getShareType() === IShare::TYPE_EMAIL + || $share->getShareType() === IShare::TYPE_LINK) { + if ($share->getSharedBy() !== $this->userId) { + throw new OCSForbiddenException($this->l->t('You are not allowed to send mail notifications')); + } + } + + try { + $provider = $this->factory->getProviderForType($share->getShareType()); + if (!($provider instanceof IShareProviderWithNotification)) { + throw new OCSBadRequestException($this->l->t('No mail notification configured for this share type')); + } + + // Circumvent the password encrypted data by + // setting the password clear. We're not storing + // the password clear, it is just a temporary + // object manipulation. The password will stay + // encrypted in the database. + if ($share->getPassword() !== null && $share->getPassword() !== $password) { + if (!$this->shareManager->checkPassword($share, $password)) { + throw new OCSBadRequestException($this->l->t('Wrong password')); + } + $share = $share->setPassword($password); + } + + $provider->sendMailNotification($share); + return new DataResponse(); + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new OCSException($this->l->t('Error while sending mail notification')); + } + + } catch (ShareNotFound $e) { + throw new OCSNotFoundException($this->l->t('Wrong share ID, share does not exist')); + } + } + + /** + * Get a unique share token + * + * @throws OCSException Failed to generate a unique token + * + * @return DataResponse<Http::STATUS_OK, array{token: string}, array{}> + * + * 200: Token generated successfully + */ + #[ApiRoute(verb: 'GET', url: '/api/v1/token')] + #[NoAdminRequired] + public function generateToken(): DataResponse { + try { + $token = $this->shareManager->generateToken(); + return new DataResponse([ + 'token' => $token, + ]); + } catch (ShareTokenException $e) { + throw new OCSException($this->l->t('Failed to generate a unique token')); + } + } + + /** + * Populate the result set with file tags + * + * @psalm-template T of array{tags?: list<string>, file_source: int, ...array<string, mixed>} + * @param list<T> $fileList + * @return list<T> file list populated with tags + */ + private function populateTags(array $fileList): array { + $tagger = $this->tagManager->load('files'); + $tags = $tagger->getTagsForObjects(array_map(static fn (array $fileData) => $fileData['file_source'], $fileList)); + + if (!is_array($tags)) { + throw new \UnexpectedValueException('$tags must be an array'); + } + + // Set empty tag array + foreach ($fileList as &$fileData) { + $fileData['tags'] = []; + } + unset($fileData); + + if (!empty($tags)) { + foreach ($tags as $fileId => $fileTags) { + foreach ($fileList as &$fileData) { + if ($fileId !== $fileData['file_source']) { + continue; + } + + $fileData['tags'] = $fileTags; + } + unset($fileData); + } + } + + return $fileList; + } } diff --git a/apps/files_sharing/lib/Controller/ShareController.php b/apps/files_sharing/lib/Controller/ShareController.php index c51bc1a75dd..5a776379fce 100644 --- a/apps/files_sharing/lib/Controller/ShareController.php +++ b/apps/files_sharing/lib/Controller/ShareController.php @@ -1,239 +1,228 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Maxence Lange <maxence@pontapreta.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Piotr Filiciak <piotr@filiciak.pl> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sascha Sambale <mastixmc@gmail.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @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 <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\Files_Sharing\Controller; -use OC\Files\Node\Folder; -use OC_Files; -use OC_Util; +use OC\Security\CSP\ContentSecurityPolicy; +use OCA\DAV\Connector\Sabre\PublicAuth; use OCA\FederatedFileSharing\FederatedShareProvider; +use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; +use OCA\Files_Sharing\Event\ShareLinkAccessedEvent; +use OCP\Accounts\IAccountManager; +use OCP\AppFramework\AuthPublicShareController; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TemplateResponse; +use OCP\Constants; use OCP\Defaults; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\HintException; +use OCP\IConfig; use OCP\IL10N; -use OCP\Template; -use OCP\Share; -use OCP\AppFramework\Controller; +use OCP\IPreview; use OCP\IRequest; -use OCP\AppFramework\Http\TemplateResponse; -use OCP\AppFramework\Http\RedirectResponse; -use OCP\AppFramework\Http\NotFoundResponse; +use OCP\ISession; use OCP\IURLGenerator; -use OCP\IConfig; -use OCP\ILogger; use OCP\IUserManager; -use OCP\ISession; -use OCP\IPreview; -use OCA\Files_Sharing\Activity\Providers\Downloads; -use \OCP\Files\NotFoundException; -use OCP\Files\IRootFolder; +use OCP\Security\Events\GenerateSecurePasswordEvent; +use OCP\Security\ISecureRandom; +use OCP\Security\PasswordContext; +use OCP\Share; use OCP\Share\Exceptions\ShareNotFound; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use OCP\Share\IManager as ShareManager; +use OCP\Share\IPublicShareTemplateFactory; +use OCP\Share\IShare; /** - * Class ShareController - * * @package OCA\Files_Sharing\Controllers */ -class ShareController extends Controller { - - /** @var IConfig */ - protected $config; - /** @var IURLGenerator */ - protected $urlGenerator; - /** @var IUserManager */ - protected $userManager; - /** @var ILogger */ - protected $logger; - /** @var \OCP\Activity\IManager */ - protected $activityManager; - /** @var \OCP\Share\IManager */ - protected $shareManager; - /** @var ISession */ - protected $session; - /** @var IPreview */ - protected $previewManager; - /** @var IRootFolder */ - protected $rootFolder; - /** @var FederatedShareProvider */ - protected $federatedShareProvider; - /** @var EventDispatcherInterface */ - protected $eventDispatcher; - /** @var IL10N */ - protected $l10n; - /** @var Defaults */ - protected $defaults; +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] +class ShareController extends AuthPublicShareController { + protected ?IShare $share = null; + + public const SHARE_ACCESS = 'access'; + public const SHARE_AUTH = 'auth'; + public const SHARE_DOWNLOAD = 'download'; + + public function __construct( + string $appName, + IRequest $request, + protected IConfig $config, + IURLGenerator $urlGenerator, + protected IUserManager $userManager, + protected \OCP\Activity\IManager $activityManager, + protected ShareManager $shareManager, + ISession $session, + protected IPreview $previewManager, + protected IRootFolder $rootFolder, + protected FederatedShareProvider $federatedShareProvider, + protected IAccountManager $accountManager, + protected IEventDispatcher $eventDispatcher, + protected IL10N $l10n, + protected ISecureRandom $secureRandom, + protected Defaults $defaults, + private IPublicShareTemplateFactory $publicShareTemplateFactory, + ) { + parent::__construct($appName, $request, $session, $urlGenerator); + } /** - * @param string $appName - * @param IRequest $request - * @param IConfig $config - * @param IURLGenerator $urlGenerator - * @param IUserManager $userManager - * @param ILogger $logger - * @param \OCP\Activity\IManager $activityManager - * @param \OCP\Share\IManager $shareManager - * @param ISession $session - * @param IPreview $previewManager - * @param IRootFolder $rootFolder - * @param FederatedShareProvider $federatedShareProvider - * @param EventDispatcherInterface $eventDispatcher - * @param IL10N $l10n - * @param Defaults $defaults + * Show the authentication page + * The form has to submit to the authenticate method route */ - public function __construct($appName, - IRequest $request, - IConfig $config, - IURLGenerator $urlGenerator, - IUserManager $userManager, - ILogger $logger, - \OCP\Activity\IManager $activityManager, - \OCP\Share\IManager $shareManager, - ISession $session, - IPreview $previewManager, - IRootFolder $rootFolder, - FederatedShareProvider $federatedShareProvider, - EventDispatcherInterface $eventDispatcher, - IL10N $l10n, - Defaults $defaults) { - parent::__construct($appName, $request); - - $this->config = $config; - $this->urlGenerator = $urlGenerator; - $this->userManager = $userManager; - $this->logger = $logger; - $this->activityManager = $activityManager; - $this->shareManager = $shareManager; - $this->session = $session; - $this->previewManager = $previewManager; - $this->rootFolder = $rootFolder; - $this->federatedShareProvider = $federatedShareProvider; - $this->eventDispatcher = $eventDispatcher; - $this->l10n = $l10n; - $this->defaults = $defaults; + #[PublicPage] + #[NoCSRFRequired] + public function showAuthenticate(): TemplateResponse { + $templateParameters = ['share' => $this->share]; + + $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($this->share, BeforeTemplateRenderedEvent::SCOPE_PUBLIC_SHARE_AUTH)); + + $response = new TemplateResponse('core', 'publicshareauth', $templateParameters, 'guest'); + if ($this->share->getSendPasswordByTalk()) { + $csp = new ContentSecurityPolicy(); + $csp->addAllowedConnectDomain('*'); + $csp->addAllowedMediaDomain('blob:'); + $response->setContentSecurityPolicy($csp); + } + + return $response; } /** - * @PublicPage - * @NoCSRFRequired - * - * @param string $token - * @return TemplateResponse|RedirectResponse + * The template to show when authentication failed */ - public function showAuthenticate($token) { - $share = $this->shareManager->getShareByToken($token); + protected function showAuthFailed(): TemplateResponse { + $templateParameters = ['share' => $this->share, 'wrongpw' => true]; + + $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($this->share, BeforeTemplateRenderedEvent::SCOPE_PUBLIC_SHARE_AUTH)); - if($this->linkShareAuth($share)) { - return new RedirectResponse($this->urlGenerator->linkToRoute('files_sharing.sharecontroller.showShare', array('token' => $token))); + $response = new TemplateResponse('core', 'publicshareauth', $templateParameters, 'guest'); + if ($this->share->getSendPasswordByTalk()) { + $csp = new ContentSecurityPolicy(); + $csp->addAllowedConnectDomain('*'); + $csp->addAllowedMediaDomain('blob:'); + $response->setContentSecurityPolicy($csp); } - return new TemplateResponse($this->appName, 'authenticate', array(), 'guest'); + return $response; } /** - * @PublicPage - * @UseSession - * @BruteForceProtection(action=publicLinkAuth) - * - * Authenticates against password-protected shares - * @param string $token - * @param string $password - * @return RedirectResponse|TemplateResponse|NotFoundResponse + * The template to show after user identification */ - public function authenticate($token, $password = '') { + protected function showIdentificationResult(bool $success = false): TemplateResponse { + $templateParameters = ['share' => $this->share, 'identityOk' => $success]; - // Check whether share exists - try { - $share = $this->shareManager->getShareByToken($token); - } catch (ShareNotFound $e) { - return new NotFoundResponse(); - } + $this->eventDispatcher->dispatchTyped(new BeforeTemplateRenderedEvent($this->share, BeforeTemplateRenderedEvent::SCOPE_PUBLIC_SHARE_AUTH)); - $authenticate = $this->linkShareAuth($share, $password); - - if($authenticate === true) { - return new RedirectResponse($this->urlGenerator->linkToRoute('files_sharing.sharecontroller.showShare', array('token' => $token))); + $response = new TemplateResponse('core', 'publicshareauth', $templateParameters, 'guest'); + if ($this->share->getSendPasswordByTalk()) { + $csp = new ContentSecurityPolicy(); + $csp->addAllowedConnectDomain('*'); + $csp->addAllowedMediaDomain('blob:'); + $response->setContentSecurityPolicy($csp); } - $response = new TemplateResponse($this->appName, 'authenticate', array('wrongpw' => true), 'guest'); - $response->throttle(); return $response; } /** - * Authenticate a link item with the given password. - * Or use the session if no password is provided. - * - * This is a modified version of Helper::authenticate - * TODO: Try to merge back eventually with Helper::authenticate + * Validate the identity token of a public share * - * @param \OCP\Share\IShare $share - * @param string|null $password + * @param ?string $identityToken * @return bool */ - private function linkShareAuth(\OCP\Share\IShare $share, $password = null) { - if ($password !== null) { - if ($this->shareManager->checkPassword($share, $password)) { - $this->session->set('public_link_authenticated', (string)$share->getId()); - } else { - $this->emitAccessShareHook($share, 403, 'Wrong password'); - return false; - } - } else { - // not authenticated ? - if ( ! $this->session->exists('public_link_authenticated') - || $this->session->get('public_link_authenticated') !== (string)$share->getId()) { - return false; - } + protected function validateIdentity(?string $identityToken = null): bool { + if ($this->share->getShareType() !== IShare::TYPE_EMAIL) { + return false; } + + if ($identityToken === null || $this->share->getSharedWith() === null) { + return false; + } + + return $identityToken === $this->share->getSharedWith(); + } + + /** + * Generates a password for the share, respecting any password policy defined + */ + protected function generatePassword(): void { + $event = new GenerateSecurePasswordEvent(PasswordContext::SHARING); + $this->eventDispatcher->dispatchTyped($event); + $password = $event->getPassword() ?? $this->secureRandom->generate(20); + + $this->share->setPassword($password); + $this->shareManager->updateShare($this->share); + } + + protected function verifyPassword(string $password): bool { + return $this->shareManager->checkPassword($this->share, $password); + } + + protected function getPasswordHash(): ?string { + return $this->share->getPassword(); + } + + public function isValidToken(): bool { + try { + $this->share = $this->shareManager->getShareByToken($this->getToken()); + } catch (ShareNotFound $e) { + return false; + } + return true; } + protected function isPasswordProtected(): bool { + return $this->share->getPassword() !== null; + } + + protected function authSucceeded() { + if ($this->share === null) { + throw new NotFoundException(); + } + + // For share this was always set so it is still used in other apps + $this->session->set(PublicAuth::DAV_AUTHENTICATED, $this->share->getId()); + } + + protected function authFailed() { + $this->emitAccessShareHook($this->share, 403, 'Wrong password'); + $this->emitShareAccessEvent($this->share, self::SHARE_AUTH, 403, 'Wrong password'); + } + /** * throws hooks when a share is attempted to be accessed * - * @param \OCP\Share\IShare|string $share the Share instance if available, - * otherwise token + * @param IShare|string $share the Share instance if available, + * otherwise token * @param int $errorCode * @param string $errorMessage - * @throws \OC\HintException + * + * @throws HintException * @throws \OC\ServerNotAvailableException + * + * @deprecated use OCP\Files_Sharing\Event\ShareLinkAccessedEvent */ - protected function emitAccessShareHook($share, $errorCode = 200, $errorMessage = '') { + protected function emitAccessShareHook($share, int $errorCode = 200, string $errorMessage = '') { $itemType = $itemSource = $uidOwner = ''; $token = $share; $exception = null; - if($share instanceof \OCP\Share\IShare) { + if ($share instanceof IShare) { try { $token = $share->getToken(); $uidOwner = $share->getSharedBy(); @@ -244,250 +233,143 @@ class ShareController extends Controller { $exception = $e; } } - \OC_Hook::emit('OCP\Share', 'share_link_access', [ + + \OC_Hook::emit(Share::class, 'share_link_access', [ 'itemType' => $itemType, 'itemSource' => $itemSource, 'uidOwner' => $uidOwner, 'token' => $token, 'errorCode' => $errorCode, - 'errorMessage' => $errorMessage, + 'errorMessage' => $errorMessage ]); - if(!is_null($exception)) { + + if (!is_null($exception)) { throw $exception; } } /** + * Emit a ShareLinkAccessedEvent event when a share is accessed, downloaded, auth... + */ + protected function emitShareAccessEvent(IShare $share, string $step = '', int $errorCode = 200, string $errorMessage = ''): void { + if ($step !== self::SHARE_ACCESS + && $step !== self::SHARE_AUTH + && $step !== self::SHARE_DOWNLOAD) { + return; + } + $this->eventDispatcher->dispatchTyped(new ShareLinkAccessedEvent($share, $step, $errorCode, $errorMessage)); + } + + /** * Validate the permissions of the share * * @param Share\IShare $share * @return bool */ - private function validateShare(\OCP\Share\IShare $share) { + private function validateShare(IShare $share) { + // If the owner is disabled no access to the link is granted + $owner = $this->userManager->get($share->getShareOwner()); + if ($owner === null || !$owner->isEnabled()) { + return false; + } + + // If the initiator of the share is disabled no access is granted + $initiator = $this->userManager->get($share->getSharedBy()); + if ($initiator === null || !$initiator->isEnabled()) { + return false; + } + return $share->getNode()->isReadable() && $share->getNode()->isShareable(); } /** - * @PublicPage - * @NoCSRFRequired - * - * @param string $token * @param string $path - * @return TemplateResponse|RedirectResponse|NotFoundResponse + * @return TemplateResponse * @throws NotFoundException * @throws \Exception */ - public function showShare($token, $path = '') { + #[PublicPage] + #[NoCSRFRequired] + public function showShare($path = ''): TemplateResponse { \OC_User::setIncognitoMode(true); // Check whether share exists try { - $share = $this->shareManager->getShareByToken($token); + $share = $this->shareManager->getShareByToken($this->getToken()); } catch (ShareNotFound $e) { - $this->emitAccessShareHook($token, 404, 'Share not found'); - return new NotFoundResponse(); + // The share does not exists, we do not emit an ShareLinkAccessedEvent + $this->emitAccessShareHook($this->getToken(), 404, 'Share not found'); + throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); } - // Share is password protected - check whether the user is permitted to access the share - if ($share->getPassword() !== null && !$this->linkShareAuth($share)) { - return new RedirectResponse($this->urlGenerator->linkToRoute('files_sharing.sharecontroller.authenticate', - array('token' => $token))); + if (!$this->validateShare($share)) { + throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); } - if (!$this->validateShare($share)) { - throw new NotFoundException(); + $shareNode = $share->getNode(); + + try { + $templateProvider = $this->publicShareTemplateFactory->getProvider($share); + $response = $templateProvider->renderPage($share, $this->getToken(), $path); + } catch (NotFoundException $e) { + $this->emitAccessShareHook($share, 404, 'Share not found'); + $this->emitShareAccessEvent($share, ShareController::SHARE_ACCESS, 404, 'Share not found'); + throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); } + // We can't get the path of a file share try { - if ($share->getNode() instanceof \OCP\Files\File && $path !== '') { + if ($shareNode instanceof File && $path !== '') { $this->emitAccessShareHook($share, 404, 'Share not found'); - throw new NotFoundException(); + $this->emitShareAccessEvent($share, self::SHARE_ACCESS, 404, 'Share not found'); + throw new NotFoundException($this->l10n->t('This share does not exist or is no longer available')); } } catch (\Exception $e) { $this->emitAccessShareHook($share, 404, 'Share not found'); + $this->emitShareAccessEvent($share, self::SHARE_ACCESS, 404, 'Share not found'); throw $e; } - $shareTmpl = []; - $shareTmpl['displayName'] = $this->userManager->get($share->getShareOwner())->getDisplayName(); - $shareTmpl['owner'] = $share->getShareOwner(); - $shareTmpl['filename'] = $share->getNode()->getName(); - $shareTmpl['directory_path'] = $share->getTarget(); - $shareTmpl['mimetype'] = $share->getNode()->getMimetype(); - $shareTmpl['previewSupported'] = $this->previewManager->isMimeSupported($share->getNode()->getMimetype()); - $shareTmpl['dirToken'] = $token; - $shareTmpl['sharingToken'] = $token; - $shareTmpl['server2serversharing'] = $this->federatedShareProvider->isOutgoingServer2serverShareEnabled(); - $shareTmpl['protected'] = $share->getPassword() !== null ? 'true' : 'false'; - $shareTmpl['dir'] = ''; - $shareTmpl['nonHumanFileSize'] = $share->getNode()->getSize(); - $shareTmpl['fileSize'] = \OCP\Util::humanFileSize($share->getNode()->getSize()); - - // Show file list - $hideFileList = false; - if ($share->getNode() instanceof \OCP\Files\Folder) { - /** @var \OCP\Files\Folder $rootFolder */ - $rootFolder = $share->getNode(); - - try { - $folderNode = $rootFolder->get($path); - } catch (\OCP\Files\NotFoundException $e) { - $this->emitAccessShareHook($share, 404, 'Share not found'); - throw new NotFoundException(); - } - - $shareTmpl['dir'] = $rootFolder->getRelativePath($folderNode->getPath()); - - /* - * The OC_Util methods require a view. This just uses the node API - */ - $freeSpace = $share->getNode()->getStorage()->free_space($share->getNode()->getInternalPath()); - if ($freeSpace < \OCP\Files\FileInfo::SPACE_UNLIMITED) { - $freeSpace = max($freeSpace, 0); - } else { - $freeSpace = (INF > 0) ? INF: PHP_INT_MAX; // work around https://bugs.php.net/bug.php?id=69188 - } - - $hideFileList = $share->getPermissions() & \OCP\Constants::PERMISSION_READ ? false : true; - $maxUploadFilesize = $freeSpace; - - $folder = new Template('files', 'list', ''); - $folder->assign('dir', $rootFolder->getRelativePath($folderNode->getPath())); - $folder->assign('dirToken', $token); - $folder->assign('permissions', \OCP\Constants::PERMISSION_READ); - $folder->assign('isPublic', true); - $folder->assign('hideFileList', $hideFileList); - $folder->assign('publicUploadEnabled', 'no'); - $folder->assign('uploadMaxFilesize', $maxUploadFilesize); - $folder->assign('uploadMaxHumanFilesize', \OCP\Util::humanFileSize($maxUploadFilesize)); - $folder->assign('freeSpace', $freeSpace); - $folder->assign('usedSpacePercent', 0); - $folder->assign('trash', false); - $shareTmpl['folder'] = $folder->fetchPage(); - } - - $shareTmpl['hideFileList'] = $hideFileList; - $shareTmpl['shareOwner'] = $this->userManager->get($share->getShareOwner())->getDisplayName(); - $shareTmpl['downloadURL'] = $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.downloadShare', ['token' => $token]); - $shareTmpl['shareUrl'] = $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', ['token' => $token]); - $shareTmpl['maxSizeAnimateGif'] = $this->config->getSystemValue('max_filesize_animated_gifs_public_sharing', 10); - $shareTmpl['previewEnabled'] = $this->config->getSystemValue('enable_previews', true); - $shareTmpl['previewMaxX'] = $this->config->getSystemValue('preview_max_x', 1024); - $shareTmpl['previewMaxY'] = $this->config->getSystemValue('preview_max_y', 1024); - $shareTmpl['disclaimer'] = $this->config->getAppValue('core', 'shareapi_public_link_disclaimertext', null); - $shareTmpl['previewURL'] = $shareTmpl['downloadURL']; - $ogPreview = ''; - if ($shareTmpl['previewSupported']) { - $shareTmpl['previewImage'] = $this->urlGenerator->linkToRouteAbsolute( 'files_sharing.PublicPreview.getPreview', - ['x' => 200, 'y' => 200, 'file' => $shareTmpl['directory_path'], 't' => $shareTmpl['dirToken']]); - $ogPreview = $shareTmpl['previewImage']; - - // We just have direct previews for image files - if ($share->getNode()->getMimePart() === 'image') { - $shareTmpl['previewURL'] = $this->urlGenerator->linkToRouteAbsolute('files_sharing.publicpreview.directLink', ['token' => $token]); - $ogPreview = $shareTmpl['previewURL']; - } - } else { - $shareTmpl['previewImage'] = $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'favicon-fb.png')); - $ogPreview = $shareTmpl['previewImage']; - } - - // Load files we need - \OCP\Util::addScript('files', 'file-upload'); - \OCP\Util::addStyle('files_sharing', 'publicView'); - \OCP\Util::addScript('files_sharing', 'public'); - \OCP\Util::addScript('files', 'fileactions'); - \OCP\Util::addScript('files', 'fileactionsmenu'); - \OCP\Util::addScript('files', 'jquery.fileupload'); - \OCP\Util::addScript('files_sharing', 'files_drop'); - - if (isset($shareTmpl['folder'])) { - // JS required for folders - \OCP\Util::addStyle('files', 'merged'); - \OCP\Util::addScript('files', 'filesummary'); - \OCP\Util::addScript('files', 'breadcrumb'); - \OCP\Util::addScript('files', 'fileinfomodel'); - \OCP\Util::addScript('files', 'newfilemenu'); - \OCP\Util::addScript('files', 'files'); - \OCP\Util::addScript('files', 'filelist'); - \OCP\Util::addScript('files', 'keyboardshortcuts'); - } - - // OpenGraph Support: http://ogp.me/ - \OCP\Util::addHeader('meta', ['property' => "og:title", 'content' => $shareTmpl['filename']]); - \OCP\Util::addHeader('meta', ['property' => "og:description", 'content' => $this->defaults->getName() . ($this->defaults->getSlogan() !== '' ? ' - ' . $this->defaults->getSlogan() : '')]); - \OCP\Util::addHeader('meta', ['property' => "og:site_name", 'content' => $this->defaults->getName()]); - \OCP\Util::addHeader('meta', ['property' => "og:url", 'content' => $shareTmpl['shareUrl']]); - \OCP\Util::addHeader('meta', ['property' => "og:type", 'content' => "object"]); - \OCP\Util::addHeader('meta', ['property' => "og:image", 'content' => $ogPreview]); - - $this->eventDispatcher->dispatch('OCA\Files_Sharing::loadAdditionalScripts'); - - $csp = new \OCP\AppFramework\Http\ContentSecurityPolicy(); - $csp->addAllowedFrameDomain('\'self\''); - $response = new TemplateResponse($this->appName, 'public', $shareTmpl, 'base'); - $response->setContentSecurityPolicy($csp); $this->emitAccessShareHook($share); + $this->emitShareAccessEvent($share, self::SHARE_ACCESS); return $response; } /** - * @PublicPage - * @NoCSRFRequired + * @NoSameSiteCookieRequired * * @param string $token - * @param string $files + * @param string|null $files * @param string $path - * @param string $downloadStartSecret - * @return void|\OCP\AppFramework\Http\Response + * @return void|Response * @throws NotFoundException + * @deprecated 31.0.0 Users are encouraged to use the DAV endpoint */ - public function downloadShare($token, $files = null, $path = '', $downloadStartSecret = '') { + #[PublicPage] + #[NoCSRFRequired] + public function downloadShare($token, $files = null, $path = '') { \OC_User::setIncognitoMode(true); $share = $this->shareManager->getShareByToken($token); - if(!($share->getPermissions() & \OCP\Constants::PERMISSION_READ)) { - return new \OCP\AppFramework\Http\DataResponse('Share is read-only'); - } - - // Share is password protected - check whether the user is permitted to access the share - if ($share->getPassword() !== null && !$this->linkShareAuth($share)) { - return new RedirectResponse($this->urlGenerator->linkToRoute('files_sharing.sharecontroller.authenticate', - ['token' => $token])); + if (!($share->getPermissions() & Constants::PERMISSION_READ)) { + return new DataResponse('Share has no read permission'); } - $files_list = null; - if (!is_null($files)) { // download selected files - $files_list = json_decode($files); - // in case we get only a single file - if ($files_list === null) { - $files_list = [$files]; - } - // Just in case $files is a single int like '1234' - if (!is_array($files_list)) { - $files_list = [$files_list]; - } + $attributes = $share->getAttributes(); + if ($attributes?->getAttribute('permissions', 'download') === false) { + return new DataResponse('Share has no download permission'); } - $userFolder = $this->rootFolder->getUserFolder($share->getShareOwner()); - $originalSharePath = $userFolder->getRelativePath($share->getNode()->getPath()); - if (!$this->validateShare($share)) { throw new NotFoundException(); } - // Single file share - if ($share->getNode() instanceof \OCP\Files\File) { - // Single file download - $this->singleFileDownloaded($share, $share->getNode()); - } - // Directory share - else { - /** @var \OCP\Files\Folder $node */ - $node = $share->getNode(); + $node = $share->getNode(); + if ($node instanceof Folder) { + // Directory share // Try to get the path if ($path !== '') { @@ -495,144 +377,27 @@ class ShareController extends Controller { $node = $node->get($path); } catch (NotFoundException $e) { $this->emitAccessShareHook($share, 404, 'Share not found'); + $this->emitShareAccessEvent($share, self::SHARE_DOWNLOAD, 404, 'Share not found'); return new NotFoundResponse(); } } - $originalSharePath = $userFolder->getRelativePath($node->getPath()); - - if ($node instanceof \OCP\Files\File) { - // Single file download - $this->singleFileDownloaded($share, $share->getNode()); - } else if (!empty($files_list)) { - $this->fileListDownloaded($share, $files_list, $node); - } else { - // The folder is downloaded - $this->singleFileDownloaded($share, $share->getNode()); + if ($node instanceof Folder) { + if ($files === null || $files === '') { + if ($share->getHideDownload()) { + throw new NotFoundException('Downloading a folder'); + } + } } } - /* FIXME: We should do this all nicely in OCP */ - OC_Util::tearDownFS(); - OC_Util::setupFS($share->getShareOwner()); - - /** - * this sets a cookie to be able to recognize the start of the download - * the content must not be longer than 32 characters and must only contain - * alphanumeric characters - */ - if (!empty($downloadStartSecret) - && !isset($downloadStartSecret[32]) - && preg_match('!^[a-zA-Z0-9]+$!', $downloadStartSecret) === 1) { - - // FIXME: set on the response once we use an actual app framework response - setcookie('ocDownloadStarted', $downloadStartSecret, time() + 20, '/'); - } - $this->emitAccessShareHook($share); + $this->emitShareAccessEvent($share, self::SHARE_DOWNLOAD); - $server_params = array( 'head' => $this->request->getMethod() === 'HEAD' ); - - /** - * Http range requests support - */ - if (isset($_SERVER['HTTP_RANGE'])) { - $server_params['range'] = $this->request->getHeader('Range'); - } - - // download selected files - if (!is_null($files) && $files !== '') { - // FIXME: The exit is required here because otherwise the AppFramework is trying to add headers as well - // after dispatching the request which results in a "Cannot modify header information" notice. - OC_Files::get($originalSharePath, $files_list, $server_params); - exit(); - } else { - // FIXME: The exit is required here because otherwise the AppFramework is trying to add headers as well - // after dispatching the request which results in a "Cannot modify header information" notice. - OC_Files::get(dirname($originalSharePath), basename($originalSharePath), $server_params); - exit(); - } - } - - /** - * create activity for every downloaded file - * - * @param Share\IShare $share - * @param array $files_list - * @param \OCP\Files\Folder $node - */ - protected function fileListDownloaded(Share\IShare $share, array $files_list, \OCP\Files\Folder $node) { - foreach ($files_list as $file) { - $subNode = $node->get($file); - $this->singleFileDownloaded($share, $subNode); - } - - } - - /** - * create activity if a single file was downloaded from a link share - * - * @param Share\IShare $share - */ - protected function singleFileDownloaded(Share\IShare $share, \OCP\Files\Node $node) { - - $fileId = $node->getId(); - - $userFolder = $this->rootFolder->getUserFolder($share->getSharedBy()); - $userNodeList = $userFolder->getById($fileId); - $userNode = $userNodeList[0]; - $ownerFolder = $this->rootFolder->getUserFolder($share->getShareOwner()); - $userPath = $userFolder->getRelativePath($userNode->getPath()); - $ownerPath = $ownerFolder->getRelativePath($node->getPath()); - - $parameters = [$userPath]; - - if ($share->getShareType() === \OCP\Share::SHARE_TYPE_EMAIL) { - if ($node instanceof \OCP\Files\File) { - $subject = Downloads::SUBJECT_SHARED_FILE_BY_EMAIL_DOWNLOADED; - } else { - $subject = Downloads::SUBJECT_SHARED_FOLDER_BY_EMAIL_DOWNLOADED; - } - $parameters[] = $share->getSharedWith(); - } else { - if ($node instanceof \OCP\Files\File) { - $subject = Downloads::SUBJECT_PUBLIC_SHARED_FILE_DOWNLOADED; - } else { - $subject = Downloads::SUBJECT_PUBLIC_SHARED_FOLDER_DOWNLOADED; - } - } - - $this->publishActivity($subject, $parameters, $share->getSharedBy(), $fileId, $userPath); - - if ($share->getShareOwner() !== $share->getSharedBy()) { - $parameters[0] = $ownerPath; - $this->publishActivity($subject, $parameters, $share->getShareOwner(), $fileId, $ownerPath); + $davUrl = '/public.php/dav/files/' . $token . '/?accept=zip'; + if ($files !== null) { + $davUrl .= '&files=' . $files; } + return new RedirectResponse($this->urlGenerator->getAbsoluteURL($davUrl)); } - - /** - * publish activity - * - * @param string $subject - * @param array $parameters - * @param string $affectedUser - * @param int $fileId - * @param string $filePath - */ - protected function publishActivity($subject, - array $parameters, - $affectedUser, - $fileId, - $filePath) { - - $event = $this->activityManager->generateEvent(); - $event->setApp('files_sharing') - ->setType('public_links') - ->setSubject($subject, $parameters) - ->setAffectedUser($affectedUser) - ->setObject('files', $fileId, $filePath); - $this->activityManager->publish($event); - } - - } diff --git a/apps/files_sharing/lib/Controller/ShareInfoController.php b/apps/files_sharing/lib/Controller/ShareInfoController.php index 28bfcd12c24..b7e79aec830 100644 --- a/apps/files_sharing/lib/Controller/ShareInfoController.php +++ b/apps/files_sharing/lib/Controller/ShareInfoController.php @@ -1,46 +1,33 @@ <?php + /** - * - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Sharing\Controller; use OCA\Files_External\NotFoundException; +use OCA\Files_Sharing\ResponseDefinitions; use OCP\AppFramework\ApiController; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\BruteForceProtection; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\JSONResponse; use OCP\Constants; use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\Node; -use OCP\ILogger; use OCP\IRequest; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; +/** + * @psalm-import-type Files_SharingShareInfo from ResponseDefinitions + */ class ShareInfoController extends ApiController { - /** @var IManager */ - private $shareManager; - /** * ShareInfoController constructor. * @@ -48,82 +35,107 @@ class ShareInfoController extends ApiController { * @param IRequest $request * @param IManager $shareManager */ - public function __construct($appName, - IRequest $request, - IManager $shareManager) { + public function __construct( + string $appName, + IRequest $request, + private IManager $shareManager, + ) { parent::__construct($appName, $request); - - $this->shareManager = $shareManager; } /** - * @PublicPage - * @NoCSRFRequired + * Get the info about a share + * + * @param string $t Token of the share + * @param string|null $password Password of the share + * @param string|null $dir Subdirectory to get info about + * @param int $depth Maximum depth to get info about + * @return JSONResponse<Http::STATUS_OK, Files_SharingShareInfo, array{}>|JSONResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}> * - * @param string $t - * @param null $password - * @param null $dir - * @return JSONResponse - * @throws ShareNotFound + * 200: Share info returned + * 403: Getting share info is not allowed + * 404: Share not found */ - public function info($t, $password = null, $dir = null) { + #[PublicPage] + #[NoCSRFRequired] + #[BruteForceProtection(action: 'shareinfo')] + #[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] + public function info(string $t, ?string $password = null, ?string $dir = null, int $depth = -1): JSONResponse { try { $share = $this->shareManager->getShareByToken($t); } catch (ShareNotFound $e) { - return new JSONResponse([], Http::STATUS_NOT_FOUND); + $response = new JSONResponse([], Http::STATUS_NOT_FOUND); + $response->throttle(['token' => $t]); + return $response; } if ($share->getPassword() && !$this->shareManager->checkPassword($share, $password)) { - return new JSONResponse([], Http::STATUS_FORBIDDEN); + $response = new JSONResponse([], Http::STATUS_FORBIDDEN); + $response->throttle(['token' => $t]); + return $response; } if (!($share->getPermissions() & Constants::PERMISSION_READ)) { - return new JSONResponse([], Http::STATUS_FORBIDDEN); - } - - $isWritable = $share->getPermissions() & (\OCP\Constants::PERMISSION_UPDATE | \OCP\Constants::PERMISSION_CREATE); - if (!$isWritable) { - $this->addROWrapper(); + $response = new JSONResponse([], Http::STATUS_FORBIDDEN); + $response->throttle(['token' => $t]); + return $response; } + $permissionMask = $share->getPermissions(); $node = $share->getNode(); if ($dir !== null && $node instanceof Folder) { try { $node = $node->get($dir); } catch (NotFoundException $e) { - } } - return new JSONResponse($this->parseNode($node)); + return new JSONResponse($this->parseNode($node, $permissionMask, $depth)); } - private function parseNode(Node $node) { + /** + * @return Files_SharingShareInfo + */ + private function parseNode(Node $node, int $permissionMask, int $depth): array { if ($node instanceof File) { - return $this->parseFile($node); + return $this->parseFile($node, $permissionMask); } - return $this->parseFolder($node); + /** @var Folder $node */ + return $this->parseFolder($node, $permissionMask, $depth); } - private function parseFile(File $file) { - return $this->format($file); + /** + * @return Files_SharingShareInfo + */ + private function parseFile(File $file, int $permissionMask): array { + return $this->format($file, $permissionMask); } - private function parseFolder(Folder $folder) { - $data = $this->format($folder); + /** + * @return Files_SharingShareInfo + */ + private function parseFolder(Folder $folder, int $permissionMask, int $depth): array { + $data = $this->format($folder, $permissionMask); + + if ($depth === 0) { + return $data; + } $data['children'] = []; $nodes = $folder->getDirectoryListing(); foreach ($nodes as $node) { - $data['children'][] = $this->parseNode($node); + $data['children'][] = $this->parseNode($node, $permissionMask, $depth <= -1 ? -1 : $depth - 1); } return $data; } - private function format(Node $node) { + /** + * @return Files_SharingShareInfo + */ + private function format(Node $node, int $permissionMask): array { $entry = []; $entry['id'] = $node->getId(); @@ -131,7 +143,7 @@ class ShareInfoController extends ApiController { $entry['mtime'] = $node->getMTime(); $entry['name'] = $node->getName(); - $entry['permissions'] = $node->getPermissions(); + $entry['permissions'] = $node->getPermissions() & $permissionMask; $entry['mimetype'] = $node->getMimetype(); $entry['size'] = $node->getSize(); $entry['type'] = $node->getType(); @@ -139,13 +151,4 @@ class ShareInfoController extends ApiController { return $entry; } - - protected function addROWrapper() { - // FIXME: should not add storage wrappers outside of preSetup, need to find a better way - $previousLog = \OC\Files\Filesystem::logWarningWhenAddingStorageWrapper(false); - \OC\Files\Filesystem::addStorageWrapper('readonly', function ($mountPoint, $storage) { - return new \OC\Files\Storage\Wrapper\PermissionsMask(array('storage' => $storage, 'mask' => \OCP\Constants::PERMISSION_READ + \OCP\Constants::PERMISSION_SHARE)); - }); - \OC\Files\Filesystem::logWarningWhenAddingStorageWrapper($previousLog); - } } diff --git a/apps/files_sharing/lib/Controller/ShareesAPIController.php b/apps/files_sharing/lib/Controller/ShareesAPIController.php index 575bf01fdb0..0c458ce9662 100644 --- a/apps/files_sharing/lib/Controller/ShareesAPIController.php +++ b/apps/files_sharing/lib/Controller/ShareesAPIController.php @@ -1,58 +1,43 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Joas Schilling <coding@schilljs.com> - * @author Maxence Lange <maxence@nextcloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files_Sharing\Controller; +use Generator; +use OC\Collaboration\Collaborators\SearchResult; +use OC\Share\Share; +use OCA\Files_Sharing\ResponseDefinitions; +use OCP\App\IAppManager; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCSController; use OCP\Collaboration\Collaborators\ISearch; -use OCP\IRequest; +use OCP\Collaboration\Collaborators\ISearchResult; +use OCP\Collaboration\Collaborators\SearchResultType; +use OCP\Constants; +use OCP\GlobalScale\IConfig as GlobalScaleIConfig; use OCP\IConfig; +use OCP\IRequest; use OCP\IURLGenerator; -use OCP\Share; +use OCP\Server; use OCP\Share\IManager; +use OCP\Share\IShare; +use function array_slice; +use function array_values; +use function usort; +/** + * @psalm-import-type Files_SharingShareesSearchResult from ResponseDefinitions + * @psalm-import-type Files_SharingShareesRecommendedResult from ResponseDefinitions + */ class ShareesAPIController extends OCSController { - /** @var IConfig */ - protected $config; - - /** @var IURLGenerator */ - protected $urlGenerator; - - /** @var IManager */ - protected $shareManager; - - /** @var bool */ - protected $shareWithGroupOnly = false; - - /** @var bool */ - protected $shareeEnumeration = true; /** @var int */ protected $offset = 0; @@ -60,73 +45,71 @@ class ShareesAPIController extends OCSController { /** @var int */ protected $limit = 10; - /** @var array */ + /** @var Files_SharingShareesSearchResult */ protected $result = [ 'exact' => [ 'users' => [], 'groups' => [], 'remotes' => [], + 'remote_groups' => [], 'emails' => [], 'circles' => [], + 'rooms' => [], ], 'users' => [], 'groups' => [], 'remotes' => [], + 'remote_groups' => [], 'emails' => [], 'lookup' => [], 'circles' => [], + 'rooms' => [], + 'lookupEnabled' => false, ]; protected $reachedEndFor = []; - /** @var ISearch */ - private $collaboratorSearch; - /** - * @param string $appName - * @param IRequest $request - * @param IConfig $config - * @param IURLGenerator $urlGenerator - * @param IManager $shareManager - * @param ISearch $collaboratorSearch - */ public function __construct( - $appName, + string $appName, IRequest $request, - IConfig $config, - IURLGenerator $urlGenerator, - IManager $shareManager, - ISearch $collaboratorSearch + protected ?string $userId, + protected IConfig $config, + protected IURLGenerator $urlGenerator, + protected IManager $shareManager, + protected ISearch $collaboratorSearch, ) { parent::__construct($appName, $request); - - $this->config = $config; - $this->urlGenerator = $urlGenerator; - $this->shareManager = $shareManager; - $this->collaboratorSearch = $collaboratorSearch; } /** - * @NoAdminRequired + * Search for sharees * - * @param string $search - * @param string $itemType - * @param int $page - * @param int $perPage - * @param int|int[] $shareType - * @param bool $lookup - * @return DataResponse - * @throws OCSBadRequestException + * @param string $search Text to search for + * @param string|null $itemType Limit to specific item types + * @param int $page Page offset for searching + * @param int $perPage Limit amount of search results per page + * @param int|list<int>|null $shareType Limit to specific share types + * @param bool $lookup If a global lookup should be performed too + * @return DataResponse<Http::STATUS_OK, Files_SharingShareesSearchResult, array{Link?: string}> + * @throws OCSBadRequestException Invalid search parameters + * + * 200: Sharees search result returned */ - public function search($search = '', $itemType = null, $page = 1, $perPage = 200, $shareType = null, $lookup = true) { + #[NoAdminRequired] + public function search(string $search = '', ?string $itemType = null, int $page = 1, int $perPage = 200, $shareType = null, bool $lookup = false): DataResponse { // only search for string larger than a given threshold - $threshold = (int)$this->config->getSystemValue('sharing.minSearchStringLength', 0); + $threshold = $this->config->getSystemValueInt('sharing.minSearchStringLength', 0); if (strlen($search) < $threshold) { return new DataResponse($this->result); } + if ($this->shareManager->sharingDisabledForUser($this->userId)) { + return new DataResponse($this->result); + } + // never return more than the max. number of results configured in the config.php - $maxResults = (int)$this->config->getSystemValue('sharing.maxAutocompleteResults', 0); + $maxResults = $this->config->getSystemValueInt('sharing.maxAutocompleteResults', Constants::SHARING_MAX_AUTOCOMPLETE_RESULTS_DEFAULT); if ($maxResults > 0) { $perPage = min($perPage, $maxResults); } @@ -138,78 +121,250 @@ class ShareesAPIController extends OCSController { } $shareTypes = [ - Share::SHARE_TYPE_USER, + IShare::TYPE_USER, ]; if ($itemType === null) { throw new OCSBadRequestException('Missing itemType'); } elseif ($itemType === 'file' || $itemType === 'folder') { if ($this->shareManager->allowGroupSharing()) { - $shareTypes[] = Share::SHARE_TYPE_GROUP; + $shareTypes[] = IShare::TYPE_GROUP; } if ($this->isRemoteSharingAllowed($itemType)) { - $shareTypes[] = Share::SHARE_TYPE_REMOTE; + $shareTypes[] = IShare::TYPE_REMOTE; + } + + if ($this->isRemoteGroupSharingAllowed($itemType)) { + $shareTypes[] = IShare::TYPE_REMOTE_GROUP; + } + + if ($this->shareManager->shareProviderExists(IShare::TYPE_EMAIL)) { + $shareTypes[] = IShare::TYPE_EMAIL; } - if ($this->shareManager->shareProviderExists(Share::SHARE_TYPE_EMAIL)) { - $shareTypes[] = Share::SHARE_TYPE_EMAIL; + if ($this->shareManager->shareProviderExists(IShare::TYPE_ROOM)) { + $shareTypes[] = IShare::TYPE_ROOM; + } + + if ($this->shareManager->shareProviderExists(IShare::TYPE_SCIENCEMESH)) { + $shareTypes[] = IShare::TYPE_SCIENCEMESH; } } else { - $shareTypes[] = Share::SHARE_TYPE_GROUP; - $shareTypes[] = Share::SHARE_TYPE_EMAIL; + if ($this->shareManager->allowGroupSharing()) { + $shareTypes[] = IShare::TYPE_GROUP; + } + $shareTypes[] = IShare::TYPE_EMAIL; } // FIXME: DI - if (\OC::$server->getAppManager()->isEnabledForUser('circles') && class_exists('\OCA\Circles\ShareByCircleProvider')) { - $shareTypes[] = Share::SHARE_TYPE_CIRCLE; + if (Server::get(IAppManager::class)->isEnabledForUser('circles') && class_exists('\OCA\Circles\ShareByCircleProvider')) { + $shareTypes[] = IShare::TYPE_CIRCLE; } - if (isset($_GET['shareType']) && is_array($_GET['shareType'])) { - $shareTypes = array_intersect($shareTypes, $_GET['shareType']); - sort($shareTypes); - } else if (is_numeric($shareType)) { - $shareTypes = array_intersect($shareTypes, [(int) $shareType]); - sort($shareTypes); + if ($this->shareManager->shareProviderExists(IShare::TYPE_SCIENCEMESH)) { + $shareTypes[] = IShare::TYPE_SCIENCEMESH; + } + + if ($shareType !== null && is_array($shareType)) { + $shareTypes = array_intersect($shareTypes, $shareType); + } elseif (is_numeric($shareType)) { + $shareTypes = array_intersect($shareTypes, [(int)$shareType]); } + sort($shareTypes); - $this->shareWithGroupOnly = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members', 'no') === 'yes'; - $this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; - $this->limit = (int) $perPage; + $this->limit = $perPage; $this->offset = $perPage * ($page - 1); - list($result, $hasMoreResults) = $this->collaboratorSearch->search($search, $shareTypes, $lookup, $this->limit, $this->offset); + // In global scale mode we always search the lookup server + $this->result['lookupEnabled'] = Server::get(GlobalScaleIConfig::class)->isGlobalScaleEnabled(); + // TODO: Reconsider using lookup server for non-global-scale federation + + [$result, $hasMoreResults] = $this->collaboratorSearch->search($search, $shareTypes, $this->result['lookupEnabled'], $this->limit, $this->offset); // extra treatment for 'exact' subarray, with a single merge expected keys might be lost - if(isset($result['exact'])) { + if (isset($result['exact'])) { $result['exact'] = array_merge($this->result['exact'], $result['exact']); } $this->result = array_merge($this->result, $result); $response = new DataResponse($this->result); if ($hasMoreResults) { - $response->addHeader('Link', $this->getPaginationLink($page, [ + $response->setHeaders(['Link' => $this->getPaginationLink($page, [ 'search' => $search, 'itemType' => $itemType, 'shareType' => $shareTypes, 'perPage' => $perPage, - ])); + ])]); } return $response; } /** + * @param string $user + * @param int $shareType + * + * @return Generator<array<string>> + */ + private function getAllShareesByType(string $user, int $shareType): Generator { + $offset = 0; + $pageSize = 50; + + while (count($page = $this->shareManager->getSharesBy( + $user, + $shareType, + null, + false, + $pageSize, + $offset + ))) { + foreach ($page as $share) { + yield [$share->getSharedWith(), $share->getSharedWithDisplayName() ?? $share->getSharedWith()]; + } + + $offset += $pageSize; + } + } + + private function sortShareesByFrequency(array $sharees): array { + usort($sharees, function (array $s1, array $s2): int { + return $s2['count'] - $s1['count']; + }); + return $sharees; + } + + private $searchResultTypeMap = [ + IShare::TYPE_USER => 'users', + IShare::TYPE_GROUP => 'groups', + IShare::TYPE_REMOTE => 'remotes', + IShare::TYPE_REMOTE_GROUP => 'remote_groups', + IShare::TYPE_EMAIL => 'emails', + ]; + + private function getAllSharees(string $user, array $shareTypes): ISearchResult { + $result = []; + foreach ($shareTypes as $shareType) { + $sharees = $this->getAllShareesByType($user, $shareType); + $shareTypeResults = []; + foreach ($sharees as [$sharee, $displayname]) { + if (!isset($this->searchResultTypeMap[$shareType]) || trim($sharee) === '') { + continue; + } + + if (!isset($shareTypeResults[$sharee])) { + $shareTypeResults[$sharee] = [ + 'count' => 1, + 'label' => $displayname, + 'value' => [ + 'shareType' => $shareType, + 'shareWith' => $sharee, + ], + ]; + } else { + $shareTypeResults[$sharee]['count']++; + } + } + $result = array_merge($result, array_values($shareTypeResults)); + } + + $top5 = array_slice( + $this->sortShareesByFrequency($result), + 0, + 5 + ); + + $searchResult = new SearchResult(); + foreach ($this->searchResultTypeMap as $int => $str) { + $searchResult->addResultSet(new SearchResultType($str), [], []); + foreach ($top5 as $x) { + if ($x['value']['shareType'] === $int) { + $searchResult->addResultSet(new SearchResultType($str), [], [$x]); + } + } + } + return $searchResult; + } + + /** + * Find recommended sharees + * + * @param string $itemType Limit to specific item types + * @param int|list<int>|null $shareType Limit to specific share types + * @return DataResponse<Http::STATUS_OK, Files_SharingShareesRecommendedResult, array{}> + * + * 200: Recommended sharees returned + */ + #[NoAdminRequired] + public function findRecommended(string $itemType, $shareType = null): DataResponse { + $shareTypes = [ + IShare::TYPE_USER, + ]; + + if ($itemType === 'file' || $itemType === 'folder') { + if ($this->shareManager->allowGroupSharing()) { + $shareTypes[] = IShare::TYPE_GROUP; + } + + if ($this->isRemoteSharingAllowed($itemType)) { + $shareTypes[] = IShare::TYPE_REMOTE; + } + + if ($this->isRemoteGroupSharingAllowed($itemType)) { + $shareTypes[] = IShare::TYPE_REMOTE_GROUP; + } + + if ($this->shareManager->shareProviderExists(IShare::TYPE_EMAIL)) { + $shareTypes[] = IShare::TYPE_EMAIL; + } + + if ($this->shareManager->shareProviderExists(IShare::TYPE_ROOM)) { + $shareTypes[] = IShare::TYPE_ROOM; + } + } else { + $shareTypes[] = IShare::TYPE_GROUP; + $shareTypes[] = IShare::TYPE_EMAIL; + } + + // FIXME: DI + if (Server::get(IAppManager::class)->isEnabledForUser('circles') && class_exists('\OCA\Circles\ShareByCircleProvider')) { + $shareTypes[] = IShare::TYPE_CIRCLE; + } + + if (isset($_GET['shareType']) && is_array($_GET['shareType'])) { + $shareTypes = array_intersect($shareTypes, $_GET['shareType']); + sort($shareTypes); + } elseif (is_numeric($shareType)) { + $shareTypes = array_intersect($shareTypes, [(int)$shareType]); + sort($shareTypes); + } + + return new DataResponse( + $this->getAllSharees($this->userId, $shareTypes)->asArray() + ); + } + + /** * Method to get out the static call for better testing * * @param string $itemType * @return bool */ - protected function isRemoteSharingAllowed($itemType) { + protected function isRemoteSharingAllowed(string $itemType): bool { try { // FIXME: static foo makes unit testing unnecessarily difficult - $backend = \OC\Share\Share::getBackend($itemType); - return $backend->isShareTypeAllowed(Share::SHARE_TYPE_REMOTE); + $backend = Share::getBackend($itemType); + return $backend->isShareTypeAllowed(IShare::TYPE_REMOTE); + } catch (\Exception $e) { + return false; + } + } + + protected function isRemoteGroupSharingAllowed(string $itemType): bool { + try { + // FIXME: static foo makes unit testing unnecessarily difficult + $backend = Share::getBackend($itemType); + return $backend->isShareTypeAllowed(IShare::TYPE_REMOTE_GROUP); } catch (\Exception $e) { return false; } @@ -223,22 +378,20 @@ class ShareesAPIController extends OCSController { * @param array $params Parameters for the URL * @return string */ - protected function getPaginationLink($page, array $params) { + protected function getPaginationLink(int $page, array $params): string { if ($this->isV2()) { $url = $this->urlGenerator->getAbsoluteURL('/ocs/v2.php/apps/files_sharing/api/v1/sharees') . '?'; } else { $url = $this->urlGenerator->getAbsoluteURL('/ocs/v1.php/apps/files_sharing/api/v1/sharees') . '?'; } $params['page'] = $page + 1; - $link = '<' . $url . http_build_query($params) . '>; rel="next"'; - - return $link; + return '<' . $url . http_build_query($params) . '>; rel="next"'; } /** * @return bool */ - protected function isV2() { + protected function isV2(): bool { return $this->request->getScriptName() === '/ocs/v2.php'; } } |