src/Controller/Controller.php line 91
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Dto\WlSettingsDto;
use App\Enum\MirrorStatusEnum;
use App\Exception\RotatorNotFoundException;
use App\Service\MirrorService;
use App\Service\PlayerTokenService;
use App\Service\RotatorService;
use App\Service\WlRotatorDomainService;
use App\Service\WlSettingsService;
use DateTimeImmutable;
use Doctrine\DBAL\Exception as DbalException;
use InfluxDB\Database\Exception as InfluxDatabaseException;
use InfluxDB\Exception as InfluxException;
use JsonException;
use MarfaTech\Bundle\MetricBundle\Client\InfluxDb\InfluxDbClient;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\OptionsResolver\Exception\ExceptionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Throwable;
use App\Message\ApiRequestLog;
use Symfony\Component\Messenger\MessageBusInterface;
use function array_merge;
use function base64_decode;
use function http_build_query;
use function is_string;
use function json_decode;
use function json_encode;
use function sha1;
use function sprintf;
use function str_contains;
use function time;
use function urldecode;
use const JSON_THROW_ON_ERROR;
class Controller extends AbstractController implements LoggerAwareInterface
{
use LoggerAwareTrait;
private const COOKIE_NAME = 'rotatorId';
private const ROTATOR_TTL = 30;
private const OLD_COOKIE_NAME = '__router';
private const COUNTRY_CODE_HEADER_NAME = 'X-GeoIP-Country-Code';
private const BACK_URL = 'backurl';
private const RETURN_URL = 'returnUrl';
private const MONITORING_USER_AGENT_LIST = [
'Better Uptime Bot',
'Zabbix',
'sentry',
'GuzzleHttp',
'Google-PageRenderer',
];
public function __construct(
private readonly MirrorService $mirrorService,
private readonly RotatorService $rotatorService,
private readonly PlayerTokenService $playerTokenService,
private readonly WlSettingsService $wlSettingsService,
private readonly WlRotatorDomainService $wlRotatorDomainService,
private readonly InfluxDbClient $influxDbClient,
private readonly MessageBusInterface $bus,
private readonly ControllerV2 $controllerV2,
private readonly ?string $whitelabel,
) {
}
/**
* @Route("{path}", requirements={"path"=".*"}, methods={"GET"}, priority=-2)
*
* @throws DbalException
* @throws ExceptionInterface
* @throws InfluxDatabaseException
* @throws InfluxException
* @throws JsonException
*/
public function index(Request $request, ?string $path = null): Response
{
if ($this->whitelabel === null) {
return new Response('Unknown whitelabel. Please, set <b>HTTP_X_WHITELABEL</b> fastcgi_param');
}
$wlSlug = $this->wlSettingsService->getRealWlSlug($this->whitelabel);
if ($wlSlug === 'real-betonred') { // TODO need to hardcode to use v2 for brand
return $this->controllerV2->indexV2($request, $path);
}
$country = $request->server->get(self::COUNTRY_CODE_HEADER_NAME);
$this->sendMetric('hit', $country);
$rotatorId = $request->cookies->get(self::COOKIE_NAME);
$rotatorIdEncoded = $request->query->get('_ri');
$rdParam = $request->query->get('_rd');
if (is_string($rotatorIdEncoded) && !empty($rotatorIdEncoded)) {
$rotatorId = base64_decode(urldecode($rotatorIdEncoded));
}
$rotatorRouter = $request->getHost();
$rotatorRouterEncoded = $request->query->get('_rr');
if ($rotatorRouterEncoded) {
$rotatorRouter = base64_decode(urldecode($rotatorRouterEncoded));
}
if (is_string($rdParam)) {
try {
$rdDecoded = json_decode(base64_decode(urldecode($rdParam)), true, 512, JSON_THROW_ON_ERROR);
if (!empty($rdDecoded['_ri'])) {
$rotatorId = $rdDecoded['_ri'];
}
if (!empty($rdDecoded['_rr'])) {
$rotatorRouter = $rdDecoded['_rr'];
}
} catch (Throwable $e) {
$this->logger->error(
'bad rdParam',
[
'rdParam' => $rdParam,
'request' => $request,
'exception' => (string) $e,
]
);
}
}
$oldHost = $request->cookies->get(self::OLD_COOKIE_NAME);
$oldRotatorCallback = null;
$oldRotatorId = null;
$rotator = null;
$requestRotatorId = $request->query->get(self::COOKIE_NAME);
$from = $request->query->get('from');
$ts = (int) $request->query->get('ts');
$uuid = $request->query->get('uuid');
$sign = $request->query->get('sign');
$needTsUuidSignCheck = false;
$query = $request->query->all();
if ($requestRotatorId !== null) {
$rotatorId = $requestRotatorId;
if ($from === null) {
$needTsUuidSignCheck = true;
}
}
$userAgent = substr((string) $request->server->get('HTTP_USER_AGENT'), 0, 255);
foreach (self::MONITORING_USER_AGENT_LIST as $monitoringUserAgent) {
if (str_contains($userAgent, $monitoringUserAgent)) {
$rotatorId = 'monitoring';
$country = 'DE';
}
}
if ($rotatorId) {
$rotator = $this->rotatorService->findBySticky($rotatorId);
if ($rotator === null) {
$rotator = $this->rotatorService->findByCountry($rotatorId, $country);
}
if ($rotator === null) {
$rotator = $this->rotatorService->findByOldRotatorCountry($rotatorId, $country);
}
if ($rotator === null) {
$oldRotator = $this->rotatorService->find($rotatorId);
if ($oldRotator !== null) {
$oldRotatorCallback = $oldRotator['callback'] ?? null;
$oldRotatorId = $rotatorId;
}
}
$rotatorId = $rotator['rotatorId'] ?? null;
}
// if ($rotatorId === null) {
if (!empty($query[self::RETURN_URL])) { // logic for sticky host from returnUrl
$host = false;
if (is_string($query[self::RETURN_URL])) {
$host = parse_url($query[self::RETURN_URL], PHP_URL_HOST);
}
if ($host !== false && $host !== null) {
$mirror = $this->mirrorService->findStickyHost($this->whitelabel, $host);
$this->logger->info(
'mirror from redirect url',
[
'mirror' => is_array($mirror) ? implode(', ', $mirror) : ($mirror ?? 'not found'),
'rotatorId' => $rotatorId,
'redirectUrl' => $query[self::RETURN_URL],
]
);
}
if ($host === null) {
$mirror = $this->mirrorService->findStickyHost($this->whitelabel, $query[self::RETURN_URL]);
}
if (empty($mirror)) { // redirect to returnUrl for non sticky
if ($host !== false && $host !== null) {
$mirror = $this->mirrorService->findByWlCountryHost($this->whitelabel, $country, $host);
}
if ($host === null) {
$mirror = $this->mirrorService->findByWlCountryHost($this->whitelabel, $country, $query[self::RETURN_URL]);
}
if ($mirror === null && $host !== false && $host !== null) {
$mirror = $this->mirrorService->findByHost($host);
}
}
// unset($query[self::RETURN_URL]);
}
if (empty($mirror) && $oldHost) {
$mirror = $this->mirrorService->findByHost($oldHost);
}
if (empty($mirror)) {
$mirror = $this->mirrorService->getRandom($this->whitelabel, $country);
}
$rotatorId = $rotatorId
?? $this->rotatorService->create($mirror['id'], $country, $userAgent, $oldRotatorId);
// }
if (empty($rotator)) {
$rotator = $this->rotatorService->findByCountry($rotatorId, $country);
if ($rotator !== null && $oldRotatorCallback !== null) {
$this->rotatorService->updateRotatorCallback($rotator, $oldRotatorCallback);
$rotator['callback'] = $oldRotatorCallback;
$this->sendMetric('old_rotator_callback', $country);
}
}
$mirrorStatus = $rotator['mirrorStatus'] ?? MirrorStatusEnum::BLOCKED;
if ($mirrorStatus !== MirrorStatusEnum::ACTIVE) {
$mirror = $this->mirrorService->getRandom($this->whitelabel, $country);
$this->rotatorService->update($rotator['id'], [
'rotatorId' => $rotatorId,
'country' => $country,
'mirrorId' => $mirror['id'],
]);
$rotator['mirrorHost'] = $mirror['host'];
}
// google auth state after redirect
$state = $request->query->get('state');
if (!empty($state)) {
try {
$state = json_decode(urldecode($state), true, 512, JSON_THROW_ON_ERROR);
if (!empty($state['backurl'])) {
$query[self::BACK_URL] = urldecode($state['backurl']);
}
} catch (Throwable) {
}
}
$wlSettingsDto = $this->wlSettingsService->getWlSettings($this->whitelabel);
if ($wlSettingsDto === null) {
return new JsonResponse(
[
'message' => 'WL settings not found',
],
Response::HTTP_NOT_FOUND,
);
}
try {
$accessToken = $this->getAccessToken(
$rotator,
$query,
$wlSettingsDto,
$needTsUuidSignCheck,
$ts,
$uuid,
$sign,
);
} catch (Throwable) {
$accessToken = null;
}
$isHostRouterEqual = $request->getHost() === $wlSettingsDto->getDefaultRotatorDomain();
if ($accessToken !== null) {
$query['accessToken'] = $accessToken;
if ($isHostRouterEqual === true) {
$this->sendMetric('access_token', $country, $rotator['mirrorHost']);
}
}
if (!empty($query['rotatorToken'])) {
unset($query['rotatorToken']);
}
$redirect = '/' . $path;
$redirectHost = $rotator['mirrorHost'];
if ($isHostRouterEqual === false) {
$redirectHost = $wlSettingsDto->getDefaultRotatorDomain();
$query['rotatorId'] = $rotatorId;
$this->sendMetric('default_router_redirect', $country);
}
if (!empty($query[self::BACK_URL])) {
$redirect = $query[self::BACK_URL];
unset($query[self::BACK_URL]);
} else if (!empty($query[self::RETURN_URL])) {
$pathFromReturnUrl = parse_url($query[self::RETURN_URL], PHP_URL_PATH);
$queryString = parse_url($query[self::RETURN_URL], PHP_URL_QUERY);
$redirect = $pathFromReturnUrl . ($queryString ? '?' . $queryString : '');
if (!str_starts_with($redirect, '/')) {
$redirect = '/' . $redirect;
}
unset($query[self::RETURN_URL]);
}
if ($redirect[0] === '/') {
$redirect = 'https://' . trim($redirectHost) . $redirect;
}
$query['_rd'] = urlencode(base64_encode(json_encode([
'rotatorId' => $rotatorId,
'rotatorRouter' => $rotatorRouter,
], JSON_THROW_ON_ERROR)));
$redirect .= sprintf(
'%s%s',
str_contains($redirect, '?') ? '&' : '?',
http_build_query($query)
);
$response = new RedirectResponse($redirect);
$response->headers
->setCookie(
new Cookie(
self::COOKIE_NAME,
$rotatorId,
time() + (365 * 24 * 60 * 60)
)
)
;
$this->sendMetric('redirect', $country, $rotator['mirrorHost']);
$redirectData = [
'ip' => $request->getClientIp(),
'rotatorId' => $rotatorId,
'mirrorHost' => $redirectHost,
'rotatorRouter' => $request->getHost(),
];
$this->logger->info('Redirect', $redirectData);
$this->bus->dispatch(new ApiRequestLog(
$redirectData['rotatorId'],
$redirectData['ip'],
$redirectData['mirrorHost'],
$redirectData['rotatorRouter'],
$country,
(string) $request,
(string) $response,
time() - $request->server->get('REQUEST_TIME'),
date('Y-m-d H:i:s', $request->server->get('REQUEST_TIME')),
$response->getStatusCode(),
));
return $response;
}
/**
* @Route("/callback", methods={"POST"})
*
* @param Request $request
* @return Response
* @throws DbalException
* @throws InfluxDatabaseException
* @throws InfluxException
* @throws JsonException
*/
public function callbackBulk(Request $request): Response
{
try {
$body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
$body = [];
}
foreach ($body as $item) {
try {
$this->handleCallback($item['rotatorId'], $item['callbackData']);
} catch (RotatorNotFoundException) {
continue;
}
}
return new Response();
}
/**
* @Route("/callback/{rotatorId}", methods={"POST"})
*
* @throws DbalException
* @throws InfluxDatabaseException
* @throws InfluxException
* @throws JsonException
*/
public function callback(Request $request, string $rotatorId): Response
{
try {
$body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
$body = [];
}
try {
$this->handleCallback($rotatorId, $body);
} catch (RotatorNotFoundException) {
}
return new Response();
}
/**
* @Route("/api/generateLinkToken", methods={"POST"})
*/
public function generateLinkToken(Request $request): Response
{
try {
$body = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$playerId = $body['playerId'] ?? null;
$source = $body['source'] ?? null;
$tokenCreatedAt = new DateTimeImmutable();
if ($playerId === null) {
return new JsonResponse(
[
'message' => 'Player id is required',
],
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
if (!is_string($playerId)) {
return new JsonResponse(
[
'message' => 'Player id must be string',
],
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
$wlSettingsDto = $this->wlSettingsService->getWlSettings($this->whitelabel);
if ($wlSettingsDto === null) {
return new JsonResponse(
[
'message' => 'WL settings not found',
],
Response::HTTP_NOT_FOUND,
);
}
$token = sha1(
sprintf(
'%s%s%s',
$playerId,
$tokenCreatedAt->getTimestamp(),
$wlSettingsDto->getSalt()
)
);
$expiredAt = (new DateTimeImmutable())
->modify('+' . $wlSettingsDto->getTokenLifetime() . ' seconds')
;
$this->playerTokenService->createNewPlayerToken($playerId, $this->whitelabel, $token, $expiredAt);
$wlRotatorDomainDto = null;
if ($source !== null) {
$wlRotatorDomainDto = $this->wlRotatorDomainService->getWlRotatorDomainDtoBySource(
$this->whitelabel,
$source,
);
}
if ($wlRotatorDomainDto === null) {
$wlRotatorDomainDto = $this->wlRotatorDomainService->getRandomWlRotatorDomainDto($this->whitelabel);
if ($wlRotatorDomainDto === null) {
return new JsonResponse(
[
'message' => 'WL rotator domain not found',
],
Response::HTTP_NOT_FOUND,
);
}
}
$data = [
'link' => sprintf('%s/?rotatorToken=%s', $wlRotatorDomainDto->getDomain(), $token),
'endTime' => $expiredAt->getTimestamp(),
'token' => $token,
];
return new JsonResponse($data, Response::HTTP_OK);
} catch (Throwable $e) {
return new JsonResponse($e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* @throws InfluxException
* @throws InfluxDatabaseException
*/
private function sendMetric(string $key, string $country, ?string $mirror = null): void
{
$fields = [
'country' => $country,
'rotator' => $this->whitelabel,
];
if ($mirror) {
$fields['mirror'] = $mirror;
}
$this->influxDbClient->sendBulk($key, $fields, $fields);
}
/**
* @throws InfluxException
* @throws JsonException
* @throws DbalException
* @throws InfluxDatabaseException
* @throws RotatorNotFoundException
*/
private function handleCallback(string $rotatorId, array $callbackData): void
{
$rotator = $this->rotatorService->find($rotatorId);
if ($rotator === null) {
throw new RotatorNotFoundException();
}
$isPlayerIdMismatch = !empty($callbackData['playerId'])
&& !empty($rotator['playerId'])
&& $callbackData['playerId'] !== $rotator['playerId']
;
if ($isPlayerIdMismatch) {
return;
}
$isPlayerUuidMismatch = !empty($callbackData['playerUuid'])
&& !empty($rotator['playerUuid'])
&& $callbackData['playerUuid'] !== $rotator['playerUuid']
;
if ($isPlayerUuidMismatch) {
return;
}
$country = $rotator['country'] ?? '';
$this->sendMetric('callback', $country, $rotator['mirrorHost']);
$data = [
'rotatorId' => $rotator['rotatorId'],
'country' => $country,
'callback' => json_encode($callbackData, JSON_THROW_ON_ERROR),
'callbackAt' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
];
if (empty($rotator['playerId']) && !empty($callbackData['playerId'])) {
$data['playerId'] = $callbackData['playerId'];
}
if (empty($rotator['playerUuid']) && !empty($callbackData['playerUuid'])) {
$data['playerUuid'] = $callbackData['playerUuid'];
}
if (!empty($callbackData['host'])) {
$mirror = $this->mirrorService->findByHost($callbackData['host']);
if ($mirror !== null && $rotator['mirrorId'] !== $mirror['id']) {
$data['mirrorId'] = $mirror['id'];
}
}
$this->rotatorService->update($rotator['id'], $data);
}
private function getAccessToken(
array $rotator,
$query,
WlSettingsDto $wlSettingsDto,
bool $needTsUuidSignCheck,
?int $ts,
?string $uuid,
?string $sign,
): ?string {
if (
$needTsUuidSignCheck &&
$this->checkTsUuidSign(
$rotator,
$wlSettingsDto->getSecret(),
$ts,
$uuid,
$sign,
) === false
) {
return null;
}
try {
return $this->rotatorService->getAccessToken($rotator, $query, $wlSettingsDto);
} catch (Throwable) {
}
return null;
}
private function checkTsUuidSign(
array $rotator,
?string $secret,
?int $ts,
?string $uuid,
?string $sign,
): bool {
if (empty($ts) || empty($uuid) || empty($sign)) {
return false;
}
if (time() - floor($ts / 1000) > self::ROTATOR_TTL) {
return false;
}
if (empty($secret)) {
return true;
}
if ($sign !== sha1($rotator['rotatorId'] . $ts . $uuid . $secret)) {
return false;
}
if (!empty($rotator['playerUuid']) && $uuid !== $rotator['playerUuid']) {
return false;
}
return true;
}
}