mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-27 16:26:37 +03:00
Add logic for IP-based dynamic redirects
This commit is contained in:
parent
1312ea61f4
commit
f49d98f2ea
9 changed files with 102 additions and 25 deletions
|
@ -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?');
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.*',
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue