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
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:
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation;
|
||||
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
use OCP\IRequest;
|
||||
|
||||
class Authenticator {
|
||||
protected ?bool $isFederationRequest = null;
|
||||
protected ?string $federationCloudId = null;
|
||||
protected ?string $accessToken = null;
|
||||
protected ?Room $room = null;
|
||||
protected ?Participant $participant = null;
|
||||
|
||||
public function __construct(
|
||||
protected IRequest $request,
|
||||
protected ICloudIdManager $cloudIdManager,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function readHeaders(): void {
|
||||
$this->isFederationRequest = (bool)$this->request->getHeader('x-nextcloud-federation');
|
||||
if (!$this->isFederationRequest) {
|
||||
$this->federationCloudId = '';
|
||||
$this->accessToken = '';
|
||||
return;
|
||||
}
|
||||
|
||||
$authUser = $this->request->server['PHP_AUTH_USER'] ?? '';
|
||||
$authUser = urldecode($authUser);
|
||||
|
||||
try {
|
||||
$cloudId = $this->cloudIdManager->resolveCloudId($authUser);
|
||||
$this->federationCloudId = $cloudId->getId();
|
||||
$this->accessToken = $this->request->server['PHP_AUTH_PW'] ?? '';
|
||||
} catch (\InvalidArgumentException) {
|
||||
$this->isFederationRequest = false;
|
||||
$this->federationCloudId = '';
|
||||
$this->accessToken = '';
|
||||
}
|
||||
}
|
||||
|
||||
public function isFederationRequest(): bool {
|
||||
if ($this->isFederationRequest === null) {
|
||||
$this->readHeaders();
|
||||
|
||||
if ($this->isFederationRequest === null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->isFederationRequest;
|
||||
}
|
||||
|
||||
public function getCloudId(): string {
|
||||
if ($this->federationCloudId === null) {
|
||||
$this->readHeaders();
|
||||
|
||||
if ($this->federationCloudId === null) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return $this->federationCloudId;
|
||||
}
|
||||
|
||||
public function getAccessToken(): string {
|
||||
if ($this->accessToken === null) {
|
||||
$this->readHeaders();
|
||||
|
||||
if ($this->accessToken === null) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return $this->accessToken;
|
||||
}
|
||||
|
||||
public function authenticated(Room $room, Participant $participant): void {
|
||||
$this->room = $room;
|
||||
$this->participant = $participant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws RoomNotFoundException
|
||||
*/
|
||||
public function getRoom(): Room {
|
||||
if ($this->room === null) {
|
||||
throw new RoomNotFoundException();
|
||||
}
|
||||
return $this->room;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ParticipantNotFoundException
|
||||
*/
|
||||
public function getParticipant(): Participant {
|
||||
if ($this->participant === null) {
|
||||
throw new ParticipantNotFoundException();
|
||||
}
|
||||
return $this->participant;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation;
|
||||
|
||||
use OCA\FederatedFileSharing\AddressHandler;
|
||||
use OCA\Talk\Events\AParticipantModifiedEvent;
|
||||
use OCA\Talk\Events\ARoomModifiedEvent;
|
||||
use OCA\Talk\Exceptions\RoomHasNoModeratorException;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\RetryNotification;
|
||||
use OCA\Talk\Model\RetryNotificationMapper;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\Federation\ICloudFederationFactory;
|
||||
use OCP\Federation\ICloudFederationNotification;
|
||||
use OCP\Federation\ICloudFederationProviderManager;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
use OCP\HintException;
|
||||
use OCP\IURLGenerator;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\OCM\Exceptions\OCMProviderException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use SensitiveParameter;
|
||||
|
||||
class BackendNotifier {
|
||||
|
||||
public function __construct(
|
||||
private ICloudFederationFactory $cloudFederationFactory,
|
||||
private AddressHandler $addressHandler,
|
||||
private LoggerInterface $logger,
|
||||
private ICloudFederationProviderManager $federationProviderManager,
|
||||
private IUserManager $userManager,
|
||||
private IURLGenerator $url,
|
||||
private RetryNotificationMapper $retryNotificationMapper,
|
||||
private ITimeFactory $timeFactory,
|
||||
private ICloudIdManager $cloudIdManager,
|
||||
private RestrictionValidator $restrictionValidator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the invitation to participant to join the federated room
|
||||
* Sent from Host server to Remote participant server
|
||||
*
|
||||
* @return array{displayName: string, cloudId: string}|false
|
||||
* @throws HintException
|
||||
* @throws RoomHasNoModeratorException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function sendRemoteShare(
|
||||
string $providerId,
|
||||
string $token,
|
||||
string $shareWith,
|
||||
IUser $sharedBy,
|
||||
string $shareType,
|
||||
Room $room,
|
||||
Attendee $roomOwnerAttendee,
|
||||
): array|bool {
|
||||
$invitedCloudId = $this->cloudIdManager->resolveCloudId($shareWith);
|
||||
|
||||
$roomName = $room->getName();
|
||||
$roomType = $room->getType();
|
||||
$roomToken = $room->getToken();
|
||||
$roomDefaultPermissions = $room->getDefaultPermissions();
|
||||
|
||||
try {
|
||||
$this->restrictionValidator->isAllowedToInvite($sharedBy, $invitedCloudId);
|
||||
} catch (\InvalidArgumentException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var IUser $roomOwner */
|
||||
$roomOwner = $this->userManager->get($roomOwnerAttendee->getActorId());
|
||||
|
||||
$remote = $this->prepareRemoteUrl($invitedCloudId->getRemote());
|
||||
if (str_starts_with($remote, 'https://')) {
|
||||
$remote = substr($remote, 8);
|
||||
}
|
||||
|
||||
$shareWithCloudId = $invitedCloudId->getUser() . '@' . $remote;
|
||||
$share = $this->cloudFederationFactory->getCloudFederationShare(
|
||||
$shareWithCloudId,
|
||||
$roomToken,
|
||||
'',
|
||||
$providerId,
|
||||
$roomOwner->getCloudId(),
|
||||
$roomOwner->getDisplayName(),
|
||||
$sharedBy->getCloudId(),
|
||||
$sharedBy->getDisplayName(),
|
||||
$token,
|
||||
$shareType,
|
||||
FederationManager::TALK_ROOM_RESOURCE
|
||||
);
|
||||
|
||||
// Put room name info in the share
|
||||
$protocol = $share->getProtocol();
|
||||
$protocol['invitedCloudId'] = $invitedCloudId->getId();
|
||||
$protocol['roomName'] = $roomName;
|
||||
$protocol['roomType'] = $roomType;
|
||||
$protocol['roomDefaultPermissions'] = $roomDefaultPermissions;
|
||||
$protocol['name'] = FederationManager::TALK_PROTOCOL_NAME;
|
||||
$share->setProtocol($protocol);
|
||||
|
||||
try {
|
||||
$response = $this->federationProviderManager->sendCloudShare($share);
|
||||
if ($response->getStatusCode() === Http::STATUS_CREATED) {
|
||||
$body = $response->getBody();
|
||||
$data = json_decode((string)$body, true);
|
||||
if (isset($data['recipientUserId']) && $data['recipientUserId'] !== '') {
|
||||
$shareWithCloudId = $data['recipientUserId'] . '@' . $remote;
|
||||
}
|
||||
return [
|
||||
'displayName' => $data['recipientDisplayName'] ?: $shareWithCloudId,
|
||||
'cloudId' => $shareWithCloudId,
|
||||
];
|
||||
}
|
||||
|
||||
$this->logger->warning("Failed sharing $roomToken with $shareWith, received status code {code}\n{body}", [
|
||||
'code' => $response->getStatusCode(),
|
||||
'body' => (string)$response->getBody(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
} catch (OCMProviderException $e) {
|
||||
$this->logger->error("Failed sharing $roomToken with $shareWith, received OCMProviderException", ['exception' => $e]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The invited participant accepted joining the federated room
|
||||
* Sent from Remote participant server to Host server
|
||||
*
|
||||
* @return bool success
|
||||
*/
|
||||
public function sendShareAccepted(
|
||||
string $remoteServerUrl,
|
||||
int $remoteAttendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
string $displayName,
|
||||
string $cloudId,
|
||||
): bool {
|
||||
$remote = $this->prepareRemoteUrl($remoteServerUrl);
|
||||
|
||||
$notification = $this->cloudFederationFactory->getCloudFederationNotification();
|
||||
$notification->setMessage(
|
||||
FederationManager::NOTIFICATION_SHARE_ACCEPTED,
|
||||
FederationManager::TALK_ROOM_RESOURCE,
|
||||
(string)$remoteAttendeeId,
|
||||
[
|
||||
'remoteServerUrl' => $this->getServerRemoteUrl(),
|
||||
'sharedSecret' => $accessToken,
|
||||
'message' => 'Recipient accepted the share',
|
||||
'displayName' => $displayName,
|
||||
'cloudId' => $cloudId,
|
||||
]
|
||||
);
|
||||
|
||||
return $this->sendUpdateToRemote($remote, $notification, retry: false) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The invited participant declined joining the federated room
|
||||
* Sent from Remote participant server to Host server
|
||||
*/
|
||||
public function sendShareDeclined(
|
||||
string $remoteServerUrl,
|
||||
int $remoteAttendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
): void {
|
||||
$remote = $this->prepareRemoteUrl($remoteServerUrl);
|
||||
|
||||
$notification = $this->cloudFederationFactory->getCloudFederationNotification();
|
||||
$notification->setMessage(
|
||||
FederationManager::NOTIFICATION_SHARE_DECLINED,
|
||||
FederationManager::TALK_ROOM_RESOURCE,
|
||||
(string)$remoteAttendeeId,
|
||||
[
|
||||
'remoteServerUrl' => $this->getServerRemoteUrl(),
|
||||
'sharedSecret' => $accessToken,
|
||||
'message' => 'Recipient declined the share',
|
||||
]
|
||||
);
|
||||
|
||||
// We don't handle the return here as all local data is already deleted.
|
||||
// If the retry ever aborts due to "unknown" we are fine with it.
|
||||
$this->sendUpdateToRemote($remote, $notification);
|
||||
}
|
||||
|
||||
public function sendRemoteUnShare(
|
||||
string $remoteServerUrl,
|
||||
int $localAttendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
): void {
|
||||
$remote = $this->prepareRemoteUrl($remoteServerUrl);
|
||||
|
||||
$notification = $this->cloudFederationFactory->getCloudFederationNotification();
|
||||
$notification->setMessage(
|
||||
FederationManager::NOTIFICATION_SHARE_UNSHARED,
|
||||
FederationManager::TALK_ROOM_RESOURCE,
|
||||
(string)$localAttendeeId,
|
||||
[
|
||||
'remoteServerUrl' => $this->getServerRemoteUrl(),
|
||||
'sharedSecret' => $accessToken,
|
||||
'message' => 'This room has been unshared',
|
||||
]
|
||||
);
|
||||
|
||||
// We don't handle the return here as when the retry ever
|
||||
// aborts due to "unknown" we are fine with it.
|
||||
$this->sendUpdateToRemote($remote, $notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send information to remote participants that the room meta info updated
|
||||
* Sent from Host server to Remote participant server
|
||||
*/
|
||||
public function sendRoomModifiedUpdate(
|
||||
string $remoteServer,
|
||||
int $localAttendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
string $localToken,
|
||||
string $changedProperty,
|
||||
string|int|bool|null $newValue,
|
||||
string|int|bool|null $oldValue,
|
||||
): ?bool {
|
||||
$remote = $this->prepareRemoteUrl($remoteServer);
|
||||
|
||||
$notification = $this->cloudFederationFactory->getCloudFederationNotification();
|
||||
$notification->setMessage(
|
||||
FederationManager::NOTIFICATION_ROOM_MODIFIED,
|
||||
FederationManager::TALK_ROOM_RESOURCE,
|
||||
(string)$localAttendeeId,
|
||||
[
|
||||
'remoteServerUrl' => $this->getServerRemoteUrl(),
|
||||
'sharedSecret' => $accessToken,
|
||||
'remoteToken' => $localToken,
|
||||
'changedProperty' => $changedProperty,
|
||||
'newValue' => $newValue,
|
||||
'oldValue' => $oldValue,
|
||||
],
|
||||
);
|
||||
|
||||
return $this->sendUpdateToRemote($remote, $notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send information to remote participants that the participant meta info updated
|
||||
* Sent from Host server to Remote participant server (only for the affected participant)
|
||||
*/
|
||||
public function sendParticipantModifiedUpdate(
|
||||
string $remoteServer,
|
||||
int $localAttendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
string $localToken,
|
||||
string $changedProperty,
|
||||
string|int $newValue,
|
||||
string|int|null $oldValue,
|
||||
): ?bool {
|
||||
$remote = $this->prepareRemoteUrl($remoteServer);
|
||||
|
||||
$notification = $this->cloudFederationFactory->getCloudFederationNotification();
|
||||
$notification->setMessage(
|
||||
FederationManager::NOTIFICATION_PARTICIPANT_MODIFIED,
|
||||
FederationManager::TALK_ROOM_RESOURCE,
|
||||
(string)$localAttendeeId,
|
||||
[
|
||||
'remoteServerUrl' => $this->getServerRemoteUrl(),
|
||||
'sharedSecret' => $accessToken,
|
||||
'remoteToken' => $localToken,
|
||||
'changedProperty' => $changedProperty,
|
||||
'newValue' => $newValue,
|
||||
'oldValue' => $oldValue,
|
||||
],
|
||||
);
|
||||
|
||||
return $this->sendUpdateToRemote($remote, $notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send information to remote participants that "active since" was updated
|
||||
* Sent from Host server to Remote participant server
|
||||
*
|
||||
* @psalm-param array<AParticipantModifiedEvent::DETAIL_*, bool> $details
|
||||
*/
|
||||
public function sendCallStarted(
|
||||
string $remoteServer,
|
||||
int $localAttendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
string $localToken,
|
||||
string $changedProperty,
|
||||
\DateTime $activeSince,
|
||||
int $callFlag,
|
||||
array $details,
|
||||
): ?bool {
|
||||
$remote = $this->prepareRemoteUrl($remoteServer);
|
||||
|
||||
$notification = $this->cloudFederationFactory->getCloudFederationNotification();
|
||||
$notification->setMessage(
|
||||
FederationManager::NOTIFICATION_ROOM_MODIFIED,
|
||||
FederationManager::TALK_ROOM_RESOURCE,
|
||||
(string)$localAttendeeId,
|
||||
[
|
||||
'remoteServerUrl' => $this->getServerRemoteUrl(),
|
||||
'sharedSecret' => $accessToken,
|
||||
'remoteToken' => $localToken,
|
||||
'changedProperty' => $changedProperty,
|
||||
'newValue' => $activeSince->getTimestamp(),
|
||||
'oldValue' => null,
|
||||
'callFlag' => $callFlag,
|
||||
'details' => $details,
|
||||
],
|
||||
);
|
||||
|
||||
return $this->sendUpdateToRemote($remote, $notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send information to remote participants that "active since" was updated
|
||||
* Sent from Host server to Remote participant server
|
||||
*
|
||||
* @psalm-param array<AParticipantModifiedEvent::DETAIL_*, bool> $details
|
||||
*/
|
||||
public function sendCallEnded(
|
||||
string $remoteServer,
|
||||
int $localAttendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
string $localToken,
|
||||
string $changedProperty,
|
||||
?\DateTime $activeSince,
|
||||
int $callFlag,
|
||||
array $details,
|
||||
): ?bool {
|
||||
$remote = $this->prepareRemoteUrl($remoteServer);
|
||||
|
||||
$notification = $this->cloudFederationFactory->getCloudFederationNotification();
|
||||
$notification->setMessage(
|
||||
FederationManager::NOTIFICATION_ROOM_MODIFIED,
|
||||
FederationManager::TALK_ROOM_RESOURCE,
|
||||
(string)$localAttendeeId,
|
||||
[
|
||||
'remoteServerUrl' => $this->getServerRemoteUrl(),
|
||||
'sharedSecret' => $accessToken,
|
||||
'remoteToken' => $localToken,
|
||||
'changedProperty' => $changedProperty,
|
||||
'newValue' => $activeSince?->getTimestamp(),
|
||||
'oldValue' => null,
|
||||
'callFlag' => $callFlag,
|
||||
'details' => $details,
|
||||
],
|
||||
);
|
||||
|
||||
return $this->sendUpdateToRemote($remote, $notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send information to remote participants that the lobby was updated
|
||||
* Sent from Host server to Remote participant server
|
||||
*/
|
||||
public function sendRoomModifiedLobbyUpdate(
|
||||
string $remoteServer,
|
||||
int $localAttendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
string $localToken,
|
||||
string $changedProperty,
|
||||
int $newValue,
|
||||
int $oldValue,
|
||||
?\DateTime $dateTime,
|
||||
bool $timerReached,
|
||||
): ?bool {
|
||||
$remote = $this->prepareRemoteUrl($remoteServer);
|
||||
|
||||
$notification = $this->cloudFederationFactory->getCloudFederationNotification();
|
||||
$notification->setMessage(
|
||||
FederationManager::NOTIFICATION_ROOM_MODIFIED,
|
||||
FederationManager::TALK_ROOM_RESOURCE,
|
||||
(string)$localAttendeeId,
|
||||
[
|
||||
'remoteServerUrl' => $this->getServerRemoteUrl(),
|
||||
'sharedSecret' => $accessToken,
|
||||
'remoteToken' => $localToken,
|
||||
'changedProperty' => $changedProperty,
|
||||
'newValue' => $newValue,
|
||||
'oldValue' => $oldValue,
|
||||
'dateTime' => $dateTime ? (string)$dateTime->getTimestamp() : '',
|
||||
'timerReached' => $timerReached,
|
||||
],
|
||||
);
|
||||
|
||||
return $this->sendUpdateToRemote($remote, $notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send information to remote participants that a message was posted
|
||||
* Sent from Host server to Remote participant server
|
||||
*
|
||||
* @param array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string, creationDatetime: string, metaData: string} $messageData
|
||||
* @param array{unreadMessages: int, unreadMention: bool, unreadMentionDirect: bool, lastReadMessage: int} $unreadInfo
|
||||
*/
|
||||
public function sendMessageUpdate(
|
||||
string $remoteServer,
|
||||
int $localAttendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
string $localToken,
|
||||
array $messageData,
|
||||
array $unreadInfo,
|
||||
): ?bool {
|
||||
$remote = $this->prepareRemoteUrl($remoteServer);
|
||||
|
||||
$notification = $this->cloudFederationFactory->getCloudFederationNotification();
|
||||
$notification->setMessage(
|
||||
FederationManager::NOTIFICATION_MESSAGE_POSTED,
|
||||
FederationManager::TALK_ROOM_RESOURCE,
|
||||
(string)$localAttendeeId,
|
||||
[
|
||||
'remoteServerUrl' => $this->getServerRemoteUrl(),
|
||||
'sharedSecret' => $accessToken,
|
||||
'remoteToken' => $localToken,
|
||||
'messageData' => $messageData,
|
||||
'unreadInfo' => $unreadInfo,
|
||||
],
|
||||
);
|
||||
|
||||
return $this->sendUpdateToRemote($remote, $notification);
|
||||
}
|
||||
|
||||
protected function sendUpdateToRemote(string $remote, ICloudFederationNotification $notification, int $try = 0, bool $retry = true): ?bool {
|
||||
try {
|
||||
$response = $this->federationProviderManager->sendCloudNotification($remote, $notification);
|
||||
if ($response->getStatusCode() === Http::STATUS_CREATED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($response->getStatusCode() === Http::STATUS_BAD_REQUEST) {
|
||||
$ocmBody = json_decode((string)$response->getBody(), true) ?? [];
|
||||
if (isset($ocmBody['message']) && $ocmBody['message'] === FederationManager::OCM_RESOURCE_NOT_FOUND) {
|
||||
// Remote exists but tells us the OCM notification can not be received (invalid invite data)
|
||||
// So we stop retrying
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->warning("Failed to send notification for share from $remote, received status code {code}\n{body}", [
|
||||
'code' => $response->getStatusCode(),
|
||||
'body' => (string)$response->getBody(),
|
||||
]);
|
||||
} catch (OCMProviderException $e) {
|
||||
$this->logger->error("Failed to send notification for share from $remote, received OCMProviderException", ['exception' => $e]);
|
||||
}
|
||||
|
||||
if ($retry && $try === 0) {
|
||||
$now = $this->timeFactory->getTime();
|
||||
$now += $this->getRetryDelay(1);
|
||||
|
||||
// Talk data
|
||||
$retryNotification = new RetryNotification();
|
||||
$retryNotification->setRemoteServer($remote);
|
||||
$retryNotification->setNumAttempts(1);
|
||||
$retryNotification->setNextRetry($this->timeFactory->getDateTime('@' . $now));
|
||||
|
||||
// OCM notification data
|
||||
$data = $notification->getMessage();
|
||||
$retryNotification->setNotificationType($data['notificationType']);
|
||||
$retryNotification->setResourceType($data['resourceType']);
|
||||
$retryNotification->setProviderId($data['providerId']);
|
||||
$retryNotification->setNotification(json_encode($data['notification']));
|
||||
|
||||
$this->retryNotificationMapper->insert($retryNotification);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function retrySendingFailedNotifications(\DateTimeInterface $dueDateTime): void {
|
||||
$retryNotifications = $this->retryNotificationMapper->getAllDue($dueDateTime);
|
||||
|
||||
foreach ($retryNotifications as $retryNotification) {
|
||||
$this->retrySendingFailedNotification($retryNotification);
|
||||
}
|
||||
}
|
||||
|
||||
protected function retrySendingFailedNotification(RetryNotification $retryNotification): void {
|
||||
$data = json_decode($retryNotification->getNotification(), true, flags: JSON_THROW_ON_ERROR);
|
||||
if ($retryNotification->getNotificationType() === FederationManager::NOTIFICATION_ROOM_MODIFIED) {
|
||||
$localToken = $data['remoteToken'];
|
||||
|
||||
try {
|
||||
$manager = \OCP\Server::get(Manager::class);
|
||||
$room = $manager->getRoomByToken($localToken);
|
||||
} catch (RoomNotFoundException) {
|
||||
// Room was deleted in the meantime
|
||||
return;
|
||||
}
|
||||
|
||||
if ($data['changedProperty'] === ARoomModifiedEvent::PROPERTY_LOBBY) {
|
||||
$dateTime = $room->getLobbyTimer();
|
||||
$data['newValue'] = $room->getLobbyState();
|
||||
$data['dateTime'] = (string)$dateTime?->getTimestamp();
|
||||
} elseif ($data['changedProperty'] === ARoomModifiedEvent::PROPERTY_ACTIVE_SINCE) {
|
||||
if ($room->getActiveSince() === null) {
|
||||
$data['newValue'] = null;
|
||||
$data['callFlag'] = Participant::FLAG_DISCONNECTED;
|
||||
} else {
|
||||
$data['newValue'] = $room->getActiveSince()->getTimestamp();
|
||||
$data['callFlag'] = $room->getCallFlag();
|
||||
}
|
||||
} else {
|
||||
$data['newValue'] = match ($data['changedProperty']) {
|
||||
ARoomModifiedEvent::PROPERTY_AVATAR => $room->getAvatar(),
|
||||
ARoomModifiedEvent::PROPERTY_CALL_RECORDING => $room->getCallRecording(),
|
||||
ARoomModifiedEvent::PROPERTY_DEFAULT_PERMISSIONS => $room->getDefaultPermissions(),
|
||||
ARoomModifiedEvent::PROPERTY_DESCRIPTION => $room->getDescription(),
|
||||
ARoomModifiedEvent::PROPERTY_IN_CALL => $room->getCallFlag(),
|
||||
ARoomModifiedEvent::PROPERTY_MENTION_PERMISSIONS => $room->getMentionPermissions(),
|
||||
ARoomModifiedEvent::PROPERTY_MESSAGE_EXPIRATION => $room->getMessageExpiration(),
|
||||
ARoomModifiedEvent::PROPERTY_NAME => $room->getName(),
|
||||
ARoomModifiedEvent::PROPERTY_READ_ONLY => $room->getReadOnly(),
|
||||
ARoomModifiedEvent::PROPERTY_RECORDING_CONSENT => $room->getRecordingConsent(),
|
||||
ARoomModifiedEvent::PROPERTY_SIP_ENABLED => $room->getSIPEnabled(),
|
||||
ARoomModifiedEvent::PROPERTY_TYPE => $room->getType(),
|
||||
default => $data['newValue'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$notification = $this->cloudFederationFactory->getCloudFederationNotification();
|
||||
$notification->setMessage(
|
||||
$retryNotification->getNotificationType(),
|
||||
$retryNotification->getResourceType(),
|
||||
$retryNotification->getProviderId(),
|
||||
$data,
|
||||
);
|
||||
|
||||
$success = $this->sendUpdateToRemote($retryNotification->getRemoteServer(), $notification, $retryNotification->getNumAttempts());
|
||||
|
||||
if ($success) {
|
||||
$this->retryNotificationMapper->delete($retryNotification);
|
||||
} elseif ($success === null) {
|
||||
$this->logger->error('Server signaled the OCM notification is not accepted at ' . $retryNotification->getRemoteServer() . ', giving up!');
|
||||
$this->retryNotificationMapper->delete($retryNotification);
|
||||
} elseif ($retryNotification->getNumAttempts() === RetryNotification::MAX_NUM_ATTEMPTS) {
|
||||
$this->logger->error('Failed to send notification to ' . $retryNotification->getRemoteServer() . ' ' . RetryNotification::MAX_NUM_ATTEMPTS . ' times, giving up!');
|
||||
$this->retryNotificationMapper->delete($retryNotification);
|
||||
} else {
|
||||
$retryNotification->setNumAttempts($retryNotification->getNumAttempts() + 1);
|
||||
|
||||
$now = $this->timeFactory->getTime();
|
||||
$now += $this->getRetryDelay($retryNotification->getNumAttempts());
|
||||
|
||||
$retryNotification->setNextRetry($this->timeFactory->getDateTime('@' . $now));
|
||||
$this->retryNotificationMapper->update($retryNotification);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First 5 attempts are retried on the next cron run.
|
||||
* Attempts 6-10 we back off to cover slightly longer maintenance/downtimes (5 minutes * per attempt)
|
||||
* And the last tries 11-20 are retried with ~8 hours delay
|
||||
*
|
||||
* This means the last retry is after ~84 hours so a downtime from Friday to Monday would be covered
|
||||
*/
|
||||
protected function getRetryDelay(int $attempt): int {
|
||||
if ($attempt < 5) {
|
||||
// Retry after "attempt" minutes
|
||||
return 5 * 60;
|
||||
}
|
||||
|
||||
if ($attempt > 10) {
|
||||
// Retry after 8 hours
|
||||
return 8 * 3600;
|
||||
}
|
||||
|
||||
// Retry after "attempt" * 5 minutes
|
||||
return $attempt * 5 * 60;
|
||||
}
|
||||
|
||||
protected function prepareRemoteUrl(string $remote): string {
|
||||
if (!$this->addressHandler->urlContainProtocol($remote)) {
|
||||
return 'https://' . $remote;
|
||||
}
|
||||
return $remote;
|
||||
}
|
||||
|
||||
protected function getServerRemoteUrl(): string {
|
||||
$server = rtrim($this->url->getAbsoluteURL('/'), '/');
|
||||
if (str_ends_with($server, '/index.php')) {
|
||||
$server = substr($server, 0, -10);
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation;
|
||||
|
||||
use Exception;
|
||||
use NCU\Federation\ISignedCloudFederationProvider;
|
||||
use OCA\FederatedFileSharing\AddressHandler;
|
||||
use OCA\Talk\AppInfo\Application;
|
||||
use OCA\Talk\CachePrefix;
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Events\AAttendeeRemovedEvent;
|
||||
use OCA\Talk\Events\AParticipantModifiedEvent;
|
||||
use OCA\Talk\Events\ARoomModifiedEvent;
|
||||
use OCA\Talk\Events\AttendeesAddedEvent;
|
||||
use OCA\Talk\Events\CallNotificationSendEvent;
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\AttendeeMapper;
|
||||
use OCA\Talk\Model\Invitation;
|
||||
use OCA\Talk\Model\InvitationMapper;
|
||||
use OCA\Talk\Model\ProxyCacheMessage;
|
||||
use OCA\Talk\Model\ProxyCacheMessageMapper;
|
||||
use OCA\Talk\Notification\FederationChatNotifier;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\ProxyCacheMessageService;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Services\IAppConfig;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\DB\Exception as DBException;
|
||||
use OCP\EventDispatcher\IEventDispatcher;
|
||||
use OCP\Federation\Exceptions\ActionNotSupportedException;
|
||||
use OCP\Federation\Exceptions\AuthenticationFailedException;
|
||||
use OCP\Federation\Exceptions\BadRequestException;
|
||||
use OCP\Federation\Exceptions\ProviderCouldNotAddShareException;
|
||||
use OCP\Federation\ICloudFederationProvider;
|
||||
use OCP\Federation\ICloudFederationShare;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
use OCP\HintException;
|
||||
use OCP\ICache;
|
||||
use OCP\ICacheFactory;
|
||||
use OCP\ISession;
|
||||
use OCP\IUser;
|
||||
use OCP\IUserManager;
|
||||
use OCP\Notification\IManager as INotificationManager;
|
||||
use OCP\Share\Exceptions\ShareNotFound;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use SensitiveParameter;
|
||||
|
||||
class CloudFederationProviderTalk implements ICloudFederationProvider, ISignedCloudFederationProvider {
|
||||
protected ?ICache $proxyCacheMessages;
|
||||
|
||||
public function __construct(
|
||||
private ICloudIdManager $cloudIdManager,
|
||||
private IUserManager $userManager,
|
||||
private AddressHandler $addressHandler,
|
||||
private FederationManager $federationManager,
|
||||
private Config $config,
|
||||
private IAppConfig $appConfig,
|
||||
private INotificationManager $notificationManager,
|
||||
private ParticipantService $participantService,
|
||||
private RoomService $roomService,
|
||||
private AttendeeMapper $attendeeMapper,
|
||||
private InvitationMapper $invitationMapper,
|
||||
private Manager $manager,
|
||||
private ISession $session,
|
||||
private IEventDispatcher $dispatcher,
|
||||
private LoggerInterface $logger,
|
||||
private ProxyCacheMessageMapper $proxyCacheMessageMapper,
|
||||
private ProxyCacheMessageService $pcmService,
|
||||
private FederationChatNotifier $federationChatNotifier,
|
||||
private UserConverter $userConverter,
|
||||
private ITimeFactory $timeFactory,
|
||||
ICacheFactory $cacheFactory,
|
||||
) {
|
||||
$this->proxyCacheMessages = $cacheFactory->isAvailable() ? $cacheFactory->createDistributed(CachePrefix::FEDERATED_PCM) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function getShareType(): string {
|
||||
return 'talk-room';
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @throws HintException
|
||||
* @throws DBException
|
||||
*/
|
||||
#[\Override]
|
||||
public function shareReceived(ICloudFederationShare $share): string {
|
||||
if (!$this->config->isFederationEnabled()) {
|
||||
$this->logger->debug('Received a federation invite but federation is disabled');
|
||||
throw new ProviderCouldNotAddShareException('Server does not support talk federation', '', Http::STATUS_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
if (!$this->appConfig->getAppValueBool('federation_incoming_enabled', true)) {
|
||||
$this->logger->warning('Received a federation invite but incoming federation is disabled');
|
||||
throw new ProviderCouldNotAddShareException('Server does not support talk federation', '', Http::STATUS_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
if (!in_array($share->getShareType(), $this->getSupportedShareTypes(), true)) {
|
||||
$this->logger->debug('Received a federation invite for invalid share type');
|
||||
throw new ProviderCouldNotAddShareException('Support for sharing with non-users not implemented yet', '', Http::STATUS_NOT_IMPLEMENTED);
|
||||
// TODO: Implement group shares
|
||||
}
|
||||
|
||||
$roomType = $share->getProtocol()['roomType'];
|
||||
if (!is_numeric($roomType) || !in_array((int)$roomType, $this->validSharedRoomTypes(), true)) {
|
||||
$this->logger->debug('Received a federation invite for invalid room type');
|
||||
throw new ProviderCouldNotAddShareException('roomType is not a valid number', '', Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$shareSecret = $share->getShareSecret();
|
||||
$shareWith = $share->getShareWith();
|
||||
$remoteId = $share->getProviderId();
|
||||
$roomToken = $share->getResourceName();
|
||||
$roomName = $share->getProtocol()['roomName'];
|
||||
$roomDefaultPermissions = $share->getProtocol()['roomDefaultPermissions'] ?? Attendee::PERMISSIONS_DEFAULT;
|
||||
if (isset($share->getProtocol()['invitedCloudId'])) {
|
||||
$localCloudId = $share->getProtocol()['invitedCloudId'];
|
||||
} else {
|
||||
$this->logger->debug('Received a federation invite without invitedCloudId, falling back to shareWith');
|
||||
$cloudId = $this->cloudIdManager->getCloudId($shareWith, null);
|
||||
$localCloudId = $cloudId->getUser() . '@' . $cloudId->getRemote();
|
||||
}
|
||||
$roomType = (int)$roomType;
|
||||
$sharedByDisplayName = $share->getSharedByDisplayName();
|
||||
$sharedByFederatedId = $share->getSharedBy();
|
||||
$ownerDisplayName = $share->getOwnerDisplayName();
|
||||
$ownerFederatedId = $share->getOwner();
|
||||
[, $remote] = $this->addressHandler->splitUserRemote($ownerFederatedId);
|
||||
|
||||
if (!$this->addressHandler->urlContainProtocol($remote)) {
|
||||
// Heal federation from before Nextcloud 29.0.4 which sends requests
|
||||
// without the protocol on the remote in case it is https://
|
||||
$remote = 'https://' . $remote;
|
||||
}
|
||||
|
||||
// if no explicit information about the person who created the share was sent
|
||||
// we assume that the share comes from the owner
|
||||
if ($sharedByFederatedId === null) {
|
||||
$sharedByDisplayName = $ownerDisplayName;
|
||||
$sharedByFederatedId = $ownerFederatedId;
|
||||
}
|
||||
|
||||
if ($remote && $shareSecret && $shareWith && $roomToken && $remoteId && is_string($roomName) && $roomName && $ownerDisplayName) {
|
||||
$shareWithUser = $this->userManager->get($shareWith);
|
||||
if ($shareWithUser === null) {
|
||||
$this->logger->debug('Received a federation invite for user that could not be found');
|
||||
throw new ProviderCouldNotAddShareException('User does not exist', '', Http::STATUS_BAD_REQUEST);
|
||||
} elseif (!str_starts_with($localCloudId, $shareWithUser->getUID() . '@')) {
|
||||
// Fix the user ID as we also return it via the cloud federation api response in Nextcloud 30+
|
||||
$cloudId = $this->cloudIdManager->resolveCloudId($localCloudId);
|
||||
$localRemote = $cloudId->getRemote();
|
||||
if (str_starts_with($localRemote, 'https://')) {
|
||||
$localRemote = substr($localRemote, 8);
|
||||
}
|
||||
$localCloudId = $shareWithUser->getUID() . '@' . $localRemote;
|
||||
}
|
||||
|
||||
if ($this->config->isDisabledForUser($shareWithUser)) {
|
||||
$this->logger->debug('Received a federation invite for user that is not allowed to use Talk');
|
||||
throw new ProviderCouldNotAddShareException('User does not exist', '', Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!$this->config->isFederationEnabledForUserId($shareWithUser)) {
|
||||
$this->logger->debug('Received a federation invite for user that is not allowed to use Talk Federation');
|
||||
throw new ProviderCouldNotAddShareException('User does not exist', '', Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$invite = $this->federationManager->addRemoteRoom($shareWithUser, (int)$remoteId, $roomType, $roomName, $roomDefaultPermissions, $roomToken, $remote, $shareSecret, $sharedByFederatedId, $sharedByDisplayName, $localCloudId);
|
||||
|
||||
$this->notifyAboutNewShare($shareWithUser, (string)$invite->getId(), $sharedByFederatedId, $sharedByDisplayName, $roomName, $roomToken, $remote);
|
||||
return (string)$invite->getId();
|
||||
}
|
||||
|
||||
$this->logger->debug('Received a federation invite with missing request data');
|
||||
throw new ProviderCouldNotAddShareException('required request data not found', '', Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function notificationReceived($notificationType, $providerId, array $notification): array {
|
||||
if (!is_numeric($providerId)) {
|
||||
throw new BadRequestException(['providerId']);
|
||||
}
|
||||
switch ($notificationType) {
|
||||
case FederationManager::NOTIFICATION_SHARE_ACCEPTED:
|
||||
return $this->shareAccepted((int)$providerId, $notification);
|
||||
case FederationManager::NOTIFICATION_SHARE_DECLINED:
|
||||
return $this->shareDeclined((int)$providerId, $notification);
|
||||
case FederationManager::NOTIFICATION_SHARE_UNSHARED:
|
||||
return $this->shareUnshared((int)$providerId, $notification);
|
||||
case FederationManager::NOTIFICATION_PARTICIPANT_MODIFIED:
|
||||
return $this->participantModified((int)$providerId, $notification);
|
||||
case FederationManager::NOTIFICATION_ROOM_MODIFIED:
|
||||
return $this->roomModified((int)$providerId, $notification);
|
||||
case FederationManager::NOTIFICATION_MESSAGE_POSTED:
|
||||
return $this->messagePosted((int)$providerId, $notification);
|
||||
}
|
||||
|
||||
throw new BadRequestException([$notificationType]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ActionNotSupportedException
|
||||
* @throws ShareNotFound
|
||||
* @throws AuthenticationFailedException
|
||||
*/
|
||||
private function shareAccepted(int $id, array $notification): array {
|
||||
$attendee = $this->getLocalAttendeeAndValidate($id, $notification['sharedSecret']);
|
||||
|
||||
if (!empty($notification['displayName'])) {
|
||||
$attendee->setDisplayName($notification['displayName']);
|
||||
$attendee->setState(Invitation::STATE_ACCEPTED);
|
||||
|
||||
if (!empty($notification['cloudId'])) {
|
||||
$attendee->setActorId($notification['cloudId']);
|
||||
}
|
||||
|
||||
$this->attendeeMapper->update($attendee);
|
||||
}
|
||||
|
||||
$this->session->set('talk-overwrite-actor-type', $attendee->getActorType());
|
||||
$this->session->set('talk-overwrite-actor-id', $attendee->getActorId());
|
||||
$this->session->set('talk-overwrite-actor-displayname', $attendee->getDisplayName());
|
||||
|
||||
$room = $this->manager->getRoomById($attendee->getRoomId());
|
||||
$event = new AttendeesAddedEvent($room, [$attendee]);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
|
||||
$this->session->remove('talk-overwrite-actor-type');
|
||||
$this->session->remove('talk-overwrite-actor-id');
|
||||
$this->session->remove('talk-overwrite-actor-displayname');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ActionNotSupportedException
|
||||
* @throws ShareNotFound
|
||||
* @throws AuthenticationFailedException
|
||||
*/
|
||||
private function shareDeclined(int $id, array $notification): array {
|
||||
$attendee = $this->getLocalAttendeeAndValidate($id, $notification['sharedSecret']);
|
||||
|
||||
$this->session->set('talk-overwrite-actor-type', $attendee->getActorType());
|
||||
$this->session->set('talk-overwrite-actor-id', $attendee->getActorId());
|
||||
$this->session->set('talk-overwrite-actor-displayname', $attendee->getDisplayName());
|
||||
|
||||
$room = $this->manager->getRoomById($attendee->getRoomId());
|
||||
$participant = new Participant($room, $attendee, null);
|
||||
$this->participantService->removeAttendee($room, $participant, AAttendeeRemovedEvent::REASON_LEFT);
|
||||
|
||||
$this->session->remove('talk-overwrite-actor-type');
|
||||
$this->session->remove('talk-overwrite-actor-id');
|
||||
$this->session->remove('talk-overwrite-actor-displayname');
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ActionNotSupportedException
|
||||
* @throws ShareNotFound
|
||||
* @throws AuthenticationFailedException
|
||||
*/
|
||||
private function shareUnshared(int $remoteAttendeeId, array $notification): array {
|
||||
$invite = $this->getByRemoteAttendeeAndValidate($notification['remoteServerUrl'], $remoteAttendeeId, $notification['sharedSecret']);
|
||||
try {
|
||||
$room = $this->manager->getRoomById($invite->getLocalRoomId());
|
||||
} catch (RoomNotFoundException) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Sanity check to make sure the room is a remote room
|
||||
if (!$room->isFederatedConversation()) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
|
||||
$this->invitationMapper->delete($invite);
|
||||
|
||||
try {
|
||||
$participant = $this->participantService->getParticipantByActor($room, Attendee::ACTOR_USERS, $invite->getUserId());
|
||||
$this->participantService->removeAttendee($room, $participant, AAttendeeRemovedEvent::REASON_REMOVED);
|
||||
} catch (ParticipantNotFoundException) {
|
||||
// Never accepted the invite
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $remoteAttendeeId
|
||||
* @param array{remoteServerUrl: string, sharedSecret: string, remoteToken: string, changedProperty: string, newValue: string|int, oldValue: string|int|null} $notification
|
||||
* @return array
|
||||
* @throws ActionNotSupportedException
|
||||
* @throws AuthenticationFailedException
|
||||
* @throws ShareNotFound
|
||||
*/
|
||||
private function participantModified(int $remoteAttendeeId, array $notification): array {
|
||||
$invite = $this->getByRemoteAttendeeAndValidate($notification['remoteServerUrl'], $remoteAttendeeId, $notification['sharedSecret']);
|
||||
try {
|
||||
$room = $this->manager->getRoomById($invite->getLocalRoomId());
|
||||
} catch (RoomNotFoundException) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Sanity check to make sure the room is a remote room
|
||||
if (!$room->isFederatedConversation()) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
|
||||
try {
|
||||
$participant = $this->participantService->getParticipant($room, $invite->getUserId());
|
||||
} catch (ParticipantNotFoundException $e) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($notification['changedProperty'] === AParticipantModifiedEvent::PROPERTY_PERMISSIONS) {
|
||||
$this->participantService->updatePermissions($room, $participant, Attendee::PERMISSIONS_MODIFY_SET, $notification['newValue']);
|
||||
} elseif ($notification['changedProperty'] === AParticipantModifiedEvent::PROPERTY_RESEND_CALL) {
|
||||
$event = new CallNotificationSendEvent($room, null, $participant);
|
||||
$this->dispatcher->dispatchTyped($event);
|
||||
} else {
|
||||
$this->logger->debug('Update of participant property "' . $notification['changedProperty'] . '" is not handled and should not be send via federation');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $remoteAttendeeId
|
||||
* @param array{remoteServerUrl: string, sharedSecret: string, remoteToken: string, changedProperty: string, newValue: string|int|bool|null, oldValue: string|int|bool|null, callFlag?: int, dateTime?: string, timerReached?: bool, details?: array<AParticipantModifiedEvent::DETAIL_*, bool>} $notification
|
||||
* @return array
|
||||
* @throws ActionNotSupportedException
|
||||
* @throws AuthenticationFailedException
|
||||
* @throws ShareNotFound
|
||||
*/
|
||||
private function roomModified(int $remoteAttendeeId, array $notification): array {
|
||||
$invite = $this->getByRemoteAttendeeAndValidate($notification['remoteServerUrl'], $remoteAttendeeId, $notification['sharedSecret']);
|
||||
try {
|
||||
$room = $this->manager->getRoomById($invite->getLocalRoomId());
|
||||
} catch (RoomNotFoundException) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Sanity check to make sure the room is a remote room
|
||||
if (!$room->isFederatedConversation()) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_ACTIVE_SINCE) {
|
||||
if ($notification['newValue'] === null) {
|
||||
$this->roomService->resetActiveSince($room, null);
|
||||
} else {
|
||||
$activeSince = $room->getActiveSince();
|
||||
if ($activeSince === null || $notification['newValue'] < $activeSince->getTimestamp()) {
|
||||
/**
|
||||
* If the host is sending a lower timestamp, we healed an early in_call update,
|
||||
* so we take the older value as the host should know more specifically.
|
||||
*/
|
||||
$activeSince = $this->timeFactory->getDateTime('@' . $notification['newValue']);
|
||||
}
|
||||
$this->roomService->setActiveSince(
|
||||
$room,
|
||||
null,
|
||||
$activeSince,
|
||||
$notification['callFlag'] | $room->getCallFlag(),
|
||||
!empty($notification['details'][AParticipantModifiedEvent::DETAIL_IN_CALL_SILENT]),
|
||||
);
|
||||
}
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_AVATAR) {
|
||||
$this->roomService->setAvatar($room, $notification['newValue']);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_CALL_RECORDING) {
|
||||
/** @psalm-suppress InvalidArgument */
|
||||
$this->roomService->setCallRecording($room, $notification['newValue']);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_DEFAULT_PERMISSIONS) {
|
||||
$this->roomService->setDefaultPermissions($room, $notification['newValue']);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_DESCRIPTION) {
|
||||
$this->roomService->setDescription($room, $notification['newValue']);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_IN_CALL) {
|
||||
/**
|
||||
* In case the in_call update arrives before the actual active_since update,
|
||||
* we fake the timestamp so we at least don't fail the request.
|
||||
* When the active_since finally arrives we merge the results.
|
||||
*/
|
||||
$this->roomService->setActiveSince(
|
||||
$room,
|
||||
null,
|
||||
$room->getActiveSince() ?? $this->timeFactory->getDateTime(),
|
||||
$notification['newValue'],
|
||||
true,
|
||||
);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_LOBBY) {
|
||||
$dateTime = !empty($notification['dateTime']) ? \DateTime::createFromFormat('U', $notification['dateTime']) : null;
|
||||
$this->roomService->setLobby($room, $notification['newValue'], $dateTime, $notification['timerReached'] ?? false);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_MENTION_PERMISSIONS) {
|
||||
/** @psalm-suppress InvalidArgument */
|
||||
$this->roomService->setMentionPermissions($room, $notification['newValue']);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_MESSAGE_EXPIRATION) {
|
||||
$this->roomService->setMessageExpiration($room, $notification['newValue']);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_NAME) {
|
||||
$this->roomService->setName($room, $notification['newValue'], $notification['oldValue']);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_READ_ONLY) {
|
||||
$this->roomService->setReadOnly($room, $notification['newValue']);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_RECORDING_CONSENT) {
|
||||
/** @psalm-suppress InvalidArgument */
|
||||
$this->roomService->setRecordingConsent($room, $notification['newValue']);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_SIP_ENABLED) {
|
||||
$this->roomService->setSIPEnabled($room, $notification['newValue']);
|
||||
} elseif ($notification['changedProperty'] === ARoomModifiedEvent::PROPERTY_TYPE) {
|
||||
$this->roomService->setType($room, $notification['newValue']);
|
||||
} else {
|
||||
$this->logger->debug('Update of room property "' . $notification['changedProperty'] . '" is not handled and should not be send via federation');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $remoteAttendeeId
|
||||
* @param array{remoteServerUrl: string, sharedSecret: string, remoteToken: string, messageData: array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string, creationDatetime: string, metaData: string}, unreadInfo: array{unreadMessages: int, unreadMention: bool, unreadMentionDirect: bool, lastReadMessage: int}} $notification
|
||||
* @return array
|
||||
* @throws ActionNotSupportedException
|
||||
* @throws AuthenticationFailedException
|
||||
* @throws ShareNotFound
|
||||
*/
|
||||
private function messagePosted(int $remoteAttendeeId, array $notification): array {
|
||||
$invite = $this->getByRemoteAttendeeAndValidate($notification['remoteServerUrl'], $remoteAttendeeId, $notification['sharedSecret']);
|
||||
try {
|
||||
$room = $this->manager->getRoomById($invite->getLocalRoomId());
|
||||
} catch (RoomNotFoundException) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Sanity check to make sure the room is a remote room
|
||||
if (!$room->isFederatedConversation()) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
|
||||
$removeParentMessage = null;
|
||||
if ($notification['messageData']['systemMessage'] === 'message_edited'
|
||||
|| $notification['messageData']['systemMessage'] === 'message_deleted') {
|
||||
$metaData = json_decode($notification['messageData']['metaData'], true);
|
||||
if (isset($metaData['replyToMessageId'])) {
|
||||
$removeParentMessage = $metaData['replyToMessageId'];
|
||||
}
|
||||
}
|
||||
|
||||
// We transform the parameters when storing in the PCM, so we only have
|
||||
// to do it once for each message.
|
||||
// Note: `messageParameters` (array during parsing) vs `messageParameter` (string during sending)
|
||||
$notification['messageData']['messageParameters'] = json_decode($notification['messageData']['messageParameter'], true, flags: JSON_THROW_ON_ERROR);
|
||||
unset($notification['messageData']['messageParameter']);
|
||||
$converted = $this->userConverter->convertMessage($room, $notification['messageData']);
|
||||
$converted['messageParameter'] = json_encode($converted['messageParameters'], JSON_THROW_ON_ERROR);
|
||||
unset($converted['messageParameters']);
|
||||
|
||||
/** @var array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string, creationDatetime: string, metaData: string} $converted */
|
||||
$notification['messageData'] = $converted;
|
||||
|
||||
$message = null;
|
||||
if ($removeParentMessage === null) {
|
||||
$message = new ProxyCacheMessage();
|
||||
$message->setLocalToken($room->getToken());
|
||||
$message->setRemoteServerUrl($notification['remoteServerUrl']);
|
||||
$message->setRemoteToken($notification['remoteToken']);
|
||||
$message->setRemoteMessageId($notification['messageData']['remoteMessageId']);
|
||||
$message->setActorType($notification['messageData']['actorType']);
|
||||
$message->setActorId($notification['messageData']['actorId']);
|
||||
$message->setActorDisplayName($notification['messageData']['actorDisplayName']);
|
||||
$message->setMessageType($notification['messageData']['messageType']);
|
||||
$message->setSystemMessage($notification['messageData']['systemMessage']);
|
||||
if ($notification['messageData']['expirationDatetime']) {
|
||||
$message->setExpirationDatetime(new \DateTime($notification['messageData']['expirationDatetime']));
|
||||
}
|
||||
$message->setMessage($notification['messageData']['message']);
|
||||
$message->setMessageParameters($notification['messageData']['messageParameter']);
|
||||
$message->setCreationDatetime(new \DateTime($notification['messageData']['creationDatetime']));
|
||||
$message->setMetaData($notification['messageData']['metaData']);
|
||||
|
||||
try {
|
||||
$this->proxyCacheMessageMapper->insert($message);
|
||||
|
||||
$lastMessageId = $room->getLastMessageId();
|
||||
if ($notification['messageData']['remoteMessageId'] > $lastMessageId) {
|
||||
$lastMessageId = (int)$notification['messageData']['remoteMessageId'];
|
||||
}
|
||||
|
||||
if ($notification['messageData']['systemMessage'] !== 'message_edited'
|
||||
&& $notification['messageData']['systemMessage'] !== 'message_deleted') {
|
||||
$this->roomService->setLastMessageInfo($room, $lastMessageId, $this->timeFactory->getDateTime());
|
||||
}
|
||||
|
||||
if ($this->proxyCacheMessages instanceof ICache) {
|
||||
$cacheKey = sha1(json_encode([$notification['remoteServerUrl'], $notification['remoteToken']]));
|
||||
$cacheData = $this->proxyCacheMessages->get($cacheKey);
|
||||
if ($cacheData === null || $cacheData < $notification['messageData']['remoteMessageId']) {
|
||||
$this->proxyCacheMessages->set($cacheKey, $notification['messageData']['remoteMessageId'], 300);
|
||||
}
|
||||
}
|
||||
} catch (DBException $e) {
|
||||
// DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION happens when
|
||||
// multiple users are in the same conversation. We are therefore
|
||||
// informed multiple times about the same remote message.
|
||||
if ($e->getReason() !== DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||||
$this->logger->error('Error saving proxy cache message failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$message = $this->pcmService->findByRemote(
|
||||
$notification['remoteServerUrl'],
|
||||
$notification['remoteToken'],
|
||||
$notification['messageData']['remoteMessageId'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$participant = $this->participantService->getParticipantWithActiveSession($room, $invite->getUserId());
|
||||
} catch (ParticipantNotFoundException) {
|
||||
// Not accepted the invite yet
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($removeParentMessage !== null) {
|
||||
try {
|
||||
$this->pcmService->syncRemoteMessage($room, $participant, $removeParentMessage);
|
||||
} catch (\InvalidArgumentException|CannotReachRemoteException) {
|
||||
$oldMessage = $this->pcmService->findByRemote(
|
||||
$notification['remoteServerUrl'],
|
||||
$notification['remoteToken'],
|
||||
$removeParentMessage,
|
||||
);
|
||||
$this->pcmService->delete($oldMessage);
|
||||
$this->logger->info('Failed to resync chat message #' . $removeParentMessage . ' after being notified by host ' . $notification['remoteServerUrl']);
|
||||
}
|
||||
|
||||
// Update the last activity so the left sidebar refreshes the data as well
|
||||
$this->roomService->setLastMessageInfo($room, $room->getLastMessageId(), new \DateTime());
|
||||
}
|
||||
|
||||
$this->logger->debug('Setting unread info for local federated user ' . $invite->getUserId() . ' in ' . $room->getToken() . ' to ' . json_encode($notification['unreadInfo']), [
|
||||
'app' => 'spreed-federation',
|
||||
]);
|
||||
|
||||
$this->participantService->updateUnreadInfoForProxyParticipant(
|
||||
$participant,
|
||||
$notification['unreadInfo']['unreadMessages'],
|
||||
$notification['unreadInfo']['unreadMention'],
|
||||
$notification['unreadInfo']['unreadMentionDirect'],
|
||||
$notification['unreadInfo']['lastReadMessage'],
|
||||
);
|
||||
|
||||
if ($message instanceof ProxyCacheMessage) {
|
||||
$this->federationChatNotifier->handleChatMessage($room, $participant, $message, $notification);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthenticationFailedException
|
||||
* @throws ActionNotSupportedException
|
||||
* @throws ShareNotFound
|
||||
*/
|
||||
private function getLocalAttendeeAndValidate(
|
||||
int $attendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $sharedSecret,
|
||||
): Attendee {
|
||||
if (!$this->config->isFederationEnabled()) {
|
||||
throw new ActionNotSupportedException('Server does not support Talk federation');
|
||||
}
|
||||
|
||||
try {
|
||||
$attendee = $this->attendeeMapper->getById($attendeeId);
|
||||
} catch (Exception) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
if ($attendee->getActorType() !== Attendee::ACTOR_FEDERATED_USERS) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
if ($attendee->getAccessToken() !== $sharedSecret) {
|
||||
throw new AuthenticationFailedException();
|
||||
}
|
||||
return $attendee;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ActionNotSupportedException
|
||||
* @throws ShareNotFound
|
||||
* @throws AuthenticationFailedException
|
||||
*/
|
||||
private function getByRemoteAttendeeAndValidate(
|
||||
string $remoteServerUrl,
|
||||
int $remoteAttendeeId,
|
||||
#[SensitiveParameter]
|
||||
string $sharedSecret,
|
||||
): Invitation {
|
||||
if (!$this->config->isFederationEnabled()) {
|
||||
throw new ActionNotSupportedException('Server does not support Talk federation');
|
||||
}
|
||||
|
||||
if (!$sharedSecret) {
|
||||
throw new AuthenticationFailedException();
|
||||
}
|
||||
|
||||
if (!$this->addressHandler->urlContainProtocol($remoteServerUrl)) {
|
||||
// Heal federation from before Nextcloud 29.0.4 which sends requests
|
||||
// without the protocol on the remote in case it is https://
|
||||
$remoteServerUrl = 'https://' . $remoteServerUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->invitationMapper->getByRemoteAndAccessToken($remoteServerUrl, $remoteAttendeeId, $sharedSecret);
|
||||
} catch (DoesNotExistException) {
|
||||
throw new ShareNotFound(FederationManager::OCM_RESOURCE_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyAboutNewShare(IUser $shareWith, string $inviteId, string $sharedByFederatedId, string $sharedByName, string $roomName, string $remoteRoomToken, string $remoteServerUrl): void {
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$notification->setApp(Application::APP_ID)
|
||||
->setUser($shareWith->getUID())
|
||||
->setDateTime(new \DateTime())
|
||||
->setObject('remote_talk_share', $inviteId)
|
||||
->setSubject('remote_talk_share', [
|
||||
'sharedByDisplayName' => $sharedByName,
|
||||
'sharedByFederatedId' => $sharedByFederatedId,
|
||||
'roomName' => $roomName,
|
||||
'serverUrl' => $remoteServerUrl,
|
||||
'roomToken' => $remoteRoomToken,
|
||||
]);
|
||||
|
||||
$this->notificationManager->notify($notification);
|
||||
}
|
||||
|
||||
private function validSharedRoomTypes(): array {
|
||||
return [
|
||||
Room::TYPE_ONE_TO_ONE,
|
||||
Room::TYPE_GROUP,
|
||||
Room::TYPE_PUBLIC,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function getSupportedShareTypes(): array {
|
||||
return ['user'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
#[\Override]
|
||||
public function getFederationIdFromSharedSecret(
|
||||
#[SensitiveParameter]
|
||||
string $sharedSecret,
|
||||
array $payload,
|
||||
): string {
|
||||
$remoteServerUrl = $payload['remoteServerUrl'];
|
||||
if (str_starts_with($remoteServerUrl, 'https://')) {
|
||||
$remoteServerUrl = substr($remoteServerUrl, strlen('https://'));
|
||||
}
|
||||
|
||||
try {
|
||||
$invite = $this->invitationMapper->getByRemoteServerAndAccessToken($payload['remoteServerUrl'], $sharedSecret);
|
||||
return $invite->getInviterCloudId();
|
||||
} catch (DoesNotExistException) {
|
||||
}
|
||||
|
||||
$attendees = $this->attendeeMapper->getByAccessToken($sharedSecret);
|
||||
foreach ($attendees as $attendee) {
|
||||
if (str_ends_with($attendee->getActorId(), '@' . $remoteServerUrl)) {
|
||||
return $attendee->getActorId();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation;
|
||||
|
||||
use OCA\Talk\AppInfo\Application;
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Exceptions\FederationRestrictionException;
|
||||
use OCA\Talk\Exceptions\ParticipantNotFoundException;
|
||||
use OCA\Talk\Exceptions\RoomNotFoundException;
|
||||
use OCA\Talk\Exceptions\UnauthorizedException;
|
||||
use OCA\Talk\Manager;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\AttendeeMapper;
|
||||
use OCA\Talk\Model\Invitation;
|
||||
use OCA\Talk\Model\InvitationMapper;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RoomService;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\Federation\ICloudId;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
use OCP\IUser;
|
||||
use OCP\Notification\IManager;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* Class FederationManager
|
||||
*
|
||||
* @package OCA\Talk\Federation
|
||||
*
|
||||
* FederationManager handles incoming federated rooms
|
||||
*/
|
||||
class FederationManager {
|
||||
public const OCM_RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND';
|
||||
public const TALK_ROOM_RESOURCE = 'talk-room';
|
||||
public const TALK_PROTOCOL_NAME = 'nctalk';
|
||||
public const NOTIFICATION_SHARE_ACCEPTED = 'SHARE_ACCEPTED';
|
||||
public const NOTIFICATION_SHARE_DECLINED = 'SHARE_DECLINED';
|
||||
public const NOTIFICATION_SHARE_UNSHARED = 'SHARE_UNSHARED';
|
||||
public const NOTIFICATION_PARTICIPANT_MODIFIED = 'PARTICIPANT_MODIFIED';
|
||||
public const NOTIFICATION_ROOM_MODIFIED = 'ROOM_MODIFIED';
|
||||
public const NOTIFICATION_MESSAGE_POSTED = 'MESSAGE_POSTED';
|
||||
public const TOKEN_LENGTH = 64;
|
||||
|
||||
public function __construct(
|
||||
private Manager $manager,
|
||||
private ParticipantService $participantService,
|
||||
private RoomService $roomService,
|
||||
private InvitationMapper $invitationMapper,
|
||||
private AttendeeMapper $attendeeMapper,
|
||||
private BackendNotifier $backendNotifier,
|
||||
private IManager $notificationManager,
|
||||
private ICloudIdManager $cloudIdManager,
|
||||
private RestrictionValidator $restrictionValidator,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if $sharedBy is allowed to invite $shareWith
|
||||
*
|
||||
* @throws FederationRestrictionException
|
||||
*/
|
||||
public function isAllowedToInvite(
|
||||
IUser $user,
|
||||
ICloudId $cloudIdToInvite,
|
||||
): void {
|
||||
$this->restrictionValidator->isAllowedToInvite($user, $cloudIdToInvite);
|
||||
}
|
||||
|
||||
public function addRemoteRoom(
|
||||
IUser $user,
|
||||
int $remoteAttendeeId,
|
||||
int $roomType,
|
||||
string $roomName,
|
||||
int $roomDefaultPermissions,
|
||||
string $remoteToken,
|
||||
string $remoteServerUrl,
|
||||
#[SensitiveParameter]
|
||||
string $sharedSecret,
|
||||
string $inviterCloudId,
|
||||
string $inviterDisplayName,
|
||||
string $localCloudId,
|
||||
): Invitation {
|
||||
$couldHaveInviteWithOtherCasing = false;
|
||||
try {
|
||||
$room = $this->manager->getRoomByToken($remoteToken, null, $remoteServerUrl);
|
||||
$couldHaveInviteWithOtherCasing = true;
|
||||
} catch (RoomNotFoundException) {
|
||||
$room = $this->manager->createRemoteRoom($roomType, $roomName, $remoteToken, $remoteServerUrl);
|
||||
}
|
||||
|
||||
// Only update the room permissions if there are no participants in the
|
||||
// remote room. Otherwise, the room permissions would be up to date
|
||||
// already due to the notifications about room permission changes.
|
||||
if (!$this->participantService->getNumberOfActors($room)) {
|
||||
$this->roomService->setDefaultPermissions($room, $roomDefaultPermissions);
|
||||
}
|
||||
|
||||
if ($couldHaveInviteWithOtherCasing) {
|
||||
try {
|
||||
$invitation = $this->invitationMapper->getInvitationForUserByLocalRoom($room, $user->getUID(), true);
|
||||
$invitation->setAccessToken($sharedSecret);
|
||||
$invitation->setRemoteAttendeeId($remoteAttendeeId);
|
||||
$invitation->setInviterCloudId($inviterCloudId);
|
||||
$invitation->setInviterDisplayName($inviterDisplayName);
|
||||
|
||||
if ($invitation->getState() === Invitation::STATE_ACCEPTED) {
|
||||
try {
|
||||
$participant = $this->participantService->getParticipantByActor($room, Attendee::ACTOR_USERS, $user->getUID());
|
||||
$attendee = $participant->getAttendee();
|
||||
$attendee->setAccessToken($sharedSecret);
|
||||
$attendee->setRemoteId((string)$remoteAttendeeId);
|
||||
$this->attendeeMapper->update($attendee);
|
||||
} catch (ParticipantNotFoundException) {
|
||||
$invitation->setState(Invitation::STATE_PENDING);
|
||||
}
|
||||
}
|
||||
$this->invitationMapper->update($invitation);
|
||||
|
||||
return $invitation;
|
||||
} catch (DoesNotExistException) {
|
||||
// Not invited with any casing already, so all good.
|
||||
}
|
||||
}
|
||||
|
||||
$invitation = new Invitation();
|
||||
$invitation->setUserId($user->getUID());
|
||||
$invitation->setState(Invitation::STATE_PENDING);
|
||||
$invitation->setLocalRoomId($room->getId());
|
||||
$invitation->setLocalCloudId($localCloudId);
|
||||
$invitation->setAccessToken($sharedSecret);
|
||||
$invitation->setRemoteServerUrl($remoteServerUrl);
|
||||
$invitation->setRemoteToken($remoteToken);
|
||||
$invitation->setRemoteAttendeeId($remoteAttendeeId);
|
||||
$invitation->setInviterCloudId($inviterCloudId);
|
||||
$invitation->setInviterDisplayName($inviterDisplayName);
|
||||
$this->invitationMapper->insert($invitation);
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
protected function markNotificationProcessed(string $userId, int $shareId): void {
|
||||
$notification = $this->notificationManager->createNotification();
|
||||
$notification->setApp(Application::APP_ID)
|
||||
->setUser($userId)
|
||||
->setObject('remote_talk_share', (string)$shareId);
|
||||
$this->notificationManager->markProcessed($notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
* @throws CannotReachRemoteException
|
||||
*/
|
||||
public function acceptRemoteRoomShare(IUser $user, int $shareId): Participant {
|
||||
try {
|
||||
$invitation = $this->invitationMapper->getInvitationById($shareId);
|
||||
} catch (DoesNotExistException $e) {
|
||||
throw new \InvalidArgumentException('invitation');
|
||||
}
|
||||
if ($invitation->getUserId() !== $user->getUID()) {
|
||||
throw new UnauthorizedException('user');
|
||||
}
|
||||
|
||||
if ($invitation->getState() === Invitation::STATE_ACCEPTED) {
|
||||
throw new \InvalidArgumentException('state');
|
||||
}
|
||||
|
||||
|
||||
$cloudId = $this->cloudIdManager->getCloudId($user->getUID(), null);
|
||||
|
||||
// Add user to the room
|
||||
$room = $this->manager->getRoomById($invitation->getLocalRoomId());
|
||||
if (
|
||||
!$this->backendNotifier->sendShareAccepted($invitation->getRemoteServerUrl(), $invitation->getRemoteAttendeeId(), $invitation->getAccessToken(), $user->getDisplayName(), $cloudId->getId())
|
||||
) {
|
||||
throw new CannotReachRemoteException();
|
||||
}
|
||||
|
||||
$participant = [
|
||||
[
|
||||
'actorType' => Attendee::ACTOR_USERS,
|
||||
'actorId' => $user->getUID(),
|
||||
'displayName' => $user->getDisplayName(),
|
||||
'accessToken' => $invitation->getAccessToken(),
|
||||
'remoteId' => $invitation->getRemoteAttendeeId(),
|
||||
'invitedCloudId' => $invitation->getLocalCloudId(),
|
||||
'lastReadMessage' => $room->getLastMessageId(),
|
||||
]
|
||||
];
|
||||
$attendees = $this->participantService->addUsers($room, $participant, $user);
|
||||
/** @var Attendee $attendee */
|
||||
$attendee = array_pop($attendees);
|
||||
|
||||
$invitation->setState(Invitation::STATE_ACCEPTED);
|
||||
$invitation->setLocalCloudId($cloudId->getId());
|
||||
$this->invitationMapper->update($invitation);
|
||||
|
||||
$this->markNotificationProcessed($user->getUID(), $shareId);
|
||||
|
||||
return new Participant($room, $attendee, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function getRemoteShareById(int $shareId): Invitation {
|
||||
return $this->invitationMapper->getInvitationById($shareId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
* @throws UnauthorizedException
|
||||
*/
|
||||
public function rejectRemoteRoomShare(IUser $user, int $shareId): void {
|
||||
try {
|
||||
$invitation = $this->invitationMapper->getInvitationById($shareId);
|
||||
} catch (DoesNotExistException $e) {
|
||||
throw new \InvalidArgumentException('invitation');
|
||||
}
|
||||
|
||||
if ($invitation->getUserId() !== $user->getUID()) {
|
||||
throw new UnauthorizedException('user');
|
||||
}
|
||||
|
||||
if ($invitation->getState() !== Invitation::STATE_PENDING) {
|
||||
throw new \InvalidArgumentException('state');
|
||||
}
|
||||
|
||||
$this->rejectInvitation($invitation, $user->getUID());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
* @throws UnauthorizedException
|
||||
*/
|
||||
public function rejectByRemoveSelf(Room $room, string $userId): void {
|
||||
try {
|
||||
$invitation = $this->invitationMapper->getInvitationForUserByLocalRoom($room, $userId);
|
||||
} catch (DoesNotExistException $e) {
|
||||
throw new \InvalidArgumentException('invitation');
|
||||
}
|
||||
|
||||
$this->rejectInvitation($invitation, $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException
|
||||
* @throws UnauthorizedException
|
||||
*/
|
||||
protected function rejectInvitation(Invitation $invitation, string $userId): void {
|
||||
$this->invitationMapper->delete($invitation);
|
||||
$this->markNotificationProcessed($userId, $invitation->getId());
|
||||
|
||||
$this->backendNotifier->sendShareDeclined($invitation->getRemoteServerUrl(), $invitation->getRemoteAttendeeId(), $invitation->getAccessToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IUser $user
|
||||
* @return Invitation[]
|
||||
*/
|
||||
public function getRemoteRoomShares(IUser $user): array {
|
||||
return $this->invitationMapper->getInvitationsForUser($user);
|
||||
}
|
||||
|
||||
public function getNumberOfPendingInvitationsForUser(IUser $user): int {
|
||||
return $this->invitationMapper->countInvitationsForUser($user, Invitation::STATE_PENDING);
|
||||
}
|
||||
|
||||
public function getNumberOfInvitations(Room $room): int {
|
||||
return $this->invitationMapper->countInvitationsForLocalRoom($room);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Controller;
|
||||
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
|
||||
use OCA\Talk\Model\Invitation;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\FileDisplayResponse;
|
||||
use OCP\Files\SimpleFS\InMemoryFile;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkChatMentionSuggestion from ResponseDefinitions
|
||||
* @psalm-import-type TalkChatMessageWithParent from ResponseDefinitions
|
||||
*/
|
||||
class AvatarController {
|
||||
public function __construct(
|
||||
protected ProxyRequest $proxy,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\AvatarController::getAvatar()
|
||||
*
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Room avatar returned
|
||||
*/
|
||||
public function getAvatar(Room $room, ?Participant $participant, ?Invitation $invitation, bool $darkTheme): FileDisplayResponse {
|
||||
if ($participant === null && $invitation === null) {
|
||||
throw new CannotReachRemoteException('Must receive either participant or invitation');
|
||||
}
|
||||
|
||||
$proxy = $this->proxy->get(
|
||||
$participant ? $participant->getAttendee()->getInvitedCloudId() : $invitation->getLocalCloudId(),
|
||||
$participant ? $participant->getAttendee()->getAccessToken() : $invitation->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/room/' . $room->getRemoteToken() . '/avatar' . ($darkTheme ? '/dark' : ''),
|
||||
);
|
||||
|
||||
if ($proxy->getStatusCode() !== Http::STATUS_OK) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode(), (string)$proxy->getBody());
|
||||
throw new CannotReachRemoteException('Avatar request had unexpected status code');
|
||||
}
|
||||
|
||||
$content = $proxy->getBody();
|
||||
if ($content === '') {
|
||||
throw new CannotReachRemoteException('No avatar content received');
|
||||
}
|
||||
|
||||
$file = new InMemoryFile($room->getToken(), $content);
|
||||
|
||||
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]);
|
||||
// Cache for 1 day
|
||||
$response->cacheFor(60 * 60 * 24, false, true);
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\AvatarController::getUserProxyAvatar()
|
||||
*
|
||||
* @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: User avatar returned
|
||||
*/
|
||||
public function getUserProxyAvatar(string $remoteServer, string $user, int $size, bool $darkTheme): FileDisplayResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
null,
|
||||
null,
|
||||
$remoteServer . '/index.php/avatar/' . $user . '/' . $size . ($darkTheme ? '/dark' : ''),
|
||||
);
|
||||
|
||||
if ($proxy->getStatusCode() !== Http::STATUS_OK) {
|
||||
if ($proxy->getStatusCode() !== Http::STATUS_NOT_FOUND) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode(), (string)$proxy->getBody());
|
||||
}
|
||||
throw new CannotReachRemoteException('Avatar request had unexpected status code');
|
||||
}
|
||||
|
||||
$content = $proxy->getBody();
|
||||
if ($content === '') {
|
||||
throw new CannotReachRemoteException('No avatar content received');
|
||||
}
|
||||
|
||||
$file = new InMemoryFile($user, $content);
|
||||
|
||||
$response = new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]);
|
||||
// Cache for 1 day
|
||||
$response->cacheFor(60 * 60 * 24, false, true);
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Controller;
|
||||
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
|
||||
use OCA\Talk\Model\Session;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkCapabilities from ResponseDefinitions
|
||||
* @psalm-import-type TalkCallPeer from ResponseDefinitions
|
||||
* @psalm-import-type TalkParticipant from ResponseDefinitions
|
||||
* @psalm-import-type TalkRoom from ResponseDefinitions
|
||||
*/
|
||||
class CallController {
|
||||
public function __construct(
|
||||
protected ProxyRequest $proxy,
|
||||
protected UserConverter $userConverter,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\CallController::getPeersForCall()
|
||||
*
|
||||
* @param Room $room the federated room to get the call peers
|
||||
* @param Participant $participant the federated user to get the call peers
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkCallPeer>, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: List of peers in the call returned
|
||||
*/
|
||||
public function getPeersForCall(Room $room, Participant $participant): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v4/call/' . $room->getRemoteToken(),
|
||||
);
|
||||
|
||||
/** @var list<TalkCallPeer> $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
|
||||
/** @var list<TalkCallPeer> $data */
|
||||
$data = $this->userConverter->convertAttendees($room, $data, 'actorType', 'actorId', 'displayName');
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if (!in_array($statusCode, [Http::STATUS_OK], true)) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode());
|
||||
throw new CannotReachRemoteException();
|
||||
}
|
||||
|
||||
return new DataResponse($data, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\CallController::joinFederatedCall()
|
||||
*
|
||||
* @param Room $room the federated room to join the call in
|
||||
* @param Participant $participant the federated user that will join the
|
||||
* call; the participant must have a session
|
||||
* @param int<0, 15> $flags In-Call flags
|
||||
* @psalm-param int-mask-of<Participant::FLAG_*> $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{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Federated user is now in the call
|
||||
* 400: Conditions to join not met
|
||||
* 404: Room not found
|
||||
*/
|
||||
public function joinFederatedCall(Room $room, Participant $participant, int $flags, bool $silent, bool $recordingConsent): DataResponse {
|
||||
$options = [
|
||||
'sessionId' => $participant->getSession()->getSessionId(),
|
||||
'flags' => $flags,
|
||||
'silent' => $silent,
|
||||
'recordingConsent' => $recordingConsent,
|
||||
];
|
||||
|
||||
$proxy = $this->proxy->post(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v4/call/' . $room->getRemoteToken() . '/federation',
|
||||
$options,
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if (!in_array($statusCode, [Http::STATUS_OK, Http::STATUS_BAD_REQUEST, Http::STATUS_NOT_FOUND], true)) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode());
|
||||
throw new CannotReachRemoteException();
|
||||
}
|
||||
|
||||
if ($statusCode === Http::STATUS_BAD_REQUEST) {
|
||||
/** @var array{error: string} $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_BAD_REQUEST]);
|
||||
return new DataResponse($data, $statusCode);
|
||||
}
|
||||
|
||||
return new DataResponse(null, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\CallController::ringAttendee()
|
||||
*
|
||||
* @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{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Attendee rang successfully
|
||||
* 400: Ringing attendee is not possible
|
||||
* 404: Attendee could not be found
|
||||
*/
|
||||
public function ringAttendee(Room $room, Participant $participant, int $attendeeId): DataResponse {
|
||||
$proxy = $this->proxy->post(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v4/call/' . $room->getRemoteToken() . '/ring/' . $attendeeId,
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if (!in_array($statusCode, [Http::STATUS_OK, Http::STATUS_BAD_REQUEST, Http::STATUS_NOT_FOUND], true)) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode());
|
||||
throw new CannotReachRemoteException();
|
||||
}
|
||||
|
||||
if ($statusCode === Http::STATUS_BAD_REQUEST) {
|
||||
/** @var array{error: string} $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_BAD_REQUEST]);
|
||||
return new DataResponse($data, $statusCode);
|
||||
}
|
||||
|
||||
return new DataResponse(null, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\CallController::updateFederatedCallFlags()
|
||||
*
|
||||
* @param Room $room the federated room to update the call flags in
|
||||
* @param Participant $participant the federated user to update the call
|
||||
* flags; the participant must have a session
|
||||
* @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{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: In-call flags updated successfully for federated user
|
||||
* 400: Updating in-call flags is not possible
|
||||
* 404: Room not found
|
||||
*/
|
||||
public function updateFederatedCallFlags(Room $room, Participant $participant, int $flags): DataResponse {
|
||||
$options = [
|
||||
'sessionId' => $participant->getSession()->getSessionId(),
|
||||
'flags' => $flags,
|
||||
];
|
||||
|
||||
$proxy = $this->proxy->put(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v4/call/' . $room->getRemoteToken() . '/federation',
|
||||
$options,
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if (!in_array($statusCode, [Http::STATUS_OK, Http::STATUS_BAD_REQUEST, Http::STATUS_NOT_FOUND], true)) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode());
|
||||
throw new CannotReachRemoteException();
|
||||
}
|
||||
|
||||
return new DataResponse(null, $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\CallController::leaveFederatedCall()
|
||||
*
|
||||
* @param Room $room the federated room to leave the call in
|
||||
* @param Participant $participant the federated user that will leave the
|
||||
* call; the participant must have a session
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, null, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Federated user left the call
|
||||
* 404: Room not found
|
||||
*/
|
||||
public function leaveFederatedCall(Room $room, Participant $participant): DataResponse {
|
||||
$options = [
|
||||
'sessionId' => $participant->getSession()->getSessionId(),
|
||||
];
|
||||
|
||||
$proxy = $this->proxy->delete(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v4/call/' . $room->getRemoteToken() . '/federation',
|
||||
$options,
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if (!in_array($statusCode, [Http::STATUS_OK, Http::STATUS_NOT_FOUND], true)) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode());
|
||||
throw new CannotReachRemoteException();
|
||||
}
|
||||
|
||||
return new DataResponse(null, $statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Controller;
|
||||
|
||||
use OCA\Talk\CachePrefix;
|
||||
use OCA\Talk\Chat\Notifier;
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RoomFormatter;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\ICache;
|
||||
use OCP\ICacheFactory;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkChatMentionSuggestion from ResponseDefinitions
|
||||
* @psalm-import-type TalkChatMessage from ResponseDefinitions
|
||||
* @psalm-import-type TalkChatMessageWithParent from ResponseDefinitions
|
||||
* @psalm-import-type TalkRoom from ResponseDefinitions
|
||||
*/
|
||||
class ChatController {
|
||||
protected ?ICache $proxyCacheMessages;
|
||||
|
||||
public function __construct(
|
||||
protected ProxyRequest $proxy,
|
||||
protected UserConverter $userConverter,
|
||||
protected ParticipantService $participantService,
|
||||
protected RoomFormatter $roomFormatter,
|
||||
protected Notifier $notifier,
|
||||
ICacheFactory $cacheFactory,
|
||||
) {
|
||||
$this->proxyCacheMessages = $cacheFactory->isAvailable() ? $cacheFactory->createDistributed(CachePrefix::FEDERATED_PCM) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\ChatController::sendMessage()
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_CREATED, ?TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_REQUEST_ENTITY_TOO_LARGE|Http::STATUS_TOO_MANY_REQUESTS, array{error: string}, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 201: Message sent successfully
|
||||
* 400: Sending message is not possible
|
||||
* 404: Actor not found
|
||||
* 413: Message too long
|
||||
* 429: Mention rate limit exceeded (guests only)
|
||||
*/
|
||||
public function sendMessage(Room $room, Participant $participant, string $message, string $referenceId, int $replyTo, bool $silent, string $threadTitle, int $threadId): DataResponse {
|
||||
$proxy = $this->proxy->post(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken(),
|
||||
[
|
||||
'message' => $message,
|
||||
'actorDisplayName' => $participant->getAttendee()->getDisplayName(),
|
||||
'referenceId' => $referenceId,
|
||||
'replyTo' => $replyTo,
|
||||
'silent' => $silent,
|
||||
'threadTitle' => $threadTitle,
|
||||
'threadId' => $threadId,
|
||||
],
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if ($statusCode !== Http::STATUS_CREATED) {
|
||||
if (!in_array($statusCode, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
Http::STATUS_NOT_FOUND,
|
||||
Http::STATUS_REQUEST_ENTITY_TOO_LARGE,
|
||||
Http::STATUS_TOO_MANY_REQUESTS,
|
||||
], true)) {
|
||||
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
}
|
||||
/** @var array{error: string} $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_CREATED]);
|
||||
return new DataResponse($data, $statusCode);
|
||||
}
|
||||
|
||||
/** @var ?TalkChatMessageWithParent $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_CREATED]);
|
||||
if (!empty($data)) {
|
||||
$data = $this->userConverter->convertMessage($room, $data);
|
||||
} else {
|
||||
$data = null;
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
if ($proxy->getHeader('X-Chat-Last-Common-Read')) {
|
||||
$headers['X-Chat-Last-Common-Read'] = (string)(int)$proxy->getHeader('X-Chat-Last-Common-Read');
|
||||
}
|
||||
|
||||
return new DataResponse(
|
||||
$data,
|
||||
Http::STATUS_CREATED,
|
||||
$headers,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkChatMessageWithParent>, array{'X-Chat-Last-Common-Read'?: numeric-string, X-Chat-Last-Given?: numeric-string}>|DataResponse<Http::STATUS_NOT_MODIFIED, null, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Messages returned
|
||||
* 304: No messages
|
||||
*
|
||||
* @see \OCA\Talk\Controller\ChatController::getMessageContext()
|
||||
*/
|
||||
public function receiveMessages(
|
||||
Room $room,
|
||||
Participant $participant,
|
||||
int $lookIntoFuture,
|
||||
int $limit,
|
||||
int $lastKnownMessageId,
|
||||
int $lastCommonReadId,
|
||||
int $timeout,
|
||||
int $setReadMarker,
|
||||
int $includeLastKnown,
|
||||
int $noStatusUpdate,
|
||||
int $markNotificationsAsRead): DataResponse {
|
||||
$cacheKey = sha1(json_encode([$room->getRemoteServer(), $room->getRemoteToken()]));
|
||||
|
||||
|
||||
if ($lookIntoFuture && $markNotificationsAsRead && $participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
|
||||
$this->notifier->markMentionNotificationsRead($room, $participant->getAttendee()->getActorId());
|
||||
}
|
||||
|
||||
if ($lookIntoFuture) {
|
||||
if ($this->proxyCacheMessages instanceof ICache) {
|
||||
for ($i = 0; $i <= $timeout; $i++) {
|
||||
$cacheData = (int)$this->proxyCacheMessages->get($cacheKey);
|
||||
if ($lastKnownMessageId !== $cacheData) {
|
||||
break;
|
||||
}
|
||||
sleep(1);
|
||||
}
|
||||
} else {
|
||||
// Poor-mans timeout, should later on cancel/trigger earlier,
|
||||
// by checking the PCM database table
|
||||
sleep(max(0, $timeout - 5));
|
||||
}
|
||||
}
|
||||
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken(),
|
||||
[
|
||||
'lookIntoFuture' => $lookIntoFuture,
|
||||
'limit' => $limit,
|
||||
'lastKnownMessageId' => $lastKnownMessageId,
|
||||
'lastCommonReadId' => $lastCommonReadId,
|
||||
'timeout' => 0,
|
||||
'setReadMarker' => $setReadMarker,
|
||||
'includeLastKnown' => $includeLastKnown,
|
||||
'noStatusUpdate' => $noStatusUpdate,
|
||||
'markNotificationsAsRead' => $markNotificationsAsRead,
|
||||
],
|
||||
);
|
||||
|
||||
if ($lookIntoFuture && $setReadMarker) {
|
||||
$this->participantService->updateUnreadInfoForProxyParticipant($participant,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
(int)($proxy->getHeader('X-Chat-Last-Given') ?: $lastKnownMessageId),
|
||||
);
|
||||
}
|
||||
|
||||
if ($proxy->getStatusCode() === Http::STATUS_NOT_MODIFIED) {
|
||||
if ($lookIntoFuture && $this->proxyCacheMessages instanceof ICache) {
|
||||
$cacheData = $this->proxyCacheMessages->get($cacheKey);
|
||||
if ($cacheData === null || $cacheData < $lastKnownMessageId) {
|
||||
$this->proxyCacheMessages->set($cacheKey, $lastKnownMessageId, 300);
|
||||
}
|
||||
}
|
||||
return new DataResponse(null, Http::STATUS_NOT_MODIFIED);
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
if ($proxy->getHeader('X-Chat-Last-Common-Read')) {
|
||||
$headers['X-Chat-Last-Common-Read'] = (string)(int)$proxy->getHeader('X-Chat-Last-Common-Read');
|
||||
}
|
||||
if ($proxy->getHeader('X-Chat-Last-Given')) {
|
||||
$headers['X-Chat-Last-Given'] = (string)(int)$proxy->getHeader('X-Chat-Last-Given');
|
||||
if ($lookIntoFuture && $this->proxyCacheMessages instanceof ICache) {
|
||||
$cacheData = $this->proxyCacheMessages->get($cacheKey);
|
||||
if ($cacheData === null || $cacheData < $headers['X-Chat-Last-Given']) {
|
||||
$this->proxyCacheMessages->set($cacheKey, (int)$headers['X-Chat-Last-Given'], 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @var list<TalkChatMessageWithParent> $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
/** @var list<TalkChatMessageWithParent> $data */
|
||||
$data = $this->userConverter->convertMessages($room, $data);
|
||||
|
||||
return new DataResponse($data, Http::STATUS_OK, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkChatMessageWithParent>, array{'X-Chat-Last-Common-Read'?: numeric-string, X-Chat-Last-Given?: numeric-string}>|DataResponse<Http::STATUS_NOT_MODIFIED, null, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Message context returned
|
||||
* 304: No messages
|
||||
*
|
||||
* @see \OCA\Talk\Controller\ChatController::getMessageContext()
|
||||
*/
|
||||
public function getMessageContext(Room $room, Participant $participant, int $messageId, int $limit): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/' . $messageId . '/context',
|
||||
[
|
||||
'limit' => $limit,
|
||||
],
|
||||
);
|
||||
|
||||
if ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {
|
||||
$this->notifier->markMentionNotificationsRead($room, $participant->getAttendee()->getActorId());
|
||||
}
|
||||
|
||||
if ($proxy->getStatusCode() === Http::STATUS_NOT_MODIFIED) {
|
||||
return new DataResponse(null, Http::STATUS_NOT_MODIFIED);
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
if ($proxy->getHeader('X-Chat-Last-Common-Read')) {
|
||||
$headers['X-Chat-Last-Common-Read'] = (string)(int)$proxy->getHeader('X-Chat-Last-Common-Read');
|
||||
}
|
||||
if ($proxy->getHeader('X-Chat-Last-Given')) {
|
||||
$headers['X-Chat-Last-Given'] = (string)(int)$proxy->getHeader('X-Chat-Last-Given');
|
||||
}
|
||||
|
||||
/** @var list<TalkChatMessageWithParent> $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
/** @var list<TalkChatMessageWithParent> $data */
|
||||
$data = $this->userConverter->convertMessages($room, $data);
|
||||
|
||||
return new DataResponse($data, Http::STATUS_OK, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DataResponse<Http::STATUS_OK, array<string, list<TalkChatMessage>>, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: List of shared objects messages of each type returned
|
||||
*
|
||||
* @see \OCA\Talk\Controller\ChatController::getObjectsSharedInRoomOverview()
|
||||
*/
|
||||
public function getObjectsSharedInRoomOverview(Room $room, Participant $participant, int $limit): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/share/overview',
|
||||
[
|
||||
'limit' => $limit,
|
||||
],
|
||||
);
|
||||
|
||||
// We allow "200 OK" and "406 Not Acceptable" here, so that 33 against 32 and before is working well
|
||||
/** @var array<string, list<TalkChatMessage>> $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_OK, Http::STATUS_NOT_ACCEPTABLE]);
|
||||
|
||||
$result = [];
|
||||
foreach ($data as $type => $items) {
|
||||
$result[$type] = array_values($this->userConverter->convertMessages($room, $items));
|
||||
}
|
||||
|
||||
/** @var array<string, list<TalkChatMessage>> $result */
|
||||
return new DataResponse($result, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DataResponse<Http::STATUS_OK, array<string, TalkChatMessage>, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: List of shared objects messages returned
|
||||
*
|
||||
* @see \OCA\Talk\Controller\ChatController::getObjectsSharedInRoom()
|
||||
*/
|
||||
public function getObjectsSharedInRoom(Room $room, Participant $participant, string $objectType, int $lastKnownMessageId, int $limit): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/share',
|
||||
[
|
||||
'objectType' => $objectType,
|
||||
'lastKnownMessageId' => $lastKnownMessageId,
|
||||
'limit' => $limit,
|
||||
],
|
||||
);
|
||||
|
||||
// We allow "200 OK" and "406 Not Acceptable" here, so that 33 against 32 and before is working well
|
||||
/** @var array<string, TalkChatMessage> $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_OK, Http::STATUS_NOT_ACCEPTABLE]);
|
||||
/** @var array<string, TalkChatMessage> $data */
|
||||
$data = $this->userConverter->convertMessages($room, $data);
|
||||
|
||||
return new DataResponse($data, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_ACCEPTED, TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: string}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_METHOD_NOT_ALLOWED|Http::STATUS_REQUEST_ENTITY_TOO_LARGE, array{error: string}, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Message edited successfully
|
||||
* 202: Message edited successfully, but a bot or Matterbridge is configured, so the information can be replicated to other services
|
||||
* 400: Editing message is not possible, e.g. when the new message is empty or the message is too old
|
||||
* 403: Missing permissions to edit message
|
||||
* 404: Message not found
|
||||
* 405: Editing this message type is not allowed
|
||||
*
|
||||
* @see \OCA\Talk\Controller\ChatController::editMessage()
|
||||
*/
|
||||
public function editMessage(Room $room, Participant $participant, int $messageId, string $message): DataResponse {
|
||||
$proxy = $this->proxy->put(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/' . $messageId,
|
||||
[
|
||||
'message' => $message,
|
||||
],
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if ($statusCode !== Http::STATUS_OK && $statusCode !== Http::STATUS_ACCEPTED) {
|
||||
if (!in_array($statusCode, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
Http::STATUS_FORBIDDEN,
|
||||
Http::STATUS_NOT_FOUND,
|
||||
Http::STATUS_METHOD_NOT_ALLOWED,
|
||||
Http::STATUS_REQUEST_ENTITY_TOO_LARGE,
|
||||
], true)) {
|
||||
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
$data = ['error' => 'status'];
|
||||
} elseif ($statusCode === Http::STATUS_BAD_REQUEST) {
|
||||
/** @var array{error: string} $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_BAD_REQUEST]);
|
||||
} else {
|
||||
$data = [];
|
||||
}
|
||||
return new DataResponse($data, $statusCode);
|
||||
}
|
||||
|
||||
/** @var TalkChatMessageWithParent $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_OK, Http::STATUS_ACCEPTED]);
|
||||
$data = $this->userConverter->convertMessage($room, $data);
|
||||
|
||||
$headers = [];
|
||||
if ($proxy->getHeader('X-Chat-Last-Common-Read')) {
|
||||
$headers['X-Chat-Last-Common-Read'] = (string)(int)$proxy->getHeader('X-Chat-Last-Common-Read');
|
||||
}
|
||||
|
||||
return new DataResponse(
|
||||
$data,
|
||||
$statusCode,
|
||||
$headers,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_ACCEPTED, TalkChatMessageWithParent, array{X-Chat-Last-Common-Read?: numeric-string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_METHOD_NOT_ALLOWED, array{error: string}, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Message deleted successfully
|
||||
* 202: Message deleted successfully, but a bot or Matterbridge is configured, so the information can be replicated elsewhere
|
||||
* 400: Deleting message is not possible
|
||||
* 403: Missing permissions to delete message
|
||||
* 404: Message not found
|
||||
* 405: Deleting this message type is not allowed
|
||||
*
|
||||
* @see \OCA\Talk\Controller\ChatController::deleteMessage()
|
||||
*/
|
||||
public function deleteMessage(Room $room, Participant $participant, int $messageId): DataResponse {
|
||||
$proxy = $this->proxy->delete(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/' . $messageId,
|
||||
);
|
||||
|
||||
/** @var Http::STATUS_OK|Http::STATUS_ACCEPTED|Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_REQUEST_ENTITY_TOO_LARGE $statusCode */
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
|
||||
if ($statusCode !== Http::STATUS_OK && $statusCode !== Http::STATUS_ACCEPTED) {
|
||||
if (in_array($statusCode, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
Http::STATUS_FORBIDDEN,
|
||||
Http::STATUS_NOT_FOUND,
|
||||
Http::STATUS_REQUEST_ENTITY_TOO_LARGE,
|
||||
], true)) {
|
||||
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
}
|
||||
return new DataResponse([], $statusCode);
|
||||
}
|
||||
|
||||
/** @var TalkChatMessageWithParent $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_OK, Http::STATUS_ACCEPTED]);
|
||||
$data = $this->userConverter->convertMessage($room, $data);
|
||||
|
||||
$headers = [];
|
||||
if ($proxy->getHeader('X-Chat-Last-Common-Read')) {
|
||||
$headers['X-Chat-Last-Common-Read'] = (string)(int)$proxy->getHeader('X-Chat-Last-Common-Read');
|
||||
}
|
||||
|
||||
return new DataResponse(
|
||||
$data,
|
||||
$statusCode,
|
||||
$headers,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\ChatController::setReadMarker()
|
||||
*
|
||||
* @param 'json'|'xml' $responseFormat
|
||||
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{X-Chat-Last-Common-Read?: numeric-string}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: List of mention suggestions returned
|
||||
*/
|
||||
public function setReadMarker(Room $room, Participant $participant, string $responseFormat, ?int $lastReadMessage): DataResponse {
|
||||
$proxy = $this->proxy->post(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/read',
|
||||
$lastReadMessage !== null ? [
|
||||
'lastReadMessage' => $lastReadMessage,
|
||||
] : [],
|
||||
);
|
||||
|
||||
/** @var TalkRoom $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
|
||||
$this->participantService->updateUnreadInfoForProxyParticipant(
|
||||
$participant,
|
||||
$data['unreadMessages'],
|
||||
$data['unreadMention'],
|
||||
$data['unreadMentionDirect'],
|
||||
$data['lastReadMessage'],
|
||||
);
|
||||
|
||||
$headers = $lastCommonRead = [];
|
||||
if ($proxy->getHeader('X-Chat-Last-Common-Read')) {
|
||||
$lastCommonRead[$room->getId()] = (int)$proxy->getHeader('X-Chat-Last-Common-Read');
|
||||
$headers['X-Chat-Last-Common-Read'] = (string)$lastCommonRead[$room->getId()];
|
||||
}
|
||||
|
||||
return new DataResponse($this->roomFormatter->formatRoom(
|
||||
$responseFormat,
|
||||
$lastCommonRead,
|
||||
$room,
|
||||
$participant,
|
||||
), Http::STATUS_OK, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\ChatController::markUnread()
|
||||
*
|
||||
* @param 'json'|'xml' $responseFormat
|
||||
* @return DataResponse<Http::STATUS_OK, TalkRoom, array{X-Chat-Last-Common-Read?: numeric-string}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: List of mention suggestions returned
|
||||
*/
|
||||
public function markUnread(Room $room, Participant $participant, string $responseFormat): DataResponse {
|
||||
$proxy = $this->proxy->delete(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/read',
|
||||
);
|
||||
|
||||
/** @var TalkRoom $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
|
||||
$this->participantService->updateUnreadInfoForProxyParticipant(
|
||||
$participant,
|
||||
$data['unreadMessages'],
|
||||
$data['unreadMention'],
|
||||
$data['unreadMentionDirect'],
|
||||
$data['lastReadMessage'],
|
||||
);
|
||||
|
||||
$headers = $lastCommonRead = [];
|
||||
if ($proxy->getHeader('X-Chat-Last-Common-Read')) {
|
||||
$lastCommonRead[$room->getId()] = (int)$proxy->getHeader('X-Chat-Last-Common-Read');
|
||||
$headers['X-Chat-Last-Common-Read'] = (string)$lastCommonRead[$room->getId()];
|
||||
}
|
||||
|
||||
return new DataResponse($this->roomFormatter->formatRoom(
|
||||
$responseFormat,
|
||||
$lastCommonRead,
|
||||
$room,
|
||||
$participant,
|
||||
), Http::STATUS_OK, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\ChatController::mentions()
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkChatMentionSuggestion>, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: List of mention suggestions returned
|
||||
*/
|
||||
public function mentions(Room $room, Participant $participant, string $search, int $limit, bool $includeStatus): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/mentions',
|
||||
[
|
||||
'search' => $search,
|
||||
'limit' => $limit,
|
||||
'includeStatus' => $includeStatus,
|
||||
],
|
||||
);
|
||||
|
||||
/** @var list<TalkChatMentionSuggestion> $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
/** @var list<TalkChatMentionSuggestion> $data */
|
||||
$data = $this->userConverter->convertAttendees($room, $data, 'source', 'id', 'label');
|
||||
|
||||
// FIXME post-load status information
|
||||
return new DataResponse($data, Http::STATUS_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Controller;
|
||||
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Exceptions\PollPropertyException;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkPoll from ResponseDefinitions
|
||||
* @psalm-import-type TalkPollDraft from ResponseDefinitions
|
||||
*/
|
||||
class PollController {
|
||||
public function __construct(
|
||||
protected ProxyRequest $proxy,
|
||||
protected UserConverter $userConverter,
|
||||
protected LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkPollDraft>, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND, list<empty>, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Polls returned
|
||||
* 404: Polls not found
|
||||
*
|
||||
* @see \OCA\Talk\Controller\PollController::showPoll()
|
||||
*/
|
||||
public function getDraftsForRoom(Room $room, Participant $participant): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/drafts',
|
||||
);
|
||||
|
||||
$status = $proxy->getStatusCode();
|
||||
if ($status === Http::STATUS_NOT_FOUND || $status === Http::STATUS_FORBIDDEN) {
|
||||
return new DataResponse([], $status);
|
||||
}
|
||||
|
||||
/** @var list<TalkPollDraft> $list */
|
||||
$list = $this->proxy->getOCSData($proxy);
|
||||
|
||||
$data = [];
|
||||
foreach ($list as $poll) {
|
||||
$data[] = $this->userConverter->convertPoll($room, $poll);
|
||||
}
|
||||
|
||||
return new DataResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: string}, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Poll returned
|
||||
* 404: Poll not found
|
||||
*
|
||||
* @see \OCA\Talk\Controller\PollController::showPoll()
|
||||
*/
|
||||
public function showPoll(Room $room, Participant $participant, int $pollId): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/' . $pollId,
|
||||
);
|
||||
|
||||
if ($proxy->getStatusCode() === Http::STATUS_NOT_FOUND) {
|
||||
/** @var array{error?: string} $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
return new DataResponse(['error' => $data['error'] ?? 'poll'], Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
/** @var TalkPoll $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
$data = $this->userConverter->convertPoll($room, $data);
|
||||
|
||||
return new DataResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $optionIds
|
||||
* @return DataResponse<Http::STATUS_OK, TalkPoll, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, array{error: string}, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Voted successfully
|
||||
* 400: Voting is not possible
|
||||
* 404: Poll not found
|
||||
*
|
||||
* @see \OCA\Talk\Controller\PollController::votePoll()
|
||||
*/
|
||||
public function votePoll(Room $room, Participant $participant, int $pollId, array $optionIds): DataResponse {
|
||||
$proxy = $this->proxy->post(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/' . $pollId,
|
||||
['optionIds' => $optionIds],
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if ($statusCode !== Http::STATUS_OK) {
|
||||
if (!in_array($statusCode, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
Http::STATUS_NOT_FOUND,
|
||||
], true)) {
|
||||
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
}
|
||||
/** @var array{error?: string} $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
return new DataResponse(['error' => $data['error'] ?? 'poll'], $statusCode);
|
||||
}
|
||||
|
||||
/** @var TalkPoll $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
$data = $this->userConverter->convertPoll($room, $data);
|
||||
|
||||
return new DataResponse($data);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @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{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Draft created successfully
|
||||
* 201: Poll created successfully
|
||||
* 400: Creating poll is not possible
|
||||
*
|
||||
* @see \OCA\Talk\Controller\PollController::createPoll()
|
||||
*/
|
||||
public function createPoll(Room $room, Participant $participant, string $question, array $options, int $resultMode, int $maxVotes, bool $draft): DataResponse {
|
||||
$proxy = $this->proxy->post(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken(),
|
||||
[
|
||||
'question' => $question,
|
||||
'options' => $options,
|
||||
'resultMode' => $resultMode,
|
||||
'maxVotes' => $maxVotes,
|
||||
'draft' => $draft,
|
||||
],
|
||||
);
|
||||
|
||||
$status = $proxy->getStatusCode();
|
||||
if ($status === Http::STATUS_BAD_REQUEST) {
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_BAD_REQUEST]);
|
||||
return new DataResponse($data, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
/** @var TalkPoll $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_OK, Http::STATUS_CREATED]);
|
||||
$data = $this->userConverter->convertPoll($room, $data);
|
||||
|
||||
if ($status === Http::STATUS_OK) {
|
||||
return new DataResponse($data);
|
||||
}
|
||||
return new DataResponse($data, Http::STATUS_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Draft created successfully
|
||||
* 201: Poll created successfully
|
||||
* 400: Creating poll is not possible
|
||||
*
|
||||
* @see \OCA\Talk\Controller\PollController::createPoll()
|
||||
*/
|
||||
public function updateDraftPoll(int $pollId, Room $room, Participant $participant, string $question, array $options, int $resultMode, int $maxVotes): DataResponse {
|
||||
$proxy = $this->proxy->post(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/draft/' . $pollId,
|
||||
[
|
||||
'question' => $question,
|
||||
'options' => $options,
|
||||
'resultMode' => $resultMode,
|
||||
'maxVotes' => $maxVotes
|
||||
],
|
||||
);
|
||||
|
||||
$status = $proxy->getStatusCode();
|
||||
if ($status === Http::STATUS_BAD_REQUEST) {
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_BAD_REQUEST]);
|
||||
return new DataResponse($data, Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
|
||||
/** @var TalkPollDraft $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_OK, Http::STATUS_CREATED]);
|
||||
$data = $this->userConverter->convertPoll($room, $data);
|
||||
|
||||
if ($status === Http::STATUS_OK) {
|
||||
return new DataResponse($data);
|
||||
}
|
||||
return new DataResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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: 'poll'}, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Poll closed successfully
|
||||
* 400: Poll already closed
|
||||
* 403: Missing permissions to close poll
|
||||
* 404: Poll not found
|
||||
*
|
||||
* @see \OCA\Talk\Controller\PollController::closePoll()
|
||||
*/
|
||||
public function closePoll(Room $room, Participant $participant, int $pollId): DataResponse {
|
||||
$proxy = $this->proxy->delete(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/poll/' . $room->getRemoteToken() . '/' . $pollId,
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if ($statusCode !== Http::STATUS_OK) {
|
||||
if (!in_array($statusCode, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
Http::STATUS_FORBIDDEN,
|
||||
Http::STATUS_NOT_FOUND,
|
||||
], true)) {
|
||||
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
}
|
||||
/** @var array{error?: string} $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
|
||||
if ($data['error'] !== PollPropertyException::REASON_POLL) {
|
||||
$this->logger->error('Unhandled error in ' . __METHOD__ . ': ' . $data['error']);
|
||||
}
|
||||
|
||||
return new DataResponse(['error' => PollPropertyException::REASON_POLL], $statusCode);
|
||||
}
|
||||
|
||||
/** @var TalkPoll $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
$data = $this->userConverter->convertPoll($room, $data);
|
||||
|
||||
return new DataResponse($data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Controller;
|
||||
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkReaction from ResponseDefinitions
|
||||
*/
|
||||
class ReactionController {
|
||||
public function __construct(
|
||||
protected ProxyRequest $proxy,
|
||||
protected UserConverter $userConverter,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @see \OCA\Talk\Controller\ReactionController::react()
|
||||
*/
|
||||
public function react(Room $room, Participant $participant, int $messageId, string $reaction, string $format): DataResponse {
|
||||
$proxy = $this->proxy->post(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/reaction/' . $room->getRemoteToken() . '/' . $messageId,
|
||||
[
|
||||
'reaction' => $reaction,
|
||||
],
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if ($statusCode !== Http::STATUS_OK && $statusCode !== Http::STATUS_CREATED) {
|
||||
if (!in_array($statusCode, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
Http::STATUS_NOT_FOUND,
|
||||
], true)) {
|
||||
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
}
|
||||
return new DataResponse(null, $statusCode);
|
||||
}
|
||||
|
||||
/** @var array<string, list<TalkReaction>> $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_CREATED, Http::STATUS_OK]);
|
||||
$data = $this->userConverter->convertReactionsList($room, $data);
|
||||
|
||||
return new DataResponse($this->formatReactions($format, $data), $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @see \OCA\Talk\Controller\ReactionController::delete()
|
||||
*/
|
||||
public function delete(Room $room, Participant $participant, int $messageId, string $reaction, string $format): DataResponse {
|
||||
$proxy = $this->proxy->delete(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/reaction/' . $room->getRemoteToken() . '/' . $messageId,
|
||||
[
|
||||
'reaction' => $reaction,
|
||||
],
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if ($statusCode !== Http::STATUS_OK) {
|
||||
if (!in_array($statusCode, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
Http::STATUS_NOT_FOUND,
|
||||
], true)) {
|
||||
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
}
|
||||
return new DataResponse(null, $statusCode);
|
||||
}
|
||||
|
||||
/** @var array<string, list<TalkReaction>> $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
$data = $this->userConverter->convertReactionsList($room, $data);
|
||||
|
||||
return new DataResponse($this->formatReactions($format, $data), $statusCode);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @see \OCA\Talk\Controller\ReactionController::getReactions()
|
||||
*/
|
||||
public function getReactions(Room $room, Participant $participant, int $messageId, ?string $reaction, string $format): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/reaction/' . $room->getRemoteToken() . '/' . $messageId,
|
||||
$reaction === null ? [] : [
|
||||
'reaction' => $reaction,
|
||||
],
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if ($statusCode !== Http::STATUS_OK) {
|
||||
if ($statusCode !== Http::STATUS_NOT_FOUND) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
}
|
||||
return new DataResponse(null, Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
/** @var array<string, list<TalkReaction>> $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
$data = $this->userConverter->convertReactionsList($room, $data);
|
||||
|
||||
return new DataResponse($this->formatReactions($format, $data), $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<TalkReaction>> $reactions
|
||||
* @return array<string, list<TalkReaction>>|\stdClass
|
||||
*/
|
||||
protected function formatReactions(string $format, array $reactions): array|\stdClass {
|
||||
if ($format === '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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Controller;
|
||||
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
|
||||
use OCA\Talk\Model\Session;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkCapabilities from ResponseDefinitions
|
||||
* @psalm-import-type TalkParticipant from ResponseDefinitions
|
||||
* @psalm-import-type TalkRoom from ResponseDefinitions
|
||||
*/
|
||||
class RoomController {
|
||||
public function __construct(
|
||||
protected ProxyRequest $proxy,
|
||||
protected UserConverter $userConverter,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\RoomController::getParticipants()
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkParticipant>, array{X-Nextcloud-Has-User-Statuses?: bool}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Participants returned
|
||||
* 403: Missing permissions for getting participants
|
||||
*/
|
||||
public function getParticipants(Room $room, Participant $participant): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v4/room/' . $room->getRemoteToken() . '/participants',
|
||||
);
|
||||
|
||||
/** @var list<TalkParticipant> $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
|
||||
/** @var list<TalkParticipant> $data */
|
||||
$data = $this->userConverter->convertAttendees($room, $data, 'actorType', 'actorId', 'displayName');
|
||||
$headers = [];
|
||||
if ($proxy->getHeader('X-Nextcloud-Has-User-Statuses')) {
|
||||
$headers['X-Nextcloud-Has-User-Statuses'] = (bool)$proxy->getHeader('X-Nextcloud-Has-User-Statuses');
|
||||
}
|
||||
|
||||
return new DataResponse($data, Http::STATUS_OK, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\RoomController::joinFederatedRoom()
|
||||
*
|
||||
* @param Room $room the federated room to join
|
||||
* @param Participant $participant the federated user to will join the room;
|
||||
* the participant must have a session
|
||||
* @return DataResponse<Http::STATUS_OK|Http::STATUS_NOT_FOUND, array<empty>, array{X-Nextcloud-Talk-Proxy-Hash: string}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Federated user joined the room
|
||||
* 404: Room not found
|
||||
*/
|
||||
public function joinFederatedRoom(Room $room, Participant $participant): DataResponse {
|
||||
$options = [
|
||||
'sessionId' => $participant->getSession()->getSessionId(),
|
||||
];
|
||||
|
||||
$proxy = $this->proxy->post(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v4/room/' . $room->getRemoteToken() . '/federation/active',
|
||||
$options,
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if (!in_array($statusCode, [Http::STATUS_OK, Http::STATUS_NOT_FOUND], true)) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode());
|
||||
throw new CannotReachRemoteException();
|
||||
}
|
||||
|
||||
$headers = ['X-Nextcloud-Talk-Proxy-Hash' => $this->proxy->overwrittenRemoteTalkHash($proxy->getHeader('X-Nextcloud-Talk-Hash'))];
|
||||
|
||||
/** @var TalkRoom[] $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
|
||||
$data = $this->userConverter->convertAttendee($room, $data, 'actorType', 'actorId', 'displayName');
|
||||
|
||||
return new DataResponse($data, $statusCode, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\RoomController::leaveFederatedRoom()
|
||||
*
|
||||
* @param Room $room the federated room to leave
|
||||
* @param Participant $participant the federated user that will leave the
|
||||
* room; the participant must have a session
|
||||
* @return DataResponse<Http::STATUS_OK, array<empty>, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Federated user left the room
|
||||
*/
|
||||
public function leaveFederatedRoom(Room $room, Participant $participant): DataResponse {
|
||||
$options = [
|
||||
'sessionId' => $participant->getSession()->getSessionId(),
|
||||
];
|
||||
|
||||
$proxy = $this->proxy->delete(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v4/room/' . $room->getRemoteToken() . '/federation/active',
|
||||
$options,
|
||||
);
|
||||
|
||||
// STATUS_NOT_FOUND is not taken into account, as it should happen only
|
||||
// for non-federation requests.
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if (!in_array($statusCode, [Http::STATUS_OK], true)) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode());
|
||||
throw new CannotReachRemoteException();
|
||||
}
|
||||
|
||||
return new DataResponse([], $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\RoomController::getCapabilities()
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, TalkCapabilities|array<empty>, array{X-Nextcloud-Talk-Hash: string}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Get capabilities successfully
|
||||
*/
|
||||
public function getCapabilities(Room $room, Participant $participant): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v4/room/' . $room->getRemoteToken() . '/capabilities',
|
||||
);
|
||||
|
||||
/** @var TalkCapabilities|array<empty> $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
|
||||
$headers = [
|
||||
'X-Nextcloud-Talk-Hash' => $this->proxy->overwrittenRemoteTalkHash($proxy->getHeader('X-Nextcloud-Talk-Hash')),
|
||||
];
|
||||
|
||||
return new DataResponse($data, Http::STATUS_OK, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|\stdClass
|
||||
*/
|
||||
protected function emptyArray(): array|\stdClass {
|
||||
// Cheating here to make sure the array is always a
|
||||
// JSON object on the API, even when there is no entry at all.
|
||||
return new \stdClass();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Controller;
|
||||
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkSignalingSettings from ResponseDefinitions
|
||||
*/
|
||||
class SignalingController {
|
||||
public function __construct(
|
||||
protected ProxyRequest $proxy,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\SignalingController::getSettings()
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, TalkSignalingSettings, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array<empty>, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Signaling settings returned
|
||||
* 404: Room not found
|
||||
*/
|
||||
public function getSettings(Room $room, Participant $participant): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v3/signaling/settings',
|
||||
[
|
||||
'token' => $room->getRemoteToken(),
|
||||
],
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if (!in_array($statusCode, [Http::STATUS_OK, Http::STATUS_NOT_FOUND], true)) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $proxy->getStatusCode());
|
||||
throw new CannotReachRemoteException();
|
||||
}
|
||||
|
||||
/** @var TalkSignalingSettings|array<empty> $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
|
||||
return new DataResponse($data, $statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Controller;
|
||||
|
||||
use OCA\Talk\Chat\Notifier;
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
|
||||
use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCA\Talk\Service\RoomFormatter;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\ICacheFactory;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkThreadInfo from ResponseDefinitions
|
||||
* @psalm-import-type TalkRoom from ResponseDefinitions
|
||||
*/
|
||||
class ThreadController {
|
||||
|
||||
public function __construct(
|
||||
protected ProxyRequest $proxy,
|
||||
protected UserConverter $userConverter,
|
||||
protected ParticipantService $participantService,
|
||||
protected RoomFormatter $roomFormatter,
|
||||
protected Notifier $notifier,
|
||||
ICacheFactory $cacheFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\ThreadController::getRecentActiveThreads()
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, list<TalkThreadInfo>, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: List of threads returned
|
||||
*/
|
||||
public function getRecentActiveThreads(Room $room, Participant $participant, int $limit): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/threads/recent',
|
||||
[
|
||||
'limit' => $limit,
|
||||
],
|
||||
);
|
||||
|
||||
/** @var list<TalkThreadInfo> $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
if (!empty($data)) {
|
||||
$data = $this->userConverter->convertThreadInfos($room, $data);
|
||||
}
|
||||
|
||||
return new DataResponse($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\ThreadController::getThread()
|
||||
*
|
||||
* @psalm-param non-negative-int $threadId
|
||||
* @return DataResponse<Http::STATUS_OK, TalkThreadInfo, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'thread'|'status'}, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Thread info returned
|
||||
* 404: Thread not found
|
||||
*/
|
||||
public function getThread(Room $room, Participant $participant, int $threadId): DataResponse {
|
||||
$proxy = $this->proxy->get(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/threads/' . $threadId,
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if ($statusCode !== Http::STATUS_OK) {
|
||||
if ($statusCode !== Http::STATUS_NOT_FOUND) {
|
||||
$this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
$data = ['error' => 'status'];
|
||||
} else {
|
||||
/** @var array{error: 'thread'} $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [Http::STATUS_NOT_FOUND]);
|
||||
}
|
||||
return new DataResponse($data, Http::STATUS_NOT_FOUND);
|
||||
}
|
||||
|
||||
/** @var TalkThreadInfo $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
$data = $this->userConverter->convertThreadInfo($room, $data);
|
||||
|
||||
return new DataResponse($data, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\ThreadController::setNotificationLevel()
|
||||
*
|
||||
* @psalm-param non-negative-int $threadId
|
||||
* @return DataResponse<Http::STATUS_OK, TalkThreadInfo, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'title'}, array{}>|DataResponse<Http::STATUS_NOT_FOUND, array{error: 'thread'}, array{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Thread renamed successfully
|
||||
* 400: When the provided title is empty
|
||||
* 404: Thread not found
|
||||
*/
|
||||
public function renameThread(Room $room, Participant $participant, int $threadId, string $threadTitle): DataResponse {
|
||||
$proxy = $this->proxy->put(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/threads/' . $threadId,
|
||||
['threadTitle' => $threadTitle],
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if ($statusCode !== Http::STATUS_OK) {
|
||||
if (!in_array($statusCode, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
Http::STATUS_NOT_FOUND,
|
||||
], true)) {
|
||||
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
$data = ['error' => 'thread'];
|
||||
} elseif ($statusCode === Http::STATUS_BAD_REQUEST) {
|
||||
/** @var array{error: 'title'} $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
]);
|
||||
} else {
|
||||
/** @var array{error: 'thread'} $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [
|
||||
Http::STATUS_NOT_FOUND,
|
||||
]);
|
||||
}
|
||||
return new DataResponse($data, $statusCode);
|
||||
}
|
||||
|
||||
/** @var TalkThreadInfo $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
$data = $this->userConverter->convertThreadInfo($room, $data);
|
||||
|
||||
return new DataResponse($data, Http::STATUS_OK);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @see \OCA\Talk\Controller\ThreadController::setNotificationLevel()
|
||||
*
|
||||
* @psalm-param non-negative-int $messageId
|
||||
* @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{}>
|
||||
* @throws CannotReachRemoteException
|
||||
*
|
||||
* 200: Successfully set notification level for thread
|
||||
* 400: Notification level was invalid
|
||||
* 404: Message or top most message not found
|
||||
*/
|
||||
public function setNotificationLevel(Room $room, Participant $participant, int $messageId, int $level): DataResponse {
|
||||
$proxy = $this->proxy->post(
|
||||
$participant->getAttendee()->getInvitedCloudId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$room->getRemoteServer() . '/ocs/v2.php/apps/spreed/api/v1/chat/' . $room->getRemoteToken() . '/threads/' . $messageId . '/notify',
|
||||
['level' => $level],
|
||||
);
|
||||
|
||||
$statusCode = $proxy->getStatusCode();
|
||||
if ($statusCode !== Http::STATUS_OK) {
|
||||
if (!in_array($statusCode, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
Http::STATUS_NOT_FOUND,
|
||||
], true)) {
|
||||
$statusCode = $this->proxy->logUnexpectedStatusCode(__METHOD__, $statusCode);
|
||||
$data = ['error' => 'status'];
|
||||
} else {
|
||||
/** @var array{error: 'level'|'message'|'top-most'} $data */
|
||||
$data = $this->proxy->getOCSData($proxy, [
|
||||
Http::STATUS_BAD_REQUEST,
|
||||
Http::STATUS_NOT_FOUND,
|
||||
]);
|
||||
}
|
||||
return new DataResponse($data, $statusCode);
|
||||
}
|
||||
|
||||
/** @var TalkThreadInfo $data */
|
||||
$data = $this->proxy->getOCSData($proxy);
|
||||
$data = $this->userConverter->convertThreadInfo($room, $data);
|
||||
|
||||
return new DataResponse($data, Http::STATUS_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Listener;
|
||||
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Federation\CloudFederationProviderTalk;
|
||||
use OCA\Talk\Federation\FederationManager;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\OCM\Events\ResourceTypeRegisterEvent;
|
||||
use OCP\OCM\IOCMProvider;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class ResourceTypeRegisterListener implements IEventListener {
|
||||
|
||||
public function __construct(
|
||||
protected Config $talkConfig,
|
||||
protected IOCMProvider $provider,
|
||||
protected CloudFederationProviderTalk $talkProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof ResourceTypeRegisterEvent) {
|
||||
// Unrelated
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->talkConfig->isFederationEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->registerResourceType(
|
||||
FederationManager::TALK_ROOM_RESOURCE,
|
||||
$this->talkProvider->getSupportedShareTypes(),
|
||||
[
|
||||
'talk-v1' => '/ocs/v2.php/apps/spreed/api/',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Notifier;
|
||||
|
||||
use OCA\Talk\Events\BeforeRoomDeletedEvent;
|
||||
use OCA\Talk\Federation\BackendNotifier;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class BeforeRoomDeletedListener implements IEventListener {
|
||||
public function __construct(
|
||||
protected BackendNotifier $backendNotifier,
|
||||
protected ParticipantService $participantService,
|
||||
protected ICloudIdManager $cloudIdManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof BeforeRoomDeletedEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$participants = $this->participantService->getParticipantsByActorType($event->getRoom(), Attendee::ACTOR_FEDERATED_USERS);
|
||||
foreach ($participants as $participant) {
|
||||
$cloudId = $this->cloudIdManager->resolveCloudId($participant->getAttendee()->getActorId());
|
||||
|
||||
$this->backendNotifier->sendRemoteUnShare(
|
||||
$cloudId->getRemote(),
|
||||
$participant->getAttendee()->getId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Notifier;
|
||||
|
||||
use OCA\Talk\Events\AttendeeRemovedEvent;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\RetryNotificationMapper;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class CancelRetryOCMListener implements IEventListener {
|
||||
public function __construct(
|
||||
protected RetryNotificationMapper $retryNotificationMapper,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof AttendeeRemovedEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attendee = $event->getAttendee();
|
||||
if ($attendee->getActorType() !== Attendee::ACTOR_FEDERATED_USERS) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->retryNotificationMapper->deleteByProviderId(
|
||||
(string)$event->getAttendee()->getId()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Notifier;
|
||||
|
||||
use OCA\Talk\Chat\ChatManager;
|
||||
use OCA\Talk\Chat\MessageParser;
|
||||
use OCA\Talk\Events\AAttendeeRemovedEvent;
|
||||
use OCA\Talk\Events\ASystemMessageSentEvent;
|
||||
use OCA\Talk\Events\ChatMessageSentEvent;
|
||||
use OCA\Talk\Events\SystemMessageSentEvent;
|
||||
use OCA\Talk\Events\SystemMessagesMultipleSentEvent;
|
||||
use OCA\Talk\Federation\BackendNotifier;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Model\ProxyCacheMessage;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\Comments\IComment;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
use OCP\L10N\IFactory;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class MessageSentListener implements IEventListener {
|
||||
public function __construct(
|
||||
protected BackendNotifier $backendNotifier,
|
||||
protected ParticipantService $participantService,
|
||||
protected ICloudIdManager $cloudIdManager,
|
||||
protected MessageParser $messageParser,
|
||||
protected IFactory $l10nFactory,
|
||||
protected ChatManager $chatManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof ChatMessageSentEvent
|
||||
&& !$event instanceof SystemMessageSentEvent
|
||||
&& !$event instanceof SystemMessagesMultipleSentEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME once we store/cache the info skip this if the room has no federation participant
|
||||
// if (!$event->getRoom()->hasFederatedParticipants()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Try to have as neutral as possible messages
|
||||
$l = $this->l10nFactory->get('spreed', 'en', 'en');
|
||||
$chatMessage = $this->messageParser->createMessage($event->getRoom(), null, $event->getComment(), $l);
|
||||
$this->messageParser->parseMessage($chatMessage);
|
||||
|
||||
$systemMessage = $chatMessage->getMessageType() === ChatManager::VERB_SYSTEM ? $chatMessage->getMessageRaw() : '';
|
||||
if ($systemMessage !== 'message_edited'
|
||||
&& $systemMessage !== 'message_deleted'
|
||||
&& $event instanceof ASystemMessageSentEvent
|
||||
&& $event->shouldSkipLastActivityUpdate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$chatMessage->getVisibility()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expireDate = $event->getComment()->getExpireDate();
|
||||
$creationDate = $event->getComment()->getCreationDateTime();
|
||||
|
||||
$metaData = $event->getComment()->getMetaData() ?? [];
|
||||
$parent = $event->getParent();
|
||||
if ($parent instanceof IComment) {
|
||||
$metaData[ProxyCacheMessage::METADATA_REPLY_TO_ACTOR_TYPE] = $parent->getActorType();
|
||||
$metaData[ProxyCacheMessage::METADATA_REPLY_TO_ACTOR_ID] = $parent->getActorId();
|
||||
$metaData[ProxyCacheMessage::METADATA_REPLY_TO_MESSAGE_ID] = (int)$parent->getId();
|
||||
}
|
||||
|
||||
$messageData = [
|
||||
'remoteMessageId' => (int)$event->getComment()->getId(),
|
||||
'actorType' => $chatMessage->getActorType(),
|
||||
'actorId' => $chatMessage->getActorId(),
|
||||
'actorDisplayName' => $chatMessage->getActorDisplayName(),
|
||||
'messageType' => $chatMessage->getMessageType(),
|
||||
'systemMessage' => $systemMessage,
|
||||
'expirationDatetime' => $expireDate ? $expireDate->format(\DateTime::ATOM) : '',
|
||||
'message' => $chatMessage->getMessage(),
|
||||
'messageParameter' => json_encode($chatMessage->getMessageParameters(), JSON_THROW_ON_ERROR),
|
||||
'creationDatetime' => $creationDate->format(\DateTime::ATOM),
|
||||
'metaData' => json_encode($metaData, JSON_THROW_ON_ERROR),
|
||||
];
|
||||
|
||||
$participants = $this->participantService->getParticipantsByActorType($event->getRoom(), Attendee::ACTOR_FEDERATED_USERS);
|
||||
foreach ($participants as $participant) {
|
||||
$attendee = $participant->getAttendee();
|
||||
$cloudId = $this->cloudIdManager->resolveCloudId($attendee->getActorId());
|
||||
|
||||
$lastReadMessage = $attendee->getLastReadMessage();
|
||||
$lastMention = $attendee->getLastMentionMessage();
|
||||
$lastMentionDirect = $attendee->getLastMentionDirect();
|
||||
|
||||
$unreadInfo = [
|
||||
'lastReadMessage' => $lastReadMessage,
|
||||
'unreadMessages' => $this->chatManager->getUnreadCount($event->getRoom(), $lastReadMessage),
|
||||
'unreadMention' => $lastMention !== 0 && $lastReadMessage < $lastMention,
|
||||
'unreadMentionDirect' => $lastMentionDirect !== 0 && $lastReadMessage < $lastMentionDirect,
|
||||
];
|
||||
|
||||
$success = $this->backendNotifier->sendMessageUpdate(
|
||||
$cloudId->getRemote(),
|
||||
$participant->getAttendee()->getId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$event->getRoom()->getToken(),
|
||||
$messageData,
|
||||
$unreadInfo,
|
||||
);
|
||||
|
||||
if ($success === null) {
|
||||
$this->participantService->removeAttendee($event->getRoom(), $participant, AAttendeeRemovedEvent::REASON_LEFT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Notifier;
|
||||
|
||||
use OCA\Talk\Events\AAttendeeRemovedEvent;
|
||||
use OCA\Talk\Events\AParticipantModifiedEvent;
|
||||
use OCA\Talk\Events\ParticipantModifiedEvent;
|
||||
use OCA\Talk\Federation\BackendNotifier;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Federation\ICloudId;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class ParticipantModifiedListener implements IEventListener {
|
||||
public function __construct(
|
||||
protected BackendNotifier $backendNotifier,
|
||||
protected ParticipantService $participantService,
|
||||
protected ICloudIdManager $cloudIdManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof ParticipantModifiedEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$participant = $event->getParticipant();
|
||||
if ($participant->getAttendee()->getActorType() !== Attendee::ACTOR_FEDERATED_USERS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($event->getProperty(), [
|
||||
AParticipantModifiedEvent::PROPERTY_PERMISSIONS,
|
||||
AParticipantModifiedEvent::PROPERTY_RESEND_CALL,
|
||||
], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For modifying participants we only notify the affected participant's server
|
||||
$cloudId = $this->cloudIdManager->resolveCloudId($participant->getAttendee()->getActorId());
|
||||
$success = $this->notifyParticipantModified($cloudId, $participant, $event);
|
||||
|
||||
if ($success === null) {
|
||||
$this->participantService->removeAttendee($event->getRoom(), $participant, AAttendeeRemovedEvent::REASON_LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyParticipantModified(ICloudId $cloudId, Participant $participant, AParticipantModifiedEvent $event): ?bool {
|
||||
return $this->backendNotifier->sendParticipantModifiedUpdate(
|
||||
$cloudId->getRemote(),
|
||||
$participant->getAttendee()->getId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$event->getRoom()->getToken(),
|
||||
$event->getProperty(),
|
||||
$event->getNewValue(),
|
||||
$event->getOldValue(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1\Notifier;
|
||||
|
||||
use OCA\Talk\Events\AAttendeeRemovedEvent;
|
||||
use OCA\Talk\Events\ALobbyModifiedEvent;
|
||||
use OCA\Talk\Events\AParticipantModifiedEvent;
|
||||
use OCA\Talk\Events\ARoomModifiedEvent;
|
||||
use OCA\Talk\Events\CallEndedEvent;
|
||||
use OCA\Talk\Events\CallEndedForEveryoneEvent;
|
||||
use OCA\Talk\Events\CallStartedEvent;
|
||||
use OCA\Talk\Events\LobbyModifiedEvent;
|
||||
use OCA\Talk\Events\RoomModifiedEvent;
|
||||
use OCA\Talk\Federation\BackendNotifier;
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
use OCP\EventDispatcher\Event;
|
||||
use OCP\EventDispatcher\IEventListener;
|
||||
use OCP\Federation\ICloudId;
|
||||
use OCP\Federation\ICloudIdManager;
|
||||
|
||||
/**
|
||||
* @template-implements IEventListener<Event>
|
||||
*/
|
||||
class RoomModifiedListener implements IEventListener {
|
||||
public function __construct(
|
||||
protected BackendNotifier $backendNotifier,
|
||||
protected ParticipantService $participantService,
|
||||
protected ICloudIdManager $cloudIdManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function handle(Event $event): void {
|
||||
if (!$event instanceof CallStartedEvent
|
||||
&& !$event instanceof CallEndedEvent
|
||||
&& !$event instanceof CallEndedForEveryoneEvent
|
||||
&& !$event instanceof LobbyModifiedEvent
|
||||
&& !$event instanceof RoomModifiedEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($event->getProperty(), [
|
||||
ARoomModifiedEvent::PROPERTY_ACTIVE_SINCE,
|
||||
ARoomModifiedEvent::PROPERTY_AVATAR,
|
||||
ARoomModifiedEvent::PROPERTY_CALL_RECORDING,
|
||||
ARoomModifiedEvent::PROPERTY_DEFAULT_PERMISSIONS,
|
||||
ARoomModifiedEvent::PROPERTY_DESCRIPTION,
|
||||
ARoomModifiedEvent::PROPERTY_IN_CALL,
|
||||
ARoomModifiedEvent::PROPERTY_LOBBY,
|
||||
ARoomModifiedEvent::PROPERTY_MENTION_PERMISSIONS,
|
||||
ARoomModifiedEvent::PROPERTY_MESSAGE_EXPIRATION,
|
||||
ARoomModifiedEvent::PROPERTY_NAME,
|
||||
ARoomModifiedEvent::PROPERTY_READ_ONLY,
|
||||
ARoomModifiedEvent::PROPERTY_RECORDING_CONSENT,
|
||||
ARoomModifiedEvent::PROPERTY_SIP_ENABLED,
|
||||
ARoomModifiedEvent::PROPERTY_TYPE,
|
||||
], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event->getRoom()->isFederatedConversation()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$participants = $this->participantService->getParticipantsByActorType($event->getRoom(), Attendee::ACTOR_FEDERATED_USERS);
|
||||
foreach ($participants as $participant) {
|
||||
$cloudId = $this->cloudIdManager->resolveCloudId($participant->getAttendee()->getActorId());
|
||||
|
||||
if ($event instanceof CallStartedEvent) {
|
||||
$success = $this->notifyCallStarted($cloudId, $participant, $event);
|
||||
} elseif ($event instanceof CallEndedEvent || $event instanceof CallEndedForEveryoneEvent) {
|
||||
$success = $this->notifyCallEnded($cloudId, $participant, $event);
|
||||
} elseif ($event instanceof ALobbyModifiedEvent) {
|
||||
$success = $this->notifyLobbyModified($cloudId, $participant, $event);
|
||||
} else {
|
||||
$success = $this->notifyRoomModified($cloudId, $participant, $event);
|
||||
}
|
||||
|
||||
if ($success === null) {
|
||||
$this->participantService->removeAttendee($event->getRoom(), $participant, AAttendeeRemovedEvent::REASON_LEFT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyCallStarted(ICloudId $cloudId, Participant $participant, CallStartedEvent $event) {
|
||||
$details = [];
|
||||
if ($event->getDetail(AParticipantModifiedEvent::DETAIL_IN_CALL_SILENT)) {
|
||||
$details = [AParticipantModifiedEvent::DETAIL_IN_CALL_SILENT => true];
|
||||
}
|
||||
|
||||
return $this->backendNotifier->sendCallStarted(
|
||||
$cloudId->getRemote(),
|
||||
$participant->getAttendee()->getId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$event->getRoom()->getToken(),
|
||||
$event->getProperty(),
|
||||
$event->getNewValue(),
|
||||
$event->getCallFlag(),
|
||||
$details,
|
||||
);
|
||||
}
|
||||
|
||||
private function notifyCallEnded(ICloudId $cloudId, Participant $participant, CallEndedEvent|CallEndedForEveryoneEvent $event) {
|
||||
$details = [];
|
||||
if ($event instanceof CallEndedForEveryoneEvent) {
|
||||
$details = [AParticipantModifiedEvent::DETAIL_IN_CALL_END_FOR_EVERYONE => true];
|
||||
}
|
||||
|
||||
return $this->backendNotifier->sendCallEnded(
|
||||
$cloudId->getRemote(),
|
||||
$participant->getAttendee()->getId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$event->getRoom()->getToken(),
|
||||
ARoomModifiedEvent::PROPERTY_ACTIVE_SINCE,
|
||||
null,
|
||||
Participant::FLAG_DISCONNECTED,
|
||||
$details,
|
||||
);
|
||||
}
|
||||
|
||||
private function notifyLobbyModified(ICloudId $cloudId, Participant $participant, ALobbyModifiedEvent $event) {
|
||||
return $this->backendNotifier->sendRoomModifiedLobbyUpdate(
|
||||
$cloudId->getRemote(),
|
||||
$participant->getAttendee()->getId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$event->getRoom()->getToken(),
|
||||
$event->getProperty(),
|
||||
$event->getNewValue(),
|
||||
$event->getOldValue(),
|
||||
$event->getLobbyTimer(),
|
||||
$event->isTimerReached(),
|
||||
);
|
||||
}
|
||||
|
||||
private function notifyRoomModified(ICloudId $cloudId, Participant $participant, ARoomModifiedEvent $event) {
|
||||
return $this->backendNotifier->sendRoomModifiedUpdate(
|
||||
$cloudId->getRemote(),
|
||||
$participant->getAttendee()->getId(),
|
||||
$participant->getAttendee()->getAccessToken(),
|
||||
$event->getRoom()->getToken(),
|
||||
$event->getProperty(),
|
||||
$event->getNewValue(),
|
||||
$event->getOldValue(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1;
|
||||
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use GuzzleHttp\Exception\ServerException;
|
||||
use OC\Http\Client\Response;
|
||||
use OCA\Talk\AppInfo\Application;
|
||||
use OCA\Talk\Config as TalkConfig;
|
||||
use OCA\Talk\Exceptions\CannotReachRemoteException;
|
||||
use OCA\Talk\Exceptions\RemoteClientException;
|
||||
use OCA\Talk\Participant;
|
||||
use OCA\Talk\Settings\UserPreference;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\Http\Client\IClientService;
|
||||
use OCP\Http\Client\IResponse;
|
||||
use OCP\IConfig;
|
||||
use OCP\IUserSession;
|
||||
use OCP\L10N\IFactory;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use SensitiveParameter;
|
||||
|
||||
class ProxyRequest {
|
||||
public function __construct(
|
||||
protected IConfig $config,
|
||||
protected IClientService $clientService,
|
||||
protected LoggerInterface $logger,
|
||||
protected IFactory $l10nFactory,
|
||||
protected IUserSession $userSession,
|
||||
protected TalkConfig $talkConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
public function overwrittenRemoteTalkHash(string $hash): string {
|
||||
$typingIndicator = $this->config->getUserValue(
|
||||
$this->userSession->getUser()?->getUID(),
|
||||
Application::APP_ID,
|
||||
UserPreference::TYPING_PRIVACY,
|
||||
Participant::PRIVACY_PRIVATE,
|
||||
);
|
||||
return sha1(json_encode([
|
||||
'remoteHash' => $hash,
|
||||
'manipulated' => [
|
||||
'config' => [
|
||||
'chat' => [
|
||||
'read-privacy',
|
||||
'typing-privacy' => $typingIndicator,
|
||||
],
|
||||
'call' => [
|
||||
'blur-virtual-background',
|
||||
],
|
||||
'conversations' => [
|
||||
'list-style',
|
||||
],
|
||||
],
|
||||
]
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Http::STATUS_BAD_REQUEST
|
||||
*/
|
||||
public function logUnexpectedStatusCode(string $method, int $statusCode, string $logDetails = ''): int {
|
||||
if ($this->config->getSystemValueBool('debug')) {
|
||||
$this->logger->error('Unexpected status code ' . $statusCode . ' returned for ' . $method . ($logDetails !== '' ? "\n" . $logDetails : ''));
|
||||
} else {
|
||||
$this->logger->debug('Unexpected status code ' . $statusCode . ' returned for ' . $method . ($logDetails !== '' ? "\n" . $logDetails : ''));
|
||||
}
|
||||
return Http::STATUS_BAD_REQUEST;
|
||||
}
|
||||
|
||||
protected function generateDefaultRequestOptions(
|
||||
?string $cloudId,
|
||||
#[SensitiveParameter]
|
||||
?string $accessToken,
|
||||
): array {
|
||||
$options = [
|
||||
'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates'),
|
||||
'nextcloud' => [
|
||||
'allow_local_address' => $this->config->getSystemValueBool('allow_local_remote_servers'),
|
||||
],
|
||||
'headers' => [
|
||||
'Accept' => 'application/json',
|
||||
'X-Nextcloud-Federation' => 'true',
|
||||
'OCS-APIRequest' => 'true',
|
||||
'Accept-Language' => $this->l10nFactory->getUserLanguage($this->userSession->getUser()),
|
||||
],
|
||||
'timeout' => 5,
|
||||
];
|
||||
|
||||
if ($cloudId !== null && $accessToken !== null) {
|
||||
$options['auth'] = [urlencode($cloudId), $accessToken];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
protected function prependProtocolIfNotAvailable(string $url): string {
|
||||
if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
|
||||
$url = 'https://' . $url;
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param 'get'|'post'|'put'|'delete' $verb
|
||||
* @throws CannotReachRemoteException
|
||||
*/
|
||||
protected function request(
|
||||
string $verb,
|
||||
?string $cloudId,
|
||||
#[SensitiveParameter]
|
||||
?string $accessToken,
|
||||
string $url,
|
||||
array $parameters,
|
||||
): IResponse {
|
||||
if (!$this->talkConfig->isFederationEnabled()) {
|
||||
throw new CannotReachRemoteException();
|
||||
}
|
||||
|
||||
$requestOptions = $this->generateDefaultRequestOptions($cloudId, $accessToken);
|
||||
if (!empty($parameters)) {
|
||||
$requestOptions['json'] = $parameters;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->clientService->newClient()->{$verb}(
|
||||
$this->prependProtocolIfNotAvailable($url),
|
||||
$requestOptions
|
||||
);
|
||||
} catch (ClientException $e) {
|
||||
$status = $e->getResponse()->getStatusCode();
|
||||
|
||||
try {
|
||||
$body = $e->getResponse()->getBody()->getContents();
|
||||
$data = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
|
||||
$e->getResponse()->getBody()->rewind();
|
||||
if (!is_array($data)) {
|
||||
throw new \RuntimeException('JSON response is not an array');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
throw new CannotReachRemoteException('Error parsing JSON response', $e->getCode(), $e);
|
||||
}
|
||||
|
||||
$clientException = new RemoteClientException($e->getMessage(), $status, $e, $data);
|
||||
$this->logger->debug('Client error from remote', ['exception' => $clientException]);
|
||||
return new Response($e->getResponse(), false);
|
||||
} catch (ServerException|\Throwable $e) {
|
||||
$serverException = new CannotReachRemoteException($e->getMessage(), $e->getCode(), $e);
|
||||
$this->logger->error('Could not reach remote', ['exception' => $serverException]);
|
||||
throw $serverException;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotReachRemoteException
|
||||
*/
|
||||
public function get(
|
||||
?string $cloudId,
|
||||
#[SensitiveParameter]
|
||||
?string $accessToken,
|
||||
string $url,
|
||||
array $parameters = [],
|
||||
): IResponse {
|
||||
return $this->request(
|
||||
'get',
|
||||
$cloudId,
|
||||
$accessToken,
|
||||
$url,
|
||||
$parameters,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotReachRemoteException
|
||||
*/
|
||||
public function put(
|
||||
string $cloudId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
string $url,
|
||||
array $parameters = [],
|
||||
): IResponse {
|
||||
return $this->request(
|
||||
'put',
|
||||
$cloudId,
|
||||
$accessToken,
|
||||
$url,
|
||||
$parameters,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotReachRemoteException
|
||||
*/
|
||||
public function delete(
|
||||
string $cloudId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
string $url,
|
||||
array $parameters = [],
|
||||
): IResponse {
|
||||
return $this->request(
|
||||
'delete',
|
||||
$cloudId,
|
||||
$accessToken,
|
||||
$url,
|
||||
$parameters,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotReachRemoteException
|
||||
*/
|
||||
public function post(
|
||||
string $cloudId,
|
||||
#[SensitiveParameter]
|
||||
string $accessToken,
|
||||
string $url,
|
||||
array $parameters = [],
|
||||
): IResponse {
|
||||
return $this->request(
|
||||
'post',
|
||||
$cloudId,
|
||||
$accessToken,
|
||||
$url,
|
||||
$parameters,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $allowedStatusCodes
|
||||
* @throws CannotReachRemoteException
|
||||
*/
|
||||
public function getOCSData(IResponse $response, array $allowedStatusCodes = [Http::STATUS_OK]): array {
|
||||
if (!in_array($response->getStatusCode(), $allowedStatusCodes, true)) {
|
||||
$this->logUnexpectedStatusCode(__METHOD__, $response->getStatusCode());
|
||||
}
|
||||
|
||||
try {
|
||||
$content = $response->getBody();
|
||||
$responseData = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
|
||||
if (!is_array($responseData)) {
|
||||
throw new \RuntimeException('JSON response is not an array');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Error parsing JSON response: ' . ($content ?? 'no-data'), ['exception' => $e]);
|
||||
throw new CannotReachRemoteException('Error parsing JSON response', $e->getCode(), $e);
|
||||
}
|
||||
|
||||
return $responseData['ocs']['data'] ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation\Proxy\TalkV1;
|
||||
|
||||
use OCA\Talk\Model\Attendee;
|
||||
use OCA\Talk\ResponseDefinitions;
|
||||
use OCA\Talk\Room;
|
||||
use OCA\Talk\Service\AvatarService;
|
||||
use OCA\Talk\Service\ParticipantService;
|
||||
|
||||
/**
|
||||
* @psalm-import-type TalkChatMessageWithParent from ResponseDefinitions
|
||||
* @psalm-import-type TalkPoll from ResponseDefinitions
|
||||
* @psalm-import-type TalkPollDraft from ResponseDefinitions
|
||||
* @psalm-import-type TalkReaction from ResponseDefinitions
|
||||
* @psalm-import-type TalkThreadInfo from ResponseDefinitions
|
||||
*/
|
||||
class UserConverter {
|
||||
/**
|
||||
* @var array<string, array<string, array{userId: string, displayName: string}>>
|
||||
*/
|
||||
protected array $participantsPerRoom = [];
|
||||
|
||||
public function __construct(
|
||||
protected ParticipantService $participantService,
|
||||
protected AvatarService $avatarService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{type: string, id: string}
|
||||
*/
|
||||
public function convertTypeAndId(Room $room, string $type, string $id): array {
|
||||
if ($type === Attendee::ACTOR_USERS) {
|
||||
$type = Attendee::ACTOR_FEDERATED_USERS;
|
||||
$id = $this->createCloudIdFromUserIdAndFullServerUrl($id, $room->getRemoteServer());
|
||||
} elseif ($type === Attendee::ACTOR_FEDERATED_USERS) {
|
||||
$localParticipants = $this->getLocalParticipants($room);
|
||||
if (isset($localParticipants[$id])) {
|
||||
$local = $localParticipants[$id];
|
||||
|
||||
$type = Attendee::ACTOR_USERS;
|
||||
$id = $local['userId'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['type' => $type, 'id' => $id];
|
||||
}
|
||||
|
||||
public function convertAttendee(Room $room, array $entry, string $typeField, string $idField, string $displayNameField): array {
|
||||
if (!isset($entry[$typeField])) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
if ($entry[$typeField] === Attendee::ACTOR_USERS) {
|
||||
$entry[$typeField] = Attendee::ACTOR_FEDERATED_USERS;
|
||||
$entry[$idField] = $this->createCloudIdFromUserIdAndFullServerUrl($entry[$idField], $room->getRemoteServer());
|
||||
} elseif ($entry[$typeField] === Attendee::ACTOR_FEDERATED_USERS) {
|
||||
$localParticipants = $this->getLocalParticipants($room);
|
||||
if (isset($localParticipants[$entry[$idField]])) {
|
||||
$local = $localParticipants[$entry[$idField]];
|
||||
|
||||
$entry[$typeField] = Attendee::ACTOR_USERS;
|
||||
$entry[$idField] = $local['userId'];
|
||||
$entry[$displayNameField] = $local['displayName'];
|
||||
}
|
||||
}
|
||||
return $entry;
|
||||
}
|
||||
|
||||
public function convertAttendees(Room $room, array $entries, string $typeField, string $idField, string $displayNameField): array {
|
||||
return array_map(
|
||||
fn (array $entry): array => $this->convertAttendee($room, $entry, $typeField, $idField, $displayNameField),
|
||||
$entries
|
||||
);
|
||||
}
|
||||
|
||||
protected function convertMessageParameter(Room $room, array $parameter): array {
|
||||
if ($parameter['type'] === 'user') { // RichObjectDefinition, not Attendee::ACTOR_USERS
|
||||
if (!isset($parameter['server'])) {
|
||||
$parameter['server'] = $room->getRemoteServer();
|
||||
if (!isset($parameter['mention-id'])) {
|
||||
$parameter['mention-id'] = $parameter['id'];
|
||||
}
|
||||
} elseif ($parameter['server']) {
|
||||
$localParticipants = $this->getLocalParticipants($room);
|
||||
$cloudId = $this->createCloudIdFromUserIdAndFullServerUrl($parameter['id'], $parameter['server']);
|
||||
if (!isset($parameter['mention-id'])) {
|
||||
$parameter['mention-id'] = 'federated_user/' . $parameter['id'] . '@' . $parameter['server'];
|
||||
}
|
||||
if (isset($localParticipants[$cloudId])) {
|
||||
unset($parameter['server']);
|
||||
$parameter['name'] = $localParticipants[$cloudId]['displayName'];
|
||||
}
|
||||
}
|
||||
} elseif ($parameter['type'] === 'call' && $parameter['id'] === $room->getRemoteToken()) {
|
||||
$parameter['id'] = $room->getToken();
|
||||
$parameter['icon-url'] = $this->avatarService->getAvatarUrl($room);
|
||||
if (!isset($parameter['mention-id'])) {
|
||||
$parameter['mention-id'] = 'all';
|
||||
}
|
||||
} elseif ($parameter['type'] === 'circle') {
|
||||
if (!isset($parameter['mention-id'])) {
|
||||
$parameter['mention-id'] = 'team/' . $parameter['id'];
|
||||
}
|
||||
} elseif ($parameter['type'] === 'user-group') {
|
||||
if (!isset($parameter['mention-id'])) {
|
||||
$parameter['mention-id'] = 'group/' . $parameter['id'];
|
||||
}
|
||||
} elseif ($parameter['type'] === 'email' || $parameter['type'] === 'guest') {
|
||||
if (!isset($parameter['mention-id'])) {
|
||||
$parameter['mention-id'] = $parameter['type'] . '/' . $parameter['id'];
|
||||
}
|
||||
}
|
||||
return $parameter;
|
||||
}
|
||||
|
||||
public function convertMessageParameters(Room $room, array $message): array {
|
||||
$message['messageParameters'] = array_map(
|
||||
fn (array $message): array => $this->convertMessageParameter($room, $message),
|
||||
$message['messageParameters']
|
||||
);
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param TalkChatMessageWithParent $message
|
||||
* @return TalkChatMessageWithParent
|
||||
*/
|
||||
public function convertMessage(Room $room, array $message): array {
|
||||
$message['token'] = $room->getToken();
|
||||
$message = $this->convertAttendee($room, $message, 'actorType', 'actorId', 'actorDisplayName');
|
||||
$message = $this->convertAttendee($room, $message, 'lastEditActorType', 'lastEditActorId', 'lastEditActorDisplayName');
|
||||
$message = $this->convertMessageParameters($room, $message);
|
||||
|
||||
if (isset($message['parent'])) {
|
||||
$message['parent'] = $this->convertMessage($room, $message['parent']);
|
||||
}
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param TalkChatMessageWithParent[] $messages
|
||||
* @return TalkChatMessageWithParent[]
|
||||
*/
|
||||
public function convertMessages(Room $room, array $messages): array {
|
||||
return array_map(
|
||||
fn (array $message): array => $this->convertMessage($room, $message),
|
||||
$messages
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param TalkThreadInfo $threadInfo
|
||||
* @return TalkThreadInfo
|
||||
*/
|
||||
public function convertThreadInfo(Room $room, array $threadInfo): array {
|
||||
$threadInfo['thread']['roomToken'] = $room->getToken();
|
||||
if (isset($threadInfo['first'])) {
|
||||
$threadInfo['first'] = $this->convertMessageParameters($room, $threadInfo['first']);
|
||||
}
|
||||
if (isset($threadInfo['last'])) {
|
||||
$threadInfo['last'] = $this->convertMessageParameters($room, $threadInfo['last']);
|
||||
}
|
||||
return $threadInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param list<TalkThreadInfo> $threadInfos
|
||||
* @return list<TalkThreadInfo>
|
||||
*/
|
||||
public function convertThreadInfos(Room $room, array $threadInfos): array {
|
||||
return array_map(
|
||||
fn (array $threadInfo): array => $this->convertThreadInfo($room, $threadInfo),
|
||||
$threadInfos
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T of TalkPoll|TalkPollDraft
|
||||
* @param Room $room
|
||||
* @param TalkPoll|TalkPollDraft $poll
|
||||
* @psalm-param T $poll
|
||||
* @return TalkPoll|TalkPollDraft
|
||||
* @psalm-return T
|
||||
*/
|
||||
public function convertPoll(Room $room, array $poll): array {
|
||||
$poll = $this->convertAttendee($room, $poll, 'actorType', 'actorId', 'actorDisplayName');
|
||||
if (isset($poll['details'])) {
|
||||
$poll['details'] = array_map(
|
||||
fn (array $vote): array => $this->convertAttendee($room, $vote, 'actorType', 'actorId', 'actorDisplayName'),
|
||||
$poll['details']
|
||||
);
|
||||
}
|
||||
return $poll;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param TalkReaction[] $reactions
|
||||
* @return TalkReaction[]
|
||||
*/
|
||||
protected function convertReactions(Room $room, array $reactions): array {
|
||||
return array_map(
|
||||
fn (array $reaction): array => $this->convertAttendee($room, $reaction, 'actorType', 'actorId', 'actorDisplayName'),
|
||||
$reactions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Room $room
|
||||
* @param array<string, TalkReaction[]> $reactionsList
|
||||
* @return array<string, TalkReaction[]>
|
||||
*/
|
||||
public function convertReactionsList(Room $room, array $reactionsList): array {
|
||||
return array_map(
|
||||
fn (array $reactions): array => $this->convertReactions($room, $reactions),
|
||||
$reactionsList
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{userId: string, displayName: string}>
|
||||
*/
|
||||
protected function getLocalParticipants(Room $room): array {
|
||||
if (array_key_exists($room->getToken(), $this->participantsPerRoom)) {
|
||||
return $this->participantsPerRoom[$room->getToken()];
|
||||
}
|
||||
|
||||
$this->participantsPerRoom[$room->getToken()] = [];
|
||||
$localParticipants = $this->participantService->getActorsByType($room, Attendee::ACTOR_USERS);
|
||||
foreach ($localParticipants as $participant) {
|
||||
$this->participantsPerRoom[$room->getToken()][$participant->getInvitedCloudId()] = [
|
||||
'userId' => $participant->getActorId(),
|
||||
'displayName' => $participant->getDisplayName(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->participantsPerRoom[$room->getToken()];
|
||||
}
|
||||
|
||||
protected function createCloudIdFromUserIdAndFullServerUrl(string $userId, string $serverUrl): string {
|
||||
if (str_starts_with($serverUrl, 'https://')) {
|
||||
$serverUrl = substr($serverUrl, strlen('https://'));
|
||||
}
|
||||
return $userId . '@' . $serverUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace OCA\Talk\Federation;
|
||||
|
||||
use OCA\FederatedFileSharing\AddressHandler;
|
||||
use OCA\Federation\TrustedServers;
|
||||
use OCA\Talk\Config;
|
||||
use OCA\Talk\Exceptions\FederationRestrictionException;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\AppFramework\Services\IAppConfig;
|
||||
use OCP\Federation\ICloudId;
|
||||
use OCP\IUser;
|
||||
use OCP\Server;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class RestrictionValidator {
|
||||
public function __construct(
|
||||
private AddressHandler $addressHandler,
|
||||
private IAppManager $appManager,
|
||||
private Config $talkConfig,
|
||||
private IAppConfig $appConfig,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if $sharedBy is allowed to invite $shareWith
|
||||
*
|
||||
* @throws FederationRestrictionException
|
||||
*/
|
||||
public function isAllowedToInvite(
|
||||
IUser $user,
|
||||
ICloudId $cloudIdToInvite,
|
||||
): void {
|
||||
if (!($cloudIdToInvite->getUser() && $cloudIdToInvite->getRemote())) {
|
||||
$this->logger->debug('Could not share conversation as the recipient is invalid: ' . $cloudIdToInvite->getId());
|
||||
throw new FederationRestrictionException(FederationRestrictionException::REASON_CLOUD_ID);
|
||||
}
|
||||
|
||||
if (!$this->appConfig->getAppValueBool('federation_outgoing_enabled', true)) {
|
||||
$this->logger->debug('Could not share conversation as outgoing federation is disabled');
|
||||
throw new FederationRestrictionException(FederationRestrictionException::REASON_OUTGOING);
|
||||
}
|
||||
|
||||
if (!$this->talkConfig->isFederationEnabledForUserId($user)) {
|
||||
$this->logger->debug('Talk federation not allowed for user ' . $user->getUID());
|
||||
throw new FederationRestrictionException(FederationRestrictionException::REASON_FEDERATION);
|
||||
}
|
||||
|
||||
if ($this->appConfig->getAppValueBool('federation_only_trusted_servers')) {
|
||||
if (!$this->appManager->isEnabledForUser('federation')) {
|
||||
$this->logger->error('Federation is limited to trusted servers but the "federation" app is disabled');
|
||||
throw new FederationRestrictionException(FederationRestrictionException::REASON_TRUSTED_SERVERS);
|
||||
}
|
||||
|
||||
$trustedServers = Server::get(TrustedServers::class);
|
||||
$serverUrl = $this->addressHandler->removeProtocolFromUrl($cloudIdToInvite->getRemote());
|
||||
if (!$trustedServers->isTrustedServer($serverUrl)) {
|
||||
$this->logger->warning(
|
||||
'Tried to send Talk federation invite to untrusted server {serverUrl}',
|
||||
['serverUrl' => $serverUrl]
|
||||
);
|
||||
throw new FederationRestrictionException(FederationRestrictionException::REASON_TRUSTED_SERVERS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user