From 8195befae445ccef9457ff22a3a30f49bba3a1f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Thu, 10 Dec 2020 05:08:08 +0100 Subject: [PATCH 01/12] Add basic implementation of IAvatar for rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RoomAvatar does not provide (yet) a default avatar for a room if it has not been set. Signed-off-by: Daniel Calviño Sánchez --- lib/AppInfo/Application.php | 3 + lib/Avatar/RoomAvatar.php | 309 ++++++++++++++++++++++++++++++ lib/Avatar/RoomAvatarProvider.php | 174 +++++++++++++++++ lib/Avatar/Util.php | 83 ++++++++ 4 files changed, 569 insertions(+) create mode 100644 lib/Avatar/RoomAvatar.php create mode 100644 lib/Avatar/RoomAvatarProvider.php create mode 100644 lib/Avatar/Util.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 787db6da2f3..10ce6d313fe 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\RoomAvatarProvider; use OCA\Talk\Capabilities; use OCA\Talk\Chat\Changelog\Listener as ChangelogListener; use OCA\Talk\Chat\ChatManager; @@ -102,6 +103,8 @@ public function register(IRegistrationContext $context): void { $context->registerSearchProvider(MessageSearch::class); $context->registerDashboardWidget(TalkWidget::class); + + $context->registerAvatarProvider('room', RoomAvatarProvider::class); } public function boot(IBootContext $context): void { diff --git a/lib/Avatar/RoomAvatar.php b/lib/Avatar/RoomAvatar.php new file mode 100644 index 00000000000..28515154dd2 --- /dev/null +++ b/lib/Avatar/RoomAvatar.php @@ -0,0 +1,309 @@ + + * + * @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; + + public function __construct( + ISimpleFolder $folder, + Room $room, + IL10N $l, + LoggerInterface $logger) { + $this->folder = $folder; + $this->room = $room; + $this->l = $l; + $this->logger = $logger; + } + + public function getRoom(): Room { + return $this->room; + } + + /** + * 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->remove(); + $type = $this->getAvatarImageType($image); + $file = $this->folder->newFile('avatar.' . $type); + $file->putContent($data); + } + + /** + * 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 { + $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; + + $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..b1f020b5bec --- /dev/null +++ b/lib/Avatar/RoomAvatarProvider.php @@ -0,0 +1,174 @@ + + * + * @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\IAvatarProvider; +use OCP\IL10N; +use Psr\Log\LoggerInterface; + +class RoomAvatarProvider implements IAvatarProvider { + + /** @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); + } + + /** + * 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 ignored + * @return int 0, as versions are not supported by room avatars + */ + public function getVersion(IAvatar $avatar): int { + return 0; + } + + /** + * 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..ffa76ac799c --- /dev/null +++ b/lib/Avatar/Util.php @@ -0,0 +1,83 @@ + + * + * @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\TalkSession; + +class Util { + + /** @var string|null */ + protected $userId; + + /** @var TalkSession */ + protected $session; + + /** @var Manager */ + private $manager; + + /** + * @param string|null $userId + * @param TalkSession $session + * @param Manager $manager + */ + public function __construct( + ?string $userId, + TalkSession $session, + Manager $manager) { + $this->userId = $userId; + $this->session = $session; + $this->manager = $manager; + } + + /** + * @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); + } +} From 46812d642e7fa51ade2e401ef04bd0cdf80101c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Thu, 10 Dec 2020 05:13:55 +0100 Subject: [PATCH 02/12] Add integration tests for conversation avatars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- tests/integration/data/blue-square-256.jpg | Bin 0 -> 1319 bytes .../data/green-rectangle-256-128.png | Bin 0 -> 424 bytes tests/integration/data/green-square-256.png | Bin 0 -> 645 bytes tests/integration/data/textfile.txt | 3 + .../features/bootstrap/AvatarTrait.php | 264 ++++++++++ .../features/bootstrap/FeatureContext.php | 26 + .../features/conversation/avatar.feature | 460 ++++++++++++++++++ 7 files changed, 753 insertions(+) create mode 100644 tests/integration/data/blue-square-256.jpg create mode 100644 tests/integration/data/green-rectangle-256-128.png create mode 100644 tests/integration/data/green-square-256.png create mode 100644 tests/integration/data/textfile.txt create mode 100644 tests/integration/features/bootstrap/AvatarTrait.php create mode 100644 tests/integration/features/conversation/avatar.feature diff --git a/tests/integration/data/blue-square-256.jpg b/tests/integration/data/blue-square-256.jpg new file mode 100644 index 0000000000000000000000000000000000000000..13eb46a10a8054f8389161128a9ae5831ed6901f GIT binary patch literal 1319 zcmex=LJ%Z3btM5{dxG z5Q+={Y5sqRL6C!yfsuikQILU2kdaxC@&6G9QJ~`(Pyr`USPY~CRhXUg|1Aa{W<~}k z0cHUP2FAYXpW3J@MtO7&0d`vJaGVHkCBy$K^~++5W5OyMn-!^yegOw wDsbAuW)HOmrwT@UMW8Lvw8Ov*3?pFj83h9w0t8Zp0KrrNRe>kYK`L$n0I?iKr~m)} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ff8090950945e863d1e7420063d7b8fb31a6c7da GIT binary patch literal 424 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58aSAMWcGE1SRlog|H(?D8gCb z5n0T@z%2~Ij105pNB{-dOFVsD*`M$TFbS)r^hK@)3dv@MM3gw^=jNv7l>oU649-QV zi6yBi3gww484B*6z5ywEsq8>Ic|2VlLn>~)z0AnTz`((>@%q2HTiYIpJ$#@gT(Mg9 z07C+U0fPhs4+9$ma|0vQxwJ7A$-PKSLO$7%%y|D0`q$ BRJH&B literal 0 HcmV?d00001 diff --git a/tests/integration/data/green-square-256.png b/tests/integration/data/green-square-256.png new file mode 100644 index 0000000000000000000000000000000000000000..9f14b707ca369534f80cc2be75f5dfcd97642fc3 GIT binary patch literal 645 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLmVg9HN) w1GTxdF%`+ZNKBgegxUdu;a*yVI>eF~?g#~@|DmDUoS^jK>FVdQ&MBb@061uAZ~y=R literal 0 HcmV?d00001 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..37f19f3fd87 --- /dev/null +++ b/tests/integration/features/bootstrap/AvatarTrait.php @@ -0,0 +1,264 @@ + + * + * @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', '/core/avatar/room/' . 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', '/core/avatar/room/' . 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', '/core/avatar/room/' . FeatureContext::getTokenForIdentifier($identifier), null); + $this->assertStatusCode($this->response, $statusCode); + } + + /** + * @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..a094499da51 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -84,6 +84,7 @@ class FeatureContext implements Context, SnippetAcceptingContext { /** @var string */ private $guestsOldWhitelist; + use AvatarTrait; use CommandLineTrait; public static function getTokenForIdentifier(string $identifier) { @@ -1792,6 +1793,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 +1826,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/conversation/avatar.feature b/tests/integration/features/conversation/avatar.feature new file mode 100644 index 00000000000..50bcfb4c78c --- /dev/null +++ b/tests/integration/features/conversation/avatar.feature @@ -0,0 +1,460 @@ +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" with size "256" with 404 + And user "moderator" gets avatar for room "one-to-one room" with size "256" with 404 + + + + 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" From 81836c1d8810c7aea95c301fbe40de1164ed565d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Tue, 29 Dec 2020 02:24:59 +0100 Subject: [PATCH 03/12] Add default avatars for one-to-one conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now the avatar of the other user will be returned when getting the avatar of a one-to-one conversation. Signed-off-by: Daniel Calviño Sánchez --- lib/Avatar/RoomAvatar.php | 13 +++++- lib/Avatar/RoomAvatarProvider.php | 2 +- lib/Avatar/Util.php | 45 ++++++++++++++++++- .../features/bootstrap/AvatarTrait.php | 14 ++++++ .../features/conversation/avatar.feature | 6 ++- 5 files changed, 75 insertions(+), 5 deletions(-) diff --git a/lib/Avatar/RoomAvatar.php b/lib/Avatar/RoomAvatar.php index 28515154dd2..8fef269bb5a 100644 --- a/lib/Avatar/RoomAvatar.php +++ b/lib/Avatar/RoomAvatar.php @@ -52,15 +52,20 @@ class RoomAvatar implements IAvatar { /** @var LoggerInterface */ private $logger; + /** @var Util */ + private $util; + public function __construct( ISimpleFolder $folder, Room $room, IL10N $l, - LoggerInterface $logger) { + LoggerInterface $logger, + Util $util) { $this->folder = $folder; $this->room = $room; $this->l = $l; $this->logger = $logger; + $this->util = $util; } public function getRoom(): Room { @@ -227,6 +232,12 @@ public function remove(): void { 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) { diff --git a/lib/Avatar/RoomAvatarProvider.php b/lib/Avatar/RoomAvatarProvider.php index b1f020b5bec..847bd2713bd 100644 --- a/lib/Avatar/RoomAvatarProvider.php +++ b/lib/Avatar/RoomAvatarProvider.php @@ -83,7 +83,7 @@ public function getAvatar(string $id): IAvatar { $folder = $this->appData->newFolder('avatar/' . $id); } - return new RoomAvatar($folder, $room, $this->l, $this->logger); + return new RoomAvatar($folder, $room, $this->l, $this->logger, $this->util); } /** diff --git a/lib/Avatar/Util.php b/lib/Avatar/Util.php index ffa76ac799c..b9c6c13114c 100644 --- a/lib/Avatar/Util.php +++ b/lib/Avatar/Util.php @@ -30,7 +30,10 @@ 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 { @@ -40,21 +43,33 @@ class Util { /** @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, - Manager $manager) { + IAvatarManager $avatarManager, + Manager $manager, + ParticipantService $participantService) { $this->userId = $userId; $this->session = $session; + $this->avatarManager = $avatarManager; $this->manager = $manager; + $this->participantService = $participantService; } /** @@ -80,4 +95,32 @@ public function getCurrentParticipant(Room $room): Participant { 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/tests/integration/features/bootstrap/AvatarTrait.php b/tests/integration/features/bootstrap/AvatarTrait.php index 37f19f3fd87..6fcd914f8b8 100644 --- a/tests/integration/features/bootstrap/AvatarTrait.php +++ b/tests/integration/features/bootstrap/AvatarTrait.php @@ -142,6 +142,20 @@ public function userDeletesAvatarForRoomWith(string $user, string $identifier, s $this->assertStatusCode($this->response, $statusCode); } + /** + * @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 * diff --git a/tests/integration/features/conversation/avatar.feature b/tests/integration/features/conversation/avatar.feature index 50bcfb4c78c..1d07b1c1a61 100644 --- a/tests/integration/features/conversation/avatar.feature +++ b/tests/integration/features/conversation/avatar.feature @@ -16,8 +16,10 @@ Feature: avatar 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" with size "256" with 404 - And user "moderator" gets avatar for room "one-to-one room" with size "256" 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" From f38f459d4bcb66c6e94837a4e77d2a88c437a8a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Wed, 30 Dec 2020 02:08:19 +0100 Subject: [PATCH 04/12] Add avatar id and version fields to conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Although the avatar itself is stored in the app data the avatar id and its version are stored in the database for convenience. Signed-off-by: Daniel Calviño Sánchez --- appinfo/info.xml | 2 +- lib/Avatar/RoomAvatar.php | 36 +++++++ lib/Avatar/RoomAvatarProvider.php | 13 ++- lib/Manager.php | 6 ++ .../Version11000Date20201229115215.php | 101 ++++++++++++++++++ lib/Model/SelectHelper.php | 2 + lib/Room.php | 16 +++ tests/php/RoomTest.php | 2 + 8 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 lib/Migration/Version11000Date20201229115215.php 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/lib/Avatar/RoomAvatar.php b/lib/Avatar/RoomAvatar.php index 8fef269bb5a..2ae1850cd90 100644 --- a/lib/Avatar/RoomAvatar.php +++ b/lib/Avatar/RoomAvatar.php @@ -72,6 +72,42 @@ 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'; + } + /** * Gets the room avatar * diff --git a/lib/Avatar/RoomAvatarProvider.php b/lib/Avatar/RoomAvatarProvider.php index 847bd2713bd..2ea0444cbe0 100644 --- a/lib/Avatar/RoomAvatarProvider.php +++ b/lib/Avatar/RoomAvatarProvider.php @@ -154,11 +154,18 @@ public function canBeModifiedByCurrentUser(IAvatar $avatar): bool { /** * Returns the latest value of the avatar version * - * @param IAvatar $avatar ignored - * @return int 0, as versions are not supported by room avatars + * @param IAvatar $avatar + * @return int + * @throws \InvalidArgumentException if the given avatar is not a RoomAvatar */ public function getVersion(IAvatar $avatar): int { - return 0; + if (!($avatar instanceof RoomAvatar)) { + throw new \InvalidArgumentException(); + } + + $room = $avatar->getRoom(); + + return $room->getAvatarVersion(); } /** 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..29ba1b1888c 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -165,6 +165,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 +206,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 +233,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 +322,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; } 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, From cdd81dca13e9b29037295367c70810f6da8bfce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Wed, 30 Dec 2020 02:37:20 +0100 Subject: [PATCH 05/12] Update avatar information in database when the avatar is set or removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- lib/Avatar/RoomAvatar.php | 29 +++++++++++++++++- lib/Events/ModifyAvatarEvent.php | 51 ++++++++++++++++++++++++++++++++ lib/Room.php | 38 ++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 lib/Events/ModifyAvatarEvent.php diff --git a/lib/Avatar/RoomAvatar.php b/lib/Avatar/RoomAvatar.php index 2ae1850cd90..3637858356e 100644 --- a/lib/Avatar/RoomAvatar.php +++ b/lib/Avatar/RoomAvatar.php @@ -108,6 +108,20 @@ public static function getDefaultRoomAvatarType(int $roomType, string $objectTyp 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 * @@ -163,10 +177,12 @@ public function set($data): void { $this->validateAvatar($image); - $this->remove(); + $this->removeFiles(); $type = $this->getAvatarImageType($image); $file = $this->folder->newFile('avatar.' . $type); $file->putContent($data); + + $this->room->setAvatar($this->getRoomAvatarType(), $this->room->getAvatarVersion() + 1); } /** @@ -250,6 +266,17 @@ private function validateAvatar(IImage $avatar): void { * @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. 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/Room.php b/lib/Room.php index 29ba1b1888c..df867fdfea7 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'; @@ -634,6 +637,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 From 7c39c636ba013a10996144881c1af7174d39c1b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Wed, 30 Dec 2020 02:38:19 +0100 Subject: [PATCH 06/12] Return avatar id and version with the rest of the conversation data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- docs/conversation.md | 2 + lib/Controller/RoomController.php | 4 ++ .../features/bootstrap/FeatureContext.php | 6 ++ .../features/conversation/avatar.feature | 71 +++++++++++++++++++ 4 files changed, 83 insertions(+) diff --git a/docs/conversation.md b/docs/conversation.md index 7c3ae7e40af..f29af7b6899 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") + `avatarVersion` | int | v3 | The version of the avatar `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/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/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index a094499da51..6e0089bf286 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -253,6 +253,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']; } diff --git a/tests/integration/features/conversation/avatar.feature b/tests/integration/features/conversation/avatar.feature index 1d07b1c1a61..e403ac155e9 100644 --- a/tests/integration/features/conversation/avatar.feature +++ b/tests/integration/features/conversation/avatar.feature @@ -460,3 +460,74 @@ Feature: avatar 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 | From 906c2f7c45416c39a3c10f0053b49ccefbf39c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Wed, 30 Dec 2020 05:19:22 +0100 Subject: [PATCH 07/12] Notify external signaling server when avatar changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- lib/Room.php | 2 + lib/Signaling/Listener.php | 1 + tests/php/Signaling/BackendNotifierTest.php | 42 +++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/lib/Room.php b/lib/Room.php index df867fdfea7..5beb4525c44 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -399,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(), ]); } 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/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, From fcbf045ac38aa685fde07991c5588f9a57d0b242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Wed, 30 Dec 2020 17:07:47 +0100 Subject: [PATCH 08/12] Add integration test step to send requests as the logged in user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- .../features/bootstrap/FeatureContext.php | 53 +++++++++++++++++-- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index 6e0089bf286..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; @@ -1752,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; @@ -1763,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(); + } } /** From a878ce2ae2f29f3220e051c19d109ca9a9831a3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Wed, 30 Dec 2020 18:44:23 +0100 Subject: [PATCH 09/12] Update one-to-one conversation avatars when the user avatar changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image itself is always got directly from the user avatar, so it is always up to date. However, as the version is specific to the room avatar and stored in the database it needs to be explicitly bumped. Signed-off-by: Daniel Calviño Sánchez --- lib/AppInfo/Application.php | 2 + lib/Avatar/Listener.php | 84 +++++++++++++++++++ .../features/bootstrap/AvatarTrait.php | 43 ++++++++++ .../features/conversation/avatar.feature | 53 ++++++++++++ tests/psalm-baseline.xml | 5 ++ 5 files changed, 187 insertions(+) create mode 100644 lib/Avatar/Listener.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 10ce6d313fe..bf4a8c1c210 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\Avatar\RoomAvatarProvider; use OCA\Talk\Capabilities; use OCA\Talk\Chat\Changelog\Listener as ChangelogListener; @@ -134,6 +135,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/tests/integration/features/bootstrap/AvatarTrait.php b/tests/integration/features/bootstrap/AvatarTrait.php index 6fcd914f8b8..d6758a150be 100644 --- a/tests/integration/features/bootstrap/AvatarTrait.php +++ b/tests/integration/features/bootstrap/AvatarTrait.php @@ -142,6 +142,49 @@ public function userDeletesAvatarForRoomWith(string $user, string $identifier, s $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 * diff --git a/tests/integration/features/conversation/avatar.feature b/tests/integration/features/conversation/avatar.feature index e403ac155e9..5c5f67987c2 100644 --- a/tests/integration/features/conversation/avatar.feature +++ b/tests/integration/features/conversation/avatar.feature @@ -531,3 +531,56 @@ Feature: avatar 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/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 From 5ecf33f0359a5fe1f8c8ad92dc8fc268e3dece30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Wed, 30 Dec 2020 05:36:28 +0100 Subject: [PATCH 10/12] Add system message when the avatar of a conversation is changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- lib/Chat/Parser/SystemMessage.php | 10 +++++ lib/Chat/SystemMessage/Listener.php | 11 +++++ .../features/chat/system-messages.feature | 42 +++++++++++++++++++ tests/php/Chat/Parser/SystemMessageTest.php | 16 +++++++ 4 files changed, 79 insertions(+) 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/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/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']], From 0eae41080e45aa7d1128ec3569c25c2f712b09ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Wed, 30 Dec 2020 13:16:12 +0100 Subject: [PATCH 11/12] Add capability for room avatars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Calviño Sánchez --- docs/capabilities.md | 1 + docs/conversation.md | 4 ++-- lib/Capabilities.php | 1 + tests/php/CapabilitiesTest.php | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) 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 f29af7b6899..912a3250560 100644 --- a/docs/conversation.md +++ b/docs/conversation.md @@ -48,8 +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") - `avatarVersion` | int | v3 | The version of the avatar + `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/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/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', ]; } From 74cdd1ec9a44ce7e11ce66cb80499aa15c12e6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Calvi=C3=B1o=20S=C3=A1nchez?= Date: Wed, 30 Dec 2020 06:08:17 +0100 Subject: [PATCH 12/12] Replace generic avatar system with specific Talk endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic avatar system will not be included in Nextcloud 21, so for the time being Talk needs to provide its own endpoints for room avatars. Signed-off-by: Daniel Calviño Sánchez --- appinfo/routes.php | 28 +++ lib/AppInfo/Application.php | 3 - lib/Avatar/RoomAvatarProvider.php | 3 +- lib/Controller/RoomAvatarController.php | 233 ++++++++++++++++++ .../features/bootstrap/AvatarTrait.php | 6 +- 5 files changed, 265 insertions(+), 8 deletions(-) create mode 100644 lib/Controller/RoomAvatarController.php 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/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index bf4a8c1c210..528d432f537 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -26,7 +26,6 @@ use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; use OCA\Talk\Activity\Listener as ActivityListener; use OCA\Talk\Avatar\Listener as AvatarListener; -use OCA\Talk\Avatar\RoomAvatarProvider; use OCA\Talk\Capabilities; use OCA\Talk\Chat\Changelog\Listener as ChangelogListener; use OCA\Talk\Chat\ChatManager; @@ -104,8 +103,6 @@ public function register(IRegistrationContext $context): void { $context->registerSearchProvider(MessageSearch::class); $context->registerDashboardWidget(TalkWidget::class); - - $context->registerAvatarProvider('room', RoomAvatarProvider::class); } public function boot(IBootContext $context): void { diff --git a/lib/Avatar/RoomAvatarProvider.php b/lib/Avatar/RoomAvatarProvider.php index 2ea0444cbe0..b44d62b0af0 100644 --- a/lib/Avatar/RoomAvatarProvider.php +++ b/lib/Avatar/RoomAvatarProvider.php @@ -33,11 +33,10 @@ use OCP\Files\IAppData; use OCP\Files\NotFoundException; use OCP\IAvatar; -use OCP\IAvatarProvider; use OCP\IL10N; use Psr\Log\LoggerInterface; -class RoomAvatarProvider implements IAvatarProvider { +class RoomAvatarProvider { /** @var IAppData */ private $appData; 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/tests/integration/features/bootstrap/AvatarTrait.php b/tests/integration/features/bootstrap/AvatarTrait.php index d6758a150be..8edc6146711 100644 --- a/tests/integration/features/bootstrap/AvatarTrait.php +++ b/tests/integration/features/bootstrap/AvatarTrait.php @@ -74,7 +74,7 @@ public function userGetsAvatarForRoomWithSize(string $user, string $identifier, */ public function userGetsAvatarForRoomWithSizeWith(string $user, string $identifier, string $size, string $statusCode) { $this->setCurrentUser($user); - $this->sendRequest('GET', '/core/avatar/room/' . FeatureContext::getTokenForIdentifier($identifier) . '/' . $size, null); + $this->sendRequest('GET', '/apps/spreed/api/v3/avatar/' . FeatureContext::getTokenForIdentifier($identifier) . '/' . $size, null); $this->assertStatusCode($this->response, $statusCode); if ($statusCode !== '200') { @@ -107,7 +107,7 @@ public function userSetsAvatarForRoomFromFileWith(string $user, string $identifi $file = \GuzzleHttp\Psr7\stream_for(fopen($source, 'r')); $this->setCurrentUser($user); - $this->sendRequest('POST', '/core/avatar/room/' . FeatureContext::getTokenForIdentifier($identifier), + $this->sendRequest('POST', '/apps/spreed/api/v3/avatar/' . FeatureContext::getTokenForIdentifier($identifier), [ 'multipart' => [ [ @@ -138,7 +138,7 @@ public function userDeletesAvatarForRoom(string $user, string $identifier) { */ public function userDeletesAvatarForRoomWith(string $user, string $identifier, string $statusCode) { $this->setCurrentUser($user); - $this->sendRequest('DELETE', '/core/avatar/room/' . FeatureContext::getTokenForIdentifier($identifier), null); + $this->sendRequest('DELETE', '/apps/spreed/api/v3/avatar/' . FeatureContext::getTokenForIdentifier($identifier), null); $this->assertStatusCode($this->response, $statusCode); }