AbstractPrincipalBackend.php 15 KB

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