diff options
Diffstat (limited to 'apps/dav/lib/Connector/Sabre')
46 files changed, 2550 insertions, 2432 deletions
diff --git a/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php b/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php index 6c3600fa5eb..0e2b1c58748 100644 --- a/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php +++ b/apps/dav/lib/Connector/Sabre/AnonymousOptionsPlugin.php @@ -1,28 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Bastien Durel <bastien@durel.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; @@ -54,7 +34,7 @@ class AnonymousOptionsPlugin extends ServerPlugin { * @return bool */ public function isRequestInRoot($path) { - return $path === '' || (is_string($path) && strpos($path, '/') === false); + return $path === '' || (is_string($path) && !str_contains($path, '/')); } /** diff --git a/apps/dav/lib/Connector/Sabre/AppEnabledPlugin.php b/apps/dav/lib/Connector/Sabre/AppEnabledPlugin.php deleted file mode 100644 index 244e5de0683..00000000000 --- a/apps/dav/lib/Connector/Sabre/AppEnabledPlugin.php +++ /dev/null @@ -1,88 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ -namespace OCA\DAV\Connector\Sabre; - -use OCP\App\IAppManager; -use Sabre\DAV\Exception\Forbidden; -use Sabre\DAV\ServerPlugin; - -/** - * Plugin to check if an app is enabled for the current user - */ -class AppEnabledPlugin extends ServerPlugin { - - /** - * Reference to main server object - * - * @var \Sabre\DAV\Server - */ - private $server; - - /** - * @var string - */ - private $app; - - /** - * @var \OCP\App\IAppManager - */ - private $appManager; - - /** - * @param string $app - * @param \OCP\App\IAppManager $appManager - */ - public function __construct($app, IAppManager $appManager) { - $this->app = $app; - $this->appManager = $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) { - $this->server = $server; - $this->server->on('beforeMethod:*', [$this, 'checkAppEnabled'], 30); - } - - /** - * This method is called before any HTTP after auth and checks if the user has access to the app - * - * @throws \Sabre\DAV\Exception\Forbidden - * @return bool - */ - public function checkAppEnabled() { - if (!$this->appManager->isEnabledForUser($this->app)) { - throw new Forbidden(); - } - } -} 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 index df4e3c65ce0..a174920946a 100644 --- a/apps/dav/lib/Connector/Sabre/Auth.php +++ b/apps/dav/lib/Connector/Sabre/Auth.php @@ -1,46 +1,26 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Markus Goetz <markus@woboq.com> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use Exception; use OC\Authentication\Exceptions\PasswordLoginForbiddenException; use OC\Authentication\TwoFactorAuth\Manager; -use OC\Security\Bruteforce\Throttler; 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; @@ -49,44 +29,21 @@ use Sabre\HTTP\ResponseInterface; class Auth extends AbstractBasic { public const DAV_AUTHENTICATED = 'AUTHENTICATED_TO_DAV_BACKEND'; - - /** @var ISession */ - private $session; - /** @var Session */ - private $userSession; - /** @var IRequest */ - private $request; - /** @var string */ - private $currentUser; - /** @var Manager */ - private $twoFactorManager; - /** @var Throttler */ - private $throttler; - - /** - * @param ISession $session - * @param Session $userSession - * @param IRequest $request - * @param Manager $twoFactorManager - * @param Throttler $throttler - * @param string $principalPrefix - */ - public function __construct(ISession $session, - Session $userSession, - IRequest $request, - Manager $twoFactorManager, - Throttler $throttler, - $principalPrefix = 'principals/users/') { - $this->session = $session; - $this->userSession = $userSession; - $this->twoFactorManager = $twoFactorManager; - $this->request = $request; - $this->throttler = $throttler; + 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 \OCP\Defaults(); - $this->realm = $defaults->getName(); + $defaults = new Defaults(); + $this->realm = $defaults->getName() ?: 'Nextcloud'; } /** @@ -96,13 +53,10 @@ class Auth extends AbstractBasic { * account was changed. * * @see https://github.com/owncloud/core/issues/13245 - * - * @param string $username - * @return bool */ - public function isDavAuthenticated($username) { - return !is_null($this->session->get(self::DAV_AUTHENTICATED)) && - $this->session->get(self::DAV_AUTHENTICATED) === $username; + public function isDavAuthenticated(string $username): bool { + return !is_null($this->session->get(self::DAV_AUTHENTICATED)) + && $this->session->get(self::DAV_AUTHENTICATED) === $username; } /** @@ -117,17 +71,14 @@ class Auth extends AbstractBasic { * @throws PasswordLoginForbidden */ protected function validateUserPass($username, $password) { - if ($this->userSession->isLoggedIn() && - $this->isDavAuthenticated($this->userSession->getUser()->getUID()) + if ($this->userSession->isLoggedIn() + && $this->isDavAuthenticated($this->userSession->getUser()->getUID()) ) { - \OC_Util::setupFS($this->userSession->getUser()->getUID()); $this->session->close(); return true; } else { - \OC_Util::setupFS(); //login hooks may need early access to the filesystem try { if ($this->userSession->logClientIn($username, $password, $this->request, $this->throttler)) { - \OC_Util::setupFS($this->userSession->getUser()->getUID()); $this->session->set(self::DAV_AUTHENTICATED, $this->userSession->getUser()->getUID()); $this->session->close(); return true; @@ -138,14 +89,15 @@ class Auth extends AbstractBasic { } catch (PasswordLoginForbiddenException $ex) { $this->session->close(); throw new PasswordLoginForbidden(); + } catch (MaxDelayReached $ex) { + $this->session->close(); + throw new TooManyRequests(); } } } /** - * @param RequestInterface $request - * @param ResponseInterface $response - * @return array + * @return array{bool, string} * @throws NotAuthenticated * @throws ServiceUnavailable */ @@ -157,19 +109,18 @@ class Auth extends AbstractBasic { } catch (Exception $e) { $class = get_class($e); $msg = $e->getMessage(); - \OC::$server->getLogger()->logException($e); + Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); throw new ServiceUnavailable("$class: $msg"); } } /** * Checks whether a CSRF check is required on the request - * - * @return bool */ - private function requiresCSRFCheck() { - // GET requires no check at all - if ($this->request->getMethod() === 'GET') { + private function requiresCSRFCheck(): bool { + + $methodsWithoutCsrf = ['GET', 'HEAD', 'OPTIONS']; + if (in_array($this->request->getMethod(), $methodsWithoutCsrf)) { return false; } @@ -193,8 +144,8 @@ class Auth extends AbstractBasic { } // If logged-in AND DAV authenticated no check is required - if ($this->userSession->isLoggedIn() && - $this->isDavAuthenticated($this->userSession->getUser()->getUID())) { + if ($this->userSession->isLoggedIn() + && $this->isDavAuthenticated($this->userSession->getUser()->getUID())) { return false; } @@ -202,21 +153,19 @@ class Auth extends AbstractBasic { } /** - * @param RequestInterface $request - * @param ResponseInterface $response - * @return array + * @return array{bool, string} * @throws NotAuthenticated */ - private function auth(RequestInterface $request, ResponseInterface $response) { + private function auth(RequestInterface $request, ResponseInterface $response): array { $forcedLogout = false; - if (!$this->request->passesCSRFCheck() && - $this->requiresCSRFCheck()) { + if (!$this->request->passesCSRFCheck() + && $this->requiresCSRFCheck()) { // In case of a fail with POST we need to recheck the credentials if ($this->request->getMethod() === 'POST') { $forcedLogout = true; } else { - $response->setStatus(401); + $response->setStatus(Http::STATUS_UNAUTHORIZED); throw new \Sabre\DAV\Exception\NotAuthenticated('CSRF check not passed.'); } } @@ -229,10 +178,10 @@ class Auth extends AbstractBasic { } if ( //Fix for broken webdav clients - ($this->userSession->isLoggedIn() && is_null($this->session->get(self::DAV_AUTHENTICATED))) || + ($this->userSession->isLoggedIn() && is_null($this->session->get(self::DAV_AUTHENTICATED))) //Well behaved clients that only send the cookie are allowed - ($this->userSession->isLoggedIn() && $this->session->get(self::DAV_AUTHENTICATED) === $this->userSession->getUser()->getUID() && $request->getHeader('Authorization') === null) || - \OC_User::handleApacheAuth() + || ($this->userSession->isLoggedIn() && $this->session->get(self::DAV_AUTHENTICATED) === $this->userSession->getUser()->getUID() && empty($request->getHeader('Authorization'))) + || \OC_User::handleApacheAuth() ) { $user = $this->userSession->getUser()->getUID(); $this->currentUser = $user; @@ -241,18 +190,16 @@ class Auth extends AbstractBasic { } } - if (!$this->userSession->isLoggedIn() && in_array('XMLHttpRequest', explode(',', $request->getHeader('X-Requested-With') ?? ''))) { - // do not re-authenticate over ajax, use dummy auth name to prevent browser popup - $response->addHeader('WWW-Authenticate','DummyBasic realm="' . $this->realm . '"'); - $response->setStatus(401); - throw new \Sabre\DAV\Exception\NotAuthenticated('Cannot authenticate over ajax calls'); - } - $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 index d28a9cfdb84..23453ae8efb 100644 --- a/apps/dav/lib/Connector/Sabre/BearerAuth.php +++ b/apps/dav/lib/Connector/Sabre/BearerAuth.php @@ -1,28 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2017 Lukas Reschke <lukas@statuscode.ch> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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; @@ -31,33 +17,16 @@ use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class BearerAuth extends AbstractBearer { - /** @var IUserSession */ - private $userSession; - /** @var ISession */ - private $session; - /** @var IRequest */ - private $request; - /** @var string */ - private $principalPrefix; - - /** - * @param IUserSession $userSession - * @param ISession $session - * @param string $principalPrefix - * @param IRequest $request - */ - public function __construct(IUserSession $userSession, - ISession $session, - IRequest $request, - $principalPrefix = 'principals/users/') { - $this->userSession = $userSession; - $this->session = $session; - $this->request = $request; - $this->principalPrefix = $principalPrefix; - + public function __construct( + private IUserSession $userSession, + private ISession $session, + private IRequest $request, + private IConfig $config, + private string $principalPrefix = 'principals/users/', + ) { // setup realm - $defaults = new \OCP\Defaults(); - $this->realm = $defaults->getName(); + $defaults = new Defaults(); + $this->realm = $defaults->getName() ?: 'Nextcloud'; } private function setupUserFs($userId) { @@ -90,7 +59,15 @@ class BearerAuth extends AbstractBearer { * @param RequestInterface $request * @param ResponseInterface $response */ - public function challenge(RequestInterface $request, ResponseInterface $response) { - $response->setStatus(401); + 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 index 8e2ea4d4e16..21358406a4a 100644 --- a/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php +++ b/apps/dav/lib/Connector/Sabre/BlockLegacyClientPlugin.php @@ -1,31 +1,16 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\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; @@ -36,23 +21,18 @@ use Sabre\HTTP\RequestInterface; * @package OCA\DAV\Connector\Sabre */ class BlockLegacyClientPlugin extends ServerPlugin { - /** @var \Sabre\DAV\Server */ - protected $server; - /** @var IConfig */ - protected $config; + protected ?Server $server = null; - /** - * @param IConfig $config - */ - public function __construct(IConfig $config) { - $this->config = $config; + public function __construct( + private IConfig $config, + private ThemingDefaults $themingDefaults, + ) { } /** - * @param \Sabre\DAV\Server $server * @return void */ - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(Server $server) { $this->server = $server; $this->server->on('beforeMethod:*', [$this, 'beforeHandler'], 200); } @@ -69,14 +49,26 @@ class BlockLegacyClientPlugin extends ServerPlugin { return; } - $minimumSupportedDesktopVersion = $this->config->getSystemValue('minimum.supported.desktop.version', '2.0.0'); + $minimumSupportedDesktopVersion = $this->config->getSystemValueString('minimum.supported.desktop.version', '3.1.0'); + $maximumSupportedDesktopVersion = $this->config->getSystemValueString('maximum.supported.desktop.version', '99.99.99'); + + // Check if the client is a desktop client + preg_match(IRequest::USER_AGENT_CLIENT_DESKTOP, $userAgent, $versionMatches); + + // If 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); - // Match on the mirall version which is in scheme "Mozilla/5.0 (%1) mirall/%2" or - // "mirall/%1" for older releases - preg_match("/(?:mirall\\/)([\d.]+)/i", $userAgent, $versionMatches); - if (isset($versionMatches[1]) && - version_compare($versionMatches[1], $minimumSupportedDesktopVersion) === -1) { - throw new \Sabre\DAV\Exception\Forbidden('Unsupported client version.'); + throw new \Sabre\DAV\Exception\Forbidden("This version of the client is unsupported. Downgrade to <a href=\"$customClientDesktopLink\">version $maximumSupportedDesktopVersion or earlier</a>."); } } } diff --git a/apps/dav/lib/Connector/Sabre/CachingTree.php b/apps/dav/lib/Connector/Sabre/CachingTree.php index eb1233d3540..5d72b530f58 100644 --- a/apps/dav/lib/Connector/Sabre/CachingTree.php +++ b/apps/dav/lib/Connector/Sabre/CachingTree.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; @@ -28,24 +11,25 @@ use Sabre\DAV\Tree; class CachingTree extends Tree { /** * Store a node in the cache - * - * @param Node $node - * @param null|string $path */ - public function cacheNode(Node $node, $path = null) { + 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 || 0 === strpos($nodePath, $path.'/')) { + $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 index 74cdc98ef4f..75d1d718de1 100644 --- a/apps/dav/lib/Connector/Sabre/ChecksumList.php +++ b/apps/dav/lib/Connector/Sabre/ChecksumList.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -35,17 +20,14 @@ class ChecksumList implements XmlSerializable { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** @var string[] of TYPE:CHECKSUM */ - private $checksums; + private array $checksums; - /** - * @param string $checksum - */ - public function __construct($checksum) { - $this->checksums = explode(',', $checksum); + public function __construct(string $checksum) { + $this->checksums = explode(' ', $checksum); } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * Use the $writer argument to write its own xml serialization. * diff --git a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php index 3247259357f..18009080585 100644 --- a/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php +++ b/apps/dav/lib/Connector/Sabre/ChecksumUpdatePlugin.php @@ -2,38 +2,22 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 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 { - /** - * @var \Sabre\DAV\Server - */ - protected $server; + protected ?Server $server = null; - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(Server $server) { $this->server = $server; $server->on('method:PATCH', [$this, 'httpPatch']); } @@ -42,19 +26,7 @@ class ChecksumUpdatePlugin extends ServerPlugin { return 'checksumupdate'; } - public function getHTTPMethods($path): array { - $tree = $this->server->tree; - - if ($tree->nodeExists($path)) { - $node = $tree->getNodeForPath($path); - if ($node instanceof File) { - return ['PATCH']; - } - } - - return []; - } - + /** @return string[] */ public function getFeatures(): array { return ['nextcloud-checksum-update']; } @@ -74,7 +46,7 @@ class ChecksumUpdatePlugin extends ServerPlugin { $node->setChecksum($checksum); $response->addHeader('OC-Checksum', $checksum); $response->setHeader('Content-Length', '0'); - $response->setStatus(204); + $response->setStatus(Http::STATUS_NO_CONTENT); return false; } diff --git a/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php index 82980553fa8..e4b6c2636da 100644 --- a/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/CommentPropertiesPlugin.php @@ -1,33 +1,17 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use OCP\Comments\ICommentsManager; use OCP\IUserSession; use Sabre\DAV\PropFind; +use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; class CommentPropertiesPlugin extends ServerPlugin { @@ -35,20 +19,13 @@ class CommentPropertiesPlugin extends ServerPlugin { public const PROPERTY_NAME_COUNT = '{http://owncloud.org/ns}comments-count'; public const PROPERTY_NAME_UNREAD = '{http://owncloud.org/ns}comments-unread'; - /** @var \Sabre\DAV\Server */ - protected $server; - - /** @var ICommentsManager */ - private $commentsManager; - - /** @var IUserSession */ - private $userSession; - - private $cachedUnreadCount = []; + protected ?Server $server = null; + private array $cachedUnreadCount = []; - public function __construct(ICommentsManager $commentsManager, IUserSession $userSession) { - $this->commentsManager = $commentsManager; - $this->userSession = $userSession; + public function __construct( + private ICommentsManager $commentsManager, + private IUserSession $userSession, + ) { } /** @@ -67,7 +44,7 @@ class CommentPropertiesPlugin extends ServerPlugin { $this->server->on('propFind', [$this, 'handleGetProperties']); } - private function cacheDirectory(Directory $directory) { + private function cacheDirectory(Directory $directory): void { $children = $directory->getChildren(); $ids = []; @@ -84,7 +61,7 @@ class CommentPropertiesPlugin extends ServerPlugin { $ids[] = (string)$id; } - $ids[] = (string) $directory->getId(); + $ids[] = (string)$directory->getId(); $unread = $this->commentsManager->getNumberOfUnreadCommentsForObjects('files', $ids, $this->userSession->getUser()); foreach ($unread as $id => $count) { @@ -102,62 +79,52 @@ class CommentPropertiesPlugin extends ServerPlugin { */ public function handleGetProperties( PropFind $propFind, - \Sabre\DAV\INode $node + \Sabre\DAV\INode $node, ) { if (!($node instanceof File) && !($node instanceof Directory)) { return; } // need prefetch ? - if ($node instanceof \OCA\DAV\Connector\Sabre\Directory + if ($node instanceof Directory && $propFind->getDepth() !== 0 && !is_null($propFind->getStatus(self::PROPERTY_NAME_UNREAD)) ) { $this->cacheDirectory($node); } - $propFind->handle(self::PROPERTY_NAME_COUNT, function () use ($node) { + $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) { + $propFind->handle(self::PROPERTY_NAME_HREF, function () use ($node): ?string { return $this->getCommentsLink($node); }); - $propFind->handle(self::PROPERTY_NAME_UNREAD, function () use ($node) { - if (isset($this->cachedUnreadCount[$node->getId()])) { - return $this->cachedUnreadCount[$node->getId()]; - } - return $this->getUnreadCount($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 - * - * @param Node $node - * @return mixed|string + * Returns a reference to the comments node */ - public function getCommentsLink(Node $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($node->getId()); - $href = substr_replace($href, $commentsPart, $entryPoint + strlen('/remote.php/')); - return $href; + $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 + * Returns the number of unread comments for the currently logged in user * on the given file or directory node - * - * @param Node $node - * @return Int|null */ - public function getUnreadCount(Node $node) { + public function getUnreadCount(Node $node): ?int { $user = $this->userSession->getUser(); if (is_null($user)) { return null; diff --git a/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php b/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php index 029e631f4d9..609ac295b4c 100644 --- a/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php +++ b/apps/dav/lib/Connector/Sabre/CopyEtagHeaderPlugin.php @@ -1,30 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Server; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; @@ -34,9 +18,8 @@ use Sabre\HTTP\ResponseInterface; * or mangle Etag headers. */ class CopyEtagHeaderPlugin extends \Sabre\DAV\ServerPlugin { + private ?Server $server = null; - /** @var \Sabre\DAV\Server */ - private $server; /** * This initializes the plugin. * diff --git a/apps/dav/lib/Connector/Sabre/DavAclPlugin.php b/apps/dav/lib/Connector/Sabre/DavAclPlugin.php index 6842975835d..100d719ef01 100644 --- a/apps/dav/lib/Connector/Sabre/DavAclPlugin.php +++ b/apps/dav/lib/Connector/Sabre/DavAclPlugin.php @@ -1,33 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\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; @@ -58,23 +42,38 @@ class DavAclPlugin extends \Sabre\DAVACL\Plugin { case AddressBook::class: $type = 'Addressbook'; break; + case Calendar::class: + case CachedSubscription::class: + $type = 'Calendar'; + break; default: $type = 'Node'; break; } - throw new NotFound( - sprintf( - "%s with name '%s' could not be found", - $type, - $node->getName() - ) - ); + + if ($this->getCurrentUserPrincipal() === $node->getOwner()) { + throw new Forbidden('Access denied'); + } else { + throw new NotFound( + sprintf( + "%s with name '%s' could not be found", + $type, + $node->getName() + ) + ); + } + } return $access; } 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)) { @@ -94,8 +93,23 @@ class DavAclPlugin extends \Sabre\DAVACL\Plugin { $path = $request->getPath(); // prevent the plugin from causing an unneeded overhead for file requests - if (strpos($path, 'files/') !== 0) { - parent::beforeMethod($request, $response); + 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 index ed98b5050f8..fe09c3f423f 100644 --- a/apps/dav/lib/Connector/Sabre/Directory.php +++ b/apps/dav/lib/Connector/Sabre/Directory.php @@ -1,52 +1,36 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use OC\Files\Mount\MoveableMount; use OC\Files\View; -use OC\Metadata\FileMetadata; -use OC\Metadata\MetadataGroup; +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; @@ -55,41 +39,26 @@ use Sabre\DAV\Exception\ServiceUnavailable; use Sabre\DAV\IFile; use Sabre\DAV\INode; -class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuota, \Sabre\DAV\IMoveTarget, \Sabre\DAV\ICopyTarget { - +class Directory extends Node implements \Sabre\DAV\ICollection, \Sabre\DAV\IQuota, \Sabre\DAV\IMoveTarget, \Sabre\DAV\ICopyTarget { /** * Cached directory content - * - * @var \OCP\Files\FileInfo[] - */ - private $dirContent; - - /** - * Cached quota info - * - * @var array + * @var FileInfo[] */ - private $quotaInfo; + private ?array $dirContent = null; - /** - * @var ObjectTree|null - */ - private $tree; - - /** @var array<string, array<int, FileMetadata>> */ - private array $metadata = []; + /** Cached quota info */ + private ?array $quotaInfo = null; /** * Sets up the node, expects a full path name - * - * @param \OC\Files\View $view - * @param \OCP\Files\FileInfo $info - * @param ObjectTree|null $tree - * @param \OCP\Share\IManager $shareManager */ - public function __construct(View $view, FileInfo $info, $tree = null, $shareManager = null) { + public function __construct( + View $view, + FileInfo $info, + private ?CachingTree $tree = null, + ?IShareManager $shareManager = null, + ) { parent::__construct($view, $info, $shareManager); - $this->tree = $tree; } /** @@ -126,22 +95,8 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol */ public function createFile($name, $data = null) { try { - // for chunked upload also updating a existing file is a "createFile" - // because we create all the chunks before re-assemble them to the existing file. - if (isset($_SERVER['HTTP_OC_CHUNKED'])) { - - // exit if we can't create a new file and we don't updatable existing file - $chunkInfo = \OC_FileChunking::decodeName($name); - if (!$this->fileView->isCreatable($this->path) && - !$this->fileView->isUpdatable($this->path . '/' . $chunkInfo['name']) - ) { - throw new \Sabre\DAV\Exception\Forbidden(); - } - } else { - // For non-chunked upload it is enough to check if we can create a new file - if (!$this->fileView->isCreatable($this->path)) { - throw new \Sabre\DAV\Exception\Forbidden(); - } + if (!$this->fileView->isCreatable($this->path)) { + throw new \Sabre\DAV\Exception\Forbidden(); } $this->fileView->verifyPath($this->path, $name); @@ -155,18 +110,18 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol 'type' => FileInfo::TYPE_FILE ], null); } - $node = new \OCA\DAV\Connector\Sabre\File($this->fileView, $info); + $node = new File($this->fileView, $info); // only allow 1 process to upload a file at once but still allow reading the file while writing the part file $node->acquireLock(ILockingProvider::LOCK_SHARED); - $this->fileView->lockFile($path . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); + $this->fileView->lockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); $result = $node->put($data); - $this->fileView->unlockFile($path . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); + $this->fileView->unlockFile($this->path . '/' . $name . '.upload.part', ILockingProvider::LOCK_EXCLUSIVE); $node->releaseLock(ILockingProvider::LOCK_SHARED); return $result; - } catch (\OCP\Files\StorageNotAvailableException $e) { + } catch (StorageNotAvailableException $e) { throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), $e->getCode(), $e); } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage(), false, $ex); @@ -197,12 +152,12 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol if (!$this->fileView->mkdir($newPath)) { throw new \Sabre\DAV\Exception\Forbidden('Could not create directory ' . $newPath); } - } catch (\OCP\Files\StorageNotAvailableException $e) { - throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage()); + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), 0, $e); } catch (InvalidPathException $ex) { - throw new InvalidPath($ex->getMessage()); + throw new InvalidPath($ex->getMessage(), false, $ex); } catch (ForbiddenException $ex) { - throw new Forbidden($ex->getMessage(), $ex->getRetry()); + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); } catch (LockedException $e) { throw new FileLocked($e->getMessage(), $e->getCode(), $e); } @@ -212,14 +167,27 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol * Returns a specific child node, referenced by its name * * @param string $name - * @param \OCP\Files\FileInfo $info + * @param FileInfo $info * @return \Sabre\DAV\INode * @throws InvalidPath * @throws \Sabre\DAV\Exception\NotFound * @throws \Sabre\DAV\Exception\ServiceUnavailable */ - public function getChild($name, $info = null) { - if (!$this->info->isReadable()) { + 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(); } @@ -227,14 +195,14 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol $path = $this->path . '/' . $name; if (is_null($info)) { try { - $this->fileView->verifyPath($this->path, $name); + $this->fileView->verifyPath($this->path, $name, true); $info = $this->fileView->getFileInfo($path); - } catch (\OCP\Files\StorageNotAvailableException $e) { - throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage()); + } catch (StorageNotAvailableException $e) { + throw new \Sabre\DAV\Exception\ServiceUnavailable($e->getMessage(), 0, $e); } catch (InvalidPathException $ex) { - throw new InvalidPath($ex->getMessage()); + throw new InvalidPath($ex->getMessage(), false, $ex); } catch (ForbiddenException $e) { - throw new \Sabre\DAV\Exception\Forbidden(); + throw new \Sabre\DAV\Exception\Forbidden($e->getMessage(), $e->getCode(), $e); } } @@ -245,7 +213,12 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol if ($info->getMimeType() === FileInfo::MIMETYPE_FOLDER) { $node = new \OCA\DAV\Connector\Sabre\Directory($this->fileView, $info, $this->tree, $this->shareManager); } else { - $node = new \OCA\DAV\Connector\Sabre\File($this->fileView, $info, $this->shareManager); + // 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); @@ -258,7 +231,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol * * @return \Sabre\DAV\INode[] * @throws \Sabre\DAV\Exception\Locked - * @throws \OCA\DAV\Connector\Sabre\Exception\Forbidden + * @throws Forbidden */ public function getChildren() { if (!is_null($this->dirContent)) { @@ -268,7 +241,11 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol if (!$this->info->isReadable()) { // return 403 instead of 404 because a 404 would make // the caller believe that the collection itself does not exist - throw new Forbidden('No read permissions'); + 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) { @@ -276,8 +253,11 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol } $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); + $node = $this->getChild($info->getName(), $info, $request, $l10n); $nodes[] = $node; } $this->dirContent = $nodes; @@ -326,21 +306,29 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol } } + private function getLogger(): LoggerInterface { + return Server::get(LoggerInterface::class); + } + /** * Returns available diskspace information * * @return array */ public function getQuotaInfo() { - /** @var LoggerInterface $logger */ - $logger = \OC::$server->get(LoggerInterface::class); 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($this->info->getPath(), $this->info, false); - if ($storageInfo['quota'] === \OCP\Files\FileInfo::SPACE_UNLIMITED) { - $free = \OCP\Files\FileInfo::SPACE_UNLIMITED; + $storageInfo = \OC_Helper::getStorageInfo($relativePath, $this->info, false); + if ($storageInfo['quota'] === FileInfo::SPACE_UNLIMITED) { + $free = FileInfo::SPACE_UNLIMITED; } else { $free = $storageInfo['free']; } @@ -349,14 +337,14 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol $free ]; return $this->quotaInfo; - } catch (\OCP\Files\NotFoundException $e) { - $logger->warning("error while getting quota into", ['exception' => $e]); + } catch (NotFoundException $e) { + $this->getLogger()->warning('error while getting quota into', ['exception' => $e]); return [0, 0]; - } catch (\OCP\Files\StorageNotAvailableException $e) { - $logger->warning("error while getting quota into", ['exception' => $e]); + } catch (StorageNotAvailableException $e) { + $this->getLogger()->warning('error while getting quota into', ['exception' => $e]); return [0, 0]; } catch (NotPermittedException $e) { - $logger->warning("error while getting quota into", ['exception' => $e]); + $this->getLogger()->warning('error while getting quota into', ['exception' => $e]); return [0, 0]; } } @@ -396,10 +384,6 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol throw new BadRequest('Incompatible node types'); } - if (!$this->fileView) { - throw new ServiceUnavailable('filesystem not setup'); - } - $destinationPath = $this->getPath() . '/' . $targetName; @@ -417,7 +401,7 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol $sourcePath = $sourceNode->getPath(); $isMovableMount = false; - $sourceMount = \OC::$server->getMountManager()->find($this->fileView->getAbsolutePath($sourcePath)); + $sourceMount = Server::get(IMountManager::class)->find($this->fileView->getAbsolutePath($sourcePath)); $internalPath = $sourceMount->getInternalPath($this->fileView->getAbsolutePath($sourcePath)); if ($sourceMount instanceof MoveableMount && $internalPath === '') { $isMovableMount = true; @@ -457,9 +441,9 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol throw new \Sabre\DAV\Exception\Forbidden(''); } } catch (StorageNotAvailableException $e) { - throw new ServiceUnavailable($e->getMessage()); + throw new ServiceUnavailable($e->getMessage(), $e->getCode(), $e); } catch (ForbiddenException $ex) { - throw new Forbidden($ex->getMessage(), $ex->getRetry()); + throw new Forbidden($ex->getMessage(), $ex->getRetry(), $ex); } catch (LockedException $e) { throw new FileLocked($e->getMessage(), $e->getCode(), $e); } @@ -470,20 +454,34 @@ class Directory extends \OCA\DAV\Connector\Sabre\Node implements \Sabre\DAV\ICol public function copyInto($targetName, $sourcePath, INode $sourceNode) { if ($sourceNode instanceof File || $sourceNode instanceof Directory) { - $destinationPath = $this->getPath() . '/' . $targetName; - $sourcePath = $sourceNode->getPath(); + try { + $destinationPath = $this->getPath() . '/' . $targetName; + $sourcePath = $sourceNode->getPath(); - if (!$this->fileView->isCreatable($this->getPath())) { - throw new \Sabre\DAV\Exception\Forbidden(); - } + 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()); - } + try { + $this->fileView->verifyPath($this->getPath(), $targetName); + } catch (InvalidPathException $ex) { + throw new InvalidPath($ex->getMessage()); + } + + $copyOkay = $this->fileView->copy($sourcePath, $destinationPath); - return $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; diff --git a/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php b/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php index 3a8469e99b4..f6baceb748b 100644 --- a/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php +++ b/apps/dav/lib/Connector/Sabre/DummyGetResponsePlugin.php @@ -1,31 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wickert <cwickert@suse.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; +use OCP\AppFramework\Http; +use Sabre\DAV\Server; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; @@ -43,8 +26,7 @@ use Sabre\HTTP\ResponseInterface; * @package OCA\DAV\Connector\Sabre */ class DummyGetResponsePlugin extends \Sabre\DAV\ServerPlugin { - /** @var \Sabre\DAV\Server */ - protected $server; + protected ?Server $server = null; /** * @param \Sabre\DAV\Server $server @@ -61,13 +43,13 @@ class DummyGetResponsePlugin extends \Sabre\DAV\ServerPlugin { * @return false */ public function httpGet(RequestInterface $request, ResponseInterface $response) { - $string = 'This is the WebDAV interface. It can only be accessed by ' . - 'WebDAV clients such as the Nextcloud desktop sync client.'; - $stream = fopen('php://memory','r+'); + $string = 'This is the WebDAV interface. It can only be accessed by ' + . 'WebDAV clients such as the Nextcloud desktop sync client.'; + $stream = fopen('php://memory', 'r+'); fwrite($stream, $string); rewind($stream); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setBody($stream); return false; diff --git a/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php b/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php index c4cd6db190a..1e1e4aaed04 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php +++ b/apps/dav/lib/Connector/Sabre/Exception/BadGateway.php @@ -1,23 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me> - * - * @author Louis Chemineau <louis@chmn.me> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; diff --git a/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php b/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php index 4fc3399ca81..60b3b06ea01 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php +++ b/apps/dav/lib/Connector/Sabre/Exception/EntityTooLarge.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; diff --git a/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php b/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php index 36063da8d65..38708e945e9 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php +++ b/apps/dav/lib/Connector/Sabre/Exception/FileLocked.php @@ -1,36 +1,22 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Owen Winkler <a_github@midnightcircus.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; use Exception; +use OCP\Files\LockNotAcquiredException; class FileLocked extends \Sabre\DAV\Exception { - public function __construct($message = "", $code = 0, Exception $previous = null) { - if ($previous instanceof \OCP\Files\LockNotAcquiredException) { + /** + * @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); diff --git a/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php b/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php index aabd5fda2fb..95d4b3ab514 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php +++ b/apps/dav/lib/Connector/Sabre/Exception/Forbidden.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; @@ -26,18 +11,16 @@ class Forbidden extends \Sabre\DAV\Exception\Forbidden { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * @var bool - */ - private $retry; - - /** * @param string $message * @param bool $retry * @param \Exception $previous */ - public function __construct($message, $retry = false, \Exception $previous = null) { + public function __construct( + $message, + private $retry = false, + ?\Exception $previous = null, + ) { parent::__construct($message, 0, $previous); - $this->retry = $retry; } /** @@ -48,17 +31,17 @@ class Forbidden extends \Sabre\DAV\Exception\Forbidden { * @param \DOMElement $errorNode * @return void */ - public function serialize(\Sabre\DAV\Server $server,\DOMElement $errorNode) { + 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)); + $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()); + $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 index c504483d45a..dfc08aa8b88 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php +++ b/apps/dav/lib/Connector/Sabre/Exception/InvalidPath.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; @@ -29,18 +13,16 @@ class InvalidPath extends Exception { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * @var bool - */ - private $retry; - - /** * @param string $message * @param bool $retry * @param \Exception|null $previous */ - public function __construct($message, $retry = false, \Exception $previous = null) { + public function __construct( + $message, + private $retry = false, + ?\Exception $previous = null, + ) { parent::__construct($message, 0, $previous); - $this->retry = $retry; } /** @@ -60,17 +42,17 @@ class InvalidPath extends Exception { * @param \DOMElement $errorNode * @return void */ - public function serialize(\Sabre\DAV\Server $server,\DOMElement $errorNode) { + 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)); + $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()); + $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 index 7c00d15f627..f5cc117fafc 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/PasswordLoginForbidden.php +++ b/apps/dav/lib/Connector/Sabre/Exception/PasswordLoginForbidden.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; 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 index a7e935d2497..c5fbfa3a16c 100644 --- a/apps/dav/lib/Connector/Sabre/Exception/UnsupportedMediaType.php +++ b/apps/dav/lib/Connector/Sabre/Exception/UnsupportedMediaType.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre\Exception; diff --git a/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php b/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php index b4df1f582db..686386dbfef 100644 --- a/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php +++ b/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php @@ -1,36 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\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 OCP\ILogger; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Conflict; use Sabre\DAV\Exception\Forbidden; @@ -41,7 +22,6 @@ use Sabre\DAV\Exception\NotFound; use Sabre\DAV\Exception\NotImplemented; use Sabre\DAV\Exception\PreconditionFailed; use Sabre\DAV\Exception\RequestedRangeNotSatisfiable; -use Sabre\DAV\Exception\ServiceUnavailable; class ExceptionLoggerPlugin extends \Sabre\DAV\ServerPlugin { protected $nonFatalExceptions = [ @@ -65,6 +45,9 @@ class ExceptionLoggerPlugin extends \Sabre\DAV\ServerPlugin { // 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, @@ -81,21 +64,16 @@ class ExceptionLoggerPlugin extends \Sabre\DAV\ServerPlugin { FileLocked::class => true, // An invalid range is requested RequestedRangeNotSatisfiable::class => true, + ServerMaintenanceMode::class => true, ]; - /** @var string */ - private $appName; - - /** @var ILogger */ - private $logger; - /** - * @param string $loggerAppName app name to use when logging - * @param ILogger $logger + * @param string $appName app name to use when logging */ - public function __construct($loggerAppName, $logger) { - $this->appName = $loggerAppName; - $this->logger = $logger; + public function __construct( + private string $appName, + private LoggerInterface $logger, + ) { } /** @@ -115,23 +93,20 @@ class ExceptionLoggerPlugin extends \Sabre\DAV\ServerPlugin { /** * Log exception - * */ public function logException(\Throwable $ex) { $exceptionClass = get_class($ex); - $level = ILogger::FATAL; - if (isset($this->nonFatalExceptions[$exceptionClass]) || - ( - $exceptionClass === ServiceUnavailable::class && - $ex->getMessage() === 'System in maintenance mode.' - ) - ) { - $level = ILogger::DEBUG; + if (isset($this->nonFatalExceptions[$exceptionClass])) { + $this->logger->debug($ex->getMessage(), [ + 'app' => $this->appName, + 'exception' => $ex, + ]); + return; } - $this->logger->logException($ex, [ + $this->logger->critical($ex->getMessage(), [ 'app' => $this->appName, - 'level' => $level, + 'exception' => $ex, ]); } } diff --git a/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php b/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php index d05cd6d2e6d..b0c5a079ce1 100644 --- a/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FakeLockerPlugin.php @@ -1,33 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\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; @@ -47,11 +31,11 @@ use Sabre\HTTP\ResponseInterface; * @package OCA\DAV\Connector\Sabre */ class FakeLockerPlugin extends ServerPlugin { - /** @var \Sabre\DAV\Server */ + /** @var Server */ private $server; /** {@inheritDoc} */ - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(Server $server) { $this->server = $server; $this->server->on('method:LOCK', [$this, 'fakeLockProvider'], 1); $this->server->on('method:UNLOCK', [$this, 'fakeUnlockProvider'], 1); @@ -90,7 +74,7 @@ class FakeLockerPlugin extends ServerPlugin { */ public function propFind(PropFind $propFind, INode $node) { $propFind->handle('{DAV:}supportedlock', function () { - return new SupportedLock(true); + return new SupportedLock(); }); $propFind->handle('{DAV:}lockdiscovery', function () use ($propFind) { return new LockDiscovery([]); @@ -108,7 +92,7 @@ class FakeLockerPlugin extends ServerPlugin { if (isset($fileCondition['tokens'])) { foreach ($fileCondition['tokens'] as &$token) { if (isset($token['token'])) { - if (substr($token['token'], 0, 16) === 'opaquelocktoken:') { + if (str_starts_with($token['token'], 'opaquelocktoken:')) { $token['validToken'] = true; } } @@ -125,19 +109,19 @@ class FakeLockerPlugin extends ServerPlugin { * @return bool */ public function fakeLockProvider(RequestInterface $request, - ResponseInterface $response) { + ResponseInterface $response) { $lockInfo = new LockInfo(); $lockInfo->token = md5($request->getPath()); $lockInfo->uri = $request->getPath(); - $lockInfo->depth = \Sabre\DAV\Server::DEPTH_INFINITY; + $lockInfo->depth = Server::DEPTH_INFINITY; $lockInfo->timeout = 1800; $body = $this->server->xml->write('{DAV:}prop', [ - '{DAV:}lockdiscovery' => - new LockDiscovery([$lockInfo]) + '{DAV:}lockdiscovery' + => new LockDiscovery([$lockInfo]) ]); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); $response->setBody($body); return false; @@ -151,8 +135,8 @@ class FakeLockerPlugin extends ServerPlugin { * @return bool */ public function fakeUnlockProvider(RequestInterface $request, - ResponseInterface $response) { - $response->setStatus(204); + 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 index 6c379984995..d2a71eb3e7b 100644 --- a/apps/dav/lib/Connector/Sabre/File.php +++ b/apps/dav/lib/Connector/Sabre/File.php @@ -1,40 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Jakob Sack <mail@jakobsack.de> - * @author Jan-Philipp Litza <jplitza@users.noreply.github.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Owen Winkler <a_github@midnightcircus.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Semih Serhat Karakaya <karakayasemi@itu.edu.tr> - * @author Stefan Schneider <stefan.schneider@squareweave.com.au> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -43,67 +12,71 @@ use OC\AppFramework\Http\Request; use OC\Files\Filesystem; use OC\Files\Stream\HashWrapper; use OC\Files\View; -use OC\Metadata\FileMetadata; 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 OCA\DAV\Connector\Sabre\Exception\BadGateway; +use OCP\App\IAppManager; use OCP\Encryption\Exceptions\GenericEncryptionException; +use OCP\Files; use OCP\Files\EntityTooLargeException; use OCP\Files\FileInfo; use OCP\Files\ForbiddenException; use OCP\Files\GenericFileException; +use OCP\Files\IMimeTypeDetector; use OCP\Files\InvalidContentException; use OCP\Files\InvalidPathException; use OCP\Files\LockNotAcquiredException; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; -use OCP\Files\Storage; +use OCP\Files\Storage\IWriteStreamStorage; use OCP\Files\StorageNotAvailableException; +use OCP\IConfig; use OCP\IL10N; -use OCP\ILogger; +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\NotImplemented; use Sabre\DAV\Exception\ServiceUnavailable; use Sabre\DAV\IFile; class File extends Node implements IFile { - protected $request; - + protected IRequest $request; protected IL10N $l10n; - /** @var array<string, FileMetadata> */ - private array $metadata = []; - /** * Sets up the node, expects a full path name * - * @param \OC\Files\View $view - * @param \OCP\Files\FileInfo $info - * @param \OCP\Share\IManager $shareManager - * @param \OC\AppFramework\Http\Request $request + * @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, Request $request = null) { + public function __construct(View $view, FileInfo $info, ?IManager $shareManager = null, ?IRequest $request = null, ?IL10N $l10n = null) { parent::__construct($view, $info, $shareManager); - // Querying IL10N directly results in a dependency loop - /** @var IL10NFactory $l10nFactory */ - $l10nFactory = \OC::$server->get(IL10NFactory::class); - $this->l10n = $l10nFactory->get(Application::APP_ID); + 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 = \OC::$server->getRequest(); + $this->request = Server::get(IRequest::class); } } @@ -124,7 +97,7 @@ class File extends Node implements IFile { * different object on a subsequent GET you are strongly recommended to not * return an ETag, and just return null. * - * @param resource $data + * @param resource|string $data * * @throws Forbidden * @throws UnsupportedMediaType @@ -138,7 +111,7 @@ class File extends Node implements IFile { public function put($data) { try { $exists = $this->fileView->file_exists($this->path); - if ($this->info && $exists && !$this->info->isUpdateable()) { + if ($exists && !$this->info->isUpdateable()) { throw new Forbidden(); } } catch (StorageNotAvailableException $e) { @@ -148,24 +121,18 @@ class File extends Node implements IFile { // verify path of the target $this->verifyPath(); - // chunked handling - if (isset($_SERVER['HTTP_OC_CHUNKED'])) { - try { - return $this->createFileChunked($data); - } catch (\Exception $e) { - $this->convertToSabreException($e); - } - } - - /** @var Storage $partStorage */ [$partStorage] = $this->fileView->resolvePath($this->path); + if ($partStorage === null) { + throw new ServiceUnavailable($this->l10n->t('Failed to get storage for file')); + } $needsPartFile = $partStorage->needsPartFile() && (strlen($this->path) > 1); - $view = \OC\Files\Filesystem::getView(); + $view = Filesystem::getView(); if ($needsPartFile) { + $transferId = \rand(); // mark file as partial while uploading (ignored by the scanner) - $partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . rand() . '.part'; + $partFilePath = $this->getPartFileBasePath($this->path) . '.ocTransferId' . $transferId . '.part'; if (!$view->isCreatable($partFilePath) && $view->isUpdatable($this->path)) { $needsPartFile = false; @@ -181,10 +148,11 @@ class File extends Node implements IFile { } // the part file and target file might be on a different storage in case of a single file storage (e.g. single file share) - /** @var \OC\Files\Storage\Storage $partStorage */ [$partStorage, $internalPartPath] = $this->fileView->resolvePath($partFilePath); - /** @var \OC\Files\Storage\Storage $storage */ [$storage, $internalPath] = $this->fileView->resolvePath($this->path); + if ($partStorage === null || $storage === null) { + throw new ServiceUnavailable($this->l10n->t('Failed to get storage for file')); + } try { if (!$needsPartFile) { try { @@ -215,93 +183,97 @@ class File extends Node implements IFile { $data = $tmpData; } - $data = HashWrapper::wrap($data, 'md5', function ($hash) { - $this->header('X-Hash-MD5: ' . $hash); - }); - $data = HashWrapper::wrap($data, 'sha1', function ($hash) { - $this->header('X-Hash-SHA1: ' . $hash); - }); - $data = HashWrapper::wrap($data, 'sha256', function ($hash) { - $this->header('X-Hash-SHA256: ' . $hash); - }); - - if ($partStorage->instanceOfStorage(Storage\IWriteStreamStorage::class)) { - $isEOF = false; - $wrappedData = CallbackWrapper::wrap($data, null, null, null, null, function ($stream) use (&$isEOF) { - $isEOF = feof($stream); - }); + 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); + }); + } - $result = true; - $count = -1; - try { - $count = $partStorage->writeStream($internalPartPath, $wrappedData); - } catch (GenericFileException $e) { - $result = false; - } catch (BadGateway $e) { - throw $e; + 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); + }); - if ($result === false) { - $result = $isEOF; - if (is_resource($wrappedData)) { - $result = feof($wrappedData); + $result = is_resource($wrappedData); + if ($result) { + $count = -1; + try { + /** @var IWriteStreamStorage $partStorage */ + $count = $partStorage->writeStream($internalPartPath, $wrappedData, $expected); + } catch (GenericFileException $e) { + $logger = Server::get(LoggerInterface::class); + $logger->error('Error while writing stream to storage: ' . $e->getMessage(), ['exception' => $e, 'app' => 'webdav']); + $result = $isEOF; + if (is_resource($wrappedData)) { + $result = feof($wrappedData); + } } } } else { $target = $partStorage->fopen($internalPartPath, 'wb'); if ($target === false) { - \OC::$server->getLogger()->error('\OC\Files\Filesystem::fopen() failed', ['app' => 'webdav']); + Server::get(LoggerInterface::class)->error('\OC\Files\Filesystem::fopen() failed', ['app' => 'webdav']); // because we have no clue about the cause we can only throw back a 500/Internal Server Error throw new Exception($this->l10n->t('Could not write file contents')); } - [$count, $result] = \OC_Helper::streamCopy($data, $target); + [$count, $result] = Files::streamCopy($data, $target, true); fclose($target); } - - if ($result === false) { - $expected = -1; - if (isset($_SERVER['CONTENT_LENGTH'])) { - $expected = $_SERVER['CONTENT_LENGTH']; - } - if ($expected !== "0") { - throw new Exception( - $this->l10n->t( - 'Error while copying file to target location (copied: %1$s, expected filesize: %2$s)', - [ - $this->l10n->n('%n byte', '%n bytes', $count), - $this->l10n->n('%n byte', '%n bytes', $expected), - ], - ) - ); - } + if ($result === false && $expected !== null) { + throw new Exception( + $this->l10n->t( + 'Error while copying file to target location (copied: %1$s, expected filesize: %2$s)', + [ + $this->l10n->n('%n byte', '%n bytes', $count), + $this->l10n->n('%n byte', '%n bytes', $expected), + ], + ) + ); } // if content length is sent by client: // double check if the file was fully received // compare expected and actual size - if (isset($_SERVER['CONTENT_LENGTH']) && $_SERVER['REQUEST_METHOD'] === 'PUT') { - $expected = (int)$_SERVER['CONTENT_LENGTH']; - if ($count !== $expected) { - throw new BadRequest( - $this->l10n->t( - 'Expected filesize of %1$s but read (from Nextcloud client) and wrote (to Nextcloud storage) %2$s. Could either be a network problem on the sending side or a problem writing to the storage on the server side.', - [ - $this->l10n->n('%n byte', '%n bytes', $expected), - $this->l10n->n('%n byte', '%n bytes', $count), - ], - ) - ); - } + if ($expected !== null + && $expected !== $count + && $this->request->getMethod() === 'PUT' + ) { + throw new BadRequest( + $this->l10n->t( + 'Expected filesize of %1$s but read (from Nextcloud client) and wrote (to Nextcloud storage) %2$s. Could either be a network problem on the sending side or a problem writing to the storage on the server side.', + [ + $this->l10n->n('%n byte', '%n bytes', $expected), + $this->l10n->n('%n byte', '%n bytes', $count), + ], + ) + ); } } catch (\Exception $e) { - $context = []; - if ($e instanceof LockedException) { - $context['level'] = ILogger::DEBUG; + Server::get(LoggerInterface::class)->debug($e->getMessage(), ['exception' => $e]); + } else { + Server::get(LoggerInterface::class)->error($e->getMessage(), ['exception' => $e]); } - \OC::$server->getLogger()->logException($e, $context); if ($needsPartFile) { $partStorage->unlink($internalPartPath); } @@ -340,7 +312,7 @@ class File extends Node implements IFile { $renameOkay = $storage->moveFromStorage($partStorage, $internalPartPath, $internalPath); $fileExists = $storage->file_exists($internalPath); if ($renameOkay === false || $fileExists === false) { - \OC::$server->getLogger()->error('renaming part file to final file failed $renameOkay: ' . ($renameOkay ? 'true' : 'false') . ', $fileExists: ' . ($fileExists ? 'true' : 'false') . ')', ['app' => 'webdav']); + Server::get(LoggerInterface::class)->error('renaming part file to final file failed $renameOkay: ' . ($renameOkay ? 'true' : 'false') . ', $fileExists: ' . ($fileExists ? 'true' : 'false') . ')', ['app' => 'webdav']); throw new Exception($this->l10n->t('Could not rename part file to final file')); } } catch (ForbiddenException $ex) { @@ -364,8 +336,9 @@ class File extends Node implements IFile { } // allow sync clients to send the mtime along in a header - if (isset($this->request->server['HTTP_X_OC_MTIME'])) { - $mtime = $this->sanitizeMtime($this->request->server['HTTP_X_OC_MTIME']); + $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'); } @@ -376,8 +349,9 @@ class File extends Node implements IFile { ]; // allow sync clients to send the creation time along in a header - if (isset($this->request->server['HTTP_X_OC_CTIME'])) { - $ctime = $this->sanitizeMtime($this->request->server['HTTP_X_OC_CTIME']); + $ctimeHeader = $this->request->getHeader('x-oc-ctime'); + if ($ctimeHeader) { + $ctime = $this->sanitizeMtime($ctimeHeader); $fileInfoUpdate['creation_time'] = $ctime; $this->header('X-OC-CTime: accepted'); } @@ -390,8 +364,9 @@ class File extends Node implements IFile { $this->refreshInfo(); - if (isset($this->request->server['HTTP_OC_CHECKSUM'])) { - $checksum = trim($this->request->server['HTTP_OC_CHECKSUM']); + $checksumHeader = $this->request->getHeader('oc-checksum'); + if ($checksumHeader) { + $checksum = trim($checksumHeader); $this->setChecksum($checksum); } elseif ($this->getChecksum() !== null && $this->getChecksum() !== '') { $this->setChecksum(''); @@ -404,61 +379,68 @@ class File extends Node implements IFile { } private function getPartFileBasePath($path) { - $partFileInStorage = \OC::$server->getConfig()->getSystemValue('part_file_in_storage', true); + $partFileInStorage = Server::get(IConfig::class)->getSystemValue('part_file_in_storage', true); if ($partFileInStorage) { - return $path; + $filename = basename($path); + // hash does not need to be secure but fast and semi unique + $hashedFilename = hash('xxh128', $filename); + return substr($path, 0, strlen($path) - strlen($filename)) . $hashedFilename; } else { - return md5($path); // will place it in the root of the view with a unique name + // will place the .part file in the users root directory + // therefor we need to make the name (semi) unique - hash does not need to be secure but fast. + return hash('xxh128', $path); } } - /** - * @param string $path - */ - private function emitPreHooks($exists, $path = null) { + 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(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_create, [ - \OC\Files\Filesystem::signal_param_path => $hookPath, - \OC\Files\Filesystem::signal_param_run => &$run, + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_create, [ + Filesystem::signal_param_path => $hookPath, + Filesystem::signal_param_run => &$run, ]); } else { - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_update, [ - \OC\Files\Filesystem::signal_param_path => $hookPath, - \OC\Files\Filesystem::signal_param_run => &$run, + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_update, [ + Filesystem::signal_param_path => $hookPath, + Filesystem::signal_param_run => &$run, ]); } - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_write, [ - \OC\Files\Filesystem::signal_param_path => $hookPath, - \OC\Files\Filesystem::signal_param_run => &$run, + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_write, [ + Filesystem::signal_param_path => $hookPath, + Filesystem::signal_param_run => &$run, ]); return $run; } - /** - * @param string $path - */ - private function emitPostHooks($exists, $path = null) { + 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(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_post_create, [ - \OC\Files\Filesystem::signal_param_path => $hookPath + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_create, [ + Filesystem::signal_param_path => $hookPath ]); } else { - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_post_update, [ - \OC\Files\Filesystem::signal_param_path => $hookPath + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_update, [ + Filesystem::signal_param_path => $hookPath ]); } - \OC_Hook::emit(\OC\Files\Filesystem::CLASSNAME, \OC\Files\Filesystem::signal_post_write, [ - \OC\Files\Filesystem::signal_param_path => $hookPath + \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_write, [ + Filesystem::signal_param_path => $hookPath ]); } @@ -476,14 +458,31 @@ class File extends Node implements IFile { // do a if the file did not exist throw new NotFound(); } + $path = ltrim($this->path, '/'); try { - $res = $this->fileView->fopen(ltrim($this->path, '/'), 'rb'); + $res = $this->fileView->fopen($path, 'rb'); } catch (\Exception $e) { $this->convertToSabreException($e); } + if ($res === false) { - throw new ServiceUnavailable($this->l10n->t('Could not open file')); + if ($this->fileView->file_exists($path)) { + throw new ServiceUnavailable($this->l10n->t('Could not open file: %1$s, file does seem to exist', [$path])); + } else { + throw new ServiceUnavailable($this->l10n->t('Could not open file: %1$s, file doesn\'t seem to exist', [$path])); + } } + + // comparing current file size with the one in DB + // if different, fix DB and refresh cache. + if ($this->getSize() !== $this->fileView->filesize($this->getPath())) { + $logger = 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 @@ -533,20 +532,19 @@ class File extends Node implements IFile { $mimeType = $this->info->getMimetype(); // PROPFIND needs to return the correct mime type, for consistency with the web UI - if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'PROPFIND') { + if ($this->request->getMethod() === 'PROPFIND') { return $mimeType; } - return \OC::$server->getMimeTypeDetector()->getSecureMimeType($mimeType); + return Server::get(IMimeTypeDetector::class)->getSecureMimeType($mimeType); } /** * @return array|bool */ public function getDirectDownload() { - if (\OCP\App::isEnabled('encryption')) { + if (Server::get(IAppManager::class)->isEnabledForUser('encryption')) { return []; } - /** @var \OCP\Files\Storage $storage */ [$storage, $internalPath] = $this->fileView->resolvePath($this->path); if (is_null($storage)) { return []; @@ -556,132 +554,6 @@ class File extends Node implements IFile { } /** - * @param resource $data - * @return null|string - * @throws Exception - * @throws BadRequest - * @throws NotImplemented - * @throws ServiceUnavailable - */ - private function createFileChunked($data) { - [$path, $name] = \Sabre\Uri\split($this->path); - - $info = \OC_FileChunking::decodeName($name); - if (empty($info)) { - throw new NotImplemented($this->l10n->t('Invalid chunk name')); - } - - $chunk_handler = new \OC_FileChunking($info); - $bytesWritten = $chunk_handler->store($info['index'], $data); - - //detect aborted upload - if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'PUT') { - if (isset($_SERVER['CONTENT_LENGTH'])) { - $expected = (int)$_SERVER['CONTENT_LENGTH']; - if ($bytesWritten !== $expected) { - $chunk_handler->remove($info['index']); - 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', $bytesWritten), - ], - ) - ); - } - } - } - - if ($chunk_handler->isComplete()) { - /** @var Storage $storage */ - [$storage,] = $this->fileView->resolvePath($path); - $needsPartFile = $storage->needsPartFile(); - $partFile = null; - - $targetPath = $path . '/' . $info['name']; - /** @var \OC\Files\Storage\Storage $targetStorage */ - [$targetStorage, $targetInternalPath] = $this->fileView->resolvePath($targetPath); - - $exists = $this->fileView->file_exists($targetPath); - - try { - $this->fileView->lockFile($targetPath, ILockingProvider::LOCK_SHARED); - - $this->emitPreHooks($exists, $targetPath); - $this->fileView->changeLock($targetPath, ILockingProvider::LOCK_EXCLUSIVE); - /** @var \OC\Files\Storage\Storage $targetStorage */ - [$targetStorage, $targetInternalPath] = $this->fileView->resolvePath($targetPath); - - if ($needsPartFile) { - // we first assembly the target file as a part file - $partFile = $this->getPartFileBasePath($path . '/' . $info['name']) . '.ocTransferId' . $info['transferid'] . '.part'; - /** @var \OC\Files\Storage\Storage $targetStorage */ - [$partStorage, $partInternalPath] = $this->fileView->resolvePath($partFile); - - - $chunk_handler->file_assemble($partStorage, $partInternalPath); - - // here is the final atomic rename - $renameOkay = $targetStorage->moveFromStorage($partStorage, $partInternalPath, $targetInternalPath); - $fileExists = $targetStorage->file_exists($targetInternalPath); - if ($renameOkay === false || $fileExists === false) { - \OC::$server->getLogger()->error('\OC\Files\Filesystem::rename() failed', ['app' => 'webdav']); - // only delete if an error occurred and the target file was already created - if ($fileExists) { - // set to null to avoid double-deletion when handling exception - // stray part file - $partFile = null; - $targetStorage->unlink($targetInternalPath); - } - $this->fileView->changeLock($targetPath, ILockingProvider::LOCK_SHARED); - throw new Exception($this->l10n->t('Could not rename part file assembled from chunks')); - } - } else { - // assemble directly into the final file - $chunk_handler->file_assemble($targetStorage, $targetInternalPath); - } - - // allow sync clients to send the mtime along in a header - if (isset($this->request->server['HTTP_X_OC_MTIME'])) { - $mtime = $this->sanitizeMtime($this->request->server['HTTP_X_OC_MTIME']); - if ($targetStorage->touch($targetInternalPath, $mtime)) { - $this->header('X-OC-MTime: accepted'); - } - } - - // since we skipped the view we need to scan and emit the hooks ourselves - $targetStorage->getUpdater()->update($targetInternalPath); - - $this->fileView->changeLock($targetPath, ILockingProvider::LOCK_SHARED); - - $this->emitPostHooks($exists, $targetPath); - - // FIXME: should call refreshInfo but can't because $this->path is not the of the final file - $info = $this->fileView->getFileInfo($targetPath); - - if (isset($this->request->server['HTTP_OC_CHECKSUM'])) { - $checksum = trim($this->request->server['HTTP_OC_CHECKSUM']); - $this->fileView->putFileInfo($targetPath, ['checksum' => $checksum]); - } elseif ($info->getChecksum() !== null && $info->getChecksum() !== '') { - $this->fileView->putFileInfo($this->path, ['checksum' => '']); - } - - $this->fileView->unlockFile($targetPath, ILockingProvider::LOCK_SHARED); - - return $info->getEtag(); - } catch (\Exception $e) { - if ($partFile !== null) { - $targetStorage->unlink($targetInternalPath); - } - $this->convertToSabreException($e); - } - } - - return null; - } - - /** * Convert the given exception to a SabreException instance * * @param \Exception $e @@ -737,9 +609,6 @@ class File extends Node implements IFile { * @return string|null */ public function getChecksum() { - if (!$this->info) { - return null; - } return $this->info->getChecksum(); } @@ -761,16 +630,4 @@ class File extends Node implements IFile { public function getNode(): \OCP\Files\File { return $this->node; } - - public function getMetadata(string $group): FileMetadata { - return $this->metadata[$group]; - } - - public function setMetadata(string $group, FileMetadata $metadata): void { - $this->metadata[$group] = $metadata; - } - - public function hasMetadata(string $group) { - return array_key_exists($group, $this->metadata); - } } diff --git a/apps/dav/lib/Connector/Sabre/FilesPlugin.php b/apps/dav/lib/Connector/Sabre/FilesPlugin.php index 9c4f912610b..843383a0452 100644 --- a/apps/dav/lib/Connector/Sabre/FilesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesPlugin.php @@ -1,61 +1,45 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Michael Jobst <mjobst+github@tecratech.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; use OC\AppFramework\Http\Request; -use OC\Metadata\IMetadataManager; +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 Psr\Log\LoggerInterface; +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; -use Sabre\Uri; class FilesPlugin extends ServerPlugin { - // namespace public const NS_OWNCLOUD = 'http://owncloud.org/ns'; public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; @@ -64,93 +48,55 @@ class FilesPlugin extends ServerPlugin { 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 IS_ENCRYPTED_PROPERTYNAME = '{http://nextcloud.org/ns}is-encrypted'; + 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_SIZE = '{http://nextcloud.org/ns}file-metadata-size'; - - /** - * Reference to main server object - * - * @var \Sabre\DAV\Server - */ - private $server; - - /** - * @var Tree - */ - private $tree; - - /** - * @var IUserSession - */ - private $userSession; - - /** - * Whether this is public webdav. - * If true, some returned information will be stripped off. - * - * @var bool - */ - private $isPublic; + public const FILE_METADATA_PREFIX = '{http://nextcloud.org/ns}metadata-'; + public const HIDDEN_PROPERTYNAME = '{http://nextcloud.org/ns}hidden'; - /** - * @var bool - */ - private $downloadAttachment; - - /** - * @var IConfig - */ - private $config; - - /** - * @var IRequest - */ - private $request; - - /** - * @var IPreview - */ - private $previewManager; + /** Reference to main server object */ + private ?Server $server = null; /** * @param Tree $tree * @param IConfig $config * @param IRequest $request * @param IPreview $previewManager - * @param bool $isPublic + * @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(Tree $tree, - IConfig $config, - IRequest $request, - IPreview $previewManager, - IUserSession $userSession, - $isPublic = false, - $downloadAttachment = true) { - $this->tree = $tree; - $this->config = $config; - $this->request = $request; - $this->userSession = $userSession; - $this->isPublic = $isPublic; - $this->downloadAttachment = $downloadAttachment; - $this->previewManager = $previewManager; + public function __construct( + private Tree $tree, + private IConfig $config, + private IRequest $request, + private IPreview $previewManager, + private IUserSession $userSession, + private IFilenameValidator $validator, + private IAccountManager $accountManager, + private bool $isPublic = false, + private bool $downloadAttachment = true, + ) { } /** @@ -161,10 +107,9 @@ class FilesPlugin extends ServerPlugin { * * This method should set up the required event subscriptions. * - * @param \Sabre\DAV\Server $server * @return void */ - public function initialize(\Sabre\DAV\Server $server) { + public function initialize(Server $server) { $server->xml->namespaceMap[self::NS_OWNCLOUD] = 'oc'; $server->xml->namespaceMap[self::NS_NEXTCLOUD] = 'nc'; $server->protectedProperties[] = self::FILEID_PROPERTYNAME; @@ -172,6 +117,7 @@ class FilesPlugin extends ServerPlugin { $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; @@ -180,7 +126,7 @@ class FilesPlugin extends ServerPlugin { $server->protectedProperties[] = self::DATA_FINGERPRINT_PROPERTYNAME; $server->protectedProperties[] = self::HAS_PREVIEW_PROPERTYNAME; $server->protectedProperties[] = self::MOUNT_TYPE_PROPERTYNAME; - $server->protectedProperties[] = self::IS_ENCRYPTED_PROPERTYNAME; + $server->protectedProperties[] = self::IS_FEDERATED_PROPERTYNAME; $server->protectedProperties[] = self::SHARE_NOTE; // normally these cannot be changed (RFC4918), but we want them modifiable through PROPPATCH @@ -194,40 +140,79 @@ class FilesPlugin extends ServerPlugin { $this->server->on('afterWriteContent', [$this, 'sendFileIdHeader']); $this->server->on('afterMethod:GET', [$this,'httpGet']); $this->server->on('afterMethod:GET', [$this, 'handleDownloadToken']); - $this->server->on('afterResponse', function ($request, ResponseInterface $response) { + $this->server->on('afterResponse', function ($request, ResponseInterface $response): void { $body = $response->getBody(); if (is_resource($body)) { fclose($body); } }); $this->server->on('beforeMove', [$this, 'checkMove']); + $this->server->on('beforeCopy', [$this, 'checkCopy']); } /** - * Plugin that checks if a move can actually be performed. + * Plugin that checks if a copy can actually be performed. * * @param string $source source path - * @param string $destination destination path - * @throws Forbidden - * @throws NotFound + * @param string $target target path + * @throws NotFound If the source does not exist + * @throws InvalidPath If the target is invalid */ - public function checkMove($source, $destination) { + public function checkCopy($source, $target): void { $sourceNode = $this->tree->getNodeForPath($source); if (!$sourceNode instanceof Node) { return; } - [$sourceDir,] = \Sabre\Uri\split($source); - [$destinationDir,] = \Sabre\Uri\split($destination); - if ($sourceDir !== $destinationDir) { - $sourceNodeFileInfo = $sourceNode->getFileInfo(); - if ($sourceNodeFileInfo === null) { - throw new NotFound($source . ' does not exist'); + // Ensure source exists + $sourceNodeFileInfo = $sourceNode->getFileInfo(); + if ($sourceNodeFileInfo === null) { + throw new NotFound($source . ' does not exist'); + } + // Ensure the target name is valid + try { + [$targetPath, $targetName] = \Sabre\Uri\split($target); + $this->validator->validateFilename($targetName); + } catch (InvalidPathException $e) { + throw new InvalidPath($e->getMessage(), false); + } + // Ensure the target path is valid + $segments = array_slice(explode('/', $targetPath), 2); + foreach ($segments as $segment) { + if ($this->validator->isFilenameValid($segment) === false) { + $l = \OCP\Server::get(IFactory::class)->get('dav'); + throw new InvalidPath($l->t('Invalid target path')); } + } + } - if (!$sourceNodeFileInfo->isDeletable()) { - throw new Forbidden($source . " cannot be deleted"); - } + /** + * Plugin that checks if a move can actually be performed. + * + * @param string $source source path + * @param string $target target path + * @throws Forbidden If the source is not deletable + * @throws NotFound If the source does not exist + * @throws InvalidPath If the target name is invalid + */ + public function checkMove(string $source, string $target): void { + $sourceNode = $this->tree->getNodeForPath($source); + if (!$sourceNode instanceof Node) { + return; + } + + // First check copyable (move only needs additional delete permission) + $this->checkCopy($source, $target); + + // The source needs to be deletable for moving + $sourceNodeFileInfo = $sourceNode->getFileInfo(); + if (!$sourceNodeFileInfo->isDeletable()) { + throw new Forbidden($source . ' cannot be deleted'); + } + + // The source is not allowed to be the parent of the target + if (str_starts_with($source, $target . '/')) { + throw new Forbidden($source . ' cannot be moved to it\'s parent'); } } @@ -272,8 +257,8 @@ class FilesPlugin extends ServerPlugin { // adds a 'Content-Disposition: attachment' header in case no disposition // header has been set before - if ($this->downloadAttachment && - $response->getHeader('Content-Disposition') === null) { + if ($this->downloadAttachment + && $response->getHeader('Content-Disposition') === null) { $filename = $node->getName(); if ($this->request->isUserAgent( [ @@ -288,7 +273,7 @@ class FilesPlugin extends ServerPlugin { } } - if ($node instanceof \OCA\DAV\Connector\Sabre\File) { + if ($node instanceof File) { //Add OC-Checksum header $checksum = $node->getChecksum(); if ($checksum !== null && $checksum !== '') { @@ -308,7 +293,7 @@ class FilesPlugin extends ServerPlugin { public function handleGetProperties(PropFind $propFind, \Sabre\DAV\INode $node) { $httpRequest = $this->server->httpRequest; - if ($node instanceof \OCA\DAV\Connector\Sabre\Node) { + if ($node instanceof Node) { /** * This was disabled, because it made dir listing throw an exception, * so users were unable to navigate into folders where one subitem @@ -347,7 +332,7 @@ class FilesPlugin extends ServerPlugin { ); }); - $propFind->handle(self::OCM_SHARE_PERMISSIONS_PROPERTYNAME, function () use ($node, $httpRequest) { + $propFind->handle(self::OCM_SHARE_PERMISSIONS_PROPERTYNAME, function () use ($node, $httpRequest): ?string { $user = $this->userSession->getUser(); if ($user === null) { return null; @@ -356,14 +341,18 @@ class FilesPlugin extends ServerPlugin { $user->getUID() ); $ocmPermissions = $this->ncPermissions2ocmPermissions($ncPermissions); - return json_encode($ocmPermissions); + 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) { + $propFind->handle(self::GETETAG_PROPERTYNAME, function () use ($node): string { return $node->getETag(); }); - $propFind->handle(self::OWNER_ID_PROPERTYNAME, function () use ($node) { + $propFind->handle(self::OWNER_ID_PROPERTYNAME, function () use ($node): ?string { $owner = $node->getOwner(); if (!$owner) { return null; @@ -371,36 +360,79 @@ class FilesPlugin extends ServerPlugin { return $owner->getUID(); } }); - $propFind->handle(self::OWNER_DISPLAY_NAME_PROPERTYNAME, function () use ($node) { + $propFind->handle(self::OWNER_DISPLAY_NAME_PROPERTYNAME, function () use ($node): ?string { $owner = $node->getOwner(); if (!$owner) { return null; - } else { + } + + // Get current user to see if we're in a public share or not + $user = $this->userSession->getUser(); + + // If the user is logged in, we can return the display name + if ($user !== null) { return $owner->getDisplayName(); } + + // Check if the user published their display name + try { + $ownerAccount = $this->accountManager->getAccount($owner); + } catch (NoUserException) { + // do not lock process if owner is not local + return null; + } + + $ownerNameProperty = $ownerAccount->getProperty(IAccountManager::PROPERTY_DISPLAYNAME); + + // Since we are not logged in, we need to have at least the published scope + if ($ownerNameProperty->getScope() === IAccountManager::SCOPE_PUBLISHED) { + return $owner->getDisplayName(); + } + + return null; }); $propFind->handle(self::HAS_PREVIEW_PROPERTYNAME, function () use ($node) { - return json_encode($this->previewManager->isAvailable($node->getFileInfo())); + return json_encode($this->previewManager->isAvailable($node->getFileInfo()), JSON_THROW_ON_ERROR); }); - $propFind->handle(self::SIZE_PROPERTYNAME, function () use ($node) { + $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(); }); - $propFind->handle(self::SHARE_NOTE, function () use ($node, $httpRequest) { + /** + * 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(); - if ($user === null) { - return null; - } return $node->getNoteFromShare( - $user->getUID() + $user?->getUID() ); }); - $propFind->handle(self::DATA_FINGERPRINT_PROPERTYNAME, function () use ($node) { + $propFind->handle(self::SHARE_HIDE_DOWNLOAD_PROPERTYNAME, function () use ($node) { + $storage = $node->getNode()->getStorage(); + if ($storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ + return match($storage->getShare()->getHideDownload()) { + true => 'true', + false => 'false', + }; + } else { + return null; + } + }); + + $propFind->handle(self::DATA_FINGERPRINT_PROPERTYNAME, function () { return $this->config->getSystemValue('data-fingerprint', ''); }); $propFind->handle(self::CREATIONDATE_PROPERTYNAME, function () use ($node) { @@ -411,9 +443,34 @@ class FilesPlugin extends ServerPlugin { $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 \OCA\DAV\Connector\Sabre\File) { + if ($node instanceof File) { $propFind->handle(self::DOWNLOADURL_PROPERTYNAME, function () use ($node) { try { $directDownloadUrl = $node->getDirectDownload(); @@ -440,29 +497,6 @@ class FilesPlugin extends ServerPlugin { $propFind->handle(self::UPLOAD_TIME_PROPERTYNAME, function () use ($node) { return $node->getFileInfo()->getUploadTime(); }); - - if ($this->config->getSystemValueBool('enable_file_metadata', true)) { - $propFind->handle(self::FILE_METADATA_SIZE, function () use ($node) { - if (!str_starts_with($node->getFileInfo()->getMimetype(), 'image')) { - return json_encode((object)[]); - } - - if ($node->hasMetadata('size')) { - $sizeMetadata = $node->getMetadata('size'); - } else { - // This code path should not be called since we try to preload - // the metadata when loading the folder or the search results - // in one go - $metadataManager = \OC::$server->get(IMetadataManager::class); - $sizeMetadata = $metadataManager->fetchMetadataFor('size', [$node->getId()])[$node->getId()]; - - // TODO would be nice to display this in the profiler... - \OC::$server->get(LoggerInterface::class)->debug('Inefficient fetching of metadata'); - } - - return json_encode((object)$sizeMetadata->getMetadata()); - }); - } } if ($node instanceof Directory) { @@ -470,37 +504,8 @@ class FilesPlugin extends ServerPlugin { return $node->getSize(); }); - $propFind->handle(self::IS_ENCRYPTED_PROPERTYNAME, function () use ($node) { - return $node->getFileInfo()->isEncrypted() ? '1' : '0'; - }); - $requestProperties = $propFind->getRequestedProperties(); - // TODO detect dynamically which metadata groups are requested and - // preload all of them and not just size - if ($this->config->getSystemValueBool('enable_file_metadata', true) - && in_array(self::FILE_METADATA_SIZE, $requestProperties, true)) { - // Preloading of the metadata - $fileIds = []; - foreach ($node->getChildren() as $child) { - /** @var \OCP\Files\Node|Node $child */ - if (str_starts_with($child->getFileInfo()->getMimeType(), 'image/')) { - /** @var File $child */ - $fileIds[] = $child->getFileInfo()->getId(); - } - } - /** @var IMetaDataManager $metadataManager */ - $metadataManager = \OC::$server->get(IMetadataManager::class); - $preloadedMetadata = $metadataManager->fetchMetadataFor('size', $fileIds); - foreach ($node->getChildren() as $child) { - /** @var \OCP\Files\Node|Node $child */ - if (str_starts_with($child->getFileInfo()->getMimeType(), 'image')) { - /** @var File $child */ - $child->setMetadata('size', $preloadedMetadata[$child->getFileInfo()->getId()]); - } - } - } - if (in_array(self::SUBFILE_COUNT_PROPERTYNAME, $requestProperties, true) || in_array(self::SUBFOLDER_COUNT_PROPERTYNAME, $requestProperties, true)) { $nbFiles = 0; @@ -536,8 +541,8 @@ class FilesPlugin extends ServerPlugin { $ocmPermissions[] = 'read'; } - if (($ncPermissions & Constants::PERMISSION_CREATE) || - ($ncPermissions & Constants::PERMISSION_UPDATE)) { + if (($ncPermissions & Constants::PERMISSION_CREATE) + || ($ncPermissions & Constants::PERMISSION_UPDATE)) { $ocmPermissions[] = 'write'; } @@ -554,7 +559,7 @@ class FilesPlugin extends ServerPlugin { */ public function handleUpdateProperties($path, PropPatch $propPatch) { $node = $this->tree->getNodeForPath($path); - if (!($node instanceof \OCA\DAV\Connector\Sabre\Node)) { + if (!($node instanceof Node)) { return; } @@ -569,10 +574,7 @@ class FilesPlugin extends ServerPlugin { if (empty($etag)) { return false; } - if ($node->setEtag($etag) !== -1) { - return true; - } - return false; + return $node->setEtag($etag) !== -1; }); $propPatch->handle(self::CREATIONDATE_PROPERTYNAME, function ($time) use ($node) { if (empty($time)) { @@ -586,36 +588,154 @@ class FilesPlugin extends ServerPlugin { if (empty($time)) { return false; } - $node->setCreationTime((int) $time); + $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; + }); } + /** - * @param string $filePath - * @param \Sabre\DAV\INode $node - * @throws \Sabre\DAV\Exception\BadRequest + * handle the update of metadata from PROPPATCH requests + * + * @param PropPatch $propPatch + * @param Node $node + * + * @throws FilesMetadataException */ - public function sendFileIdHeader($filePath, \Sabre\DAV\INode $node = null) { - // chunked upload handling - if (isset($_SERVER['HTTP_OC_CHUNKED'])) { - [$path, $name] = \Sabre\Uri\split($filePath); - $info = \OC_FileChunking::decodeName($name); - if (!empty($info)) { - $filePath = $path . '/' . $info['name']; + 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; + } + ); } + } - // we get the node for the given $filePath here because in case of afterCreateFile $node is the parent folder - if (!$this->server->tree->nodeExists($filePath)) { - return; + /** + * 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; + } } - $node = $this->server->tree->getNodeForPath($filePath); - if ($node instanceof \OCA\DAV\Connector\Sabre\Node) { - $fileId = $node->getFileId(); - if (!is_null($fileId)) { - $this->server->httpResponse->setHeader('OC-FileId', $fileId); + + 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 index 4876e9ad8f3..b59d1373af5 100644 --- a/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php +++ b/apps/dav/lib/Connector/Sabre/FilesReportPlugin.php @@ -1,35 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\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; @@ -45,9 +28,9 @@ 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'; @@ -60,55 +43,8 @@ class FilesReportPlugin extends ServerPlugin { private $server; /** - * @var Tree - */ - private $tree; - - /** - * @var View - */ - private $fileView; - - /** - * @var ISystemTagManager - */ - private $tagManager; - - /** - * @var ISystemTagObjectMapper - */ - private $tagMapper; - - /** - * Manager for private tags - * - * @var ITagManager - */ - private $fileTagger; - - /** - * @var IUserSession - */ - private $userSession; - - /** - * @var IGroupManager - */ - private $groupManager; - - /** - * @var Folder - */ - private $userFolder; - - /** - * @var IAppManager - */ - private $appManager; - - /** * @param Tree $tree - * @param View $view + * @param View $fileView * @param ISystemTagManager $tagManager * @param ISystemTagObjectMapper $tagMapper * @param ITagManager $fileTagger manager for private tags @@ -117,25 +53,20 @@ class FilesReportPlugin extends ServerPlugin { * @param Folder $userFolder * @param IAppManager $appManager */ - public function __construct(Tree $tree, - View $view, - ISystemTagManager $tagManager, - ISystemTagObjectMapper $tagMapper, - ITagManager $fileTagger, - IUserSession $userSession, - IGroupManager $groupManager, - Folder $userFolder, - IAppManager $appManager + public function __construct( + private Tree $tree, + private View $fileView, + private ISystemTagManager $tagManager, + private ISystemTagObjectMapper $tagMapper, + /** + * Manager for private tags + */ + private ITagManager $fileTagger, + private IUserSession $userSession, + private IGroupManager $groupManager, + private Folder $userFolder, + private IAppManager $appManager, ) { - $this->tree = $tree; - $this->fileView = $view; - $this->tagManager = $tagManager; - $this->tagMapper = $tagMapper; - $this->fileTagger = $fileTagger; - $this->userSession = $userSession; - $this->groupManager = $groupManager; - $this->userFolder = $userFolder; - $this->appManager = $appManager; } /** @@ -186,6 +117,7 @@ class FilesReportPlugin extends ServerPlugin { } $ns = '{' . $this::NS_OWNCLOUD . '}'; + $ncns = '{' . $this::NS_NEXTCLOUD . '}'; $requestedProps = []; $filterRules = []; @@ -199,6 +131,14 @@ class FilesReportPlugin extends ServerPlugin { 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']; + } + } } } @@ -209,13 +149,32 @@ class FilesReportPlugin extends ServerPlugin { // gather all file ids matching filter try { - $resultFileIds = $this->processFilterRules($filterRules); + $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', 0, $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 - $results = $this->findNodesByFileIds($reportTargetNode, $resultFileIds); + $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); @@ -225,7 +184,7 @@ class FilesReportPlugin extends ServerPlugin { new MultiStatus($responses) ); - $this->server->httpResponse->setStatus(207); + $this->server->httpResponse->setStatus(Http::STATUS_MULTI_STATUS); $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); $this->server->httpResponse->setBody($xml); @@ -261,19 +220,13 @@ class FilesReportPlugin extends ServerPlugin { * * @param array $filterRules * @return array array of unique file id results - * - * @throws TagNotFoundException whenever a tag was not found */ - protected function processFilterRules($filterRules) { + protected function processFilterRulesForFileIDs(array $filterRules): array { $ns = '{' . $this::NS_OWNCLOUD . '}'; - $resultFileIds = null; - $systemTagIds = []; + $resultFileIds = []; $circlesIds = []; $favoriteFilter = null; foreach ($filterRules as $filterRule) { - if ($filterRule['name'] === $ns . 'systemtag') { - $systemTagIds[] = $filterRule['value']; - } if ($filterRule['name'] === self::CIRCLE_PROPERTYNAME) { $circlesIds[] = $filterRule['value']; } @@ -289,15 +242,6 @@ class FilesReportPlugin extends ServerPlugin { } } - if (!empty($systemTagIds)) { - $fileIds = $this->getSystemTagFileIds($systemTagIds); - if (empty($resultFileIds)) { - $resultFileIds = $fileIds; - } else { - $resultFileIds = array_intersect($fileIds, $resultFileIds); - } - } - if (!empty($circlesIds)) { $fileIds = $this->getCirclesFileIds($circlesIds); if (empty($resultFileIds)) { @@ -310,47 +254,46 @@ class FilesReportPlugin extends ServerPlugin { return $resultFileIds; } - private function getSystemTagFileIds($systemTagIds) { - $resultFileIds = null; - - // check user permissions, if applicable - if (!$this->isAdmin()) { - // check visibility/permission - $tags = $this->tagManager->getTagsByIds($systemTagIds); - $unknownTagIds = []; - foreach ($tags as $tag) { - if (!$tag->isUserVisible()) { - $unknownTagIds[] = $tag->getId(); - } - } - - if (!empty($unknownTagIds)) { - throw new TagNotFoundException('Tag with ids ' . implode(', ', $unknownTagIds) . ' not found'); + protected function processFilterRulesForFileNodes(array $filterRules, ?int $limit, ?int $offset): array { + $systemTagIds = []; + foreach ($filterRules as $filterRule) { + if ($filterRule['name'] === self::SYSTEMTAG_PROPERTYNAME) { + $systemTagIds[] = $filterRule['value']; } } - // fetch all file ids and intersect them - foreach ($systemTagIds as $systemTagId) { - $fileIds = $this->tagMapper->getObjectIdsForTags($systemTagId, 'files'); + $nodes = []; - if (empty($fileIds)) { - // This tag has no files, nothing can ever show up - return []; - } + if (!empty($systemTagIds)) { + $tags = $this->tagManager->getTagsByIds($systemTagIds, $this->userSession->getUser()); - // first run ? - if ($resultFileIds === null) { - $resultFileIds = $fileIds; - } else { - $resultFileIds = array_intersect($resultFileIds, $fileIds); + // 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 (empty($resultFileIds)) { - // Empty intersection, nothing can show up anymore - return []; + if (!$oneTagSearch && ($limit !== null || $offset !== null)) { + $nodes = array_slice($nodes, $offset, $limit); } } - return $resultFileIds; + + return $nodes; } /** @@ -362,7 +305,7 @@ class FilesReportPlugin extends ServerPlugin { if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) { return []; } - return \OCA\Circles\Api\v1\Circles::getFilesForCircles($circlesIds); + return Circles::getFilesForCircles($circlesIds); } @@ -370,7 +313,7 @@ class FilesReportPlugin extends ServerPlugin { * Prepare propfind response for the given nodes * * @param string $filesUri $filesUri URI leading to root of the files URI, - * with a leading slash but no trailing slash + * with a leading slash but no trailing slash * @param string[] $requestedProps requested properties * @param Node[] nodes nodes for which to fetch and prepare responses * @return Response[] @@ -393,7 +336,6 @@ class FilesReportPlugin extends ServerPlugin { $responses[] = new Response( rtrim($this->server->getBaseUri(), '/') . $filesUri . $node->getPath(), $result, - 200 ); } return $responses; @@ -406,28 +348,35 @@ class FilesReportPlugin extends ServerPlugin { * @param array $fileIds file ids * @return Node[] array of Sabre nodes */ - public function findNodesByFileIds($rootNode, $fileIds) { + 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->getById($fileId); + $entry = $folder->getFirstNodeById((int)$fileId); if ($entry) { - $entry = current($entry); - if ($entry instanceof \OCP\Files\File) { - $results[] = new File($this->fileView, $entry); - } elseif ($entry instanceof \OCP\Files\Folder) { - $results[] = new Directory($this->fileView, $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 */ diff --git a/apps/dav/lib/Connector/Sabre/LockPlugin.php b/apps/dav/lib/Connector/Sabre/LockPlugin.php index 6305b0ec138..6640771dc31 100644 --- a/apps/dav/lib/Connector/Sabre/LockPlugin.php +++ b/apps/dav/lib/Connector/Sabre/LockPlugin.php @@ -1,28 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Jaakko Salo <jaakkos@gmail.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Stefan Weil <sw@weilnetz.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -61,7 +42,7 @@ class LockPlugin extends ServerPlugin { 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' || isset($_SERVER['HTTP_OC_CHUNKED'])) { + if ($request->getMethod() !== 'PUT') { return; } try { @@ -84,7 +65,7 @@ class LockPlugin extends ServerPlugin { if ($this->isLocked === false) { return; } - if ($request->getMethod() !== 'PUT' || isset($_SERVER['HTTP_OC_CHUNKED'])) { + if ($request->getMethod() !== 'PUT') { return; } try { diff --git a/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php b/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php index e7e3b273b98..d5ab7f09dfa 100644 --- a/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php +++ b/apps/dav/lib/Connector/Sabre/MaintenancePlugin.php @@ -1,32 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Valdnet <47037905+Valdnet@users.noreply.github.com> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; +use OCA\DAV\Exception\ServerMaintenanceMode; use OCP\IConfig; use OCP\IL10N; use OCP\Util; @@ -35,10 +16,7 @@ use Sabre\DAV\ServerPlugin; class MaintenancePlugin extends ServerPlugin { - /** @var IConfig */ - private $config; - - /** @var \OCP\IL10N */ + /** @var IL10N */ private $l10n; /** @@ -51,8 +29,10 @@ class MaintenancePlugin extends ServerPlugin { /** * @param IConfig $config */ - public function __construct(IConfig $config, IL10N $l10n) { - $this->config = $config; + public function __construct( + private IConfig $config, + IL10N $l10n, + ) { $this->l10n = \OC::$server->getL10N('dav'); } @@ -82,10 +62,10 @@ class MaintenancePlugin extends ServerPlugin { */ public function checkMaintenanceMode() { if ($this->config->getSystemValueBool('maintenance')) { - throw new ServiceUnavailable($this->l10n->t('System is in maintenance mode.')); + throw new ServerMaintenanceMode($this->l10n->t('System is in maintenance mode.')); } if (Util::needUpgrade()) { - throw new ServiceUnavailable($this->l10n->t('Upgrade needed')); + 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 index 6700b1eb81b..e18ef58149a 100644 --- a/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php +++ b/apps/dav/lib/Connector/Sabre/MtimeSanitizer.php @@ -1,23 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2021, Louis Chemineau <louis@chmn.me> - * - * @author Louis Chemineau <louis@chmn.me> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; diff --git a/apps/dav/lib/Connector/Sabre/Node.php b/apps/dav/lib/Connector/Sabre/Node.php index e4517068f42..505e6b5eda4 100644 --- a/apps/dav/lib/Connector/Sabre/Node.php +++ b/apps/dav/lib/Connector/Sabre/Node.php @@ -1,37 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Calviño Sánchez <danxuliu@gmail.com> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Klaas Freitag <freitag@owncloud.com> - * @author Markus Goetz <markus@woboq.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -40,20 +12,19 @@ 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\Share\IShare; +use OCP\Server; use OCP\Share\Exceptions\ShareNotFound; use OCP\Share\IManager; abstract class Node implements \Sabre\DAV\INode { - - /** - * @var \OC\Files\View - */ - protected $fileView; - /** * The path to the current node * @@ -68,10 +39,7 @@ abstract class Node implements \Sabre\DAV\INode { */ protected $property_cache = null; - /** - * @var \OCP\Files\FileInfo - */ - protected $info; + protected FileInfo $info; /** * @var IManager @@ -82,39 +50,45 @@ abstract class Node implements \Sabre\DAV\INode { /** * Sets up the node, expects a full path name - * - * @param \OC\Files\View $view - * @param \OCP\Files\FileInfo $info - * @param IManager $shareManager */ - public function __construct(View $view, FileInfo $info, IManager $shareManager = null) { - $this->fileView = $view; + public function __construct( + protected View $fileView, + FileInfo $info, + ?IManager $shareManager = null, + ) { $this->path = $this->fileView->getRelativePath($info->getPath()); $this->info = $info; if ($shareManager) { $this->shareManager = $shareManager; } else { - $this->shareManager = \OC::$server->getShareManager(); + $this->shareManager = Server::get(\OCP\Share\IManager::class); } if ($info instanceof Folder || $info instanceof File) { $this->node = $info; } else { - $root = \OC::$server->get(IRootFolder::class); + // 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, $view, $this->path, $info); + $this->node = new Folder($root, $rootView, $this->fileView->getAbsolutePath($this->path), $info); } else { - $this->node = new File($root, $view, $this->path, $info); + $this->node = new File($root, $rootView, $this->fileView->getAbsolutePath($this->path), $info); } } } - protected function refreshInfo() { - $this->info = $this->fileView->getFileInfo($this->path); - $root = \OC::$server->get(IRootFolder::class); + 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, $this->fileView, $this->path, $this->info); + $this->node = new Folder($root, $rootView, $this->path, $this->info); } else { - $this->node = new File($root, $this->fileView, $this->path, $this->info); + $this->node = new File($root, $rootView, $this->path, $this->info); } } @@ -144,22 +118,21 @@ abstract class Node implements \Sabre\DAV\INode { * @throws \Sabre\DAV\Exception\Forbidden */ public function setName($name) { - - // rename is only allowed if the update privilege is granted - if (!($this->info->isUpdateable() || ($this->info->getMountPoint() instanceof MoveableMount && $this->info->getInternalPath() === ''))) { + // rename is only allowed if the delete privilege is granted + // (basically rename is a copy with delete of the original node) + if (!($this->info->isDeletable() || ($this->info->getMountPoint() instanceof MoveableMount && $this->info->getInternalPath() === ''))) { throw new \Sabre\DAV\Exception\Forbidden(); } [$parentPath,] = \Sabre\Uri\split($this->path); [, $newName] = \Sabre\Uri\split($name); + $newPath = $parentPath . '/' . $newName; // verify path of the target - $this->verifyPath(); - - $newPath = $parentPath . '/' . $newName; + $this->verifyPath($newPath); if (!$this->fileView->rename($this->path, $newPath)) { - throw new \Sabre\DAV\Exception('Failed to rename '. $this->path . ' to ' . $newPath); + throw new \Sabre\DAV\Exception('Failed to rename ' . $this->path . ' to ' . $newPath); } $this->path = $newPath; @@ -232,9 +205,10 @@ abstract class Node implements \Sabre\DAV\INode { /** * Returns the size of the node, in bytes * - * @return integer + * @psalm-suppress ImplementedReturnTypeMismatch \Sabre\DAV\IFile::getSize signature does not support 32bit + * @return int|float */ - public function getSize() { + public function getSize(): int|float { return $this->info->getSize(); } @@ -251,10 +225,8 @@ abstract class Node implements \Sabre\DAV\INode { * @return string|null */ public function getFileId() { - if ($this->info->getId()) { - $instanceId = \OC_Util::getInstanceId(); - $id = sprintf('%08d', $this->info->getId()); - return $id . $instanceId; + if ($id = $this->info->getId()) { + return DavUtil::getDavFileId($id); } return null; @@ -267,12 +239,15 @@ abstract class Node implements \Sabre\DAV\INode { 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 { @@ -289,8 +264,8 @@ abstract class Node implements \Sabre\DAV\INode { $storage = null; } - if ($storage && $storage->instanceOfStorage('\OCA\Files_Sharing\SharedStorage')) { - /** @var \OCA\Files_Sharing\SharedStorage $storage */ + if ($storage && $storage->instanceOfStorage(ISharedStorage::class)) { + /** @var ISharedStorage $storage */ $permissions = (int)$storage->getShare()->getPermissions(); } else { $permissions = $this->info->getPermissions(); @@ -303,98 +278,88 @@ abstract class Node implements \Sabre\DAV\INode { $mountpoint = $this->info->getMountPoint(); if (!($mountpoint instanceof MoveableMount)) { $mountpointpath = $mountpoint->getMountPoint(); - if (substr($mountpointpath, -1) === '/') { + if (str_ends_with($mountpointpath, '/')) { $mountpointpath = substr($mountpointpath, 0, -1); } if (!$mountpoint->getOption('readonly', false) && $mountpointpath === $this->info->getPath()) { - $permissions |= \OCP\Constants::PERMISSION_DELETE | \OCP\Constants::PERMISSION_UPDATE; + $permissions |= Constants::PERMISSION_DELETE | Constants::PERMISSION_UPDATE; } } /* * Files can't have create or delete permissions */ - if ($this->info->getType() === \OCP\Files\FileInfo::TYPE_FILE) { - $permissions &= ~(\OCP\Constants::PERMISSION_CREATE | \OCP\Constants::PERMISSION_DELETE); + if ($this->info->getType() === FileInfo::TYPE_FILE) { + $permissions &= ~(Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE); } return $permissions; } /** - * @param string $user - * @return string + * @return array */ - public function getNoteFromShare($user) { - if ($user === null) { - return ''; + public function getShareAttributes(): array { + try { + $storage = $this->node->getStorage(); + } catch (NotFoundException $e) { + return []; } - $types = [ - IShare::TYPE_USER, - IShare::TYPE_GROUP, - IShare::TYPE_CIRCLE, - IShare::TYPE_ROOM - ]; - - foreach ($types as $shareType) { - $shares = $this->shareManager->getSharedWith($user, $shareType, $this, -1); - foreach ($shares as $share) { - $note = $share->getNote(); - if ($share->getShareOwner() !== $user && !empty($note)) { - return $note; - } + $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 ''; + return null; } /** * @return string */ public function getDavPermissions() { - $p = ''; - if ($this->info->isShared()) { - $p .= 'S'; - } - if ($this->info->isShareable()) { - $p .= 'R'; - } - if ($this->info->isMounted()) { - $p .= 'M'; - } - if ($this->info->isReadable()) { - $p .= 'G'; - } - if ($this->info->isDeletable()) { - $p .= 'D'; - } - if ($this->info->isUpdateable()) { - $p .= 'NV'; // Renameable, Moveable - } - if ($this->info->getType() === \OCP\Files\FileInfo::TYPE_FILE) { - if ($this->info->isUpdateable()) { - $p .= 'W'; - } - } else { - if ($this->info->isCreatable()) { - $p .= 'CK'; - } - } - return $p; + return DavUtil::getDavPermissions($this->info); } public function getOwner() { return $this->info->getOwner(); } - protected function verifyPath() { + protected function verifyPath(?string $path = null): void { try { - $fileName = basename($this->info->getPath()); - $this->fileView->verifyPath($this->path, $fileName); - } catch (\OCP\Files\InvalidPathException $ex) { + $path = $path ?? $this->info->getPath(); + $this->fileView->verifyPath( + dirname($path), + basename($path), + ); + } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage()); } } @@ -428,7 +393,7 @@ abstract class Node implements \Sabre\DAV\INode { return $this->node; } - protected function sanitizeMtime($mtimeFromRequest) { + protected function sanitizeMtime(string $mtimeFromRequest): int { return MtimeSanitizer::sanitizeMtime($mtimeFromRequest); } } diff --git a/apps/dav/lib/Connector/Sabre/ObjectTree.php b/apps/dav/lib/Connector/Sabre/ObjectTree.php index c129371e376..bfbdfb33db0 100644 --- a/apps/dav/lib/Connector/Sabre/ObjectTree.php +++ b/apps/dav/lib/Connector/Sabre/ObjectTree.php @@ -1,39 +1,22 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\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; @@ -41,12 +24,12 @@ use OCP\Lock\LockedException; class ObjectTree extends CachingTree { /** - * @var \OC\Files\View + * @var View */ protected $fileView; /** - * @var \OCP\Files\Mount\IMountManager + * @var IMountManager */ protected $mountManager; @@ -58,45 +41,16 @@ class ObjectTree extends CachingTree { /** * @param \Sabre\DAV\INode $rootNode - * @param \OC\Files\View $view - * @param \OCP\Files\Mount\IMountManager $mountManager + * @param View $view + * @param IMountManager $mountManager */ - public function init(\Sabre\DAV\INode $rootNode, \OC\Files\View $view, \OCP\Files\Mount\IMountManager $mountManager) { + public function init(\Sabre\DAV\INode $rootNode, View $view, IMountManager $mountManager) { $this->rootNode = $rootNode; $this->fileView = $view; $this->mountManager = $mountManager; } /** - * If the given path is a chunked file name, converts it - * to the real file name. Only applies if the OC-CHUNKED header - * is present. - * - * @param string $path chunk file path to convert - * - * @return string path to real file - */ - private function resolveChunkFile($path) { - if (isset($_SERVER['HTTP_OC_CHUNKED'])) { - // resolve to real file name to find the proper node - [$dir, $name] = \Sabre\Uri\split($path); - if ($dir === '/' || $dir === '.') { - $dir = ''; - } - - $info = \OC_FileChunking::decodeName($name); - // only replace path if it was really the chunked file - if (isset($info['transferid'])) { - // getNodePath is called for multiple nodes within a chunk - // upload call - $path = $dir . '/' . $info['name']; - $path = ltrim($path, '/'); - } - } - return $path; - } - - /** * Returns the INode object for the requested path * * @param string $path @@ -120,7 +74,7 @@ class ObjectTree extends CachingTree { if ($path) { try { $this->fileView->verifyPath($path, basename($path)); - } catch (\OCP\Files\InvalidPathException $ex) { + } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage()); } } @@ -138,7 +92,7 @@ class ObjectTree extends CachingTree { $internalPath = $mount->getInternalPath($absPath); if ($storage && $storage->file_exists($internalPath)) { /** - * @var \OC\Files\Storage\Storage $storage + * @var Storage $storage */ // get data directly $data = $storage->getMetaData($internalPath); @@ -147,9 +101,6 @@ class ObjectTree extends CachingTree { $info = null; } } else { - // resolve chunk file name to real name, if applicable - $path = $this->resolveChunkFile($path); - // read from cache try { $info = $this->fileView->getFileInfo($path); @@ -173,9 +124,9 @@ class ObjectTree extends CachingTree { } if ($info->getType() === 'dir') { - $node = new \OCA\DAV\Connector\Sabre\Directory($this->fileView, $info, $this); + $node = new Directory($this->fileView, $info, $this); } else { - $node = new \OCA\DAV\Connector\Sabre\File($this->fileView, $info); + $node = new File($this->fileView, $info); } $this->cache[$path] = $node; @@ -222,7 +173,7 @@ class ObjectTree extends CachingTree { [$destinationDir, $destinationName] = \Sabre\Uri\split($destinationPath); try { $this->fileView->verifyPath($destinationDir, $destinationName); - } catch (\OCP\Files\InvalidPathException $ex) { + } catch (InvalidPathException $ex) { throw new InvalidPath($ex->getMessage()); } diff --git a/apps/dav/lib/Connector/Sabre/Principal.php b/apps/dav/lib/Connector/Sabre/Principal.php index c3f06f95783..d6ea9fd887d 100644 --- a/apps/dav/lib/Connector/Sabre/Principal.php +++ b/apps/dav/lib/Connector/Sabre/Principal.php @@ -1,46 +1,21 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2018, Georg Ehrke - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Christoph Seitz <christoph.seitz@posteo.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Maxence Lange <maxence@artificial-owl.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 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; @@ -58,21 +33,6 @@ use Sabre\DAVACL\PrincipalBackend\BackendInterface; class Principal implements BackendInterface { - /** @var IUserManager */ - private $userManager; - - /** @var IGroupManager */ - private $groupManager; - - /** @var IShareManager */ - private $shareManager; - - /** @var IUserSession */ - private $userSession; - - /** @var IAppManager */ - private $appManager; - /** @var string */ private $principalPrefix; @@ -82,38 +42,25 @@ class Principal implements BackendInterface { /** @var bool */ private $hasCircles; - /** @var ProxyMapper */ - private $proxyMapper; - /** @var KnownUserService */ private $knownUserService; - /** @var IConfig */ - private $config; - /** @var IFactory */ - private $languageFactory; - - public function __construct(IUserManager $userManager, - IGroupManager $groupManager, - IShareManager $shareManager, - IUserSession $userSession, - IAppManager $appManager, - ProxyMapper $proxyMapper, - KnownUserService $knownUserService, - IConfig $config, - IFactory $languageFactory, - string $principalPrefix = 'principals/users/') { - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->shareManager = $shareManager; - $this->userSession = $userSession; - $this->appManager = $appManager; + 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->proxyMapper = $proxyMapper; $this->knownUserService = $knownUserService; - $this->config = $config; - $this->languageFactory = $languageFactory; } use PrincipalProxyTrait { @@ -200,6 +147,16 @@ class Principal implements BackendInterface { '{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; } @@ -229,6 +186,9 @@ class Principal implements BackendInterface { if ($this->hasGroups || $needGroups) { $userGroups = $this->groupManager->getUserGroups($user); foreach ($userGroups as $userGroup) { + if ($userGroup->hideFromCollaboration()) { + continue; + } $groups[] = 'principals/groups/' . urlencode($userGroup->getGID()); } } @@ -247,6 +207,7 @@ class Principal implements BackendInterface { * @return int */ public function updatePrincipal($path, PropPatch $propPatch) { + // Updating schedule-default-calendar-URL is handled in CustomPropertiesBackend return 0; } @@ -270,6 +231,8 @@ class Principal implements BackendInterface { $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 @@ -298,7 +261,7 @@ class Principal implements BackendInterface { switch ($prop) { case '{http://sabredav.org/ns}email-address': if (!$allowEnumeration) { - if ($allowEnumerationFullMatch) { + if ($allowEnumerationFullMatch && $matchEmail) { $users = $this->userManager->getByEmail($value); } else { $users = []; @@ -349,8 +312,9 @@ class Principal implements BackendInterface { if ($allowEnumerationFullMatch) { $lowerSearch = strtolower($value); $users = $this->userManager->searchDisplayName($value, $searchLimit); - $users = \array_filter($users, static function (IUser $user) use ($lowerSearch) { - return strtolower($user->getDisplayName()) === $lowerSearch; + $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 = []; @@ -471,7 +435,7 @@ class Principal implements BackendInterface { $restrictGroups = $this->groupManager->getUserGroupIds($user); } - if (strpos($uri, 'mailto:') === 0) { + if (str_starts_with($uri, 'mailto:')) { if ($principalPrefix === 'principals/users') { $users = $this->userManager->getByEmail(substr($uri, 7)); if (count($users) !== 1) { @@ -489,7 +453,7 @@ class Principal implements BackendInterface { return $this->principalPrefix . '/' . $user->getUID(); } } - if (substr($uri, 0, 10) === 'principal:') { + if (str_starts_with($uri, 'principal:')) { $principal = substr($uri, 10); $principal = $this->getPrincipalByPath($principal); if ($principal !== null) { @@ -503,6 +467,7 @@ class Principal implements BackendInterface { /** * @param IUser $user * @return array + * @throws PropertyDoesNotExistException */ protected function userToPrincipal($user) { $userId = $user->getUID(); @@ -514,11 +479,18 @@ class Principal implements BackendInterface { '{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; } @@ -536,7 +508,7 @@ class Principal implements BackendInterface { } try { - $circle = \OCA\Circles\Api\v1\Circles::detailsCircle($circleUniqueId, true); + $circle = Circles::detailsCircle($circleUniqueId, true); } catch (QueryException $ex) { return null; } catch (CircleNotFoundException $ex) { @@ -561,7 +533,7 @@ class Principal implements BackendInterface { * @param string $principal * @return array * @throws Exception - * @throws \OCP\AppFramework\QueryException + * @throws QueryException * @suppress PhanUndeclaredClassMethod */ public function getCircleMembership($principal):array { @@ -576,10 +548,10 @@ class Principal implements BackendInterface { throw new Exception('Principal not found'); } - $circles = \OCA\Circles\Api\v1\Circles::joinedCircles($name, true); + $circles = Circles::joinedCircles($name, true); $circles = array_map(function ($circle) { - /** @var \OCA\Circles\Model\Circle $circle */ + /** @var Circle $circle */ return 'principals/circles/' . urlencode($circle->getSingleId()); }, $circles); @@ -588,4 +560,44 @@ class Principal implements BackendInterface { 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..130d4562146 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/PropFindMonitorPlugin.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types = 1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use Sabre\DAV\Server as SabreServer; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +/** + * This plugin runs after requests and logs an error if a plugin is detected + * to be doing too many SQL requests. + */ +class PropFindMonitorPlugin extends ServerPlugin { + + /** + * A Plugin can scan up to this amount of nodes without an error being + * reported. + */ + public const THRESHOLD_NODES = 50; + + /** + * A plugin can use up to this amount of queries per node. + */ + public const THRESHOLD_QUERY_FACTOR = 1; + + private SabreServer $server; + + public function initialize(SabreServer $server): void { + $this->server = $server; + $this->server->on('afterResponse', [$this, 'afterResponse']); + } + + public function afterResponse( + RequestInterface $request, + ResponseInterface $response): void { + if (!$this->server instanceof Server) { + return; + } + + $pluginQueries = $this->server->getPluginQueries(); + if (empty($pluginQueries)) { + return; + } + $maxDepth = max(0, ...array_keys($pluginQueries)); + // entries at the top are usually not interesting + unset($pluginQueries[$maxDepth]); + + $logger = $this->server->getLogger(); + foreach ($pluginQueries as $depth => $propFinds) { + foreach ($propFinds as $pluginName => $propFind) { + [ + 'queries' => $queries, + 'nodes' => $nodes + ] = $propFind; + if ($queries === 0 || $nodes > $queries || $nodes < self::THRESHOLD_NODES + || $queries < $nodes * self::THRESHOLD_QUERY_FACTOR) { + continue; + } + $logger->error( + '{name} scanned {scans} nodes with {count} queries in depth {depth}/{maxDepth}. This is bad for performance, please report to the plugin developer!', [ + 'name' => $pluginName, + 'scans' => $nodes, + 'count' => $queries, + 'depth' => $depth, + 'maxDepth' => $maxDepth, + ] + ); + } + } + } +} diff --git a/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php b/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php index d0251b2755f..15daf1f34b6 100644 --- a/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php +++ b/apps/dav/lib/Connector/Sabre/PropfindCompressionPlugin.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; @@ -61,7 +44,7 @@ class PropfindCompressionPlugin extends ServerPlugin { return $response; } - if (strpos($header, 'gzip') !== false) { + if (str_contains($header, 'gzip')) { $body = $response->getBody(); if (is_string($body)) { $response->setHeader('Content-Encoding', 'gzip'); 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 index f2b652e3320..bbb378edc9b 100644 --- a/apps/dav/lib/Connector/Sabre/QuotaPlugin.php +++ b/apps/dav/lib/Connector/Sabre/QuotaPlugin.php @@ -1,40 +1,22 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (C) 2012 entreCables S.L. All rights reserved. - * @copyright Copyright (C) 2012 entreCables S.L. All rights reserved. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Felix Moeller <mail@felixmoeller.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author scambra <sergio@entrecables.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-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. @@ -44,10 +26,6 @@ use Sabre\DAV\INode; * @license http://code.google.com/p/sabredav/wiki/License Modified BSD License */ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { - - /** @var \OC\Files\View */ - private $view; - /** * Reference to main server object * @@ -56,10 +34,11 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { private $server; /** - * @param \OC\Files\View $view + * @param View $view */ - public function __construct($view) { - $this->view = $view; + public function __construct( + private $view, + ) { } /** @@ -78,7 +57,9 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { $server->on('beforeWriteContent', [$this, 'beforeWriteContent'], 10); $server->on('beforeCreateFile', [$this, 'beforeCreateFile'], 10); + $server->on('method:MKCOL', [$this, 'onCreateCollection'], 30); $server->on('beforeMove', [$this, 'beforeMove'], 10); + $server->on('beforeCopy', [$this, 'beforeCopy'], 10); } /** @@ -90,6 +71,19 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { * @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; } @@ -98,6 +92,31 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { } /** + * Check quota before creating directory + * + * @param RequestInterface $request + * @param ResponseInterface $response + * @return bool + * @throws InsufficientStorage + * @throws \Sabre\DAV\Exception\Forbidden + */ + public function onCreateCollection(RequestInterface $request, ResponseInterface $response): bool { + try { + $destinationPath = $this->server->calculateUri($request->getUrl()); + $quotaPath = $this->getPathForDestination($destinationPath); + } catch (\Exception $e) { + return true; + } + if ($quotaPath) { + // MKCOL does not have a Content-Length header, so we can use + // a fixed value for the quota check. + return $this->checkQuota($quotaPath, 4096, true); + } + + return true; + } + + /** * Check quota before writing content * * @param string $uri target file URI @@ -114,40 +133,76 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { } /** - * Check if we're moving a Futurefile in which case we need to check + * Check if we're moving a FutureFile in which case we need to check * the quota on the target destination. - * - * @param string $source source path - * @param string $destination destination path */ - public function beforeMove($source, $destination) { - $sourceNode = $this->server->tree->getNodeForPath($source); + public function beforeMove(string $sourcePath, string $destinationPath): bool { + $sourceNode = $this->server->tree->getNodeForPath($sourcePath); if (!$sourceNode instanceof FutureFile) { - return; + return true; } - // get target node for proper path conversion - if ($this->server->tree->nodeExists($destination)) { - $destinationNode = $this->server->tree->getNodeForPath($destination); - $path = $destinationNode->getPath(); - } else { - $parentNode = $this->server->tree->getNodeForPath(dirname($destination)); - $path = $parentNode->getPath(); + 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 $length + * @param int|float|null $length * @throws InsufficientStorage * @return bool */ - public function checkQuota($path, $length = null) { + public function checkQuota(string $path, $length = null, $isDir = false) { if ($length === null) { $length = $this->getLength(); } @@ -158,29 +213,21 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { $parentPath = ''; } $req = $this->server->httpRequest; - if ($req->getHeader('OC-Chunked')) { - $info = \OC_FileChunking::decodeName($newName); - $chunkHandler = $this->getFileChunking($info); - // subtract the already uploaded size to see whether - // there is still enough space for the remaining chunks - $length -= $chunkHandler->getCurrentSize(); - // use target file name for free space check in case of shared files - $path = rtrim($parentPath, '/') . '/' . $info['name']; - } + + // Strip any duplicate slashes + $path = str_replace('//', '/', $path); + $freeSpace = $this->getFreeSpace($path); if ($freeSpace >= 0 && $length > $freeSpace) { - if (isset($chunkHandler)) { - $chunkHandler->cleanup(); + 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 getFileChunking($info) { - // FIXME: need a factory for better mocking support - return new \OC_FileChunking($info); + return true; } public function getLength() { @@ -192,11 +239,13 @@ class QuotaPlugin extends \Sabre\DAV\ServerPlugin { } $ocLength = $req->getHeader('OC-Total-Length'); - if (is_numeric($length) && is_numeric($ocLength)) { - return max($length, $ocLength); + if (!is_numeric($ocLength)) { + return $length; } - - return $length; + if (!is_numeric($length)) { + return $ocLength; + } + return max($length, $ocLength); } /** diff --git a/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php b/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php index b281a1053b8..5484bab9237 100644 --- a/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php +++ b/apps/dav/lib/Connector/Sabre/RequestIdHeaderPlugin.php @@ -1,22 +1,9 @@ -<?php declare(strict_types=1); +<?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; @@ -26,11 +13,9 @@ use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class RequestIdHeaderPlugin extends \Sabre\DAV\ServerPlugin { - /** @var IRequest */ - private $request; - - public function __construct(IRequest $request) { - $this->request = $request; + public function __construct( + private IRequest $request, + ) { } public function initialize(\Sabre\DAV\Server $server) { diff --git a/apps/dav/lib/Connector/Sabre/Server.php b/apps/dav/lib/Connector/Sabre/Server.php index 6cf6fa954c8..dda9c29b763 100644 --- a/apps/dav/lib/Connector/Sabre/Server.php +++ b/apps/dav/lib/Connector/Sabre/Server.php @@ -1,30 +1,20 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author scolebrook <scolebrook@mac.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\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 * @@ -36,6 +26,14 @@ class Server extends \Sabre\DAV\Server { /** @var CachingTree $tree */ /** + * Tracks queries done by plugins. + * @var array<int, array<string, array{nodes:int, queries:int}>> + */ + private array $pluginQueries = []; + + public bool $debugEnabled = false; + + /** * @see \Sabre\DAV\Server */ public function __construct($treeOrNode = null) { @@ -43,4 +41,190 @@ class Server extends \Sabre\DAV\Server { self::$exposeVersion = false; $this->enablePropfindDepthInfinity = true; } + + #[Override] + public function once( + string $eventName, + callable $callBack, + int $priority = 100, + ): void { + $this->debugEnabled ? $this->monitorPropfindQueries( + parent::once(...), + ...func_get_args(), + ) : parent::once(...func_get_args()); + } + + #[Override] + public function on( + string $eventName, + callable $callBack, + int $priority = 100, + ): void { + $this->debugEnabled ? $this->monitorPropfindQueries( + parent::on(...), + ...func_get_args(), + ) : parent::on(...func_get_args()); + } + + /** + * Wraps the handler $callBack into a query-monitoring function and calls + * $parentFn to register it. + */ + private function monitorPropfindQueries( + callable $parentFn, + string $eventName, + callable $callBack, + int $priority = 100, + ): void { + if ($eventName !== 'propFind') { + $parentFn($eventName, $callBack, $priority); + return; + } + + $pluginName = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['class'] ?? 'unknown'; + $callback = $this->getMonitoredCallback($callBack, $pluginName); + + $parentFn($eventName, $callback, $priority); + } + + /** + * Returns a callable that wraps $callBack with code that monitors and + * records queries per plugin. + */ + private function getMonitoredCallback( + callable $callBack, + string $pluginName, + ): callable { + return function (PropFind $propFind, INode $node) use ( + $callBack, + $pluginName, + ) { + $connection = \OCP\Server::get(Connection::class); + $queriesBefore = $connection->getStats()['executed']; + $result = $callBack($propFind, $node); + $queriesAfter = $connection->getStats()['executed']; + $this->trackPluginQueries( + $pluginName, + $queriesAfter - $queriesBefore, + $propFind->getDepth() + ); + + return $result; + }; + } + + /** + * Tracks the queries executed by a specific plugin. + */ + private function trackPluginQueries( + string $pluginName, + int $queriesExecuted, + int $depth, + ): void { + // report only nodes which cause queries to the DB + if ($queriesExecuted === 0) { + return; + } + + $this->pluginQueries[$depth][$pluginName]['nodes'] + = ($this->pluginQueries[$depth][$pluginName]['nodes'] ?? 0) + 1; + + $this->pluginQueries[$depth][$pluginName]['queries'] + = ($this->pluginQueries[$depth][$pluginName]['queries'] ?? 0) + $queriesExecuted; + } + + /** + * + * @return void + */ + public function start() { + try { + // If nginx (pre-1.2) is used as a proxy server, and SabreDAV as an + // origin, we must make sure we send back HTTP/1.0 if this was + // requested. + // This is mainly because nginx doesn't support Chunked Transfer + // Encoding, and this forces the webserver SabreDAV is running on, + // to buffer entire responses to calculate Content-Length. + $this->httpResponse->setHTTPVersion($this->httpRequest->getHTTPVersion()); + + // Setting the base url + $this->httpRequest->setBaseUrl($this->getBaseUri()); + $this->invokeMethod($this->httpRequest, $this->httpResponse); + } catch (\Throwable $e) { + try { + $this->emit('exception', [$e]); + } catch (\Exception) { + } + + if ($e instanceof TypeError) { + /* + * The TypeError includes the file path where the error occurred, + * potentially revealing the installation directory. + */ + $e = new TypeError('A type error occurred. For more details, please refer to the logs, which provide additional context about the type error.'); + } + + $DOM = new \DOMDocument('1.0', 'utf-8'); + $DOM->formatOutput = true; + + $error = $DOM->createElementNS('DAV:', 'd:error'); + $error->setAttribute('xmlns:s', self::NS_SABREDAV); + $DOM->appendChild($error); + + $h = function ($v) { + return htmlspecialchars((string)$v, ENT_NOQUOTES, 'UTF-8'); + }; + + if (self::$exposeVersion) { + $error->appendChild($DOM->createElement('s:sabredav-version', $h(Version::VERSION))); + } + + $error->appendChild($DOM->createElement('s:exception', $h(get_class($e)))); + $error->appendChild($DOM->createElement('s:message', $h($e->getMessage()))); + if ($this->debugExceptions) { + $error->appendChild($DOM->createElement('s:file', $h($e->getFile()))); + $error->appendChild($DOM->createElement('s:line', $h($e->getLine()))); + $error->appendChild($DOM->createElement('s:code', $h($e->getCode()))); + $error->appendChild($DOM->createElement('s:stacktrace', $h($e->getTraceAsString()))); + } + + if ($this->debugExceptions) { + $previous = $e; + while ($previous = $previous->getPrevious()) { + $xPrevious = $DOM->createElement('s:previous-exception'); + $xPrevious->appendChild($DOM->createElement('s:exception', $h(get_class($previous)))); + $xPrevious->appendChild($DOM->createElement('s:message', $h($previous->getMessage()))); + $xPrevious->appendChild($DOM->createElement('s:file', $h($previous->getFile()))); + $xPrevious->appendChild($DOM->createElement('s:line', $h($previous->getLine()))); + $xPrevious->appendChild($DOM->createElement('s:code', $h($previous->getCode()))); + $xPrevious->appendChild($DOM->createElement('s:stacktrace', $h($previous->getTraceAsString()))); + $error->appendChild($xPrevious); + } + } + + if ($e instanceof Exception) { + $httpCode = $e->getHTTPCode(); + $e->serialize($this, $error); + $headers = $e->getHTTPHeaders($this); + } else { + $httpCode = 500; + $headers = []; + } + $headers['Content-Type'] = 'application/xml; charset=utf-8'; + + $this->httpResponse->setStatus($httpCode); + $this->httpResponse->setHeaders($headers); + $this->httpResponse->setBody($DOM->saveXML()); + $this->sapi->sendResponse($this->httpResponse); + } + } + + /** + * Returns queries executed by registered plugins. + * + * @return array<int, array<string, array{nodes:int, queries:int}>> + */ + public function getPluginQueries(): array { + return $this->pluginQueries; + } } diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index b13dbd20ca9..a6a27057177 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -1,137 +1,111 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; -use OCP\Files\Folder; +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\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\ILogger; 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 Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Sabre\DAV\SimpleCollection; class ServerFactory { - /** @var IConfig */ - private $config; - /** @var ILogger */ - private $logger; - /** @var IDBConnection */ - private $databaseConnection; - /** @var IUserSession */ - private $userSession; - /** @var IMountManager */ - private $mountManager; - /** @var ITagManager */ - private $tagManager; - /** @var IRequest */ - private $request; - /** @var IPreview */ - private $previewManager; - /** @var EventDispatcherInterface */ - private $eventDispatcher; - /** @var IL10N */ - private $l10n; - /** - * @param IConfig $config - * @param ILogger $logger - * @param IDBConnection $databaseConnection - * @param IUserSession $userSession - * @param IMountManager $mountManager - * @param ITagManager $tagManager - * @param IRequest $request - * @param IPreview $previewManager - */ public function __construct( - IConfig $config, - ILogger $logger, - IDBConnection $databaseConnection, - IUserSession $userSession, - IMountManager $mountManager, - ITagManager $tagManager, - IRequest $request, - IPreview $previewManager, - EventDispatcherInterface $eventDispatcher, - IL10N $l10n + private IConfig $config, + private LoggerInterface $logger, + private IDBConnection $databaseConnection, + private IUserSession $userSession, + private IMountManager $mountManager, + private ITagManager $tagManager, + private IRequest $request, + private IPreview $previewManager, + private IEventDispatcher $eventDispatcher, + private IL10N $l10n, ) { - $this->config = $config; - $this->logger = $logger; - $this->databaseConnection = $databaseConnection; - $this->userSession = $userSession; - $this->mountManager = $mountManager; - $this->tagManager = $tagManager; - $this->request = $request; - $this->previewManager = $previewManager; - $this->eventDispatcher = $eventDispatcher; - $this->l10n = $l10n; } /** - * @param string $baseUri - * @param string $requestUri - * @param Plugin $authPlugin * @param callable $viewCallBack callback that should return the view for the dav endpoint - * @return Server */ - public function createServer($baseUri, - $requestUri, - Plugin $authPlugin, - callable $viewCallBack) { + public function createServer( + bool $isPublicShare, + string $baseUri, + string $requestUri, + Plugin $authPlugin, + callable $viewCallBack, + ): Server { + $debugEnabled = $this->config->getSystemValue('debug', false); // Fire up server - $objectTree = new \OCA\DAV\Connector\Sabre\ObjectTree(); - $server = new \OCA\DAV\Connector\Sabre\Server($objectTree); + if ($isPublicShare) { + $rootCollection = new SimpleCollection('root'); + $tree = new CachingTree($rootCollection); + } else { + $rootCollection = null; + $tree = new ObjectTree(); + } + $server = new Server($tree); // Set URL explicitly due to reverse-proxy situations $server->httpRequest->setUrl($requestUri); $server->setBaseUri($baseUri); // Load plugins - $server->addPlugin(new \OCA\DAV\Connector\Sabre\MaintenancePlugin($this->config, $this->l10n)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin($this->config)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin()); + $server->addPlugin(new MaintenancePlugin($this->config, $this->l10n)); + $server->addPlugin(new BlockLegacyClientPlugin( + $this->config, + \OCP\Server::get(ThemingDefaults::class), + )); + $server->addPlugin(new AnonymousOptionsPlugin()); $server->addPlugin($authPlugin); + if ($debugEnabled) { + $server->debugEnabled = $debugEnabled; + $server->addPlugin(new PropFindMonitorPlugin()); + } // FIXME: The following line is a workaround for legacy components relying on being able to send a GET to / - $server->addPlugin(new \OCA\DAV\Connector\Sabre\DummyGetResponsePlugin()); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin('webdav', $this->logger)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\LockPlugin()); + $server->addPlugin(new DummyGetResponsePlugin()); + $server->addPlugin(new ExceptionLoggerPlugin('webdav', $this->logger)); + $server->addPlugin(new LockPlugin()); - $server->addPlugin(new RequestIdHeaderPlugin(\OC::$server->get(IRequest::class))); + $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. @@ -140,7 +114,7 @@ class ServerFactory { '/OneNote/', '/Microsoft-WebDAV-MiniRedir/', ])) { - $server->addPlugin(new \OCA\DAV\Connector\Sabre\FakeLockerPlugin()); + $server->addPlugin(new FakeLockerPlugin()); } if (BrowserErrorPagePlugin::isBrowserRequest($this->request)) { @@ -148,11 +122,12 @@ class ServerFactory { } // wait with registering these until auth is handled and the filesystem is setup - $server->on('beforeMethod:*', function () use ($server, $objectTree, $viewCallBack) { + $server->on('beforeMethod:*', function () use ($server, $tree, + $viewCallBack, $isPublicShare, $rootCollection, $debugEnabled): void { // ensure the skeleton is copied $userFolder = \OC::$server->getUserFolder(); - /** @var \OC\Files\View $view */ + /** @var View $view */ $view = $viewCallBack($server); if ($userFolder instanceof Folder && $userFolder->getPath() === $view->getRoot()) { $rootInfo = $userFolder; @@ -162,65 +137,108 @@ class ServerFactory { // Create Nextcloud Dir if ($rootInfo->getType() === 'dir') { - $root = new \OCA\DAV\Connector\Sabre\Directory($view, $rootInfo, $objectTree); + $root = new Directory($view, $rootInfo, $tree); } else { - $root = new \OCA\DAV\Connector\Sabre\File($view, $rootInfo); + $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); } - $objectTree->init($root, $view, $this->mountManager); $server->addPlugin( - new \OCA\DAV\Connector\Sabre\FilesPlugin( - $objectTree, + new FilesPlugin( + $tree, $this->config, $this->request, $this->previewManager, $this->userSession, + \OCP\Server::get(IFilenameValidator::class), + \OCP\Server::get(IAccountManager::class), false, - !$this->config->getSystemValue('debug', false) + !$debugEnabled ) ); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\QuotaPlugin($view, true)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\ChecksumUpdatePlugin()); + $server->addPlugin(new QuotaPlugin($view)); + $server->addPlugin(new ChecksumUpdatePlugin()); + + // Allow view-only plugin for webdav requests + $server->addPlugin(new ViewOnlyPlugin( + $userFolder, + )); if ($this->userSession->isLoggedIn()) { - $server->addPlugin(new \OCA\DAV\Connector\Sabre\TagsPlugin($objectTree, $this->tagManager)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\SharesPlugin( - $objectTree, + $server->addPlugin(new TagsPlugin($tree, $this->tagManager, $this->eventDispatcher, $this->userSession)); + $server->addPlugin(new SharesPlugin( + $tree, $this->userSession, $userFolder, - \OC::$server->getShareManager() + \OCP\Server::get(\OCP\Share\IManager::class) )); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\CommentPropertiesPlugin(\OC::$server->getCommentsManager(), $this->userSession)); - $server->addPlugin(new \OCA\DAV\Connector\Sabre\FilesReportPlugin( - $objectTree, + $server->addPlugin(new CommentPropertiesPlugin(\OCP\Server::get(ICommentsManager::class), $this->userSession)); + $server->addPlugin(new FilesReportPlugin( + $tree, $view, - \OC::$server->getSystemTagManager(), - \OC::$server->getSystemTagObjectMapper(), - \OC::$server->getTagManager(), + \OCP\Server::get(ISystemTagManager::class), + \OCP\Server::get(ISystemTagObjectMapper::class), + \OCP\Server::get(ITagManager::class), $this->userSession, - \OC::$server->getGroupManager(), + \OCP\Server::get(IGroupManager::class), $userFolder, - \OC::$server->getAppManager() + \OCP\Server::get(IAppManager::class) )); // custom properties plugin must be the last one $server->addPlugin( new \Sabre\DAV\PropertyStorage\Plugin( - new \OCA\DAV\DAV\CustomPropertiesBackend( - $objectTree, + new CustomPropertiesBackend( + $server, + $tree, $this->databaseConnection, - $this->userSession->getUser() + $this->userSession->getUser(), + \OCP\Server::get(DefaultCalendarValidator::class), ) ) ); } - $server->addPlugin(new \OCA\DAV\Connector\Sabre\CopyEtagHeaderPlugin()); + $server->addPlugin(new CopyEtagHeaderPlugin()); // Load dav plugins from apps $event = new SabrePluginEvent($server); - $this->eventDispatcher->dispatch($event); + $this->eventDispatcher->dispatchTyped($event); $pluginManager = new PluginManager( \OC::$server, - \OC::$server->getAppManager() + \OCP\Server::get(IAppManager::class) ); foreach ($pluginManager->getAppPlugins() as $appPlugin) { $server->addPlugin($appPlugin); diff --git a/apps/dav/lib/Connector/Sabre/ShareTypeList.php b/apps/dav/lib/Connector/Sabre/ShareTypeList.php index 6fbae0dee4a..0b66ed27576 100644 --- a/apps/dav/lib/Connector/Sabre/ShareTypeList.php +++ b/apps/dav/lib/Connector/Sabre/ShareTypeList.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -35,17 +20,14 @@ class ShareTypeList implements Element { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * Share types - * - * @var int[] - */ - private $shareTypes; - - /** * @param int[] $shareTypes */ - public function __construct($shareTypes) { - $this->shareTypes = $shareTypes; + public function __construct( + /** + * Share types + */ + private $shareTypes, + ) { } /** @@ -79,7 +61,7 @@ class ShareTypeList implements Element { } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * @param Writer $writer * @return void diff --git a/apps/dav/lib/Connector/Sabre/ShareeList.php b/apps/dav/lib/Connector/Sabre/ShareeList.php index db8c011cc45..909c29fc24b 100644 --- a/apps/dav/lib/Connector/Sabre/ShareeList.php +++ b/apps/dav/lib/Connector/Sabre/ShareeList.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\Connector\Sabre; @@ -37,15 +18,14 @@ use Sabre\Xml\XmlSerializable; class ShareeList implements XmlSerializable { public const NS_NEXTCLOUD = 'http://nextcloud.org/ns'; - /** @var IShare[] */ - private $shares; - - public function __construct(array $shares) { - $this->shares = $shares; + public function __construct( + /** @var IShare[] */ + private array $shares, + ) { } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * @param Writer $writer * @return void diff --git a/apps/dav/lib/Connector/Sabre/SharesPlugin.php b/apps/dav/lib/Connector/Sabre/SharesPlugin.php index 57c91e05a8c..f49e85333f3 100644 --- a/apps/dav/lib/Connector/Sabre/SharesPlugin.php +++ b/apps/dav/lib/Connector/Sabre/SharesPlugin.php @@ -1,39 +1,23 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\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\PropFind; +use Sabre\DAV\Server; +use Sabre\DAV\Tree; /** * Sabre Plugin to provide share-related properties @@ -50,40 +34,19 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { * @var \Sabre\DAV\Server */ private $server; - - /** @var \OCP\Share\IManager */ - private $shareManager; - - /** @var \Sabre\DAV\Tree */ - private $tree; - - /** @var string */ - private $userId; - - /** @var \OCP\Files\Folder */ - private $userFolder; + private string $userId; /** @var IShare[][] */ - private $cachedShares = []; - + private array $cachedShares = []; /** @var string[] */ - private $cachedFolders = []; + private array $cachedFolders = []; - /** - * @param \Sabre\DAV\Tree $tree tree - * @param IUserSession $userSession user session - * @param \OCP\Files\Folder $userFolder user home folder - * @param \OCP\Share\IManager $shareManager share manager - */ public function __construct( - \Sabre\DAV\Tree $tree, - IUserSession $userSession, - \OCP\Files\Folder $userFolder, - \OCP\Share\IManager $shareManager + private Tree $tree, + private IUserSession $userSession, + private Folder $userFolder, + private IManager $shareManager, ) { - $this->tree = $tree; - $this->shareManager = $shareManager; - $this->userFolder = $userFolder; $this->userId = $userSession->getUser()->getUID(); } @@ -95,9 +58,9 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { * * This method should set up the required event subscriptions. * - * @param \Sabre\DAV\Server $server + * @return void */ - public function initialize(\Sabre\DAV\Server $server) { + 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; @@ -108,10 +71,10 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { } /** - * @param \OCP\Files\Node $node + * @param Node $node * @return IShare[] */ - private function getShare(\OCP\Files\Node $node): array { + private function getShare(Node $node): array { $result = []; $requestedShareTypes = [ IShare::TYPE_USER, @@ -122,19 +85,31 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { IShare::TYPE_ROOM, IShare::TYPE_CIRCLE, IShare::TYPE_DECK, + IShare::TYPE_SCIENCEMESH, ]; + foreach ($requestedShareTypes as $requestedShareType) { - $shares = $this->shareManager->getSharesBy( + $result = array_merge($result, $this->shareManager->getSharesBy( $this->userId, $requestedShareType, $node, false, -1 - ); - foreach ($shares as $share) { - $result[] = $share; + )); + + // Also check for shares where the user is the recipient + try { + $result = array_merge($result, $this->shareManager->getSharedWith( + $this->userId, + $requestedShareType, + $node, + -1 + )); + } catch (BackendError $e) { + // ignore } } + return $result; } @@ -156,27 +131,29 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { */ private function getShares(DavNode $sabreNode): array { if (isset($this->cachedShares[$sabreNode->getId()])) { - $shares = $this->cachedShares[$sabreNode->getId()]; - } else { - [$parentPath,] = \Sabre\Uri\split($sabreNode->getPath()); - if ($parentPath === '') { - $parentPath = '/'; - } - // if we already cached the folder this file is in we know there are no shares for this file - if (array_search($parentPath, $this->cachedFolders) === false) { - try { - $node = $sabreNode->getNode(); - } catch (NotFoundException $e) { - return []; - } - $shares = $this->getShare($node); - $this->cachedShares[$sabreNode->getId()] = $shares; - } else { + 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 (array_search($parentPath, $this->cachedFolders) === false) { + try { + $node = $sabreNode->getNode(); + } catch (NotFoundException $e) { return []; } + + $shares = $this->getShare($node); + $this->cachedShares[$sabreNode->getId()] = $shares; + return $shares; } - return $shares; + return []; } /** @@ -187,18 +164,20 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { */ public function handleGetProperties( PropFind $propFind, - \Sabre\DAV\INode $sabreNode + \Sabre\DAV\INode $sabreNode, ) { if (!($sabreNode instanceof DavNode)) { return; } - // need prefetch ? + // 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. if ($sabreNode instanceof Directory && $propFind->getDepth() !== 0 && ( - !is_null($propFind->getStatus(self::SHARETYPES_PROPERTYNAME)) || - !is_null($propFind->getStatus(self::SHAREES_PROPERTYNAME)) + !is_null($propFind->getStatus(self::SHARETYPES_PROPERTYNAME)) + || !is_null($propFind->getStatus(self::SHAREES_PROPERTYNAME)) ) ) { $folderNode = $sabreNode->getNode(); @@ -209,7 +188,7 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { } } - $propFind->handle(self::SHARETYPES_PROPERTYNAME, function () use ($sabreNode) { + $propFind->handle(self::SHARETYPES_PROPERTYNAME, function () use ($sabreNode): ShareTypeList { $shares = $this->getShares($sabreNode); $shareTypes = array_unique(array_map(function (IShare $share) { @@ -219,7 +198,7 @@ class SharesPlugin extends \Sabre\DAV\ServerPlugin { return new ShareTypeList($shareTypes); }); - $propFind->handle(self::SHAREES_PROPERTYNAME, function () use ($sabreNode) { + $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 index bbb938fb27d..9a5cd0d51cf 100644 --- a/apps/dav/lib/Connector/Sabre/TagList.php +++ b/apps/dav/lib/Connector/Sabre/TagList.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -36,17 +20,14 @@ class TagList implements Element { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; /** - * tags - * - * @var array - */ - private $tags; - - /** * @param array $tags */ - public function __construct(array $tags) { - $this->tags = $tags; + public function __construct( + /** + * tags + */ + private array $tags, + ) { } /** @@ -95,7 +76,7 @@ class TagList implements Element { } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * Use the $writer argument to write its own xml serialization. * diff --git a/apps/dav/lib/Connector/Sabre/TagsPlugin.php b/apps/dav/lib/Connector/Sabre/TagsPlugin.php index da5dd874905..25c1633df36 100644 --- a/apps/dav/lib/Connector/Sabre/TagsPlugin.php +++ b/apps/dav/lib/Connector/Sabre/TagsPlugin.php @@ -1,31 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright 2014 Vincent Petry <pvince81@owncloud.com> - * @copyright 2014 Vincent Petry <pvince81@owncloud.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sergio Bertolín <sbertolin@solidgear.es> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\Connector\Sabre; @@ -49,7 +27,10 @@ namespace OCA\DAV\Connector\Sabre; * License along with this library. If not, see <http://www.gnu.org/licenses/>. * */ - +use OCP\EventDispatcher\IEventDispatcher; +use OCP\ITagManager; +use OCP\ITags; +use OCP\IUserSession; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; @@ -69,12 +50,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { private $server; /** - * @var \OCP\ITagManager - */ - private $tagManager; - - /** - * @var \OCP\ITags + * @var ITags */ private $tagger; @@ -87,17 +63,15 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { private $cachedTags; /** - * @var \Sabre\DAV\Tree - */ - private $tree; - - /** * @param \Sabre\DAV\Tree $tree tree - * @param \OCP\ITagManager $tagManager tag manager + * @param ITagManager $tagManager tag manager */ - public function __construct(\Sabre\DAV\Tree $tree, \OCP\ITagManager $tagManager) { - $this->tree = $tree; - $this->tagManager = $tagManager; + public function __construct( + private \Sabre\DAV\Tree $tree, + private ITagManager $tagManager, + private IEventDispatcher $eventDispatcher, + private IUserSession $userSession, + ) { $this->tagger = null; $this->cachedTags = []; } @@ -120,12 +94,13 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { $this->server = $server; $this->server->on('propFind', [$this, 'handleGetProperties']); $this->server->on('propPatch', [$this, 'handleUpdateProperties']); + $this->server->on('preloadProperties', [$this, 'handlePreloadProperties']); } /** * Returns the tagger * - * @return \OCP\ITags tagger + * @return ITags tagger */ private function getTagger() { if (!$this->tagger) { @@ -139,7 +114,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { * * @param integer $fileId file id * @return array list($tags, $favorite) with $tags as tag array - * and $favorite is a boolean whether the file was favorited + * and $favorite is a boolean whether the file was favorited */ private function getTagsAndFav($fileId) { $isFav = false; @@ -176,6 +151,24 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { } /** + * Prefetches tags for a list of file IDs and caches the results + * + * @param array $fileIds List of file IDs to prefetch tags for + * @return void + */ + private function prefetchTagsForFileIds(array $fileIds) { + $tags = $this->getTagger()->getTagsForObjects($fileIds); + if ($tags === false) { + // the tags API returns false on error... + $tags = []; + } + + foreach ($fileIds as $fileId) { + $this->cachedTags[$fileId] = $tags[$fileId] ?? []; + } + } + + /** * Updates the tags of the given file id * * @param int $fileId @@ -211,36 +204,25 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { */ public function handleGetProperties( PropFind $propFind, - \Sabre\DAV\INode $node + \Sabre\DAV\INode $node, ) { - if (!($node instanceof \OCA\DAV\Connector\Sabre\Node)) { + if (!($node instanceof Node)) { return; } // need prefetch ? - if ($node instanceof \OCA\DAV\Connector\Sabre\Directory + if ($node instanceof Directory && $propFind->getDepth() !== 0 && (!is_null($propFind->getStatus(self::TAGS_PROPERTYNAME)) || !is_null($propFind->getStatus(self::FAVORITE_PROPERTYNAME)) - )) { + )) { // note: pre-fetching only supported for depth <= 1 $folderContent = $node->getChildren(); - $fileIds[] = (int)$node->getId(); + $fileIds = [(int)$node->getId()]; foreach ($folderContent as $info) { $fileIds[] = (int)$info->getId(); } - $tags = $this->getTagger()->getTagsForObjects($fileIds); - if ($tags === false) { - // the tags API returns false on error... - $tags = []; - } - - $this->cachedTags = $this->cachedTags + $tags; - $emptyFileIds = array_diff($fileIds, array_keys($tags)); - // also cache the ones that were not found - foreach ($emptyFileIds as $fileId) { - $this->cachedTags[$fileId] = []; - } + $this->prefetchTagsForFileIds($fileIds); } $isFav = null; @@ -272,7 +254,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { */ public function handleUpdateProperties($path, PropPatch $propPatch) { $node = $this->tree->getNodeForPath($path); - if (!($node instanceof \OCA\DAV\Connector\Sabre\Node)) { + if (!($node instanceof Node)) { return; } @@ -281,7 +263,7 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { return true; }); - $propPatch->handle(self::FAVORITE_PROPERTYNAME, function ($favState) use ($node) { + $propPatch->handle(self::FAVORITE_PROPERTYNAME, function ($favState) use ($node, $path) { if ((int)$favState === 1 || $favState === 'true') { $this->getTagger()->tagAs($node->getId(), self::TAG_FAVORITE); } else { @@ -296,4 +278,14 @@ class TagsPlugin extends \Sabre\DAV\ServerPlugin { return 200; }); } + + public function handlePreloadProperties(array $nodes, array $requestProperties): void { + if ( + !in_array(self::FAVORITE_PROPERTYNAME, $requestProperties, true) + && !in_array(self::TAGS_PROPERTYNAME, $requestProperties, true) + ) { + return; + } + $this->prefetchTagsForFileIds(array_map(fn ($node) => $node->getId(), $nodes)); + } } diff --git a/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php new file mode 100644 index 00000000000..f198519b454 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ZipFolderPlugin.php @@ -0,0 +1,193 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\Connector\Sabre; + +use OC\Streamer; +use OCA\DAV\Connector\Sabre\Exception\Forbidden; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Events\BeforeZipCreatedEvent; +use OCP\Files\File as NcFile; +use OCP\Files\Folder as NcFolder; +use OCP\Files\Node as NcNode; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\DAV\Tree; +use Sabre\HTTP\Request; +use Sabre\HTTP\Response; + +/** + * This plugin allows to download folders accessed by GET HTTP requests on DAV. + * The WebDAV standard explicitly say that GET is not covered and should return what ever the application thinks would be a good representation. + * + * When a collection is accessed using GET, this will provide the content as a archive. + * The type can be set by the `Accept` header (MIME type of zip or tar), or as browser fallback using a `accept` GET parameter. + * It is also possible to only include some child nodes (from the collection it self) by providing a `filter` GET parameter or `X-NC-Files` custom header. + */ +class ZipFolderPlugin extends ServerPlugin { + + /** + * Reference to main server object + */ + private ?Server $server = null; + + public function __construct( + private Tree $tree, + private LoggerInterface $logger, + private IEventDispatcher $eventDispatcher, + ) { + } + + /** + * This initializes the plugin. + * + * This function is called by \Sabre\DAV\Server, after + * addPlugin is called. + * + * This method should set up the required event subscriptions. + */ + public function initialize(Server $server): void { + $this->server = $server; + $this->server->on('method:GET', $this->handleDownload(...), 100); + // low priority to give any other afterMethod:* a chance to fire before we cancel everything + $this->server->on('afterMethod:GET', $this->afterDownload(...), 999); + } + + /** + * Adding a node to the archive streamer. + * This will recursively add new nodes to the stream if the node is a directory. + */ + protected function streamNode(Streamer $streamer, NcNode $node, string $rootPath): void { + // Remove the root path from the filename to make it relative to the requested folder + $filename = str_replace($rootPath, '', $node->getPath()); + + $mtime = $node->getMTime(); + if ($node instanceof NcFile) { + $resource = $node->fopen('rb'); + if ($resource === false) { + $this->logger->info('Cannot read file for zip stream', ['filePath' => $node->getPath()]); + throw new \Sabre\DAV\Exception\ServiceUnavailable('Requested file can currently not be accessed.'); + } + $streamer->addFileFromStream($resource, $filename, $node->getSize(), $mtime); + } elseif ($node instanceof NcFolder) { + $streamer->addEmptyDir($filename, $mtime); + $content = $node->getDirectoryListing(); + foreach ($content as $subNode) { + $this->streamNode($streamer, $subNode, $rootPath); + } + } + } + + /** + * Download a folder as an archive. + * It is possible to filter / limit the files that should be downloaded, + * either by passing (multiple) `X-NC-Files: the-file` headers + * or by setting a `files=JSON_ARRAY_OF_FILES` URL query. + * + * @return false|null + */ + public function handleDownload(Request $request, Response $response): ?bool { + $node = $this->tree->getNodeForPath($request->getPath()); + if (!($node instanceof Directory)) { + // only handle directories + return null; + } + + $query = $request->getQueryParameters(); + + // Get accept header - or if set overwrite with accept GET-param + $accept = $request->getHeaderAsArray('Accept'); + $acceptParam = $query['accept'] ?? ''; + if ($acceptParam !== '') { + $accept = array_map(fn (string $name) => strtolower(trim($name)), explode(',', $acceptParam)); + } + $zipRequest = !empty(array_intersect(['application/zip', 'zip'], $accept)); + $tarRequest = !empty(array_intersect(['application/x-tar', 'tar'], $accept)); + if (!$zipRequest && !$tarRequest) { + // does not accept zip or tar stream + return null; + } + + $files = $request->getHeaderAsArray('X-NC-Files'); + $filesParam = $query['files'] ?? ''; + // The preferred way would be headers, but this is not possible for simple browser requests ("links") + // so we also need to support GET parameters + if ($filesParam !== '') { + $files = json_decode($filesParam); + if (!is_array($files)) { + $files = [$files]; + } + + foreach ($files as $file) { + if (!is_string($file)) { + // we log this as this means either we - or an app - have a bug somewhere or a user is trying invalid things + $this->logger->notice('Invalid files filter parameter for ZipFolderPlugin', ['filter' => $filesParam]); + // no valid parameter so continue with Sabre behavior + return null; + } + } + } + + $folder = $node->getNode(); + $event = new BeforeZipCreatedEvent($folder, $files); + $this->eventDispatcher->dispatchTyped($event); + if ((!$event->isSuccessful()) || $event->getErrorMessage() !== null) { + $errorMessage = $event->getErrorMessage(); + if ($errorMessage === null) { + // Not allowed to download but also no explaining error + // so we abort the ZIP creation and fall back to Sabre default behavior. + return null; + } + // Downloading was denied by an app + throw new Forbidden($errorMessage); + } + + $content = empty($files) ? $folder->getDirectoryListing() : []; + foreach ($files as $path) { + $child = $node->getChild($path); + assert($child instanceof Node); + $content[] = $child->getNode(); + } + + $archiveName = 'download'; + $rootPath = $folder->getPath(); + if (empty($files)) { + // We download the full folder so keep it in the tree + $rootPath = dirname($folder->getPath()); + // Full folder is loaded to rename the archive to the folder name + $archiveName = $folder->getName(); + } + $streamer = new Streamer($tarRequest, -1, count($content)); + $streamer->sendHeaders($archiveName); + // For full folder downloads we also add the folder itself to the archive + if (empty($files)) { + $streamer->addEmptyDir($archiveName); + } + foreach ($content as $node) { + $this->streamNode($streamer, $node, $rootPath); + } + $streamer->finalize(); + return false; + } + + /** + * Tell sabre/dav not to trigger it's own response sending logic as the handleDownload will have already send the response + * + * @return false|null + */ + public function afterDownload(Request $request, Response $response): ?bool { + $node = $this->tree->getNodeForPath($request->getPath()); + if (!($node instanceof Directory)) { + // only handle directories + return null; + } else { + return false; + } + } +} |