diff options
Diffstat (limited to 'apps/dav/lib/Connector')
48 files changed, 7548 insertions, 0 deletions
diff --git a/apps/dav/lib/Connector/LegacyDAVACL.php b/apps/dav/lib/Connector/LegacyDAVACL.php new file mode 100644 index 00000000000..40ce53b8ab0 --- /dev/null +++ b/apps/dav/lib/Connector/LegacyDAVACL.php @@ -0,0 +1,57 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector; + +use OCA\DAV\Connector\Sabre\DavAclPlugin; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAVACL\Xml\Property\Principal; + +class LegacyDAVACL extends DavAclPlugin { + + /** + * @inheritdoc + */ + public function getCurrentUserPrincipals() { + $principalV2 = $this->getCurrentUserPrincipal(); + + if (is_null($principalV2)) { + return []; + } + + $principalV1 = $this->convertPrincipal($principalV2, false); + return array_merge( + [ + $principalV2, + $principalV1 + ], + $this->getPrincipalMembership($principalV1) + ); + } + + private function convertPrincipal($principal, $toV2) { + [, $name] = \Sabre\Uri\split($principal); + if ($toV2) { + return "principals/users/$name"; + } + return "principals/$name"; + } + + public function propFind(PropFind $propFind, INode $node) { + /* Overload current-user-principal */ + $propFind->handle('{DAV:}current-user-principal', function () { + if ($url = parent::getCurrentUserPrincipal()) { + return new Principal(Principal::HREF, $url . '/'); + } else { + return new Principal(Principal::UNAUTHENTICATED); + } + }); + + return parent::propFind($propFind, $node); + } +} diff --git a/apps/dav/lib/Connector/LegacyPublicAuth.php b/apps/dav/lib/Connector/LegacyPublicAuth.php new file mode 100644 index 00000000000..03d18853de0 --- /dev/null +++ b/apps/dav/lib/Connector/LegacyPublicAuth.php @@ -0,0 +1,102 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector; + +use OCA\DAV\Connector\Sabre\PublicAuth; +use OCP\Defaults; +use OCP\IRequest; +use OCP\ISession; +use OCP\Security\Bruteforce\IThrottler; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IManager; +use OCP\Share\IShare; +use Sabre\DAV\Auth\Backend\AbstractBasic; + +/** + * Class PublicAuth + * + * @package OCA\DAV\Connector + */ +class LegacyPublicAuth extends AbstractBasic { + private const BRUTEFORCE_ACTION = 'legacy_public_webdav_auth'; + + private ?IShare $share = null; + + public function __construct( + private IRequest $request, + private IManager $shareManager, + private ISession $session, + private IThrottler $throttler, + ) { + // setup realm + $defaults = new Defaults(); + $this->realm = $defaults->getName() ?: 'Nextcloud'; + } + + /** + * Validates a username and password + * + * This method should return true or false depending on if login + * succeeded. + * + * @param string $username + * @param string $password + * + * @return bool + * @throws \Sabre\DAV\Exception\NotAuthenticated + */ + protected function validateUserPass($username, $password) { + $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION); + + try { + $share = $this->shareManager->getShareByToken($username); + } 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 + if ($share->getPassword() !== null) { + if ($share->getShareType() === IShare::TYPE_LINK + || $share->getShareType() === IShare::TYPE_EMAIL + || $share->getShareType() === IShare::TYPE_CIRCLE) { + if ($this->shareManager->checkPassword($share, $password)) { + return true; + } elseif ($this->session->exists(PublicAuth::DAV_AUTHENTICATED) + && $this->session->get(PublicAuth::DAV_AUTHENTICATED) === $share->getId()) { + return true; + } else { + if (in_array('XMLHttpRequest', explode(',', $this->request->getHeader('X-Requested-With')))) { + // do not re-authenticate over ajax, use dummy auth name to prevent browser popup + http_response_code(401); + header('WWW-Authenticate: DummyBasic realm="' . $this->realm . '"'); + throw new \Sabre\DAV\Exception\NotAuthenticated('Cannot authenticate over ajax calls'); + } + + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } + } elseif ($share->getShareType() === IShare::TYPE_REMOTE) { + return true; + } else { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } + } + return true; + } + + public function getShare(): IShare { + assert($this->share !== null); + return $this->share; + } +} diff --git a/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php b/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php new file mode 100644 index 00000000000..0e2b1c58748 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php @@ -0,0 +1,64 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\DAV\CorePlugin; +use Sabre\DAV\FS\Directory; +use Sabre\DAV\ServerPlugin; +use Sabre\DAV\Tree; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class AnonymousOptionsPlugin extends ServerPlugin { + + /** + * @var \Sabre\DAV\Server + */ + private $server; + + /** + * @param \Sabre\DAV\Server $server + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $this->server = $server; + // before auth + $this->server->on('beforeMethod:*', [$this, 'handleAnonymousOptions'], 9); + } + + /** + * @return bool + */ + public function isRequestInRoot($path) { + return $path === '' || (is_string($path) && !str_contains($path, '/')); + } + + /** + * @throws \Sabre\DAV\Exception\Forbidden + * @return bool + */ + public function handleAnonymousOptions(RequestInterface $request, ResponseInterface $response) { + $isOffice = preg_match('/Microsoft Office/i', $request->getHeader('User-Agent') ?? ''); + $emptyAuth = $request->getHeader('Authorization') === null + || $request->getHeader('Authorization') === '' + || trim($request->getHeader('Authorization')) === 'Bearer'; + $isAnonymousOfficeOption = $request->getMethod() === 'OPTIONS' && $isOffice && $emptyAuth; + $isOfficeHead = $request->getMethod() === 'HEAD' && $isOffice && $emptyAuth; + if ($isAnonymousOfficeOption || $isOfficeHead) { + /** @var CorePlugin $corePlugin */ + $corePlugin = $this->server->getPlugin('core'); + // setup a fake tree for anonymous access + $this->server->tree = new Tree(new Directory('')); + $corePlugin->httpOptions($request, $response); + $this->server->emit('afterMethod:*', [$request, $response]); + $this->server->emit('afterMethod:OPTIONS', [$request, $response]); + + $this->server->sapi->sendResponse($response); + return false; + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php b/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php new file mode 100644 index 00000000000..9cff113140a --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/AppleQuirksPlugin.php @@ -0,0 +1,112 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * A plugin which tries to work-around peculiarities of the MacOS DAV client + * apps. The following problems are addressed: + * + * - OSX calendar client sends REPORT requests to a random principal + * collection but expects to find all principals (forgot to set + * {DAV:}principal-property-search flag?) + */ +class AppleQuirksPlugin extends ServerPlugin { + + /* + private const OSX_CALENDAR_AGENT = 'CalendarAgent'; + private const OSX_DATAACCESSD_AGENT = 'dataaccessd'; + private const OSX_ACCOUNTSD_AGENT = 'accountsd'; + private const OSX_CONTACTS_AGENT = 'AddressBookCore'; + */ + + private const OSX_AGENT_PREFIX = 'macOS'; + + /** @var bool */ + private $isMacOSDavAgent = false; + + /** + * Sets up the plugin. + * + * This method is automatically called by the server class. + * + * @return void + */ + public function initialize(Server $server) { + $server->on('beforeMethod:REPORT', [$this, 'beforeReport'], 0); + $server->on('report', [$this, 'report'], 0); + } + + /** + * Triggered before any method is handled. + * + * @return void + */ + public function beforeReport(RequestInterface $request, ResponseInterface $response) { + $userAgent = $request->getRawServerValue('HTTP_USER_AGENT') ?? 'unknown'; + $this->isMacOSDavAgent = $this->isMacOSUserAgent($userAgent); + } + + /** + * This method handles HTTP REPORT requests. + * + * @param string $reportName + * @param mixed $report + * @param mixed $path + * + * @return bool + */ + public function report($reportName, $report, $path) { + if ($reportName == '{DAV:}principal-property-search' && $this->isMacOSDavAgent) { + /** @var \Sabre\DAVACL\Xml\Request\PrincipalPropertySearchReport $report */ + $report->applyToPrincipalCollectionSet = true; + } + return true; + } + + /** + * Check whether the given $userAgent string pretends to originate from OSX. + * + * @param string $userAgent + * + * @return bool + */ + protected function isMacOSUserAgent(string $userAgent):bool { + return str_starts_with(self::OSX_AGENT_PREFIX, $userAgent); + } + + /** + * Decode the given OSX DAV agent string. + * + * @param string $agent + * + * @return null|array + */ + protected function decodeMacOSAgentString(string $userAgent):?array { + // OSX agent string is like: macOS/13.2.1 (22D68) dataaccessd/1.0 + if (preg_match('|^' . self::OSX_AGENT_PREFIX . '/([0-9]+)\\.([0-9]+)\\.([0-9]+)\s+\((\w+)\)\s+([^/]+)/([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?$|i', $userAgent, $matches)) { + return [ + 'macOSVersion' => [ + 'major' => $matches[1], + 'minor' => $matches[2], + 'patch' => $matches[3], + ], + 'macOSAgent' => $matches[5], + 'macOSAgentVersion' => [ + 'major' => $matches[6], + 'minor' => $matches[7] ?? null, + 'patch' => $matches[8] ?? null, + ], + ]; + } + return null; + } +} diff --git a/apps/dav/lib/Connector/Sabre/Auth.php b/apps/dav/lib/Connector/Sabre/Auth.php new file mode 100644 index 00000000000..a174920946a --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Auth.php @@ -0,0 +1,206 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use Exception; +use OC\Authentication\Exceptions\PasswordLoginForbiddenException; +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; +use Sabre\DAV\Exception\ServiceUnavailable; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class Auth extends AbstractBasic { + public const DAV_AUTHENTICATED = 'AUTHENTICATED_TO_DAV_BACKEND'; + private ?string $currentUser = null; + + 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 Defaults(); + $this->realm = $defaults->getName() ?: 'Nextcloud'; + } + + /** + * Whether the user has initially authenticated via DAV + * + * This is required for WebDAV clients that resent the cookies even when the + * account was changed. + * + * @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; + } + + /** + * Validates a username and password + * + * This method should return true or false depending on if login + * succeeded. + * + * @param string $username + * @param string $password + * @return bool + * @throws PasswordLoginForbidden + */ + protected function validateUserPass($username, $password) { + if ($this->userSession->isLoggedIn() + && $this->isDavAuthenticated($this->userSession->getUser()->getUID()) + ) { + $this->session->close(); + return true; + } else { + try { + if ($this->userSession->logClientIn($username, $password, $this->request, $this->throttler)) { + $this->session->set(self::DAV_AUTHENTICATED, $this->userSession->getUser()->getUID()); + $this->session->close(); + return true; + } else { + $this->session->close(); + return false; + } + } catch (PasswordLoginForbiddenException $ex) { + $this->session->close(); + throw new PasswordLoginForbidden(); + } catch (MaxDelayReached $ex) { + $this->session->close(); + throw new TooManyRequests(); + } + } + } + + /** + * @return array{bool, string} + * @throws NotAuthenticated + * @throws ServiceUnavailable + */ + public function check(RequestInterface $request, ResponseInterface $response) { + try { + return $this->auth($request, $response); + } catch (NotAuthenticated $e) { + throw $e; + } catch (Exception $e) { + $class = get_class($e); + $msg = $e->getMessage(); + Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); + throw new ServiceUnavailable("$class: $msg"); + } + } + + /** + * Checks whether a CSRF check is required on the request + */ + private function requiresCSRFCheck(): bool { + + $methodsWithoutCsrf = ['GET', 'HEAD', 'OPTIONS']; + if (in_array($this->request->getMethod(), $methodsWithoutCsrf)) { + return false; + } + + // Official Nextcloud clients require no checks + if ($this->request->isUserAgent([ + IRequest::USER_AGENT_CLIENT_DESKTOP, + IRequest::USER_AGENT_CLIENT_ANDROID, + IRequest::USER_AGENT_CLIENT_IOS, + ])) { + return false; + } + + // If not logged-in no check is required + if (!$this->userSession->isLoggedIn()) { + return false; + } + + // POST always requires a check + if ($this->request->getMethod() === 'POST') { + return true; + } + + // If logged-in AND DAV authenticated no check is required + if ($this->userSession->isLoggedIn() + && $this->isDavAuthenticated($this->userSession->getUser()->getUID())) { + return false; + } + + return true; + } + + /** + * @return array{bool, string} + * @throws NotAuthenticated + */ + private function auth(RequestInterface $request, ResponseInterface $response): array { + $forcedLogout = false; + + 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(Http::STATUS_UNAUTHORIZED); + throw new \Sabre\DAV\Exception\NotAuthenticated('CSRF check not passed.'); + } + } + + if ($forcedLogout) { + $this->userSession->logout(); + } else { + if ($this->twoFactorManager->needsSecondFactor($this->userSession->getUser())) { + throw new \Sabre\DAV\Exception\NotAuthenticated('2FA challenge not passed.'); + } + if ( + //Fix for broken webdav clients + ($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() + ) { + $user = $this->userSession->getUser()->getUID(); + $this->currentUser = $user; + $this->session->close(); + return [true, $this->principalPrefix . $user]; + } + } + + $data = parent::check($request, $response); + if ($data[0] === true) { + $startPos = strrpos($data[1], '/') + 1; + $user = $this->userSession->getUser()->getUID(); + $data[1] = substr_replace($data[1], $user, $startPos); + } 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(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 new file mode 100644 index 00000000000..23453ae8efb --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -0,0 +1,73 @@ +<?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; +use Sabre\DAV\Auth\Backend\AbstractBearer; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class BearerAuth extends AbstractBearer { + public function __construct( + private IUserSession $userSession, + private ISession $session, + private IRequest $request, + private IConfig $config, + private string $principalPrefix = 'principals/users/', + ) { + // setup realm + $defaults = new Defaults(); + $this->realm = $defaults->getName() ?: 'Nextcloud'; + } + + private function setupUserFs($userId) { + \OC_Util::setupFS($userId); + $this->session->close(); + return $this->principalPrefix . $userId; + } + + /** + * {@inheritdoc} + */ + public function validateBearerToken($bearerToken) { + \OC_Util::setupFS(); + + if (!$this->userSession->isLoggedIn()) { + $this->userSession->tryTokenLogin($this->request); + } + if ($this->userSession->isLoggedIn()) { + return $this->setupUserFs($this->userSession->getUser()->getUID()); + } + + return false; + } + + /** + * \Sabre\DAV\Auth\Backend\AbstractBearer::challenge sets an WWW-Authenticate + * header which some DAV clients can't handle. Thus we override this function + * and make it simply return a 401. + * + * @param RequestInterface $request + * @param ResponseInterface $response + */ + public function challenge(RequestInterface $request, ResponseInterface $response): void { + // 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 new file mode 100644 index 00000000000..21358406a4a --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php @@ -0,0 +1,74 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use OCA\Theming\ThemingDefaults; +use OCP\IConfig; +use OCP\IRequest; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; + +/** + * Class BlockLegacyClientPlugin is used to detect old legacy sync clients and + * returns a 403 status to those clients + * + * @package OCA\DAV\Connector\Sabre + */ +class BlockLegacyClientPlugin extends ServerPlugin { + protected ?Server $server = null; + + public function __construct( + private IConfig $config, + private ThemingDefaults $themingDefaults, + ) { + } + + /** + * @return void + */ + public function initialize(Server $server) { + $this->server = $server; + $this->server->on('beforeMethod:*', [$this, 'beforeHandler'], 200); + } + + /** + * Detects all unsupported clients and throws a \Sabre\DAV\Exception\Forbidden + * exception which will result in a 403 to them. + * @param RequestInterface $request + * @throws \Sabre\DAV\Exception\Forbidden If the client version is not supported + */ + public function beforeHandler(RequestInterface $request) { + $userAgent = $request->getHeader('User-Agent'); + if ($userAgent === null) { + return; + } + + $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 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 new file mode 100644 index 00000000000..5d72b530f58 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/CachingTree.php @@ -0,0 +1,37 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\DAV\Tree; + +class CachingTree extends Tree { + /** + * Store a node in the cache + */ + public function cacheNode(Node $node, ?string $path = null): void { + if (is_null($path)) { + $path = $node->getPath(); + } + $this->cache[trim($path, '/')] = $node; + } + + /** + * @param string $path + * @return void + */ + public function markDirty($path) { + // We don't care enough about sub-paths + // flushing the entire cache + $path = trim($path, '/'); + foreach ($this->cache as $nodePath => $node) { + $nodePath = (string)$nodePath; + if ($path === '' || $nodePath == $path || str_starts_with($nodePath, $path . '/')) { + unset($this->cache[$nodePath]); + } + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/ChecksumList.php b/apps/dav/lib/Connector/Sabre/ChecksumList.php new file mode 100644 index 00000000000..75d1d718de1 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ChecksumList.php @@ -0,0 +1,53 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\Xml\Writer; +use Sabre\Xml\XmlSerializable; + +/** + * Checksumlist property + * + * This property contains multiple "checksum" elements, each containing a + * checksum name. + */ +class ChecksumList implements XmlSerializable { + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + + /** @var string[] of TYPE:CHECKSUM */ + private array $checksums; + + public function __construct(string $checksum) { + $this->checksums = explode(' ', $checksum); + } + + /** + * The xmlSerialize method is called during xml writing. + * + * Use the $writer argument to write its own xml serialization. + * + * An important note: do _not_ create a parent element. Any element + * implementing XmlSerializble should only ever write what's considered + * its 'inner xml'. + * + * The parent of the current element is responsible for writing a + * containing element. + * + * This allows serializers to be re-used for different element names. + * + * If you are opening new elements, you must also close them again. + * + * @param Writer $writer + * @return void + */ + public function xmlSerialize(Writer $writer) { + foreach ($this->checksums as $checksum) { + $writer->writeElement('{' . self::NS_OWNCLOUD . '}checksum', $checksum); + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php new file mode 100644 index 00000000000..18009080585 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Connector\Sabre; + +use OCP\AppFramework\Http; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class ChecksumUpdatePlugin extends ServerPlugin { + protected ?Server $server = null; + + public function initialize(Server $server) { + $this->server = $server; + $server->on('method:PATCH', [$this, 'httpPatch']); + } + + public function getPluginName(): string { + return 'checksumupdate'; + } + + /** @return string[] */ + public function getFeatures(): array { + return ['nextcloud-checksum-update']; + } + + public function httpPatch(RequestInterface $request, ResponseInterface $response) { + $path = $request->getPath(); + + $node = $this->server->tree->getNodeForPath($path); + if ($node instanceof File) { + $type = strtolower( + (string)$request->getHeader('X-Recalculate-Hash') + ); + + $hash = $node->hash($type); + if ($hash) { + $checksum = strtoupper($type) . ':' . $hash; + $node->setChecksum($checksum); + $response->addHeader('OC-Checksum', $checksum); + $response->setHeader('Content-Length', '0'); + $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 new file mode 100644 index 00000000000..ef9bd1ae472 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php @@ -0,0 +1,148 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use OCP\Comments\ICommentsManager; +use OCP\IUserSession; +use Sabre\DAV\ICollection; +use Sabre\DAV\PropFind; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; + +class CommentPropertiesPlugin extends ServerPlugin { + public const PROPERTY_NAME_HREF = '{http://owncloud.org/ns}comments-href'; + public const PROPERTY_NAME_COUNT = '{http://owncloud.org/ns}comments-count'; + public const PROPERTY_NAME_UNREAD = '{http://owncloud.org/ns}comments-unread'; + + protected ?Server $server = null; + private array $cachedUnreadCount = []; + private array $cachedDirectories = []; + + public function __construct( + private ICommentsManager $commentsManager, + private IUserSession $userSession, + ) { + } + + /** + * This initializes the plugin. + * + * This function is called by Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param \Sabre\DAV\Server $server + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $this->server = $server; + + $this->server->on('preloadCollection', $this->preloadCollection(...)); + $this->server->on('propFind', [$this, 'handleGetProperties']); + } + + private function cacheDirectory(Directory $directory): void { + $children = $directory->getChildren(); + + $ids = []; + foreach ($children as $child) { + if (!($child instanceof File || $child instanceof Directory)) { + continue; + } + + $id = $child->getId(); + if ($id === null) { + continue; + } + + $ids[] = (string)$id; + } + + $ids[] = (string)$directory->getId(); + $unread = $this->commentsManager->getNumberOfUnreadCommentsForObjects('files', $ids, $this->userSession->getUser()); + + foreach ($unread as $id => $count) { + $this->cachedUnreadCount[(int)$id] = $count; + } + } + + private function preloadCollection(PropFind $propFind, ICollection $collection): + void { + if (!($collection instanceof Directory)) { + return; + } + + $collectionPath = $collection->getPath(); + if (!isset($this->cachedDirectories[$collectionPath]) && $propFind->getStatus( + self::PROPERTY_NAME_UNREAD + ) !== null) { + $this->cacheDirectory($collection); + $this->cachedDirectories[$collectionPath] = true; + } + } + + /** + * Adds tags and favorites properties to the response, + * if requested. + * + * @param PropFind $propFind + * @param \Sabre\DAV\INode $node + * @return void + */ + public function handleGetProperties( + PropFind $propFind, + \Sabre\DAV\INode $node, + ) { + if (!($node instanceof File) && !($node instanceof Directory)) { + return; + } + + $propFind->handle(self::PROPERTY_NAME_COUNT, function () use ($node): int { + return $this->commentsManager->getNumberOfCommentsForObject('files', (string)$node->getId()); + }); + + $propFind->handle(self::PROPERTY_NAME_HREF, function () use ($node): ?string { + return $this->getCommentsLink($node); + }); + + $propFind->handle(self::PROPERTY_NAME_UNREAD, function () use ($node): ?int { + return $this->cachedUnreadCount[$node->getId()] ?? $this->getUnreadCount($node); + }); + } + + /** + * Returns a reference to the comments node + */ + public function getCommentsLink(Node $node): ?string { + $href = $this->server->getBaseUri(); + $entryPoint = strpos($href, '/remote.php/'); + if ($entryPoint === false) { + // in case we end up somewhere else, unexpectedly. + return null; + } + $commentsPart = 'dav/comments/files/' . rawurldecode((string)$node->getId()); + return substr_replace($href, $commentsPart, $entryPoint + strlen('/remote.php/')); + } + + /** + * Returns the number of unread comments for the currently logged in user + * on the given file or directory node + */ + public function getUnreadCount(Node $node): ?int { + $user = $this->userSession->getUser(); + if (is_null($user)) { + return null; + } + + $lastRead = $this->commentsManager->getReadMark('files', (string)$node->getId(), $user); + + return $this->commentsManager->getNumberOfCommentsForObject('files', (string)$node->getId(), $lastRead); + } +} diff --git a/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php b/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php new file mode 100644 index 00000000000..609ac295b4c --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php @@ -0,0 +1,73 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Server; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * Copies the "Etag" header to "OC-Etag" after any request. + * This is a workaround for setups that automatically strip + * or mangle Etag headers. + */ +class CopyEtagHeaderPlugin extends \Sabre\DAV\ServerPlugin { + private ?Server $server = null; + + /** + * This initializes the plugin. + * + * @param \Sabre\DAV\Server $server Sabre server + * + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $this->server = $server; + + $server->on('afterMethod:*', [$this, 'afterMethod']); + $server->on('afterMove', [$this, 'afterMove']); + } + + /** + * After method, copy the "Etag" header to "OC-Etag" header. + * + * @param RequestInterface $request request + * @param ResponseInterface $response response + */ + public function afterMethod(RequestInterface $request, ResponseInterface $response) { + $eTag = $response->getHeader('Etag'); + if (!empty($eTag)) { + $response->setHeader('OC-ETag', $eTag); + } + } + + /** + * Called after a node is moved. + * + * This allows the backend to move all the associated properties. + * + * @param string $source + * @param string $destination + * @return void + */ + public function afterMove($source, $destination) { + try { + $node = $this->server->tree->getNodeForPath($destination); + } catch (NotFound $e) { + // Don't care + return; + } + + if ($node instanceof File) { + $eTag = $node->getETag(); + $this->server->httpResponse->setHeader('OC-ETag', $eTag); + $this->server->httpResponse->setHeader('ETag', $eTag); + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/DavAclPlugin.php b/apps/dav/lib/Connector/Sabre/DavAclPlugin.php new file mode 100644 index 00000000000..100d719ef01 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/DavAclPlugin.php @@ -0,0 +1,115 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +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; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * Class DavAclPlugin is a wrapper around \Sabre\DAVACL\Plugin that returns 404 + * responses in case the resource to a response has been forbidden instead of + * a 403. This is used to prevent enumeration of valid resources. + * + * @see https://github.com/owncloud/core/issues/22578 + * @package OCA\DAV\Connector\Sabre + */ +class DavAclPlugin extends \Sabre\DAVACL\Plugin { + public function __construct() { + $this->hideNodesFromListings = true; + $this->allowUnauthenticatedAccess = false; + } + + public function checkPrivileges($uri, $privileges, $recursion = self::R_PARENT, $throwExceptions = true) { + $access = parent::checkPrivileges($uri, $privileges, $recursion, false); + if ($access === false && $throwExceptions) { + /** @var INode $node */ + $node = $this->server->tree->getNodeForPath($uri); + + switch (get_class($node)) { + case AddressBook::class: + $type = 'Addressbook'; + break; + case Calendar::class: + case CachedSubscription::class: + $type = 'Calendar'; + break; + default: + $type = 'Node'; + break; + } + + 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; + } + + public function propFind(PropFind $propFind, INode $node) { + if ($node instanceof Node) { + // files don't use dav acls + return; + } + + // If the node is neither readable nor writable then fail unless its of + // the standard user-principal + if (!($node instanceof User)) { + $path = $propFind->getPath(); + $readPermissions = $this->checkPrivileges($path, '{DAV:}read', self::R_PARENT, false); + $writePermissions = $this->checkPrivileges($path, '{DAV:}write', self::R_PARENT, false); + if ($readPermissions === false && $writePermissions === false) { + $this->checkPrivileges($path, '{DAV:}read', self::R_PARENT, true); + $this->checkPrivileges($path, '{DAV:}write', self::R_PARENT, true); + } + } + + return parent::propFind($propFind, $node); + } + + public function beforeMethod(RequestInterface $request, ResponseInterface $response) { + $path = $request->getPath(); + + // prevent the plugin from causing an unneeded overhead for file requests + if (str_starts_with($path, 'files/')) { + return; + } + + parent::beforeMethod($request, $response); + + if (!str_starts_with($path, 'addressbooks/') && !str_starts_with($path, 'calendars/')) { + return; + } + + [$parentName] = \Sabre\Uri\split($path); + if ($request->getMethod() === 'REPORT') { + // is calendars/users/bob or addressbooks/users/bob readable? + $this->checkPrivileges($parentName, '{DAV:}read'); + } elseif ($request->getMethod() === 'MKCALENDAR' || $request->getMethod() === 'MKCOL') { + // is calendars/users/bob or addressbooks/users/bob writeable? + $this->checkPrivileges($parentName, '{DAV:}write'); + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/Directory.php b/apps/dav/lib/Connector/Sabre/Directory.php new file mode 100644 index 00000000000..fe09c3f423f --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Directory.php @@ -0,0 +1,493 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use OC\Files\Mount\MoveableMount; +use OC\Files\View; +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; +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; +use Sabre\DAV\Exception\Locked; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Exception\ServiceUnavailable; +use Sabre\DAV\IFile; +use Sabre\DAV\INode; + +class Directory extends Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuota, \Sabre\DAV\IMoveTarget, \Sabre\DAV\ICopyTarget { + /** + * Cached directory content + * @var FileInfo[] + */ + private ?array $dirContent = null; + + /** Cached quota info */ + private ?array $quotaInfo = null; + + /** + * Sets up the node, expects a full path name + */ + public function __construct( + View $view, + FileInfo $info, + private ?CachingTree $tree = null, + ?IShareManager $shareManager = null, + ) { + parent::__construct($view, $info, $shareManager); + } + + /** + * Creates a new file in the directory + * + * Data will either be supplied as a stream resource, or in certain cases + * as a string. Keep in mind that you may have to support either. + * + * After successful creation of the file, you may choose to return the ETag + * of the new file here. + * + * The returned ETag must be surrounded by double-quotes (The quotes should + * be part of the actual string). + * + * If you cannot accurately determine the ETag, you should not return it. + * If you don't store the file exactly as-is (you're transforming it + * somehow) you should also not return an ETag. + * + * This means that if a subsequent GET to this new file does not exactly + * return the same contents of what was submitted here, you are strongly + * recommended to omit the ETag. + * + * @param string $name Name of the file + * @param resource|string $data Initial payload + * @return null|string + * @throws Exception\EntityTooLarge + * @throws Exception\UnsupportedMediaType + * @throws FileLocked + * @throws InvalidPath + * @throws \Sabre\DAV\Exception + * @throws \Sabre\DAV\Exception\BadRequest + * @throws \Sabre\DAV\Exception\Forbidden + * @throws \Sabre\DAV\Exception\ServiceUnavailable + */ + public function createFile($name, $data = null) { + try { + if (!$this->fileView->isCreatable($this->path)) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + + $this->fileView->verifyPath($this->path, $name); + + $path = $this->fileView->getAbsolutePath($this->path) . '/' . $name; + // in case the file already exists/overwriting + $info = $this->fileView->getFileInfo($this->path . '/' . $name); + if (!$info) { + // use a dummy FileInfo which is acceptable here since it will be refreshed after the put is complete + $info = new \OC\Files\FileInfo($path, null, null, [ + 'type' => FileInfo::TYPE_FILE + ], null); + } + $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($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); + + $result = $node->put($data); + + $this->fileView->unlockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); + $node->releaseLock(ILockingProvider::LOCK_SHARED); + return $result; + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), $e->getCode(), $e); + } catch (InvalidPathException $ex) { + throw new InvalidPath($ex->getMessage(), false, $ex); + } catch (ForbiddenException $ex) { + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Creates a new subdirectory + * + * @param string $name + * @throws FileLocked + * @throws InvalidPath + * @throws \Sabre\DAV\Exception\Forbidden + * @throws \Sabre\DAV\Exception\ServiceUnavailable + */ + public function createDirectory($name) { + try { + if (!$this->info->isCreatable()) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + + $this->fileView->verifyPath($this->path, $name); + $newPath = $this->path . '/' . $name; + if (!$this->fileView->mkdir($newPath)) { + throw new \Sabre\DAV\Exception\Forbidden('Could not create directory ' . $newPath); + } + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), 0, $e); + } catch (InvalidPathException $ex) { + throw new InvalidPath($ex->getMessage(), false, $ex); + } catch (ForbiddenException $ex) { + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Returns a specific child node, referenced by its name + * + * @param string $name + * @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) { + $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(); + } + + $path = $this->path . '/' . $name; + if (is_null($info)) { + try { + $this->fileView->verifyPath($this->path, $name, true); + $info = $this->fileView->getFileInfo($path); + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), 0, $e); + } catch (InvalidPathException $ex) { + throw new InvalidPath($ex->getMessage(), false, $ex); + } catch (ForbiddenException $e) { + throw new \Sabre\DAV\Exception\Forbidden($e->getMessage(), $e->getCode(), $e); + } + } + + if (!$info) { + throw new \Sabre\DAV\Exception\NotFound('File with name ' . $path . ' could not be located'); + } + + if ($info->getMimeType() === FileInfo::MIMETYPE_FOLDER) { + $node = new \OCA\DAV\Connector\Sabre\Directory($this->fileView, $info, $this->tree, $this->shareManager); + } else { + // 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); + } + return $node; + } + + /** + * Returns an array with all the child nodes + * + * @return \Sabre\DAV\INode[] + * @throws \Sabre\DAV\Exception\Locked + * @throws Forbidden + */ + public function getChildren() { + if (!is_null($this->dirContent)) { + return $this->dirContent; + } + try { + 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 (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'); + } + } + $folderContent = $this->getNode()->getDirectoryListing(); + } catch (LockedException $e) { + throw new Locked(); + } + + $nodes = []; + $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); + $nodes[] = $node; + } + $this->dirContent = $nodes; + return $this->dirContent; + } + + /** + * Checks if a child exists. + * + * @param string $name + * @return bool + */ + public function childExists($name) { + // note: here we do NOT resolve the chunk file name to the real file name + // to make sure we return false when checking for file existence with a chunk + // file name. + // This is to make sure that "createFile" is still triggered + // (required old code) instead of "updateFile". + // + // TODO: resolve chunk file name here and implement "updateFile" + $path = $this->path . '/' . $name; + return $this->fileView->file_exists($path); + } + + /** + * Deletes all files in this directory, and then itself + * + * @return void + * @throws FileLocked + * @throws \Sabre\DAV\Exception\Forbidden + */ + public function delete() { + if ($this->path === '' || $this->path === '/' || !$this->info->isDeletable()) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + + try { + if (!$this->fileView->rmdir($this->path)) { + // assume it wasn't possible to remove due to permission issue + throw new \Sabre\DAV\Exception\Forbidden(); + } + } catch (ForbiddenException $ex) { + throw new Forbidden($ex->getMessage(), $ex->getRetry()); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + } + + private function getLogger(): LoggerInterface { + return Server::get(LoggerInterface::class); + } + + /** + * Returns available diskspace information + * + * @return array + */ + public function getQuotaInfo() { + if ($this->quotaInfo) { + return $this->quotaInfo; + } + $relativePath = $this->fileView->getRelativePath($this->info->getPath()); + if ($relativePath === null) { + $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'] === FileInfo::SPACE_UNLIMITED) { + $free = FileInfo::SPACE_UNLIMITED; + } else { + $free = $storageInfo['free']; + } + $this->quotaInfo = [ + $storageInfo['used'], + $free + ]; + return $this->quotaInfo; + } catch (NotFoundException $e) { + $this->getLogger()->warning('error while getting quota into', ['exception' => $e]); + return [0, 0]; + } 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]); + return [0, 0]; + } + } + + /** + * Moves a node into this collection. + * + * It is up to the implementors to: + * 1. Create the new resource. + * 2. Remove the old resource. + * 3. Transfer any properties or other data. + * + * Generally you should make very sure that your collection can easily move + * the move. + * + * If you don't, just return false, which will trigger sabre/dav to handle + * the move itself. If you return true from this function, the assumption + * is that the move was successful. + * + * @param string $targetName New local file/collection name. + * @param string $fullSourcePath Full path to source node + * @param INode $sourceNode Source node itself + * @return bool + * @throws BadRequest + * @throws ServiceUnavailable + * @throws Forbidden + * @throws FileLocked + * @throws \Sabre\DAV\Exception\Forbidden + */ + public function moveInto($targetName, $fullSourcePath, INode $sourceNode) { + if (!$sourceNode instanceof Node) { + // it's a file of another kind, like FutureFile + if ($sourceNode instanceof IFile) { + // fallback to default copy+delete handling + return false; + } + throw new BadRequest('Incompatible node types'); + } + + $destinationPath = $this->getPath() . '/' . $targetName; + + + $targetNodeExists = $this->childExists($targetName); + + // at getNodeForPath we also check the path for isForbiddenFileOrDir + // with that we have covered both source and destination + if ($sourceNode instanceof Directory && $targetNodeExists) { + throw new \Sabre\DAV\Exception\Forbidden('Could not copy directory ' . $sourceNode->getName() . ', target exists'); + } + + [$sourceDir,] = \Sabre\Uri\split($sourceNode->getPath()); + $destinationDir = $this->getPath(); + + $sourcePath = $sourceNode->getPath(); + + $isMovableMount = false; + $sourceMount = Server::get(IMountManager::class)->find($this->fileView->getAbsolutePath($sourcePath)); + $internalPath = $sourceMount->getInternalPath($this->fileView->getAbsolutePath($sourcePath)); + if ($sourceMount instanceof MoveableMount && $internalPath === '') { + $isMovableMount = true; + } + + try { + $sameFolder = ($sourceDir === $destinationDir); + // if we're overwriting or same folder + if ($targetNodeExists || $sameFolder) { + // note that renaming a share mount point is always allowed + if (!$this->fileView->isUpdatable($destinationDir) && !$isMovableMount) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + } else { + if (!$this->fileView->isCreatable($destinationDir)) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + } + + if (!$sameFolder) { + // moving to a different folder, source will be gone, like a deletion + // note that moving a share mount point is always allowed + if (!$this->fileView->isDeletable($sourcePath) && !$isMovableMount) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + } + + $fileName = basename($destinationPath); + try { + $this->fileView->verifyPath($destinationDir, $fileName); + } catch (InvalidPathException $ex) { + throw new InvalidPath($ex->getMessage()); + } + + $renameOkay = $this->fileView->rename($sourcePath, $destinationPath); + if (!$renameOkay) { + throw new \Sabre\DAV\Exception\Forbidden(''); + } + } catch (StorageNotAvailableException $e) { + throw new ServiceUnavailable($e->getMessage(), $e->getCode(), $e); + } catch (ForbiddenException $ex) { + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + + return true; + } + + + public function copyInto($targetName, $sourcePath, INode $sourceNode) { + if ($sourceNode instanceof File || $sourceNode instanceof Directory) { + try { + $destinationPath = $this->getPath() . '/' . $targetName; + $sourcePath = $sourceNode->getPath(); + + if (!$this->fileView->isCreatable($this->getPath())) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + + try { + $this->fileView->verifyPath($this->getPath(), $targetName); + } catch (InvalidPathException $ex) { + throw new InvalidPath($ex->getMessage()); + } + + $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(), $e->getCode(), $e); + } catch (ForbiddenException $ex) { + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + } + + return false; + } + + public function getNode(): Folder { + return $this->node; + } +} diff --git a/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php b/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php new file mode 100644 index 00000000000..f6baceb748b --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php @@ -0,0 +1,57 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use OCP\AppFramework\Http; +use Sabre\DAV\Server; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * Class DummyGetResponsePlugin is a plugin used to not show a "Not implemented" + * error to clients that rely on verifying the functionality of the Nextcloud + * WebDAV backend using a simple GET to /. + * + * This is considered a legacy behaviour and implementers should consider sending + * a PROPFIND request instead to verify whether the WebDAV component is working + * properly. + * + * FIXME: Remove once clients are all compliant. + * + * @package OCA\DAV\Connector\Sabre + */ +class DummyGetResponsePlugin extends \Sabre\DAV\ServerPlugin { + protected ?Server $server = null; + + /** + * @param \Sabre\DAV\Server $server + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $this->server = $server; + $this->server->on('method:GET', [$this, 'httpGet'], 200); + } + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * @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.'; + $stream = fopen('php://memory', 'r+'); + fwrite($stream, $string); + rewind($stream); + + $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 new file mode 100644 index 00000000000..1e1e4aaed04 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php @@ -0,0 +1,25 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre\Exception; + +/** + * Bad Gateway + * + * This exception is thrown whenever the server, while acting as a gateway or proxy, received an invalid response from the upstream server. + * + */ +class BadGateway extends \Sabre\DAV\Exception { + + /** + * Returns the HTTP status code for this exception + * + * @return int + */ + public function getHTTPCode() { + return 502; + } +} diff --git a/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php b/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php new file mode 100644 index 00000000000..60b3b06ea01 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php @@ -0,0 +1,26 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre\Exception; + +/** + * Entity Too Large + * + * This exception is thrown whenever a user tries to upload a file which exceeds hard limitations + * + */ +class EntityTooLarge extends \Sabre\DAV\Exception { + + /** + * Returns the HTTP status code for this exception + * + * @return int + */ + public function getHTTPCode() { + return 413; + } +} diff --git a/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php b/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php new file mode 100644 index 00000000000..38708e945e9 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php @@ -0,0 +1,33 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +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 LockNotAcquiredException) { + $message = sprintf('Target file %s is locked by another process.', $previous->path); + } + parent::__construct($message, $code, $previous); + } + + /** + * Returns the HTTP status code for this exception + * + * @return int + */ + public function getHTTPCode() { + return 423; + } +} diff --git a/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php b/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php new file mode 100644 index 00000000000..95d4b3ab514 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php @@ -0,0 +1,47 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre\Exception; + +class Forbidden extends \Sabre\DAV\Exception\Forbidden { + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + + /** + * @param string $message + * @param bool $retry + * @param \Exception $previous + */ + public function __construct( + $message, + private $retry = false, + ?\Exception $previous = null, + ) { + parent::__construct($message, 0, $previous); + } + + /** + * This method allows the exception to include additional information + * into the WebDAV error response + * + * @param \Sabre\DAV\Server $server + * @param \DOMElement $errorNode + * @return void + */ + public function serialize(\Sabre\DAV\Server $server, \DOMElement $errorNode) { + + // set ownCloud namespace + $errorNode->setAttribute('xmlns:o', self::NS_OWNCLOUD); + + // adding the retry node + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:retry', var_export($this->retry, true)); + $errorNode->appendChild($error); + + // adding the message node + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:reason', $this->getMessage()); + $errorNode->appendChild($error); + } +} diff --git a/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php b/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php new file mode 100644 index 00000000000..dfc08aa8b88 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php @@ -0,0 +1,58 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre\Exception; + +use Sabre\DAV\Exception; + +class InvalidPath extends Exception { + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + + /** + * @param string $message + * @param bool $retry + * @param \Exception|null $previous + */ + public function __construct( + $message, + private $retry = false, + ?\Exception $previous = null, + ) { + parent::__construct($message, 0, $previous); + } + + /** + * Returns the HTTP status code for this exception + * + * @return int + */ + public function getHTTPCode() { + return 400; + } + + /** + * This method allows the exception to include additional information + * into the WebDAV error response + * + * @param \Sabre\DAV\Server $server + * @param \DOMElement $errorNode + * @return void + */ + public function serialize(\Sabre\DAV\Server $server, \DOMElement $errorNode) { + + // set ownCloud namespace + $errorNode->setAttribute('xmlns:o', self::NS_OWNCLOUD); + + // adding the retry node + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:retry', var_export($this->retry, true)); + $errorNode->appendChild($error); + + // adding the message node + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:reason', $this->getMessage()); + $errorNode->appendChild($error); + } +} diff --git a/apps/dav/lib/Connector/Sabre/Exception/PasswordLoginForbidden.php b/apps/dav/lib/Connector/Sabre/Exception/PasswordLoginForbidden.php new file mode 100644 index 00000000000..f5cc117fafc --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Exception/PasswordLoginForbidden.php @@ -0,0 +1,37 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre\Exception; + +use DOMElement; +use Sabre\DAV\Exception\NotAuthenticated; +use Sabre\DAV\Server; + +class PasswordLoginForbidden extends NotAuthenticated { + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + + public function getHTTPCode() { + return 401; + } + + /** + * This method allows the exception to include additional information + * into the WebDAV error response + * + * @param Server $server + * @param DOMElement $errorNode + * @return void + */ + public function serialize(Server $server, DOMElement $errorNode) { + + // set ownCloud namespace + $errorNode->setAttribute('xmlns:o', self::NS_OWNCLOUD); + + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:hint', 'password login forbidden'); + $errorNode->appendChild($error); + } +} diff --git a/apps/dav/lib/Connector/Sabre/Exception/TooManyRequests.php b/apps/dav/lib/Connector/Sabre/Exception/TooManyRequests.php new file mode 100644 index 00000000000..67455fc9474 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Exception/TooManyRequests.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Connector\Sabre\Exception; + +use DOMElement; +use Sabre\DAV\Exception\NotAuthenticated; +use Sabre\DAV\Server; + +class TooManyRequests extends NotAuthenticated { + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + + public function getHTTPCode() { + return 429; + } + + /** + * This method allows the exception to include additional information + * into the WebDAV error response + * + * @param Server $server + * @param DOMElement $errorNode + * @return void + */ + public function serialize(Server $server, DOMElement $errorNode) { + + // set ownCloud namespace + $errorNode->setAttribute('xmlns:o', self::NS_OWNCLOUD); + + $error = $errorNode->ownerDocument->createElementNS('o:', 'o:hint', 'too many requests'); + $errorNode->appendChild($error); + } +} diff --git a/apps/dav/lib/Connector/Sabre/Exception/UnsupportedMediaType.php b/apps/dav/lib/Connector/Sabre/Exception/UnsupportedMediaType.php new file mode 100644 index 00000000000..c5fbfa3a16c --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Exception/UnsupportedMediaType.php @@ -0,0 +1,26 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre\Exception; + +/** + * Unsupported Media Type + * + * This exception is thrown whenever a user tries to upload a file which holds content which is not allowed + * + */ +class UnsupportedMediaType extends \Sabre\DAV\Exception { + + /** + * Returns the HTTP status code for this exception + * + * @return int + */ + public function getHTTPCode() { + return 415; + } +} diff --git a/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php b/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php new file mode 100644 index 00000000000..686386dbfef --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php @@ -0,0 +1,112 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use OCA\DAV\Connector\Sabre\Exception\FileLocked; +use OCA\DAV\Connector\Sabre\Exception\PasswordLoginForbidden; +use OCA\DAV\Exception\ServerMaintenanceMode; +use OCP\Files\StorageNotAvailableException; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\Conflict; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\InvalidSyncToken; +use Sabre\DAV\Exception\MethodNotAllowed; +use Sabre\DAV\Exception\NotAuthenticated; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Exception\NotImplemented; +use Sabre\DAV\Exception\PreconditionFailed; +use Sabre\DAV\Exception\RequestedRangeNotSatisfiable; + +class ExceptionLoggerPlugin extends \Sabre\DAV\ServerPlugin { + protected $nonFatalExceptions = [ + NotAuthenticated::class => true, + // If tokenauth can throw this exception (which is basically as + // NotAuthenticated. So not fatal. + PasswordLoginForbidden::class => true, + // basically a NotAuthenticated + InvalidSyncToken::class => true, + // the sync client uses this to find out whether files exist, + // so it is not always an error, log it as debug + NotFound::class => true, + // the sync client messed up their request + // (e.g. propfind for tags with string instead of int) + // so it is not always an error, log it as debug + BadRequest::class => true, + // this one mostly happens when the same file is uploaded at + // exactly the same time from two clients, only one client + // wins, the second one gets "Precondition failed" + PreconditionFailed::class => true, + // forbidden can be expected when trying to upload to + // read-only folders for example + Forbidden::class => true, + // our forbidden is expected when access control is blocking + // an item in a folder + \OCA\DAV\Connector\Sabre\Exception\Forbidden::class => true, + // Happens when an external storage or federated share is temporarily + // not available + StorageNotAvailableException::class => true, + // happens if some a client uses the wrong method for a given URL + // the error message itself is visible on the client side anyways + NotImplemented::class => true, + // happens when the parent directory is not present (for example when a + // move is done to a non-existent directory) + Conflict::class => true, + // happens when a certain method is not allowed to be called + // for example creating a folder that already exists + MethodNotAllowed::class => true, + // A locked file is perfectly valid and can happen in various cases + FileLocked::class => true, + // An invalid range is requested + RequestedRangeNotSatisfiable::class => true, + ServerMaintenanceMode::class => true, + ]; + + /** + * @param string $appName app name to use when logging + */ + public function __construct( + private string $appName, + private LoggerInterface $logger, + ) { + } + + /** + * This initializes the plugin. + * + * This function is called by \Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param \Sabre\DAV\Server $server + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $server->on('exception', [$this, 'logException'], 10); + } + + /** + * Log exception + */ + public function logException(\Throwable $ex) { + $exceptionClass = get_class($ex); + if (isset($this->nonFatalExceptions[$exceptionClass])) { + $this->logger->debug($ex->getMessage(), [ + 'app' => $this->appName, + 'exception' => $ex, + ]); + return; + } + + $this->logger->critical($ex->getMessage(), [ + 'app' => $this->appName, + 'exception' => $ex, + ]); + } +} diff --git a/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php b/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php new file mode 100644 index 00000000000..b0c5a079ce1 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php @@ -0,0 +1,143 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +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; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * Class FakeLockerPlugin is a plugin only used when connections come in from + * OS X via Finder. The fake locking plugin does emulate Class 2 WebDAV support + * (locking of files) which allows Finder to access the storage in write mode as + * well. + * + * No real locking is performed, instead the plugin just returns always positive + * responses. + * + * @see https://github.com/owncloud/core/issues/17732 + * @package OCA\DAV\Connector\Sabre + */ +class FakeLockerPlugin extends ServerPlugin { + /** @var Server */ + private $server; + + /** {@inheritDoc} */ + public function initialize(Server $server) { + $this->server = $server; + $this->server->on('method:LOCK', [$this, 'fakeLockProvider'], 1); + $this->server->on('method:UNLOCK', [$this, 'fakeUnlockProvider'], 1); + $server->on('propFind', [$this, 'propFind']); + $server->on('validateTokens', [$this, 'validateTokens']); + } + + /** + * Indicate that we support LOCK and UNLOCK + * + * @param string $path + * @return string[] + */ + public function getHTTPMethods($path) { + return [ + 'LOCK', + 'UNLOCK', + ]; + } + + /** + * Indicate that we support locking + * + * @return integer[] + */ + public function getFeatures() { + return [2]; + } + + /** + * Return some dummy response for PROPFIND requests with regard to locking + * + * @param PropFind $propFind + * @param INode $node + * @return void + */ + public function propFind(PropFind $propFind, INode $node) { + $propFind->handle('{DAV:}supportedlock', function () { + return new SupportedLock(); + }); + $propFind->handle('{DAV:}lockdiscovery', function () use ($propFind) { + return new LockDiscovery([]); + }); + } + + /** + * Mark a locking token always as valid + * + * @param RequestInterface $request + * @param array $conditions + */ + public function validateTokens(RequestInterface $request, &$conditions) { + foreach ($conditions as &$fileCondition) { + if (isset($fileCondition['tokens'])) { + foreach ($fileCondition['tokens'] as &$token) { + if (isset($token['token'])) { + if (str_starts_with($token['token'], 'opaquelocktoken:')) { + $token['validToken'] = true; + } + } + } + } + } + } + + /** + * Fakes a successful LOCK + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return bool + */ + public function fakeLockProvider(RequestInterface $request, + ResponseInterface $response) { + $lockInfo = new LockInfo(); + $lockInfo->token = md5($request->getPath()); + $lockInfo->uri = $request->getPath(); + $lockInfo->depth = Server::DEPTH_INFINITY; + $lockInfo->timeout = 1800; + + $body = $this->server->xml->write('{DAV:}prop', [ + '{DAV:}lockdiscovery' + => new LockDiscovery([$lockInfo]) + ]); + + $response->setStatus(Http::STATUS_OK); + $response->setBody($body); + + return false; + } + + /** + * Fakes a successful LOCK + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return bool + */ + public function fakeUnlockProvider(RequestInterface $request, + ResponseInterface $response) { + $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 new file mode 100644 index 00000000000..d2a71eb3e7b --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/File.php @@ -0,0 +1,633 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use Icewind\Streams\CallbackWrapper; +use OC\AppFramework\Http\Request; +use OC\Files\Filesystem; +use OC\Files\Stream\HashWrapper; +use OC\Files\View; +use OCA\DAV\AppInfo\Application; +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\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; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Exception\ServiceUnavailable; +use Sabre\DAV\IFile; + +class File extends Node implements IFile { + protected IRequest $request; + protected IL10N $l10n; + + /** + * Sets up the node, expects a full path name + * + * @param View $view + * @param FileInfo $info + * @param ?\OCP\Share\IManager $shareManager + * @param ?IRequest $request + * @param ?IL10N $l10n + */ + public function __construct(View $view, FileInfo $info, ?IManager $shareManager = null, ?IRequest $request = null, ?IL10N $l10n = null) { + parent::__construct($view, $info, $shareManager); + + if ($l10n) { + $this->l10n = $l10n; + } else { + // Querying IL10N directly results in a dependency loop + /** @var IL10NFactory $l10nFactory */ + $l10nFactory = Server::get(IL10NFactory::class); + $this->l10n = $l10nFactory->get(Application::APP_ID); + } + + if (isset($request)) { + $this->request = $request; + } else { + $this->request = Server::get(IRequest::class); + } + } + + /** + * Updates the data + * + * The data argument is a readable stream resource. + * + * After a successful put operation, you may choose to return an ETag. The + * etag must always be surrounded by double-quotes. These quotes must + * appear in the actual string you're returning. + * + * Clients may use the ETag from a PUT request to later on make sure that + * when they update the file, the contents haven't changed in the mean + * time. + * + * If you don't plan to store the file byte-by-byte, and you return a + * different object on a subsequent GET you are strongly recommended to not + * return an ETag, and just return null. + * + * @param resource|string $data + * + * @throws Forbidden + * @throws UnsupportedMediaType + * @throws BadRequest + * @throws Exception + * @throws EntityTooLarge + * @throws ServiceUnavailable + * @throws FileLocked + * @return string|null + */ + public function put($data) { + try { + $exists = $this->fileView->file_exists($this->path); + if ($exists && !$this->info->isUpdateable()) { + throw new Forbidden(); + } + } catch (StorageNotAvailableException $e) { + throw new ServiceUnavailable($this->l10n->t('File is not updatable: %1$s', [$e->getMessage()])); + } + + // verify path of the target + $this->verifyPath(); + + [$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 = Filesystem::getView(); + + if ($needsPartFile) { + $transferId = \rand(); + // mark file as partial while uploading (ignored by the scanner) + $partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . $transferId . '.part'; + + if (!$view->isCreatable($partFilePath) && $view->isUpdatable($this->path)) { + $needsPartFile = false; + } + } + if (!$needsPartFile) { + // upload file directly as the final path + $partFilePath = $this->path; + + if ($view && !$this->emitPreHooks($exists)) { + throw new Exception($this->l10n->t('Could not write to final file, canceled by hook')); + } + } + + // the part file and target file might be on a different storage in case of a single file storage (e.g. single file share) + [$partStorage, $internalPartPath] = $this->fileView->resolvePath($partFilePath); + [$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 { + $this->changeLock(ILockingProvider::LOCK_EXCLUSIVE); + } catch (LockedException $e) { + // during very large uploads, the shared lock we got at the start might have been expired + // meaning that the above lock can fail not just only because somebody else got a shared lock + // or because there is no existing shared lock to make exclusive + // + // Thus we try to get a new exclusive lock, if the original lock failed because of a different shared + // lock this will still fail, if our original shared lock expired the new lock will be successful and + // the entire operation will be safe + + try { + $this->acquireLock(ILockingProvider::LOCK_EXCLUSIVE); + } catch (LockedException $ex) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + } + } + + if (!is_resource($data)) { + $tmpData = fopen('php://temp', 'r+'); + if ($data !== null) { + fwrite($tmpData, $data); + rewind($tmpData); + } + $data = $tmpData; + } + + if ($this->request->getHeader('X-HASH') !== '') { + $hash = $this->request->getHeader('X-HASH'); + if ($hash === 'all' || $hash === 'md5') { + $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): void { + $this->header('X-Hash-SHA1: ' . $hash); + }); + } + + if ($hash === 'all' || $hash === 'sha256') { + $data = HashWrapper::wrap($data, 'sha256', function ($hash): void { + $this->header('X-Hash-SHA256: ' . $hash); + }); + } + } + + $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): void { + $isEOF = feof($stream); + }); + + $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) { + 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] = Files::streamCopy($data, $target, true); + fclose($target); + } + 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 + 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) { + Server::get(LoggerInterface::class)->debug($e->getMessage(), ['exception' => $e]); + } else { + Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); + } + + if ($needsPartFile) { + $partStorage->unlink($internalPartPath); + } + $this->convertToSabreException($e); + } + + try { + if ($needsPartFile) { + if ($view && !$this->emitPreHooks($exists)) { + $partStorage->unlink($internalPartPath); + throw new Exception($this->l10n->t('Could not rename part file to final file, canceled by hook')); + } + try { + $this->changeLock(ILockingProvider::LOCK_EXCLUSIVE); + } catch (LockedException $e) { + // during very large uploads, the shared lock we got at the start might have been expired + // meaning that the above lock can fail not just only because somebody else got a shared lock + // or because there is no existing shared lock to make exclusive + // + // Thus we try to get a new exclusive lock, if the original lock failed because of a different shared + // lock this will still fail, if our original shared lock expired the new lock will be successful and + // the entire operation will be safe + + try { + $this->acquireLock(ILockingProvider::LOCK_EXCLUSIVE); + } catch (LockedException $ex) { + if ($needsPartFile) { + $partStorage->unlink($internalPartPath); + } + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + } + + // rename to correct path + try { + $renameOkay = $storage->moveFromStorage($partStorage, $internalPartPath, $internalPath); + $fileExists = $storage->file_exists($internalPath); + if ($renameOkay === false || $fileExists === false) { + 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) { + if (!$ex->getRetry()) { + $partStorage->unlink($internalPartPath); + } + throw new DAVForbiddenException($ex->getMessage(), $ex->getRetry()); + } catch (\Exception $e) { + $partStorage->unlink($internalPartPath); + $this->convertToSabreException($e); + } + } + + // since we skipped the view we need to scan and emit the hooks ourselves + $storage->getUpdater()->update($internalPath); + + try { + $this->changeLock(ILockingProvider::LOCK_SHARED); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + + // allow sync clients to send the mtime along in a header + $mtimeHeader = $this->request->getHeader('x-oc-mtime'); + if ($mtimeHeader !== '') { + $mtime = $this->sanitizeMtime($mtimeHeader); + if ($this->fileView->touch($this->path, $mtime)) { + $this->header('X-OC-MTime: accepted'); + } + } + + $fileInfoUpdate = [ + 'upload_time' => time() + ]; + + // allow sync clients to send the creation time along in a header + $ctimeHeader = $this->request->getHeader('x-oc-ctime'); + if ($ctimeHeader) { + $ctime = $this->sanitizeMtime($ctimeHeader); + $fileInfoUpdate['creation_time'] = $ctime; + $this->header('X-OC-CTime: accepted'); + } + + $this->fileView->putFileInfo($this->path, $fileInfoUpdate); + + if ($view) { + $this->emitPostHooks($exists); + } + + $this->refreshInfo(); + + $checksumHeader = $this->request->getHeader('oc-checksum'); + if ($checksumHeader) { + $checksum = trim($checksumHeader); + $this->setChecksum($checksum); + } elseif ($this->getChecksum() !== null && $this->getChecksum() !== '') { + $this->setChecksum(''); + } + } catch (StorageNotAvailableException $e) { + throw new ServiceUnavailable($this->l10n->t('Failed to check file size: %1$s', [$e->getMessage()]), 0, $e); + } + + return '"' . $this->info->getEtag() . '"'; + } + + private function getPartFileBasePath($path) { + $partFileInStorage = Server::get(IConfig::class)->getSystemValue('part_file_in_storage', true); + if ($partFileInStorage) { + $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 { + // 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); + } + } + + private function emitPreHooks(bool $exists, ?string $path = null): bool { + if (is_null($path)) { + $path = $this->path; + } + $hookPath = Filesystem::getView()->getRelativePath($this->fileView->getAbsolutePath($path)); + if ($hookPath === null) { + // We only trigger hooks from inside default view + return true; + } + $run = true; + + if (!$exists) { + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_create, [ + Filesystem::signal_param_path => $hookPath, + Filesystem::signal_param_run => &$run, + ]); + } else { + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_update, [ + Filesystem::signal_param_path => $hookPath, + 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; + } + + private function emitPostHooks(bool $exists, ?string $path = null): void { + if (is_null($path)) { + $path = $this->path; + } + $hookPath = Filesystem::getView()->getRelativePath($this->fileView->getAbsolutePath($path)); + if ($hookPath === null) { + // We only trigger hooks from inside default view + return; + } + if (!$exists) { + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_create, [ + Filesystem::signal_param_path => $hookPath + ]); + } else { + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_update, [ + Filesystem::signal_param_path => $hookPath + ]); + } + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_write, [ + Filesystem::signal_param_path => $hookPath + ]); + } + + /** + * Returns the data + * + * @return resource + * @throws Forbidden + * @throws ServiceUnavailable + */ + public function get() { + //throw exception if encryption is disabled but files are still encrypted + try { + if (!$this->info->isReadable()) { + // do a if the file did not exist + throw new NotFound(); + } + $path = ltrim($this->path, '/'); + try { + $res = $this->fileView->fopen($path, 'rb'); + } catch (\Exception $e) { + $this->convertToSabreException($e); + } + + if ($res === false) { + 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 = Server::get(LoggerInterface::class); + $logger->warning('fixing cached size of file id=' . $this->getId()); + + $this->getFileInfo()->getStorage()->getUpdater()->update($this->getFileInfo()->getInternalPath()); + $this->refreshInfo(); + } + + return $res; + } catch (GenericEncryptionException $e) { + // returning 503 will allow retry of the operation at a later point in time + throw new ServiceUnavailable($this->l10n->t('Encryption not ready: %1$s', [$e->getMessage()])); + } catch (StorageNotAvailableException $e) { + throw new ServiceUnavailable($this->l10n->t('Failed to open file: %1$s', [$e->getMessage()])); + } catch (ForbiddenException $ex) { + throw new DAVForbiddenException($ex->getMessage(), $ex->getRetry()); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Delete the current file + * + * @throws Forbidden + * @throws ServiceUnavailable + */ + public function delete() { + if (!$this->info->isDeletable()) { + throw new Forbidden(); + } + + try { + if (!$this->fileView->unlink($this->path)) { + // assume it wasn't possible to delete due to permissions + throw new Forbidden(); + } + } catch (StorageNotAvailableException $e) { + throw new ServiceUnavailable($this->l10n->t('Failed to unlink: %1$s', [$e->getMessage()])); + } catch (ForbiddenException $ex) { + throw new DAVForbiddenException($ex->getMessage(), $ex->getRetry()); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Returns the mime-type for a file + * + * If null is returned, we'll assume application/octet-stream + * + * @return string + */ + public function getContentType() { + $mimeType = $this->info->getMimetype(); + + // PROPFIND needs to return the correct mime type, for consistency with the web UI + if ($this->request->getMethod() === 'PROPFIND') { + return $mimeType; + } + return Server::get(IMimeTypeDetector::class)->getSecureMimeType($mimeType); + } + + /** + * @return array|bool + */ + public function getDirectDownload() { + if (Server::get(IAppManager::class)->isEnabledForUser('encryption')) { + return []; + } + [$storage, $internalPath] = $this->fileView->resolvePath($this->path); + if (is_null($storage)) { + return []; + } + + return $storage->getDirectDownload($internalPath); + } + + /** + * Convert the given exception to a SabreException instance + * + * @param \Exception $e + * + * @throws \Sabre\DAV\Exception + */ + private function convertToSabreException(\Exception $e) { + if ($e instanceof \Sabre\DAV\Exception) { + throw $e; + } + if ($e instanceof NotPermittedException) { + // a more general case - due to whatever reason the content could not be written + throw new Forbidden($e->getMessage(), 0, $e); + } + if ($e instanceof ForbiddenException) { + // the path for the file was forbidden + throw new DAVForbiddenException($e->getMessage(), $e->getRetry(), $e); + } + if ($e instanceof EntityTooLargeException) { + // the file is too big to be stored + throw new EntityTooLarge($e->getMessage(), 0, $e); + } + if ($e instanceof InvalidContentException) { + // the file content is not permitted + throw new UnsupportedMediaType($e->getMessage(), 0, $e); + } + if ($e instanceof InvalidPathException) { + // the path for the file was not valid + // TODO: find proper http status code for this case + throw new Forbidden($e->getMessage(), 0, $e); + } + if ($e instanceof LockedException || $e instanceof LockNotAcquiredException) { + // the file is currently being written to by another process + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + if ($e instanceof GenericEncryptionException) { + // returning 503 will allow retry of the operation at a later point in time + throw new ServiceUnavailable($this->l10n->t('Encryption not ready: %1$s', [$e->getMessage()]), 0, $e); + } + if ($e instanceof StorageNotAvailableException) { + throw new ServiceUnavailable($this->l10n->t('Failed to write file contents: %1$s', [$e->getMessage()]), 0, $e); + } + if ($e instanceof NotFoundException) { + throw new NotFound($this->l10n->t('File not found: %1$s', [$e->getMessage()]), 0, $e); + } + + throw new \Sabre\DAV\Exception($e->getMessage(), 0, $e); + } + + /** + * Get the checksum for this file + * + * @return string|null + */ + public function getChecksum() { + return $this->info->getChecksum(); + } + + public function setChecksum(string $checksum) { + $this->fileView->putFileInfo($this->path, ['checksum' => $checksum]); + $this->refreshInfo(); + } + + protected function header($string) { + if (!\OC::$CLI) { + \header($string); + } + } + + public function hash(string $type) { + return $this->fileView->hash($type, $this->path); + } + + public function getNode(): \OCP\Files\File { + return $this->node; + } +} diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php new file mode 100644 index 00000000000..843383a0452 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -0,0 +1,741 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +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; +use OCP\FilesMetadata\IFilesMetadataManager; +use OCP\FilesMetadata\Model\IMetadataValueWrapper; +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; +use Sabre\DAV\PropFind; +use Sabre\DAV\PropPatch; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\DAV\Tree; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class FilesPlugin extends ServerPlugin { + // namespace + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; + public const FILEID_PROPERTYNAME = '{http://owncloud.org/ns}id'; + public const INTERNAL_FILEID_PROPERTYNAME = '{http://owncloud.org/ns}fileid'; + public const PERMISSIONS_PROPERTYNAME = '{http://owncloud.org/ns}permissions'; + public const SHARE_PERMISSIONS_PROPERTYNAME = '{http://open-collaboration-services.org/ns}share-permissions'; + public const OCM_SHARE_PERMISSIONS_PROPERTYNAME = '{http://open-cloud-mesh.org/ns}share-permissions'; + public const SHARE_ATTRIBUTES_PROPERTYNAME = '{http://nextcloud.org/ns}share-attributes'; + public const DOWNLOADURL_PROPERTYNAME = '{http://owncloud.org/ns}downloadURL'; + public const SIZE_PROPERTYNAME = '{http://owncloud.org/ns}size'; + public const GETETAG_PROPERTYNAME = '{DAV:}getetag'; + public const LASTMODIFIED_PROPERTYNAME = '{DAV:}lastmodified'; + public const CREATIONDATE_PROPERTYNAME = '{DAV:}creationdate'; + public const DISPLAYNAME_PROPERTYNAME = '{DAV:}displayname'; + public const OWNER_ID_PROPERTYNAME = '{http://owncloud.org/ns}owner-id'; + public const OWNER_DISPLAY_NAME_PROPERTYNAME = '{http://owncloud.org/ns}owner-display-name'; + public const CHECKSUMS_PROPERTYNAME = '{http://owncloud.org/ns}checksums'; + public const DATA_FINGERPRINT_PROPERTYNAME = '{http://owncloud.org/ns}data-fingerprint'; + 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_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-'; + public const HIDDEN_PROPERTYNAME = '{http://nextcloud.org/ns}hidden'; + + /** Reference to main server object */ + private ?Server $server = null; + + /** + * @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 + */ + 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, + ) { + } + + /** + * 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. + * + * @return void + */ + public function initialize(Server $server) { + $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; + $server->xml->namespaceMap[self::NS_NEXTCLOUD] = 'nc'; + $server->protectedProperties[] = self::FILEID_PROPERTYNAME; + $server->protectedProperties[] = self::INTERNAL_FILEID_PROPERTYNAME; + $server->protectedProperties[] = self::PERMISSIONS_PROPERTYNAME; + $server->protectedProperties[] = self::SHARE_PERMISSIONS_PROPERTYNAME; + $server->protectedProperties[] = self::OCM_SHARE_PERMISSIONS_PROPERTYNAME; + $server->protectedProperties[] = self::SHARE_ATTRIBUTES_PROPERTYNAME; + $server->protectedProperties[] = self::SIZE_PROPERTYNAME; + $server->protectedProperties[] = self::DOWNLOADURL_PROPERTYNAME; + $server->protectedProperties[] = self::OWNER_ID_PROPERTYNAME; + $server->protectedProperties[] = self::OWNER_DISPLAY_NAME_PROPERTYNAME; + $server->protectedProperties[] = self::CHECKSUMS_PROPERTYNAME; + $server->protectedProperties[] = self::DATA_FINGERPRINT_PROPERTYNAME; + $server->protectedProperties[] = self::HAS_PREVIEW_PROPERTYNAME; + $server->protectedProperties[] = self::MOUNT_TYPE_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 + $allowedProperties = ['{DAV:}getetag']; + $server->protectedProperties = array_diff($server->protectedProperties, $allowedProperties); + + $this->server = $server; + $this->server->on('propFind', [$this, 'handleGetProperties']); + $this->server->on('propPatch', [$this, 'handleUpdateProperties']); + $this->server->on('afterBind', [$this, 'sendFileIdHeader']); + $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): 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 copy can actually be performed. + * + * @param string $source source path + * @param string $target target path + * @throws NotFound If the source does not exist + * @throws InvalidPath If the target is invalid + */ + public function checkCopy($source, $target): void { + $sourceNode = $this->tree->getNodeForPath($source); + if (!$sourceNode instanceof Node) { + return; + } + + // 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')); + } + } + } + + /** + * 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'); + } + } + + /** + * This sets a cookie to be able to recognize the start of the download + * the content must not be longer than 32 characters and must only contain + * alphanumeric characters + * + * @param RequestInterface $request + * @param ResponseInterface $response + */ + public function handleDownloadToken(RequestInterface $request, ResponseInterface $response) { + $queryParams = $request->getQueryParameters(); + + /** + * this sets a cookie to be able to recognize the start of the download + * the content must not be longer than 32 characters and must only contain + * alphanumeric characters + */ + if (isset($queryParams['downloadStartSecret'])) { + $token = $queryParams['downloadStartSecret']; + if (!isset($token[32]) + && preg_match('!^[a-zA-Z0-9]+$!', $token) === 1) { + // FIXME: use $response->setHeader() instead + setcookie('ocDownloadStarted', $token, time() + 20, '/'); + } + } + } + + /** + * Add headers to file download + * + * @param RequestInterface $request + * @param ResponseInterface $response + */ + public function httpGet(RequestInterface $request, ResponseInterface $response) { + // Only handle valid files + $node = $this->tree->getNodeForPath($request->getPath()); + if (!($node instanceof IFile)) { + return; + } + + // adds a 'Content-Disposition: attachment' header in case no disposition + // header has been set before + if ($this->downloadAttachment + && $response->getHeader('Content-Disposition') === null) { + $filename = $node->getName(); + if ($this->request->isUserAgent( + [ + Request::USER_AGENT_IE, + Request::USER_AGENT_ANDROID_MOBILE_CHROME, + Request::USER_AGENT_FREEBOX, + ])) { + $response->addHeader('Content-Disposition', 'attachment; filename="' . rawurlencode($filename) . '"'); + } else { + $response->addHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . rawurlencode($filename) + . '; filename="' . rawurlencode($filename) . '"'); + } + } + + if ($node instanceof File) { + //Add OC-Checksum header + $checksum = $node->getChecksum(); + if ($checksum !== null && $checksum !== '') { + $response->addHeader('OC-Checksum', $checksum); + } + } + $response->addHeader('X-Accel-Buffering', 'no'); + } + + /** + * Adds all ownCloud-specific properties + * + * @param PropFind $propFind + * @param \Sabre\DAV\INode $node + * @return void + */ + public function handleGetProperties(PropFind $propFind, \Sabre\DAV\INode $node) { + $httpRequest = $this->server->httpRequest; + + 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 + * is blocked by the files_accesscontrol app, see: + * https://github.com/nextcloud/files_accesscontrol/issues/65 + * if (!$node->getFileInfo()->isReadable()) { + * // avoid detecting files through this means + * throw new NotFound(); + * } + */ + + $propFind->handle(self::FILEID_PROPERTYNAME, function () use ($node) { + return $node->getFileId(); + }); + + $propFind->handle(self::INTERNAL_FILEID_PROPERTYNAME, function () use ($node) { + return $node->getInternalFileId(); + }); + + $propFind->handle(self::PERMISSIONS_PROPERTYNAME, function () use ($node) { + $perms = $node->getDavPermissions(); + if ($this->isPublic) { + // remove mount information + $perms = str_replace(['S', 'M'], '', $perms); + } + return $perms; + }); + + $propFind->handle(self::SHARE_PERMISSIONS_PROPERTYNAME, function () use ($node, $httpRequest) { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } + return $node->getSharePermissions( + $user->getUID() + ); + }); + + $propFind->handle(self::OCM_SHARE_PERMISSIONS_PROPERTYNAME, function () use ($node, $httpRequest): ?string { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } + $ncPermissions = $node->getSharePermissions( + $user->getUID() + ); + $ocmPermissions = $this->ncPermissions2ocmPermissions($ncPermissions); + return json_encode($ocmPermissions, JSON_THROW_ON_ERROR); + }); + + $propFind->handle(self::SHARE_ATTRIBUTES_PROPERTYNAME, function () use ($node, $httpRequest) { + return json_encode($node->getShareAttributes(), JSON_THROW_ON_ERROR); + }); + + $propFind->handle(self::GETETAG_PROPERTYNAME, function () use ($node): string { + return $node->getETag(); + }); + + $propFind->handle(self::OWNER_ID_PROPERTYNAME, function () use ($node): ?string { + $owner = $node->getOwner(); + if (!$owner) { + return null; + } else { + return $owner->getUID(); + } + }); + $propFind->handle(self::OWNER_DISPLAY_NAME_PROPERTYNAME, function () use ($node): ?string { + $owner = $node->getOwner(); + if (!$owner) { + return null; + } + + // 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) { + return json_encode($this->previewManager->isAvailable($node->getFileInfo()), JSON_THROW_ON_ERROR); + }); + $propFind->handle(self::SIZE_PROPERTYNAME, function () use ($node): int|float { + return $node->getSize(); + }); + $propFind->handle(self::MOUNT_TYPE_PROPERTYNAME, function () use ($node) { + return $node->getFileInfo()->getMountPoint()->getMountType(); + }); + + /** + * This is a special property which is used to determine if a node + * is a mount root or not, e.g. a shared folder. + * If so, then the node can only be unshared and not deleted. + * @see https://github.com/nextcloud/server/blob/cc75294eb6b16b916a342e69998935f89222619d/lib/private/Files/View.php#L696-L698 + */ + $propFind->handle(self::MOUNT_ROOT_PROPERTYNAME, function () use ($node) { + return $node->getNode()->getInternalPath() === '' ? 'true' : 'false'; + }); + + $propFind->handle(self::SHARE_NOTE, function () use ($node): ?string { + $user = $this->userSession->getUser(); + return $node->getNoteFromShare( + $user?->getUID() + ); + }); + + $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) { + return (new \DateTimeImmutable()) + ->setTimestamp($node->getFileInfo()->getCreationTime()) + ->format(\DateTimeInterface::ATOM); + }); + $propFind->handle(self::CREATION_TIME_PROPERTYNAME, function () use ($node) { + return $node->getFileInfo()->getCreationTime(); + }); + + foreach ($node->getFileInfo()->getMetadata() as $metadataKey => $metadataValue) { + $propFind->handle(self::FILE_METADATA_PREFIX . $metadataKey, $metadataValue); + } + + $propFind->handle(self::HIDDEN_PROPERTYNAME, function () use ($node) { + $isLivePhoto = isset($node->getFileInfo()->getMetadata()['files-live-photo']); + $isMovFile = $node->getFileInfo()->getMimetype() === 'video/quicktime'; + return ($isLivePhoto && $isMovFile) ? 'true' : 'false'; + }); + + /** + * Return file/folder name as displayname. The primary reason to + * implement it this way is to avoid costly fallback to + * CustomPropertiesBackend (esp. visible when querying all files + * in a folder). + */ + $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 File) { + $propFind->handle(self::DOWNLOADURL_PROPERTYNAME, function () use ($node) { + try { + $directDownloadUrl = $node->getDirectDownload(); + if (isset($directDownloadUrl['url'])) { + return $directDownloadUrl['url']; + } + } catch (StorageNotAvailableException $e) { + return false; + } catch (ForbiddenException $e) { + return false; + } + return false; + }); + + $propFind->handle(self::CHECKSUMS_PROPERTYNAME, function () use ($node) { + $checksum = $node->getChecksum(); + if ($checksum === null || $checksum === '') { + return null; + } + + return new ChecksumList($checksum); + }); + + $propFind->handle(self::UPLOAD_TIME_PROPERTYNAME, function () use ($node) { + return $node->getFileInfo()->getUploadTime(); + }); + } + + if ($node instanceof Directory) { + $propFind->handle(self::SIZE_PROPERTYNAME, function () use ($node) { + return $node->getSize(); + }); + + $requestProperties = $propFind->getRequestedProperties(); + + if (in_array(self::SUBFILE_COUNT_PROPERTYNAME, $requestProperties, true) + || in_array(self::SUBFOLDER_COUNT_PROPERTYNAME, $requestProperties, true)) { + $nbFiles = 0; + $nbFolders = 0; + foreach ($node->getChildren() as $child) { + if ($child instanceof File) { + $nbFiles++; + } elseif ($child instanceof Directory) { + $nbFolders++; + } + } + + $propFind->handle(self::SUBFILE_COUNT_PROPERTYNAME, $nbFiles); + $propFind->handle(self::SUBFOLDER_COUNT_PROPERTYNAME, $nbFolders); + } + } + } + + /** + * translate Nextcloud permissions to OCM Permissions + * + * @param $ncPermissions + * @return array + */ + protected function ncPermissions2ocmPermissions($ncPermissions) { + $ocmPermissions = []; + + if ($ncPermissions & Constants::PERMISSION_SHARE) { + $ocmPermissions[] = 'share'; + } + + if ($ncPermissions & Constants::PERMISSION_READ) { + $ocmPermissions[] = 'read'; + } + + if (($ncPermissions & Constants::PERMISSION_CREATE) + || ($ncPermissions & Constants::PERMISSION_UPDATE)) { + $ocmPermissions[] = 'write'; + } + + return $ocmPermissions; + } + + /** + * Update ownCloud-specific properties + * + * @param string $path + * @param PropPatch $propPatch + * + * @return void + */ + public function handleUpdateProperties($path, PropPatch $propPatch) { + $node = $this->tree->getNodeForPath($path); + if (!($node instanceof Node)) { + return; + } + + $propPatch->handle(self::LASTMODIFIED_PROPERTYNAME, function ($time) use ($node) { + if (empty($time)) { + return false; + } + $node->touch($time); + return true; + }); + $propPatch->handle(self::GETETAG_PROPERTYNAME, function ($etag) use ($node) { + if (empty($etag)) { + return false; + } + return $node->setEtag($etag) !== -1; + }); + $propPatch->handle(self::CREATIONDATE_PROPERTYNAME, function ($time) use ($node) { + if (empty($time)) { + return false; + } + $dateTime = new \DateTimeImmutable($time); + $node->setCreationTime($dateTime->getTimestamp()); + return true; + }); + $propPatch->handle(self::CREATION_TIME_PROPERTYNAME, function ($time) use ($node) { + if (empty($time)) { + return false; + } + $node->setCreationTime((int)$time); + return true; + }); + + $this->handleUpdatePropertiesMetadata($propPatch, $node); + + /** + * Disable modification of the displayname property for files and + * folders via PROPPATCH. See PROPFIND for more information. + */ + $propPatch->handle(self::DISPLAYNAME_PROPERTYNAME, function ($displayName) { + return 403; + }); + } + + + /** + * handle the update of metadata from PROPPATCH requests + * + * @param PropPatch $propPatch + * @param Node $node + * + * @throws FilesMetadataException + */ + private function handleUpdatePropertiesMetadata(PropPatch $propPatch, Node $node): void { + $userId = $this->userSession->getUser()?->getUID(); + if ($userId === null) { + return; + } + + $accessRight = $this->getMetadataFileAccessRight($node, $userId); + $filesMetadataManager = $this->initFilesMetadataManager(); + $knownMetadata = $filesMetadataManager->getKnownMetadata(); + + foreach ($propPatch->getRemainingMutations() as $mutation) { + if (!str_starts_with($mutation, self::FILE_METADATA_PREFIX)) { + continue; + } + + $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 + if ($knownMetadata->getEditPermission($metadataKey) < $accessRight) { + 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); + } catch (FilesMetadataNotFoundException) { + $type = IMetadataValueWrapper::TYPE_STRING; + } + + switch ($type) { + case IMetadataValueWrapper::TYPE_STRING: + $metadata->setString($metadataKey, $value, $knownMetadata->isIndex($metadataKey)); + break; + case IMetadataValueWrapper::TYPE_INT: + $metadata->setInt($metadataKey, $value, $knownMetadata->isIndex($metadataKey)); + break; + case IMetadataValueWrapper::TYPE_FLOAT: + $metadata->setFloat($metadataKey, $value); + break; + case IMetadataValueWrapper::TYPE_BOOL: + $metadata->setBool($metadataKey, $value, $knownMetadata->isIndex($metadataKey)); + break; + case IMetadataValueWrapper::TYPE_ARRAY: + $metadata->setArray($metadataKey, $value); + break; + case IMetadataValueWrapper::TYPE_STRING_LIST: + $metadata->setStringList($metadataKey, $value, $knownMetadata->isIndex($metadataKey)); + break; + case IMetadataValueWrapper::TYPE_INT_LIST: + $metadata->setIntList($metadataKey, $value, $knownMetadata->isIndex($metadataKey)); + break; + } + + $filesMetadataManager->saveMetadata($metadata); + + return true; + } + ); + } + } + + /** + * init default internal metadata + * + * @return IFilesMetadataManager + */ + private function initFilesMetadataManager(): IFilesMetadataManager { + /** @var IFilesMetadataManager $manager */ + $manager = \OCP\Server::get(IFilesMetadataManager::class); + $manager->initMetadata('files-live-photo', IMetadataValueWrapper::TYPE_STRING, false, IMetadataValueWrapper::EDIT_REQ_OWNERSHIP); + + return $manager; + } + + /** + * based on owner and shares, returns the bottom limit to update related metadata + * + * @param Node $node + * @param string $userId + * + * @return int + */ + private function getMetadataFileAccessRight(Node $node, string $userId): int { + if ($node->getOwner()?->getUID() === $userId) { + return IMetadataValueWrapper::EDIT_REQ_OWNERSHIP; + } else { + $filePermissions = $node->getSharePermissions($userId); + if ($filePermissions & Constants::PERMISSION_UPDATE) { + return IMetadataValueWrapper::EDIT_REQ_WRITE_PERMISSION; + } + } + + return IMetadataValueWrapper::EDIT_REQ_READ_PERMISSION; + } + + /** + * @param string $filePath + * @param ?\Sabre\DAV\INode $node + * @return void + * @throws \Sabre\DAV\Exception\BadRequest + */ + 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 + 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 new file mode 100644 index 00000000000..b59d1373af5 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php @@ -0,0 +1,390 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +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; +use OCP\ITagManager; +use OCP\IUserSession; +use OCP\SystemTag\ISystemTagManager; +use OCP\SystemTag\ISystemTagObjectMapper; +use OCP\SystemTag\TagNotFoundException; +use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\PreconditionFailed; +use Sabre\DAV\PropFind; +use Sabre\DAV\ServerPlugin; +use Sabre\DAV\Tree; +use Sabre\DAV\Xml\Element\Response; +use Sabre\DAV\Xml\Response\MultiStatus; + +class FilesReportPlugin extends ServerPlugin { + // namespace + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; + public const REPORT_NAME = '{http://owncloud.org/ns}filter-files'; + public const SYSTEMTAG_PROPERTYNAME = '{http://owncloud.org/ns}systemtag'; + public const CIRCLE_PROPERTYNAME = '{http://owncloud.org/ns}circle'; + + /** + * Reference to main server object + * + * @var \Sabre\DAV\Server + */ + private $server; + + /** + * @param Tree $tree + * @param View $fileView + * @param ISystemTagManager $tagManager + * @param ISystemTagObjectMapper $tagMapper + * @param ITagManager $fileTagger manager for private tags + * @param IUserSession $userSession + * @param IGroupManager $groupManager + * @param Folder $userFolder + * @param 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 initializes the plugin. + * + * This function is called by \Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param \Sabre\DAV\Server $server + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; + + $this->server = $server; + $this->server->on('report', [$this, 'onReport']); + } + + /** + * Returns a list of reports this plugin supports. + * + * This will be used in the {DAV:}supported-report-set property. + * + * @param string $uri + * @return array + */ + public function getSupportedReportSet($uri) { + return [self::REPORT_NAME]; + } + + /** + * REPORT operations to look for files + * + * @param string $reportName + * @param $report + * @param string $uri + * @return bool + * @throws BadRequest + * @throws PreconditionFailed + * @internal param $ [] $report + */ + public function onReport($reportName, $report, $uri) { + $reportTargetNode = $this->server->tree->getNodeForPath($uri); + if (!$reportTargetNode instanceof Directory || $reportName !== self::REPORT_NAME) { + return; + } + + $ns = '{' . $this::NS_OWNCLOUD . '}'; + $ncns = '{' . $this::NS_NEXTCLOUD . '}'; + $requestedProps = []; + $filterRules = []; + + // parse report properties and gather filter info + foreach ($report as $reportProps) { + $name = $reportProps['name']; + if ($name === $ns . 'filter-rules') { + $filterRules = $reportProps['value']; + } elseif ($name === '{DAV:}prop') { + // propfind properties + foreach ($reportProps['value'] as $propVal) { + $requestedProps[] = $propVal['name']; + } + } elseif ($name === '{DAV:}limit') { + foreach ($reportProps['value'] as $propVal) { + if ($propVal['name'] === '{DAV:}nresults') { + $limit = (int)$propVal['value']; + } elseif ($propVal['name'] === $ncns . 'firstresult') { + $offset = (int)$propVal['value']; + } + } + } + } + + if (empty($filterRules)) { + // an empty filter would return all existing files which would be slow + throw new BadRequest('Missing filter-rule block in request'); + } + + // gather all file ids matching filter + try { + $resultFileIds = $this->processFilterRulesForFileIDs($filterRules); + // no logic in circles and favorites for paging, we always have all results, and slice later on + $resultFileIds = array_slice($resultFileIds, $offset ?? 0, $limit ?? null); + // fetching nodes has paging on DB level – therefore we cannot mix and slice the results, similar + // 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'); + } + + $results = []; + foreach ($resultNodes as $entry) { + if ($entry) { + $results[] = $this->wrapNode($entry); + } + } + + // find sabre nodes by file id, restricted to the root node path + $additionalNodes = $this->findNodesByFileIds($reportTargetNode, $resultFileIds); + if ($additionalNodes && $results) { + $results = array_uintersect($results, $additionalNodes, function (Node $a, Node $b): int { + return $a->getId() - $b->getId(); + }); + } elseif (!$results && $additionalNodes) { + $results = $additionalNodes; + } + + $filesUri = $this->getFilesBaseUri($uri, $reportTargetNode->getPath()); + $responses = $this->prepareResponses($filesUri, $requestedProps, $results); + + $xml = $this->server->xml->write( + '{DAV:}multistatus', + new MultiStatus($responses) + ); + + $this->server->httpResponse->setStatus(Http::STATUS_MULTI_STATUS); + $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); + $this->server->httpResponse->setBody($xml); + + return false; + } + + /** + * Returns the base uri of the files root by removing + * the subpath from the URI + * + * @param string $uri URI from this request + * @param string $subPath subpath to remove from the URI + * + * @return string files base uri + */ + private function getFilesBaseUri(string $uri, string $subPath): string { + $uri = trim($uri, '/'); + $subPath = trim($subPath, '/'); + if (empty($subPath)) { + $filesUri = $uri; + } else { + $filesUri = substr($uri, 0, strlen($uri) - strlen($subPath)); + } + $filesUri = trim($filesUri, '/'); + if (empty($filesUri)) { + return ''; + } + return '/' . $filesUri; + } + + /** + * Find file ids matching the given filter rules + * + * @param array $filterRules + * @return array array of unique file id results + */ + protected function processFilterRulesForFileIDs(array $filterRules): array { + $ns = '{' . $this::NS_OWNCLOUD . '}'; + $resultFileIds = []; + $circlesIds = []; + $favoriteFilter = null; + foreach ($filterRules as $filterRule) { + if ($filterRule['name'] === self::CIRCLE_PROPERTYNAME) { + $circlesIds[] = $filterRule['value']; + } + if ($filterRule['name'] === $ns . 'favorite') { + $favoriteFilter = true; + } + } + + if ($favoriteFilter !== null) { + $resultFileIds = $this->fileTagger->load('files')->getFavorites(); + if (empty($resultFileIds)) { + return []; + } + } + + if (!empty($circlesIds)) { + $fileIds = $this->getCirclesFileIds($circlesIds); + if (empty($resultFileIds)) { + $resultFileIds = $fileIds; + } else { + $resultFileIds = array_intersect($fileIds, $resultFileIds); + } + } + + return $resultFileIds; + } + + protected function processFilterRulesForFileNodes(array $filterRules, ?int $limit, ?int $offset): array { + $systemTagIds = []; + foreach ($filterRules as $filterRule) { + if ($filterRule['name'] === self::SYSTEMTAG_PROPERTYNAME) { + $systemTagIds[] = $filterRule['value']; + } + } + + $nodes = []; + + if (!empty($systemTagIds)) { + $tags = $this->tagManager->getTagsByIds($systemTagIds, $this->userSession->getUser()); + + // For we run DB queries per tag and require intersection, we cannot apply limit and offset for DB queries on multi tag search. + $oneTagSearch = count($tags) === 1; + $dbLimit = $oneTagSearch ? $limit ?? 0 : 0; + $dbOffset = $oneTagSearch ? $offset ?? 0 : 0; + + foreach ($tags as $tag) { + $tagName = $tag->getName(); + $tmpNodes = $this->userFolder->searchBySystemTag($tagName, $this->userSession->getUser()->getUID(), $dbLimit, $dbOffset); + if (count($nodes) === 0) { + $nodes = $tmpNodes; + } else { + $nodes = array_uintersect($nodes, $tmpNodes, function (INode $a, INode $b): int { + return $a->getId() - $b->getId(); + }); + } + if ($nodes === []) { + // there cannot be a common match when nodes are empty early. + return $nodes; + } + } + + if (!$oneTagSearch && ($limit !== null || $offset !== null)) { + $nodes = array_slice($nodes, $offset, $limit); + } + } + + return $nodes; + } + + /** + * @suppress PhanUndeclaredClassMethod + * @param array $circlesIds + * @return array + */ + private function getCirclesFileIds(array $circlesIds) { + if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) { + return []; + } + return Circles::getFilesForCircles($circlesIds); + } + + + /** + * 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 + * @param string[] $requestedProps requested properties + * @param Node[] nodes nodes for which to fetch and prepare responses + * @return Response[] + */ + public function prepareResponses($filesUri, $requestedProps, $nodes) { + $responses = []; + foreach ($nodes as $node) { + $propFind = new PropFind($filesUri . $node->getPath(), $requestedProps); + + $this->server->getPropertiesByNode($propFind, $node); + // copied from Sabre Server's getPropertiesForPath + $result = $propFind->getResultForMultiStatus(); + $result['href'] = $propFind->getPath(); + + $resourceType = $this->server->getResourceTypeForNode($node); + if (in_array('{DAV:}collection', $resourceType) || in_array('{DAV:}principal', $resourceType)) { + $result['href'] .= '/'; + } + + $responses[] = new Response( + rtrim($this->server->getBaseUri(), '/') . $filesUri . $node->getPath(), + $result, + ); + } + return $responses; + } + + /** + * Find Sabre nodes by file ids + * + * @param Node $rootNode root node for search + * @param array $fileIds file ids + * @return Node[] array of Sabre nodes + */ + public function findNodesByFileIds(Node $rootNode, array $fileIds): array { + if (empty($fileIds)) { + return []; + } + $folder = $this->userFolder; + if (trim($rootNode->getPath(), '/') !== '') { + /** @var Folder $folder */ + $folder = $folder->get($rootNode->getPath()); + } + + $results = []; + foreach ($fileIds as $fileId) { + $entry = $folder->getFirstNodeById((int)$fileId); + if ($entry) { + $results[] = $this->wrapNode($entry); + } + } + + return $results; + } + + protected function wrapNode(INode $node): File|Directory { + if ($node instanceof \OCP\Files\File) { + return new File($this->fileView, $node); + } else { + return new Directory($this->fileView, $node); + } + } + + /** + * Returns whether the currently logged in user is an administrator + */ + private function isAdmin() { + $user = $this->userSession->getUser(); + if ($user !== null) { + return $this->groupManager->isAdmin($user->getUID()); + } + return false; + } +} diff --git a/apps/dav/lib/Connector/Sabre/LockPlugin.php b/apps/dav/lib/Connector/Sabre/LockPlugin.php new file mode 100644 index 00000000000..6640771dc31 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/LockPlugin.php @@ -0,0 +1,81 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use OCA\DAV\Connector\Sabre\Exception\FileLocked; +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; + +class LockPlugin extends ServerPlugin { + /** + * Reference to main server object + * + * @var \Sabre\DAV\Server + */ + private $server; + + /** + * State of the lock + * + * @var bool + */ + private $isLocked; + + /** + * {@inheritdoc} + */ + public function initialize(\Sabre\DAV\Server $server) { + $this->server = $server; + $this->server->on('beforeMethod:*', [$this, 'getLock'], 50); + $this->server->on('afterMethod:*', [$this, 'releaseLock'], 50); + $this->isLocked = false; + } + + public function getLock(RequestInterface $request) { + // we can't listen on 'beforeMethod:PUT' due to order of operations with setting up the tree + // so instead we limit ourselves to the PUT method manually + if ($request->getMethod() !== 'PUT') { + return; + } + try { + $node = $this->server->tree->getNodeForPath($request->getPath()); + } catch (NotFound $e) { + return; + } + if ($node instanceof Node) { + try { + $node->acquireLock(ILockingProvider::LOCK_SHARED); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + $this->isLocked = true; + } + } + + public function releaseLock(RequestInterface $request) { + // don't try to release the lock if we never locked one + if ($this->isLocked === false) { + return; + } + if ($request->getMethod() !== 'PUT') { + return; + } + try { + $node = $this->server->tree->getNodeForPath($request->getPath()); + } catch (NotFound $e) { + return; + } + if ($node instanceof Node) { + $node->releaseLock(ILockingProvider::LOCK_SHARED); + $this->isLocked = false; + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php b/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php new file mode 100644 index 00000000000..d5ab7f09dfa --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php @@ -0,0 +1,73 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use OCA\DAV\Exception\ServerMaintenanceMode; +use OCP\IConfig; +use OCP\IL10N; +use OCP\Util; +use Sabre\DAV\Exception\ServiceUnavailable; +use Sabre\DAV\ServerPlugin; + +class MaintenancePlugin extends ServerPlugin { + + /** @var IL10N */ + private $l10n; + + /** + * Reference to main server object + * + * @var Server + */ + private $server; + + /** + * @param IConfig $config + */ + public function __construct( + private IConfig $config, + IL10N $l10n, + ) { + $this->l10n = \OC::$server->getL10N('dav'); + } + + + /** + * This initializes the plugin. + * + * This function is called by \Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param \Sabre\DAV\Server $server + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $this->server = $server; + $this->server->on('beforeMethod:*', [$this, 'checkMaintenanceMode'], 1); + } + + /** + * This method is called before any HTTP method and returns http status code 503 + * in case the system is in maintenance mode. + * + * @throws ServiceUnavailable + * @return bool + */ + public function checkMaintenanceMode() { + if ($this->config->getSystemValueBool('maintenance')) { + throw new ServerMaintenanceMode($this->l10n->t('System is in maintenance mode.')); + } + if (Util::needUpgrade()) { + throw new ServerMaintenanceMode($this->l10n->t('Upgrade needed')); + } + + return true; + } +} diff --git a/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php b/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php new file mode 100644 index 00000000000..e18ef58149a --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php @@ -0,0 +1,27 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\DAV\Connector\Sabre; + +class MtimeSanitizer { + public static function sanitizeMtime(string $mtimeFromRequest): int { + // In PHP 5.X "is_numeric" returns true for strings in hexadecimal + // notation. This is no longer the case in PHP 7.X, so this check + // ensures that strings with hexadecimal notations fail too in PHP 5.X. + $isHexadecimal = preg_match('/^\s*0[xX]/', $mtimeFromRequest); + if ($isHexadecimal || !is_numeric($mtimeFromRequest)) { + throw new \InvalidArgumentException('X-OC-MTime header must be an integer (unix timestamp).'); + } + + // Prevent writing invalid mtime (timezone-proof) + if ((int)$mtimeFromRequest <= 24 * 60 * 60) { + throw new \InvalidArgumentException('X-OC-MTime header must be a valid positive integer'); + } + + return (int)$mtimeFromRequest; + } +} diff --git a/apps/dav/lib/Connector/Sabre/Node.php b/apps/dav/lib/Connector/Sabre/Node.php new file mode 100644 index 00000000000..505e6b5eda4 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Node.php @@ -0,0 +1,399 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use OC\Files\Mount\MoveableMount; +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 { + /** + * The path to the current node + * + * @var string + */ + protected $path; + + /** + * node properties cache + * + * @var array + */ + protected $property_cache = null; + + protected FileInfo $info; + + /** + * @var IManager + */ + protected $shareManager; + + protected \OCP\Files\Node $node; + + /** + * Sets up the node, expects a full path name + */ + 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 = 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 = 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 { + $this->node = new File($root, $rootView, $this->fileView->getAbsolutePath($this->path), $info); + } + } + } + + 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); + } + $this->info = $info; + $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 { + $this->node = new File($root, $rootView, $this->path, $this->info); + } + } + + /** + * Returns the name of the node + * + * @return string + */ + public function getName() { + return $this->info->getName(); + } + + /** + * Returns the full path + * + * @return string + */ + public function getPath() { + return $this->path; + } + + /** + * Renames the node + * + * @param string $name The new name + * @throws \Sabre\DAV\Exception\BadRequest + * @throws \Sabre\DAV\Exception\Forbidden + */ + public function setName($name) { + // 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); + + if (!$this->fileView->rename($this->path, $newPath)) { + throw new \Sabre\DAV\Exception('Failed to rename ' . $this->path . ' to ' . $newPath); + } + + $this->path = $newPath; + + $this->refreshInfo(); + } + + public function setPropertyCache($property_cache) { + $this->property_cache = $property_cache; + } + + /** + * Returns the last modification time, as a unix timestamp + * + * @return int timestamp as integer + */ + public function getLastModified() { + $timestamp = $this->info->getMtime(); + if (!empty($timestamp)) { + return (int)$timestamp; + } + return $timestamp; + } + + /** + * sets the last modification time of the file (mtime) to the value given + * in the second parameter or to now if the second param is empty. + * Even if the modification time is set to a custom value the access time is set to now. + */ + public function touch($mtime) { + $mtime = $this->sanitizeMtime($mtime); + $this->fileView->touch($this->path, $mtime); + $this->refreshInfo(); + } + + /** + * Returns the ETag for a file + * + * An ETag is a unique identifier representing the current version of the + * file. If the file changes, the ETag MUST change. The ETag is an + * arbitrary string, but MUST be surrounded by double-quotes. + * + * Return null if the ETag can not effectively be determined + * + * @return string + */ + public function getETag() { + return '"' . $this->info->getEtag() . '"'; + } + + /** + * Sets the ETag + * + * @param string $etag + * + * @return int file id of updated file or -1 on failure + */ + public function setETag($etag) { + return $this->fileView->putFileInfo($this->path, ['etag' => $etag]); + } + + public function setCreationTime(int $time) { + return $this->fileView->putFileInfo($this->path, ['creation_time' => $time]); + } + + public function setUploadTime(int $time) { + return $this->fileView->putFileInfo($this->path, ['upload_time' => $time]); + } + + /** + * Returns the size of the node, in bytes + * + * @psalm-suppress ImplementedReturnTypeMismatch \Sabre\DAV\IFile::getSize signature does not support 32bit + * @return int|float + */ + public function getSize(): int|float { + return $this->info->getSize(); + } + + /** + * Returns the cache's file id + * + * @return int + */ + public function getId() { + return $this->info->getId(); + } + + /** + * @return string|null + */ + public function getFileId() { + if ($id = $this->info->getId()) { + return DavUtil::getDavFileId($id); + } + + return null; + } + + /** + * @return integer + */ + public function getInternalFileId() { + return $this->info->getId(); + } + + public function getInternalPath(): string { + return $this->info->getInternalPath(); + } + + /** + * @param string $user + * @return int + */ + public function getSharePermissions($user) { + // check of we access a federated share + if ($user !== null) { + try { + $share = $this->shareManager->getShareByToken($user); + return $share->getPermissions(); + } catch (ShareNotFound $e) { + // ignore + } + } + + try { + $storage = $this->info->getStorage(); + } catch (StorageNotAvailableException $e) { + $storage = null; + } + + if ($storage && $storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ + $permissions = (int)$storage->getShare()->getPermissions(); + } else { + $permissions = $this->info->getPermissions(); + } + + /* + * We can always share non moveable mount points with DELETE and UPDATE + * Eventually we need to do this properly + */ + $mountpoint = $this->info->getMountPoint(); + if (!($mountpoint instanceof MoveableMount)) { + $mountpointpath = $mountpoint->getMountPoint(); + if (str_ends_with($mountpointpath, '/')) { + $mountpointpath = substr($mountpointpath, 0, -1); + } + + if (!$mountpoint->getOption('readonly', false) && $mountpointpath === $this->info->getPath()) { + $permissions |= Constants::PERMISSION_DELETE | Constants::PERMISSION_UPDATE; + } + } + + /* + * Files can't have create or delete permissions + */ + if ($this->info->getType() === FileInfo::TYPE_FILE) { + $permissions &= ~(Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE); + } + + return $permissions; + } + + /** + * @return array + */ + public function getShareAttributes(): array { + try { + $storage = $this->node->getStorage(); + } catch (NotFoundException $e) { + return []; + } + + $attributes = []; + if ($storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ + $attributes = $storage->getShare()->getAttributes(); + if ($attributes === null) { + return []; + } else { + return $attributes->toArray(); + } + } + + return $attributes; + } + + public function getNoteFromShare(?string $user): ?string { + try { + $storage = $this->node->getStorage(); + } catch (NotFoundException) { + return null; + } + + 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(); + } + + return null; + } + + /** + * @return string + */ + public function getDavPermissions() { + return DavUtil::getDavPermissions($this->info); + } + + public function getOwner() { + return $this->info->getOwner(); + } + + protected function verifyPath(?string $path = null): void { + try { + $path = $path ?? $this->info->getPath(); + $this->fileView->verifyPath( + dirname($path), + basename($path), + ); + } catch (InvalidPathException $ex) { + throw new InvalidPath($ex->getMessage()); + } + } + + /** + * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE + */ + public function acquireLock($type) { + $this->fileView->lockFile($this->path, $type); + } + + /** + * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE + */ + public function releaseLock($type) { + $this->fileView->unlockFile($this->path, $type); + } + + /** + * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE + */ + public function changeLock($type) { + $this->fileView->changeLock($this->path, $type); + } + + public function getFileInfo() { + return $this->info; + } + + public function getNode(): \OCP\Files\Node { + return $this->node; + } + + 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 new file mode 100644 index 00000000000..bfbdfb33db0 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ObjectTree.php @@ -0,0 +1,193 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +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; + +class ObjectTree extends CachingTree { + + /** + * @var View + */ + protected $fileView; + + /** + * @var IMountManager + */ + protected $mountManager; + + /** + * Creates the object + */ + public function __construct() { + } + + /** + * @param \Sabre\DAV\INode $rootNode + * @param View $view + * @param IMountManager $mountManager + */ + public function init(\Sabre\DAV\INode $rootNode, View $view, IMountManager $mountManager) { + $this->rootNode = $rootNode; + $this->fileView = $view; + $this->mountManager = $mountManager; + } + + /** + * Returns the INode object for the requested path + * + * @param string $path + * @return \Sabre\DAV\INode + * @throws InvalidPath + * @throws \Sabre\DAV\Exception\Locked + * @throws \Sabre\DAV\Exception\NotFound + * @throws \Sabre\DAV\Exception\ServiceUnavailable + */ + public function getNodeForPath($path) { + if (!$this->fileView) { + throw new \Sabre\DAV\Exception\ServiceUnavailable('filesystem not setup'); + } + + $path = trim($path, '/'); + + if (isset($this->cache[$path])) { + return $this->cache[$path]; + } + + if ($path) { + try { + $this->fileView->verifyPath($path, basename($path)); + } catch (InvalidPathException $ex) { + throw new InvalidPath($ex->getMessage()); + } + } + + // Is it the root node? + if (!strlen($path)) { + return $this->rootNode; + } + + if (pathinfo($path, PATHINFO_EXTENSION) === 'part') { + // read from storage + $absPath = $this->fileView->getAbsolutePath($path); + $mount = $this->fileView->getMount($path); + $storage = $mount->getStorage(); + $internalPath = $mount->getInternalPath($absPath); + if ($storage && $storage->file_exists($internalPath)) { + /** + * @var Storage $storage + */ + // get data directly + $data = $storage->getMetaData($internalPath); + $info = new FileInfo($absPath, $storage, $internalPath, $data, $mount); + } else { + $info = null; + } + } else { + // read from cache + try { + $info = $this->fileView->getFileInfo($path); + + if ($info instanceof \OCP\Files\FileInfo && $info->getStorage()->instanceOfStorage(FailedStorage::class)) { + throw new StorageNotAvailableException(); + } + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable('Storage is temporarily not available', 0, $e); + } catch (StorageInvalidException $e) { + throw new \Sabre\DAV\Exception\NotFound('Storage ' . $path . ' is invalid'); + } catch (LockedException $e) { + throw new \Sabre\DAV\Exception\Locked(); + } catch (ForbiddenException $e) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + } + + if (!$info) { + throw new \Sabre\DAV\Exception\NotFound('File with name ' . $path . ' could not be located'); + } + + if ($info->getType() === 'dir') { + $node = new Directory($this->fileView, $info, $this); + } else { + $node = new File($this->fileView, $info); + } + + $this->cache[$path] = $node; + return $node; + } + + /** + * Copies a file or directory. + * + * This method must work recursively and delete the destination + * if it exists + * + * @param string $sourcePath + * @param string $destinationPath + * @throws FileLocked + * @throws Forbidden + * @throws InvalidPath + * @throws \Exception + * @throws \Sabre\DAV\Exception\Forbidden + * @throws \Sabre\DAV\Exception\Locked + * @throws \Sabre\DAV\Exception\NotFound + * @throws \Sabre\DAV\Exception\ServiceUnavailable + * @return void + */ + public function copy($sourcePath, $destinationPath) { + if (!$this->fileView) { + throw new \Sabre\DAV\Exception\ServiceUnavailable('filesystem not setup'); + } + + + $info = $this->fileView->getFileInfo(dirname($destinationPath)); + if ($this->fileView->file_exists($destinationPath)) { + $destinationPermission = $info && $info->isUpdateable(); + } else { + $destinationPermission = $info && $info->isCreatable(); + } + if (!$destinationPermission) { + throw new Forbidden('No permissions to copy object.'); + } + + // this will trigger existence check + $this->getNodeForPath($sourcePath); + + [$destinationDir, $destinationName] = \Sabre\Uri\split($destinationPath); + try { + $this->fileView->verifyPath($destinationDir, $destinationName); + } catch (InvalidPathException $ex) { + throw new InvalidPath($ex->getMessage()); + } + + try { + $this->fileView->copy($sourcePath, $destinationPath); + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage()); + } catch (ForbiddenException $ex) { + throw new Forbidden($ex->getMessage(), $ex->getRetry()); + } catch (LockedException $e) { + throw new FileLocked($e->getMessage(), $e->getCode(), $e); + } + + [$destinationDir,] = \Sabre\Uri\split($destinationPath); + $this->markDirty($destinationDir); + } +} diff --git a/apps/dav/lib/Connector/Sabre/Principal.php b/apps/dav/lib/Connector/Sabre/Principal.php new file mode 100644 index 00000000000..d6ea9fd887d --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Principal.php @@ -0,0 +1,603 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +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; +use OCP\Accounts\IAccountProperty; +use OCP\Accounts\PropertyDoesNotExistException; +use OCP\App\IAppManager; +use OCP\AppFramework\QueryException; +use OCP\Constants; +use OCP\IConfig; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\L10N\IFactory; +use OCP\Share\IManager as IShareManager; +use Sabre\DAV\Exception; +use Sabre\DAV\PropPatch; +use Sabre\DAVACL\PrincipalBackend\BackendInterface; + +class Principal implements BackendInterface { + + /** @var string */ + private $principalPrefix; + + /** @var bool */ + private $hasGroups; + + /** @var bool */ + private $hasCircles; + + /** @var KnownUserService */ + private $knownUserService; + + 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, + private IConfig $config, + private IFactory $languageFactory, + string $principalPrefix = 'principals/users/', + ) { + $this->principalPrefix = trim($principalPrefix, '/'); + $this->hasGroups = $this->hasCircles = ($principalPrefix === 'principals/users/'); + $this->knownUserService = $knownUserService; + } + + use PrincipalProxyTrait { + getGroupMembership as protected traitGetGroupMembership; + } + + /** + * Returns a list of principals based on a prefix. + * + * This prefix will often contain something like 'principals'. You are only + * expected to return principals that are in this base path. + * + * You are expected to return at least a 'uri' for every user, you can + * return any additional properties if you wish so. Common properties are: + * {DAV:}displayname + * + * @param string $prefixPath + * @return string[] + */ + public function getPrincipalsByPrefix($prefixPath) { + $principals = []; + + if ($prefixPath === $this->principalPrefix) { + foreach ($this->userManager->search('') as $user) { + $principals[] = $this->userToPrincipal($user); + } + } + + return $principals; + } + + /** + * Returns a specific principal, specified by it's path. + * The returned structure should be the exact same as from + * getPrincipalsByPrefix. + * + * @param string $path + * @return array + */ + public function getPrincipalByPath($path) { + [$prefix, $name] = \Sabre\Uri\split($path); + $decodedName = urldecode($name); + + if ($name === 'calendar-proxy-write' || $name === 'calendar-proxy-read') { + [$prefix2, $name2] = \Sabre\Uri\split($prefix); + + if ($prefix2 === $this->principalPrefix) { + $user = $this->userManager->get($name2); + + if ($user !== null) { + return [ + 'uri' => 'principals/users/' . $user->getUID() . '/' . $name, + ]; + } + return null; + } + } + + if ($prefix === $this->principalPrefix) { + // Depending on where it is called, it may happen that this function + // is called either with a urlencoded version of the name or with a non-urlencoded one. + // The urldecode function replaces %## and +, both of which are forbidden in usernames. + // Hence there can be no ambiguity here and it is safe to call urldecode on all usernames + $user = $this->userManager->get($decodedName); + + if ($user !== null) { + return $this->userToPrincipal($user); + } + } elseif ($prefix === 'principals/circles') { + if ($this->userSession->getUser() !== null) { + // At the time of writing - 2021-01-19 — a mixed state is possible. + // The second condition can be removed when this is fixed. + return $this->circleToPrincipal($decodedName) + ?: $this->circleToPrincipal($name); + } + } elseif ($prefix === 'principals/groups') { + // At the time of writing - 2021-01-19 — a mixed state is possible. + // The second condition can be removed when this is fixed. + $group = $this->groupManager->get($decodedName) + ?: $this->groupManager->get($name); + if ($group instanceof IGroup) { + return [ + 'uri' => 'principals/groups/' . $name, + '{DAV:}displayname' => $group->getDisplayName(), + ]; + } + } elseif ($prefix === 'principals/system') { + return [ + 'uri' => 'principals/system/' . $name, + '{DAV:}displayname' => $this->languageFactory->get('dav')->t('Accounts'), + ]; + } elseif ($prefix === 'principals/shares') { + return [ + 'uri' => 'principals/shares/' . $name, + '{DAV:}displayname' => $name, + ]; + } + return null; + } + + /** + * Returns the list of groups a principal is a member of + * + * @param string $principal + * @param bool $needGroups + * @return array + * @throws Exception + */ + public function getGroupMembership($principal, $needGroups = false) { + [$prefix, $name] = \Sabre\Uri\split($principal); + + if ($prefix !== $this->principalPrefix) { + return []; + } + + $user = $this->userManager->get($name); + if (!$user) { + throw new Exception('Principal not found'); + } + + $groups = []; + + if ($this->hasGroups || $needGroups) { + $userGroups = $this->groupManager->getUserGroups($user); + foreach ($userGroups as $userGroup) { + if ($userGroup->hideFromCollaboration()) { + continue; + } + $groups[] = 'principals/groups/' . urlencode($userGroup->getGID()); + } + } + + $groups = array_unique(array_merge( + $groups, + $this->traitGetGroupMembership($principal, $needGroups) + )); + + return $groups; + } + + /** + * @param string $path + * @param PropPatch $propPatch + * @return int + */ + public function updatePrincipal($path, PropPatch $propPatch) { + // Updating schedule-default-calendar-URL is handled in CustomPropertiesBackend + return 0; + } + + /** + * Search user principals + * + * @param array $searchProperties + * @param string $test + * @return array + */ + protected function searchUserPrincipals(array $searchProperties, $test = 'allof') { + $results = []; + + // If sharing is disabled, return the empty array + $shareAPIEnabled = $this->shareManager->shareApiEnabled(); + if (!$shareAPIEnabled) { + return []; + } + + $allowEnumeration = $this->shareManager->allowEnumeration(); + $limitEnumerationGroup = $this->shareManager->limitEnumerationToGroups(); + $limitEnumerationPhone = $this->shareManager->limitEnumerationToPhone(); + $allowEnumerationFullMatch = $this->shareManager->allowEnumerationFullMatch(); + $ignoreSecondDisplayName = $this->shareManager->ignoreSecondDisplayName(); + $matchEmail = $this->shareManager->matchEmail(); + + // If sharing is restricted to group members only, + // return only members that have groups in common + $restrictGroups = false; + $currentUser = $this->userSession->getUser(); + if ($this->shareManager->shareWithGroupMembersOnly()) { + if (!$currentUser instanceof IUser) { + return []; + } + + $restrictGroups = $this->groupManager->getUserGroupIds($currentUser); + } + + $currentUserGroups = []; + if ($limitEnumerationGroup) { + if ($currentUser instanceof IUser) { + $currentUserGroups = $this->groupManager->getUserGroupIds($currentUser); + } + } + + $searchLimit = $this->config->getSystemValueInt('sharing.maxAutocompleteResults', Constants::SHARING_MAX_AUTOCOMPLETE_RESULTS_DEFAULT); + if ($searchLimit <= 0) { + $searchLimit = null; + } + foreach ($searchProperties as $prop => $value) { + switch ($prop) { + case '{http://sabredav.org/ns}email-address': + if (!$allowEnumeration) { + if ($allowEnumerationFullMatch && $matchEmail) { + $users = $this->userManager->getByEmail($value); + } else { + $users = []; + } + } else { + $users = $this->userManager->getByEmail($value); + $users = \array_filter($users, function (IUser $user) use ($currentUser, $value, $limitEnumerationPhone, $limitEnumerationGroup, $allowEnumerationFullMatch, $currentUserGroups) { + if ($allowEnumerationFullMatch && $user->getSystemEMailAddress() === $value) { + return true; + } + + if ($limitEnumerationPhone + && $currentUser instanceof IUser + && $this->knownUserService->isKnownToUser($currentUser->getUID(), $user->getUID())) { + // Synced phonebook match + return true; + } + + if (!$limitEnumerationGroup) { + // No limitation on enumeration, all allowed + return true; + } + + return !empty($currentUserGroups) && !empty(array_intersect( + $this->groupManager->getUserGroupIds($user), + $currentUserGroups + )); + }); + } + + $results[] = array_reduce($users, function (array $carry, IUser $user) use ($restrictGroups) { + // is sharing restricted to groups only? + if ($restrictGroups !== false) { + $userGroups = $this->groupManager->getUserGroupIds($user); + if (count(array_intersect($userGroups, $restrictGroups)) === 0) { + return $carry; + } + } + + $carry[] = $this->principalPrefix . '/' . $user->getUID(); + return $carry; + }, []); + break; + + case '{DAV:}displayname': + + if (!$allowEnumeration) { + if ($allowEnumerationFullMatch) { + $lowerSearch = strtolower($value); + $users = $this->userManager->searchDisplayName($value, $searchLimit); + $users = \array_filter($users, static function (IUser $user) use ($lowerSearch, $ignoreSecondDisplayName) { + $lowerDisplayName = strtolower($user->getDisplayName()); + return $lowerDisplayName === $lowerSearch || ($ignoreSecondDisplayName && trim(preg_replace('/ \(.*\)$/', '', $lowerDisplayName)) === $lowerSearch); + }); + } else { + $users = []; + } + } else { + $users = $this->userManager->searchDisplayName($value, $searchLimit); + $users = \array_filter($users, function (IUser $user) use ($currentUser, $value, $limitEnumerationPhone, $limitEnumerationGroup, $allowEnumerationFullMatch, $currentUserGroups) { + if ($allowEnumerationFullMatch && $user->getDisplayName() === $value) { + return true; + } + + if ($limitEnumerationPhone + && $currentUser instanceof IUser + && $this->knownUserService->isKnownToUser($currentUser->getUID(), $user->getUID())) { + // Synced phonebook match + return true; + } + + if (!$limitEnumerationGroup) { + // No limitation on enumeration, all allowed + return true; + } + + return !empty($currentUserGroups) && !empty(array_intersect( + $this->groupManager->getUserGroupIds($user), + $currentUserGroups + )); + }); + } + + $results[] = array_reduce($users, function (array $carry, IUser $user) use ($restrictGroups) { + // is sharing restricted to groups only? + if ($restrictGroups !== false) { + $userGroups = $this->groupManager->getUserGroupIds($user); + if (count(array_intersect($userGroups, $restrictGroups)) === 0) { + return $carry; + } + } + + $carry[] = $this->principalPrefix . '/' . $user->getUID(); + return $carry; + }, []); + break; + + case '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set': + // If you add support for more search properties that qualify as a user-address, + // please also add them to the array below + $results[] = $this->searchUserPrincipals([ + // In theory this should also search for principal:principals/users/... + // but that's used internally only anyway and i don't know of any client querying that + '{http://sabredav.org/ns}email-address' => $value, + ], 'anyof'); + break; + + default: + $results[] = []; + break; + } + } + + // results is an array of arrays, so this is not the first search result + // but the results of the first searchProperty + if (count($results) === 1) { + return $results[0]; + } + + switch ($test) { + case 'anyof': + return array_values(array_unique(array_merge(...$results))); + + case 'allof': + default: + return array_values(array_intersect(...$results)); + } + } + + /** + * @param string $prefixPath + * @param array $searchProperties + * @param string $test + * @return array + */ + public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') { + if (count($searchProperties) === 0) { + return []; + } + + switch ($prefixPath) { + case 'principals/users': + return $this->searchUserPrincipals($searchProperties, $test); + + default: + return []; + } + } + + /** + * @param string $uri + * @param string $principalPrefix + * @return string + */ + public function findByUri($uri, $principalPrefix) { + // If sharing is disabled, return the empty array + $shareAPIEnabled = $this->shareManager->shareApiEnabled(); + if (!$shareAPIEnabled) { + return null; + } + + // If sharing is restricted to group members only, + // return only members that have groups in common + $restrictGroups = false; + if ($this->shareManager->shareWithGroupMembersOnly()) { + $user = $this->userSession->getUser(); + if (!$user) { + return null; + } + + $restrictGroups = $this->groupManager->getUserGroupIds($user); + } + + if (str_starts_with($uri, 'mailto:')) { + if ($principalPrefix === 'principals/users') { + $users = $this->userManager->getByEmail(substr($uri, 7)); + if (count($users) !== 1) { + return null; + } + $user = $users[0]; + + if ($restrictGroups !== false) { + $userGroups = $this->groupManager->getUserGroupIds($user); + if (count(array_intersect($userGroups, $restrictGroups)) === 0) { + return null; + } + } + + return $this->principalPrefix . '/' . $user->getUID(); + } + } + if (str_starts_with($uri, 'principal:')) { + $principal = substr($uri, 10); + $principal = $this->getPrincipalByPath($principal); + if ($principal !== null) { + return $principal['uri']; + } + } + + return null; + } + + /** + * @param IUser $user + * @return array + * @throws PropertyDoesNotExistException + */ + protected function userToPrincipal($user) { + $userId = $user->getUID(); + $displayName = $user->getDisplayName(); + $principal = [ + 'uri' => $this->principalPrefix . '/' . $userId, + '{DAV:}displayname' => is_null($displayName) ? $userId : $displayName, + '{urn:ietf:params:xml:ns:caldav}calendar-user-type' => 'INDIVIDUAL', + '{http://nextcloud.com/ns}language' => $this->languageFactory->getUserLanguage($user), + ]; + + $account = $this->accountManager->getAccount($user); + $alternativeEmails = array_map(fn (IAccountProperty $property) => 'mailto:' . $property->getValue(), $account->getPropertyCollection(IAccountManager::COLLECTION_EMAIL)->getProperties()); + + $email = $user->getSystemEMailAddress(); + if (!empty($email)) { + $principal['{http://sabredav.org/ns}email-address'] = $email; + } + + if (!empty($alternativeEmails)) { + $principal['{DAV:}alternate-URI-set'] = $alternativeEmails; + } + + return $principal; + } + + public function getPrincipalPrefix() { + return $this->principalPrefix; + } + + /** + * @param string $circleUniqueId + * @return array|null + */ + protected function circleToPrincipal($circleUniqueId) { + if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) { + return null; + } + + try { + $circle = Circles::detailsCircle($circleUniqueId, true); + } catch (QueryException $ex) { + return null; + } catch (CircleNotFoundException $ex) { + return null; + } + + if (!$circle) { + return null; + } + + $principal = [ + 'uri' => 'principals/circles/' . $circleUniqueId, + '{DAV:}displayname' => $circle->getDisplayName(), + ]; + + return $principal; + } + + /** + * Returns the list of circles a principal is a member of + * + * @param string $principal + * @return array + * @throws Exception + * @throws QueryException + * @suppress PhanUndeclaredClassMethod + */ + public function getCircleMembership($principal):array { + if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) { + return []; + } + + [$prefix, $name] = \Sabre\Uri\split($principal); + if ($this->hasCircles && $prefix === $this->principalPrefix) { + $user = $this->userManager->get($name); + if (!$user) { + throw new Exception('Principal not found'); + } + + $circles = Circles::joinedCircles($name, true); + + $circles = array_map(function ($circle) { + /** @var Circle $circle */ + return 'principals/circles/' . urlencode($circle->getSingleId()); + }, $circles); + + return $circles; + } + + return []; + } + + /** + * Get all email addresses associated to a principal. + * + * @param array $principal Data from getPrincipal*() + * @return string[] All email addresses without the mailto: prefix + */ + public function getEmailAddressesOfPrincipal(array $principal): array { + $emailAddresses = []; + + if (isset($principal['{http://sabredav.org/ns}email-address'])) { + $emailAddresses[] = $principal['{http://sabredav.org/ns}email-address']; + } + + if (isset($principal['{DAV:}alternate-URI-set'])) { + foreach ($principal['{DAV:}alternate-URI-set'] as $address) { + if (str_starts_with($address, 'mailto:')) { + $emailAddresses[] = substr($address, 7); + } + } + } + + if (isset($principal['{urn:ietf:params:xml:ns:caldav}calendar-user-address-set'])) { + foreach ($principal['{urn:ietf:params:xml:ns:caldav}calendar-user-address-set'] as $address) { + if (str_starts_with($address, 'mailto:')) { + $emailAddresses[] = substr($address, 7); + } + } + } + + if (isset($principal['{http://calendarserver.org/ns/}email-address-set'])) { + foreach ($principal['{http://calendarserver.org/ns/}email-address-set'] as $address) { + if (str_starts_with($address, 'mailto:')) { + $emailAddresses[] = substr($address, 7); + } + } + } + + return array_values(array_unique($emailAddresses)); + } +} diff --git a/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php new file mode 100644 index 00000000000..38538fdcff0 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php @@ -0,0 +1,82 @@ +<?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; + } + + $logger = $this->server->getLogger(); + foreach ($pluginQueries as $eventName => $eventQueries) { + $maxDepth = max(0, ...array_keys($eventQueries)); + // entries at the top are usually not interesting + unset($eventQueries[$maxDepth]); + foreach ($eventQueries 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}:{event} 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, + 'event' => $eventName, + ] + ); + } + } + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php b/apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php new file mode 100644 index 00000000000..c7b0c64132c --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PropFindPreloadNotifyPlugin.php @@ -0,0 +1,55 @@ +<?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\ICollection; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; + +/** + * This plugin asks other plugins to preload data for a collection, so that + * subsequent PROPFIND handlers for children do not query the DB on a per-node + * basis. + */ +class PropFindPreloadNotifyPlugin extends ServerPlugin { + + private Server $server; + + public function initialize(Server $server): void { + $this->server = $server; + $this->server->on('propFind', [$this, 'collectionPreloadNotifier' ], 1); + } + + /** + * Uses the server instance to emit a `preloadCollection` event to signal + * to interested plugins that a collection can be preloaded. + * + * NOTE: this can be emitted several times, so ideally every plugin + * should cache what they need and check if a cache exists before + * re-fetching. + */ + public function collectionPreloadNotifier(PropFind $propFind, INode $node): bool { + if (!$this->shouldPreload($propFind, $node)) { + return true; + } + + return $this->server->emit('preloadCollection', [$propFind, $node]); + } + + private function shouldPreload( + PropFind $propFind, + INode $node, + ): bool { + $depth = $propFind->getDepth(); + return $node instanceof ICollection + && ($depth === Server::DEPTH_INFINITY || $depth > 0); + } +} diff --git a/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php b/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php new file mode 100644 index 00000000000..15daf1f34b6 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\Request; +use Sabre\HTTP\Response; + +class PropfindCompressionPlugin extends ServerPlugin { + + /** + * Reference to main server object + * + * @var Server + */ + private $server; + + /** + * This initializes the plugin. + * + * This function is called by \Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param \Sabre\DAV\Server $server + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $this->server = $server; + $this->server->on('afterMethod:PROPFIND', [$this, 'compressResponse'], 100); + } + + public function compressResponse(Request $request, Response $response) { + $header = $request->getHeader('Accept-Encoding'); + + if ($header === null) { + return $response; + } + + if (str_contains($header, 'gzip')) { + $body = $response->getBody(); + if (is_string($body)) { + $response->setHeader('Content-Encoding', 'gzip'); + $response->setBody(gzencode($body)); + } + } + + return $response; + } +} diff --git a/apps/dav/lib/Connector/Sabre/PublicAuth.php b/apps/dav/lib/Connector/Sabre/PublicAuth.php new file mode 100644 index 00000000000..2ca1c25e2f6 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PublicAuth.php @@ -0,0 +1,227 @@ +<?php + +declare(strict_types=1); + + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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; +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; +use Sabre\HTTP\ResponseInterface; + +/** + * Class PublicAuth + * + * @package OCA\DAV\Connector + */ +class PublicAuth extends AbstractBasic { + private const BRUTEFORCE_ACTION = 'public_dav_auth'; + public const DAV_AUTHENTICATED = 'public_link_authenticated'; + + private ?IShare $share = null; + + 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 Defaults(); + $this->realm = $defaults->getName(); + } + + /** + * @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, + $response + ); + + $userpass = $auth->getCredentials(); + // If authentication provided, checking its validity + if ($userpass && !$this->validateUserPass($userpass[0], $userpass[1])) { + return [false, 'Username or password was incorrect']; + } + + return $this->checkToken(); + } 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); + $msg = $e->getMessage(); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new ServiceUnavailable("$class: $msg"); + } + } + + /** + * Extract token from request url + * @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(); + } + + return $splittedPath[3]; + } + + /** + * Check token validity + * + * @throws NotFound + * @throws NotAuthenticated + */ + private function checkToken(): array { + $token = $this->getToken(); + + try { + /** @var IShare $share */ + $share = $this->shareManager->getShareByToken($token); + } catch (ShareNotFound $e) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + throw new NotFound(); + } + + $this->share = $share; + \OC_User::setIncognitoMode(true); + + // If already authenticated + if ($this->session->exists(self::DAV_AUTHENTICATED) + && $this->session->get(self::DAV_AUTHENTICATED) === $share->getId()) { + return [true, $this->principalPrefix . $token]; + } + + // If the share is protected but user is not authenticated + if ($share->getPassword() !== null) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + throw new NotAuthenticated(); + } + + return [true, $this->principalPrefix . $token]; + } + + /** + * Validates a username and password + * + * This method should return true or false depending on if login + * succeeded. + * + * @param string $username + * @param string $password + * + * @return bool + * @throws NotAuthenticated + */ + protected function validateUserPass($username, $password) { + $this->throttler->sleepDelayOrThrowOnMax($this->request->getRemoteAddress(), self::BRUTEFORCE_ACTION); + + try { + $share = $this->getShare(); + } catch (ShareNotFound $e) { + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } + + \OC_User::setIncognitoMode(true); + + // check if the share is password protected + if ($share->getPassword() !== null) { + if ($share->getShareType() === IShare::TYPE_LINK + || $share->getShareType() === IShare::TYPE_EMAIL + || $share->getShareType() === IShare::TYPE_CIRCLE) { + if ($this->shareManager->checkPassword($share, $password)) { + // If not set, set authenticated session cookie + if (!$this->session->exists(self::DAV_AUTHENTICATED) + || $this->session->get(self::DAV_AUTHENTICATED) !== $share->getId()) { + $this->session->set(self::DAV_AUTHENTICATED, $share->getId()); + } + return true; + } + + if ($this->session->exists(PublicAuth::DAV_AUTHENTICATED) + && $this->session->get(PublicAuth::DAV_AUTHENTICATED) === $share->getId()) { + return true; + } + + if (in_array('XMLHttpRequest', explode(',', $this->request->getHeader('X-Requested-With')))) { + // do not re-authenticate over ajax, use dummy auth name to prevent browser popup + http_response_code(401); + header('WWW-Authenticate: DummyBasic realm="' . $this->realm . '"'); + throw new NotAuthenticated('Cannot authenticate over ajax calls'); + } + + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } elseif ($share->getShareType() === IShare::TYPE_REMOTE) { + return true; + } + + $this->throttler->registerAttempt(self::BRUTEFORCE_ACTION, $this->request->getRemoteAddress()); + return false; + } + + return true; + } + + public function getShare(): IShare { + $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 new file mode 100644 index 00000000000..bbb378edc9b --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/QuotaPlugin.php @@ -0,0 +1,264 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-FileCopyrightText: 2012 entreCables S.L. All rights reserved + * SPDX-License-Identifier: AGPL-3.0-only + */ +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. + * + * @author Sergio Cambra + * @copyright Copyright (C) 2012 entreCables S.L. All rights reserved. + * @license http://code.google.com/p/sabredav/wiki/License Modified BSD License + */ +class QuotaPlugin extends \Sabre\DAV\ServerPlugin { + /** + * Reference to main server object + * + * @var \Sabre\DAV\Server + */ + private $server; + + /** + * @param View $view + */ + public function __construct( + private $view, + ) { + } + + /** + * This initializes the plugin. + * + * This function is called by \Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the requires event subscriptions. + * + * @param \Sabre\DAV\Server $server + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $this->server = $server; + + $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); + } + + /** + * Check quota before creating file + * + * @param string $uri target file URI + * @param resource $data data + * @param INode $parent Sabre Node + * @param bool $modified modified + */ + public function beforeCreateFile($uri, $data, INode $parent, $modified) { + $request = $this->server->httpRequest; + if ($parent instanceof UploadFolder && $request->getHeader('Destination')) { + // If chunked upload and Total-Length header is set, use that + // value for quota check. This allows us to also check quota while + // uploading chunks and not only when the file is assembled. + $length = $request->getHeader('OC-Total-Length'); + $destinationPath = $this->server->calculateUri($request->getHeader('Destination')); + $quotaPath = $this->getPathForDestination($destinationPath); + if ($quotaPath && is_numeric($length)) { + return $this->checkQuota($quotaPath, (int)$length); + } + } + + if (!$parent instanceof Node) { + return; + } + + return $this->checkQuota($parent->getPath() . '/' . basename($uri)); + } + + /** + * 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 + * @param INode $node Sabre Node + * @param resource $data data + * @param bool $modified modified + */ + public function beforeWriteContent($uri, INode $node, $data, $modified) { + if (!$node instanceof Node) { + return; + } + + return $this->checkQuota($node->getPath()); + } + + /** + * Check if we're moving a FutureFile in which case we need to check + * the quota on the target destination. + */ + public function beforeMove(string $sourcePath, string $destinationPath): bool { + $sourceNode = $this->server->tree->getNodeForPath($sourcePath); + if (!$sourceNode instanceof FutureFile) { + return true; + } + + try { + // The final path is not known yet, we check the quota on the parent + $path = $this->getPathForDestination($destinationPath); + } catch (\Exception $e) { + return true; + } + + return $this->checkQuota($path, $sourceNode->getSize()); + } + + /** + * Check quota on the target destination before a copy. + */ + public function beforeCopy(string $sourcePath, string $destinationPath): bool { + $sourceNode = $this->server->tree->getNodeForPath($sourcePath); + if (!$sourceNode instanceof Node) { + return true; + } + + try { + $path = $this->getPathForDestination($destinationPath); + } catch (\Exception $e) { + return true; + } + + return $this->checkQuota($path, $sourceNode->getSize()); + } + + private function getPathForDestination(string $destinationPath): string { + // get target node for proper path conversion + if ($this->server->tree->nodeExists($destinationPath)) { + $destinationNode = $this->server->tree->getNodeForPath($destinationPath); + if (!$destinationNode instanceof Node) { + throw new \Exception('Invalid destination node'); + } + return $destinationNode->getPath(); + } + + $parent = dirname($destinationPath); + if ($parent === '.') { + $parent = ''; + } + + $parentNode = $this->server->tree->getNodeForPath($parent); + if (!$parentNode instanceof Node) { + throw new \Exception('Invalid destination node'); + } + + return $parentNode->getPath(); + } + + + /** + * This method is called before any HTTP method and validates there is enough free space to store the file + * + * @param string $path relative to the users home + * @param int|float|null $length + * @throws InsufficientStorage + * @return bool + */ + public function checkQuota(string $path, $length = null, $isDir = false) { + if ($length === null) { + $length = $this->getLength(); + } + + if ($length) { + [$parentPath, $newName] = \Sabre\Uri\split($path); + if (is_null($parentPath)) { + $parentPath = ''; + } + $req = $this->server->httpRequest; + + // Strip any duplicate slashes + $path = str_replace('//', '/', $path); + + $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"); + } + } + + return true; + } + + public function getLength() { + $req = $this->server->httpRequest; + $length = $req->getHeader('X-Expected-Entity-Length'); + if (!is_numeric($length)) { + $length = $req->getHeader('Content-Length'); + $length = is_numeric($length) ? $length : null; + } + + $ocLength = $req->getHeader('OC-Total-Length'); + if (!is_numeric($ocLength)) { + return $length; + } + if (!is_numeric($length)) { + return $ocLength; + } + return max($length, $ocLength); + } + + /** + * @param string $uri + * @return mixed + * @throws ServiceUnavailable + */ + public function getFreeSpace($uri) { + try { + $freeSpace = $this->view->free_space(ltrim($uri, '/')); + return $freeSpace; + } catch (StorageNotAvailableException $e) { + throw new ServiceUnavailable($e->getMessage()); + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php b/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php new file mode 100644 index 00000000000..5484bab9237 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Connector\Sabre; + +use OCP\IRequest; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class RequestIdHeaderPlugin extends \Sabre\DAV\ServerPlugin { + public function __construct( + private IRequest $request, + ) { + } + + public function initialize(\Sabre\DAV\Server $server) { + $server->on('afterMethod:*', [$this, 'afterMethod']); + } + + /** + * Add the request id as a header in the response + * + * @param RequestInterface $request request + * @param ResponseInterface $response response + */ + public function afterMethod(RequestInterface $request, ResponseInterface $response) { + $response->setHeader('X-Request-Id', $this->request->getId()); + } +} diff --git a/apps/dav/lib/Connector/Sabre/Server.php b/apps/dav/lib/Connector/Sabre/Server.php new file mode 100644 index 00000000000..eef65000131 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/Server.php @@ -0,0 +1,240 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +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 + * + * This class overrides some methods from @see \Sabre\DAV\Server. + * + * @see \Sabre\DAV\Server + */ +class Server extends \Sabre\DAV\Server { + /** @var CachingTree $tree */ + + /** + * Tracks queries done by plugins. + * @var array<string, array<int, array<string, array{nodes:int, + * queries:int}>>> The keys represent: event name, depth and plugin name + */ + private array $pluginQueries = []; + + public bool $debugEnabled = false; + + /** + * @see \Sabre\DAV\Server + */ + public function __construct($treeOrNode = null) { + parent::__construct($treeOrNode); + 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 { + $pluginName = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['class'] ?? 'unknown'; + // The NotifyPlugin needs to be excluded as it emits the + // `preloadCollection` event, which causes many plugins run queries. + /** @psalm-suppress TypeDoesNotContainType */ + if ($pluginName === PropFindPreloadNotifyPlugin::class || ($eventName !== 'propFind' + && $eventName !== 'preloadCollection')) { + $parentFn($eventName, $callBack, $priority); + return; + } + + $callback = $this->getMonitoredCallback($callBack, $pluginName, $eventName); + + $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, + string $eventName, + ): callable { + return function (PropFind $propFind, INode $node) use ( + $callBack, + $pluginName, + $eventName, + ): bool { + $connection = \OCP\Server::get(Connection::class); + $queriesBefore = $connection->getStats()['executed']; + $result = $callBack($propFind, $node); + $queriesAfter = $connection->getStats()['executed']; + $this->trackPluginQueries( + $pluginName, + $eventName, + $queriesAfter - $queriesBefore, + $propFind->getDepth() + ); + + // many callbacks don't care about returning a bool + return $result ?? true; + }; + } + + /** + * Tracks the queries executed by a specific plugin. + */ + private function trackPluginQueries( + string $pluginName, + string $eventName, + int $queriesExecuted, + int $depth, + ): void { + // report only nodes which cause queries to the DB + if ($queriesExecuted === 0) { + return; + } + + $this->pluginQueries[$eventName][$depth][$pluginName]['nodes'] + = ($this->pluginQueries[$eventName][$depth][$pluginName]['nodes'] ?? 0) + 1; + + $this->pluginQueries[$eventName][$depth][$pluginName]['queries'] + = ($this->pluginQueries[$eventName][$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<string, array<int, array<string, array{nodes:int, + * queries:int}>>> The keys represent: event name, depth and plugin name + */ + 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 new file mode 100644 index 00000000000..1b4de841ec6 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -0,0 +1,253 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +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 { + + public function __construct( + 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, + ) { + } + + /** + * @param callable $viewCallBack callback that should return the view for the dav endpoint + */ + public function createServer( + bool $isPublicShare, + string $baseUri, + string $requestUri, + Plugin $authPlugin, + callable $viewCallBack, + ): Server { + $debugEnabled = $this->config->getSystemValue('debug', false); + // Fire up server + 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 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()); + } + + $server->addPlugin(new PropFindPreloadNotifyPlugin()); + // FIXME: The following line is a workaround for legacy components relying on being able to send a GET to / + $server->addPlugin(new DummyGetResponsePlugin()); + $server->addPlugin(new ExceptionLoggerPlugin('webdav', $this->logger)); + $server->addPlugin(new LockPlugin()); + + $server->addPlugin(new RequestIdHeaderPlugin($this->request)); + + $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. + if ($this->request->isUserAgent([ + '/WebDAVFS/', + '/OneNote/', + '/Microsoft-WebDAV-MiniRedir/', + ])) { + $server->addPlugin(new FakeLockerPlugin()); + } + + if (BrowserErrorPagePlugin::isBrowserRequest($this->request)) { + $server->addPlugin(new BrowserErrorPagePlugin()); + } + + // wait with registering these until auth is handled and the filesystem is setup + $server->on('beforeMethod:*', function () use ($server, $tree, + $viewCallBack, $isPublicShare, $rootCollection, $debugEnabled): void { + // ensure the skeleton is copied + $userFolder = \OC::$server->getUserFolder(); + + /** @var View $view */ + $view = $viewCallBack($server); + if ($userFolder instanceof Folder && $userFolder->getPath() === $view->getRoot()) { + $rootInfo = $userFolder; + } else { + $rootInfo = $view->getFileInfo(''); + } + + // Create Nextcloud Dir + if ($rootInfo->getType() === 'dir') { + $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 { + /** @var ObjectTree $tree */ + $tree->init($root, $view, $this->mountManager); + } + + $server->addPlugin( + new FilesPlugin( + $tree, + $this->config, + $this->request, + $this->previewManager, + $this->userSession, + \OCP\Server::get(IFilenameValidator::class), + \OCP\Server::get(IAccountManager::class), + false, + !$debugEnabled + ) + ); + $server->addPlugin(new QuotaPlugin($view)); + $server->addPlugin(new ChecksumUpdatePlugin()); + + // Allow view-only plugin for webdav requests + $server->addPlugin(new ViewOnlyPlugin( + $userFolder, + )); + + if ($this->userSession->isLoggedIn()) { + $server->addPlugin(new TagsPlugin($tree, $this->tagManager, $this->eventDispatcher, $this->userSession)); + $server->addPlugin(new SharesPlugin( + $tree, + $this->userSession, + $userFolder, + \OCP\Server::get(\OCP\Share\IManager::class) + )); + $server->addPlugin(new CommentPropertiesPlugin(\OCP\Server::get(ICommentsManager::class), $this->userSession)); + $server->addPlugin(new FilesReportPlugin( + $tree, + $view, + \OCP\Server::get(ISystemTagManager::class), + \OCP\Server::get(ISystemTagObjectMapper::class), + \OCP\Server::get(ITagManager::class), + $this->userSession, + \OCP\Server::get(IGroupManager::class), + $userFolder, + \OCP\Server::get(IAppManager::class) + )); + // custom properties plugin must be the last one + $server->addPlugin( + new \Sabre\DAV\PropertyStorage\Plugin( + new CustomPropertiesBackend( + $server, + $tree, + $this->databaseConnection, + $this->userSession->getUser(), + \OCP\Server::get(PropertyMapper::class), + \OCP\Server::get(DefaultCalendarValidator::class), + ) + ) + ); + } + $server->addPlugin(new CopyEtagHeaderPlugin()); + + // Load dav plugins from apps + $event = new SabrePluginEvent($server); + $this->eventDispatcher->dispatchTyped($event); + $pluginManager = new PluginManager( + \OC::$server, + \OCP\Server::get(IAppManager::class) + ); + foreach ($pluginManager->getAppPlugins() as $appPlugin) { + $server->addPlugin($appPlugin); + } + }, 30); // priority 30: after auth (10) and acl(20), before lock(50) and handling the request + return $server; + } +} diff --git a/apps/dav/lib/Connector/Sabre/ShareTypeList.php b/apps/dav/lib/Connector/Sabre/ShareTypeList.php new file mode 100644 index 00000000000..0b66ed27576 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ShareTypeList.php @@ -0,0 +1,74 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\Xml\Element; +use Sabre\Xml\Reader; +use Sabre\Xml\Writer; + +/** + * ShareTypeList property + * + * This property contains multiple "share-type" elements, each containing a share type. + */ +class ShareTypeList implements Element { + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + + /** + * @param int[] $shareTypes + */ + public function __construct( + /** + * Share types + */ + private $shareTypes, + ) { + } + + /** + * Returns the share types + * + * @return int[] + */ + public function getShareTypes() { + return $this->shareTypes; + } + + /** + * The deserialize method is called during xml parsing. + * + * @param Reader $reader + * @return mixed + */ + public static function xmlDeserialize(Reader $reader) { + $shareTypes = []; + + $tree = $reader->parseInnerTree(); + if ($tree === null) { + return null; + } + foreach ($tree as $elem) { + if ($elem['name'] === '{' . self::NS_OWNCLOUD . '}share-type') { + $shareTypes[] = (int)$elem['value']; + } + } + return new self($shareTypes); + } + + /** + * The xmlSerialize method is called during xml writing. + * + * @param Writer $writer + * @return void + */ + public function xmlSerialize(Writer $writer) { + foreach ($this->shareTypes as $shareType) { + $writer->writeElement('{' . self::NS_OWNCLOUD . '}share-type', $shareType); + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/ShareeList.php b/apps/dav/lib/Connector/Sabre/ShareeList.php new file mode 100644 index 00000000000..909c29fc24b --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ShareeList.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use OCP\Share\IShare; +use Sabre\Xml\Writer; +use Sabre\Xml\XmlSerializable; + +/** + * This property contains multiple "sharee" elements, each containing a share sharee + */ +class ShareeList implements XmlSerializable { + public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; + + public function __construct( + /** @var IShare[] */ + private array $shares, + ) { + } + + /** + * The xmlSerialize method is called during xml writing. + * + * @param Writer $writer + * @return void + */ + public function xmlSerialize(Writer $writer) { + foreach ($this->shares as $share) { + $writer->startElement('{' . self::NS_NEXTCLOUD . '}sharee'); + $writer->writeElement('{' . self::NS_NEXTCLOUD . '}id', $share->getSharedWith()); + $writer->writeElement('{' . self::NS_NEXTCLOUD . '}display-name', $share->getSharedWithDisplayName()); + $writer->writeElement('{' . self::NS_NEXTCLOUD . '}type', $share->getShareType()); + $writer->endElement(); + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/SharesPlugin.php b/apps/dav/lib/Connector/Sabre/SharesPlugin.php new file mode 100644 index 00000000000..11e50362dc2 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/SharesPlugin.php @@ -0,0 +1,219 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use OC\Share20\Exception\BackendError; +use OCA\DAV\Connector\Sabre\Node as DavNode; +use OCP\Files\Folder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\IUserSession; +use OCP\Share\IManager; +use OCP\Share\IShare; +use Sabre\DAV\ICollection; +use Sabre\DAV\PropFind; +use Sabre\DAV\Server; +use Sabre\DAV\Tree; + +/** + * Sabre Plugin to provide share-related properties + */ +class SharesPlugin extends \Sabre\DAV\ServerPlugin { + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; + public const SHARETYPES_PROPERTYNAME = '{http://owncloud.org/ns}share-types'; + public const SHAREES_PROPERTYNAME = '{http://nextcloud.org/ns}sharees'; + + /** + * Reference to main server object + * + * @var \Sabre\DAV\Server + */ + private $server; + private string $userId; + + /** @var IShare[][] */ + private array $cachedShares = []; + + /** + * Tracks which folders have been cached. + * When a folder is cached, it will appear with its path as key and true + * as value. + * + * @var bool[] + */ + private array $cachedFolders = []; + + public function __construct( + private Tree $tree, + private IUserSession $userSession, + private Folder $userFolder, + private IManager $shareManager, + ) { + $this->userId = $userSession->getUser()->getUID(); + } + + /** + * 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. + * + * @return void + */ + public function initialize(Server $server) { + $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; + $server->xml->elementMap[self::SHARETYPES_PROPERTYNAME] = ShareTypeList::class; + $server->protectedProperties[] = self::SHARETYPES_PROPERTYNAME; + $server->protectedProperties[] = self::SHAREES_PROPERTYNAME; + + $this->server = $server; + $this->server->on('preloadCollection', $this->preloadCollection(...)); + $this->server->on('propFind', [$this, 'handleGetProperties']); + } + + /** + * @param Node $node + * @return IShare[] + */ + private function getShare(Node $node): array { + $result = []; + $requestedShareTypes = [ + IShare::TYPE_USER, + IShare::TYPE_GROUP, + IShare::TYPE_LINK, + IShare::TYPE_REMOTE, + IShare::TYPE_EMAIL, + IShare::TYPE_ROOM, + IShare::TYPE_CIRCLE, + IShare::TYPE_DECK, + IShare::TYPE_SCIENCEMESH, + ]; + + foreach ($requestedShareTypes as $requestedShareType) { + $result[] = $this->shareManager->getSharesBy( + $this->userId, + $requestedShareType, + $node, + false, + -1 + ); + + // Also check for shares where the user is the recipient + try { + $result[] = $this->shareManager->getSharedWith( + $this->userId, + $requestedShareType, + $node, + -1 + ); + } catch (BackendError $e) { + // ignore + } + } + + return array_merge(...$result); + } + + /** + * @param Folder $node + * @return IShare[][] + */ + private function getSharesFolder(Folder $node): array { + return $this->shareManager->getSharesInFolder( + $this->userId, + $node, + true + ); + } + + /** + * @param DavNode $sabreNode + * @return IShare[] + */ + private function getShares(DavNode $sabreNode): array { + if (isset($this->cachedShares[$sabreNode->getId()])) { + return $this->cachedShares[$sabreNode->getId()]; + } + + [$parentPath,] = \Sabre\Uri\split($sabreNode->getPath()); + if ($parentPath === '') { + $parentPath = '/'; + } + + // if we already cached the folder containing this file + // then we already know there are no shares here. + if (!isset($this->cachedFolders[$parentPath])) { + try { + $node = $sabreNode->getNode(); + } catch (NotFoundException $e) { + return []; + } + + $shares = $this->getShare($node); + $this->cachedShares[$sabreNode->getId()] = $shares; + return $shares; + } + + return []; + } + + private function preloadCollection(PropFind $propFind, ICollection $collection): void { + if (!$collection instanceof Directory + || isset($this->cachedFolders[$collection->getPath()]) + || ( + $propFind->getStatus(self::SHARETYPES_PROPERTYNAME) === null + && $propFind->getStatus(self::SHAREES_PROPERTYNAME) === null + ) + ) { + return; + } + + // If the node is a directory and we are requesting share types or sharees + // then we get all the shares in the folder and cache them. + // This is more performant than iterating each files afterwards. + $folderNode = $collection->getNode(); + $this->cachedFolders[$collection->getPath()] = true; + foreach ($this->getSharesFolder($folderNode) as $id => $shares) { + $this->cachedShares[$id] = $shares; + } + } + + /** + * Adds shares to propfind response + * + * @param PropFind $propFind propfind object + * @param \Sabre\DAV\INode $sabreNode sabre node + */ + public function handleGetProperties( + PropFind $propFind, + \Sabre\DAV\INode $sabreNode, + ) { + if (!($sabreNode instanceof DavNode)) { + return; + } + + $propFind->handle(self::SHARETYPES_PROPERTYNAME, function () use ($sabreNode): ShareTypeList { + $shares = $this->getShares($sabreNode); + + $shareTypes = array_unique(array_map(function (IShare $share) { + return $share->getShareType(); + }, $shares)); + + return new ShareTypeList($shareTypes); + }); + + $propFind->handle(self::SHAREES_PROPERTYNAME, function () use ($sabreNode): ShareeList { + $shares = $this->getShares($sabreNode); + + return new ShareeList($shares); + }); + } +} diff --git a/apps/dav/lib/Connector/Sabre/TagList.php b/apps/dav/lib/Connector/Sabre/TagList.php new file mode 100644 index 00000000000..9a5cd0d51cf --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/TagList.php @@ -0,0 +1,102 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\Xml\Element; +use Sabre\Xml\Reader; +use Sabre\Xml\Writer; + +/** + * TagList property + * + * This property contains multiple "tag" elements, each containing a tag name. + */ +class TagList implements Element { + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + + /** + * @param array $tags + */ + public function __construct( + /** + * tags + */ + private array $tags, + ) { + } + + /** + * Returns the tags + * + * @return array + */ + public function getTags() { + return $this->tags; + } + + /** + * The deserialize method is called during xml parsing. + * + * This method is called statictly, this is because in theory this method + * may be used as a type of constructor, or factory method. + * + * Often you want to return an instance of the current class, but you are + * free to return other data as well. + * + * You are responsible for advancing the reader to the next element. Not + * doing anything will result in a never-ending loop. + * + * If you just want to skip parsing for this element altogether, you can + * just call $reader->next(); + * + * $reader->parseInnerTree() will parse the entire sub-tree, and advance to + * the next element. + * + * @param Reader $reader + * @return mixed + */ + public static function xmlDeserialize(Reader $reader) { + $tags = []; + + $tree = $reader->parseInnerTree(); + if ($tree === null) { + return null; + } + foreach ($tree as $elem) { + if ($elem['name'] === '{' . self::NS_OWNCLOUD . '}tag') { + $tags[] = $elem['value']; + } + } + return new self($tags); + } + + /** + * The xmlSerialize method is called during xml writing. + * + * Use the $writer argument to write its own xml serialization. + * + * An important note: do _not_ create a parent element. Any element + * implementing XmlSerializble should only ever write what's considered + * its 'inner xml'. + * + * The parent of the current element is responsible for writing a + * containing element. + * + * This allows serializers to be re-used for different element names. + * + * If you are opening new elements, you must also close them again. + * + * @param Writer $writer + * @return void + */ + public function xmlSerialize(Writer $writer) { + foreach ($this->tags as $tag) { + $writer->writeElement('{' . self::NS_OWNCLOUD . '}tag', $tag); + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/TagsPlugin.php b/apps/dav/lib/Connector/Sabre/TagsPlugin.php new file mode 100644 index 00000000000..ec3e6fc5320 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/TagsPlugin.php @@ -0,0 +1,302 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\DAV\Connector\Sabre; + +/** + * ownCloud + * + * @author Vincent Petry + * @copyright 2014 Vincent Petry <pvince81@owncloud.com> + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE + * License as published by the Free Software Foundation; either + * version 3 of the License, or any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU AFFERO GENERAL PUBLIC LICENSE for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with this 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\ICollection; +use Sabre\DAV\PropFind; +use Sabre\DAV\PropPatch; + +class TagsPlugin extends \Sabre\DAV\ServerPlugin { + + // namespace + public const NS_OWNCLOUD = 'http://owncloud.org/ns'; + public const TAGS_PROPERTYNAME = '{http://owncloud.org/ns}tags'; + public const FAVORITE_PROPERTYNAME = '{http://owncloud.org/ns}favorite'; + public const TAG_FAVORITE = '_$!<Favorite>!$_'; + + /** + * Reference to main server object + * + * @var \Sabre\DAV\Server + */ + private $server; + + /** + * @var ITags + */ + private $tagger; + + /** + * Array of file id to tags array + * The null value means the cache wasn't initialized. + * + * @var array + */ + private $cachedTags; + private array $cachedDirectories; + + /** + * @param \Sabre\DAV\Tree $tree tree + * @param ITagManager $tagManager tag manager + */ + public function __construct( + private \Sabre\DAV\Tree $tree, + private ITagManager $tagManager, + private IEventDispatcher $eventDispatcher, + private IUserSession $userSession, + ) { + $this->tagger = null; + $this->cachedTags = []; + } + + /** + * This initializes the plugin. + * + * This function is called by \Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + * + * @param \Sabre\DAV\Server $server + * @return void + */ + public function initialize(\Sabre\DAV\Server $server) { + $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; + $server->xml->elementMap[self::TAGS_PROPERTYNAME] = TagList::class; + + $this->server = $server; + $this->server->on('preloadCollection', $this->preloadCollection(...)); + $this->server->on('propFind', [$this, 'handleGetProperties']); + $this->server->on('propPatch', [$this, 'handleUpdateProperties']); + $this->server->on('preloadProperties', [$this, 'handlePreloadProperties']); + } + + /** + * Returns the tagger + * + * @return ITags tagger + */ + private function getTagger() { + if (!$this->tagger) { + $this->tagger = $this->tagManager->load('files'); + } + return $this->tagger; + } + + /** + * Returns tags and favorites. + * + * @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 + */ + private function getTagsAndFav($fileId) { + $isFav = false; + $tags = $this->getTags($fileId); + if ($tags) { + $favPos = array_search(self::TAG_FAVORITE, $tags); + if ($favPos !== false) { + $isFav = true; + unset($tags[$favPos]); + } + } + return [$tags, $isFav]; + } + + /** + * Returns tags for the given file id + * + * @param integer $fileId file id + * @return array list of tags for that file + */ + private function getTags($fileId) { + if (isset($this->cachedTags[$fileId])) { + return $this->cachedTags[$fileId]; + } else { + $tags = $this->getTagger()->getTagsForObjects([$fileId]); + if ($tags !== false) { + if (empty($tags)) { + return []; + } + return current($tags); + } + } + return null; + } + + /** + * 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 + * @param array $tags array of tag strings + */ + private function updateTags($fileId, $tags) { + $tagger = $this->getTagger(); + $currentTags = $this->getTags($fileId); + + $newTags = array_diff($tags, $currentTags); + foreach ($newTags as $tag) { + if ($tag === self::TAG_FAVORITE) { + continue; + } + $tagger->tagAs($fileId, $tag); + } + $deletedTags = array_diff($currentTags, $tags); + foreach ($deletedTags as $tag) { + if ($tag === self::TAG_FAVORITE) { + continue; + } + $tagger->unTag($fileId, $tag); + } + } + + private function preloadCollection(PropFind $propFind, ICollection $collection): + void { + if (!($collection instanceof Node)) { + return; + } + + // need prefetch ? + if ($collection instanceof Directory + && !isset($this->cachedDirectories[$collection->getPath()]) + && (!is_null($propFind->getStatus(self::TAGS_PROPERTYNAME)) + || !is_null($propFind->getStatus(self::FAVORITE_PROPERTYNAME)) + )) { + // note: pre-fetching only supported for depth <= 1 + $folderContent = $collection->getChildren(); + $fileIds = [(int)$collection->getId()]; + foreach ($folderContent as $info) { + $fileIds[] = (int)$info->getId(); + } + $this->prefetchTagsForFileIds($fileIds); + $this->cachedDirectories[$collection->getPath()] = true; + } + } + + /** + * Adds tags and favorites properties to the response, + * if requested. + * + * @param PropFind $propFind + * @param \Sabre\DAV\INode $node + * @return void + */ + public function handleGetProperties( + PropFind $propFind, + \Sabre\DAV\INode $node, + ) { + if (!($node instanceof Node)) { + return; + } + + $isFav = null; + + $propFind->handle(self::TAGS_PROPERTYNAME, function () use (&$isFav, $node) { + [$tags, $isFav] = $this->getTagsAndFav($node->getId()); + return new TagList($tags); + }); + + $propFind->handle(self::FAVORITE_PROPERTYNAME, function () use ($isFav, $node) { + if (is_null($isFav)) { + [, $isFav] = $this->getTagsAndFav($node->getId()); + } + if ($isFav) { + return 1; + } else { + return 0; + } + }); + } + + /** + * Updates tags and favorites properties, if applicable. + * + * @param string $path + * @param PropPatch $propPatch + * + * @return void + */ + public function handleUpdateProperties($path, PropPatch $propPatch) { + $node = $this->tree->getNodeForPath($path); + if (!($node instanceof Node)) { + return; + } + + $propPatch->handle(self::TAGS_PROPERTYNAME, function ($tagList) use ($node) { + $this->updateTags($node->getId(), $tagList->getTags()); + return true; + }); + + $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 { + $this->getTagger()->unTag($node->getId(), self::TAG_FAVORITE); + } + + if (is_null($favState)) { + // confirm deletion + return 204; + } + + 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; + } + } +} |