Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,11 @@ reviews:
high_level_summary_in_walkthrough: false
changed_files_summary: false
poem: false
finishing_touches:
docstrings:
enabled: false
auto_review:
enabled: true
base_branches:
- ".*"
drafts: false

checks:
docstring_coverage:
enabled: false
104 changes: 104 additions & 0 deletions src/Statistics/Controller/MessageOpenTrackController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php

declare(strict_types=1);

namespace PhpList\RestBundle\Statistics\Controller;

use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use PhpList\RestBundle\Common\Controller\BaseController;
use PhpList\Core\Domain\Analytics\Service\UserMessageService;
use PhpList\Core\Security\Authentication;
use PhpList\RestBundle\Common\Validator\RequestValidator;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
use Throwable;

#[Route('/t', name: 'tracks_')]
class MessageOpenTrackController extends BaseController
{
public function __construct(
Authentication $authentication,
RequestValidator $validator,
private readonly UserMessageService $userMessageService,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
parent::__construct($authentication, $validator);
}

#[Route('/open.gif', name: 'user_message_open', methods: ['GET'])]
#[OA\Get(
path: '/api/v2/t/open.gif',
description: '1x1 tracking pixel endpoint that records a message view.'
. ' Requires `u` (subscriber UID) and `m` (message ID) as query parameters.',
summary: 'Track user message open',
tags: ['tracking'],
parameters: [
new OA\Parameter(
name: 'u',
description: 'Subscriber unique identifier (UID)',
in: 'query',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'm',
description: 'Message ID',
in: 'query',
required: true,
schema: new OA\Schema(type: 'integer')
),
],
responses: [
new OA\Response(response: 200, description: 'Transparent 1x1 GIF'),
]
)]
public function trackUserMessageView(
Request $request,
#[MapQueryParameter(name: 'u')] ?string $uid = null,
#[MapQueryParameter(name: 'm')] ?int $messageId = null,
): Response {
if ($uid === null || $messageId === null || $messageId <= 0) {
return $this->returnPixelResponse();
}

$metadata = [
'HTTP_USER_AGENT' => $request->server->get('HTTP_USER_AGENT'),
'HTTP_REFERER' => $request->server->get('HTTP_REFERER'),
'client_ip' => $request->getClientIp(),
];

try {
$this->userMessageService->trackUserMessageView($uid, $messageId, $metadata);
$this->entityManager->flush();
} catch (Throwable $e) {
$this->logger->error(
'Failed to track user message view',
[
'exception' => $e,
'message_id' => $messageId,
]
);
}

return $this->returnPixelResponse();
}

private function returnPixelResponse(): Response
{
return new Response(
content: base64_decode('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='),
status: 200,
headers: [
'Content-Type' => 'image/gif',
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma' => 'no-cache',
'Expires' => '0',
]
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace PhpList\RestBundle\Tests\Integration\Statistics\Controller;

use PhpList\RestBundle\Statistics\Controller\MessageOpenTrackController;
use PhpList\RestBundle\Tests\Integration\Common\AbstractTestController;
use PhpList\RestBundle\Tests\Integration\Identity\Fixtures\AdministratorFixture;
use PhpList\RestBundle\Tests\Integration\Messaging\Fixtures\MessageFixture;
use PhpList\RestBundle\Tests\Integration\Messaging\Fixtures\TemplateFixture;
use PhpList\RestBundle\Tests\Integration\Statistics\Fixtures\UserMessageFixture;
use PhpList\RestBundle\Tests\Integration\Subscription\Fixtures\SubscriberFixture;

class MessageOpenTrackControllerTest extends AbstractTestController
{
// from SubscriberFixture (id=1)
private const TEST_UID = '95feb7fe7e06e6c11ca8d0c48cb46e89';
// from MessageFixture
private const TEST_MESSAGE_ID = 1;

public function testControllerIsAvailableViaContainer(): void
{
self::assertInstanceOf(
MessageOpenTrackController::class,
self::getContainer()->get(MessageOpenTrackController::class)
);
}

public function testOpenGifReturnsTransparentGifWithNoCacheHeaders(): void
{
$this->loadFixtures([
AdministratorFixture::class,
TemplateFixture::class,
MessageFixture::class,
SubscriberFixture::class,
UserMessageFixture::class,
]);

self::getClient()->request(
'GET',
sprintf('/api/v2/t/open.gif?u=%s&m=%d', self::TEST_UID, self::TEST_MESSAGE_ID)
);

$response = self::getClient()->getResponse();

self::assertSame(200, $response->getStatusCode());
self::assertSame('image/gif', $response->headers->get('Content-Type'));
self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
self::assertSame('no-cache', $response->headers->get('Pragma'));
self::assertSame('0', $response->headers->get('Expires'));

$expectedGif = base64_decode('R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==');
self::assertSame($expectedGif, $response->getContent());
}

public function testOpenGifReturnsGifEvenIfUidUnknown(): void
{
// No fixtures needed for this; the controller should still return the GIF even if tracking no-ops
self::getClient()->request('GET', '/api/v2/t/open.gif?u=unknown-uid&m=999999');

$response = self::getClient()->getResponse();
self::assertSame(200, $response->getStatusCode());
self::assertSame('image/gif', $response->headers->get('Content-Type'));
}

public function testOpenGifMissingParametersReturns200Anyway(): void
{
self::getClient()->request('GET', '/api/v2/t/open.gif');
$status = self::getClient()->getResponse()->getStatusCode();

self::assertSame(200, $status);
}
}
43 changes: 43 additions & 0 deletions tests/Integration/Statistics/Fixtures/UserMessageFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace PhpList\RestBundle\Tests\Integration\Statistics\Fixtures;

use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use PhpList\Core\Domain\Messaging\Model\Message;
use PhpList\Core\Domain\Messaging\Model\Message\UserMessageStatus;
use PhpList\Core\Domain\Messaging\Model\UserMessage;
use PhpList\Core\Domain\Subscription\Model\Subscriber;

/**
* Links an existing test Subscriber (id=1) with an existing test Message (id=1)
* via a UserMessage record in status "sent".
*/
class UserMessageFixture extends Fixture
{
public const SUBSCRIBER_ID = 1;
public const MESSAGE_ID = 1;

public function load(ObjectManager $manager): void
{
/** @var Subscriber|null $subscriber */
$subscriber = $manager->getRepository(Subscriber::class)->find(self::SUBSCRIBER_ID);
/** @var Message|null $message */
$message = $manager->getRepository(Message::class)->find(self::MESSAGE_ID);

// Doctrine may return null here when prerequisite fixtures are not loaded.
// PHPStan infers non-null from PHPDoc in some environments; suppress that false positive.
if ($subscriber === null || $message === null) {
// Pre-requisite fixtures aren't loaded; nothing to do.
return;
}

$userMessage = new UserMessage($subscriber, $message);
$userMessage->setStatus(UserMessageStatus::Sent);

$manager->persist($userMessage);
$manager->flush();
}
}
Loading