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 faf3b18d962..3da3721d74c 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -62,5 +62,6 @@ title: Capabilities * `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-description` - A description can be get and set for conversations. +* `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 7c3ae7e40af..912a3250560 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) (only available with `room-description` capability) + `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 1f3a5472080..5a3d46c1c2e 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -87,6 +87,7 @@ public function getCapabilities(): array { 'phonebook-search', 'raise-hand', 'room-description', + '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/tests/integration/data/blue-square-256.jpg b/tests/integration/data/blue-square-256.jpg new file mode 100644 index 00000000000..13eb46a10a8 Binary files /dev/null and b/tests/integration/data/blue-square-256.jpg differ diff --git a/tests/integration/data/green-rectangle-256-128.png b/tests/integration/data/green-rectangle-256-128.png new file mode 100644 index 00000000000..ff809095094 Binary files /dev/null and b/tests/integration/data/green-rectangle-256-128.png differ diff --git a/tests/integration/data/green-square-256.png b/tests/integration/data/green-square-256.png new file mode 100644 index 00000000000..9f14b707ca3 Binary files /dev/null and b/tests/integration/data/green-square-256.png differ diff --git a/tests/integration/data/textfile.txt b/tests/integration/data/textfile.txt new file mode 100644 index 00000000000..efffdeff159 --- /dev/null +++ b/tests/integration/data/textfile.txt @@ -0,0 +1,3 @@ +This is a testfile. + +Cheers. \ No newline at end of file diff --git a/tests/integration/features/bootstrap/AvatarTrait.php b/tests/integration/features/bootstrap/AvatarTrait.php new file mode 100644 index 00000000000..8edc6146711 --- /dev/null +++ b/tests/integration/features/bootstrap/AvatarTrait.php @@ -0,0 +1,321 @@ + + * + * @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 . + * + */ + +use Behat\Gherkin\Node\TableNode; +use PHPUnit\Framework\Assert; + +trait AvatarTrait { + + /** @var string **/ + private $lastAvatar; + + /** @AfterScenario **/ + public function cleanupLastAvatar() { + $this->lastAvatar = null; + } + + private function getLastAvatar() { + $this->lastAvatar = ''; + + $body = $this->response->getBody(); + while (!$body->eof()) { + $this->lastAvatar .= $body->read(8192); + } + $body->close(); + } + /** + * @When user :user gets avatar for room :identifier + * + * @param string $user + * @param string $identifier + */ + public function userGetsAvatarForRoom(string $user, string $identifier) { + $this->userGetsAvatarForRoomWithSize($user, $identifier, '128'); + } + + /** + * @When user :user gets avatar for room :identifier with size :size + * + * @param string $user + * @param string $identifier + * @param string $size + */ + public function userGetsAvatarForRoomWithSize(string $user, string $identifier, string $size) { + $this->userGetsAvatarForRoomWithSizeWith($user, $identifier, $size, '200'); + } + + /** + * @When user :user gets avatar for room :identifier with size :size with :statusCode + * + * @param string $user + * @param string $identifier + * @param string $size + * @param string $statusCode + */ + public function userGetsAvatarForRoomWithSizeWith(string $user, string $identifier, string $size, string $statusCode) { + $this->setCurrentUser($user); + $this->sendRequest('GET', '/apps/spreed/api/v3/avatar/' . FeatureContext::getTokenForIdentifier($identifier) . '/' . $size, null); + $this->assertStatusCode($this->response, $statusCode); + + if ($statusCode !== '200') { + return; + } + + $this->getLastAvatar(); + } + + /** + * @When user :user sets avatar for room :identifier from file :source + * + * @param string $user + * @param string $identifier + * @param string $source + */ + public function userSetsAvatarForRoomFromFile(string $user, string $identifier, string $source) { + $this->userSetsAvatarForRoomFromFileWith($user, $identifier, $source, '200'); + } + + /** + * @When user :user sets avatar for room :identifier from file :source with :statusCode + * + * @param string $user + * @param string $identifier + * @param string $source + * @param string $statusCode + */ + public function userSetsAvatarForRoomFromFileWith(string $user, string $identifier, string $source, string $statusCode) { + $file = \GuzzleHttp\Psr7\stream_for(fopen($source, 'r')); + + $this->setCurrentUser($user); + $this->sendRequest('POST', '/apps/spreed/api/v3/avatar/' . FeatureContext::getTokenForIdentifier($identifier), + [ + 'multipart' => [ + [ + 'name' => 'files[]', + 'contents' => $file + ] + ] + ]); + $this->assertStatusCode($this->response, $statusCode); + } + + /** + * @When user :user deletes avatar for room :identifier + * + * @param string $user + * @param string $identifier + */ + public function userDeletesAvatarForRoom(string $user, string $identifier) { + $this->userDeletesAvatarForRoomWith($user, $identifier, '200'); + } + + /** + * @When user :user deletes avatar for room :identifier with :statusCode + * + * @param string $user + * @param string $identifier + * @param string $statusCode + */ + public function userDeletesAvatarForRoomWith(string $user, string $identifier, string $statusCode) { + $this->setCurrentUser($user); + $this->sendRequest('DELETE', '/apps/spreed/api/v3/avatar/' . FeatureContext::getTokenForIdentifier($identifier), null); + $this->assertStatusCode($this->response, $statusCode); + } + + /** + * @When logged in user posts temporary avatar from file :source + * + * @param string $source + */ + public function loggedInUserPostsTemporaryAvatarFromFile(string $source) { + $file = \GuzzleHttp\Psr7\stream_for(fopen($source, 'r')); + + $this->sendingToWithRequestToken('POST', '/index.php/avatar', + [ + 'multipart' => [ + [ + 'name' => 'files[]', + 'contents' => $file + ] + ] + ]); + $this->assertStatusCode($this->response, '200'); + } + + /** + * @When logged in user crops temporary avatar + * + * @param TableNode $crop + */ + public function loggedInUserCropsTemporaryAvatar(TableNode $crop) { + $parameters = []; + foreach ($crop->getRowsHash() as $key => $value) { + $parameters[] = 'crop[' . $key . ']=' . $value; + } + + $this->sendingToWithRequestToken('POST', '/index.php/avatar/cropped?' . implode('&', $parameters)); + $this->assertStatusCode($this->response, '200'); + } + + /** + * @When logged in user deletes the user avatar + */ + public function loggedInUserDeletesTheUserAvatar() { + $this->sendingToWithRequesttoken('DELETE', '/index.php/avatar'); + $this->assertStatusCode($this->response, '200'); + } + + /** + * @Then last avatar is a default avatar of size :size + * + * @param string size + */ + public function lastAvatarIsADefaultAvatarOfSize(string $size) { + $this->theFollowingHeadersShouldBeSet(new TableNode([ + [ 'Content-Type', 'image/png' ], + [ 'X-NC-IsCustomAvatar', '0' ] + ])); + $this->lastAvatarIsASquareOfSize($size); + $this->lastAvatarIsNotASingleColor(); + } + + /** + * @Then last avatar is a custom avatar of size :size and color :color + * + * @param string size + */ + public function lastAvatarIsACustomAvatarOfSizeAndColor(string $size, string $color) { + $this->theFollowingHeadersShouldBeSet(new TableNode([ + [ 'Content-Type', 'image/png' ], + [ 'X-NC-IsCustomAvatar', '1' ] + ])); + $this->lastAvatarIsASquareOfSize($size); + $this->lastAvatarIsASingleColor($color); + } + + /** + * @Then last avatar is a square of size :size + * + * @param string size + */ + public function lastAvatarIsASquareOfSize(string $size) { + list($width, $height) = getimagesizefromstring($this->lastAvatar); + + Assert::assertEquals($width, $height, 'Avatar is not a square'); + Assert::assertEquals($size, $width); + } + + /** + * @Then last avatar is not a single color + */ + public function lastAvatarIsNotASingleColor() { + Assert::assertEquals(null, $this->getColorFromLastAvatar()); + } + + /** + * @Then last avatar is a single :color color + * + * @param string $color + * @param string $size + */ + public function lastAvatarIsASingleColor(string $color) { + $expectedColor = $this->hexStringToRgbColor($color); + $colorFromLastAvatar = $this->getColorFromLastAvatar(); + + if (!$colorFromLastAvatar) { + Assert::fail('Last avatar is not a single color'); + } + + Assert::assertTrue($this->isSameColor($expectedColor, $colorFromLastAvatar), + $this->rgbColorToHexString($colorFromLastAvatar) . ' does not match expected ' . $color); + } + + private function hexStringToRgbColor($hexString) { + // Strip initial "#" + $hexString = substr($hexString, 1); + + $rgbColorInt = hexdec($hexString); + + // RGBA hex strings are not supported; the given string is assumed to be + // an RGB hex string. + return [ + 'red' => ($rgbColorInt >> 16) & 0xFF, + 'green' => ($rgbColorInt >> 8) & 0xFF, + 'blue' => $rgbColorInt & 0xFF, + 'alpha' => 0 + ]; + } + + private function rgbColorToHexString($rgbColor) { + $rgbColorInt = ($rgbColor['red'] << 16) + ($rgbColor['green'] << 8) + ($rgbColor['blue']); + + return '#' . str_pad(strtoupper(dechex($rgbColorInt)), 6, '0', STR_PAD_LEFT); + } + + private function getColorFromLastAvatar() { + $image = imagecreatefromstring($this->lastAvatar); + + $firstPixelColorIndex = imagecolorat($image, 0, 0); + $firstPixelColor = imagecolorsforindex($image, $firstPixelColorIndex); + + for ($i = 0; $i < imagesx($image); $i++) { + for ($j = 0; $j < imagesx($image); $j++) { + $currentPixelColorIndex = imagecolorat($image, $i, $j); + $currentPixelColor = imagecolorsforindex($image, $currentPixelColorIndex); + + // The colors are compared with a small allowed delta, as even + // on solid color images the resizing can cause some small + // artifacts that slightly modify the color of certain pixels. + if (!$this->isSameColor($firstPixelColor, $currentPixelColor)) { + imagedestroy($image); + + return null; + } + } + } + + imagedestroy($image); + + return $firstPixelColor; + } + + private function isSameColor(array $firstColor, array $secondColor, int $allowedDelta = 1) { + if ($this->isSameColorComponent($firstColor['red'], $secondColor['red'], $allowedDelta) && + $this->isSameColorComponent($firstColor['green'], $secondColor['green'], $allowedDelta) && + $this->isSameColorComponent($firstColor['blue'], $secondColor['blue'], $allowedDelta) && + $this->isSameColorComponent($firstColor['alpha'], $secondColor['alpha'], $allowedDelta)) { + return true; + } + + return false; + } + + private function isSameColorComponent(int $firstColorComponent, int $secondColorComponent, int $allowedDelta) { + if ($firstColorComponent >= ($secondColorComponent - $allowedDelta) && + $firstColorComponent <= ($secondColorComponent + $allowedDelta)) { + return true; + } + + return false; + } +} diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 8a57ed81399..d86b1e3df24 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -51,12 +51,18 @@ class FeatureContext implements Context, SnippetAcceptingContext { /** @var string */ protected $currentUser; + /** @var string */ + protected $loggedInUser; + /** @var ResponseInterface */ private $response; /** @var CookieJar[] */ private $cookieJars; + /** @var string */ + private $requestToken; + /** @var string */ protected $baseUrl; @@ -84,6 +90,7 @@ class FeatureContext implements Context, SnippetAcceptingContext { /** @var string */ private $guestsOldWhitelist; + use AvatarTrait; use CommandLineTrait; public static function getTokenForIdentifier(string $identifier) { @@ -252,6 +259,12 @@ private function assertRooms($rooms, TableNode $formData) { if (isset($expectedRoom['description'])) { $data['description'] = $room['description']; } + if (isset($expectedRoom['avatarId'])) { + $data['avatarId'] = $room['avatarId']; + } + if (isset($expectedRoom['avatarVersion'])) { + $data['avatarVersion'] = $room['avatarVersion']; + } if (isset($expectedRoom['type'])) { $data['type'] = (string) $room['type']; } @@ -1745,7 +1758,7 @@ public function userLogsIn(string $user) { ] ); - $requestToken = $this->extractRequestTokenFromResponse($this->response); + $this->extractRequestTokenFromResponse($this->response); // Login and extract new token $password = ($user === 'admin') ? 'admin' : self::TEST_PASSWORD; @@ -1756,21 +1769,58 @@ public function userLogsIn(string $user) { 'form_params' => [ 'user' => $user, 'password' => $password, - 'requesttoken' => $requestToken, + 'requesttoken' => $this->requestToken, ], 'cookies' => $cookieJar, ] ); + $this->extractRequestTokenFromResponse($this->response); $this->assertStatusCode($this->response, 200); + + $this->loggedInUser = $user; } /** * @param ResponseInterface $response - * @return string */ - private function extractRequestTokenFromResponse(ResponseInterface $response): string { - return substr(preg_replace('/(.*)data-requesttoken="(.*)">(.*)/sm', '\2', $response->getBody()->getContents()), 0, 89); + private function extractRequestTokenFromResponse(ResponseInterface $response): void { + $this->requestToken = substr(preg_replace('/(.*)data-requesttoken="(.*)">(.*)/sm', '\2', $response->getBody()->getContents()), 0, 89); + } + + /** + * @When /^sending "([^"]*)" to "([^"]*)" with request token$/ + * @param string $verb + * @param string $url + * @param TableNode|array|null $body + */ + public function sendingToWithRequestToken(string $verb, string $url, $body = null) { + $fullUrl = $this->baseUrl . $url; + + $options = [ + 'cookies' => $this->getUserCookieJar($this->loggedInUser), + 'headers' => [ + 'requesttoken' => $this->requestToken + ], + ]; + + if ($body instanceof TableNode) { + $fd = $body->getRowsHash(); + $options['form_params'] = $fd; + } elseif ($body) { + $options = array_merge($options, $body); + } + + $client = new Client(); + try { + $this->response = $client->request( + $verb, + $fullUrl, + $options + ); + } catch (ClientException $e) { + $this->response = $e->getResponse(); + } } /** @@ -1792,6 +1842,8 @@ public function sendRequest($verb, $url, $body = null, array $headers = []) { if ($body instanceof TableNode) { $fd = $body->getRowsHash(); $options['form_params'] = $fd; + } elseif (is_array($body) && array_key_exists('multipart', $body)) { + $options = array_merge($options, $body); } elseif (is_array($body)) { $options['form_params'] = $body; } @@ -1823,4 +1875,27 @@ protected function getUserCookieJar($user) { protected function assertStatusCode(ResponseInterface $response, int $statusCode, string $message = '') { Assert::assertEquals($statusCode, $response->getStatusCode(), $message); } + + /** + * @Then /^the following headers should be set$/ + * @param TableNode $table + * @throws \Exception + */ + public function theFollowingHeadersShouldBeSet(TableNode $table) { + foreach ($table->getTable() as $header) { + $headerName = $header[0]; + $expectedHeaderValue = $header[1]; + $returnedHeader = $this->response->getHeader($headerName)[0]; + if ($returnedHeader !== $expectedHeaderValue) { + throw new \Exception( + sprintf( + "Expected value '%s' for header '%s', got '%s'", + $expectedHeaderValue, + $headerName, + $returnedHeader + ) + ); + } + } + } } diff --git a/tests/integration/features/chat/system-messages.feature b/tests/integration/features/chat/system-messages.feature index 453b52c1e60..98247a8a09b 100644 --- a/tests/integration/features/chat/system-messages.feature +++ b/tests/integration/features/chat/system-messages.feature @@ -47,6 +47,48 @@ Feature: System messages | room | users | participant1 | participant1-displayname | description_set | | room | users | participant1 | participant1-displayname | conversation_created | + Scenario: Set an avatar + Given user "participant1" creates room "room" + | roomType | 2 | + | roomName | room | + When user "participant1" sets avatar for room "room" from file "data/green-square-256.png" + Then user "participant1" sees the following system messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | systemMessage | + | room | users | participant1 | participant1-displayname | avatar_set | + | room | users | participant1 | participant1-displayname | conversation_created | + + Scenario: Set user avatar of a one-to-one conversation participant + Given user "participant1" creates room "room" + | roomType | 1 | + | invite | participant2 | + When user "participant1" logs in + And logged in user posts temporary avatar from file "data/green-square-256.png" + And logged in user crops temporary avatar + | x | 0 | + | y | 0 | + | w | 256 | + | h | 256 | + # Although the room avatar changes for the other participant no system + # message should be added + Then user "participant1" sees the following system messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | systemMessage | + | room | users | participant1 | participant1-displayname | conversation_created | + And user "participant2" sees the following system messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | systemMessage | + | room | users | participant1 | participant1-displayname | conversation_created | + + Scenario: Removes an avatar + Given user "participant1" creates room "room" + | roomType | 2 | + | roomName | room | + And user "participant1" sets avatar for room "room" from file "data/green-square-256.png" + When user "participant1" deletes avatar for room "room" + Then user "participant1" sees the following system messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | systemMessage | + | room | users | participant1 | participant1-displayname | avatar_removed | + | room | users | participant1 | participant1-displayname | avatar_set | + | room | users | participant1 | participant1-displayname | conversation_created | + Scenario: Toggle guests Given user "participant1" creates room "room" | roomType | 2 | diff --git a/tests/integration/features/conversation/avatar.feature b/tests/integration/features/conversation/avatar.feature new file mode 100644 index 00000000000..5c5f67987c2 --- /dev/null +++ b/tests/integration/features/conversation/avatar.feature @@ -0,0 +1,586 @@ +Feature: avatar + + Background: + Given user "owner" exists + Given user "moderator" exists + Given user "invited user" exists + Given user "not invited user" exists + Given user "not invited but joined user" exists + Given user "not joined user" exists + + Scenario: participants can not set avatar in one-to-one room + Given user "owner" creates room "one-to-one room" + | roomType | 1 | + | invite | moderator | + When user "owner" sets avatar for room "one-to-one room" from file "data/green-square-256.png" with 404 + And user "moderator" sets avatar for room "one-to-one room" from file "data/green-square-256.png" with 404 + And user "not invited user" sets avatar for room "one-to-one room" from file "data/green-square-256.png" with 404 + And user "guest" sets avatar for room "one-to-one room" from file "data/green-square-256.png" with 404 + Then user "owner" gets avatar for room "one-to-one room" + And last avatar is a default avatar of size "128" + And user "moderator" gets avatar for room "one-to-one room" + And last avatar is a default avatar of size "128" + + + + Scenario: owner can set avatar in group room + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "owner" adds "moderator" to room "group room" with 200 + And user "owner" promotes "moderator" in room "group room" with 200 + And user "owner" adds "invited user" to room "group room" with 200 + When user "owner" sets avatar for room "group room" from file "data/green-square-256.png" + Then user "owner" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "moderator" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "invited user" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + + Scenario: moderator can set avatar in group room + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "owner" adds "moderator" to room "group room" with 200 + And user "owner" promotes "moderator" in room "group room" with 200 + And user "owner" adds "invited user" to room "group room" with 200 + When user "moderator" sets avatar for room "group room" from file "data/green-square-256.png" + Then user "owner" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "moderator" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "invited user" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + + Scenario: others can not set avatar in group room + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "owner" adds "moderator" to room "group room" with 200 + And user "owner" promotes "moderator" in room "group room" with 200 + And user "owner" adds "invited user" to room "group room" with 200 + When user "invited user" sets avatar for room "group room" from file "data/green-square-256.png" with 404 + And user "not invited user" sets avatar for room "group room" from file "data/green-square-256.png" with 404 + # Guest user names in tests must being with "guest" + And user "guest not joined" sets avatar for room "group room" from file "data/green-square-256.png" with 404 + Then user "owner" gets avatar for room "group room" with size "256" with 404 + And user "moderator" gets avatar for room "group room" with size "256" with 404 + And user "invited user" gets avatar for room "group room" with size "256" with 404 + + + + Scenario: owner can set avatar in public room + Given user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + And user "guest moderator" joins room "public room" with 200 + And user "owner" promotes "guest moderator" in room "public room" with 200 + And user "guest" joins room "public room" with 200 + When user "owner" sets avatar for room "public room" from file "data/green-square-256.png" + Then user "owner" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "moderator" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "invited user" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "not invited but joined user" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "guest moderator" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "guest" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + + Scenario: moderator can set avatar in public room + Given user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + And user "guest moderator" joins room "public room" with 200 + And user "owner" promotes "guest moderator" in room "public room" with 200 + And user "guest" joins room "public room" with 200 + When user "moderator" sets avatar for room "public room" from file "data/green-square-256.png" + Then user "owner" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "moderator" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "invited user" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "not invited but joined user" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "guest moderator" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "guest" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + + Scenario: guest moderator can set avatar in public room + Given user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + And user "guest moderator" joins room "public room" with 200 + And user "owner" promotes "guest moderator" in room "public room" with 200 + And user "guest" joins room "public room" with 200 + When user "guest moderator" sets avatar for room "public room" from file "data/green-square-256.png" + Then user "owner" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "moderator" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "invited user" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "not invited but joined user" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "guest moderator" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "guest" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + + Scenario: others can not set avatar in public room + Given user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + And user "guest moderator" joins room "public room" with 200 + And user "owner" promotes "guest moderator" in room "public room" with 200 + And user "guest" joins room "public room" with 200 + When user "invited user" sets avatar for room "public room" from file "data/green-square-256.png" with 404 + And user "not invited but joined user" sets avatar for room "public room" from file "data/green-square-256.png" with 404 + And user "not joined user" sets avatar for room "public room" from file "data/green-square-256.png" with 404 + And user "guest" sets avatar for room "public room" from file "data/green-square-256.png" with 404 + # Guest user names in tests must being with "guest" + And user "guest not joined" sets avatar for room "public room" from file "data/green-square-256.png" with 404 + Then user "owner" gets avatar for room "public room" with size "256" with 404 + And user "moderator" gets avatar for room "public room" with size "256" with 404 + And user "invited user" gets avatar for room "public room" with size "256" with 404 + And user "not invited but joined user" gets avatar for room "public room" with size "256" with 404 + And user "guest moderator" gets avatar for room "public room" with size "256" with 404 + And user "guest" gets avatar for room "public room" with size "256" with 404 + + + + Scenario: owner can set avatar in listable room + Given user "owner" creates room "listable room" + | roomType | 2 | + | roomName | room | + And user "owner" adds "moderator" to room "listable room" with 200 + And user "owner" promotes "moderator" in room "listable room" with 200 + And user "owner" adds "invited user" to room "listable room" with 200 + And user "owner" allows listing room "listable room" for "users" with 200 + When user "owner" sets avatar for room "listable room" from file "data/green-square-256.png" + Then user "owner" gets avatar for room "listable room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "moderator" gets avatar for room "listable room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "invited user" gets avatar for room "listable room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "not invited user" gets avatar for room "listable room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "guest not joined" gets avatar for room "listable room" with size "256" with 404 + + Scenario: moderator can set avatar in listable room + Given user "owner" creates room "listable room" + | roomType | 2 | + | roomName | room | + And user "owner" adds "moderator" to room "listable room" with 200 + And user "owner" promotes "moderator" in room "listable room" with 200 + And user "owner" adds "invited user" to room "listable room" with 200 + And user "owner" allows listing room "listable room" for "users" with 200 + When user "moderator" sets avatar for room "listable room" from file "data/green-square-256.png" + Then user "owner" gets avatar for room "listable room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "moderator" gets avatar for room "listable room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "invited user" gets avatar for room "listable room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "not invited user" gets avatar for room "listable room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "guest not joined" gets avatar for room "listable room" with size "256" with 404 + + Scenario: others can not set avatar in listable room + Given user "owner" creates room "listable room" + | roomType | 2 | + | roomName | room | + And user "owner" adds "moderator" to room "listable room" with 200 + And user "owner" promotes "moderator" in room "listable room" with 200 + And user "owner" adds "invited user" to room "listable room" with 200 + And user "owner" allows listing room "listable room" for "users" with 200 + When user "invited user" sets avatar for room "listable room" from file "data/green-square-256.png" with 404 + And user "not invited user" sets avatar for room "listable room" from file "data/green-square-256.png" with 404 + # Guest user names in tests must being with "guest" + And user "guest not joined" sets avatar for room "listable room" from file "data/green-square-256.png" with 404 + Then user "owner" gets avatar for room "listable room" with size "256" with 404 + And user "moderator" gets avatar for room "listable room" with size "256" with 404 + And user "invited user" gets avatar for room "listable room" with size "256" with 404 + And user "not invited user" gets avatar for room "listable room" with size "256" with 404 + And user "guest not joined" gets avatar for room "listable room" with size "256" with 404 + + + + Scenario: participants can not set avatar in room for a share + # These users are only needed in very specific tests, so they are not + # created in the background step. + Given user "owner of file" exists + And user "user with access to file" exists + And user "owner of file" shares "welcome.txt" with user "user with access to file" with OCS 100 + And user "user with access to file" accepts last share + And user "owner of file" shares "welcome.txt" by link with OCS 100 + And user "guest" gets the room for last share with 200 + And user "owner of file" joins room "file last share room" with 200 + And user "user with access to file" joins room "file last share room" with 200 + And user "guest" joins room "file last share room" with 200 + When user "owner of file" sets avatar for room "file last share room" from file "data/green-square-256.png" with 404 + And user "user with access to file" sets avatar for room "file last share room" from file "data/green-square-256.png" with 404 + And user "guest" sets avatar for room "file last share room" from file "data/green-square-256.png" with 404 + Then user "owner of file" gets avatar for room "file last share room" with size "256" with 404 + And user "user with access to file" gets avatar for room "file last share room" with size "256" with 404 + And user "guest" gets avatar for room "file last share room" with size "256" with 404 + + + + Scenario: participants can not set avatar in a password request room + # The user is only needed in very specific tests, so it is not created in + # the background step. + Given user "owner of file" exists + And user "owner of file" shares "welcome.txt" by link with OCS 100 + | password | 123456 | + | sendPasswordByTalk | true | + And user "guest" creates the password request room for last share with 201 + And user "guest" joins room "password request for last share room" with 200 + And user "owner of file" joins room "password request for last share room" with 200 + When user "owner of file" sets avatar for room "password request for last share room" from file "data/green-square-256.png" with 404 + And user "guest" sets avatar for room "password request for last share room" from file "data/green-square-256.png" with 404 + Then user "owner of file" gets avatar for room "password request for last share room" with size "256" with 404 + And user "guest" gets avatar for room "password request for last share room" with size "256" with 404 + + + + Scenario: set jpg image as room avatar + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + When user "owner" sets avatar for room "group room" from file "data/blue-square-256.jpg" + Then user "owner" gets avatar for room "group room" with size "256" + And the following headers should be set + | Content-Type | image/jpeg | + | X-NC-IsCustomAvatar | 1 | + And last avatar is a square of size "256" + And last avatar is a single "#0000FF" color + + Scenario: set non squared image as room avatar + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + When user "owner" sets avatar for room "group room" from file "data/green-rectangle-256-128.png" with 400 + Then user "owner" gets avatar for room "group room" with size "256" with 404 + + Scenario: set not an image as room avatar + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + When user "owner" sets avatar for room "group room" from file "data/textfile.txt" with 400 + Then user "owner" gets avatar for room "group room" with size "256" with 404 + + + + Scenario: owner can delete avatar in group room + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "owner" adds "moderator" to room "group room" with 200 + And user "owner" promotes "moderator" in room "group room" with 200 + And user "owner" adds "invited user" to room "group room" with 200 + And user "owner" sets avatar for room "group room" from file "data/green-square-256.png" + And user "owner" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + When user "owner" deletes avatar for room "group room" + Then user "owner" gets avatar for room "group room" with size "256" with 404 + And user "moderator" gets avatar for room "group room" with size "256" with 404 + And user "invited user" gets avatar for room "group room" with size "256" with 404 + + Scenario: moderator can delete avatar in group room + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "owner" adds "moderator" to room "group room" with 200 + And user "owner" promotes "moderator" in room "group room" with 200 + And user "owner" adds "invited user" to room "group room" with 200 + And user "owner" sets avatar for room "group room" from file "data/green-square-256.png" + And user "owner" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + When user "moderator" deletes avatar for room "group room" + Then user "owner" gets avatar for room "group room" with size "256" with 404 + And user "moderator" gets avatar for room "group room" with size "256" with 404 + And user "invited user" gets avatar for room "group room" with size "256" with 404 + + Scenario: others can not delete avatar in group room + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "owner" adds "moderator" to room "group room" with 200 + And user "owner" promotes "moderator" in room "group room" with 200 + And user "owner" adds "invited user" to room "group room" with 200 + And user "owner" sets avatar for room "group room" from file "data/green-square-256.png" + When user "invited user" deletes avatar for room "group room" with 404 + And user "not invited user" deletes avatar for room "group room" with 404 + # Guest user names in tests must being with "guest" + And user "guest not joined" deletes avatar for room "group room" with 404 + Then user "owner" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "moderator" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "invited user" gets avatar for room "group room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + + + + Scenario: owner can delete avatar in public room + Given user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + And user "guest moderator" joins room "public room" with 200 + And user "owner" promotes "guest moderator" in room "public room" with 200 + And user "guest" joins room "public room" with 200 + And user "owner" sets avatar for room "public room" from file "data/green-square-256.png" + And user "owner" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + When user "owner" deletes avatar for room "public room" + Then user "owner" gets avatar for room "public room" with size "256" with 404 + And user "moderator" gets avatar for room "public room" with size "256" with 404 + And user "invited user" gets avatar for room "public room" with size "256" with 404 + And user "not invited but joined user" gets avatar for room "public room" with size "256" with 404 + And user "guest moderator" gets avatar for room "public room" with size "256" with 404 + And user "guest" gets avatar for room "public room" with size "256" with 404 + + Scenario: moderator can delete avatar in public room + Given user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + And user "guest moderator" joins room "public room" with 200 + And user "owner" promotes "guest moderator" in room "public room" with 200 + And user "guest" joins room "public room" with 200 + And user "owner" sets avatar for room "public room" from file "data/green-square-256.png" + And user "owner" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + When user "moderator" deletes avatar for room "public room" + Then user "owner" gets avatar for room "public room" with size "256" with 404 + And user "moderator" gets avatar for room "public room" with size "256" with 404 + And user "invited user" gets avatar for room "public room" with size "256" with 404 + And user "not invited but joined user" gets avatar for room "public room" with size "256" with 404 + And user "guest moderator" gets avatar for room "public room" with size "256" with 404 + And user "guest" gets avatar for room "public room" with size "256" with 404 + + Scenario: guest moderator can delete avatar in public room + Given user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + And user "guest moderator" joins room "public room" with 200 + And user "owner" promotes "guest moderator" in room "public room" with 200 + And user "guest" joins room "public room" with 200 + And user "owner" sets avatar for room "public room" from file "data/green-square-256.png" + And user "owner" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + When user "guest moderator" deletes avatar for room "public room" + Then user "owner" gets avatar for room "public room" with size "256" with 404 + And user "moderator" gets avatar for room "public room" with size "256" with 404 + And user "invited user" gets avatar for room "public room" with size "256" with 404 + And user "not invited but joined user" gets avatar for room "public room" with size "256" with 404 + And user "guest moderator" gets avatar for room "public room" with size "256" with 404 + And user "guest" gets avatar for room "public room" with size "256" with 404 + + Scenario: others can not delete avatar in public room + Given user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + And user "guest moderator" joins room "public room" with 200 + And user "owner" promotes "guest moderator" in room "public room" with 200 + And user "guest" joins room "public room" with 200 + And user "owner" sets avatar for room "public room" from file "data/green-square-256.png" + When user "invited user" deletes avatar for room "public room" with 404 + And user "not invited but joined user" deletes avatar for room "public room" with 404 + And user "not joined user" deletes avatar for room "public room" with 404 + And user "guest" deletes avatar for room "public room" with 404 + # Guest user names in tests must being with "guest" + And user "guest not joined" deletes avatar for room "public room" with 404 + Then user "owner" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "moderator" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "invited user" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "not invited but joined user" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "guest moderator" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + And user "guest" gets avatar for room "public room" with size "256" + And last avatar is a custom avatar of size "256" and color "#00FF00" + + + + Scenario: get room avatar with a larger size than the original one + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "owner" sets avatar for room "group room" from file "data/green-square-256.png" + When user "owner" gets avatar for room "group room" with size "512" + Then last avatar is a custom avatar of size "512" and color "#00FF00" + + Scenario: get room avatar with a smaller size than the original one + Given user "owner" creates room "group room" + | roomType | 2 | + | roomName | room | + And user "owner" sets avatar for room "group room" from file "data/green-square-256.png" + When user "owner" gets avatar for room "group room" with size "128" + Then last avatar is a custom avatar of size "128" and color "#00FF00" + + + + Scenario: room list returns the default avatar after room creation + When user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + Then user "owner" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | icon-public | 1 | + And user "moderator" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | icon-public | 1 | + And user "invited user" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | icon-public | 1 | + And user "not invited but joined user" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | icon-public | 1 | + + Scenario: room list returns a custom avatar after avatar is set + Given user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + When user "owner" sets avatar for room "public room" from file "data/green-square-256.png" + Then user "owner" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | custom | 2 | + And user "moderator" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | custom | 2 | + And user "invited user" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | custom | 2 | + And user "not invited but joined user" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | custom | 2 | + + Scenario: room list returns a default avatar after avatar is deleted + Given user "owner" creates room "public room" + | roomType | 3 | + | roomName | room | + And user "owner" adds "moderator" to room "public room" with 200 + And user "owner" promotes "moderator" in room "public room" with 200 + And user "owner" adds "invited user" to room "public room" with 200 + And user "not invited but joined user" joins room "public room" with 200 + And user "owner" sets avatar for room "public room" from file "data/green-square-256.png" + And user "owner" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | custom | 2 | + When user "owner" deletes avatar for room "public room" + Then user "owner" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | icon-public | 3 | + And user "moderator" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | icon-public | 3 | + And user "invited user" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | icon-public | 3 | + And user "not invited but joined user" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | icon-public | 3 | + + + + Scenario: one-to-one room avatar is updated when user avatar is updated + Given user "owner" creates room "one-to-one room" + | roomType | 1 | + | invite | moderator | + When user "owner" logs in + And logged in user posts temporary avatar from file "data/green-square-256.png" + And logged in user crops temporary avatar + | x | 0 | + | y | 0 | + | w | 256 | + | h | 256 | + Then user "owner" gets avatar for room "one-to-one room" with size "256" + And last avatar is a default avatar of size "256" + And user "moderator" gets avatar for room "one-to-one room" with size "256" + # Although the user avatar is a custom avatar the room avatar is still a + # default avatar. + And the following headers should be set + | Content-Type | image/png | + | X-NC-IsCustomAvatar | 0 | + And last avatar is a square of size "256" + And last avatar is a single "#00FF00" color + And user "owner" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | user | 2 | + And user "moderator" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | user | 2 | + + Scenario: one-to-one room avatar is updated when user avatar is deleted + Given user "owner" creates room "one-to-one room" + | roomType | 1 | + | invite | moderator | + And user "owner" logs in + And logged in user posts temporary avatar from file "data/green-square-256.png" + And logged in user crops temporary avatar + | x | 0 | + | y | 0 | + | w | 256 | + | h | 256 | + When logged in user deletes the user avatar + Then user "owner" gets avatar for room "one-to-one room" + And last avatar is a default avatar of size "128" + And user "moderator" gets avatar for room "one-to-one room" + And last avatar is a default avatar of size "128" + And user "owner" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | user | 3 | + And user "moderator" is participant of the following rooms (v3) + | avatarId | avatarVersion | + | user | 3 | diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index b53bf418e51..1515811c903 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -84,6 +84,7 @@ public function setUp(): void { 'phonebook-search', 'raise-hand', 'room-description', + 'room-avatar', ]; } diff --git a/tests/php/Chat/Parser/SystemMessageTest.php b/tests/php/Chat/Parser/SystemMessageTest.php index 772f60b40bf..156d8839630 100644 --- a/tests/php/Chat/Parser/SystemMessageTest.php +++ b/tests/php/Chat/Parser/SystemMessageTest.php @@ -153,6 +153,22 @@ public function dataParseMessage(): array { 'You removed the description', ['actor' => ['id' => 'actor', 'type' => 'user']], ], + ['avatar_set', [], 'recipient', + '{actor} set the conversation picture', + ['actor' => ['id' => 'actor', 'type' => 'user']], + ], + ['avatar_set', [], 'actor', + 'You set the conversation picture', + ['actor' => ['id' => 'actor', 'type' => 'user']], + ], + ['avatar_removed', [], 'recipient', + '{actor} removed the conversation picture', + ['actor' => ['id' => 'actor', 'type' => 'user']], + ], + ['avatar_removed', [], 'actor', + 'You removed the conversation picture', + ['actor' => ['id' => 'actor', 'type' => 'user']], + ], ['call_started', [], 'recipient', '{actor} started a call', ['actor' => ['id' => 'actor', 'type' => 'user']], diff --git a/tests/php/RoomTest.php b/tests/php/RoomTest.php index 5c710c4f46a..7ed0912cd23 100644 --- a/tests/php/RoomTest.php +++ b/tests/php/RoomTest.php @@ -70,6 +70,8 @@ public function testVerifyPassword() { 'foobar', 'Test', 'description', + 'avatar-id', + 1, 'passy', 0, null, diff --git a/tests/php/Signaling/BackendNotifierTest.php b/tests/php/Signaling/BackendNotifierTest.php index 57226b74312..579fd42ef80 100644 --- a/tests/php/Signaling/BackendNotifierTest.php +++ b/tests/php/Signaling/BackendNotifierTest.php @@ -311,6 +311,8 @@ public function testRoomNameChanged() { 'properties' => [ 'name' => $room->getDisplayName(''), 'description' => '', + 'avatarId' => 'icon-public', + 'avatarVersion' => 1, 'type' => $room->getType(), 'lobby-state' => Webinary::LOBBY_NONE, 'lobby-timer' => null, @@ -335,6 +337,34 @@ public function testRoomDescriptionChanged() { 'properties' => [ 'name' => $room->getDisplayName(''), 'description' => 'The description', + 'avatarId' => 'icon-public', + 'avatarVersion' => 1, + 'type' => $room->getType(), + 'lobby-state' => Webinary::LOBBY_NONE, + 'lobby-timer' => null, + 'read-only' => Room::READ_WRITE, + 'listable' => Room::LISTABLE_NONE, + 'active-since' => null, + 'sip-enabled' => 0, + ], + ], + ]); + } + + public function testRoomAvatarChanged() { + $room = $this->manager->createRoom(Room::PUBLIC_CALL); + $room->setAvatar('avatar-id', 42); + + $this->assertMessageWasSent($room, [ + 'type' => 'update', + 'update' => [ + 'userids' => [ + ], + 'properties' => [ + 'name' => $room->getDisplayName(''), + 'description' => '', + 'avatarId' => 'avatar-id', + 'avatarVersion' => 42, 'type' => $room->getType(), 'lobby-state' => Webinary::LOBBY_NONE, 'lobby-timer' => null, @@ -359,6 +389,8 @@ public function testRoomPasswordChanged() { 'properties' => [ 'name' => $room->getDisplayName(''), 'description' => '', + 'avatarId' => 'icon-public', + 'avatarVersion' => 1, 'type' => $room->getType(), 'lobby-state' => Webinary::LOBBY_NONE, 'lobby-timer' => null, @@ -383,6 +415,8 @@ public function testRoomTypeChanged() { 'properties' => [ 'name' => $room->getDisplayName(''), 'description' => '', + 'avatarId' => 'icon-public', + 'avatarVersion' => 1, 'type' => $room->getType(), 'lobby-state' => Webinary::LOBBY_NONE, 'lobby-timer' => null, @@ -407,6 +441,8 @@ public function testRoomReadOnlyChanged() { 'properties' => [ 'name' => $room->getDisplayName(''), 'description' => '', + 'avatarId' => 'icon-public', + 'avatarVersion' => 1, 'type' => $room->getType(), 'lobby-state' => Webinary::LOBBY_NONE, 'lobby-timer' => null, @@ -438,6 +474,8 @@ public function testRoomListableChanged() { 'active-since' => null, 'sip-enabled' => 0, 'description' => '', + 'avatarId' => 'icon-public', + 'avatarVersion' => 1, ], ], ]); @@ -455,6 +493,8 @@ public function testRoomLobbyStateChanged() { 'properties' => [ 'name' => $room->getDisplayName(''), 'description' => '', + 'avatarId' => 'icon-public', + 'avatarVersion' => 1, 'type' => $room->getType(), 'lobby-state' => Webinary::LOBBY_NON_MODERATORS, 'lobby-timer' => null, @@ -615,6 +655,8 @@ public function testRoomPropertiesEvent(): void { 'properties' => [ 'name' => $room->getDisplayName(''), 'description' => '', + 'avatarId' => 'icon-public', + 'avatarVersion' => 1, 'type' => $room->getType(), 'lobby-state' => Webinary::LOBBY_NONE, 'lobby-timer' => null, diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 9fd61c02938..6a525551f55 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -9,6 +9,11 @@ getSettingsManager + + + $listener + + SchemaWrapper