diff --git a/appinfo/info.xml b/appinfo/info.xml index 22b94f93af5..6d3d3492c99 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-alpha.2 + 11.0.0-alpha.3 agpl Daniel Calviño Sánchez diff --git a/appinfo/routes.php b/appinfo/routes.php index 1c6aed1ad11..ce647f4878c 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -592,5 +592,33 @@ 'apiVersion' => 'v1', ], ], + + /** + * Room avatar + */ + [ + 'name' => 'RoomAvatar#getAvatar', + 'url' => '/api/{apiVersion}/avatar/{roomToken}/{size}', + 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => 'v3', + ], + ], + [ + 'name' => 'RoomAvatar#setAvatar', + 'url' => '/api/{apiVersion}/avatar/{roomToken}', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v3', + ], + ], + [ + 'name' => 'RoomAvatar#deleteAvatar', + 'url' => '/api/{apiVersion}/avatar/{roomToken}', + 'verb' => 'DELETE', + 'requirements' => [ + 'apiVersion' => 'v3', + ], + ], ], ]; diff --git a/docs/capabilities.md b/docs/capabilities.md index 67c8fb5c8a9..751b7c1bde9 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -60,5 +60,6 @@ title: Capabilities * `listable-rooms` - Conversations can searched for even when not joined. A "listable" attribute set on rooms defines the scope of who can find it. * `phonebook-search` - Is present when the server has the endpoint to search for phone numbers to find matches in the accounts list * `raise-hand` - Participants can raise or lower hand, the state change is sent through signaling messages. +* `room-avatar` - A custom picture can be got and set for conversations. * `config => chat => read-privacy` - See `chat-read-status` * `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. diff --git a/docs/conversation.md b/docs/conversation.md index 7b90beed55a..0ba12e31185 100644 --- a/docs/conversation.md +++ b/docs/conversation.md @@ -48,6 +48,8 @@ `name` | string | * | Name of the conversation (can also be empty) `displayName` | string | * | `name` if non empty, otherwise it falls back to a list of participants `description` | string | v3 | Description of the conversation (can also be empty) + `avatarId` | string | v3 | The type of the avatar ("custom", "user", "icon-public", "icon-contacts", "icon-mail", "icon-password", "icon-changelog", "icon-file") (only available with `room-avatar` capability) + `avatarVersion` | int | v3 | The version of the avatar (only available with `room-avatar` capability) `participantType` | int | * | Permissions level of the current user `attendeeId` | int | v3 | Unique attendee id `attendeePin` | string | v3 | Unique dial-in authentication code for this user, when the conversation has SIP enabled (see `sipEnabled` attribute) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 787db6da2f3..528d432f537 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -25,6 +25,7 @@ use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; use OCA\Talk\Activity\Listener as ActivityListener; +use OCA\Talk\Avatar\Listener as AvatarListener; use OCA\Talk\Capabilities; use OCA\Talk\Chat\Changelog\Listener as ChangelogListener; use OCA\Talk\Chat\ChatManager; @@ -131,6 +132,7 @@ public function boot(IBootContext $context): void { ChangelogListener::register($dispatcher); ShareListener::register($dispatcher); Operation::register($dispatcher); + AvatarListener::register($dispatcher); $this->registerRoomActivityHooks($dispatcher); $this->registerChatHooks($dispatcher); diff --git a/lib/Avatar/Listener.php b/lib/Avatar/Listener.php new file mode 100644 index 00000000000..4c50ff68c05 --- /dev/null +++ b/lib/Avatar/Listener.php @@ -0,0 +1,84 @@ + + * + * @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\Avatar; + +use OCA\Talk\Manager; +use OCA\Talk\Room; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUser; +use Symfony\Component\EventDispatcher\GenericEvent; + +class Listener { + + /** @var Manager */ + private $manager; + + /** + * @param Manager $manager + */ + public function __construct( + Manager $manager) { + $this->manager = $manager; + } + + public static function register(IEventDispatcher $dispatcher): void { + $listener = static function (GenericEvent $event) { + if ($event->getArgument('feature') !== 'avatar') { + return; + } + + /** @var self $listener */ + $listener = \OC::$server->query(self::class); + $listener->updateRoomAvatarsFromChangedUserAvatar($event->getSubject()); + }; + $dispatcher->addListener(IUser::class . '::changeUser', $listener); + } + + /** + * Updates the associated room avatars from the changed user avatar + * + * The avatar versions of all the one-to-one conversations of that user are + * bumped. + * + * Note that the avatar seen by the user who has changed her avatar will not + * change, as she will get the avatar of the other user, but even if the + * avatar images are independent the avatar version is a shared value and + * needs to be bumped for both. + * + * @param IUser $user the user whose avatar changed + */ + public function updateRoomAvatarsFromChangedUserAvatar(IUser $user): void { + $rooms = $this->manager->getRoomsForUser($user->getUID()); + foreach ($rooms as $room) { + if ($room->getType() !== Room::ONE_TO_ONE_CALL) { + continue; + } + + $room->setAvatar($room->getAvatarId(), $room->getAvatarVersion() + 1); + } + } +} diff --git a/lib/Avatar/RoomAvatar.php b/lib/Avatar/RoomAvatar.php new file mode 100644 index 00000000000..3637858356e --- /dev/null +++ b/lib/Avatar/RoomAvatar.php @@ -0,0 +1,383 @@ + + * + * @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\Avatar; + +use OCA\Talk\Room; +use OCP\Files\File; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; +use OCP\IAvatar; +use OCP\IImage; +use OCP\IL10N; +use OCP\Image; +use Psr\Log\LoggerInterface; + +class RoomAvatar implements IAvatar { + + /** @var ISimpleFolder */ + private $folder; + + /** @var Room */ + private $room; + + /** @var IL10N */ + private $l; + + /** @var LoggerInterface */ + private $logger; + + /** @var Util */ + private $util; + + public function __construct( + ISimpleFolder $folder, + Room $room, + IL10N $l, + LoggerInterface $logger, + Util $util) { + $this->folder = $folder; + $this->room = $room; + $this->l = $l; + $this->logger = $logger; + $this->util = $util; + } + + public function getRoom(): Room { + return $this->room; + } + + /** + * Returns the default room avatar type ("user", "icon-public", + * "icon-contacts"...) for the given room data + * + * @param int $roomType the type of the room + * @param string $objectType the object type of the room + * @return string the room avatar type + */ + public static function getDefaultRoomAvatarType(int $roomType, string $objectType): string { + if ($roomType === Room::ONE_TO_ONE_CALL) { + return 'user'; + } + + if ($objectType === 'emails') { + return 'icon-mail'; + } + + if ($objectType === 'file') { + return 'icon-file'; + } + + if ($objectType === 'share:password') { + return 'icon-password'; + } + + if ($roomType === Room::CHANGELOG_CONVERSATION) { + return 'icon-changelog'; + } + + if ($roomType === Room::GROUP_CALL) { + return 'icon-contacts'; + } + + return 'icon-public'; + } + + /** + * Returns the room avatar type ("custom", "user", "icon-public", + * "icon-contacts"...) of this RoomAvatar + * + * @return string the room avatar type + */ + public function getRoomAvatarType(): string { + if ($this->isCustomAvatar()) { + return 'custom'; + } + + return self::getDefaultRoomAvatarType($this->room->getType(), $this->room->getObjectType()); + } + + /** + * Gets the room avatar + * + * @param int $size size in px of the avatar, avatars are square, defaults + * to 64, -1 can be used to not scale the image + * @return bool|\OCP\IImage containing the avatar or false if there is no + * image + */ + public function get($size = 64) { + $size = (int) $size; + + try { + $file = $this->getFile($size); + } catch (NotFoundException $e) { + return false; + } + + $avatar = new Image(); + $avatar->loadFromData($file->getContent()); + return $avatar; + } + + /** + * Checks if an avatar exists for the room + * + * @return bool + */ + public function exists(): bool { + return $this->folder->fileExists('avatar.jpg') || $this->folder->fileExists('avatar.png'); + } + + /** + * Checks if the avatar of a room is a custom uploaded one + * + * @return bool + */ + public function isCustomAvatar(): bool { + return $this->exists(); + } + + /** + * Sets the room avatar + * + * @param \OCP\IImage|resource|string $data An image object, imagedata or + * path to set a new avatar + * @throws \Exception if the provided file is not a jpg or png image + * @throws \Exception if the provided image is not valid + * @return void + */ + public function set($data): void { + $image = $this->getAvatarImage($data); + $data = $image->data(); + + $this->validateAvatar($image); + + $this->removeFiles(); + $type = $this->getAvatarImageType($image); + $file = $this->folder->newFile('avatar.' . $type); + $file->putContent($data); + + $this->room->setAvatar($this->getRoomAvatarType(), $this->room->getAvatarVersion() + 1); + } + + /** + * Returns an image from several sources + * + * @param IImage|resource|string $data An image object, imagedata or path to + * the avatar + * @return IImage + */ + private function getAvatarImage($data): IImage { + if ($data instanceof IImage) { + return $data; + } + + $image = new Image(); + if (is_resource($data) && get_resource_type($data) === 'gd') { + $image->setResource($data); + } elseif (is_resource($data)) { + $image->loadFromFileHandle($data); + } else { + try { + // detect if it is a path or maybe the images as string + $result = @realpath($data); + if ($result === false || $result === null) { + $image->loadFromData($data); + } else { + $image->loadFromFile($data); + } + } catch (\Error $e) { + $image->loadFromData($data); + } + } + + return $image; + } + + /** + * Returns the avatar image type + * + * @param IImage $avatar + * @return string + */ + private function getAvatarImageType(IImage $avatar): string { + $type = substr($avatar->mimeType(), -3); + if ($type === 'peg') { + $type = 'jpg'; + } + return $type; + } + + /** + * Validates an avatar image: + * - must be "png" or "jpg" + * - must be "valid" + * - must be in square format + * + * @param IImage $avatar The avatar to validate + * @throws \Exception if the provided file is not a jpg or png image + * @throws \Exception if the provided image is not valid + * @throws \Exception if the image is not square + */ + private function validateAvatar(IImage $avatar): void { + $type = $this->getAvatarImageType($avatar); + + if ($type !== 'jpg' && $type !== 'png') { + throw new \Exception($this->l->t('Unknown filetype')); + } + + if (!$avatar->valid()) { + throw new \Exception($this->l->t('Invalid image')); + } + + if (!($avatar->height() === $avatar->width())) { + throw new \Exception($this->l->t('Avatar image is not square')); + } + } + + /** + * Remove the room avatar + * + * @return void + */ + public function remove(): void { + $this->removeFiles(); + + $this->room->setAvatar($this->getRoomAvatarType(), $this->room->getAvatarVersion() + 1); + } + + /** + * Remove the files for the room avatar + * + * @return void + */ + private function removeFiles(): void { + $files = $this->folder->getDirectoryListing(); + + // Deletes the original image as well as the resized ones. + foreach ($files as $file) { + $file->delete(); + } + } + + /** + * Get the file of the avatar + * + * @param int $size -1 can be used to not scale the image + * @return ISimpleFile|File + * @throws NotFoundException + */ + public function getFile($size) { + $size = (int) $size; + + if ($this->room->getType() === Room::ONE_TO_ONE_CALL) { + $userAvatar = $this->util->getUserAvatarForOtherParticipant($this->room); + + return $userAvatar->getFile($size); + } + + $extension = $this->getExtension(); + + if ($size === -1) { + $path = 'avatar.' . $extension; + } else { + $path = 'avatar.' . $size . '.' . $extension; + } + + try { + $file = $this->folder->getFile($path); + } catch (NotFoundException $e) { + if ($size <= 0) { + throw new NotFoundException(); + } + + $file = $this->generateResizedAvatarFile($extension, $path, $size); + } + + return $file; + } + + /** + * Gets the extension of the avatar file + * + * @return string the extension + * @throws NotFoundException if there is no avatar + */ + private function getExtension(): string { + if ($this->folder->fileExists('avatar.jpg')) { + return 'jpg'; + } + if ($this->folder->fileExists('avatar.png')) { + return 'png'; + } + throw new NotFoundException; + } + + /** + * Generates a resized avatar file with the given size + * + * @param string $extension the extension of the original avatar file + * @param string $path the path to the resized avatar file + * @param int $size the size of the avatar + * @return ISimpleFile the resized avatar file + * @throws NotFoundException if it was not possible to generate the resized + * avatar file + */ + private function generateResizedAvatarFile(string $extension, string $path, int $size): ISimpleFile { + $avatar = new Image(); + $file = $this->folder->getFile('avatar.' . $extension); + $avatar->loadFromData($file->getContent()); + $avatar->resize($size); + $data = $avatar->data(); + + try { + $file = $this->folder->newFile($path); + $file->putContent($data); + } catch (NotPermittedException $e) { + $this->logger->error('Failed to save avatar for room ' . $this->room->getToken() . ' with size ' . $size); + throw new NotFoundException(); + } + + return $file; + } + + /** + * Ignored. + */ + public function avatarBackgroundColor(string $text) { + // Unused, unneeded, and Color class it not even public, so just return + // null. + return null; + } + + /** + * Ignored. + */ + public function userChanged($feature, $oldValue, $newValue) { + } +} diff --git a/lib/Avatar/RoomAvatarProvider.php b/lib/Avatar/RoomAvatarProvider.php new file mode 100644 index 00000000000..b44d62b0af0 --- /dev/null +++ b/lib/Avatar/RoomAvatarProvider.php @@ -0,0 +1,180 @@ + + * + * @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\Avatar; + +use OCA\Talk\Exceptions\ParticipantNotFoundException; +use OCA\Talk\Exceptions\RoomNotFoundException; +use OCA\Talk\Manager; +use OCA\Talk\Room; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\IAvatar; +use OCP\IL10N; +use Psr\Log\LoggerInterface; + +class RoomAvatarProvider { + + /** @var IAppData */ + private $appData; + + /** @var Manager */ + private $manager; + + /** @var IL10N */ + private $l; + + /** @var LoggerInterface */ + private $logger; + + /** @var Util */ + private $util; + + public function __construct( + IAppData $appData, + Manager $manager, + IL10N $l, + LoggerInterface $logger, + Util $util) { + $this->appData = $appData; + $this->manager = $manager; + $this->l = $l; + $this->logger = $logger; + $this->util = $util; + } + + /** + * Returns a RoomAvatar instance for the given room token + * + * @param string $id the identifier of the avatar + * @returns IAvatar the RoomAvatar + * @throws RoomNotFoundException if there is no room with the given token + */ + public function getAvatar(string $id): IAvatar { + $room = $this->manager->getRoomByToken($id); + + try { + $folder = $this->appData->getFolder('avatar/' . $id); + } catch (NotFoundException $e) { + $folder = $this->appData->newFolder('avatar/' . $id); + } + + return new RoomAvatar($folder, $room, $this->l, $this->logger, $this->util); + } + + /** + * Returns whether the current user can access the given avatar or not + * + * @param IAvatar $avatar the avatar to check + * @return bool true if the room is public, the current user is a + * participant of the room or can list it, false otherwise + * @throws \InvalidArgumentException if the given avatar is not a RoomAvatar + */ + public function canBeAccessedByCurrentUser(IAvatar $avatar): bool { + if (!($avatar instanceof RoomAvatar)) { + throw new \InvalidArgumentException(); + } + + $room = $avatar->getRoom(); + + if ($room->getType() === Room::PUBLIC_CALL) { + return true; + } + + try { + $this->util->getCurrentParticipant($room); + } catch (ParticipantNotFoundException $e) { + return $this->util->isRoomListableByUser($room); + } + + return true; + } + + /** + * Returns whether the current user can modify the given avatar or not + * + * @param IAvatar $avatar the avatar to check + * @return bool true if the current user is a moderator of the room and the + * room is not a one-to-one, password request or file room, false + * otherwise + * @throws \InvalidArgumentException if the given avatar is not a RoomAvatar + */ + public function canBeModifiedByCurrentUser(IAvatar $avatar): bool { + if (!($avatar instanceof RoomAvatar)) { + throw new \InvalidArgumentException(); + } + + $room = $avatar->getRoom(); + + if ($room->getType() === Room::ONE_TO_ONE_CALL) { + return false; + } + + if ($room->getObjectType() === 'share:password') { + return false; + } + + if ($room->getObjectType() === 'file') { + return false; + } + + try { + $currentParticipant = $this->util->getCurrentParticipant($room); + } catch (ParticipantNotFoundException $e) { + return false; + } + + return $currentParticipant->hasModeratorPermissions(); + } + + /** + * Returns the latest value of the avatar version + * + * @param IAvatar $avatar + * @return int + * @throws \InvalidArgumentException if the given avatar is not a RoomAvatar + */ + public function getVersion(IAvatar $avatar): int { + if (!($avatar instanceof RoomAvatar)) { + throw new \InvalidArgumentException(); + } + + $room = $avatar->getRoom(); + + return $room->getAvatarVersion(); + } + + /** + * Returns the cache duration for room avatars in seconds + * + * @param IAvatar $avatar ignored, same duration for all room avatars + * @return int|null the cache duration + */ + public function getCacheTimeToLive(IAvatar $avatar): ?int { + // Cache for 1 day. + return 60 * 60 * 24; + } +} diff --git a/lib/Avatar/Util.php b/lib/Avatar/Util.php new file mode 100644 index 00000000000..b9c6c13114c --- /dev/null +++ b/lib/Avatar/Util.php @@ -0,0 +1,126 @@ + + * + * @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\Avatar; + +use OCA\Talk\Exceptions\ParticipantNotFoundException; +use OCA\Talk\Manager; +use OCA\Talk\Participant; +use OCA\Talk\Room; +use OCA\Talk\Service\ParticipantService; +use OCA\Talk\TalkSession; +use OCP\IAvatar; +use OCP\IAvatarManager; + +class Util { + + /** @var string|null */ + protected $userId; + + /** @var TalkSession */ + protected $session; + + /** @var IAvatarManager */ + private $avatarManager; + + /** @var Manager */ + private $manager; + + /** @var ParticipantService */ + private $participantService; + + /** + * @param string|null $userId + * @param TalkSession $session + * @param IAvatarManager $avatarManager + * @param Manager $manager + * @param ParticipantService $participantService + */ + public function __construct( + ?string $userId, + TalkSession $session, + IAvatarManager $avatarManager, + Manager $manager, + ParticipantService $participantService) { + $this->userId = $userId; + $this->session = $session; + $this->avatarManager = $avatarManager; + $this->manager = $manager; + $this->participantService = $participantService; + } + + /** + * @param Room $room + * @return Participant + * @throws ParticipantNotFoundException + */ + public function getCurrentParticipant(Room $room): Participant { + $participant = null; + try { + $participant = $room->getParticipant($this->userId); + } catch (ParticipantNotFoundException $e) { + $participant = $room->getParticipantBySession($this->session->getSessionForRoom($room->getToken())); + } + + return $participant; + } + + /** + * @param Room $room + * @return bool + */ + public function isRoomListableByUser(Room $room): bool { + return $this->manager->isRoomListableByUser($room, $this->userId); + } + + /** + * @param Room $room + * @return IAvatar + * @throws \InvalidArgumentException if the given room is not a one-to-one + * room, the current participant is not a member of the room or + * there is no other participant in the room + */ + public function getUserAvatarForOtherParticipant(Room $room): IAvatar { + if ($room->getType() !== Room::ONE_TO_ONE_CALL) { + throw new \InvalidArgumentException('Not a one-to-one room'); + } + + $userIds = $this->participantService->getParticipantUserIds($room); + if (array_search($this->userId, $userIds) === false) { + throw new \InvalidArgumentException('Current participant is not a member of the room'); + } + if (count($userIds) < 2) { + throw new \InvalidArgumentException('No other participant in the room'); + } + + $otherParticipantUserId = $userIds[0]; + if ($otherParticipantUserId === $this->userId) { + $otherParticipantUserId = $userIds[1]; + } + + return $this->avatarManager->getAvatar($otherParticipantUserId); + } +} diff --git a/lib/Capabilities.php b/lib/Capabilities.php index b9faad6cd31..0212a2ad4de 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -85,6 +85,7 @@ public function getCapabilities(): array { 'chat-read-status', 'phonebook-search', 'raise-hand', + 'room-avatar', ], 'config' => [ 'attachments' => [ diff --git a/lib/Chat/Parser/SystemMessage.php b/lib/Chat/Parser/SystemMessage.php index 9633b957eef..d08bad77224 100644 --- a/lib/Chat/Parser/SystemMessage.php +++ b/lib/Chat/Parser/SystemMessage.php @@ -134,6 +134,16 @@ public function parseMessage(Message $chatMessage): void { } elseif ($cliIsActor) { $parsedMessage = $this->l->t('An administrator removed the description'); } + } elseif ($message === 'avatar_set') { + $parsedMessage = $this->l->t('{actor} set the conversation picture'); + if ($currentUserIsActor) { + $parsedMessage = $this->l->t('You set the conversation picture'); + } + } elseif ($message === 'avatar_removed') { + $parsedMessage = $this->l->t('{actor} removed the conversation picture'); + if ($currentUserIsActor) { + $parsedMessage = $this->l->t('You removed the conversation picture'); + } } elseif ($message === 'call_started') { $parsedMessage = $this->l->t('{actor} started a call'); if ($currentUserIsActor) { diff --git a/lib/Chat/SystemMessage/Listener.php b/lib/Chat/SystemMessage/Listener.php index f839196ad0e..f9ecf868852 100644 --- a/lib/Chat/SystemMessage/Listener.php +++ b/lib/Chat/SystemMessage/Listener.php @@ -136,6 +136,17 @@ public static function register(IEventDispatcher $dispatcher): void { $listener->sendSystemMessage($room, 'description_removed'); } }); + $dispatcher->addListener(Room::EVENT_AFTER_AVATAR_SET, static function (ModifyRoomEvent $event) { + $room = $event->getRoom(); + /** @var self $listener */ + $listener = \OC::$server->get(self::class); + + if ($event->getNewValue() === 'custom') { + $listener->sendSystemMessage($room, 'avatar_set'); + } elseif ($event->getNewValue() !== 'custom' && $event->getOldValue() === 'custom') { + $listener->sendSystemMessage($room, 'avatar_removed'); + } + }); $dispatcher->addListener(Room::EVENT_AFTER_PASSWORD_SET, static function (ModifyRoomEvent $event) { $room = $event->getRoom(); /** @var self $listener */ diff --git a/lib/Controller/RoomAvatarController.php b/lib/Controller/RoomAvatarController.php new file mode 100644 index 00000000000..fd701039ad7 --- /dev/null +++ b/lib/Controller/RoomAvatarController.php @@ -0,0 +1,233 @@ + + * + * @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\Controller; + +use OCA\Talk\Avatar\RoomAvatarProvider; +use OCP\AppFramework\OCSController; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\Response; +use OCP\Files\NotFoundException; +use OCP\IL10N; +use OCP\Image; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +class RoomAvatarController extends OCSController { + + /** @var IL10N */ + protected $l; + + /** @var LoggerInterface */ + protected $logger; + + /** @var RoomAvatarProvider */ + protected $roomAvatarProvider; + + public function __construct($appName, + IRequest $request, + IL10N $l10n, + LoggerInterface $logger, + RoomAvatarProvider $roomAvatarProvider) { + parent::__construct($appName, $request); + + $this->l = $l10n; + $this->logger = $logger; + $this->roomAvatarProvider = $roomAvatarProvider; + } + + /** + * @PublicPage + * + * @param string $roomToken + * @param int $size + * @return DataResponse|FileDisplayResponse + */ + public function getAvatar(string $roomToken, int $size): Response { + $size = $this->sanitizeSize($size); + + try { + $avatar = $this->roomAvatarProvider->getAvatar($roomToken); + } catch (\InvalidArgumentException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + if (!$this->roomAvatarProvider->canBeAccessedByCurrentUser($avatar)) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + try { + $avatarFile = $avatar->getFile($size); + $response = new FileDisplayResponse( + $avatarFile, + Http::STATUS_OK, + [ + 'Content-Type' => $avatarFile->getMimeType(), + 'X-NC-IsCustomAvatar' => $avatar->isCustomAvatar() ? '1' : '0', + ] + ); + } catch (NotFoundException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + $cache = $this->roomAvatarProvider->getCacheTimeToLive($avatar); + if ($cache !== null) { + $response->cacheFor($cache); + } + + return $response; + } + + /** + * Returns the closest value to the predefined set of sizes + * + * @param int $size the size to sanitize + * @return int the sanitized size + */ + private function sanitizeSize(int $size): int { + $validSizes = [64, 128, 256, 512]; + + if ($size < $validSizes[0]) { + return $validSizes[0]; + } + + if ($size > $validSizes[count($validSizes) - 1]) { + return $validSizes[count($validSizes) - 1]; + } + + for ($i = 0; $i < count($validSizes) - 1; $i++) { + if ($size >= $validSizes[$i] && $size <= $validSizes[$i + 1]) { + $middlePoint = ($validSizes[$i] + $validSizes[$i + 1]) / 2; + if ($size < $middlePoint) { + return $validSizes[$i]; + } + return $validSizes[$i + 1]; + } + } + + return $size; + } + + /** + * @PublicPage + * + * @param string $roomToken + * @return DataResponse + */ + public function setAvatar(string $roomToken): DataResponse { + $files = $this->request->getUploadedFile('files'); + + if (is_null($files)) { + return new DataResponse( + ['data' => ['message' => $this->l->t('No file provided')]], + Http::STATUS_BAD_REQUEST + ); + } + + if ( + $files['error'][0] !== 0 || + !is_uploaded_file($files['tmp_name'][0]) || + \OC\Files\Filesystem::isFileBlacklisted($files['tmp_name'][0]) + ) { + return new DataResponse( + ['data' => ['message' => $this->l->t('Invalid file provided')]], + Http::STATUS_BAD_REQUEST + ); + } + + if ($files['size'][0] > 20 * 1024 * 1024) { + return new DataResponse( + ['data' => ['message' => $this->l->t('File is too big')]], + Http::STATUS_BAD_REQUEST + ); + } + + $content = file_get_contents($files['tmp_name'][0]); + unlink($files['tmp_name'][0]); + + $image = new Image(); + $image->loadFromData($content); + + try { + $avatar = $this->roomAvatarProvider->getAvatar($roomToken); + } catch (\InvalidArgumentException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + if (!$this->roomAvatarProvider->canBeModifiedByCurrentUser($avatar)) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + try { + $avatar->set($image); + return new DataResponse( + ['status' => 'success'] + ); + } catch (\OC\NotSquareException $e) { + return new DataResponse( + ['data' => ['message' => $this->l->t('Crop is not square')]], + Http::STATUS_BAD_REQUEST + ); + } catch (\Exception $e) { + $this->logger->error('Error when setting avatar', ['app' => 'core', 'exception' => $e]); + return new DataResponse( + ['data' => ['message' => $this->l->t('An error occurred. Please contact your admin.')]], + Http::STATUS_BAD_REQUEST + ); + } + } + + /** + * @PublicPage + * + * @param string $roomToken + * @return DataResponse + */ + public function deleteAvatar(string $roomToken): DataResponse { + try { + $avatar = $this->roomAvatarProvider->getAvatar($roomToken); + } catch (\InvalidArgumentException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + if (!$this->roomAvatarProvider->canBeModifiedByCurrentUser($avatar)) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + try { + $avatar->remove(); + return new DataResponse(); + } catch (\Exception $e) { + $this->logger->error('Error when deleting avatar', ['app' => 'core', 'exception' => $e]); + return new DataResponse( + ['data' => ['message' => $this->l->t('An error occurred. Please contact your admin.')]], + Http::STATUS_BAD_REQUEST + ); + } + } +} diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index 6371f131e05..0089908688b 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -584,6 +584,8 @@ protected function formatRoomV2andV3(Room $room, ?Participant $currentParticipan 'canEnableSIP' => false, 'attendeePin' => '', 'description' => '', + 'avatarId' => '', + 'avatarVersion' => 0, 'lastCommonReadMessage' => 0, 'listable' => Room::LISTABLE_NONE, ]); @@ -658,6 +660,8 @@ protected function formatRoomV2andV3(Room $room, ?Participant $currentParticipan 'actorId' => $attendee->getActorId(), 'attendeeId' => $attendee->getId(), 'description' => $room->getDescription(), + 'avatarId' => $room->getAvatarId(), + 'avatarVersion' => $room->getAvatarVersion(), 'listable' => $room->getListable(), ]); diff --git a/lib/Events/ModifyAvatarEvent.php b/lib/Events/ModifyAvatarEvent.php new file mode 100644 index 00000000000..a16d65438b9 --- /dev/null +++ b/lib/Events/ModifyAvatarEvent.php @@ -0,0 +1,51 @@ + + * + * @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\Events; + +use OCA\Talk\Room; + +class ModifyAvatarEvent extends ModifyRoomEvent { + + /** @var int */ + protected $avatarVersion; + + public function __construct(Room $room, + string $parameter, + string $newValue, + string $oldValue, + int $avatarVersion) { + parent::__construct($room, $parameter, $newValue, $oldValue); + $this->avatarVersion = $avatarVersion; + } + + /** + * @return int + */ + public function avatarVersion(): int { + return $this->avatarVersion; + } +} diff --git a/lib/Manager.php b/lib/Manager.php index fc396ff56b1..aeb6bc6232d 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -23,6 +23,7 @@ namespace OCA\Talk; +use OCA\Talk\Avatar\RoomAvatar; use OCA\Talk\Chat\CommentsManager; use OCA\Talk\Events\RoomEvent; use OCA\Talk\Exceptions\ParticipantNotFoundException; @@ -185,6 +186,8 @@ public function createRoomObject(array $row): Room { (string) $row['token'], (string) $row['name'], (string) $row['description'], + (string) $row['avatar_id'], + (int) $row['avatar_version'], (string) $row['password'], (int) $row['active_guests'], $activeSince, @@ -804,6 +807,8 @@ public function getChangelogRoom(string $userId): Room { public function createRoom(int $type, string $name = '', string $objectType = '', string $objectId = ''): Room { $token = $this->getNewToken(); + $defaultRoomAvatarType = RoomAvatar::getDefaultRoomAvatarType($type, $objectType); + $query = $this->db->getQueryBuilder(); $query->insert('talk_rooms') ->values( @@ -811,6 +816,7 @@ public function createRoom(int $type, string $name = '', string $objectType = '' 'name' => $query->createNamedParameter($name), 'type' => $query->createNamedParameter($type, IQueryBuilder::PARAM_INT), 'token' => $query->createNamedParameter($token), + 'avatar_id' => $query->createNamedParameter($defaultRoomAvatarType), ] ); diff --git a/lib/Migration/Version11000Date20201229115215.php b/lib/Migration/Version11000Date20201229115215.php new file mode 100644 index 00000000000..e0587624f09 --- /dev/null +++ b/lib/Migration/Version11000Date20201229115215.php @@ -0,0 +1,101 @@ + + * + * @author Daniel Calviño Sánchez + * + * @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 OCA\Talk\Avatar\RoomAvatar; +use OCP\DB\ISchemaWrapper; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version11000Date20201229115215 extends SimpleMigrationStep { + + /** @var IDBConnection */ + protected $connection; + + public function __construct(IDBConnection $connection) { + $this->connection = $connection; + } + + /** + * @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(); + + $changedSchema = false; + + $table = $schema->getTable('talk_rooms'); + if (!$table->hasColumn('avatar_id')) { + $table->addColumn('avatar_id', Types::STRING, [ + 'notnull' => false, + ]); + + $changedSchema = true; + } + if (!$table->hasColumn('avatar_version')) { + $table->addColumn('avatar_version', Types::INTEGER, [ + 'notnull' => true, + 'default' => 1, + ]); + + $changedSchema = true; + } + + return $changedSchema ? $schema : null; + } + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + */ + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $update = $this->connection->getQueryBuilder(); + $update->update('talk_rooms') + ->set('avatar_id', $update->createParameter('avatar_id')) + ->where($update->expr()->eq('id', $update->createParameter('id'))); + + $query = $this->connection->getQueryBuilder(); + $query->select('*') + ->from('talk_rooms'); + + $result = $query->execute(); + while ($row = $result->fetch()) { + $defaultRoomAvatarType = RoomAvatar::getDefaultRoomAvatarType((int) $row['type'], (string) $row['object_type']); + $update->setParameter('avatar_id', $defaultRoomAvatarType) + ->setParameter('id', (int) $row['id']); + $update->execute(); + } + $result->closeCursor(); + } +} diff --git a/lib/Model/SelectHelper.php b/lib/Model/SelectHelper.php index 48973cacc36..16372cf42a4 100644 --- a/lib/Model/SelectHelper.php +++ b/lib/Model/SelectHelper.php @@ -39,6 +39,8 @@ public function selectRoomsTable(IQueryBuilder $query, string $alias = 'r'): voi ->addSelect($alias . 'token') ->addSelect($alias . 'name') ->addSelect($alias . 'description') + ->addSelect($alias . 'avatar_id') + ->addSelect($alias . 'avatar_version') ->addSelect($alias . 'password') ->addSelect($alias . 'active_guests') ->addSelect($alias . 'active_since') diff --git a/lib/Room.php b/lib/Room.php index 6943e69d56c..5beb4525c44 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -27,6 +27,7 @@ namespace OCA\Talk; +use OCA\Talk\Events\ModifyAvatarEvent; use OCA\Talk\Events\ModifyLobbyEvent; use OCA\Talk\Events\ModifyRoomEvent; use OCA\Talk\Events\RoomEvent; @@ -92,6 +93,8 @@ class Room { public const EVENT_AFTER_NAME_SET = self::class . '::postSetName'; public const EVENT_BEFORE_DESCRIPTION_SET = self::class . '::preSetDescription'; public const EVENT_AFTER_DESCRIPTION_SET = self::class . '::postSetDescription'; + public const EVENT_BEFORE_AVATAR_SET = self::class . '::preSetAvatar'; + public const EVENT_AFTER_AVATAR_SET = self::class . '::postSetAvatar'; public const EVENT_BEFORE_PASSWORD_SET = self::class . '::preSetPassword'; public const EVENT_AFTER_PASSWORD_SET = self::class . '::postSetPassword'; public const EVENT_BEFORE_TYPE_SET = self::class . '::preSetType'; @@ -165,6 +168,10 @@ class Room { /** @var string */ private $description; /** @var string */ + private $avatarId; + /** @var int */ + private $avatarVersion; + /** @var string */ private $password; /** @var int */ private $activeGuests; @@ -202,6 +209,8 @@ public function __construct(Manager $manager, string $token, string $name, string $description, + string $avatarId, + int $avatarVersion, string $password, int $activeGuests, \DateTime $activeSince = null, @@ -227,6 +236,8 @@ public function __construct(Manager $manager, $this->token = $token; $this->name = $name; $this->description = $description; + $this->avatarId = $avatarId; + $this->avatarVersion = $avatarVersion; $this->password = $password; $this->activeGuests = $activeGuests; $this->activeSince = $activeSince; @@ -314,6 +325,14 @@ public function getDescription(): string { return $this->description; } + public function getAvatarId(): string { + return $this->avatarId; + } + + public function getAvatarVersion(): int { + return $this->avatarVersion; + } + public function getActiveGuests(): int { return $this->activeGuests; } @@ -380,6 +399,8 @@ public function getPropertiesForSignaling(string $userId, bool $roomModified = t if ($roomModified) { $properties = array_merge($properties, [ 'description' => $this->getDescription(), + 'avatarId' => $this->getAvatarId(), + 'avatarVersion' => $this->getAvatarVersion(), ]); } @@ -618,6 +639,41 @@ public function setDescription(string $description): bool { return true; } + /** + * Sets the avatar id and version. + * + * @param string $avatarId + * @param int $avatarVersion + * @return bool True when the change was valid, false otherwise + */ + public function setAvatar(string $avatarId, int $avatarVersion): bool { + $oldAvatarId = $this->getAvatarId(); + $oldAvatarVersion = $this->getAvatarVersion(); + if ($avatarId === $oldAvatarId && $avatarVersion === $oldAvatarVersion) { + return false; + } + + if ($avatarVersion <= $oldAvatarVersion) { + return false; + } + + $event = new ModifyAvatarEvent($this, 'avatarId', $avatarId, $oldAvatarId, $avatarVersion); + $this->dispatcher->dispatch(self::EVENT_BEFORE_AVATAR_SET, $event); + + $query = $this->db->getQueryBuilder(); + $query->update('talk_rooms') + ->set('avatar_id', $query->createNamedParameter($avatarId)) + ->set('avatar_version', $query->createNamedParameter($avatarVersion, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('id', $query->createNamedParameter($this->getId(), IQueryBuilder::PARAM_INT))); + $query->execute(); + $this->avatarId = $avatarId; + $this->avatarVersion = $avatarVersion; + + $this->dispatcher->dispatch(self::EVENT_AFTER_AVATAR_SET, $event); + + return true; + } + /** * @param string $password Currently it is only allowed to have a password for Room::PUBLIC_CALL * @return bool True when the change was valid, false otherwise diff --git a/lib/Signaling/Listener.php b/lib/Signaling/Listener.php index d5625d1676a..e4f33afc53a 100644 --- a/lib/Signaling/Listener.php +++ b/lib/Signaling/Listener.php @@ -137,6 +137,7 @@ protected static function registerExternalSignaling(IEventDispatcher $dispatcher }; $dispatcher->addListener(Room::EVENT_AFTER_NAME_SET, $listener); $dispatcher->addListener(Room::EVENT_AFTER_DESCRIPTION_SET, $listener); + $dispatcher->addListener(Room::EVENT_AFTER_AVATAR_SET, $listener); $dispatcher->addListener(Room::EVENT_AFTER_PASSWORD_SET, $listener); $dispatcher->addListener(Room::EVENT_AFTER_TYPE_SET, $listener); $dispatcher->addListener(Room::EVENT_AFTER_READONLY_SET, $listener); diff --git a/src/components/ConversationIcon.vue b/src/components/ConversationIcon.vue index 4990bfdc403..de155e49c7b 100644 --- a/src/components/ConversationIcon.vue +++ b/src/components/ConversationIcon.vue @@ -24,12 +24,9 @@
- +
@@ -44,14 +41,11 @@