Add logic for IP-based dynamic redirects

This commit is contained in:
Alejandro Celaya 2024-07-17 19:51:13 +02:00
parent 1312ea61f4
commit f49d98f2ea
9 changed files with 102 additions and 25 deletions

View file

@ -108,6 +108,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
$this->askMandatory('Query param name?', $io), $this->askMandatory('Query param name?', $io),
$this->askOptional('Query param value?', $io), $this->askOptional('Query param value?', $io),
), ),
RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress(
$this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io),
),
}; };
$continue = $io->confirm('Do you want to add another condition?'); $continue = $io->confirm('Do you want to add another condition?');

View file

@ -14,6 +14,8 @@ use Jaybizzle\CrawlerDetect\CrawlerDetect;
use Laminas\Filter\Word\CamelCaseToSeparator; use Laminas\Filter\Word\CamelCaseToSeparator;
use Laminas\Filter\Word\CamelCaseToUnderscore; use Laminas\Filter\Word\CamelCaseToUnderscore;
use Laminas\InputFilter\InputFilter; use Laminas\InputFilter\InputFilter;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
@ -273,3 +275,8 @@ function splitByComma(?string $value): array
return array_map(trim(...), explode(',', $value)); return array_map(trim(...), explode(',', $value));
} }
function ipAddressFromRequest(ServerRequestInterface $request): ?string
{
return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
}

View file

@ -5,14 +5,14 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity;
use JsonSerializable; use JsonSerializable;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
use Shlinkio\Shlink\Core\Util\IpAddressUtils; use Shlinkio\Shlink\Core\Util\IpAddressUtils;
use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\acceptLanguageToLocales;
use function Shlinkio\Shlink\Core\ArrayUtils\some; use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
use function Shlinkio\Shlink\Core\normalizeLocale; use function Shlinkio\Shlink\Core\normalizeLocale;
use function Shlinkio\Shlink\Core\splitLocale; use function Shlinkio\Shlink\Core\splitLocale;
use function sprintf; use function sprintf;
@ -114,7 +114,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
private function matchesRemoteIpAddress(ServerRequestInterface $request): bool private function matchesRemoteIpAddress(ServerRequestInterface $request): bool
{ {
$remoteAddress = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR); $remoteAddress = ipAddressFromRequest($request);
return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]); return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]);
} }

View file

@ -14,8 +14,9 @@ use function array_map;
use function explode; use function explode;
use function implode; use function implode;
use function Shlinkio\Shlink\Core\ArrayUtils\some; use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function str_contains;
class IpAddressUtils final class IpAddressUtils
{ {
/** /**
* Checks if an IP address matches any of provided groups. * Checks if an IP address matches any of provided groups.

View file

@ -5,9 +5,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Model; namespace Shlinkio\Shlink\Core\Visit\Model;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Options\TrackingOptions;
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
use function Shlinkio\Shlink\Core\isCrawler; use function Shlinkio\Shlink\Core\isCrawler;
use function substr; use function substr;
@ -46,7 +46,7 @@ final class Visitor
return new self( return new self(
$request->getHeaderLine('User-Agent'), $request->getHeaderLine('User-Agent'),
$request->getHeaderLine('Referer'), $request->getHeaderLine('Referer'),
$request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR), ipAddressFromRequest($request),
$request->getUri()->__toString(), $request->getUri()->__toString(),
); );
} }

View file

@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Core\Visit;
use Fig\Http\Message\RequestMethodInterface; use Fig\Http\Message\RequestMethodInterface;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware; use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException; use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Options\TrackingOptions;
@ -15,6 +14,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Util\IpAddressUtils; use Shlinkio\Shlink\Core\Util\IpAddressUtils;
use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\Visitor;
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
readonly class RequestTracker implements RequestTrackerInterface, RequestMethodInterface readonly class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
{ {
public function __construct(private VisitsTrackerInterface $visitsTracker, private TrackingOptions $trackingOptions) public function __construct(private VisitsTrackerInterface $visitsTracker, private TrackingOptions $trackingOptions)
@ -53,7 +54,7 @@ readonly class RequestTracker implements RequestTrackerInterface, RequestMethodI
return false; return false;
} }
$remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR); $remoteAddr = ipAddressFromRequest($request);
if ($this->shouldDisableTrackingFromAddress($remoteAddr)) { if ($this->shouldDisableTrackingFromAddress($remoteAddr)) {
return false; return false;
} }

View file

@ -6,6 +6,7 @@ use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
@ -28,19 +29,19 @@ class RedirectConditionTest extends TestCase
} }
#[Test] #[Test]
#[TestWith([null, '', false])] // no accept language #[TestWith([null, '', false], 'no accept language')]
#[TestWith(['', '', false])] // empty accept language #[TestWith(['', '', false], 'empty accept language')]
#[TestWith(['*', '', false])] // wildcard accept language #[TestWith(['*', '', false], 'wildcard accept language')]
#[TestWith(['en', 'en', true])] // single language match #[TestWith(['en', 'en', true], 'single language match')]
#[TestWith(['es, en,fr', 'en', true])] // multiple languages match #[TestWith(['es, en,fr', 'en', true], 'multiple languages match')]
#[TestWith(['es, en-US,fr', 'EN', true])] // multiple locales match #[TestWith(['es, en-US,fr', 'EN', true], 'multiple locales match')]
#[TestWith(['es_ES', 'es-ES', true])] // single locale match #[TestWith(['es_ES', 'es-ES', true], 'single locale match')]
#[TestWith(['en-US,es-ES;q=0.6', 'es-ES', false])] // too low quality #[TestWith(['en-US,es-ES;q=0.6', 'es-ES', false], 'too low quality')]
#[TestWith(['en-US,es-ES;q=0.9', 'es-ES', true])] // quality high enough #[TestWith(['en-US,es-ES;q=0.9', 'es-ES', true], 'quality high enough')]
#[TestWith(['en-UK', 'en-uk', true])] // different casing match #[TestWith(['en-UK', 'en-uk', true], 'different casing match')]
#[TestWith(['en-UK', 'en', true])] // only lang #[TestWith(['en-UK', 'en', true], 'only lang')]
#[TestWith(['es-AR', 'en', false])] // different only lang #[TestWith(['es-AR', 'en', false], 'different only lang')]
#[TestWith(['fr', 'fr-FR', false])] // less restrictive matching locale #[TestWith(['fr', 'fr-FR', false], 'less restrictive matching locale')]
public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void
{ {
$request = ServerRequestFactory::fromGlobals(); $request = ServerRequestFactory::fromGlobals();
@ -72,4 +73,24 @@ class RedirectConditionTest extends TestCase
self::assertEquals($expected, $result); self::assertEquals($expected, $result);
} }
#[Test]
#[TestWith([null, '1.2.3.4', false], 'no remote IP address')]
#[TestWith(['1.2.3.4', '1.2.3.4', true], 'static IP address match')]
#[TestWith(['4.3.2.1', '1.2.3.4', false], 'no static IP address match')]
#[TestWith(['192.168.1.35', '192.168.1.0/24', true], 'CIDR block match')]
#[TestWith(['1.2.3.4', '192.168.1.0/24', false], 'no CIDR block match')]
#[TestWith(['192.168.1.35', '192.168.1.*', true], 'wildcard pattern match')]
#[TestWith(['1.2.3.4', '192.168.1.*', false], 'no wildcard pattern match')]
public function matchesRemoteIpAddress(?string $remoteIp, string $ipToMatch, bool $expected): void
{
$request = ServerRequestFactory::fromGlobals();
if ($remoteIp !== null) {
$request = $request->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, $remoteIp);
}
$result = RedirectCondition::forIpAddress($ipToMatch)->matchesRequest($request);
self::assertEquals($expected, $result);
}
} }

View file

@ -1,17 +1,20 @@
<?php <?php
namespace RedirectRule\Entity; namespace ShlinkioTest\Shlink\Core\RedirectRule\Entity;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Laminas\Diactoros\ServerRequestFactory; use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use function sprintf;
class ShortUrlRedirectRuleTest extends TestCase class ShortUrlRedirectRuleTest extends TestCase
{ {
#[Test, DataProvider('provideConditions')] #[Test, DataProvider('provideConditions')]
@ -55,9 +58,12 @@ class ShortUrlRedirectRuleTest extends TestCase
#[Test, DataProvider('provideConditionMappingCallbacks')] #[Test, DataProvider('provideConditionMappingCallbacks')]
public function conditionsCanBeMapped(callable $callback, array $expectedResult): void public function conditionsCanBeMapped(callable $callback, array $expectedResult): void
{ {
$conditions = new ArrayCollection( $conditions = new ArrayCollection([
[RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')], RedirectCondition::forLanguage('en-UK'),
); RedirectCondition::forQueryParam('foo', 'bar'),
RedirectCondition::forDevice(DeviceType::ANDROID),
RedirectCondition::forIpAddress('1.2.3.*'),
]);
$rule = $this->createRule($conditions); $rule = $this->createRule($conditions);
$result = $rule->mapConditions($callback); $result = $rule->mapConditions($callback);
@ -78,10 +84,22 @@ class ShortUrlRedirectRuleTest extends TestCase
'matchKey' => 'foo', 'matchKey' => 'foo',
'matchValue' => 'bar', 'matchValue' => 'bar',
], ],
[
'type' => RedirectConditionType::DEVICE->value,
'matchKey' => null,
'matchValue' => DeviceType::ANDROID->value,
],
[
'type' => RedirectConditionType::IP_ADDRESS->value,
'matchKey' => null,
'matchValue' => '1.2.3.*',
],
]]; ]];
yield 'human-friendly conditions' => [fn (RedirectCondition $cond) => $cond->toHumanFriendly(), [ yield 'human-friendly conditions' => [fn (RedirectCondition $cond) => $cond->toHumanFriendly(), [
'en-UK language is accepted', 'en-UK language is accepted',
'query string contains foo=bar', 'query string contains foo=bar',
sprintf('device is %s', DeviceType::ANDROID->value),
'IP address matches 1.2.3.*',
]]; ]];
} }

View file

@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
@ -88,5 +89,30 @@ class ShortUrlRedirectionResolverTest extends TestCase
RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar'),
'https://example.com/from-rule', 'https://example.com/from-rule',
]; ];
yield 'matching static IP address' => [
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.3.4'),
RedirectCondition::forIpAddress('1.2.3.4'),
'https://example.com/from-rule',
];
yield 'matching CIDR block' => [
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '192.168.1.35'),
RedirectCondition::forIpAddress('192.168.1.0/24'),
'https://example.com/from-rule',
];
yield 'matching wildcard IP address' => [
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '1.2.5.5'),
RedirectCondition::forIpAddress('1.2.*.*'),
'https://example.com/from-rule',
];
yield 'non-matching IP address' => [
$request()->withAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR, '4.3.2.1'),
RedirectCondition::forIpAddress('1.2.3.4'),
'https://example.com/foo/bar',
];
yield 'missing remote IP address' => [
$request(),
RedirectCondition::forIpAddress('1.2.3.4'),
'https://example.com/foo/bar',
];
} }
} }