diff options
Diffstat (limited to 'apps/dav/lib/Connector')
38 files changed, 1350 insertions, 710 deletions
diff --git a/apps/dav/lib/Connector/LegacyPublicAuth.php b/apps/dav/lib/Connector/LegacyPublicAuth.php index 564eae506ac..03d18853de0 100644 --- a/apps/dav/lib/Connector/LegacyPublicAuth.php +++ b/apps/dav/lib/Connector/LegacyPublicAuth.php @@ -8,6 +8,7 @@ namespace OCA\DAV\Connector; use OCA\DAV\Connector\Sabre\PublicAuth; +use OCP\Defaults; use OCP\IRequest; use OCP\ISession; use OCP\Security\Bruteforce\IThrottler; @@ -25,22 +26,15 @@ class LegacyPublicAuth extends AbstractBasic { private const BRUTEFORCE_ACTION = 'legacy_public_webdav_auth'; private ?IShare $share = null; - private IManager $shareManager; - private ISession $session; - private IRequest $request; - private IThrottler $throttler; - - public function __construct(IRequest $request, - IManager $shareManager, - ISession $session, - IThrottler $throttler) { - $this->request = $request; - $this->shareManager = $shareManager; - $this->session = $session; - $this->throttler = $throttler; + public function __construct( + private IRequest $request, + private IManager $shareManager, + private ISession $session, + private IThrottler $throttler, + ) { // setup realm - $defaults = new \OCP\Defaults(); + $defaults = new Defaults(); $this->realm = $defaults->getName() ?: 'Nextcloud'; } diff --git a/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php b/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php index b39dc7197b0..0e2b1c58748 100644 --- a/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php +++ b/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php b/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php index 8f5195e926b..9cff113140a 100644 --- a/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php +++ b/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -58,8 +59,8 @@ class AppleQuirksPlugin extends ServerPlugin { * This method handles HTTP REPORT requests. * * @param string $reportName - * @param mixed $report - * @param mixed $path + * @param mixed $report + * @param mixed $path * * @return bool */ diff --git a/apps/dav/lib/Connector/Sabre/Auth.php b/apps/dav/lib/Connector/Sabre/Auth.php index 9b67d960107..a174920946a 100644 --- a/apps/dav/lib/Connector/Sabre/Auth.php +++ b/apps/dav/lib/Connector/Sabre/Auth.php @@ -13,10 +13,13 @@ use OC\Authentication\TwoFactorAuth\Manager; use OC\User\Session; use OCA\DAV\Connector\Sabre\Exception\PasswordLoginForbidden; use OCA\DAV\Connector\Sabre\Exception\TooManyRequests; +use OCP\AppFramework\Http; +use OCP\Defaults; use OCP\IRequest; use OCP\ISession; use OCP\Security\Bruteforce\IThrottler; use OCP\Security\Bruteforce\MaxDelayReached; +use OCP\Server; use Psr\Log\LoggerInterface; use Sabre\DAV\Auth\Backend\AbstractBasic; use Sabre\DAV\Exception\NotAuthenticated; @@ -26,29 +29,20 @@ use Sabre\HTTP\ResponseInterface; class Auth extends AbstractBasic { public const DAV_AUTHENTICATED = 'AUTHENTICATED_TO_DAV_BACKEND'; - - private ISession $session; - private Session $userSession; - private IRequest $request; private ?string $currentUser = null; - private Manager $twoFactorManager; - private IThrottler $throttler; - - public function __construct(ISession $session, - Session $userSession, - IRequest $request, - Manager $twoFactorManager, - IThrottler $throttler, - string $principalPrefix = 'principals/users/') { - $this->session = $session; - $this->userSession = $userSession; - $this->twoFactorManager = $twoFactorManager; - $this->request = $request; - $this->throttler = $throttler; + + public function __construct( + private ISession $session, + private Session $userSession, + private IRequest $request, + private Manager $twoFactorManager, + private IThrottler $throttler, + string $principalPrefix = 'principals/users/', + ) { $this->principalPrefix = $principalPrefix; // setup realm - $defaults = new \OCP\Defaults(); + $defaults = new Defaults(); $this->realm = $defaults->getName() ?: 'Nextcloud'; } @@ -61,8 +55,8 @@ class Auth extends AbstractBasic { * @see https://github.com/owncloud/core/issues/13245 */ public function isDavAuthenticated(string $username): bool { - return !is_null($this->session->get(self::DAV_AUTHENTICATED)) && - $this->session->get(self::DAV_AUTHENTICATED) === $username; + return !is_null($this->session->get(self::DAV_AUTHENTICATED)) + && $this->session->get(self::DAV_AUTHENTICATED) === $username; } /** @@ -77,8 +71,8 @@ class Auth extends AbstractBasic { * @throws PasswordLoginForbidden */ protected function validateUserPass($username, $password) { - if ($this->userSession->isLoggedIn() && - $this->isDavAuthenticated($this->userSession->getUser()->getUID()) + if ($this->userSession->isLoggedIn() + && $this->isDavAuthenticated($this->userSession->getUser()->getUID()) ) { $this->session->close(); return true; @@ -115,7 +109,7 @@ class Auth extends AbstractBasic { } catch (Exception $e) { $class = get_class($e); $msg = $e->getMessage(); - \OC::$server->get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); + Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); throw new ServiceUnavailable("$class: $msg"); } } @@ -124,8 +118,9 @@ class Auth extends AbstractBasic { * Checks whether a CSRF check is required on the request */ private function requiresCSRFCheck(): bool { - // GET requires no check at all - if ($this->request->getMethod() === 'GET') { + + $methodsWithoutCsrf = ['GET', 'HEAD', 'OPTIONS']; + if (in_array($this->request->getMethod(), $methodsWithoutCsrf)) { return false; } @@ -149,8 +144,8 @@ class Auth extends AbstractBasic { } // If logged-in AND DAV authenticated no check is required - if ($this->userSession->isLoggedIn() && - $this->isDavAuthenticated($this->userSession->getUser()->getUID())) { + if ($this->userSession->isLoggedIn() + && $this->isDavAuthenticated($this->userSession->getUser()->getUID())) { return false; } @@ -164,13 +159,13 @@ class Auth extends AbstractBasic { private function auth(RequestInterface $request, ResponseInterface $response): array { $forcedLogout = false; - if (!$this->request->passesCSRFCheck() && - $this->requiresCSRFCheck()) { + if (!$this->request->passesCSRFCheck() + && $this->requiresCSRFCheck()) { // In case of a fail with POST we need to recheck the credentials if ($this->request->getMethod() === 'POST') { $forcedLogout = true; } else { - $response->setStatus(401); + $response->setStatus(Http::STATUS_UNAUTHORIZED); throw new \Sabre\DAV\Exception\NotAuthenticated('CSRF check not passed.'); } } @@ -183,10 +178,10 @@ class Auth extends AbstractBasic { } if ( //Fix for broken webdav clients - ($this->userSession->isLoggedIn() && is_null($this->session->get(self::DAV_AUTHENTICATED))) || + ($this->userSession->isLoggedIn() && is_null($this->session->get(self::DAV_AUTHENTICATED))) //Well behaved clients that only send the cookie are allowed - ($this->userSession->isLoggedIn() && $this->session->get(self::DAV_AUTHENTICATED) === $this->userSession->getUser()->getUID() && empty($request->getHeader('Authorization'))) || - \OC_User::handleApacheAuth() + || ($this->userSession->isLoggedIn() && $this->session->get(self::DAV_AUTHENTICATED) === $this->userSession->getUser()->getUID() && empty($request->getHeader('Authorization'))) + || \OC_User::handleApacheAuth() ) { $user = $this->userSession->getUser()->getUID(); $this->currentUser = $user; @@ -203,7 +198,7 @@ class Auth extends AbstractBasic { } elseif (in_array('XMLHttpRequest', explode(',', $request->getHeader('X-Requested-With') ?? ''))) { // For ajax requests use dummy auth name to prevent browser popup in case of invalid creditials $response->addHeader('WWW-Authenticate', 'DummyBasic realm="' . $this->realm . '"'); - $response->setStatus(401); + $response->setStatus(Http::STATUS_UNAUTHORIZED); throw new \Sabre\DAV\Exception\NotAuthenticated('Cannot authenticate over ajax calls'); } return $data; diff --git a/apps/dav/lib/Connector/Sabre/BearerAuth.php b/apps/dav/lib/Connector/Sabre/BearerAuth.php index 8caae8dced9..23453ae8efb 100644 --- a/apps/dav/lib/Connector/Sabre/BearerAuth.php +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -1,10 +1,14 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; +use OCP\AppFramework\Http; +use OCP\Defaults; +use OCP\IConfig; use OCP\IRequest; use OCP\ISession; use OCP\IUserSession; @@ -13,22 +17,15 @@ use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class BearerAuth extends AbstractBearer { - private IUserSession $userSession; - private ISession $session; - private IRequest $request; - private string $principalPrefix; - - public function __construct(IUserSession $userSession, - ISession $session, - IRequest $request, - $principalPrefix = 'principals/users/') { - $this->userSession = $userSession; - $this->session = $session; - $this->request = $request; - $this->principalPrefix = $principalPrefix; - + public function __construct( + private IUserSession $userSession, + private ISession $session, + private IRequest $request, + private IConfig $config, + private string $principalPrefix = 'principals/users/', + ) { // setup realm - $defaults = new \OCP\Defaults(); + $defaults = new Defaults(); $this->realm = $defaults->getName() ?: 'Nextcloud'; } @@ -63,6 +60,14 @@ class BearerAuth extends AbstractBearer { * @param ResponseInterface $response */ public function challenge(RequestInterface $request, ResponseInterface $response): void { - $response->setStatus(401); + // Legacy ownCloud clients still authenticate via OAuth2 + $enableOcClients = $this->config->getSystemValueBool('oauth2.enable_oc_clients', false); + $userAgent = $request->getHeader('User-Agent'); + if ($enableOcClients && $userAgent !== null && str_contains($userAgent, 'mirall')) { + parent::challenge($request, $response); + return; + } + + $response->setStatus(Http::STATUS_UNAUTHORIZED); } } diff --git a/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php b/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php index c4e579bef0b..21358406a4a 100644 --- a/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php +++ b/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php @@ -7,6 +7,7 @@ */ namespace OCA\DAV\Connector\Sabre; +use OCA\Theming\ThemingDefaults; use OCP\IConfig; use OCP\IRequest; use Sabre\DAV\Server; @@ -21,10 +22,11 @@ use Sabre\HTTP\RequestInterface; */ class BlockLegacyClientPlugin extends ServerPlugin { protected ?Server $server = null; - protected IConfig $config; - public function __construct(IConfig $config) { - $this->config = $config; + public function __construct( + private IConfig $config, + private ThemingDefaults $themingDefaults, + ) { } /** @@ -47,11 +49,26 @@ class BlockLegacyClientPlugin extends ServerPlugin { return; } - $minimumSupportedDesktopVersion = $this->config->getSystemValue('minimum.supported.desktop.version', '2.3.0'); + $minimumSupportedDesktopVersion = $this->config->getSystemValueString('minimum.supported.desktop.version', '3.1.0'); + $maximumSupportedDesktopVersion = $this->config->getSystemValueString('maximum.supported.desktop.version', '99.99.99'); + + // Check if the client is a desktop client preg_match(IRequest::USER_AGENT_CLIENT_DESKTOP, $userAgent, $versionMatches); - if (isset($versionMatches[1]) && - version_compare($versionMatches[1], $minimumSupportedDesktopVersion) === -1) { - throw new \Sabre\DAV\Exception\Forbidden('Unsupported client version.'); + + // If the client is a desktop client and the version is too old, block it + if (isset($versionMatches[1]) && version_compare($versionMatches[1], $minimumSupportedDesktopVersion) === -1) { + $customClientDesktopLink = htmlspecialchars($this->themingDefaults->getSyncClientUrl()); + $minimumSupportedDesktopVersion = htmlspecialchars($minimumSupportedDesktopVersion); + + throw new \Sabre\DAV\Exception\Forbidden("This version of the client is unsupported. Upgrade to <a href=\"$customClientDesktopLink\">version $minimumSupportedDesktopVersion or later</a>."); + } + + // If the client is a desktop client and the version is too new, block it + if (isset($versionMatches[1]) && version_compare($versionMatches[1], $maximumSupportedDesktopVersion) === 1) { + $customClientDesktopLink = htmlspecialchars($this->themingDefaults->getSyncClientUrl()); + $maximumSupportedDesktopVersion = htmlspecialchars($maximumSupportedDesktopVersion); + + throw new \Sabre\DAV\Exception\Forbidden("This version of the client is unsupported. Downgrade to <a href=\"$customClientDesktopLink\">version $maximumSupportedDesktopVersion or earlier</a>."); } } } diff --git a/apps/dav/lib/Connector/Sabre/CachingTree.php b/apps/dav/lib/Connector/Sabre/CachingTree.php index a715991fcf6..5d72b530f58 100644 --- a/apps/dav/lib/Connector/Sabre/CachingTree.php +++ b/apps/dav/lib/Connector/Sabre/CachingTree.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -27,7 +28,7 @@ class CachingTree extends Tree { // flushing the entire cache $path = trim($path, '/'); foreach ($this->cache as $nodePath => $node) { - $nodePath = (string) $nodePath; + $nodePath = (string)$nodePath; if ($path === '' || $nodePath == $path || str_starts_with($nodePath, $path . '/')) { unset($this->cache[$nodePath]); } diff --git a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php index 1bf45b22537..18009080585 100644 --- a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php +++ b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace OCA\DAV\Connector\Sabre; +use OCP\AppFramework\Http; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\HTTP\RequestInterface; @@ -26,20 +27,6 @@ class ChecksumUpdatePlugin extends ServerPlugin { } /** @return string[] */ - public function getHTTPMethods($path): array { - $tree = $this->server->tree; - - if ($tree->nodeExists($path)) { - $node = $tree->getNodeForPath($path); - if ($node instanceof File) { - return ['PATCH']; - } - } - - return []; - } - - /** @return string[] */ public function getFeatures(): array { return ['nextcloud-checksum-update']; } @@ -59,7 +46,7 @@ class ChecksumUpdatePlugin extends ServerPlugin { $node->setChecksum($checksum); $response->addHeader('OC-Checksum', $checksum); $response->setHeader('Content-Length', '0'); - $response->setStatus(204); + $response->setStatus(Http::STATUS_NO_CONTENT); return false; } diff --git a/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php index 71514016bda..e4b6c2636da 100644 --- a/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php @@ -20,13 +20,12 @@ class CommentPropertiesPlugin extends ServerPlugin { public const PROPERTY_NAME_UNREAD = '{http://owncloud.org/ns}comments-unread'; protected ?Server $server = null; - private ICommentsManager $commentsManager; - private IUserSession $userSession; private array $cachedUnreadCount = []; - public function __construct(ICommentsManager $commentsManager, IUserSession $userSession) { - $this->commentsManager = $commentsManager; - $this->userSession = $userSession; + public function __construct( + private ICommentsManager $commentsManager, + private IUserSession $userSession, + ) { } /** @@ -62,7 +61,7 @@ class CommentPropertiesPlugin extends ServerPlugin { $ids[] = (string)$id; } - $ids[] = (string) $directory->getId(); + $ids[] = (string)$directory->getId(); $unread = $this->commentsManager->getNumberOfUnreadCommentsForObjects('files', $ids, $this->userSession->getUser()); foreach ($unread as $id => $count) { @@ -80,7 +79,7 @@ class CommentPropertiesPlugin extends ServerPlugin { */ public function handleGetProperties( PropFind $propFind, - \Sabre\DAV\INode $node + \Sabre\DAV\INode $node, ) { if (!($node instanceof File) && !($node instanceof Directory)) { return; diff --git a/apps/dav/lib/Connector/Sabre/DavAclPlugin.php b/apps/dav/lib/Connector/Sabre/DavAclPlugin.php index c499f806eba..100d719ef01 100644 --- a/apps/dav/lib/Connector/Sabre/DavAclPlugin.php +++ b/apps/dav/lib/Connector/Sabre/DavAclPlugin.php @@ -11,6 +11,7 @@ use OCA\DAV\CalDAV\CachedSubscription; use OCA\DAV\CalDAV\Calendar; use OCA\DAV\CardDAV\AddressBook; use Sabre\CalDAV\Principal\User; +use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\INode; use Sabre\DAV\PropFind; @@ -49,13 +50,19 @@ class DavAclPlugin extends \Sabre\DAVACL\Plugin { $type = 'Node'; break; } - throw new NotFound( - sprintf( - "%s with name '%s' could not be found", - $type, - $node->getName() - ) - ); + + if ($this->getCurrentUserPrincipal() === $node->getOwner()) { + throw new Forbidden('Access denied'); + } else { + throw new NotFound( + sprintf( + "%s with name '%s' could not be found", + $type, + $node->getName() + ) + ); + } + } return $access; diff --git a/apps/dav/lib/Connector/Sabre/Directory.php b/apps/dav/lib/Connector/Sabre/Directory.php index 101c0d13935..fe09c3f423f 100644 --- a/apps/dav/lib/Connector/Sabre/Directory.php +++ b/apps/dav/lib/Connector/Sabre/Directory.php @@ -13,10 +13,15 @@ use OCA\DAV\AppInfo\Application; use OCA\DAV\Connector\Sabre\Exception\FileLocked; use OCA\DAV\Connector\Sabre\Exception\Forbidden; use OCA\DAV\Connector\Sabre\Exception\InvalidPath; +use OCA\DAV\Storage\PublicShareWrapper; +use OCP\App\IAppManager; +use OCP\Constants; use OCP\Files\FileInfo; use OCP\Files\Folder; use OCP\Files\ForbiddenException; use OCP\Files\InvalidPathException; +use OCP\Files\Mount\IMountManager; +use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\StorageNotAvailableException; use OCP\IL10N; @@ -24,6 +29,7 @@ use OCP\IRequest; use OCP\L10N\IFactory; use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; +use OCP\Server; use OCP\Share\IManager as IShareManager; use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\BadRequest; @@ -33,23 +39,26 @@ use Sabre\DAV\Exception\ServiceUnavailable; use Sabre\DAV\IFile; use Sabre\DAV\INode; -class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuota, \Sabre\DAV\IMoveTarget, \Sabre\DAV\ICopyTarget { +class Directory extends Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuota, \Sabre\DAV\IMoveTarget, \Sabre\DAV\ICopyTarget { /** * Cached directory content - * @var \OCP\Files\FileInfo[] + * @var FileInfo[] */ private ?array $dirContent = null; /** Cached quota info */ private ?array $quotaInfo = null; - private ?CachingTree $tree = null; /** * Sets up the node, expects a full path name */ - public function __construct(View $view, FileInfo $info, ?CachingTree $tree = null, ?IShareManager $shareManager = null) { + public function __construct( + View $view, + FileInfo $info, + private ?CachingTree $tree = null, + ?IShareManager $shareManager = null, + ) { parent::__construct($view, $info, $shareManager); - $this->tree = $tree; } /** @@ -101,18 +110,18 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol 'type' => FileInfo::TYPE_FILE ], null); } - $node = new \OCA\DAV\Connector\Sabre\File($this->fileView, $info); + $node = new File($this->fileView, $info); // only allow 1 process to upload a file at once but still allow reading the file while writing the part file $node->acquireLock(ILockingProvider::LOCK_SHARED); - $this->fileView->lockFile($path . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); + $this->fileView->lockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); $result = $node->put($data); - $this->fileView->unlockFile($path . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); + $this->fileView->unlockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); $node->releaseLock(ILockingProvider::LOCK_SHARED); return $result; - } catch (\OCP\Files\StorageNotAvailableException $e) { + } catch (StorageNotAvailableException $e) { throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), $e->getCode(), $e); } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage(), false, $ex); @@ -143,12 +152,12 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol if (!$this->fileView->mkdir($newPath)) { throw new \Sabre\DAV\Exception\Forbidden('Could not create directory ' . $newPath); } - } catch (\OCP\Files\StorageNotAvailableException $e) { - throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage()); + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), 0, $e); } catch (InvalidPathException $ex) { - throw new InvalidPath($ex->getMessage()); + throw new InvalidPath($ex->getMessage(), false, $ex); } catch (ForbiddenException $ex) { - throw new Forbidden($ex->getMessage(), $ex->getRetry()); + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); } catch (LockedException $e) { throw new FileLocked($e->getMessage(), $e->getCode(), $e); } @@ -158,14 +167,27 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol * Returns a specific child node, referenced by its name * * @param string $name - * @param \OCP\Files\FileInfo $info + * @param FileInfo $info * @return \Sabre\DAV\INode * @throws InvalidPath * @throws \Sabre\DAV\Exception\NotFound * @throws \Sabre\DAV\Exception\ServiceUnavailable */ public function getChild($name, $info = null, ?IRequest $request = null, ?IL10N $l10n = null) { - if (!$this->info->isReadable()) { + $storage = $this->info->getStorage(); + $allowDirectory = false; + + // Checking if we're in a file drop + // If we are, then only PUT and MKCOL are allowed (see plugin) + // so we are safe to return the directory without a risk of + // leaking files and folders structure. + if ($storage instanceof PublicShareWrapper) { + $share = $storage->getShare(); + $allowDirectory = ($share->getPermissions() & Constants::PERMISSION_READ) !== Constants::PERMISSION_READ; + } + + // For file drop we need to be allowed to read the directory with the nickname + if (!$allowDirectory && !$this->info->isReadable()) { // avoid detecting files through this way throw new NotFound(); } @@ -173,14 +195,14 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol $path = $this->path . '/' . $name; if (is_null($info)) { try { - $this->fileView->verifyPath($this->path, $name); + $this->fileView->verifyPath($this->path, $name, true); $info = $this->fileView->getFileInfo($path); - } catch (\OCP\Files\StorageNotAvailableException $e) { - throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage()); + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), 0, $e); } catch (InvalidPathException $ex) { - throw new InvalidPath($ex->getMessage()); + throw new InvalidPath($ex->getMessage(), false, $ex); } catch (ForbiddenException $e) { - throw new \Sabre\DAV\Exception\Forbidden(); + throw new \Sabre\DAV\Exception\Forbidden($e->getMessage(), $e->getCode(), $e); } } @@ -191,7 +213,12 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol if ($info->getMimeType() === FileInfo::MIMETYPE_FOLDER) { $node = new \OCA\DAV\Connector\Sabre\Directory($this->fileView, $info, $this->tree, $this->shareManager); } else { - $node = new \OCA\DAV\Connector\Sabre\File($this->fileView, $info, $this->shareManager, $request, $l10n); + // In case reading a directory was allowed but it turns out the node was a not a directory, reject it now. + if (!$this->info->isReadable()) { + throw new NotFound(); + } + + $node = new File($this->fileView, $info, $this->shareManager, $request, $l10n); } if ($this->tree) { $this->tree->cacheNode($node); @@ -204,7 +231,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol * * @return \Sabre\DAV\INode[] * @throws \Sabre\DAV\Exception\Locked - * @throws \OCA\DAV\Connector\Sabre\Exception\Forbidden + * @throws Forbidden */ public function getChildren() { if (!is_null($this->dirContent)) { @@ -214,7 +241,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol if (!$this->info->isReadable()) { // return 403 instead of 404 because a 404 would make // the caller believe that the collection itself does not exist - if (\OCP\Server::get(\OCP\App\IAppManager::class)->isInstalled('files_accesscontrol')) { + if (Server::get(IAppManager::class)->isEnabledForAnyone('files_accesscontrol')) { throw new Forbidden('No read permissions. This might be caused by files_accesscontrol, check your configured rules'); } else { throw new Forbidden('No read permissions'); @@ -226,8 +253,8 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol } $nodes = []; - $request = \OC::$server->get(IRequest::class); - $l10nFactory = \OC::$server->get(IFactory::class); + $request = Server::get(IRequest::class); + $l10nFactory = Server::get(IFactory::class); $l10n = $l10nFactory->get(Application::APP_ID); foreach ($folderContent as $info) { $node = $this->getChild($info->getName(), $info, $request, $l10n); @@ -280,7 +307,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol } private function getLogger(): LoggerInterface { - return \OC::$server->get(LoggerInterface::class); + return Server::get(LoggerInterface::class); } /** @@ -294,14 +321,14 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol } $relativePath = $this->fileView->getRelativePath($this->info->getPath()); if ($relativePath === null) { - $this->getLogger()->warning("error while getting quota as the relative path cannot be found"); + $this->getLogger()->warning('error while getting quota as the relative path cannot be found'); return [0, 0]; } try { $storageInfo = \OC_Helper::getStorageInfo($relativePath, $this->info, false); - if ($storageInfo['quota'] === \OCP\Files\FileInfo::SPACE_UNLIMITED) { - $free = \OCP\Files\FileInfo::SPACE_UNLIMITED; + if ($storageInfo['quota'] === FileInfo::SPACE_UNLIMITED) { + $free = FileInfo::SPACE_UNLIMITED; } else { $free = $storageInfo['free']; } @@ -310,14 +337,14 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol $free ]; return $this->quotaInfo; - } catch (\OCP\Files\NotFoundException $e) { - $this->getLogger()->warning("error while getting quota into", ['exception' => $e]); + } catch (NotFoundException $e) { + $this->getLogger()->warning('error while getting quota into', ['exception' => $e]); return [0, 0]; - } catch (\OCP\Files\StorageNotAvailableException $e) { - $this->getLogger()->warning("error while getting quota into", ['exception' => $e]); + } catch (StorageNotAvailableException $e) { + $this->getLogger()->warning('error while getting quota into', ['exception' => $e]); return [0, 0]; } catch (NotPermittedException $e) { - $this->getLogger()->warning("error while getting quota into", ['exception' => $e]); + $this->getLogger()->warning('error while getting quota into', ['exception' => $e]); return [0, 0]; } } @@ -357,10 +384,6 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol throw new BadRequest('Incompatible node types'); } - if (!$this->fileView) { - throw new ServiceUnavailable('filesystem not setup'); - } - $destinationPath = $this->getPath() . '/' . $targetName; @@ -378,7 +401,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol $sourcePath = $sourceNode->getPath(); $isMovableMount = false; - $sourceMount = \OC::$server->getMountManager()->find($this->fileView->getAbsolutePath($sourcePath)); + $sourceMount = Server::get(IMountManager::class)->find($this->fileView->getAbsolutePath($sourcePath)); $internalPath = $sourceMount->getInternalPath($this->fileView->getAbsolutePath($sourcePath)); if ($sourceMount instanceof MoveableMount && $internalPath === '') { $isMovableMount = true; @@ -418,9 +441,9 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol throw new \Sabre\DAV\Exception\Forbidden(''); } } catch (StorageNotAvailableException $e) { - throw new ServiceUnavailable($e->getMessage()); + throw new ServiceUnavailable($e->getMessage(), $e->getCode(), $e); } catch (ForbiddenException $ex) { - throw new Forbidden($ex->getMessage(), $ex->getRetry()); + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); } catch (LockedException $e) { throw new FileLocked($e->getMessage(), $e->getCode(), $e); } @@ -445,11 +468,17 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol throw new InvalidPath($ex->getMessage()); } - return $this->fileView->copy($sourcePath, $destinationPath); + $copyOkay = $this->fileView->copy($sourcePath, $destinationPath); + + if (!$copyOkay) { + throw new \Sabre\DAV\Exception\Forbidden('Copy did not proceed'); + } + + return true; } catch (StorageNotAvailableException $e) { - throw new ServiceUnavailable($e->getMessage()); + throw new ServiceUnavailable($e->getMessage(), $e->getCode(), $e); } catch (ForbiddenException $ex) { - throw new Forbidden($ex->getMessage(), $ex->getRetry()); + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); } catch (LockedException $e) { throw new FileLocked($e->getMessage(), $e->getCode(), $e); } diff --git a/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php b/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php index 26819836001..f6baceb748b 100644 --- a/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php +++ b/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php @@ -7,6 +7,7 @@ */ namespace OCA\DAV\Connector\Sabre; +use OCP\AppFramework\Http; use Sabre\DAV\Server; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; @@ -42,13 +43,13 @@ class DummyGetResponsePlugin extends \Sabre\DAV\ServerPlugin { * @return false */ public function httpGet(RequestInterface $request, ResponseInterface $response) { - $string = 'This is the WebDAV interface. It can only be accessed by ' . - 'WebDAV clients such as the Nextcloud desktop sync client.'; + $string = 'This is the WebDAV interface. It can only be accessed by ' + . 'WebDAV clients such as the Nextcloud desktop sync client.'; $stream = fopen('php://memory', 'r+'); fwrite($stream, $string); rewind($stream); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setBody($stream); return false; diff --git a/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php b/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php index 41ace002660..1e1e4aaed04 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php +++ b/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-only diff --git a/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php b/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php index 5c39e6a437a..38708e945e9 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php +++ b/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php @@ -8,14 +8,15 @@ namespace OCA\DAV\Connector\Sabre\Exception; use Exception; +use OCP\Files\LockNotAcquiredException; class FileLocked extends \Sabre\DAV\Exception { /** * @param string $message * @param int $code */ - public function __construct($message = "", $code = 0, ?Exception $previous = null) { - if ($previous instanceof \OCP\Files\LockNotAcquiredException) { + public function __construct($message = '', $code = 0, ?Exception $previous = null) { + if ($previous instanceof LockNotAcquiredException) { $message = sprintf('Target file %s is locked by another process.', $previous->path); } parent::__construct($message, $code, $previous); diff --git a/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php b/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php index 0dd346a2546..95d4b3ab514 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php +++ b/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php @@ -11,18 +11,16 @@ class Forbidden extends \Sabre\DAV\Exception\Forbidden { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * @var bool - */ - private $retry; - - /** * @param string $message * @param bool $retry * @param \Exception $previous */ - public function __construct($message, $retry = false, ?\Exception $previous = null) { + public function __construct( + $message, + private $retry = false, + ?\Exception $previous = null, + ) { parent::__construct($message, 0, $previous); - $this->retry = $retry; } /** diff --git a/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php b/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php index a9ec8ffef9a..dfc08aa8b88 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php +++ b/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php @@ -13,18 +13,16 @@ class InvalidPath extends Exception { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * @var bool - */ - private $retry; - - /** * @param string $message * @param bool $retry * @param \Exception|null $previous */ - public function __construct($message, $retry = false, ?\Exception $previous = null) { + public function __construct( + $message, + private $retry = false, + ?\Exception $previous = null, + ) { parent::__construct($message, 0, $previous); - $this->retry = $retry; } /** diff --git a/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php b/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php index 2e8fcefe577..686386dbfef 100644 --- a/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php +++ b/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php @@ -67,15 +67,13 @@ class ExceptionLoggerPlugin extends \Sabre\DAV\ServerPlugin { ServerMaintenanceMode::class => true, ]; - private string $appName; - private LoggerInterface $logger; - /** - * @param string $loggerAppName app name to use when logging + * @param string $appName app name to use when logging */ - public function __construct(string $loggerAppName, LoggerInterface $logger) { - $this->appName = $loggerAppName; - $this->logger = $logger; + public function __construct( + private string $appName, + private LoggerInterface $logger, + ) { } /** diff --git a/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php b/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php index 485b6b09a3c..b0c5a079ce1 100644 --- a/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php @@ -7,9 +7,11 @@ */ namespace OCA\DAV\Connector\Sabre; +use OCP\AppFramework\Http; use Sabre\DAV\INode; use Sabre\DAV\Locks\LockInfo; use Sabre\DAV\PropFind; +use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\DAV\Xml\Property\LockDiscovery; use Sabre\DAV\Xml\Property\SupportedLock; @@ -29,11 +31,11 @@ use Sabre\HTTP\ResponseInterface; * @package OCA\DAV\Connector\Sabre */ class FakeLockerPlugin extends ServerPlugin { - /** @var \Sabre\DAV\Server */ + /** @var Server */ private $server; /** {@inheritDoc} */ - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(Server $server) { $this->server = $server; $this->server->on('method:LOCK', [$this, 'fakeLockProvider'], 1); $this->server->on('method:UNLOCK', [$this, 'fakeUnlockProvider'], 1); @@ -111,15 +113,15 @@ class FakeLockerPlugin extends ServerPlugin { $lockInfo = new LockInfo(); $lockInfo->token = md5($request->getPath()); $lockInfo->uri = $request->getPath(); - $lockInfo->depth = \Sabre\DAV\Server::DEPTH_INFINITY; + $lockInfo->depth = Server::DEPTH_INFINITY; $lockInfo->timeout = 1800; $body = $this->server->xml->write('{DAV:}prop', [ - '{DAV:}lockdiscovery' => - new LockDiscovery([$lockInfo]) + '{DAV:}lockdiscovery' + => new LockDiscovery([$lockInfo]) ]); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setBody($body); return false; @@ -134,7 +136,7 @@ class FakeLockerPlugin extends ServerPlugin { */ public function fakeUnlockProvider(RequestInterface $request, ResponseInterface $response) { - $response->setStatus(204); + $response->setStatus(Http::STATUS_NO_CONTENT); $response->setHeader('Content-Length', '0'); return false; } diff --git a/apps/dav/lib/Connector/Sabre/File.php b/apps/dav/lib/Connector/Sabre/File.php index 0b9199492fe..d2a71eb3e7b 100644 --- a/apps/dav/lib/Connector/Sabre/File.php +++ b/apps/dav/lib/Connector/Sabre/File.php @@ -13,28 +13,32 @@ use OC\Files\Filesystem; use OC\Files\Stream\HashWrapper; use OC\Files\View; use OCA\DAV\AppInfo\Application; -use OCA\DAV\Connector\Sabre\Exception\BadGateway; use OCA\DAV\Connector\Sabre\Exception\EntityTooLarge; use OCA\DAV\Connector\Sabre\Exception\FileLocked; use OCA\DAV\Connector\Sabre\Exception\Forbidden as DAVForbiddenException; use OCA\DAV\Connector\Sabre\Exception\UnsupportedMediaType; +use OCP\App\IAppManager; use OCP\Encryption\Exceptions\GenericEncryptionException; +use OCP\Files; use OCP\Files\EntityTooLargeException; use OCP\Files\FileInfo; use OCP\Files\ForbiddenException; use OCP\Files\GenericFileException; +use OCP\Files\IMimeTypeDetector; use OCP\Files\InvalidContentException; use OCP\Files\InvalidPathException; use OCP\Files\LockNotAcquiredException; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; -use OCP\Files\Storage; +use OCP\Files\Storage\IWriteStreamStorage; use OCP\Files\StorageNotAvailableException; +use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; use OCP\L10N\IFactory as IL10NFactory; use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; +use OCP\Server; use OCP\Share\IManager; use Psr\Log\LoggerInterface; use Sabre\DAV\Exception; @@ -51,8 +55,8 @@ class File extends Node implements IFile { /** * Sets up the node, expects a full path name * - * @param \OC\Files\View $view - * @param \OCP\Files\FileInfo $info + * @param View $view + * @param FileInfo $info * @param ?\OCP\Share\IManager $shareManager * @param ?IRequest $request * @param ?IL10N $l10n @@ -65,14 +69,14 @@ class File extends Node implements IFile { } else { // Querying IL10N directly results in a dependency loop /** @var IL10NFactory $l10nFactory */ - $l10nFactory = \OC::$server->get(IL10NFactory::class); + $l10nFactory = Server::get(IL10NFactory::class); $this->l10n = $l10nFactory->get(Application::APP_ID); } if (isset($request)) { $this->request = $request; } else { - $this->request = \OC::$server->get(IRequest::class); + $this->request = Server::get(IRequest::class); } } @@ -117,15 +121,18 @@ class File extends Node implements IFile { // verify path of the target $this->verifyPath(); - /** @var Storage $partStorage */ [$partStorage] = $this->fileView->resolvePath($this->path); + if ($partStorage === null) { + throw new ServiceUnavailable($this->l10n->t('Failed to get storage for file')); + } $needsPartFile = $partStorage->needsPartFile() && (strlen($this->path) > 1); - $view = \OC\Files\Filesystem::getView(); + $view = Filesystem::getView(); if ($needsPartFile) { + $transferId = \rand(); // mark file as partial while uploading (ignored by the scanner) - $partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . rand() . '.part'; + $partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . $transferId . '.part'; if (!$view->isCreatable($partFilePath) && $view->isUpdatable($this->path)) { $needsPartFile = false; @@ -141,10 +148,11 @@ class File extends Node implements IFile { } // the part file and target file might be on a different storage in case of a single file storage (e.g. single file share) - /** @var \OC\Files\Storage\Storage $partStorage */ [$partStorage, $internalPartPath] = $this->fileView->resolvePath($partFilePath); - /** @var \OC\Files\Storage\Storage $storage */ [$storage, $internalPath] = $this->fileView->resolvePath($this->path); + if ($partStorage === null || $storage === null) { + throw new ServiceUnavailable($this->l10n->t('Failed to get storage for file')); + } try { if (!$needsPartFile) { try { @@ -178,100 +186,92 @@ class File extends Node implements IFile { if ($this->request->getHeader('X-HASH') !== '') { $hash = $this->request->getHeader('X-HASH'); if ($hash === 'all' || $hash === 'md5') { - $data = HashWrapper::wrap($data, 'md5', function ($hash) { + $data = HashWrapper::wrap($data, 'md5', function ($hash): void { $this->header('X-Hash-MD5: ' . $hash); }); } if ($hash === 'all' || $hash === 'sha1') { - $data = HashWrapper::wrap($data, 'sha1', function ($hash) { + $data = HashWrapper::wrap($data, 'sha1', function ($hash): void { $this->header('X-Hash-SHA1: ' . $hash); }); } if ($hash === 'all' || $hash === 'sha256') { - $data = HashWrapper::wrap($data, 'sha256', function ($hash) { + $data = HashWrapper::wrap($data, 'sha256', function ($hash): void { $this->header('X-Hash-SHA256: ' . $hash); }); } } - if ($partStorage->instanceOfStorage(Storage\IWriteStreamStorage::class)) { + $lengthHeader = $this->request->getHeader('content-length'); + $expected = $lengthHeader !== '' ? (int)$lengthHeader : null; + + if ($partStorage->instanceOfStorage(IWriteStreamStorage::class)) { $isEOF = false; - $wrappedData = CallbackWrapper::wrap($data, null, null, null, null, function ($stream) use (&$isEOF) { + $wrappedData = CallbackWrapper::wrap($data, null, null, null, null, function ($stream) use (&$isEOF): void { $isEOF = feof($stream); }); - $result = true; - $count = -1; - try { - $count = $partStorage->writeStream($internalPartPath, $wrappedData); - } catch (GenericFileException $e) { - $result = false; - } catch (BadGateway $e) { - throw $e; - } - - - if ($result === false) { - $result = $isEOF; - if (is_resource($wrappedData)) { - $result = feof($wrappedData); + $result = is_resource($wrappedData); + if ($result) { + $count = -1; + try { + /** @var IWriteStreamStorage $partStorage */ + $count = $partStorage->writeStream($internalPartPath, $wrappedData, $expected); + } catch (GenericFileException $e) { + $logger = Server::get(LoggerInterface::class); + $logger->error('Error while writing stream to storage: ' . $e->getMessage(), ['exception' => $e, 'app' => 'webdav']); + $result = $isEOF; + if (is_resource($wrappedData)) { + $result = feof($wrappedData); + } } } } else { $target = $partStorage->fopen($internalPartPath, 'wb'); if ($target === false) { - \OC::$server->get(LoggerInterface::class)->error('\OC\Files\Filesystem::fopen() failed', ['app' => 'webdav']); + Server::get(LoggerInterface::class)->error('\OC\Files\Filesystem::fopen() failed', ['app' => 'webdav']); // because we have no clue about the cause we can only throw back a 500/Internal Server Error throw new Exception($this->l10n->t('Could not write file contents')); } - [$count, $result] = \OC_Helper::streamCopy($data, $target); + [$count, $result] = Files::streamCopy($data, $target, true); fclose($target); } - - if ($result === false) { - $expected = -1; - $lengthHeader = $this->request->getHeader('content-length'); - if ($lengthHeader) { - $expected = (int)$lengthHeader; - } - if ($expected !== 0) { - throw new Exception( - $this->l10n->t( - 'Error while copying file to target location (copied: %1$s, expected filesize: %2$s)', - [ - $this->l10n->n('%n byte', '%n bytes', $count), - $this->l10n->n('%n byte', '%n bytes', $expected), - ], - ) - ); - } + if ($result === false && $expected !== null) { + throw new Exception( + $this->l10n->t( + 'Error while copying file to target location (copied: %1$s, expected filesize: %2$s)', + [ + $this->l10n->n('%n byte', '%n bytes', $count), + $this->l10n->n('%n byte', '%n bytes', $expected), + ], + ) + ); } // if content length is sent by client: // double check if the file was fully received // compare expected and actual size - $lengthHeader = $this->request->getHeader('content-length'); - if ($lengthHeader && $this->request->getMethod() === 'PUT') { - $expected = (int)$lengthHeader; - if ($count !== $expected) { - throw new BadRequest( - $this->l10n->t( - 'Expected filesize of %1$s but read (from Nextcloud client) and wrote (to Nextcloud storage) %2$s. Could either be a network problem on the sending side or a problem writing to the storage on the server side.', - [ - $this->l10n->n('%n byte', '%n bytes', $expected), - $this->l10n->n('%n byte', '%n bytes', $count), - ], - ) - ); - } + if ($expected !== null + && $expected !== $count + && $this->request->getMethod() === 'PUT' + ) { + throw new BadRequest( + $this->l10n->t( + 'Expected filesize of %1$s but read (from Nextcloud client) and wrote (to Nextcloud storage) %2$s. Could either be a network problem on the sending side or a problem writing to the storage on the server side.', + [ + $this->l10n->n('%n byte', '%n bytes', $expected), + $this->l10n->n('%n byte', '%n bytes', $count), + ], + ) + ); } } catch (\Exception $e) { if ($e instanceof LockedException) { - \OC::$server->get(LoggerInterface::class)->debug($e->getMessage(), ['exception' => $e]); + Server::get(LoggerInterface::class)->debug($e->getMessage(), ['exception' => $e]); } else { - \OC::$server->get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); + Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); } if ($needsPartFile) { @@ -312,7 +312,7 @@ class File extends Node implements IFile { $renameOkay = $storage->moveFromStorage($partStorage, $internalPartPath, $internalPath); $fileExists = $storage->file_exists($internalPath); if ($renameOkay === false || $fileExists === false) { - \OC::$server->get(LoggerInterface::class)->error('renaming part file to final file failed $renameOkay: ' . ($renameOkay ? 'true' : 'false') . ', $fileExists: ' . ($fileExists ? 'true' : 'false') . ')', ['app' => 'webdav']); + Server::get(LoggerInterface::class)->error('renaming part file to final file failed $renameOkay: ' . ($renameOkay ? 'true' : 'false') . ', $fileExists: ' . ($fileExists ? 'true' : 'false') . ')', ['app' => 'webdav']); throw new Exception($this->l10n->t('Could not rename part file to final file')); } } catch (ForbiddenException $ex) { @@ -379,11 +379,16 @@ class File extends Node implements IFile { } private function getPartFileBasePath($path) { - $partFileInStorage = \OC::$server->getConfig()->getSystemValue('part_file_in_storage', true); + $partFileInStorage = Server::get(IConfig::class)->getSystemValue('part_file_in_storage', true); if ($partFileInStorage) { - return $path; + $filename = basename($path); + // hash does not need to be secure but fast and semi unique + $hashedFilename = hash('xxh128', $filename); + return substr($path, 0, strlen($path) - strlen($filename)) . $hashedFilename; } else { - return md5($path); // will place it in the root of the view with a unique name + // will place the .part file in the users root directory + // therefor we need to make the name (semi) unique - hash does not need to be secure but fast. + return hash('xxh128', $path); } } @@ -399,19 +404,19 @@ class File extends Node implements IFile { $run = true; if (!$exists) { - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_create, [ - \OC\Files\Filesystem::signal_param_path => $hookPath, - \OC\Files\Filesystem::signal_param_run => &$run, + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_create, [ + Filesystem::signal_param_path => $hookPath, + Filesystem::signal_param_run => &$run, ]); } else { - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_update, [ - \OC\Files\Filesystem::signal_param_path => $hookPath, - \OC\Files\Filesystem::signal_param_run => &$run, + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_update, [ + Filesystem::signal_param_path => $hookPath, + Filesystem::signal_param_run => &$run, ]); } - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_write, [ - \OC\Files\Filesystem::signal_param_path => $hookPath, - \OC\Files\Filesystem::signal_param_run => &$run, + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_write, [ + Filesystem::signal_param_path => $hookPath, + Filesystem::signal_param_run => &$run, ]); return $run; } @@ -426,16 +431,16 @@ class File extends Node implements IFile { return; } if (!$exists) { - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_post_create, [ - \OC\Files\Filesystem::signal_param_path => $hookPath + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_create, [ + Filesystem::signal_param_path => $hookPath ]); } else { - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_post_update, [ - \OC\Files\Filesystem::signal_param_path => $hookPath + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_update, [ + Filesystem::signal_param_path => $hookPath ]); } - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_post_write, [ - \OC\Files\Filesystem::signal_param_path => $hookPath + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_write, [ + Filesystem::signal_param_path => $hookPath ]); } @@ -453,20 +458,25 @@ class File extends Node implements IFile { // do a if the file did not exist throw new NotFound(); } + $path = ltrim($this->path, '/'); try { - $res = $this->fileView->fopen(ltrim($this->path, '/'), 'rb'); + $res = $this->fileView->fopen($path, 'rb'); } catch (\Exception $e) { $this->convertToSabreException($e); } if ($res === false) { - throw new ServiceUnavailable($this->l10n->t('Could not open file')); + if ($this->fileView->file_exists($path)) { + throw new ServiceUnavailable($this->l10n->t('Could not open file: %1$s, file does seem to exist', [$path])); + } else { + throw new ServiceUnavailable($this->l10n->t('Could not open file: %1$s, file doesn\'t seem to exist', [$path])); + } } // comparing current file size with the one in DB // if different, fix DB and refresh cache. if ($this->getSize() !== $this->fileView->filesize($this->getPath())) { - $logger = \OC::$server->get(LoggerInterface::class); + $logger = Server::get(LoggerInterface::class); $logger->warning('fixing cached size of file id=' . $this->getId()); $this->getFileInfo()->getStorage()->getUpdater()->update($this->getFileInfo()->getInternalPath()); @@ -525,17 +535,16 @@ class File extends Node implements IFile { if ($this->request->getMethod() === 'PROPFIND') { return $mimeType; } - return \OC::$server->getMimeTypeDetector()->getSecureMimeType($mimeType); + return Server::get(IMimeTypeDetector::class)->getSecureMimeType($mimeType); } /** * @return array|bool */ public function getDirectDownload() { - if (\OCP\Server::get(\OCP\App\IAppManager::class)->isEnabledForUser('encryption')) { + if (Server::get(IAppManager::class)->isEnabledForUser('encryption')) { return []; } - /** @var \OCP\Files\Storage $storage */ [$storage, $internalPath] = $this->fileView->resolvePath($this->path); if (is_null($storage)) { return []; diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index 3b96f67a82b..843383a0452 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -8,8 +8,16 @@ namespace OCA\DAV\Connector\Sabre; use OC\AppFramework\Http\Request; +use OC\FilesMetadata\Model\FilesMetadata; +use OC\User\NoUserException; +use OCA\DAV\Connector\Sabre\Exception\InvalidPath; +use OCA\Files_Sharing\External\Mount as SharingExternalMount; +use OCP\Accounts\IAccountManager; use OCP\Constants; use OCP\Files\ForbiddenException; +use OCP\Files\IFilenameValidator; +use OCP\Files\InvalidPathException; +use OCP\Files\Storage\ISharedStorage; use OCP\Files\StorageNotAvailableException; use OCP\FilesMetadata\Exceptions\FilesMetadataException; use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException; @@ -19,6 +27,7 @@ use OCP\IConfig; use OCP\IPreview; use OCP\IRequest; use OCP\IUserSession; +use OCP\L10N\IFactory; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\IFile; @@ -53,11 +62,12 @@ class FilesPlugin extends ServerPlugin { public const HAS_PREVIEW_PROPERTYNAME = '{http://nextcloud.org/ns}has-preview'; public const MOUNT_TYPE_PROPERTYNAME = '{http://nextcloud.org/ns}mount-type'; public const MOUNT_ROOT_PROPERTYNAME = '{http://nextcloud.org/ns}is-mount-root'; - public const IS_ENCRYPTED_PROPERTYNAME = '{http://nextcloud.org/ns}is-encrypted'; + public const IS_FEDERATED_PROPERTYNAME = '{http://nextcloud.org/ns}is-federated'; public const METADATA_ETAG_PROPERTYNAME = '{http://nextcloud.org/ns}metadata_etag'; public const UPLOAD_TIME_PROPERTYNAME = '{http://nextcloud.org/ns}upload_time'; public const CREATION_TIME_PROPERTYNAME = '{http://nextcloud.org/ns}creation_time'; public const SHARE_NOTE = '{http://nextcloud.org/ns}note'; + public const SHARE_HIDE_DOWNLOAD_PROPERTYNAME = '{http://nextcloud.org/ns}hide-download'; public const SUBFOLDER_COUNT_PROPERTYNAME = '{http://nextcloud.org/ns}contained-folder-count'; public const SUBFILE_COUNT_PROPERTYNAME = '{http://nextcloud.org/ns}contained-file-count'; public const FILE_METADATA_PREFIX = '{http://nextcloud.org/ns}metadata-'; @@ -65,33 +75,28 @@ class FilesPlugin extends ServerPlugin { /** Reference to main server object */ private ?Server $server = null; - private Tree $tree; - private IUserSession $userSession; /** - * Whether this is public webdav. - * If true, some returned information will be stripped off. + * @param Tree $tree + * @param IConfig $config + * @param IRequest $request + * @param IPreview $previewManager + * @param IUserSession $userSession + * @param bool $isPublic Whether this is public WebDAV. If true, some returned information will be stripped off. + * @param bool $downloadAttachment + * @return void */ - private bool $isPublic; - private bool $downloadAttachment; - private IConfig $config; - private IRequest $request; - private IPreview $previewManager; - - public function __construct(Tree $tree, - IConfig $config, - IRequest $request, - IPreview $previewManager, - IUserSession $userSession, - bool $isPublic = false, - bool $downloadAttachment = true) { - $this->tree = $tree; - $this->config = $config; - $this->request = $request; - $this->userSession = $userSession; - $this->isPublic = $isPublic; - $this->downloadAttachment = $downloadAttachment; - $this->previewManager = $previewManager; + public function __construct( + private Tree $tree, + private IConfig $config, + private IRequest $request, + private IPreview $previewManager, + private IUserSession $userSession, + private IFilenameValidator $validator, + private IAccountManager $accountManager, + private bool $isPublic = false, + private bool $downloadAttachment = true, + ) { } /** @@ -121,7 +126,7 @@ class FilesPlugin extends ServerPlugin { $server->protectedProperties[] = self::DATA_FINGERPRINT_PROPERTYNAME; $server->protectedProperties[] = self::HAS_PREVIEW_PROPERTYNAME; $server->protectedProperties[] = self::MOUNT_TYPE_PROPERTYNAME; - $server->protectedProperties[] = self::IS_ENCRYPTED_PROPERTYNAME; + $server->protectedProperties[] = self::IS_FEDERATED_PROPERTYNAME; $server->protectedProperties[] = self::SHARE_NOTE; // normally these cannot be changed (RFC4918), but we want them modifiable through PROPPATCH @@ -135,40 +140,79 @@ class FilesPlugin extends ServerPlugin { $this->server->on('afterWriteContent', [$this, 'sendFileIdHeader']); $this->server->on('afterMethod:GET', [$this,'httpGet']); $this->server->on('afterMethod:GET', [$this, 'handleDownloadToken']); - $this->server->on('afterResponse', function ($request, ResponseInterface $response) { + $this->server->on('afterResponse', function ($request, ResponseInterface $response): void { $body = $response->getBody(); if (is_resource($body)) { fclose($body); } }); $this->server->on('beforeMove', [$this, 'checkMove']); + $this->server->on('beforeCopy', [$this, 'checkCopy']); } /** - * Plugin that checks if a move can actually be performed. + * Plugin that checks if a copy can actually be performed. * * @param string $source source path - * @param string $destination destination path - * @throws Forbidden - * @throws NotFound + * @param string $target target path + * @throws NotFound If the source does not exist + * @throws InvalidPath If the target is invalid */ - public function checkMove($source, $destination) { + public function checkCopy($source, $target): void { $sourceNode = $this->tree->getNodeForPath($source); if (!$sourceNode instanceof Node) { return; } - [$sourceDir,] = \Sabre\Uri\split($source); - [$destinationDir,] = \Sabre\Uri\split($destination); - if ($sourceDir !== $destinationDir) { - $sourceNodeFileInfo = $sourceNode->getFileInfo(); - if ($sourceNodeFileInfo === null) { - throw new NotFound($source . ' does not exist'); + // Ensure source exists + $sourceNodeFileInfo = $sourceNode->getFileInfo(); + if ($sourceNodeFileInfo === null) { + throw new NotFound($source . ' does not exist'); + } + // Ensure the target name is valid + try { + [$targetPath, $targetName] = \Sabre\Uri\split($target); + $this->validator->validateFilename($targetName); + } catch (InvalidPathException $e) { + throw new InvalidPath($e->getMessage(), false); + } + // Ensure the target path is valid + $segments = array_slice(explode('/', $targetPath), 2); + foreach ($segments as $segment) { + if ($this->validator->isFilenameValid($segment) === false) { + $l = \OCP\Server::get(IFactory::class)->get('dav'); + throw new InvalidPath($l->t('Invalid target path')); } + } + } - if (!$sourceNodeFileInfo->isDeletable()) { - throw new Forbidden($source . " cannot be deleted"); - } + /** + * Plugin that checks if a move can actually be performed. + * + * @param string $source source path + * @param string $target target path + * @throws Forbidden If the source is not deletable + * @throws NotFound If the source does not exist + * @throws InvalidPath If the target name is invalid + */ + public function checkMove(string $source, string $target): void { + $sourceNode = $this->tree->getNodeForPath($source); + if (!$sourceNode instanceof Node) { + return; + } + + // First check copyable (move only needs additional delete permission) + $this->checkCopy($source, $target); + + // The source needs to be deletable for moving + $sourceNodeFileInfo = $sourceNode->getFileInfo(); + if (!$sourceNodeFileInfo->isDeletable()) { + throw new Forbidden($source . ' cannot be deleted'); + } + + // The source is not allowed to be the parent of the target + if (str_starts_with($source, $target . '/')) { + throw new Forbidden($source . ' cannot be moved to it\'s parent'); } } @@ -213,8 +257,8 @@ class FilesPlugin extends ServerPlugin { // adds a 'Content-Disposition: attachment' header in case no disposition // header has been set before - if ($this->downloadAttachment && - $response->getHeader('Content-Disposition') === null) { + if ($this->downloadAttachment + && $response->getHeader('Content-Disposition') === null) { $filename = $node->getName(); if ($this->request->isUserAgent( [ @@ -229,7 +273,7 @@ class FilesPlugin extends ServerPlugin { } } - if ($node instanceof \OCA\DAV\Connector\Sabre\File) { + if ($node instanceof File) { //Add OC-Checksum header $checksum = $node->getChecksum(); if ($checksum !== null && $checksum !== '') { @@ -249,7 +293,7 @@ class FilesPlugin extends ServerPlugin { public function handleGetProperties(PropFind $propFind, \Sabre\DAV\INode $node) { $httpRequest = $this->server->httpRequest; - if ($node instanceof \OCA\DAV\Connector\Sabre\Node) { + if ($node instanceof Node) { /** * This was disabled, because it made dir listing throw an exception, * so users were unable to navigate into folders where one subitem @@ -320,9 +364,32 @@ class FilesPlugin extends ServerPlugin { $owner = $node->getOwner(); if (!$owner) { return null; - } else { + } + + // Get current user to see if we're in a public share or not + $user = $this->userSession->getUser(); + + // If the user is logged in, we can return the display name + if ($user !== null) { + return $owner->getDisplayName(); + } + + // Check if the user published their display name + try { + $ownerAccount = $this->accountManager->getAccount($owner); + } catch (NoUserException) { + // do not lock process if owner is not local + return null; + } + + $ownerNameProperty = $ownerAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME); + + // Since we are not logged in, we need to have at least the published scope + if ($ownerNameProperty->getScope() === IAccountManager::SCOPE_PUBLISHED) { return $owner->getDisplayName(); } + + return null; }); $propFind->handle(self::HAS_PREVIEW_PROPERTYNAME, function () use ($node) { @@ -345,17 +412,27 @@ class FilesPlugin extends ServerPlugin { return $node->getNode()->getInternalPath() === '' ? 'true' : 'false'; }); - $propFind->handle(self::SHARE_NOTE, function () use ($node, $httpRequest): ?string { + $propFind->handle(self::SHARE_NOTE, function () use ($node): ?string { $user = $this->userSession->getUser(); - if ($user === null) { - return null; - } return $node->getNoteFromShare( - $user->getUID() + $user?->getUID() ); }); - $propFind->handle(self::DATA_FINGERPRINT_PROPERTYNAME, function () use ($node) { + $propFind->handle(self::SHARE_HIDE_DOWNLOAD_PROPERTYNAME, function () use ($node) { + $storage = $node->getNode()->getStorage(); + if ($storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ + return match($storage->getShare()->getHideDownload()) { + true => 'true', + false => 'false', + }; + } else { + return null; + } + }); + + $propFind->handle(self::DATA_FINGERPRINT_PROPERTYNAME, function () { return $this->config->getSystemValue('data-fingerprint', ''); }); $propFind->handle(self::CREATIONDATE_PROPERTYNAME, function () use ($node) { @@ -386,9 +463,14 @@ class FilesPlugin extends ServerPlugin { $propFind->handle(self::DISPLAYNAME_PROPERTYNAME, function () use ($node) { return $node->getName(); }); + + $propFind->handle(self::IS_FEDERATED_PROPERTYNAME, function () use ($node) { + return $node->getFileInfo()->getMountPoint() + instanceof SharingExternalMount; + }); } - if ($node instanceof \OCA\DAV\Connector\Sabre\File) { + if ($node instanceof File) { $propFind->handle(self::DOWNLOADURL_PROPERTYNAME, function () use ($node) { try { $directDownloadUrl = $node->getDirectDownload(); @@ -422,10 +504,6 @@ class FilesPlugin extends ServerPlugin { return $node->getSize(); }); - $propFind->handle(self::IS_ENCRYPTED_PROPERTYNAME, function () use ($node) { - return $node->getFileInfo()->isEncrypted() ? '1' : '0'; - }); - $requestProperties = $propFind->getRequestedProperties(); if (in_array(self::SUBFILE_COUNT_PROPERTYNAME, $requestProperties, true) @@ -463,8 +541,8 @@ class FilesPlugin extends ServerPlugin { $ocmPermissions[] = 'read'; } - if (($ncPermissions & Constants::PERMISSION_CREATE) || - ($ncPermissions & Constants::PERMISSION_UPDATE)) { + if (($ncPermissions & Constants::PERMISSION_CREATE) + || ($ncPermissions & Constants::PERMISSION_UPDATE)) { $ocmPermissions[] = 'write'; } @@ -481,7 +559,7 @@ class FilesPlugin extends ServerPlugin { */ public function handleUpdateProperties($path, PropPatch $propPatch) { $node = $this->tree->getNodeForPath($path); - if (!($node instanceof \OCA\DAV\Connector\Sabre\Node)) { + if (!($node instanceof Node)) { return; } @@ -510,7 +588,7 @@ class FilesPlugin extends ServerPlugin { if (empty($time)) { return false; } - $node->setCreationTime((int) $time); + $node->setCreationTime((int)$time); return true; }); @@ -552,7 +630,9 @@ class FilesPlugin extends ServerPlugin { $propPatch->handle( $mutation, function (mixed $value) use ($accessRight, $knownMetadata, $node, $mutation, $filesMetadataManager): bool { + /** @var FilesMetadata $metadata */ $metadata = $filesMetadataManager->getMetadata((int)$node->getFileId(), true); + $metadata->setStorageId($node->getNode()->getStorage()->getCache()->getNumericStorageId()); $metadataKey = substr($mutation, strlen(self::FILE_METADATA_PREFIX)); // confirm metadata key is editable via PROPPATCH @@ -560,6 +640,12 @@ class FilesPlugin extends ServerPlugin { throw new FilesMetadataException('you do not have enough rights to update \'' . $metadataKey . '\' on this node'); } + if ($value === null) { + $metadata->unset($metadataKey); + $filesMetadataManager->saveMetadata($metadata); + return true; + } + // If the metadata is unknown, it defaults to string. try { $type = $knownMetadata->getType($metadataKey); @@ -633,8 +719,6 @@ class FilesPlugin extends ServerPlugin { return IMetadataValueWrapper::EDIT_REQ_READ_PERMISSION; } - - /** * @param string $filePath * @param ?\Sabre\DAV\INode $node @@ -643,15 +727,15 @@ class FilesPlugin extends ServerPlugin { */ public function sendFileIdHeader($filePath, ?\Sabre\DAV\INode $node = null) { // we get the node for the given $filePath here because in case of afterCreateFile $node is the parent folder - if (!$this->server->tree->nodeExists($filePath)) { - return; - } - $node = $this->server->tree->getNodeForPath($filePath); - if ($node instanceof \OCA\DAV\Connector\Sabre\Node) { - $fileId = $node->getFileId(); - if (!is_null($fileId)) { - $this->server->httpResponse->setHeader('OC-FileId', $fileId); + try { + $node = $this->server->tree->getNodeForPath($filePath); + if ($node instanceof Node) { + $fileId = $node->getFileId(); + if (!is_null($fileId)) { + $this->server->httpResponse->setHeader('OC-FileId', $fileId); + } } + } catch (NotFound) { } } } diff --git a/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php b/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php index 0a978e099dc..b59d1373af5 100644 --- a/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php @@ -8,7 +8,9 @@ namespace OCA\DAV\Connector\Sabre; use OC\Files\View; +use OCA\Circles\Api\v1\Circles; use OCP\App\IAppManager; +use OCP\AppFramework\Http; use OCP\Files\Folder; use OCP\Files\Node as INode; use OCP\IGroupManager; @@ -41,55 +43,8 @@ class FilesReportPlugin extends ServerPlugin { private $server; /** - * @var Tree - */ - private $tree; - - /** - * @var View - */ - private $fileView; - - /** - * @var ISystemTagManager - */ - private $tagManager; - - /** - * @var ISystemTagObjectMapper - */ - private $tagMapper; - - /** - * Manager for private tags - * - * @var ITagManager - */ - private $fileTagger; - - /** - * @var IUserSession - */ - private $userSession; - - /** - * @var IGroupManager - */ - private $groupManager; - - /** - * @var Folder - */ - private $userFolder; - - /** - * @var IAppManager - */ - private $appManager; - - /** * @param Tree $tree - * @param View $view + * @param View $fileView * @param ISystemTagManager $tagManager * @param ISystemTagObjectMapper $tagMapper * @param ITagManager $fileTagger manager for private tags @@ -98,25 +53,20 @@ class FilesReportPlugin extends ServerPlugin { * @param Folder $userFolder * @param IAppManager $appManager */ - public function __construct(Tree $tree, - View $view, - ISystemTagManager $tagManager, - ISystemTagObjectMapper $tagMapper, - ITagManager $fileTagger, - IUserSession $userSession, - IGroupManager $groupManager, - Folder $userFolder, - IAppManager $appManager + public function __construct( + private Tree $tree, + private View $fileView, + private ISystemTagManager $tagManager, + private ISystemTagObjectMapper $tagMapper, + /** + * Manager for private tags + */ + private ITagManager $fileTagger, + private IUserSession $userSession, + private IGroupManager $groupManager, + private Folder $userFolder, + private IAppManager $appManager, ) { - $this->tree = $tree; - $this->fileView = $view; - $this->tagManager = $tagManager; - $this->tagMapper = $tagMapper; - $this->fileTagger = $fileTagger; - $this->userSession = $userSession; - $this->groupManager = $groupManager; - $this->userFolder = $userFolder; - $this->appManager = $appManager; } /** @@ -206,7 +156,7 @@ class FilesReportPlugin extends ServerPlugin { // to user backends. I.e. the final result may return more results than requested. $resultNodes = $this->processFilterRulesForFileNodes($filterRules, $limit ?? null, $offset ?? null); } catch (TagNotFoundException $e) { - throw new PreconditionFailed('Cannot filter by non-existing tag', 0, $e); + throw new PreconditionFailed('Cannot filter by non-existing tag'); } $results = []; @@ -234,7 +184,7 @@ class FilesReportPlugin extends ServerPlugin { new MultiStatus($responses) ); - $this->server->httpResponse->setStatus(207); + $this->server->httpResponse->setStatus(Http::STATUS_MULTI_STATUS); $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); $this->server->httpResponse->setBody($xml); @@ -355,7 +305,7 @@ class FilesReportPlugin extends ServerPlugin { if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) { return []; } - return \OCA\Circles\Api\v1\Circles::getFilesForCircles($circlesIds); + return Circles::getFilesForCircles($circlesIds); } @@ -363,7 +313,7 @@ class FilesReportPlugin extends ServerPlugin { * Prepare propfind response for the given nodes * * @param string $filesUri $filesUri URI leading to root of the files URI, - * with a leading slash but no trailing slash + * with a leading slash but no trailing slash * @param string[] $requestedProps requested properties * @param Node[] nodes nodes for which to fetch and prepare responses * @return Response[] @@ -410,7 +360,7 @@ class FilesReportPlugin extends ServerPlugin { $results = []; foreach ($fileIds as $fileId) { - $entry = $folder->getFirstNodeById($fileId); + $entry = $folder->getFirstNodeById((int)$fileId); if ($entry) { $results[] = $this->wrapNode($entry); } @@ -419,7 +369,7 @@ class FilesReportPlugin extends ServerPlugin { return $results; } - protected function wrapNode(\OCP\Files\Node $node): File|Directory { + protected function wrapNode(INode $node): File|Directory { if ($node instanceof \OCP\Files\File) { return new File($this->fileView, $node); } else { diff --git a/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php b/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php index 8d23a05a3ec..d5ab7f09dfa 100644 --- a/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php +++ b/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php @@ -16,10 +16,7 @@ use Sabre\DAV\ServerPlugin; class MaintenancePlugin extends ServerPlugin { - /** @var IConfig */ - private $config; - - /** @var \OCP\IL10N */ + /** @var IL10N */ private $l10n; /** @@ -32,8 +29,10 @@ class MaintenancePlugin extends ServerPlugin { /** * @param IConfig $config */ - public function __construct(IConfig $config, IL10N $l10n) { - $this->config = $config; + public function __construct( + private IConfig $config, + IL10N $l10n, + ) { $this->l10n = \OC::$server->getL10N('dav'); } diff --git a/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php b/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php index efed6ce09f8..e18ef58149a 100644 --- a/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php +++ b/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-only diff --git a/apps/dav/lib/Connector/Sabre/Node.php b/apps/dav/lib/Connector/Sabre/Node.php index 63e453c86af..505e6b5eda4 100644 --- a/apps/dav/lib/Connector/Sabre/Node.php +++ b/apps/dav/lib/Connector/Sabre/Node.php @@ -12,20 +12,20 @@ use OC\Files\Node\File; use OC\Files\Node\Folder; use OC\Files\View; use OCA\DAV\Connector\Sabre\Exception\InvalidPath; +use OCP\Constants; use OCP\Files\DavUtil; use OCP\Files\FileInfo; +use OCP\Files\InvalidPathException; use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\ISharedStorage; use OCP\Files\StorageNotAvailableException; +use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; abstract class Node implements \Sabre\DAV\INode { /** - * @var View - */ - protected $fileView; - - /** * The path to the current node * * @var string @@ -51,21 +51,24 @@ abstract class Node implements \Sabre\DAV\INode { /** * Sets up the node, expects a full path name */ - public function __construct(View $view, FileInfo $info, ?IManager $shareManager = null) { - $this->fileView = $view; + public function __construct( + protected View $fileView, + FileInfo $info, + ?IManager $shareManager = null, + ) { $this->path = $this->fileView->getRelativePath($info->getPath()); $this->info = $info; if ($shareManager) { $this->shareManager = $shareManager; } else { - $this->shareManager = \OC::$server->getShareManager(); + $this->shareManager = Server::get(\OCP\Share\IManager::class); } if ($info instanceof Folder || $info instanceof File) { $this->node = $info; } else { // The Node API assumes that the view passed doesn't have a fake root - $rootView = \OC::$server->get(View::class); - $root = \OC::$server->get(IRootFolder::class); + $rootView = Server::get(View::class); + $root = Server::get(IRootFolder::class); if ($info->getType() === FileInfo::TYPE_FOLDER) { $this->node = new Folder($root, $rootView, $this->fileView->getAbsolutePath($this->path), $info); } else { @@ -77,11 +80,11 @@ abstract class Node implements \Sabre\DAV\INode { protected function refreshInfo(): void { $info = $this->fileView->getFileInfo($this->path); if ($info === false) { - throw new \Sabre\DAV\Exception('Failed to get fileinfo for '. $this->path); + throw new \Sabre\DAV\Exception('Failed to get fileinfo for ' . $this->path); } $this->info = $info; - $root = \OC::$server->get(IRootFolder::class); - $rootView = \OC::$server->get(View::class); + $root = Server::get(IRootFolder::class); + $rootView = Server::get(View::class); if ($this->info->getType() === FileInfo::TYPE_FOLDER) { $this->node = new Folder($root, $rootView, $this->path, $this->info); } else { @@ -115,21 +118,21 @@ abstract class Node implements \Sabre\DAV\INode { * @throws \Sabre\DAV\Exception\Forbidden */ public function setName($name) { - // rename is only allowed if the update privilege is granted - if (!($this->info->isUpdateable() || ($this->info->getMountPoint() instanceof MoveableMount && $this->info->getInternalPath() === ''))) { + // rename is only allowed if the delete privilege is granted + // (basically rename is a copy with delete of the original node) + if (!($this->info->isDeletable() || ($this->info->getMountPoint() instanceof MoveableMount && $this->info->getInternalPath() === ''))) { throw new \Sabre\DAV\Exception\Forbidden(); } [$parentPath,] = \Sabre\Uri\split($this->path); [, $newName] = \Sabre\Uri\split($name); + $newPath = $parentPath . '/' . $newName; // verify path of the target - $this->verifyPath(); - - $newPath = $parentPath . '/' . $newName; + $this->verifyPath($newPath); if (!$this->fileView->rename($this->path, $newPath)) { - throw new \Sabre\DAV\Exception('Failed to rename '. $this->path . ' to ' . $newPath); + throw new \Sabre\DAV\Exception('Failed to rename ' . $this->path . ' to ' . $newPath); } $this->path = $newPath; @@ -261,8 +264,8 @@ abstract class Node implements \Sabre\DAV\INode { $storage = null; } - if ($storage && $storage->instanceOfStorage('\OCA\Files_Sharing\SharedStorage')) { - /** @var \OCA\Files_Sharing\SharedStorage $storage */ + if ($storage && $storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ $permissions = (int)$storage->getShare()->getPermissions(); } else { $permissions = $this->info->getPermissions(); @@ -280,15 +283,15 @@ abstract class Node implements \Sabre\DAV\INode { } if (!$mountpoint->getOption('readonly', false) && $mountpointpath === $this->info->getPath()) { - $permissions |= \OCP\Constants::PERMISSION_DELETE | \OCP\Constants::PERMISSION_UPDATE; + $permissions |= Constants::PERMISSION_DELETE | Constants::PERMISSION_UPDATE; } } /* * Files can't have create or delete permissions */ - if ($this->info->getType() === \OCP\Files\FileInfo::TYPE_FILE) { - $permissions &= ~(\OCP\Constants::PERMISSION_CREATE | \OCP\Constants::PERMISSION_DELETE); + if ($this->info->getType() === FileInfo::TYPE_FILE) { + $permissions &= ~(Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE); } return $permissions; @@ -298,16 +301,15 @@ abstract class Node implements \Sabre\DAV\INode { * @return array */ public function getShareAttributes(): array { - $attributes = []; - try { - $storage = $this->info->getStorage(); - } catch (StorageNotAvailableException $e) { - $storage = null; + $storage = $this->node->getStorage(); + } catch (NotFoundException $e) { + return []; } - if ($storage && $storage->instanceOfStorage(\OCA\Files_Sharing\SharedStorage::class)) { - /** @var \OCA\Files_Sharing\SharedStorage $storage */ + $attributes = []; + if ($storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ $attributes = $storage->getShare()->getAttributes(); if ($attributes === null) { return []; @@ -319,29 +321,24 @@ abstract class Node implements \Sabre\DAV\INode { return $attributes; } - /** - * @param string $user - * @return string - */ - public function getNoteFromShare($user) { - if ($user === null) { - return ''; + public function getNoteFromShare(?string $user): ?string { + try { + $storage = $this->node->getStorage(); + } catch (NotFoundException) { + return null; } - // Retrieve note from the share object already loaded into - // memory, to avoid additional database queries. - $storage = $this->getNode()->getStorage(); - if (!$storage->instanceOfStorage(\OCA\Files_Sharing\SharedStorage::class)) { - return ''; + if ($storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ + $share = $storage->getShare(); + if ($user === $share->getShareOwner()) { + // Note is only for recipient not the owner + return null; + } + return $share->getNote(); } - /** @var \OCA\Files_Sharing\SharedStorage $storage */ - $share = $storage->getShare(); - $note = $share->getNote(); - if ($share->getShareOwner() !== $user) { - return $note; - } - return ''; + return null; } /** @@ -355,11 +352,14 @@ abstract class Node implements \Sabre\DAV\INode { return $this->info->getOwner(); } - protected function verifyPath() { + protected function verifyPath(?string $path = null): void { try { - $fileName = basename($this->info->getPath()); - $this->fileView->verifyPath($this->path, $fileName); - } catch (\OCP\Files\InvalidPathException $ex) { + $path = $path ?? $this->info->getPath(); + $this->fileView->verifyPath( + dirname($path), + basename($path), + ); + } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage()); } } @@ -393,7 +393,7 @@ abstract class Node implements \Sabre\DAV\INode { return $this->node; } - protected function sanitizeMtime($mtimeFromRequest) { + protected function sanitizeMtime(string $mtimeFromRequest): int { return MtimeSanitizer::sanitizeMtime($mtimeFromRequest); } } diff --git a/apps/dav/lib/Connector/Sabre/ObjectTree.php b/apps/dav/lib/Connector/Sabre/ObjectTree.php index 7ac5b476154..bfbdfb33db0 100644 --- a/apps/dav/lib/Connector/Sabre/ObjectTree.php +++ b/apps/dav/lib/Connector/Sabre/ObjectTree.php @@ -9,10 +9,14 @@ namespace OCA\DAV\Connector\Sabre; use OC\Files\FileInfo; use OC\Files\Storage\FailedStorage; +use OC\Files\Storage\Storage; +use OC\Files\View; use OCA\DAV\Connector\Sabre\Exception\FileLocked; use OCA\DAV\Connector\Sabre\Exception\Forbidden; use OCA\DAV\Connector\Sabre\Exception\InvalidPath; use OCP\Files\ForbiddenException; +use OCP\Files\InvalidPathException; +use OCP\Files\Mount\IMountManager; use OCP\Files\StorageInvalidException; use OCP\Files\StorageNotAvailableException; use OCP\Lock\LockedException; @@ -20,12 +24,12 @@ use OCP\Lock\LockedException; class ObjectTree extends CachingTree { /** - * @var \OC\Files\View + * @var View */ protected $fileView; /** - * @var \OCP\Files\Mount\IMountManager + * @var IMountManager */ protected $mountManager; @@ -37,10 +41,10 @@ class ObjectTree extends CachingTree { /** * @param \Sabre\DAV\INode $rootNode - * @param \OC\Files\View $view - * @param \OCP\Files\Mount\IMountManager $mountManager + * @param View $view + * @param IMountManager $mountManager */ - public function init(\Sabre\DAV\INode $rootNode, \OC\Files\View $view, \OCP\Files\Mount\IMountManager $mountManager) { + public function init(\Sabre\DAV\INode $rootNode, View $view, IMountManager $mountManager) { $this->rootNode = $rootNode; $this->fileView = $view; $this->mountManager = $mountManager; @@ -70,7 +74,7 @@ class ObjectTree extends CachingTree { if ($path) { try { $this->fileView->verifyPath($path, basename($path)); - } catch (\OCP\Files\InvalidPathException $ex) { + } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage()); } } @@ -88,7 +92,7 @@ class ObjectTree extends CachingTree { $internalPath = $mount->getInternalPath($absPath); if ($storage && $storage->file_exists($internalPath)) { /** - * @var \OC\Files\Storage\Storage $storage + * @var Storage $storage */ // get data directly $data = $storage->getMetaData($internalPath); @@ -120,9 +124,9 @@ class ObjectTree extends CachingTree { } if ($info->getType() === 'dir') { - $node = new \OCA\DAV\Connector\Sabre\Directory($this->fileView, $info, $this); + $node = new Directory($this->fileView, $info, $this); } else { - $node = new \OCA\DAV\Connector\Sabre\File($this->fileView, $info); + $node = new File($this->fileView, $info); } $this->cache[$path] = $node; @@ -169,7 +173,7 @@ class ObjectTree extends CachingTree { [$destinationDir, $destinationName] = \Sabre\Uri\split($destinationPath); try { $this->fileView->verifyPath($destinationDir, $destinationName); - } catch (\OCP\Files\InvalidPathException $ex) { + } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage()); } diff --git a/apps/dav/lib/Connector/Sabre/Principal.php b/apps/dav/lib/Connector/Sabre/Principal.php index f09ff4def64..d6ea9fd887d 100644 --- a/apps/dav/lib/Connector/Sabre/Principal.php +++ b/apps/dav/lib/Connector/Sabre/Principal.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. @@ -7,7 +8,9 @@ namespace OCA\DAV\Connector\Sabre; use OC\KnownUser\KnownUserService; +use OCA\Circles\Api\v1\Circles; use OCA\Circles\Exceptions\CircleNotFoundException; +use OCA\Circles\Model\Circle; use OCA\DAV\CalDAV\Proxy\ProxyMapper; use OCA\DAV\Traits\PrincipalProxyTrait; use OCP\Accounts\IAccountManager; @@ -30,24 +33,6 @@ use Sabre\DAVACL\PrincipalBackend\BackendInterface; class Principal implements BackendInterface { - /** @var IUserManager */ - private $userManager; - - /** @var IGroupManager */ - private $groupManager; - - /** @var IAccountManager */ - private $accountManager; - - /** @var IShareManager */ - private $shareManager; - - /** @var IUserSession */ - private $userSession; - - /** @var IAppManager */ - private $appManager; - /** @var string */ private $principalPrefix; @@ -57,40 +42,25 @@ class Principal implements BackendInterface { /** @var bool */ private $hasCircles; - /** @var ProxyMapper */ - private $proxyMapper; - /** @var KnownUserService */ private $knownUserService; - /** @var IConfig */ - private $config; - /** @var IFactory */ - private $languageFactory; - - public function __construct(IUserManager $userManager, - IGroupManager $groupManager, - IAccountManager $accountManager, - IShareManager $shareManager, - IUserSession $userSession, - IAppManager $appManager, - ProxyMapper $proxyMapper, + public function __construct( + private IUserManager $userManager, + private IGroupManager $groupManager, + private IAccountManager $accountManager, + private IShareManager $shareManager, + private IUserSession $userSession, + private IAppManager $appManager, + private ProxyMapper $proxyMapper, KnownUserService $knownUserService, - IConfig $config, - IFactory $languageFactory, - string $principalPrefix = 'principals/users/') { - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->accountManager = $accountManager; - $this->shareManager = $shareManager; - $this->userSession = $userSession; - $this->appManager = $appManager; + private IConfig $config, + private IFactory $languageFactory, + string $principalPrefix = 'principals/users/', + ) { $this->principalPrefix = trim($principalPrefix, '/'); $this->hasGroups = $this->hasCircles = ($principalPrefix === 'principals/users/'); - $this->proxyMapper = $proxyMapper; $this->knownUserService = $knownUserService; - $this->config = $config; - $this->languageFactory = $languageFactory; } use PrincipalProxyTrait { @@ -180,7 +150,12 @@ class Principal implements BackendInterface { } elseif ($prefix === 'principals/system') { return [ 'uri' => 'principals/system/' . $name, - '{DAV:}displayname' => $this->languageFactory->get('dav')->t("Accounts"), + '{DAV:}displayname' => $this->languageFactory->get('dav')->t('Accounts'), + ]; + } elseif ($prefix === 'principals/shares') { + return [ + 'uri' => 'principals/shares/' . $name, + '{DAV:}displayname' => $name, ]; } return null; @@ -211,6 +186,9 @@ class Principal implements BackendInterface { if ($this->hasGroups || $needGroups) { $userGroups = $this->groupManager->getUserGroups($user); foreach ($userGroups as $userGroup) { + if ($userGroup->hideFromCollaboration()) { + continue; + } $groups[] = 'principals/groups/' . urlencode($userGroup->getGID()); } } @@ -530,7 +508,7 @@ class Principal implements BackendInterface { } try { - $circle = \OCA\Circles\Api\v1\Circles::detailsCircle($circleUniqueId, true); + $circle = Circles::detailsCircle($circleUniqueId, true); } catch (QueryException $ex) { return null; } catch (CircleNotFoundException $ex) { @@ -555,7 +533,7 @@ class Principal implements BackendInterface { * @param string $principal * @return array * @throws Exception - * @throws \OCP\AppFramework\QueryException + * @throws QueryException * @suppress PhanUndeclaredClassMethod */ public function getCircleMembership($principal):array { @@ -570,10 +548,10 @@ class Principal implements BackendInterface { throw new Exception('Principal not found'); } - $circles = \OCA\Circles\Api\v1\Circles::joinedCircles($name, true); + $circles = Circles::joinedCircles($name, true); $circles = array_map(function ($circle) { - /** @var \OCA\Circles\Model\Circle $circle */ + /** @var Circle $circle */ return 'principals/circles/' . urlencode($circle->getSingleId()); }, $circles); diff --git a/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php new file mode 100644 index 00000000000..130d4562146 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types = 1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\DAV\Server as SabreServer; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * This plugin runs after requests and logs an error if a plugin is detected + * to be doing too many SQL requests. + */ +class PropFindMonitorPlugin extends ServerPlugin { + + /** + * A Plugin can scan up to this amount of nodes without an error being + * reported. + */ + public const THRESHOLD_NODES = 50; + + /** + * A plugin can use up to this amount of queries per node. + */ + public const THRESHOLD_QUERY_FACTOR = 1; + + private SabreServer $server; + + public function initialize(SabreServer $server): void { + $this->server = $server; + $this->server->on('afterResponse', [$this, 'afterResponse']); + } + + public function afterResponse( + RequestInterface $request, + ResponseInterface $response): void { + if (!$this->server instanceof Server) { + return; + } + + $pluginQueries = $this->server->getPluginQueries(); + if (empty($pluginQueries)) { + return; + } + $maxDepth = max(0, ...array_keys($pluginQueries)); + // entries at the top are usually not interesting + unset($pluginQueries[$maxDepth]); + + $logger = $this->server->getLogger(); + foreach ($pluginQueries as $depth => $propFinds) { + foreach ($propFinds as $pluginName => $propFind) { + [ + 'queries' => $queries, + 'nodes' => $nodes + ] = $propFind; + if ($queries === 0 || $nodes > $queries || $nodes < self::THRESHOLD_NODES + || $queries < $nodes * self::THRESHOLD_QUERY_FACTOR) { + continue; + } + $logger->error( + '{name} scanned {scans} nodes with {count} queries in depth {depth}/{maxDepth}. This is bad for performance, please report to the plugin developer!', [ + 'name' => $pluginName, + 'scans' => $nodes, + 'count' => $queries, + 'depth' => $depth, + 'maxDepth' => $maxDepth, + ] + ); + } + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/PublicAuth.php b/apps/dav/lib/Connector/Sabre/PublicAuth.php index 3e2cd81a800..2ca1c25e2f6 100644 --- a/apps/dav/lib/Connector/Sabre/PublicAuth.php +++ b/apps/dav/lib/Connector/Sabre/PublicAuth.php @@ -11,9 +11,12 @@ declare(strict_types=1); namespace OCA\DAV\Connector\Sabre; +use OCP\Defaults; use OCP\IRequest; use OCP\ISession; +use OCP\IURLGenerator; use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\Bruteforce\MaxDelayReached; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; use OCP\Share\IShare; @@ -21,6 +24,7 @@ use Psr\Log\LoggerInterface; use Sabre\DAV\Auth\Backend\AbstractBasic; use Sabre\DAV\Exception\NotAuthenticated; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Exception\PreconditionFailed; use Sabre\DAV\Exception\ServiceUnavailable; use Sabre\HTTP; use Sabre\HTTP\RequestInterface; @@ -36,40 +40,33 @@ class PublicAuth extends AbstractBasic { public const DAV_AUTHENTICATED = 'public_link_authenticated'; private ?IShare $share = null; - private IManager $shareManager; - private ISession $session; - private IRequest $request; - private IThrottler $throttler; - private LoggerInterface $logger; - - public function __construct(IRequest $request, - IManager $shareManager, - ISession $session, - IThrottler $throttler, - LoggerInterface $logger) { - $this->request = $request; - $this->shareManager = $shareManager; - $this->session = $session; - $this->throttler = $throttler; - $this->logger = $logger; + public function __construct( + private IRequest $request, + private IManager $shareManager, + private ISession $session, + private IThrottler $throttler, + private LoggerInterface $logger, + private IURLGenerator $urlGenerator, + ) { // setup realm - $defaults = new \OCP\Defaults(); + $defaults = new Defaults(); $this->realm = $defaults->getName(); } /** - * @param RequestInterface $request - * @param ResponseInterface $response - * - * @return array * @throws NotAuthenticated + * @throws MaxDelayReached * @throws ServiceUnavailable */ public function check(RequestInterface $request, ResponseInterface $response): array { try { $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION); + if (count($_COOKIE) > 0 && !$this->request->passesStrictCookieCheck() && $this->getShare()->getPassword() !== null) { + throw new PreconditionFailed('Strict cookie check failed'); + } + $auth = new HTTP\Auth\Basic( $this->realm, $request, @@ -83,7 +80,17 @@ class PublicAuth extends AbstractBasic { } return $this->checkToken(); - } catch (NotAuthenticated $e) { + } catch (NotAuthenticated|MaxDelayReached $e) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + throw $e; + } catch (PreconditionFailed $e) { + $response->setHeader( + 'Location', + $this->urlGenerator->linkToRoute( + 'files_sharing.share.showShare', + [ 'token' => $this->getToken() ], + ), + ); throw $e; } catch (\Exception $e) { $class = get_class($e); @@ -95,14 +102,13 @@ class PublicAuth extends AbstractBasic { /** * Extract token from request url - * @return string * @throws NotFound */ private function getToken(): string { $path = $this->request->getPathInfo() ?: ''; // ['', 'dav', 'files', 'token'] $splittedPath = explode('/', $path); - + if (count($splittedPath) < 4 || $splittedPath[3] === '') { throw new NotFound(); } @@ -112,7 +118,7 @@ class PublicAuth extends AbstractBasic { /** * Check token validity - * @return array + * * @throws NotFound * @throws NotAuthenticated */ @@ -160,15 +166,13 @@ class PublicAuth extends AbstractBasic { protected function validateUserPass($username, $password) { $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION); - $token = $this->getToken(); try { - $share = $this->shareManager->getShareByToken($token); + $share = $this->getShare(); } catch (ShareNotFound $e) { $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); return false; } - $this->share = $share; \OC_User::setIncognitoMode(true); // check if the share is password protected @@ -184,7 +188,7 @@ class PublicAuth extends AbstractBasic { } return true; } - + if ($this->session->exists(PublicAuth::DAV_AUTHENTICATED) && $this->session->get(PublicAuth::DAV_AUTHENTICATED) === $share->getId()) { return true; @@ -211,7 +215,13 @@ class PublicAuth extends AbstractBasic { } public function getShare(): IShare { - assert($this->share !== null); + $token = $this->getToken(); + + if ($this->share === null) { + $share = $this->shareManager->getShareByToken($token); + $this->share = $share; + } + return $this->share; } } diff --git a/apps/dav/lib/Connector/Sabre/QuotaPlugin.php b/apps/dav/lib/Connector/Sabre/QuotaPlugin.php index deaa4cf672b..bbb378edc9b 100644 --- a/apps/dav/lib/Connector/Sabre/QuotaPlugin.php +++ b/apps/dav/lib/Connector/Sabre/QuotaPlugin.php @@ -8,12 +8,15 @@ */ namespace OCA\DAV\Connector\Sabre; +use OC\Files\View; use OCA\DAV\Upload\FutureFile; use OCA\DAV\Upload\UploadFolder; use OCP\Files\StorageNotAvailableException; use Sabre\DAV\Exception\InsufficientStorage; use Sabre\DAV\Exception\ServiceUnavailable; use Sabre\DAV\INode; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; /** * This plugin check user quota and deny creating files when they exceeds the quota. @@ -23,9 +26,6 @@ use Sabre\DAV\INode; * @license http://code.google.com/p/sabredav/wiki/License Modified BSD License */ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { - /** @var \OC\Files\View */ - private $view; - /** * Reference to main server object * @@ -34,10 +34,11 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { private $server; /** - * @param \OC\Files\View $view + * @param View $view */ - public function __construct($view) { - $this->view = $view; + public function __construct( + private $view, + ) { } /** @@ -56,6 +57,7 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { $server->on('beforeWriteContent', [$this, 'beforeWriteContent'], 10); $server->on('beforeCreateFile', [$this, 'beforeCreateFile'], 10); + $server->on('method:MKCOL', [$this, 'onCreateCollection'], 30); $server->on('beforeMove', [$this, 'beforeMove'], 10); $server->on('beforeCopy', [$this, 'beforeCopy'], 10); } @@ -90,6 +92,31 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { } /** + * Check quota before creating directory + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return bool + * @throws InsufficientStorage + * @throws \Sabre\DAV\Exception\Forbidden + */ + public function onCreateCollection(RequestInterface $request, ResponseInterface $response): bool { + try { + $destinationPath = $this->server->calculateUri($request->getUrl()); + $quotaPath = $this->getPathForDestination($destinationPath); + } catch (\Exception $e) { + return true; + } + if ($quotaPath) { + // MKCOL does not have a Content-Length header, so we can use + // a fixed value for the quota check. + return $this->checkQuota($quotaPath, 4096, true); + } + + return true; + } + + /** * Check quota before writing content * * @param string $uri target file URI @@ -175,7 +202,7 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { * @throws InsufficientStorage * @return bool */ - public function checkQuota(string $path, $length = null) { + public function checkQuota(string $path, $length = null, $isDir = false) { if ($length === null) { $length = $this->getLength(); } @@ -192,6 +219,10 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { $freeSpace = $this->getFreeSpace($path); if ($freeSpace >= 0 && $length > $freeSpace) { + if ($isDir) { + throw new InsufficientStorage("Insufficient space in $path. $freeSpace available. Cannot create directory"); + } + throw new InsufficientStorage("Insufficient space in $path, $length required, $freeSpace available"); } } diff --git a/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php b/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php index f856fca51e1..5484bab9237 100644 --- a/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php +++ b/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php @@ -13,11 +13,9 @@ use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class RequestIdHeaderPlugin extends \Sabre\DAV\ServerPlugin { - /** @var IRequest */ - private $request; - - public function __construct(IRequest $request) { - $this->request = $request; + public function __construct( + private IRequest $request, + ) { } public function initialize(\Sabre\DAV\Server $server) { diff --git a/apps/dav/lib/Connector/Sabre/Server.php b/apps/dav/lib/Connector/Sabre/Server.php index b7ca8a0a1c0..dda9c29b763 100644 --- a/apps/dav/lib/Connector/Sabre/Server.php +++ b/apps/dav/lib/Connector/Sabre/Server.php @@ -7,6 +7,14 @@ */ namespace OCA\DAV\Connector\Sabre; +use OC\DB\Connection; +use Override; +use Sabre\DAV\Exception; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\Version; +use TypeError; + /** * Class \OCA\DAV\Connector\Sabre\Server * @@ -18,6 +26,14 @@ class Server extends \Sabre\DAV\Server { /** @var CachingTree $tree */ /** + * Tracks queries done by plugins. + * @var array<int, array<string, array{nodes:int, queries:int}>> + */ + private array $pluginQueries = []; + + public bool $debugEnabled = false; + + /** * @see \Sabre\DAV\Server */ public function __construct($treeOrNode = null) { @@ -25,4 +41,190 @@ class Server extends \Sabre\DAV\Server { self::$exposeVersion = false; $this->enablePropfindDepthInfinity = true; } + + #[Override] + public function once( + string $eventName, + callable $callBack, + int $priority = 100, + ): void { + $this->debugEnabled ? $this->monitorPropfindQueries( + parent::once(...), + ...func_get_args(), + ) : parent::once(...func_get_args()); + } + + #[Override] + public function on( + string $eventName, + callable $callBack, + int $priority = 100, + ): void { + $this->debugEnabled ? $this->monitorPropfindQueries( + parent::on(...), + ...func_get_args(), + ) : parent::on(...func_get_args()); + } + + /** + * Wraps the handler $callBack into a query-monitoring function and calls + * $parentFn to register it. + */ + private function monitorPropfindQueries( + callable $parentFn, + string $eventName, + callable $callBack, + int $priority = 100, + ): void { + if ($eventName !== 'propFind') { + $parentFn($eventName, $callBack, $priority); + return; + } + + $pluginName = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['class'] ?? 'unknown'; + $callback = $this->getMonitoredCallback($callBack, $pluginName); + + $parentFn($eventName, $callback, $priority); + } + + /** + * Returns a callable that wraps $callBack with code that monitors and + * records queries per plugin. + */ + private function getMonitoredCallback( + callable $callBack, + string $pluginName, + ): callable { + return function (PropFind $propFind, INode $node) use ( + $callBack, + $pluginName, + ) { + $connection = \OCP\Server::get(Connection::class); + $queriesBefore = $connection->getStats()['executed']; + $result = $callBack($propFind, $node); + $queriesAfter = $connection->getStats()['executed']; + $this->trackPluginQueries( + $pluginName, + $queriesAfter - $queriesBefore, + $propFind->getDepth() + ); + + return $result; + }; + } + + /** + * Tracks the queries executed by a specific plugin. + */ + private function trackPluginQueries( + string $pluginName, + int $queriesExecuted, + int $depth, + ): void { + // report only nodes which cause queries to the DB + if ($queriesExecuted === 0) { + return; + } + + $this->pluginQueries[$depth][$pluginName]['nodes'] + = ($this->pluginQueries[$depth][$pluginName]['nodes'] ?? 0) + 1; + + $this->pluginQueries[$depth][$pluginName]['queries'] + = ($this->pluginQueries[$depth][$pluginName]['queries'] ?? 0) + $queriesExecuted; + } + + /** + * + * @return void + */ + public function start() { + try { + // If nginx (pre-1.2) is used as a proxy server, and SabreDAV as an + // origin, we must make sure we send back HTTP/1.0 if this was + // requested. + // This is mainly because nginx doesn't support Chunked Transfer + // Encoding, and this forces the webserver SabreDAV is running on, + // to buffer entire responses to calculate Content-Length. + $this->httpResponse->setHTTPVersion($this->httpRequest->getHTTPVersion()); + + // Setting the base url + $this->httpRequest->setBaseUrl($this->getBaseUri()); + $this->invokeMethod($this->httpRequest, $this->httpResponse); + } catch (\Throwable $e) { + try { + $this->emit('exception', [$e]); + } catch (\Exception) { + } + + if ($e instanceof TypeError) { + /* + * The TypeError includes the file path where the error occurred, + * potentially revealing the installation directory. + */ + $e = new TypeError('A type error occurred. For more details, please refer to the logs, which provide additional context about the type error.'); + } + + $DOM = new \DOMDocument('1.0', 'utf-8'); + $DOM->formatOutput = true; + + $error = $DOM->createElementNS('DAV:', 'd:error'); + $error->setAttribute('xmlns:s', self::NS_SABREDAV); + $DOM->appendChild($error); + + $h = function ($v) { + return htmlspecialchars((string)$v, ENT_NOQUOTES, 'UTF-8'); + }; + + if (self::$exposeVersion) { + $error->appendChild($DOM->createElement('s:sabredav-version', $h(Version::VERSION))); + } + + $error->appendChild($DOM->createElement('s:exception', $h(get_class($e)))); + $error->appendChild($DOM->createElement('s:message', $h($e->getMessage()))); + if ($this->debugExceptions) { + $error->appendChild($DOM->createElement('s:file', $h($e->getFile()))); + $error->appendChild($DOM->createElement('s:line', $h($e->getLine()))); + $error->appendChild($DOM->createElement('s:code', $h($e->getCode()))); + $error->appendChild($DOM->createElement('s:stacktrace', $h($e->getTraceAsString()))); + } + + if ($this->debugExceptions) { + $previous = $e; + while ($previous = $previous->getPrevious()) { + $xPrevious = $DOM->createElement('s:previous-exception'); + $xPrevious->appendChild($DOM->createElement('s:exception', $h(get_class($previous)))); + $xPrevious->appendChild($DOM->createElement('s:message', $h($previous->getMessage()))); + $xPrevious->appendChild($DOM->createElement('s:file', $h($previous->getFile()))); + $xPrevious->appendChild($DOM->createElement('s:line', $h($previous->getLine()))); + $xPrevious->appendChild($DOM->createElement('s:code', $h($previous->getCode()))); + $xPrevious->appendChild($DOM->createElement('s:stacktrace', $h($previous->getTraceAsString()))); + $error->appendChild($xPrevious); + } + } + + if ($e instanceof Exception) { + $httpCode = $e->getHTTPCode(); + $e->serialize($this, $error); + $headers = $e->getHTTPHeaders($this); + } else { + $httpCode = 500; + $headers = []; + } + $headers['Content-Type'] = 'application/xml; charset=utf-8'; + + $this->httpResponse->setStatus($httpCode); + $this->httpResponse->setHeaders($headers); + $this->httpResponse->setBody($DOM->saveXML()); + $this->sapi->sendResponse($this->httpResponse); + } + } + + /** + * Returns queries executed by registered plugins. + * + * @return array<int, array<string, array{nodes:int, queries:int}>> + */ + public function getPluginQueries(): array { + return $this->pluginQueries; + } } diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index b07cbc904da..38fd057bc91 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -7,84 +7,106 @@ */ namespace OCA\DAV\Connector\Sabre; +use OC\Files\View; +use OC\KnownUser\KnownUserService; use OCA\DAV\AppInfo\PluginManager; +use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\CalDAV\Proxy\ProxyMapper; +use OCA\DAV\DAV\CustomPropertiesBackend; use OCA\DAV\DAV\ViewOnlyPlugin; +use OCA\DAV\Db\PropertyMapper; use OCA\DAV\Files\BrowserErrorPagePlugin; +use OCA\DAV\Files\Sharing\RootCollection; +use OCA\DAV\Upload\CleanupService; +use OCA\Theming\ThemingDefaults; +use OCP\Accounts\IAccountManager; +use OCP\App\IAppManager; +use OCP\Comments\ICommentsManager; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Folder; +use OCP\Files\IFilenameValidator; +use OCP\Files\IRootFolder; use OCP\Files\Mount\IMountManager; use OCP\IConfig; use OCP\IDBConnection; +use OCP\IGroupManager; use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; use OCP\ITagManager; +use OCP\IUserManager; use OCP\IUserSession; use OCP\SabrePluginEvent; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; use Psr\Log\LoggerInterface; use Sabre\DAV\Auth\Plugin; +use Sabre\DAV\SimpleCollection; class ServerFactory { - private IConfig $config; - private LoggerInterface $logger; - private IDBConnection $databaseConnection; - private IUserSession $userSession; - private IMountManager $mountManager; - private ITagManager $tagManager; - private IRequest $request; - private IPreview $previewManager; - private IEventDispatcher $eventDispatcher; - private IL10N $l10n; public function __construct( - IConfig $config, - LoggerInterface $logger, - IDBConnection $databaseConnection, - IUserSession $userSession, - IMountManager $mountManager, - ITagManager $tagManager, - IRequest $request, - IPreview $previewManager, - IEventDispatcher $eventDispatcher, - IL10N $l10n + private IConfig $config, + private LoggerInterface $logger, + private IDBConnection $databaseConnection, + private IUserSession $userSession, + private IMountManager $mountManager, + private ITagManager $tagManager, + private IRequest $request, + private IPreview $previewManager, + private IEventDispatcher $eventDispatcher, + private IL10N $l10n, ) { - $this->config = $config; - $this->logger = $logger; - $this->databaseConnection = $databaseConnection; - $this->userSession = $userSession; - $this->mountManager = $mountManager; - $this->tagManager = $tagManager; - $this->request = $request; - $this->previewManager = $previewManager; - $this->eventDispatcher = $eventDispatcher; - $this->l10n = $l10n; } /** * @param callable $viewCallBack callback that should return the view for the dav endpoint */ - public function createServer(string $baseUri, + public function createServer( + bool $isPublicShare, + string $baseUri, string $requestUri, Plugin $authPlugin, - callable $viewCallBack): Server { + callable $viewCallBack, + ): Server { + $debugEnabled = $this->config->getSystemValue('debug', false); // Fire up server - $objectTree = new \OCA\DAV\Connector\Sabre\ObjectTree(); - $server = new \OCA\DAV\Connector\Sabre\Server($objectTree); + if ($isPublicShare) { + $rootCollection = new SimpleCollection('root'); + $tree = new CachingTree($rootCollection); + } else { + $rootCollection = null; + $tree = new ObjectTree(); + } + $server = new Server($tree); // Set URL explicitly due to reverse-proxy situations $server->httpRequest->setUrl($requestUri); $server->setBaseUri($baseUri); // Load plugins - $server->addPlugin(new \OCA\DAV\Connector\Sabre\MaintenancePlugin($this->config, $this->l10n)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin($this->config)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin()); + $server->addPlugin(new MaintenancePlugin($this->config, $this->l10n)); + $server->addPlugin(new BlockLegacyClientPlugin( + $this->config, + \OCP\Server::get(ThemingDefaults::class), + )); + $server->addPlugin(new AnonymousOptionsPlugin()); $server->addPlugin($authPlugin); + if ($debugEnabled) { + $server->debugEnabled = $debugEnabled; + $server->addPlugin(new PropFindMonitorPlugin()); + } // FIXME: The following line is a workaround for legacy components relying on being able to send a GET to / - $server->addPlugin(new \OCA\DAV\Connector\Sabre\DummyGetResponsePlugin()); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin('webdav', $this->logger)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\LockPlugin()); + $server->addPlugin(new DummyGetResponsePlugin()); + $server->addPlugin(new ExceptionLoggerPlugin('webdav', $this->logger)); + $server->addPlugin(new LockPlugin()); + + $server->addPlugin(new RequestIdHeaderPlugin($this->request)); - $server->addPlugin(new RequestIdHeaderPlugin(\OC::$server->get(IRequest::class))); + $server->addPlugin(new ZipFolderPlugin( + $tree, + $this->logger, + $this->eventDispatcher, + )); // Some WebDAV clients do require Class 2 WebDAV support (locking), since // we do not provide locking we emulate it using a fake locking plugin. @@ -93,7 +115,7 @@ class ServerFactory { '/OneNote/', '/Microsoft-WebDAV-MiniRedir/', ])) { - $server->addPlugin(new \OCA\DAV\Connector\Sabre\FakeLockerPlugin()); + $server->addPlugin(new FakeLockerPlugin()); } if (BrowserErrorPagePlugin::isBrowserRequest($this->request)) { @@ -101,11 +123,12 @@ class ServerFactory { } // wait with registering these until auth is handled and the filesystem is setup - $server->on('beforeMethod:*', function () use ($server, $objectTree, $viewCallBack) { + $server->on('beforeMethod:*', function () use ($server, $tree, + $viewCallBack, $isPublicShare, $rootCollection, $debugEnabled): void { // ensure the skeleton is copied $userFolder = \OC::$server->getUserFolder(); - /** @var \OC\Files\View $view */ + /** @var View $view */ $view = $viewCallBack($server); if ($userFolder instanceof Folder && $userFolder->getPath() === $view->getRoot()) { $rootInfo = $userFolder; @@ -115,25 +138,61 @@ class ServerFactory { // Create Nextcloud Dir if ($rootInfo->getType() === 'dir') { - $root = new \OCA\DAV\Connector\Sabre\Directory($view, $rootInfo, $objectTree); + $root = new Directory($view, $rootInfo, $tree); + } else { + $root = new File($view, $rootInfo); + } + + if ($isPublicShare) { + $userPrincipalBackend = new Principal( + \OCP\Server::get(IUserManager::class), + \OCP\Server::get(IGroupManager::class), + \OCP\Server::get(IAccountManager::class), + \OCP\Server::get(\OCP\Share\IManager::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(IAppManager::class), + \OCP\Server::get(ProxyMapper::class), + \OCP\Server::get(KnownUserService::class), + \OCP\Server::get(IConfig::class), + \OC::$server->getL10NFactory(), + ); + + // Mount the share collection at /public.php/dav/shares/<share token> + $rootCollection->addChild(new RootCollection( + $root, + $userPrincipalBackend, + 'principals/shares', + )); + + // Mount the upload collection at /public.php/dav/uploads/<share token> + $rootCollection->addChild(new \OCA\DAV\Upload\RootCollection( + $userPrincipalBackend, + 'principals/shares', + \OCP\Server::get(CleanupService::class), + \OCP\Server::get(IRootFolder::class), + \OCP\Server::get(IUserSession::class), + \OCP\Server::get(\OCP\Share\IManager::class), + )); } else { - $root = new \OCA\DAV\Connector\Sabre\File($view, $rootInfo); + /** @var ObjectTree $tree */ + $tree->init($root, $view, $this->mountManager); } - $objectTree->init($root, $view, $this->mountManager); $server->addPlugin( - new \OCA\DAV\Connector\Sabre\FilesPlugin( - $objectTree, + new FilesPlugin( + $tree, $this->config, $this->request, $this->previewManager, $this->userSession, + \OCP\Server::get(IFilenameValidator::class), + \OCP\Server::get(IAccountManager::class), false, - !$this->config->getSystemValue('debug', false) + !$debugEnabled ) ); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\QuotaPlugin($view, true)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\ChecksumUpdatePlugin()); + $server->addPlugin(new QuotaPlugin($view)); + $server->addPlugin(new ChecksumUpdatePlugin()); // Allow view-only plugin for webdav requests $server->addPlugin(new ViewOnlyPlugin( @@ -141,45 +200,47 @@ class ServerFactory { )); if ($this->userSession->isLoggedIn()) { - $server->addPlugin(new \OCA\DAV\Connector\Sabre\TagsPlugin($objectTree, $this->tagManager)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\SharesPlugin( - $objectTree, + $server->addPlugin(new TagsPlugin($tree, $this->tagManager, $this->eventDispatcher, $this->userSession)); + $server->addPlugin(new SharesPlugin( + $tree, $this->userSession, $userFolder, - \OC::$server->getShareManager() + \OCP\Server::get(\OCP\Share\IManager::class) )); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\CommentPropertiesPlugin(\OC::$server->getCommentsManager(), $this->userSession)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\FilesReportPlugin( - $objectTree, + $server->addPlugin(new CommentPropertiesPlugin(\OCP\Server::get(ICommentsManager::class), $this->userSession)); + $server->addPlugin(new FilesReportPlugin( + $tree, $view, - \OC::$server->getSystemTagManager(), - \OC::$server->getSystemTagObjectMapper(), - \OC::$server->getTagManager(), + \OCP\Server::get(ISystemTagManager::class), + \OCP\Server::get(ISystemTagObjectMapper::class), + \OCP\Server::get(ITagManager::class), $this->userSession, - \OC::$server->getGroupManager(), + \OCP\Server::get(IGroupManager::class), $userFolder, - \OC::$server->getAppManager() + \OCP\Server::get(IAppManager::class) )); // custom properties plugin must be the last one $server->addPlugin( new \Sabre\DAV\PropertyStorage\Plugin( - new \OCA\DAV\DAV\CustomPropertiesBackend( + new CustomPropertiesBackend( $server, - $objectTree, + $tree, $this->databaseConnection, - $this->userSession->getUser() + $this->userSession->getUser(), + \OCP\Server::get(PropertyMapper::class), + \OCP\Server::get(DefaultCalendarValidator::class), ) ) ); } - $server->addPlugin(new \OCA\DAV\Connector\Sabre\CopyEtagHeaderPlugin()); + $server->addPlugin(new CopyEtagHeaderPlugin()); // Load dav plugins from apps $event = new SabrePluginEvent($server); $this->eventDispatcher->dispatchTyped($event); $pluginManager = new PluginManager( \OC::$server, - \OC::$server->getAppManager() + \OCP\Server::get(IAppManager::class) ); foreach ($pluginManager->getAppPlugins() as $appPlugin) { $server->addPlugin($appPlugin); diff --git a/apps/dav/lib/Connector/Sabre/ShareTypeList.php b/apps/dav/lib/Connector/Sabre/ShareTypeList.php index abe56cd0301..0b66ed27576 100644 --- a/apps/dav/lib/Connector/Sabre/ShareTypeList.php +++ b/apps/dav/lib/Connector/Sabre/ShareTypeList.php @@ -20,17 +20,14 @@ class ShareTypeList implements Element { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * Share types - * - * @var int[] - */ - private $shareTypes; - - /** * @param int[] $shareTypes */ - public function __construct($shareTypes) { - $this->shareTypes = $shareTypes; + public function __construct( + /** + * Share types + */ + private $shareTypes, + ) { } /** diff --git a/apps/dav/lib/Connector/Sabre/ShareeList.php b/apps/dav/lib/Connector/Sabre/ShareeList.php index b8f9f98d1c7..909c29fc24b 100644 --- a/apps/dav/lib/Connector/Sabre/ShareeList.php +++ b/apps/dav/lib/Connector/Sabre/ShareeList.php @@ -18,11 +18,10 @@ use Sabre\Xml\XmlSerializable; class ShareeList implements XmlSerializable { public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; - /** @var IShare[] */ - private $shares; - - public function __construct(array $shares) { - $this->shares = $shares; + public function __construct( + /** @var IShare[] */ + private array $shares, + ) { } /** diff --git a/apps/dav/lib/Connector/Sabre/SharesPlugin.php b/apps/dav/lib/Connector/Sabre/SharesPlugin.php index 20e94e9aede..f49e85333f3 100644 --- a/apps/dav/lib/Connector/Sabre/SharesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/SharesPlugin.php @@ -164,7 +164,7 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { */ public function handleGetProperties( PropFind $propFind, - \Sabre\DAV\INode $sabreNode + \Sabre\DAV\INode $sabreNode, ) { if (!($sabreNode instanceof DavNode)) { return; @@ -176,8 +176,8 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { if ($sabreNode instanceof Directory && $propFind->getDepth() !== 0 && ( - !is_null($propFind->getStatus(self::SHARETYPES_PROPERTYNAME)) || - !is_null($propFind->getStatus(self::SHAREES_PROPERTYNAME)) + !is_null($propFind->getStatus(self::SHARETYPES_PROPERTYNAME)) + || !is_null($propFind->getStatus(self::SHAREES_PROPERTYNAME)) ) ) { $folderNode = $sabreNode->getNode(); diff --git a/apps/dav/lib/Connector/Sabre/TagList.php b/apps/dav/lib/Connector/Sabre/TagList.php index 5537acc452c..9a5cd0d51cf 100644 --- a/apps/dav/lib/Connector/Sabre/TagList.php +++ b/apps/dav/lib/Connector/Sabre/TagList.php @@ -20,17 +20,14 @@ class TagList implements Element { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * tags - * - * @var array - */ - private $tags; - - /** * @param array $tags */ - public function __construct(array $tags) { - $this->tags = $tags; + public function __construct( + /** + * tags + */ + private array $tags, + ) { } /** diff --git a/apps/dav/lib/Connector/Sabre/TagsPlugin.php b/apps/dav/lib/Connector/Sabre/TagsPlugin.php index d7572c46ed3..25c1633df36 100644 --- a/apps/dav/lib/Connector/Sabre/TagsPlugin.php +++ b/apps/dav/lib/Connector/Sabre/TagsPlugin.php @@ -27,7 +27,10 @@ namespace OCA\DAV\Connector\Sabre; * License along with this library. If not, see <http://www.gnu.org/licenses/>. * */ - +use OCP\EventDispatcher\IEventDispatcher; +use OCP\ITagManager; +use OCP\ITags; +use OCP\IUserSession; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; @@ -47,12 +50,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { private $server; /** - * @var \OCP\ITagManager - */ - private $tagManager; - - /** - * @var \OCP\ITags + * @var ITags */ private $tagger; @@ -65,17 +63,15 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { private $cachedTags; /** - * @var \Sabre\DAV\Tree - */ - private $tree; - - /** * @param \Sabre\DAV\Tree $tree tree - * @param \OCP\ITagManager $tagManager tag manager + * @param ITagManager $tagManager tag manager */ - public function __construct(\Sabre\DAV\Tree $tree, \OCP\ITagManager $tagManager) { - $this->tree = $tree; - $this->tagManager = $tagManager; + public function __construct( + private \Sabre\DAV\Tree $tree, + private ITagManager $tagManager, + private IEventDispatcher $eventDispatcher, + private IUserSession $userSession, + ) { $this->tagger = null; $this->cachedTags = []; } @@ -98,12 +94,13 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { $this->server = $server; $this->server->on('propFind', [$this, 'handleGetProperties']); $this->server->on('propPatch', [$this, 'handleUpdateProperties']); + $this->server->on('preloadProperties', [$this, 'handlePreloadProperties']); } /** * Returns the tagger * - * @return \OCP\ITags tagger + * @return ITags tagger */ private function getTagger() { if (!$this->tagger) { @@ -117,7 +114,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { * * @param integer $fileId file id * @return array list($tags, $favorite) with $tags as tag array - * and $favorite is a boolean whether the file was favorited + * and $favorite is a boolean whether the file was favorited */ private function getTagsAndFav($fileId) { $isFav = false; @@ -154,6 +151,24 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { } /** + * Prefetches tags for a list of file IDs and caches the results + * + * @param array $fileIds List of file IDs to prefetch tags for + * @return void + */ + private function prefetchTagsForFileIds(array $fileIds) { + $tags = $this->getTagger()->getTagsForObjects($fileIds); + if ($tags === false) { + // the tags API returns false on error... + $tags = []; + } + + foreach ($fileIds as $fileId) { + $this->cachedTags[$fileId] = $tags[$fileId] ?? []; + } + } + + /** * Updates the tags of the given file id * * @param int $fileId @@ -189,36 +204,25 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { */ public function handleGetProperties( PropFind $propFind, - \Sabre\DAV\INode $node + \Sabre\DAV\INode $node, ) { - if (!($node instanceof \OCA\DAV\Connector\Sabre\Node)) { + if (!($node instanceof Node)) { return; } // need prefetch ? - if ($node instanceof \OCA\DAV\Connector\Sabre\Directory + if ($node instanceof Directory && $propFind->getDepth() !== 0 && (!is_null($propFind->getStatus(self::TAGS_PROPERTYNAME)) || !is_null($propFind->getStatus(self::FAVORITE_PROPERTYNAME)) )) { // note: pre-fetching only supported for depth <= 1 $folderContent = $node->getChildren(); - $fileIds[] = (int)$node->getId(); + $fileIds = [(int)$node->getId()]; foreach ($folderContent as $info) { $fileIds[] = (int)$info->getId(); } - $tags = $this->getTagger()->getTagsForObjects($fileIds); - if ($tags === false) { - // the tags API returns false on error... - $tags = []; - } - - $this->cachedTags = $this->cachedTags + $tags; - $emptyFileIds = array_diff($fileIds, array_keys($tags)); - // also cache the ones that were not found - foreach ($emptyFileIds as $fileId) { - $this->cachedTags[$fileId] = []; - } + $this->prefetchTagsForFileIds($fileIds); } $isFav = null; @@ -250,7 +254,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { */ public function handleUpdateProperties($path, PropPatch $propPatch) { $node = $this->tree->getNodeForPath($path); - if (!($node instanceof \OCA\DAV\Connector\Sabre\Node)) { + if (!($node instanceof Node)) { return; } @@ -259,7 +263,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { return true; }); - $propPatch->handle(self::FAVORITE_PROPERTYNAME, function ($favState) use ($node) { + $propPatch->handle(self::FAVORITE_PROPERTYNAME, function ($favState) use ($node, $path) { if ((int)$favState === 1 || $favState === 'true') { $this->getTagger()->tagAs($node->getId(), self::TAG_FAVORITE); } else { @@ -274,4 +278,14 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { return 200; }); } + + public function handlePreloadProperties(array $nodes, array $requestProperties): void { + if ( + !in_array(self::FAVORITE_PROPERTYNAME, $requestProperties, true) + && !in_array(self::TAGS_PROPERTYNAME, $requestProperties, true) + ) { + return; + } + $this->prefetchTagsForFileIds(array_map(fn ($node) => $node->getId(), $nodes)); + } } diff --git a/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php new file mode 100644 index 00000000000..f198519b454 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php @@ -0,0 +1,193 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use OC\Streamer; +use OCA\DAV\Connector\Sabre\Exception\Forbidden; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Events\BeforeZipCreatedEvent; +use OCP\Files\File as NcFile; +use OCP\Files\Folder as NcFolder; +use OCP\Files\Node as NcNode; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\DAV\Tree; +use Sabre\HTTP\Request; +use Sabre\HTTP\Response; + +/** + * This plugin allows to download folders accessed by GET HTTP requests on DAV. + * The WebDAV standard explicitly say that GET is not covered and should return what ever the application thinks would be a good representation. + * + * When a collection is accessed using GET, this will provide the content as a archive. + * The type can be set by the `Accept` header (MIME type of zip or tar), or as browser fallback using a `accept` GET parameter. + * It is also possible to only include some child nodes (from the collection it self) by providing a `filter` GET parameter or `X-NC-Files` custom header. + */ +class ZipFolderPlugin extends ServerPlugin { + + /** + * Reference to main server object + */ + private ?Server $server = null; + + public function __construct( + private Tree $tree, + private LoggerInterface $logger, + private IEventDispatcher $eventDispatcher, + ) { + } + + /** + * This initializes the plugin. + * + * This function is called by \Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + */ + public function initialize(Server $server): void { + $this->server = $server; + $this->server->on('method:GET', $this->handleDownload(...), 100); + // low priority to give any other afterMethod:* a chance to fire before we cancel everything + $this->server->on('afterMethod:GET', $this->afterDownload(...), 999); + } + + /** + * Adding a node to the archive streamer. + * This will recursively add new nodes to the stream if the node is a directory. + */ + protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath): void { + // Remove the root path from the filename to make it relative to the requested folder + $filename = str_replace($rootPath, '', $node->getPath()); + + $mtime = $node->getMTime(); + if ($node instanceof NcFile) { + $resource = $node->fopen('rb'); + if ($resource === false) { + $this->logger->info('Cannot read file for zip stream', ['filePath' => $node->getPath()]); + throw new \Sabre\DAV\Exception\ServiceUnavailable('Requested file can currently not be accessed.'); + } + $streamer->addFileFromStream($resource, $filename, $node->getSize(), $mtime); + } elseif ($node instanceof NcFolder) { + $streamer->addEmptyDir($filename, $mtime); + $content = $node->getDirectoryListing(); + foreach ($content as $subNode) { + $this->streamNode($streamer, $subNode, $rootPath); + } + } + } + + /** + * Download a folder as an archive. + * It is possible to filter / limit the files that should be downloaded, + * either by passing (multiple) `X-NC-Files: the-file` headers + * or by setting a `files=JSON_ARRAY_OF_FILES` URL query. + * + * @return false|null + */ + public function handleDownload(Request $request, Response $response): ?bool { + $node = $this->tree->getNodeForPath($request->getPath()); + if (!($node instanceof Directory)) { + // only handle directories + return null; + } + + $query = $request->getQueryParameters(); + + // Get accept header - or if set overwrite with accept GET-param + $accept = $request->getHeaderAsArray('Accept'); + $acceptParam = $query['accept'] ?? ''; + if ($acceptParam !== '') { + $accept = array_map(fn (string $name) => strtolower(trim($name)), explode(',', $acceptParam)); + } + $zipRequest = !empty(array_intersect(['application/zip', 'zip'], $accept)); + $tarRequest = !empty(array_intersect(['application/x-tar', 'tar'], $accept)); + if (!$zipRequest && !$tarRequest) { + // does not accept zip or tar stream + return null; + } + + $files = $request->getHeaderAsArray('X-NC-Files'); + $filesParam = $query['files'] ?? ''; + // The preferred way would be headers, but this is not possible for simple browser requests ("links") + // so we also need to support GET parameters + if ($filesParam !== '') { + $files = json_decode($filesParam); + if (!is_array($files)) { + $files = [$files]; + } + + foreach ($files as $file) { + if (!is_string($file)) { + // we log this as this means either we - or an app - have a bug somewhere or a user is trying invalid things + $this->logger->notice('Invalid files filter parameter for ZipFolderPlugin', ['filter' => $filesParam]); + // no valid parameter so continue with Sabre behavior + return null; + } + } + } + + $folder = $node->getNode(); + $event = new BeforeZipCreatedEvent($folder, $files); + $this->eventDispatcher->dispatchTyped($event); + if ((!$event->isSuccessful()) || $event->getErrorMessage() !== null) { + $errorMessage = $event->getErrorMessage(); + if ($errorMessage === null) { + // Not allowed to download but also no explaining error + // so we abort the ZIP creation and fall back to Sabre default behavior. + return null; + } + // Downloading was denied by an app + throw new Forbidden($errorMessage); + } + + $content = empty($files) ? $folder->getDirectoryListing() : []; + foreach ($files as $path) { + $child = $node->getChild($path); + assert($child instanceof Node); + $content[] = $child->getNode(); + } + + $archiveName = 'download'; + $rootPath = $folder->getPath(); + if (empty($files)) { + // We download the full folder so keep it in the tree + $rootPath = dirname($folder->getPath()); + // Full folder is loaded to rename the archive to the folder name + $archiveName = $folder->getName(); + } + $streamer = new Streamer($tarRequest, -1, count($content)); + $streamer->sendHeaders($archiveName); + // For full folder downloads we also add the folder itself to the archive + if (empty($files)) { + $streamer->addEmptyDir($archiveName); + } + foreach ($content as $node) { + $this->streamNode($streamer, $node, $rootPath); + } + $streamer->finalize(); + return false; + } + + /** + * Tell sabre/dav not to trigger it's own response sending logic as the handleDownload will have already send the response + * + * @return false|null + */ + public function afterDownload(Request $request, Response $response): ?bool { + $node = $this->tree->getNodeForPath($request->getPath()); + if (!($node instanceof Directory)) { + // only handle directories + return null; + } else { + return false; + } + } +} |