diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index b2da154b..09817a99 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -247,6 +247,11 @@ "type": "string", "nullable": true, "description": "The originally visited URL that triggered the tracking of this visit" + }, + "redirectUrl": { + "type": "string", + "nullable": true, + "description": "The URL to which the visitor was redirected" } }, "example": { diff --git a/docs/swagger/definitions/Visit.json b/docs/swagger/definitions/Visit.json index c4589bb1..826ad1ac 100644 --- a/docs/swagger/definitions/Visit.json +++ b/docs/swagger/definitions/Visit.json @@ -25,6 +25,10 @@ "visitedUrl": { "type": ["string", "null"], "description": "The originally visited URL that triggered the tracking of this visit" + }, + "redirectUrl": { + "type": ["string", "null"], + "description": "The URL to which the visitor was redirected" } } } diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php index 7d402384..34d98572 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php @@ -75,4 +75,10 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->columnName('potential_bot') ->option('default', false) ->build(); + + fieldWithUtf8Charset($builder->createField('redirectUrl', Types::STRING), $emConfig) + ->columnName('redirect_url') + ->length(Visitor::REDIRECT_URL_MAX_LENGTH) + ->nullable() + ->build(); }; diff --git a/module/Core/migrations/Version20241124112257.php b/module/Core/migrations/Version20241124112257.php new file mode 100644 index 00000000..49c5eb05 --- /dev/null +++ b/module/Core/migrations/Version20241124112257.php @@ -0,0 +1,39 @@ +getTable('visits'); + $this->skipIf($visits->hasColumn(self::COLUMN_NAME)); + + $visits->addColumn('redirected_url', Types::STRING, [ + 'length' => 2048, + 'notnull' => false, + 'default' => null, + ]); + } + + public function down(Schema $schema): void + { + $visits = $schema->getTable('visits'); + $this->skipIf(! $visits->hasColumn(self::COLUMN_NAME)); + $visits->dropColumn(self::COLUMN_NAME); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/src/Visit/Entity/Visit.php b/module/Core/src/Visit/Entity/Visit.php index 9e8540bc..033d451b 100644 --- a/module/Core/src/Visit/Entity/Visit.php +++ b/module/Core/src/Visit/Entity/Visit.php @@ -28,6 +28,7 @@ class Visit extends AbstractEntity implements JsonSerializable public readonly bool $potentialBot, public readonly string|null $remoteAddr = null, public readonly string|null $visitedUrl = null, + public readonly string|null $redirectUrl = null, private VisitLocation|null $visitLocation = null, public readonly Chronos $date = new Chronos(), ) { @@ -68,6 +69,7 @@ class Visit extends AbstractEntity implements JsonSerializable potentialBot: $visitor->potentialBot, remoteAddr: self::processAddress($visitor->remoteAddress, $anonymize), visitedUrl: $visitor->visitedUrl, + redirectUrl: null, // TODO visitLocation: $geolocation !== null ? VisitLocation::fromGeolocation($geolocation) : null, ); } @@ -156,6 +158,7 @@ class Visit extends AbstractEntity implements JsonSerializable 'visitLocation' => $this->visitLocation, 'potentialBot' => $this->potentialBot, 'visitedUrl' => $this->visitedUrl, + 'redirectUrl' => $this->redirectUrl, ]; if (! $this->isOrphan()) { return $base; diff --git a/module/Core/src/Visit/Model/Visitor.php b/module/Core/src/Visit/Model/Visitor.php index e13712e1..b33d10a1 100644 --- a/module/Core/src/Visit/Model/Visitor.php +++ b/module/Core/src/Visit/Model/Visitor.php @@ -19,6 +19,7 @@ final readonly class Visitor public const REFERER_MAX_LENGTH = 1024; public const REMOTE_ADDRESS_MAX_LENGTH = 256; public const VISITED_URL_MAX_LENGTH = 2048; + public const REDIRECT_URL_MAX_LENGTH = 2048; private function __construct( public string $userAgent, @@ -27,6 +28,7 @@ final readonly class Visitor public string $visitedUrl, public bool $potentialBot, public Location|null $geolocation, + public string $redirectUrl, ) { } @@ -36,6 +38,7 @@ final readonly class Visitor string|null $remoteAddress = null, string $visitedUrl = '', Location|null $geolocation = null, + string $redirectUrl = '', ): self { return new self( userAgent: self::cropToLength($userAgent, self::USER_AGENT_MAX_LENGTH), @@ -46,6 +49,7 @@ final readonly class Visitor visitedUrl: self::cropToLength($visitedUrl, self::VISITED_URL_MAX_LENGTH), potentialBot: isCrawler($userAgent), geolocation: $geolocation, + redirectUrl: self::cropToLength($redirectUrl, self::REDIRECT_URL_MAX_LENGTH), ); } @@ -62,6 +66,8 @@ final readonly class Visitor remoteAddress: ipAddressFromRequest($request), visitedUrl: $request->getUri()->__toString(), geolocation: geolocationFromRequest($request), + // TODO + redirectUrl: '', ); } @@ -85,6 +91,7 @@ final readonly class Visitor // Keep the fact that the visit was a potential bot, even if we no longer save the user agent potentialBot: $this->potentialBot, geolocation: $this->geolocation, + redirectUrl: $this->redirectUrl, ); } } diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 2e232038..310c8b3f 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -80,6 +80,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'date' => $visit->date->toAtomString(), 'potentialBot' => false, 'visitedUrl' => '', + 'redirectUrl' => null, ], ], $update->payload); } @@ -105,6 +106,7 @@ class PublishingUpdatesGeneratorTest extends TestCase 'potentialBot' => false, 'visitedUrl' => $orphanVisit->visitedUrl, 'type' => $orphanVisit->type->value, + 'redirectUrl' => null, ], ], $update->payload); } diff --git a/module/Core/test/Visit/Entity/VisitTest.php b/module/Core/test/Visit/Entity/VisitTest.php index db23af97..438ca55f 100644 --- a/module/Core/test/Visit/Entity/VisitTest.php +++ b/module/Core/test/Visit/Entity/VisitTest.php @@ -34,6 +34,7 @@ class VisitTest extends TestCase 'visitLocation' => null, 'potentialBot' => $expectedToBePotentialBot, 'visitedUrl' => $visit->visitedUrl, + 'redirectUrl' => $visit->redirectUrl, ], $visit->jsonSerialize()); } @@ -67,6 +68,7 @@ class VisitTest extends TestCase 'potentialBot' => false, 'visitedUrl' => '', 'type' => VisitType::BASE_URL->value, + 'redirectUrl' => null, ], ]; yield 'invalid short url visit' => [ @@ -83,6 +85,7 @@ class VisitTest extends TestCase 'potentialBot' => false, 'visitedUrl' => 'https://example.com/foo', 'type' => VisitType::INVALID_SHORT_URL->value, + 'redirectUrl' => null, ], ]; yield 'regular 404 visit' => [ @@ -101,6 +104,7 @@ class VisitTest extends TestCase 'potentialBot' => false, 'visitedUrl' => 'https://s.test/foo/bar', 'type' => VisitType::REGULAR_404->value, + 'redirectUrl' => null, ], ]; } diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index cf7cee0f..3761e113 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -21,6 +21,7 @@ class OrphanVisitsTest extends ApiTestCase 'potentialBot' => true, 'visitedUrl' => 'foo.com', 'type' => 'invalid_short_url', + 'redirectUrl' => null, ]; private const REGULAR_NOT_FOUND = [ 'referer' => 'https://s.test/foo/bar', @@ -30,6 +31,7 @@ class OrphanVisitsTest extends ApiTestCase 'potentialBot' => false, 'visitedUrl' => '', 'type' => 'regular_404', + 'redirectUrl' => null, ]; private const BASE_URL = [ 'referer' => 'https://s.test', @@ -39,6 +41,7 @@ class OrphanVisitsTest extends ApiTestCase 'potentialBot' => false, 'visitedUrl' => '', 'type' => 'base_url', + 'redirectUrl' => null, ]; #[Test, DataProvider('provideQueries')]