123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563 |
- <?php
- declare(strict_types=1);
- /**
- * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
- namespace OCA\UserStatus\Service;
- use OCA\UserStatus\Db\UserStatus;
- use OCA\UserStatus\Db\UserStatusMapper;
- use OCA\UserStatus\Exception\InvalidClearAtException;
- use OCA\UserStatus\Exception\InvalidMessageIdException;
- use OCA\UserStatus\Exception\InvalidStatusIconException;
- use OCA\UserStatus\Exception\InvalidStatusTypeException;
- use OCA\UserStatus\Exception\StatusMessageTooLongException;
- use OCP\AppFramework\Db\DoesNotExistException;
- use OCP\AppFramework\Utility\ITimeFactory;
- use OCP\DB\Exception;
- use OCP\IConfig;
- use OCP\IEmojiHelper;
- use OCP\IUserManager;
- use OCP\UserStatus\IUserStatus;
- use function in_array;
- /**
- * Class StatusService
- *
- * @package OCA\UserStatus\Service
- */
- class StatusService {
- private bool $shareeEnumeration;
- private bool $shareeEnumerationInGroupOnly;
- private bool $shareeEnumerationPhone;
- /**
- * List of priorities ordered by their priority
- */
- public const PRIORITY_ORDERED_STATUSES = [
- IUserStatus::ONLINE,
- IUserStatus::AWAY,
- IUserStatus::DND,
- IUserStatus::BUSY,
- IUserStatus::INVISIBLE,
- IUserStatus::OFFLINE,
- ];
- /**
- * List of statuses that persist the clear-up
- * or UserLiveStatusEvents
- */
- public const PERSISTENT_STATUSES = [
- IUserStatus::AWAY,
- IUserStatus::BUSY,
- IUserStatus::DND,
- IUserStatus::INVISIBLE,
- ];
- /** @var int */
- public const INVALIDATE_STATUS_THRESHOLD = 15 /* minutes */ * 60 /* seconds */;
- /** @var int */
- public const MAXIMUM_MESSAGE_LENGTH = 80;
- public function __construct(private UserStatusMapper $mapper,
- private ITimeFactory $timeFactory,
- private PredefinedStatusService $predefinedStatusService,
- private IEmojiHelper $emojiHelper,
- private IConfig $config,
- private IUserManager $userManager) {
- $this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
- $this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
- $this->shareeEnumerationPhone = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
- }
- /**
- * @param int|null $limit
- * @param int|null $offset
- * @return UserStatus[]
- */
- public function findAll(?int $limit = null, ?int $offset = null): array {
- // Return empty array if user enumeration is disabled or limited to groups
- // TODO: find a solution that scales to get only users from common groups if user enumeration is limited to
- // groups. See discussion at https://github.com/nextcloud/server/pull/27879#discussion_r729715936
- if (!$this->shareeEnumeration || $this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone) {
- return [];
- }
- return array_map(function ($status) {
- return $this->processStatus($status);
- }, $this->mapper->findAll($limit, $offset));
- }
- /**
- * @param int|null $limit
- * @param int|null $offset
- * @return array
- */
- public function findAllRecentStatusChanges(?int $limit = null, ?int $offset = null): array {
- // Return empty array if user enumeration is disabled or limited to groups
- // TODO: find a solution that scales to get only users from common groups if user enumeration is limited to
- // groups. See discussion at https://github.com/nextcloud/server/pull/27879#discussion_r729715936
- if (!$this->shareeEnumeration || $this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone) {
- return [];
- }
- return array_map(function ($status) {
- return $this->processStatus($status);
- }, $this->mapper->findAllRecent($limit, $offset));
- }
- /**
- * @param string $userId
- * @return UserStatus
- * @throws DoesNotExistException
- */
- public function findByUserId(string $userId): UserStatus {
- return $this->processStatus($this->mapper->findByUserId($userId));
- }
- /**
- * @param array $userIds
- * @return UserStatus[]
- */
- public function findByUserIds(array $userIds):array {
- return array_map(function ($status) {
- return $this->processStatus($status);
- }, $this->mapper->findByUserIds($userIds));
- }
- /**
- * @param string $userId
- * @param string $status
- * @param int|null $statusTimestamp
- * @param bool $isUserDefined
- * @return UserStatus
- * @throws InvalidStatusTypeException
- */
- public function setStatus(string $userId,
- string $status,
- ?int $statusTimestamp,
- bool $isUserDefined): UserStatus {
- try {
- $userStatus = $this->mapper->findByUserId($userId);
- } catch (DoesNotExistException $ex) {
- $userStatus = new UserStatus();
- $userStatus->setUserId($userId);
- }
- // Check if status-type is valid
- if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
- throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
- }
- if ($statusTimestamp === null) {
- $statusTimestamp = $this->timeFactory->getTime();
- }
- $userStatus->setStatus($status);
- $userStatus->setStatusTimestamp($statusTimestamp);
- $userStatus->setIsUserDefined($isUserDefined);
- $userStatus->setIsBackup(false);
- if ($userStatus->getId() === null) {
- return $this->mapper->insert($userStatus);
- }
- return $this->mapper->update($userStatus);
- }
- /**
- * @param string $userId
- * @param string $messageId
- * @param int|null $clearAt
- * @return UserStatus
- * @throws InvalidMessageIdException
- * @throws InvalidClearAtException
- */
- public function setPredefinedMessage(string $userId,
- string $messageId,
- ?int $clearAt): UserStatus {
- try {
- $userStatus = $this->mapper->findByUserId($userId);
- } catch (DoesNotExistException $ex) {
- $userStatus = new UserStatus();
- $userStatus->setUserId($userId);
- $userStatus->setStatus(IUserStatus::OFFLINE);
- $userStatus->setStatusTimestamp(0);
- $userStatus->setIsUserDefined(false);
- $userStatus->setIsBackup(false);
- }
- if (!$this->predefinedStatusService->isValidId($messageId)) {
- throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
- }
- // Check that clearAt is in the future
- if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
- throw new InvalidClearAtException('ClearAt is in the past');
- }
- $userStatus->setMessageId($messageId);
- $userStatus->setCustomIcon(null);
- $userStatus->setCustomMessage(null);
- $userStatus->setClearAt($clearAt);
- $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
- if ($userStatus->getId() === null) {
- return $this->mapper->insert($userStatus);
- }
- return $this->mapper->update($userStatus);
- }
- /**
- * @param string $userId
- * @param string $status
- * @param string $messageId
- * @param bool $createBackup
- * @param string|null $customMessage
- * @throws InvalidStatusTypeException
- * @throws InvalidMessageIdException
- */
- public function setUserStatus(string $userId,
- string $status,
- string $messageId,
- bool $createBackup,
- ?string $customMessage = null): ?UserStatus {
- // Check if status-type is valid
- if (!in_array($status, self::PRIORITY_ORDERED_STATUSES, true)) {
- throw new InvalidStatusTypeException('Status-type "' . $status . '" is not supported');
- }
- if (!$this->predefinedStatusService->isValidId($messageId)) {
- throw new InvalidMessageIdException('Message-Id "' . $messageId . '" is not supported');
- }
- try {
- $userStatus = $this->mapper->findByUserId($userId);
- } catch (DoesNotExistException $e) {
- // We don't need to do anything
- $userStatus = new UserStatus();
- $userStatus->setUserId($userId);
- }
- // CALL trumps CALENDAR status, but we don't need to do anything but overwrite the message
- if ($userStatus->getMessageId() === IUserStatus::MESSAGE_CALENDAR_BUSY && $messageId === IUserStatus::MESSAGE_CALL) {
- $userStatus->setStatus($status);
- $userStatus->setStatusTimestamp($this->timeFactory->getTime());
- $userStatus->setIsUserDefined(true);
- $userStatus->setIsBackup(false);
- $userStatus->setMessageId($messageId);
- $userStatus->setCustomIcon(null);
- $userStatus->setCustomMessage($customMessage);
- $userStatus->setClearAt(null);
- $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
- return $this->mapper->update($userStatus);
- }
- if ($createBackup) {
- if ($this->backupCurrentStatus($userId) === false) {
- return null; // Already a status set automatically => abort.
- }
- // If we just created the backup
- // we need to create a new status to insert
- // Unfortunatley there's no way to unset the DB ID on an Entity
- $userStatus = new UserStatus();
- $userStatus->setUserId($userId);
- }
- $userStatus->setStatus($status);
- $userStatus->setStatusTimestamp($this->timeFactory->getTime());
- $userStatus->setIsUserDefined(true);
- $userStatus->setIsBackup(false);
- $userStatus->setMessageId($messageId);
- $userStatus->setCustomIcon(null);
- $userStatus->setCustomMessage($customMessage);
- $userStatus->setClearAt(null);
- if ($this->predefinedStatusService->getTranslatedStatusForId($messageId) !== null
- || ($customMessage !== null && $customMessage !== '')) {
- // Only track status message ID if there is one
- $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
- } else {
- $userStatus->setStatusMessageTimestamp(0);
- }
- if ($userStatus->getId() !== null) {
- return $this->mapper->update($userStatus);
- }
- return $this->mapper->insert($userStatus);
- }
- /**
- * @param string $userId
- * @param string|null $statusIcon
- * @param string|null $message
- * @param int|null $clearAt
- * @return UserStatus
- * @throws InvalidClearAtException
- * @throws InvalidStatusIconException
- * @throws StatusMessageTooLongException
- */
- public function setCustomMessage(string $userId,
- ?string $statusIcon,
- ?string $message,
- ?int $clearAt): UserStatus {
- try {
- $userStatus = $this->mapper->findByUserId($userId);
- } catch (DoesNotExistException $ex) {
- $userStatus = new UserStatus();
- $userStatus->setUserId($userId);
- $userStatus->setStatus(IUserStatus::OFFLINE);
- $userStatus->setStatusTimestamp(0);
- $userStatus->setIsUserDefined(false);
- }
- // Check if statusIcon contains only one character
- if ($statusIcon !== null && !$this->emojiHelper->isValidSingleEmoji($statusIcon)) {
- throw new InvalidStatusIconException('Status-Icon is longer than one character');
- }
- // Check for maximum length of custom message
- if ($message !== null && \mb_strlen($message) > self::MAXIMUM_MESSAGE_LENGTH) {
- throw new StatusMessageTooLongException('Message is longer than supported length of ' . self::MAXIMUM_MESSAGE_LENGTH . ' characters');
- }
- // Check that clearAt is in the future
- if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
- throw new InvalidClearAtException('ClearAt is in the past');
- }
- $userStatus->setMessageId(null);
- $userStatus->setCustomIcon($statusIcon);
- $userStatus->setCustomMessage($message);
- $userStatus->setClearAt($clearAt);
- $userStatus->setStatusMessageTimestamp($this->timeFactory->now()->getTimestamp());
- if ($userStatus->getId() === null) {
- return $this->mapper->insert($userStatus);
- }
- return $this->mapper->update($userStatus);
- }
- /**
- * @param string $userId
- * @return bool
- */
- public function clearStatus(string $userId): bool {
- try {
- $userStatus = $this->mapper->findByUserId($userId);
- } catch (DoesNotExistException $ex) {
- // if there is no status to remove, just return
- return false;
- }
- $userStatus->setStatus(IUserStatus::OFFLINE);
- $userStatus->setStatusTimestamp(0);
- $userStatus->setIsUserDefined(false);
- $this->mapper->update($userStatus);
- return true;
- }
- /**
- * @param string $userId
- * @return bool
- */
- public function clearMessage(string $userId): bool {
- try {
- $userStatus = $this->mapper->findByUserId($userId);
- } catch (DoesNotExistException $ex) {
- // if there is no status to remove, just return
- return false;
- }
- $userStatus->setMessageId(null);
- $userStatus->setCustomMessage(null);
- $userStatus->setCustomIcon(null);
- $userStatus->setClearAt(null);
- $userStatus->setStatusMessageTimestamp(0);
- $this->mapper->update($userStatus);
- return true;
- }
- /**
- * @param string $userId
- * @return bool
- */
- public function removeUserStatus(string $userId): bool {
- try {
- $userStatus = $this->mapper->findByUserId($userId, false);
- } catch (DoesNotExistException $ex) {
- // if there is no status to remove, just return
- return false;
- }
- $this->mapper->delete($userStatus);
- return true;
- }
- public function removeBackupUserStatus(string $userId): bool {
- try {
- $userStatus = $this->mapper->findByUserId($userId, true);
- } catch (DoesNotExistException $ex) {
- // if there is no status to remove, just return
- return false;
- }
- $this->mapper->delete($userStatus);
- return true;
- }
- /**
- * Processes a status to check if custom message is still
- * up to date and provides translated default status if needed
- *
- * @param UserStatus $status
- * @return UserStatus
- */
- private function processStatus(UserStatus $status): UserStatus {
- $clearAt = $status->getClearAt();
- if ($status->getStatusTimestamp() < $this->timeFactory->getTime() - self::INVALIDATE_STATUS_THRESHOLD
- && (!$status->getIsUserDefined() || $status->getStatus() === IUserStatus::ONLINE)) {
- $this->cleanStatus($status);
- }
- if ($clearAt !== null && $clearAt < $this->timeFactory->getTime()) {
- $this->cleanStatus($status);
- $this->cleanStatusMessage($status);
- }
- if ($status->getMessageId() !== null) {
- $this->addDefaultMessage($status);
- }
- return $status;
- }
- /**
- * @param UserStatus $status
- */
- private function cleanStatus(UserStatus $status): void {
- if ($status->getStatus() === IUserStatus::OFFLINE && !$status->getIsUserDefined()) {
- return;
- }
- $status->setStatus(IUserStatus::OFFLINE);
- $status->setStatusTimestamp($this->timeFactory->getTime());
- $status->setIsUserDefined(false);
- $this->mapper->update($status);
- }
- /**
- * @param UserStatus $status
- */
- private function cleanStatusMessage(UserStatus $status): void {
- $status->setMessageId(null);
- $status->setCustomIcon(null);
- $status->setCustomMessage(null);
- $status->setClearAt(null);
- $status->setStatusMessageTimestamp(0);
- $this->mapper->update($status);
- }
- /**
- * @param UserStatus $status
- */
- private function addDefaultMessage(UserStatus $status): void {
- // If the message is predefined, insert the translated message and icon
- $predefinedMessage = $this->predefinedStatusService->getDefaultStatusById($status->getMessageId());
- if ($predefinedMessage === null) {
- return;
- }
- // If there is a custom message, don't overwrite it
- if(empty($status->getCustomMessage())) {
- $status->setCustomMessage($predefinedMessage['message']);
- }
- if(empty($status->getCustomIcon())) {
- $status->setCustomIcon($predefinedMessage['icon']);
- }
- }
- /**
- * @return bool false if there is already a backup. In this case abort the procedure.
- */
- public function backupCurrentStatus(string $userId): bool {
- try {
- $this->mapper->createBackupStatus($userId);
- return true;
- } catch (Exception $ex) {
- if ($ex->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
- return false;
- }
- throw $ex;
- }
- }
- public function revertUserStatus(string $userId, string $messageId, bool $revertedManually = false): ?UserStatus {
- try {
- /** @var UserStatus $userStatus */
- $backupUserStatus = $this->mapper->findByUserId($userId, true);
- } catch (DoesNotExistException $ex) {
- // No user status to revert, do nothing
- return null;
- }
- $deleted = $this->mapper->deleteCurrentStatusToRestoreBackup($userId, $messageId);
- if (!$deleted) {
- // Another status is set automatically or no status, do nothing
- return null;
- }
- if ($revertedManually) {
- if ($backupUserStatus->getStatus() === IUserStatus::OFFLINE) {
- // When the user reverts the status manually they are online
- $backupUserStatus->setStatus(IUserStatus::ONLINE);
- }
- $backupUserStatus->setStatusTimestamp($this->timeFactory->getTime());
- }
- $backupUserStatus->setIsBackup(false);
- // Remove the underscore prefix added when creating the backup
- $backupUserStatus->setUserId(substr($backupUserStatus->getUserId(), 1));
- $this->mapper->update($backupUserStatus);
- return $backupUserStatus;
- }
- public function revertMultipleUserStatus(array $userIds, string $messageId): void {
- // Get all user statuses and the backups
- $findById = $userIds;
- foreach ($userIds as $userId) {
- $findById[] = '_' . $userId;
- }
- $userStatuses = $this->mapper->findByUserIds($findById);
- $backups = $restoreIds = $statuesToDelete = [];
- foreach ($userStatuses as $userStatus) {
- if (!$userStatus->getIsBackup()
- && $userStatus->getMessageId() === $messageId) {
- $statuesToDelete[$userStatus->getUserId()] = $userStatus->getId();
- } elseif ($userStatus->getIsBackup()) {
- $backups[$userStatus->getUserId()] = $userStatus->getId();
- }
- }
- // For users with both (normal and backup) delete the status when matching
- foreach ($statuesToDelete as $userId => $statusId) {
- $backupUserId = '_' . $userId;
- if (isset($backups[$backupUserId])) {
- $restoreIds[] = $backups[$backupUserId];
- }
- }
- $this->mapper->deleteByIds(array_values($statuesToDelete));
- // For users that matched restore the previous status
- $this->mapper->restoreBackupStatuses($restoreIds);
- }
- }
|