diff --git a/appinfo/info.xml b/appinfo/info.xml index fcd28db7a..52561d744 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -16,7 +16,7 @@ And in the works for the [coming versions](https://github.com/nextcloud/spreed/m ]]> - 13.0.0-dev + 13.0.0-dev.1 agpl Daniel Calviño Sánchez diff --git a/appinfo/routes.php b/appinfo/routes.php index 26ec3ccbd..59143fd5c 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -523,6 +523,27 @@ return [ ], ], + /** + * Federation + */ + + [ + 'name' => 'Federation#acceptShare', + 'url' => 'api/{apiVersion}/federation/invitation/{id}', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v1', + ], + ], + [ + 'name' => 'Federation#rejectShare', + 'url' => 'api/{apiVersion}/federation/invitation/{id}', + 'verb' => 'DELETE', + 'requirements' => [ + 'apiVersion' => 'v1', + ], + ], + /** * PublicShareAuth */ diff --git a/lib/BackgroundJob/RemoveEmptyRooms.php b/lib/BackgroundJob/RemoveEmptyRooms.php index af9738809..3c620c171 100644 --- a/lib/BackgroundJob/RemoveEmptyRooms.php +++ b/lib/BackgroundJob/RemoveEmptyRooms.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace OCA\Talk\BackgroundJob; +use OCA\Talk\Federation\FederationManager; use OCA\Talk\Service\ParticipantService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\TimedJob; @@ -46,6 +47,9 @@ class RemoveEmptyRooms extends TimedJob { /** @var LoggerInterface */ protected $logger; + /** @var FederationManager */ + protected $federationManager; + protected $numDeletedRooms = 0; public function __construct(ITimeFactory $timeFactory, @@ -77,7 +81,7 @@ class RemoveEmptyRooms extends TimedJob { return; } - if ($this->participantService->getNumberOfActors($room) === 0 && $room->getObjectType() !== 'file') { + if ($this->participantService->getNumberOfActors($room) === 0 && $room->getObjectType() !== 'file' && $this->federationManager->getNumberOfInvitations($room) === 0) { $room->deleteRoom(); $this->numDeletedRooms++; } diff --git a/lib/Controller/FederationController.php b/lib/Controller/FederationController.php new file mode 100644 index 000000000..ea951d593 --- /dev/null +++ b/lib/Controller/FederationController.php @@ -0,0 +1,87 @@ + + * + * @author Gary Kim + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Controller; + +use OCA\Talk\AppInfo\Application; +use OCA\Talk\Exceptions\UnauthorizedException; +use OCA\Talk\Federation\FederationManager; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\DB\Exception as DBException; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; + +class FederationController extends OCSController { + /** @var FederationManager */ + private $federationManager; + + /** @var IUserSession */ + private $userSession; + + public function __construct(IRequest $request, FederationManager $federationManager, IUserSession $userSession) { + parent::__construct(Application::APP_ID, $request); + $this->federationManager = $federationManager; + $this->userSession = $userSession; + } + + /** + * @NoAdminRequired + * + * @param int $id + * @return DataResponse + * @throws UnauthorizedException + * @throws DBException + * @throws MultipleObjectsReturnedException + */ + public function acceptShare(int $id): DataResponse { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + throw new UnauthorizedException(); + } + $this->federationManager->acceptRemoteRoomShare($user, $id); + return new DataResponse(); + } + + /** + * @NoAdminRequired + * + * @param int $id + * @return DataResponse + * @throws UnauthorizedException + * @throws DBException + * @throws MultipleObjectsReturnedException + */ + public function rejectShare(int $id): DataResponse { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + throw new UnauthorizedException(); + } + $this->federationManager->rejectRemoteRoomShare($user, $id); + return new DataResponse(); + } +} diff --git a/lib/Federation/CloudFederationProviderTalk.php b/lib/Federation/CloudFederationProviderTalk.php new file mode 100644 index 000000000..3e89bda07 --- /dev/null +++ b/lib/Federation/CloudFederationProviderTalk.php @@ -0,0 +1,265 @@ + + * + * @author Gary Kim + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Federation; + +use Exception; +use OCA\FederatedFileSharing\AddressHandler; +use OCA\Talk\AppInfo\Application; +use OCA\Talk\Manager; +use OCA\Talk\Model\Attendee; +use OCA\Talk\Model\AttendeeMapper; +use OCA\Talk\Participant; +use OCA\Talk\Room; +use OCA\Talk\Service\ParticipantService; +use OCP\AppFramework\Http; +use OCP\DB\Exception as DBException; +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\HintException; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Notification\IManager as INotificationManager; +use OCP\Share\Exceptions\ShareNotFound; + +class CloudFederationProviderTalk implements ICloudFederationProvider { + + /** @var IUserManager */ + private $userManager; + + /** @var AddressHandler */ + private $addressHandler; + + /** @var FederationManager */ + private $federationManager; + + /** @var INotificationManager */ + private $notificationManager; + + /** @var IURLGenerator */ + private $urlGenerator; + + /** @var ParticipantService */ + private $participantService; + + /** @var AttendeeMapper */ + private $attendeeMapper; + + /** @var Manager */ + private $manager; + + public function __construct( + IUserManager $userManager, + AddressHandler $addressHandler, + FederationManager $federationManager, + INotificationManager $notificationManager, + IURLGenerator $urlGenerator, + ParticipantService $participantService, + AttendeeMapper $attendeeMapper, + Manager $manager + ) { + $this->userManager = $userManager; + $this->addressHandler = $addressHandler; + $this->federationManager = $federationManager; + $this->notificationManager = $notificationManager; + $this->urlGenerator = $urlGenerator; + $this->participantService = $participantService; + $this->attendeeMapper = $attendeeMapper; + $this->manager = $manager; + } + + /** + * @inheritDoc + */ + public function getShareType(): string { + return 'talk-room'; + } + + /** + * @inheritDoc + * @throws HintException + * @throws DBException + */ + public function shareReceived(ICloudFederationShare $share): string { + if (!$this->federationManager->isEnabled()) { + throw new ProviderCouldNotAddShareException('Server does not support talk federation', '', Http::STATUS_SERVICE_UNAVAILABLE); + } + if (!in_array($share->getShareType(), $this->getSupportedShareTypes(), true)) { + throw new ProviderCouldNotAddShareException('Support for sharing with non-users not implemented yet', '', Http::STATUS_NOT_IMPLEMENTED); + // TODO: Implement group shares + } + + if (!is_numeric($share->getShareType())) { + throw new ProviderCouldNotAddShareException('shareType is not a number', '', Http::STATUS_BAD_REQUEST); + } + + $shareSecret = $share->getShareSecret(); + $shareWith = $share->getShareWith(); + $remoteId = $share->getProviderId(); + $roomToken = $share->getResourceName(); + $roomName = $share->getProtocol()['roomName']; + $roomType = (int) $share->getShareType(); + $sharedBy = $share->getSharedByDisplayName(); + $sharedByFederatedId = $share->getSharedBy(); + $owner = $share->getOwnerDisplayName(); + $ownerFederatedId = $share->getOwner(); + [, $remote] = $this->addressHandler->splitUserRemote($ownerFederatedId); + + // if no explicit information about the person who created the share was send + // we assume that the share comes from the owner + if ($sharedByFederatedId === null) { + $sharedBy = $owner; + $sharedByFederatedId = $ownerFederatedId; + } + + if ($remote && $shareSecret && $shareWith && $roomToken && $remoteId && is_string($roomName) && $roomName && $owner) { + $shareWith = $this->userManager->get($shareWith); + if ($shareWith === null) { + throw new ProviderCouldNotAddShareException('User does not exist', '',Http::STATUS_BAD_REQUEST); + } + + $shareId = (string) $this->federationManager->addRemoteRoom($shareWith, $remoteId, $roomType, $roomName, $roomToken, $remote, $shareSecret); + + $this->notifyAboutNewShare($shareWith, $shareId, $sharedByFederatedId, $sharedBy, $roomName, $roomToken, $remote); + return $shareId; + } + throw new ProviderCouldNotAddShareException('required request data not found', '', Http::STATUS_BAD_REQUEST); + } + + /** + * @inheritDoc + */ + public function notificationReceived($notificationType, $providerId, array $notification): array { + if (!is_numeric($providerId)) { + throw new BadRequestException(['providerId']); + } + switch ($notificationType) { + case 'SHARE_ACCEPTED': + return $this->shareAccepted((int) $providerId, $notification); + case 'SHARE_DECLINED': + return $this->shareDeclined((int) $providerId, $notification); + case 'SHARE_UNSHARED': + return []; // TODO: Implement + case 'REQUEST_RESHARE': + return []; // TODO: Implement + case 'RESHARE_UNDO': + return []; // TODO: Implement + case 'RESHARE_CHANGE_PERMISSION': + return []; // TODO: Implement + } + return []; + // TODO: Implement notificationReceived() method. + } + + /** + * @throws ActionNotSupportedException + * @throws ShareNotFound + * @throws AuthenticationFailedException + */ + private function shareAccepted(int $id, array $notification): array { + $attendee = $this->getAttendeeAndValidate($id, $notification['sharedSecret']); + + // TODO: Add activity for share accepted + + return []; + } + + /** + * @throws ActionNotSupportedException + * @throws ShareNotFound + * @throws AuthenticationFailedException + */ + private function shareDeclined(int $id, array $notification): array { + $attendee = $this->getAttendeeAndValidate($id, $notification['sharedSecret']); + + $room = $this->manager->getRoomById($attendee->getRoomId()); + $participant = new Participant($room, $attendee, null); + $this->participantService->removeAttendee($room, $participant, Room::PARTICIPANT_LEFT); + return []; + } + + /** + * @throws AuthenticationFailedException + * @throws ActionNotSupportedException + * @throws ShareNotFound + */ + private function getAttendeeAndValidate(int $id, string $sharedSecret): Attendee { + if (!$this->federationManager->isEnabled()) { + throw new ActionNotSupportedException('Server does not support Talk federation'); + } + + try { + $attendee = $this->attendeeMapper->getById($id); + } catch (Exception $ex) { + throw new ShareNotFound(); + } + if ($attendee->getActorType() !== Attendee::ACTOR_FEDERATED_USERS) { + throw new ShareNotFound(); + } + if ($attendee->getAccessToken() !== $sharedSecret) { + throw new AuthenticationFailedException(); + } + return $attendee; + } + + private function notifyAboutNewShare(IUser $shareWith, string $shareId, string $sharedByFederatedId, string $sharedByName, string $roomName, string $roomToken, string $serverUrl) { + $notification = $this->notificationManager->createNotification(); + $notification->setApp(Application::APP_ID) + ->setUser($shareWith->getUID()) + ->setDateTime(new \DateTime()) + ->setObject('remote_talk_share', $shareId) + ->setSubject('remote_talk_share', [ + 'sharedByDisplayName' => $sharedByName, + 'sharedByFederatedId' => $sharedByFederatedId, + 'roomName' => $roomName, + 'serverUrl' => $serverUrl, + 'roomToken' => $roomToken, + ]); + + $declineAction = $notification->createAction(); + $declineAction->setLabel('decline') + ->setLink($this->urlGenerator->linkToOCSRouteAbsolute('spreed.Federation.rejectShare', ['id' => $shareId]), 'DELETE'); + $notification->addAction($declineAction); + + $acceptAction = $notification->createAction(); + $acceptAction->setLabel('accept') + ->setLink($this->urlGenerator->linkToOCSRouteAbsolute('spreed.Federation.acceptShare', ['id' => $shareId]), 'POST'); + $notification->addAction($acceptAction); + + $this->notificationManager->notify($notification); + } + + /** + * @inheritDoc + */ + public function getSupportedShareTypes() { + return ['user']; + } +} diff --git a/lib/Federation/FederationManager.php b/lib/Federation/FederationManager.php new file mode 100644 index 000000000..691d83a6c --- /dev/null +++ b/lib/Federation/FederationManager.php @@ -0,0 +1,159 @@ + + * + * @author Gary Kim + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Federation; + +use OCA\Talk\AppInfo\Application; +use OCA\Talk\Exceptions\RoomNotFoundException; +use OCA\Talk\Exceptions\UnauthorizedException; +use OCA\Talk\Manager; +use OCA\Talk\Model\Attendee; +use OCA\Talk\Model\Invitation; +use OCA\Talk\Model\InvitationMapper; +use OCA\Talk\Room; +use OCA\Talk\Service\ParticipantService; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\DB\Exception as DBException; +use OCP\IConfig; +use OCP\IUser; + +/** + * Class FederationManager + * + * @package OCA\Talk\Federation + * + * FederationManager handles incoming federated rooms + */ +class FederationManager { + /** @var IConfig */ + private $config; + + /** @var Manager */ + private $manager; + + /** @var ParticipantService */ + private $participantService; + + /** @var InvitationMapper */ + private $invitationMapper; + + public function __construct( + IConfig $config, + Manager $manager, + ParticipantService $participantService, + InvitationMapper $invitationMapper + ) { + $this->config = $config; + $this->manager = $manager; + $this->participantService = $participantService; + $this->invitationMapper = $invitationMapper; + } + + /** + * Determine if Talk federation is enabled on this instance + * @return bool + */ + public function isEnabled(): bool { + // TODO: Set to default true once implementation is complete + return $this->config->getAppValue(Application::APP_ID, 'federation_enabled', "false") === "true"; + } + + /** + * @param IUser $user + * @param int $roomType + * @param string $roomName + * @param string $roomToken + * @param string $remoteUrl + * @param string $sharedSecret + * @return int share id for this specific remote room share + * @throws DBException + */ + public function addRemoteRoom(IUser $user, string $remoteId, int $roomType, string $roomName, string $roomToken, string $remoteUrl, string $sharedSecret): int { + try { + $room = $this->manager->getRoomByToken($roomToken, null, $remoteUrl); + } catch (RoomNotFoundException $ex) { + $room = $this->manager->createRemoteRoom($roomType, $roomName, $roomToken, $remoteUrl); + } + $invitation = new Invitation(); + $invitation->setUserId($user->getUID()); + $invitation->setRoomId($room->getId()); + $invitation->setAccessToken($sharedSecret); + $invitation->setRemoteId($remoteId); + $invitation = $this->invitationMapper->insert($invitation); + + return $invitation->getId(); + } + + /** + * @throws DBException + * @throws UnauthorizedException + * @throws MultipleObjectsReturnedException + */ + public function acceptRemoteRoomShare(IUser $user, int $shareId) { + $invitation = $this->invitationMapper->getInvitationById($shareId); + if ($invitation->getUserId() !== $user->getUID()) { + throw new UnauthorizedException('invitation is for a different user'); + } + + // Add user to the room + $room = $this->manager->getRoomById($invitation->getRoomId()); + $participant = [ + [ + 'actorType' => Attendee::ACTOR_USERS, + 'actorId' => $user->getUID(), + 'displayName' => $user->getDisplayName(), + 'accessToken' => $invitation->getAccessToken(), + 'remoteId' => $invitation->getRemoteId(), + ] + ]; + $this->participantService->addUsers($room, $participant); + + $this->invitationMapper->delete($invitation); + + // TODO: Send SHARE_ACCEPTED notification + } + + /** + * @throws DBException + * @throws UnauthorizedException + * @throws MultipleObjectsReturnedException + */ + public function rejectRemoteRoomShare(IUser $user, int $shareId) { + $invitation = $this->invitationMapper->getInvitationById($shareId); + if ($invitation->getUserId() !== $user->getUID()) { + throw new UnauthorizedException('invitation is for a different user'); + } + $this->invitationMapper->delete($invitation); + + // TODO: Send SHARE_DECLINED notification + } + + /** + * @throws DBException + */ + public function getNumberOfInvitations(Room $room): int { + return $this->invitationMapper->countInvitationsForRoom($room); + } +} diff --git a/lib/Manager.php b/lib/Manager.php index aeb52b200..ecbfdd8cd 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -37,6 +37,7 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\Comments\IComment; use OCP\Comments\ICommentsManager; use OCP\Comments\NotFoundException; +use OCP\DB\Exception as DBException; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; use OCP\ICache; @@ -185,6 +186,7 @@ class Manager { (string) $row['name'], (string) $row['description'], (string) $row['password'], + (string) $row['server_url'], (int) $row['active_guests'], (int) $row['call_flag'], $activeSince, @@ -628,7 +630,7 @@ class Manager { * @return Room * @throws RoomNotFoundException */ - public function getRoomByActor(string $token, string $actorType, string $actorId, ?string $sessionId = null): Room { + public function getRoomByActor(string $token, string $actorType, string $actorId, ?string $sessionId = null, ?string $serverUrl = null): Room { $query = $this->db->getQueryBuilder(); $helper = new SelectHelper(); $helper->selectRoomsTable($query); @@ -641,6 +643,12 @@ class Manager { )) ->where($query->expr()->eq('r.token', $query->createNamedParameter($token))); + if ($serverUrl === null) { + $query->andWhere($query->expr()->isNull('r.server_url')); + } else { + $query->andWhere($query->expr()->eq('r.server_url', $query->createNamedParameter($serverUrl))); + } + if ($sessionId !== null) { $helper->selectSessionsTable($query); $query->leftJoin('a', 'talk_sessions', 's', $query->expr()->andX( @@ -676,10 +684,10 @@ class Manager { * @return Room * @throws RoomNotFoundException */ - public function getRoomByToken(string $token, ?string $preloadUserId = null): Room { + public function getRoomByToken(string $token, ?string $preloadUserId = null, ?string $serverUrl = null): Room { $preloadUserId = $preloadUserId === '' ? null : $preloadUserId; if ($preloadUserId !== null) { - return $this->getRoomByActor($token, Attendee::ACTOR_USERS, $preloadUserId); + return $this->getRoomByActor($token, Attendee::ACTOR_USERS, $preloadUserId, null, $serverUrl); } $query = $this->db->getQueryBuilder(); @@ -688,6 +696,13 @@ class Manager { $query->from('talk_rooms', 'r') ->where($query->expr()->eq('r.token', $query->createNamedParameter($token))); + if ($serverUrl === null) { + $query->andWhere($query->expr()->isNull('r.server_url')); + } else { + $query->andWhere($query->expr()->eq('r.server_url', $query->createNamedParameter($serverUrl))); + } + + $result = $query->execute(); $row = $result->fetch(); $result->closeCursor(); @@ -908,6 +923,29 @@ class Manager { return $room; } + /** + * @param int $type + * @param string $name + * @return Room + * @throws DBException + */ + public function createRemoteRoom(int $type, string $name, string $token, string $serverUrl): Room { + $qb = $this->db->getQueryBuilder(); + + $qb->insert('talk_rooms') + ->values([ + 'name' => $qb->createNamedParameter($name), + 'type' => $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT), + 'token' => $qb->createNamedParameter($token), + 'server_url' => $qb->createNamedParameter($serverUrl), + ]); + + $qb->executeStatement(); + $roomId = $qb->getLastInsertId(); + + return $this->getRoomById($roomId); + } + public function resolveRoomDisplayName(Room $room, string $userId): string { if ($room->getObjectType() === 'share:password') { return $this->l->t('Password request: %s', [$room->getName()]); diff --git a/lib/Migration/Version13000Date20210625232111.php b/lib/Migration/Version13000Date20210625232111.php new file mode 100644 index 000000000..3f19a546c --- /dev/null +++ b/lib/Migration/Version13000Date20210625232111.php @@ -0,0 +1,102 @@ + + * + * @author Gary Kim + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Migration; + +use Closure; +use Doctrine\DBAL\Schema\SchemaException; +use Doctrine\DBAL\Types\Types; +use OCP\DB\ISchemaWrapper; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version13000Date20210625232111 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + * @throws SchemaException + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $table = $schema->getTable('talk_attendees'); + if (!$table->hasColumn('access_token')) { + $table->addColumn('access_token', Types::STRING, [ + 'notnull' => false, + 'default' => null, + 'length' => 64 + ]); + } + if (!$table->hasColumn('remote_id')) { + $table->addColumn('remote_id', Types::STRING, [ + 'notnull' => false, + 'default' => null, + 'length' => 255, + ]); + } + + $table = $schema->getTable('talk_rooms'); + if (!$table->hasColumn('server_url')) { + $table->addColumn('server_url', Types::STRING, [ + 'notnull' => false, + 'default' => null, + ]); + } + + if (!$schema->hasTable('talk_invitations')) { + $table = $schema->createTable('talk_invitations'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('room_id', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('access_token', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('remote_id', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + + $table->setPrimaryKey(['id']); + + $table->addIndex(['room_id']); + } + + + return $schema; + } +} diff --git a/lib/Model/Attendee.php b/lib/Model/Attendee.php index 6f7b083a5..e0de04da1 100644 --- a/lib/Model/Attendee.php +++ b/lib/Model/Attendee.php @@ -27,7 +27,7 @@ use OCP\AppFramework\Db\Entity; /** * @method void setRoomId(int $roomId) - * @method string getRoomId() + * @method int getRoomId() * @method void setActorType(string $actorType) * @method string getActorType() * @method void setActorId(string $actorId) @@ -51,6 +51,10 @@ use OCP\AppFramework\Db\Entity; * @method int getReadPrivacy() * @method void setPublishingPermissions(int $publishingPermissions) * @method int getPublishingPermissions() + * @method void setAccessToken(string $accessToken) + * @method null|string getAccessToken() + * @method void setRemoteId(string $remoteId) + * @method string getRemoteId() */ class Attendee extends Entity { public const ACTOR_USERS = 'users'; @@ -59,6 +63,7 @@ class Attendee extends Entity { public const ACTOR_EMAILS = 'emails'; public const ACTOR_CIRCLES = 'circles'; public const ACTOR_BRIDGED = 'bridged'; + public const ACTOR_FEDERATED_USERS = 'federated_users'; public const PUBLISHING_PERMISSIONS_NONE = 0; public const PUBLISHING_PERMISSIONS_AUDIO = 1; @@ -105,6 +110,12 @@ class Attendee extends Entity { /** @var int */ protected $publishingPermissions; + /** @var string */ + protected $accessToken; + + /** @var string */ + protected $remoteId; + public function __construct() { $this->addType('roomId', 'int'); $this->addType('actorType', 'string'); @@ -119,6 +130,8 @@ class Attendee extends Entity { $this->addType('lastMentionMessage', 'int'); $this->addType('readPrivacy', 'int'); $this->addType('publishingPermissions', 'int'); + $this->addType('accessToken', 'string'); + $this->addType('remote_id', 'string'); } public function getDisplayName(): string { @@ -144,6 +157,8 @@ class Attendee extends Entity { 'last_mention_message' => $this->getLastMentionMessage(), 'read_privacy' => $this->getReadPrivacy(), 'publishing_permissions' => $this->getPublishingPermissions(), + 'access_token' => $this->getAccessToken(), + 'remote_id' => $this->getRemoteId(), ]; } } diff --git a/lib/Model/AttendeeMapper.php b/lib/Model/AttendeeMapper.php index 3e8586389..9a57c7887 100644 --- a/lib/Model/AttendeeMapper.php +++ b/lib/Model/AttendeeMapper.php @@ -23,12 +23,17 @@ declare(strict_types=1); namespace OCA\Talk\Model; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception as DBException; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; /** * @method Attendee mapRowToEntity(array $row) + * @method Attendee findEntity(IQueryBuilder $query) + * @method Attendee[] findEntities(IQueryBuilder $query) */ class AttendeeMapper extends QBMapper { @@ -44,7 +49,7 @@ class AttendeeMapper extends QBMapper { * @param string $actorType * @param string $actorId * @return Attendee - * @throws \OCP\AppFramework\Db\DoesNotExistException + * @throws DoesNotExistException */ public function findByActor(int $roomId, string $actorType, string $actorId): Attendee { $query = $this->db->getQueryBuilder(); @@ -57,6 +62,22 @@ class AttendeeMapper extends QBMapper { return $this->findEntity($query); } + /** + * @param int $id + * @return Attendee + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws DBException + */ + public function getById(int $id): Attendee { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from($this->getTableName()) + ->where($query->expr()->eq('id', $query->createNamedParameter($id))); + + return $this->findEntity($query); + } + /** * @param int $roomId * @param string $actorType @@ -153,6 +174,8 @@ class AttendeeMapper extends QBMapper { 'last_mention_message' => (int) $row['last_mention_message'], 'read_privacy' => (int) $row['read_privacy'], 'publishing_permissions' => (int) $row['publishing_permissions'], + 'access_token' => (string) $row['access_token'], + 'remote_id' => (string) $row['remote_id'], ]); } } diff --git a/lib/Model/Invitation.php b/lib/Model/Invitation.php new file mode 100644 index 000000000..8ba075615 --- /dev/null +++ b/lib/Model/Invitation.php @@ -0,0 +1,73 @@ + + * + * @author Gary Kim + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Model; + +use OCP\AppFramework\Db\Entity; + +/** + * Class Invitation + * + * @package OCA\Talk\Model + * + * @method void setRoomId(int $roomId) + * @method int getRoomId() + * @method void setUserId(string $userId) + * @method string getUserId() + * @method void setAccessToken(string $accessToken) + * @method string getAccessToken() + * @method void setRemoteId(string $remoteId) + * @method string getRemoteId() + */ +class Invitation extends Entity { + /** @var int */ + protected $roomId; + + /** @var string */ + protected $userId; + + /** @var string */ + protected $accessToken; + + /** @var string */ + protected $remoteId; + + public function __construct() { + $this->addType('roomId', 'int'); + $this->addType('userId', 'string'); + $this->addType('accessToken', 'string'); + $this->addType('remoteId', 'string'); + } + + public function asArray(): array { + return [ + 'id' => $this->getId(), + 'room_id' => $this->getRoomId(), + 'user_id' => $this->getUserId(), + 'access_token' => $this->getAccessToken(), + 'remote_id' => $this->getRemoteId(), + ]; + } +} diff --git a/lib/Model/InvitationMapper.php b/lib/Model/InvitationMapper.php new file mode 100644 index 000000000..f54cbafd8 --- /dev/null +++ b/lib/Model/InvitationMapper.php @@ -0,0 +1,106 @@ + + * + * @author Gary Kim + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\Talk\Model; + +use OCA\Talk\Room; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception as DBException; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * Class InvitationMapper + * + * @package OCA\Talk\Model + * + * @method Invitation mapRowToEntity(array $row) + * @method Invitation findEntity(IQueryBuilder $query) + * @method Invitation[] findEntities(IQueryBuilder $query) + */ +class InvitationMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'talk_invitations', Invitation::class); + } + + /** + * @throws DBException + * @throws MultipleObjectsReturnedException + * @throws DoesNotExistException + */ + public function getInvitationById(int $id): Invitation { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity($qb); + } + + /** + * @param Room $room + * @return Invitation[] + * @throws DBException + */ + public function getInvitationsForRoom(Room $room): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('room_id', $qb->createNamedParameter($room->getId()))); + + return $this->findEntities($qb); + } + + /** + * @throws DBException + */ + public function countInvitationsForRoom(Room $room): int { + $qb = $this->db->getQueryBuilder(); + + $qb->select($qb->func()->count('*', 'num_invitations')) + ->from($this->getTableName()) + ->where($qb->expr()->eq('room_id', $qb->createNamedParameter($room->getId()))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + return (int) ($row['num_invitations' ?? 0]); + } + + public function createInvitationFromRow(array $row): Invitation { + return $this->mapRowToEntity([ + 'id' => $row['id'], + 'room_id' => (int) $row['room_id'], + 'user_id' => (string) $row['user_id'], + 'access_token' => (string) $row['access_token'], + 'remote_id' => (string) $row['remote_id'], + ]); + } +} diff --git a/lib/Model/SelectHelper.php b/lib/Model/SelectHelper.php index dfcab988a..0ba06d2ad 100644 --- a/lib/Model/SelectHelper.php +++ b/lib/Model/SelectHelper.php @@ -49,6 +49,7 @@ class SelectHelper { ->addSelect($alias . 'object_type') ->addSelect($alias . 'object_id') ->addSelect($alias . 'listable') + ->addSelect($alias . 'server_url') ->selectAlias($alias . 'id', 'r_id'); } @@ -70,6 +71,8 @@ class SelectHelper { ->addSelect($alias . 'last_mention_message') ->addSelect($alias . 'read_privacy') ->addSelect($alias . 'publishing_permissions') + ->addSelect($alias . 'access_token') + ->addSelect($alias . 'remote_id') ->selectAlias($alias . 'id', 'a_id'); } diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index eeb657bdf..a1607caad 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -23,6 +23,7 @@ declare(strict_types=1); namespace OCA\Talk\Notification; +use OCA\FederatedFileSharing\AddressHandler; use OCA\Talk\Chat\CommentsManager; use OCA\Talk\Chat\MessageParser; use OCA\Talk\Config; @@ -36,6 +37,7 @@ use OCA\Talk\Room; use OCA\Talk\Service\ParticipantService; use OCP\Comments\ICommentsManager; use OCP\Comments\NotFoundException; +use OCP\HintException; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUser; @@ -77,6 +79,8 @@ class Notifier implements INotifier { protected $messageParser; /** @var Definitions */ protected $definitions; + /** @var AddressHandler */ + protected $addressHandler; /** @var Room[] */ protected $rooms = []; @@ -94,7 +98,8 @@ class Notifier implements INotifier { INotificationManager $notificationManager, CommentsManager $commentManager, MessageParser $messageParser, - Definitions $definitions) { + Definitions $definitions, + AddressHandler $addressHandler) { $this->lFactory = $lFactory; $this->url = $url; $this->config = $config; @@ -107,6 +112,7 @@ class Notifier implements INotifier { $this->commentManager = $commentManager; $this->messageParser = $messageParser; $this->definitions = $definitions; + $this->addressHandler = $addressHandler; } /** @@ -258,6 +264,10 @@ class Notifier implements INotifier { return $this->parseChatMessage($notification, $room, $participant, $l); } + if ($subject === 'remote_talk_share') { + return $this->parseRemoteInvitationMessage($notification, $l); + } + $this->notificationManager->markProcessed($notification); throw new \InvalidArgumentException('Unknown subject'); } @@ -270,6 +280,47 @@ class Notifier implements INotifier { return $temp; } + /** + * @throws HintException + */ + protected function parseRemoteInvitationMessage(INotification $notification, IL10N $l): INotification { + $subjectParameters = $notification->getSubjectParameters(); + + [$sharedById, $sharedByServer] = $this->addressHandler->splitUserRemote($subjectParameters['sharedByFederatedId']); + + $message = $l->t('{user1} shared room {roomName} on {remoteServer} with you'); + + $rosParameters = [ + 'user1' => [ + 'type' => 'user', + 'id' => $sharedById, + 'name' => $subjectParameters['sharedByDisplayName'], + 'server' => $sharedByServer, + ], + 'roomName' => [ + 'type' => 'highlight', + 'id' => $subjectParameters['serverUrl'] . '::' . $subjectParameters['roomToken'], + 'name' => $subjectParameters['roomName'], + ], + 'remoteServer' => [ + 'type' => 'highlight', + 'id' => $subjectParameters['serverUrl'], + 'name' => $subjectParameters['serverUrl'], + ] + ]; + + $placeholders = $replacements = []; + foreach ($rosParameters as $placeholder => $parameter) { + $placeholders[] = '{' . $placeholder .'}'; + $replacements[] = $parameter['name']; + } + + $notification->setParsedMessage(str_replace($placeholders, $replacements, $message)); + $notification->setRichMessage($message, $rosParameters); + + return $notification; + } + /** * @param INotification $notification * @param Room $room diff --git a/lib/Room.php b/lib/Room.php index d52fd2200..035072261 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -180,6 +180,8 @@ class Room { private $description; /** @var string */ private $password; + /** @var string */ + private $serverUrl; /** @var int */ private $activeGuests; /** @var int */ @@ -218,6 +220,7 @@ class Room { string $name, string $description, string $password, + string $serverUrl, int $activeGuests, int $callFlag, ?\DateTime $activeSince, @@ -243,6 +246,7 @@ class Room { $this->name = $name; $this->description = $description; $this->password = $password; + $this->serverUrl = $serverUrl; $this->activeGuests = $activeGuests; $this->callFlag = $callFlag; $this->activeSince = $activeSince; @@ -377,6 +381,14 @@ class Room { return $this->password; } + public function getServerUrl(): string { + return $this->serverUrl; + } + + public function isFederatedRemoteRoom(): bool { + return $this->serverUrl !== ''; + } + public function setParticipant(?string $userId, Participant $participant): void { $this->currentUser = $userId; $this->participant = $participant; diff --git a/lib/Service/ParticipantService.php b/lib/Service/ParticipantService.php index be27b16ed..673c1f157 100644 --- a/lib/Service/ParticipantService.php +++ b/lib/Service/ParticipantService.php @@ -326,6 +326,12 @@ class ParticipantService { if (isset($participant['displayName'])) { $attendee->setDisplayName($participant['displayName']); } + if (isset($participant['accessToken'])) { + $attendee->setAccessToken($participant['accessToken']); + } + if (isset($participant['remoteId'])) { + $attendee->setRemoteId($participant['remoteId']); + } $attendee->setParticipantType($participant['participantType'] ?? Participant::USER); $attendee->setLastReadMessage($lastMessage); $attendee->setReadPrivacy($readPrivacy); diff --git a/psalm.xml b/psalm.xml index 4ffa77ec0..711e3dcaa 100644 --- a/psalm.xml +++ b/psalm.xml @@ -28,6 +28,7 @@ + @@ -43,6 +44,7 @@ + diff --git a/tests/php/Notification/NotifierTest.php b/tests/php/Notification/NotifierTest.php index c6d1a234d..6aeff338f 100644 --- a/tests/php/Notification/NotifierTest.php +++ b/tests/php/Notification/NotifierTest.php @@ -21,6 +21,7 @@ namespace OCA\Talk\Tests\php\Notifications; +use OCA\FederatedFileSharing\AddressHandler; use OCA\Talk\Chat\CommentsManager; use OCA\Talk\Chat\MessageParser; use OCA\Talk\Config; @@ -75,6 +76,8 @@ class NotifierTest extends \Test\TestCase { protected $definitions; /** @var Notifier */ protected $notifier; + /** @var AddressHandler|MockObject */ + protected $addressHandler; public function setUp(): void { parent::setUp(); @@ -91,6 +94,7 @@ class NotifierTest extends \Test\TestCase { $this->commentsManager = $this->createMock(CommentsManager::class); $this->messageParser = $this->createMock(MessageParser::class); $this->definitions = $this->createMock(Definitions::class); + $this->addressHandler = $this->createMock(AddressHandler::class); $this->notifier = new Notifier( $this->lFactory, @@ -104,7 +108,8 @@ class NotifierTest extends \Test\TestCase { $this->notificationManager, $this->commentsManager, $this->messageParser, - $this->definitions + $this->definitions, + $this->addressHandler ); } diff --git a/tests/php/RoomTest.php b/tests/php/RoomTest.php index 623c3176d..65df9b395 100644 --- a/tests/php/RoomTest.php +++ b/tests/php/RoomTest.php @@ -70,6 +70,7 @@ class RoomTest extends TestCase { 'Test', 'description', 'passy', + '', 0, Participant::FLAG_DISCONNECTED, null,