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.
* `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.

View File

@ -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": {

View File

@ -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),
)
};

View File

@ -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]

View File

@ -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),
};
}
}

View File

@ -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

View File

@ -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,
];
}
}