Spaces:
No application file
No application file
namespace Mautic\PageBundle\Controller; | |
use Mautic\CoreBundle\Controller\AbstractFormController; | |
use Mautic\CoreBundle\Exception\InvalidDecodedStringException; | |
use Mautic\CoreBundle\Helper\CookieHelper; | |
use Mautic\CoreBundle\Helper\IpLookupHelper; | |
use Mautic\CoreBundle\Helper\TrackingPixelHelper; | |
use Mautic\CoreBundle\Helper\UrlHelper; | |
use Mautic\CoreBundle\Security\Permissions\CorePermissions; | |
use Mautic\CoreBundle\Twig\Helper\AnalyticsHelper; | |
use Mautic\CoreBundle\Twig\Helper\AssetsHelper; | |
use Mautic\LeadBundle\Entity\Lead; | |
use Mautic\LeadBundle\Helper\ContactRequestHelper; | |
use Mautic\LeadBundle\Helper\PrimaryCompanyHelper; | |
use Mautic\LeadBundle\Helper\TokenHelper; | |
use Mautic\LeadBundle\Model\LeadModel; | |
use Mautic\LeadBundle\Tracker\ContactTracker; | |
use Mautic\LeadBundle\Tracker\Service\DeviceTrackingService\DeviceTrackingServiceInterface; | |
use Mautic\PageBundle\Entity\Page; | |
use Mautic\PageBundle\Event\PageDisplayEvent; | |
use Mautic\PageBundle\Event\TrackingEvent; | |
use Mautic\PageBundle\Helper\TrackingHelper; | |
use Mautic\PageBundle\Model\PageModel; | |
use Mautic\PageBundle\Model\Tracking404Model; | |
use Mautic\PageBundle\Model\VideoModel; | |
use Mautic\PageBundle\PageEvents; | |
use Psr\Log\LoggerInterface; | |
use Symfony\Component\HttpFoundation\JsonResponse; | |
use Symfony\Component\HttpFoundation\Request; | |
use Symfony\Component\HttpFoundation\Response; | |
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | |
use Symfony\Component\Routing\RouterInterface; | |
class PublicController extends AbstractFormController | |
{ | |
/** | |
* @return Response | |
* | |
* @throws \Exception | |
* @throws \Mautic\CoreBundle\Exception\FileNotFoundException | |
*/ | |
public function indexAction( | |
Request $request, | |
ContactRequestHelper $contactRequestHelper, | |
CookieHelper $cookieHelper, | |
AnalyticsHelper $analyticsHelper, | |
AssetsHelper $assetsHelper, | |
Tracking404Model $tracking404Model, | |
RouterInterface $router, | |
$slug) | |
{ | |
/** @var PageModel $model */ | |
$model = $this->getModel('page'); | |
$security = $this->security; | |
/** @var Page|bool $entity */ | |
$entity = $model->getEntityBySlugs($slug); | |
// Do not hit preference center pages | |
if (!empty($entity) && !$entity->getIsPreferenceCenter()) { | |
$userAccess = $security->hasEntityAccess('page:pages:viewown', 'page:pages:viewother', $entity->getCreatedBy()); | |
$published = $entity->isPublished(); | |
// Make sure the page is published or deny access if not | |
if (!$published && !$userAccess) { | |
// If the page has a redirect type, handle it | |
if (null != $entity->getRedirectType()) { | |
$model->hitPage($entity, $request, $entity->getRedirectType()); | |
return $this->redirect($entity->getRedirectUrl(), (int) $entity->getRedirectType()); | |
} else { | |
$model->hitPage($entity, $request, 401); | |
return $this->accessDenied(); | |
} | |
} | |
$lead = null; | |
$query = null; | |
if (!$userAccess) { | |
// Extract the lead from the request so it can be used to determine language if applicable | |
$query = $model->getHitQuery($request, $entity); | |
$lead = $contactRequestHelper->getContactFromQuery($query); | |
} | |
// Correct the URL if it doesn't match up | |
if (!$request->attributes->get('ignore_mismatch', 0)) { | |
// Make sure URLs match up | |
$url = $model->generateUrl($entity, false); | |
$requestUri = $request->getRequestUri(); | |
// Remove query when comparing | |
$query = $request->getQueryString(); | |
if (!empty($query)) { | |
$requestUri = str_replace("?{$query}", '', $url); | |
} | |
// Redirect if they don't match | |
if ($requestUri != $url) { | |
$model->hitPage($entity, $request, 301, $lead, $query); | |
return $this->redirect($url, 301); | |
} | |
} | |
// Check for variants | |
[$parentVariant, $childrenVariants] = $entity->getVariants(); | |
// Is this a variant of another? If so, the parent URL should be used unless a user is logged in and previewing | |
if ($parentVariant != $entity && !$userAccess) { | |
$model->hitPage($entity, $request, 301, $lead, $query); | |
$url = $model->generateUrl($parentVariant, false); | |
return $this->redirect($url, 301); | |
} | |
// First determine the A/B test to display if applicable | |
if (!$userAccess) { | |
// Check to see if a variant should be shown versus the parent but ignore if a user is previewing | |
if (count($childrenVariants)) { | |
$variants = []; | |
$variantWeight = 0; | |
$totalHits = $entity->getVariantHits(); | |
foreach ($childrenVariants as $id => $child) { | |
if ($child->isPublished()) { | |
$variantSettings = $child->getVariantSettings(); | |
$variants[$id] = [ | |
'weight' => ($variantSettings['weight'] / 100), | |
'hits' => $child->getVariantHits(), | |
]; | |
$variantWeight += $variantSettings['weight']; | |
// Count translations for this variant as well | |
$translations = $child->getTranslations(true); | |
/** @var Page $translation */ | |
foreach ($translations as $translation) { | |
if ($translation->isPublished()) { | |
$variants[$id]['hits'] += (int) $translation->getVariantHits(); | |
} | |
} | |
$totalHits += $variants[$id]['hits']; | |
} | |
} | |
if (count($variants)) { | |
// check to see if this user has already been displayed a specific variant | |
$variantCookie = $request->cookies->get('mautic_page_'.$entity->getId()); | |
if (!empty($variantCookie)) { | |
if (isset($variants[$variantCookie])) { | |
// if not the parent, show the specific variant already displayed to the visitor | |
if ($variantCookie !== $entity->getId()) { | |
$entity = $childrenVariants[$variantCookie]; | |
} // otherwise proceed with displaying parent | |
} | |
} else { | |
// Add parent weight | |
$variants[$entity->getId()] = [ | |
'weight' => ((100 - $variantWeight) / 100), | |
'hits' => $entity->getVariantHits(), | |
]; | |
// Count translations for the parent as well | |
$translations = $entity->getTranslations(true); | |
/** @var Page $translation */ | |
foreach ($translations as $translation) { | |
if ($translation->isPublished()) { | |
$variants[$entity->getId()]['hits'] += (int) $translation->getVariantHits(); | |
} | |
} | |
$totalHits += $variants[$id]['hits']; | |
// determine variant to show | |
foreach ($variants as &$variant) { | |
$variant['weight_deficit'] = ($totalHits) ? $variant['weight'] - ($variant['hits'] / $totalHits) : $variant['weight']; | |
} | |
// Reorder according to send_weight so that campaigns which currently send one at a time alternate | |
uasort( | |
$variants, | |
function ($a, $b): int { | |
if ($a['weight_deficit'] === $b['weight_deficit']) { | |
if ($a['hits'] === $b['hits']) { | |
return 0; | |
} | |
// if weight is the same - sort by least number displayed | |
return ($a['hits'] < $b['hits']) ? -1 : 1; | |
} | |
// sort by the one with the greatest deficit first | |
return ($a['weight_deficit'] > $b['weight_deficit']) ? -1 : 1; | |
} | |
); | |
// find the one with the most difference from weight | |
$useId = array_key_first($variants); | |
// set the cookie - 14 days | |
$cookieHelper->setCookie( | |
'mautic_page_'.$entity->getId(), | |
$useId, | |
3600 * 24 * 14 | |
); | |
if ($useId != $entity->getId()) { | |
$entity = $childrenVariants[$useId]; | |
} | |
} | |
} | |
} | |
// Now show the translation for the page or a/b test - only fetch a translation if a slug was not used | |
if ($entity->isTranslation() && empty($entity->languageSlug)) { | |
[$translationParent, $translatedEntity] = $model->getTranslatedEntity( | |
$entity, | |
$lead, | |
$request | |
); | |
if ($translationParent && $translatedEntity !== $entity) { | |
if (!$request->get('ntrd', 0)) { | |
$url = $model->generateUrl($translatedEntity, false); | |
$model->hitPage($entity, $request, 302, $lead, $query); | |
return $this->redirect($url, 302); | |
} | |
} | |
} | |
} | |
// Generate contents | |
$analytics = $analyticsHelper->getCode(); | |
$BCcontent = $entity->getContent(); | |
$content = $entity->getCustomHtml(); | |
// This condition remains so the Mautic v1 themes would display the content | |
if (empty($content) && !empty($BCcontent)) { | |
/** | |
* @deprecated BC support to be removed in 3.0 | |
*/ | |
$template = $entity->getTemplate(); | |
// all the checks pass so display the content | |
$slots = $this->factory->getTheme($template)->getSlots('page'); | |
$content = $entity->getContent(); | |
$this->processSlots($slots, $entity); | |
// Add the GA code to the template assets | |
if (!empty($analytics)) { | |
$this->factory->getHelper('template.assets')->addCustomDeclaration($analytics); | |
} | |
$logicalName = $this->factory->getHelper('theme')->checkForTwigTemplate('@themes/'.$template.'/html/page.html.twig'); | |
$response = $this->render( | |
$logicalName, | |
[ | |
'slots' => $slots, | |
'content' => $content, | |
'page' => $entity, | |
'template' => $template, | |
'public' => true, | |
] | |
); | |
$content = $response->getContent(); | |
} else { | |
if (!empty($analytics)) { | |
$content = str_replace('</head>', $analytics."\n</head>", $content); | |
} | |
if ($entity->getNoIndex()) { | |
$content = str_replace('</head>', "<meta name=\"robots\" content=\"noindex\">\n</head>", $content); | |
} | |
} | |
$assetsHelper->addScript($router->generate('mautic_js', [], UrlGeneratorInterface::ABSOLUTE_URL), | |
'onPageDisplay_headClose', | |
true, | |
'mautic_js' | |
); | |
$event = new PageDisplayEvent($content, $entity); | |
$this->dispatcher->dispatch($event, PageEvents::PAGE_ON_DISPLAY); | |
$content = $event->getContent(); | |
$model->hitPage($entity, $request, 200, $lead, $query); | |
return new Response($content); | |
} | |
if (false !== $entity && $tracking404Model->isTrackable()) { | |
$tracking404Model->hitPage($entity, $request); | |
} | |
return $this->notFound(); | |
} | |
/** | |
* @return Response|\Symfony\Component\HttpKernel\Exception\NotFoundHttpException | |
* | |
* @throws \Exception | |
* @throws \Mautic\CoreBundle\Exception\FileNotFoundException | |
*/ | |
public function previewAction(Request $request, CorePermissions $security, int $id) | |
{ | |
$contactId = (int) $request->query->get('contactId'); | |
if ($contactId) { | |
/** @var LeadModel $leadModel */ | |
$leadModel = $this->getModel('lead.lead'); | |
/** @var Lead $contact */ | |
$contact = $leadModel->getEntity($contactId); | |
} | |
/** @var PageModel $model */ | |
$model = $this->getModel('page'); | |
/** @var Page $page */ | |
$page = $model->getEntity($id); | |
if (!$page->getId()) { | |
return $this->notFound(); | |
} | |
$analytics = $this->factory->getHelper('twig.analytics')->getCode(); | |
$BCcontent = $page->getContent(); | |
$content = $page->getCustomHtml(); | |
if (!$security->isAdmin() | |
&& ( | |
(!$page->isPublished()) | |
|| (!$security->hasEntityAccess( | |
'email:emails:viewown', | |
'email:emails:viewother', | |
$page->getCreatedBy() | |
))) | |
) { | |
return $this->accessDenied(); | |
} | |
if ($contactId && ( | |
!$security->isAdmin() | |
|| !$security->hasEntityAccess('lead:leads:viewown', 'lead:leads:viewother') | |
) | |
) { | |
return $this->accessDenied(); | |
} | |
if (empty($content) && !empty($BCcontent)) { | |
$template = $page->getTemplate(); | |
// all the checks pass so display the content | |
$slots = $this->factory->getTheme($template)->getSlots('page'); | |
$content = $page->getContent(); | |
$this->processSlots($slots, $page); | |
// Add the GA code to the template assets | |
if (!empty($analytics)) { | |
$this->factory->getHelper('template.assets')->addCustomDeclaration($analytics); | |
} | |
$logicalName = $this->factory->getHelper('theme')->checkForTwigTemplate('@themes/'.$template.'/html/page.html.twig'); | |
$response = $this->render( | |
$logicalName, | |
[ | |
'slots' => $slots, | |
'content' => $content, | |
'page' => $page, | |
'template' => $template, | |
'public' => true, // @deprecated Remove in 2.0 | |
] | |
); | |
$content = $response->getContent(); | |
} else { | |
$content = str_replace('</head>', $analytics.$this->renderView('@MauticPage/Page/preview_header.html.twig')."\n</head>", $content); | |
} | |
if ($this->dispatcher->hasListeners(PageEvents::PAGE_ON_DISPLAY)) { | |
$event = new PageDisplayEvent($content, $page, $this->getPreferenceCenterConfig()); | |
if (isset($contact) && $contact instanceof Lead) { | |
$event->setLead($contact); | |
} | |
$this->dispatcher->dispatch($event, PageEvents::PAGE_ON_DISPLAY); | |
$content = $event->getContent(); | |
} | |
return new Response($content); | |
} | |
/** | |
* @return Response | |
* | |
* @throws \Exception | |
*/ | |
public function trackingImageAction(Request $request) | |
{ | |
/** @var PageModel $model */ | |
$model = $this->getModel('page'); | |
$model->hitPage(null, $request); | |
return TrackingPixelHelper::getResponse($request); | |
} | |
/** | |
* @return JsonResponse | |
* | |
* @throws \Exception | |
*/ | |
public function trackingAction( | |
Request $request, | |
DeviceTrackingServiceInterface $deviceTrackingService, | |
TrackingHelper $trackingHelper, | |
ContactTracker $contactTracker | |
) { | |
$notSuccessResponse = new JsonResponse( | |
[ | |
'success' => 0, | |
] | |
); | |
if (!$this->security->isAnonymous()) { | |
return $notSuccessResponse; | |
} | |
/** @var PageModel $model */ | |
$model = $this->getModel('page'); | |
try { | |
$model->hitPage(null, $request); | |
} catch (InvalidDecodedStringException) { | |
// do not track invalid ct | |
return $notSuccessResponse; | |
} | |
$lead = $contactTracker->getContact(); | |
$trackedDevice = $deviceTrackingService->getTrackedDevice(); | |
$trackingId = (null === $trackedDevice ? null : $trackedDevice->getTrackingId()); | |
$sessionValue = $trackingHelper->getCacheItem(true); | |
$event = new TrackingEvent($lead, $request, $sessionValue); | |
$this->dispatcher->dispatch($event, PageEvents::ON_CONTACT_TRACKED); | |
return new JsonResponse( | |
[ | |
'success' => 1, | |
'id' => ($lead) ? $lead->getId() : null, | |
'sid' => $trackingId, | |
'device_id' => $trackingId, | |
'events' => $event->getResponse()->all(), | |
] | |
); | |
} | |
/** | |
* @throws \Exception | |
*/ | |
public function redirectAction( | |
Request $request, | |
ContactRequestHelper $contactRequestHelper, | |
PrimaryCompanyHelper $primaryCompanyHelper, | |
IpLookupHelper $ipLookupHelper, | |
LoggerInterface $logger, | |
$redirectId | |
): \Symfony\Component\HttpFoundation\RedirectResponse { | |
$logger->debug('Attempting to load redirect with tracking_id of: '.$redirectId); | |
/** @var \Mautic\PageBundle\Model\RedirectModel $redirectModel */ | |
$redirectModel = $this->getModel('page.redirect'); | |
$redirect = $redirectModel->getRedirectById($redirectId); | |
$logger->debug('Executing Redirect: '.$redirect); | |
if (null === $redirect || !$redirect->isPublished(false)) { | |
$logger->debug('Redirect with tracking_id of '.$redirectId.' not found'); | |
$url = ($redirect) ? $redirect->getUrl() : 'n/a'; | |
throw $this->createNotFoundException($this->translator->trans('mautic.core.url.error.404', ['%url%' => $url])); | |
} | |
// Ensure the URL does not have encoded ampersands | |
$url = UrlHelper::decodeAmpersands($redirect->getUrl()); | |
// Get query string | |
$query = $request->query->all(); | |
$ct = $query['ct'] ?? null; | |
// Tak on anything left to the URL | |
if (count($query)) { | |
$url = UrlHelper::appendQueryToUrl($url, http_build_query($query)); | |
} | |
// If the IP address is not trackable, it means it came form a configured "do not track" IP or a "do not track" user agent | |
// This prevents simulated clicks from 3rd party services such as URL shorteners from simulating clicks | |
$ipAddress = $ipLookupHelper->getIpAddress(); | |
if ($ct) { | |
if ($ipAddress->isTrackable()) { | |
// Search replace lead fields in the URL | |
/** @var PageModel $pageModel */ | |
$pageModel = $this->getModel('page'); | |
try { | |
$lead = $contactRequestHelper->getContactFromQuery(['ct' => $ct]); | |
$pageModel->hitPage($redirect, $request, 200, $lead); | |
} catch (InvalidDecodedStringException $e) { | |
// Invalid ct value so we must unset it | |
// and process the request without it | |
$logger->error(sprintf('Invalid clickthrough value: %s', $ct), ['exception' => $e]); | |
$request->request->set('ct', ''); | |
$request->query->set('ct', ''); | |
$lead = $contactRequestHelper->getContactFromQuery(); | |
$pageModel->hitPage($redirect, $request, 200, $lead); | |
} | |
$leadArray = ($lead) ? $primaryCompanyHelper->getProfileFieldsWithPrimaryCompany($lead) : []; | |
$url = TokenHelper::findLeadTokens($url, $leadArray, true); | |
} | |
if (str_contains($url, $this->generateUrl('mautic_asset_download'))) { | |
if (strpos($url, '&')) { | |
$url .= '&ct='.$ct; | |
} else { | |
$url .= '?ct='.$ct; | |
} | |
} | |
} | |
$url = UrlHelper::sanitizeAbsoluteUrl($url); | |
if (!UrlHelper::isValidUrl($url)) { | |
throw $this->createNotFoundException($this->translator->trans('mautic.core.url.error.404', ['%url%' => $url])); | |
} | |
return $this->redirect($url); | |
} | |
/** | |
* PreProcess page slots for public view. | |
* | |
* @deprecated - to be removed in 3.0 | |
* | |
* @param array $slots | |
* @param Page $entity | |
*/ | |
private function processSlots($slots, $entity): void | |
{ | |
/** @var AssetsHelper $assetsHelper */ | |
$assetsHelper = $this->factory->getHelper('template.assets'); | |
/** @var \Mautic\CoreBundle\Twig\Helper\SlotsHelper $slotsHelper */ | |
$slotsHelper = $this->factory->getHelper('template.slots'); | |
$content = $entity->getContent(); | |
foreach ($slots as $slot => $slotConfig) { | |
// backward compatibility - if slotConfig array does not exist | |
if (is_numeric($slot)) { | |
$slot = $slotConfig; | |
$slotConfig = []; | |
} | |
if (isset($slotConfig['type']) && 'slideshow' == $slotConfig['type']) { | |
if (isset($content[$slot])) { | |
$options = json_decode($content[$slot], true); | |
} else { | |
$options = [ | |
'width' => '100%', | |
'height' => '250px', | |
'background_color' => 'transparent', | |
'arrow_navigation' => false, | |
'dot_navigation' => true, | |
'interval' => 5000, | |
'pause' => 'hover', | |
'wrap' => true, | |
'keyboard' => true, | |
]; | |
} | |
// Create sample slides for first time or if all slides were deleted | |
if (empty($options['slides'])) { | |
$options['slides'] = [ | |
[ | |
'order' => 0, | |
'background-image' => $assetsHelper->getOverridableUrl('images/mautic_logo_lb200.png'), | |
'captionheader' => 'Caption 1', | |
], | |
[ | |
'order' => 1, | |
'background-image' => $assetsHelper->getOverridableUrl('images/mautic_logo_db200.png'), | |
'captionheader' => 'Caption 2', | |
], | |
]; | |
} | |
// Order slides | |
usort( | |
$options['slides'], | |
fn ($a, $b): int => strcmp($a['order'], $b['order']) | |
); | |
$options['slot'] = $slot; | |
$options['public'] = true; | |
} elseif (isset($slotConfig['type']) && 'textarea' == $slotConfig['type']) { | |
$value = isset($content[$slot]) ? nl2br($content[$slot]) : ''; | |
$slotsHelper->set($slot, $value); | |
} else { | |
// Fallback for other types like html, text, textarea and all unknown | |
$value = $content[$slot] ?? ''; | |
$slotsHelper->set($slot, $value); | |
} | |
} | |
$parentVariant = $entity->getVariantParent(); | |
$title = (!empty($parentVariant)) ? $parentVariant->getTitle() : $entity->getTitle(); | |
$slotsHelper->set('pageTitle', $title); | |
} | |
/** | |
* Track video views. | |
*/ | |
public function hitVideoAction(Request $request) | |
{ | |
// Only track XMLHttpRequests, because the hit should only come from there | |
if ($request->isXmlHttpRequest()) { | |
/** @var VideoModel $model */ | |
$model = $this->getModel('page.video'); | |
try { | |
$model->hitVideo($request); | |
} catch (\Exception) { | |
return new JsonResponse(['success' => false]); | |
} | |
return new JsonResponse(['success' => true]); | |
} | |
return new Response(); | |
} | |
/** | |
* Get the ID of the currently tracked Contact. | |
*/ | |
public function getContactIdAction(DeviceTrackingServiceInterface $trackedDeviceService, ContactTracker $contactTracker): JsonResponse | |
{ | |
$data = []; | |
if ($this->security->isAnonymous()) { | |
$lead = $contactTracker->getContact(); | |
$trackedDevice = $trackedDeviceService->getTrackedDevice(); | |
$trackingId = (null === $trackedDevice ? null : $trackedDevice->getTrackingId()); | |
$data = [ | |
'id' => ($lead) ? $lead->getId() : null, | |
'sid' => $trackingId, | |
'device_id' => $trackingId, | |
]; | |
} | |
return new JsonResponse($data); | |
} | |
/** | |
* @return array<string,bool> | |
*/ | |
private function getPreferenceCenterConfig(): array | |
{ | |
return [ | |
'showContactFrequency' => $this->coreParametersHelper->get('show_contact_frequency'), | |
'showContactPauseDates' => $this->coreParametersHelper->get('show_contact_pause_dates'), | |
'showContactPreferredChannels' => $this->coreParametersHelper->get('show_contact_preferred_channels'), | |
'showContactCategories' => $this->coreParametersHelper->get('show_contact_categories'), | |
'showContactSegments' => $this->coreParametersHelper->get('show_contact_segments'), | |
]; | |
} | |
} | |