Add support for city name dynamic redirects

This commit is contained in:
Alejandro Celaya 2024-11-14 09:58:53 +01:00
parent dbef32ffcb
commit a6e0916272
7 changed files with 68 additions and 3 deletions

View File

@ -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. * [#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-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 ### Changed
* [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text. * [#2193](https://github.com/shlinkio/shlink/issues/2193) API keys are now hashed using SHA256, instead of being saved in plain text.

View File

@ -15,7 +15,14 @@
"properties": { "properties": {
"type": { "type": {
"type": "string", "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" "description": "The type of the condition, which will determine the logic used to match it"
}, },
"matchKey": { "matchKey": {

View File

@ -113,6 +113,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
), ),
RedirectConditionType::GEOLOCATION_COUNTRY_CODE => RedirectCondition::forGeolocationCountryCode( RedirectConditionType::GEOLOCATION_COUNTRY_CODE => RedirectCondition::forGeolocationCountryCode(
$this->askMandatory('Country code to match?', $io), $this->askMandatory('Country code to match?', $io),
),
RedirectConditionType::GEOLOCATION_CITY_NAME => RedirectCondition::forGeolocationCityName(
$this->askMandatory('City name to match?', $io),
) )
}; };

View File

@ -118,6 +118,7 @@ class RedirectRuleHandlerTest extends TestCase
'Query param value?' => 'bar', 'Query param value?' => 'bar',
'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4', 'IP address, CIDR block or wildcard-pattern (1.2.*.*)' => '1.2.3.4',
'Country code to match?' => 'FR', 'Country code to match?' => 'FR',
'City name to match?' => 'Los angeles',
default => '', default => '',
}, },
); );
@ -170,6 +171,10 @@ class RedirectRuleHandlerTest extends TestCase
RedirectConditionType::GEOLOCATION_COUNTRY_CODE, RedirectConditionType::GEOLOCATION_COUNTRY_CODE,
[RedirectCondition::forGeolocationCountryCode('FR')], [RedirectCondition::forGeolocationCountryCode('FR')],
]; ];
yield 'Geolocation city name' => [
RedirectConditionType::GEOLOCATION_CITY_NAME,
[RedirectCondition::forGeolocationCityName('Los angeles')],
];
} }
#[Test] #[Test]

View File

@ -59,6 +59,11 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
return new self(RedirectConditionType::GEOLOCATION_COUNTRY_CODE, $countryCode); 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 public static function fromRawData(array $rawData): self
{ {
$type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]);
@ -79,6 +84,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
RedirectConditionType::DEVICE => $this->matchesDevice($request), RedirectConditionType::DEVICE => $this->matchesDevice($request),
RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request), RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request),
RedirectConditionType::GEOLOCATION_COUNTRY_CODE => $this->matchesGeolocationCountryCode($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; 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 public function jsonSerialize(): array
{ {
return [ return [
@ -158,6 +176,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
), ),
RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue), RedirectConditionType::IP_ADDRESS => sprintf('IP address matches %s', $this->matchValue),
RedirectConditionType::GEOLOCATION_COUNTRY_CODE => sprintf('country code is %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),
}; };
} }
} }

View File

@ -15,6 +15,7 @@ enum RedirectConditionType: string
case QUERY_PARAM = 'query-param'; case QUERY_PARAM = 'query-param';
case IP_ADDRESS = 'ip-address'; case IP_ADDRESS = 'ip-address';
case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code'; case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code';
case GEOLOCATION_CITY_NAME = 'geolocation-city-name';
/** /**
* Tells if a value is valid for the condition type * Tells if a value is valid for the condition type

View File

@ -97,7 +97,7 @@ class RedirectConditionTest extends TestCase
self::assertEquals($expected, $result); self::assertEquals($expected, $result);
} }
#[Test, DataProvider('provideVisits')] #[Test, DataProvider('provideVisitsWithCountry')]
public function matchesGeolocationCountryCode( public function matchesGeolocationCountryCode(
Location|VisitLocation|null $location, Location|VisitLocation|null $location,
string $countryCodeToMatch, string $countryCodeToMatch,
@ -108,7 +108,7 @@ class RedirectConditionTest extends TestCase
self::assertEquals($expected, $result); self::assertEquals($expected, $result);
} }
public static function provideVisits(): iterable public static function provideVisitsWithCountry(): iterable
{ {
yield 'no location' => [null, 'US', false]; yield 'no location' => [null, 'US', false];
yield 'non-matching location' => [new Location(countryCode: 'ES'), 'US', false]; yield 'non-matching location' => [new Location(countryCode: 'ES'), 'US', false];
@ -125,4 +125,33 @@ class RedirectConditionTest extends TestCase
true, 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,
];
}
} }