1<?php
2/**
3 * @copyright 2019, Georg Ehrke <oc.list@georgehrke.com>
4 *
5 * @author Christoph Wurst <christoph@winzerhof-wurst.at>
6 * @author Georg Ehrke <oc.list@georgehrke.com>
7 * @author Roeland Jago Douma <roeland@famdouma.nl>
8 *
9 * @license GNU AGPL version 3 or any later version
10 *
11 * This program is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU Affero General Public License as
13 * published by the Free Software Foundation, either version 3 of the
14 * License, or (at your option) any later version.
15 *
16 * This program is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 * GNU Affero General Public License for more details.
20 *
21 * You should have received a copy of the GNU Affero General Public License
22 * along with this program. If not, see <http://www.gnu.org/licenses/>.
23 *
24 */
25namespace OCA\DAV\BackgroundJob;
26
27use OC\BackgroundJob\TimedJob;
28use OCA\DAV\CalDAV\CalDavBackend;
29use OCP\Calendar\BackendTemporarilyUnavailableException;
30use OCP\Calendar\IMetadataProvider;
31use OCP\Calendar\Resource\IBackend as IResourceBackend;
32use OCP\Calendar\Resource\IManager as IResourceManager;
33use OCP\Calendar\Resource\IResource;
34use OCP\Calendar\Room\IManager as IRoomManager;
35use OCP\Calendar\Room\IRoom;
36use OCP\IDBConnection;
37
38class UpdateCalendarResourcesRoomsBackgroundJob extends TimedJob {
39
40	/** @var IResourceManager */
41	private $resourceManager;
42
43	/** @var IRoomManager */
44	private $roomManager;
45
46	/** @var IDBConnection */
47	private $dbConnection;
48
49	/** @var CalDavBackend */
50	private $calDavBackend;
51
52	/**
53	 * UpdateCalendarResourcesRoomsBackgroundJob constructor.
54	 *
55	 * @param IResourceManager $resourceManager
56	 * @param IRoomManager $roomManager
57	 * @param IDBConnection $dbConnection
58	 * @param CalDavBackend $calDavBackend
59	 */
60	public function __construct(IResourceManager $resourceManager,
61								IRoomManager $roomManager,
62								IDBConnection $dbConnection,
63								CalDavBackend $calDavBackend) {
64		$this->resourceManager = $resourceManager;
65		$this->roomManager = $roomManager;
66		$this->dbConnection = $dbConnection;
67		$this->calDavBackend = $calDavBackend;
68
69		// run once an hour
70		$this->setInterval(60 * 60);
71	}
72
73	/**
74	 * @param $argument
75	 */
76	public function run($argument):void {
77		$this->runForBackend(
78			$this->resourceManager,
79			'calendar_resources',
80			'calendar_resources_md',
81			'resource_id',
82			'principals/calendar-resources'
83		);
84		$this->runForBackend(
85			$this->roomManager,
86			'calendar_rooms',
87			'calendar_rooms_md',
88			'room_id',
89			'principals/calendar-rooms'
90		);
91	}
92
93	/**
94	 * Run background-job for one specific backendManager
95	 * either ResourceManager or RoomManager
96	 *
97	 * @param IResourceManager|IRoomManager $backendManager
98	 * @param string $dbTable
99	 * @param string $dbTableMetadata
100	 * @param string $foreignKey
101	 * @param string $principalPrefix
102	 */
103	private function runForBackend($backendManager,
104								   string $dbTable,
105								   string $dbTableMetadata,
106								   string $foreignKey,
107								   string $principalPrefix):void {
108		$backends = $backendManager->getBackends();
109
110		foreach ($backends as $backend) {
111			$backendId = $backend->getBackendIdentifier();
112
113			try {
114				if ($backend instanceof IResourceBackend) {
115					$list = $backend->listAllResources();
116				} else {
117					$list = $backend->listAllRooms();
118				}
119			} catch (BackendTemporarilyUnavailableException $ex) {
120				continue;
121			}
122
123			$cachedList = $this->getAllCachedByBackend($dbTable, $backendId);
124			$newIds = array_diff($list, $cachedList);
125			$deletedIds = array_diff($cachedList, $list);
126			$editedIds = array_intersect($list, $cachedList);
127
128			foreach ($newIds as $newId) {
129				try {
130					if ($backend instanceof IResourceBackend) {
131						$resource = $backend->getResource($newId);
132					} else {
133						$resource = $backend->getRoom($newId);
134					}
135
136					$metadata = [];
137					if ($resource instanceof IMetadataProvider) {
138						$metadata = $this->getAllMetadataOfBackend($resource);
139					}
140				} catch (BackendTemporarilyUnavailableException $ex) {
141					continue;
142				}
143
144				$id = $this->addToCache($dbTable, $backendId, $resource);
145				$this->addMetadataToCache($dbTableMetadata, $foreignKey, $id, $metadata);
146				// we don't create the calendar here, it is created lazily
147				// when an event is actually scheduled with this resource / room
148			}
149
150			foreach ($deletedIds as $deletedId) {
151				$id = $this->getIdForBackendAndResource($dbTable, $backendId, $deletedId);
152				$this->deleteFromCache($dbTable, $id);
153				$this->deleteMetadataFromCache($dbTableMetadata, $foreignKey, $id);
154
155				$principalName = implode('-', [$backendId, $deletedId]);
156				$this->deleteCalendarDataForResource($principalPrefix, $principalName);
157			}
158
159			foreach ($editedIds as $editedId) {
160				$id = $this->getIdForBackendAndResource($dbTable, $backendId, $editedId);
161
162				try {
163					if ($backend instanceof IResourceBackend) {
164						$resource = $backend->getResource($editedId);
165					} else {
166						$resource = $backend->getRoom($editedId);
167					}
168
169					$metadata = [];
170					if ($resource instanceof IMetadataProvider) {
171						$metadata = $this->getAllMetadataOfBackend($resource);
172					}
173				} catch (BackendTemporarilyUnavailableException $ex) {
174					continue;
175				}
176
177				$this->updateCache($dbTable, $id, $resource);
178
179				if ($resource instanceof IMetadataProvider) {
180					$cachedMetadata = $this->getAllMetadataOfCache($dbTableMetadata, $foreignKey, $id);
181					$this->updateMetadataCache($dbTableMetadata, $foreignKey, $id, $metadata, $cachedMetadata);
182				}
183			}
184		}
185	}
186
187	/**
188	 * add entry to cache that exists remotely but not yet in cache
189	 *
190	 * @param string $table
191	 * @param string $backendId
192	 * @param IResource|IRoom $remote
193	 * @return int Insert id
194	 */
195	private function addToCache(string $table,
196								string $backendId,
197								$remote):int {
198		$query = $this->dbConnection->getQueryBuilder();
199		$query->insert($table)
200			->values([
201				'backend_id' => $query->createNamedParameter($backendId),
202				'resource_id' => $query->createNamedParameter($remote->getId()),
203				'email' => $query->createNamedParameter($remote->getEMail()),
204				'displayname' => $query->createNamedParameter($remote->getDisplayName()),
205				'group_restrictions' => $query->createNamedParameter(
206					$this->serializeGroupRestrictions(
207						$remote->getGroupRestrictions()
208					))
209			])
210			->execute();
211		return $query->getLastInsertId();
212	}
213
214	/**
215	 * @param string $table
216	 * @param string $foreignKey
217	 * @param int $foreignId
218	 * @param array $metadata
219	 */
220	private function addMetadataToCache(string $table,
221										string $foreignKey,
222										int $foreignId,
223										array $metadata):void {
224		foreach ($metadata as $key => $value) {
225			$query = $this->dbConnection->getQueryBuilder();
226			$query->insert($table)
227				->values([
228					$foreignKey => $query->createNamedParameter($foreignId),
229					'key' => $query->createNamedParameter($key),
230					'value' => $query->createNamedParameter($value),
231				])
232				->execute();
233		}
234	}
235
236	/**
237	 * delete entry from cache that does not exist anymore remotely
238	 *
239	 * @param string $table
240	 * @param int $id
241	 */
242	private function deleteFromCache(string $table,
243									 int $id):void {
244		$query = $this->dbConnection->getQueryBuilder();
245		$query->delete($table)
246			->where($query->expr()->eq('id', $query->createNamedParameter($id)))
247			->execute();
248	}
249
250	/**
251	 * @param string $table
252	 * @param string $foreignKey
253	 * @param int $id
254	 */
255	private function deleteMetadataFromCache(string $table,
256											 string $foreignKey,
257											 int $id):void {
258		$query = $this->dbConnection->getQueryBuilder();
259		$query->delete($table)
260			->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id)))
261			->execute();
262	}
263
264	/**
265	 * update an existing entry in cache
266	 *
267	 * @param string $table
268	 * @param int $id
269	 * @param IResource|IRoom $remote
270	 */
271	private function updateCache(string $table,
272								 int $id,
273								 $remote):void {
274		$query = $this->dbConnection->getQueryBuilder();
275		$query->update($table)
276			->set('email', $query->createNamedParameter($remote->getEMail()))
277			->set('displayname', $query->createNamedParameter($remote->getDisplayName()))
278			->set('group_restrictions', $query->createNamedParameter(
279				$this->serializeGroupRestrictions(
280					$remote->getGroupRestrictions()
281				)))
282			->where($query->expr()->eq('id', $query->createNamedParameter($id)))
283			->execute();
284	}
285
286	/**
287	 * @param string $dbTable
288	 * @param string $foreignKey
289	 * @param int $id
290	 * @param array $metadata
291	 * @param array $cachedMetadata
292	 */
293	private function updateMetadataCache(string $dbTable,
294										 string $foreignKey,
295										 int $id,
296										 array $metadata,
297										 array $cachedMetadata):void {
298		$newMetadata = array_diff_key($metadata, $cachedMetadata);
299		$deletedMetadata = array_diff_key($cachedMetadata, $metadata);
300
301		foreach ($newMetadata as $key => $value) {
302			$query = $this->dbConnection->getQueryBuilder();
303			$query->insert($dbTable)
304				->values([
305					$foreignKey => $query->createNamedParameter($id),
306					'key' => $query->createNamedParameter($key),
307					'value' => $query->createNamedParameter($value),
308				])
309				->execute();
310		}
311
312		foreach ($deletedMetadata as $key => $value) {
313			$query = $this->dbConnection->getQueryBuilder();
314			$query->delete($dbTable)
315				->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id)))
316				->andWhere($query->expr()->eq('key', $query->createNamedParameter($key)))
317				->execute();
318		}
319
320		$existingKeys = array_keys(array_intersect_key($metadata, $cachedMetadata));
321		foreach ($existingKeys as $existingKey) {
322			if ($metadata[$existingKey] !== $cachedMetadata[$existingKey]) {
323				$query = $this->dbConnection->getQueryBuilder();
324				$query->update($dbTable)
325					->set('value', $query->createNamedParameter($metadata[$existingKey]))
326					->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id)))
327					->andWhere($query->expr()->eq('key', $query->createNamedParameter($existingKey)))
328					->execute();
329			}
330		}
331	}
332
333	/**
334	 * serialize array of group restrictions to store them in database
335	 *
336	 * @param array $groups
337	 * @return string
338	 */
339	private function serializeGroupRestrictions(array $groups):string {
340		return \json_encode($groups);
341	}
342
343	/**
344	 * Gets all metadata of a backend
345	 *
346	 * @param IResource|IRoom $resource
347	 * @return array
348	 */
349	private function getAllMetadataOfBackend($resource):array {
350		if (!($resource instanceof IMetadataProvider)) {
351			return [];
352		}
353
354		$keys = $resource->getAllAvailableMetadataKeys();
355		$metadata = [];
356		foreach ($keys as $key) {
357			$metadata[$key] = $resource->getMetadataForKey($key);
358		}
359
360		return $metadata;
361	}
362
363	/**
364	 * @param string $table
365	 * @param string $foreignKey
366	 * @param int $id
367	 * @return array
368	 */
369	private function getAllMetadataOfCache(string $table,
370										   string $foreignKey,
371										   int $id):array {
372		$query = $this->dbConnection->getQueryBuilder();
373		$query->select(['key', 'value'])
374			->from($table)
375			->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id)));
376		$stmt = $query->execute();
377		$rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
378
379		$metadata = [];
380		foreach ($rows as $row) {
381			$metadata[$row['key']] = $row['value'];
382		}
383
384		return $metadata;
385	}
386
387	/**
388	 * Gets all cached rooms / resources by backend
389	 *
390	 * @param $tableName
391	 * @param $backendId
392	 * @return array
393	 */
394	private function getAllCachedByBackend(string $tableName,
395										   string $backendId):array {
396		$query = $this->dbConnection->getQueryBuilder();
397		$query->select('resource_id')
398			->from($tableName)
399			->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)));
400		$stmt = $query->execute();
401
402		return array_map(function ($row): string {
403			return $row['resource_id'];
404		}, $stmt->fetchAll());
405	}
406
407	/**
408	 * @param $principalPrefix
409	 * @param $principalUri
410	 */
411	private function deleteCalendarDataForResource(string $principalPrefix,
412												   string $principalUri):void {
413		$calendar = $this->calDavBackend->getCalendarByUri(
414			implode('/', [$principalPrefix, $principalUri]),
415			CalDavBackend::RESOURCE_BOOKING_CALENDAR_URI);
416
417		if ($calendar !== null) {
418			$this->calDavBackend->deleteCalendar(
419				$calendar['id'],
420				true // Because this wasn't deleted by a user
421			);
422		}
423	}
424
425	/**
426	 * @param $table
427	 * @param $backendId
428	 * @param $resourceId
429	 * @return int
430	 */
431	private function getIdForBackendAndResource(string $table,
432												string $backendId,
433												string $resourceId):int {
434		$query = $this->dbConnection->getQueryBuilder();
435		$query->select('id')
436			->from($table)
437			->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
438			->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
439		$stmt = $query->execute();
440
441		return $stmt->fetch()['id'];
442	}
443}
444