1241 lines
42 KiB
PHP
Executable File
1241 lines
42 KiB
PHP
Executable File
<?php
|
|
|
|
namespace Mautic\PageBundle\Model;
|
|
|
|
use Doctrine\DBAL\Query\QueryBuilder;
|
|
use Doctrine\ORM\EntityManager;
|
|
use Mautic\CoreBundle\Helper\Chart\ChartQuery;
|
|
use Mautic\CoreBundle\Helper\Chart\LineChart;
|
|
use Mautic\CoreBundle\Helper\Chart\PieChart;
|
|
use Mautic\CoreBundle\Helper\CookieHelper;
|
|
use Mautic\CoreBundle\Helper\CoreParametersHelper;
|
|
use Mautic\CoreBundle\Helper\DateTimeHelper;
|
|
use Mautic\CoreBundle\Helper\InputHelper;
|
|
use Mautic\CoreBundle\Helper\IpLookupHelper;
|
|
use Mautic\CoreBundle\Helper\UserHelper;
|
|
use Mautic\CoreBundle\Model\BuilderModelTrait;
|
|
use Mautic\CoreBundle\Model\FormModel;
|
|
use Mautic\CoreBundle\Model\GlobalSearchInterface;
|
|
use Mautic\CoreBundle\Model\TranslationModelTrait;
|
|
use Mautic\CoreBundle\Model\VariantModelTrait;
|
|
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
|
|
use Mautic\CoreBundle\Translation\Translator;
|
|
use Mautic\EmailBundle\Entity\Stat;
|
|
use Mautic\EmailBundle\Entity\StatRepository;
|
|
use Mautic\EmailBundle\Helper\BotRatioHelper;
|
|
use Mautic\LeadBundle\DataObject\LeadManipulator;
|
|
use Mautic\LeadBundle\Entity\Company;
|
|
use Mautic\LeadBundle\Entity\Lead;
|
|
use Mautic\LeadBundle\Entity\UtmTag;
|
|
use Mautic\LeadBundle\Helper\ContactRequestHelper;
|
|
use Mautic\LeadBundle\Helper\IdentifyCompanyHelper;
|
|
use Mautic\LeadBundle\Model\CompanyModel;
|
|
use Mautic\LeadBundle\Model\FieldModel;
|
|
use Mautic\LeadBundle\Model\LeadModel;
|
|
use Mautic\LeadBundle\Tracker\ContactTracker;
|
|
use Mautic\LeadBundle\Tracker\DeviceTracker;
|
|
use Mautic\MessengerBundle\Message\PageHitNotification;
|
|
use Mautic\PageBundle\Entity\Hit;
|
|
use Mautic\PageBundle\Entity\Page;
|
|
use Mautic\PageBundle\Entity\Redirect;
|
|
use Mautic\PageBundle\Event\PageBuilderEvent;
|
|
use Mautic\PageBundle\Event\PageEvent;
|
|
use Mautic\PageBundle\Event\PageHitEvent;
|
|
use Mautic\PageBundle\Form\Type\PageType;
|
|
use Mautic\PageBundle\PageEvents;
|
|
use Psr\Log\LoggerInterface;
|
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
|
use Symfony\Component\Form\FormFactoryInterface;
|
|
use Symfony\Component\HttpFoundation\Cookie;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
|
use Symfony\Component\Messenger\MessageBusInterface;
|
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|
use Symfony\Contracts\EventDispatcher\Event;
|
|
|
|
/**
|
|
* @extends FormModel<Page>
|
|
*/
|
|
class PageModel extends FormModel implements GlobalSearchInterface
|
|
{
|
|
use TranslationModelTrait;
|
|
use VariantModelTrait;
|
|
use BuilderModelTrait;
|
|
|
|
/**
|
|
* We have to limit length of some fields
|
|
* to store them in the database.
|
|
*/
|
|
private const MAX_FIELD_LENGTH = 191;
|
|
|
|
/**
|
|
* An encoding to use to calculate
|
|
* length of a field.
|
|
*/
|
|
private const STRING_ENCODING = 'UTF-8';
|
|
|
|
/**
|
|
* @var bool
|
|
*/
|
|
protected $catInUrl;
|
|
|
|
protected DateTimeHelper $dateTimeHelper;
|
|
|
|
public function __construct(
|
|
protected CookieHelper $cookieHelper,
|
|
protected IpLookupHelper $ipLookupHelper,
|
|
protected LeadModel $leadModel,
|
|
protected FieldModel $leadFieldModel,
|
|
protected RedirectModel $pageRedirectModel,
|
|
protected TrackableModel $pageTrackableModel,
|
|
private MessageBusInterface $messageBus,
|
|
private CompanyModel $companyModel,
|
|
private DeviceTracker $deviceTracker,
|
|
private ContactTracker $contactTracker,
|
|
CoreParametersHelper $coreParametersHelper,
|
|
private ContactRequestHelper $contactRequestHelper,
|
|
EntityManager $em,
|
|
CorePermissions $security,
|
|
EventDispatcherInterface $dispatcher,
|
|
UrlGeneratorInterface $router,
|
|
Translator $translator,
|
|
UserHelper $userHelper,
|
|
LoggerInterface $mauticLogger,
|
|
private StatRepository $statRepository,
|
|
private BotRatioHelper $botRatioHelper,
|
|
) {
|
|
$this->dateTimeHelper = new DateTimeHelper();
|
|
|
|
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
|
|
}
|
|
|
|
public function setCatInUrl($catInUrl): void
|
|
{
|
|
$this->catInUrl = $catInUrl;
|
|
}
|
|
|
|
/**
|
|
* @return \Mautic\PageBundle\Entity\PageRepository
|
|
*/
|
|
public function getRepository()
|
|
{
|
|
$repo = $this->em->getRepository(Page::class);
|
|
$repo->setCurrentUser($this->userHelper->getUser());
|
|
|
|
return $repo;
|
|
}
|
|
|
|
/**
|
|
* @return \Mautic\PageBundle\Entity\HitRepository
|
|
*/
|
|
public function getHitRepository()
|
|
{
|
|
return $this->em->getRepository(Hit::class);
|
|
}
|
|
|
|
public function getPermissionBase(): string
|
|
{
|
|
return 'page:pages';
|
|
}
|
|
|
|
public function getNameGetter(): string
|
|
{
|
|
return 'getTitle';
|
|
}
|
|
|
|
/**
|
|
* @param Page $entity
|
|
* @param bool $unlock
|
|
*/
|
|
public function saveEntity($entity, $unlock = true): void
|
|
{
|
|
$pageIds = $entity->getRelatedEntityIds();
|
|
|
|
if (empty($this->inConversion)) {
|
|
$alias = $entity->getAlias();
|
|
if (empty($alias)) {
|
|
$alias = $entity->getTitle();
|
|
}
|
|
$alias = $this->cleanAlias($alias, '', 0, '-', ['_']);
|
|
|
|
// make sure alias is not already taken
|
|
$repo = $this->getRepository();
|
|
$testAlias = $alias;
|
|
$count = $repo->checkPageUniqueAlias($testAlias, $pageIds);
|
|
$aliasTag = 1;
|
|
|
|
while ($count) {
|
|
$testAlias = $alias.$aliasTag;
|
|
$count = $repo->checkPageUniqueAlias($testAlias, $pageIds);
|
|
++$aliasTag;
|
|
}
|
|
if ($testAlias != $alias) {
|
|
$alias = $testAlias;
|
|
}
|
|
$entity->setAlias($alias);
|
|
}
|
|
|
|
// Set the author for new pages
|
|
$isNew = $entity->isNew();
|
|
if (!$isNew) {
|
|
// increase the revision
|
|
$revision = $entity->getRevision();
|
|
++$revision;
|
|
$entity->setRevision($revision);
|
|
}
|
|
|
|
// Reset a/b test if applicable
|
|
$variantStartDate = new \DateTime();
|
|
$resetVariants = $this->preVariantSaveEntity($entity, ['setVariantHits'], $variantStartDate);
|
|
|
|
parent::saveEntity($entity, $unlock);
|
|
|
|
$this->postVariantSaveEntity($entity, $resetVariants, $pageIds, $variantStartDate);
|
|
$this->postTranslationEntitySave($entity);
|
|
}
|
|
|
|
/**
|
|
* @param Page $entity
|
|
*/
|
|
public function deleteEntity($entity): void
|
|
{
|
|
if ($entity->isVariant() && $entity->getIsPublished()) {
|
|
$this->resetVariants($entity);
|
|
}
|
|
|
|
parent::deleteEntity($entity);
|
|
}
|
|
|
|
public function createForm($entity, FormFactoryInterface $formFactory, $action = null, $options = []): \Symfony\Component\Form\FormInterface
|
|
{
|
|
if (!$entity instanceof Page) {
|
|
throw new MethodNotAllowedHttpException(['Page']);
|
|
}
|
|
|
|
$formClass = PageType::class;
|
|
|
|
if (!empty($options['formName'])) {
|
|
$formClass = $options['formName'];
|
|
}
|
|
|
|
if (!empty($action)) {
|
|
$options['action'] = $action;
|
|
}
|
|
|
|
return $formFactory->create($formClass, $entity, $options);
|
|
}
|
|
|
|
public function getEntity($id = null): ?Page
|
|
{
|
|
if (null === $id) {
|
|
$entity = new Page();
|
|
$entity->setSessionId('new_'.hash('sha1', uniqid(mt_rand())));
|
|
} else {
|
|
$entity = parent::getEntity($id);
|
|
if (null !== $entity) {
|
|
$entity->setSessionId($entity->getId());
|
|
}
|
|
}
|
|
|
|
return $entity;
|
|
}
|
|
|
|
/**
|
|
* @throws MethodNotAllowedHttpException
|
|
*/
|
|
protected function dispatchEvent($action, &$entity, $isNew = false, ?Event $event = null): ?Event
|
|
{
|
|
if (!$entity instanceof Page) {
|
|
throw new MethodNotAllowedHttpException(['Page']);
|
|
}
|
|
|
|
switch ($action) {
|
|
case 'pre_save':
|
|
$name = PageEvents::PAGE_PRE_SAVE;
|
|
break;
|
|
case 'post_save':
|
|
$name = PageEvents::PAGE_POST_SAVE;
|
|
break;
|
|
case 'pre_delete':
|
|
$name = PageEvents::PAGE_PRE_DELETE;
|
|
break;
|
|
case 'post_delete':
|
|
$name = PageEvents::PAGE_POST_DELETE;
|
|
break;
|
|
default:
|
|
return null;
|
|
}
|
|
|
|
if ($this->dispatcher->hasListeners($name)) {
|
|
if (empty($event)) {
|
|
$event = new PageEvent($entity, $isNew);
|
|
$event->setEntityManager($this->em);
|
|
}
|
|
|
|
$this->dispatcher->dispatch($event, $name);
|
|
|
|
return $event;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get list of entities for autopopulate fields.
|
|
*
|
|
* @param string $type
|
|
* @param string $filter
|
|
* @param int $limit
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getLookupResults($type, $filter = '', $limit = 10)
|
|
{
|
|
$results = [];
|
|
switch ($type) {
|
|
case 'page':
|
|
$viewOther = $this->security->isGranted('page:pages:viewother');
|
|
$repo = $this->getRepository();
|
|
$repo->setCurrentUser($this->userHelper->getUser());
|
|
$results = $repo->getPageList($filter, $limit, 0, $viewOther);
|
|
break;
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Generate URL for a page.
|
|
*
|
|
* @param Page $entity
|
|
* @param bool $absolute
|
|
* @param array $clickthrough
|
|
*
|
|
* @return string
|
|
*/
|
|
public function generateUrl($entity, $absolute = true, $clickthrough = [])
|
|
{
|
|
// If this is a variant, then get the parent's URL
|
|
$parent = $entity->getVariantParent();
|
|
if (null != $parent) {
|
|
$entity = $parent;
|
|
}
|
|
|
|
$slug = $this->generateSlug($entity);
|
|
|
|
return $this->buildUrl('mautic_page_public', ['slug' => $slug], $absolute, $clickthrough);
|
|
}
|
|
|
|
/**
|
|
* Generates slug string.
|
|
*/
|
|
public function generateSlug($entity): string
|
|
{
|
|
$pageSlug = $entity->getAlias();
|
|
|
|
// should the url include the category
|
|
if ($this->catInUrl) {
|
|
$category = $entity->getCategory();
|
|
$catSlug = (!empty($category))
|
|
? $category->getAlias()
|
|
:
|
|
$this->translator->trans('mautic.core.url.uncategorized');
|
|
}
|
|
|
|
$parent = $entity->getTranslationParent();
|
|
$slugs = [];
|
|
if ($parent) {
|
|
// multiple languages so tack on the language
|
|
$slugs[] = $entity->getLanguage();
|
|
}
|
|
|
|
if (!empty($catSlug)) {
|
|
// Insert category slug
|
|
$slugs[] = $catSlug;
|
|
$slugs[] = $pageSlug;
|
|
} else {
|
|
// Insert just the page slug
|
|
$slugs[] = $pageSlug;
|
|
}
|
|
|
|
return implode('/', $slugs);
|
|
}
|
|
|
|
/**
|
|
* @return array|mixed
|
|
*/
|
|
protected function generateClickThrough(Hit $hit)
|
|
{
|
|
$query = $hit->getQuery();
|
|
|
|
// Check for any clickthrough info
|
|
$clickthrough = [];
|
|
if (!empty($query['ct'])) {
|
|
$clickthrough = $query['ct'];
|
|
if (!is_array($clickthrough)) {
|
|
$clickthrough = $this->decodeArrayFromUrl($clickthrough);
|
|
}
|
|
}
|
|
|
|
return $clickthrough;
|
|
}
|
|
|
|
/**
|
|
* @param string|int $code
|
|
* @param array $query
|
|
*
|
|
* @return bool If hit is tracked or not
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public function hitPage(Redirect|Page|null $page, Request $request, $code = '200', ?Lead $lead = null, $query = [], ?\DateTime $dateTime = null): bool
|
|
{
|
|
// Don't skew results with user hits
|
|
if (!$this->security->isAnonymous() || $request->cookies->get('Blocked-Tracking')) {
|
|
return false;
|
|
}
|
|
|
|
$ipAddress = $this->ipLookupHelper->getIpAddress();
|
|
if (!$ipAddress->isTrackable()) {
|
|
return false;
|
|
}
|
|
|
|
// Process the query
|
|
if (empty($query) || !is_array($query)) {
|
|
$query = $this->getHitQuery($request, $page);
|
|
}
|
|
|
|
$dateTime = $dateTime ?: new \DateTime();
|
|
$userAgent = $request->server->get('HTTP_USER_AGENT');
|
|
if (array_key_exists('ct', $query) && is_array($query['ct']) && array_key_exists('email', $query['ct']) && array_key_exists('stat', $query['ct'])) {
|
|
/** @var Stat $stat */
|
|
$stat = $this->statRepository->findOneBy(['trackingHash' => $query['ct']['stat']]);
|
|
if (null !== $stat && $this->botRatioHelper->isHitByBot($stat, $dateTime, $ipAddress, (string) $userAgent)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Get lead if required
|
|
if (null == $lead) {
|
|
$lead = $this->contactRequestHelper->getContactFromQuery($query);
|
|
|
|
// company
|
|
[$company, $leadAdded, $companyEntity] = IdentifyCompanyHelper::identifyLeadsCompany($query, $lead, $this->companyModel);
|
|
$companyChangeLog = null;
|
|
if ($leadAdded) {
|
|
$companyChangeLog = $lead->addCompanyChangeLogEntry('form', 'Identify Company', 'Lead added to the company, '.$company['companyname'], $company['id']);
|
|
} elseif ($companyEntity instanceof Company) {
|
|
$this->companyModel->setFieldValues($companyEntity, $query);
|
|
$this->companyModel->saveEntity($companyEntity);
|
|
}
|
|
|
|
if (!empty($company) and $companyEntity instanceof Company) {
|
|
// Save after the lead in for new leads created through the API and maybe other places
|
|
$this->companyModel->addLeadToCompany($companyEntity, $lead);
|
|
$this->leadModel->setPrimaryCompany($companyEntity->getId(), $lead->getId());
|
|
}
|
|
|
|
if (null !== $companyChangeLog) {
|
|
$this->companyModel->getCompanyLeadRepository()->detachEntity($companyChangeLog);
|
|
}
|
|
}
|
|
|
|
if (!$lead || !$lead->getId()) {
|
|
// Lead came from a non-trackable IP so ignore
|
|
return false;
|
|
}
|
|
|
|
$hit = new Hit();
|
|
$hit->setDateHit($dateTime);
|
|
$hit->setIpAddress($ipAddress);
|
|
|
|
// Set info from request
|
|
$hit->setQuery($query);
|
|
$hit->setCode($code);
|
|
|
|
$trackedDevice = $this->deviceTracker->createDeviceFromUserAgent($lead, $userAgent);
|
|
|
|
$hit->setTrackingId($this->limitString($trackedDevice->getTrackingId()));
|
|
$hit->setDeviceStat($trackedDevice);
|
|
|
|
// Wrap in a try/catch to prevent deadlock errors on busy servers
|
|
try {
|
|
$this->em->persist($hit);
|
|
$this->em->flush();
|
|
} catch (\Exception $exception) {
|
|
if (MAUTIC_ENV !== 'prod') {
|
|
throw $exception;
|
|
} else {
|
|
$this->logger->error(
|
|
$exception->getMessage(),
|
|
['exception' => $exception]
|
|
);
|
|
}
|
|
}
|
|
|
|
// save hit to the cookie to use to update the exit time
|
|
if ($hit) {
|
|
$this->cookieHelper->setCookie(
|
|
name: 'mautic_referer_id',
|
|
value: $hit->getId() ?: null,
|
|
sameSite: Cookie::SAMESITE_NONE
|
|
);
|
|
}
|
|
|
|
$message = new PageHitNotification(
|
|
$hit->getId(),
|
|
$request,
|
|
$this->deviceTracker->wasDeviceChanged(),
|
|
$page instanceof Redirect,
|
|
$page?->getId(),
|
|
$lead->getId()
|
|
);
|
|
|
|
try {
|
|
$this->messageBus->dispatch($message);
|
|
} catch (\Exception $exception) {
|
|
$this->logger->error('Failed to dispatch a message to messenger. '.$exception->getMessage());
|
|
// Fallback measure
|
|
$this->processPageHit($hit, $page, $request, $lead, $this->deviceTracker->wasDeviceChanged());
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Process page hit.
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public function processPageHit(
|
|
Hit $hit,
|
|
Redirect|Page|null $page,
|
|
Request $request,
|
|
Lead $lead,
|
|
bool $trackingNewlyGenerated,
|
|
bool $activeRequest = true,
|
|
?\DateTimeInterface $hitDate = null,
|
|
): void {
|
|
// Store Page/Redirect association
|
|
if ($page) {
|
|
if ($page instanceof Page) {
|
|
$hit->setPage($page);
|
|
} else {
|
|
$hit->setRedirect($page);
|
|
}
|
|
}
|
|
|
|
// Check for any clickthrough info
|
|
$clickthrough = $this->generateClickThrough($hit);
|
|
if (!empty($clickthrough)) {
|
|
if (!empty($clickthrough['channel'])) {
|
|
if (1 === count($clickthrough['channel'])) {
|
|
$channelId = reset($clickthrough['channel']);
|
|
$channel = key($clickthrough['channel']);
|
|
} else {
|
|
$channel = $clickthrough['channel'][0];
|
|
$channelId = (int) $clickthrough['channel'][1];
|
|
}
|
|
$hit->setSource($this->limitString($channel));
|
|
$hit->setSourceId($channelId);
|
|
} elseif (!empty($clickthrough['source'])) {
|
|
$hit->setSource($this->limitString($clickthrough['source'][0]));
|
|
$hit->setSourceId($clickthrough['source'][1]);
|
|
}
|
|
|
|
if (!empty($clickthrough['email'])) {
|
|
$emailRepo = $this->em->getRepository(\Mautic\EmailBundle\Entity\Email::class);
|
|
if ($emailEntity = $emailRepo->getEntity($clickthrough['email'])) {
|
|
$hit->setEmail($emailEntity);
|
|
}
|
|
}
|
|
}
|
|
|
|
$query = $hit->getQuery() ?: [];
|
|
|
|
if (isset($query['timezone_offset']) && !$lead->getTimezone()) {
|
|
// timezone_offset holds timezone offset in minutes. Multiply by 60 to get seconds.
|
|
// Multiply by -1 because Firgerprint2 seems to have it the other way around.
|
|
$timezone = (-1 * $query['timezone_offset'] * 60);
|
|
$lead->setTimezone($this->dateTimeHelper->guessTimezoneFromOffset($timezone));
|
|
}
|
|
|
|
$query = $this->cleanQuery($query);
|
|
|
|
if (isset($query['page_referrer'])) {
|
|
$hit->setReferer($query['page_referrer']);
|
|
}
|
|
if (isset($query['page_language'])) {
|
|
$hit->setPageLanguage($this->limitString($query['page_language']));
|
|
}
|
|
|
|
if ($pageTitle = $query['page_title'] ?? ($page instanceof Page ? $page->getTitle() : false)) {
|
|
// Transliterate page titles.
|
|
if ($this->coreParametersHelper->get('transliterate_page_title')) {
|
|
$pageTitle = InputHelper::transliterate($pageTitle);
|
|
}
|
|
|
|
$query['page_title'] = $pageTitle;
|
|
$hit->setUrlTitle($this->limitString($pageTitle));
|
|
}
|
|
|
|
$hit->setQuery($query);
|
|
$hit->setUrl($query['page_url'] ?? $request->getRequestUri());
|
|
|
|
// Add entry to contact log table
|
|
$this->setLeadManipulator($page, $hit, $lead);
|
|
|
|
// Store tracking ID
|
|
$hit->setLead($lead);
|
|
|
|
if (!$activeRequest) {
|
|
// Queue is consuming this hit outside of the lead's active request so this must be set in order for listeners to know who the request belongs to
|
|
$this->contactTracker->setSystemContact($lead);
|
|
}
|
|
$trackingId = $hit->getTrackingId();
|
|
if (!$trackingNewlyGenerated) {
|
|
$lastHit = $request->cookies->get('mautic_referer_id');
|
|
if (!empty($lastHit) && is_numeric($lastHit)) {
|
|
// this is not a new session so update the last hit if applicable with the date/time the user left
|
|
$this->getHitRepository()->updateHitDateLeft((int) $lastHit);
|
|
}
|
|
}
|
|
|
|
// Check if this is a unique page hit
|
|
$isUnique = $this->getHitRepository()->isUniquePageHit($page, $trackingId, $lead);
|
|
|
|
if (!empty($page)) {
|
|
if ($page instanceof Page) {
|
|
$hit->setPageLanguage($this->limitString($page->getLanguage()));
|
|
|
|
$isVariant = ($isUnique) ? $page->getVariantStartDate() : false;
|
|
|
|
try {
|
|
$this->getRepository()->upHitCount($page->getId(), 1, $isUnique, !empty($isVariant));
|
|
} catch (\Exception $exception) {
|
|
$this->logger->error(
|
|
$exception->getMessage(),
|
|
['exception' => $exception]
|
|
);
|
|
}
|
|
} elseif ($page instanceof Redirect) {
|
|
try {
|
|
$this->pageRedirectModel->getRepository()->upHitCount($page->getId(), 1, $isUnique);
|
|
|
|
// If this is a trackable, up the trackable counts as well
|
|
if ($hit->getSource() && $hit->getSourceId()) {
|
|
$this->pageTrackableModel->getRepository()->upHitCount(
|
|
$page->getId(),
|
|
$hit->getSource(),
|
|
$hit->getSourceId(),
|
|
1,
|
|
$isUnique
|
|
);
|
|
}
|
|
} catch (\Exception $exception) {
|
|
if (MAUTIC_ENV !== 'prod') {
|
|
throw $exception;
|
|
} else {
|
|
$this->logger->error(
|
|
$exception->getMessage(),
|
|
['exception' => $exception]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// glean info from the IP address
|
|
$ipAddress = $hit->getIpAddress();
|
|
if ($ipAddress && $details = $ipAddress->getIpDetails()) {
|
|
$hit->setCountry($this->limitString($details['country']));
|
|
$hit->setRegion($this->limitString($details['region']));
|
|
$hit->setCity($this->limitString($details['city']));
|
|
$hit->setIsp($this->limitString($details['isp']));
|
|
$hit->setOrganization($this->limitString($details['organization']));
|
|
}
|
|
|
|
if (!$hit->getReferer()) {
|
|
$hit->setReferer($request->server->get('HTTP_REFERER'));
|
|
}
|
|
|
|
$hit->setUserAgent($request->server->get('HTTP_USER_AGENT'));
|
|
$hit->setRemoteHost($this->limitString($request->server->get('REMOTE_HOST')));
|
|
|
|
$this->setUtmTags($hit, $lead);
|
|
|
|
// get a list of the languages the user prefers
|
|
$browserLanguages = $request->server->get('HTTP_ACCEPT_LANGUAGE');
|
|
if (!empty($browserLanguages)) {
|
|
$languages = explode(',', $browserLanguages);
|
|
foreach ($languages as $k => $l) {
|
|
if (($pos = strpos(';q=', $l)) !== false) {
|
|
// remove weights
|
|
$languages[$k] = substr($l, 0, $pos);
|
|
}
|
|
}
|
|
$hit->setBrowserLanguages($languages);
|
|
}
|
|
|
|
// Wrap in a try/catch to prevent deadlock errors on busy servers
|
|
try {
|
|
$this->em->persist($hit);
|
|
$this->em->flush();
|
|
} catch (\Exception $exception) {
|
|
if (MAUTIC_ENV === 'dev') {
|
|
throw $exception;
|
|
} else {
|
|
$this->logger->error(
|
|
$exception->getMessage(),
|
|
['exception' => $exception]
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($this->dispatcher->hasListeners(PageEvents::PAGE_ON_HIT)) {
|
|
$event = new PageHitEvent($hit, $request, $hit->getCode(), $clickthrough, $isUnique);
|
|
$this->dispatcher->dispatch($event, PageEvents::PAGE_ON_HIT);
|
|
}
|
|
|
|
if (null !== $hitDate) {
|
|
if (null === $lead->getLastActive() || $lead->getLastActive() < $hitDate) {
|
|
try {
|
|
$this->leadModel->getRepository()->updateLastActive($lead->getId(), $hitDate);
|
|
} catch (\Exception $e) {
|
|
$data = [
|
|
'unique' => ($isUnique ? 'true' : 'false'),
|
|
'lead' => $lead->getId(),
|
|
'page' => $page->getId(),
|
|
'hit' => $hit->getId(),
|
|
'lastActiveOriginal' => $lead->getLastActive(),
|
|
'newLastActive' => $hitDate,
|
|
];
|
|
|
|
$this->logger->error(
|
|
'Failed to update event time due to '.$e->getMessage(),
|
|
['context' => $data, 'exception' => (array) $e]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param Redirect|Page|null $page
|
|
*/
|
|
public function getHitQuery(Request $request, $page = null): array
|
|
{
|
|
$get = $request->query->all();
|
|
$post = $request->request->all();
|
|
|
|
$query = \array_merge($get, $post);
|
|
|
|
// Set generated page url
|
|
$query['page_url'] = $this->getPageUrl($request, $page);
|
|
|
|
// get all params from the url (actual url or passed in as page_url)
|
|
if (!empty($query['page_url'])) {
|
|
$queryUrl = $this->getQueryFromUrl($query['page_url']);
|
|
$query = \array_merge($queryUrl, $query);
|
|
}
|
|
|
|
// Process clickthrough if applicable
|
|
if (!empty($query['ct'])) {
|
|
$query['ct'] = $this->decodeArrayFromUrl($query['ct']);
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
/**
|
|
* Get array of page builder tokens from bundles subscribed PageEvents::PAGE_ON_BUILD.
|
|
*
|
|
* @param array|string $requestedComponents all | tokens | abTestWinnerCriteria
|
|
*
|
|
* @return array
|
|
*/
|
|
public function getBuilderComponents(?Page $page = null, $requestedComponents = 'all', string $tokenFilter = '')
|
|
{
|
|
$event = new PageBuilderEvent($this->translator, $page, $requestedComponents, $tokenFilter);
|
|
$this->dispatcher->dispatch($event, PageEvents::PAGE_ON_BUILD);
|
|
|
|
return $this->getCommonBuilderComponents($requestedComponents, $event);
|
|
}
|
|
|
|
/**
|
|
* Get number of page bounces.
|
|
*
|
|
* @return mixed[]
|
|
*/
|
|
public function getBounces(Page $page, ?\DateTime $fromDate = null): array
|
|
{
|
|
return $this->getHitRepository()->getBounces($page->getId(), $fromDate);
|
|
}
|
|
|
|
/**
|
|
* Joins the page table and limits created_by to currently logged in user.
|
|
*/
|
|
public function limitQueryToCreator(QueryBuilder &$q): void
|
|
{
|
|
$q->join('t', MAUTIC_TABLE_PREFIX.'pages', 'p', 'p.id = t.page_id')
|
|
->andWhere('p.created_by = :userId')
|
|
->setParameter('userId', $this->userHelper->getUser()->getId());
|
|
}
|
|
|
|
/**
|
|
* Get line chart data of hits.
|
|
*
|
|
* @param char $unit {@link php.net/manual/en/function.date.php#refsect1-function.date-parameters}
|
|
* @param string $dateFormat
|
|
* @param array $filter
|
|
* @param bool $canViewOthers
|
|
*/
|
|
public function getHitsLineChartData($unit, \DateTime $dateFrom, \DateTime $dateTo, $dateFormat = null, $filter = [], $canViewOthers = true): array
|
|
{
|
|
$flag = null;
|
|
|
|
if (isset($filter['flag'])) {
|
|
$flag = $filter['flag'];
|
|
unset($filter['flag']);
|
|
}
|
|
|
|
$chart = new LineChart($unit, $dateFrom, $dateTo, $dateFormat);
|
|
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
|
|
|
|
if (!$flag || 'total_and_unique' == $flag) {
|
|
$q = $query->prepareTimeDataQuery('page_hits', 'date_hit', $filter);
|
|
|
|
if (!$canViewOthers) {
|
|
$this->limitQueryToCreator($q);
|
|
}
|
|
|
|
$data = $query->loadAndBuildTimeData($q);
|
|
$chart->setDataset($this->translator->trans('mautic.page.show.total.visits'), $data);
|
|
}
|
|
|
|
if ('unique' == $flag || 'total_and_unique' == $flag) {
|
|
$q = $query->prepareTimeDataQuery(
|
|
'page_hits',
|
|
'date_hit',
|
|
$filter,
|
|
'distinct(t.lead_id)',
|
|
true,
|
|
false
|
|
);
|
|
|
|
if (!$canViewOthers) {
|
|
$this->limitQueryToCreator($q);
|
|
}
|
|
|
|
$data = $query->loadAndBuildTimeData($q);
|
|
$chart->setDataset($this->translator->trans('mautic.page.show.unique.visits'), $data);
|
|
}
|
|
|
|
return $chart->render();
|
|
}
|
|
|
|
/**
|
|
* Get data for pie chart showing new vs returning leads.
|
|
* Returning leads are even leads who visits 2 different page once.
|
|
*
|
|
* @return array<string, array<int|string>>
|
|
*/
|
|
public function getUniqueVsReturningPieChartData(\DateTime $dateFrom, \DateTime $dateTo, bool $canViewOthers = true): array
|
|
{
|
|
$chart = new PieChart();
|
|
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
|
|
|
|
$filters = [
|
|
'lead_id' => [
|
|
'expression' => 'isNotNull',
|
|
],
|
|
'date_left' => [
|
|
'expression' => 'isNull',
|
|
],
|
|
'redirect_id' => [
|
|
'expression' => 'isNull',
|
|
],
|
|
'email_id' => [
|
|
'expression' => 'isNull',
|
|
],
|
|
];
|
|
|
|
$allQ = $query->getCountQuery('page_hits', 'id', 'date_hit', $filters);
|
|
|
|
if (!$canViewOthers) {
|
|
$this->limitQueryToCreator($allQ);
|
|
}
|
|
|
|
$allQ->resetQueryPart('select')->select('t.lead_id');
|
|
$allQ->groupBy('t.lead_id');
|
|
|
|
// fetch all group by lead_id
|
|
$q = $this->em->getConnection()->createQueryBuilder();
|
|
$q->select('COUNT(*) as count')
|
|
->from(
|
|
sprintf('(%s)', $allQ->getSQL()), 'tt'
|
|
);
|
|
$q->setParameters($allQ->getParameters());
|
|
$all = $query->fetchCount($q);
|
|
|
|
// date_left is NULL more like 1 mean returned visitor
|
|
$allQ->having('COUNT(t.id) > 1');
|
|
$q = $this->em->getConnection()->createQueryBuilder();
|
|
$q->select('COUNT(*) as count')
|
|
->from(
|
|
sprintf('(%s)', $allQ->getSQL()), 'tt'
|
|
);
|
|
$q->setParameters($allQ->getParameters());
|
|
$returning = $query->fetchCount($q);
|
|
|
|
$unique = $all - $returning;
|
|
$chart->setDataset($this->translator->trans('mautic.page.unique'), $unique);
|
|
$chart->setDataset($this->translator->trans('mautic.page.graph.pie.new.vs.returning.returning'), $returning);
|
|
|
|
return $chart->render();
|
|
}
|
|
|
|
/**
|
|
* Get pie chart data of dwell times.
|
|
*
|
|
* @param array $filters
|
|
* @param bool $canViewOthers
|
|
*/
|
|
public function getDwellTimesPieChartData(\DateTime $dateFrom, \DateTime $dateTo, $filters = [], $canViewOthers = true): array
|
|
{
|
|
$timesOnSite = $this->getHitRepository()->getDwellTimeLabels();
|
|
$chart = new PieChart();
|
|
$query = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
|
|
|
|
foreach ($timesOnSite as $time) {
|
|
$q = $query->getCountDateDiffQuery('page_hits', 'date_hit', 'date_left', $time['from'], $time['till'], $filters);
|
|
|
|
if (!$canViewOthers) {
|
|
$this->limitQueryToCreator($q);
|
|
}
|
|
|
|
$data = $query->fetchCountDateDiff($q);
|
|
$chart->setDataset($time['label'], $data);
|
|
}
|
|
|
|
return $chart->render();
|
|
}
|
|
|
|
/**
|
|
* Get bar chart data of hits.
|
|
*/
|
|
public function getDeviceGranularityData(\DateTime $dateFrom, \DateTime $dateTo, $filters = [], $canViewOthers = true): array
|
|
{
|
|
$q = $this->em->getConnection()->createQueryBuilder();
|
|
|
|
$q->select('count(h.id) as count, ds.device as device')
|
|
->from(MAUTIC_TABLE_PREFIX.'page_hits', 'h')
|
|
->join('h', MAUTIC_TABLE_PREFIX.'lead_devices', 'ds', 'ds.id=h.device_id')
|
|
->orderBy('device', 'DESC')
|
|
->andWhere($q->expr()->gte('h.date_hit', ':date_from'))
|
|
->setParameter('date_from', $dateFrom->format('Y-m-d'))
|
|
->andWhere($q->expr()->lte('h.date_hit', ':date_to'))
|
|
->setParameter('date_to', $dateTo->format('Y-m-d 23:59:59'));
|
|
$q->groupBy('ds.device');
|
|
|
|
$results = $q->executeQuery()->fetchAllAssociative();
|
|
|
|
$chart = new PieChart();
|
|
|
|
if (empty($results)) {
|
|
$results[] = [
|
|
'device' => $this->translator->trans('mautic.report.report.noresults'),
|
|
'count' => 0,
|
|
];
|
|
}
|
|
|
|
foreach ($results as $result) {
|
|
$label = empty($result['device']) ? $this->translator->trans('mautic.core.no.info') : $result['device'];
|
|
|
|
$chart->setDataset($label, $result['count']);
|
|
}
|
|
|
|
return $chart->render();
|
|
}
|
|
|
|
/**
|
|
* Get a list of popular (by hits) pages.
|
|
*
|
|
* @param int $limit
|
|
* @param array $filters
|
|
* @param bool $canViewOthers
|
|
*/
|
|
public function getPopularPages($limit = 10, ?\DateTime $dateFrom = null, ?\DateTime $dateTo = null, $filters = [], $canViewOthers = true): array
|
|
{
|
|
$q = $this->em->getConnection()->createQueryBuilder();
|
|
$q->select('COUNT(DISTINCT t.id) AS hits, p.id, p.title, p.alias')
|
|
->from(MAUTIC_TABLE_PREFIX.'page_hits', 't')
|
|
->join('t', MAUTIC_TABLE_PREFIX.'pages', 'p', 'p.id = t.page_id')
|
|
->orderBy('hits', 'DESC')
|
|
->groupBy('p.id')
|
|
->setMaxResults($limit);
|
|
|
|
if (!$canViewOthers) {
|
|
$q->andWhere('p.created_by = :userId')
|
|
->setParameter('userId', $this->userHelper->getUser()->getId());
|
|
}
|
|
|
|
$chartQuery = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
|
|
$chartQuery->applyFilters($q, $filters);
|
|
$chartQuery->applyDateFilters($q, 'date_hit');
|
|
|
|
return $q->executeQuery()->fetchAllAssociative();
|
|
}
|
|
|
|
/**
|
|
* Get a list of pages created in a date range.
|
|
*
|
|
* @param int $limit
|
|
* @param array $filters
|
|
* @param bool $canViewOthers
|
|
*/
|
|
public function getPageList($limit = 10, ?\DateTime $dateFrom = null, ?\DateTime $dateTo = null, $filters = [], $canViewOthers = true): array
|
|
{
|
|
$q = $this->em->getConnection()->createQueryBuilder();
|
|
$q->select('t.id, t.title AS name, t.date_added, t.date_modified')
|
|
->from(MAUTIC_TABLE_PREFIX.'pages', 't')
|
|
->setMaxResults($limit);
|
|
|
|
if (!$canViewOthers) {
|
|
$q->andWhere('t.created_by = :userId')
|
|
->setParameter('userId', $this->userHelper->getUser()->getId());
|
|
}
|
|
|
|
$chartQuery = new ChartQuery($this->em->getConnection(), $dateFrom, $dateTo);
|
|
$chartQuery->applyFilters($q, $filters);
|
|
$chartQuery->applyDateFilters($q, 'date_added');
|
|
|
|
return $q->executeQuery()->fetchAllAssociative();
|
|
}
|
|
|
|
/**
|
|
* Get all params (e.g. UTM tags) from a url.
|
|
*/
|
|
private function getQueryFromUrl(string $pageUrl): array
|
|
{
|
|
$query = [];
|
|
$urlQuery = parse_url($pageUrl, PHP_URL_QUERY);
|
|
|
|
if (is_string($urlQuery)) {
|
|
$urlQueryArray = [];
|
|
parse_str($urlQuery, $urlQueryArray);
|
|
|
|
foreach ($urlQueryArray as $key => $value) {
|
|
if (is_string($value)) {
|
|
$key = strtolower($key);
|
|
$query[$key] = urldecode($value);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
/**
|
|
* Set UTM Tags based on the query of a page hit.
|
|
*/
|
|
private function setUtmTags(Hit $hit, Lead $lead): void
|
|
{
|
|
// Add UTM tags entry if a UTM tag exist
|
|
$queryHasUtmTags = false;
|
|
$query = $hit->getQuery();
|
|
foreach ($query as $key => $value) {
|
|
if (str_contains($key, 'utm_')) {
|
|
$queryHasUtmTags = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($queryHasUtmTags && $lead) {
|
|
$utmTags = new UtmTag();
|
|
$utmTags->setDateAdded($hit->getDateHit());
|
|
$utmTags->setUrl($hit->getUrl());
|
|
$utmTags->setReferer($hit->getReferer());
|
|
$utmTags->setQuery($hit->getQuery());
|
|
$utmTags->setUserAgent($hit->getUserAgent());
|
|
$utmTags->setRemoteHost($hit->getRemoteHost());
|
|
$utmTags->setLead($lead);
|
|
|
|
if (array_key_exists('utm_campaign', $query)) {
|
|
$utmTags->setUtmCampaign($query['utm_campaign']);
|
|
}
|
|
if (array_key_exists('utm_term', $query)) {
|
|
$utmTags->setUtmTerm($query['utm_term']);
|
|
}
|
|
if (array_key_exists('utm_content', $query)) {
|
|
$utmTags->setUtmContent($query['utm_content']);
|
|
}
|
|
if (array_key_exists('utm_medium', $query)) {
|
|
$utmTags->setUtmMedium($query['utm_medium']);
|
|
}
|
|
if (array_key_exists('utm_source', $query)) {
|
|
$utmTags->setUtmSource($query['utm_source']);
|
|
}
|
|
|
|
$repo = $this->em->getRepository(UtmTag::class);
|
|
$repo->saveEntity($utmTags);
|
|
|
|
$this->leadModel->setUtmTags($lead, $utmTags);
|
|
}
|
|
}
|
|
|
|
private function setLeadManipulator($page, Hit $hit, Lead $lead): void
|
|
{
|
|
// Only save the lead and dispatch events if needed
|
|
$source = 'hit';
|
|
$sourceId = $hit->getId();
|
|
if ($page) {
|
|
$source = $page instanceof Page ? 'page' : 'redirect';
|
|
$sourceId = $page->getId();
|
|
}
|
|
|
|
$lead->setManipulator(
|
|
new LeadManipulator(
|
|
'page',
|
|
$source,
|
|
$sourceId,
|
|
$hit->getUrl()
|
|
)
|
|
);
|
|
|
|
$this->leadModel->saveEntity($lead);
|
|
}
|
|
|
|
/**
|
|
* @return mixed|string
|
|
*/
|
|
private function getPageUrl(Request $request, $page)
|
|
{
|
|
// Default to page_url set in the query from tracking pixel and/or contactfield token
|
|
if ($pageURL = $request->get('page_url')) {
|
|
return $pageURL;
|
|
}
|
|
|
|
if ($page instanceof Redirect) {
|
|
// use the configured redirect URL
|
|
return $page->getUrl();
|
|
}
|
|
|
|
// Use the current URL
|
|
$isPageEvent = false;
|
|
if (str_contains($request->server->get('REQUEST_URI'), $this->router->generate('mautic_page_tracker'))) {
|
|
// Tracking pixel is used
|
|
if ($request->server->get('QUERY_STRING')) {
|
|
parse_str($request->server->get('QUERY_STRING'), $query);
|
|
$isPageEvent = true;
|
|
}
|
|
} elseif (str_contains($request->server->get('REQUEST_URI'), $this->router->generate('mautic_page_tracker_cors'))) {
|
|
$query = $request->request->all();
|
|
$isPageEvent = true;
|
|
}
|
|
|
|
if ($isPageEvent) {
|
|
$pageURL = $request->server->get('HTTP_REFERER');
|
|
|
|
// if additional data were sent with the tracking pixel
|
|
if (isset($query)) {
|
|
// URL attr 'd' is encoded so let's decode it first.
|
|
$decoded = false;
|
|
if (isset($query['d'])) {
|
|
// parse_str auto urldecodes
|
|
$query = $this->decodeArrayFromUrl($query['d'], false);
|
|
$decoded = true;
|
|
}
|
|
|
|
if (is_array($query) && !empty($query)) {
|
|
if (isset($query['page_url'])) {
|
|
$pageURL = $query['page_url'];
|
|
if (!$decoded) {
|
|
$pageURL = urldecode($pageURL);
|
|
}
|
|
}
|
|
|
|
if (isset($query['page_referrer'])) {
|
|
if (!$decoded) {
|
|
$query['page_referrer'] = urldecode($query['page_referrer']);
|
|
}
|
|
}
|
|
|
|
if (isset($query['page_language'])) {
|
|
if (!$decoded) {
|
|
$query['page_language'] = urldecode($query['page_language']);
|
|
}
|
|
}
|
|
|
|
if (isset($query['page_title'])) {
|
|
if (!$decoded) {
|
|
$query['page_title'] = urldecode($query['page_title']);
|
|
}
|
|
}
|
|
|
|
if (isset($query['tags'])) {
|
|
if (!$decoded) {
|
|
$query['tags'] = urldecode($query['tags']);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $pageURL;
|
|
}
|
|
|
|
$pageURL = 'http';
|
|
if ('on' == $request->server->get('HTTPS')) {
|
|
$pageURL .= 's';
|
|
}
|
|
$pageURL .= '://';
|
|
|
|
if (!in_array((int) $request->server->get('SERVER_PORT', 80), [80, 8080, 443])) {
|
|
return $pageURL.$request->server->get('SERVER_NAME').':'.$request->server->get('SERVER_PORT').
|
|
$request->server->get('REQUEST_URI');
|
|
}
|
|
|
|
return $pageURL.$request->server->get('SERVER_NAME').$request->server->get('REQUEST_URI');
|
|
}
|
|
|
|
/*
|
|
* Cleans query params saving url values.
|
|
*
|
|
* @param $query array
|
|
*
|
|
* @return array
|
|
*/
|
|
private function cleanQuery(array $query): array
|
|
{
|
|
foreach ($query as $key => $value) {
|
|
if (filter_var($value, FILTER_VALIDATE_URL)) {
|
|
$query[$key] = InputHelper::url($value);
|
|
} else {
|
|
$query[$key] = InputHelper::clean($value);
|
|
}
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
/**
|
|
* This function limits $value to desired length.
|
|
*
|
|
* @param mixed $value The string to limit or other value to return as-is
|
|
*
|
|
* @return mixed The limited string or the original value if not a string
|
|
*/
|
|
private function limitString($value)
|
|
{
|
|
if (!is_string($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (self::MAX_FIELD_LENGTH >= mb_strwidth($value, self::STRING_ENCODING)) {
|
|
return $value;
|
|
}
|
|
|
|
return rtrim(mb_strimwidth($value, 0, self::MAX_FIELD_LENGTH, '', self::STRING_ENCODING));
|
|
}
|
|
}
|