Merge pull request #1087 from acelaya-forks/feature/granular-tracking

Feature/granular tracking
This commit is contained in:
Alejandro Celaya 2021-05-16 13:26:52 +02:00 committed by GitHub
commit 2803f65479
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 255 additions and 72 deletions

View File

@ -15,6 +15,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#1059](https://github.com/shlinkio/shlink/issues/1059) Added ability to optionally display author API key and its name when listing short URLs from the command line. * [#1059](https://github.com/shlinkio/shlink/issues/1059) Added ability to optionally display author API key and its name when listing short URLs from the command line.
* [#1066](https://github.com/shlinkio/shlink/issues/1066) Added support to import short URLs and their visits from another Shlink instance using its API. * [#1066](https://github.com/shlinkio/shlink/issues/1066) Added support to import short URLs and their visits from another Shlink instance using its API.
* [#898](https://github.com/shlinkio/shlink/issues/898) Improved tracking granularity, allowing to disable visits tracking completely, or just parts of it.
In order to achieve it, Shlink now supports 4 new tracking-related options, that can be customized via env vars for docker, or via installer:
* `disable_tracking`: If true, visits will not be tracked at all.
* `disable_ip_tracking`: If true, visits will be tracked, but neither the IP address, nor the location will be resolved.
* `disable_referrer_tracking`: If true, the referrer will not be tracked.
* `disable_ua_tracking`: If true, the user agent will not be tracked.
### Changed ### Changed
* [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0. * [#1036](https://github.com/shlinkio/shlink/issues/1036) Updated to `happyr/doctrine-specification` 2.0.

View File

@ -50,7 +50,7 @@
"shlinkio/shlink-config": "^1.0", "shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^2.1", "shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "dev-main#39928b6 as 2.3", "shlinkio/shlink-importer": "dev-main#39928b6 as 2.3",
"shlinkio/shlink-installer": "dev-develop#aa50ea9 as 5.5", "shlinkio/shlink-installer": "dev-develop#15cf3b3 as 6.0",
"shlinkio/shlink-ip-geolocation": "^1.5", "shlinkio/shlink-ip-geolocation": "^1.5",
"symfony/console": "^5.1", "symfony/console": "^5.1",
"symfony/filesystem": "^5.1", "symfony/filesystem": "^5.1",

View File

@ -7,7 +7,6 @@ return [
'app_options' => [ 'app_options' => [
'name' => 'Shlink', 'name' => 'Shlink',
'version' => '%SHLINK_VERSION%', 'version' => '%SHLINK_VERSION%',
'disable_track_param' => null,
], ],
]; ];

View File

@ -27,7 +27,6 @@ return [
Option\Redirect\BaseUrlRedirectConfigOption::class, Option\Redirect\BaseUrlRedirectConfigOption::class,
Option\Redirect\InvalidShortUrlRedirectConfigOption::class, Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
Option\Redirect\Regular404RedirectConfigOption::class, Option\Redirect\Regular404RedirectConfigOption::class,
Option\DisableTrackParamConfigOption::class,
Option\Visit\CheckVisitsThresholdConfigOption::class, Option\Visit\CheckVisitsThresholdConfigOption::class,
Option\Visit\VisitsThresholdConfigOption::class, Option\Visit\VisitsThresholdConfigOption::class,
Option\BasePathConfigOption::class, Option\BasePathConfigOption::class,
@ -40,11 +39,16 @@ return [
Option\Mercure\MercureInternalUrlConfigOption::class, Option\Mercure\MercureInternalUrlConfigOption::class,
Option\Mercure\MercureJwtSecretConfigOption::class, Option\Mercure\MercureJwtSecretConfigOption::class,
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class, Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
Option\UrlShortener\IpAnonymizationConfigOption::class,
Option\UrlShortener\RedirectStatusCodeConfigOption::class, Option\UrlShortener\RedirectStatusCodeConfigOption::class,
Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class, Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class,
Option\UrlShortener\AutoResolveTitlesConfigOption::class, Option\UrlShortener\AutoResolveTitlesConfigOption::class,
Option\UrlShortener\OrphanVisitsTrackingConfigOption::class, Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,
Option\Tracking\DisableTrackingConfigOption::class,
Option\Tracking\DisableIpTrackingConfigOption::class,
Option\Tracking\DisableReferrerTrackingConfigOption::class,
Option\Tracking\DisableUaTrackingConfigOption::class,
], ],
'installation_commands' => [ 'installation_commands' => [

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
return [
'tracking' => [
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
// This applies only if IP address tracking is enabled
'anonymize_remote_addr' => true,
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
'track_orphan_visits' => true,
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
'disable_track_param' => null,
// If true, visits will not be tracked at all
'disable_tracking' => false,
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
'disable_ip_tracking' => false,
// If true, the referrer will not be tracked
'disable_referrer_tracking' => false,
// If true, the user agent will not be tracked
'disable_ua_tracking' => false,
],
];

View File

@ -14,13 +14,11 @@ return [
'hostname' => '', 'hostname' => '',
], ],
'validate_url' => false, // Deprecated 'validate_url' => false, // Deprecated
'anonymize_remote_addr' => true,
'visits_webhooks' => [], 'visits_webhooks' => [],
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH, 'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE, 'redirect_status_code' => DEFAULT_REDIRECT_STATUS_CODE,
'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME, 'redirect_cache_lifetime' => DEFAULT_REDIRECT_CACHE_LIFETIME,
'auto_resolve_titles' => false, 'auto_resolve_titles' => false,
'track_orphan_visits' => true,
], ],
]; ];

View File

@ -94,10 +94,6 @@ $helper = new class {
return [ return [
'app_options' => [
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
],
'delete_short_urls' => [ 'delete_short_urls' => [
'check_visits_threshold' => true, 'check_visits_threshold' => true,
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD), 'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
@ -113,13 +109,21 @@ return [
'hostname' => env('SHORT_DOMAIN_HOST', ''), 'hostname' => env('SHORT_DOMAIN_HOST', ''),
], ],
'validate_url' => (bool) env('VALIDATE_URLS', false), 'validate_url' => (bool) env('VALIDATE_URLS', false),
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
'visits_webhooks' => $helper->getVisitsWebhooks(), 'visits_webhooks' => $helper->getVisitsWebhooks(),
'default_short_codes_length' => $helper->getDefaultShortCodesLength(), 'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE), 'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME), 'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false), 'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
],
'tracking' => [
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true), 'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
'disable_tracking' => (bool) env('DISABLE_TRACKING', false),
'disable_ip_tracking' => (bool) env('DISABLE_IP_TRACKING', false),
'disable_referrer_tracking' => (bool) env('DISABLE_REFERRER_TRACKING', false),
'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false),
], ],
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(), 'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),

View File

@ -24,6 +24,7 @@ return [
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class, Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class, Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class,
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class, Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
Options\TrackingOptions::class => ConfigAbstractFactory::class,
Service\UrlShortener::class => ConfigAbstractFactory::class, Service\UrlShortener::class => ConfigAbstractFactory::class,
Service\ShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrlService::class => ConfigAbstractFactory::class,
@ -75,6 +76,7 @@ return [
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'], Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'], Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
Options\UrlShortenerOptions::class => ['config.url_shortener'], Options\UrlShortenerOptions::class => ['config.url_shortener'],
Options\TrackingOptions::class => ['config.tracking'],
Service\UrlShortener::class => [ Service\UrlShortener::class => [
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class, ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
@ -85,7 +87,7 @@ return [
Visit\VisitsTracker::class => [ Visit\VisitsTracker::class => [
'em', 'em',
EventDispatcherInterface::class, EventDispatcherInterface::class,
Options\UrlShortenerOptions::class, Options\TrackingOptions::class,
], ],
Service\ShortUrlService::class => [ Service\ShortUrlService::class => [
'em', 'em',
@ -112,14 +114,14 @@ return [
Action\RedirectAction::class => [ Action\RedirectAction::class => [
Service\ShortUrl\ShortUrlResolver::class, Service\ShortUrl\ShortUrlResolver::class,
Visit\VisitsTracker::class, Visit\VisitsTracker::class,
Options\AppOptions::class, Options\TrackingOptions::class,
Util\RedirectResponseHelper::class, Util\RedirectResponseHelper::class,
'Logger_Shlink', 'Logger_Shlink',
], ],
Action\PixelAction::class => [ Action\PixelAction::class => [
Service\ShortUrl\ShortUrlResolver::class, Service\ShortUrl\ShortUrlResolver::class,
Visit\VisitsTracker::class, Visit\VisitsTracker::class,
Options\AppOptions::class, Options\TrackingOptions::class,
'Logger_Shlink', 'Logger_Shlink',
], ],
Action\QrCodeAction::class => [ Action\QrCodeAction::class => [

View File

@ -18,7 +18,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Visit\VisitsTrackerInterface;
@ -29,18 +29,18 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
{ {
private ShortUrlResolverInterface $urlResolver; private ShortUrlResolverInterface $urlResolver;
private VisitsTrackerInterface $visitTracker; private VisitsTrackerInterface $visitTracker;
private AppOptions $appOptions; private TrackingOptions $trackingOptions;
private LoggerInterface $logger; private LoggerInterface $logger;
public function __construct( public function __construct(
ShortUrlResolverInterface $urlResolver, ShortUrlResolverInterface $urlResolver,
VisitsTrackerInterface $visitTracker, VisitsTrackerInterface $visitTracker,
AppOptions $appOptions, TrackingOptions $trackingOptions,
?LoggerInterface $logger = null ?LoggerInterface $logger = null
) { ) {
$this->urlResolver = $urlResolver; $this->urlResolver = $urlResolver;
$this->visitTracker = $visitTracker; $this->visitTracker = $visitTracker;
$this->appOptions = $appOptions; $this->trackingOptions = $trackingOptions;
$this->logger = $logger ?? new NullLogger(); $this->logger = $logger ?? new NullLogger();
} }
@ -48,7 +48,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface, RequestMet
{ {
$identifier = ShortUrlIdentifier::fromRedirectRequest($request); $identifier = ShortUrlIdentifier::fromRedirectRequest($request);
$query = $request->getQueryParams(); $query = $request->getQueryParams();
$disableTrackParam = $this->appOptions->getDisableTrackParam(); $disableTrackParam = $this->trackingOptions->getDisableTrackParam();
try { try {
$shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier); $shortUrl = $this->urlResolver->resolveEnabledShortUrl($identifier);

View File

@ -21,11 +21,11 @@ class RedirectAction extends AbstractTrackingAction implements StatusCodeInterfa
public function __construct( public function __construct(
ShortUrlResolverInterface $urlResolver, ShortUrlResolverInterface $urlResolver,
VisitsTrackerInterface $visitTracker, VisitsTrackerInterface $visitTracker,
Options\AppOptions $appOptions, Options\TrackingOptions $trackingOptions,
RedirectResponseHelperInterface $redirectResponseHelper, RedirectResponseHelperInterface $redirectResponseHelper,
?LoggerInterface $logger = null ?LoggerInterface $logger = null
) { ) {
parent::__construct($urlResolver, $visitTracker, $appOptions, $logger); parent::__construct($urlResolver, $visitTracker, $trackingOptions, $logger);
$this->redirectResponseHelper = $redirectResponseHelper; $this->redirectResponseHelper = $redirectResponseHelper;
} }

View File

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Config;
use function Functional\compose; use function Functional\compose;
/** @deprecated */
class DeprecatedConfigParser class DeprecatedConfigParser
{ {
public function __invoke(array $config): array public function __invoke(array $config): array

View File

@ -19,7 +19,7 @@ use function uksort;
class SimplifiedConfigParser class SimplifiedConfigParser
{ {
private const SIMPLIFIED_CONFIG_MAPPING = [ private const SIMPLIFIED_CONFIG_MAPPING = [
'disable_track_param' => ['app_options', 'disable_track_param'], 'disable_track_param' => ['tracking', 'disable_track_param'],
'short_domain_schema' => ['url_shortener', 'domain', 'schema'], 'short_domain_schema' => ['url_shortener', 'domain', 'schema'],
'short_domain_host' => ['url_shortener', 'domain', 'hostname'], 'short_domain_host' => ['url_shortener', 'domain', 'hostname'],
'validate_url' => ['url_shortener', 'validate_url'], 'validate_url' => ['url_shortener', 'validate_url'],
@ -38,7 +38,7 @@ class SimplifiedConfigParser
'mercure_public_hub_url' => ['mercure', 'public_hub_url'], 'mercure_public_hub_url' => ['mercure', 'public_hub_url'],
'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'], 'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'],
'mercure_jwt_secret' => ['mercure', 'jwt_secret'], 'mercure_jwt_secret' => ['mercure', 'jwt_secret'],
'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'], 'anonymize_remote_addr' => ['tracking', 'anonymize_remote_addr'],
'redirect_status_code' => ['url_shortener', 'redirect_status_code'], 'redirect_status_code' => ['url_shortener', 'redirect_status_code'],
'redirect_cache_lifetime' => ['url_shortener', 'redirect_cache_lifetime'], 'redirect_cache_lifetime' => ['url_shortener', 'redirect_cache_lifetime'],
'port' => ['mezzio-swoole', 'swoole-http-server', 'port'], 'port' => ['mezzio-swoole', 'swoole-http-server', 'port'],

View File

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory; use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use function substr; use function substr;
@ -68,4 +69,16 @@ final class Visitor
{ {
return $this->visitedUrl; return $this->visitedUrl;
} }
public function normalizeForTrackingOptions(TrackingOptions $options): self
{
$instance = self::emptyInstance();
$instance->userAgent = $options->disableUaTracking() ? '' : $this->userAgent;
$instance->referer = $options->disableReferrerTracking() ? '' : $this->referer;
$instance->remoteAddress = $options->disableIpTracking() ? null : $this->remoteAddress;
$instance->visitedUrl = $this->visitedUrl;
return $instance;
}
} }

View File

@ -12,7 +12,6 @@ class AppOptions extends AbstractOptions
{ {
private string $name = ''; private string $name = '';
private string $version = '1.0'; private string $version = '1.0';
private ?string $disableTrackParam = null;
public function getName(): string public function getName(): string
{ {
@ -36,16 +35,10 @@ class AppOptions extends AbstractOptions
return $this; return $this;
} }
/** /** @deprecated */
*/
public function getDisableTrackParam(): ?string
{
return $this->disableTrackParam;
}
protected function setDisableTrackParam(?string $disableTrackParam): self protected function setDisableTrackParam(?string $disableTrackParam): self
{ {
$this->disableTrackParam = $disableTrackParam; // Keep just for backwards compatibility during hydration
return $this; return $this;
} }

View File

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions;
class TrackingOptions extends AbstractOptions
{
private bool $anonymizeRemoteAddr = true;
private bool $trackOrphanVisits = true;
private ?string $disableTrackParam = null;
private bool $disableTracking = false;
private bool $disableIpTracking = false;
private bool $disableReferrerTracking = false;
private bool $disableUaTracking = false;
public function anonymizeRemoteAddr(): bool
{
return $this->anonymizeRemoteAddr;
}
protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void
{
$this->anonymizeRemoteAddr = $anonymizeRemoteAddr;
}
public function trackOrphanVisits(): bool
{
return $this->trackOrphanVisits;
}
protected function setTrackOrphanVisits(bool $trackOrphanVisits): void
{
$this->trackOrphanVisits = $trackOrphanVisits;
}
public function getDisableTrackParam(): ?string
{
return $this->disableTrackParam;
}
protected function setDisableTrackParam(?string $disableTrackParam): void
{
$this->disableTrackParam = $disableTrackParam;
}
public function disableTracking(): bool
{
return $this->disableTracking;
}
protected function setDisableTracking(bool $disableTracking): void
{
$this->disableTracking = $disableTracking;
}
public function disableIpTracking(): bool
{
return $this->disableIpTracking;
}
protected function setDisableIpTracking(bool $disableIpTracking): void
{
$this->disableIpTracking = $disableIpTracking;
}
public function disableReferrerTracking(): bool
{
return $this->disableReferrerTracking;
}
protected function setDisableReferrerTracking(bool $disableReferrerTracking): void
{
$this->disableReferrerTracking = $disableReferrerTracking;
}
public function disableUaTracking(): bool
{
return $this->disableUaTracking;
}
protected function setDisableUaTracking(bool $disableUaTracking): void
{
$this->disableUaTracking = $disableUaTracking;
}
}

View File

@ -19,8 +19,6 @@ class UrlShortenerOptions extends AbstractOptions
private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE; private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE;
private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME; private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME;
private bool $autoResolveTitles = false; private bool $autoResolveTitles = false;
private bool $anonymizeRemoteAddr = true;
private bool $trackOrphanVisits = true;
public function isUrlValidationEnabled(): bool public function isUrlValidationEnabled(): bool
{ {
@ -69,23 +67,15 @@ class UrlShortenerOptions extends AbstractOptions
$this->autoResolveTitles = $autoResolveTitles; $this->autoResolveTitles = $autoResolveTitles;
} }
public function anonymizeRemoteAddr(): bool /** @deprecated */
{
return $this->anonymizeRemoteAddr;
}
protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void
{ {
$this->anonymizeRemoteAddr = $anonymizeRemoteAddr; // Keep just for backwards compatibility during hydration
}
public function trackOrphanVisits(): bool
{
return $this->trackOrphanVisits;
} }
/** @deprecated */
protected function setTrackOrphanVisits(bool $trackOrphanVisits): void protected function setTrackOrphanVisits(bool $trackOrphanVisits): void
{ {
$this->trackOrphanVisits = $trackOrphanVisits; // Keep just for backwards compatibility during hydration
} }
} }

View File

@ -10,18 +10,18 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\TrackingOptions;
class VisitsTracker implements VisitsTrackerInterface class VisitsTracker implements VisitsTrackerInterface
{ {
private ORM\EntityManagerInterface $em; private ORM\EntityManagerInterface $em;
private EventDispatcherInterface $eventDispatcher; private EventDispatcherInterface $eventDispatcher;
private UrlShortenerOptions $options; private TrackingOptions $options;
public function __construct( public function __construct(
ORM\EntityManagerInterface $em, ORM\EntityManagerInterface $em,
EventDispatcherInterface $eventDispatcher, EventDispatcherInterface $eventDispatcher,
UrlShortenerOptions $options TrackingOptions $options
) { ) {
$this->em = $em; $this->em = $em;
$this->eventDispatcher = $eventDispatcher; $this->eventDispatcher = $eventDispatcher;
@ -31,40 +31,51 @@ class VisitsTracker implements VisitsTrackerInterface
public function track(ShortUrl $shortUrl, Visitor $visitor): void public function track(ShortUrl $shortUrl, Visitor $visitor): void
{ {
$this->trackVisit( $this->trackVisit(
Visit::forValidShortUrl($shortUrl, $visitor, $this->options->anonymizeRemoteAddr()), fn (Visitor $v) => Visit::forValidShortUrl($shortUrl, $v, $this->options->anonymizeRemoteAddr()),
$visitor, $visitor,
); );
} }
public function trackInvalidShortUrlVisit(Visitor $visitor): void public function trackInvalidShortUrlVisit(Visitor $visitor): void
{ {
if (! $this->options->trackOrphanVisits()) { $this->trackOrphanVisit(
return; fn (Visitor $v) => Visit::forInvalidShortUrl($v, $this->options->anonymizeRemoteAddr()),
} $visitor,
);
$this->trackVisit(Visit::forInvalidShortUrl($visitor, $this->options->anonymizeRemoteAddr()), $visitor);
} }
public function trackBaseUrlVisit(Visitor $visitor): void public function trackBaseUrlVisit(Visitor $visitor): void
{ {
if (! $this->options->trackOrphanVisits()) { $this->trackOrphanVisit(
return; fn (Visitor $v) => Visit::forBasePath($v, $this->options->anonymizeRemoteAddr()),
} $visitor,
);
$this->trackVisit(Visit::forBasePath($visitor, $this->options->anonymizeRemoteAddr()), $visitor);
} }
public function trackRegularNotFoundVisit(Visitor $visitor): void public function trackRegularNotFoundVisit(Visitor $visitor): void
{
$this->trackOrphanVisit(
fn (Visitor $v) => Visit::forRegularNotFound($v, $this->options->anonymizeRemoteAddr()),
$visitor,
);
}
private function trackOrphanVisit(callable $createVisit, Visitor $visitor): void
{ {
if (! $this->options->trackOrphanVisits()) { if (! $this->options->trackOrphanVisits()) {
return; return;
} }
$this->trackVisit(Visit::forRegularNotFound($visitor, $this->options->anonymizeRemoteAddr()), $visitor); $this->trackVisit($createVisit, $visitor);
} }
private function trackVisit(Visit $visit, Visitor $visitor): void private function trackVisit(callable $createVisit, Visitor $visitor): void
{ {
if ($this->options->disableTracking()) {
return;
}
$visit = $createVisit($visitor->normalizeForTrackingOptions($this->options));
$this->em->transactional(function () use ($visit, $visitor): void { $this->em->transactional(function () use ($visit, $visitor): void {
$this->em->persist($visit); $this->em->persist($visit);
$this->em->flush(); $this->em->flush();

View File

@ -14,7 +14,7 @@ use Shlinkio\Shlink\Common\Response\PixelResponse;
use Shlinkio\Shlink\Core\Action\PixelAction; use Shlinkio\Shlink\Core\Action\PixelAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Visit\VisitsTracker; use Shlinkio\Shlink\Core\Visit\VisitsTracker;
@ -34,7 +34,7 @@ class PixelActionTest extends TestCase
$this->action = new PixelAction( $this->action = new PixelAction(
$this->urlResolver->reveal(), $this->urlResolver->reveal(),
$this->visitTracker->reveal(), $this->visitTracker->reveal(),
new AppOptions(), new TrackingOptions(),
); );
} }

View File

@ -42,7 +42,7 @@ class RedirectActionTest extends TestCase
$this->action = new RedirectAction( $this->action = new RedirectAction(
$this->urlResolver->reveal(), $this->urlResolver->reveal(),
$this->visitTracker->reveal(), $this->visitTracker->reveal(),
new Options\AppOptions(['disableTrackParam' => 'foobar']), new Options\TrackingOptions(['disableTrackParam' => 'foobar']),
$this->redirectRespHelper->reveal(), $this->redirectRespHelper->reveal(),
); );
} }

View File

@ -22,7 +22,7 @@ class SimplifiedConfigParserTest extends TestCase
public function properlyMapsSimplifiedConfig(): void public function properlyMapsSimplifiedConfig(): void
{ {
$config = [ $config = [
'app_options' => [ 'tracking' => [
'disable_track_param' => 'foo', 'disable_track_param' => 'foo',
], ],
@ -70,8 +70,9 @@ class SimplifiedConfigParserTest extends TestCase
'port' => 8888, 'port' => 8888,
]; ];
$expected = [ $expected = [
'app_options' => [ 'tracking' => [
'disable_track_param' => 'bar', 'disable_track_param' => 'bar',
'anonymize_remote_addr' => false,
], ],
'entity_manager' => [ 'entity_manager' => [
@ -96,7 +97,6 @@ class SimplifiedConfigParserTest extends TestCase
'https://third-party.io/foo', 'https://third-party.io/foo',
], ],
'default_short_codes_length' => 8, 'default_short_codes_length' => 8,
'anonymize_remote_addr' => false,
'redirect_status_code' => 301, 'redirect_status_code' => 301,
'redirect_cache_lifetime' => 90, 'redirect_cache_lifetime' => 90,
], ],

View File

@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Model;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use function random_int; use function random_int;
use function str_repeat; use function str_repeat;
@ -71,4 +72,28 @@ class VisitorTest extends TestCase
} }
return $randomString; return $randomString;
} }
/** @test */
public function newNormalizedInstanceIsCreatedFromTrackingOptions(): void
{
$visitor = new Visitor(
$this->generateRandomString(2000),
$this->generateRandomString(2000),
$this->generateRandomString(2000),
$this->generateRandomString(2000),
);
$normalizedVisitor = $visitor->normalizeForTrackingOptions(new TrackingOptions([
'disableIpTracking' => true,
'disableReferrerTracking' => true,
'disableUaTracking' => true,
]));
self::assertNotSame($visitor, $normalizedVisitor);
self::assertEmpty($normalizedVisitor->getUserAgent());
self::assertNotEmpty($visitor->getUserAgent());
self::assertEmpty($normalizedVisitor->getReferer());
self::assertNotEmpty($visitor->getReferer());
self::assertNull($normalizedVisitor->getRemoteAddress());
self::assertNotNull($visitor->getRemoteAddress());
}
} }

View File

@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\Event\UrlVisited;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Visit\VisitsTracker; use Shlinkio\Shlink\Core\Visit\VisitsTracker;
class VisitsTrackerTest extends TestCase class VisitsTrackerTest extends TestCase
@ -24,7 +24,7 @@ class VisitsTrackerTest extends TestCase
private VisitsTracker $visitsTracker; private VisitsTracker $visitsTracker;
private ObjectProphecy $em; private ObjectProphecy $em;
private ObjectProphecy $eventDispatcher; private ObjectProphecy $eventDispatcher;
private UrlShortenerOptions $options; private TrackingOptions $options;
public function setUp(): void public function setUp(): void
{ {
@ -35,7 +35,7 @@ class VisitsTrackerTest extends TestCase
}); });
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
$this->options = new UrlShortenerOptions(); $this->options = new TrackingOptions();
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), $this->options); $this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), $this->options);
} }
@ -57,6 +57,22 @@ class VisitsTrackerTest extends TestCase
$this->eventDispatcher->dispatch(Argument::type(UrlVisited::class))->shouldHaveBeenCalled(); $this->eventDispatcher->dispatch(Argument::type(UrlVisited::class))->shouldHaveBeenCalled();
} }
/**
* @test
* @dataProvider provideTrackingMethodNames
*/
public function trackingIsSkippedCompletelyWhenDisabledFromOptions(string $method, array $args): void
{
$this->options->disableTracking = true;
$this->visitsTracker->{$method}(...$args);
$this->eventDispatcher->dispatch(Argument::cetera())->shouldNotHaveBeenCalled();
$this->em->transactional(Argument::cetera())->shouldNotHaveBeenCalled();
$this->em->persist(Argument::cetera())->shouldNotHaveBeenCalled();
$this->em->flush()->shouldNotHaveBeenCalled();
}
public function provideTrackingMethodNames(): iterable public function provideTrackingMethodNames(): iterable
{ {
yield 'track' => ['track', [ShortUrl::createEmpty(), Visitor::emptyInstance()]]; yield 'track' => ['track', [ShortUrl::createEmpty(), Visitor::emptyInstance()]];