Use IpGeolocationMiddleware to geolocate visitors instead of LocateVisit event

This commit is contained in:
Alejandro Celaya
2024-11-15 08:51:57 +01:00
parent 4a0b7e3fc9
commit b5ff568651
21 changed files with 130 additions and 378 deletions

View File

@@ -15,7 +15,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMethodInterface
{
@@ -31,12 +30,9 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
try {
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);
$visit = $this->requestTracker->trackIfApplicable($shortUrl, $request);
$this->requestTracker->trackIfApplicable($shortUrl, $request);
return $this->createSuccessResp(
$shortUrl,
$request->withAttribute(Location::class, $visit?->getVisitLocation()),
);
return $this->createSuccessResp($shortUrl, $request);
} catch (ShortUrlNotFoundException) {
return $this->createErrorResp($request, $handler);
}

View File

@@ -8,7 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface;
use Shlinkio\Shlink\Common\UpdatePublishing\Update;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Throwable;
@@ -25,7 +25,7 @@ abstract class AbstractNotifyVisitListener extends AbstractAsyncListener
) {
}
public function __invoke(VisitLocated $visitLocated): void
public function __invoke(UrlVisited $visitLocated): void
{
if (! $this->isEnabled()) {
return;

View File

@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
use JsonSerializable;
use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable;
abstract class AbstractVisitEvent implements JsonSerializable, JsonUnserializable
{
final public function __construct(
public readonly string $visitId,
public readonly string|null $originalIpAddress = null,
) {
}
public function jsonSerialize(): array
{
return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress];
}
public static function fromPayload(array $payload): self
{
return new static($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null);
}
}

View File

@@ -7,9 +7,9 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
use JsonSerializable;
use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable;
final class ShortUrlCreated implements JsonSerializable, JsonUnserializable
final readonly class ShortUrlCreated implements JsonSerializable, JsonUnserializable
{
public function __construct(public readonly string $shortUrlId)
public function __construct(public string $shortUrlId)
{
}

View File

@@ -4,6 +4,24 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
final class UrlVisited extends AbstractVisitEvent
use JsonSerializable;
use Shlinkio\Shlink\EventDispatcher\Util\JsonUnserializable;
final readonly class UrlVisited implements JsonSerializable, JsonUnserializable
{
final public function __construct(
public string $visitId,
public string|null $originalIpAddress = null,
) {
}
public function jsonSerialize(): array
{
return ['visitId' => $this->visitId, 'originalIpAddress' => $this->originalIpAddress];
}
public static function fromPayload(array $payload): self
{
return new self($payload['visitId'] ?? '', $payload['originalIpAddress'] ?? null);
}
}

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher\Event;
final class VisitLocated extends AbstractVisitEvent
{
}

View File

@@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Throwable;
readonly class LocateVisit
{
public function __construct(
private IpLocationResolverInterface $ipLocationResolver,
private EntityManagerInterface $em,
private LoggerInterface $logger,
private DbUpdaterInterface $dbUpdater,
private EventDispatcherInterface $eventDispatcher,
) {
}
public function __invoke(UrlVisited $shortUrlVisited): void
{
$visitId = $shortUrlVisited->visitId;
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
$this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit);
$this->eventDispatcher->dispatch(new VisitLocated($visitId, $shortUrlVisited->originalIpAddress));
}
private function locateVisit(string $visitId, string|null $originalIpAddress, Visit $visit): void
{
if (! $this->dbUpdater->databaseFileExists()) {
$this->logger->warning('Tried to locate visit with id "{visitId}", but a GeoLite2 db was not found.', [
'visitId' => $visitId,
]);
return;
}
$isLocatable = $originalIpAddress !== null || $visit->isLocatable();
$addr = $originalIpAddress ?? $visit->remoteAddr ?? '';
try {
$location = $isLocatable ? $this->ipLocationResolver->resolveIpLocation($addr) : Location::emptyInstance();
$visit->locate(VisitLocation::fromGeolocation($location));
$this->em->flush();
} catch (WrongIpException $e) {
$this->logger->warning(
'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}',
['e' => $e, 'visitId' => $visitId],
);
} catch (Throwable $e) {
$this->logger->error(
'An unexpected error occurred while trying to locate visit with id "{visitId}". {e}',
['e' => $e, 'visitId' => $visitId],
);
}
}
}

View File

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Matomo;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated;
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
@@ -22,7 +22,7 @@ readonly class SendVisitToMatomo
) {
}
public function __invoke(VisitLocated $visitLocated): void
public function __invoke(UrlVisited $visitLocated): void
{
if (! $this->matomoOptions->enabled) {
return;

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Geolocation\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Config\Options\TrackingOptions;
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Throwable;
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
readonly class IpGeolocationMiddleware implements MiddlewareInterface
{
public function __construct(
private IpLocationResolverInterface $ipLocationResolver,
private DbUpdaterInterface $dbUpdater,
private LoggerInterface $logger,
private TrackingOptions $trackingOptions,
) {
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (! $this->trackingOptions->isGeolocationRelevant()) {
return $handler->handle($request);
}
if (! $this->dbUpdater->databaseFileExists()) {
$this->logger->warning('Tried to geolocate IP address, but a GeoLite2 db was not found.');
return $handler->handle($request);
}
$location = $this->geolocateIpAddress(ipAddressFromRequest($request));
return $handler->handle($request->withAttribute(Location::class, $location));
}
private function geolocateIpAddress(string|null $ipAddress): Location
{
try {
return $ipAddress === null ? Location::empty() : $this->ipLocationResolver->resolveIpLocation($ipAddress);
} catch (WrongIpException $e) {
$this->logger->warning('Tried to locate IP address, but it seems to be wrong. {e}', ['e' => $e]);
return Location::empty();
} catch (Throwable $e) {
$this->logger->error('An unexpected error occurred while trying to locate IP address. {e}', ['e' => $e]);
return Location::empty();
}
}
}

View File

@@ -17,7 +17,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface;
use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface;
use Shlinkio\Shlink\IpGeolocation\Model\Location;
use function array_slice;
use function count;
@@ -74,13 +73,9 @@ readonly class ExtraPathRedirectMiddleware implements MiddlewareInterface
try {
$shortUrl = $this->resolver->resolveEnabledShortUrl($identifier);
$visit = $this->requestTracker->trackIfApplicable($shortUrl, $request);
$this->requestTracker->trackIfApplicable($shortUrl, $request);
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect(
$shortUrl,
$request->withAttribute(Location::class, $visit?->getVisitLocation()),
$extraPath,
);
$longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath);
return $this->redirectResponseHelper->buildRedirectResponse($longUrl);
} catch (ShortUrlNotFoundException) {
if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled) {

View File

@@ -59,6 +59,7 @@ class Visit extends AbstractEntity implements JsonSerializable
Visitor $visitor,
bool $anonymize,
): self {
$geolocation = $visitor->geolocation;
return new self(
shortUrl: $shortUrl,
type: $type,
@@ -67,6 +68,7 @@ class Visit extends AbstractEntity implements JsonSerializable
potentialBot: $visitor->potentialBot,
remoteAddr: self::processAddress($visitor->remoteAddress, $anonymize),
visitedUrl: $visitor->visitedUrl,
visitLocation: $geolocation !== null ? VisitLocation::fromGeolocation($geolocation) : null,
);
}