Files
CloudOps/docker-compose/mautic-setup/mautic-backup-files/docroot/app/bundles/PageBundle/Controller/PublicController.php

627 lines
24 KiB
PHP
Executable File

<?php
namespace Mautic\PageBundle\Controller;
use Mautic\CoreBundle\Controller\AbstractFormController;
use Mautic\CoreBundle\Exception\FileNotFoundException;
use Mautic\CoreBundle\Exception\InvalidDecodedStringException;
use Mautic\CoreBundle\Helper\CookieHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Helper\ThemeHelper;
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\PageConfig;
use Mautic\PageBundle\Helper\TrackingHelper;
use Mautic\PageBundle\Model\PageModel;
use Mautic\PageBundle\Model\RedirectModel;
use Mautic\PageBundle\Model\Tracking404Model;
use Mautic\PageBundle\Model\VideoModel;
use Mautic\PageBundle\PageEvents;
use Psr\Log\LoggerInterface;
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\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Routing\RouterInterface;
class PublicController extends AbstractFormController
{
/**
* @param string $slug
*
* @return Response
*
* @throws \Exception
* @throws FileNotFoundException
*/
public function indexAction(
Request $request,
ContactRequestHelper $contactRequestHelper,
CookieHelper $cookieHelper,
AnalyticsHelper $analyticsHelper,
AssetsHelper $assetsHelper,
ThemeHelper $themeHelper,
Tracking404Model $tracking404Model,
RouterInterface $router,
DeviceTrackingServiceInterface $deviceTrackingService,
$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());
if ($entity->getRedirectUrl()) {
return $this->redirect($entity->getRedirectUrl(), (int) $entity->getRedirectType());
} else {
return $this->notFound();
}
} 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 ((string) $variantCookie !== (string) $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
);
\assert($translatedEntity instanceof Page);
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
$content = $entity->getContent();
// Add the GA code to the template assets
if (!empty($analytics)) {
$assetsHelper->addCustomDeclaration($analytics);
}
$logicalName = $themeHelper->checkForTwigTemplate('@themes/'.$template.'/html/page.html.twig');
$response = $this->render(
$logicalName,
[
'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((string) $content, $entity);
$this->dispatcher->dispatch($event, PageEvents::PAGE_ON_DISPLAY);
$content = $event->getContent();
$model->hitPage($entity, $request, Response::HTTP_OK, $lead, $query);
$response = new Response($content);
if ($request->cookies->has('Blocked-Tracking')) {
$deviceTrackingService->clearTrackingCookies();
}
return $response;
}
if (false !== $entity && $tracking404Model->isTrackable()) {
$tracking404Model->hitPage($entity, $request);
}
return $this->notFound();
}
/**
* @return mixed[]|JsonResponse|RedirectResponse|Response
*
* @throws FileNotFoundException
*/
public function previewAction(Request $request, PageConfig $pageConfig, CorePermissions $security, AnalyticsHelper $analyticsHelper, AssetsHelper $assetsHelper, ThemeHelper $themeHelper, int $id, ?string $objectType = null)
{
/** @var PageModel $model */
$model = $this->getModel('page');
/** @var Page $page */
$page = $model->getEntity($id);
if (!$page || !$page->getId()) {
return $this->notFound();
}
$contactId = (int) $request->query->get('contactId');
if ($contactId) {
/** @var LeadModel $leadModel */
$leadModel = $this->getModel('lead.lead');
$contact = $leadModel->getEntity($contactId);
}
$draftEnabled = $pageConfig->isDraftEnabled();
$analytics = $analyticsHelper->getCode();
$BCcontent = $page->getContent();
$content = $page->getCustomHtml();
$publicPreview = $page->isPublicPreview();
if ('draft' === $objectType && $draftEnabled && $page->hasDraft()) {
$content = $page->getDraftContent();
$publicPreview = $page->getDraft()->isPublicPreview();
}
if (($security->isAnonymous() && (!$page->isPublished() || !$publicPreview)) || (!$security->isAnonymous(
) && !$security->hasEntityAccess(
'page:pages:viewown',
'page:pages:viewother',
$page->getCreatedBy()
))) {
return $this->accessDenied();
}
if ($contactId && (!$security->isAdmin() || !$security->hasEntityAccess(
'lead:leads:viewown',
'lead:leads:viewother'
))) {
// disallow displaying contact information
return $this->accessDenied();
}
if (empty($content) && !empty($BCcontent)) {
$template = $page->getTemplate();
// all the checks pass so display the content
$content = $page->getContent();
// Add the GA code to the template assets
if (!empty($analytics)) {
$assetsHelper->addCustomDeclaration($analytics);
}
$logicalName = $themeHelper->checkForTwigTemplate('@themes/'.$template.'/html/page.html.twig');
$response = $this->render(
$logicalName,
[
'content' => $content,
'page' => $page,
'template' => $template,
'public' => true, // @deprecated Remove in 2.0
]
);
$content = $response->getContent();
} else {
$content = str_replace('</head>', $analytics."\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,
): RedirectResponse {
$logger->debug('Attempting to load redirect with tracking_id of: '.$redirectId);
/** @var RedirectModel $redirectModel */
$redirectModel = $this->getModel(RedirectModel::class);
$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 = null;
// Remove click-trough parameter, so it won't be duplicated later.
if (isset($query['ct'])) {
$ct = $query['ct'];
unset($query['ct']);
}
// 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();
$isHitTrackable = false;
if (null !== $ct && '' !== $ct) {
if ($ipAddress->isTrackable()) {
// Search replace lead fields in the URL
/** @var PageModel $pageModel */
$pageModel = $this->getModel(PageModel::class);
try {
$lead = $contactRequestHelper->getContactFromQuery(['ct' => $ct]);
$isHitTrackable = $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->remove('ct');
$request->query->remove('ct');
$lead = $contactRequestHelper->getContactFromQuery();
$isHitTrackable = $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 (str_contains($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]));
}
$response = $this->redirect($url);
$response->headers->setCookie(new Cookie('Blocked-Tracking', (string) !$isHitTrackable, strtotime('now + 15 seconds')));
return $response;
}
/**
* Track video views.
*/
public function hitVideoAction(Request $request): JsonResponse|Response
{
// 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'),
];
}
}