Completed
Push — master ( ef4298...14cc8d )
by Joas
33:00 queued 06:32
created
apps/user_status/lib/Service/StatusService.php 1 patch
Indentation   +565 added lines, -565 removed lines patch added patch discarded remove patch
@@ -31,569 +31,569 @@
 block discarded – undo
31 31
  * @package OCA\UserStatus\Service
32 32
  */
33 33
 class StatusService {
34
-	private bool $shareeEnumeration;
35
-	private bool $shareeEnumerationInGroupOnly;
36
-	private bool $shareeEnumerationPhone;
37
-
38
-	/**
39
-	 * List of priorities ordered by their priority
40
-	 */
41
-	public const PRIORITY_ORDERED_STATUSES = [
42
-		IUserStatus::ONLINE,
43
-		IUserStatus::AWAY,
44
-		IUserStatus::DND,
45
-		IUserStatus::BUSY,
46
-		IUserStatus::INVISIBLE,
47
-		IUserStatus::OFFLINE,
48
-	];
49
-
50
-	/**
51
-	 * List of statuses that persist the clear-up
52
-	 * or UserLiveStatusEvents
53
-	 */
54
-	public const PERSISTENT_STATUSES = [
55
-		IUserStatus::AWAY,
56
-		IUserStatus::BUSY,
57
-		IUserStatus::DND,
58
-		IUserStatus::INVISIBLE,
59
-	];
60
-
61
-	/** @var int */
62
-	public const INVALIDATE_STATUS_THRESHOLD = 15 /* minutes */ * 60 /* seconds */;
63
-
64
-	/** @var int */
65
-	public const MAXIMUM_MESSAGE_LENGTH = 80;
66
-
67
-	public function __construct(
68
-		private UserStatusMapper $mapper,
69
-		private ITimeFactory $timeFactory,
70
-		private PredefinedStatusService $predefinedStatusService,
71
-		private IEmojiHelper $emojiHelper,
72
-		private IConfig $config,
73
-		private IUserManager $userManager,
74
-		private LoggerInterface $logger,
75
-	) {
76
-		$this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
77
-		$this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
78
-		$this->shareeEnumerationPhone = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
79
-	}
80
-
81
-	/**
82
-	 * @param int|null $limit
83
-	 * @param int|null $offset
84
-	 * @return UserStatus[]
85
-	 */
86
-	public function findAll(?int $limit = null, ?int $offset = null): array {
87
-		// Return empty array if user enumeration is disabled or limited to groups
88
-		// TODO: find a solution that scales to get only users from common groups if user enumeration is limited to
89
-		//       groups. See discussion at https://github.com/nextcloud/server/pull/27879#discussion_r729715936
90
-		if (!$this->shareeEnumeration || $this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone) {
91
-			return [];
92
-		}
93
-
94
-		return array_map(function ($status) {
95
-			return $this->processStatus($status);
96
-		}, $this->mapper->findAll($limit, $offset));
97
-	}
98
-
99
-	/**
100
-	 * @param int|null $limit
101
-	 * @param int|null $offset
102
-	 * @return array
103
-	 */
104
-	public function findAllRecentStatusChanges(?int $limit = null, ?int $offset = null): array {
105
-		// Return empty array if user enumeration is disabled or limited to groups
106
-		// TODO: find a solution that scales to get only users from common groups if user enumeration is limited to
107
-		//       groups. See discussion at https://github.com/nextcloud/server/pull/27879#discussion_r729715936
108
-		if (!$this->shareeEnumeration || $this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone) {
109
-			return [];
110
-		}
111
-
112
-		return array_map(function ($status) {
113
-			return $this->processStatus($status);
114
-		}, $this->mapper->findAllRecent($limit, $offset));
115
-	}
116
-
117
-	/**
118
-	 * @param string $userId
119
-	 * @return UserStatus
120
-	 * @throws DoesNotExistException
121
-	 */
122
-	public function findByUserId(string $userId): UserStatus {
123
-		return $this->processStatus($this->mapper->findByUserId($userId));
124
-	}
125
-
126
-	/**
127
-	 * @param array $userIds
128
-	 * @return UserStatus[]
129
-	 */
130
-	public function findByUserIds(array $userIds):array {
131
-		return array_map(function ($status) {
132
-			return $this->processStatus($status);
133
-		}, $this->mapper->findByUserIds($userIds));
134
-	}
135
-
136
-	/**
137
-	 * @param string $userId
138
-	 * @param string $status
139
-	 * @param int|null $statusTimestamp
140
-	 * @param bool $isUserDefined
141
-	 * @return UserStatus
142
-	 * @throws InvalidStatusTypeException
143
-	 */
144
-	public function setStatus(string $userId,
145
-		string $status,
146
-		?int $statusTimestamp,
147
-		bool $isUserDefined): UserStatus {
148
-		try {
149
-			$userStatus = $this->mapper->findByUserId($userId);
150
-		} catch (DoesNotExistException $ex) {
151
-			$userStatus = new UserStatus();
152
-			$userStatus->setUserId($userId);
153
-		}
154
-
155
-		// Check if status-type is valid
156
-		if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
157
-			throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
158
-		}
159
-
160
-		if ($statusTimestamp === null) {
161
-			$statusTimestamp = $this->timeFactory->getTime();
162
-		}
163
-
164
-		$userStatus->setStatus($status);
165
-		$userStatus->setStatusTimestamp($statusTimestamp);
166
-		$userStatus->setIsUserDefined($isUserDefined);
167
-		$userStatus->setIsBackup(false);
168
-
169
-		if ($userStatus->getId() === null) {
170
-			return $this->insertWithoutThrowingUniqueConstrain($userStatus);
171
-		}
172
-
173
-		return $this->mapper->update($userStatus);
174
-	}
175
-
176
-	/**
177
-	 * @param string $userId
178
-	 * @param string $messageId
179
-	 * @param int|null $clearAt
180
-	 * @return UserStatus
181
-	 * @throws InvalidMessageIdException
182
-	 * @throws InvalidClearAtException
183
-	 */
184
-	public function setPredefinedMessage(string $userId,
185
-		string $messageId,
186
-		?int $clearAt): UserStatus {
187
-		try {
188
-			$userStatus = $this->mapper->findByUserId($userId);
189
-		} catch (DoesNotExistException $ex) {
190
-			$userStatus = new UserStatus();
191
-			$userStatus->setUserId($userId);
192
-			$userStatus->setStatus(IUserStatus::OFFLINE);
193
-			$userStatus->setStatusTimestamp(0);
194
-			$userStatus->setIsUserDefined(false);
195
-			$userStatus->setIsBackup(false);
196
-		}
197
-
198
-		if (!$this->predefinedStatusService->isValidId($messageId)) {
199
-			throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
200
-		}
201
-
202
-		// Check that clearAt is in the future
203
-		if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
204
-			throw new InvalidClearAtException('ClearAt is in the past');
205
-		}
206
-
207
-		$userStatus->setMessageId($messageId);
208
-		$userStatus->setCustomIcon(null);
209
-		$userStatus->setCustomMessage(null);
210
-		$userStatus->setClearAt($clearAt);
211
-		$userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
212
-
213
-		if ($userStatus->getId() === null) {
214
-			return $this->insertWithoutThrowingUniqueConstrain($userStatus);
215
-		}
216
-
217
-		return $this->mapper->update($userStatus);
218
-	}
219
-
220
-	/**
221
-	 * @param string $userId
222
-	 * @param string $status
223
-	 * @param string $messageId
224
-	 * @param bool $createBackup
225
-	 * @param string|null $customMessage
226
-	 * @throws InvalidStatusTypeException
227
-	 * @throws InvalidMessageIdException
228
-	 */
229
-	public function setUserStatus(string $userId,
230
-		string $status,
231
-		string $messageId,
232
-		bool $createBackup,
233
-		?string $customMessage = null): ?UserStatus {
234
-		// Check if status-type is valid
235
-		if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
236
-			throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
237
-		}
238
-
239
-		if (!$this->predefinedStatusService->isValidId($messageId)) {
240
-			throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
241
-		}
242
-
243
-		try {
244
-			$userStatus = $this->mapper->findByUserId($userId);
245
-		} catch (DoesNotExistException $e) {
246
-			// We don't need to do anything
247
-			$userStatus = new UserStatus();
248
-			$userStatus->setUserId($userId);
249
-		}
250
-
251
-		$updateStatus = false;
252
-		if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE) {
253
-			// OUT_OF_OFFICE trumps AVAILABILITY, CALL and CALENDAR status
254
-			$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_AVAILABILITY || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
255
-		} elseif ($messageId === IUserStatus::MESSAGE_AVAILABILITY) {
256
-			// AVAILABILITY trumps CALL and CALENDAR status
257
-			$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
258
-		} elseif ($messageId === IUserStatus::MESSAGE_CALL) {
259
-			// CALL trumps CALENDAR status
260
-			$updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
261
-		}
262
-
263
-		if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE || $messageId === IUserStatus::MESSAGE_AVAILABILITY || $messageId === IUserStatus::MESSAGE_CALL || $messageId === IUserStatus::MESSAGE_CALENDAR_BUSY) {
264
-			if ($updateStatus) {
265
-				$this->logger->debug('User ' . $userId . ' is currently NOT available, overwriting status [status: ' . $userStatus->getStatus() . ', messageId: ' . json_encode($userStatus->getMessageId()) . ']', ['app' => 'dav']);
266
-			} else {
267
-				$this->logger->debug('User ' . $userId . ' is currently NOT available, but we are NOT overwriting status [status: ' . $userStatus->getStatus() . ', messageId: ' . json_encode($userStatus->getMessageId()) . ']', ['app' => 'dav']);
268
-			}
269
-		}
270
-
271
-		// There should be a backup already or none is needed. So we take a shortcut.
272
-		if ($updateStatus) {
273
-			$userStatus->setStatus($status);
274
-			$userStatus->setStatusTimestamp($this->timeFactory->getTime());
275
-			$userStatus->setIsUserDefined(true);
276
-			$userStatus->setIsBackup(false);
277
-			$userStatus->setMessageId($messageId);
278
-			$userStatus->setCustomIcon(null);
279
-			$userStatus->setCustomMessage($customMessage);
280
-			$userStatus->setClearAt(null);
281
-			$userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
282
-			return $this->mapper->update($userStatus);
283
-		}
284
-
285
-		if ($createBackup) {
286
-			if ($this->backupCurrentStatus($userId) === false) {
287
-				return null; // Already a status set automatically => abort.
288
-			}
289
-
290
-			// If we just created the backup
291
-			// we need to create a new status to insert
292
-			// Unfortunately there's no way to unset the DB ID on an Entity
293
-			$userStatus = new UserStatus();
294
-			$userStatus->setUserId($userId);
295
-		}
296
-
297
-		$userStatus->setStatus($status);
298
-		$userStatus->setStatusTimestamp($this->timeFactory->getTime());
299
-		$userStatus->setIsUserDefined(true);
300
-		$userStatus->setIsBackup(false);
301
-		$userStatus->setMessageId($messageId);
302
-		$userStatus->setCustomIcon(null);
303
-		$userStatus->setCustomMessage($customMessage);
304
-		$userStatus->setClearAt(null);
305
-		if ($this->predefinedStatusService->getTranslatedStatusForId($messageId) !== null
306
-			|| ($customMessage !== null && $customMessage !== '')) {
307
-			// Only track status message ID if there is one
308
-			$userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
309
-		} else {
310
-			$userStatus->setStatusMessageTimestamp(0);
311
-		}
312
-
313
-		if ($userStatus->getId() !== null) {
314
-			return $this->mapper->update($userStatus);
315
-		}
316
-		return $this->insertWithoutThrowingUniqueConstrain($userStatus);
317
-	}
318
-
319
-	/**
320
-	 * @param string $userId
321
-	 * @param string|null $statusIcon
322
-	 * @param string|null $message
323
-	 * @param int|null $clearAt
324
-	 * @return UserStatus
325
-	 * @throws InvalidClearAtException
326
-	 * @throws InvalidStatusIconException
327
-	 * @throws StatusMessageTooLongException
328
-	 */
329
-	public function setCustomMessage(string $userId,
330
-		?string $statusIcon,
331
-		?string $message,
332
-		?int $clearAt): UserStatus {
333
-		try {
334
-			$userStatus = $this->mapper->findByUserId($userId);
335
-		} catch (DoesNotExistException $ex) {
336
-			$userStatus = new UserStatus();
337
-			$userStatus->setUserId($userId);
338
-			$userStatus->setStatus(IUserStatus::OFFLINE);
339
-			$userStatus->setStatusTimestamp(0);
340
-			$userStatus->setIsUserDefined(false);
341
-		}
342
-
343
-		// Check if statusIcon contains only one character
344
-		if ($statusIcon !== null && !$this->emojiHelper->isValidSingleEmoji($statusIcon)) {
345
-			throw new InvalidStatusIconException('Status-Icon is longer than one character');
346
-		}
347
-		// Check for maximum length of custom message
348
-		if ($message !== null && \mb_strlen($message) > self::MAXIMUM_MESSAGE_LENGTH) {
349
-			throw new StatusMessageTooLongException('Message is longer than supported length of ' . self::MAXIMUM_MESSAGE_LENGTH . ' characters');
350
-		}
351
-		// Check that clearAt is in the future
352
-		if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
353
-			throw new InvalidClearAtException('ClearAt is in the past');
354
-		}
355
-
356
-		$userStatus->setMessageId(null);
357
-		$userStatus->setCustomIcon($statusIcon);
358
-		$userStatus->setCustomMessage($message);
359
-		$userStatus->setClearAt($clearAt);
360
-		$userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
361
-
362
-		if ($userStatus->getId() === null) {
363
-			return $this->insertWithoutThrowingUniqueConstrain($userStatus);
364
-		}
365
-
366
-		return $this->mapper->update($userStatus);
367
-	}
368
-
369
-	/**
370
-	 * @param string $userId
371
-	 * @return bool
372
-	 */
373
-	public function clearStatus(string $userId): bool {
374
-		try {
375
-			$userStatus = $this->mapper->findByUserId($userId);
376
-		} catch (DoesNotExistException $ex) {
377
-			// if there is no status to remove, just return
378
-			return false;
379
-		}
380
-
381
-		$userStatus->setStatus(IUserStatus::OFFLINE);
382
-		$userStatus->setStatusTimestamp(0);
383
-		$userStatus->setIsUserDefined(false);
384
-
385
-		$this->mapper->update($userStatus);
386
-		return true;
387
-	}
388
-
389
-	/**
390
-	 * @param string $userId
391
-	 * @return bool
392
-	 */
393
-	public function clearMessage(string $userId): bool {
394
-		try {
395
-			$userStatus = $this->mapper->findByUserId($userId);
396
-		} catch (DoesNotExistException $ex) {
397
-			// if there is no status to remove, just return
398
-			return false;
399
-		}
400
-
401
-		$userStatus->setMessageId(null);
402
-		$userStatus->setCustomMessage(null);
403
-		$userStatus->setCustomIcon(null);
404
-		$userStatus->setClearAt(null);
405
-		$userStatus->setStatusMessageTimestamp(0);
406
-
407
-		$this->mapper->update($userStatus);
408
-		return true;
409
-	}
410
-
411
-	/**
412
-	 * @param string $userId
413
-	 * @return bool
414
-	 */
415
-	public function removeUserStatus(string $userId): bool {
416
-		try {
417
-			$userStatus = $this->mapper->findByUserId($userId, false);
418
-		} catch (DoesNotExistException $ex) {
419
-			// if there is no status to remove, just return
420
-			return false;
421
-		}
422
-
423
-		$this->mapper->delete($userStatus);
424
-		return true;
425
-	}
426
-
427
-	public function removeBackupUserStatus(string $userId): bool {
428
-		try {
429
-			$userStatus = $this->mapper->findByUserId($userId, true);
430
-		} catch (DoesNotExistException $ex) {
431
-			// if there is no status to remove, just return
432
-			return false;
433
-		}
434
-
435
-		$this->mapper->delete($userStatus);
436
-		return true;
437
-	}
438
-
439
-	/**
440
-	 * Processes a status to check if custom message is still
441
-	 * up to date and provides translated default status if needed
442
-	 *
443
-	 * @param UserStatus $status
444
-	 * @return UserStatus
445
-	 */
446
-	private function processStatus(UserStatus $status): UserStatus {
447
-		$clearAt = $status->getClearAt();
448
-
449
-		if ($status->getStatusTimestamp() < $this->timeFactory->getTime() - self::INVALIDATE_STATUS_THRESHOLD
450
-			&& (!$status->getIsUserDefined() || $status->getStatus() === IUserStatus::ONLINE)) {
451
-			$this->cleanStatus($status);
452
-		}
453
-		if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
454
-			$this->cleanStatus($status);
455
-			$this->cleanStatusMessage($status);
456
-		}
457
-		if ($status->getMessageId() !== null) {
458
-			$this->addDefaultMessage($status);
459
-		}
460
-
461
-		return $status;
462
-	}
463
-
464
-	/**
465
-	 * @param UserStatus $status
466
-	 */
467
-	private function cleanStatus(UserStatus $status): void {
468
-		if ($status->getStatus() === IUserStatus::OFFLINE && !$status->getIsUserDefined()) {
469
-			return;
470
-		}
471
-
472
-		$status->setStatus(IUserStatus::OFFLINE);
473
-		$status->setStatusTimestamp($this->timeFactory->getTime());
474
-		$status->setIsUserDefined(false);
475
-
476
-		$this->mapper->update($status);
477
-	}
478
-
479
-	/**
480
-	 * @param UserStatus $status
481
-	 */
482
-	private function cleanStatusMessage(UserStatus $status): void {
483
-		$status->setMessageId(null);
484
-		$status->setCustomIcon(null);
485
-		$status->setCustomMessage(null);
486
-		$status->setClearAt(null);
487
-		$status->setStatusMessageTimestamp(0);
488
-
489
-		$this->mapper->update($status);
490
-	}
491
-
492
-	/**
493
-	 * @param UserStatus $status
494
-	 */
495
-	private function addDefaultMessage(UserStatus $status): void {
496
-		// If the message is predefined, insert the translated message and icon
497
-		$predefinedMessage = $this->predefinedStatusService->getDefaultStatusById($status->getMessageId());
498
-		if ($predefinedMessage === null) {
499
-			return;
500
-		}
501
-		// If there is a custom message, don't overwrite it
502
-		if (empty($status->getCustomMessage())) {
503
-			$status->setCustomMessage($predefinedMessage['message']);
504
-		}
505
-		if (empty($status->getCustomIcon())) {
506
-			$status->setCustomIcon($predefinedMessage['icon']);
507
-		}
508
-	}
509
-
510
-	/**
511
-	 * @return bool false if there is already a backup. In this case abort the procedure.
512
-	 */
513
-	public function backupCurrentStatus(string $userId): bool {
514
-		try {
515
-			$this->mapper->createBackupStatus($userId);
516
-			return true;
517
-		} catch (Exception $ex) {
518
-			if ($ex->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
519
-				return false;
520
-			}
521
-			throw $ex;
522
-		}
523
-	}
524
-
525
-	public function revertUserStatus(string $userId, string $messageId, bool $revertedManually = false): ?UserStatus {
526
-		try {
527
-			/** @var UserStatus $userStatus */
528
-			$backupUserStatus = $this->mapper->findByUserId($userId, true);
529
-		} catch (DoesNotExistException $ex) {
530
-			// No user status to revert, do nothing
531
-			return null;
532
-		}
533
-
534
-		$deleted = $this->mapper->deleteCurrentStatusToRestoreBackup($userId, $messageId);
535
-		if (!$deleted) {
536
-			// Another status is set automatically or no status, do nothing
537
-			return null;
538
-		}
539
-
540
-		if ($revertedManually) {
541
-			if ($backupUserStatus->getStatus() === IUserStatus::OFFLINE) {
542
-				// When the user reverts the status manually they are online
543
-				$backupUserStatus->setStatus(IUserStatus::ONLINE);
544
-			}
545
-			$backupUserStatus->setStatusTimestamp($this->timeFactory->getTime());
546
-		}
547
-
548
-		$backupUserStatus->setIsBackup(false);
549
-		// Remove the underscore prefix added when creating the backup
550
-		$backupUserStatus->setUserId(substr($backupUserStatus->getUserId(), 1));
551
-		$this->mapper->update($backupUserStatus);
552
-
553
-		return $backupUserStatus;
554
-	}
555
-
556
-	public function revertMultipleUserStatus(array $userIds, string $messageId): void {
557
-		// Get all user statuses and the backups
558
-		$findById = $userIds;
559
-		foreach ($userIds as $userId) {
560
-			$findById[] = '_' . $userId;
561
-		}
562
-		$userStatuses = $this->mapper->findByUserIds($findById);
563
-
564
-		$backups = $restoreIds = $statuesToDelete = [];
565
-		foreach ($userStatuses as $userStatus) {
566
-			if (!$userStatus->getIsBackup()
567
-				&& $userStatus->getMessageId() === $messageId) {
568
-				$statuesToDelete[$userStatus->getUserId()] = $userStatus->getId();
569
-			} elseif ($userStatus->getIsBackup()) {
570
-				$backups[$userStatus->getUserId()] = $userStatus->getId();
571
-			}
572
-		}
573
-
574
-		// For users with both (normal and backup) delete the status when matching
575
-		foreach ($statuesToDelete as $userId => $statusId) {
576
-			$backupUserId = '_' . $userId;
577
-			if (isset($backups[$backupUserId])) {
578
-				$restoreIds[] = $backups[$backupUserId];
579
-			}
580
-		}
581
-
582
-		$this->mapper->deleteByIds(array_values($statuesToDelete));
583
-
584
-		// For users that matched restore the previous status
585
-		$this->mapper->restoreBackupStatuses($restoreIds);
586
-	}
587
-
588
-	protected function insertWithoutThrowingUniqueConstrain(UserStatus $userStatus): UserStatus {
589
-		try {
590
-			return $this->mapper->insert($userStatus);
591
-		} catch (Exception $e) {
592
-			// Ignore if a parallel request already set the status
593
-			if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
594
-				throw $e;
595
-			}
596
-		}
597
-		return $userStatus;
598
-	}
34
+    private bool $shareeEnumeration;
35
+    private bool $shareeEnumerationInGroupOnly;
36
+    private bool $shareeEnumerationPhone;
37
+
38
+    /**
39
+     * List of priorities ordered by their priority
40
+     */
41
+    public const PRIORITY_ORDERED_STATUSES = [
42
+        IUserStatus::ONLINE,
43
+        IUserStatus::AWAY,
44
+        IUserStatus::DND,
45
+        IUserStatus::BUSY,
46
+        IUserStatus::INVISIBLE,
47
+        IUserStatus::OFFLINE,
48
+    ];
49
+
50
+    /**
51
+     * List of statuses that persist the clear-up
52
+     * or UserLiveStatusEvents
53
+     */
54
+    public const PERSISTENT_STATUSES = [
55
+        IUserStatus::AWAY,
56
+        IUserStatus::BUSY,
57
+        IUserStatus::DND,
58
+        IUserStatus::INVISIBLE,
59
+    ];
60
+
61
+    /** @var int */
62
+    public const INVALIDATE_STATUS_THRESHOLD = 15 /* minutes */ * 60 /* seconds */;
63
+
64
+    /** @var int */
65
+    public const MAXIMUM_MESSAGE_LENGTH = 80;
66
+
67
+    public function __construct(
68
+        private UserStatusMapper $mapper,
69
+        private ITimeFactory $timeFactory,
70
+        private PredefinedStatusService $predefinedStatusService,
71
+        private IEmojiHelper $emojiHelper,
72
+        private IConfig $config,
73
+        private IUserManager $userManager,
74
+        private LoggerInterface $logger,
75
+    ) {
76
+        $this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
77
+        $this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
78
+        $this->shareeEnumerationPhone = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
79
+    }
80
+
81
+    /**
82
+     * @param int|null $limit
83
+     * @param int|null $offset
84
+     * @return UserStatus[]
85
+     */
86
+    public function findAll(?int $limit = null, ?int $offset = null): array {
87
+        // Return empty array if user enumeration is disabled or limited to groups
88
+        // TODO: find a solution that scales to get only users from common groups if user enumeration is limited to
89
+        //       groups. See discussion at https://github.com/nextcloud/server/pull/27879#discussion_r729715936
90
+        if (!$this->shareeEnumeration || $this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone) {
91
+            return [];
92
+        }
93
+
94
+        return array_map(function ($status) {
95
+            return $this->processStatus($status);
96
+        }, $this->mapper->findAll($limit, $offset));
97
+    }
98
+
99
+    /**
100
+     * @param int|null $limit
101
+     * @param int|null $offset
102
+     * @return array
103
+     */
104
+    public function findAllRecentStatusChanges(?int $limit = null, ?int $offset = null): array {
105
+        // Return empty array if user enumeration is disabled or limited to groups
106
+        // TODO: find a solution that scales to get only users from common groups if user enumeration is limited to
107
+        //       groups. See discussion at https://github.com/nextcloud/server/pull/27879#discussion_r729715936
108
+        if (!$this->shareeEnumeration || $this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone) {
109
+            return [];
110
+        }
111
+
112
+        return array_map(function ($status) {
113
+            return $this->processStatus($status);
114
+        }, $this->mapper->findAllRecent($limit, $offset));
115
+    }
116
+
117
+    /**
118
+     * @param string $userId
119
+     * @return UserStatus
120
+     * @throws DoesNotExistException
121
+     */
122
+    public function findByUserId(string $userId): UserStatus {
123
+        return $this->processStatus($this->mapper->findByUserId($userId));
124
+    }
125
+
126
+    /**
127
+     * @param array $userIds
128
+     * @return UserStatus[]
129
+     */
130
+    public function findByUserIds(array $userIds):array {
131
+        return array_map(function ($status) {
132
+            return $this->processStatus($status);
133
+        }, $this->mapper->findByUserIds($userIds));
134
+    }
135
+
136
+    /**
137
+     * @param string $userId
138
+     * @param string $status
139
+     * @param int|null $statusTimestamp
140
+     * @param bool $isUserDefined
141
+     * @return UserStatus
142
+     * @throws InvalidStatusTypeException
143
+     */
144
+    public function setStatus(string $userId,
145
+        string $status,
146
+        ?int $statusTimestamp,
147
+        bool $isUserDefined): UserStatus {
148
+        try {
149
+            $userStatus = $this->mapper->findByUserId($userId);
150
+        } catch (DoesNotExistException $ex) {
151
+            $userStatus = new UserStatus();
152
+            $userStatus->setUserId($userId);
153
+        }
154
+
155
+        // Check if status-type is valid
156
+        if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
157
+            throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
158
+        }
159
+
160
+        if ($statusTimestamp === null) {
161
+            $statusTimestamp = $this->timeFactory->getTime();
162
+        }
163
+
164
+        $userStatus->setStatus($status);
165
+        $userStatus->setStatusTimestamp($statusTimestamp);
166
+        $userStatus->setIsUserDefined($isUserDefined);
167
+        $userStatus->setIsBackup(false);
168
+
169
+        if ($userStatus->getId() === null) {
170
+            return $this->insertWithoutThrowingUniqueConstrain($userStatus);
171
+        }
172
+
173
+        return $this->mapper->update($userStatus);
174
+    }
175
+
176
+    /**
177
+     * @param string $userId
178
+     * @param string $messageId
179
+     * @param int|null $clearAt
180
+     * @return UserStatus
181
+     * @throws InvalidMessageIdException
182
+     * @throws InvalidClearAtException
183
+     */
184
+    public function setPredefinedMessage(string $userId,
185
+        string $messageId,
186
+        ?int $clearAt): UserStatus {
187
+        try {
188
+            $userStatus = $this->mapper->findByUserId($userId);
189
+        } catch (DoesNotExistException $ex) {
190
+            $userStatus = new UserStatus();
191
+            $userStatus->setUserId($userId);
192
+            $userStatus->setStatus(IUserStatus::OFFLINE);
193
+            $userStatus->setStatusTimestamp(0);
194
+            $userStatus->setIsUserDefined(false);
195
+            $userStatus->setIsBackup(false);
196
+        }
197
+
198
+        if (!$this->predefinedStatusService->isValidId($messageId)) {
199
+            throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
200
+        }
201
+
202
+        // Check that clearAt is in the future
203
+        if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
204
+            throw new InvalidClearAtException('ClearAt is in the past');
205
+        }
206
+
207
+        $userStatus->setMessageId($messageId);
208
+        $userStatus->setCustomIcon(null);
209
+        $userStatus->setCustomMessage(null);
210
+        $userStatus->setClearAt($clearAt);
211
+        $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
212
+
213
+        if ($userStatus->getId() === null) {
214
+            return $this->insertWithoutThrowingUniqueConstrain($userStatus);
215
+        }
216
+
217
+        return $this->mapper->update($userStatus);
218
+    }
219
+
220
+    /**
221
+     * @param string $userId
222
+     * @param string $status
223
+     * @param string $messageId
224
+     * @param bool $createBackup
225
+     * @param string|null $customMessage
226
+     * @throws InvalidStatusTypeException
227
+     * @throws InvalidMessageIdException
228
+     */
229
+    public function setUserStatus(string $userId,
230
+        string $status,
231
+        string $messageId,
232
+        bool $createBackup,
233
+        ?string $customMessage = null): ?UserStatus {
234
+        // Check if status-type is valid
235
+        if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
236
+            throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
237
+        }
238
+
239
+        if (!$this->predefinedStatusService->isValidId($messageId)) {
240
+            throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
241
+        }
242
+
243
+        try {
244
+            $userStatus = $this->mapper->findByUserId($userId);
245
+        } catch (DoesNotExistException $e) {
246
+            // We don't need to do anything
247
+            $userStatus = new UserStatus();
248
+            $userStatus->setUserId($userId);
249
+        }
250
+
251
+        $updateStatus = false;
252
+        if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE) {
253
+            // OUT_OF_OFFICE trumps AVAILABILITY, CALL and CALENDAR status
254
+            $updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_AVAILABILITY || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
255
+        } elseif ($messageId === IUserStatus::MESSAGE_AVAILABILITY) {
256
+            // AVAILABILITY trumps CALL and CALENDAR status
257
+            $updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALL || $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
258
+        } elseif ($messageId === IUserStatus::MESSAGE_CALL) {
259
+            // CALL trumps CALENDAR status
260
+            $updateStatus = $userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY;
261
+        }
262
+
263
+        if ($messageId === IUserStatus::MESSAGE_OUT_OF_OFFICE || $messageId === IUserStatus::MESSAGE_AVAILABILITY || $messageId === IUserStatus::MESSAGE_CALL || $messageId === IUserStatus::MESSAGE_CALENDAR_BUSY) {
264
+            if ($updateStatus) {
265
+                $this->logger->debug('User ' . $userId . ' is currently NOT available, overwriting status [status: ' . $userStatus->getStatus() . ', messageId: ' . json_encode($userStatus->getMessageId()) . ']', ['app' => 'dav']);
266
+            } else {
267
+                $this->logger->debug('User ' . $userId . ' is currently NOT available, but we are NOT overwriting status [status: ' . $userStatus->getStatus() . ', messageId: ' . json_encode($userStatus->getMessageId()) . ']', ['app' => 'dav']);
268
+            }
269
+        }
270
+
271
+        // There should be a backup already or none is needed. So we take a shortcut.
272
+        if ($updateStatus) {
273
+            $userStatus->setStatus($status);
274
+            $userStatus->setStatusTimestamp($this->timeFactory->getTime());
275
+            $userStatus->setIsUserDefined(true);
276
+            $userStatus->setIsBackup(false);
277
+            $userStatus->setMessageId($messageId);
278
+            $userStatus->setCustomIcon(null);
279
+            $userStatus->setCustomMessage($customMessage);
280
+            $userStatus->setClearAt(null);
281
+            $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
282
+            return $this->mapper->update($userStatus);
283
+        }
284
+
285
+        if ($createBackup) {
286
+            if ($this->backupCurrentStatus($userId) === false) {
287
+                return null; // Already a status set automatically => abort.
288
+            }
289
+
290
+            // If we just created the backup
291
+            // we need to create a new status to insert
292
+            // Unfortunately there's no way to unset the DB ID on an Entity
293
+            $userStatus = new UserStatus();
294
+            $userStatus->setUserId($userId);
295
+        }
296
+
297
+        $userStatus->setStatus($status);
298
+        $userStatus->setStatusTimestamp($this->timeFactory->getTime());
299
+        $userStatus->setIsUserDefined(true);
300
+        $userStatus->setIsBackup(false);
301
+        $userStatus->setMessageId($messageId);
302
+        $userStatus->setCustomIcon(null);
303
+        $userStatus->setCustomMessage($customMessage);
304
+        $userStatus->setClearAt(null);
305
+        if ($this->predefinedStatusService->getTranslatedStatusForId($messageId) !== null
306
+            || ($customMessage !== null && $customMessage !== '')) {
307
+            // Only track status message ID if there is one
308
+            $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
309
+        } else {
310
+            $userStatus->setStatusMessageTimestamp(0);
311
+        }
312
+
313
+        if ($userStatus->getId() !== null) {
314
+            return $this->mapper->update($userStatus);
315
+        }
316
+        return $this->insertWithoutThrowingUniqueConstrain($userStatus);
317
+    }
318
+
319
+    /**
320
+     * @param string $userId
321
+     * @param string|null $statusIcon
322
+     * @param string|null $message
323
+     * @param int|null $clearAt
324
+     * @return UserStatus
325
+     * @throws InvalidClearAtException
326
+     * @throws InvalidStatusIconException
327
+     * @throws StatusMessageTooLongException
328
+     */
329
+    public function setCustomMessage(string $userId,
330
+        ?string $statusIcon,
331
+        ?string $message,
332
+        ?int $clearAt): UserStatus {
333
+        try {
334
+            $userStatus = $this->mapper->findByUserId($userId);
335
+        } catch (DoesNotExistException $ex) {
336
+            $userStatus = new UserStatus();
337
+            $userStatus->setUserId($userId);
338
+            $userStatus->setStatus(IUserStatus::OFFLINE);
339
+            $userStatus->setStatusTimestamp(0);
340
+            $userStatus->setIsUserDefined(false);
341
+        }
342
+
343
+        // Check if statusIcon contains only one character
344
+        if ($statusIcon !== null && !$this->emojiHelper->isValidSingleEmoji($statusIcon)) {
345
+            throw new InvalidStatusIconException('Status-Icon is longer than one character');
346
+        }
347
+        // Check for maximum length of custom message
348
+        if ($message !== null && \mb_strlen($message) > self::MAXIMUM_MESSAGE_LENGTH) {
349
+            throw new StatusMessageTooLongException('Message is longer than supported length of ' . self::MAXIMUM_MESSAGE_LENGTH . ' characters');
350
+        }
351
+        // Check that clearAt is in the future
352
+        if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
353
+            throw new InvalidClearAtException('ClearAt is in the past');
354
+        }
355
+
356
+        $userStatus->setMessageId(null);
357
+        $userStatus->setCustomIcon($statusIcon);
358
+        $userStatus->setCustomMessage($message);
359
+        $userStatus->setClearAt($clearAt);
360
+        $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
361
+
362
+        if ($userStatus->getId() === null) {
363
+            return $this->insertWithoutThrowingUniqueConstrain($userStatus);
364
+        }
365
+
366
+        return $this->mapper->update($userStatus);
367
+    }
368
+
369
+    /**
370
+     * @param string $userId
371
+     * @return bool
372
+     */
373
+    public function clearStatus(string $userId): bool {
374
+        try {
375
+            $userStatus = $this->mapper->findByUserId($userId);
376
+        } catch (DoesNotExistException $ex) {
377
+            // if there is no status to remove, just return
378
+            return false;
379
+        }
380
+
381
+        $userStatus->setStatus(IUserStatus::OFFLINE);
382
+        $userStatus->setStatusTimestamp(0);
383
+        $userStatus->setIsUserDefined(false);
384
+
385
+        $this->mapper->update($userStatus);
386
+        return true;
387
+    }
388
+
389
+    /**
390
+     * @param string $userId
391
+     * @return bool
392
+     */
393
+    public function clearMessage(string $userId): bool {
394
+        try {
395
+            $userStatus = $this->mapper->findByUserId($userId);
396
+        } catch (DoesNotExistException $ex) {
397
+            // if there is no status to remove, just return
398
+            return false;
399
+        }
400
+
401
+        $userStatus->setMessageId(null);
402
+        $userStatus->setCustomMessage(null);
403
+        $userStatus->setCustomIcon(null);
404
+        $userStatus->setClearAt(null);
405
+        $userStatus->setStatusMessageTimestamp(0);
406
+
407
+        $this->mapper->update($userStatus);
408
+        return true;
409
+    }
410
+
411
+    /**
412
+     * @param string $userId
413
+     * @return bool
414
+     */
415
+    public function removeUserStatus(string $userId): bool {
416
+        try {
417
+            $userStatus = $this->mapper->findByUserId($userId, false);
418
+        } catch (DoesNotExistException $ex) {
419
+            // if there is no status to remove, just return
420
+            return false;
421
+        }
422
+
423
+        $this->mapper->delete($userStatus);
424
+        return true;
425
+    }
426
+
427
+    public function removeBackupUserStatus(string $userId): bool {
428
+        try {
429
+            $userStatus = $this->mapper->findByUserId($userId, true);
430
+        } catch (DoesNotExistException $ex) {
431
+            // if there is no status to remove, just return
432
+            return false;
433
+        }
434
+
435
+        $this->mapper->delete($userStatus);
436
+        return true;
437
+    }
438
+
439
+    /**
440
+     * Processes a status to check if custom message is still
441
+     * up to date and provides translated default status if needed
442
+     *
443
+     * @param UserStatus $status
444
+     * @return UserStatus
445
+     */
446
+    private function processStatus(UserStatus $status): UserStatus {
447
+        $clearAt = $status->getClearAt();
448
+
449
+        if ($status->getStatusTimestamp() < $this->timeFactory->getTime() - self::INVALIDATE_STATUS_THRESHOLD
450
+            && (!$status->getIsUserDefined() || $status->getStatus() === IUserStatus::ONLINE)) {
451
+            $this->cleanStatus($status);
452
+        }
453
+        if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
454
+            $this->cleanStatus($status);
455
+            $this->cleanStatusMessage($status);
456
+        }
457
+        if ($status->getMessageId() !== null) {
458
+            $this->addDefaultMessage($status);
459
+        }
460
+
461
+        return $status;
462
+    }
463
+
464
+    /**
465
+     * @param UserStatus $status
466
+     */
467
+    private function cleanStatus(UserStatus $status): void {
468
+        if ($status->getStatus() === IUserStatus::OFFLINE && !$status->getIsUserDefined()) {
469
+            return;
470
+        }
471
+
472
+        $status->setStatus(IUserStatus::OFFLINE);
473
+        $status->setStatusTimestamp($this->timeFactory->getTime());
474
+        $status->setIsUserDefined(false);
475
+
476
+        $this->mapper->update($status);
477
+    }
478
+
479
+    /**
480
+     * @param UserStatus $status
481
+     */
482
+    private function cleanStatusMessage(UserStatus $status): void {
483
+        $status->setMessageId(null);
484
+        $status->setCustomIcon(null);
485
+        $status->setCustomMessage(null);
486
+        $status->setClearAt(null);
487
+        $status->setStatusMessageTimestamp(0);
488
+
489
+        $this->mapper->update($status);
490
+    }
491
+
492
+    /**
493
+     * @param UserStatus $status
494
+     */
495
+    private function addDefaultMessage(UserStatus $status): void {
496
+        // If the message is predefined, insert the translated message and icon
497
+        $predefinedMessage = $this->predefinedStatusService->getDefaultStatusById($status->getMessageId());
498
+        if ($predefinedMessage === null) {
499
+            return;
500
+        }
501
+        // If there is a custom message, don't overwrite it
502
+        if (empty($status->getCustomMessage())) {
503
+            $status->setCustomMessage($predefinedMessage['message']);
504
+        }
505
+        if (empty($status->getCustomIcon())) {
506
+            $status->setCustomIcon($predefinedMessage['icon']);
507
+        }
508
+    }
509
+
510
+    /**
511
+     * @return bool false if there is already a backup. In this case abort the procedure.
512
+     */
513
+    public function backupCurrentStatus(string $userId): bool {
514
+        try {
515
+            $this->mapper->createBackupStatus($userId);
516
+            return true;
517
+        } catch (Exception $ex) {
518
+            if ($ex->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
519
+                return false;
520
+            }
521
+            throw $ex;
522
+        }
523
+    }
524
+
525
+    public function revertUserStatus(string $userId, string $messageId, bool $revertedManually = false): ?UserStatus {
526
+        try {
527
+            /** @var UserStatus $userStatus */
528
+            $backupUserStatus = $this->mapper->findByUserId($userId, true);
529
+        } catch (DoesNotExistException $ex) {
530
+            // No user status to revert, do nothing
531
+            return null;
532
+        }
533
+
534
+        $deleted = $this->mapper->deleteCurrentStatusToRestoreBackup($userId, $messageId);
535
+        if (!$deleted) {
536
+            // Another status is set automatically or no status, do nothing
537
+            return null;
538
+        }
539
+
540
+        if ($revertedManually) {
541
+            if ($backupUserStatus->getStatus() === IUserStatus::OFFLINE) {
542
+                // When the user reverts the status manually they are online
543
+                $backupUserStatus->setStatus(IUserStatus::ONLINE);
544
+            }
545
+            $backupUserStatus->setStatusTimestamp($this->timeFactory->getTime());
546
+        }
547
+
548
+        $backupUserStatus->setIsBackup(false);
549
+        // Remove the underscore prefix added when creating the backup
550
+        $backupUserStatus->setUserId(substr($backupUserStatus->getUserId(), 1));
551
+        $this->mapper->update($backupUserStatus);
552
+
553
+        return $backupUserStatus;
554
+    }
555
+
556
+    public function revertMultipleUserStatus(array $userIds, string $messageId): void {
557
+        // Get all user statuses and the backups
558
+        $findById = $userIds;
559
+        foreach ($userIds as $userId) {
560
+            $findById[] = '_' . $userId;
561
+        }
562
+        $userStatuses = $this->mapper->findByUserIds($findById);
563
+
564
+        $backups = $restoreIds = $statuesToDelete = [];
565
+        foreach ($userStatuses as $userStatus) {
566
+            if (!$userStatus->getIsBackup()
567
+                && $userStatus->getMessageId() === $messageId) {
568
+                $statuesToDelete[$userStatus->getUserId()] = $userStatus->getId();
569
+            } elseif ($userStatus->getIsBackup()) {
570
+                $backups[$userStatus->getUserId()] = $userStatus->getId();
571
+            }
572
+        }
573
+
574
+        // For users with both (normal and backup) delete the status when matching
575
+        foreach ($statuesToDelete as $userId => $statusId) {
576
+            $backupUserId = '_' . $userId;
577
+            if (isset($backups[$backupUserId])) {
578
+                $restoreIds[] = $backups[$backupUserId];
579
+            }
580
+        }
581
+
582
+        $this->mapper->deleteByIds(array_values($statuesToDelete));
583
+
584
+        // For users that matched restore the previous status
585
+        $this->mapper->restoreBackupStatuses($restoreIds);
586
+    }
587
+
588
+    protected function insertWithoutThrowingUniqueConstrain(UserStatus $userStatus): UserStatus {
589
+        try {
590
+            return $this->mapper->insert($userStatus);
591
+        } catch (Exception $e) {
592
+            // Ignore if a parallel request already set the status
593
+            if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
594
+                throw $e;
595
+            }
596
+        }
597
+        return $userStatus;
598
+    }
599 599
 }
Please login to merge, or discard this patch.