Merge pull request #1199 from acelaya-forks/feature/address-based-tracking

Feature/address based tracking
This commit is contained in:
Alejandro Celaya 2021-10-10 22:42:27 +02:00 committed by GitHub
commit f49e94052d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 115 additions and 15 deletions

View file

@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
## [2.9.0] - 2021-10-10
### Added
* [#1015](https://github.com/shlinkio/shlink/issues/1015) Shlink now accepts configuration via env vars even when not using docker.
@ -26,6 +26,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
The option is disabled by default, as the payload is backwards incompatible. You will need to adapt your webhooks to treat the `shortUrl` property as optional before enabling this option.
* [#1104](https://github.com/shlinkio/shlink/issues/1104) Added ability to disable tracking based on IP addresses.
IP addresses can be provided in the form of fixed addresses, CIDR blocks, or wildcard patterns (192.168.*.*).
### Changed
* [#1142](https://github.com/shlinkio/shlink/issues/1142) Replaced `doctrine/cache` package with `symfony/cache`.
* [#1157](https://github.com/shlinkio/shlink/issues/1157) All routes now support CORS, not only rest ones.

View file

@ -46,11 +46,12 @@
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.7",
"ramsey/uuid": "^3.9",
"rlanvin/php-ip": "3.0.0-rc2",
"shlinkio/shlink-common": "^4.0",
"shlinkio/shlink-config": "^1.2",
"shlinkio/shlink-event-dispatcher": "^2.1",
"shlinkio/shlink-importer": "^2.3.1",
"shlinkio/shlink-installer": "dev-develop#b45a340 as 6.2",
"shlinkio/shlink-installer": "^6.2",
"shlinkio/shlink-ip-geolocation": "^2.0",
"symfony/console": "^5.3",
"symfony/filesystem": "^5.3",
@ -63,7 +64,7 @@
"devster/ubench": "^2.1",
"dms/phpunit-arraysubset-asserts": "^0.3.0",
"eaglewu/swoole-ide-helper": "dev-master",
"infection/infection": "^0.24.0",
"infection/infection": "^0.25.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/phpstan": "^0.12.94",
"phpstan/phpstan-doctrine": "^0.12.42",

View file

@ -47,6 +47,7 @@ return [
Option\Tracking\IpAnonymizationConfigOption::class,
Option\Tracking\OrphanVisitsTrackingConfigOption::class,
Option\Tracking\DisableTrackParamConfigOption::class,
Option\Tracking\DisableTrackingFromConfigOption::class,
Option\Tracking\DisableTrackingConfigOption::class,
Option\Tracking\DisableIpTrackingConfigOption::class,
Option\Tracking\DisableReferrerTrackingConfigOption::class,

View file

@ -28,6 +28,9 @@ return [
// If true, the user agent will not be tracked
'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false),
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
'disable_tracking_from' => env('DISABLE_TRACKING_FROM'),
],
];

View file

@ -6,6 +6,10 @@ namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions;
use function array_key_exists;
use function explode;
use function is_array;
class TrackingOptions extends AbstractOptions
{
private bool $anonymizeRemoteAddr = true;
@ -15,6 +19,7 @@ class TrackingOptions extends AbstractOptions
private bool $disableIpTracking = false;
private bool $disableReferrerTracking = false;
private bool $disableUaTracking = false;
private array $disableTrackingFrom = [];
public function anonymizeRemoteAddr(): bool
{
@ -41,6 +46,11 @@ class TrackingOptions extends AbstractOptions
return $this->disableTrackParam;
}
public function queryHasDisableTrackParam(array $query): bool
{
return $this->disableTrackParam !== null && array_key_exists($this->disableTrackParam, $query);
}
protected function setDisableTrackParam(?string $disableTrackParam): void
{
$this->disableTrackParam = $disableTrackParam;
@ -85,4 +95,23 @@ class TrackingOptions extends AbstractOptions
{
$this->disableUaTracking = $disableUaTracking;
}
public function disableTrackingFrom(): array
{
return $this->disableTrackingFrom;
}
public function hasDisableTrackingFrom(): bool
{
return ! empty($this->disableTrackingFrom);
}
protected function setDisableTrackingFrom(string|array|null $disableTrackingFrom): void
{
if (is_array($disableTrackingFrom)) {
$this->disableTrackingFrom = $disableTrackingFrom;
} else {
$this->disableTrackingFrom = $disableTrackingFrom === null ? [] : explode(',', $disableTrackingFrom);
}
}
}

View file

@ -5,14 +5,21 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit;
use Fig\Http\Message\RequestMethodInterface;
use InvalidArgumentException;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use PhpIP\IP;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use function array_key_exists;
use function explode;
use function Functional\map;
use function Functional\some;
use function implode;
use function str_contains;
class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
{
@ -37,24 +44,63 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
$notFoundType = $request->getAttribute(NotFoundType::class);
$visitor = Visitor::fromRequest($request);
if ($notFoundType?->isBaseUrl()) {
$this->visitsTracker->trackBaseUrlVisit($visitor);
} elseif ($notFoundType?->isRegularNotFound()) {
$this->visitsTracker->trackRegularNotFoundVisit($visitor);
} elseif ($notFoundType?->isInvalidShortUrl()) {
$this->visitsTracker->trackInvalidShortUrlVisit($visitor);
}
match (true) { // @phpstan-ignore-line
$notFoundType?->isBaseUrl() => $this->visitsTracker->trackBaseUrlVisit($visitor),
$notFoundType?->isRegularNotFound() => $this->visitsTracker->trackRegularNotFoundVisit($visitor),
$notFoundType?->isInvalidShortUrl() => $this->visitsTracker->trackInvalidShortUrlVisit($visitor),
};
}
private function shouldTrackRequest(ServerRequestInterface $request): bool
{
$query = $request->getQueryParams();
$disableTrackParam = $this->trackingOptions->getDisableTrackParam();
$forwardedMethod = $request->getAttribute(ImplicitHeadMiddleware::FORWARDED_HTTP_METHOD_ATTRIBUTE);
if ($forwardedMethod === self::METHOD_HEAD) {
return false;
}
return $disableTrackParam === null || ! array_key_exists($disableTrackParam, $query);
$remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
if ($this->shouldDisableTrackingFromAddress($remoteAddr)) {
return false;
}
$query = $request->getQueryParams();
return ! $this->trackingOptions->queryHasDisableTrackParam($query);
}
private function shouldDisableTrackingFromAddress(?string $remoteAddr): bool
{
if ($remoteAddr === null || ! $this->trackingOptions->hasDisableTrackingFrom()) {
return false;
}
try {
$ip = IP::create($remoteAddr);
} catch (InvalidArgumentException) {
return false;
}
$remoteAddrParts = explode('.', $remoteAddr);
$disableTrackingFrom = $this->trackingOptions->disableTrackingFrom();
return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool {
try {
return match (true) {
str_contains($value, '*') => $ip->matches($this->parseValueWithWildcards($value, $remoteAddrParts)),
str_contains($value, '/') => $ip->isIn($value),
default => $ip->matches($value),
};
} catch (InvalidArgumentException) {
return false;
}
});
}
private function parseValueWithWildcards(string $value, array $remoteAddrParts): string
{
// Replace wildcard parts with the corresponding ones from the remote address
return implode('.', map(
explode('.', $value),
fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part,
));
}
}

View file

@ -12,6 +12,7 @@ use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Model\Visitor;
@ -37,7 +38,10 @@ class RequestTrackerTest extends TestCase
$this->requestTracker = new RequestTracker(
$this->visitsTracker->reveal(),
new TrackingOptions(['disable_track_param' => 'foobar']),
new TrackingOptions([
'disable_track_param' => 'foobar',
'disable_tracking_from' => ['80.90.100.110', '192.168.10.0/24', '1.2.*.*'],
]),
);
$this->request = ServerRequestFactory::fromGlobals()->withAttribute(
@ -69,6 +73,18 @@ class RequestTrackerTest extends TestCase
yield 'disable track param as null' => [
ServerRequestFactory::fromGlobals()->withQueryParams(['foobar' => null]),
];
yield 'exact remote address' => [ServerRequestFactory::fromGlobals()->withAttribute(
IpAddressMiddlewareFactory::REQUEST_ATTR,
'80.90.100.110',
)];
yield 'matching wildcard remote address' => [ServerRequestFactory::fromGlobals()->withAttribute(
IpAddressMiddlewareFactory::REQUEST_ATTR,
'1.2.3.4',
)];
yield 'matching CIDR block remote address' => [ServerRequestFactory::fromGlobals()->withAttribute(
IpAddressMiddlewareFactory::REQUEST_ATTR,
'192.168.10.100',
)];
}
/** @test */