diff --git a/.drone.yml b/.drone.yml index f2535429e02..c4b91b7730a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -378,12 +378,14 @@ steps: environment: APP_NAME: spreed CORE_BRANCH: master + GUESTS_BRANCH: master DATABASEHOST: sqlite commands: - bash tests/drone-run-integration-tests.sh || exit 0 - wget https://raw.githubusercontent.com/nextcloud/travis_ci/master/before_install.sh - bash ./before_install.sh $APP_NAME $CORE_BRANCH $DATABASEHOST - cd ../server + - git clone --depth 1 -b "$GUESTS_BRANCH" https://github.com/nextcloud/guests apps/guests - ./occ app:enable $APP_NAME - cd apps/$APP_NAME diff --git a/appinfo/info.xml b/appinfo/info.xml index e737c0346de..f8f54e929f0 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -16,7 +16,7 @@ And in the works for the [coming versions](https://github.com/nextcloud/spreed/m ]]> - 11.0.0-dev.9 + 11.0.0-dev.10 agpl Daniel Calviño Sánchez diff --git a/appinfo/routes.php b/appinfo/routes.php index 1148c4db9e3..1c6aed1ad11 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -183,6 +183,14 @@ 'apiVersion' => 'v(1|2|3)', ], ], + [ + 'name' => 'Room#getListedRooms', + 'url' => '/api/{apiVersion}/listed-room', + 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => 'v3', + ], + ], [ 'name' => 'Room#createRoom', 'url' => '/api/{apiVersion}/room', @@ -254,6 +262,15 @@ 'token' => '^[a-z0-9]{4,30}$', ], ], + [ + 'name' => 'Room#setListable', + 'url' => '/api/{apiVersion}/room/{token}/listable', + 'verb' => 'PUT', + 'requirements' => [ + 'apiVersion' => 'v3', + 'token' => '^[a-z0-9]{4,30}$', + ], + ], [ 'name' => 'Room#setPassword', 'url' => '/api/{apiVersion}/room/{token}/password', diff --git a/docs/capabilities.md b/docs/capabilities.md index 6bf6a30eca0..22fca01ff04 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -59,3 +59,4 @@ title: Capabilities * `config => previews => max-gif-size` - Maximum size in bytes below which a GIF can be embedded directly in the page at render time. Bigger files will be rendered statically using the preview endpoint instead. Can be set with `occ config:app:set spreed max-gif-size --value=X` where X is the new value in bytes. Defaults to 3 MB. * `chat-read-status` - On conversation API v3 and the chat API the last common read message is exposed which can be used to update the "read status" flag of own chat messages. The info should be shown only when the user also shares their read status. The user's value can be found in `config => chat => read-privacy`. * `config => chat => read-privacy` - See `chat-read-status` +* `listable-rooms` - Conversations can searched for even when not joined. A "listable" attribute set on rooms defines the scope of who can find it. diff --git a/docs/chat.md b/docs/chat.md index 11191c68ff7..95700a8c911 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -149,7 +149,7 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` `status` | string | Optional: Only available with `includeStatus=true` and for users with a set status `statusIcon` | string | Optional: Only available with `includeStatus=true` and for users with a set status `statusMessage` | string | Optional: Only available with `includeStatus=true` and for users with a set status - + ## System messages * `conversation_created` - {actor} created the conversation @@ -160,6 +160,9 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` * `call_ended` - Call with {user1}, {user2}, {user3}, {user4} and {user5} (Duration 30:23) * `read_only_off` - {actor} unlocked the conversation * `read_only` - {actor} locked the conversation +* `listable_none` - {actor} made the conversation visible for nobody +* `listable_users` - {actor} made the conversation visible for regular users +* `listable_all` - {actor} made the conversation visible for everone which includes users and guests * `lobby_timer_reached` - The conversation is now open to everyone * `lobby_none` - {actor} opened the conversation to everyone * `lobby_non_moderators` - {actor} restricted the conversation to moderators diff --git a/docs/constants.md b/docs/constants.md index 2304240d580..dd4f0e02f66 100644 --- a/docs/constants.md +++ b/docs/constants.md @@ -12,6 +12,11 @@ title: Constants * `0` read-write * `1` read-only +## Listable scope +* `0` participants only +* `1` regular users only, excluding guest app users +* `2` everyone + ## Participant types * `1` owner * `2` moderator diff --git a/docs/conversation.md b/docs/conversation.md index 4c495507f65..7b90beed55a 100644 --- a/docs/conversation.md +++ b/docs/conversation.md @@ -4,6 +4,24 @@ * Base endpoint for API v2 is: `/ocs/v2.php/apps/spreed/api/v2` * Base endpoint for API v3 is: `/ocs/v2.php/apps/spreed/api/v3` +## Get listed conversations + +* Method: `GET` +* Endpoint: `/listed-room` + +* Response: + - Status code: + + `200 OK` + + `401 Unauthorized` when the user is not logged in + + - Header: + + field | type | Description + ------|------|------------ + `searchTerm` | string | search term + + - Data: See array definition in `Get user´s conversations` + ## Get user´s conversations * Method: `GET` @@ -38,6 +56,7 @@ `participantInCall` | bool | 🏴 v1 | Flag if the current user is in the call (deprecated, use `participantFlags` instead) `participantFlags` | int | * | Flags of the current user (only available with `in-call-flags` capability) `readOnly` | int | * | Read-only state for the current user (only available with `read-only-rooms` capability) + `listable` | int | * | Listable scope for the room (only available with `listable-rooms` capability) `count` | int | 🏴 v1 | **Deprecated:** ~~Number of active users~~ - always returns `0` `numGuests` | int | 🏴 v1 | Number of active guests `lastPing` | int | * | Timestamp of the last ping of the current user (should be used for sorting) @@ -266,3 +285,20 @@ + `400 Bad Request` When the the given level is invalid + `401 Unauthorized` When the participant is a guest + `404 Not Found` When the conversation could not be found for the participant + +## Set listable scope for a conversation + +* Method: `PUT` +* Endpoint: `/room/{token}/listable` +* Data: + + field | type | Description + ------|------|------------ + `scope` | int | New flags for the conversation + +* Response: + - Status code: + + `200 OK` + + `400 Bad Request` When the conversation type does not support making it listable (only group and public conversation) + + `403 Forbidden` When the current user is not a moderator/owner or the conversation is not a public conversation + + `404 Not Found` When the conversation could not be found for the participant diff --git a/docs/events.md b/docs/events.md index c4701df75b1..b8d6d694c7c 100644 --- a/docs/events.md +++ b/docs/events.md @@ -12,7 +12,6 @@ Explanations: * Event name: `OCA\Talk\Controller\RoomController::EVENT_BEFORE_ROOMS_GET` * Since: 8.0.0 - ### Create conversation * Event class: `OCA\Talk\Events\RoomEvent` @@ -54,6 +53,13 @@ Explanations: * After event name: `OCA\Talk\Room::EVENT_AFTER_READONLY_SET` * Since: 8.0.0 +### Set listable + +* Event class: `OCA\Talk\Events\ModifyRoomEvent` +* Before event name: `OCA\Talk\Room::EVENT_BEFORE_LISTABLE_SET` +* After event name: `OCA\Talk\Room::EVENT_AFTER_LISTABLE_SET` +* Since: 11.0.0 + ### Set lobby * Event class: `OCA\Talk\Events\ModifyLobbyEvent` diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 8dd4cca51e1..a95bc55311c 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -74,6 +74,7 @@ public function getCapabilities(): array { 'invite-groups-and-mails', 'locked-one-to-one-rooms', 'read-only-rooms', + 'listable-rooms', 'chat-read-marker', 'webinary-lobby', 'start-call-flag', diff --git a/lib/Chat/Parser/SystemMessage.php b/lib/Chat/Parser/SystemMessage.php index b840dd8d248..5a226616334 100644 --- a/lib/Chat/Parser/SystemMessage.php +++ b/lib/Chat/Parser/SystemMessage.php @@ -165,6 +165,27 @@ public function parseMessage(Message $chatMessage): void { } elseif ($cliIsActor) { $parsedMessage = $this->l->t('An administrator locked the conversation'); } + } elseif ($message === 'listable_none') { + $parsedMessage = $this->l->t('{actor} made the conversation invisible'); + if ($currentUserIsActor) { + $parsedMessage = $this->l->t('You made the conversation invisible'); + } elseif ($cliIsActor) { + $parsedMessage = $this->l->t('An administrator made the conversation invisible'); + } + } elseif ($message === 'listable_users') { + $parsedMessage = $this->l->t('{actor} made the conversation visible for registered users only'); + if ($currentUserIsActor) { + $parsedMessage = $this->l->t('You made the conversation visible for registered users only'); + } elseif ($cliIsActor) { + $parsedMessage = $this->l->t('An administrator made the visible for registered users only'); + } + } elseif ($message === 'listable_all') { + $parsedMessage = $this->l->t('{actor} made the conversation visible for everyone'); + if ($currentUserIsActor) { + $parsedMessage = $this->l->t('You made the conversation visible for everyone'); + } elseif ($cliIsActor) { + $parsedMessage = $this->l->t('An administrator made the conversation visible for everyone'); + } } elseif ($message === 'lobby_timer_reached') { $parsedMessage = $this->l->t('The conversation is now open to everyone'); } elseif ($message === 'lobby_none') { diff --git a/lib/Chat/SystemMessage/Listener.php b/lib/Chat/SystemMessage/Listener.php index 6c019b90aba..f839196ad0e 100644 --- a/lib/Chat/SystemMessage/Listener.php +++ b/lib/Chat/SystemMessage/Listener.php @@ -176,6 +176,20 @@ public static function register(IEventDispatcher $dispatcher): void { $listener->sendSystemMessage($room, 'read_only_off'); } }); + $dispatcher->addListener(Room::EVENT_AFTER_LISTABLE_SET, static function (ModifyRoomEvent $event) { + $room = $event->getRoom(); + + /** @var self $listener */ + $listener = \OC::$server->query(self::class); + + if ($event->getNewValue() === Room::LISTABLE_NONE) { + $listener->sendSystemMessage($room, 'listable_none'); + } elseif ($event->getNewValue() === Room::LISTABLE_USERS) { + $listener->sendSystemMessage($room, 'listable_users'); + } elseif ($event->getNewValue() === Room::LISTABLE_ALL) { + $listener->sendSystemMessage($room, 'listable_all'); + } + }); $dispatcher->addListener(Room::EVENT_AFTER_LOBBY_STATE_SET, static function (ModifyLobbyEvent $event) { if ($event->getNewValue() === $event->getOldValue()) { return; @@ -196,26 +210,36 @@ public static function register(IEventDispatcher $dispatcher): void { }); $dispatcher->addListener(Room::EVENT_AFTER_USERS_ADD, static function (AddParticipantsEvent $event) { - $participants = $event->getParticipants(); - $user = \OC::$server->getUserSession()->getUser(); - $userId = $user instanceof IUser ? $user->getUID() : null; - $room = $event->getRoom(); - if ($room->getType() === Room::ONE_TO_ONE_CALL) { return; } /** @var self $listener */ $listener = \OC::$server->query(self::class); + + $participants = $event->getParticipants(); + foreach ($participants as $participant) { if ($participant['actorType'] !== 'users') { continue; } - $userJoinedFileRoom = $room->getObjectType() === 'file' && - (!isset($participant['participantType']) || $participant['participantType'] !== Participant::USER_SELF_JOINED); - if ($userJoinedFileRoom || $userId !== $participant['actorId']) { + $participantType = null; + if (isset($participant['participantType'])) { + $participantType = $participant['participantType']; + } + + $userJoinedFileRoom = $room->getObjectType() === 'file' && $participantType !== Participant::USER_SELF_JOINED; + + // add a message "X joined the conversation", whenever user $userId: + if ( + // - has joined a file room but not through a public link + $userJoinedFileRoom + // - has been added by another user (and not when creating a conversation) + || $listener->getUserId() !== $participant['actorId'] + // - has joined a listable room on their own + || $participantType === Participant::USER) { $listener->sendSystemMessage($room, 'user_added', ['user' => $participant['actorId']]); } } @@ -314,4 +338,9 @@ protected function sendSystemMessage(Room $room, string $message, array $paramet $referenceId ); } + + protected function getUserId() { + $user = $this->userSession->getUser(); + return $user instanceof IUser ? $user->getUID() : null; + } } diff --git a/lib/Command/Room/Create.php b/lib/Command/Room/Create.php index 7d595cb2f7a..8e992aaf3ef 100644 --- a/lib/Command/Room/Create.php +++ b/lib/Command/Room/Create.php @@ -70,6 +70,11 @@ protected function configure(): void { null, InputOption::VALUE_NONE, 'Creates the room with read-only access only if set' + )->addOption( + 'listable', + null, + InputOption::VALUE_REQUIRED, + 'Creates the room with the given listable scope' )->addOption( 'password', null, @@ -95,10 +100,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int $groups = $input->getOption('group'); $public = $input->getOption('public'); $readonly = $input->getOption('readonly'); + $listable = $input->getOption('listable'); $password = $input->getOption('password'); $owner = $input->getOption('owner'); $moderators = $input->getOption('moderator'); + if (!in_array($readOnly, [null, (string)Room::READ_WRITE, (string)Room::READ_ONLY], true)) { + $output->writeln('Invalid value for option "--readonly" given.'); + return 1; + } + + if (!in_array($listable, [ + null, + (string)Room::LISTABLE_NONE, + (string)Room::LISTABLE_USERS, + (string)Room::LISTABLE_ALL, + ], true)) { + $output->writeln('Invalid value for option "--listable" given.'); + return 1; + } + $roomType = $public ? Room::PUBLIC_CALL : Room::GROUP_CALL; try { $room = $this->roomService->createConversation($roomType, $name); @@ -116,6 +137,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $this->setRoomReadOnly($room, $readonly); + $this->setRoomListable($room, (int)$listable); if ($password !== null) { $this->setRoomPassword($room, $password); @@ -150,6 +172,14 @@ public function completeOptionValues($optionName, CompletionContext $context) { case 'owner': case 'moderator': return $this->completeParticipantValues($context); + case 'readonly': + return [(string)Room::READ_ONLY, (string)Room::READ_WRITE]; + case 'listable': + return [ + (string)Room::LISTABLE_ALL, + (string)Room::LISTABLE_USERS, + (string)Room::LISTABLE_NONE, + ]; } return parent::completeOptionValues($optionName, $context); diff --git a/lib/Command/Room/TRoomCommand.php b/lib/Command/Room/TRoomCommand.php index f2986232025..68c3d238c21 100644 --- a/lib/Command/Room/TRoomCommand.php +++ b/lib/Command/Room/TRoomCommand.php @@ -151,6 +151,22 @@ protected function setRoomReadOnly(Room $room, bool $readOnly): void { } } + /** + * @param Room $room + * @param int $listable + * + * @throws InvalidArgumentException + */ + protected function setRoomListable(Room $room, int $listable): void { + if ($room->getListable() === $listable) { + return; + } + + if (!$room->setListable($listable)) { + throw new InvalidArgumentException('Unable to change room state.'); + } + } + /** * @param Room $room * @param string $password diff --git a/lib/Command/Room/Update.php b/lib/Command/Room/Update.php index 76480a116a6..a3279745568 100644 --- a/lib/Command/Room/Update.php +++ b/lib/Command/Room/Update.php @@ -66,6 +66,11 @@ protected function configure(): void { null, InputOption::VALUE_REQUIRED, 'Modifies the room to be read-only (value 1) or read-write (value 0)' + )->addOption( + 'listable', + null, + InputOption::VALUE_REQUIRED, + 'Modifies the room\'s listable scope' )->addOption( 'password', null, @@ -85,6 +90,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $description = $input->getOption('description'); $public = $input->getOption('public'); $readOnly = $input->getOption('readonly'); + $listable = $input->getOption('listable'); $password = $input->getOption('password'); $owner = $input->getOption('owner'); @@ -93,11 +99,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } - if (!in_array($readOnly, [null, '0', '1'], true)) { + if (!in_array($readOnly, [null, (string)Room::READ_WRITE, (string)Room::READ_ONLY], true)) { $output->writeln('Invalid value for option "--readonly" given.'); return 1; } + if (!in_array($listable, [ + null, + (string)Room::LISTABLE_NONE, + (string)Room::LISTABLE_USERS, + (string)Room::LISTABLE_ALL, + ], true)) { + $output->writeln('Invalid value for option "--listable" given.'); + return 1; + } + try { $room = $this->manager->getRoomByToken($token); } catch (RoomNotFoundException $e) { @@ -127,6 +143,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->setRoomReadOnly($room, ($readOnly === '1')); } + if ($listable !== null) { + $this->setRoomListable($room, (int)$listable); + } + if ($password !== null) { $this->setRoomPassword($room, $password); } @@ -151,7 +171,13 @@ public function completeOptionValues($optionName, CompletionContext $context) { switch ($optionName) { case 'public': case 'readonly': - return ['1', '0']; + return [(string)Room::READ_ONLY, (string)Room::READ_WRITE]; + case 'listable': + return [ + (string)Room::LISTABLE_ALL, + (string)Room::LISTABLE_USERS, + (string)Room::LISTABLE_NONE, + ]; case 'owner': return $this->completeParticipantValues($context); diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index 06d7e6334e0..0180fbf486b 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -219,6 +219,32 @@ public function getRooms(int $noStatusUpdate = 0): DataResponse { return new DataResponse($return, Http::STATUS_OK, $this->getTalkHashHeader()); } + /** + * Get listed rooms with optional search term + * + * @NoAdminRequired + * + * @param string $searchTerm search term + * @return DataResponse + */ + public function getListedRooms(string $searchTerm = ''): DataResponse { + $event = new UserEvent($this->userId); + + $rooms = $this->manager->getListedRoomsForUser($this->userId, $searchTerm); + + $return = []; + foreach ($rooms as $room) { + try { + $return[] = $this->formatRoomV2andV3($room, null); + } catch (RoomNotFoundException $e) { + } catch (\RuntimeException $e) { + } + } + + return new DataResponse($return, Http::STATUS_OK); + } + + /** * @PublicPage * @@ -559,6 +585,7 @@ protected function formatRoomV2andV3(Room $room, ?Participant $currentParticipan 'attendeePin' => '', 'description' => '', 'lastCommonReadMessage' => 0, + 'listable' => Room::LISTABLE_NONE, ]); } @@ -576,10 +603,12 @@ protected function formatRoomV2andV3(Room $room, ?Participant $currentParticipan $lobbyTimer = 0; } - if ($isSIPBridgeRequest) { + if ($isSIPBridgeRequest + || ($room->getListable() !== Room::LISTABLE_NONE && !$currentParticipant instanceof Participant) + ) { return array_merge($roomData, [ 'name' => $room->getName(), - 'displayName' => $room->getDisplayName(''), + 'displayName' => $room->getDisplayName($isSIPBridgeRequest ? '' : $this->userId), 'objectType' => $room->getObjectType(), 'objectId' => $room->getObjectId(), 'readOnly' => $room->getReadOnly(), @@ -588,6 +617,7 @@ protected function formatRoomV2andV3(Room $room, ?Participant $currentParticipan 'lobbyState' => $room->getLobbyState(), 'lobbyTimer' => $lobbyTimer, 'sipEnabled' => $room->getSIPEnabled(), + 'listable' => $room->getListable(), ]); } @@ -628,6 +658,7 @@ protected function formatRoomV2andV3(Room $room, ?Participant $currentParticipan 'actorId' => $attendee->getActorId(), 'attendeeId' => $attendee->getId(), 'description' => $room->getDescription(), + 'listable' => $room->getListable(), ]); if ($currentParticipant->getAttendee()->getReadPrivacy() === Participant::PRIVACY_PUBLIC) { @@ -1469,6 +1500,21 @@ public function setReadOnly(int $state): DataResponse { return new DataResponse(); } + /** + * @NoAdminRequired + * @RequireModeratorParticipant + * + * @param int $state + * @return DataResponse + */ + public function setListable(int $scope): DataResponse { + if (!$this->room->setListable($scope)) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse(); + } + /** * @PublicPage * @RequireModeratorParticipant diff --git a/lib/Manager.php b/lib/Manager.php index f5ea5ccd6d1..57b8d9a1fb2 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -31,6 +31,7 @@ use OCA\Talk\Model\AttendeeMapper; use OCA\Talk\Model\SessionMapper; use OCA\Talk\Service\ParticipantService; +use OCP\App\IAppManager; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Comments\IComment; use OCP\Comments\ICommentsManager; @@ -43,6 +44,7 @@ use OCP\IL10N; use OCP\IUser; use OCP\IUserManager; +use OCP\IGroupManager; use OCP\Security\IHasher; use OCP\Security\ISecureRandom; @@ -55,6 +57,8 @@ class Manager { private $config; /** @var Config */ private $talkConfig; + /** @var IAppManager */ + private $appManager; /** @var AttendeeMapper */ private $attendeeMapper; /** @var SessionMapper */ @@ -65,6 +69,8 @@ class Manager { private $secureRandom; /** @var IUserManager */ private $userManager; + /** @var IGroupManager */ + private $groupManager; /** @var ICommentsManager */ private $commentsManager; /** @var TalkSession */ @@ -81,11 +87,13 @@ class Manager { public function __construct(IDBConnection $db, IConfig $config, Config $talkConfig, + IAppManager $appManager, AttendeeMapper $attendeeMapper, SessionMapper $sessionMapper, ParticipantService $participantService, ISecureRandom $secureRandom, IUserManager $userManager, + IGroupManager $groupManager, CommentsManager $commentsManager, TalkSession $talkSession, IEventDispatcher $dispatcher, @@ -95,11 +103,13 @@ public function __construct(IDBConnection $db, $this->db = $db; $this->config = $config; $this->talkConfig = $talkConfig; + $this->appManager = $appManager; $this->attendeeMapper = $attendeeMapper; $this->sessionMapper = $sessionMapper; $this->participantService = $participantService; $this->secureRandom = $secureRandom; $this->userManager = $userManager; + $this->groupManager = $groupManager; $this->commentsManager = $commentsManager; $this->talkSession = $talkSession; $this->dispatcher = $dispatcher; @@ -167,6 +177,7 @@ public function createRoomObject(array $row): Room { (int) $row['r_id'], (int) $row['type'], (int) $row['read_only'], + (int) $row['listable'], (int) $row['lobby_state'], (int) $row['sip_enabled'], $assignedSignalingServer, @@ -330,6 +341,54 @@ public function getRoomsForUser(string $userId, bool $includeLastMessage = false return $rooms; } + /** + * Returns rooms that are listable where the current user is not a participant. + * + * @param string $userId user id + * @param string $term search term + * @return Room[] + */ + public function getListedRoomsForUser(string $userId, string $term = ''): array { + $allowedRoomTypes = [Room::GROUP_CALL, Room::PUBLIC_CALL]; + $allowedListedTypes = [Room::LISTABLE_ALL]; + if (!$this->isGuestUser($userId)) { + $allowedListedTypes[] = Room::LISTABLE_USERS; + } + $query = $this->db->getQueryBuilder(); + $query->select('r.*') + ->selectAlias('r.id', 'r_id') + ->from('talk_rooms', 'r') + ->leftJoin('r', 'talk_attendees', 'a', $query->expr()->andX( + $query->expr()->eq('a.actor_id', $query->createNamedParameter($userId)), + $query->expr()->eq('a.actor_type', $query->createNamedParameter(Attendee::ACTOR_USERS)), + $query->expr()->eq('a.room_id', 'r.id') + )) + ->leftJoin('a', 'talk_sessions', 's', $query->expr()->andX( + $query->expr()->eq('a.id', 's.attendee_id') + )) + ->where($query->expr()->isNull('a.id')) + ->andWhere($query->expr()->in('r.type', $query->createNamedParameter($allowedRoomTypes, IQueryBuilder::PARAM_INT_ARRAY))) + ->andWhere($query->expr()->in('r.listable', $query->createNamedParameter($allowedListedTypes, IQueryBuilder::PARAM_INT_ARRAY))); + + if ($term !== '') { + $query->andWhere( + $query->expr()->iLike('name', $query->createNamedParameter( + '%' . $this->db->escapeLikeParameter($term). '%' + )) + ); + } + + $result = $query->execute(); + $rooms = []; + while ($row = $result->fetch()) { + $room = $this->createRoomObject($row); + $rooms[] = $room; + } + $result->closeCursor(); + + return $rooms; + } + /** * Does *not* return public rooms for participants that have not been invited * @@ -388,8 +447,13 @@ public function getRoomForUser(int $roomId, ?string $userId): Room { } /** - * Also returns public rooms for participants that have not been invited, - * so they can join. + * Returns room object for a user by token. + * + * Also returns: + * - public rooms for participants that have not been invited + * - listable rooms for participants that have not been invited + * + * This is useful so they can join. * * @param string $token * @param string|null $userId @@ -447,8 +511,17 @@ public function getRoomForUserByToken(string $token, ?string $userId, bool $incl return $room; } - if ($userId !== null && $row['actor_id'] === $userId) { - return $room; + if ($userId !== null) { + // user already joined that room before + if ($row['actor_id'] === $userId) { + return $room; + } + + // never joined before but found in listing + $listable = (int)$row['listable']; + if ($this->isRoomListableByUser($room, $userId)) { + return $room; + } } throw new RoomNotFoundException(); @@ -704,6 +777,7 @@ public function getChangelogRoom(string $userId): Room { if ($row === false) { $room = $this->createRoom(Room::CHANGELOG_CONVERSATION, $userId); $room->setReadOnly(Room::READ_ONLY); + $room->setListable(Room::LISTABLE_NONE); $this->participantService->addUsers($room,[[ 'actorType' => Attendee::ACTOR_USERS, @@ -810,21 +884,46 @@ public function resolveRoomDisplayName(Room $room, string $userId): string { return $otherParticipant; } - try { - if ($userId === '') { - $sessionId = $this->talkSession->getSessionForRoom($room->getToken()); - $room->getParticipantBySession($sessionId); - } else { - $room->getParticipant($userId); + if (!$this->isRoomListableByUser($room, $userId)) { + try { + if ($userId === '') { + $sessionId = $this->talkSession->getSessionForRoom($room->getToken()); + $room->getParticipantBySession($sessionId); + } else { + $room->getParticipant($userId); + } + } catch (ParticipantNotFoundException $e) { + // Do not leak the name of rooms the user is not a part of + return $this->l->t('Private conversation'); } - } catch (ParticipantNotFoundException $e) { - // Do not leak the name of rooms the user is not a part of - return $this->l->t('Private conversation'); } return $room->getName(); } + /** + * Returns whether the given room is listable for the given user. + * + * @param Room $room room + * @param string|null $userId user id + */ + public function isRoomListableByUser(Room $room, ?string $userId): bool { + if ($userId === null) { + // not listable for guest users with no account + return false; + } + + if ($room->getListable() === Room::LISTABLE_ALL) { + return true; + } + + if ($room->getListable() === Room::LISTABLE_USERS && !$this->isGuestUser($userId)) { + return true; + } + + return false; + } + protected function getRoomNameByParticipants(Room $room): string { $users = $this->participantService->getParticipantUserIds($room); $displayNames = []; @@ -922,6 +1021,21 @@ public function isValidParticipant(string $userId): bool { return $this->userManager->userExists($userId); } + /** + * Returns whether the given user id is a guest user from + * the guest app + * + * @param string $userId user id to check + * @return bool true if the user is a guest, false otherwise + */ + public function isGuestUser(string $userId): bool { + if (!$this->appManager->isEnabledForUser('guests')) { + return false; + } + // TODO: retrieve guest group name from app once exposed + return $this->groupManager->isInGroup($userId, 'guest_app'); + } + protected function loadLastMessageInfo(IQueryBuilder $query): void { $query->leftJoin('r','comments', 'c', $query->expr()->eq('r.last_message', 'c.id')); $query->selectAlias('c.id', 'comment_id'); diff --git a/lib/Migration/Version11000Date20201201102528.php b/lib/Migration/Version11000Date20201201102528.php new file mode 100644 index 00000000000..ef31fb0a826 --- /dev/null +++ b/lib/Migration/Version11000Date20201201102528.php @@ -0,0 +1,66 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Migration; + +use Closure; +use Doctrine\DBAL\Types\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Add listable column to the rooms table. + */ +class Version11000Date20201201102528 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if ($schema->hasTable('talk_rooms')) { + $table = $schema->getTable('talk_rooms'); + + if (!$table->hasColumn('listable')) { + $table->addColumn('listable', Types::SMALLINT, [ + 'notnull' => false, + 'default' => 0, + 'unsigned' => true, + ]); + } + + if (!$table->hasIndex('tr_listable')) { + $table->addIndex(['listable'], 'tr_listable'); + } + + return $schema; + } + + return null; + } +} diff --git a/lib/Room.php b/lib/Room.php index 8f9ed227806..e371631c5be 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -62,6 +62,21 @@ class Room { public const READ_WRITE = 0; public const READ_ONLY = 1; + /** + * Only visible when joined + */ + public const LISTABLE_NONE = 0; + + /** + * Searchable by all regular users and moderators, even when not joined, excluding users from the guest app + */ + public const LISTABLE_USERS = 1; + + /** + * Searchable by everyone, which includes guest users (from guest app), even when not joined + */ + public const LISTABLE_ALL = 2; + public const START_CALL_EVERYONE = 0; public const START_CALL_USERS = 1; public const START_CALL_MODERATORS = 2; @@ -82,6 +97,8 @@ class Room { public const EVENT_AFTER_TYPE_SET = self::class . '::postSetType'; public const EVENT_BEFORE_READONLY_SET = self::class . '::preSetReadOnly'; public const EVENT_AFTER_READONLY_SET = self::class . '::postSetReadOnly'; + public const EVENT_BEFORE_LISTABLE_SET = self::class . '::preSetListable'; + public const EVENT_AFTER_LISTABLE_SET = self::class . '::postSetListable'; public const EVENT_BEFORE_LOBBY_STATE_SET = self::class . '::preSetLobbyState'; public const EVENT_AFTER_LOBBY_STATE_SET = self::class . '::postSetLobbyState'; public const EVENT_BEFORE_SIP_ENABLED_SET = self::class . '::preSetSIPEnabled'; @@ -131,6 +148,8 @@ class Room { /** @var int */ private $readOnly; /** @var int */ + private $listable; + /** @var int */ private $lobbyState; /** @var int */ private $sipEnabled; @@ -175,6 +194,7 @@ public function __construct(Manager $manager, int $id, int $type, int $readOnly, + int $listable, int $lobbyState, int $sipEnabled, ?int $assignedSignalingServer, @@ -199,6 +219,7 @@ public function __construct(Manager $manager, $this->id = $id; $this->type = $type; $this->readOnly = $readOnly; + $this->listable = $listable; $this->lobbyState = $lobbyState; $this->sipEnabled = $sipEnabled; $this->assignedSignalingServer = $assignedSignalingServer; @@ -228,6 +249,10 @@ public function getReadOnly(): int { return $this->readOnly; } + public function getListable(): int { + return $this->listable; + } + public function getLobbyState(): int { $this->validateTimer(); return $this->lobbyState; @@ -346,6 +371,7 @@ public function getPropertiesForSignaling(string $userId, bool $roomModified = t 'lobby-state' => $this->getLobbyState(), 'lobby-timer' => $this->getLobbyTimer(), 'read-only' => $this->getReadOnly(), + 'listable' => $this->getListable(), 'active-since' => $this->getActiveSince(), 'sip-enabled' => $this->getSIPEnabled(), ]; @@ -780,6 +806,46 @@ public function setReadOnly(int $newState): bool { return true; } + /** + * @param int $newState New listable scope from self::LISTABLE_* + * Also it's only allowed on rooms of type + * `self::GROUP_CALL` and `self::PUBLIC_CALL` + * @return bool True when the change was valid, false otherwise + */ + public function setListable(int $newState): bool { + $oldState = $this->getListable(); + if ($newState === $oldState) { + return true; + } + + if (!in_array($this->getType(), [self::GROUP_CALL, self::PUBLIC_CALL], true)) { + return false; + } + + if (!in_array($newState, [ + Room::LISTABLE_NONE, + Room::LISTABLE_USERS, + Room::LISTABLE_ALL, + ], true)) { + return false; + } + + $event = new ModifyRoomEvent($this, 'listable', $newState, $oldState); + $this->dispatcher->dispatch(self::EVENT_BEFORE_LISTABLE_SET, $event); + + $query = $this->db->getQueryBuilder(); + $query->update('talk_rooms') + ->set('listable', $query->createNamedParameter($newState, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('id', $query->createNamedParameter($this->getId(), IQueryBuilder::PARAM_INT))); + $query->execute(); + + $this->listable = $newState; + + $this->dispatcher->dispatch(self::EVENT_AFTER_LISTABLE_SET, $event); + + return true; + } + /** * @param int $newState Currently it is only allowed to change between * `Webinary::LOBBY_NON_MODERATORS` and `Webinary::LOBBY_NONE` diff --git a/lib/Service/ParticipantService.php b/lib/Service/ParticipantService.php index d5d59827d3f..c7975cc752c 100644 --- a/lib/Service/ParticipantService.php +++ b/lib/Service/ParticipantService.php @@ -51,6 +51,7 @@ use OCP\IDBConnection; use OCP\IUser; use OCP\IUserManager; +use OCP\IGroupManager; use OCP\Security\ISecureRandom; class ParticipantService { @@ -72,6 +73,8 @@ class ParticipantService { private $dispatcher; /** @var IUserManager */ private $userManager; + /** @var IGroupManager */ + private $groupManager; /** @var ITimeFactory */ private $timeFactory; @@ -84,6 +87,7 @@ public function __construct(IConfig $serverConfig, IDBConnection $connection, IEventDispatcher $dispatcher, IUserManager $userManager, + IGroupManager $groupManager, ITimeFactory $timeFactory) { $this->serverConfig = $serverConfig; $this->talkConfig = $talkConfig; @@ -94,6 +98,7 @@ public function __construct(IConfig $serverConfig, $this->connection = $connection; $this->dispatcher = $dispatcher; $this->userManager = $userManager; + $this->groupManager = $groupManager; $this->timeFactory = $timeFactory; } @@ -166,12 +171,30 @@ public function joinRoom(Room $room, IUser $user, string $password, bool $passed throw new InvalidPasswordException('Provided password is invalid'); } - // User joining a public room, without being invited - $this->addUsers($room, [[ - 'actorType' => Attendee::ACTOR_USERS, - 'actorId' => $user->getUID(), - 'participantType' => Participant::USER_SELF_JOINED, - ]]); + // queried here to avoid loop deps + $manager = \OC::$server->get(\OCA\Talk\Manager::class); + + // User joining a group or public call through listing + if (($room->getType() === Room::GROUP_CALL || $room->getType() === Room::PUBLIC_CALL) && + $manager->isRoomListableByUser($room, $user->getUID()) + ) { + $this->addUsers($room, [[ + 'actorType' => Attendee::ACTOR_USERS, + 'actorId' => $user->getUID(), + // need to use "USER" here, because "USER_SELF_JOINED" only works for public calls + 'participantType' => Participant::USER, + ]]); + } elseif ($room->getType() === Room::PUBLIC_CALL) { + // User joining a public room, without being invited + $this->addUsers($room, [[ + 'actorType' => Attendee::ACTOR_USERS, + 'actorId' => $user->getUID(), + 'participantType' => Participant::USER_SELF_JOINED, + ]]); + } else { + // shouldn't happen unless some code called joinRoom without previous checks + throw new UnauthorizedException('Participant is not allowed to join'); + } $attendee = $this->attendeeMapper->findByActor($room->getId(), Attendee::ACTOR_USERS, $user->getUID()); } diff --git a/lib/Signaling/Listener.php b/lib/Signaling/Listener.php index f62d3200522..d5625d1676a 100644 --- a/lib/Signaling/Listener.php +++ b/lib/Signaling/Listener.php @@ -140,6 +140,7 @@ protected static function registerExternalSignaling(IEventDispatcher $dispatcher $dispatcher->addListener(Room::EVENT_AFTER_PASSWORD_SET, $listener); $dispatcher->addListener(Room::EVENT_AFTER_TYPE_SET, $listener); $dispatcher->addListener(Room::EVENT_AFTER_READONLY_SET, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_LISTABLE_SET, $listener); $dispatcher->addListener(Room::EVENT_AFTER_LOBBY_STATE_SET, $listener); $dispatcher->addListener(Room::EVENT_AFTER_SIP_ENABLED_SET, $listener); // TODO remove handler with "roomModified" in favour of handler with diff --git a/src/components/ConversationSettings/ConversationSettingsDialog.vue b/src/components/ConversationSettings/ConversationSettingsDialog.vue index bc2f8276b1b..6e55e61afec 100644 --- a/src/components/ConversationSettings/ConversationSettingsDialog.vue +++ b/src/components/ConversationSettings/ConversationSettingsDialog.vue @@ -30,11 +30,19 @@ class="app-settings-section"> + + + + - + + @@ -45,7 +53,10 @@ import { PARTICIPANT } from '../../constants' import AppSettingsDialog from '@nextcloud/vue/dist/Components/AppSettingsDialog' import AppSettingsSection from '@nextcloud/vue/dist/Components/AppSettingsSection' import LinkShareSettings from './LinkShareSettings' -import ModerationSettings from './ModerationSettings' +import ListableSettings from './ListableSettings' +import LockingSettings from './LockingSettings' +import LobbySettings from './LobbySettings' +import SipSettings from './SipSettings' export default { name: 'ConversationSettingsDialog', @@ -54,7 +65,10 @@ export default { AppSettingsDialog, AppSettingsSection, LinkShareSettings, - ModerationSettings, + LobbySettings, + ListableSettings, + LockingSettings, + SipSettings, }, data() { @@ -64,6 +78,10 @@ export default { }, computed: { + canUserEnableSIP() { + return this.conversation.canEnableSIP + }, + token() { return this.$store.getters.getToken() }, diff --git a/src/components/ConversationSettings/LinkShareSettings.vue b/src/components/ConversationSettings/LinkShareSettings.vue index 0c769888314..08ccf8e2faf 100644 --- a/src/components/ConversationSettings/LinkShareSettings.vue +++ b/src/components/ConversationSettings/LinkShareSettings.vue @@ -226,4 +226,8 @@ export default { .icon-clippy { margin-right: 10px; } + +input[type=password] { + width: 200px; +} diff --git a/src/components/ConversationSettings/ListableSettings.vue b/src/components/ConversationSettings/ListableSettings.vue new file mode 100644 index 00000000000..cd11dbcdbe1 --- /dev/null +++ b/src/components/ConversationSettings/ListableSettings.vue @@ -0,0 +1,136 @@ + + + + + + diff --git a/src/components/ConversationSettings/ModerationSettings.vue b/src/components/ConversationSettings/LobbySettings.vue similarity index 75% rename from src/components/ConversationSettings/ModerationSettings.vue rename to src/components/ConversationSettings/LobbySettings.vue index e49305e11b1..474b7027935 100644 --- a/src/components/ConversationSettings/ModerationSettings.vue +++ b/src/components/ConversationSettings/LobbySettings.vue @@ -21,22 +21,6 @@ diff --git a/src/components/LeftSidebar/ConversationsList/Conversation.vue b/src/components/LeftSidebar/ConversationsList/Conversation.vue index e34360c7138..bb5e29ef32c 100644 --- a/src/components/LeftSidebar/ConversationsList/Conversation.vue +++ b/src/components/LeftSidebar/ConversationsList/Conversation.vue @@ -23,8 +23,9 @@ + :to="!isSearchResult ? { name: 'conversation', params: { token: item.token }} : ''" + :class="{ 'has-unread-messages': item.unreadMessages }" + @click="onClick">