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