From 781c083c9fb461bf7359a381a841bf0b8f9be992 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 12 Nov 2024 08:37:22 +0100 Subject: [PATCH] Add new geolocatio-country-code redirect condition type --- composer.json | 2 +- .../src/RedirectRule/RedirectRuleHandler.php | 3 +++ .../RedirectRule/Entity/RedirectCondition.php | 22 +++++++++++++++++-- .../Model/RedirectConditionType.php | 1 + .../Entity/RedirectConditionTest.php | 18 +++++++++++++++ 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 88e94946..76d94a07 100644 --- a/composer.json +++ b/composer.json @@ -49,7 +49,7 @@ "shlinkio/shlink-event-dispatcher": "^4.1", "shlinkio/shlink-importer": "^5.3.2", "shlinkio/shlink-installer": "^9.2", - "shlinkio/shlink-ip-geolocation": "^4.1", + "shlinkio/shlink-ip-geolocation": "dev-main#fadae5d as 4.2", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2024.1", "spiral/roadrunner-cli": "^2.6", diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index 924876fc..f72d1ed0 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -111,6 +111,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress( $this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io), ), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => RedirectCondition::forGeolocationCountryCode( + $this->askMandatory('Country code to match?', $io), + ) }; $continue = $io->confirm('Do you want to add another condition?'); diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 99f5fb9c..affa994a 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -9,6 +9,7 @@ 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 Shlinkio\Shlink\IpGeolocation\Model\Location; use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\ArrayUtils\some; @@ -16,7 +17,7 @@ use function Shlinkio\Shlink\Core\ipAddressFromRequest; use function Shlinkio\Shlink\Core\normalizeLocale; use function Shlinkio\Shlink\Core\splitLocale; use function sprintf; -use function strtolower; +use function strcasecmp; use function trim; class RedirectCondition extends AbstractEntity implements JsonSerializable @@ -52,6 +53,11 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return new self(RedirectConditionType::IP_ADDRESS, $ipAddressPattern); } + public static function forGeolocationCountryCode(string $countryCode): self + { + return new self(RedirectConditionType::GEOLOCATION_COUNTRY_CODE, $countryCode); + } + public static function fromRawData(array $rawData): self { $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); @@ -71,6 +77,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::LANGUAGE => $this->matchesLanguage($request), RedirectConditionType::DEVICE => $this->matchesDevice($request), RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => $this->matchesGeolocationCountryCode($request), }; } @@ -109,7 +116,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable private function matchesDevice(ServerRequestInterface $request): bool { $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); - return $device !== null && $device->value === strtolower($this->matchValue); + return $device !== null && $device->value === $this->matchValue; } private function matchesRemoteIpAddress(ServerRequestInterface $request): bool @@ -118,6 +125,16 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return $remoteAddress !== null && IpAddressUtils::ipAddressMatchesGroups($remoteAddress, [$this->matchValue]); } + private function matchesGeolocationCountryCode(ServerRequestInterface $request): bool + { + $geolocation = $request->getAttribute(Location::class); + if (!($geolocation instanceof Location)) { + return false; + } + + return strcasecmp($geolocation->countryCode, $this->matchValue) === 0; + } + public function jsonSerialize(): array { return [ @@ -138,6 +155,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable $this->matchValue, ), RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue), + RedirectConditionType::GEOLOCATION_COUNTRY_CODE => sprintf('country code is %s', $this->matchValue), }; } } diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 891a8ccc..ed587ffa 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -8,4 +8,5 @@ enum RedirectConditionType: string case LANGUAGE = 'language'; case QUERY_PARAM = 'query-param'; case IP_ADDRESS = 'ip-address'; + case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code'; } diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index b31d1fd3..81b69fe5 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -3,12 +3,14 @@ namespace ShlinkioTest\Shlink\Core\RedirectRule\Entity; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\Attributes\DataProvider; 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; +use Shlinkio\Shlink\IpGeolocation\Model\Location; use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; @@ -93,4 +95,20 @@ class RedirectConditionTest extends TestCase self::assertEquals($expected, $result); } + + #[Test, DataProvider('provideVisits')] + public function matchesGeolocationCountryCode(Location|null $location, string $countryCodeToMatch, bool $expected): void + { + $request = ServerRequestFactory::fromGlobals()->withAttribute(Location::class, $location); + $result = RedirectCondition::forGeolocationCountryCode($countryCodeToMatch)->matchesRequest($request); + + self::assertEquals($expected, $result); + } + public static function provideVisits(): iterable + { + yield 'no location' => [null, 'US', false]; + yield 'non-matching location' => [new Location(countryCode: 'ES'), 'US', false]; + yield 'matching location' => [new Location(countryCode: 'US'), 'US', true]; + yield 'matching case-insensitive' => [new Location(countryCode: 'US'), 'us', true]; + } }