diff options
author | Richard Steinmetz <richard@steinmetz.cloud> | 2024-02-21 22:29:09 +0100 |
---|---|---|
committer | Richard Steinmetz <richard@steinmetz.cloud> | 2024-02-28 14:51:10 +0100 |
commit | 53ef6c5f712bf910c2c976e1a1b6eb142349b5c2 (patch) | |
tree | 52887c3bf3971a86ad15bdf4d7284b476a6e4fe0 /apps | |
parent | 7cc20468f13cddb433a35a58e048ce8c4ce43f1d (diff) | |
download | nextcloud-server-53ef6c5f712bf910c2c976e1a1b6eb142349b5c2.tar.gz nextcloud-server-53ef6c5f712bf910c2c976e1a1b6eb142349b5c2.zip |
feat(dav): update a principal's schedule-default-calendar-URL
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
Diffstat (limited to 'apps')
-rw-r--r-- | apps/dav/lib/CalDAV/Schedule/Plugin.php | 7 | ||||
-rw-r--r-- | apps/dav/lib/Connector/Sabre/Principal.php | 1 | ||||
-rw-r--r-- | apps/dav/lib/Connector/Sabre/ServerFactory.php | 1 | ||||
-rw-r--r-- | apps/dav/lib/DAV/CustomPropertiesBackend.php | 139 | ||||
-rw-r--r-- | apps/dav/lib/Server.php | 1 | ||||
-rw-r--r-- | apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php | 1 | ||||
-rw-r--r-- | apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php | 200 |
7 files changed, 341 insertions, 9 deletions
diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php index 232ee607c94..2ccecc8131c 100644 --- a/apps/dav/lib/CalDAV/Schedule/Plugin.php +++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php @@ -95,6 +95,13 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { $server->on('propFind', [$this, 'propFindDefaultCalendarUrl'], 90); $server->on('afterWriteContent', [$this, 'dispatchSchedulingResponses']); $server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']); + + // We allow mutating the default calendar URL through the CustomPropertiesBackend + // (oc_properties table) + $server->protectedProperties = array_filter( + $server->protectedProperties, + static fn (string $property) => $property !== self::SCHEDULE_DEFAULT_CALENDAR_URL, + ); } /** diff --git a/apps/dav/lib/Connector/Sabre/Principal.php b/apps/dav/lib/Connector/Sabre/Principal.php index 9d9cfbe43cb..c6f9fe3affc 100644 --- a/apps/dav/lib/Connector/Sabre/Principal.php +++ b/apps/dav/lib/Connector/Sabre/Principal.php @@ -260,6 +260,7 @@ class Principal implements BackendInterface { * @return int */ public function updatePrincipal($path, PropPatch $propPatch) { + // Updating schedule-default-calendar-URL is handled in CustomPropertiesBackend return 0; } diff --git a/apps/dav/lib/Connector/Sabre/ServerFactory.php b/apps/dav/lib/Connector/Sabre/ServerFactory.php index 113cd8a8c23..758951c42ff 100644 --- a/apps/dav/lib/Connector/Sabre/ServerFactory.php +++ b/apps/dav/lib/Connector/Sabre/ServerFactory.php @@ -188,6 +188,7 @@ class ServerFactory { $server->addPlugin( new \Sabre\DAV\PropertyStorage\Plugin( new \OCA\DAV\DAV\CustomPropertiesBackend( + $server, $objectTree, $this->databaseConnection, $this->userSession->getUser() diff --git a/apps/dav/lib/DAV/CustomPropertiesBackend.php b/apps/dav/lib/DAV/CustomPropertiesBackend.php index e76a71aec63..48872048ea8 100644 --- a/apps/dav/lib/DAV/CustomPropertiesBackend.php +++ b/apps/dav/lib/DAV/CustomPropertiesBackend.php @@ -6,6 +6,7 @@ * @author Georg Ehrke <oc.list@georgehrke.com> * @author Robin Appelman <robin@icewind.nl> * @author Thomas Müller <thomas.mueller@tmit.eu> + * @author Richard Steinmetz <richard@steinmetz.cloud> * * @license AGPL-3.0 * @@ -31,11 +32,19 @@ use OCA\DAV\Connector\Sabre\FilesPlugin; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\IUser; +use Sabre\CalDAV\ICalendar; +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 { @@ -59,6 +68,11 @@ class CustomPropertiesBackend implements BackendInterface { public const PROPERTY_TYPE_OBJECT = 3; /** + * Value is stored as a {DAV:}href string. + */ + public const PROPERTY_TYPE_HREF = 4; + + /** * Ignored properties * * @var string[] @@ -105,6 +119,15 @@ class CustomPropertiesBackend implements BackendInterface { */ private const PUBLISHED_READ_ONLY_PROPERTIES = [ '{urn:ietf:params:xml:ns:caldav}calendar-availability', + '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL', + ]; + + /** + * 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 const COMPLEX_XML_ELEMENT_MAP = [ + '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class, ]; /** @@ -129,19 +152,29 @@ class CustomPropertiesBackend implements BackendInterface { */ private $userCache = []; + private Server $server; + private XmlService $xmlService; + /** * @param Tree $tree node tree * @param IDBConnection $connection database connection * @param IUser $user owner of the tree and properties */ public function __construct( + Server $server, Tree $tree, IDBConnection $connection, IUser $user, ) { + $this->server = $server; $this->tree = $tree; $this->connection = $connection; $this->user = $user; + $this->xmlService = new XmlService(); + $this->xmlService->elementMap = array_merge( + $this->xmlService->elementMap, + self::COMPLEX_XML_ELEMENT_MAP, + ); } /** @@ -199,6 +232,21 @@ class CustomPropertiesBackend implements BackendInterface { } } + // 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; } @@ -211,9 +259,19 @@ class CustomPropertiesBackend implements BackendInterface { // 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); } } @@ -265,6 +323,30 @@ class CustomPropertiesBackend implements BackendInterface { } /** + * 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 ICalendar) || $node->getOwner() !== $path) { + throw new DavException('No such calendar'); + } + + break; + } + } + + /** * @param string $path * @param string[] $requestedProperties * @@ -393,7 +475,11 @@ class CustomPropertiesBackend implements BackendInterface { ->executeStatement(); } } else { - [$value, $valueType] = $this->encodeValueForDatabase($propertyValue); + [$value, $valueType] = $this->encodeValueForDatabase( + $path, + $propertyName, + $propertyValue, + ); $dbParameters['propertyValue'] = $value; $dbParameters['valueType'] = $valueType; @@ -436,15 +522,38 @@ class CustomPropertiesBackend implements BackendInterface { } /** - * @param mixed $value - * @return array + * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails + * @throws DavException If the property value is invalid */ - private function encodeValueForDatabase($value): array { + 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 { $valueType = self::PROPERTY_TYPE_OBJECT; $value = serialize($value); @@ -459,6 +568,8 @@ class CustomPropertiesBackend implements BackendInterface { 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: return unserialize($value); case self::PROPERTY_TYPE_STRING: @@ -467,6 +578,26 @@ class CustomPropertiesBackend implements BackendInterface { } } + 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') diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 3c7e0936735..deee381d24c 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -276,6 +276,7 @@ class Server { $this->server->addPlugin( new \Sabre\DAV\PropertyStorage\Plugin( new CustomPropertiesBackend( + $this->server, $this->server->tree, \OC::$server->getDatabaseConnection(), \OC::$server->getUserSession()->getUser() diff --git a/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php b/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php index 395c4a6a779..636fd0d2d8d 100644 --- a/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/CustomPropertiesBackendTest.php @@ -87,6 +87,7 @@ class CustomPropertiesBackendTest extends \Test\TestCase { ->willReturn($userId); $this->plugin = new \OCA\DAV\DAV\CustomPropertiesBackend( + $this->server, $this->tree, \OC::$server->getDatabaseConnection(), $this->user diff --git a/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php b/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php index 2c8a55d3da1..b81d7f24ae4 100644 --- a/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php +++ b/apps/dav/tests/unit/DAV/CustomPropertiesBackendTest.php @@ -8,6 +8,7 @@ * @author Morris Jobke <hey@morrisjobke.de> * @author Robin Appelman <robin@icewind.nl> * @author Roeland Jago Douma <roeland@famdouma.nl> + * @author Richard Steinmetz <richard@steinmetz.cloud> * * @license GNU AGPL version 3 or any later version * @@ -28,17 +29,29 @@ namespace OCA\DAV\Tests\DAV; use OCA\DAV\DAV\CustomPropertiesBackend; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\IUser; +use Sabre\CalDAV\ICalendar; +use Sabre\DAV\Exception\NotFound; use Sabre\DAV\PropFind; use Sabre\DAV\PropPatch; +use Sabre\DAV\Server; use Sabre\DAV\Tree; +use Sabre\DAV\Xml\Property\Href; +use Sabre\DAVACL\IACL; +use Sabre\DAVACL\IPrincipal; use Test\TestCase; /** * @group DB */ class CustomPropertiesBackendTest extends TestCase { + private const BASE_URI = '/remote.php/dav/'; + + /** @var Server | \PHPUnit\Framework\MockObject\MockObject */ + private $server; + /** @var Tree | \PHPUnit\Framework\MockObject\MockObject */ private $tree; @@ -54,6 +67,9 @@ class CustomPropertiesBackendTest extends TestCase { protected function setUp(): void { parent::setUp(); + $this->server = $this->createMock(Server::class); + $this->server->method('getBaseUri') + ->willReturn(self::BASE_URI); $this->tree = $this->createMock(Tree::class); $this->user = $this->createMock(IUser::class); $this->user->method('getUID') @@ -62,9 +78,10 @@ class CustomPropertiesBackendTest extends TestCase { $this->dbConnection = \OC::$server->getDatabaseConnection(); $this->backend = new CustomPropertiesBackend( + $this->server, $this->tree, $this->dbConnection, - $this->user + $this->user, ); } @@ -90,7 +107,13 @@ class CustomPropertiesBackendTest extends TestCase { } } - protected function insertProp(string $user, string $path, string $name, string $value) { + protected function insertProp(string $user, string $path, string $name, mixed $value) { + $type = CustomPropertiesBackend::PROPERTY_TYPE_STRING; + if ($value instanceof Href) { + $value = $value->getHref(); + $type = CustomPropertiesBackend::PROPERTY_TYPE_HREF; + } + $query = $this->dbConnection->getQueryBuilder(); $query->insert('properties') ->values([ @@ -98,13 +121,14 @@ class CustomPropertiesBackendTest extends TestCase { 'propertypath' => $query->createNamedParameter($this->formatPath($path)), 'propertyname' => $query->createNamedParameter($name), 'propertyvalue' => $query->createNamedParameter($value), + 'valuetype' => $query->createNamedParameter($type, IQueryBuilder::PARAM_INT) ]); $query->execute(); } protected function getProps(string $user, string $path) { $query = $this->dbConnection->getQueryBuilder(); - $query->select('propertyname', 'propertyvalue') + $query->select('propertyname', 'propertyvalue', 'valuetype') ->from('properties') ->where($query->expr()->eq('userid', $query->createNamedParameter($user))) ->andWhere($query->expr()->eq('propertypath', $query->createNamedParameter($this->formatPath($path)))); @@ -112,7 +136,11 @@ class CustomPropertiesBackendTest extends TestCase { $result = $query->execute(); $data = []; while ($row = $result->fetch()) { - $data[$row['propertyname']] = $row['propertyvalue']; + $value = $row['propertyvalue']; + if ((int)$row['valuetype'] === CustomPropertiesBackend::PROPERTY_TYPE_HREF) { + $value = new Href($value); + } + $data[$row['propertyname']] = $value; } $result->closeCursor(); @@ -122,9 +150,10 @@ class CustomPropertiesBackendTest extends TestCase { public function testPropFindNoDbCalls(): void { $db = $this->createMock(IDBConnection::class); $backend = new CustomPropertiesBackend( + $this->server, $this->tree, $db, - $this->user + $this->user, ); $propFind = $this->createMock(PropFind::class); @@ -186,10 +215,169 @@ class CustomPropertiesBackendTest extends TestCase { $this->assertEquals($props, $setProps); } + public function testPropFindPrincipalCall(): void { + $this->tree->method('getNodeForPath') + ->willReturnCallback(function ($uri) { + $node = $this->createMock(ICalendar::class); + $node->method('getOwner') + ->willReturn('principals/users/dummy_user_42'); + return $node; + }); + + $propFind = $this->createMock(PropFind::class); + $propFind->method('get404Properties') + ->with() + ->willReturn([ + '{DAV:}getcontentlength', + '{DAV:}getcontenttype', + '{DAV:}getetag', + '{abc}def', + ]); + + $propFind->method('getRequestedProperties') + ->with() + ->willReturn([ + '{DAV:}getcontentlength', + '{DAV:}getcontenttype', + '{DAV:}getetag', + '{abc}def', + '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL', + ]); + + $props = [ + '{abc}def' => 'a', + '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/admin/personal'), + ]; + $this->insertProps('dummy_user_42', 'principals/users/dummy_user_42', $props); + + $setProps = []; + $propFind->method('set') + ->willReturnCallback(function ($name, $value, $status) use (&$setProps): void { + $setProps[$name] = $value; + }); + + $this->backend->propFind('principals/users/dummy_user_42', $propFind); + $this->assertEquals($props, $setProps); + } + + public function propFindPrincipalScheduleDefaultCalendarProviderUrlProvider(): array { + // [ user, nodes, existingProps, requestedProps, returnedProps ] + return [ + [ // Exists + 'dummy_user_42', + ['calendars/dummy_user_42/foo/' => ICalendar::class], + ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/foo/')], + ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'], + ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/foo/')], + ], + [ // Doesn't exist + 'dummy_user_42', + ['calendars/dummy_user_42/foo/' => ICalendar::class], + ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/dummy_user_42/bar/')], + ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'], + [], + ], + [ // No privilege + 'dummy_user_42', + ['calendars/user2/baz/' => ICalendar::class], + ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('calendars/user2/baz/')], + ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'], + [], + ], + [ // Not a calendar + 'dummy_user_42', + ['foo/dummy_user_42/bar/' => IACL::class], + ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/dummy_user_42/bar/')], + ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL'], + [], + ], + ]; + + } + + /** + * @dataProvider propFindPrincipalScheduleDefaultCalendarProviderUrlProvider + */ + public function testPropFindPrincipalScheduleDefaultCalendarUrl( + string $user, + array $nodes, + array $existingProps, + array $requestedProps, + array $returnedProps, + ): void { + $propFind = $this->createMock(PropFind::class); + $propFind->method('get404Properties') + ->with() + ->willReturn([ + '{DAV:}getcontentlength', + '{DAV:}getcontenttype', + '{DAV:}getetag', + ]); + + $propFind->method('getRequestedProperties') + ->with() + ->willReturn(array_merge([ + '{DAV:}getcontentlength', + '{DAV:}getcontenttype', + '{DAV:}getetag', + '{abc}def', + ], + $requestedProps, + )); + + $this->server->method('calculateUri') + ->willReturnCallback(function ($uri) { + if (!str_starts_with($uri, self::BASE_URI)) { + return trim(substr($uri, strlen(self::BASE_URI)), '/'); + } + return null; + }); + $this->tree->method('getNodeForPath') + ->willReturnCallback(function ($uri) use ($nodes) { + if (str_starts_with($uri, 'principals/')) { + return $this->createMock(IPrincipal::class); + } + if (array_key_exists($uri, $nodes)) { + $owner = explode('/', $uri)[1]; + $node = $this->createMock($nodes[$uri]); + $node->method('getOwner') + ->willReturn("principals/users/$owner"); + return $node; + } + throw new NotFound('Node not found'); + }); + + $this->insertProps($user, "principals/users/$user", $existingProps); + + $setProps = []; + $propFind->method('set') + ->willReturnCallback(function ($name, $value, $status) use (&$setProps): void { + $setProps[$name] = $value; + }); + + $this->backend->propFind("principals/users/$user", $propFind); + $this->assertEquals($returnedProps, $setProps); + } + /** * @dataProvider propPatchProvider */ public function testPropPatch(string $path, array $existing, array $props, array $result): void { + $this->server->method('calculateUri') + ->willReturnCallback(function ($uri) { + if (str_starts_with($uri, self::BASE_URI)) { + return trim(substr($uri, strlen(self::BASE_URI)), '/'); + } + return null; + }); + $this->tree->method('getNodeForPath') + ->willReturnCallback(function ($uri) { + $node = $this->createMock(ICalendar::class); + $node->method('getOwner') + ->willReturn('principals/users/' . $this->user->getUID()); + return $node; + }); + $this->insertProps($this->user->getUID(), $path, $existing); $propPatch = new PropPatch($props); @@ -207,6 +395,8 @@ class CustomPropertiesBackendTest extends TestCase { ['foo_bar_path_1337', ['{DAV:}displayname' => 'foo'], ['{DAV:}displayname' => 'anything'], ['{DAV:}displayname' => 'anything']], ['foo_bar_path_1337', ['{DAV:}displayname' => 'foo'], ['{DAV:}displayname' => null], []], [$longPath, [], ['{DAV:}displayname' => 'anything'], ['{DAV:}displayname' => 'anything']], + ['principals/users/dummy_user_42', [], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')]], + ['principals/users/dummy_user_42', [], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href(self::BASE_URI . 'foo/bar/')], ['{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => new Href('foo/bar/')]], ]; } |