1
0

AbstractPrincipalBackend.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-License-Identifier: AGPL-3.0-or-later
  5. */
  6. namespace OCA\DAV\CalDAV\ResourceBooking;
  7. use OCA\DAV\CalDAV\Proxy\ProxyMapper;
  8. use OCA\DAV\Traits\PrincipalProxyTrait;
  9. use OCP\Calendar\Resource\IResourceMetadata;
  10. use OCP\Calendar\Room\IRoomMetadata;
  11. use OCP\DB\Exception;
  12. use OCP\DB\QueryBuilder\IQueryBuilder;
  13. use OCP\IDBConnection;
  14. use OCP\IGroupManager;
  15. use OCP\IUserSession;
  16. use Psr\Log\LoggerInterface;
  17. use Sabre\DAV\PropPatch;
  18. use Sabre\DAVACL\PrincipalBackend\BackendInterface;
  19. use function array_intersect;
  20. use function array_map;
  21. use function array_merge;
  22. use function array_unique;
  23. use function array_values;
  24. abstract class AbstractPrincipalBackend implements BackendInterface {
  25. /** @var ProxyMapper */
  26. private $proxyMapper;
  27. /** @var string */
  28. private $principalPrefix;
  29. /** @var string */
  30. private $dbTableName;
  31. /** @var string */
  32. private $dbMetaDataTableName;
  33. /** @var string */
  34. private $dbForeignKeyName;
  35. public function __construct(
  36. private IDBConnection $db,
  37. private IUserSession $userSession,
  38. private IGroupManager $groupManager,
  39. private LoggerInterface $logger,
  40. ProxyMapper $proxyMapper,
  41. string $principalPrefix,
  42. string $dbPrefix,
  43. private string $cuType,
  44. ) {
  45. $this->proxyMapper = $proxyMapper;
  46. $this->principalPrefix = $principalPrefix;
  47. $this->dbTableName = 'calendar_' . $dbPrefix . 's';
  48. $this->dbMetaDataTableName = $this->dbTableName . '_md';
  49. $this->dbForeignKeyName = $dbPrefix . '_id';
  50. }
  51. use PrincipalProxyTrait;
  52. /**
  53. * Returns a list of principals based on a prefix.
  54. *
  55. * This prefix will often contain something like 'principals'. You are only
  56. * expected to return principals that are in this base path.
  57. *
  58. * You are expected to return at least a 'uri' for every user, you can
  59. * return any additional properties if you wish so. Common properties are:
  60. * {DAV:}displayname
  61. *
  62. * @param string $prefixPath
  63. * @return string[]
  64. */
  65. public function getPrincipalsByPrefix($prefixPath): array {
  66. $principals = [];
  67. if ($prefixPath === $this->principalPrefix) {
  68. $query = $this->db->getQueryBuilder();
  69. $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
  70. ->from($this->dbTableName);
  71. $stmt = $query->execute();
  72. $metaDataQuery = $this->db->getQueryBuilder();
  73. $metaDataQuery->select([$this->dbForeignKeyName, 'key', 'value'])
  74. ->from($this->dbMetaDataTableName);
  75. $metaDataStmt = $metaDataQuery->execute();
  76. $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
  77. $metaDataById = [];
  78. foreach ($metaDataRows as $metaDataRow) {
  79. if (!isset($metaDataById[$metaDataRow[$this->dbForeignKeyName]])) {
  80. $metaDataById[$metaDataRow[$this->dbForeignKeyName]] = [];
  81. }
  82. $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] =
  83. $metaDataRow['value'];
  84. }
  85. while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
  86. $id = $row['id'];
  87. if (isset($metaDataById[$id])) {
  88. $principals[] = $this->rowToPrincipal($row, $metaDataById[$id]);
  89. } else {
  90. $principals[] = $this->rowToPrincipal($row);
  91. }
  92. }
  93. $stmt->closeCursor();
  94. }
  95. return $principals;
  96. }
  97. /**
  98. * Returns a specific principal, specified by its path.
  99. * The returned structure should be the exact same as from
  100. * getPrincipalsByPrefix.
  101. *
  102. * @param string $prefixPath
  103. *
  104. * @return array
  105. */
  106. public function getPrincipalByPath($path) {
  107. if (!str_starts_with($path, $this->principalPrefix)) {
  108. return null;
  109. }
  110. [, $name] = \Sabre\Uri\split($path);
  111. [$backendId, $resourceId] = explode('-', $name, 2);
  112. $query = $this->db->getQueryBuilder();
  113. $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
  114. ->from($this->dbTableName)
  115. ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
  116. ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
  117. $stmt = $query->execute();
  118. $row = $stmt->fetch(\PDO::FETCH_ASSOC);
  119. if (!$row) {
  120. return null;
  121. }
  122. $metaDataQuery = $this->db->getQueryBuilder();
  123. $metaDataQuery->select(['key', 'value'])
  124. ->from($this->dbMetaDataTableName)
  125. ->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
  126. $metaDataStmt = $metaDataQuery->execute();
  127. $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
  128. $metadata = [];
  129. foreach ($metaDataRows as $metaDataRow) {
  130. $metadata[$metaDataRow['key']] = $metaDataRow['value'];
  131. }
  132. return $this->rowToPrincipal($row, $metadata);
  133. }
  134. /**
  135. * @param int $id
  136. * @return string[]|null
  137. */
  138. public function getPrincipalById($id): ?array {
  139. $query = $this->db->getQueryBuilder();
  140. $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
  141. ->from($this->dbTableName)
  142. ->where($query->expr()->eq('id', $query->createNamedParameter($id)));
  143. $stmt = $query->execute();
  144. $row = $stmt->fetch(\PDO::FETCH_ASSOC);
  145. if (!$row) {
  146. return null;
  147. }
  148. $metaDataQuery = $this->db->getQueryBuilder();
  149. $metaDataQuery->select(['key', 'value'])
  150. ->from($this->dbMetaDataTableName)
  151. ->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
  152. $metaDataStmt = $metaDataQuery->execute();
  153. $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
  154. $metadata = [];
  155. foreach ($metaDataRows as $metaDataRow) {
  156. $metadata[$metaDataRow['key']] = $metaDataRow['value'];
  157. }
  158. return $this->rowToPrincipal($row, $metadata);
  159. }
  160. /**
  161. * @param string $path
  162. * @param PropPatch $propPatch
  163. * @return int
  164. */
  165. public function updatePrincipal($path, PropPatch $propPatch): int {
  166. return 0;
  167. }
  168. /**
  169. * @param string $prefixPath
  170. * @param string $test
  171. *
  172. * @return array
  173. */
  174. public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
  175. $results = [];
  176. if (\count($searchProperties) === 0) {
  177. return [];
  178. }
  179. if ($prefixPath !== $this->principalPrefix) {
  180. return [];
  181. }
  182. $user = $this->userSession->getUser();
  183. if (!$user) {
  184. return [];
  185. }
  186. $usersGroups = $this->groupManager->getUserGroupIds($user);
  187. foreach ($searchProperties as $prop => $value) {
  188. switch ($prop) {
  189. case '{http://sabredav.org/ns}email-address':
  190. $query = $this->db->getQueryBuilder();
  191. $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
  192. ->from($this->dbTableName)
  193. ->where($query->expr()->iLike('email', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
  194. $stmt = $query->execute();
  195. $principals = [];
  196. while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
  197. if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
  198. continue;
  199. }
  200. $principals[] = $this->rowToPrincipal($row)['uri'];
  201. }
  202. $results[] = $principals;
  203. $stmt->closeCursor();
  204. break;
  205. case '{DAV:}displayname':
  206. $query = $this->db->getQueryBuilder();
  207. $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
  208. ->from($this->dbTableName)
  209. ->where($query->expr()->iLike('displayname', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
  210. $stmt = $query->execute();
  211. $principals = [];
  212. while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
  213. if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
  214. continue;
  215. }
  216. $principals[] = $this->rowToPrincipal($row)['uri'];
  217. }
  218. $results[] = $principals;
  219. $stmt->closeCursor();
  220. break;
  221. case '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set':
  222. // If you add support for more search properties that qualify as a user-address,
  223. // please also add them to the array below
  224. $results[] = $this->searchPrincipals($this->principalPrefix, [
  225. '{http://sabredav.org/ns}email-address' => $value,
  226. ], 'anyof');
  227. break;
  228. case IRoomMetadata::FEATURES:
  229. $results[] = $this->searchPrincipalsByRoomFeature($prop, $value);
  230. break;
  231. case IRoomMetadata::CAPACITY:
  232. case IResourceMetadata::VEHICLE_SEATING_CAPACITY:
  233. $results[] = $this->searchPrincipalsByCapacity($prop, $value);
  234. break;
  235. default:
  236. $results[] = $this->searchPrincipalsByMetadataKey($prop, $value, $usersGroups);
  237. break;
  238. }
  239. }
  240. // results is an array of arrays, so this is not the first search result
  241. // but the results of the first searchProperty
  242. if (count($results) === 1) {
  243. return $results[0];
  244. }
  245. switch ($test) {
  246. case 'anyof':
  247. return array_values(array_unique(array_merge(...$results)));
  248. case 'allof':
  249. default:
  250. return array_values(array_intersect(...$results));
  251. }
  252. }
  253. /**
  254. * @param string $key
  255. * @return IQueryBuilder
  256. */
  257. private function getMetadataQuery(string $key): IQueryBuilder {
  258. $query = $this->db->getQueryBuilder();
  259. $query->select([$this->dbForeignKeyName])
  260. ->from($this->dbMetaDataTableName)
  261. ->where($query->expr()->eq('key', $query->createNamedParameter($key)));
  262. return $query;
  263. }
  264. /**
  265. * Searches principals based on their metadata keys.
  266. * This allows to search for all principals with a specific key.
  267. * e.g.:
  268. * '{http://nextcloud.com/ns}room-building-address' => 'ABC Street 123, ...'
  269. *
  270. * @param string $key
  271. * @param string $value
  272. * @param string[] $usersGroups
  273. * @return string[]
  274. */
  275. private function searchPrincipalsByMetadataKey(string $key, string $value, array $usersGroups = []): array {
  276. $query = $this->getMetadataQuery($key);
  277. $query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
  278. return $this->getRows($query, $usersGroups);
  279. }
  280. /**
  281. * Searches principals based on room features
  282. * e.g.:
  283. * '{http://nextcloud.com/ns}room-features' => 'TV,PROJECTOR'
  284. *
  285. * @param string $key
  286. * @param string $value
  287. * @param string[] $usersGroups
  288. * @return string[]
  289. */
  290. private function searchPrincipalsByRoomFeature(string $key, string $value, array $usersGroups = []): array {
  291. $query = $this->getMetadataQuery($key);
  292. foreach (explode(',', $value) as $v) {
  293. $query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($v) . '%')));
  294. }
  295. return $this->getRows($query, $usersGroups);
  296. }
  297. /**
  298. * Searches principals based on room seating capacity or vehicle capacity
  299. * e.g.:
  300. * '{http://nextcloud.com/ns}room-seating-capacity' => '100'
  301. *
  302. * @param string $key
  303. * @param string $value
  304. * @param string[] $usersGroups
  305. * @return string[]
  306. */
  307. private function searchPrincipalsByCapacity(string $key, string $value, array $usersGroups = []): array {
  308. $query = $this->getMetadataQuery($key);
  309. $query->andWhere($query->expr()->gte('value', $query->createNamedParameter($value)));
  310. return $this->getRows($query, $usersGroups);
  311. }
  312. /**
  313. * @param IQueryBuilder $query
  314. * @param string[] $usersGroups
  315. * @return string[]
  316. */
  317. private function getRows(IQueryBuilder $query, array $usersGroups): array {
  318. try {
  319. $stmt = $query->executeQuery();
  320. } catch (Exception $e) {
  321. $this->logger->error('Could not search resources: ' . $e->getMessage(), ['exception' => $e]);
  322. }
  323. $rows = [];
  324. while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
  325. $principalRow = $this->getPrincipalById($row[$this->dbForeignKeyName]);
  326. if (!$principalRow) {
  327. continue;
  328. }
  329. $rows[] = $principalRow;
  330. }
  331. $stmt->closeCursor();
  332. $filteredRows = array_filter($rows, function ($row) use ($usersGroups) {
  333. return $this->isAllowedToAccessResource($row, $usersGroups);
  334. });
  335. return array_map(static function ($row): string {
  336. return $row['uri'];
  337. }, $filteredRows);
  338. }
  339. /**
  340. * @param string $uri
  341. * @param string $principalPrefix
  342. * @return null|string
  343. * @throws Exception
  344. */
  345. public function findByUri($uri, $principalPrefix): ?string {
  346. $user = $this->userSession->getUser();
  347. if (!$user) {
  348. return null;
  349. }
  350. $usersGroups = $this->groupManager->getUserGroupIds($user);
  351. if (str_starts_with($uri, 'mailto:')) {
  352. $email = substr($uri, 7);
  353. $query = $this->db->getQueryBuilder();
  354. $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
  355. ->from($this->dbTableName)
  356. ->where($query->expr()->eq('email', $query->createNamedParameter($email)));
  357. $stmt = $query->execute();
  358. $row = $stmt->fetch(\PDO::FETCH_ASSOC);
  359. if (!$row) {
  360. return null;
  361. }
  362. if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
  363. return null;
  364. }
  365. return $this->rowToPrincipal($row)['uri'];
  366. }
  367. if (str_starts_with($uri, 'principal:')) {
  368. $path = substr($uri, 10);
  369. if (!str_starts_with($path, $this->principalPrefix)) {
  370. return null;
  371. }
  372. [, $name] = \Sabre\Uri\split($path);
  373. [$backendId, $resourceId] = explode('-', $name, 2);
  374. $query = $this->db->getQueryBuilder();
  375. $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
  376. ->from($this->dbTableName)
  377. ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
  378. ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
  379. $stmt = $query->execute();
  380. $row = $stmt->fetch(\PDO::FETCH_ASSOC);
  381. if (!$row) {
  382. return null;
  383. }
  384. if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
  385. return null;
  386. }
  387. return $this->rowToPrincipal($row)['uri'];
  388. }
  389. return null;
  390. }
  391. /**
  392. * convert database row to principal
  393. *
  394. * @param string[] $row
  395. * @param string[] $metadata
  396. * @return string[]
  397. */
  398. private function rowToPrincipal(array $row, array $metadata = []): array {
  399. return array_merge([
  400. 'uri' => $this->principalPrefix . '/' . $row['backend_id'] . '-' . $row['resource_id'],
  401. '{DAV:}displayname' => $row['displayname'],
  402. '{http://sabredav.org/ns}email-address' => $row['email'],
  403. '{urn:ietf:params:xml:ns:caldav}calendar-user-type' => $this->cuType,
  404. ], $metadata);
  405. }
  406. /**
  407. * @param array $row
  408. * @param array $userGroups
  409. * @return bool
  410. */
  411. private function isAllowedToAccessResource(array $row, array $userGroups): bool {
  412. if (!isset($row['group_restrictions']) ||
  413. $row['group_restrictions'] === null ||
  414. $row['group_restrictions'] === '') {
  415. return true;
  416. }
  417. // group restrictions contains something, but not parsable, deny access and log warning
  418. $json = json_decode($row['group_restrictions'], null, 512, JSON_THROW_ON_ERROR);
  419. if (!\is_array($json)) {
  420. $this->logger->info('group_restrictions field could not be parsed for ' . $this->dbTableName . '::' . $row['id'] . ', denying access to resource');
  421. return false;
  422. }
  423. // empty array => no group restrictions
  424. if (empty($json)) {
  425. return true;
  426. }
  427. return !empty(array_intersect($json, $userGroups));
  428. }
  429. }