diff options
Diffstat (limited to 'apps/dav/lib/DAV')
-rw-r--r-- | apps/dav/lib/DAV/CustomPropertiesBackend.php | 487 | ||||
-rw-r--r-- | apps/dav/lib/DAV/GroupPrincipalBackend.php | 70 | ||||
-rw-r--r-- | apps/dav/lib/DAV/PublicAuth.php | 29 | ||||
-rw-r--r-- | apps/dav/lib/DAV/Sharing/Backend.php | 323 | ||||
-rw-r--r-- | apps/dav/lib/DAV/Sharing/IShareable.php | 44 | ||||
-rw-r--r-- | apps/dav/lib/DAV/Sharing/Plugin.php | 70 | ||||
-rw-r--r-- | apps/dav/lib/DAV/Sharing/SharingMapper.php | 137 | ||||
-rw-r--r-- | apps/dav/lib/DAV/Sharing/SharingService.php | 53 | ||||
-rw-r--r-- | apps/dav/lib/DAV/Sharing/Xml/Invite.php | 59 | ||||
-rw-r--r-- | apps/dav/lib/DAV/Sharing/Xml/ShareRequest.php | 40 | ||||
-rw-r--r-- | apps/dav/lib/DAV/SystemPrincipalBackend.php | 27 | ||||
-rw-r--r-- | apps/dav/lib/DAV/ViewOnlyPlugin.php | 114 |
12 files changed, 919 insertions, 534 deletions
diff --git a/apps/dav/lib/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php index acee65cd00d..f9a4f8ee986 100644 --- a/apps/dav/lib/DAV/CustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php @@ -1,36 +1,33 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2017, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ + namespace OCA\DAV\DAV; -use OCA\DAV\Connector\Sabre\Node; +use Exception; +use OCA\DAV\CalDAV\Calendar; +use OCA\DAV\CalDAV\CalendarObject; +use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\Connector\Sabre\Directory; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\IUser; +use Sabre\DAV\Exception as DavException; use Sabre\DAV\PropertyStorage\Backend\BackendInterface; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; +use Sabre\DAV\Server; use Sabre\DAV\Tree; +use Sabre\DAV\Xml\Property\Complex; +use Sabre\DAV\Xml\Property\Href; +use Sabre\DAV\Xml\Property\LocalHref; +use Sabre\Xml\ParseException; +use Sabre\Xml\Service as XmlService; + use function array_intersect; class CustomPropertiesBackend implements BackendInterface { @@ -39,6 +36,26 @@ class CustomPropertiesBackend implements BackendInterface { private const TABLE_NAME = 'properties'; /** + * Value is stored as string. + */ + public const PROPERTY_TYPE_STRING = 1; + + /** + * Value is stored as XML fragment. + */ + public const PROPERTY_TYPE_XML = 2; + + /** + * Value is stored as a property object. + */ + public const PROPERTY_TYPE_OBJECT = 3; + + /** + * Value is stored as a {DAV:}href string. + */ + public const PROPERTY_TYPE_HREF = 4; + + /** * Ignored properties * * @var string[] @@ -49,58 +66,35 @@ class CustomPropertiesBackend implements BackendInterface { '{DAV:}getetag', '{DAV:}quota-used-bytes', '{DAV:}quota-available-bytes', - '{http://owncloud.org/ns}permissions', - '{http://owncloud.org/ns}downloadURL', - '{http://owncloud.org/ns}dDC', - '{http://owncloud.org/ns}size', - '{http://nextcloud.org/ns}is-encrypted', - - // Currently, returning null from any propfind handler would still trigger the backend, - // so we add all known Nextcloud custom properties in here to avoid that - - // text app - '{http://nextcloud.org/ns}rich-workspace', - '{http://nextcloud.org/ns}rich-workspace-file', - // groupfolders - '{http://nextcloud.org/ns}acl-enabled', - '{http://nextcloud.org/ns}acl-can-manage', - '{http://nextcloud.org/ns}acl-list', - '{http://nextcloud.org/ns}inherited-acl-list', - '{http://nextcloud.org/ns}group-folder-id', - // files_lock - '{http://nextcloud.org/ns}lock', - '{http://nextcloud.org/ns}lock-owner-type', - '{http://nextcloud.org/ns}lock-owner', - '{http://nextcloud.org/ns}lock-owner-displayname', - '{http://nextcloud.org/ns}lock-owner-editor', - '{http://nextcloud.org/ns}lock-time', - '{http://nextcloud.org/ns}lock-timeout', - '{http://nextcloud.org/ns}lock-token', ]; /** - * Properties set by one user, readable by all others + * Allowed properties for the oc/nc namespace, all other properties in the namespace are ignored * - * @var array[] + * @var string[] */ - private const PUBLISHED_READ_ONLY_PROPERTIES = [ - '{urn:ietf:params:xml:ns:caldav}calendar-availability', + private const ALLOWED_NC_PROPERTIES = [ + '{http://owncloud.org/ns}calendar-enabled', + '{http://owncloud.org/ns}enabled', ]; /** - * @var Tree - */ - private $tree; - - /** - * @var IDBConnection + * Properties set by one user, readable by all others + * + * @var string[] */ - private $connection; + private const PUBLISHED_READ_ONLY_PROPERTIES = [ + '{urn:ietf:params:xml:ns:caldav}calendar-availability', + '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL', + ]; /** - * @var IUser + * Map of custom XML elements to parse when trying to deserialize an instance of + * \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_* */ - private $user; + private const COMPLEX_XML_ELEMENT_MAP = [ + '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class, + ]; /** * Properties cache @@ -108,6 +102,7 @@ class CustomPropertiesBackend implements BackendInterface { * @var array */ private $userCache = []; + private XmlService $xmlService; /** * @param Tree $tree node tree @@ -115,12 +110,17 @@ class CustomPropertiesBackend implements BackendInterface { * @param IUser $user owner of the tree and properties */ public function __construct( - Tree $tree, - IDBConnection $connection, - IUser $user) { - $this->tree = $tree; - $this->connection = $connection; - $this->user = $user; + private Server $server, + private Tree $tree, + private IDBConnection $connection, + private IUser $user, + private DefaultCalendarValidator $defaultCalendarValidator, + ) { + $this->xmlService = new XmlService(); + $this->xmlService->elementMap = array_merge( + $this->xmlService->elementMap, + self::COMPLEX_XML_ELEMENT_MAP, + ); } /** @@ -133,15 +133,14 @@ class CustomPropertiesBackend implements BackendInterface { public function propFind($path, PropFind $propFind) { $requestedProps = $propFind->get404Properties(); - // these might appear - $requestedProps = array_diff( + $requestedProps = array_filter( $requestedProps, - self::IGNORED_PROPERTIES + $this->isPropertyAllowed(...), ); // substr of calendars/ => path is inside the CalDAV component // two '/' => this a calendar (no calendar-home nor calendar object) - if (substr($path, 0, 10) === 'calendars/' && substr_count($path, '/') === 2) { + if (str_starts_with($path, 'calendars/') && substr_count($path, '/') === 2) { $allRequestedProps = $propFind->getRequestedProperties(); $customPropertiesForShares = [ '{DAV:}displayname', @@ -159,20 +158,80 @@ class CustomPropertiesBackend implements BackendInterface { } } + // substr of addressbooks/ => path is inside the CardDAV component + // three '/' => this a addressbook (no addressbook-home nor contact object) + if (str_starts_with($path, 'addressbooks/') && substr_count($path, '/') === 3) { + $allRequestedProps = $propFind->getRequestedProperties(); + $customPropertiesForShares = [ + '{DAV:}displayname', + ]; + + foreach ($customPropertiesForShares as $customPropertyForShares) { + if (in_array($customPropertyForShares, $allRequestedProps, true)) { + $requestedProps[] = $customPropertyForShares; + } + } + } + + // substr of principals/users/ => path is a user principal + // two '/' => this a principal collection (and not some child object) + if (str_starts_with($path, 'principals/users/') && substr_count($path, '/') === 2) { + $allRequestedProps = $propFind->getRequestedProperties(); + $customProperties = [ + '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL', + ]; + + foreach ($customProperties as $customProperty) { + if (in_array($customProperty, $allRequestedProps, true)) { + $requestedProps[] = $customProperty; + } + } + } + if (empty($requestedProps)) { return; } + $node = $this->tree->getNodeForPath($path); + if ($node instanceof Directory && $propFind->getDepth() !== 0) { + $this->cacheDirectory($path, $node); + } + + if ($node instanceof CalendarObject) { + // No custom properties supported on individual events + return; + } + // First fetch the published properties (set by another user), then get the ones set by // the current user. If both are set then the latter as priority. foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) { + try { + $this->validateProperty($path, $propName, $propValue); + } catch (DavException $e) { + continue; + } $propFind->set($propName, $propValue); } foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) { + try { + $this->validateProperty($path, $propName, $propValue); + } catch (DavException $e) { + continue; + } $propFind->set($propName, $propValue); } } + private function isPropertyAllowed(string $property): bool { + if (in_array($property, self::IGNORED_PROPERTIES)) { + return false; + } + if (str_starts_with($property, '{http://owncloud.org/ns}') || str_starts_with($property, '{http://nextcloud.org/ns}')) { + return in_array($property, self::ALLOWED_NC_PROPERTIES); + } + return true; + } + /** * Updates properties for a path * @@ -212,14 +271,39 @@ class CustomPropertiesBackend implements BackendInterface { */ public function move($source, $destination) { $statement = $this->connection->prepare( - 'UPDATE `*PREFIX*properties` SET `propertypath` = ?' . - ' WHERE `userid` = ? AND `propertypath` = ?' + 'UPDATE `*PREFIX*properties` SET `propertypath` = ?' + . ' WHERE `userid` = ? AND `propertypath` = ?' ); $statement->execute([$this->formatPath($destination), $this->user->getUID(), $this->formatPath($source)]); $statement->closeCursor(); } /** + * Validate the value of a property. Will throw if a value is invalid. + * + * @throws DavException The value of the property is invalid + */ + private function validateProperty(string $path, string $propName, mixed $propValue): void { + switch ($propName) { + case '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL': + /** @var Href $propValue */ + $href = $propValue->getHref(); + if ($href === null) { + throw new DavException('Href is empty'); + } + + // $path is the principal here as this prop is only set on principals + $node = $this->tree->getNodeForPath($href); + if (!($node instanceof Calendar) || $node->getOwner() !== $path) { + throw new DavException('No such calendar'); + } + + $this->defaultCalendarValidator->validateScheduleDefaultCalendar($node); + break; + } + } + + /** * @param string $path * @param string[] $requestedProperties * @@ -239,13 +323,48 @@ class CustomPropertiesBackend implements BackendInterface { $result = $qb->executeQuery(); $props = []; while ($row = $result->fetch()) { - $props[$row['propertyname']] = $row['propertyvalue']; + $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']); } $result->closeCursor(); return $props; } /** + * prefetch all user properties in a directory + */ + private function cacheDirectory(string $path, Directory $node): void { + $prefix = ltrim($path . '/', '/'); + $query = $this->connection->getQueryBuilder(); + $query->select('name', 'p.propertypath', 'p.propertyname', 'p.propertyvalue', 'p.valuetype') + ->from('filecache', 'f') + ->hintShardKey('storage', $node->getNode()->getMountPoint()->getNumericStorageId()) + ->leftJoin('f', 'properties', 'p', $query->expr()->eq('p.propertypath', $query->func()->concat( + $query->createNamedParameter($prefix), + 'f.name' + )), + ) + ->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->orX( + $query->expr()->eq('p.userid', $query->createNamedParameter($this->user->getUID())), + $query->expr()->isNull('p.userid'), + )); + $result = $query->executeQuery(); + + $propsByPath = []; + + while ($row = $result->fetch()) { + $childPath = $prefix . $row['name']; + if (!isset($propsByPath[$childPath])) { + $propsByPath[$childPath] = []; + } + if (isset($row['propertyname'])) { + $propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']); + } + } + $this->userCache = array_merge($this->userCache, $propsByPath); + } + + /** * Returns a list of properties for the given path and current user * * @param string $path @@ -271,7 +390,7 @@ class CustomPropertiesBackend implements BackendInterface { // request only a subset $sql .= ' AND `propertyname` in (?)'; $whereValues[] = $requestedProperties; - $whereTypes[] = \Doctrine\DBAL\Connection::PARAM_STR_ARRAY; + $whereTypes[] = IQueryBuilder::PARAM_STR_ARRAY; } $result = $this->connection->executeQuery( @@ -282,7 +401,7 @@ class CustomPropertiesBackend implements BackendInterface { $props = []; while ($row = $result->fetch()) { - $props[$row['propertyname']] = $row['propertyvalue']; + $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']); } $result->closeCursor(); @@ -292,68 +411,58 @@ class CustomPropertiesBackend implements BackendInterface { } /** - * Update properties - * - * @param string $path path for which to update properties - * @param array $properties array of properties to update - * - * @return bool + * @throws Exception */ - private function updateProperties(string $path, array $properties) { - $deleteStatement = 'DELETE FROM `*PREFIX*properties`' . - ' WHERE `userid` = ? AND `propertypath` = ? AND `propertyname` = ?'; - - $insertStatement = 'INSERT INTO `*PREFIX*properties`' . - ' (`userid`,`propertypath`,`propertyname`,`propertyvalue`) VALUES(?,?,?,?)'; - - $updateStatement = 'UPDATE `*PREFIX*properties` SET `propertyvalue` = ?' . - ' WHERE `userid` = ? AND `propertypath` = ? AND `propertyname` = ?'; - + private function updateProperties(string $path, array $properties): bool { // TODO: use "insert or update" strategy ? $existing = $this->getUserProperties($path, []); - $this->connection->beginTransaction(); - foreach ($properties as $propertyName => $propertyValue) { - // If it was null, we need to delete the property - if (is_null($propertyValue)) { - if (array_key_exists($propertyName, $existing)) { - $this->connection->executeUpdate($deleteStatement, - [ - $this->user->getUID(), - $this->formatPath($path), - $propertyName, - ] - ); - } - } else { - if ($propertyValue instanceOf \Sabre\DAV\Xml\Property\Complex) { - $propertyValue = $propertyValue->getXml(); - } elseif (!is_string($propertyValue)) { - $propertyValue = (string)$propertyValue; - } - if (!array_key_exists($propertyName, $existing)) { - $this->connection->executeUpdate($insertStatement, - [ - $this->user->getUID(), - $this->formatPath($path), - $propertyName, - $propertyValue, - ] - ); + try { + $this->connection->beginTransaction(); + foreach ($properties as $propertyName => $propertyValue) { + // common parameters for all queries + $dbParameters = [ + 'userid' => $this->user->getUID(), + 'propertyPath' => $this->formatPath($path), + 'propertyName' => $propertyName, + ]; + + // If it was null, we need to delete the property + if (is_null($propertyValue)) { + if (array_key_exists($propertyName, $existing)) { + $deleteQuery = $deleteQuery ?? $this->createDeleteQuery(); + $deleteQuery + ->setParameters($dbParameters) + ->executeStatement(); + } } else { - $this->connection->executeUpdate($updateStatement, - [ - $propertyValue, - $this->user->getUID(), - $this->formatPath($path), - $propertyName, - ] + [$value, $valueType] = $this->encodeValueForDatabase( + $path, + $propertyName, + $propertyValue, ); + $dbParameters['propertyValue'] = $value; + $dbParameters['valueType'] = $valueType; + + if (!array_key_exists($propertyName, $existing)) { + $insertQuery = $insertQuery ?? $this->createInsertQuery(); + $insertQuery + ->setParameters($dbParameters) + ->executeStatement(); + } else { + $updateQuery = $updateQuery ?? $this->createUpdateQuery(); + $updateQuery + ->setParameters($dbParameters) + ->executeStatement(); + } } } - } - $this->connection->commit(); - unset($this->userCache[$path]); + $this->connection->commit(); + unset($this->userCache[$path]); + } catch (Exception $e) { + $this->connection->rollBack(); + throw $e; + } return true; } @@ -367,8 +476,122 @@ class CustomPropertiesBackend implements BackendInterface { private function formatPath(string $path): string { if (strlen($path) > 250) { return sha1($path); + } + + return $path; + } + + /** + * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails + * @throws DavException If the property value is invalid + */ + private function encodeValueForDatabase(string $path, string $name, mixed $value): array { + // Try to parse a more specialized property type first + if ($value instanceof Complex) { + $xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri()); + $value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value; + } + + if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') { + $value = $this->encodeDefaultCalendarUrl($value); + } + + try { + $this->validateProperty($path, $name, $value); + } catch (DavException $e) { + throw new DavException( + "Property \"$name\" has an invalid value: " . $e->getMessage(), + 0, + $e, + ); + } + + if (is_scalar($value)) { + $valueType = self::PROPERTY_TYPE_STRING; + } elseif ($value instanceof Complex) { + $valueType = self::PROPERTY_TYPE_XML; + $value = $value->getXml(); + } elseif ($value instanceof Href) { + $valueType = self::PROPERTY_TYPE_HREF; + $value = $value->getHref(); } else { - return $path; + $valueType = self::PROPERTY_TYPE_OBJECT; + // serialize produces null character + // these can not be properly stored in some databases and need to be replaced + $value = str_replace(chr(0), '\x00', serialize($value)); + } + return [$value, $valueType]; + } + + /** + * @return mixed|Complex|string + */ + private function decodeValueFromDatabase(string $value, int $valueType) { + switch ($valueType) { + case self::PROPERTY_TYPE_XML: + return new Complex($value); + case self::PROPERTY_TYPE_HREF: + return new Href($value); + case self::PROPERTY_TYPE_OBJECT: + // some databases can not handel null characters, these are custom encoded during serialization + // this custom encoding needs to be first reversed before unserializing + return unserialize(str_replace('\x00', chr(0), $value)); + case self::PROPERTY_TYPE_STRING: + default: + return $value; + } + } + + private function encodeDefaultCalendarUrl(Href $value): Href { + $href = $value->getHref(); + if ($href === null) { + return $value; + } + + if (!str_starts_with($href, '/')) { + return $value; + } + + try { + // Build path relative to the dav base URI to be used later to find the node + $value = new LocalHref($this->server->calculateUri($href) . '/'); + } catch (DavException\Forbidden) { + // Not existing calendars will be handled later when the value is validated } + + return $value; + } + + private function createDeleteQuery(): IQueryBuilder { + $deleteQuery = $this->connection->getQueryBuilder(); + $deleteQuery->delete('properties') + ->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid'))) + ->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath'))) + ->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName'))); + return $deleteQuery; + } + + private function createInsertQuery(): IQueryBuilder { + $insertQuery = $this->connection->getQueryBuilder(); + $insertQuery->insert('properties') + ->values([ + 'userid' => $insertQuery->createParameter('userid'), + 'propertypath' => $insertQuery->createParameter('propertyPath'), + 'propertyname' => $insertQuery->createParameter('propertyName'), + 'propertyvalue' => $insertQuery->createParameter('propertyValue'), + 'valuetype' => $insertQuery->createParameter('valueType'), + ]); + return $insertQuery; + } + + private function createUpdateQuery(): IQueryBuilder { + $updateQuery = $this->connection->getQueryBuilder(); + $updateQuery->update('properties') + ->set('propertyvalue', $updateQuery->createParameter('propertyValue')) + ->set('valuetype', $updateQuery->createParameter('valueType')) + ->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid'))) + ->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath'))) + ->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName'))); + return $updateQuery; } } diff --git a/apps/dav/lib/DAV/GroupPrincipalBackend.php b/apps/dav/lib/DAV/GroupPrincipalBackend.php index f1f15fd61a6..77ba45182c9 100644 --- a/apps/dav/lib/DAV/GroupPrincipalBackend.php +++ b/apps/dav/lib/DAV/GroupPrincipalBackend.php @@ -1,30 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2018, Georg Ehrke - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV; @@ -42,32 +21,17 @@ use Sabre\DAVACL\PrincipalBackend\BackendInterface; class GroupPrincipalBackend implements BackendInterface { public const PRINCIPAL_PREFIX = 'principals/groups'; - /** @var IGroupManager */ - private $groupManager; - - /** @var IUserSession */ - private $userSession; - - /** @var IShareManager */ - private $shareManager; - /** @var IConfig */ - private $config; - /** - * @param IGroupManager $IGroupManager + * @param IGroupManager $groupManager * @param IUserSession $userSession * @param IShareManager $shareManager */ public function __construct( - IGroupManager $IGroupManager, - IUserSession $userSession, - IShareManager $shareManager, - IConfig $config + private IGroupManager $groupManager, + private IUserSession $userSession, + private IShareManager $shareManager, + private IConfig $config, ) { - $this->groupManager = $IGroupManager; - $this->userSession = $userSession; - $this->shareManager = $shareManager; - $this->config = $config; } /** @@ -87,8 +51,10 @@ class GroupPrincipalBackend implements BackendInterface { $principals = []; if ($prefixPath === self::PRINCIPAL_PREFIX) { - foreach ($this->groupManager->search('') as $user) { - $principals[] = $this->groupToPrincipal($user); + foreach ($this->groupManager->search('') as $group) { + if (!$group->hideFromCollaboration()) { + $principals[] = $this->groupToPrincipal($group); + } } } @@ -104,7 +70,7 @@ class GroupPrincipalBackend implements BackendInterface { * @return array */ public function getPrincipalByPath($path) { - $elements = explode('/', $path, 3); + $elements = explode('/', $path, 3); if ($elements[0] !== 'principals') { return null; } @@ -114,7 +80,7 @@ class GroupPrincipalBackend implements BackendInterface { $name = urldecode($elements[2]); $group = $this->groupManager->get($name); - if (!is_null($group)) { + if ($group !== null && !$group->hideFromCollaboration()) { return $this->groupToPrincipal($group); } @@ -223,6 +189,10 @@ class GroupPrincipalBackend implements BackendInterface { $groups = $this->groupManager->search($value, $searchLimit); $results[] = array_reduce($groups, function (array $carry, IGroup $group) use ($restrictGroups) { + if ($group->hideFromCollaboration()) { + return $carry; + } + $gid = $group->getGID(); // is sharing restricted to groups only? if ($restrictGroups !== false) { @@ -288,7 +258,7 @@ class GroupPrincipalBackend implements BackendInterface { $restrictGroups = $this->groupManager->getUserGroupIds($user); } - if (strpos($uri, 'principal:principals/groups/') === 0) { + if (str_starts_with($uri, 'principal:principals/groups/')) { $name = urlencode(substr($uri, 28)); if ($restrictGroups !== false && !\in_array($name, $restrictGroups, true)) { return null; diff --git a/apps/dav/lib/DAV/PublicAuth.php b/apps/dav/lib/DAV/PublicAuth.php index 83874ab0d4d..c2b4ada173a 100644 --- a/apps/dav/lib/DAV/PublicAuth.php +++ b/apps/dav/lib/DAV/PublicAuth.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV; @@ -68,9 +53,9 @@ class PublicAuth implements BackendInterface { */ public function check(RequestInterface $request, ResponseInterface $response) { if ($this->isRequestPublic($request)) { - return [true, "principals/system/public"]; + return [true, 'principals/system/public']; } - return [false, "No public access to this resource."]; + return [false, 'No public access to this resource.']; } /** @@ -86,7 +71,7 @@ class PublicAuth implements BackendInterface { private function isRequestPublic(RequestInterface $request) { $url = $request->getPath(); $matchingUrls = array_filter($this->publicURLs, function ($publicUrl) use ($url) { - return strpos($url, $publicUrl, 0) === 0; + return str_starts_with($url, $publicUrl); }); return !empty($matchingUrls); } diff --git a/apps/dav/lib/DAV/Sharing/Backend.php b/apps/dav/lib/DAV/Sharing/Backend.php index 0f675ea4c15..d60f5cca7c6 100644 --- a/apps/dav/lib/DAV/Sharing/Backend.php +++ b/apps/dav/lib/DAV/Sharing/Backend.php @@ -1,178 +1,106 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV\Sharing; use OCA\DAV\Connector\Sabre\Principal; -use OCP\IDBConnection; +use OCP\AppFramework\Db\TTransactional; +use OCP\ICache; +use OCP\ICacheFactory; use OCP\IGroupManager; use OCP\IUserManager; +use Psr\Log\LoggerInterface; -class Backend { - - /** @var IDBConnection */ - private $db; - /** @var IUserManager */ - private $userManager; - /** @var IGroupManager */ - private $groupManager; - /** @var Principal */ - private $principalBackend; - /** @var string */ - private $resourceType; - +abstract class Backend { + use TTransactional; public const ACCESS_OWNER = 1; + public const ACCESS_READ_WRITE = 2; public const ACCESS_READ = 3; - - /** - * @param IDBConnection $db - * @param IUserManager $userManager - * @param IGroupManager $groupManager - * @param Principal $principalBackend - * @param string $resourceType - */ - public function __construct(IDBConnection $db, IUserManager $userManager, IGroupManager $groupManager, Principal $principalBackend, $resourceType) { - $this->db = $db; - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->principalBackend = $principalBackend; - $this->resourceType = $resourceType; + // 4 is already in use for public calendars + public const ACCESS_UNSHARED = 5; + + private ICache $shareCache; + + public function __construct( + private IUserManager $userManager, + private IGroupManager $groupManager, + private Principal $principalBackend, + private ICacheFactory $cacheFactory, + private SharingService $service, + private LoggerInterface $logger, + ) { + $this->shareCache = $this->cacheFactory->createInMemory(); } /** - * @param IShareable $shareable - * @param string[] $add - * @param string[] $remove + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove */ - public function updateShares(IShareable $shareable, array $add, array $remove) { + public function updateShares(IShareable $shareable, array $add, array $remove, array $oldShares = []): void { + $this->shareCache->clear(); foreach ($add as $element) { $principal = $this->principalBackend->findByUri($element['href'], ''); - if ($principal !== '') { - $this->shareWith($shareable, $element); + if (empty($principal)) { + continue; } - } - foreach ($remove as $element) { - $principal = $this->principalBackend->findByUri($element, ''); - if ($principal !== '') { - $this->unshare($shareable, $element); + + // We need to validate manually because some principals are only virtual + // i.e. Group principals + $principalparts = explode('/', $principal, 3); + if (count($principalparts) !== 3 || $principalparts[0] !== 'principals' || !in_array($principalparts[1], ['users', 'groups', 'circles'], true)) { + // Invalid principal + continue; } - } - } - /** - * @param IShareable $shareable - * @param string $element - */ - private function shareWith($shareable, $element) { - $user = $element['href']; - $parts = explode(':', $user, 2); - if ($parts[0] !== 'principal') { - return; - } + // Don't add share for owner + if ($shareable->getOwner() !== null && strcasecmp($shareable->getOwner(), $principal) === 0) { + continue; + } - // don't share with owner - if ($shareable->getOwner() === $parts[1]) { - return; - } + $principalparts[2] = urldecode($principalparts[2]); + if (($principalparts[1] === 'users' && !$this->userManager->userExists($principalparts[2])) + || ($principalparts[1] === 'groups' && !$this->groupManager->groupExists($principalparts[2]))) { + // User or group does not exist + continue; + } - $principal = explode('/', $parts[1], 3); - if (count($principal) !== 3 || $principal[0] !== 'principals' || !in_array($principal[1], ['users', 'groups', 'circles'], true)) { - // Invalid principal - return; - } + $access = Backend::ACCESS_READ; + if (isset($element['readOnly'])) { + $access = $element['readOnly'] ? Backend::ACCESS_READ : Backend::ACCESS_READ_WRITE; + } - $principal[2] = urldecode($principal[2]); - if (($principal[1] === 'users' && !$this->userManager->userExists($principal[2])) || - ($principal[1] === 'groups' && !$this->groupManager->groupExists($principal[2]))) { - // User or group does not exist - return; + $this->service->shareWith($shareable->getResourceId(), $principal, $access); } + foreach ($remove as $element) { + $principal = $this->principalBackend->findByUri($element, ''); + if (empty($principal)) { + continue; + } - // remove the share if it already exists - $this->unshare($shareable, $element['href']); - $access = self::ACCESS_READ; - if (isset($element['readOnly'])) { - $access = $element['readOnly'] ? self::ACCESS_READ : self::ACCESS_READ_WRITE; - } + // Don't add unshare for owner + if ($shareable->getOwner() !== null && strcasecmp($shareable->getOwner(), $principal) === 0) { + continue; + } - $query = $this->db->getQueryBuilder(); - $query->insert('dav_shares') - ->values([ - 'principaluri' => $query->createNamedParameter($parts[1]), - 'type' => $query->createNamedParameter($this->resourceType), - 'access' => $query->createNamedParameter($access), - 'resourceid' => $query->createNamedParameter($shareable->getResourceId()) - ]); - $query->execute(); + // Delete any possible direct shares (since the frontend does not separate between them) + $this->service->deleteShare($shareable->getResourceId(), $principal); + } } - /** - * @param $resourceId - */ - public function deleteAllShares($resourceId) { - $query = $this->db->getQueryBuilder(); - $query->delete('dav_shares') - ->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId))) - ->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType))) - ->execute(); + public function deleteAllShares(int $resourceId): void { + $this->shareCache->clear(); + $this->service->deleteAllShares($resourceId); } - public function deleteAllSharesByUser($principaluri) { - $query = $this->db->getQueryBuilder(); - $query->delete('dav_shares') - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principaluri))) - ->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType))) - ->execute(); - } - - /** - * @param IShareable $shareable - * @param string $element - */ - private function unshare($shareable, $element) { - $parts = explode(':', $element, 2); - if ($parts[0] !== 'principal') { - return; - } - - // don't share with owner - if ($shareable->getOwner() === $parts[1]) { - return; - } - - $query = $this->db->getQueryBuilder(); - $query->delete('dav_shares') - ->where($query->expr()->eq('resourceid', $query->createNamedParameter($shareable->getResourceId()))) - ->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType))) - ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($parts[1]))) - ; - $query->execute(); + public function deleteAllSharesByUser(string $principaluri): void { + $this->shareCache->clear(); + $this->service->deleteAllSharesByUser($principaluri); } /** @@ -183,45 +111,67 @@ class Backend { * * commonName - Optional, for example a first + last name * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. * * readOnly - boolean - * * summary - Optional, a description for the share * * @param int $resourceId - * @return array + * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> */ - public function getShares($resourceId) { - $query = $this->db->getQueryBuilder(); - $result = $query->select(['principaluri', 'access']) - ->from('dav_shares') - ->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId))) - ->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType))) - ->groupBy(['principaluri', 'access']) - ->execute(); + public function getShares(int $resourceId): array { + $cached = $this->shareCache->get((string)$resourceId); + if ($cached) { + return $cached; + } + $rows = $this->service->getShares($resourceId); $shares = []; - while ($row = $result->fetch()) { + foreach ($rows as $row) { $p = $this->principalBackend->getPrincipalByPath($row['principaluri']); $shares[] = [ - 'href' => "principal:${row['principaluri']}", - 'commonName' => isset($p['{DAV:}displayname']) ? $p['{DAV:}displayname'] : '', + 'href' => "principal:{$row['principaluri']}", + 'commonName' => isset($p['{DAV:}displayname']) ? (string)$p['{DAV:}displayname'] : '', 'status' => 1, - 'readOnly' => (int) $row['access'] === self::ACCESS_READ, - '{http://owncloud.org/ns}principal' => $row['principaluri'], - '{http://owncloud.org/ns}group-share' => is_null($p) + 'readOnly' => (int)$row['access'] === Backend::ACCESS_READ, + '{http://owncloud.org/ns}principal' => (string)$row['principaluri'], + '{http://owncloud.org/ns}group-share' => isset($p['uri']) && (str_starts_with($p['uri'], 'principals/groups') || str_starts_with($p['uri'], 'principals/circles')) ]; } - + $this->shareCache->set((string)$resourceId, $shares); return $shares; } + public function preloadShares(array $resourceIds): void { + $resourceIds = array_filter($resourceIds, function (int $resourceId) { + return empty($this->shareCache->get((string)$resourceId)); + }); + if (empty($resourceIds)) { + return; + } + + $rows = $this->service->getSharesForIds($resourceIds); + $sharesByResource = array_fill_keys($resourceIds, []); + foreach ($rows as $row) { + $resourceId = (int)$row['resourceid']; + $p = $this->principalBackend->getPrincipalByPath($row['principaluri']); + $sharesByResource[$resourceId][] = [ + 'href' => "principal:{$row['principaluri']}", + 'commonName' => isset($p['{DAV:}displayname']) ? (string)$p['{DAV:}displayname'] : '', + 'status' => 1, + 'readOnly' => (int)$row['access'] === self::ACCESS_READ, + '{http://owncloud.org/ns}principal' => (string)$row['principaluri'], + '{http://owncloud.org/ns}group-share' => isset($p['uri']) && str_starts_with($p['uri'], 'principals/groups') + ]; + $this->shareCache->set((string)$resourceId, $sharesByResource[$resourceId]); + } + } + /** * For shared resources the sharee is set in the ACL of the resource * * @param int $resourceId - * @param array $acl - * @return array + * @param list<array{privilege: string, principal: string, protected: bool}> $acl + * @param list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> $shares + * @return list<array{principal: string, privilege: string, protected: bool}> */ - public function applyShareAcl($resourceId, $acl) { - $shares = $this->getShares($resourceId); + public function applyShareAcl(array $shares, array $acl): array { foreach ($shares as $share) { $acl[] = [ 'privilege' => '{DAV:}read', @@ -234,7 +184,7 @@ class Backend { 'principal' => $share['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}principal'], 'protected' => true, ]; - } elseif ($this->resourceType === 'calendar') { + } elseif (in_array($this->service->getResourceType(), ['calendar','addressbook'])) { // Allow changing the properties of read only calendars, // so users can change the visibility. $acl[] = [ @@ -246,4 +196,45 @@ class Backend { } return $acl; } + + public function unshare(IShareable $shareable, string $principalUri): bool { + $this->shareCache->clear(); + + $principal = $this->principalBackend->findByUri($principalUri, ''); + if (empty($principal)) { + return false; + } + + if ($shareable->getOwner() === $principal) { + return false; + } + + // Delete any possible direct shares (since the frontend does not separate between them) + $this->service->deleteShare($shareable->getResourceId(), $principal); + + $needsUnshare = $this->hasAccessByGroupOrCirclesMembership( + $shareable->getResourceId(), + $principal + ); + + if ($needsUnshare) { + $this->service->unshare($shareable->getResourceId(), $principal); + } + + return true; + } + + private function hasAccessByGroupOrCirclesMembership(int $resourceId, string $principal) { + $memberships = array_merge( + $this->principalBackend->getGroupMembership($principal, true), + $this->principalBackend->getCircleMembership($principal) + ); + + $shares = array_column( + $this->service->getShares($resourceId), + 'principaluri' + ); + + return count(array_intersect($memberships, $shares)) > 0; + } } diff --git a/apps/dav/lib/DAV/Sharing/IShareable.php b/apps/dav/lib/DAV/Sharing/IShareable.php index 3833e026696..d83079f6975 100644 --- a/apps/dav/lib/DAV/Sharing/IShareable.php +++ b/apps/dav/lib/DAV/Sharing/IShareable.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV\Sharing; @@ -40,16 +24,14 @@ interface IShareable extends INode { * Every element in the add array has the following properties: * * href - A url. Usually a mailto: address * * commonName - Usually a first and last name, or false - * * summary - A description of the share, can also be false * * readOnly - A boolean value * * Every element in the remove array is just the address string. * - * @param array $add - * @param array $remove - * @return void + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove */ - public function updateShares(array $add, array $remove); + public function updateShares(array $add, array $remove): void; /** * Returns the list of people whom this resource is shared with. @@ -59,19 +41,15 @@ interface IShareable extends INode { * * commonName - Optional, for example a first + last name * * status - See the Sabre\CalDAV\SharingPlugin::STATUS_ constants. * * readOnly - boolean - * * summary - Optional, a description for the share * - * @return array + * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> */ - public function getShares(); + public function getShares(): array; - /** - * @return int - */ - public function getResourceId(); + public function getResourceId(): int; /** - * @return string + * @return ?string */ public function getOwner(); } diff --git a/apps/dav/lib/DAV/Sharing/Plugin.php b/apps/dav/lib/DAV/Sharing/Plugin.php index a4b2cd3681c..03e63813bab 100644 --- a/apps/dav/lib/DAV/Sharing/Plugin.php +++ b/apps/dav/lib/DAV/Sharing/Plugin.php @@ -1,32 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV\Sharing; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\CalendarHome; use OCA\DAV\Connector\Sabre\Auth; use OCA\DAV\DAV\Sharing\Xml\Invite; use OCA\DAV\DAV\Sharing\Xml\ShareRequest; +use OCP\AppFramework\Http; use OCP\IConfig; use OCP\IRequest; use Sabre\DAV\Exception\NotFound; @@ -41,26 +27,18 @@ class Plugin extends ServerPlugin { public const NS_OWNCLOUD = 'http://owncloud.org/ns'; public const NS_NEXTCLOUD = 'http://nextcloud.com/ns'; - /** @var Auth */ - private $auth; - - /** @var IRequest */ - private $request; - - /** @var IConfig */ - private $config; - /** * Plugin constructor. * - * @param Auth $authBackEnd + * @param Auth $auth * @param IRequest $request * @param IConfig $config */ - public function __construct(Auth $authBackEnd, IRequest $request, IConfig $config) { - $this->auth = $authBackEnd; - $this->request = $request; - $this->config = $config; + public function __construct( + private Auth $auth, + private IRequest $request, + private IConfig $config, + ) { } /** @@ -111,7 +89,7 @@ class Plugin extends ServerPlugin { $this->server->xml->elementMap['{' . Plugin::NS_OWNCLOUD . '}invite'] = Invite::class; $this->server->on('method:POST', [$this, 'httpPost']); - $this->server->on('propFind', [$this, 'propFind']); + $this->server->on('propFind', [$this, 'propFind']); } /** @@ -125,8 +103,8 @@ class Plugin extends ServerPlugin { $path = $request->getPath(); // Only handling xml - $contentType = $request->getHeader('Content-Type'); - if (strpos($contentType, 'application/xml') === false && strpos($contentType, 'text/xml') === false) { + $contentType = (string)$request->getHeader('Content-Type'); + if (!str_contains($contentType, 'application/xml') && !str_contains($contentType, 'text/xml')) { return; } @@ -180,7 +158,7 @@ class Plugin extends ServerPlugin { $node->updateShares($message->set, $message->remove); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); // Adding this because sending a response body may cause issues, // and I wanted some type of indicator the response was handled. $response->setHeader('X-Sabre-Status', 'everything-went-well'); @@ -201,6 +179,20 @@ class Plugin extends ServerPlugin { * @return void */ public function propFind(PropFind $propFind, INode $node) { + if ($node instanceof CalendarHome && $propFind->getDepth() === 1) { + $backend = $node->getCalDAVBackend(); + if ($backend instanceof CalDavBackend) { + $calendars = $node->getChildren(); + $calendars = array_filter($calendars, function (INode $node) { + return $node instanceof IShareable; + }); + /** @var int[] $resourceIds */ + $resourceIds = array_map(function (IShareable $node) { + return $node->getResourceId(); + }, $calendars); + $backend->preloadShares($resourceIds); + } + } if ($node instanceof IShareable) { $propFind->handle('{' . Plugin::NS_OWNCLOUD . '}invite', function () use ($node) { return new Invite( diff --git a/apps/dav/lib/DAV/Sharing/SharingMapper.php b/apps/dav/lib/DAV/Sharing/SharingMapper.php new file mode 100644 index 00000000000..e4722208189 --- /dev/null +++ b/apps/dav/lib/DAV/Sharing/SharingMapper.php @@ -0,0 +1,137 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\DAV\Sharing; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +class SharingMapper { + public function __construct( + private IDBConnection $db, + ) { + } + + protected function getSharesForIdByAccess(int $resourceId, string $resourceType, bool $sharesWithAccess): array { + $query = $this->db->getQueryBuilder(); + $query->select(['principaluri', 'access']) + ->from('dav_shares') + ->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR))) + ->groupBy(['principaluri', 'access']); + + if ($sharesWithAccess) { + $query->andWhere($query->expr()->neq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT))); + } else { + $query->andWhere($query->expr()->eq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT))); + } + + $result = $query->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + return $rows; + } + + public function getSharesForId(int $resourceId, string $resourceType): array { + return $this->getSharesForIdByAccess($resourceId, $resourceType, true); + } + + public function getUnsharesForId(int $resourceId, string $resourceType): array { + return $this->getSharesForIdByAccess($resourceId, $resourceType, false); + } + + public function getSharesForIds(array $resourceIds, string $resourceType): array { + $query = $this->db->getQueryBuilder(); + $result = $query->select(['resourceid', 'principaluri', 'access']) + ->from('dav_shares') + ->where($query->expr()->in('resourceid', $query->createNamedParameter($resourceIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->andWhere($query->expr()->neq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT))) + ->groupBy(['principaluri', 'access', 'resourceid']) + ->executeQuery(); + + $rows = $result->fetchAll(); + $result->closeCursor(); + return $rows; + } + + public function unshare(int $resourceId, string $resourceType, string $principal): void { + $query = $this->db->getQueryBuilder(); + $query->insert('dav_shares') + ->values([ + 'principaluri' => $query->createNamedParameter($principal), + 'type' => $query->createNamedParameter($resourceType), + 'access' => $query->createNamedParameter(Backend::ACCESS_UNSHARED), + 'resourceid' => $query->createNamedParameter($resourceId) + ]); + $query->executeStatement(); + } + + public function share(int $resourceId, string $resourceType, int $access, string $principal): void { + $query = $this->db->getQueryBuilder(); + $query->insert('dav_shares') + ->values([ + 'principaluri' => $query->createNamedParameter($principal), + 'type' => $query->createNamedParameter($resourceType), + 'access' => $query->createNamedParameter($access), + 'resourceid' => $query->createNamedParameter($resourceId) + ]); + $query->executeStatement(); + } + + public function deleteShare(int $resourceId, string $resourceType, string $principal): void { + $query = $this->db->getQueryBuilder(); + $query->delete('dav_shares'); + $query->where( + $query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT)), + $query->expr()->eq('type', $query->createNamedParameter($resourceType)), + $query->expr()->eq('principaluri', $query->createNamedParameter($principal)) + ); + $query->executeStatement(); + + } + + public function deleteAllShares(int $resourceId, string $resourceType): void { + $query = $this->db->getQueryBuilder(); + $query->delete('dav_shares') + ->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->executeStatement(); + } + + public function deleteAllSharesByUser(string $principaluri, string $resourceType): void { + $query = $this->db->getQueryBuilder(); + $query->delete('dav_shares') + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principaluri))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->executeStatement(); + } + + public function getSharesByPrincipals(array $principals, string $resourceType): array { + $query = $this->db->getQueryBuilder(); + $result = $query->select(['id', 'principaluri', 'type', 'access', 'resourceid']) + ->from('dav_shares') + ->where($query->expr()->in('principaluri', $query->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->orderBy('id') + ->executeQuery(); + + $rows = $result->fetchAll(); + $result->closeCursor(); + + return $rows; + } + + public function deleteUnsharesByPrincipal(string $principal, string $resourceType): void { + $query = $this->db->getQueryBuilder(); + $query->delete('dav_shares') + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principal))) + ->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType))) + ->andWhere($query->expr()->eq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } +} diff --git a/apps/dav/lib/DAV/Sharing/SharingService.php b/apps/dav/lib/DAV/Sharing/SharingService.php new file mode 100644 index 00000000000..11459e12d74 --- /dev/null +++ b/apps/dav/lib/DAV/Sharing/SharingService.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\DAV\Sharing; + +abstract class SharingService { + protected string $resourceType = ''; + public function __construct( + protected SharingMapper $mapper, + ) { + } + + public function getResourceType(): string { + return $this->resourceType; + } + public function shareWith(int $resourceId, string $principal, int $access): void { + // remove the share if it already exists + $this->mapper->deleteShare($resourceId, $this->getResourceType(), $principal); + $this->mapper->share($resourceId, $this->getResourceType(), $access, $principal); + } + + public function unshare(int $resourceId, string $principal): void { + $this->mapper->unshare($resourceId, $this->getResourceType(), $principal); + } + + public function deleteShare(int $resourceId, string $principal): void { + $this->mapper->deleteShare($resourceId, $this->getResourceType(), $principal); + } + + public function deleteAllShares(int $resourceId): void { + $this->mapper->deleteAllShares($resourceId, $this->getResourceType()); + } + + public function deleteAllSharesByUser(string $principaluri): void { + $this->mapper->deleteAllSharesByUser($principaluri, $this->getResourceType()); + } + + public function getShares(int $resourceId): array { + return $this->mapper->getSharesForId($resourceId, $this->getResourceType()); + } + + public function getUnshares(int $resourceId): array { + return $this->mapper->getUnsharesForId($resourceId, $this->getResourceType()); + } + + public function getSharesForIds(array $resourceIds): array { + return $this->mapper->getSharesForIds($resourceIds, $this->getResourceType()); + } +} diff --git a/apps/dav/lib/DAV/Sharing/Xml/Invite.php b/apps/dav/lib/DAV/Sharing/Xml/Invite.php index 161a8dd0ebf..7a20dbe6df7 100644 --- a/apps/dav/lib/DAV/Sharing/Xml/Invite.php +++ b/apps/dav/lib/DAV/Sharing/Xml/Invite.php @@ -1,28 +1,10 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (C) fruux GmbH (https://fruux.com/) - * @copyright Copyright (C) fruux GmbH (https://fruux.com/) - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-FileCopyrightText: fruux GmbH (https://fruux.com/) + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV\Sharing\Xml; @@ -45,21 +27,6 @@ use Sabre\Xml\XmlSerializable; class Invite implements XmlSerializable { /** - * The list of users a calendar has been shared to. - * - * @var array - */ - protected $users; - - /** - * The organizer contains information about the person who shared the - * object. - * - * @var array|null - */ - protected $organizer; - - /** * Creates the property. * * Users is an array. Each element of the array has the following @@ -85,9 +52,17 @@ class Invite implements XmlSerializable { * * @param array $users */ - public function __construct(array $users, array $organizer = null) { - $this->users = $users; - $this->organizer = $organizer; + public function __construct( + /** + * The list of users a calendar has been shared to. + */ + protected array $users, + /** + * The organizer contains information about the person who shared the + * object. + */ + protected ?array $organizer = null, + ) { } /** @@ -100,7 +75,7 @@ class Invite implements XmlSerializable { } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * Use the $writer argument to write its own xml serialization. * diff --git a/apps/dav/lib/DAV/Sharing/Xml/ShareRequest.php b/apps/dav/lib/DAV/Sharing/Xml/ShareRequest.php index eb5d7d4661d..aefb39c5701 100644 --- a/apps/dav/lib/DAV/Sharing/Xml/ShareRequest.php +++ b/apps/dav/lib/DAV/Sharing/Xml/ShareRequest.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV\Sharing\Xml; @@ -27,24 +12,21 @@ use Sabre\Xml\Reader; use Sabre\Xml\XmlDeserializable; class ShareRequest implements XmlDeserializable { - public $set = []; - - public $remove = []; - /** * Constructor * * @param array $set * @param array $remove */ - public function __construct(array $set, array $remove) { - $this->set = $set; - $this->remove = $remove; + public function __construct( + public array $set, + public array $remove, + ) { } public static function xmlDeserialize(Reader $reader) { $elements = $reader->parseInnerTree([ - '{' . Plugin::NS_OWNCLOUD. '}set' => 'Sabre\\Xml\\Element\\KeyValue', + '{' . Plugin::NS_OWNCLOUD . '}set' => 'Sabre\\Xml\\Element\\KeyValue', '{' . Plugin::NS_OWNCLOUD . '}remove' => 'Sabre\\Xml\\Element\\KeyValue', ]); @@ -62,8 +44,8 @@ class ShareRequest implements XmlDeserializable { $set[] = [ 'href' => $sharee['{DAV:}href'], - 'commonName' => isset($sharee[$commonName]) ? $sharee[$commonName] : null, - 'summary' => isset($sharee[$sumElem]) ? $sharee[$sumElem] : null, + 'commonName' => $sharee[$commonName] ?? null, + 'summary' => $sharee[$sumElem] ?? null, 'readOnly' => !array_key_exists('{' . Plugin::NS_OWNCLOUD . '}read-write', $sharee), ]; break; diff --git a/apps/dav/lib/DAV/SystemPrincipalBackend.php b/apps/dav/lib/DAV/SystemPrincipalBackend.php index e5b9a20037f..9760d68f05f 100644 --- a/apps/dav/lib/DAV/SystemPrincipalBackend.php +++ b/apps/dav/lib/DAV/SystemPrincipalBackend.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\DAV; @@ -60,7 +45,7 @@ class SystemPrincipalBackend extends AbstractBackend { } /** - * Returns a specific principal, specified by it's path. + * Returns a specific principal, specified by its path. * The returned structure should be the exact same as from * getPrincipalsByPrefix. * @@ -87,7 +72,7 @@ class SystemPrincipalBackend extends AbstractBackend { } /** - * Updates one ore more webdav properties on a principal. + * Updates one or more webdav properties on a principal. * * The list of mutations is stored in a Sabre\DAV\PropPatch object. * To do the actual updates, you must tell this object which properties diff --git a/apps/dav/lib/DAV/ViewOnlyPlugin.php b/apps/dav/lib/DAV/ViewOnlyPlugin.php new file mode 100644 index 00000000000..9b9615b8063 --- /dev/null +++ b/apps/dav/lib/DAV/ViewOnlyPlugin.php @@ -0,0 +1,114 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2019 ownCloud GmbH + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\DAV\DAV; + +use OCA\DAV\Connector\Sabre\Exception\Forbidden; +use OCA\DAV\Connector\Sabre\File as DavFile; +use OCA\Files_Versions\Sabre\VersionFile; +use OCP\Files\Folder; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\ISharedStorage; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; + +/** + * Sabre plugin for restricting file share receiver download: + */ +class ViewOnlyPlugin extends ServerPlugin { + private ?Server $server = null; + + public function __construct( + private ?Folder $userFolder, + ) { + } + + /** + * 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; + //priority 90 to make sure the plugin is called before + //Sabre\DAV\CorePlugin::httpGet + $this->server->on('method:GET', [$this, 'checkViewOnly'], 90); + $this->server->on('method:COPY', [$this, 'checkViewOnly'], 90); + $this->server->on('method:MOVE', [$this, 'checkViewOnly'], 90); + } + + /** + * Disallow download via DAV Api in case file being received share + * and having special permission + * + * @throws Forbidden + * @throws NotFoundException + */ + public function checkViewOnly(RequestInterface $request): bool { + $path = $request->getPath(); + + try { + assert($this->server !== null); + $davNode = $this->server->tree->getNodeForPath($path); + if ($davNode instanceof DavFile) { + // Restrict view-only to nodes which are shared + $node = $davNode->getNode(); + } elseif ($davNode instanceof VersionFile) { + $node = $davNode->getVersion()->getSourceFile(); + $currentUserId = $this->userFolder?->getOwner()?->getUID(); + // The version source file is relative to the owner storage. + // But we need the node from the current user perspective. + if ($node->getOwner()->getUID() !== $currentUserId) { + $nodes = $this->userFolder->getById($node->getId()); + $node = array_pop($nodes); + if (!$node) { + throw new NotFoundException('Version file not accessible by current user'); + } + } + } else { + return true; + } + + $storage = $node->getStorage(); + + if (!$storage->instanceOfStorage(ISharedStorage::class)) { + return true; + } + + // Extract extra permissions + /** @var ISharedStorage $storage */ + $share = $storage->getShare(); + $attributes = $share->getAttributes(); + if ($attributes === null) { + return true; + } + + // We have two options here, if download is disabled, but viewing is allowed, + // we still allow the GET request to return the file content. + $canDownload = $attributes->getAttribute('permissions', 'download'); + if (!$share->canSeeContent()) { + throw new Forbidden('Access to this shared resource has been denied because its download permission is disabled.'); + } + + // If download is disabled, we disable the COPY and MOVE methods even if the + // shareapi_allow_view_without_download is set to true. + if ($request->getMethod() !== 'GET' && ($canDownload !== null && !$canDownload)) { + throw new Forbidden('Access to this shared resource has been denied because its download permission is disabled.'); + } + } catch (NotFound $e) { + // File not found + } + + return true; + } +} |