UPSTREAM BASELINE: nextcloud/spreed v22.0.12 (без изменений)
Type checking / changes (push) Has been cancelled
Type checking / test (push) Has been cancelled
Type checking / typescript-summary (push) Has been cancelled
Node tests / changes (push) Has been cancelled
Node tests / test (push) Has been cancelled
Node tests / test-summary (push) Has been cancelled

Источник: https://github.com/nextcloud/spreed/archive/refs/tags/v22.0.12.tar.gz
С этого коммита ветка официального Nextcloud Talk отрезана (решение владельца 2026-07-06).
Все дальнейшие изменения — только наши; версии релизов: 22.0.12-f7.N.
This commit is contained in:
2026-07-06 14:07:50 +00:00
commit 01acfa3b40
1716 changed files with 613013 additions and 0 deletions
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OC\AppFramework\Http\Dispatcher;
use OCA\Talk\Model\Invitation;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCP\AppFramework\OCSController;
abstract class AEnvironmentAwareOCSController extends OCSController {
protected int $apiVersion = 1;
protected ?Room $room = null;
protected ?Participant $participant = null;
protected ?Invitation $invitation = null;
public function setAPIVersion(int $apiVersion): void {
$this->apiVersion = $apiVersion;
}
public function getAPIVersion(): int {
return $this->apiVersion;
}
public function setRoom(Room $room): void {
$this->room = $room;
}
public function getRoom(): ?Room {
return $this->room;
}
public function setParticipant(Participant $participant): void {
$this->participant = $participant;
}
public function getParticipant(): ?Participant {
return $this->participant;
}
public function setInvitation(Invitation $invitation): void {
$this->invitation = $invitation;
}
public function getInvitation(): ?Invitation {
return $this->invitation;
}
/**
* Following the logic of {@see Dispatcher::executeController}
* @return string Either 'json' or 'xml'
* @psalm-return 'json'|'xml'
*/
public function getResponseFormat(): string {
// get format from the url format or request format parameter
$format = $this->request->getParam('format');
// if none is given try the first Accept header
if ($format === null) {
$headers = $this->request->getHeader('accept');
/**
* Default value of
* @see OCSController::buildResponse()
*/
$format = $this->getResponderByHTTPHeader($headers, 'xml');
}
return $format;
}
}
+333
View File
@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use InvalidArgumentException;
use OCA\Talk\Exceptions\CannotReachRemoteException;
use OCA\Talk\Middleware\Attribute\AllowWithoutParticipantWhenPendingInvitation;
use OCA\Talk\Middleware\Attribute\FederationSupported;
use OCA\Talk\Middleware\Attribute\RequireLoggedInParticipant;
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
use OCA\Talk\Middleware\Attribute\RequireParticipantOrLoggedInAndListedConversation;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\AvatarService;
use OCA\Talk\Service\RoomFormatter;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\FileDisplayResponse;
use OCP\Federation\ICloudIdManager;
use OCP\IAvatarManager;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkRoom from ResponseDefinitions
*/
class AvatarController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
protected RoomFormatter $roomFormatter,
protected AvatarService $avatarService,
protected IUserSession $userSession,
protected IL10N $l,
protected LoggerInterface $logger,
protected ICloudIdManager $cloudIdManager,
protected IAvatarManager $avatarManager,
) {
parent::__construct($appName, $request);
}
/**
*
* Upload an avatar for a room
*
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
*
* 200: Avatar uploaded successfully
* 400: Avatar invalid
*/
#[PublicPage]
#[RequireModeratorParticipant]
public function uploadAvatar(): DataResponse {
try {
$file = $this->request->getUploadedFile('file');
$this->avatarService->setAvatarFromRequest($this->getRoom(), $file);
return new DataResponse($this->roomFormatter->formatRoom(
$this->getResponseFormat(),
[],
$this->getRoom(),
$this->participant,
));
} catch (InvalidArgumentException $e) {
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
} catch (\Exception $e) {
$this->logger->error('Failed to post avatar', [
'exception' => $e,
]);
return new DataResponse(['message' => $this->l->t('An error occurred. Please contact your administrator.')], Http::STATUS_BAD_REQUEST);
}
}
/**
* Set an emoji as avatar
*
* @param string $emoji Emoji
* @param ?string $color Color of the emoji
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
*
* 200: Avatar set successfully
* 400: Setting emoji avatar is not possible
*/
#[PublicPage]
#[RequireModeratorParticipant]
public function emojiAvatar(string $emoji, ?string $color): DataResponse {
try {
$this->avatarService->setAvatarFromEmoji($this->getRoom(), $emoji, $color);
return new DataResponse($this->roomFormatter->formatRoom(
$this->getResponseFormat(),
[],
$this->getRoom(),
$this->participant,
));
} catch (InvalidArgumentException $e) {
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
} catch (\Exception $e) {
$this->logger->error('Failed to post avatar', [
'exception' => $e,
]);
return new DataResponse(['message' => $this->l->t('An error occurred. Please contact your administrator.')], Http::STATUS_BAD_REQUEST);
}
}
/**
* Get the avatar of a room
*
* @param bool $darkTheme Theme used for background
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
*
* 200: Room avatar returned
*/
#[FederationSupported]
#[PublicPage]
#[NoCSRFRequired]
#[AllowWithoutParticipantWhenPendingInvitation]
#[RequireParticipantOrLoggedInAndListedConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function getAvatar(bool $darkTheme = false): FileDisplayResponse {
// Cache for 1 day
$cacheDuration = 60 * 60 * 24;
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController::class);
try {
return $proxy->getAvatar($this->room, $this->participant, $this->invitation, $darkTheme);
} catch (CannotReachRemoteException) {
// Falling back to a local "globe" avatar for indicating the federation
// Cache for 15 minutes only
$cacheDuration = 15 * 60;
}
}
$file = $this->avatarService->getAvatar($this->getRoom(), $this->userSession->getUser(), $darkTheme);
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]);
$response->cacheFor($cacheDuration, false, true);
return $response;
}
/**
* Get the dark mode avatar of a room
*
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
*
* 200: Room avatar returned
*/
#[FederationSupported]
#[PublicPage]
#[NoCSRFRequired]
#[AllowWithoutParticipantWhenPendingInvitation]
#[RequireParticipantOrLoggedInAndListedConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function getAvatarDark(): FileDisplayResponse {
return $this->getAvatar(true);
}
/**
* Get the avatar of a cloudId user when inviting users while creating a conversation
*
* @param int $size Avatar size
* @psalm-param 64|512 $size
* @param string $cloudId Federation CloudID to get the avatar for
* @param bool $darkTheme Theme used for background
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
*
* 200: User avatar returned
*/
#[FederationSupported]
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
#[NoAdminRequired]
#[NoCSRFRequired]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function getUserProxyAvatarWithoutRoom(int $size, string $cloudId, bool $darkTheme = false): FileDisplayResponse {
return $this->getUserProxyAvatar($size, $cloudId, $darkTheme);
}
/**
* Get the dark mode avatar of a cloudId user when inviting users while creating a conversation
*
* @param int $size Avatar size
* @psalm-param 64|512 $size
* @param string $cloudId Federation CloudID to get the avatar for
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
*
* 200: User avatar returned
*/
#[FederationSupported]
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
#[NoAdminRequired]
#[NoCSRFRequired]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function getUserProxyAvatarDarkWithoutRoom(int $size, string $cloudId): FileDisplayResponse {
return $this->getUserProxyAvatar($size, $cloudId, true);
}
/**
* Get the avatar of a cloudId user
*
* @param int $size Avatar size
* @psalm-param 64|512 $size
* @param string $cloudId Federation CloudID to get the avatar for
* @param bool $darkTheme Theme used for background
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
*
* 200: User avatar returned
*/
#[FederationSupported]
#[BruteForceProtection(action: 'talkRoomToken')]
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
#[PublicPage]
#[NoCSRFRequired]
#[AllowWithoutParticipantWhenPendingInvitation]
#[RequireLoggedInParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function getUserProxyAvatar(int $size, string $cloudId, bool $darkTheme = false): FileDisplayResponse {
try {
$resolvedCloudId = $this->cloudIdManager->resolveCloudId($cloudId);
} catch (\InvalidArgumentException) {
return $this->getPlaceholderResponse($darkTheme);
}
$ownId = $this->cloudIdManager->getCloudId($this->userSession->getUser()->getCloudId(), null);
/**
* Reach out to the remote server to get the avatar
*/
if ($ownId->getRemote() !== $resolvedCloudId->getRemote()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\AvatarController::class);
try {
return $proxy->getUserProxyAvatar($resolvedCloudId->getRemote(), $resolvedCloudId->getUser(), $size, $darkTheme);
} catch (CannotReachRemoteException) {
// Falling back to a local "user" avatar
return $this->getPlaceholderResponse($darkTheme);
}
}
/**
* We are the server that hosts the user, so getting it from the avatar manager
*/
try {
$avatar = $this->avatarManager->getAvatar($resolvedCloudId->getUser());
$avatarFile = $avatar->getFile($size, $darkTheme);
} catch (\Exception) {
return $this->getPlaceholderResponse($darkTheme);
}
$response = new FileDisplayResponse(
$avatarFile,
Http::STATUS_OK,
['Content-Type' => $avatarFile->getMimeType()],
);
// Cache for 1 day
$response->cacheFor(60 * 60 * 24, false, true);
return $response;
}
/**
* Get the dark mode avatar of a cloudId user
*
* @param int $size Avatar size
* @psalm-param 64|512 $size
* @param string $cloudId Federation CloudID to get the avatar for
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
*
* 200: User avatar returned
*/
#[FederationSupported]
#[BruteForceProtection(action: 'talkRoomToken')]
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
#[PublicPage]
#[NoCSRFRequired]
#[AllowWithoutParticipantWhenPendingInvitation]
#[RequireLoggedInParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function getUserProxyAvatarDark(int $size, string $cloudId): FileDisplayResponse {
return $this->getUserProxyAvatar($size, $cloudId, true);
}
/**
* Get the placeholder avatar
*
* @param bool $darkTheme Theme used for background
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
*
* 200: User avatar returned
*/
protected function getPlaceholderResponse(bool $darkTheme): FileDisplayResponse {
$file = $this->avatarService->getPersonPlaceholder($darkTheme);
$response = new FileDisplayResponse(
$file,
Http::STATUS_OK,
['Content-Type' => $file->getMimeType()],
);
$response->cacheFor(60 * 15, false, true);
return $response;
}
/**
* Delete the avatar of a room
*
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
*
* 200: Avatar removed successfully
*/
#[PublicPage]
#[RequireModeratorParticipant]
public function deleteAvatar(): DataResponse {
$this->avatarService->deleteAvatar($this->getRoom());
return new DataResponse($this->roomFormatter->formatRoom(
$this->getResponseFormat(),
[],
$this->getRoom(),
$this->participant,
));
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
use OCA\Talk\Model\Ban;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\BanService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IRequest;
/**
* @psalm-import-type TalkBan from ResponseDefinitions
*/
class BanController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
protected BanService $banService,
protected ITimeFactory $timeFactory,
) {
parent::__construct($appName, $request);
}
/**
* Ban an actor or IP address
*
* Required capability: `ban-v1`
*
* @param 'users'|'guests'|'emails'|'ip' $actorType Type of actor to ban, or `ip` when banning a clients remote address
* @param string $actorId Actor ID or the IP address or range in case of type `ip`
* @param string $internalNote Optional internal note (max. 4000 characters)
* @return DataResponse<Http::STATUS_OK, TalkBan, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'bannedActor'|'internalNote'|'moderator'|'self'|'room'}, array{}>
*
* 200: Ban successfully
* 400: Actor information is invalid
*/
#[PublicPage]
#[RequireModeratorParticipant]
public function banActor(string $actorType, string $actorId, string $internalNote = ''): DataResponse {
try {
$moderator = $this->participant->getAttendee();
$ban = $this->banService->createBan(
$this->room,
$moderator->getActorType(),
$moderator->getActorId(),
$moderator->getDisplayName(),
$actorType,
$actorId,
$this->timeFactory->getDateTime(),
$internalNote
);
return new DataResponse($ban->jsonSerialize(), Http::STATUS_OK);
} catch (\InvalidArgumentException $e) {
/** @var 'bannedActor'|'internalNote'|'moderator'|'self' $message */
$message = $e->getMessage();
return new DataResponse([
'error' => $message,
], Http::STATUS_BAD_REQUEST);
}
}
/**
* List the bans of a conversation
*
* Required capability: `ban-v1`
*
* @return DataResponse<Http::STATUS_OK, list<TalkBan>, array{}>
*
* 200: List all bans
*/
#[PublicPage]
#[RequireModeratorParticipant]
public function listBans(): DataResponse {
$bans = $this->banService->getBansForRoom($this->room->getId());
$result = array_map(static fn (Ban $ban): array => $ban->jsonSerialize(), $bans);
return new DataResponse($result, Http::STATUS_OK);
}
/**
* Unban an actor or IP address
*
* Required capability: `ban-v1`
*
* @param int $banId ID of the ban to be removed
* @return DataResponse<Http::STATUS_OK, null, array{}>
*
* 200: Unban successfully or not found
*/
#[PublicPage]
#[RequireModeratorParticipant]
public function unbanActor(int $banId): DataResponse {
$this->banService->findAndDeleteBanByIdForRoom($banId, $this->room->getId());
return new DataResponse(null, Http::STATUS_OK);
}
}
+493
View File
@@ -0,0 +1,493 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Chat\ReactionManager;
use OCA\Talk\Events\BotDisabledEvent;
use OCA\Talk\Events\BotEnabledEvent;
use OCA\Talk\Exceptions\ReactionAlreadyExistsException;
use OCA\Talk\Exceptions\ReactionNotSupportedException;
use OCA\Talk\Exceptions\ReactionOutOfContextException;
use OCA\Talk\Exceptions\UnauthorizedException;
use OCA\Talk\Manager;
use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Bot;
use OCA\Talk\Model\BotConversation;
use OCA\Talk\Model\BotConversationMapper;
use OCA\Talk\Model\BotServer;
use OCA\Talk\Model\BotServerMapper;
use OCA\Talk\Model\Thread;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCA\Talk\Service\BotService;
use OCA\Talk\Service\ChecksumVerificationService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\ThreadService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\MessageTooLongException;
use OCP\Comments\NotFoundException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkBot from ResponseDefinitions
* @psalm-import-type TalkBotWithDetails from ResponseDefinitions
*/
class BotController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
protected ChatManager $chatManager,
protected ParticipantService $participantService,
protected ITimeFactory $timeFactory,
protected ChecksumVerificationService $checksumVerificationService,
protected BotConversationMapper $botConversationMapper,
protected BotServerMapper $botServerMapper,
protected BotService $botService,
protected Manager $manager,
protected ReactionManager $reactionManager,
protected ThreadService $threadService,
protected IL10N $l,
protected LoggerInterface $logger,
private IEventDispatcher $dispatcher,
) {
parent::__construct($appName, $request);
}
/**
* @param string $token
* @param string $message
* @return Bot
* @throws \InvalidArgumentException When the request could not be linked with a bot
*/
#[RequestHeader(name: 'x-nextcloud-talk-bot-random', description: 'Random seed used to generate the request signature')]
#[RequestHeader(name: 'x-nextcloud-talk-bot-signature', description: 'Signature over the request body to verify authenticity')]
protected function getBotFromHeaders(string $token, string $message): Bot {
$random = $this->request->getHeader('x-nextcloud-talk-bot-random');
if (empty($random) || strlen($random) < 32) {
$this->logger->error('Invalid Random received from bot response');
throw new \InvalidArgumentException('Invalid Random received from bot response', Http::STATUS_BAD_REQUEST);
}
$checksum = $this->request->getHeader('x-nextcloud-talk-bot-signature');
if (empty($checksum)) {
$this->logger->error('Invalid Signature received from bot response');
throw new \InvalidArgumentException('Invalid Signature received from bot response', Http::STATUS_BAD_REQUEST);
}
$bots = $this->botService->getBotsForToken($token, Bot::FEATURE_RESPONSE);
foreach ($bots as $botAttempt) {
try {
$this->checksumVerificationService->validateRequest(
$random,
$checksum,
$botAttempt->getBotServer()->getSecret(),
$message
);
if (!($botAttempt->getBotServer()->getFeatures() & Bot::FEATURE_RESPONSE)) {
$this->logger->debug('Not accepting response from bot ID ' . $botAttempt->getBotServer()->getId() . ' because the feature is disabled for it');
throw new \InvalidArgumentException('Feature not enabled for bot', Http::STATUS_BAD_REQUEST);
}
return $botAttempt;
} catch (UnauthorizedException) {
}
}
$this->logger->debug('No valid Bot entry found');
throw new \InvalidArgumentException('No valid Bot entry found', Http::STATUS_UNAUTHORIZED);
}
/**
* Sends a new chat message to the given room
*
* The author and timestamp are automatically set to the current user/guest
* and time.
*
* @param string $token Conversation token
* @param string $message The message to send
* @param string $referenceId For the message to be able to later identify it again
* @param int $replyTo Parent id which this message is a reply to
* @param bool $silent If sent silent the chat message will not create any notifications
* @param string $threadTitle Only supported when not replying, when given will create a thread (requires `threads` capability)
* @param int $threadId Thread id which this message is a reply to without quoting a specific message (ignored when $replyTo is given, also requires `threads` capability)
* @return DataResponse<Http::STATUS_CREATED|Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_REQUEST_ENTITY_TOO_LARGE, null, array{}>
*
* 201: Message sent successfully
* 400: When the replyTo is invalid or message is empty
* 401: Sending message is not allowed
* 413: Message too long
*/
#[BruteForceProtection(action: 'bot')]
#[OpenAPI(scope: 'bots')]
#[PublicPage]
public function sendMessage(string $token, string $message, string $referenceId = '', int $replyTo = 0, bool $silent = false, string $threadTitle = '', int $threadId = 0): DataResponse {
if (trim($message) === '') {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
try {
$bot = $this->getBotFromHeaders($token, $message);
} catch (\InvalidArgumentException $e) {
/** @var Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED $status */
$status = $e->getCode();
$response = new DataResponse(null, $status);
if ($e->getCode() === Http::STATUS_UNAUTHORIZED) {
$response->throttle(['action' => 'bot']);
}
return $response;
}
$room = $this->manager->getRoomByToken($token);
$actorType = Attendee::ACTOR_BOTS;
$actorId = Attendee::ACTOR_BOT_PREFIX . $bot->getBotServer()->getUrlHash();
$parent = null;
if ($replyTo !== 0) {
try {
$parent = $this->chatManager->getParentComment($room, (string)$replyTo);
} catch (NotFoundException $e) {
// Someone is trying to reply cross-rooms or to a non-existing message
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
} elseif ($threadId !== Thread::THREAD_NONE && $threadId !== Thread::THREAD_CREATE) {
if (!$this->threadService->validateThread($room->getId(), $threadId)) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
}
$this->participantService->ensureOneToOneRoomIsFilled($room);
$creationDateTime = $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'));
try {
$createThread = $replyTo === 0 && $threadId === Thread::THREAD_NONE && $threadTitle !== '';
$threadId = $createThread ? Thread::THREAD_CREATE : $threadId;
$comment = $this->chatManager->sendMessage($room, null, $actorType, $actorId, $message, $creationDateTime, $parent, $referenceId, $silent, false, $threadId);
if ($createThread) {
$thread = $this->threadService->createThread($room, (int)$comment->getId(), $threadTitle);
$this->chatManager->addSystemMessage(
$room,
null,
$actorType,
$actorId,
json_encode(['message' => 'thread_created', 'parameters' => ['thread' => (int)$comment->getId(), 'title' => $thread->getName()]]),
$this->timeFactory->getDateTime(),
false,
null,
$comment,
true,
true
);
}
} catch (MessageTooLongException) {
return new DataResponse(null, Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
} catch (\Exception) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null, Http::STATUS_CREATED);
}
/**
* Adds a reaction to a chat message
*
* @param string $token Conversation token
* @param int $messageId ID of the message
* @param string $reaction Reaction to add
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED|Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Reaction already exists
* 201: Reacted successfully
* 400: Reacting is not possible
* 401: Reacting is not allowed
* 404: Reaction not found
*/
#[BruteForceProtection(action: 'bot')]
#[OpenAPI(scope: 'bots')]
#[PublicPage]
public function react(string $token, int $messageId, string $reaction): DataResponse {
try {
$bot = $this->getBotFromHeaders($token, $reaction);
} catch (\InvalidArgumentException $e) {
/** @var Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED $status */
$status = $e->getCode();
$response = new DataResponse(null, $status);
if ($e->getCode() === Http::STATUS_UNAUTHORIZED) {
$response->throttle(['action' => 'bot']);
}
return $response;
}
$room = $this->manager->getRoomByToken($token);
$actorType = Attendee::ACTOR_BOTS;
$actorId = Attendee::ACTOR_BOT_PREFIX . $bot->getBotServer()->getUrlHash();
try {
$this->reactionManager->addReactionMessage(
$room,
$actorType,
$actorId,
$bot->getBotServer()->getName(),
$messageId,
$reaction
);
} catch (NotFoundException) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
} catch (ReactionAlreadyExistsException) {
return new DataResponse(null, Http::STATUS_OK);
} catch (ReactionNotSupportedException|ReactionOutOfContextException|\Exception) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null, Http::STATUS_CREATED);
}
/**
* Deletes a reaction from a chat message
*
* @param string $token Conversation token
* @param int $messageId ID of the message
* @param string $reaction Reaction to delete
* @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_UNAUTHORIZED, null, array{}>
*
* 200: Reaction deleted successfully
* 400: Reacting is not possible
* 401: Reacting is not allowed
* 404: Reaction not found
*/
#[BruteForceProtection(action: 'bot')]
#[OpenAPI(scope: 'bots')]
#[PublicPage]
public function deleteReaction(string $token, int $messageId, string $reaction): DataResponse {
try {
$bot = $this->getBotFromHeaders($token, $reaction);
} catch (\InvalidArgumentException $e) {
/** @var Http::STATUS_BAD_REQUEST|Http::STATUS_UNAUTHORIZED $status */
$status = $e->getCode();
$response = new DataResponse(null, $status);
if ($e->getCode() === Http::STATUS_UNAUTHORIZED) {
$response->throttle(['action' => 'bot']);
}
return $response;
}
$room = $this->manager->getRoomByToken($token);
$actorType = Attendee::ACTOR_BOTS;
$actorId = Attendee::ACTOR_BOT_PREFIX . $bot->getBotServer()->getUrlHash();
try {
$this->reactionManager->deleteReactionMessage(
$room,
$actorType,
$actorId,
$bot->getBotServer()->getName(),
$messageId,
$reaction
);
} catch (ReactionNotSupportedException|ReactionOutOfContextException|NotFoundException) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
} catch (\Exception) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null, Http::STATUS_OK);
}
/**
* List admin bots
*
* @return DataResponse<Http::STATUS_OK, list<TalkBotWithDetails>, array{}>
*
* 200: Bot list returned
*/
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])]
public function adminListBots(): DataResponse {
$data = [];
$bots = $this->botServerMapper->getAllBots();
foreach ($bots as $bot) {
$botData = $bot->jsonSerialize();
unset($botData['secret']);
if (!$this->botService->isAppForBotEnabled($bot)) {
$botData['state'] = Bot::STATE_UNAVAILABLE;
$botData['error_count'] = 1;
$botData['last_error_date'] = $this->timeFactory->getTime();
$botData['last_error_message'] = $this->l->t('App disabled');
}
$data[] = $botData;
}
return new DataResponse($data);
}
/**
* List bots
*
* @return DataResponse<Http::STATUS_OK, list<TalkBot>, array{}>
*
* 200: Bot list returned
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function listBots(): DataResponse {
$alreadyInstalled = array_map(static function (BotConversation $bot): int {
return $bot->getBotId();
}, $this->botConversationMapper->findForToken($this->room->getToken()));
$data = [];
$bots = $this->botServerMapper->getAllBots();
foreach ($bots as $bot) {
$botData = $this->formatBot($bot, in_array($bot->getId(), $alreadyInstalled, true));
if (!$this->botService->isAppForBotEnabled($bot)) {
if ($botData['state'] !== Bot::STATE_DISABLED) {
$botData['state'] = Bot::STATE_UNAVAILABLE;
} else {
continue;
}
}
if ($botData !== null) {
$data[] = $botData;
}
}
return new DataResponse($data);
}
/**
* Enables a bot
*
* @param int $botId ID of the bot
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED, ?TalkBot, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Bot already enabled
* 201: Bot enabled successfully
* 400: Enabling bot errored
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function enableBot(int $botId): DataResponse {
if ($this->room->isFederatedConversation() || $this->room->getType() === ROOM::TYPE_ONE_TO_ONE_FORMER) {
return new DataResponse([
'error' => 'room',
], Http::STATUS_BAD_REQUEST);
}
try {
$bot = $this->botServerMapper->findById($botId);
} catch (DoesNotExistException) {
return new DataResponse([
'error' => 'bot',
], Http::STATUS_BAD_REQUEST);
}
if ($bot->getState() !== Bot::STATE_ENABLED || !$this->botService->isAppForBotEnabled($bot)) {
return new DataResponse([
'error' => 'bot',
], Http::STATUS_BAD_REQUEST);
}
$alreadyInstalled = array_map(static function (BotConversation $bot): int {
return $bot->getBotId();
}, $this->botConversationMapper->findForToken($this->room->getToken()));
if (in_array($botId, $alreadyInstalled)) {
return new DataResponse($this->formatBot($bot, true), Http::STATUS_OK);
}
$conversationBot = new BotConversation();
$conversationBot->setBotId($botId);
$conversationBot->setToken($this->room->getToken());
$conversationBot->setState(Bot::STATE_ENABLED);
$this->botConversationMapper->insert($conversationBot);
$event = new BotEnabledEvent($this->room, $bot);
$this->dispatcher->dispatchTyped($event);
return new DataResponse($this->formatBot($bot, true), Http::STATUS_CREATED);
}
/**
* Disables a bot
*
* @param int $botId ID of the bot
* @return DataResponse<Http::STATUS_OK, ?TalkBot, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Bot disabled successfully
* 400: Disabling bot errored
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function disableBot(int $botId): DataResponse {
try {
$bot = $this->botServerMapper->findById($botId);
} catch (DoesNotExistException) {
return new DataResponse([
'error' => 'bot',
], Http::STATUS_BAD_REQUEST);
}
if ($bot->getState() !== Bot::STATE_ENABLED) {
return new DataResponse([
'error' => 'bot',
], Http::STATUS_BAD_REQUEST);
}
$this->botConversationMapper->deleteByBotIdAndTokens($botId, [$this->room->getToken()]);
$event = new BotDisabledEvent($this->room, $bot);
$this->dispatcher->dispatchTyped($event);
return new DataResponse($this->formatBot($bot, false), Http::STATUS_OK);
}
/**
* @param BotServer $bot
* @param bool $conversationEnabled
* @return array|null
* @psalm-return ?TalkBot
*/
protected function formatBot(BotServer $bot, bool $conversationEnabled): ?array {
$state = $conversationEnabled ? Bot::STATE_ENABLED : Bot::STATE_DISABLED;
if ($bot->getState() === Bot::STATE_NO_SETUP) {
if ($state === Bot::STATE_DISABLED) {
return null;
}
$state = Bot::STATE_NO_SETUP;
}
return [
'id' => $bot->getId(),
'name' => $bot->getName(),
'description' => $bot->getDescription(),
'state' => $state,
];
}
}
+270
View File
@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use InvalidArgumentException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
use OCA\Talk\Middleware\Attribute\RequireLoggedInParticipant;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\BreakoutRoomService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RoomFormatter;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\Comments\MessageTooLongException;
use OCP\IRequest;
/**
* @psalm-import-type TalkRoom from ResponseDefinitions
*/
class BreakoutRoomController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
protected BreakoutRoomService $breakoutRoomService,
protected ParticipantService $participantService,
protected RoomFormatter $roomFormatter,
protected ?string $userId,
) {
parent::__construct($appName, $request);
}
/**
* Configure the breakout rooms
*
* @param 0|1|2|3 $mode Mode of the breakout rooms
* @psalm-param BreakoutRoom::MODE_* $mode
* @param int<1, 20> $amount Number of breakout rooms - Constants {@see BreakoutRoom::MINIMUM_ROOM_AMOUNT} and {@see BreakoutRoom::MAXIMUM_ROOM_AMOUNT}
* @param string $attendeeMap Mapping of the attendees to breakout rooms
* @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Breakout rooms configured successfully
* 400: Configuring breakout rooms errored
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function configureBreakoutRooms(int $mode, int $amount, string $attendeeMap = '[]'): DataResponse {
try {
$rooms = $this->breakoutRoomService->setupBreakoutRooms($this->room, $mode, $amount, $attendeeMap);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
$rooms[] = $this->room;
return new DataResponse($this->formatMultipleRooms($rooms), Http::STATUS_OK);
}
/**
* Remove the breakout rooms
*
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>
*
* 200: Breakout rooms removed successfully
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function removeBreakoutRooms(): DataResponse {
$this->breakoutRoomService->removeBreakoutRooms($this->room);
return new DataResponse($this->roomFormatter->formatRoom(
$this->getResponseFormat(),
[],
$this->room,
$this->participant,
));
}
/**
* Broadcast a chat message to all breakout rooms
*
* @param string $message Message to broadcast
* @return DataResponse<Http::STATUS_CREATED, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array{error: string}, array{}>
*
* 201: Chat message broadcasted successfully
* 400: Broadcasting chat message is not possible
* 413: Chat message too long
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function broadcastChatMessage(string $message): DataResponse {
try {
$rooms = $this->breakoutRoomService->broadcastChatMessage($this->room, $this->participant, $message);
} catch (MessageTooLongException $e) {
return new DataResponse(['error' => 'message'], Http::STATUS_REQUEST_ENTITY_TOO_LARGE);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
$rooms[] = $this->room;
return new DataResponse($this->formatMultipleRooms($rooms), Http::STATUS_CREATED);
}
/**
* Apply an attendee map to the breakout rooms
*
* @param string $attendeeMap JSON encoded mapping of the attendees to breakout rooms `array<int, int>`
* @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Attendee map applied successfully
* 400: Applying attendee map is not possible
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function applyAttendeeMap(string $attendeeMap): DataResponse {
try {
$rooms = $this->breakoutRoomService->applyAttendeeMap($this->room, $attendeeMap);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
$rooms[] = $this->room;
return new DataResponse($this->formatMultipleRooms($rooms), Http::STATUS_OK);
}
/**
* Request assistance
*
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Assistance requested successfully
* 400: Requesting assistance is not possible
*/
#[NoAdminRequired]
#[RequireLoggedInParticipant]
public function requestAssistance(): DataResponse {
try {
$this->breakoutRoomService->requestAssistance($this->room);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse($this->roomFormatter->formatRoom(
$this->getResponseFormat(),
[],
$this->room,
$this->participant,
));
}
/**
* Reset the request for assistance
*
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Request for assistance reset successfully
* 400: Resetting the request for assistance is not possible
*/
#[NoAdminRequired]
#[RequireLoggedInParticipant]
public function resetRequestForAssistance(): DataResponse {
try {
$this->breakoutRoomService->resetRequestForAssistance($this->room);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse($this->roomFormatter->formatRoom(
$this->getResponseFormat(),
[],
$this->room,
$this->participant,
));
}
/**
* Start the breakout rooms
*
* @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Breakout rooms started successfully
* 400: Starting breakout rooms is not possible
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function startBreakoutRooms(): DataResponse {
try {
$rooms = $this->breakoutRoomService->startBreakoutRooms($this->room);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
$rooms[] = $this->room;
return new DataResponse($this->formatMultipleRooms($rooms), Http::STATUS_OK);
}
/**
* Stop the breakout rooms
*
* @return DataResponse<Http::STATUS_OK, list<TalkRoom>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Breakout rooms stopped successfully
* 400: Stopping breakout rooms is not possible
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function stopBreakoutRooms(): DataResponse {
try {
$rooms = $this->breakoutRoomService->stopBreakoutRooms($this->room);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
$rooms[] = $this->room;
return new DataResponse($this->formatMultipleRooms($rooms), Http::STATUS_OK);
}
/**
* Switch to another breakout room
*
* @param string $target Target breakout room
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Switched to breakout room successfully
* 400: Switching to breakout room is not possible
*/
#[NoAdminRequired]
#[RequireLoggedInParticipant]
public function switchBreakoutRoom(string $target): DataResponse {
try {
$room = $this->breakoutRoomService->switchBreakoutRoom($this->room, $this->participant, $target);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse($this->roomFormatter->formatRoom(
$this->getResponseFormat(),
[],
$room,
$this->participant,
));
}
/**
* @return list<TalkRoom>
*/
protected function formatMultipleRooms(array $rooms): array {
$return = [];
foreach ($rooms as $room) {
try {
$return[] = $this->roomFormatter->formatRoom(
$this->getResponseFormat(),
[],
$room,
$this->participantService->getParticipant($room, $this->userId),
[],
false,
true
);
} catch (ParticipantNotFoundException $e) {
}
}
return $return;
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Exceptions\InvalidRoomException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Middleware\Attribute\RequireParticipant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\CalendarIntegrationService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkDashboardEvent from ResponseDefinitions
*/
class CalendarIntegrationController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
protected IUserSession $userSession,
protected LoggerInterface $logger,
protected CalendarIntegrationService $service,
) {
parent::__construct($appName, $request);
}
/**
* Get up to 10 rooms that have events in the next 7 days
* sorted by their start timestamp ascending
*
* Required capability: `dashboard-event-rooms`
*
* @return DataResponse<Http::STATUS_OK, list<TalkDashboardEvent>, array{}>
*
* 200: A list of dashboard entries or an empty array
*/
#[NoAdminRequired]
public function getDashboardEvents(): DataResponse {
$userId = $this->userSession->getUser()?->getUID();
$entries = $this->service->getDashboardEvents($userId);
return new DataResponse($entries);
}
/**
* Get up to 3 events in the next 7 days
* sorted by their start timestamp ascending
*
* Required capability: `mutual-calendar-events`
*
* @return DataResponse<Http::STATUS_OK, list<TalkDashboardEvent>, array{}>|DataResponse<Http::STATUS_FORBIDDEN, null, array{}>
*
* 200: A list of dashboard entries or an empty array
* 403: Room is not a 1 to 1 room, room is invalid, or user is not participant
*/
#[NoAdminRequired]
#[RequireParticipant]
public function getMutualEvents(): DataResponse {
$userId = $this->userSession->getUser()?->getUID();
try {
$entries = $this->service->getMutualEvents($userId, $this->room);
} catch (InvalidRoomException|ParticipantNotFoundException) {
return new DataResponse(null, Http::STATUS_FORBIDDEN);
}
return new DataResponse($entries);
}
}
+595
View File
@@ -0,0 +1,595 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Config;
use OCA\Talk\Exceptions\DialOutFailedException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Federation\Authenticator;
use OCA\Talk\Manager;
use OCA\Talk\Middleware\Attribute\FederationSupported;
use OCA\Talk\Middleware\Attribute\RequireCallEnabled;
use OCA\Talk\Middleware\Attribute\RequireFederatedParticipant;
use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby;
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
use OCA\Talk\Middleware\Attribute\RequireParticipant;
use OCA\Talk\Middleware\Attribute\RequirePermission;
use OCA\Talk\Middleware\Attribute\RequireReadWriteConversation;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\PhoneNumberMapper;
use OCA\Talk\Model\Session;
use OCA\Talk\Participant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\ConsentService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RecordingService;
use OCA\Talk\Service\RoomService;
use OCA\Talk\Service\SIPDialOutService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Services\IAppConfig;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IConfig;
use OCP\IRequest;
use OCP\IUserManager;
/**
* @psalm-import-type TalkCallPeer from ResponseDefinitions
*/
class CallController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
protected Manager $manager,
private ConsentService $consentService,
private ParticipantService $participantService,
private PhoneNumberMapper $phoneNumberMapper,
private RoomService $roomService,
private IUserManager $userManager,
private ITimeFactory $timeFactory,
private IConfig $serverConfig,
private IAppConfig $appConfig,
private Config $talkConfig,
protected Authenticator $federationAuthenticator,
private SIPDialOutService $dialOutService,
) {
parent::__construct($appName, $request);
}
/**
* Get the peers for a call
*
* @return DataResponse<Http::STATUS_OK, list<TalkCallPeer>, array{}>
*
* 200: List of peers in the call returned
*/
#[FederationSupported]
#[PublicPage]
#[RequireCallEnabled]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequireReadWriteConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function getPeersForCall(): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController::class);
return $proxy->getPeersForCall($this->room, $this->participant);
}
$timeout = $this->timeFactory->getTime() - Session::SESSION_TIMEOUT;
$result = [];
$participants = $this->participantService->getParticipantsInCall($this->room, $timeout);
foreach ($participants as $participant) {
$displayName = $participant->getAttendee()->getActorId();
if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
if ($participant->getAttendee()->getDisplayName()) {
$displayName = $participant->getAttendee()->getDisplayName();
} else {
$userDisplayName = $this->userManager->getDisplayName($participant->getAttendee()->getActorId());
if ($userDisplayName !== null) {
$displayName = $userDisplayName;
}
}
} else {
$displayName = $participant->getAttendee()->getDisplayName();
}
$result[] = [
'actorType' => $participant->getAttendee()->getActorType(),
'actorId' => $participant->getAttendee()->getActorId(),
'displayName' => $displayName,
'token' => $this->room->getToken(),
'lastPing' => $participant->getSession()->getLastPing(),
'sessionId' => $participant->getSession()->getSessionId(),
];
}
return new DataResponse($result);
}
/**
* Download the list of current call participants
*
* Required capability: `download-call-participants`
*
* @param 'csv' $format Download format
* @return DataDownloadResponse<Http::STATUS_OK, 'text/csv', array{}>|Response<Http::STATUS_BAD_REQUEST, array{}>
*
* 200: List of participants in the call downloaded in the requested format
* 400: No call in progress
*/
#[PublicPage]
#[RequireModeratorParticipant]
#[NoCSRFRequired]
public function downloadParticipantsForCall(string $format = 'csv'): DataDownloadResponse|Response {
$callStart = $this->room->getActiveSince()?->getTimestamp() ?? 0;
if ($callStart === 0) {
return new Response(Http::STATUS_BAD_REQUEST);
}
$participants = $this->participantService->getParticipantsJoinedCurrentCall($this->room, $callStart);
if (empty($participants)) {
return new Response(Http::STATUS_BAD_REQUEST);
}
if ($format !== 'csv') {
// Unsupported format
return new Response(Http::STATUS_BAD_REQUEST);
}
$output = fopen('php://memory', 'w');
fputcsv($output, [
'name',
'email',
'type',
'identifier',
], escape: '');
foreach ($participants as $participant) {
$email = '';
if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_EMAILS) {
$email = $participant->getAttendee()->getInvitedCloudId();
} elseif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
$email = $this->userManager->get($participant->getAttendee()->getActorId())?->getEMailAddress() ?? '';
}
fputcsv($output, array_map([$this, 'escapeFormulae'], [
$participant->getAttendee()->getDisplayName(),
$email,
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId(),
]), escape: '');
}
fseek($output, 0);
// Clean the room name
$cleanedRoomName = preg_replace('/[\/\\\\:*?"<>|\- ]+/', '-', $this->room->getName());
// Limit to a reasonable length
$cleanedRoomName = substr($cleanedRoomName, 0, 100);
$timezone = 'UTC';
if ($this->participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
$timezone = $this->serverConfig->getUserValue($this->participant->getAttendee()->getActorId(), 'core', 'timezone', 'UTC');
}
try {
$dateTimeZone = new \DateTimeZone($timezone);
} catch (\Throwable) {
$dateTimeZone = null;
}
$date = $this->timeFactory->getDateTime('now', $dateTimeZone)->format('Y-m-d');
$fileName = $cleanedRoomName . ' ' . $date . '.csv';
return new DataDownloadResponse(stream_get_contents($output), $fileName, 'text/csv');
}
protected function escapeFormulae(string $value): string {
if (preg_match('/^[=+\-@\t\r]/', $value)) {
return "'" . $value;
}
return $value;
}
/**
* Join a call
*
* @param int<0, 15>|null $flags In-Call flags
* @psalm-param int-mask-of<Participant::FLAG_*>|null $flags
* @param bool $silent Join the call silently
* @param bool $recordingConsent When the user ticked a checkbox and agreed with being recorded
* (Only needed when the `config => call => recording-consent` capability is set to {@see RecordingService::CONSENT_REQUIRED_YES}
* or the capability is {@see RecordingService::CONSENT_REQUIRED_OPTIONAL}
* and the conversation `recordingConsent` value is {@see RecordingService::CONSENT_REQUIRED_YES} )
* @param list<string> $silentFor Send no call notification for previous participants
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Call joined successfully
* 400: No recording consent was given
* 404: Call not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireCallEnabled]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequireReadWriteConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function joinCall(?int $flags = null, bool $silent = false, bool $recordingConsent = false, array $silentFor = []): DataResponse {
try {
$this->validateRecordingConsent($recordingConsent);
} catch (\InvalidArgumentException) {
return new DataResponse(['error' => 'consent'], Http::STATUS_BAD_REQUEST);
}
$this->participantService->ensureOneToOneRoomIsFilled($this->room);
$session = $this->participant->getSession();
if (!$session instanceof Session) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
if ($flags === null) {
// Default flags: user is in room with audio/video.
$flags = Participant::FLAG_IN_CALL | Participant::FLAG_WITH_AUDIO | Participant::FLAG_WITH_VIDEO;
}
$lastJoinedCall = $this->timeFactory->getDateTime();
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController::class);
$response = $proxy->joinFederatedCall($this->room, $this->participant, $flags, $silent, $recordingConsent);
if ($response->getStatus() === Http::STATUS_OK) {
$this->participantService->changeInCall($this->room, $this->participant, $flags, silent: $silent, lastJoinedCall: $lastJoinedCall->getTimestamp());
}
return $response;
}
try {
$this->participantService->changeInCall($this->room, $this->participant, $flags, silent: $silent, lastJoinedCall: $lastJoinedCall->getTimestamp());
$this->roomService->setActiveSince($this->room, $this->participant, $lastJoinedCall, $flags, silent: $silent, silentFor: $silentFor);
} catch (\InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Validates and stores recording consent.
*
* @throws \InvalidArgumentException if recording consent is required but
* not given
*/
protected function validateRecordingConsent(bool $recordingConsent): void {
if (!$recordingConsent && $this->talkConfig->recordingConsentRequired() !== RecordingService::CONSENT_REQUIRED_NO) {
if ($this->talkConfig->recordingConsentRequired() === RecordingService::CONSENT_REQUIRED_YES) {
throw new \InvalidArgumentException();
}
if ($this->talkConfig->recordingConsentRequired() === RecordingService::CONSENT_REQUIRED_OPTIONAL
&& $this->room->getRecordingConsent() === RecordingService::CONSENT_REQUIRED_YES) {
throw new \InvalidArgumentException();
}
} elseif ($recordingConsent && $this->talkConfig->recordingConsentRequired() !== RecordingService::CONSENT_REQUIRED_NO) {
$attendee = $this->participant->getAttendee();
$this->consentService->storeConsent($this->room, $attendee->getActorType(), $attendee->getActorId());
}
}
/**
* Join call on the host server using the session id of the federated user
*
* @param string $sessionId Federated session id to join with
* @param int<0, 15>|null $flags In-Call flags
* @psalm-param int-mask-of<Participant::FLAG_*>|null $flags
* @param bool $silent Join the call silently
* @param bool $recordingConsent Agreement to be recorded
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Call joined successfully
* 400: Conditions to join not met
* 404: Call not found
*/
#[PublicPage]
#[RequireCallEnabled]
#[RequireModeratorOrNoLobby]
#[RequireFederatedParticipant]
#[RequireReadWriteConversation]
#[BruteForceProtection(action: 'talkFederationAccess')]
#[BruteForceProtection(action: 'talkRoomToken')]
public function joinFederatedCall(string $sessionId, ?int $flags = null, bool $silent = false, bool $recordingConsent = false): DataResponse {
if (!$this->federationAuthenticator->isFederationRequest()) {
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
$response->throttle(['token' => $this->room->getToken(), 'action' => 'talkRoomToken']);
return $response;
}
try {
$this->validateRecordingConsent($recordingConsent);
} catch (\InvalidArgumentException) {
return new DataResponse(['error' => 'consent'], Http::STATUS_BAD_REQUEST);
}
try {
$this->participantService->changeInCall($this->room, $this->participant, $flags, false, $silent);
$this->roomService->setActiveSince($this->room, $this->participant, $this->timeFactory->getDateTime(), $flags, silent: $silent);
} catch (\InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Ring an attendee
*
* @param int $attendeeId ID of the attendee to ring
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Attendee rang successfully
* 400: Ringing attendee is not possible
* 404: Attendee could not be found
*/
#[FederationSupported]
#[PublicPage]
#[RequireCallEnabled]
#[RequireParticipant]
#[RequirePermission(permission: RequirePermission::START_CALL)]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function ringAttendee(int $attendeeId): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController::class);
return $proxy->ringAttendee($this->room, $this->participant, $attendeeId);
}
if ($this->room->getCallFlag() === Participant::FLAG_DISCONNECTED) {
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
}
if ($this->participant->getSession() && $this->participant->getSession()->getInCall() === Participant::FLAG_DISCONNECTED) {
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
}
try {
$this->participantService->sendCallNotificationForAttendee($this->room, $this->participant, $attendeeId);
} catch (\InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
} catch (DoesNotExistException) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
return new DataResponse(null);
}
/**
* Call a SIP dial-out attendee
*
* @param int $attendeeId ID of the attendee to call
* @return DataResponse<Http::STATUS_CREATED|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_NOT_IMPLEMENTED, array{error: string, message?: string}, array{}>
*
* 201: Dial-out initiated successfully
* 400: SIP dial-out not possible
* 404: Participant could not be found or is a wrong type
* 501: SIP dial-out is not configured on the server
*/
#[PublicPage]
#[RequireCallEnabled]
#[RequireParticipant]
#[RequirePermission(permission: RequirePermission::START_CALL)]
public function sipDialOut(int $attendeeId): DataResponse {
if ($this->room->getCallFlag() === Participant::FLAG_DISCONNECTED) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
if ($this->participant->getSession() && $this->participant->getSession()->getInCall() === Participant::FLAG_DISCONNECTED) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
$callerNumber = true;
if ($this->appConfig->getAppValueBool('sip_bridge_dialout_anonymous')) {
$callerNumber = false;
} elseif ($this->appConfig->getAppValueString('sip_bridge_dialout_number') !== '') {
$callerNumber = $this->appConfig->getAppValueString('sip_bridge_dialout_number');
}
// No elseif, so we have the fallback to sip_bridge_dialout_number when the caller is no user or doesn't have a number
if ($callerNumber !== false && $this->appConfig->getAppValueString('sip_bridge_dialout_prefix', '+') !== '') {
$attendee = $this->participant->getAttendee();
if ($attendee->getActorType() === Attendee::ACTOR_USERS) {
$numbers = $this->phoneNumberMapper->findByUser($attendee->getActorId());
if (!empty($numbers)) {
$number = array_shift($numbers);
$callerNumber = $this->appConfig->getAppValueString('sip_bridge_dialout_prefix', '+');
$callerNumber .= $number->getPhoneNumber();
}
}
}
try {
$this->participantService->startDialOutRequest($this->dialOutService, $this->room, $attendeeId, $callerNumber);
} catch (ParticipantNotFoundException) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
} catch (DialOutFailedException $e) {
return new DataResponse([
'error' => $e->getMessage(),
'message' => $e->getReadableError(),
], Http::STATUS_NOT_IMPLEMENTED);
} catch (\InvalidArgumentException $e) {
return new DataResponse(['error' => $e], Http::STATUS_NOT_IMPLEMENTED);
}
return new DataResponse(null, Http::STATUS_CREATED);
}
/**
* Update the in-call flags
*
* @param int<0, 15> $flags New flags
* @psalm-param int-mask-of<Participant::FLAG_*> $flags New flags
* @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: In-call flags updated successfully
* 400: Updating in-call flags is not possible
* 404: Call session not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function updateCallFlags(int $flags): DataResponse {
$session = $this->participant->getSession();
if (!$session instanceof Session) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController::class);
$response = $proxy->updateFederatedCallFlags($this->room, $this->participant, $flags);
if ($response->getStatus() === Http::STATUS_OK) {
$this->participantService->updateCallFlags($this->room, $this->participant, $flags);
}
return $response;
}
try {
$this->participantService->updateCallFlags($this->room, $this->participant, $flags);
} catch (\Exception $exception) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Update the in-call flags on the host server using the session id of the
* federated user
*
* @param string $sessionId Federated session id to update the flags with
* @param int<0, 15> $flags New flags
* @psalm-param int-mask-of<Participant::FLAG_*> $flags New flags
* @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: In-call flags updated successfully
* 400: Updating in-call flags is not possible
* 404: Call session not found
*/
#[PublicPage]
#[RequireFederatedParticipant]
#[BruteForceProtection(action: 'talkFederationAccess')]
#[BruteForceProtection(action: 'talkRoomToken')]
public function updateFederatedCallFlags(string $sessionId, int $flags): DataResponse {
if (!$this->federationAuthenticator->isFederationRequest()) {
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
$response->throttle(['token' => $this->room->getToken(), 'action' => 'talkRoomToken']);
return $response;
}
try {
$this->participantService->updateCallFlags($this->room, $this->participant, $flags);
} catch (\Exception) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Leave a call
*
* @param bool $all whether to also terminate the call for all participants
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Call left successfully
* 404: Call session not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function leaveCall(bool $all = false): DataResponse {
$session = $this->participant->getSession();
if (!$session instanceof Session) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\CallController::class);
$response = $proxy->leaveFederatedCall($this->room, $this->participant);
if ($response->getStatus() === Http::STATUS_OK) {
$this->participantService->changeInCall($this->room, $this->participant, Participant::FLAG_DISCONNECTED);
}
return $response;
}
if ($all && $this->participant->hasModeratorPermissions()) {
$result = $this->roomService->resetActiveSinceInDatabaseOnly($this->room);
if (!$result) {
// Someone else won the race condition, make sure this user disconnects directly and then return
$this->participantService->changeInCall($this->room, $this->participant, Participant::FLAG_DISCONNECTED);
return new DataResponse(null);
}
$this->participantService->endCallForEveryone($this->room, $this->participant);
$this->roomService->resetActiveSinceInModelOnly($this->room);
} else {
$this->participantService->changeInCall($this->room, $this->participant, Participant::FLAG_DISCONNECTED);
if (!$this->participantService->hasActiveSessionsInCall($this->room)) {
$this->roomService->resetActiveSince($this->room, $this->participant);
}
}
return new DataResponse(null);
}
/**
* Leave a call on the host server using the session id of the federated
* user
*
* @param string $sessionId Federated session id to leave with
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Call left successfully
* 404: Call session not found
*/
#[PublicPage]
#[RequireFederatedParticipant]
#[BruteForceProtection(action: 'talkFederationAccess')]
#[BruteForceProtection(action: 'talkRoomToken')]
public function leaveFederatedCall(string $sessionId): DataResponse {
if (!$this->federationAuthenticator->isFederationRequest()) {
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
$response->throttle(['token' => $this->room->getToken(), 'action' => 'talkRoomToken']);
return $response;
}
$this->participantService->changeInCall($this->room, $this->participant, Participant::FLAG_DISCONNECTED);
if (!$this->participantService->hasActiveSessionsInCall($this->room)) {
$this->roomService->resetActiveSince($this->room, $this->participant);
}
return new DataResponse(null);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Service\ParticipantService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
class CallNotificationController extends OCSController {
public const CASE_STILL_CURRENT = 0;
public const CASE_ROOM_NOT_FOUND = 1;
public const CASE_MISSED_CALL = 2;
public const CASE_PARTICIPANT_JOINED = 3;
public function __construct(
string $appName,
IRequest $request,
protected ParticipantService $participantService,
protected ?string $userId,
) {
parent::__construct($appName, $request);
}
/**
* Check the expected state of a call notification
*
* Required capability: `call-notification-state-api`
*
* @param string $token Conversation token to check
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Notification should be kept alive
* 201: Dismiss call notification and show "Missed call"-notification instead
* 403: Not logged in, try again with auth data sent
* 404: Dismiss call notification
*/
#[NoAdminRequired]
#[OpenAPI(tags: ['call'])]
public function state(string $token): DataResponse {
if ($this->userId === null) {
return new DataResponse(null, Http::STATUS_FORBIDDEN);
}
$status = match($this->participantService->checkIfUserIsMissingCall($token, $this->userId)) {
self::CASE_PARTICIPANT_JOINED,
self::CASE_ROOM_NOT_FOUND => Http::STATUS_NOT_FOUND,
self::CASE_MISSED_CALL => Http::STATUS_CREATED,
self::CASE_STILL_CURRENT => Http::STATUS_OK,
};
return new DataResponse(null, $status);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Service\CertificateService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IL10N;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
class CertificateController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
protected CertificateService $certificateService,
protected IL10N $l,
protected LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
/**
* Get the certificate expiration for a host
* @param string $host Host to check
* @return DataResponse<Http::STATUS_OK, array{expiration_in_days: ?int}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
*
* 200: Certificate expiration returned
* 400: Getting certificate expiration is not possible
*/
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])]
public function getCertificateExpiration(string $host): DataResponse {
try {
$expirationInDays = $this->certificateService->getCertificateExpirationInDays($host);
return new DataResponse([
'expiration_in_days' => $expirationInDays,
]);
} catch (\Exception $e) {
$this->logger->error('Failed get certificate expiration', [
'exception' => $e,
]);
return new DataResponse(['message' => $this->l->t('An error occurred. Please contact your administrator.')], Http::STATUS_BAD_REQUEST);
}
}
}
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\AppInfo\Application;
use OCA\Talk\Exceptions\CannotReachRemoteException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Exceptions\UnauthorizedException;
use OCA\Talk\Federation\FederationManager;
use OCA\Talk\Manager;
use OCA\Talk\Model\Invitation;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\RoomFormatter;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
/**
* @psalm-import-type TalkFederationInvite from ResponseDefinitions
* @psalm-import-type TalkRoom from ResponseDefinitions
*/
class FederationController extends OCSController {
public function __construct(
IRequest $request,
private FederationManager $federationManager,
private Manager $talkManager,
private IUserSession $userSession,
private RoomFormatter $roomFormatter,
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* Following the logic of {@see Dispatcher::executeController}
* @return string Either 'json' or 'xml'
* @psalm-return 'json'|'xml'
*/
public function getResponseFormat(): string {
// get format from the url format or request format parameter
$format = $this->request->getParam('format');
// if none is given try the first Accept header
if ($format === null) {
$headers = $this->request->getHeader('accept');
/**
* Default value of
* @see OCSController::buildResponse()
*/
$format = $this->getResponderByHTTPHeader($headers, 'xml');
}
return $format;
}
/**
* Accept a federation invites
*
* 🚧 Draft: Still work in progress
*
* @param int $id ID of the share
* @psalm-param non-negative-int $id
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_GONE, array{error: string}, array{}>
*
* 200: Invite accepted successfully
* 400: Invite can not be accepted (maybe it was accepted already)
* 404: Invite can not be found
* 410: Remote server could not be reached to notify about the acceptance
*/
#[NoAdminRequired]
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
public function acceptShare(int $id): DataResponse {
$user = $this->userSession->getUser();
if (!$user instanceof IUser) {
return new DataResponse(['error' => 'user'], Http::STATUS_NOT_FOUND);
}
try {
$participant = $this->federationManager->acceptRemoteRoomShare($user, $id);
} catch (CannotReachRemoteException) {
return new DataResponse(['error' => 'remote'], Http::STATUS_GONE);
} catch (UnauthorizedException $e) {
return new DataResponse(['error' => 'user'], Http::STATUS_NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], $e->getMessage() === 'invitation' ? Http::STATUS_NOT_FOUND : Http::STATUS_BAD_REQUEST);
}
return new DataResponse($this->roomFormatter->formatRoom(
$this->getResponseFormat(),
[],
$participant->getRoom(),
$participant,
));
}
/**
* Decline a federation invites
*
* 🚧 Draft: Still work in progress
*
* @param int $id ID of the share
* @psalm-param non-negative-int $id
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Invite declined successfully
* 400: Invite was already accepted, use the "Remove the current user from a room" endpoint instead
* 404: Invite can not be found
*/
#[NoAdminRequired]
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
public function rejectShare(int $id): DataResponse {
$user = $this->userSession->getUser();
if (!$user instanceof IUser) {
return new DataResponse(['error' => 'user'], Http::STATUS_NOT_FOUND);
}
try {
$this->federationManager->rejectRemoteRoomShare($user, $id);
} catch (UnauthorizedException $e) {
return new DataResponse(['error' => 'user'], Http::STATUS_NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], $e->getMessage() === 'invitation' ? Http::STATUS_NOT_FOUND : Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Get a list of federation invites
*
* 🚧 Draft: Still work in progress
*
* @return DataResponse<Http::STATUS_OK, list<TalkFederationInvite>, array{}>
*
* 200: Get list of received federation invites successfully
*/
#[NoAdminRequired]
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
public function getShares(): DataResponse {
$user = $this->userSession->getUser();
if (!$user instanceof IUser) {
throw new UnauthorizedException();
}
$invitations = $this->federationManager->getRemoteRoomShares($user);
/** @var list<TalkFederationInvite> $data */
$data = array_values(array_filter(array_map([$this, 'enrichInvite'], $invitations)));
return new DataResponse($data);
}
/**
* @param Invitation $invitation
* @return TalkFederationInvite|null
*/
protected function enrichInvite(Invitation $invitation): ?array {
try {
$room = $this->talkManager->getRoomById($invitation->getLocalRoomId());
} catch (RoomNotFoundException) {
return null;
}
$federationInvite = $invitation->jsonSerialize();
$federationInvite['roomName'] = $room->getName();
$federationInvite['localToken'] = $room->getToken();
return $federationInvite;
}
}
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Files\Util;
use OCA\Talk\Manager;
use OCA\Talk\Room;
use OCA\Talk\Service\RoomService;
use OCA\Talk\TalkSession;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\UseSession;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSException;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\Files\FileInfo;
use OCP\Files\NotFoundException;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as IShareManager;
class FilesIntegrationController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private Manager $manager,
private RoomService $roomService,
private IShareManager $shareManager,
private ISession $session,
private IUserSession $userSession,
private TalkSession $talkSession,
private Util $util,
private IConfig $config,
private IL10N $l,
) {
parent::__construct($appName, $request);
}
/**
* Get the token of the room associated to the given file id
*
* This is the counterpart of self::getRoomByShareToken() for file ids
* instead of share tokens, although both return the same room token if the
* given file id and share token refer to the same file.
*
* If there is no room associated to the given file id a new room is
* created; the new room is a public room associated with a "file" object
* with the given file id. Unlike normal rooms in which the owner is the
* user that created the room these are special rooms without owner
* (although self joined users with direct access to the file become
* persistent participants automatically when they join until they
* explicitly leave or no longer have access to the file).
*
* In any case, to create or even get the token of the room, the file must
* be shared and the user must be the owner of a public share of the file
* (like a link share, for example) or have direct access to that file; an
* error is returned otherwise. A user has direct access to a file if they
* have access to it (or to an ancestor) through a user, group, circle or
* room share (but not through a link share, for example), or if they are the
* owner of such a file.
*
* @param string $fileId ID of the file
* @return DataResponse<Http::STATUS_OK, array{token: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, null, array{}>
* @throws OCSNotFoundException Share not found
*
* 200: Room token returned
* 400: Rooms not allowed for shares
*/
#[NoAdminRequired]
public function getRoomByFileId(string $fileId): DataResponse {
if ($this->config->getAppValue('spreed', 'conversations_files', '1') !== '1') {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
$currentUser = $this->userSession->getUser();
if (!$currentUser instanceof IUser) {
throw new OCSException($this->l->t('File is not shared, or shared but not with the user'), Http::STATUS_UNAUTHORIZED);
}
$node = $this->util->getAnyNodeOfFileAccessibleByUser($fileId, $currentUser->getUID());
if ($node === null) {
throw new OCSNotFoundException($this->l->t('File is not shared, or shared but not with the user'));
}
$users = $this->util->getUsersWithAccessFile($fileId);
if (count($users) <= 1 && !$this->util->canGuestsAccessFile($fileId)) {
throw new OCSNotFoundException($this->l->t('File is not shared, or shared but not with the user'));
}
try {
$room = $this->manager->getRoomByObject('file', $fileId);
} catch (RoomNotFoundException $e) {
$name = $node->getName();
$name = $this->roomService->prepareConversationName($name);
$room = $this->roomService->createConversation(
Room::TYPE_PUBLIC,
$name,
null,
Room::OBJECT_TYPE_FILE,
$fileId,
);
}
return new DataResponse([
'token' => $room->getToken()
]);
}
/**
* Returns the token of the room associated to the file of the given
* share token
*
* This is the counterpart of self::getRoomByFileId() for share tokens
* instead of file ids, although both return the same room token if the
* given file id and share token refer to the same file.
*
* If there is no room associated to the file id of the given share token a
* new room is created; the new room is a public room associated with a
* "file" object with the file id of the given share token. Unlike normal
* rooms in which the owner is the user that created the room these are
* special rooms without owner (although self joined users with direct
* access to the file become persistent participants automatically when they
* join until they explicitly leave or no longer have access to the file).
*
* In any case, to create or even get the token of the room, the file must
* be publicly shared (like a link share, for example); an error is returned
* otherwise.
*
* Besides the token of the room this also returns the current user ID and
* display name, if any; this is needed by the Talk sidebar to know the
* actual current user, as the public share page uses the incognito mode and
* thus logged-in users as seen as guests.
*
* @param string $shareToken Token of the file share
* @return DataResponse<Http::STATUS_OK, array{token: string, userId: string, userDisplayName: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Room token and user info returned
* 400: Rooms not allowed for shares
* 404: Share not found
*/
#[PublicPage]
#[UseSession]
#[BruteForceProtection(action: 'shareinfo')]
public function getRoomByShareToken(string $shareToken): DataResponse {
if ($this->config->getAppValue('spreed', 'conversations_files', '1') !== '1'
|| $this->config->getAppValue('spreed', 'conversations_files_public_shares', '1') !== '1') {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
try {
$share = $this->shareManager->getShareByToken($shareToken);
if ($share->getPassword() !== null) {
$shareId = $this->session->get('public_link_authenticated');
if ($share->getId() !== $shareId) {
throw new ShareNotFound();
}
}
} catch (ShareNotFound $e) {
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
$response->throttle(['token' => $shareToken, 'action' => 'shareinfo']);
return $response;
}
try {
if ($share->getNodeType() !== FileInfo::TYPE_FILE) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
$fileId = (string)$share->getNodeId();
try {
$room = $this->manager->getRoomByObject('file', $fileId);
} catch (RoomNotFoundException) {
$name = $share->getNode()->getName();
$name = $this->roomService->prepareConversationName($name);
$room = $this->roomService->createConversation(
Room::TYPE_PUBLIC,
$name,
null,
Room::OBJECT_TYPE_FILE,
$fileId,
);
}
} catch (NotFoundException) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
$this->talkSession->setFileShareTokenForRoom($room->getToken(), $shareToken);
$currentUser = $this->userSession->getUser();
$currentUserId = $currentUser instanceof IUser ? $currentUser->getUID() : '';
$currentUserDisplayName = $currentUser instanceof IUser ? $currentUser->getDisplayName() : '';
return new DataResponse([
'token' => $room->getToken(),
'userId' => $currentUserId,
'userDisplayName' => $currentUserDisplayName,
]);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\GuestManager;
use OCA\Talk\Middleware\Attribute\RequireParticipant;
use OCA\Talk\Participant;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
class GuestController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
private GuestManager $guestManager,
) {
parent::__construct($appName, $request);
}
/**
* Set the display name as a guest
*
* @param string $displayName New display name
* @return DataResponse<Http::STATUS_OK|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Display name updated successfully
* 403: Not a guest
* 404: Not a participant
*/
#[PublicPage]
#[RequireParticipant]
public function setDisplayName(string $displayName): DataResponse {
$participant = $this->getParticipant();
if (!$participant instanceof Participant) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
if (!$participant->isGuest()) {
return new DataResponse(null, Http::STATUS_FORBIDDEN);
}
$this->guestManager->updateName($this->getRoom(), $participant, $displayName);
return new DataResponse(null);
}
}
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\DataObjects\AccountId;
use OCA\Talk\DataObjects\RegisterAccountData;
use OCA\Talk\Exceptions\HostedSignalingServerAPIException;
use OCA\Talk\Exceptions\HostedSignalingServerInputException;
use OCA\Talk\Service\HostedSignalingServerService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
class HostedSignalingServerController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
protected IClientService $clientService,
protected IL10N $l10n,
protected IConfig $config,
protected LoggerInterface $logger,
private HostedSignalingServerService $hostedSignalingServerService,
) {
parent::__construct($appName, $request);
}
/**
* Get the authentication credentials
*
* @return DataResponse<Http::STATUS_OK, array{nonce: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_PRECONDITION_FAILED, null, array{}>
*
* 200: Authentication credentials returned
* 403: Provided nonce is wrong
* 412: Getting authentication credentials is not possible
*/
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
#[PublicPage]
#[BruteForceProtection(action: 'hosted-hpb-nonce')]
#[RequestHeader(name: 'x-account-service-nonce', description: 'Random string provided to the hostedsignalingserver entity, so it can verify that it was requested')]
public function auth(): DataResponse {
$sentNonce = $this->request->getHeader('x-account-service-nonce');
if ($sentNonce === '') {
$response = new DataResponse(null, Http::STATUS_FORBIDDEN);
$response->throttle();
return $response;
}
$storedNonce = $this->config->getAppValue('spreed', 'hosted-signaling-server-nonce', '');
if ($storedNonce === '') {
return new DataResponse(null, Http::STATUS_PRECONDITION_FAILED);
}
if (!hash_equals($storedNonce, $sentNonce)) {
$response = new DataResponse(null, Http::STATUS_FORBIDDEN);
$response->throttle();
return $response;
}
// reset nonce after one request
$this->config->deleteAppValue('spreed', 'hosted-signaling-server-nonce');
return new DataResponse([
'nonce' => $storedNonce,
]);
}
/**
* Request a trial account
*
* @param string $url Server URL
* @param string $name Display name of the user
* @param string $email Email of the user
* @param string $language Language of the user
* @param string $country Country of the user
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 200: Trial requested successfully
* 400: Requesting trial is not possible
*/
public function requestTrial(string $url, string $name, string $email, string $language, string $country): DataResponse {
try {
$registerAccountData = new RegisterAccountData(
$url,
$name,
$email,
$language,
$country
);
$accountId = $this->hostedSignalingServerService->registerAccount($registerAccountData);
$accountInfo = $this->hostedSignalingServerService->fetchAccountInfo($accountId);
$this->config->setAppValue('spreed', 'hosted-signaling-server-account', json_encode($accountInfo));
} catch (HostedSignalingServerAPIException $e) { // API or connection issues
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
} catch (HostedSignalingServerInputException $e) { // user solvable issues
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse($accountInfo);
}
/**
* Delete the account
*
* @return DataResponse<Http::STATUS_NO_CONTENT, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 204: Account deleted successfully
* 400: Deleting account is not possible
*/
public function deleteAccount(): DataResponse {
$accountId = $this->config->getAppValue('spreed', 'hosted-signaling-server-account-id');
if ($accountId === null) {
return new DataResponse(['message' => $this->l10n->t('No account available to delete.')], Http::STATUS_BAD_REQUEST);
}
try {
$this->hostedSignalingServerService->deleteAccount(new AccountId($accountId));
} catch (HostedSignalingServerAPIException $e) {
if ($e->getCode() === Http::STATUS_NOT_FOUND) {
// Account was deleted, so remove the information locally
} else {
// API or connection issues - do nothing and just try again later
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
$this->config->deleteAppValue('spreed', 'hosted-signaling-server-account');
$this->config->deleteAppValue('spreed', 'hosted-signaling-server-account-id');
// remove signaling servers if account is not active anymore
$this->config->deleteAppValue('spreed', 'signaling_mode');
$this->config->deleteAppValue('spreed', 'signaling_servers');
$this->logger->info('Deleted hosted signaling server account with ID ' . $accountId);
return new DataResponse(null, Http::STATUS_NO_CONTENT);
}
}
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Exceptions\LiveTranscriptionAppNotEnabledException;
use OCA\Talk\Middleware\Attribute\RequireCallEnabled;
use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby;
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
use OCA\Talk\Middleware\Attribute\RequireParticipant;
use OCA\Talk\Participant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\LiveTranscriptionService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
/**
* @psalm-import-type TalkLiveTranscriptionLanguage from ResponseDefinitions
*/
class LiveTranscriptionController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
private LiveTranscriptionService $liveTranscriptionService,
) {
parent::__construct($appName, $request);
}
/**
* Enable the live transcription
*
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'app'|'in-call'}, array{}>
*
* 200: Live transcription enabled successfully
* 400: The external app "live_transcription" is not available
* 400: The participant is not in the call
*/
#[PublicPage]
#[RequireCallEnabled]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/live-transcription/{token}', requirements: [
'apiVersion' => '(v1)',
'token' => '[a-z0-9]{4,30}',
])]
public function enable(): DataResponse {
if ($this->room->getCallFlag() === Participant::FLAG_DISCONNECTED) {
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
}
if ($this->participant->getSession() && $this->participant->getSession()->getInCall() === Participant::FLAG_DISCONNECTED) {
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
}
try {
$this->liveTranscriptionService->enable($this->room, $this->participant);
} catch (LiveTranscriptionAppNotEnabledException $e) {
return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Disable the live transcription
*
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'app'|'in-call'}, array{}>
*
* 200: Live transcription stopped successfully
* 400: The external app "live_transcription" is not available
* 400: The participant is not in the call
*/
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/live-transcription/{token}', requirements: [
'apiVersion' => '(v1)',
'token' => '[a-z0-9]{4,30}',
])]
public function disable(): DataResponse {
if ($this->room->getCallFlag() === Participant::FLAG_DISCONNECTED) {
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
}
if ($this->participant->getSession() && $this->participant->getSession()->getInCall() === Participant::FLAG_DISCONNECTED) {
return new DataResponse(['error' => 'in-call'], Http::STATUS_BAD_REQUEST);
}
try {
$this->liveTranscriptionService->disable($this->room, $this->participant);
} catch (LiveTranscriptionAppNotEnabledException $e) {
return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Get available languages for live transcriptions
*
* @return DataResponse<Http::STATUS_OK, array<string, TalkLiveTranscriptionLanguage>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'app'}, array{}>
*
* 200: Available languages got successfully
* 400: The external app "live_transcription" is not available
*/
#[PublicPage]
#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/live-transcription/languages', requirements: [
'apiVersion' => '(v1)',
])]
public function getAvailableLanguages(): DataResponse {
try {
$languages = $this->liveTranscriptionService->getAvailableLanguages();
} catch (LiveTranscriptionAppNotEnabledException $e) {
return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST);
}
return new DataResponse($languages);
}
/**
* Set language for live transcriptions
*
* @param string $languageId the ID of the language to set
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN, array{error: 'app'}, array{}>
*
* 200: Language set successfully
* 400: The external app "live_transcription" is not available
* 403: Participant is not a moderator
*/
#[PublicPage]
#[RequireModeratorParticipant]
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/live-transcription/{token}/language', requirements: [
'apiVersion' => '(v1)',
'token' => '[a-z0-9]{4,30}',
])]
public function setLanguage(string $languageId): DataResponse {
try {
$this->liveTranscriptionService->setLanguage($this->room, $languageId);
} catch (LiveTranscriptionAppNotEnabledException $e) {
return new DataResponse(['error' => 'app'], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Exceptions\ImpossibleToKillException;
use OCA\Talk\Manager;
use OCA\Talk\MatterbridgeManager;
use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
use OCA\Talk\ResponseDefinitions;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\IRequest;
/**
* @psalm-import-type TalkMatterbridge from ResponseDefinitions
* @psalm-import-type TalkMatterbridgeConfigFields from ResponseDefinitions
* @psalm-import-type TalkMatterbridgeProcessState from ResponseDefinitions
* @psalm-import-type TalkMatterbridgeWithProcessState from ResponseDefinitions
*/
class MatterbridgeController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
protected ?string $userId,
IRequest $request,
protected Manager $manager,
protected MatterbridgeManager $bridgeManager,
) {
parent::__construct($appName, $request);
}
/**
* Get bridge information of one room
*
* @return DataResponse<Http::STATUS_OK, TalkMatterbridgeWithProcessState, array{}>
*
* 200: Return list of configured bridges
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function getBridgeOfRoom(): DataResponse {
$pid = $this->bridgeManager->checkBridge($this->room);
$logContent = $this->bridgeManager->getBridgeLog($this->room);
$bridge = $this->bridgeManager->getBridgeOfRoom($this->room);
$bridge['running'] = ($pid !== 0);
$bridge['log'] = $logContent;
return new DataResponse($bridge);
}
/**
* Get bridge process information
*
* @return DataResponse<Http::STATUS_OK, TalkMatterbridgeProcessState, array{}>
*
* 200: Return list of running processes
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function getBridgeProcessState(): DataResponse {
$state = $this->bridgeManager->getBridgeProcessState($this->room);
return new DataResponse($state);
}
/**
* Edit bridge information of one room
*
* @param bool $enabled If the bridge should be enabled
* @param TalkMatterbridgeConfigFields $parts New parts
* @return DataResponse<Http::STATUS_OK, TalkMatterbridgeProcessState, array{}>|DataResponse<Http::STATUS_NOT_ACCEPTABLE, array{error: string}, array{}>
*
* 200: Bridge edited successfully
* 406: Editing bridge is not possible
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function editBridgeOfRoom(bool $enabled, array $parts = []): DataResponse {
try {
$state = $this->bridgeManager->editBridgeOfRoom($this->room, $this->userId, $enabled, $parts);
} catch (ImpossibleToKillException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_ACCEPTABLE);
}
return new DataResponse($state);
}
/**
* Delete bridge of one room
*
* @return DataResponse<Http::STATUS_OK, bool, array{}>|DataResponse<Http::STATUS_NOT_ACCEPTABLE, array{error: string}, array{}>
*
* 200: Bridge deleted successfully
* 406: Deleting bridge is not possible
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function deleteBridgeOfRoom(): DataResponse {
try {
$success = $this->bridgeManager->deleteBridgeOfRoom($this->room);
} catch (ImpossibleToKillException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_ACCEPTABLE);
}
return new DataResponse($success);
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Exceptions\ImpossibleToKillException;
use OCA\Talk\Exceptions\WrongPermissionsException;
use OCA\Talk\MatterbridgeManager;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
class MatterbridgeSettingsController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
protected MatterbridgeManager $bridgeManager,
) {
parent::__construct($appName, $request);
}
/**
* Get Matterbridge version
*
* @return DataResponse<Http::STATUS_OK, array{version: string}, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Bridge version returned
* 400: Getting bridge version is not possible
*/
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['matterbridge'])]
public function getMatterbridgeVersion(): DataResponse {
try {
$version = $this->bridgeManager->getCurrentVersionFromBinary();
if ($version === null) {
return new DataResponse([
'error' => 'binary',
], Http::STATUS_BAD_REQUEST);
}
} catch (WrongPermissionsException $e) {
return new DataResponse([
'error' => 'binary_permissions',
], Http::STATUS_BAD_REQUEST);
}
return new DataResponse([
'version' => $version,
]);
}
/**
* Stop all bridges
*
* @return DataResponse<Http::STATUS_OK, bool, array{}>|DataResponse<Http::STATUS_NOT_ACCEPTABLE, array{error: string}, array{}>
*
* 200: All bridges stopped successfully
* 406: Stopping all bridges is not possible
*/
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['matterbridge'])]
public function stopAllBridges(): DataResponse {
try {
$success = $this->bridgeManager->stopAllBridges();
} catch (ImpossibleToKillException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_NOT_ACCEPTABLE);
}
return new DataResponse($success);
}
}
+521
View File
@@ -0,0 +1,521 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\AppInfo\Application;
use OCA\Talk\Config;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RoomService;
use OCA\Talk\TalkSession;
use OCA\Talk\TInitialState;
use OCA\Viewer\Event\LoadViewer;
use OCP\App\IAppManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\UseSession;
use OCP\AppFramework\Http\ContentSecurityPolicy;
use OCP\AppFramework\Http\NotFoundResponse;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\Response;
use OCP\AppFramework\Http\Template\PublicTemplateResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Files\IRootFolder;
use OCP\HintException;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Notification\IManager as INotificationManager;
use OCP\Security\Bruteforce\IThrottler;
use Psr\Log\LoggerInterface;
use SensitiveParameter;
#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)]
class PageController extends Controller {
use TInitialState;
public function __construct(
string $appName,
IRequest $request,
private IEventDispatcher $eventDispatcher,
private RoomController $api,
private TalkSession $talkSession,
private IUserSession $userSession,
private ?string $userId,
LoggerInterface $logger,
private Manager $manager,
private ParticipantService $participantService,
private RoomService $roomService,
private IURLGenerator $url,
private INotificationManager $notificationManager,
private IAppManager $appManager,
IInitialState $initialState,
ICacheFactory $memcacheFactory,
private IRootFolder $rootFolder,
private IThrottler $throttler,
Config $talkConfig,
IConfig $serverConfig,
IGroupManager $groupManager,
) {
parent::__construct($appName, $request);
$this->logger = $logger;
$this->initialState = $initialState;
$this->memcacheFactory = $memcacheFactory;
$this->talkConfig = $talkConfig;
$this->serverConfig = $serverConfig;
$this->groupManager = $groupManager;
}
/**
* @param string $token
* @return Response
* @throws HintException
*/
#[NoCSRFRequired]
#[PublicPage]
#[UseSession]
#[BruteForceProtection(action: 'talkRoomToken')]
public function showCall(string $token, string $email = '', string $access = ''): Response {
// This is the entry point from the `/call/{token}` URL which is hardcoded in the server.
return $this->pageHandler($token, email: $email, accessToken: $access);
}
/**
* @param string $token
* @param string $password
* @return Response
* @throws HintException
*/
#[NoCSRFRequired]
#[PublicPage]
#[UseSession]
#[BruteForceProtection(action: 'talkRoomPassword')]
public function authenticatePassword(string $token, string $password = ''): Response {
// This is the entry point from the `/call/{token}` URL which is hardcoded in the server.
return $this->pageHandler($token, password: $password);
}
#[NoCSRFRequired]
#[PublicPage]
public function notFound(): Response {
return $this->pageHandler();
}
#[NoCSRFRequired]
#[PublicPage]
public function duplicateSession(): Response {
return $this->pageHandler();
}
/**
* @param string $token
* @param string $callUser
* @return TemplateResponse|RedirectResponse
* @throws HintException
*/
#[NoCSRFRequired]
#[PublicPage]
#[BruteForceProtection(action: 'talkRoomToken')]
#[UseSession]
public function index(string $token = '', string $callUser = ''): Response {
if ($callUser !== '') {
$token = '';
}
return $this->pageHandler($token, $callUser);
}
/**
* @param string $token
* @param string $callUser
* @param string $password
* @return TemplateResponse|RedirectResponse
* @throws HintException
*/
protected function pageHandler(
string $token = '',
string $callUser = '',
string $password = '',
string $email = '',
#[SensitiveParameter]
string $accessToken = '',
): Response {
$bruteForceToken = $token;
$user = $this->userSession->getUser();
if (!$user instanceof IUser) {
return $this->guestEnterRoom($token, $password, $email, $accessToken);
}
$throttle = false;
if ($token !== '') {
$room = null;
try {
$room = $this->manager->getRoomByToken($token, $this->userId);
$notification = $this->notificationManager->createNotification();
$shouldFlush = $this->notificationManager->defer();
try {
$notification->setApp('spreed')
->setUser($this->userId)
->setObject('room', $room->getToken());
$this->notificationManager->markProcessed($notification);
$notification->setObject('call', $room->getToken());
$this->notificationManager->markProcessed($notification);
} catch (\InvalidArgumentException $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
if ($shouldFlush) {
$this->notificationManager->flush();
}
// If the room is not a public room, check if the user is in the participants
if ($room->getType() !== Room::TYPE_PUBLIC) {
$this->manager->getRoomForUser($room->getId(), $this->userId);
}
} catch (RoomNotFoundException $e) {
// Room not found, redirect to main page
$token = '';
$throttle = true;
}
if ($room instanceof Room && $room->hasPassword()) {
// If the user joined themselves or is not found, they need the password.
try {
$participant = $this->participantService->getParticipant($room, $this->userId, false);
$requirePassword = $participant->getAttendee()->getParticipantType() === Participant::USER_SELF_JOINED;
} catch (ParticipantNotFoundException $e) {
$requirePassword = true;
}
if ($requirePassword) {
$password = $password !== '' ? $password : (string)$this->talkSession->getPasswordForRoom($token);
$passwordVerification = $this->roomService->verifyPassword($room, $password);
if ($passwordVerification['result']) {
$this->talkSession->renewSessionId();
$this->talkSession->setPasswordForRoom($token, $password);
$this->throttler->resetDelay($this->request->getRemoteAddress(), 'talkRoomPassword', ['token' => $token, 'action' => 'talkRoomPassword']);
} else {
$this->talkSession->removePasswordForRoom($token);
$showBruteForceWarning = $this->throttler->getDelay($this->request->getRemoteAddress(), 'talkRoomPassword') > 5000;
if ($passwordVerification['url'] === '') {
$response = new TemplateResponse($this->appName, 'authenticate', [
'wrongpw' => $password !== '',
'showBruteForceWarning' => $showBruteForceWarning,
], 'guest');
} else {
$response = new RedirectResponse($passwordVerification['url']);
}
$this->logger->debug('User "' . ($this->userId ?? 'ANONYMOUS') . '" throttled for accessing "' . $token . '"', ['app' => 'spreed-bfp']);
$response->throttle(['token' => $token, 'action' => 'talkRoomPassword']);
return $response;
}
}
}
} else {
$response = $this->api->createRoom(Room::TYPE_ONE_TO_ONE, $callUser);
if ($response->getStatus() === Http::STATUS_OK
|| $response->getStatus() === Http::STATUS_CREATED) {
$data = $response->getData();
return $this->redirectToConversation($data['token']);
}
}
$this->publishInitialStateForUser($user, $this->rootFolder, $this->appManager);
if (class_exists(LoadViewer::class)) {
$this->eventDispatcher->dispatchTyped(new LoadViewer());
}
$this->eventDispatcher->dispatchTyped(new LoadAdditionalScriptsEvent());
$this->eventDispatcher->dispatchTyped(new RenderReferenceEvent());
$response = new TemplateResponse($this->appName, 'index', [
'app' => Application::APP_ID,
'id-app-content' => '#content-vue',
'id-app-navigation' => '#app-navigation-vue',
]);
$csp = new ContentSecurityPolicy();
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
$csp->addAllowedMediaDomain('blob:');
$csp->addAllowedWorkerSrcDomain('blob:');
$csp->addAllowedWorkerSrcDomain("'self'");
$csp->addAllowedChildSrcDomain('blob:');
$csp->addAllowedChildSrcDomain("'self'");
$csp->addAllowedScriptDomain('blob:');
$csp->addAllowedScriptDomain("'self'");
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
$csp->addAllowedConnectDomain('blob:');
$csp->addAllowedConnectDomain("'self'");
foreach ($this->talkConfig->getAllServerUrlsForCSP() as $server) {
$csp->addAllowedConnectDomain($server);
}
$response->setContentSecurityPolicy($csp);
if ($throttle) {
// Logged-in user tried to access a chat they can not access
$this->logger->debug('User "' . ($this->userId ?? 'ANONYMOUS') . '" throttled for accessing "' . $bruteForceToken . '"', ['app' => 'spreed-bfp']);
$response->throttle(['token' => $bruteForceToken, 'action' => 'talkRoomToken']);
}
return $response;
}
/**
* @param string $token
* @return TemplateResponse|NotFoundResponse
*/
#[NoCSRFRequired]
#[PublicPage]
#[BruteForceProtection(action: 'talkRoomToken')]
#[BruteForceProtection(action: 'talkRecordingStatus')]
public function recording(string $token): Response {
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$response = new NotFoundResponse();
$this->logger->debug('Recording "' . ($this->userId ?? 'ANONYMOUS') . '" throttled for accessing "' . $token . '"', ['app' => 'spreed-bfp']);
$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
return $response;
}
if ($room->getCallRecording() !== Room::RECORDING_VIDEO_STARTING && $room->getCallRecording() !== Room::RECORDING_AUDIO_STARTING) {
$response = new NotFoundResponse();
$this->logger->debug('Recording "' . ($this->userId ?? 'ANONYMOUS') . '" throttled for accessing "' . $token . '"', ['app' => 'spreed-bfp']);
$response->throttle(['token' => $token, 'action' => 'talkRecordingStatus']);
return $response;
}
if (class_exists(LoadViewer::class)) {
$this->eventDispatcher->dispatchTyped(new LoadViewer());
}
$this->publishInitialStateForGuest();
$this->eventDispatcher->dispatchTyped(new LoadAdditionalScriptsEvent());
$this->eventDispatcher->dispatchTyped(new RenderReferenceEvent());
$response = new PublicTemplateResponse($this->appName, 'recording', [
'id-app-content' => '#content-vue',
'id-app-navigation' => null,
]);
$response->setFooterVisible(false);
$csp = new ContentSecurityPolicy();
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
$csp->addAllowedMediaDomain('blob:');
$csp->addAllowedWorkerSrcDomain('blob:');
$csp->addAllowedWorkerSrcDomain("'self'");
$csp->addAllowedChildSrcDomain('blob:');
$csp->addAllowedChildSrcDomain("'self'");
$csp->addAllowedScriptDomain('blob:');
$csp->addAllowedScriptDomain("'self'");
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
$csp->addAllowedConnectDomain('blob:');
$csp->addAllowedConnectDomain("'self'");
foreach ($this->talkConfig->getAllServerUrlsForCSP() as $server) {
$csp->addAllowedConnectDomain($server);
}
$response->setContentSecurityPolicy($csp);
return $response;
}
/**
* @return TemplateResponse|RedirectResponse
* @throws HintException
*/
protected function guestEnterRoom(
string $token,
string $password,
string $email,
#[SensitiveParameter]
string $accessToken,
): Response {
if ($email && $accessToken) {
return $this->invitedEmail(
$token,
$email,
$accessToken,
);
}
try {
$room = $this->manager->getRoomByToken($token);
if ($room->getType() !== Room::TYPE_PUBLIC) {
throw new RoomNotFoundException();
}
} catch (RoomNotFoundException $e) {
$redirectUrl = $this->url->linkToRoute('spreed.Page.index');
if ($token) {
$redirectUrl = $this->url->linkToRoute('spreed.Page.showCall', ['token' => $token]);
}
$response = new RedirectResponse($this->url->linkToRoute('core.login.showLoginForm', [
'redirect_url' => $redirectUrl,
]));
$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
return $response;
}
if ($room->hasPassword()) {
$password = $password !== '' ? $password : (string)$this->talkSession->getPasswordForRoom($token);
$passwordVerification = $this->roomService->verifyPassword($room, $password);
if ($passwordVerification['result']) {
$this->talkSession->renewSessionId();
$this->talkSession->setPasswordForRoom($token, $password);
$this->throttler->resetDelay($this->request->getRemoteAddress(), 'talkRoomPassword', ['token' => $token, 'action' => 'talkRoomPassword']);
} else {
$this->talkSession->removePasswordForRoom($token);
$showBruteForceWarning = $this->throttler->getDelay($this->request->getRemoteAddress(), 'talkRoomPassword') > 5000;
if ($passwordVerification['url'] === '') {
$response = new TemplateResponse($this->appName, 'authenticate', [
'wrongpw' => $password !== '',
'showBruteForceWarning' => $showBruteForceWarning,
], 'guest');
} else {
$response = new RedirectResponse($passwordVerification['url']);
}
$response->throttle(['token' => $token, 'action' => 'talkRoomPassword']);
return $response;
}
}
$this->publishInitialStateForGuest();
$this->eventDispatcher->dispatchTyped(new RenderReferenceEvent());
$response = new PublicTemplateResponse($this->appName, 'index', [
'id-app-content' => '#content-vue',
'id-app-navigation' => null,
]);
$response->setFooterVisible(false);
$csp = new ContentSecurityPolicy();
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
$csp->addAllowedMediaDomain('blob:');
$csp->addAllowedWorkerSrcDomain('blob:');
$csp->addAllowedWorkerSrcDomain("'self'");
$csp->addAllowedChildSrcDomain('blob:');
$csp->addAllowedChildSrcDomain("'self'");
$csp->addAllowedScriptDomain('blob:');
$csp->addAllowedScriptDomain("'self'");
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
$csp->addAllowedConnectDomain('blob:');
$csp->addAllowedConnectDomain("'self'");
foreach ($this->talkConfig->getAllServerUrlsForCSP() as $server) {
$csp->addAllowedConnectDomain($server);
}
$response->setContentSecurityPolicy($csp);
return $response;
}
/**
* @return TemplateResponse|RedirectResponse
* @throws HintException
*/
protected function invitedEmail(
string $token,
string $email,
#[SensitiveParameter]
string $accessToken,
): Response {
try {
$actorId = hash('sha256', $email);
$this->manager->getRoomByAccessToken(
$token,
Attendee::ACTOR_EMAILS,
$actorId,
$accessToken,
);
$this->talkSession->renewSessionId();
$this->talkSession->setAuthedEmailActorIdForRoom($token, $actorId);
} catch (RoomNotFoundException) {
$redirectUrl = $this->url->linkToRoute('spreed.Page.index');
if ($token) {
$redirectUrl = $this->url->linkToRoute('spreed.Page.showCall', ['token' => $token]);
}
$response = new RedirectResponse($this->url->linkToRoute('core.login.showLoginForm', [
'redirect_url' => $redirectUrl,
]));
$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
return $response;
}
$this->publishInitialStateForGuest();
$this->eventDispatcher->dispatchTyped(new RenderReferenceEvent());
$response = new PublicTemplateResponse($this->appName, 'index', [
'id-app-content' => '#content-vue',
'id-app-navigation' => null,
]);
$response->setFooterVisible(false);
$csp = new ContentSecurityPolicy();
$csp->addAllowedImageDomain('https://*.tile.openstreetmap.org');
$csp->addAllowedMediaDomain('blob:');
$csp->addAllowedWorkerSrcDomain('blob:');
$csp->addAllowedWorkerSrcDomain("'self'");
$csp->addAllowedChildSrcDomain('blob:');
$csp->addAllowedChildSrcDomain("'self'");
$csp->addAllowedScriptDomain('blob:');
$csp->addAllowedScriptDomain("'self'");
$csp->addAllowedScriptDomain("'wasm-unsafe-eval'");
$csp->addAllowedConnectDomain('blob:');
$csp->addAllowedConnectDomain("'self'");
foreach ($this->talkConfig->getAllServerUrlsForCSP() as $server) {
$csp->addAllowedConnectDomain($server);
}
$response->setContentSecurityPolicy($csp);
return $response;
}
/**
* @param string $token
* @return RedirectResponse
*/
#[NoCSRFRequired]
#[PublicPage]
protected function redirectToConversation(string $token): RedirectResponse {
// These redirects are already done outside of this method
if ($this->userId === null) {
try {
$room = $this->manager->getRoomByToken($token);
if ($room->getType() !== Room::TYPE_PUBLIC) {
throw new RoomNotFoundException();
}
return new RedirectResponse($this->url->linkToRoute('spreed.Page.showCall', ['token' => $token]));
} catch (RoomNotFoundException $e) {
return new RedirectResponse($this->url->linkToRoute('core.login.showLoginForm', [
'redirect_url' => $this->url->linkToRoute('spreed.Page.showCall', ['token' => $token]),
]));
}
}
return new RedirectResponse($this->url->linkToRoute('spreed.Page.showCall', ['token' => $token]));
}
}
+467
View File
@@ -0,0 +1,467 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use JsonException;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Exceptions\PollPropertyException;
use OCA\Talk\Exceptions\WrongPermissionsException;
use OCA\Talk\Middleware\Attribute\FederationSupported;
use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby;
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
use OCA\Talk\Middleware\Attribute\RequireParticipant;
use OCA\Talk\Middleware\Attribute\RequirePermission;
use OCA\Talk\Middleware\Attribute\RequireReadWriteConversation;
use OCA\Talk\Model\Poll;
use OCA\Talk\Model\Vote;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCA\Talk\Service\AttachmentService;
use OCA\Talk\Service\PollService;
use OCA\Talk\Service\ThreadService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkPoll from ResponseDefinitions
* @psalm-import-type TalkPollDraft from ResponseDefinitions
*/
class PollController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
protected ChatManager $chatManager,
protected PollService $pollService,
protected AttachmentService $attachmentService,
protected ThreadService $threadService,
protected ITimeFactory $timeFactory,
protected LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
/**
* Create a poll
*
* @param string $question Question of the poll
* @param string[] $options Options of the poll
* @psalm-param list<string> $options
* @param 0|1 $resultMode Mode how the results will be shown
* @psalm-param Poll::MODE_* $resultMode Mode how the results will be shown
* @param int $maxVotes Number of maximum votes per voter
* @param bool $draft Whether the poll should be saved as a draft (only allowed for moderators and with `talk-polls-drafts` capability)
* @param int $threadId Thread id which this poll should be posted into (also requires `threads` capability)
* @return DataResponse<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_CREATED, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'draft'|'options'|'poll'|'question'|'room'}, array{}>
*
* 200: Draft created successfully
* 201: Poll created successfully
* 400: Creating poll is not possible
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequirePermission(permission: RequirePermission::CHAT)]
#[RequireReadWriteConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function createPoll(string $question, array $options, int $resultMode, int $maxVotes, bool $draft = false, int $threadId = 0): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class);
return $proxy->createPoll($this->room, $this->participant, $question, $options, $resultMode, $maxVotes, $draft);
}
if ($this->room->getType() !== Room::TYPE_GROUP
&& $this->room->getType() !== Room::TYPE_PUBLIC) {
return new DataResponse(['error' => PollPropertyException::REASON_ROOM], Http::STATUS_BAD_REQUEST);
}
if ($draft === true && !$this->participant->hasModeratorPermissions()) {
return new DataResponse(['error' => PollPropertyException::REASON_DRAFT], Http::STATUS_BAD_REQUEST);
}
$attendee = $this->participant->getAttendee();
try {
$poll = $this->pollService->createPoll(
$this->room->getId(),
$attendee->getActorType(),
$attendee->getActorId(),
$attendee->getDisplayName(),
$question,
$options,
$resultMode,
$maxVotes,
$draft,
);
} catch (PollPropertyException $e) {
$this->logger->error('Error creating poll', ['exception' => $e]);
return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
}
if ($draft) {
return new DataResponse($poll->renderAsDraft());
}
if ($threadId !== 0) {
try {
$this->threadService->findByThreadId($this->room->getId(), $threadId);
} catch (DoesNotExistException) {
// Someone tried to cheat, ignore
$threadId = 0;
}
}
$message = json_encode([
'message' => 'object_shared',
'parameters' => [
'objectType' => 'talk-poll',
'objectId' => $poll->getId(),
'metaData' => [
'type' => 'talk-poll',
'id' => $poll->getId(),
'name' => $question,
]
],
], JSON_THROW_ON_ERROR);
try {
$this->chatManager->addSystemMessage($this->room, $this->participant, $attendee->getActorType(), $attendee->getActorId(), $message, $this->timeFactory->getDateTime(), true, threadId: $threadId);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
return new DataResponse($this->renderPoll($poll), Http::STATUS_CREATED);
}
/**
* Modify a draft poll
*
* Required capability: `edit-draft-poll`
*
* @param int $pollId The poll id
* @param string $question Question of the poll
* @param string[] $options Options of the poll
* @psalm-param list<string> $options
* @param 0|1 $resultMode Mode how the results will be shown
* @psalm-param Poll::MODE_* $resultMode Mode how the results will be shown
* @param int $maxVotes Number of maximum votes per voter
* @return DataResponse<Http::STATUS_OK, TalkPollDraft, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'draft'|'options'|'poll'|'question'|'room'}, array{}>
*
* 200: Draft modified successfully
* 400: Modifying poll is not possible
* 403: No permission to modify this poll
* 404: No draft poll exists
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequirePermission(permission: RequirePermission::CHAT)]
#[RequireReadWriteConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function updateDraftPoll(int $pollId, string $question, array $options, int $resultMode, int $maxVotes): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class);
return $proxy->updateDraftPoll($pollId, $this->room, $this->participant, $question, $options, $resultMode, $maxVotes);
}
if ($this->room->getType() !== Room::TYPE_GROUP
&& $this->room->getType() !== Room::TYPE_PUBLIC) {
return new DataResponse(['error' => PollPropertyException::REASON_ROOM], Http::STATUS_BAD_REQUEST);
}
try {
$poll = $this->pollService->getPoll($this->room->getId(), $pollId);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_NOT_FOUND);
}
if (!$poll->isDraft()) {
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_BAD_REQUEST);
}
if (!$this->participant->hasModeratorPermissions()
&& ($poll->getActorType() !== $this->participant->getAttendee()->getActorType()
|| $poll->getActorId() !== $this->participant->getAttendee()->getActorId())) {
return new DataResponse(['error' => PollPropertyException::REASON_DRAFT], Http::STATUS_BAD_REQUEST);
}
try {
$poll->setQuestion($question);
$poll->setOptions($options);
$poll->setResultMode($resultMode);
$poll->setMaxVotes($maxVotes);
} catch (PollPropertyException $e) {
$this->logger->error('Error modifying poll', ['exception' => $e]);
return new DataResponse(['error' => $e->getReason()], Http::STATUS_BAD_REQUEST);
}
try {
$this->pollService->updatePoll($this->participant, $poll);
} catch (WrongPermissionsException $e) {
$this->logger->error('Error modifying poll', ['exception' => $e]);
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_FORBIDDEN);
}
return new DataResponse($poll->renderAsDraft());
}
/**
* Get all drafted polls
*
* Required capability: `talk-polls-drafts`
*
* @return DataResponse<Http::STATUS_OK, list<TalkPollDraft>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>
*
* 200: Poll returned
* 403: User is not a moderator
* 404: Poll not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function getAllDraftPolls(): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class);
return $proxy->getDraftsForRoom($this->room, $this->participant);
}
$polls = $this->pollService->getDraftsForRoom($this->room->getId());
$data = [];
foreach ($polls as $poll) {
$data[] = $poll->renderAsDraft();
}
return new DataResponse($data);
}
/**
* Get a poll
*
* @param int $pollId ID of the poll
* @psalm-param non-negative-int $pollId
* @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>
*
* 200: Poll returned
* 404: Poll not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function showPoll(int $pollId): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class);
return $proxy->showPoll($this->room, $this->participant, $pollId);
}
try {
$poll = $this->pollService->getPoll($this->room->getId(), $pollId);
} catch (DoesNotExistException) {
return new DataResponse(['error' => 'poll'], Http::STATUS_NOT_FOUND);
}
if ($poll->getStatus() === Poll::STATUS_DRAFT && !$this->participant->hasModeratorPermissions()) {
return new DataResponse(['error' => 'poll'], Http::STATUS_NOT_FOUND);
}
$votedSelf = $this->pollService->getVotesForActor($this->participant, $poll);
$detailedVotes = [];
if ($poll->getResultMode() === Poll::MODE_PUBLIC && $poll->getStatus() === Poll::STATUS_CLOSED) {
$detailedVotes = $this->pollService->getVotes($poll);
}
return new DataResponse($this->renderPoll($poll, $votedSelf, $detailedVotes));
}
/**
* Vote on a poll
*
* @param int $pollId ID of the poll
* @psalm-param non-negative-int $pollId
* @param list<int> $optionIds IDs of the selected options
* @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: string}, array{}>
*
* 200: Voted successfully
* 400: Voting is not possible
* 404: Poll not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function votePoll(int $pollId, array $optionIds = []): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class);
return $proxy->votePoll($this->room, $this->participant, $pollId, $optionIds);
}
try {
$poll = $this->pollService->getPoll($this->room->getId(), $pollId);
} catch (DoesNotExistException) {
return new DataResponse(['error' => 'poll'], Http::STATUS_NOT_FOUND);
}
if ($poll->getStatus() === Poll::STATUS_DRAFT) {
return new DataResponse(['error' => 'poll'], Http::STATUS_NOT_FOUND);
}
if ($poll->getStatus() === Poll::STATUS_CLOSED) {
return new DataResponse(['error' => 'poll'], Http::STATUS_BAD_REQUEST);
}
try {
$votedSelf = $this->pollService->votePoll($this->participant, $poll, $optionIds);
} catch (\RuntimeException $e) {
return new DataResponse(['error' => 'options'], Http::STATUS_BAD_REQUEST);
}
if ($poll->getResultMode() === Poll::MODE_PUBLIC) {
$attendee = $this->participant->getAttendee();
try {
$message = json_encode([
'message' => 'poll_voted',
'parameters' => [
'poll' => [
'type' => 'talk-poll',
'id' => $poll->getId(),
'name' => $poll->getQuestion(),
],
],
], JSON_THROW_ON_ERROR);
$this->chatManager->addSystemMessage($this->room, $this->participant, $attendee->getActorType(), $attendee->getActorId(), $message, $this->timeFactory->getDateTime(), false);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
}
return new DataResponse($this->renderPoll($poll, $votedSelf));
}
/**
* Close a poll
*
* @param int $pollId ID of the poll
* @psalm-param non-negative-int $pollId
* @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_ACCEPTED, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{error: 'draft'|'options'|'poll'|'question'|'room'}, array{}>
*
* 200: Poll closed successfully
* 202: Poll draft was deleted successfully
* 400: Poll already closed
* 403: Missing permissions to close poll
* 404: Poll not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function closePoll(int $pollId): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\PollController::class);
return $proxy->closePoll($this->room, $this->participant, $pollId);
}
try {
$poll = $this->pollService->getPoll($this->room->getId(), $pollId);
} catch (DoesNotExistException) {
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_NOT_FOUND);
}
if ($poll->getStatus() === Poll::STATUS_DRAFT) {
if (!$this->participant->hasModeratorPermissions(false)) {
// Only moderators can manage drafts
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_NOT_FOUND);
}
$this->pollService->deleteByPollId($poll->getId());
return new DataResponse(null, Http::STATUS_ACCEPTED);
}
if ($poll->getStatus() === Poll::STATUS_CLOSED) {
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_BAD_REQUEST);
}
try {
$this->pollService->closePoll($this->participant, $poll);
} catch (WrongPermissionsException $e) {
return new DataResponse(['error' => PollPropertyException::REASON_POLL], Http::STATUS_FORBIDDEN);
}
$attendee = $this->participant->getAttendee();
try {
$message = json_encode([
'message' => 'poll_closed',
'parameters' => [
'poll' => [
'type' => 'talk-poll',
'id' => $poll->getId(),
'name' => $poll->getQuestion(),
],
],
], JSON_THROW_ON_ERROR);
$this->chatManager->addSystemMessage($this->room, $this->participant, $attendee->getActorType(), $attendee->getActorId(), $message, $this->timeFactory->getDateTime(), true);
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
$detailedVotes = [];
if ($poll->getResultMode() === Poll::MODE_PUBLIC) {
$detailedVotes = $this->pollService->getVotes($poll);
}
$votedSelf = $this->pollService->getVotesForActor($this->participant, $poll);
return new DataResponse($this->renderPoll($poll, $votedSelf, $detailedVotes));
}
/**
* @return TalkPoll
* @throws JsonException
*/
protected function renderPoll(Poll $poll, array $votedSelf = [], array $detailedVotes = []): array {
$data = $poll->renderAsPoll();
$canSeeSummary = !empty($votedSelf) && $poll->getResultMode() === Poll::MODE_PUBLIC;
if (!$canSeeSummary && $poll->getStatus() === Poll::STATUS_OPEN) {
$data['votes'] = [];
if ($this->participant->hasModeratorPermissions()
|| ($poll->getActorType() === $this->participant->getAttendee()->getActorType()
&& $poll->getActorId() === $this->participant->getAttendee()->getActorId())) {
// Allow moderators and the author to see the number of voters,
// So they know when to close the poll.
} else {
$data['numVoters'] = 0;
}
} elseif ($poll->getResultMode() === Poll::MODE_PUBLIC && $poll->getStatus() === Poll::STATUS_CLOSED) {
$data['details'] = array_values(array_map(static fn (Vote $vote) => $vote->asArray(), $detailedVotes));
}
$data['votedSelf'] = array_values(array_map(static fn (Vote $vote) => $vote->getOptionId(), $votedSelf));
return $data;
}
}
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Room;
use OCA\Talk\Service\RoomService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Share\IManager as IShareManager;
use OCP\Share\IShare;
class PublicShareAuthController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private IUserManager $userManager,
private IShareManager $shareManager,
private IUserSession $userSession,
private RoomService $roomService,
) {
parent::__construct($appName, $request);
}
/**
* Creates a new room for video verification (requesting the password of a share)
*
* The new room is a public room associated with a "share:password" object
* with the ID of the share token. Unlike normal rooms in which the owner is
* the user that created the room these are special rooms always created by
* a guest or user on behalf of a registered user, the sharer, who will be
* the owner of the room.
*
* The share must have "send password by Talk" enabled; an error is returned
* otherwise.
*
* @param string $shareToken Token of the file share
* @return DataResponse<Http::STATUS_CREATED, array{token: string, name: string, displayName: string}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>
*
* 201: Room created successfully
* 404: Share not found
*/
#[PublicPage]
#[OpenAPI(tags: ['files_integration'])]
public function createRoom(string $shareToken): DataResponse {
try {
$share = $this->shareManager->getShareByToken($shareToken);
} catch (ShareNotFound) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
if (!$share->getSendPasswordByTalk()) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
$sharerUser = $this->userManager->get($share->getSharedBy());
if (!$sharerUser instanceof IUser) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
if ($share->getShareType() === IShare::TYPE_EMAIL) {
$roomName = $share->getSharedWith();
} else {
$roomName = trim($share->getTarget(), '/');
}
$roomName = $this->roomService->prepareConversationName($roomName);
// Create the room
$room = $this->roomService->createConversation(
Room::TYPE_PUBLIC,
$roomName,
$sharerUser,
Room::OBJECT_TYPE_VIDEO_VERIFICATION,
$shareToken,
);
$user = $this->userSession->getUser();
$userId = $user instanceof IUser ? $user->getUID() : '';
return new DataResponse([
'token' => $room->getToken(),
'name' => $room->getName(),
'displayName' => $room->getDisplayName($userId),
], Http::STATUS_CREATED);
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Chat\ReactionManager;
use OCA\Talk\Exceptions\ReactionAlreadyExistsException;
use OCA\Talk\Exceptions\ReactionNotSupportedException;
use OCA\Talk\Exceptions\ReactionOutOfContextException;
use OCA\Talk\Middleware\Attribute\FederationSupported;
use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby;
use OCA\Talk\Middleware\Attribute\RequireParticipant;
use OCA\Talk\Middleware\Attribute\RequirePermission;
use OCA\Talk\Middleware\Attribute\RequireReadWriteConversation;
use OCA\Talk\ResponseDefinitions;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\Comments\NotFoundException;
use OCP\IRequest;
/**
* @psalm-import-type TalkReaction from ResponseDefinitions
*/
class ReactionController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
private ReactionManager $reactionManager,
) {
parent::__construct($appName, $request);
}
/**
* Add a reaction to a message
*
* @param int $messageId ID of the message
* @psalm-param non-negative-int $messageId
* @param string $reaction Emoji to add
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED, array<string, list<TalkReaction>>|\stdClass, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Reaction already existed
* 201: Reaction added successfully
* 400: Adding reaction is not possible
* 404: Message not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequirePermission(permission: RequirePermission::CHAT)]
#[RequireReadWriteConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function react(int $messageId, string $reaction): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ReactionController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ReactionController::class);
return $proxy->react($this->room, $this->participant, $messageId, $reaction, $this->getResponseFormat());
}
try {
$this->reactionManager->addReactionMessage(
$this->getRoom(),
$this->getParticipant()->getAttendee()->getActorType(),
$this->getParticipant()->getAttendee()->getActorId(),
$this->getParticipant()->getAttendee()->getDisplayName(),
$messageId,
$reaction
);
$status = Http::STATUS_CREATED;
} catch (NotFoundException $e) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
} catch (ReactionAlreadyExistsException $e) {
$status = Http::STATUS_OK;
} catch (ReactionNotSupportedException|ReactionOutOfContextException|\Exception $e) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
$reactions = $this->reactionManager->retrieveReactionMessages($this->getRoom(), $this->getParticipant(), $messageId);
return new DataResponse($this->formatReactions($reactions), $status);
}
/**
* Delete a reaction from a message
*
* @param int $messageId ID of the message
* @psalm-param non-negative-int $messageId
* @param string $reaction Emoji to remove
* @return DataResponse<Http::STATUS_OK, array<string, list<TalkReaction>>|\stdClass, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Reaction deleted successfully
* 400: Deleting reaction is not possible
* 404: Message not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequirePermission(permission: RequirePermission::CHAT)]
#[RequireReadWriteConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function delete(int $messageId, string $reaction): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ReactionController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ReactionController::class);
return $proxy->delete($this->room, $this->participant, $messageId, $reaction, $this->getResponseFormat());
}
try {
$this->reactionManager->deleteReactionMessage(
$this->getRoom(),
$this->getParticipant()->getAttendee()->getActorType(),
$this->getParticipant()->getAttendee()->getActorId(),
$this->getParticipant()->getAttendee()->getDisplayName(),
$messageId,
$reaction
);
$reactions = $this->reactionManager->retrieveReactionMessages($this->getRoom(), $this->getParticipant(), $messageId);
} catch (ReactionNotSupportedException|ReactionOutOfContextException|NotFoundException $e) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
} catch (\Exception $e) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
return new DataResponse($this->formatReactions($reactions), Http::STATUS_OK);
}
/**
* Get a list of reactions for a message
*
* @param int $messageId ID of the message
* @psalm-param non-negative-int $messageId
* @param string|null $reaction Emoji to filter
* @return DataResponse<Http::STATUS_OK, array<string, list<TalkReaction>>|\stdClass, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Reactions returned
* 404: Message or reaction not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
public function getReactions(int $messageId, ?string $reaction): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ReactionController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ReactionController::class);
return $proxy->getReactions($this->room, $this->participant, $messageId, $reaction, $this->getResponseFormat());
}
try {
// Verify that messageId is part of the room
$this->reactionManager->getCommentToReact($this->getRoom(), (string)$messageId);
} catch (ReactionNotSupportedException|ReactionOutOfContextException|NotFoundException $e) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
$reactions = $this->reactionManager->retrieveReactionMessages($this->getRoom(), $this->getParticipant(), $messageId, $reaction);
return new DataResponse($this->formatReactions($reactions), Http::STATUS_OK);
}
/**
* @param array<string, list<TalkReaction>> $reactions
* @return array<string, list<TalkReaction>>|\stdClass
*/
protected function formatReactions(array $reactions): array|\stdClass {
if ($this->getResponseFormat() === 'json' && empty($reactions)) {
// Cheating here to make sure the reactions array is always a
// JSON object on the API, even when there is no reaction at all.
return new \stdClass();
}
return $reactions;
}
}
+473
View File
@@ -0,0 +1,473 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use GuzzleHttp\Exception\ConnectException;
use InvalidArgumentException;
use OCA\Talk\Config;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Exceptions\UnauthorizedException;
use OCA\Talk\Manager;
use OCA\Talk\Middleware\Attribute\RequireLoggedInModeratorParticipant;
use OCA\Talk\Middleware\Attribute\RequireModeratorParticipant;
use OCA\Talk\Middleware\Attribute\RequireRoom;
use OCA\Talk\Room;
use OCA\Talk\Service\CertificateService;
use OCA\Talk\Service\ChecksumVerificationService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RecordingService;
use OCA\Talk\Service\RoomService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Http\Client\IClientService;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
class RecordingController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
private ?string $userId,
private Config $talkConfig,
private IClientService $clientService,
private Manager $manager,
private CertificateService $certificateService,
private ParticipantService $participantService,
private RecordingService $recordingService,
private RoomService $roomService,
private ITimeFactory $timeFactory,
private ChecksumVerificationService $checksumVerificationService,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
/**
* Get the welcome message of a recording server
*
* @param int $serverId ID of the server
* @psalm-param non-negative-int $serverId
* @return DataResponse<Http::STATUS_OK, array{version: float}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
*
* 200: Welcome message returned
* 404: Recording server not found or not configured
*/
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])]
public function getWelcomeMessage(int $serverId): DataResponse {
$recordingServers = $this->talkConfig->getRecordingServers();
if (empty($recordingServers) || !isset($recordingServers[$serverId])) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
$url = rtrim($recordingServers[$serverId]['server'], '/');
$url = strtolower($url);
$verifyServer = (bool)$recordingServers[$serverId]['verify'];
if ($verifyServer && str_contains($url, 'https://')) {
$expiration = $this->certificateService->getCertificateExpirationInDays($url);
if ($expiration < 0) {
return new DataResponse(['error' => 'CERTIFICATE_EXPIRED'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
$client = $this->clientService->newClient();
try {
$timeBefore = $this->timeFactory->getTime();
$response = $client->get($url . '/api/v1/welcome', [
'verify' => $verifyServer,
'nextcloud' => [
'allow_local_address' => true,
],
]);
$timeAfter = $this->timeFactory->getTime();
if ($response->getHeader(\OCA\Talk\Signaling\Manager::FEATURE_HEADER)) {
return new DataResponse([
'error' => 'IS_SIGNALING_SERVER',
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
$responseTime = $this->timeFactory->getDateTime($response->getHeader('date'))->getTimestamp();
if (($timeBefore - Config::ALLOWED_BACKEND_TIMEOFFSET) > $responseTime
|| ($timeAfter + Config::ALLOWED_BACKEND_TIMEOFFSET) < $responseTime) {
return new DataResponse([
'error' => 'TIME_OUT_OF_SYNC',
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
$body = $response->getBody();
$data = json_decode($body, true);
if (!is_array($data)) {
return new DataResponse([
'error' => 'JSON_INVALID',
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new DataResponse($data);
} catch (ConnectException $e) {
return new DataResponse(['error' => 'CAN_NOT_CONNECT'], Http::STATUS_INTERNAL_SERVER_ERROR);
} catch (\Exception $e) {
return new DataResponse(['error' => (string)$e->getCode()], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Check if the current request is coming from an allowed backend.
*
* The backends are sending the custom header "Talk-Recording-Random"
* containing at least 32 bytes random data, and the header
* "Talk-Recording-Checksum", which is the SHA256-HMAC of the random data
* and the body of the request, calculated with the shared secret from the
* configuration.
*
* @param string $data
* @return bool
*/
private function validateBackendRequest(string $data): bool {
$random = $this->request->getHeader('talk-recording-random');
$checksum = $this->request->getHeader('talk-recording-checksum');
$secret = $this->talkConfig->getRecordingSecret();
try {
return $this->checksumVerificationService->validateRequest($random, $checksum, $secret, $data);
} catch (UnauthorizedException) {
return false;
}
}
/**
* Return the body of the backend request. This can be overridden in
* tests.
*
* @return string
*/
protected function getInputStream(): string {
return (string)file_get_contents('php://input');
}
/**
* Update the recording status as a backend
*
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
*
* 200: Recording status updated successfully
* 400: Updating recording status is not possible
* 403: Missing permissions to update recording status
* 404: Room not found
*/
#[OpenAPI(scope: 'backend-recording')]
#[PublicPage]
#[BruteForceProtection(action: 'talkRecordingSecret')]
#[BruteForceProtection(action: 'talkRecordingStatus')]
#[RequestHeader(name: 'talk-recording-random', description: 'Random seed used to generate the request checksum', indirect: true)]
#[RequestHeader(name: 'talk-recording-checksum', description: 'Checksum over the request body to verify authenticity from the recording backend', indirect: true)]
public function backend(): DataResponse {
$json = $this->getInputStream();
if (!$this->validateBackendRequest($json)) {
$response = new DataResponse([
'type' => 'error',
'error' => [
'code' => 'invalid_request',
'message' => 'The request could not be authenticated.',
],
], Http::STATUS_FORBIDDEN);
$response->throttle(['action' => 'talkRecordingSecret']);
return $response;
}
$message = json_decode($json, true);
switch ($message['type'] ?? '') {
case 'started':
return $this->backendStarted($message['started']);
case 'stopped':
return $this->backendStopped($message['stopped']);
case 'failed':
return $this->backendFailed($message['failed']);
default:
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'unknown_type',
'message' => 'The given type ' . json_encode($message) . ' is not supported.',
],
], Http::STATUS_BAD_REQUEST);
}
}
/**
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
*/
private function backendStarted(array $started): DataResponse {
$token = $started['token'];
$status = $started['status'];
$actor = $started['actor'];
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$this->logger->debug('Failed to get room {token}', [
'token' => $token,
'app' => 'spreed-recording',
]);
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'Room not found.',
],
], Http::STATUS_NOT_FOUND);
}
if ($room->getCallRecording() !== Room::RECORDING_VIDEO_STARTING && $room->getCallRecording() !== Room::RECORDING_AUDIO_STARTING) {
$this->logger->error('Recording backend tried to start recording in room {token}, but it was not requested by a moderator.', [
'token' => $token,
'app' => 'spreed-recording',
]);
$response = new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'Room not found.',
],
], Http::STATUS_NOT_FOUND);
$response->throttle(['action' => 'talkRecordingStatus']);
return $response;
}
try {
$participant = $this->participantService->getParticipantByActor($room, $actor['type'], $actor['id']);
} catch (ParticipantNotFoundException $e) {
$participant = null;
}
$this->roomService->setCallRecording($room, $status, $participant);
return new DataResponse(null);
}
/**
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
*/
private function backendStopped(array $stopped): DataResponse {
$token = $stopped['token'];
$actor = null;
if (array_key_exists('actor', $stopped)) {
$actor = $stopped['actor'];
}
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$this->logger->debug('Failed to get room {token}', [
'token' => $token,
'app' => 'spreed-recording',
]);
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'Room not found.',
],
], Http::STATUS_NOT_FOUND);
}
try {
if ($actor === null) {
throw new ParticipantNotFoundException();
}
$participant = $this->participantService->getParticipantByActor($room, $actor['type'], $actor['id']);
} catch (ParticipantNotFoundException $e) {
$participant = null;
}
$this->roomService->setCallRecording($room, Room::RECORDING_NONE, $participant);
return new DataResponse(null);
}
/**
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{type: string, error: array{code: string, message: string}}, array{}>
*/
private function backendFailed(array $failed): DataResponse {
$token = $failed['token'];
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$this->logger->debug('Failed to get room {token}', [
'token' => $token,
'app' => 'spreed-recording',
]);
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'Room not found.',
],
], Http::STATUS_NOT_FOUND);
}
$this->roomService->setCallRecording($room, Room::RECORDING_FAILED);
return new DataResponse(null);
}
/**
* Start the recording
*
* @param int $status Type of the recording
* @psalm-param Room::RECORDING_* $status
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Recording started successfully
* 400: Starting recording is not possible
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function start(int $status): DataResponse {
try {
$this->recordingService->start($this->room, $status, $this->userId, $this->participant);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Stop the recording
*
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Recording stopped successfully
* 400: Stopping recording is not possible
*/
#[NoAdminRequired]
#[RequireLoggedInModeratorParticipant]
public function stop(): DataResponse {
try {
$this->recordingService->stop($this->room, $this->participant);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Store the recording
*
* @param ?string $owner User that will own the recording file. `null` is actually not allowed and will always result in a "400 Bad Request". It's only allowed code-wise to handle requests where the post data exceeded the limits, so we can return a proper error instead of "500 Internal Server Error".
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, array{type: string, error: array{code: string, message: string}}, array{}>
*
* 200: Recording stored successfully
* 400: Storing recording is not possible
* 401: Missing permissions to store recording
*/
#[PublicPage]
#[BruteForceProtection(action: 'talkRecordingSecret')]
#[OpenAPI(scope: 'backend-recording')]
#[RequireRoom]
#[RequestHeader(name: 'talk-recording-random', description: 'Random seed used to generate the request checksum', indirect: true)]
#[RequestHeader(name: 'talk-recording-checksum', description: 'Checksum over the request body to verify authenticity from the recording backend', indirect: true)]
public function store(?string $owner): DataResponse {
$data = $this->room->getToken();
if (!$this->validateBackendRequest($data)) {
$response = new DataResponse([
'type' => 'error',
'error' => [
'code' => 'invalid_request',
'message' => 'The request could not be authenticated.',
],
], Http::STATUS_UNAUTHORIZED);
$response->throttle(['action' => 'talkRecordingSecret']);
return $response;
}
if ($owner === null) {
$this->logger->error('Recording backend failed to provide the owner when uploading a recording [ conversation: "' . $this->room->getToken() . '" ]. Most likely the post_max_size or upload_max_filesize were exceeded.');
try {
$this->recordingService->notifyAboutFailedStore($this->room);
} catch (InvalidArgumentException) {
// Ignoring, we logged an error already
}
return new DataResponse(['error' => 'size'], Http::STATUS_BAD_REQUEST);
}
try {
$file = $this->request->getUploadedFile('file');
$this->recordingService->store($this->getRoom(), $owner, $file);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Dismiss the store call recording notification
*
* @param int $timestamp Timestamp of the notification to be dismissed
* @psalm-param non-negative-int $timestamp
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Notification dismissed successfully
* 400: Dismissing notification is not possible
*/
#[NoAdminRequired]
#[RequireModeratorParticipant]
public function notificationDismiss(int $timestamp): DataResponse {
try {
$this->recordingService->notificationDismiss(
$this->getRoom(),
$this->participant,
$timestamp,
null, // FIXME we would/should extend the URL, but the iOS app is crafting it manually atm due to OS limitations
);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
/**
* Share the recorded file to the chat
*
* @param int $fileId ID of the file
* @psalm-param non-negative-int $fileId
* @param int $timestamp Timestamp of the notification to be dismissed
* @psalm-param non-negative-int $timestamp
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>
*
* 200: Recording shared to chat successfully
* 400: Sharing recording to chat is not possible
*/
#[NoAdminRequired]
#[RequireModeratorParticipant]
public function shareToChat(int $fileId, int $timestamp): DataResponse {
try {
$this->recordingService->shareToChat(
$this->getRoom(),
$this->participant,
$fileId,
$timestamp,
);
} catch (InvalidArgumentException $e) {
return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(null);
}
}
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Settings\BeforePreferenceSetEventListener;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\Files\IRootFolder;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
class SettingsController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
protected IRootFolder $rootFolder,
protected IConfig $config,
protected IGroupManager $groupManager,
protected LoggerInterface $logger,
protected BeforePreferenceSetEventListener $preferenceListener,
protected ?string $userId,
) {
parent::__construct($appName, $request);
}
/**
* Update user setting
*
* @param 'attachment_folder'|'read_status_privacy'|'typing_privacy'|'play_sounds' $key Key to update
* @param string|int|null $value New value for the key
* @return DataResponse<Http::STATUS_OK|Http::STATUS_BAD_REQUEST, null, array{}>
*
* 200: User setting updated successfully
* 400: Updating user setting is not possible
*/
#[NoAdminRequired]
public function setUserSetting(string $key, string|int|null $value): DataResponse {
if (!$this->preferenceListener->validatePreference($this->userId, $key, $value)) {
return new DataResponse(null, Http::STATUS_BAD_REQUEST);
}
$this->config->setUserValue($this->userId, 'spreed', $key, $value);
return new DataResponse(null);
}
/**
* Update SIP bridge settings
*
* @param list<string> $sipGroups New SIP groups
* @param string $dialInInfo New dial info
* @param string $sharedSecret New shared secret
* @return DataResponse<Http::STATUS_OK, null, array{}>
*
* 200: Successfully set new SIP settings
*/
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])]
public function setSIPSettings(
array $sipGroups = [],
string $dialInInfo = '',
string $sharedSecret = ''): DataResponse {
$groups = [];
foreach ($sipGroups as $gid) {
$group = $this->groupManager->get($gid);
if ($group instanceof IGroup) {
$groups[] = $group->getGID();
}
}
$this->config->setAppValue('spreed', 'sip_bridge_groups', json_encode($groups));
$this->config->setAppValue('spreed', 'sip_bridge_dialin_info', $dialInInfo);
$this->config->setAppValue('spreed', 'sip_bridge_shared_secret', $sharedSecret);
return new DataResponse(null);
}
}
+953
View File
@@ -0,0 +1,953 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Config;
use OCA\Talk\Events\BeforeSignalingResponseSentEvent;
use OCA\Talk\Exceptions\ForbiddenException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Exceptions\UnauthorizedException;
use OCA\Talk\Federation\Authenticator;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Session;
use OCA\Talk\Participant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Room;
use OCA\Talk\Service\BanService;
use OCA\Talk\Service\ChecksumVerificationService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\RoomService;
use OCA\Talk\Service\SessionService;
use OCA\Talk\Signaling\Messages;
use OCA\Talk\TalkSession;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\Exception;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IDBConnection;
use OCP\IRequest;
use OCP\ISession;
use OCP\IUser;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkSignalingFederationSettings from ResponseDefinitions
* @psalm-import-type TalkSignalingSession from ResponseDefinitions
* @psalm-import-type TalkSignalingSettings from ResponseDefinitions
*/
class SignalingController extends OCSController {
/** @var int */
private const PULL_MESSAGES_TIMEOUT = 30;
public function __construct(
string $appName,
IRequest $request,
private Config $talkConfig,
private \OCA\Talk\Signaling\Manager $signalingManager,
private ISession $serverSession,
private TalkSession $session,
private Manager $manager,
private ParticipantService $participantService,
private RoomService $roomService,
private SessionService $sessionService,
private IDBConnection $dbConnection,
private Messages $messages,
private IUserManager $userManager,
private IEventDispatcher $dispatcher,
private ITimeFactory $timeFactory,
private ChecksumVerificationService $checksumVerificationService,
private BanService $banService,
private LoggerInterface $logger,
protected Authenticator $federationAuthenticator,
private ?string $userId,
) {
parent::__construct($appName, $request);
}
/**
* Check if the current request is coming from an allowed recording backend.
*
* The backends are sending the custom header "Talk-Recording-Random"
* containing at least 32 bytes random data, and the header
* "Talk-Recording-Checksum", which is the SHA256-HMAC of the random data
* and the body of the request, calculated with the shared secret from the
* configuration.
*
* @param string $data
* @return bool
*/
private function validateRecordingBackendRequest(string $data): bool {
$random = $this->request->getHeader('talk-recording-random');
$checksum = $this->request->getHeader('talk-recording-checksum');
$secret = $this->talkConfig->getRecordingSecret();
try {
return $this->checksumVerificationService->validateRequest($random, $checksum, $secret, $data);
} catch (UnauthorizedException) {
return false;
}
}
/**
* Get the signaling settings
*
* @param string $token Token of the room
* @return DataResponse<Http::STATUS_OK, TalkSignalingSettings, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED|Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Signaling settings returned
* 401: Recording request invalid
* 404: Room not found
*/
#[PublicPage]
#[BruteForceProtection(action: 'talkRoomToken')]
#[BruteForceProtection(action: 'talkRecordingSecret')]
#[BruteForceProtection(action: 'talkFederationAccess')]
#[OpenAPI(tags: ['internal_signaling', 'external_signaling'])]
#[RequestHeader(name: 'talk-recording-random', description: 'Random seed used to generate the request checksum', indirect: true)]
#[RequestHeader(name: 'talk-recording-checksum', description: 'Checksum over the request body to verify authenticity from the recording backend', indirect: true)]
public function getSettings(string $token = ''): DataResponse {
$isRecordingRequest = false;
if (!empty($this->request->getHeader('talk-recording-random')) || !empty($this->request->getHeader('talk-recording-checksum'))) {
if (!$this->validateRecordingBackendRequest('')) {
$response = new DataResponse(null, Http::STATUS_UNAUTHORIZED);
$response->throttle(['action' => 'talkRecordingSecret']);
return $response;
}
$isRecordingRequest = true;
} elseif ($this->serverSession->get('app_api') === true) {
// Live transcription ex-app
$isRecordingRequest = true;
}
$isTalkFederation = $this->federationAuthenticator->isFederationRequest();
try {
$action = 'talkRoomToken';
if ($token !== '' && $isRecordingRequest) {
$room = $this->manager->getRoomByToken($token);
} elseif ($token !== '' && $isTalkFederation) {
$action = 'talkFederationAccess';
$room = $this->manager->getRoomByRemoteAccess(
$token,
Attendee::ACTOR_FEDERATED_USERS,
$this->federationAuthenticator->getCloudId(),
$this->federationAuthenticator->getAccessToken(),
);
$participant = $this->participantService->getParticipantByActor(
$room,
Attendee::ACTOR_FEDERATED_USERS,
$this->federationAuthenticator->getCloudId()
);
$this->federationAuthenticator->authenticated($room, $participant);
} elseif ($token !== '') {
$room = $this->manager->getRoomForUserByToken($token, $this->userId);
} elseif ($this->userId !== null || $isRecordingRequest) {
// Mobile clients and admin setup check use the neutral point
// Same for live-transcription
$room = null;
} else {
throw new RoomNotFoundException();
}
} catch (RoomNotFoundException|ParticipantNotFoundException) {
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
$response->throttle(['token' => $token, 'action' => $action]);
return $response;
}
$stun = [];
$stunUrls = [];
$stunServers = $this->talkConfig->getStunServers();
foreach ($stunServers as $stunServer) {
if (empty($stunServer)) {
continue;
}
$stunUrls[] = 'stun:' . $stunServer;
}
if (!empty($stunUrls)) {
$stun[] = [
'urls' => $stunUrls
];
}
$turn = [];
$turnSettings = $this->talkConfig->getTurnSettings();
foreach ($turnSettings as $turnServer) {
if (empty($turnServer['schemes']) || empty($turnServer['server']) || empty($turnServer['protocols'])) {
continue;
}
$turnUrls = [];
$schemes = explode(',', $turnServer['schemes']);
$protocols = explode(',', $turnServer['protocols']);
foreach ($schemes as $scheme) {
foreach ($protocols as $proto) {
$turnUrls[] = $scheme . ':' . $turnServer['server'] . '?transport=' . $proto;
}
}
$turn[] = [
'urls' => $turnUrls,
'username' => (string)$turnServer['username'],
'credential' => (string)$turnServer['password'],
];
}
$signalingMode = $this->talkConfig->getSignalingMode();
$signaling = $this->signalingManager->getSignalingServerLinkForConversation($room);
$data = [
'signalingMode' => $signalingMode,
'userId' => $this->userId,
'hideWarning' => $signaling !== '' || $this->talkConfig->getHideSignalingWarning(),
'server' => $signaling,
'federation' => $this->getFederationSettings($room),
'stunservers' => $stun,
'turnservers' => $turn,
'sipDialinInfo' => $this->talkConfig->isSIPConfigured() ? $this->talkConfig->getDialInInfo() : '',
];
if ($signalingMode !== Config::SIGNALING_INTERNAL) {
$helloAuthParams20UserId = $isTalkFederation ? null : $this->userId;
$helloAuthParams20CloudId = $isTalkFederation ? $this->federationAuthenticator->getCloudId() : null;
$helloAuthParams = [
'1.0' => [
'userid' => $this->userId,
'ticket' => $this->talkConfig->getSignalingTicket(Config::SIGNALING_TICKET_V1, $this->userId),
],
'2.0' => [
'token' => $this->talkConfig->getSignalingTicket(Config::SIGNALING_TICKET_V2, $helloAuthParams20UserId, $helloAuthParams20CloudId),
],
];
$data['ticket'] = $helloAuthParams['1.0']['ticket'];
$data['helloAuthParams'] = $helloAuthParams;
}
return new DataResponse($data);
}
/**
* @psalm-return ?TalkSignalingFederationSettings
*/
private function getFederationSettings(?Room $room): ?array {
if ($room === null || !$room->isFederatedConversation()) {
return null;
}
try {
$participant = $this->participantService->getParticipant($room, $this->userId);
} catch (ParticipantNotFoundException $e) {
return null;
}
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\SignalingController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\SignalingController::class);
$response = $proxy->getSettings($room, $participant);
if ($response->getStatus() === Http::STATUS_NOT_FOUND) {
return null;
}
/** @var TalkSignalingSettings $data */
$data = $response->getData();
return [
'server' => $data['server'],
'nextcloudServer' => $room->getRemoteServer(),
'helloAuthParams' => [
'token' => $data['helloAuthParams']['2.0']['token'],
],
'roomId' => $room->getRemoteToken(),
];
}
/**
* Get the welcome message from a signaling server
*
* Only available for logged-in users because guests can not use the apps
* right now.
*
* @param int $serverId ID of the signaling server
* @psalm-param non-negative-int $serverId
* @return DataResponse<Http::STATUS_OK, array{version: string, warning?: string, features?: non-empty-list<string>}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string, version?: string}, array{}>
*
* 200: Welcome message returned
* 404: Signaling server not found
*/
#[OpenAPI(scope: OpenAPI::SCOPE_ADMINISTRATION, tags: ['settings'])]
public function getWelcomeMessage(int $serverId): DataResponse {
try {
$testResult = $this->signalingManager->checkServerCompatibility($serverId);
} catch (\OutOfBoundsException) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
return new DataResponse($testResult['data'], $testResult['status']);
}
/**
* Send signaling messages
*
* @param string $token Token of the room
* @param string $messages JSON encoded messages
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, string, array{}>
*
* 200: Signaling message sent successfully
* 400: Sending signaling message is not possible
*/
#[PublicPage]
#[OpenAPI(tags: ['internal_signaling'])]
public function sendMessages(string $token, string $messages): DataResponse {
if ($this->talkConfig->getSignalingMode() !== Config::SIGNALING_INTERNAL) {
return new DataResponse('Internal signaling disabled.', Http::STATUS_BAD_REQUEST);
}
$response = [];
$messages = json_decode($messages, true);
foreach ($messages as $message) {
$ev = $message['ev'];
switch ($ev) {
case 'message':
$fn = $message['fn'];
if (!is_string($fn)) {
break;
}
$decodedMessage = json_decode($fn, true);
if ($message['sessionId'] !== $this->session->getSessionForRoom($token)) {
break;
}
$decodedMessage['from'] = $message['sessionId'];
$room = $this->manager->getRoomForSession($this->userId, $message['sessionId']);
$participant = $this->participantService->getParticipantBySession($room, $message['sessionId']);
try {
$this->participantService->getParticipantBySession($room, $decodedMessage['to']);
} catch (ParticipantNotFoundException) {
break;
}
if ($decodedMessage['type'] === 'control') {
if (!$participant->hasModeratorPermissions(false)) {
break;
}
} elseif ($decodedMessage['type'] === 'offer' || $decodedMessage['type'] === 'answer') {
if (!($participant->getPermissions() & Attendee::PERMISSIONS_PUBLISH_AUDIO) && $decodedMessage['roomType'] === 'video'
&& $this->isTryingToPublishMedia($decodedMessage['payload']['sdp'], 'audio')) {
break;
}
if (!($participant->getPermissions() & Attendee::PERMISSIONS_PUBLISH_VIDEO) && $decodedMessage['roomType'] === 'video'
&& $this->isTryingToPublishMedia($decodedMessage['payload']['sdp'], 'video')) {
break;
}
if (!($participant->getPermissions() & Attendee::PERMISSIONS_PUBLISH_SCREEN) && $decodedMessage['roomType'] === 'screen'
&& ($this->isTryingToPublishMedia($decodedMessage['payload']['sdp'], 'audio')
|| $this->isTryingToPublishMedia($decodedMessage['payload']['sdp'], 'video'))) {
break;
}
}
$this->messages->addMessage($message['sessionId'], $decodedMessage['to'], json_encode($decodedMessage));
break;
}
}
return new DataResponse(null);
}
/**
* Returns whether the SDP is trying to publish the given media based on the
* media direction.
*
* The SDP is trying to publish if the related media description contains a
* media direction of either "sendrecv" or "sendonly". If no media direction
* is provided in a media description the media direction in the session
* description is used instead. If that is not provided either then
* "sendrecv" is assumed.
*
* See https://www.rfc-editor.org/rfc/rfc8866.html#name-media-direction-attributes
*
* @param string $sdp the SDP to check
* @param string $media the media to check, either "audio" or "video"
* @return bool true if it is trying to publish, false otherwise
*/
private function isTryingToPublishMedia(string $sdp, string $media): bool {
$lines = preg_split('/\r\n|\n|\r/', $sdp);
$sessionMediaDirectionIndex = -1;
$mediaDirectionIndex = -1;
$mediaDescriptionIndex = -1;
$matchingMediaDescriptionIndex = -1;
for ($i = 0; $i < count($lines); $i++) {
if (strpos($lines[$i], 'a=sendrecv') === 0
|| strpos($lines[$i], 'a=sendonly') === 0
|| strpos($lines[$i], 'a=recvonly') === 0
|| strpos($lines[$i], 'a=inactive') === 0) {
$mediaDirectionIndex = $i;
if ($mediaDescriptionIndex < 0) {
$sessionMediaDirectionIndex = $mediaDirectionIndex;
}
if ($matchingMediaDescriptionIndex >= 0
&& $matchingMediaDescriptionIndex >= $mediaDescriptionIndex
&& $mediaDirectionIndex > $matchingMediaDescriptionIndex
&& (strpos($lines[$mediaDirectionIndex], 'a=sendrecv') === 0
|| strpos($lines[$mediaDirectionIndex], 'a=sendonly') === 0)) {
return true;
}
} elseif (strpos($lines[$i], 'm=') === 0) {
// No media direction in previous matching media description,
// fallback to media direction in the session description or, if
// not set, default to "sendrecv".
if ($matchingMediaDescriptionIndex >= 0
&& $matchingMediaDescriptionIndex >= $mediaDescriptionIndex
&& $mediaDirectionIndex < $matchingMediaDescriptionIndex
&& ($sessionMediaDirectionIndex < 0
|| strpos($lines[$sessionMediaDirectionIndex], 'a=sendrecv') === 0
|| strpos($lines[$sessionMediaDirectionIndex], 'a=sendonly') === 0)) {
return true;
}
$mediaDescriptionIndex = $i;
if (strpos($lines[$i], 'm=' . $media) === 0) {
$matchingMediaDescriptionIndex = $i;
}
}
}
// No media direction in last matching media description, fallback to
// media direction in the session description or, if not set, default to
// "sendrecv".
if ($matchingMediaDescriptionIndex >= 0
&& $matchingMediaDescriptionIndex >= $mediaDescriptionIndex
&& $mediaDirectionIndex < $matchingMediaDescriptionIndex
&& ($sessionMediaDirectionIndex < 0
|| strpos($lines[$sessionMediaDirectionIndex], 'a=sendrecv') === 0
|| strpos($lines[$sessionMediaDirectionIndex], 'a=sendonly') === 0)) {
return true;
}
return false;
}
/**
* Get signaling messages
*
* @param string $token Token of the room
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND|Http::STATUS_CONFLICT, list<array{type: string, data: list<TalkSignalingSession>|string}>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, string, array{}>
*
* 200: Signaling messages returned
* 400: Getting signaling messages is not possible
* 404: Session, room or participant not found
* 409: Session killed
*/
#[PublicPage]
#[OpenAPI(tags: ['internal_signaling'])]
public function pullMessages(string $token): DataResponse {
if ($this->talkConfig->getSignalingMode() !== Config::SIGNALING_INTERNAL) {
return new DataResponse('Internal signaling disabled.', Http::STATUS_BAD_REQUEST);
}
$data = [];
$seconds = self::PULL_MESSAGES_TIMEOUT;
try {
$sessionId = $this->session->getSessionForRoom($token);
if ($sessionId === null) {
// User is not active in this room
return new DataResponse([['type' => 'usersInRoom', 'data' => []]], Http::STATUS_NOT_FOUND);
}
$room = $this->manager->getRoomForSession($this->userId, $sessionId);
$participant = $this->participantService->getParticipantBySession($room, $sessionId); // FIXME this causes another query
$pingTimestamp = $this->timeFactory->getTime();
if ($participant->getSession() instanceof Session) {
$this->sessionService->updateLastPing($participant->getSession(), $pingTimestamp);
}
} catch (RoomNotFoundException) {
$this->banIpIfGuestGotBanned($token);
return new DataResponse([['type' => 'usersInRoom', 'data' => []]], Http::STATUS_NOT_FOUND);
}
while ($seconds > 0) {
// Query all messages and send them to the user
$data = $this->messages->getAndDeleteMessages($sessionId);
$messageCount = count($data);
$data = array_filter($data, function ($message) {
return $message['data'] !== 'refresh-participant-list';
});
// Make sure the array is a json array not a json object,
// because the index list has a gap
$data = array_values($data);
if ($messageCount !== count($data)) {
// Participant list changed, bail out and deliver the info to the user
break;
}
$this->dbConnection->close();
if (empty($data)) {
$seconds--;
} else {
break;
}
sleep(1);
// Refresh the session and retry
$sessionId = $this->session->getSessionForRoom($token);
if ($sessionId === null) {
// User is not active in this room
return new DataResponse([['type' => 'usersInRoom', 'data' => []]], Http::STATUS_NOT_FOUND);
}
}
try {
// Add an update of the room participants at the end of the waiting
$room = $this->manager->getRoomForSession($this->userId, $sessionId);
$data[] = ['type' => 'usersInRoom', 'data' => $this->getUsersInRoom($room, $pingTimestamp)];
} catch (RoomNotFoundException) {
$this->banIpIfGuestGotBanned($token);
$data[] = ['type' => 'usersInRoom', 'data' => []];
// Was the session killed or the complete conversation?
try {
$room = $this->manager->getRoomForUserByToken($token, $this->userId);
if ($this->userId) {
// For logged in users we check if they are still part of the public conversation,
// if not they were removed instead of having a conflict.
$this->participantService->getParticipant($room, $this->userId, false);
}
// Session was killed, make the UI redirect to an error
return new DataResponse($data, Http::STATUS_CONFLICT);
} catch (ParticipantNotFoundException $e) {
// User removed from conversation, bye!
return new DataResponse($data, Http::STATUS_NOT_FOUND);
} catch (RoomNotFoundException $e) {
// Complete conversation was killed, bye!
return new DataResponse($data, Http::STATUS_NOT_FOUND);
}
}
return new DataResponse($data);
}
/**
* @param Room $room
* @param int $pingTimestamp
* @return list<TalkSignalingSession>
*/
protected function getUsersInRoom(Room $room, int $pingTimestamp): array {
$usersInRoom = [];
// Get participants active in the last 40 seconds (an extra time is used
// to include other participants pinging almost at the same time as the
// current user), or since the last signaling ping of the current user
// if it was done more than 40 seconds ago.
$timestamp = min($this->timeFactory->getTime() - (self::PULL_MESSAGES_TIMEOUT + 10), $pingTimestamp);
// "- 1" is needed because only the participants whose last ping is
// greater than the given timestamp are returned.
$participants = $this->participantService->getParticipantsForAllSessions($room, $timestamp - 1);
foreach ($participants as $participant) {
$session = $participant->getSession();
if (!$session instanceof Session) {
// This is just to make Psalm happy, since we select by session it's always with one.
continue;
}
$userId = '';
if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
$userId = $participant->getAttendee()->getActorId();
}
$usersInRoom[] = [
'userId' => $userId,
'roomId' => $room->getId(),
'lastPing' => $session->getLastPing(),
'sessionId' => $session->getSessionId(),
'inCall' => $session->getInCall(),
'participantPermissions' => $participant->getPermissions(),
'actorType' => $participant->getAttendee()->getActorType(),
'actorId' => $participant->getAttendee()->getActorId(),
];
}
return $usersInRoom;
}
protected function banIpIfGuestGotBanned(string $token): void {
if ($this->userId !== null) {
return;
}
try {
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException) {
return;
}
try {
$this->banService->throwIfActorIsBanned($room, null);
} catch (ForbiddenException) {
}
}
/**
* Check if the current request is coming from an allowed backend.
*
* The backends are sending the custom header "Talk-Signaling-Random"
* containing at least 32 bytes random data, and the header
* "Talk-Signaling-Checksum", which is the SHA256-HMAC of the random data
* and the body of the request, calculated with the shared secret from the
* configuration.
*
* @param string $data
* @return bool
*/
private function validateBackendRequest(string $data): bool {
$random = $this->request->getHeader('spreed-signaling-random');
$checksum = $this->request->getHeader('spreed-signaling-checksum');
$secret = $this->talkConfig->getSignalingSecret();
try {
return $this->checksumVerificationService->validateRequest($random, $checksum, $secret, $data);
} catch (UnauthorizedException) {
return false;
}
}
/**
* Return the body of the backend request. This can be overridden in
* tests.
*
* @return string
*/
protected function getInputStream(): string {
return (string)file_get_contents('php://input');
}
/**
* Backend API to query information required for standalone signaling
* servers
*
* See sections "Backend validation" in
* https://nextcloud-spreed-signaling.readthedocs.io/en/latest/standalone-signaling-api-v1/#backend-requests
*
* @return DataResponse<Http::STATUS_OK, array{type: string, error?: array{code: string, message: string}, auth?: array{version: string, userid?: string, user?: array<string, mixed>}, room?: array{version: string, roomid?: string, properties?: array<string, mixed>, permissions?: list<string>, session?: array<string, mixed>}}, array{}>
*
* 200: Always, sorry about that
*/
#[OpenAPI(scope: 'backend-signaling')]
#[PublicPage]
#[BruteForceProtection(action: 'talkSignalingSecret')]
#[RequestHeader(name: 'spreed-signaling-random', description: 'Random seed used to generate the request checksum', indirect: true)]
#[RequestHeader(name: 'spreed-signaling-checksum', description: 'Checksum over the request body to verify authenticity from the signaling backend', indirect: true)]
public function backend(): DataResponse {
$json = $this->getInputStream();
if (!$this->validateBackendRequest($json)) {
$response = new DataResponse([
'type' => 'error',
'error' => [
'code' => 'invalid_request',
'message' => 'The request could not be authenticated.',
],
]);
$response->throttle(['action' => 'talkSignalingSecret']);
return $response;
}
$message = json_decode($json, true);
switch ($message['type'] ?? '') {
case 'auth':
// Query authentication information about a user.
return $this->backendAuth($message['auth']);
case 'room':
// Query information about a room.
return $this->backendRoom($message['room']);
case 'ping':
// Ping sessions connected to a room.
return $this->backendPing($message['ping']);
default:
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'unknown_type',
'message' => 'The given type ' . json_encode($message) . ' is not supported.',
],
]);
}
}
/**
* @return DataResponse<Http::STATUS_OK, array{type: string, error?: array{code: string, message: string}, auth?: array{version: string, userid?: string, user?: array<string, mixed>}}, array{}>
*/
private function backendAuth(array $auth): DataResponse {
$params = $auth['params'];
$userId = $params['userid'];
if (!$this->talkConfig->validateSignalingTicket($userId, $params['ticket'])) {
$this->logger->debug('Signaling ticket for {user} was not valid', [
'user' => !empty($userId) ? $userId : '(guests)',
'app' => 'spreed-hpb',
]);
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'invalid_ticket',
'message' => 'The given ticket is not valid for this user.',
],
]);
}
if (!empty($userId)) {
$user = $this->userManager->get($userId);
if (!$user instanceof IUser) {
$this->logger->debug('Tried to validate signaling ticket for {user}, but user manager returned no user', [
'user' => $userId,
'app' => 'spreed-hpb',
]);
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_user',
'message' => 'The given user does not exist.',
],
]);
}
}
$response = [
'type' => 'auth',
'auth' => [
'version' => '1.0',
],
];
if (!empty($userId)) {
$response['auth']['userid'] = $user->getUID();
$response['auth']['user'] = $this->talkConfig->getSignalingUserData($user);
}
$this->logger->debug('Validated signaling ticket for {user}', [
'user' => !empty($userId) ? $userId : '(guests)',
'app' => 'spreed-hpb',
]);
return new DataResponse($response);
}
/**
* @return DataResponse<Http::STATUS_OK, array{type: string, error?: array{code: string, message: string}, room?: array{version: string, roomid: string, properties: array<string, mixed>, permissions: list<string>, session?: array<string, mixed>}}, array{}>
*/
private function backendRoom(array $roomRequest): DataResponse {
$token = $roomRequest['roomid']; // It's actually the room token
$userId = $roomRequest['userid'];
$sessionId = $roomRequest['sessionid'];
$action = !empty($roomRequest['action']) ? $roomRequest['action'] : 'join';
$actorId = $roomRequest['actorid'] ?? null;
$actorType = $roomRequest['actortype'] ?? null;
$inCall = $roomRequest['incall'] ?? null;
$participant = null;
if ($actorId !== null && $actorType !== null) {
try {
$room = $this->manager->getRoomByActor($token, $actorType, $actorId);
} catch (RoomNotFoundException $e) {
$this->logger->debug('Failed to get room {token} by actor {actorType}/{actorId}', [
'token' => $token,
'actorType' => $actorType ?? 'null',
'actorId' => $actorId ?? 'null',
'app' => 'spreed-hpb',
'hpbRequest' => json_encode($roomRequest),
]);
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'The user is not invited to this room.',
],
]);
}
if ($sessionId) {
try {
$participant = $this->participantService->getParticipantBySession($room, $sessionId);
} catch (ParticipantNotFoundException $e) {
if ($action === 'join') {
// If the user joins the session might not be known to the server yet.
// In this case we load by actor information and use the session id as new session.
try {
$participant = $this->participantService->getParticipantByActor($room, $actorType, $actorId);
} catch (ParticipantNotFoundException $e) {
}
}
}
} else {
try {
$participant = $this->participantService->getParticipantByActor($room, $actorType, $actorId);
} catch (ParticipantNotFoundException $e) {
}
}
} else {
try {
// FIXME Don't preload with the user as that misses the session, kinda meh.
$room = $this->manager->getRoomByToken($token);
} catch (RoomNotFoundException $e) {
$this->logger->debug('Failed to get room by token {token}', [
'token' => $token,
'app' => 'spreed-hpb',
'hpbRequest' => json_encode($roomRequest),
]);
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'The user is not invited to this room.',
],
]);
}
if ($sessionId) {
try {
$participant = $this->participantService->getParticipantBySession($room, $sessionId);
} catch (ParticipantNotFoundException $e) {
}
} elseif (!empty($userId)) {
// User trying to join room.
try {
$participant = $this->participantService->getParticipant($room, $userId, false);
} catch (ParticipantNotFoundException $e) {
}
}
}
if (!$participant instanceof Participant) {
$this->logger->debug('Failed to get room {token} with participant', [
'token' => $token,
'app' => 'spreed-hpb',
'hpbRequest' => json_encode($roomRequest),
]);
// Return generic error to avoid leaking which rooms exist.
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'no_such_room',
'message' => 'The user is not invited to this room.',
],
]);
}
if ($action === 'join') {
if ($sessionId && !$participant->getSession() instanceof Session) {
try {
$session = $this->sessionService->createSessionForAttendee($participant->getAttendee(), $sessionId);
} catch (Exception $e) {
return new DataResponse([
'type' => 'error',
'error' => [
'code' => 'duplicate_session',
'message' => 'The given session is already in use.',
],
]);
}
$participant->setSession($session);
}
if ($participant->getSession() instanceof Session) {
if ($inCall !== null) {
$lastJoinedCall = $this->timeFactory->getDateTime();
$this->participantService->changeInCall($room, $participant, $inCall, lastJoinedCall: $lastJoinedCall->getTimestamp());
$this->roomService->setActiveSince($room, $participant, $lastJoinedCall, callFlag: $inCall, silent: false);
}
$this->sessionService->updateLastPing($participant->getSession(), $this->timeFactory->getTime());
}
} elseif ($action === 'leave') {
$this->participantService->leaveRoomAsSession($room, $participant);
}
$this->logger->debug('Room request to "{action}" room {token} by actor {actorType}/{actorId}', [
'token' => $token,
'action' => $action ?? 'null',
'actorType' => $participant->getAttendee()->getActorType(),
'actorId' => $participant->getAttendee()->getActorId(),
'app' => 'spreed-hpb',
'hpbRequest' => json_encode($roomRequest),
]);
$permissions = [];
if ($participant->getPermissions() & Attendee::PERMISSIONS_PUBLISH_AUDIO) {
$permissions[] = 'publish-audio';
}
if ($participant->getPermissions() & Attendee::PERMISSIONS_PUBLISH_VIDEO) {
$permissions[] = 'publish-video';
}
if ($participant->getPermissions() & Attendee::PERMISSIONS_PUBLISH_SCREEN) {
$permissions[] = 'publish-screen';
}
if ($participant->hasModeratorPermissions(false)) {
$permissions[] = 'control';
}
$event = new BeforeSignalingResponseSentEvent($room, $participant, $action);
$this->dispatcher->dispatchTyped($event);
$response = [
'type' => 'room',
'room' => [
'version' => '1.0',
'roomid' => $room->getToken(),
'properties' => $room->getPropertiesForSignaling((string)$userId),
'permissions' => $permissions,
],
];
if (!empty($event->getSession())) {
$response['room']['session'] = $event->getSession();
}
return new DataResponse($response);
}
/**
* @return DataResponse<Http::STATUS_OK, array{type: string, room: array{version: string}}, array{}>
*/
private function backendPing(array $request): DataResponse {
$pingSessionIds = [];
$now = $this->timeFactory->getTime();
foreach ($request['entries'] as $entry) {
if ($entry['sessionid'] !== '0') {
$pingSessionIds[] = $entry['sessionid'];
}
}
// Ping all active sessions with one query
$this->sessionService->updateMultipleLastPings($pingSessionIds, $now);
$response = [
'type' => 'room',
'room' => [
'version' => '1.0',
],
];
$this->logger->debug('Pinged {numSessions} sessions {token}', [
'numSessions' => count($pingSessionIds),
'token' => !empty($request['roomid']) ? ('in room ' . $request['roomid']) : '',
'app' => 'spreed-hpb',
]);
return new DataResponse($response);
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OC\Files\Filesystem;
use OC\NotSquareException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IAvatarManager;
use OCP\IL10N;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
class TempAvatarController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private IAvatarManager $avatarManager,
private IL10N $l,
private LoggerInterface $logger,
private string $userId,
) {
parent::__construct($appName, $request);
}
/**
* Upload your avatar as a user
*
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
*
* 200: Avatar uploaded successfully
* 400: Uploading avatar is not possible
*/
#[NoAdminRequired]
#[OpenAPI(tags: ['user_avatar'])]
public function postAvatar(): DataResponse {
$files = $this->request->getUploadedFile('files');
if (is_null($files)) {
return new DataResponse(
['message' => $this->l->t('No image file provided')],
Http::STATUS_BAD_REQUEST
);
}
if (
$files['error'][0] === 0
&& is_uploaded_file($files['tmp_name'][0])
&& !Filesystem::isFileBlacklisted($files['tmp_name'][0])
) {
if ($files['size'][0] > 20 * 1024 * 1024) {
return new DataResponse(
['message' => $this->l->t('File is too big')],
Http::STATUS_BAD_REQUEST
);
}
$content = file_get_contents($files['tmp_name'][0]);
// noopengrep: php.lang.security.unlink-use.unlink-use
unlink($files['tmp_name'][0]);
} else {
return new DataResponse(
['message' => $this->l->t('Invalid file provided')],
Http::STATUS_BAD_REQUEST
);
}
try {
$image = new \OCP\Image();
$image->loadFromData($content);
$image->readExif($content);
$image->fixOrientation();
if (!$image->valid()) {
return new DataResponse(
['data' => ['message' => $this->l->t('Invalid image')]],
Http::STATUS_BAD_REQUEST
);
}
$mimeType = $image->mimeType();
if ($mimeType !== 'image/jpeg' && $mimeType !== 'image/png') {
return new DataResponse(
['data' => ['message' => $this->l->t('Unknown filetype')]],
Http::STATUS_BAD_REQUEST
);
}
$avatar = $this->avatarManager->getAvatar($this->userId);
$avatar->set($image);
return new DataResponse(null);
} catch (NotSquareException $e) {
return new DataResponse(['message' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
} catch (\Exception $e) {
$this->logger->error('Failed to post avatar', [
'exception' => $e,
]);
return new DataResponse(['message' => $this->l->t('An error occurred. Please contact your administrator.')], Http::STATUS_BAD_REQUEST);
}
}
/**
* Delete your avatar as a user
*
* @return DataResponse<Http::STATUS_OK, null, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'avatar'}, array{}>
*
* 200: Avatar deleted successfully
* 400: Deleting avatar is not possible
*/
#[NoAdminRequired]
#[OpenAPI(tags: ['user_avatar'])]
public function deleteAvatar(): DataResponse {
try {
$avatar = $this->avatarManager->getAvatar($this->userId);
$avatar->remove();
return new DataResponse(null);
} catch (\Exception $e) {
$this->logger->error('Failed to delete avatar', [
'exception' => $e,
]);
return new DataResponse(['error' => 'avatar'], Http::STATUS_BAD_REQUEST);
}
}
}
+397
View File
@@ -0,0 +1,397 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Controller;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Chat\MessageParser;
use OCA\Talk\Manager;
use OCA\Talk\Middleware\Attribute\FederationSupported;
use OCA\Talk\Middleware\Attribute\RequireModeratorOrNoLobby;
use OCA\Talk\Middleware\Attribute\RequireParticipant;
use OCA\Talk\Middleware\Attribute\RequirePermission;
use OCA\Talk\Middleware\Attribute\RequireReadWriteConversation;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Thread;
use OCA\Talk\Model\ThreadAttendee;
use OCA\Talk\Participant;
use OCA\Talk\ResponseDefinitions;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\ThreadService;
use OCA\Talk\Share\Helper\Preloader;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\Attribute\RequestHeader;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Comments\NotFoundException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* @psalm-import-type TalkThreadInfo from ResponseDefinitions
*/
class ThreadController extends AEnvironmentAwareOCSController {
public function __construct(
string $appName,
IRequest $request,
protected Manager $manager,
protected ChatManager $chatManager,
protected Preloader $sharePreloader,
protected MessageParser $messageParser,
protected ParticipantService $participantService,
protected ThreadService $threadService,
protected ITimeFactory $timeFactory,
protected IL10N $l,
protected IEventDispatcher $eventDispatcher,
protected LoggerInterface $logger,
protected ?string $userId,
) {
parent::__construct($appName, $request);
}
/**
* Get recent active threads in a conversation
*
* Required capability: `threads`
*
* @param int<1, 50> $limit Number of threads to return
* @return DataResponse<Http::STATUS_OK, list<TalkThreadInfo>, array{}>
*
* 200: List of threads returned
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/chat/{token}/threads/recent', requirements: [
'apiVersion' => '(v1)',
'token' => '[a-z0-9]{4,30}',
])]
public function getRecentActiveThreads(int $limit = 50): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ThreadController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ThreadController::class);
return $proxy->getRecentActiveThreads($this->room, $this->participant, $limit);
}
$threads = $this->threadService->getRecentByRoomId($this->room, $limit);
$list = $this->prepareListOfThreads($threads);
return new DataResponse($list);
}
/**
* Get subscribed threads for a user
*
* Required capability: `threads`
*
* @param int<1, 100> $limit Number of threads to return
* @param non-negative-int $offset Offset in the threads list
* @return DataResponse<Http::STATUS_OK, list<TalkThreadInfo>, array{}>
*
* 200: List of threads returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/chat/subscribed-threads', requirements: [
'apiVersion' => '(v1)',
])]
public function getSubscribedThreads(int $limit = 100, int $offset = 0): DataResponse {
$results = $this->threadService->getRecentByActor(Attendee::ACTOR_USERS, $this->userId, $limit, $offset);
$roomIds = array_keys($results);
$rooms = $this->manager->getRoomsByIdForUser($roomIds, $this->userId);
$threads = $threadAttendees = [];
foreach ($results as $roomId => $data) {
if (!isset($rooms[$roomId])) {
continue;
}
foreach ($data as $threadData) {
$threads[] = $threadData['thread'];
$threadAttendees[$threadData['thread']->getId()] = $threadData['attendee'];
}
}
// Sort by last activity again
usort($threads, static function (Thread $a, Thread $b): int {
if ($b->getLastActivity() === $a->getLastActivity()) {
return $b->getId() <=> $a->getId();
}
return $b->getLastActivity() <=> $a->getLastActivity();
});
return new DataResponse($this->prepareListOfThreads($threads, $threadAttendees, $rooms));
}
/**
* Get thread info of a single thread
*
* Required capability: `threads`
*
* @param int $threadId The thread ID to get the info for
* @psalm-param non-negative-int $threadId
* @return DataResponse<Http::STATUS_OK, TalkThreadInfo, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'thread'|'status'}, array{}>
*
* 200: Thread info returned
* 404: Thread not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
#[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/chat/{token}/threads/{threadId}', requirements: [
'apiVersion' => '(v1)',
'token' => '[a-z0-9]{4,30}',
'threadId' => '[0-9]+',
])]
public function getThread(int $threadId): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ThreadController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ThreadController::class);
return $proxy->getThread($this->room, $this->participant, $threadId);
}
try {
$thread = $this->threadService->findByThreadId($this->room->getId(), $threadId);
} catch (DoesNotExistException) {
return new DataResponse(['error' => 'thread'], Http::STATUS_NOT_FOUND);
}
$list = $this->prepareListOfThreads([$thread]);
/** @var TalkThreadInfo $threadInfo */
$threadInfo = array_shift($list);
return new DataResponse($threadInfo);
}
/**
* Rename a thread
*
* Required capability: `threads`
*
* @param int $threadId The thread ID to get the info for
* @psalm-param non-negative-int $threadId
* @param string $threadTitle New thread title, must not be empty
* @return DataResponse<Http::STATUS_OK, TalkThreadInfo, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'title'}, array{}>|DataResponse<Http::STATUS_FORBIDDEN, array{error: 'permission'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'thread'}, array{}>
*
* 200: Thread renamed successfully
* 400: When the provided title is empty
* 403: Not allowed, either not the original author or not a moderator
* 404: Thread not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
#[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/chat/{token}/threads/{threadId}', requirements: [
'apiVersion' => '(v1)',
'token' => '[a-z0-9]{4,30}',
'threadId' => '[0-9]+',
])]
public function renameThread(int $threadId, string $threadTitle): DataResponse {
$threadTitle = trim($threadTitle);
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ThreadController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ThreadController::class);
return $proxy->renameThread($this->room, $this->participant, $threadId, $threadTitle);
}
try {
$thread = $this->threadService->findByThreadId($this->room->getId(), $threadId);
} catch (DoesNotExistException) {
return new DataResponse(['error' => 'thread'], Http::STATUS_NOT_FOUND);
}
$attendee = $this->participant->getAttendee();
$isOwnMessage = false;
try {
$comment = $this->chatManager->getComment($this->room, (string)$threadId);
$isOwnMessage = $comment->getActorType() === $attendee->getActorType()
&& $comment->getActorId() === $attendee->getActorId();
} catch (NotFoundException) {
// Root message expired, only moderators can edit
}
if (!$isOwnMessage
&& !$this->participant->hasModeratorPermissions(false)) {
// Actor is not a moderator or not the owner of the message
return new DataResponse(['error' => 'permission'], Http::STATUS_FORBIDDEN);
}
try {
$this->threadService->renameThread($thread, $threadTitle);
} catch (\InvalidArgumentException) {
return new DataResponse(['error' => 'title'], Http::STATUS_BAD_REQUEST);
}
try {
$comment = $this->chatManager->getComment($this->room, (string)$threadId);
} catch (NotFoundException) {
// Root message expired, continuing without replying
$comment = null;
}
$this->chatManager->addSystemMessage(
$this->room,
$this->participant,
$this->participant->getAttendee()->getActorType(),
$this->participant->getAttendee()->getActorId(),
json_encode(['message' => 'thread_renamed', 'parameters' => ['thread' => $threadId, 'title' => $thread->getName()]]),
$this->timeFactory->getDateTime(),
false,
null,
$comment,
true,
true,
$threadId,
);
$list = $this->prepareListOfThreads([$thread]);
/** @var TalkThreadInfo $threadInfo */
$threadInfo = array_shift($list);
return new DataResponse($threadInfo);
}
/**
* @param list<Thread> $threads
* @param ?list<ThreadAttendee> $attendees
* @return list<TalkThreadInfo>
*/
protected function prepareListOfThreads(array $threads, ?array $attendees = null, ?array $rooms = null): array {
$threadIds = array_map(static fn (Thread $thread) => $thread->getId(), $threads);
if ($attendees === null) {
$attendees = $this->threadService->findAttendeeByThreadIds($this->participant->getAttendee(), $threadIds);
}
if ($rooms === null) {
$rooms = [$this->room->getId() => $this->room];
$participants = [$this->room->getId() => $this->participant];
}
$messageIds = [];
foreach ($threads as $thread) {
$messageIds[] = $thread->getId();
$messageIds[] = $thread->getLastMessageId();
}
$comments = $this->chatManager->getMessagesById($messageIds);
$this->sharePreloader->preloadShares($comments);
$list = [];
foreach ($threads as $thread) {
if (!isset($rooms[$thread->getRoomId()])) {
continue;
}
$room = $rooms[$thread->getRoomId()];
// The getParticipant here should read only from the cache, so it's no problem inside the loop
$participant = $participants[$thread->getRoomId()] ?? $this->participantService->getParticipant($room, $this->userId);
$firstMessage = $lastMessage = null;
$attendee = $attendees[$thread->getId()] ?? null;
if ($attendee === null) {
$attendee = ThreadAttendee::createFromParticipant($thread->getId(), $participant);
}
$first = $comments[$thread->getId()] ?? null;
if ($first !== null) {
$firstMessage = $this->messageParser->createMessage($room, $participant, $first, $this->l);
$this->messageParser->parseMessage($firstMessage);
}
$last = $comments[$thread->getLastMessageId()] ?? null;
if ($last !== null) {
$lastMessage = $this->messageParser->createMessage($room, $participant, $last, $this->l);
$this->messageParser->parseMessage($lastMessage);
}
$list[] = [
'thread' => $thread->toArray($room),
'attendee' => $attendee->jsonSerialize(),
'first' => $firstMessage?->toArray($this->getResponseFormat(), $thread),
'last' => $lastMessage?->toArray($this->getResponseFormat(), $thread),
];
}
return $list;
}
/**
* Set notification level for a specific thread
*
* Required capability: `threads`
*
* @param int $messageId The message to create a thread for (Doesn't have to be the root)
* @psalm-param non-negative-int $messageId
* @param int $level New level
* @psalm-param Participant::NOTIFY_* $level
* @return DataResponse<Http::STATUS_OK, TalkThreadInfo, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: 'level'|'message'|'status'|'top-most'}, array{}>
*
* 200: Successfully set notification level for thread
* 400: Notification level was invalid
* 404: Message or top most message not found
*/
#[FederationSupported]
#[PublicPage]
#[RequireModeratorOrNoLobby]
#[RequireParticipant]
#[RequirePermission(permission: RequirePermission::CHAT)]
#[RequireReadWriteConversation]
#[RequestHeader(name: 'x-nextcloud-federation', description: 'Set to 1 when the request is performed by another Nextcloud Server to indicate a federation request', indirect: true)]
#[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/chat/{token}/threads/{messageId}/notify', requirements: [
'apiVersion' => '(v1)',
'token' => '[a-z0-9]{4,30}',
'messageId' => '[0-9]+',
])]
public function setNotificationLevel(int $messageId, int $level): DataResponse {
if ($this->room->isFederatedConversation()) {
/** @var \OCA\Talk\Federation\Proxy\TalkV1\Controller\ThreadController $proxy */
$proxy = \OCP\Server::get(\OCA\Talk\Federation\Proxy\TalkV1\Controller\ThreadController::class);
$response = $proxy->setNotificationLevel($this->room, $this->participant, $messageId, $level);
if ($response->getStatus() === Http::STATUS_OK) {
// Also save locally, for later handling when receiving a federated message
$this->threadService->setNotificationLevel($this->participant->getAttendee(), $messageId, $level);
}
return $response;
}
if (!\in_array($level, [
Participant::NOTIFY_DEFAULT,
Participant::NOTIFY_ALWAYS,
Participant::NOTIFY_MENTION,
Participant::NOTIFY_NEVER,
], true)) {
return new DataResponse(['error' => 'level'], Http::STATUS_BAD_REQUEST);
}
try {
$thread = $this->threadService->findByThreadId($this->room->getId(), $messageId);
} catch (DoesNotExistException) {
return new DataResponse(['error' => 'message'], Http::STATUS_NOT_FOUND);
}
$threadAttendee = $this->threadService->setNotificationLevel($this->participant->getAttendee(), $thread->getId(), $level);
$attendees = [$thread->getId() => $threadAttendee];
$list = $this->prepareListOfThreads([$thread], $attendees);
/** @var TalkThreadInfo $threadInfo */
$threadInfo = array_shift($list);
return new DataResponse($threadInfo);
}
}