diff --git a/docs/capabilities.md b/docs/capabilities.md index 91b5e29a4b9..f9e98942774 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -91,3 +91,4 @@ title: Capabilities * `chat-unread` - Whether the API to mark a conversation as unread is available * `reactions` - Api reactions to chat message * `rich-object-list-media` - When the API to get the chat messages for shared media is available +* `rich-object-delete` - When the API allows to delete chat messages which are file or rich object shares diff --git a/docs/chat.md b/docs/chat.md index 2cd349c161b..4dfe86788bd 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -208,7 +208,7 @@ See [OCP\RichObjectStrings\Definitions](https://github.com/nextcloud/server/blob ## Deleting a chat message -* Required capability: `delete-messages` +* Required capability: `delete-messages` - `rich-object-delete` indicates if shared objects can be deleted from the chat * Method: `DELETE` * Endpoint: `/chat/{token}/{messageId}` diff --git a/lib/Capabilities.php b/lib/Capabilities.php index ea7cd82e753..798bb5a1d16 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -105,6 +105,7 @@ public function getCapabilities(): array { 'notification-calls', 'conversation-permissions', 'rich-object-list-media', + 'rich-object-delete', ], 'config' => [ 'attachments' => [ diff --git a/lib/Chat/ChatManager.php b/lib/Chat/ChatManager.php index 290867592d5..7340f0a5f8b 100644 --- a/lib/Chat/ChatManager.php +++ b/lib/Chat/ChatManager.php @@ -43,6 +43,9 @@ use OCP\IDBConnection; use OCP\IUser; use OCP\Notification\IManager as INotificationManager; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IManager; +use OCP\Share\IShare; /** * Basic polling chat manager. @@ -74,6 +77,8 @@ class ChatManager { private $connection; /** @var INotificationManager */ private $notificationManager; + /** @var IManager */ + private $shareManager; /** @var RoomShareProvider */ private $shareProvider; /** @var ParticipantService */ @@ -91,6 +96,7 @@ public function __construct(CommentsManager $commentsManager, IEventDispatcher $dispatcher, IDBConnection $connection, INotificationManager $notificationManager, + IManager $shareManager, RoomShareProvider $shareProvider, ParticipantService $participantService, Notifier $notifier, @@ -100,6 +106,7 @@ public function __construct(CommentsManager $commentsManager, $this->dispatcher = $dispatcher; $this->connection = $connection; $this->notificationManager = $notificationManager; + $this->shareManager = $shareManager; $this->shareProvider = $shareProvider; $this->participantService = $participantService; $this->notifier = $notifier; @@ -287,12 +294,54 @@ public function sendMessage(Room $chat, Participant $participant, string $actorT return $comment; } - public function deleteMessage(Room $chat, int $messageId, string $actorType, string $actorId, \DateTime $deletionTime): IComment { - $comment = $this->getComment($chat, (string) $messageId); + /** + * @param Room $room + * @param Participant $participant + * @param array $messageData + * @throws ShareNotFound + */ + public function unshareFileOnMessageDelete(Room $room, Participant $participant, array $messageData): void { + if (!isset($messageData['message'], $messageData['parameters']['share']) || $messageData['message'] !== 'file_shared') { + // Not a file share + return; + } + + $share = $this->shareManager->getShareById('ocRoomShare:' . $messageData['parameters']['share']); + + if ($share->getShareType() !== IShare::TYPE_ROOM || $share->getSharedWith() !== $room->getToken()) { + // Share does not match the correct room + throw new ShareNotFound(); + } + + $attendee = $participant->getAttendee(); + + if (!$participant->hasModeratorPermissions() && + !($attendee->getActorType() === Attendee::ACTOR_USERS && $attendee->getActorId() === $share->getShareOwner())) { + // Only moderators or the share owner can delete the share + return; + } + + $this->shareManager->deleteShare($share); + } + + /** + * @param Room $chat + * @param IComment $comment + * @param Participant $participant + * @param \DateTime $deletionTime + * @return IComment + * @throws ShareNotFound + */ + public function deleteMessage(Room $chat, IComment $comment, Participant $participant, \DateTime $deletionTime): IComment { + if ($comment->getVerb() === 'object_shared') { + $messageData = json_decode($comment->getMessage(), true); + $this->unshareFileOnMessageDelete($chat, $participant, $messageData); + } + $comment->setMessage( json_encode([ - 'deleted_by_type' => $actorType, - 'deleted_by_id' => $actorId, + 'deleted_by_type' => $participant->getAttendee()->getActorType(), + 'deleted_by_id' => $participant->getAttendee()->getActorId(), 'deleted_on' => $deletionTime->getTimestamp(), ]) ); @@ -301,13 +350,13 @@ public function deleteMessage(Room $chat, int $messageId, string $actorType, str return $this->addSystemMessage( $chat, - $actorType, - $actorId, - json_encode(['message' => 'message_deleted', 'parameters' => ['message' => $messageId]]), + $participant->getAttendee()->getActorType(), + $participant->getAttendee()->getActorId(), + json_encode(['message' => 'message_deleted', 'parameters' => ['message' => $comment->getId()]]), $this->timeFactory->getDateTime(), false, null, - $messageId + (int) $comment->getId() ); } diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index df22cabe026..185051aac2f 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -53,6 +53,7 @@ use OCP\RichObjectStrings\InvalidObjectExeption; use OCP\RichObjectStrings\IValidator; use OCP\Security\ITrustedDomainHelper; +use OCP\Share\Exceptions\ShareNotFound; use OCP\User\Events\UserLiveStatusEvent; use OCP\UserStatus\IManager as IUserStatusManager; use OCP\UserStatus\IUserStatus; @@ -581,8 +582,8 @@ public function deleteMessage(int $messageId): DataResponse { return new DataResponse([], Http::STATUS_FORBIDDEN); } - if ($message->getVerb() !== 'comment') { - // System message or file share (since the message is not parsed, it has type "system") + if ($message->getVerb() !== 'comment' && $message->getVerb() !== 'object_shared') { + // System message (since the message is not parsed, it has type "system") return new DataResponse([], Http::STATUS_METHOD_NOT_ALLOWED); } @@ -593,13 +594,16 @@ public function deleteMessage(int $messageId): DataResponse { return new DataResponse([], Http::STATUS_BAD_REQUEST); } - $systemMessageComment = $this->chatManager->deleteMessage( - $this->room, - $messageId, - $attendee->getActorType(), - $attendee->getActorId(), - $this->timeFactory->getDateTime() - ); + try { + $systemMessageComment = $this->chatManager->deleteMessage( + $this->room, + $message, + $this->participant, + $this->timeFactory->getDateTime() + ); + } catch (ShareNotFound $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } $systemMessage = $this->messageParser->createMessage($this->room, $this->participant, $systemMessageComment, $this->l); $this->messageParser->parseMessage($systemMessage); diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js index 78d91b6ec1e..c0825f0262e 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js +++ b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.spec.js @@ -276,10 +276,10 @@ describe('MessageButtonsBar.vue', () => { testDeleteMessageVisible(false) }) - test('hides delete action for file messages', () => { + test('show delete action for file messages', () => { messageProps.message = '{file}' messageProps.messageParameters.file = {} - testDeleteMessageVisible(false) + testDeleteMessageVisible(true) }) test('hides delete action on other people messages for non-moderators', () => { diff --git a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue index fd7b7ca4370..04b86d19df7 100644 --- a/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue +++ b/src/components/MessagesList/MessagesGroup/Message/MessageButtonsBar/MessageButtonsBar.vue @@ -304,14 +304,9 @@ export default { return false } - const isObjectShare = this.message === '{object}' - && this.messageParameters?.object - return (moment(this.timestamp * 1000).add(6, 'h')) > moment() - && this.messageType === 'comment' + && (this.messageType === 'comment' || this.messageType === 'voice-message') && !this.isDeleting - && !this.isFileShare - && !isObjectShare && (this.isMyMsg || (this.conversation.type !== CONVERSATION.TYPE.ONE_TO_ONE && (this.participant.participantType === PARTICIPANT.TYPE.OWNER diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 7a60aba3b9e..1fc24a13384 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -1630,6 +1630,9 @@ protected function compareDataResponse(TableNode $formData = null) { // replies; this is needed to get special messages not explicitly // sent like those for shared files. self::$messages[$message['message']] = $message['id']; + if ($message['message'] === '{file}' && isset($message['messageParameters']['file']['name'])) { + self::$messages['shared::file::' . $message['messageParameters']['file']['name']] = $message['id']; + } } if ($formData === null) { diff --git a/tests/integration/features/chat/file-share.feature b/tests/integration/features/chat/file-share.feature new file mode 100644 index 00000000000..cb35a4d1655 --- /dev/null +++ b/tests/integration/features/chat/file-share.feature @@ -0,0 +1,25 @@ +Feature: chat/public + Background: + Given user "participant1" exists + + Scenario: Share a file to a chat + Given user "participant1" creates room "public room" (v4) + | roomType | 3 | + | roomName | room | + When user "participant1" shares "welcome.txt" with room "public room" + Then user "participant1" sees the following messages in room "public room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | public room | users | participant1 | participant1-displayname | {file} | "IGNORE" | + + Scenario: Delete share a file message from a chat + Given user "participant1" creates room "public room" (v4) + | roomType | 3 | + | roomName | room | + When user "participant1" shares "welcome.txt" with room "public room" + Then user "participant1" sees the following messages in room "public room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | public room | users | participant1 | participant1-displayname | {file} | "IGNORE" | + And user "participant1" deletes message "shared::file::welcome.txt" from room "public room" with 200 + Then user "participant1" sees the following messages in room "public room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | public room | users | participant1 | participant1-displayname | Message deleted by you | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | diff --git a/tests/integration/features/chat/rich-object-share.feature b/tests/integration/features/chat/rich-object-share.feature index c5cc644258c..3b81ed7cee1 100644 --- a/tests/integration/features/chat/rich-object-share.feature +++ b/tests/integration/features/chat/rich-object-share.feature @@ -11,6 +11,16 @@ Feature: chat/public | room | actorType | actorId | actorDisplayName | message | messageParameters | | public room | users | participant1 | participant1-displayname | {object} | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"},"object":{"name":"Another room","call-type":"group","type":"call","id":"R4nd0mT0k3n"}} | + Scenario: Delete a rich object from a chat + Given user "participant1" creates room "public room" (v4) + | roomType | 3 | + | roomName | room | + When user "participant1" shares rich-object "call" "R4nd0mT0k3n" '{"name":"Another room","call-type":"group"}' to room "public room" with 201 (v1) + And user "participant1" deletes message "shared::call::R4nd0mT0k3n" from room "public room" with 200 + Then user "participant1" sees the following messages in room "public room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | parentMessage | + | public room | users | participant1 | participant1-displayname | Message deleted by you | {"actor":{"type":"user","id":"participant1","name":"participant1-displayname"}} | | + Scenario: Share an invalid rich object to a chat Given user "participant1" creates room "public room" (v4) | roomType | 3 | diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index 890aff4ab74..518fa14b9b5 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -104,6 +104,7 @@ public function setUp(): void { 'notification-calls', 'conversation-permissions', 'rich-object-list-media', + 'rich-object-delete', 'reactions', ]; } diff --git a/tests/php/Chat/ChatManagerTest.php b/tests/php/Chat/ChatManagerTest.php index ad6cbd7c4e0..78f9cf693a3 100644 --- a/tests/php/Chat/ChatManagerTest.php +++ b/tests/php/Chat/ChatManagerTest.php @@ -28,6 +28,7 @@ use OCA\Talk\Chat\CommentsManager; use OCA\Talk\Chat\Notifier; use OCA\Talk\Model\Attendee; +use OCA\Talk\Model\AttendeeMapper; use OCA\Talk\Participant; use OCA\Talk\Room; use OCA\Talk\Service\ParticipantService; @@ -39,6 +40,9 @@ use OCP\ICacheFactory; use OCP\IUser; use OCP\Notification\IManager as INotificationManager; +use OCP\Share\Exceptions\ShareNotFound; +use OCP\Share\IManager; +use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; @@ -53,6 +57,8 @@ class ChatManagerTest extends TestCase { protected $dispatcher; /** @var INotificationManager|MockObject */ protected $notificationManager; + /** @var IManager|MockObject */ + protected $shareManager; /** @var RoomShareProvider|MockObject */ protected $shareProvider; /** @var ParticipantService|MockObject */ @@ -70,6 +76,7 @@ public function setUp(): void { $this->commentsManager = $this->createMock(CommentsManager::class); $this->dispatcher = $this->createMock(IEventDispatcher::class); $this->notificationManager = $this->createMock(INotificationManager::class); + $this->shareManager = $this->createMock(IManager::class); $this->shareProvider = $this->createMock(RoomShareProvider::class); $this->participantService = $this->createMock(ParticipantService::class); $this->notifier = $this->createMock(Notifier::class); @@ -81,6 +88,7 @@ public function setUp(): void { $this->dispatcher, \OC::$server->getDatabaseConnection(), $this->notificationManager, + $this->shareManager, $this->shareProvider, $this->participantService, $this->notifier, @@ -103,6 +111,7 @@ protected function getManager(array $methods = []): ChatManager { $this->dispatcher, \OC::$server->getDatabaseConnection(), $this->notificationManager, + $this->shareManager, $this->shareProvider, $this->participantService, $this->notifier, @@ -118,6 +127,7 @@ protected function getManager(array $methods = []): ChatManager { $this->dispatcher, \OC::$server->getDatabaseConnection(), $this->notificationManager, + $this->shareManager, $this->shareProvider, $this->participantService, $this->notifier, @@ -392,6 +402,196 @@ public function testDeleteMessages(): void { $this->chatManager->deleteMessages($chat); } + public function testDeleteMessage(): void { + $mapper = new AttendeeMapper(\OC::$server->getDatabaseConnection()); + $attendee = $mapper->createAttendeeFromRow([ + 'a_id' => 1, + 'room_id' => 123, + 'actor_type' => Attendee::ACTOR_USERS, + 'actor_id' => 'user', + 'display_name' => 'user-display', + 'pin' => '', + 'participant_type' => Participant::USER, + 'favorite' => true, + 'notification_level' => Participant::NOTIFY_MENTION, + 'notification_calls' => Participant::NOTIFY_CALLS_ON, + 'last_joined_call' => 0, + 'last_read_message' => 0, + 'last_mention_message' => 0, + 'last_mention_direct' => 0, + 'read_privacy' => Participant::PRIVACY_PUBLIC, + 'permissions' => Attendee::PERMISSIONS_DEFAULT, + 'access_token' => '', + 'remote_id' => '', + ]); + $chat = $this->createMock(Room::class); + $chat->expects($this->any()) + ->method('getId') + ->willReturn(1234); + $participant = new Participant($chat, $attendee, null); + + $date = new \DateTime(); + + $comment = $this->createMock(IComment::class); + $comment->method('getId') + ->willReturn('123456'); + $comment->method('getVerb') + ->willReturn('comment'); + $comment->expects($this->once()) + ->method('setMessage'); + $comment->expects($this->once()) + ->method('setVerb') + ->with('comment_deleted'); + + $this->commentsManager->expects($this->once()) + ->method('save') + ->with($comment); + + $systemMessage = $this->createMock(IComment::class); + + $chatManager = $this->getManager(['addSystemMessage']); + $chatManager->expects($this->once()) + ->method('addSystemMessage') + ->with($chat, Attendee::ACTOR_USERS, 'user', $this->anything(), $this->anything(), false, null, 123456) + ->willReturn($systemMessage); + + $this->assertSame($systemMessage, $chatManager->deleteMessage($chat, $comment, $participant, $date)); + } + + public function testDeleteMessageFileShare(): void { + $mapper = new AttendeeMapper(\OC::$server->getDatabaseConnection()); + $attendee = $mapper->createAttendeeFromRow([ + 'a_id' => 1, + 'room_id' => 123, + 'actor_type' => Attendee::ACTOR_USERS, + 'actor_id' => 'user', + 'display_name' => 'user-display', + 'pin' => '', + 'participant_type' => Participant::USER, + 'favorite' => true, + 'notification_level' => Participant::NOTIFY_MENTION, + 'notification_calls' => Participant::NOTIFY_CALLS_ON, + 'last_joined_call' => 0, + 'last_read_message' => 0, + 'last_mention_message' => 0, + 'last_mention_direct' => 0, + 'read_privacy' => Participant::PRIVACY_PUBLIC, + 'permissions' => Attendee::PERMISSIONS_DEFAULT, + 'access_token' => '', + 'remote_id' => '', + ]); + $chat = $this->createMock(Room::class); + $chat->expects($this->any()) + ->method('getId') + ->willReturn(1234); + $chat->expects($this->any()) + ->method('getToken') + ->willReturn('T0k3N'); + $participant = new Participant($chat, $attendee, null); + + $date = new \DateTime(); + + $comment = $this->createMock(IComment::class); + $comment->method('getId') + ->willReturn('123456'); + $comment->method('getVerb') + ->willReturn('object_shared'); + $comment->expects($this->once()) + ->method('getMessage') + ->willReturn(json_encode(['message' => 'file_shared', 'parameters' => ['share' => '42']])); + $comment->expects($this->once()) + ->method('setMessage'); + $comment->expects($this->once()) + ->method('setVerb') + ->with('comment_deleted'); + + $share = $this->createMock(IShare::class); + $share->method('getShareType') + ->willReturn(IShare::TYPE_ROOM); + $share->method('getSharedWith') + ->willReturn('T0k3N'); + $share->method('getShareOwner') + ->willReturn('user'); + + $this->shareManager->method('getShareById') + ->with('ocRoomShare:42') + ->willReturn($share); + + $this->shareManager->expects($this->once()) + ->method('deleteShare') + ->willReturn($share); + + $this->commentsManager->expects($this->once()) + ->method('save') + ->with($comment); + + $systemMessage = $this->createMock(IComment::class); + + $chatManager = $this->getManager(['addSystemMessage']); + $chatManager->expects($this->once()) + ->method('addSystemMessage') + ->with($chat, Attendee::ACTOR_USERS, 'user', $this->anything(), $this->anything(), false, null, 123456) + ->willReturn($systemMessage); + + $this->assertSame($systemMessage, $chatManager->deleteMessage($chat, $comment, $participant, $date)); + } + + public function testDeleteMessageFileShareNotFound(): void { + $mapper = new AttendeeMapper(\OC::$server->getDatabaseConnection()); + $attendee = $mapper->createAttendeeFromRow([ + 'a_id' => 1, + 'room_id' => 123, + 'actor_type' => Attendee::ACTOR_USERS, + 'actor_id' => 'user', + 'display_name' => 'user-display', + 'pin' => '', + 'participant_type' => Participant::USER, + 'favorite' => true, + 'notification_level' => Participant::NOTIFY_MENTION, + 'notification_calls' => Participant::NOTIFY_CALLS_ON, + 'last_joined_call' => 0, + 'last_read_message' => 0, + 'last_mention_message' => 0, + 'last_mention_direct' => 0, + 'read_privacy' => Participant::PRIVACY_PUBLIC, + 'permissions' => Attendee::PERMISSIONS_DEFAULT, + 'access_token' => '', + 'remote_id' => '', + ]); + $chat = $this->createMock(Room::class); + $chat->expects($this->any()) + ->method('getId') + ->willReturn(1234); + $participant = new Participant($chat, $attendee, null); + + $date = new \DateTime(); + + $comment = $this->createMock(IComment::class); + $comment->method('getId') + ->willReturn('123456'); + $comment->method('getVerb') + ->willReturn('object_shared'); + $comment->expects($this->once()) + ->method('getMessage') + ->willReturn(json_encode(['message' => 'file_shared', 'parameters' => ['share' => '42']])); + + $this->shareManager->method('getShareById') + ->with('ocRoomShare:42') + ->willThrowException(new ShareNotFound()); + + $this->commentsManager->expects($this->never()) + ->method('save'); + + $systemMessage = $this->createMock(IComment::class); + + $chatManager = $this->getManager(['addSystemMessage']); + $chatManager->expects($this->never()) + ->method('addSystemMessage'); + + $this->expectException(ShareNotFound::class); + $this->assertSame($systemMessage, $chatManager->deleteMessage($chat, $comment, $participant, $date)); + } + public function testClearHistory(): void { $chat = $this->createMock(Room::class); $chat->expects($this->any())