f7cloud_client/apps/spreed/lib/Federation/BackendNotifier.php
root 8b6a0139db f7cloud_client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 22:59:26 +00:00

614 lines
20 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2021 F7cloud GmbH and F7cloud 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;
}
}