diff --git a/CHANGELOG.md b/CHANGELOG.md index a22b5be9..ac74a67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/swagger/definitions/SetShortUrlRedirectRule.json b/docs/swagger/definitions/SetShortUrlRedirectRule.json index 00f0a27b..b6c66f5f 100644 --- a/docs/swagger/definitions/SetShortUrlRedirectRule.json +++ b/docs/swagger/definitions/SetShortUrlRedirectRule.json @@ -19,6 +19,8 @@ "device", "language", "query-param", + "any-value-query-param", + "valueless-query-param", "ip-address", "geolocation-country-code", "geolocation-city-name" diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index 89f93833..baab9c9e 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -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), ), diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 255cb19e..dd24ca6d 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -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), diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index efc314f9..bcd482e7 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -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, }; }