diff --git a/CHANGELOG.md b/CHANGELOG.md index c0fee24b..8e9555e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1774](https://github.com/shlinkio/shlink/issues/1774) Add new geolocation redirect rules for the dynamic redirects system. * `geolocation-country-code`: Allows to perform redirections based on the ISO 3166-1 alpha-2 two-letter country code resolved while geolocating the visitor. + * `geolocation-city-name`: Allows to perform redirections based on the city name resolved while geolocating the visitor. ### Changed * [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text. diff --git a/docs/swagger/definitions/SetShortUrlRedirectRule.json b/docs/swagger/definitions/SetShortUrlRedirectRule.json index 5ff6371c..00f0a27b 100644 --- a/docs/swagger/definitions/SetShortUrlRedirectRule.json +++ b/docs/swagger/definitions/SetShortUrlRedirectRule.json @@ -15,7 +15,14 @@ "properties": { "type": { "type": "string", - "enum": ["device", "language", "query-param", "ip-address", "geolocation-country-code"], + "enum": [ + "device", + "language", + "query-param", + "ip-address", + "geolocation-country-code", + "geolocation-city-name" + ], "description": "The type of the condition, which will determine the logic used to match it" }, "matchKey": { diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index f72d1ed0..89f93833 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -113,6 +113,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface ), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => RedirectCondition::forGeolocationCountryCode( $this->askMandatory('Country code to match?', $io), + ), + RedirectConditionType::GEOLOCATION_CITY_NAME => RedirectCondition::forGeolocationCityName( + $this->askMandatory('City name to match?', $io), ) }; diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index 99a2167b..eb78da61 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -118,6 +118,7 @@ class RedirectRuleHandlerTest extends TestCase 'Query param value?' => 'bar', 'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4', 'Country code to match?' => 'FR', + 'City name to match?' => 'Los angeles', default => '', }, ); @@ -170,6 +171,10 @@ class RedirectRuleHandlerTest extends TestCase RedirectConditionType::GEOLOCATION_COUNTRY_CODE, [RedirectCondition::forGeolocationCountryCode('FR')], ]; + yield 'Geolocation city name' => [ + RedirectConditionType::GEOLOCATION_CITY_NAME, + [RedirectCondition::forGeolocationCityName('Los angeles')], + ]; } #[Test] diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 3782f0ef..9468d582 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -59,6 +59,11 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return new self(RedirectConditionType::GEOLOCATION_COUNTRY_CODE, $countryCode); } + public static function forGeolocationCityName(string $cityName): self + { + return new self(RedirectConditionType::GEOLOCATION_CITY_NAME, $cityName); + } + public static function fromRawData(array $rawData): self { $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); @@ -79,6 +84,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable RedirectConditionType::DEVICE => $this->matchesDevice($request), RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => $this->matchesGeolocationCountryCode($request), + RedirectConditionType::GEOLOCATION_CITY_NAME => $this->matchesGeolocationCityName($request), }; } @@ -137,6 +143,18 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return strcasecmp($geolocation->countryCode, $this->matchValue) === 0; } + private function matchesGeolocationCityName(ServerRequestInterface $request): bool + { + $geolocation = $request->getAttribute(Location::class); + // TODO We should eventually rely on `Location` type only + if (! ($geolocation instanceof Location) && ! ($geolocation instanceof VisitLocation)) { + return false; + } + + $cityName = $geolocation instanceof Location ? $geolocation->city : $geolocation->cityName; + return strcasecmp($cityName, $this->matchValue) === 0; + } + public function jsonSerialize(): array { return [ @@ -158,6 +176,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable ), RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue), RedirectConditionType::GEOLOCATION_COUNTRY_CODE => sprintf('country code is %s', $this->matchValue), + RedirectConditionType::GEOLOCATION_CITY_NAME => sprintf('city name is %s', $this->matchValue), }; } } diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 6e685709..efc314f9 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -15,6 +15,7 @@ enum RedirectConditionType: string case QUERY_PARAM = 'query-param'; case IP_ADDRESS = 'ip-address'; case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code'; + case GEOLOCATION_CITY_NAME = 'geolocation-city-name'; /** * Tells if a value is valid for the condition type diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index 179d35e9..d22b632d 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -97,7 +97,7 @@ class RedirectConditionTest extends TestCase self::assertEquals($expected, $result); } - #[Test, DataProvider('provideVisits')] + #[Test, DataProvider('provideVisitsWithCountry')] public function matchesGeolocationCountryCode( Location|VisitLocation|null $location, string $countryCodeToMatch, @@ -108,7 +108,7 @@ class RedirectConditionTest extends TestCase self::assertEquals($expected, $result); } - public static function provideVisits(): iterable + public static function provideVisitsWithCountry(): iterable { yield 'no location' => [null, 'US', false]; yield 'non-matching location' => [new Location(countryCode: 'ES'), 'US', false]; @@ -125,4 +125,33 @@ class RedirectConditionTest extends TestCase true, ]; } + + #[Test, DataProvider('provideVisitsWithCity')] + public function matchesGeolocationCityName( + Location|VisitLocation|null $location, + string $cityNameToMatch, + bool $expected, + ): void { + $request = ServerRequestFactory::fromGlobals()->withAttribute(Location::class, $location); + $result = RedirectCondition::forGeolocationCityName($cityNameToMatch)->matchesRequest($request); + + self::assertEquals($expected, $result); + } + public static function provideVisitsWithCity(): iterable + { + yield 'no location' => [null, 'New York', false]; + yield 'non-matching location' => [new Location(city: 'Los Angeles'), 'New York', false]; + yield 'matching location' => [new Location(city: 'Madrid'), 'Madrid', true]; + yield 'matching case-insensitive' => [new Location(city: 'Los Angeles'), 'los angeles', true]; + yield 'matching visit location' => [ + VisitLocation::fromGeolocation(new Location(city: 'New York')), + 'New York', + true, + ]; + yield 'matching visit case-insensitive' => [ + VisitLocation::fromGeolocation(new Location(city: 'barcelona')), + 'Barcelona', + true, + ]; + } }