Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,341 @@
<?php
namespace Mautic\LeadBundle\Tracker;
use Mautic\CoreBundle\Entity\IpAddress;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\IpLookupHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\LeadBundle\Event\LeadChangeEvent;
use Mautic\LeadBundle\Event\LeadEvent;
use Mautic\LeadBundle\Event\LeadGetCurrentEvent;
use Mautic\LeadBundle\LeadEvents;
use Mautic\LeadBundle\Model\DefaultValueTrait;
use Mautic\LeadBundle\Model\FieldModel;
use Mautic\LeadBundle\Tracker\Service\ContactTrackingService\ContactTrackingServiceInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
class ContactTracker
{
use DefaultValueTrait;
private ?Lead $systemContact = null;
private ?Lead $trackedContact = null;
private FieldModel $leadFieldModel;
private ?bool $useSystemContact = null;
public function __construct(
private LeadRepository $leadRepository,
private ContactTrackingServiceInterface $contactTrackingService,
private DeviceTracker $deviceTracker,
private CorePermissions $security,
private LoggerInterface $logger,
private IpLookupHelper $ipLookupHelper,
private RequestStack $requestStack,
private CoreParametersHelper $coreParametersHelper,
private EventDispatcherInterface $dispatcher,
FieldModel $leadFieldModel,
) {
$this->leadFieldModel = $leadFieldModel;
}
/**
* @return Lead|null
*/
public function getContact()
{
if (null !== $this->getRequest() && $this->getRequest()->cookies->get('Blocked-Tracking')) {
return null;
}
if ($systemContact = $this->getSystemContact()) {
return $systemContact;
} elseif ($this->isUserSession()) {
return null;
}
if (empty($this->trackedContact)) {
$this->trackedContact = $this->getCurrentContact();
$this->generateTrackingCookies();
}
if ($request = $this->getRequest()) {
$this->logger->debug('CONTACT: Tracking session for contact ID# '.$this->trackedContact->getId().' through '.$request->getMethod().' '.$request->getRequestUri());
}
// Log last active for the tracked contact
if (!defined('MAUTIC_LEAD_LASTACTIVE_LOGGED')) {
$this->leadRepository->updateLastActive($this->trackedContact->getId());
define('MAUTIC_LEAD_LASTACTIVE_LOGGED', 1);
}
return $this->trackedContact;
}
/**
* Set the contact and generate cookies for future tracking.
*/
public function setTrackedContact(Lead $trackedContact): void
{
$this->logger->debug("CONTACT: {$trackedContact->getId()} set as current lead.");
if ($this->useSystemContact()) {
// Overwrite system current lead
$this->setSystemContact($trackedContact);
return;
}
// Take note of previously tracked in order to dispatched change event
$previouslyTrackedContact = (is_null($this->trackedContact)) ? null : $this->trackedContact;
$previouslyTrackedId = $this->getTrackingId();
// Set the newly tracked contact
$this->trackedContact = $trackedContact;
// Hydrate custom field data
$fields = $trackedContact->getFields();
if (empty($fields)) {
$this->hydrateCustomFieldData($trackedContact);
}
// Set last active
$this->trackedContact->setLastActive(new \DateTime());
// If for whatever reason this contact has not been saved yet, don't generate tracking cookies
if (!$trackedContact->getId()) {
// Delete existing cookies to prevent tracking as someone else
$this->deviceTracker->clearTrackingCookies();
return;
}
// Generate cookies for the newly tracked contact
$this->generateTrackingCookies();
if ($previouslyTrackedContact && $previouslyTrackedContact->getId() != $this->trackedContact->getId()) {
$this->dispatchContactChangeEvent($previouslyTrackedContact, $previouslyTrackedId);
}
}
/**
* System contact bypasses cookie tracking.
*/
public function setSystemContact(?Lead $lead = null): void
{
if (null !== $lead) {
$this->logger->debug("LEAD: {$lead->getId()} set as system lead.");
$fields = $lead->getFields();
if (empty($fields)) {
$this->hydrateCustomFieldData($lead);
}
}
$this->systemContact = $lead;
}
/**
* @return string|null
*/
public function getTrackingId()
{
// Use the new method first
if ($trackedDevice = $this->deviceTracker->getTrackedDevice()) {
return $trackedDevice->getTrackingId();
}
// That failed, so look for the old cookies
return $this->contactTrackingService->getTrackedIdentifier();
}
public function setUseSystemContact(?bool $useSystemContact): void
{
$this->useSystemContact = $useSystemContact;
}
/**
* @return Lead|null
*/
private function getSystemContact()
{
if ($this->useSystemContact() && $this->systemContact) {
$this->logger->debug('CONTACT: System lead is being used');
return $this->systemContact;
}
if ($this->isUserSession()) {
$this->logger->debug('CONTACT: In a Mautic user session');
}
return null;
}
/**
* @return Lead|null
*/
private function getCurrentContact()
{
$event = new LeadGetCurrentEvent($this->getRequest());
$this->dispatcher->dispatch($event);
if ($contact = $event->getContact()) {
return $contact;
}
if ($lead = $this->getContactByTrackedDevice()) {
return $lead;
}
return $this->getContactByIpAddress();
}
/**
* @return Lead|null
*/
public function getContactByTrackedDevice()
{
$lead = null;
// Return null for leads that are from a non-trackable IP, prevent anonymous lead with a non-trackable IP to be tracked
$ip = $this->ipLookupHelper->getIpAddress();
if ($ip && !$ip->isTrackable()) {
return $lead;
}
// Is there a device being tracked?
if ($trackedDevice = $this->deviceTracker->getTrackedDevice()) {
$lead = $trackedDevice->getLead();
// Lead associations are not hydrated with custom field values by default
$this->hydrateCustomFieldData($lead);
}
if (null === $lead) {
// Check to see if a contact is being tracked via the old cookie method in order to migrate them to the new
$lead = $this->contactTrackingService->getTrackedLead();
}
if ($lead) {
$this->logger->debug("CONTACT: Existing lead found with ID# {$lead->getId()}.");
}
return $lead;
}
/**
* @return Lead
*/
private function getContactByIpAddress()
{
$ip = $this->ipLookupHelper->getIpAddress();
// if no trackingId cookie set the lead is not tracked yet so create a new one
if ($ip && !$ip->isTrackable()) {
// Don't save leads that are from a non-trackable IP by default
return $this->createNewContact($ip, false);
}
if ($this->coreParametersHelper->get('track_contact_by_ip')) {
/** @var Lead[] $leads */
$leads = $this->leadRepository->getLeadsByIp($ip->getIpAddress());
if (count($leads)) {
$lead = $leads[0];
$this->logger->debug("CONTACT: Existing lead found with ID# {$lead->getId()}.");
return $lead;
}
}
return $this->createNewContact($ip);
}
/**
* @param bool $persist
*/
private function createNewContact(?IpAddress $ip = null, $persist = true): Lead
{
// let's create a lead
$lead = new Lead();
$lead->setNewlyCreated(true);
if ($ip) {
$lead->addIpAddress($ip);
}
if ($persist && !defined('MAUTIC_NON_TRACKABLE_REQUEST')) {
// Dispatch events for new lead to write create log, ip address change, etc
$event = new LeadEvent($lead, true);
$this->dispatcher->dispatch($event, LeadEvents::LEAD_PRE_SAVE);
$this->setEntityDefaultValues($lead);
$this->leadRepository->saveEntity($lead);
$this->hydrateCustomFieldData($lead);
$this->dispatcher->dispatch($event, LeadEvents::LEAD_POST_SAVE);
$this->logger->debug("CONTACT: New lead created with ID# {$lead->getId()}.");
}
return $lead;
}
private function hydrateCustomFieldData(?Lead $lead = null): void
{
if (null === $lead) {
return;
}
// Hydrate fields with custom field data
$fields = $this->leadRepository->getFieldValues($lead->getId());
$lead->setFields($fields);
}
private function useSystemContact(): bool
{
if (null !== $this->useSystemContact) {
return $this->useSystemContact;
}
return $this->isUserSession() || $this->systemContact || defined('IN_MAUTIC_CONSOLE') || null === $this->getRequest();
}
private function isUserSession(): bool
{
return !$this->security->isAnonymous();
}
private function dispatchContactChangeEvent(Lead $previouslyTrackedContact, $previouslyTrackedId): void
{
$newTrackingId = $this->getTrackingId();
$this->logger->debug(
"CONTACT: Tracking code changed from $previouslyTrackedId for contact ID# {$previouslyTrackedContact->getId()} to $newTrackingId for contact ID# {$this->trackedContact->getId()}"
);
if (null !== $previouslyTrackedId) {
if ($this->dispatcher->hasListeners(LeadEvents::CURRENT_LEAD_CHANGED)) {
$event = new LeadChangeEvent($previouslyTrackedContact, $previouslyTrackedId, $this->trackedContact, $newTrackingId);
$this->dispatcher->dispatch($event, LeadEvents::CURRENT_LEAD_CHANGED);
}
}
}
private function generateTrackingCookies(): void
{
if ($this->trackedContact->getId() && $request = $this->getRequest()) {
$this->deviceTracker->createDeviceFromUserAgent($this->trackedContact, $request->server->get('HTTP_USER_AGENT'));
}
}
private function getRequest(): ?Request
{
return $this->requestStack->getCurrentRequest();
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace Mautic\LeadBundle\Tracker;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadDevice;
use Mautic\LeadBundle\Tracker\Factory\DeviceDetectorFactory\DeviceDetectorFactoryInterface;
use Mautic\LeadBundle\Tracker\Service\DeviceCreatorService\DeviceCreatorServiceInterface;
use Mautic\LeadBundle\Tracker\Service\DeviceTrackingService\DeviceTrackingServiceInterface;
use Psr\Log\LoggerInterface;
class DeviceTracker
{
private bool $deviceWasChanged = false;
/**
* @var LeadDevice[]
*/
private array $trackedDevice = [];
public function __construct(
private DeviceCreatorServiceInterface $deviceCreatorService,
private DeviceDetectorFactoryInterface $deviceDetectorFactory,
private DeviceTrackingServiceInterface $deviceTrackingService,
private LoggerInterface $logger,
) {
}
/**
* @return LeadDevice|null
*/
public function createDeviceFromUserAgent(Lead $trackedContact, $userAgent)
{
$signature = $trackedContact->getId().$userAgent;
if (isset($this->trackedDevice[$signature])) {
// Prevent subsequent calls within the same session from creating multiple entries
return $this->trackedDevice[$signature];
}
$this->trackedDevice[$signature] = $trackedDevice = $this->deviceTrackingService->getTrackedDevice();
$deviceDetector = $this->deviceDetectorFactory->create($userAgent);
$deviceDetector->parse();
$currentDevice = $this->deviceCreatorService->getCurrentFromDetector($deviceDetector, $trackedContact);
if ( // Do not create a new device if
// ... the device is new
$trackedDevice && $trackedDevice->getId() // ... the device is the same
&& $trackedDevice->getSignature() === $currentDevice->getSignature() // ... the contact given is the same as the owner of the device tracked
&& $trackedDevice->getLead()->getId() === $trackedContact->getId()
) {
return $trackedDevice;
}
// New device so record it and track it
$this->deviceWasChanged = true;
$this->trackedDevice[$signature] = $this->deviceTrackingService->trackCurrentDevice($currentDevice, true);
return $this->trackedDevice[$signature];
}
/**
* @return LeadDevice|null
*/
public function getTrackedDevice()
{
$trackedDevice = $this->deviceTrackingService->getTrackedDevice();
if (null !== $trackedDevice) {
$this->logger->debug("LEAD: Tracking ID for this device is {$trackedDevice->getTrackingId()}");
}
return $trackedDevice;
}
public function wasDeviceChanged(): bool
{
return $this->deviceWasChanged;
}
public function clearTrackingCookies(): void
{
$this->deviceTrackingService->clearTrackingCookies();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Mautic\LeadBundle\Tracker\Factory\DeviceDetectorFactory;
use DeviceDetector\Cache\PSR6Bridge;
use DeviceDetector\DeviceDetector;
use Mautic\CacheBundle\Cache\CacheProvider;
final class DeviceDetectorFactory implements DeviceDetectorFactoryInterface
{
public function __construct(
private CacheProvider $cacheProvider,
) {
}
/**
* @param string $userAgent
*
* @throws \Exception
*/
public function create($userAgent): DeviceDetector
{
$detector = new DeviceDetector((string) $userAgent);
$bridge = new PSR6Bridge($this->cacheProvider->getCacheAdapter());
$detector->setCache($bridge);
return $detector;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Mautic\LeadBundle\Tracker\Factory\DeviceDetectorFactory;
use DeviceDetector\DeviceDetector;
/**
* Interface DeviceDetectorFactoryInterface.
*/
interface DeviceDetectorFactoryInterface
{
/**
* @param string $userAgent
*
* @return DeviceDetector
*/
public function create($userAgent);
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Mautic\LeadBundle\Tracker\Service\ContactTrackingService;
use Mautic\CoreBundle\Helper\CookieHelper;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadDeviceRepository;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\LeadBundle\Entity\MergeRecordRepository;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Used to ensure that contacts tracked under the old method are continued to be tracked under the new.
*/
final class ContactTrackingService implements ContactTrackingServiceInterface
{
public function __construct(
private CookieHelper $cookieHelper,
private LeadDeviceRepository $leadDeviceRepository,
private LeadRepository $leadRepository,
private MergeRecordRepository $mergeRecordRepository,
private RequestStack $requestStack,
) {
}
/**
* @return Lead|null
*/
public function getTrackedLead()
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return null;
}
$trackingId = $this->getTrackedIdentifier();
if (null === $trackingId) {
return null;
}
$leadId = $this->cookieHelper->getCookie($trackingId, null);
if (null === $leadId) {
$leadId = $request->get('mtc_id', null);
if (null === $leadId) {
return null;
}
}
$lead = $this->leadRepository->getEntity($leadId);
if (null === $lead) {
// Check if this contact was merged into another and if so, return the new contact
$lead = $this->mergeRecordRepository->findMergedContact($leadId);
if (null === $lead) {
return null;
}
// Hydrate fields with custom field data
$fields = $this->leadRepository->getFieldValues($lead->getId());
$lead->setFields($fields);
}
$anotherDeviceAlreadyTracked = $this->leadDeviceRepository->isAnyLeadDeviceTracked($lead);
return $anotherDeviceAlreadyTracked ? null : $lead;
}
/**
* @return string|null
*/
public function getTrackedIdentifier()
{
return $this->cookieHelper->getCookie('mautic_session_id', null);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Mautic\LeadBundle\Tracker\Service\ContactTrackingService;
use Mautic\LeadBundle\Entity\Lead;
/**
* Interface ContactTrackingInterface.
*/
interface ContactTrackingServiceInterface
{
/**
* Return current tracked Lead.
*
* @return Lead|null
*/
public function getTrackedLead();
/**
* @return string|null Unique identifier
*/
public function getTrackedIdentifier();
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Mautic\LeadBundle\Tracker\Service\DeviceCreatorService;
use DeviceDetector\DeviceDetector;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadDevice;
final class DeviceCreatorService implements DeviceCreatorServiceInterface
{
public function getCurrentFromDetector(DeviceDetector $deviceDetector, Lead $assignedLead): LeadDevice
{
$device = new LeadDevice();
$device->setClientInfo($deviceDetector->getClient());
$device->setDevice($deviceDetector->getDeviceName());
$device->setDeviceBrand($deviceDetector->getBrandName());
$device->setDeviceModel($deviceDetector->getModel());
$device->setDeviceOs($deviceDetector->getOs());
$device->setDateAdded(new \DateTime());
$device->setLead($assignedLead);
return $device;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Mautic\LeadBundle\Tracker\Service\DeviceCreatorService;
use DeviceDetector\DeviceDetector;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadDevice;
/**
* Interface DeviceCreatorServiceInterface.
*/
interface DeviceCreatorServiceInterface
{
/**
* @return LeadDevice|null Null is returned if device can't be detected
*/
public function getCurrentFromDetector(DeviceDetector $deviceDetector, Lead $assignedLead);
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Mautic\LeadBundle\Tracker\Service\DeviceTrackingService;
use Doctrine\ORM\EntityManagerInterface;
use Mautic\CoreBundle\Helper\CookieHelper;
use Mautic\CoreBundle\Helper\RandomHelper\RandomHelperInterface;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\LeadBundle\Entity\LeadDevice;
use Mautic\LeadBundle\Entity\LeadDeviceRepository;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\RequestStack;
final class DeviceTrackingService implements DeviceTrackingServiceInterface
{
private ?LeadDevice $trackedDevice = null;
public function __construct(
private CookieHelper $cookieHelper,
private EntityManagerInterface $entityManager,
private LeadDeviceRepository $leadDeviceRepository,
private RandomHelperInterface $randomHelper,
private RequestStack $requestStack,
private CorePermissions $security,
) {
}
public function isTracked(): bool
{
return null !== $this->getTrackedDevice();
}
/**
* @return ?LeadDevice
*/
public function getTrackedDevice()
{
if (!$this->security->isAnonymous()) {
// Do not track Mautic users
return null;
}
if ($this->trackedDevice) {
return $this->trackedDevice;
}
$trackingId = $this->getTrackedIdentifier();
if (null === $trackingId) {
return null;
}
return $this->leadDeviceRepository->getByTrackingId($trackingId);
}
/**
* @param bool $replaceExistingTracking
*
* @return LeadDevice
*/
public function trackCurrentDevice(LeadDevice $device, $replaceExistingTracking = false)
{
$trackedDevice = $this->getTrackedDevice();
if (null !== $trackedDevice && false === $replaceExistingTracking) {
return $trackedDevice;
}
if (null !== $existingDevice = $this->leadDeviceRepository->findExistingDevice($device)) {
$device = $existingDevice;
}
if (empty($device->getTrackingId())) {
// Ensure all devices have a tracking ID (new devices will not and pre 2.13.0 devices may not)
$device->setTrackingId($this->getUniqueTrackingIdentifier());
$this->entityManager->persist($device);
$this->entityManager->flush();
}
$this->createTrackingCookies($device);
// Store the device in case a service uses this within the same session
$this->trackedDevice = $device;
return $device;
}
public function clearTrackingCookies(): void
{
$this->cookieHelper->deleteCookie('mautic_device_id');
$this->cookieHelper->deleteCookie('mtc_id');
}
private function getTrackedIdentifier(): ?string
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return null;
}
if ($this->trackedDevice) {
// Use the device tracked in case the cookies were just created
return $this->trackedDevice->getTrackingId();
}
$deviceTrackingId = $this->cookieHelper->getCookie('mautic_device_id', null);
if (null === $deviceTrackingId) {
$deviceTrackingId = $request->get('mautic_device_id', null);
}
return $deviceTrackingId;
}
private function getUniqueTrackingIdentifier(): string
{
do {
$generatedIdentifier = $this->randomHelper->generate(23);
$device = $this->leadDeviceRepository->getByTrackingId($generatedIdentifier);
} while (null !== $device);
return $generatedIdentifier;
}
private function createTrackingCookies(LeadDevice $device): void
{
// Device cookie
$this->cookieHelper->setCookie('mautic_device_id', $device->getTrackingId(), 31_536_000, sameSite: Cookie::SAMESITE_NONE);
// Mainly for landing pages so that JS has the same access as 3rd party tracking code
$this->cookieHelper->setCookie('mtc_id', $device->getLead()->getId(), null, sameSite: Cookie::SAMESITE_NONE);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Mautic\LeadBundle\Tracker\Service\DeviceTrackingService;
use Mautic\LeadBundle\Entity\LeadDevice;
/**
* Interface DeviceTrackingServiceInterface.
*/
interface DeviceTrackingServiceInterface
{
/**
* @return bool
*/
public function isTracked();
/**
* @return LeadDevice|null
*/
public function getTrackedDevice();
/**
* @param bool $replaceExistingTracking
*
* @return LeadDevice
*/
public function trackCurrentDevice(LeadDevice $device, $replaceExistingTracking = false);
public function clearTrackingCookies();
}