src/Controller/Controller.php line 91

  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Controller;
  4. use App\Dto\WlSettingsDto;
  5. use App\Enum\MirrorStatusEnum;
  6. use App\Exception\RotatorNotFoundException;
  7. use App\Service\MirrorService;
  8. use App\Service\PlayerTokenService;
  9. use App\Service\RotatorService;
  10. use App\Service\WlRotatorDomainService;
  11. use App\Service\WlSettingsService;
  12. use DateTimeImmutable;
  13. use Doctrine\DBAL\Exception as DbalException;
  14. use InfluxDB\Database\Exception as InfluxDatabaseException;
  15. use InfluxDB\Exception as InfluxException;
  16. use JsonException;
  17. use MarfaTech\Bundle\MetricBundle\Client\InfluxDb\InfluxDbClient;
  18. use Psr\Log\LoggerAwareInterface;
  19. use Psr\Log\LoggerAwareTrait;
  20. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  21. use Symfony\Component\HttpFoundation\Cookie;
  22. use Symfony\Component\HttpFoundation\JsonResponse;
  23. use Symfony\Component\HttpFoundation\RedirectResponse;
  24. use Symfony\Component\HttpFoundation\Request;
  25. use Symfony\Component\HttpFoundation\Response;
  26. use Symfony\Component\OptionsResolver\Exception\ExceptionInterface;
  27. use Symfony\Component\Routing\Annotation\Route;
  28. use Throwable;
  29. use App\Message\ApiRequestLog;
  30. use Symfony\Component\Messenger\MessageBusInterface;
  31. use function array_merge;
  32. use function base64_decode;
  33. use function http_build_query;
  34. use function is_string;
  35. use function json_decode;
  36. use function json_encode;
  37. use function sha1;
  38. use function sprintf;
  39. use function str_contains;
  40. use function time;
  41. use function urldecode;
  42. use const JSON_THROW_ON_ERROR;
  43. class Controller extends AbstractController implements LoggerAwareInterface
  44. {
  45.     use LoggerAwareTrait;
  46.     private const COOKIE_NAME 'rotatorId';
  47.     private const ROTATOR_TTL 30;
  48.     private const OLD_COOKIE_NAME '__router';
  49.     private const COUNTRY_CODE_HEADER_NAME 'X-GeoIP-Country-Code';
  50.     private const BACK_URL 'backurl';
  51.     private const RETURN_URL 'returnUrl';
  52.     private const MONITORING_USER_AGENT_LIST = [
  53.         'Better Uptime Bot',
  54.         'Zabbix',
  55.         'sentry',
  56.         'GuzzleHttp',
  57.         'Google-PageRenderer',
  58.     ];
  59.     public function __construct(
  60.         private readonly MirrorService $mirrorService,
  61.         private readonly RotatorService $rotatorService,
  62.         private readonly PlayerTokenService $playerTokenService,
  63.         private readonly WlSettingsService $wlSettingsService,
  64.         private readonly WlRotatorDomainService $wlRotatorDomainService,
  65.         private readonly InfluxDbClient $influxDbClient,
  66.         private readonly MessageBusInterface $bus,
  67.         private readonly ControllerV2 $controllerV2,
  68.         private readonly ?string $whitelabel,
  69.     ) {
  70.     }
  71.     /**
  72.      * @Route("{path}", requirements={"path"=".*"}, methods={"GET"}, priority=-2)
  73.      *
  74.      * @throws DbalException
  75.      * @throws ExceptionInterface
  76.      * @throws InfluxDatabaseException
  77.      * @throws InfluxException
  78.      * @throws JsonException
  79.      */
  80.     public function index(Request $request, ?string $path null): Response
  81.     {
  82.         if ($this->whitelabel === null) {
  83.             return new Response('Unknown whitelabel. Please, set <b>HTTP_X_WHITELABEL</b> fastcgi_param');
  84.         }
  85.         $wlSlug $this->wlSettingsService->getRealWlSlug($this->whitelabel);
  86.         if ($wlSlug === 'real-betonred') { // TODO need to hardcode to use v2 for brand
  87.             return $this->controllerV2->indexV2($request$path);
  88.         }
  89.         $country $request->server->get(self::COUNTRY_CODE_HEADER_NAME);
  90.         $this->sendMetric('hit'$country);
  91.         $rotatorId $request->cookies->get(self::COOKIE_NAME);
  92.         $rotatorIdEncoded $request->query->get('_ri');
  93.         $rdParam $request->query->get('_rd');
  94.         if (is_string($rotatorIdEncoded) && !empty($rotatorIdEncoded)) {
  95.             $rotatorId base64_decode(urldecode($rotatorIdEncoded));
  96.         }
  97.         $rotatorRouter $request->getHost();
  98.         $rotatorRouterEncoded $request->query->get('_rr');
  99.         if ($rotatorRouterEncoded) {
  100.             $rotatorRouter base64_decode(urldecode($rotatorRouterEncoded));
  101.         }
  102.         if (is_string($rdParam)) {
  103.             try {
  104.                 $rdDecoded json_decode(base64_decode(urldecode($rdParam)), true512JSON_THROW_ON_ERROR);
  105.                 if (!empty($rdDecoded['_ri'])) {
  106.                     $rotatorId $rdDecoded['_ri'];
  107.                 }
  108.                 if (!empty($rdDecoded['_rr'])) {
  109.                     $rotatorRouter $rdDecoded['_rr'];
  110.                 }
  111.             } catch (Throwable $e) {
  112.                 $this->logger->error(
  113.                     'bad rdParam',
  114.                     [
  115.                         'rdParam' => $rdParam,
  116.                         'request' => $request,
  117.                         'exception' => (string) $e,
  118.                     ]
  119.                 );
  120.             }
  121.         }
  122.         $oldHost $request->cookies->get(self::OLD_COOKIE_NAME);
  123.         $oldRotatorCallback null;
  124.         $oldRotatorId null;
  125.         $rotator null;
  126.         $requestRotatorId $request->query->get(self::COOKIE_NAME);
  127.         $from $request->query->get('from');
  128.         $ts = (int) $request->query->get('ts');
  129.         $uuid $request->query->get('uuid');
  130.         $sign $request->query->get('sign');
  131.         $needTsUuidSignCheck false;
  132.         $query $request->query->all();
  133.         if ($requestRotatorId !== null) {
  134.             $rotatorId $requestRotatorId;
  135.             if ($from === null) {
  136.                 $needTsUuidSignCheck true;
  137.             }
  138.         }
  139.         $userAgent substr((string) $request->server->get('HTTP_USER_AGENT'), 0255);
  140.         foreach (self::MONITORING_USER_AGENT_LIST as $monitoringUserAgent) {
  141.             if (str_contains($userAgent$monitoringUserAgent)) {
  142.                 $rotatorId 'monitoring';
  143.                 $country 'DE';
  144.             }
  145.         }
  146.         if ($rotatorId) {
  147.             $rotator $this->rotatorService->findBySticky($rotatorId);
  148.             if ($rotator === null) {
  149.                 $rotator $this->rotatorService->findByCountry($rotatorId$country);
  150.             }
  151.             if ($rotator === null) {
  152.                 $rotator $this->rotatorService->findByOldRotatorCountry($rotatorId$country);
  153.             }
  154.             if ($rotator === null) {
  155.                 $oldRotator $this->rotatorService->find($rotatorId);
  156.                 if ($oldRotator !== null) {
  157.                     $oldRotatorCallback $oldRotator['callback'] ?? null;
  158.                     $oldRotatorId $rotatorId;
  159.                 }
  160.             }
  161.             $rotatorId $rotator['rotatorId'] ?? null;
  162.         }
  163.         //        if ($rotatorId === null) {
  164.         if (!empty($query[self::RETURN_URL])) { // logic for sticky host from returnUrl
  165.             $host false;
  166.             if (is_string($query[self::RETURN_URL])) {
  167.                 $host parse_url($query[self::RETURN_URL], PHP_URL_HOST);
  168.             }
  169.             if ($host !== false && $host !== null) {
  170.                 $mirror $this->mirrorService->findStickyHost($this->whitelabel$host);
  171.                 $this->logger->info(
  172.                     'mirror from redirect url',
  173.                     [
  174.                         'mirror' => is_array($mirror) ? implode(', '$mirror) : ($mirror ?? 'not found'),
  175.                         'rotatorId' => $rotatorId,
  176.                         'redirectUrl' => $query[self::RETURN_URL],
  177.                     ]
  178.                 );
  179.             }
  180.             if ($host === null) {
  181.                 $mirror $this->mirrorService->findStickyHost($this->whitelabel$query[self::RETURN_URL]);
  182.             }
  183.             if (empty($mirror)) { // redirect to returnUrl for non sticky
  184.                 if ($host !== false && $host !== null) {
  185.                     $mirror $this->mirrorService->findByWlCountryHost($this->whitelabel$country$host);
  186.                 }
  187.                 if ($host === null) {
  188.                     $mirror $this->mirrorService->findByWlCountryHost($this->whitelabel$country$query[self::RETURN_URL]);
  189.                 }
  190.                 if ($mirror === null && $host !== false && $host !== null) {
  191.                     $mirror $this->mirrorService->findByHost($host);
  192.                 }
  193.             }
  194.             // unset($query[self::RETURN_URL]);
  195.         }
  196.         if (empty($mirror) && $oldHost) {
  197.             $mirror $this->mirrorService->findByHost($oldHost);
  198.         }
  199.         if (empty($mirror)) {
  200.             $mirror $this->mirrorService->getRandom($this->whitelabel$country);
  201.         }
  202.         $rotatorId $rotatorId
  203.             ?? $this->rotatorService->create($mirror['id'], $country$userAgent$oldRotatorId);
  204.         //        }
  205.         if (empty($rotator)) {
  206.             $rotator $this->rotatorService->findByCountry($rotatorId$country);
  207.             if ($rotator !== null && $oldRotatorCallback !== null) {
  208.                 $this->rotatorService->updateRotatorCallback($rotator$oldRotatorCallback);
  209.                 $rotator['callback'] = $oldRotatorCallback;
  210.                 $this->sendMetric('old_rotator_callback'$country);
  211.             }
  212.         }
  213.         $mirrorStatus $rotator['mirrorStatus'] ?? MirrorStatusEnum::BLOCKED;
  214.         if ($mirrorStatus !== MirrorStatusEnum::ACTIVE) {
  215.             $mirror $this->mirrorService->getRandom($this->whitelabel$country);
  216.             $this->rotatorService->update($rotator['id'], [
  217.                 'rotatorId' => $rotatorId,
  218.                 'country' => $country,
  219.                 'mirrorId' => $mirror['id'],
  220.             ]);
  221.             $rotator['mirrorHost'] = $mirror['host'];
  222.         }
  223.         // google auth state after redirect
  224.         $state $request->query->get('state');
  225.         if (!empty($state)) {
  226.             try {
  227.                 $state json_decode(urldecode($state), true512JSON_THROW_ON_ERROR);
  228.                 if (!empty($state['backurl'])) {
  229.                     $query[self::BACK_URL] = urldecode($state['backurl']);
  230.                 }
  231.             } catch (Throwable) {
  232.             }
  233.         }
  234.         $wlSettingsDto $this->wlSettingsService->getWlSettings($this->whitelabel);
  235.         if ($wlSettingsDto === null) {
  236.             return new JsonResponse(
  237.                 [
  238.                     'message' => 'WL settings not found',
  239.                 ],
  240.                 Response::HTTP_NOT_FOUND,
  241.             );
  242.         }
  243.         try {
  244.             $accessToken $this->getAccessToken(
  245.                 $rotator,
  246.                 $query,
  247.                 $wlSettingsDto,
  248.                 $needTsUuidSignCheck,
  249.                 $ts,
  250.                 $uuid,
  251.                 $sign,
  252.             );
  253.         } catch (Throwable) {
  254.             $accessToken null;
  255.         }
  256.         $isHostRouterEqual $request->getHost() === $wlSettingsDto->getDefaultRotatorDomain();
  257.         if ($accessToken !== null) {
  258.             $query['accessToken'] = $accessToken;
  259.             if ($isHostRouterEqual === true) {
  260.                 $this->sendMetric('access_token'$country$rotator['mirrorHost']);
  261.             }
  262.         }
  263.         if (!empty($query['rotatorToken'])) {
  264.             unset($query['rotatorToken']);
  265.         }
  266.         $redirect '/' $path;
  267.         $redirectHost $rotator['mirrorHost'];
  268.         if ($isHostRouterEqual === false) {
  269.             $redirectHost $wlSettingsDto->getDefaultRotatorDomain();
  270.             $query['rotatorId'] = $rotatorId;
  271.             $this->sendMetric('default_router_redirect'$country);
  272.         }
  273.         if (!empty($query[self::BACK_URL])) {
  274.             $redirect $query[self::BACK_URL];
  275.             unset($query[self::BACK_URL]);
  276.         } else if (!empty($query[self::RETURN_URL])) {
  277.             $pathFromReturnUrl parse_url($query[self::RETURN_URL], PHP_URL_PATH);
  278.             $queryString parse_url($query[self::RETURN_URL], PHP_URL_QUERY);
  279.             $redirect $pathFromReturnUrl . ($queryString '?' $queryString '');
  280.             if (!str_starts_with($redirect'/')) {
  281.                 $redirect '/' $redirect;
  282.             }
  283.             unset($query[self::RETURN_URL]);
  284.         }
  285.         if ($redirect[0] === '/') {
  286.             $redirect 'https://' trim($redirectHost) . $redirect;
  287.         }
  288.         $query['_rd'] = urlencode(base64_encode(json_encode([
  289.             'rotatorId' => $rotatorId,
  290.             'rotatorRouter' => $rotatorRouter,
  291.         ], JSON_THROW_ON_ERROR)));
  292.         $redirect .= sprintf(
  293.             '%s%s',
  294.             str_contains($redirect'?') ? '&' '?',
  295.             http_build_query($query)
  296.         );
  297.         $response = new RedirectResponse($redirect);
  298.         $response->headers
  299.             ->setCookie(
  300.                 new Cookie(
  301.                     self::COOKIE_NAME,
  302.                     $rotatorId,
  303.                     time() + (365 24 60 60)
  304.                 )
  305.             )
  306.         ;
  307.         $this->sendMetric('redirect'$country$rotator['mirrorHost']);
  308.         $redirectData = [
  309.             'ip' => $request->getClientIp(),
  310.             'rotatorId' => $rotatorId,
  311.             'mirrorHost' => $redirectHost,
  312.             'rotatorRouter' => $request->getHost(),
  313.         ];
  314.         $this->logger->info('Redirect'$redirectData);
  315.         $this->bus->dispatch(new ApiRequestLog(
  316.             $redirectData['rotatorId'],
  317.             $redirectData['ip'],
  318.             $redirectData['mirrorHost'],
  319.             $redirectData['rotatorRouter'],
  320.             $country,
  321.             (string) $request,
  322.             (string) $response,
  323.             time() - $request->server->get('REQUEST_TIME'),
  324.             date('Y-m-d H:i:s'$request->server->get('REQUEST_TIME')),
  325.             $response->getStatusCode(),
  326.         ));
  327.         return $response;
  328.     }
  329.     /**
  330.      * @Route("/callback", methods={"POST"})
  331.      *
  332.      * @param Request $request
  333.      * @return Response
  334.      * @throws DbalException
  335.      * @throws InfluxDatabaseException
  336.      * @throws InfluxException
  337.      * @throws JsonException
  338.      */
  339.     public function callbackBulk(Request $request): Response
  340.     {
  341.         try {
  342.             $body json_decode($request->getContent(), true512JSON_THROW_ON_ERROR);
  343.         } catch (JsonException) {
  344.             $body = [];
  345.         }
  346.         foreach ($body as $item) {
  347.             try {
  348.                 $this->handleCallback($item['rotatorId'], $item['callbackData']);
  349.             } catch (RotatorNotFoundException) {
  350.                 continue;
  351.             }
  352.         }
  353.         return new Response();
  354.     }
  355.     /**
  356.      * @Route("/callback/{rotatorId}", methods={"POST"})
  357.      *
  358.      * @throws DbalException
  359.      * @throws InfluxDatabaseException
  360.      * @throws InfluxException
  361.      * @throws JsonException
  362.      */
  363.     public function callback(Request $requeststring $rotatorId): Response
  364.     {
  365.         try {
  366.             $body json_decode($request->getContent(), true512JSON_THROW_ON_ERROR);
  367.         } catch (JsonException) {
  368.             $body = [];
  369.         }
  370.         try {
  371.             $this->handleCallback($rotatorId$body);
  372.         } catch (RotatorNotFoundException) {
  373.         }
  374.         return new Response();
  375.     }
  376.     /**
  377.      * @Route("/api/generateLinkToken", methods={"POST"})
  378.      */
  379.     public function generateLinkToken(Request $request): Response
  380.     {
  381.         try {
  382.             $body json_decode($request->getContent(), true512JSON_THROW_ON_ERROR);
  383.             $playerId $body['playerId'] ?? null;
  384.             $source $body['source'] ?? null;
  385.             $tokenCreatedAt = new DateTimeImmutable();
  386.             if ($playerId === null) {
  387.                 return new JsonResponse(
  388.                     [
  389.                         'message' => 'Player id is required',
  390.                     ],
  391.                     Response::HTTP_UNPROCESSABLE_ENTITY,
  392.                 );
  393.             }
  394.             if (!is_string($playerId)) {
  395.                 return new JsonResponse(
  396.                     [
  397.                         'message' => 'Player id must be string',
  398.                     ],
  399.                     Response::HTTP_UNPROCESSABLE_ENTITY,
  400.                 );
  401.             }
  402.             $wlSettingsDto $this->wlSettingsService->getWlSettings($this->whitelabel);
  403.             if ($wlSettingsDto === null) {
  404.                 return new JsonResponse(
  405.                     [
  406.                         'message' => 'WL settings not found',
  407.                     ],
  408.                     Response::HTTP_NOT_FOUND,
  409.                 );
  410.             }
  411.             $token sha1(
  412.                 sprintf(
  413.                     '%s%s%s',
  414.                     $playerId,
  415.                     $tokenCreatedAt->getTimestamp(),
  416.                     $wlSettingsDto->getSalt()
  417.                 )
  418.             );
  419.             $expiredAt = (new DateTimeImmutable())
  420.                 ->modify('+' $wlSettingsDto->getTokenLifetime() . ' seconds')
  421.             ;
  422.             $this->playerTokenService->createNewPlayerToken($playerId$this->whitelabel$token$expiredAt);
  423.             $wlRotatorDomainDto null;
  424.             if ($source !== null) {
  425.                 $wlRotatorDomainDto $this->wlRotatorDomainService->getWlRotatorDomainDtoBySource(
  426.                     $this->whitelabel,
  427.                     $source,
  428.                 );
  429.             }
  430.             if ($wlRotatorDomainDto === null) {
  431.                 $wlRotatorDomainDto $this->wlRotatorDomainService->getRandomWlRotatorDomainDto($this->whitelabel);
  432.                 if ($wlRotatorDomainDto === null) {
  433.                     return new JsonResponse(
  434.                         [
  435.                             'message' => 'WL rotator domain not found',
  436.                         ],
  437.                         Response::HTTP_NOT_FOUND,
  438.                     );
  439.                 }
  440.             }
  441.             $data = [
  442.                 'link' => sprintf('%s/?rotatorToken=%s'$wlRotatorDomainDto->getDomain(), $token),
  443.                 'endTime' => $expiredAt->getTimestamp(),
  444.                 'token' => $token,
  445.             ];
  446.             return new JsonResponse($dataResponse::HTTP_OK);
  447.         } catch (Throwable $e) {
  448.             return new JsonResponse($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
  449.         }
  450.     }
  451.     /**
  452.      * @throws InfluxException
  453.      * @throws InfluxDatabaseException
  454.      */
  455.     private function sendMetric(string $keystring $country, ?string $mirror null): void
  456.     {
  457.         $fields = [
  458.             'country' => $country,
  459.             'rotator' => $this->whitelabel,
  460.         ];
  461.         if ($mirror) {
  462.             $fields['mirror'] = $mirror;
  463.         }
  464.         $this->influxDbClient->sendBulk($key$fields$fields);
  465.     }
  466.     /**
  467.      * @throws InfluxException
  468.      * @throws JsonException
  469.      * @throws DbalException
  470.      * @throws InfluxDatabaseException
  471.      * @throws RotatorNotFoundException
  472.      */
  473.     private function handleCallback(string $rotatorId, array $callbackData): void
  474.     {
  475.         $rotator $this->rotatorService->find($rotatorId);
  476.         if ($rotator === null) {
  477.             throw new RotatorNotFoundException();
  478.         }
  479.         $isPlayerIdMismatch = !empty($callbackData['playerId'])
  480.             && !empty($rotator['playerId'])
  481.             && $callbackData['playerId'] !== $rotator['playerId']
  482.         ;
  483.         if ($isPlayerIdMismatch) {
  484.             return;
  485.         }
  486.         $isPlayerUuidMismatch = !empty($callbackData['playerUuid'])
  487.             && !empty($rotator['playerUuid'])
  488.             && $callbackData['playerUuid'] !== $rotator['playerUuid']
  489.         ;
  490.         if ($isPlayerUuidMismatch) {
  491.             return;
  492.         }
  493.         $country $rotator['country'] ?? '';
  494.         $this->sendMetric('callback'$country$rotator['mirrorHost']);
  495.         $data = [
  496.             'rotatorId' => $rotator['rotatorId'],
  497.             'country' => $country,
  498.             'callback' => json_encode($callbackDataJSON_THROW_ON_ERROR),
  499.             'callbackAt' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
  500.         ];
  501.         if (empty($rotator['playerId']) && !empty($callbackData['playerId'])) {
  502.             $data['playerId'] = $callbackData['playerId'];
  503.         }
  504.         if (empty($rotator['playerUuid']) && !empty($callbackData['playerUuid'])) {
  505.             $data['playerUuid'] = $callbackData['playerUuid'];
  506.         }
  507.         if (!empty($callbackData['host'])) {
  508.             $mirror $this->mirrorService->findByHost($callbackData['host']);
  509.             if ($mirror !== null && $rotator['mirrorId'] !== $mirror['id']) {
  510.                 $data['mirrorId'] = $mirror['id'];
  511.             }
  512.         }
  513.         $this->rotatorService->update($rotator['id'], $data);
  514.     }
  515.     private function getAccessToken(
  516.         array $rotator,
  517.         $query,
  518.         WlSettingsDto $wlSettingsDto,
  519.         bool $needTsUuidSignCheck,
  520.         ?int $ts,
  521.         ?string $uuid,
  522.         ?string $sign,
  523.     ): ?string {
  524.         if (
  525.             $needTsUuidSignCheck &&
  526.             $this->checkTsUuidSign(
  527.                 $rotator,
  528.                 $wlSettingsDto->getSecret(),
  529.                 $ts,
  530.                 $uuid,
  531.                 $sign,
  532.             ) === false
  533.         ) {
  534.             return null;
  535.         }
  536.         try {
  537.             return $this->rotatorService->getAccessToken($rotator$query$wlSettingsDto);
  538.         } catch (Throwable) {
  539.         }
  540.         return null;
  541.     }
  542.     private function checkTsUuidSign(
  543.         array $rotator,
  544.         ?string $secret,
  545.         ?int $ts,
  546.         ?string $uuid,
  547.         ?string $sign,
  548.     ): bool {
  549.         if (empty($ts) || empty($uuid) || empty($sign)) {
  550.             return false;
  551.         }
  552.         if (time() - floor($ts 1000) > self::ROTATOR_TTL) {
  553.             return false;
  554.         }
  555.         if (empty($secret)) {
  556.             return true;
  557.         }
  558.         if ($sign !== sha1($rotator['rotatorId'] . $ts $uuid $secret)) {
  559.            return false;
  560.         }
  561.         if (!empty($rotator['playerUuid']) && $uuid !== $rotator['playerUuid']) {
  562.             return false;
  563.         }
  564.         return true;
  565.     }
  566. }