1 <?php 
declare(strict_types
=1); 
   4  * This file is part of the Rapsys AirBundle package. 
   6  * (c) Raphaël Gertz <symfony@rapsys.eu> 
   8  * For the full copyright and license information, please view the LICENSE 
   9  * file that was distributed with this source code. 
  12 namespace Rapsys\AirBundle\Controller
; 
  14 use Symfony\Contracts\Cache\ItemInterface
; 
  15 use Symfony\Component\HttpFoundation\Request
; 
  16 use Symfony\Component\HttpFoundation\Response
; 
  17 use Symfony\Component\Routing\Generator\UrlGeneratorInterface
; 
  19 use Rapsys\UserBundle\Controller\UserController 
as BaseUserController
; 
  21 use Rapsys\AirBundle\Entity\Dance
; 
  22 use Rapsys\AirBundle\Entity\GoogleCalendar
; 
  23 use Rapsys\AirBundle\Entity\GoogleToken
; 
  24 use Rapsys\AirBundle\Entity\User
; 
  29 class UserController 
extends BaseUserController 
{ 
  31          * Set google client scopes 
  33         const googleScopes 
= [\Google\Service\Calendar
::CALENDAR_EVENTS
, \Google\Service\Calendar
::CALENDAR
, \Google\Service\Oauth2
::USERINFO_EMAIL
]; 
  38         public function edit(Request 
$request, string $hash, string $mail): Response 
{ 
  40                 if ($hash != $this->slugger
->hash($mail)) { 
  42                         throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'hash', '%value%' => $hash])); 
  46                 $mail = $this->slugger
->unshort($smail = $mail); 
  48                 //With existing subscriber 
  49                 if (empty($user = $this->doctrine
->getRepository($this->config
['class']['user'])->findOneByMail($mail))) { 
  51                         //XXX: prevent slugger reverse engineering by not displaying decoded mail 
  52                         throw $this->createNotFoundException($this->translator
->trans('Unable to find account %mail%', ['%mail%' => $smail])); 
  55                 //Prevent access when not admin, user is not guest and not currently logged user 
  56                 if (!$this->checker
->isGranted('ROLE_ADMIN') && $user != $this->security
->getUser() || !$this->checker
->isGranted('IS_AUTHENTICATED_FULLY')) { 
  58                         //XXX: prevent slugger reverse engineering by not displaying decoded mail 
  59                         throw $this->createAccessDeniedException($this->translator
->trans('Unable to access user: %mail%', ['%mail%' => $smail])); 
  62                 //Create the RegisterType form and give the proper parameters 
  63                 $edit = $this->factory
->create($this->config
['edit']['view']['edit'], $user, [ 
  64                         //Set action to register route name and context 
  65                         'action' => $this->generateUrl($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $hash]+
$this->config
['route']['edit']['context']), 
  67                         'civility_class' => $this->config
['class']['civility'], 
  68                         //Set civility default 
  69                         'civility_default' => $this->doctrine
->getRepository($this->config
['class']['civility'])->findOneByTitle($this->config
['default']['civility']), 
  71                         'country_class' => $this->config
['class']['country'], 
  73                         'country_default' => $this->doctrine
->getRepository($this->config
['class']['country'])->findOneByTitle($this->config
['default']['country']), 
  74                         //Set country favorites 
  75                         'country_favorites' => $this->doctrine
->getRepository($this->config
['class']['country'])->findByTitle($this->config
['default']['country_favorites']), 
  77                         'dance' => $this->checker
->isGranted('ROLE_ADMIN'), 
  79                         'dance_choices' => $danceChoices = $this->doctrine
->getRepository($this->config
['class']['dance'])->findChoicesAsArray(), 
  81                         #'dance_default' => /*$this->doctrine->getRepository($this->config['class']['dance'])->findOneByNameType($this->config['default']['dance'])*/null, 
  83                         'dance_favorites' => $this->doctrine
->getRepository($this->config
['class']['dance'])->findIdByNameTypeAsArray($this->config
['default']['dance_favorites']), 
  85                         'subscription' => $this->checker
->isGranted('ROLE_ADMIN'), 
  86                         //Set subscription choices 
  87                         'subscription_choices' => $subscriptionChoices = $this->doctrine
->getRepository($this->config
['class']['user'])->findChoicesAsArray(), 
  88                         //Set subscription default 
  89                         #'subscription_default' => /*$this->doctrine->getRepository($this->config['class']['user'])->findOneByPseudonym($this->config['default']['subscription'])*/null, 
  90                         //Set subscription favorites 
  91                         'subscription_favorites' => $this->doctrine
->getRepository($this->config
['class']['user'])->findIdByPseudonymAsArray($this->config
['default']['subscription_favorites']), 
  93                         'mail' => $this->checker
->isGranted('ROLE_ADMIN'), 
  95                         'pseudonym' => $this->checker
->isGranted('ROLE_GUEST'), 
 100                 ]+
$this->config
['edit']['field']); 
 103                 if ($this->checker
->isGranted('ROLE_ADMIN')) { 
 104                         //Create the ResetType form and give the proper parameters 
 105                         $reset = $this->factory
->create($this->config
['edit']['view']['reset'], $user, [ 
 106                                 //Set action to register route name and context 
 107                                 'action' => $this->generateUrl($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $hash]+
$this->config
['route']['edit']['context']), 
 115                         if ($request->isMethod('POST')) { 
 116                                 //Refill the fields in case the form is not valid. 
 117                                 $reset->handleRequest($request); 
 119                                 //With reset submitted and valid 
 120                                 if ($reset->isSubmitted() && $reset->isValid()) { 
 122                                         $data = $reset->getData(); 
 125                                         $data->setPassword($this->hasher
->hashPassword($data, $data->getPassword())); 
 127                                         //Queue user password save 
 128                                         $this->manager
->persist($data); 
 130                                         //Flush to get the ids 
 131                                         $this->manager
->flush(); 
 134                                         $this->addFlash('notice', $this->translator
->trans('Account %mail% password updated', ['%mail%' => $mail])); 
 136                                         //Redirect to cleanup the form 
 137                                         return $this->redirectToRoute($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $hash]+
$this->config
['route']['edit']['context']); 
 142                         $this->config
['edit']['view']['context']['reset'] = $reset->createView(); 
 144                         //Add google calendar array 
 145                         $this->config
['edit']['view']['context']['calendar'] = [ 
 148                                 //Uri to link account 
 152                                         'png' => '@RapsysAir/png/calendar.png', 
 153                                         'svg' => '@RapsysAir/svg/calendar.svg' 
 158                         $googleClient = new \Google\
Client( 
 160                                         'application_name' => $request->server
->get('GOOGLE_PROJECT'), 
 161                                         'client_id' => $request->server
->get('GOOGLE_CLIENT'), 
 162                                         'client_secret' => $request->server
->get('GOOGLE_SECRET'), 
 163                                         'redirect_uri' => $this->generateUrl('rapsysair_google_callback', [], UrlGeneratorInterface
::ABSOLUTE_URL
), 
 164                                         'scopes' => self
::googleScopes
, 
 165                                         'access_type' => 'offline', 
 166                                         'login_hint' => $user->getMail(), 
 167                                         //XXX: see https://stackoverflow.com/questions/10827920/not-receiving-google-oauth-refresh-token 
 168                                         #'approval_prompt' => 'force' 
 169                                         'prompt' => 'consent' 
 174                         if (!($googleTokens = $user->getGoogleTokens())->isEmpty()) { 
 175                                 //Iterate on each google token 
 176                                 //XXX: either we finish with a valid token set or a logic exception after token removal 
 177                                 foreach($googleTokens as $googleToken) { 
 178                                         //Clear client cache before changing access token 
 179                                         //TODO: set a per token cache ? 
 180                                         $googleClient->getCache()->clear(); 
 183                                         $googleClient->setAccessToken( 
 185                                                         'access_token' => $googleToken->getAccess(), 
 186                                                         'refresh_token' => $googleToken->getRefresh(), 
 187                                                         'created' => $googleToken->getCreated()->getTimestamp(), 
 188                                                         'expires_in' => $googleToken->getExpired()->getTimestamp() - (new \
DateTime('now'))->getTimestamp(), 
 193                                         if ($googleClient->isAccessTokenExpired()) { 
 195                                                 if (($refresh = $googleClient->getRefreshToken()) && ($token = $googleClient->fetchAccessTokenWithRefreshToken($refresh)) && empty($token['error'])) { 
 197                                                         $googleToken->setAccess($token['access_token']); 
 200                                                         $googleToken->setExpired(new \
DateTime('+'.$token['expires_in'].' second')); 
 203                                                         $googleToken->setRefresh($token['refresh_token']); 
 205                                                         //Queue google token save 
 206                                                         $this->manager
->persist($googleToken); 
 208                                                         //Flush to get the ids 
 209                                                         $this->manager
->flush(); 
 212                                                         //Add error in flash message 
 215                                                                 $this->translator
->trans( 
 216                                                                         empty($token['error'])?'Unable to refresh token':'Unable to refresh token: %error%', 
 217                                                                         empty($token['error'])?[]:['%error%' => str_replace('_', ' ', $token['error'])] 
 222                                                         $this->manager
->remove($googleToken); 
 225                                                         $this->manager
->flush(); 
 232                                         //XXX: TODO: remove DEBUG 
 233                                         #$this->cache->delete('user.edit.calendar.'.$this->slugger->short($googleToken->getMail())); 
 236                                         $calendars = $this->cache
->get( 
 237                                                 //Set key to user.edit.$mail 
 238                                                 ($calendarKey = 'user.edit.calendar.'.($googleShortMail = $this->slugger
->short($googleMail = $googleToken->getMail()))), 
 239                                                 //Fetch mail calendar list 
 240                                                 function (ItemInterface 
$item) use ($googleClient): array { 
 242                                                         $item->expiresAfter(3600); 
 244                                                         //Get google calendar service 
 245                                                         $service = new \Google\Service\
Calendar($googleClient); 
 258                                                                 //Iterate until next page token is null 
 260                                                                         //Get token calendar list 
 261                                                                         //XXX: require permission to read and write events 
 262                                                                         $calendarList = $service->calendarList
->listCalendarList(['pageToken' => $pageToken, 'minAccessRole' => 'writer', 'showHidden' => true]); 
 265                                                                         foreach($calendarList->getItems() as $calendarItem) { 
 266                                                                                 //With primary calendar 
 267                                                                                 if ($calendarItem->getPrimary()) { 
 268                                                                                         //Add primary calendar 
 269                                                                                         //XXX: use primary as key as described in google api documentation 
 270                                                                                         $calendars = ['primary' => $this->translator
->trans('Primary') /*$calendarItem->getSummary()*/] + 
$calendars; 
 271                                                                                 //With secondary calendar 
 273                                                                                         //Add secondary calendar 
 274                                                                                         //XXX: Append counter to make sure summary is unique for later array_flip call 
 275                                                                                         $calendars +
= [$calendarItem->getId() => $calendarItem->getSummary().' ('.++
$count.')']; 
 278                                                                 } while ($pageToken = $calendarList->getNextPageToken()); 
 280                                                         } catch(\Google\Service\Exception 
$e) { 
 282                                                                 throw new \
LogicException('Calendar list failed', 0, $e); 
 291                                         $formData = ['calendar' => []]; 
 293                                         //With google calendars 
 294                                         if (!($googleCalendars = $googleToken->getGoogleCalendars())->isEmpty()) { 
 295                                                 //Iterate on each google calendars 
 296                                                 foreach($googleCalendars as $googleCalendar) { 
 297                                                         //With existing google calendar 
 298                                                         if (isset($calendars[$googleCalendar->getMail()])) { 
 299                                                                 //Add google calendar to form data 
 300                                                                 $formData['calendar'][] = $googleCalendar->getMail(); 
 302                                                                 //Remove google calendar from database 
 303                                                                 $this->manager
->remove($googleCalendar); 
 305                                                                 //Flush to persist ids 
 306                                                                 $this->manager
->flush(); 
 311                                         //XXX: TODO: remove DEBUG 
 312                                         #header('Content-Type: text/plain'); 
 314                                         //TODO: add feature to filter synchronized data (OrganizerId/DanceId) 
 315                                         //TODO: add feature for alerts (-30min/-1h) ? 
 316                                         //[Direct link to calendar ?][Direct link to calendar settings ?][Alerts][Remove] 
 318                                         //Create the CalendarType form and give the proper parameters 
 319                                         $form = $this->factory
->createNamed('calendar_'.$googleShortMail, 'Rapsys\AirBundle\Form\CalendarType', $formData, [ 
 320                                                 //Set action to register route name and context 
 321                                                 'action' => $this->generateUrl($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $hash]+
$this->config
['route']['edit']['context']), 
 322                                                 //Set calendar choices 
 323                                                 //XXX: unique calendar summary required by choice widget is guaranteed by appending ' (x)' to secondary calendars earlier 
 324                                                 'calendar_choices' => array_flip($calendars), 
 330                                         if ($request->isMethod('POST')) { 
 331                                                 //Refill the fields in case the form is not valid. 
 332                                                 $form->handleRequest($request); 
 334                                                 //With reset submitted and valid 
 335                                                 if ($form->isSubmitted() && $form->isValid()) { 
 337                                                         $data = $form->getData(); 
 340                                                         if (($clicked = $form->getClickedButton()->getName()) == 'refresh') { 
 341                                                                 //Remove calendar key 
 342                                                                 $this->cache
->delete($calendarKey); 
 345                                                                 $this->addFlash('notice', $this->translator
->trans('Account %mail% calendars updated', ['%mail%' => $googleMail])); 
 347                                                         } elseif ($clicked == 'add') { 
 348                                                                 //Get google calendar service 
 349                                                                 $service = new \Google\Service\
Calendar($googleClient); 
 353                                                                         //Instantiate calendar 
 354                                                                         $calendar = new \Google\Service\Calendar\
Calendar( 
 356                                                                                         'summary' => $this->translator
->trans($this->config
['context']['site']['title']), 
 357                                                                                         'timeZone' => date_default_timezone_get() 
 362                                                                         $service->calendars
->insert($calendar); 
 364                                                                 } catch(\Google\Service\Exception 
$e) { 
 366                                                                         throw new \
LogicException('Calendar insert failed', 0, $e); 
 369                                                                 //Remove calendar key 
 370                                                                 $this->cache
->delete($calendarKey); 
 373                                                                 $this->addFlash('notice', $this->translator
->trans('Account %mail% calendar added', ['%mail%' => $googleMail])); 
 375                                                         } elseif ($clicked == 'delete') { 
 376                                                                 //Get google calendar service 
 377                                                                 $service = new \Google\Service\
Calendar($googleClient); 
 382                                                                         $siteTitle = $this->translator
->trans($this->config
['context']['site']['title']); 
 384                                                                         //Iterate on calendars 
 385                                                                         foreach($calendars as $calendarId => $calendarSummary) { 
 386                                                                                 //With calendar matching site title 
 387                                                                                 if (substr($calendarSummary, 0, strlen($siteTitle)) == $siteTitle) { 
 388                                                                                         //Delete the calendar 
 389                                                                                         $service->calendars
->delete($calendarId); 
 393                                                                 } catch(\Google\Service\Exception 
$e) { 
 395                                                                         throw new \
LogicException('Calendar delete failed', 0, $e); 
 398                                                                 //Remove calendar key 
 399                                                                 $this->cache
->delete($calendarKey); 
 402                                                                 $this->addFlash('notice', $this->translator
->trans('Account %mail% calendars deleted', ['%mail%' => $googleMail])); 
 404                                                         } elseif ($clicked == 'unlink') { 
 405                                                                 //Iterate on each google calendars 
 406                                                                 foreach($googleCalendars as $googleCalendar) { 
 407                                                                         //Remove google calendar from database 
 408                                                                         $this->manager
->remove($googleCalendar); 
 411                                                                 //Remove google token from database 
 412                                                                 $this->manager
->remove($googleToken); 
 415                                                                 $this->manager
->flush(); 
 417                                                                 //Revoke access token 
 418                                                                 $googleClient->revokeToken($googleToken->getAccess()); 
 421                                                                 if ($refresh = $googleToken->getRefresh()) { 
 422                                                                         //Revoke refresh token 
 423                                                                         $googleClient->revokeToken($googleToken->getRefresh()); 
 426                                                                 //Remove calendar key 
 427                                                                 $this->cache
->delete($calendarKey); 
 430                                                                 $this->addFlash('notice', $this->translator
->trans('Account %mail% calendars unlinked', ['%mail%' => $googleMail])); 
 433                                                                 //Flipped calendar data 
 434                                                                 $dataCalendarFlip = array_flip($data['calendar']); 
 436                                                                 //Iterate on each google calendars 
 437                                                                 foreach($googleCalendars as $googleCalendar) { 
 438                                                                         //Without calendar in flipped data 
 439                                                                         if (!isset($dataCalendarFlip[$googleCalendarMail = $googleCalendar->getMail()])) { 
 440                                                                                 //Remove google calendar from database 
 441                                                                                 $this->manager
->remove($googleCalendar); 
 442                                                                         //With calendar in flipped data 
 444                                                                                 //Remove google calendar from calendar data 
 445                                                                                 unset($data['calendar'][$dataCalendarFlip[$googleCalendarMail]]); 
 449                                                                 //Iterate on remaining calendar data 
 450                                                                 foreach($data['calendar'] as $googleCalendarMail) { 
 451                                                                         //Create new google calendar 
 452                                                                         //XXX: remove trailing ' (x)' from summary 
 453                                                                         $googleCalendar = new GoogleCalendar($googleToken, $googleCalendarMail, preg_replace('/ \([0-9]\)$/', '', $calendars[$googleCalendarMail])); 
 455                                                                         //Queue google calendar save 
 456                                                                         $this->manager
->persist($googleCalendar); 
 459                                                                 //Flush to persist ids 
 460                                                                 $this->manager
->flush(); 
 463                                                                 $this->addFlash('notice', $this->translator
->trans('Account %mail% calendars updated', ['%mail%' => $googleMail])); 
 466                                                         //Redirect to cleanup the form 
 467                                                         return $this->redirectToRoute($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $hash]+
$this->config
['route']['edit']['context']); 
 472                                         $this->config
['edit']['view']['context']['calendar']['form'][$googleToken->getMail()] = $form->createView(); 
 476                         //Add google calendar auth url 
 477                         $this->config
['edit']['view']['context']['calendar']['link'] = $googleClient->createAuthUrl(); 
 481                 if ($request->isMethod('POST')) { 
 482                         //Refill the fields in case the form is not valid. 
 483                         $edit->handleRequest($request); 
 485                         //With edit submitted and valid 
 486                         if ($edit->isSubmitted() && $edit->isValid()) { 
 488                                 $data = $edit->getData(); 
 491                                 $this->manager
->persist($data); 
 493                                 //Try saving in database 
 495                                         //Flush to get the ids 
 496                                         $this->manager
->flush(); 
 499                                         //XXX: get mail from data as it may change 
 500                                         $this->addFlash('notice', $this->translator
->trans('Account %mail% updated', ['%mail%' => $mail = $data->getMail()])); 
 502                                         //Redirect to cleanup the form 
 503                                         return $this->redirectToRoute($this->config
['route']['edit']['name'], ['mail' => $smail = $this->slugger
->short($mail), 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']); 
 504                                 //Catch double slug or mail 
 505                                 } catch (UniqueConstraintViolationException 
$e) { 
 506                                         //Add error message mail already exists 
 507                                         $this->addFlash('error', $this->translator
->trans('Account %mail% already exists', ['%mail%' => $data->getMail()])); 
 511                 //XXX: prefer a reset on login to force user unspam action 
 512                 } elseif (!$this->checker
->isGranted('ROLE_ADMIN')) { 
 514                         $this->addFlash('notice', $this->translator
->trans('To change your password login with your mail and any password then follow the procedure')); 
 518                 return $this->render( 
 520                         $this->config
['edit']['view']['name'], 
 522                         ['edit' => $edit->createView(), 'sent' => $request->query
->get('sent', 0)]+
$this->config
['edit']['view']['context'] 
 527          * Handle google callback 
 529          * @param Request $request The request 
 530          * @return Response The response 
 532         public function googleCallback(Request 
$request): Response 
{ 
 534                 if (empty($code = $request->query
->get('code', ''))) { 
 535                         throw new \
InvalidArgumentException('Query parameter code is empty'); 
 539                 if (empty($user = $this->getUser())) { 
 540                         throw new \
LogicException('User is empty'); 
 544                 $googleClient = new \Google\
Client( 
 546                                 'application_name' => $request->server
->get('GOOGLE_PROJECT'), 
 547                                 'client_id' => $request->server
->get('GOOGLE_CLIENT'), 
 548                                 'client_secret' => $request->server
->get('GOOGLE_SECRET'), 
 549                                 'redirect_uri' => $this->generateUrl('rapsysair_google_callback', [], UrlGeneratorInterface
::ABSOLUTE_URL
), 
 550                                 'scopes' => self
::googleScopes
, 
 551                                 'access_type' => 'offline', 
 552                                 'login_hint' => $user->getMail(), 
 553                                 #'approval_prompt' => 'force' 
 554                                 'prompt' => 'consent' 
 558                 //Protect to extract failure 
 560                         //Authenticate with code 
 561                         if (!empty($token = $googleClient->authenticate($code))) { 
 563                                 if (!empty($token['error'])) { 
 564                                         throw new \
LogicException('Client authenticate failed: '.str_replace('_', ' ', $token['error'])); 
 565                                 //Without refresh token 
 566                                 } elseif (empty($token['refresh_token'])) { 
 567                                         throw new \
LogicException('Refresh token is empty'); 
 569                                 } elseif (empty($token['expires_in'])) { 
 570                                         throw new \
LogicException('Expires in is empty'); 
 572                                 } elseif (empty($token['scope'])) { 
 573                                         throw new \
LogicException('Scope in is empty'); 
 574                                 //Without valid scope 
 575                                 } elseif (array_intersect(self
::googleScopes
, explode(' ', $token['scope'])) != self
::googleScopes
) { 
 576                                         throw new \
LogicException('Scope in is not valid'); 
 580                                 $oauth2 = new \Google\Service\
Oauth2($googleClient); 
 582                                 //Protect user info get call 
 585                                         $userInfo = $oauth2->userinfo
->get(); 
 587                                 } catch(\Google\Service\Exception 
$e) { 
 589                                         throw new \
LogicException('Userinfo get failed', 0, $e); 
 592                                 //With existing token 
 594                                         //If available retrieve google token with matching mail 
 595                                         $googleToken = array_reduce( 
 596                                                 $user->getGoogleTokens()->getValues(), 
 597                                                 function ($c, $i) use ($userInfo) { 
 598                                                         if ($i->getMail() == $userInfo['email']) { 
 606                                         //XXX: TODO: should already be set and not change, remove ? 
 607                                         //XXX: TODO: store picture as well ? 
 608                                         $googleToken->setMail($userInfo['email']); 
 611                                         $googleToken->setAccess($token['access_token']); 
 614                                         $googleToken->setExpired(new \
DateTime('+'.$token['expires_in'].' second')); 
 617                                         $googleToken->setRefresh($token['refresh_token']); 
 620                                         //XXX: TODO: store picture as well ? 
 621                                         $googleToken = new GoogleToken($user, $userInfo['email'], $token['access_token'], new \
DateTime('+'.$token['expires_in'].' second'), $token['refresh_token']); 
 624                                 //Queue google token save 
 625                                 $this->manager
->persist($googleToken); 
 627                                 //Flush to get the ids 
 628                                 $this->manager
->flush(); 
 631                                 $this->addFlash('notice', $this->translator
->trans('Account %mail% google token updated', ['%mail%' => $user->getMail()])); 
 632                         //With failed authenticate 
 634                                 throw new \
LogicException('Client authenticate failed'); 
 637                 } catch(\Exception 
$e) { 
 639                         $this->addFlash('error', $this->translator
->trans('Account %mail% google token rejected: %error%', ['%mail%' => $user->getMail(), '%error%' => $e->getMessage()])); 
 643                 return $this->redirectToRoute('rapsysuser_edit', ['mail' => $short = $this->slugger
->short($user->getMail()), 'hash' => $this->slugger
->hash($short)]);