mirror of
https://github.com/shlinkio/shlink.git
synced 2024-12-23 07:33:58 -06:00
Merge pull request #2151 from acelaya-forks/feature/ip-dynamic-redirects
Add logic for IP-based dynamic redirects
This commit is contained in:
commit
7c659699f3
@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
* [#2120](https://github.com/shlinkio/shlink/issues/2120) Add new IP address condition for the dynamic rules redirections system.
|
||||
|
||||
The conditions allow you to define IP addresses to match as static IP (1.2.3.4), CIDR block (192.168.1.0/24) or wildcard pattern (1.2.\*.\*).
|
||||
|
||||
* [#2018](https://github.com/shlinkio/shlink/issues/2018) Add option to allow all short URLs to be unconditionally crawlable in robots.txt, via `ROBOTS_ALLOW_ALL_SHORT_URLS=true` env var, or config option.
|
||||
* [#2109](https://github.com/shlinkio/shlink/issues/2109) Add option to customize user agents robots.txt, via `ROBOTS_USER_AGENTS=foo,bar,baz` env var, or config option.
|
||||
|
||||
|
@ -15,8 +15,8 @@
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["device", "language", "query-param"],
|
||||
"description": "The type of the condition, which will condition the logic used to match it"
|
||||
"enum": ["device", "language", "query-param", "ip-address"],
|
||||
"description": "The type of the condition, which will determine the logic used to match it"
|
||||
},
|
||||
"matchKey": {
|
||||
"type": ["string", "null"]
|
||||
|
@ -108,6 +108,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
|
||||
$this->askMandatory('Query param name?', $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?');
|
||||
|
@ -116,6 +116,7 @@ class RedirectRuleHandlerTest extends TestCase
|
||||
'Language to match?' => 'en-US',
|
||||
'Query param name?' => 'foo',
|
||||
'Query param value?' => 'bar',
|
||||
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
|
||||
default => '',
|
||||
},
|
||||
);
|
||||
@ -163,6 +164,7 @@ class RedirectRuleHandlerTest extends TestCase
|
||||
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],
|
||||
true,
|
||||
];
|
||||
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
@ -14,6 +14,8 @@ use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
||||
use Laminas\Filter\Word\CamelCaseToSeparator;
|
||||
use Laminas\Filter\Word\CamelCaseToUnderscore;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||
|
||||
@ -273,3 +275,8 @@ function splitByComma(?string $value): array
|
||||
|
||||
return array_map(trim(...), explode(',', $value));
|
||||
}
|
||||
|
||||
function ipAddressFromRequest(ServerRequestInterface $request): ?string
|
||||
{
|
||||
return $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
|
||||
}
|
||||
|
15
module/Core/src/Exception/InvalidIpFormatException.php
Normal file
15
module/Core/src/Exception/InvalidIpFormatException.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Exception;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class InvalidIpFormatException extends RuntimeException implements ExceptionInterface
|
||||
{
|
||||
public static function fromInvalidIp(string $ipAddress): self
|
||||
{
|
||||
return new self(sprintf('Provided IP %s does not have the right format. Expected X.X.X.X', $ipAddress));
|
||||
}
|
||||
}
|
@ -8,9 +8,11 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
|
||||
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
|
||||
|
||||
use function Shlinkio\Shlink\Core\acceptLanguageToLocales;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
|
||||
use function Shlinkio\Shlink\Core\normalizeLocale;
|
||||
use function Shlinkio\Shlink\Core\splitLocale;
|
||||
use function sprintf;
|
||||
@ -41,6 +43,15 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
||||
return new self(RedirectConditionType::DEVICE, $device->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $ipAddressPattern - A static IP address (100.200.80.40), CIDR block (192.168.10.0/24) or wildcard
|
||||
* pattern (11.22.*.*)
|
||||
*/
|
||||
public static function forIpAddress(string $ipAddressPattern): self
|
||||
{
|
||||
return new self(RedirectConditionType::IP_ADDRESS, $ipAddressPattern);
|
||||
}
|
||||
|
||||
public static function fromRawData(array $rawData): self
|
||||
{
|
||||
$type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]);
|
||||
@ -59,6 +70,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
||||
RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request),
|
||||
RedirectConditionType::LANGUAGE => $this->matchesLanguage($request),
|
||||
RedirectConditionType::DEVICE => $this->matchesDevice($request),
|
||||
RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request),
|
||||
};
|
||||
}
|
||||
|
||||
@ -100,6 +112,12 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
||||
return $device !== null && $device->value === strtolower($this->matchValue);
|
||||
}
|
||||
|
||||
private function matchesRemoteIpAddress(ServerRequestInterface $request): bool
|
||||
{
|
||||
$remoteAddress = ipAddressFromRequest($request);
|
||||
return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
@ -119,6 +137,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
||||
$this->matchKey,
|
||||
$this->matchValue,
|
||||
),
|
||||
RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -7,4 +7,5 @@ enum RedirectConditionType: string
|
||||
case DEVICE = 'device';
|
||||
case LANGUAGE = 'language';
|
||||
case QUERY_PARAM = 'query-param';
|
||||
case IP_ADDRESS = 'ip-address';
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ use Shlinkio\Shlink\Common\Validation\InputFactory;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
|
||||
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
|
||||
use function Shlinkio\Shlink\Core\enumValues;
|
||||
@ -71,13 +72,14 @@ class RedirectRulesInputFilter extends InputFilter
|
||||
$redirectConditionInputFilter->add($type);
|
||||
|
||||
$value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true);
|
||||
$value->getValidatorChain()->attach(new Callback(function (string $value, array $context) {
|
||||
if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) {
|
||||
return contains($value, enumValues(DeviceType::class));
|
||||
}
|
||||
|
||||
return true;
|
||||
}));
|
||||
$value->getValidatorChain()->attach(new Callback(
|
||||
fn (string $value, array $context) => match ($context[self::CONDITION_TYPE]) {
|
||||
RedirectConditionType::DEVICE->value => contains($value, enumValues(DeviceType::class)),
|
||||
RedirectConditionType::IP_ADDRESS->value => IpAddressUtils::isStaticIpCidrOrWildcard($value),
|
||||
// RedirectConditionType::LANGUAGE->value => TODO,
|
||||
default => true,
|
||||
},
|
||||
));
|
||||
$redirectConditionInputFilter->add($value);
|
||||
|
||||
$redirectConditionInputFilter->add(
|
||||
|
85
module/Core/src/Util/IpAddressUtils.php
Normal file
85
module/Core/src/Util/IpAddressUtils.php
Normal file
@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Util;
|
||||
|
||||
use IPLib\Address\IPv4;
|
||||
use IPLib\Factory;
|
||||
use IPLib\Range\RangeInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
|
||||
|
||||
use function array_keys;
|
||||
use function array_map;
|
||||
use function explode;
|
||||
use function implode;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||
use function str_contains;
|
||||
|
||||
final class IpAddressUtils
|
||||
{
|
||||
public static function isStaticIpCidrOrWildcard(string $candidate): bool
|
||||
{
|
||||
return self::candidateToRange($candidate, ['0', '0', '0', '0']) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an IP address matches any of provided groups.
|
||||
* Every group can be a static IP address (100.200.80.40), a CIDR block (192.168.10.0/24) or a wildcard pattern
|
||||
* (11.22.*.*).
|
||||
*
|
||||
* Matching will happen as follows:
|
||||
* * Static IP address -> strict equality with provided IP address.
|
||||
* * CIDR block -> provided IP address is part of that block.
|
||||
* * Wildcard pattern -> static parts match the corresponding ones in provided IP address.
|
||||
*
|
||||
* @param string[] $groups
|
||||
* @throws InvalidIpFormatException
|
||||
*/
|
||||
public static function ipAddressMatchesGroups(string $ipAddress, array $groups): bool
|
||||
{
|
||||
$ip = IPv4::parseString($ipAddress);
|
||||
if ($ip === null) {
|
||||
throw InvalidIpFormatException::fromInvalidIp($ipAddress);
|
||||
}
|
||||
|
||||
$ipAddressParts = explode('.', $ipAddress);
|
||||
|
||||
return some($groups, function (string $group) use ($ip, $ipAddressParts): bool {
|
||||
$range = self::candidateToRange($group, $ipAddressParts);
|
||||
return $range !== null && $range->contains($ip);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a static IP, CIDR block or wildcard pattern into a Range object
|
||||
*
|
||||
* @param string[] $ipAddressParts
|
||||
*/
|
||||
private static function candidateToRange(string $candidate, array $ipAddressParts): ?RangeInterface
|
||||
{
|
||||
return str_contains($candidate, '*')
|
||||
? self::parseValueWithWildcards($candidate, $ipAddressParts)
|
||||
: Factory::parseRangeString($candidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to generate an IP range from a wildcard pattern.
|
||||
* Factory::parseRangeString can usually do this automatically, but only if wildcards are at the end. This also
|
||||
* covers cases where wildcards are in between.
|
||||
*/
|
||||
private static function parseValueWithWildcards(string $value, array $ipAddressParts): ?RangeInterface
|
||||
{
|
||||
$octets = explode('.', $value);
|
||||
$keys = array_keys($octets);
|
||||
|
||||
// Replace wildcard parts with the corresponding ones from the remote address
|
||||
return Factory::parseRangeString(
|
||||
implode('.', array_map(
|
||||
fn (string $part, int $index) => $part === '*' ? $ipAddressParts[$index] : $part,
|
||||
$octets,
|
||||
$keys,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Visit\Model;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||
|
||||
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
|
||||
use function Shlinkio\Shlink\Core\isCrawler;
|
||||
use function substr;
|
||||
|
||||
@ -46,7 +46,7 @@ final class Visitor
|
||||
return new self(
|
||||
$request->getHeaderLine('User-Agent'),
|
||||
$request->getHeaderLine('Referer'),
|
||||
$request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR),
|
||||
ipAddressFromRequest($request),
|
||||
$request->getUri()->__toString(),
|
||||
);
|
||||
}
|
||||
|
@ -5,30 +5,21 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use IPLib\Address\IPv4;
|
||||
use IPLib\Factory;
|
||||
use IPLib\Range\RangeInterface;
|
||||
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
|
||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\Visitor;
|
||||
|
||||
use function array_keys;
|
||||
use function array_map;
|
||||
use function explode;
|
||||
use function implode;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||
use function str_contains;
|
||||
use function Shlinkio\Shlink\Core\ipAddressFromRequest;
|
||||
|
||||
class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
||||
readonly class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VisitsTrackerInterface $visitsTracker,
|
||||
private readonly TrackingOptions $trackingOptions,
|
||||
) {
|
||||
public function __construct(private VisitsTrackerInterface $visitsTracker, private TrackingOptions $trackingOptions)
|
||||
{
|
||||
}
|
||||
|
||||
public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void
|
||||
@ -63,7 +54,7 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
$remoteAddr = $request->getAttribute(IpAddressMiddlewareFactory::REQUEST_ATTR);
|
||||
$remoteAddr = ipAddressFromRequest($request);
|
||||
if ($this->shouldDisableTrackingFromAddress($remoteAddr)) {
|
||||
return false;
|
||||
}
|
||||
@ -78,35 +69,10 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
|
||||
return false;
|
||||
}
|
||||
|
||||
$ip = IPv4::parseString($remoteAddr);
|
||||
if ($ip === null) {
|
||||
try {
|
||||
return IpAddressUtils::ipAddressMatchesGroups($remoteAddr, $this->trackingOptions->disableTrackingFrom);
|
||||
} catch (InvalidIpFormatException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$remoteAddrParts = explode('.', $remoteAddr);
|
||||
$disableTrackingFrom = $this->trackingOptions->disableTrackingFrom;
|
||||
|
||||
return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool {
|
||||
$range = str_contains($value, '*')
|
||||
? $this->parseValueWithWildcards($value, $remoteAddrParts)
|
||||
: Factory::parseRangeString($value);
|
||||
|
||||
return $range !== null && $ip->matches($range);
|
||||
});
|
||||
}
|
||||
|
||||
private function parseValueWithWildcards(string $value, array $remoteAddrParts): ?RangeInterface
|
||||
{
|
||||
$octets = explode('.', $value);
|
||||
$keys = array_keys($octets);
|
||||
|
||||
// Replace wildcard parts with the corresponding ones from the remote address
|
||||
return Factory::parseRangeString(
|
||||
implode('.', array_map(
|
||||
fn (string $part, int $index) => $part === '*' ? $remoteAddrParts[$index] : $part,
|
||||
$octets,
|
||||
$keys,
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,8 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
|
||||
use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT;
|
||||
use const ShlinkioTest\Shlink\IOS_USER_AGENT;
|
||||
@ -86,6 +88,16 @@ class RedirectTest extends ApiTestCase
|
||||
],
|
||||
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
|
||||
];
|
||||
|
||||
$clientDetection = require __DIR__ . '/../../../../config/autoload/client-detection.global.php';
|
||||
foreach ($clientDetection['ip_address_resolution']['headers_to_inspect'] as $header) {
|
||||
yield sprintf('rule: IP address in "%s" header', $header) => [
|
||||
[
|
||||
RequestOptions::HEADERS => [$header => '1.2.3.4'],
|
||||
],
|
||||
'https://example.com/static-ip-address',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
19
module/Core/test/Exception/InvalidIpFormatExceptionTest.php
Normal file
19
module/Core/test/Exception/InvalidIpFormatExceptionTest.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Exception;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidIpFormatException;
|
||||
|
||||
class InvalidIpFormatExceptionTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function fromInvalidIp(): void
|
||||
{
|
||||
$e = InvalidIpFormatException::fromInvalidIp('invalid');
|
||||
self::assertEquals('Provided IP invalid does not have the right format. Expected X.X.X.X', $e->getMessage());
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||
|
||||
@ -28,19 +29,19 @@ class RedirectConditionTest extends TestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestWith([null, '', false])] // no accept language
|
||||
#[TestWith(['', '', false])] // empty accept language
|
||||
#[TestWith(['*', '', false])] // wildcard accept language
|
||||
#[TestWith(['en', 'en', true])] // single language match
|
||||
#[TestWith(['es, en,fr', 'en', true])] // multiple languages match
|
||||
#[TestWith(['es, en-US,fr', 'EN', true])] // multiple locales 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.9', 'es-ES', true])] // quality high enough
|
||||
#[TestWith(['en-UK', 'en-uk', true])] // different casing match
|
||||
#[TestWith(['en-UK', 'en', true])] // only lang
|
||||
#[TestWith(['es-AR', 'en', false])] // different only lang
|
||||
#[TestWith(['fr', 'fr-FR', false])] // less restrictive matching locale
|
||||
#[TestWith([null, '', false], 'no accept language')]
|
||||
#[TestWith(['', '', false], 'empty accept language')]
|
||||
#[TestWith(['*', '', false], 'wildcard accept language')]
|
||||
#[TestWith(['en', 'en', true], 'single language match')]
|
||||
#[TestWith(['es, en,fr', 'en', true], 'multiple languages match')]
|
||||
#[TestWith(['es, en-US,fr', 'EN', true], 'multiple locales 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.9', 'es-ES', true], 'quality high enough')]
|
||||
#[TestWith(['en-UK', 'en-uk', true], 'different casing match')]
|
||||
#[TestWith(['en-UK', 'en', true], 'only lang')]
|
||||
#[TestWith(['es-AR', 'en', false], 'different only lang')]
|
||||
#[TestWith(['fr', 'fr-FR', false], 'less restrictive matching locale')]
|
||||
public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void
|
||||
{
|
||||
$request = ServerRequestFactory::fromGlobals();
|
||||
@ -72,4 +73,24 @@ class RedirectConditionTest extends TestCase
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace RedirectRule\Entity;
|
||||
namespace ShlinkioTest\Shlink\Core\RedirectRule\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class ShortUrlRedirectRuleTest extends TestCase
|
||||
{
|
||||
#[Test, DataProvider('provideConditions')]
|
||||
@ -55,9 +58,12 @@ class ShortUrlRedirectRuleTest extends TestCase
|
||||
#[Test, DataProvider('provideConditionMappingCallbacks')]
|
||||
public function conditionsCanBeMapped(callable $callback, array $expectedResult): void
|
||||
{
|
||||
$conditions = new ArrayCollection(
|
||||
[RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')],
|
||||
);
|
||||
$conditions = new ArrayCollection([
|
||||
RedirectCondition::forLanguage('en-UK'),
|
||||
RedirectCondition::forQueryParam('foo', 'bar'),
|
||||
RedirectCondition::forDevice(DeviceType::ANDROID),
|
||||
RedirectCondition::forIpAddress('1.2.3.*'),
|
||||
]);
|
||||
$rule = $this->createRule($conditions);
|
||||
|
||||
$result = $rule->mapConditions($callback);
|
||||
@ -78,10 +84,22 @@ class ShortUrlRedirectRuleTest extends TestCase
|
||||
'matchKey' => 'foo',
|
||||
'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(), [
|
||||
'en-UK language is accepted',
|
||||
'query string contains foo=bar',
|
||||
sprintf('device is %s', DeviceType::ANDROID->value),
|
||||
'IP address matches 1.2.3.*',
|
||||
]];
|
||||
}
|
||||
|
||||
|
@ -51,9 +51,76 @@ class RedirectRulesDataTest extends TestCase
|
||||
],
|
||||
],
|
||||
]]])]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => 'not an IP address',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]])]
|
||||
public function throwsWhenProvidedDataIsInvalid(array $invalidData): void
|
||||
{
|
||||
$this->expectException(ValidationException::class);
|
||||
RedirectRulesData::fromRawData($invalidData);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => '1.2.3.4',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]], 'static IP')]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => '1.2.3.0/24',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]], 'CIDR block')]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => '1.2.3.*',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]], 'IP wildcard pattern')]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => '1.2.*.4',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]], 'in-between IP wildcard pattern')]
|
||||
public function allowsValidDataToBeSet(array $validData): void
|
||||
{
|
||||
$result = RedirectRulesData::fromRawData($validData);
|
||||
self::assertEquals($result->rules, $validData['redirectRules']);
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Common\Middleware\IpAddressMiddlewareFactory;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||
@ -88,5 +89,30 @@ class ShortUrlRedirectionResolverTest extends TestCase
|
||||
RedirectCondition::forQueryParam('foo', 'bar'),
|
||||
'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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
26
module/Core/test/Util/IpAddressUtilsTest.php
Normal file
26
module/Core/test/Util/IpAddressUtilsTest.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Util;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
|
||||
|
||||
class IpAddressUtilsTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
#[TestWith(['', false], 'empty')]
|
||||
#[TestWith(['invalid', false], 'invalid')]
|
||||
#[TestWith(['1.2.3.4', true], 'static IP')]
|
||||
#[TestWith(['456.2.385.4', false], 'invalid IP')]
|
||||
#[TestWith(['192.168.1.0/24', true], 'CIDR block')]
|
||||
#[TestWith(['1.2.*.*', true], 'wildcard pattern')]
|
||||
#[TestWith(['1.2.*.1', true], 'in-between wildcard pattern')]
|
||||
public function isStaticIpCidrOrWildcardReturnsExpectedResult(string $candidate, bool $expected): void
|
||||
{
|
||||
self::assertEquals($expected, IpAddressUtils::isStaticIpCidrOrWildcard($candidate));
|
||||
}
|
||||
}
|
@ -92,6 +92,21 @@ class RequestTrackerTest extends TestCase
|
||||
$this->requestTracker->trackIfApplicable($shortUrl, $this->request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function trackingHappensOverShortUrlsWhenRemoteAddressIsInvalid(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::withLongUrl(self::LONG_URL);
|
||||
$this->visitsTracker->expects($this->once())->method('track')->with(
|
||||
$shortUrl,
|
||||
$this->isInstanceOf(Visitor::class),
|
||||
);
|
||||
|
||||
$this->requestTracker->trackIfApplicable($shortUrl, ServerRequestFactory::fromGlobals()->withAttribute(
|
||||
IpAddressMiddlewareFactory::REQUEST_ATTR,
|
||||
'invalid',
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function baseUrlErrorIsTracked(): void
|
||||
{
|
||||
|
@ -87,6 +87,17 @@ class ListRedirectRulesTest extends ApiTestCase
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'longUrl' => 'https://example.com/static-ip-address',
|
||||
'priority' => 6,
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => '1.2.3.4',
|
||||
],
|
||||
],
|
||||
],
|
||||
]])]
|
||||
public function returnsListOfRulesForShortUrl(string $shortCode, array $expectedRules): void
|
||||
{
|
||||
|
@ -25,7 +25,7 @@ class SetRedirectRulesTest extends ApiTestCase
|
||||
];
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedWhenInvalidUrlProvided(): void
|
||||
public function errorIsReturnedWhenInvalidUrlIsProvided(): void
|
||||
{
|
||||
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/invalid/redirect-rules');
|
||||
$payload = $this->getJsonResponsePayload($response);
|
||||
@ -39,16 +39,67 @@ class SetRedirectRulesTest extends ApiTestCase
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedWhenInvalidDataProvided(): void
|
||||
{
|
||||
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [
|
||||
RequestOptions::JSON => [
|
||||
'redirectRules' => [
|
||||
#[TestWith([[
|
||||
'redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'invalid',
|
||||
],
|
||||
],
|
||||
]], 'invalid long URL')]
|
||||
#[TestWith([[
|
||||
'redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => 'foo',
|
||||
],
|
||||
],
|
||||
]], 'non-array conditions')]
|
||||
#[TestWith([[
|
||||
'redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'longUrl' => 'invalid',
|
||||
'type' => 'invalid',
|
||||
'matchKey' => null,
|
||||
'matchValue' => 'foo',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]], 'invalid condition type')]
|
||||
#[TestWith([[
|
||||
'redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'matchValue' => 'invalid-device',
|
||||
'matchKey' => null,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]], 'invalid device type')]
|
||||
#[TestWith([[
|
||||
'redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'ip-address',
|
||||
'matchKey' => null,
|
||||
'matchValue' => 'not an IP address',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]], 'invalid IP address')]
|
||||
public function errorIsReturnedWhenInvalidDataIsProvided(array $bodyPayload): void
|
||||
{
|
||||
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [
|
||||
RequestOptions::JSON => $bodyPayload,
|
||||
]);
|
||||
$payload = $this->getJsonResponsePayload($response);
|
||||
|
||||
|
@ -70,6 +70,14 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF
|
||||
);
|
||||
$manager->persist($iosRule);
|
||||
|
||||
$ipAddressRule = new ShortUrlRedirectRule(
|
||||
shortUrl: $defShortUrl,
|
||||
priority: 6,
|
||||
longUrl: 'https://example.com/static-ip-address',
|
||||
conditions: new ArrayCollection([RedirectCondition::forIpAddress('1.2.3.4')]),
|
||||
);
|
||||
$manager->persist($ipAddressRule);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user