From f3d12ce0dc1af04ec520ce676fdee4c9650fb2ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Dre=C3=9Fler?= Date: Wed, 20 Sep 2023 17:13:28 +0200 Subject: [PATCH 1/7] feat: Allow guests to contacts user via public profile "Talk to" link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jonas Dreßler --- lib/Controller/PageController.php | 61 ++++++++++++++++++++++++++++++- lib/Profile/TalkAction.php | 8 ++-- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index eecd8a634eb..e973f3d0a34 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -34,6 +34,7 @@ use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\Template\PublicTemplateResponse; use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Http\TooManyRequestsResponse; use OCP\AppFramework\Services\IInitialState; use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent; @@ -43,9 +44,13 @@ use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUser; +use OCP\IUserManager; use OCP\IUserSession; +use OCP\L10N\IFactory; use OCP\Notification\IManager as INotificationManager; use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\RateLimiting\ILimiter; +use OCP\Security\RateLimiting\IRateLimitExceededException; use Psr\Log\LoggerInterface; use SensitiveParameter; @@ -69,6 +74,9 @@ public function __construct( private IThrottler $throttler, protected Config $talkConfig, protected IGroupManager $groupManager, + protected IUserManager $userManager, + protected ILimiter $limiter, + protected IFactory $l10nFactory, ) { parent::__construct($appName, $request); } @@ -125,7 +133,7 @@ public function duplicateSession(): Response { /** * @param string $token * @param string $callUser - * @return TemplateResponse|RedirectResponse + * @return TemplateResponse|RedirectResponse|TooManyRequestsResponse * @throws HintException */ #[NoCSRFRequired] @@ -140,11 +148,35 @@ public function index(string $token = '', string $callUser = ''): Response { return $this->pageHandler($token, $callUser); } + /** + * @param string $forUser + * @return ?Room + */ + protected function createPrivateRoom(string $forUser): ?Room { + $user = $this->userManager->get($forUser); + if (!$user instanceof IUser) { + return null; + } + + try { + $objectType = ''; + $objectId = ''; + $l = $this->l10nFactory->get('spreed', $this->l10nFactory->getUserLanguage($user)); + $room = $this->roomService->createConversation(Room::TYPE_PUBLIC, + $l->t('Contact request'), $user, $objectType, $objectId, + ); + } catch (\InvalidArgumentException $e) { + return null; + } + + return $room; + } + /** * @param string $token * @param string $callUser * @param string $password - * @return TemplateResponse|RedirectResponse + * @return TemplateResponse|RedirectResponse|TooManyRequestsResponse * @throws HintException */ protected function pageHandler( @@ -158,6 +190,31 @@ protected function pageHandler( $bruteForceToken = $token; $user = $this->userSession->getUser(); if (!$user instanceof IUser) { + if ($token === '') { + $room = $this->createPrivateRoom($callUser); + if ($room === null) { + $response = new TemplateResponse('core', '404-profile', [], 'guest'); + $response->throttle(['action' => 'callUser', 'callUser' => $callUser]); + + return $response; + } + + try { + $this->limiter->registerAnonRequest( + 'create-anonymous-conversation', + 5, // Five conversations + 60 * 60, // Per hour + $this->request->getRemoteAddress(), + ); + } catch (IRateLimitExceededException) { + return new TooManyRequestsResponse(); + } + + // FIXME: add rate limiting + return $this->redirectToConversation($room->getToken()); + } else { + return $this->guestEnterRoom($token, $password); + } return $this->guestEnterRoom($token, $password, $email, $accessToken); } diff --git a/lib/Profile/TalkAction.php b/lib/Profile/TalkAction.php index a91f9778a79..fc530e743df 100644 --- a/lib/Profile/TalkAction.php +++ b/lib/Profile/TalkAction.php @@ -51,9 +51,10 @@ public function getDisplayId(): string { #[\Override] public function getTitle(): string { $visitingUser = $this->userSession->getUser(); - if (!$visitingUser || $visitingUser === $this->targetUser) { + if ($visitingUser === $this->targetUser) { return $this->l->t('Open Talk'); } + return $this->l->t('Talk to %s', [$this->targetUser->getDisplayName()]); } @@ -71,9 +72,8 @@ public function getIcon(): string { public function getTarget(): ?string { $visitingUser = $this->userSession->getUser(); if ( - !$visitingUser - || $this->config->isDisabledForUser($this->targetUser) - || $this->config->isDisabledForUser($visitingUser) + $this->config->isDisabledForUser($this->targetUser) + || ($visitingUser && $this->config->isDisabledForUser($visitingUser)) ) { return null; } From f15414d4f2137672cde56b3cd91dbf711919ac0e Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Mon, 23 Oct 2023 11:04:26 +0200 Subject: [PATCH 2/7] fix(profile): Move rate-limiting before the action Signed-off-by: Joas Schilling --- lib/Controller/PageController.php | 39 +++++++++++++------------------ 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index e973f3d0a34..a5980a2becd 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -148,24 +148,21 @@ public function index(string $token = '', string $callUser = ''): Response { return $this->pageHandler($token, $callUser); } - /** - * @param string $forUser - * @return ?Room - */ - protected function createPrivateRoom(string $forUser): ?Room { - $user = $this->userManager->get($forUser); + protected function createPrivateRoom(string $targetUserId): ?Room { + $user = $this->userManager->get($targetUserId); if (!$user instanceof IUser) { return null; } + $l = $this->l10nFactory->get('spreed', $this->l10nFactory->getUserLanguage($user)); + try { - $objectType = ''; - $objectId = ''; - $l = $this->l10nFactory->get('spreed', $this->l10nFactory->getUserLanguage($user)); - $room = $this->roomService->createConversation(Room::TYPE_PUBLIC, - $l->t('Contact request'), $user, $objectType, $objectId, + $room = $this->roomService->createConversation( + Room::TYPE_PUBLIC, + $l->t('Contact request'), + $user, ); - } catch (\InvalidArgumentException $e) { + } catch (\InvalidArgumentException) { return null; } @@ -191,14 +188,6 @@ protected function pageHandler( $user = $this->userSession->getUser(); if (!$user instanceof IUser) { if ($token === '') { - $room = $this->createPrivateRoom($callUser); - if ($room === null) { - $response = new TemplateResponse('core', '404-profile', [], 'guest'); - $response->throttle(['action' => 'callUser', 'callUser' => $callUser]); - - return $response; - } - try { $this->limiter->registerAnonRequest( 'create-anonymous-conversation', @@ -210,10 +199,14 @@ protected function pageHandler( return new TooManyRequestsResponse(); } - // FIXME: add rate limiting + $room = $this->createPrivateRoom($callUser); + if ($room === null) { + $response = new TemplateResponse('core', '404-profile', [], TemplateResponse::RENDER_AS_GUEST); + $response->throttle(['action' => 'callUser', 'callUser' => $callUser]); + return $response; + } + return $this->redirectToConversation($room->getToken()); - } else { - return $this->guestEnterRoom($token, $password); } return $this->guestEnterRoom($token, $password, $email, $accessToken); } From b756b8c07ce4fc102fd111d0d1c2a9c3589275bb Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Mon, 23 Oct 2023 12:38:06 +0200 Subject: [PATCH 3/7] fix(profile): Check profile config before creating room from guest Signed-off-by: Joas Schilling --- lib/Controller/PageController.php | 34 +++++++++++++++++-------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index a5980a2becd..46e630d75ad 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -48,6 +48,7 @@ use OCP\IUserSession; use OCP\L10N\IFactory; use OCP\Notification\IManager as INotificationManager; +use OCP\Profile\IProfileManager; use OCP\Security\Bruteforce\IThrottler; use OCP\Security\RateLimiting\ILimiter; use OCP\Security\RateLimiting\IRateLimitExceededException; @@ -75,6 +76,7 @@ public function __construct( protected Config $talkConfig, protected IGroupManager $groupManager, protected IUserManager $userManager, + protected IProfileManager $profileManager, protected ILimiter $limiter, protected IFactory $l10nFactory, ) { @@ -148,25 +150,26 @@ public function index(string $token = '', string $callUser = ''): Response { return $this->pageHandler($token, $callUser); } - protected function createPrivateRoom(string $targetUserId): ?Room { + /** + * @throws \InvalidArgumentException + */ + protected function createPrivateRoom(string $targetUserId): Room { $user = $this->userManager->get($targetUserId); if (!$user instanceof IUser) { - return null; + throw new \InvalidArgumentException('user'); } - $l = $this->l10nFactory->get('spreed', $this->l10nFactory->getUserLanguage($user)); - - try { - $room = $this->roomService->createConversation( - Room::TYPE_PUBLIC, - $l->t('Contact request'), - $user, - ); - } catch (\InvalidArgumentException) { - return null; + if ($this->profileManager->isProfileFieldVisible('talk', $user, null)) { + throw new \InvalidArgumentException('profile'); } - return $room; + $l = $this->l10nFactory->get('spreed', $this->l10nFactory->getUserLanguage($user)); + + return $this->roomService->createConversation( + Room::TYPE_PUBLIC, + $l->t('Contact request'), + $user, + ); } /** @@ -199,8 +202,9 @@ protected function pageHandler( return new TooManyRequestsResponse(); } - $room = $this->createPrivateRoom($callUser); - if ($room === null) { + try { + $room = $this->createPrivateRoom($callUser); + } catch (\InvalidArgumentException) { $response = new TemplateResponse('core', '404-profile', [], TemplateResponse::RENDER_AS_GUEST); $response->throttle(['action' => 'callUser', 'callUser' => $callUser]); return $response; From 2c55f0e65c0327591066a90984f3c35500bf574a Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Wed, 25 Oct 2023 16:48:15 +0200 Subject: [PATCH 4/7] fix(profile): Check if the user is allowed to create rooms Signed-off-by: Joas Schilling --- lib/Controller/PageController.php | 10 +++++++--- lib/Profile/TalkAction.php | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 46e630d75ad..7c2b1e4c403 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -153,13 +153,17 @@ public function index(string $token = '', string $callUser = ''): Response { /** * @throws \InvalidArgumentException */ - protected function createPrivateRoom(string $targetUserId): Room { + protected function createContactRequestRoom(string $targetUserId): Room { $user = $this->userManager->get($targetUserId); if (!$user instanceof IUser) { throw new \InvalidArgumentException('user'); } - if ($this->profileManager->isProfileFieldVisible('talk', $user, null)) { + if ($this->talkConfig->isNotAllowedToCreateConversations($user)) { + throw new \InvalidArgumentException('config'); + } + + if (!$this->profileManager->isProfileFieldVisible('talk', $user, null)) { throw new \InvalidArgumentException('profile'); } @@ -203,7 +207,7 @@ protected function pageHandler( } try { - $room = $this->createPrivateRoom($callUser); + $room = $this->createContactRequestRoom($callUser); } catch (\InvalidArgumentException) { $response = new TemplateResponse('core', '404-profile', [], TemplateResponse::RENDER_AS_GUEST); $response->throttle(['action' => 'callUser', 'callUser' => $callUser]); diff --git a/lib/Profile/TalkAction.php b/lib/Profile/TalkAction.php index fc530e743df..890dab73af1 100644 --- a/lib/Profile/TalkAction.php +++ b/lib/Profile/TalkAction.php @@ -70,16 +70,23 @@ public function getIcon(): string { #[\Override] public function getTarget(): ?string { - $visitingUser = $this->userSession->getUser(); - if ( - $this->config->isDisabledForUser($this->targetUser) - || ($visitingUser && $this->config->isDisabledForUser($visitingUser)) - ) { + if ($this->config->isDisabledForUser($this->targetUser)) { return null; } + + $visitingUser = $this->userSession->getUser(); if ($visitingUser === $this->targetUser) { return $this->urlGenerator->linkToRouteAbsolute('spreed.Page.index'); } + + if ($visitingUser && $this->config->isDisabledForUser($visitingUser)) { + return null; + } + + if (!$visitingUser && $this->config->isNotAllowedToCreateConversations($this->targetUser)) { + return null; + } + return $this->urlGenerator->linkToRouteAbsolute('spreed.Page.index') . '?callUser=' . $this->targetUser->getUID(); } } From 0a8f3ea72220dddf6e2c6477bca057da36e23ffd Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Thu, 12 Mar 2026 23:01:39 +0100 Subject: [PATCH 5/7] fix(profile): Add intermediate page to prevent crawlers from triggering Signed-off-by: Joas Schilling --- lib/Controller/PageController.php | 87 ++++++++++------------------ lib/Controller/RoomController.php | 89 ++++++++++++++++++++++++++++ lib/Profile/TalkAction.php | 2 +- rspack.config.js | 1 + src/meet.ts | 17 ++++++ src/views/MeetView.vue | 96 +++++++++++++++++++++++++++++++ templates/meet.php | 12 ++++ 7 files changed, 245 insertions(+), 59 deletions(-) create mode 100644 src/meet.ts create mode 100644 src/views/MeetView.vue create mode 100644 templates/meet.php diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 7c2b1e4c403..d8826453639 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -34,7 +34,6 @@ use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\Template\PublicTemplateResponse; use OCP\AppFramework\Http\TemplateResponse; -use OCP\AppFramework\Http\TooManyRequestsResponse; use OCP\AppFramework\Services\IInitialState; use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent; @@ -46,12 +45,9 @@ use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; -use OCP\L10N\IFactory; use OCP\Notification\IManager as INotificationManager; use OCP\Profile\IProfileManager; use OCP\Security\Bruteforce\IThrottler; -use OCP\Security\RateLimiting\ILimiter; -use OCP\Security\RateLimiting\IRateLimitExceededException; use Psr\Log\LoggerInterface; use SensitiveParameter; @@ -77,8 +73,6 @@ public function __construct( protected IGroupManager $groupManager, protected IUserManager $userManager, protected IProfileManager $profileManager, - protected ILimiter $limiter, - protected IFactory $l10nFactory, ) { parent::__construct($appName, $request); } @@ -132,12 +126,6 @@ public function duplicateSession(): Response { return $this->pageHandler(); } - /** - * @param string $token - * @param string $callUser - * @return TemplateResponse|RedirectResponse|TooManyRequestsResponse - * @throws HintException - */ #[NoCSRFRequired] #[PublicPage] #[BruteForceProtection(action: 'talkRoomToken')] @@ -145,42 +133,48 @@ public function duplicateSession(): Response { #[FrontpageRoute(verb: 'GET', url: '/')] public function index(string $token = '', string $callUser = ''): Response { if ($callUser !== '') { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new RedirectResponse($this->url->linkToRoute('spreed.Page.meetUser', ['user' => $callUser])); + } $token = ''; } return $this->pageHandler($token, $callUser); } - /** - * @throws \InvalidArgumentException - */ - protected function createContactRequestRoom(string $targetUserId): Room { - $user = $this->userManager->get($targetUserId); - if (!$user instanceof IUser) { - throw new \InvalidArgumentException('user'); - } - - if ($this->talkConfig->isNotAllowedToCreateConversations($user)) { - throw new \InvalidArgumentException('config'); + #[NoCSRFRequired] + #[PublicPage] + #[BruteForceProtection(action: 'callUser')] + #[FrontpageRoute(verb: 'GET', url: '/meet/{user}', root: '')] + public function meetUser(string $user): Response { + $loggedInUser = $this->userSession->getUser(); + if ($loggedInUser instanceof IUser) { + $response = $this->api->createRoom(Room::TYPE_ONE_TO_ONE, $user); + if ($response->getStatus() === Http::STATUS_OK + || $response->getStatus() === Http::STATUS_CREATED) { + $data = $response->getData(); + return $this->redirectToConversation($data['token']); + } + return new RedirectResponse($this->url->linkToRoute('spreed.Page.index')); } - if (!$this->profileManager->isProfileFieldVisible('talk', $user, null)) { - throw new \InvalidArgumentException('profile'); + $targetUser = $this->userManager->get($user); + if (!$targetUser instanceof IUser + || $this->talkConfig->isNotAllowedToCreateConversations($targetUser) + || !$this->profileManager->isProfileFieldVisible('talk', $targetUser, null)) { + $response = new TemplateResponse('core', '404', [], TemplateResponse::RENDER_AS_GUEST); + $response->throttle(['action' => 'callUser', 'callUser' => $user]); + return $response; } - $l = $this->l10nFactory->get('spreed', $this->l10nFactory->getUserLanguage($user)); + $this->initialState->provideInitialState('meet_target_user_id', $user); + $this->initialState->provideInitialState('meet_target_display_name', $targetUser->getDisplayName()); - return $this->roomService->createConversation( - Room::TYPE_PUBLIC, - $l->t('Contact request'), - $user, - ); + return new TemplateResponse($this->appName, 'meet', [], TemplateResponse::RENDER_AS_GUEST); } /** - * @param string $token - * @param string $callUser - * @param string $password - * @return TemplateResponse|RedirectResponse|TooManyRequestsResponse + * @return TemplateResponse|RedirectResponse * @throws HintException */ protected function pageHandler( @@ -194,28 +188,6 @@ protected function pageHandler( $bruteForceToken = $token; $user = $this->userSession->getUser(); if (!$user instanceof IUser) { - if ($token === '') { - try { - $this->limiter->registerAnonRequest( - 'create-anonymous-conversation', - 5, // Five conversations - 60 * 60, // Per hour - $this->request->getRemoteAddress(), - ); - } catch (IRateLimitExceededException) { - return new TooManyRequestsResponse(); - } - - try { - $room = $this->createContactRequestRoom($callUser); - } catch (\InvalidArgumentException) { - $response = new TemplateResponse('core', '404-profile', [], TemplateResponse::RENDER_AS_GUEST); - $response->throttle(['action' => 'callUser', 'callUser' => $callUser]); - return $response; - } - - return $this->redirectToConversation($room->getToken()); - } return $this->guestEnterRoom($token, $password, $email, $accessToken); } @@ -341,7 +313,6 @@ protected function pageHandler( } /** - * @param string $token * @return TemplateResponse|NotFoundResponse */ #[NoCSRFRequired] diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index a4762f35fe6..438b34a4472 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -10,6 +10,7 @@ use OCA\DAV\CalDAV\TimezoneService; use OCA\Talk\Capabilities; +use OCA\Talk\Chat\ChatManager; use OCA\Talk\Config; use OCA\Talk\Events\AAttendeeRemovedEvent; use OCA\Talk\Events\BeforeRoomsFetchEvent; @@ -104,7 +105,11 @@ use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\Profile\IProfileManager; use OCP\Security\Bruteforce\IThrottler; +use OCP\Security\RateLimiting\ILimiter; +use OCP\Security\RateLimiting\IRateLimitExceededException; use OCP\Server; use OCP\User\Events\UserLiveStatusEvent; use OCP\UserStatus\IManager as IUserStatusManager; @@ -160,6 +165,10 @@ public function __construct( protected IL10N $l, protected ThreadService $threadService, protected Forced $forcedParameters, + protected IProfileManager $profileManager, + protected ILimiter $limiter, + protected IFactory $l10nFactory, + protected ChatManager $chatManager, ) { parent::__construct($appName, $request); } @@ -855,6 +864,86 @@ protected function createOneToOneRoom(string $targetUserId): DataResponse { } } + /** + * Create a meet room for a guest reaching out to a user via their public profile + * + * @param string $targetUserId ID of the user to contact + * @param string $message Initial chat message posted in the conversation + * @param string $displayName Guest display name + * @return DataResponse|DataResponse + * + * 201: Room created successfully + * 403: Not allowed to create conversations + * 404: User not found or profile not visible + * 429: Rate limit exceeded + */ + #[PublicPage] + #[BruteForceProtection(action: 'talkRoomToken')] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/meet/{targetUserId}', requirements: [ + 'apiVersion' => '(v4)', + ])] + public function createMeetRoom(string $targetUserId, string $message = '', string $displayName = ''): DataResponse { + try { + $this->limiter->registerAnonRequest( + 'create-anonymous-conversation', + 5, + 60 * 60, + $this->request->getRemoteAddress(), + ); + } catch (IRateLimitExceededException) { + return new DataResponse(null, Http::STATUS_TOO_MANY_REQUESTS); + } + + $user = $this->userManager->get($targetUserId); + if (!$user instanceof IUser) { + $response = new DataResponse(null, Http::STATUS_NOT_FOUND); + $response->throttle(['action' => 'talkRoomToken']); + return $response; + } + + if ($this->talkConfig->isNotAllowedToCreateConversations($user)) { + return new DataResponse(null, Http::STATUS_FORBIDDEN); + } + + if (!$this->profileManager->isProfileFieldVisible('talk', $user, null)) { + $response = new DataResponse(null, Http::STATUS_NOT_FOUND); + $response->throttle(['action' => 'talkRoomToken']); + return $response; + } + + $l = $this->l10nFactory->get('spreed', $this->l10nFactory->getUserLanguage($user)); + + $trimmedDisplayName = trim($displayName); + if ($trimmedDisplayName !== '') { + $roomName = $l->t('Contact request from %s', [$trimmedDisplayName]); + } else { + $roomName = $l->t('Contact request'); + } + + $room = $this->roomService->createConversation( + Room::TYPE_PUBLIC, + $roomName, + $user, + lobbyState: Webinary::LOBBY_NON_MODERATORS, + ); + + $message = trim($message); + if ($message !== '') { + $participant = $this->participantService->joinRoomAsNewGuest($this->roomService, $room, '', true, null, trim($displayName) ?: null); + $this->chatManager->sendMessage( + $room, + $participant, + Attendee::ACTOR_GUESTS, + $participant->getAttendee()->getActorId(), + $message, + $this->timeFactory->getDateTime(), + ); + $this->participantService->leaveRoomAsSession($room, $participant); + } + + return new DataResponse(['token' => $room->getToken()], Http::STATUS_CREATED); + } + /** * Add a room to the favorites * diff --git a/lib/Profile/TalkAction.php b/lib/Profile/TalkAction.php index 890dab73af1..c7c04e970c3 100644 --- a/lib/Profile/TalkAction.php +++ b/lib/Profile/TalkAction.php @@ -87,6 +87,6 @@ public function getTarget(): ?string { return null; } - return $this->urlGenerator->linkToRouteAbsolute('spreed.Page.index') . '?callUser=' . $this->targetUser->getUID(); + return $this->urlGenerator->linkToRouteAbsolute('spreed.Page.meetUser', ['user' => $this->targetUser->getUID()]); } } diff --git a/rspack.config.js b/rspack.config.js index eaaf40f3733..3d3a3af875b 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -47,6 +47,7 @@ module.exports = defineConfig((env) => { path.join(__dirname, 'src', 'mainFilesSidebar.js'), path.join(__dirname, 'src', 'mainFilesSidebarLoader.js'), ], + meet: path.join(__dirname, 'src', 'meet.ts'), 'public-share-auth-form': path.join(__dirname, 'src', 'publicShareAuthForm.ts'), 'public-share-auth-sidebar': path.join(__dirname, 'src', 'mainPublicShareAuthSidebar.js'), 'public-share-sidebar': path.join(__dirname, 'src', 'mainPublicShareSidebar.js'), diff --git a/src/meet.ts b/src/meet.ts new file mode 100644 index 00000000000..5b0512c35fd --- /dev/null +++ b/src/meet.ts @@ -0,0 +1,17 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCSPNonce } from '@nextcloud/auth' +import { generateFilePath } from '@nextcloud/router' +import { createApp } from 'vue' +import MeetView from './views/MeetView.vue' +import { NextcloudGlobalsVuePlugin } from './utils/NextcloudGlobalsVuePlugin.js' + +__webpack_nonce__ = getCSPNonce() +__webpack_public_path__ = generateFilePath('spreed', '', 'js/') + +createApp(MeetView) + .use(NextcloudGlobalsVuePlugin) + .mount('#talk-meet') diff --git a/src/views/MeetView.vue b/src/views/MeetView.vue new file mode 100644 index 00000000000..c725c405551 --- /dev/null +++ b/src/views/MeetView.vue @@ -0,0 +1,96 @@ + + + + + + + diff --git a/templates/meet.php b/templates/meet.php new file mode 100644 index 00000000000..d35f742d659 --- /dev/null +++ b/templates/meet.php @@ -0,0 +1,12 @@ + +
From d7ccc5dbff14887e9037595c108be67ade6bf39c Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Thu, 12 Mar 2026 23:52:08 +0100 Subject: [PATCH 6/7] chore(assets): Recompile assets Signed-off-by: Joas Schilling --- openapi-full.json | 203 ++++++++++++++++++++++++++++++ openapi.json | 203 ++++++++++++++++++++++++++++++ src/types/openapi/openapi-full.ts | 108 ++++++++++++++++ src/types/openapi/openapi.ts | 108 ++++++++++++++++ 4 files changed, 622 insertions(+) diff --git a/openapi-full.json b/openapi-full.json index 2db353b447e..a4d425099a5 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -18070,6 +18070,209 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/meet/{targetUserId}": { + "post": { + "operationId": "room-create-meet-room", + "summary": "Create a meet room for a guest reaching out to a user via their public profile", + "tags": [ + "room" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "default": "", + "description": "Initial chat message posted in the conversation" + }, + "displayName": { + "type": "string", + "default": "", + "description": "Guest display name" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "targetUserId", + "in": "path", + "description": "ID of the user to contact", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Room created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "User not found or profile not visible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "403": { + "description": "Not allowed to create conversations", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/favorite": { "post": { "operationId": "room-add-to-favorites", diff --git a/openapi.json b/openapi.json index f612b57f35d..e671258cf6d 100644 --- a/openapi.json +++ b/openapi.json @@ -17958,6 +17958,209 @@ } } }, + "/ocs/v2.php/apps/spreed/api/{apiVersion}/meet/{targetUserId}": { + "post": { + "operationId": "room-create-meet-room", + "summary": "Create a meet room for a guest reaching out to a user via their public profile", + "tags": [ + "room" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "default": "", + "description": "Initial chat message posted in the conversation" + }, + "displayName": { + "type": "string", + "default": "", + "description": "Guest display name" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v4" + ], + "default": "v4" + } + }, + { + "name": "targetUserId", + "in": "path", + "description": "ID of the user to contact", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "201": { + "description": "Room created successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "User not found or profile not visible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "403": { + "description": "Not allowed to create conversations", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/favorite": { "post": { "operationId": "room-add-to-favorites", diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 10c22a32eba..f2b9d57faf2 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1168,6 +1168,23 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/meet/{targetUserId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a meet room for a guest reaching out to a user via their public profile */ + post: operations["room-create-meet-room"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/favorite": { parameters: { query?: never; @@ -9879,6 +9896,97 @@ export interface operations { }; }; }; + "room-create-meet-room": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + /** @description ID of the user to contact */ + targetUserId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Initial chat message posted in the conversation + * @default + */ + message?: string; + /** + * @description Guest display name + * @default + */ + displayName?: string; + }; + }; + }; + responses: { + /** @description Room created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + token: string; + }; + }; + }; + }; + }; + /** @description Not allowed to create conversations */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description User not found or profile not visible */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "room-add-to-favorites": { parameters: { query?: never; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index e1fa42d20f8..c6036eae3bb 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1168,6 +1168,23 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/spreed/api/{apiVersion}/meet/{targetUserId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a meet room for a guest reaching out to a user via their public profile */ + post: operations["room-create-meet-room"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/spreed/api/{apiVersion}/room/{token}/favorite": { parameters: { query?: never; @@ -9312,6 +9329,97 @@ export interface operations { }; }; }; + "room-create-meet-room": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v4"; + /** @description ID of the user to contact */ + targetUserId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @description Initial chat message posted in the conversation + * @default + */ + message?: string; + /** + * @description Guest display name + * @default + */ + displayName?: string; + }; + }; + }; + responses: { + /** @description Room created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + token: string; + }; + }; + }; + }; + }; + /** @description Not allowed to create conversations */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description User not found or profile not visible */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: unknown; + }; + }; + }; + }; + }; + }; "room-add-to-favorites": { parameters: { query?: never; From 00b02ca3524a85705b34ee8ef9251196c0542276 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 13 Mar 2026 00:26:17 +0100 Subject: [PATCH 7/7] test: Add integration test Signed-off-by: Joas Schilling --- .../features/bootstrap/FeatureContext.php | 42 +++++++++++++++ .../features/conversation-4/meet.feature | 54 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/integration/features/conversation-4/meet.feature diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index fb79ea3c868..3bbff16c26f 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -1164,6 +1164,48 @@ public function userCreatesRoomWith(string $user, string $identifier, int $statu } } + #[Then('/^user "([^"]*)" sets the Talk profile visibility to "([^"]*)"$/')] + public function userSetsTalkProfileVisibility(string $user, string $visibility): void { + $this->setCurrentUser($user); + $this->sendRequest('PUT', '/profile/' . $user, [ + 'paramId' => 'talk', + 'visibility' => $visibility, + ]); + $this->assertStatusCode($this->response, 200); + } + + #[Then('/^guest creates meet room "([^"]*)" for "([^"]*)" with (\d+) \((v4)\)$/')] + public function guestCreatesMeetRoom(string $identifier, string $targetUserId, int $statusCode, string $apiVersion, ?TableNode $formData = null): void { + $body = []; + if ($formData instanceof TableNode) { + $body = $formData->getRowsHash(); + } + + $this->setCurrentUser(null); + $this->sendRequest('POST', '/apps/spreed/api/' . $apiVersion . '/meet/' . $targetUserId, $body); + $this->assertStatusCode($this->response, $statusCode); + + if ($statusCode === 201) { + $response = $this->getDataFromResponse($this->response); + self::$identifierToToken[$identifier] = $response['token']; + self::$tokenToIdentifier[$response['token']] = $identifier; + + // If a message was sent, register the guest actor ID for message assertions + if (!empty($body['message'])) { + $this->setCurrentUser($targetUserId); + $this->sendRequest('GET', '/apps/spreed/api/v1/chat/' . $response['token'] . '?lookIntoFuture=0'); + $messages = $this->getDataFromResponse($this->response); + foreach ($messages as $message) { + if ($message['actorType'] === 'guests' && $message['systemMessage'] === '') { + self::$sessionIdToUser[$message['actorId']] = 'MEET_GUEST_ACTOR_ID'; + break; + } + } + $this->setCurrentUser(null); + } + } + } + #[Then('/^user "([^"]*)" tries to create room with (\d+) \((v4)\)$/')] public function userTriesToCreateRoom(string $user, int $statusCode, string $apiVersion = 'v1', ?TableNode $formData = null): void { $this->setCurrentUser($user); diff --git a/tests/integration/features/conversation-4/meet.feature b/tests/integration/features/conversation-4/meet.feature new file mode 100644 index 00000000000..42dcc30301d --- /dev/null +++ b/tests/integration/features/conversation-4/meet.feature @@ -0,0 +1,54 @@ +Feature: conversation-4/meet + Background: + Given user "participant1" exists + Given user "participant2" exists + And user "participant1" sets the Talk profile visibility to "show" + + Scenario: Guest creates a meet room + Given guest creates meet room "room" for "participant1" with 201 (v4) + Then user "participant1" is participant of room "room" (v4) + | name | type | lobbyState | + | Contact request | 3 | 1 | + + Scenario: Guest creates a meet room with display name + Given guest creates meet room "room" for "participant1" with 201 (v4) + | displayName | Guest User | + Then user "participant1" is participant of room "room" (v4) + | name | type | lobbyState | + | Contact request from Guest User | 3 | 1 | + + Scenario: Guest creates a meet room with message and display name + Given guest creates meet room "room" for "participant1" with 201 (v4) + | message | Hello, I need help! | + | displayName | Guest User | + Then user "participant1" is participant of room "room" (v4) + | name | type | lobbyState | + | Contact request from Guest User | 3 | 1 | + Then user "participant1" sees the following messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | room | guests | MEET_GUEST_ACTOR_ID | Guest User | Hello, I need help! | [] | + + Scenario: Guest creates a meet room with message but no display name + Given guest creates meet room "room" for "participant1" with 201 (v4) + | message | Hello there | + Then user "participant1" sees the following messages in room "room" with 200 + | room | actorType | actorId | actorDisplayName | message | messageParameters | + | room | guests | MEET_GUEST_ACTOR_ID | | Hello there | [] | + + Scenario: Guest creates a meet room without message does not leave a guest participant + Given guest creates meet room "room" for "participant1" with 201 (v4) + Then user "participant1" sees the following attendees in room "room" with 200 (v4) + | actorType | participantType | + | users | 1 | + + Scenario: Guest creates a meet room for a non-existing user + Given guest creates meet room "room" for "non-existing-user" with 404 (v4) + + Scenario: Guest creates a meet room for a user that cannot create conversations + Given the following "spreed" app config is set + | start_conversations | ["admin"] | + Given guest creates meet room "room" for "participant1" with 403 (v4) + + Scenario: Guest cannot create a meet room when Talk profile is hidden + Given user "participant1" sets the Talk profile visibility to "hide" + Given guest creates meet room "room" for "participant1" with 404 (v4)