Add support for any-value and valueless query param redirect rules

This commit is contained in:
Alejandro Celaya
2025-07-17 08:26:52 +02:00
parent e762d28b67
commit 18c4c39fee
5 changed files with 53 additions and 0 deletions

View File

@@ -13,6 +13,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#2387](https://github.com/shlinkio/shlink/issues/2387) Add `REAL_TIME_UPDATES_TOPICS` env var and corresponding config option, to granularly decide which real-time updates topics should be enabled.
* [#2418](https://github.com/shlinkio/shlink/issues/2418) Add more granular control over how Shlink handles CORS. It is now possible to customize the `Access-Control-Allow-Origin`, `Access-Control-Max-Age` and `Access-Control-Allow-Credentials` headers via env vars or config options.
* [#2386](https://github.com/shlinkio/shlink/issues/2386) Add new `any-value-query-param` and `valueless-query-param` redirect rule conditions.
These new rules expand the existing `query-param`, which requires both a specific non-empty value in order to match the condition.
The new conditions match as soon as a query param exists with any or no value (in the case of `any-value-query-param`), or if a query param exists with no value at all (in the case of `valueless-query-param`).
### Changed
* [#2406](https://github.com/shlinkio/shlink/issues/2406) Remove references to bootstrap from error templates, and instead inline the very minimum required styles.

View File

@@ -19,6 +19,8 @@
"device",
"language",
"query-param",
"any-value-query-param",
"valueless-query-param",
"ip-address",
"geolocation-country-code",
"geolocation-city-name"

View File

@@ -108,6 +108,12 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
$this->askMandatory('Query param name?', $io),
$this->askOptional('Query param value?', $io),
),
RedirectConditionType::ANY_VALUE_QUERY_PARAM => RedirectCondition::forAnyValueQueryParam(
$this->askMandatory('Query param name?', $io),
),
RedirectConditionType::VALUELESS_QUERY_PARAM => RedirectCondition::forValuelessQueryParam(
$this->askMandatory('Query param name?', $io),
),
RedirectConditionType::IP_ADDRESS => RedirectCondition::forIpAddress(
$this->askMandatory('IP address, CIDR block or wildcard-pattern (1.2.*.*)', $io),
),

View File

@@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkRedirectCondition;
use function array_key_exists;
use function Shlinkio\Shlink\Core\acceptLanguageToLocales;
use function Shlinkio\Shlink\Core\ArrayUtils\some;
use function Shlinkio\Shlink\Core\geolocationFromRequest;
@@ -35,6 +36,16 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
return new self(RedirectConditionType::QUERY_PARAM, $value, $param);
}
public static function forAnyValueQueryParam(string $param): self
{
return new self(RedirectConditionType::ANY_VALUE_QUERY_PARAM, $param);
}
public static function forValuelessQueryParam(string $param): self
{
return new self(RedirectConditionType::VALUELESS_QUERY_PARAM, $param);
}
public static function forLanguage(string $language): self
{
return new self(RedirectConditionType::LANGUAGE, $language);
@@ -82,6 +93,8 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
return match ($type) {
RedirectConditionType::QUERY_PARAM => self::forQueryParam($cond->matchKey ?? '', $cond->matchValue),
RedirectConditionType::ANY_VALUE_QUERY_PARAM => self::forAnyValueQueryParam($cond->matchValue),
RedirectConditionType::VALUELESS_QUERY_PARAM => self::forValuelessQueryParam($cond->matchValue),
RedirectConditionType::LANGUAGE => self::forLanguage($cond->matchValue),
RedirectConditionType::DEVICE => self::forDevice(DeviceType::from($cond->matchValue)),
RedirectConditionType::IP_ADDRESS => self::forIpAddress($cond->matchValue),
@@ -97,6 +110,8 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
{
return match ($this->type) {
RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request),
RedirectConditionType::ANY_VALUE_QUERY_PARAM => $this->matchesAnyValueQueryParam($request),
RedirectConditionType::VALUELESS_QUERY_PARAM => $this->matchesValuelessQueryParam($request),
RedirectConditionType::LANGUAGE => $this->matchesLanguage($request),
RedirectConditionType::DEVICE => $this->matchesDevice($request),
RedirectConditionType::IP_ADDRESS => $this->matchesRemoteIpAddress($request),
@@ -113,6 +128,18 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
return $queryValue === $this->matchValue;
}
private function matchesValuelessQueryParam(ServerRequestInterface $request): bool
{
$query = $request->getQueryParams();
return array_key_exists($this->matchValue, $query) && empty($query[$this->matchValue]);
}
private function matchesAnyValueQueryParam(ServerRequestInterface $request): bool
{
$query = $request->getQueryParams();
return array_key_exists($this->matchValue, $query);
}
private function matchesLanguage(ServerRequestInterface $request): bool
{
$acceptLanguage = trim($request->getHeaderLine('Accept-Language'));
@@ -188,6 +215,14 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
$this->matchKey,
$this->matchValue,
),
RedirectConditionType::ANY_VALUE_QUERY_PARAM => sprintf(
'query string contains %s param',
$this->matchValue,
),
RedirectConditionType::VALUELESS_QUERY_PARAM => sprintf(
'query string contains %s param without a value (https://example.com?foo)',
$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_CITY_NAME => sprintf('city name is %s', $this->matchValue),

View File

@@ -13,6 +13,8 @@ enum RedirectConditionType: string
case DEVICE = 'device';
case LANGUAGE = 'language';
case QUERY_PARAM = 'query-param';
case ANY_VALUE_QUERY_PARAM = 'any-value-query-param';
case VALUELESS_QUERY_PARAM = 'valueless-query-param';
case IP_ADDRESS = 'ip-address';
case GEOLOCATION_COUNTRY_CODE = 'geolocation-country-code';
case GEOLOCATION_CITY_NAME = 'geolocation-city-name';
@@ -45,6 +47,9 @@ enum RedirectConditionType: string
'TO', 'TT', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'US', 'UM', 'UY', 'UZ', 'VU',
'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW',
]),
RedirectConditionType::ANY_VALUE_QUERY_PARAM, RedirectConditionType::VALUELESS_QUERY_PARAM => $value !== '',
// FIXME We should at least validate the value is not empty
// default => $value !== '',
default => true,
};
}