From 811798e23fe19421f94e15d2cfbd6bcca903a011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Sat, 7 May 2022 02:22:28 +0200 Subject: [PATCH] wip: synchro --- .../Command/MapUserCalendarCommand.php | 35 +++++------ .../RemoteCalendarConnectAzureController.php | 2 +- .../RemoteCalendarProxyController.php | 36 ++++++++++- .../Connector/MSGraph/MSGraphTokenStorage.php | 23 +++++++- .../Connector/MSGraph/MachineHttpClient.php | 15 ++++- .../Connector/MSGraph/MachineTokenStorage.php | 4 -- .../Connector/MSGraph/MapCalendarToUser.php | 59 +++++++++++++++++++ .../MSGraph/RemoteEventConverter.php | 19 ++++++ .../Connector/MSGraph/UserHttpClient.php | 57 ++++++++++++++++++ .../MSGraphRemoteCalendarConnector.php | 42 +++++++++---- .../Synchro/Model/RemoteEvent.php | 19 +++++- src/Bundle/ChillMainBundle/Entity/User.php | 4 +- .../Repository/UserRepository.php | 15 ++++- .../migrations/Version20220506223243.php | 28 +++++++++ 14 files changed, 315 insertions(+), 43 deletions(-) create mode 100644 src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MapCalendarToUser.php create mode 100644 src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/RemoteEventConverter.php create mode 100644 src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/UserHttpClient.php create mode 100644 src/Bundle/ChillMainBundle/migrations/Version20220506223243.php diff --git a/src/Bundle/ChillCalendarBundle/Command/MapUserCalendarCommand.php b/src/Bundle/ChillCalendarBundle/Command/MapUserCalendarCommand.php index 9a150727f..cd6c61d54 100644 --- a/src/Bundle/ChillCalendarBundle/Command/MapUserCalendarCommand.php +++ b/src/Bundle/ChillCalendarBundle/Command/MapUserCalendarCommand.php @@ -3,48 +3,49 @@ namespace Chill\CalendarBundle\Command; use Chill\CalendarBundle\Synchro\Connector\MSGraph\MachineTokenStorage; +use Chill\CalendarBundle\Synchro\Connector\MSGraph\MapCalendarToUser; use Chill\CalendarBundle\Synchro\Connector\MSGraphRemoteCalendarConnector; use Chill\MainBundle\Repository\UserRepository; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class MapUserCalendarCommand extends Command { - private MSGraphRemoteCalendarConnector $remoteCalendarConnector; + private EntityManagerInterface $em; + + private MapCalendarToUser $mapCalendarToUser; private UserRepository $userRepository; - public function __construct(MSGraphRemoteCalendarConnector $remoteCalendarConnector) + public function __construct(EntityManagerInterface $em, MapCalendarToUser $mapCalendarToUser, UserRepository $userRepository) { parent::__construct('chill:calendar:map-user'); - $this->remoteCalendarConnector = $remoteCalendarConnector; + $this->em = $em; + $this->mapCalendarToUser = $mapCalendarToUser; + $this->userRepository = $userRepository; } public function execute(InputInterface $input, OutputInterface $output): int { $limit = 2; + $offset = 0; + $total = $this->userRepository->countByNotHavingAttribute(MapCalendarToUser::METADATA_KEY); - do { - $users = $this->userRepository->findByNotHavingAttribute('ms:graph', $limit); + while ($offset < $total) { + $users = $this->userRepository->findByNotHavingAttribute(MapCalendarToUser::METADATA_KEY, $limit, $offset); foreach ($users as $user) { - $usersData = $this->remoteCalendarConnector->getUserByEmail($user->getEmailCanonical()); - - $defaultCalendar - - $user->setAttributes(['ms:graph' => [ - - ]]); + $this->mapCalendarToUser->writeMetadata($user); + $offset++; } - } while (count($users) === $limit); - - + $this->em->flush(); + $this->em->clear(); + } return 0; } - - } diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php index 1e73e6782..43520035d 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarConnectAzureController.php @@ -45,7 +45,7 @@ class RemoteCalendarConnectAzureController return $this->clientRegistry ->getClient('azure') // key used in config/packages/knpu_oauth2_client.yaml ->redirect([ - 'User.Read', 'Calendars.Read', 'Calendars.Read.Shared', + 'https://graph.microsoft.com/.default' ]); } diff --git a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarProxyController.php b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarProxyController.php index 2cf0daa50..e05189a75 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarProxyController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/RemoteCalendarProxyController.php @@ -11,11 +11,16 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Controller; +use Chill\CalendarBundle\Synchro\Connector\MSGraph\RemoteEventConverter; use Chill\CalendarBundle\Synchro\Connector\RemoteCalendarConnectorInterface; use Chill\MainBundle\Entity\User; use DateTimeImmutable; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Serializer\SerializerInterface; /** * Contains method to get events (Calendar) from remote calendar. @@ -24,18 +29,45 @@ class RemoteCalendarProxyController { private RemoteCalendarConnectorInterface $remoteCalendarConnector; + private SerializerInterface $serializer; + + + public function __construct(RemoteCalendarConnectorInterface $remoteCalendarConnector, SerializerInterface $serializer) + { + $this->remoteCalendarConnector = $remoteCalendarConnector; + $this->serializer = $serializer; + } + + /** + * @Route("api/1.0/calendar/proxy/calendar/by-user/{id}/events") + */ public function listEventForCalendar(User $user, Request $request): Response { if ($request->query->has('startDate')) { - $startDate = DateTimeImmutable::createFromFormat('Y-m-dTHis', $request->query->get('startDate') . '000000'); + $startDate = DateTimeImmutable::createFromFormat('Y-m-d', $request->query->get('startDate')); + if (false === $startDate) { + throw new BadRequestHttpException("startDate on bad format"); + } } else { throw new BadRequestHttpException('startDate not provided'); } if ($request->query->has('endDate')) { - $startDate = DateTimeImmutable::createFromFormat('Y-m-dTHis', $request->query->get('endDate') . '000000'); + $endDate = DateTimeImmutable::createFromFormat('Y-m-d', $request->query->get('endDate')); + if (false === $endDate) { + throw new BadRequestHttpException("endDate on bad format"); + } } else { throw new BadRequestHttpException('endDate not provided'); } + + $events = $this->remoteCalendarConnector->listEventsForUser($user, $startDate, $endDate); + + return new JsonResponse( + $this->serializer->serialize($events, 'json', ['groups' => ['read']]), + JsonResponse::HTTP_OK, + [], + true + ); } } diff --git a/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MSGraphTokenStorage.php b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MSGraphTokenStorage.php index 2448c9fbf..abc1c6495 100644 --- a/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MSGraphTokenStorage.php +++ b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MSGraphTokenStorage.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Synchro\Connector\MSGraph; use Symfony\Component\HttpFoundation\Session\SessionInterface; +use TheNetworg\OAuth2\Client\Provider\Azure; use TheNetworg\OAuth2\Client\Token\AccessToken; class MSGraphTokenStorage @@ -20,14 +21,32 @@ class MSGraphTokenStorage private SessionInterface $session; - public function __construct(SessionInterface $session) + private Azure $azure; + + public function __construct(Azure $azure, SessionInterface $session) { + $this->azure = $azure; $this->session = $session; } public function getToken(): AccessToken { - return $this->session->get(self::MS_GRAPH_ACCESS_TOKEN); + /** @var ?AccessToken $token */ + $token = $this->session->get(self::MS_GRAPH_ACCESS_TOKEN, null); + + if (null === $token) { + throw new \LogicException('unexisting token'); + } + + if ($token->hasExpired()) { + $token = $this->azure->getAccessToken('refresh_token', [ + 'refresh_token' => $token->getRefreshToken(), + ]); + + $this->setToken($token); + } + + return $token; } public function hasToken(): bool diff --git a/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MachineHttpClient.php b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MachineHttpClient.php index a410f1c59..cbadc8409 100644 --- a/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MachineHttpClient.php +++ b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MachineHttpClient.php @@ -29,12 +29,23 @@ class MachineHttpClient implements HttpClientInterface { $options['headers'] = array_merge( $options['headers'] ?? [], - //['Content-Type' => 'application/json'], $this->getAuthorizationHeaders($this->machineTokenStorage->getToken()) ); $options['base_uri'] = 'https://graph.microsoft.com/v1.0/'; - dump($options); + switch ($method) { + case 'GET': + case 'HEAD': + $options['headers']['Accept'] = 'application/json'; + break; + case 'POST': + case 'PUT': + case 'PATCH': + $options['headers']['Content-Type'] = 'application/json'; + break; + default: + throw new \LogicException("Method not supported: $method"); + } return $this->decoratedClient->request($method, $url, $options); } diff --git a/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MachineTokenStorage.php b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MachineTokenStorage.php index 1b007932e..b74a4967b 100644 --- a/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MachineTokenStorage.php +++ b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MachineTokenStorage.php @@ -40,11 +40,7 @@ class MachineTokenStorage ]); } - dump($this->accessToken); - return $this->accessToken; - - //return unserialize($this->chillRedis->get(self::KEY)); } public function storeToken(AccessToken $token): void diff --git a/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MapCalendarToUser.php b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MapCalendarToUser.php new file mode 100644 index 000000000..3cb6934c9 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/MapCalendarToUser.php @@ -0,0 +1,59 @@ +remoteCalendarConnector = $remoteCalendarConnector; + $this->logger = $logger; + } + + public function writeMetadata(User $user): User + { + if (null === $userData = $this->remoteCalendarConnector->getUserByEmail($user->getEmailCanonical())) { + $this->logger->warning('[MapCalendarToUser] could find user on msgraph', ['userId' => $user->getId(), 'email' => $user->getEmailCanonical()]); + return $this->writeNullData($user); + } + + if (null === $defaultCalendar = $this->remoteCalendarConnector->getDefaultUserCalendar($userData['id'])) { + $this->logger->warning('[MapCalendarToUser] could find default calendar', ['userId' => $user->getId(), 'email' => $user->getEmailCanonical()]); + return $this->writeNullData($user); + } + + return $user->setAttributes([self::METADATA_KEY => [ + 'id' => $userData['id'], + 'userPrincipalName' => $userData['userPrincipalName'], + 'defaultCalendarId' => $defaultCalendar['id'], + ]]); + } + + private function writeNullData(User $user): User + { + return $user->unsetAttribute(self::METADATA_KEY); + } + + public function getCalendarId(User $user): ?string + { + if (null === $mskey = ($user->getAttributes()[self::METADATA_KEY] ?? null)) { + return null; + } + + return $msKey['defaultCalendarId'] ?? null; + } + +} diff --git a/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/RemoteEventConverter.php b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/RemoteEventConverter.php new file mode 100644 index 000000000..d01a93e5f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraph/RemoteEventConverter.php @@ -0,0 +1,19 @@ +decoratedClient = $decoratedClient ?? \Symfony\Component\HttpClient\HttpClient::create(); + $this->tokenStorage = $tokenStorage; + } + + public function request(string $method, string $url, array $options = []): ResponseInterface + { + $options['headers'] = array_merge( + $options['headers'] ?? [], + $this->getAuthorizationHeaders($this->tokenStorage->getToken()) + ); + $options['base_uri'] = 'https://graph.microsoft.com/v1.0/'; + + switch ($method) { + case 'GET': + case 'HEAD': + $options['headers']['Accept'] = 'application/json'; + break; + case 'POST': + case 'PUT': + case 'PATCH': + $options['headers']['Content-Type'] = 'application/json'; + break; + default: + throw new \LogicException("Method not supported: $method"); + } + + return $this->decoratedClient->request($method, $url, $options); + } + + public function stream($responses, float $timeout = null): ResponseStreamInterface + { + return $this->decoratedClient->stream($responses, $timeout); + } + +} diff --git a/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraphRemoteCalendarConnector.php b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraphRemoteCalendarConnector.php index 69edb017e..1d36dfc95 100644 --- a/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraphRemoteCalendarConnector.php +++ b/src/Bundle/ChillCalendarBundle/Synchro/Connector/MSGraphRemoteCalendarConnector.php @@ -14,6 +14,8 @@ namespace Chill\CalendarBundle\Synchro\Connector; use Chill\CalendarBundle\Synchro\Connector\MSGraph\MachineHttpClient; use Chill\CalendarBundle\Synchro\Connector\MSGraph\MSGraphClient; use Chill\CalendarBundle\Synchro\Connector\MSGraph\MSGraphTokenStorage; +use Chill\CalendarBundle\Synchro\Connector\MSGraph\RemoteEventConverter; +use Chill\CalendarBundle\Synchro\Connector\MSGraph\UserHttpClient; use Chill\MainBundle\Entity\User; use DateTimeImmutable; use Symfony\Component\HttpFoundation\RedirectResponse; @@ -23,24 +25,28 @@ use function Amp\Iterator\toArray; class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface { - private MSGraphClient $client; - private MachineHttpClient $machineHttpClient; + private UserHttpClient $userHttpClient; + private MSGraphTokenStorage $tokenStorage; private UrlGeneratorInterface $urlGenerator; + private RemoteEventConverter $remoteEventConverter; + public function __construct( MachineHttpClient $machineHttpClient, - MSGraphClient $client, MSGraphTokenStorage $tokenStorage, - UrlGeneratorInterface $urlGenerator + RemoteEventConverter $remoteEventConverter, + UrlGeneratorInterface $urlGenerator, + UserHttpClient $userHttpClient ) { - $this->client = $client; $this->machineHttpClient = $machineHttpClient; + $this->remoteEventConverter = $remoteEventConverter; $this->tokenStorage = $tokenStorage; $this->urlGenerator = $urlGenerator; + $this->userHttpClient = $userHttpClient; } public function getMakeReadyResponse(string $returnPath): Response @@ -56,20 +62,36 @@ class MSGraphRemoteCalendarConnector implements RemoteCalendarConnectorInterface public function listEventsForUser(User $user, DateTimeImmutable $startDate, DateTimeImmutable $endDate): array { - return $this->client->listEventsForUserCalendar($user->getEmail(), $startDate, $endDate); + $bareEvents = $this->userHttpClient->request( + 'GET', + 'users/c4f1fcc7-10e4-4ea9-89ac-c89a00e0a51a/calendarView', + [ + 'query' => [ + 'startDateTime' => $startDate->format(DateTimeImmutable::ATOM), + 'endDateTime' => $endDate->format(DateTimeImmutable::ATOM), + '$select' => 'id,subject,start,end' + ] + ] + )->toArray(); + + return array_map(function($item) { return $this->remoteEventConverter->convertToRemote($item);}, $bareEvents['value']); } - public function getUserByEmail(string $email): array + public function getUserByEmail(string $email): ?array { - return $this->machineHttpClient->request('GET', 'users', [ + $value = $this->machineHttpClient->request('GET', 'users', [ 'query' => ['$filter' => "mail eq '${email}'"], ])->toArray()['value']; + + return $value[0] ?? null; } - public function getDefaultUserCalendar(string $idOrUserPrincipalName): array + public function getDefaultUserCalendar(string $idOrUserPrincipalName): ?array { - return $this->machineHttpClient->request('GET', "users/$idOrUserPrincipalName/calendars", [ + $value = $this->machineHttpClient->request('GET', "users/$idOrUserPrincipalName/calendars", [ 'query' => ['$filter' => 'isDefaultCalendar eq true'], ])->toArray()['value']; + + return $value[0] ?? null; } } diff --git a/src/Bundle/ChillCalendarBundle/Synchro/Model/RemoteEvent.php b/src/Bundle/ChillCalendarBundle/Synchro/Model/RemoteEvent.php index 91305a9d6..4db50574d 100644 --- a/src/Bundle/ChillCalendarBundle/Synchro/Model/RemoteEvent.php +++ b/src/Bundle/ChillCalendarBundle/Synchro/Model/RemoteEvent.php @@ -12,19 +12,36 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Synchro\Model; use DateTimeImmutable; +use Symfony\Component\Serializer\Annotation as Serializer; class RemoteEvent { + public string $description; + /** + * @Serializer\Groups({"read"}) + */ public DateTimeImmutable $endDate; + /** + * @Serializer\Groups({"read"}) + */ + public string $id; + + /** + * @Serializer\Groups({"read"}) + */ public DateTimeImmutable $startDate; + /** + * @Serializer\Groups({"read"}) + */ public string $title; - public function __construct(string $title, string $description, DateTimeImmutable $startDate, DateTimeImmutable $endDate) + public function __construct(string $id, string $title, string $description, DateTimeImmutable $startDate, DateTimeImmutable $endDate) { + $this->id = $id; $this->title = $title; $this->description = $description; $this->startDate = $startDate; diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index 25d8c29e9..d044b0b15 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -43,9 +43,9 @@ class User implements AdvancedUserInterface /** * Array where SAML attributes's data are stored. * - * @ORM\Column(type="json", nullable=true) + * @ORM\Column(type="json", nullable=false) */ - private array $attributes; + private array $attributes = []; /** * @ORM\ManyToOne(targetEntity=Location::class) diff --git a/src/Bundle/ChillMainBundle/Repository/UserRepository.php b/src/Bundle/ChillMainBundle/Repository/UserRepository.php index 43428fa9c..de8512b1c 100644 --- a/src/Bundle/ChillMainBundle/Repository/UserRepository.php +++ b/src/Bundle/ChillMainBundle/Repository/UserRepository.php @@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\GroupCenter; use Chill\MainBundle\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ObjectRepository; @@ -168,19 +169,29 @@ final class UserRepository implements ObjectRepository $rsm = new ResultSetMappingBuilder($this->entityManager); $rsm->addRootEntityFromClassMetadata(User::class, 'u'); - $sql = "SELECT ".$rsm->generateSelectClause()." FROM users u WHERE NOT attributes ? :key OR attributes IS NULL AND enabled IS TRUE"; + $sql = "SELECT ".$rsm->generateSelectClause()." FROM users u WHERE NOT attributes ?? :key OR attributes IS NULL AND enabled IS TRUE"; if (null !== $limit) { $sql .= " LIMIT $limit"; } if (null !== $offset) { - $sql .= " OFFET $offset"; + $sql .= " OFFSET $offset"; } return $this->entityManager->createNativeQuery($sql, $rsm)->setParameter(':key', $key)->getResult(); } + public function countByNotHavingAttribute(string $key): int + { + $rsm = new ResultSetMapping(); + $rsm->addScalarResult('count', 'count'); + + $sql = "SELECT count(*) FROM users u WHERE NOT attributes ?? :key OR attributes IS NULL AND enabled IS TRUE"; + + return $this->entityManager->createNativeQuery($sql, $rsm)->setParameter(':key', $key)->getSingleScalarResult(); + } + public function getClassName() { return User::class; diff --git a/src/Bundle/ChillMainBundle/migrations/Version20220506223243.php b/src/Bundle/ChillMainBundle/migrations/Version20220506223243.php new file mode 100644 index 000000000..e362cc665 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20220506223243.php @@ -0,0 +1,28 @@ +addSql('UPDATE users SET attributes = \'{}\'::jsonb WHERE attributes IS NULL'); + $this->addSql('ALTER TABLE users ALTER attributes SET NOT NULL'); + $this->addSql('ALTER TABLE users ALTER attributes SET DEFAULT \'{}\'::jsonb'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE users ALTER attributes DROP NOT NULL'); + } +}