<?php
namespace App\Services;
use App\Entity\Main\Affiliate;
use App\Entity\Main\Site;
use App\Entity\Main\SiteABTest;
use App\Entity\Main\Tracking;
use App\Entity\Main\TrackingHasABTest;
use App\Entity\Main\TrackingHasReferrer;
use App\Entity\Main\User;
use App\EventListener\Main\DebugListener;
use DateInterval;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\NonUniqueResultException;
use Mobile_Detect;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Service\Attribute\Required;
final class TrackingManager
{
const
SESSION_ID = 'actualTracking',
ALREADY_PAID_COOKIE_NAME = 'adasns',
ALREADY_PAID_CACHE_IP_PREFIX = 'ip_paid.',
SESSION_MUST_SETUP_ALREADY_PAID_COOKIE_KEY = 'already_paid_cookie_to_setup',
CACHE_IP_INFO_PREFIX = 'ip_infos.',
DEFAULT_TRACKING_O1 = 'deftrack'
;
// private $request;
private $cache;
/**
* @var string[]
*/
private array $params = ['o1', 'o2', 'o3', 'o4', 'o5', 't', 'aff', 'rmkg'];
// the value of 'aecid' is stored on 'o5', to avoid an additional column in the DB
private array $additionalParams = ['aecid'];
/**
* @var Mobile_Detect
*/
private $detector;
private ?Tracking $currentTracking = null;
private string $trackingOrigin = 'undefined';
private ?Request $request;
/**
* New Instance
*
* @param EntityManagerInterface $em
* @param RequestStack $request_stack
* @param SerializerInterface $serializer
* @param string $trackingCookieName
* @param string $trackingCookieExpiration
* @param string $trackingCookieSecret
* @param LoggerInterface $logger
* @param $environment
* @param \App\Services\ABTestManager $ABTestManager
* @param $cacheItemPool
* @param \App\Services\SiteManager $siteManager
* @throws InvalidArgumentException
*/
public function __construct(
private EntityManagerInterface $em,
private RequestStack $request_stack,
private SerializerInterface $serializer,
private string $trackingCookieName,
private string $trackingCookieExpiration,
private string $trackingCookieSecret,
private LoggerInterface $logger,
private $environment,
private ABTestManager $ABTestManager,
private SiteManager $siteManager,
private ReferralProgramManager $referralProgramManager
) {
$this->request = $this->request_stack->getCurrentRequest();
$this->cache = new FilesystemAdapter('tracking.cache');
// The same is for the mobile detector.
// $this->detector = $this->container->get('mobile_detect.mobile_detector');
}
public function setDetector(Mobile_Detect $detector): self
{
// $this->detector = $detector;
return $this;
}
/**
* @return Mobile_Detect|null
*/
public function getDetector()
{
return $this->detector;
}
/**
* @throws InvalidArgumentException
*/
public function clearCacheFromIp($client_ip): bool
{
$infosCacheItem = $this->cache->getItem(self::CACHE_IP_INFO_PREFIX . $client_ip);
if (!$infosCacheItem->isHit()) {
return true;
}
return $this->cache->deleteItem(self::CACHE_IP_INFO_PREFIX . $client_ip);
}
/**
* Return the Affiliate according to the Traking object
*
* @param Tracking $tracking
* @deprecated You should get directly affiliate from tracking now.
*
* @return Affiliate
*/
public function resolveAffiliate(Tracking $tracking): Affiliate
{
return $tracking->getAff();
}
/**
* Return the generated Cookie from the Tracking
*
* @param Tracking $tracking
*
* @return Cookie
*/
public function resolveCookie(Tracking $tracking): Cookie
{
$cookieData = $tracking->getDataForCookie();
$cookieData['tracking_hash'] = $this->makeTrackingHash($cookieData['tracking_id']);
return new Cookie(
$this->trackingCookieName,
$this->serializer->serialize($cookieData, 'json'),
time() + $this->trackingCookieExpiration
);
}
/**
* @return Cookie
*/
public function generateAlreadyPaidCookie()
{
return new Cookie(
self::ALREADY_PAID_COOKIE_NAME,
1,
time() + $this->trackingCookieExpiration
);
}
/**
* Return the Tracking according to the Site object
*
* @param Site $site
*
* @return Tracking
* @throws NonUniqueResultException
* @throws InvalidArgumentException
*/
public function resolveTracking(): Tracking
{
// If we are in cli and we don't have a request, there is no way to build a pertinent tracking.
if ('cli' === php_sapi_name() && !$this->request instanceof Request) {
$this->trackingOrigin = 'cli';
return new Tracking();
}
$site = $this->siteManager->getCurrentSite();
$rmkg = $this->request->get('rmkg', 0);
$oldTracking = null;
// check if we force to create a new tracking.
if ((in_array($this->environment, ["dev", "test"]) || $this->request->getSession()->get(DebugListener::DEBUG_ALLOWED_SESSION)) && $this->request->get('fnt', 0)) {
$forceCreateNewTracking = true;
// remove the request attribute to not recreate tracking on each call :)
$this->request->attributes->set('fnt', 0);
$this->clearAlreadyPaidInCache();
} else {
$forceCreateNewTracking = false;
}
$hasAlreadyPaidInfo = $this->getAlreadyPaidInfo();
if (!$forceCreateNewTracking) {
// we get data from the sessions
$infos = $this->getTrackingInfoFromSession();
if (!$this->isValidTrackingInfo($infos)) {
//get data from the cookie
$infos = $this->getTrackingInfoFromCookie();
} else {
// if infos from sessions are valid, we can't use rmkg.
$rmkg = 0;
}
// if (!$this->isValidTrackingInfo($infos)) {
// //get data from cache with user IP
// $infos = $this->getTrackingInfoFromCache();
// }
// from here we need to define if rmkg is allowed (only if we have an existing affiliate before).
if ($rmkg && $this->isValidTrackingInfo($infos) && (isset($infos['aff']) && $infos['aff'] instanceof Affiliate && Affiliate::FORBIDDEN_RMKG == $infos['aff']->getAffiliateRmkg())) {
$rmkg = 0;
}
// If data are empty or rmkg = 1, we get the data from the _GET
if (!$this->isValidTrackingInfo($infos) || 1 == $rmkg) {
// we keep the old tracking info to put in parent
if ($this->isValidTrackingInfo($infos)) {
$oldTracking = $infos['tracking'];
}
$infos = $this->getTrackingInfoFromRequest();
$infos['origin'] = 'request';
}
// if the info comes from the get, it's an integer, not an affiliate we have to check it exists
if (!$infos['aff'] instanceof Affiliate) {
$infos['aff'] = $this->em
->getRepository(Affiliate::class)
->loadAffiliateById($infos['aff'], "prod" == $this->environment);
}
// If still no affiliate found, we put the default one for this site.
if (!$infos['aff'] instanceof Affiliate) {
$infos['aff'] = $site->getSiteDefaultAffiliateId();
}
// Get the tracking
$tracking = $infos['tracking'] ?? null;
} else {
$tracking = null;
$infos = $this->getTrackingInfoFromRequest();
$infos['origin'] = 'request';
if (!isset($infos['aff']) or !$infos['aff'] instanceof Affiliate) {
$infos['aff'] = $this->em
->getRepository(Affiliate::class)
->loadAffiliateById($infos['aff'], "prod" == $this->environment);
}
if (!$infos['aff'] instanceof Affiliate) {
$infos['aff'] = $site->getSiteDefaultAffiliateId();
}
}
if (!$tracking instanceof Tracking) {
// if there is a $oldTracking then it is a rmkg.
$tracking = $this->newTracking($infos, $site, $oldTracking);
$tracking->setUserAgent($this->request->headers->get('User-Agent', 'N/A'));
if ('' === $tracking->getO1()) {
$tracking->setO1('def');
}
if ('' == $tracking->getDevice()) {
$tracking = $this->addDevice($tracking);
}
$this->em->flush();
$infos['origin'] = 'created';
}
$infos['tracking_id'] = $tracking->getTrackingId();
$infos['tracking_hash'] = $this->makeTrackingHash($infos['tracking_id']);
unset($infos['tracking']);
// we sae the infos by IP in the cache
$this->saveTrackingInfoToCache($infos);
$this->saveTrackingInfoToSession($infos);
$this->currentTracking = $tracking;
$this->setTrackingOrigin($infos['origin']);
$this->attachReferrerUserToTracking($tracking);
// we dynamically inject this info in the tracking.
// we don't want to insert it in the cookie of the tracking or in the db.
if ($hasAlreadyPaidInfo) {
$tracking->setAlreadyPurchasedBefore($hasAlreadyPaidInfo);
$this->setAlreadyPaidInCache();
}
return $tracking;
}
/**
* @param $origin
*/
public function setTrackingOrigin($origin)
{
if ('undefined' == $this->getTrackingOrigin()) {
$this->trackingOrigin = $origin;
}
}
/**
* @return Tracking
*/
public function getCurrentTracking()
{
return $this->currentTracking ?? $this->resolveTracking();
}
/**
* @return string
*/
public function getTrackingOrigin()
{
return $this->trackingOrigin;
}
/**
* This function is supposed to be user when we detect that the user already had a tracking and we get rid of
* the new one we just generated for him.
*/
public function updateCurrentTracking(Tracking $tracking)
{
$this->currentTracking = $tracking;
$infosToSave = $tracking->getDataForCookie();
$infosToSave['tracking_hash'] = $this->makeTrackingHash($infosToSave['tracking_id']);
$this->saveTrackingInfoToCache($infosToSave);
$this->saveTrackingInfoToSession($infosToSave);
}
/**
* @param Tracking $tracking
* @param Tracking $originalTracking
*/
public function addParentTracking(Tracking $tracking, Tracking $originalTracking)
{
$tracking->setParent($originalTracking);
if (count($originalTracking->getAbTests())) {
/** @var TrackingHasABTest $oldAbTest */
foreach ($originalTracking->getAbTests() as $oldAbTest) {
// if he already have this AB test, don't add it twice.
if ($tracking->alreadyHaveABTest($oldAbTest->getSiteABTest())) {
continue;
}
$newAbTest = new TrackingHasABTest();
$newAbTest->setTracking($tracking);
$newAbTest->setVersion($oldAbTest->getVersion());
$newAbTest->setSiteAbTest($oldAbTest->getSiteABTest());
$this->em->persist($newAbTest);
$tracking->addAbTest($newAbTest);
}
}
}
/**
* @return string
* @throws NonUniqueResultException
* @throws InvalidArgumentException
*/
public function getTemplateDir()
{
$tracking = $this->resolveTracking();
$hasDiffDesignVersions = $this->siteManager->getYamlConfigParameter('frontend')['hasDiffDesignVersions'];
$templateDir = 'main/views';
if ($hasDiffDesignVersions) {
$templateDir = 'main/site/views';
if ($tracking instanceof Tracking
&& $this->ABTestManager->hasTest($tracking, SiteABTest::BRU_DESIGN_V_OLD)
) {
$templateDir = 'main/views';
}
}
return $templateDir;
}
/**
* Returns the tracking info from the Cookie
*
* @return array
* @throws NonUniqueResultException
*/
private function getTrackingInfoFromCookie(): array
{
$cookies = $this->request->cookies;
if (!$cookies->has($this->trackingCookieName)) {
return [];
}
$infos = json_decode($cookies->get($this->trackingCookieName), true);
if (json_last_error() != JSON_ERROR_NONE) {
return [];
}
if (!isset($infos['tracking_id'], $infos['tracking_hash'])
|| !$this->isTrackingHashValid($infos['tracking_hash'], $infos['tracking_id'])
) {
return [];
}
$tracking = $this->em
->getRepository(Tracking::class)
->loadTrackingById($infos['tracking_id']);
if (!$tracking instanceof Tracking) {
return [];
}
$infos['tracking'] = $tracking;
$infos['aff'] = $tracking->getAff();
$infos['origin'] = 'cookie';
return $infos;
}
/**
* @return bool
* @throws InvalidArgumentException
*/
private function getAlreadyPaidInfo(): bool
{
// first we check in the cache.
$clientIp = $this->getFormatedIpForCache($this->request->getClientIp()) ;
$cacheKey = self::ALREADY_PAID_CACHE_IP_PREFIX . $clientIp;
if ($this->cache->hasItem($cacheKey) && true === $this->cache->getItem($cacheKey)) {
return true;
}
if ($this->getAlreadyPaidInfoFromCookie()) {
return true;
}
return false;
}
/**
* @return array|bool
*/
private function getAlreadyPaidInfoFromCookie(): bool
{
$cookies = $this->request->cookies;
if (!$cookies->has(self::ALREADY_PAID_COOKIE_NAME)) {
return false;
}
return true;
}
/**
* Returns the tracking info from the Session
* Everything from session is considered ok. No check to do.
*
* @return array
* @throws NonUniqueResultException
*/
private function getTrackingInfoFromSession(): array
{
$trackingInfo = $this->request->getSession()->get(self::SESSION_ID);
$infos = [];
if (!$trackingInfo) {
return [];
}
$tracking = $this->em
->getRepository(Tracking::class)
->loadTrackingById($trackingInfo, true, $this->environment);
if (!$tracking instanceof Tracking) {
return [];
}
$infos['aff'] = $tracking->getAff();
$infos['tracking'] = $tracking;
$infos['origin'] = 'session';
return $infos;
}
/**
* Returns whether the infos array contains valid value
*
* @param array $infos
*
* @return bool
*/
private function isValidTrackingInfo(array $infos): bool
{
$filter = function ($value): bool {
return '' !== $value;
};
return !empty(array_filter($infos, $filter));
}
/**
* Returns the tracking info from the request GET
*
* @return array
*/
private function getTrackingInfoFromRequest(): array
{
$reducer = function (array $carry, string $name): array {
$carry[$name] = $this->request->get($name, '');
return $carry;
};
return array_reduce($this->params, $reducer, []);
}
/**
* Returns the tracking info from the request GET
*
* @return array
*/
private function getAdditionalTrackingInfoFromRequest(): array
{
$reducer = function (array $carry, string $name): array {
$carry[$name] = $this->request->get($name, '');
return $carry;
};
return array_reduce($this->additionalParams, $reducer, []);
}
/**
* Tell whether the hash is valid
*
* @param string $hash
* @param string $str
*
* @return bool
*/
private function isTrackingHashValid(string $hash, string $str): bool
{
return hash_equals($hash, $this->makeTrackingHash($str));
}
/**
* @param array $additionalTrackingInfoArray
* @param Tracking $tracking
*/
private function saveAdditionalTrackingValues(array $additionalTrackingInfoArray, Tracking $tracking)
{
foreach ($this->additionalParams as $additionalParam) {
if (array_search($additionalTrackingInfoArray[$additionalParam], $additionalTrackingInfoArray) === 'aecid') {
if ($tracking->getO5() == '') {
$tracking->setO5($additionalTrackingInfoArray[$additionalParam]);
} else {
$message = 'aecid value of tracking could not be stored on o5';
$this->logger->warning($message);
}
}
}
}
/**
* Create a new Tracking object from an array
*
* @param array $resolvedArr
* @param Site $site
*
* @return Tracking
* @throws \Exception
*/
private function newTracking(array $resolvedArr, Site $site, Tracking $oldTracking = null): Tracking
{
$resolvedArr['o1'] = $resolvedArr['o1'] ?? '';
$resolvedArr['o1'] = trim($resolvedArr['o1']);
if ('' === $resolvedArr['o1']) {
$siteProps = $site->getProperties();
$resolvedArr['o1'] = $siteProps['default-tracking-params']['o1'] ?? self::DEFAULT_TRACKING_O1;
}
$tracking = new Tracking();
foreach ($this->params as $p) {
if (isset($resolvedArr[$p]) && $resolvedArr[$p] != '') {
$methodName = 'set' . strtoupper($p);
$tracking->{$methodName}($resolvedArr[$p]);
}
}
$additionalTrackingInfoArray = $this->getAdditionalTrackingInfoFromRequest();
if (count($additionalTrackingInfoArray)) {
$this->saveAdditionalTrackingValues($additionalTrackingInfoArray, $tracking);
}
if (!$tracking->getAff() instanceof Affiliate) {
$tracking->setAff($site->getSiteDefaultAffiliateId());
}
$this->em->persist($tracking);
if ($oldTracking instanceof Tracking) {
$originalTracking = $oldTracking->getParent() ?? $oldTracking;
$this->addParentTracking($tracking, $originalTracking);
$tracking->setRmkg(1);
} else {
// if it is a real new tracking we create new tests for it.
$this->ABTestManager->generateTestsForTracking($tracking, $site);
}
return $this->addDevice($tracking);
}
/**
* @param Tracking $tracking
* @return Tracking
*/
private function addDevice(Tracking $tracking): Tracking
{
if (null === $this->detector) {
return $tracking;
}
if ($this->detector->isTablet()) {
return $tracking->setDevice(Tracking::DEVICE_TABLET);
}
if ($this->detector->isMobile()) {
return $tracking->setDevice(Tracking::DEVICE_MOBILE);
}
return $tracking->setDevice(Tracking::DEVICE_DESKTOP);
}
/**
* @param Tracking $tracking
*/
private function attachReferrerUserToTracking(Tracking $tracking)
{
$referralCode = $this->request->query->get(ReferralProgramManager::USER_REFERRAL_KEY) ?? null;
if (null == $referralCode) {
return;
}
//if this tracking already has referrer we keep the old one and dont create new.
$trHasReferrer = $this->em->getRepository(TrackingHasReferrer::class)
->findOneBy(['trackingId' => $tracking->getTrackingId()]);
if ($trHasReferrer instanceof TrackingHasReferrer) {
return;
}
// check if user exists
$userReferrerId = substr($referralCode, strlen(ReferralProgramManager::USER_REFERRAL_PREFIX));
$userReferrer = $this->em->getRepository(User::class)->findOneBy(['id' => $userReferrerId]);
if (!$userReferrer instanceof User) {
return;
}
//check if program is enabled for the user
if (!$this->referralProgramManager->isReferralProgramEnabled($userReferrer, null)) {
return;
}
//create tracking has referrer.
$trackingHasReferrer = new TrackingHasReferrer();
$trackingHasReferrer->setTrackingId($tracking->getTrackingId());
$trackingHasReferrer->setReferrerUser($userReferrer);
$this->em->persist($trackingHasReferrer);
$this->em->flush();
}
/**
* Generate a tracking hash for a given string
*
* @param string $str
*
* @return string
*/
private function makeTrackingHash(string $str): string
{
return hash_hmac('sha256', $str, $this->trackingCookieSecret, false);
}
/**
* Returns the tracking info from the Cache
*
* @param null|mixed $client_ip
* @return array
* @throws InvalidArgumentException
*/
public function getTrackingInfoFromCache($client_ip = null): array
{
$client_ip = $client_ip ?? $this->request->getClientIp();
$infosCacheItem = $this->cache->getItem(self::CACHE_IP_INFO_PREFIX . $this->getFormatedIpForCache($client_ip));
if (!$infosCacheItem->isHit()) {
return [];
}
$infos = $infosCacheItem->get();
$tracking = $this->em
->getRepository(Tracking::class)
->loadTrackingById($infos['tracking_id']);
if (!$tracking instanceof Tracking) {
return [];
}
$infos['tracking'] = $tracking;
$infos['aff'] = $tracking->getAff();
$infos['origin'] = 'cache';
return $infos;
}
/**
* Save tracking info to the Cache
*
* @param array $infos
*
* @return bool
* @throws InvalidArgumentException
*/
private function saveTrackingInfoToCache(array $infos): bool
{
// we don't want to save an object into the cache, so transform it to id
if ($infos['aff'] instanceof Affiliate) {
$infos['aff'] = $infos['aff']->getAffiliateId();
}
$infosCacheItem = $this->cache->getItem(self::CACHE_IP_INFO_PREFIX . $this->getFormatedIpForCache($this->request->getClientIp()));
$infosCacheItem->set($infos);
$infosCacheItem->expiresAfter(new DateInterval('P15D'));
return $this->cache->save($infosCacheItem);
}
/**
* @param $infos
*/
private function saveTrackingInfoToSession($infos)
{
$this->request->getSession()->set(self::SESSION_ID, $infos['tracking_id']);
}
/**
* @param null $ip
* @return bool
* @throws InvalidArgumentException
*/
public function setAlreadyPaidInCache($ip = null)
{
if (null === $ip) {
$ip = $this->request->getClientIp();
}
$infosCacheItem = $this->cache->getItem(self::ALREADY_PAID_CACHE_IP_PREFIX . $this->getFormatedIpForCache($ip));
$infosCacheItem->set(true);
$infosCacheItem->expiresAfter(new DateInterval('PT12H'));
return $this->cache->save($infosCacheItem);
}
/**
* @param null $ip
* @return bool
* @throws InvalidArgumentException
*/
public function clearAlreadyPaidInCache($ip = null)
{
if (null === $ip) {
$ip = $this->request->getClientIp();
}
$this->cache->deleteItem(self::ALREADY_PAID_CACHE_IP_PREFIX . $this->getFormatedIpForCache($ip));
}
/**
* @param $ip
* @return string|string[]
*/
private function getFormatedIpForCache($ip)
{
return str_replace(':', '.', $ip);
}
}