Merge pull request #2460 from acelaya-forks/feature/enhanced-query-param-rules

Add support for any-value and valueless query param redirect rules
This commit is contained in:
Alejandro Celaya
2025-07-17 08:57:30 +02:00
committed by GitHub
9 changed files with 146 additions and 20 deletions

View File

@@ -9,10 +9,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
### Added
* [#2438](https://github.com/shlinkio/shlink/issues/2438) Add `MERCURE_ENABLED` env var and corresponding config option, to more easily allow the mercure integration to be toggled.
For BC, if this env vars is not present, we'll still consider the integration enabled if the `MERCURE_PUBLIC_HUB_URL` env var has a value. This is considered deprecated though, and next major version will rely only on `MERCURE_ENABLED`, so if you are using Mercure, make sure to set `MERCURE_ENABLED=true` to be ready.
For BC, if this env var is not present, we'll still consider the integration enabled if the `MERCURE_PUBLIC_HUB_URL` env var has a value. This is considered deprecated though, and next major version will rely only on `MERCURE_ENABLED`, so if you are using Mercure, make sure to set `MERCURE_ENABLED=true` to be ready.
* [#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

@@ -16,6 +16,28 @@ const LOOSE_URI_MATCHER = '/(.+)\:(.+)/i'; // Matches anything starting with a s
const IP_ADDRESS_REQUEST_ATTRIBUTE = 'remote_address';
const REDIRECT_URL_REQUEST_ATTRIBUTE = 'redirect_url';
/**
* List of ISO 3166-1 alpha-2 two-letter country codes https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
*/
const ISO_COUNTRY_CODES = [
'AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ',
'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR',
'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC',
'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'CI', 'HR', 'CU', 'CW', 'CY', 'CZ', 'DK', 'DJ', 'DM', 'DO',
'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF',
'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY',
'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM',
'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY',
'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX',
'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI',
'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH',
'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC',
'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS',
'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK',
'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',
];
/** @deprecated */
const DEFAULT_QR_CODE_SIZE = 300;
/** @deprecated */

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

@@ -162,6 +162,14 @@ class RedirectRuleHandlerTest extends TestCase
yield 'device' => [RedirectConditionType::DEVICE, [RedirectCondition::forDevice(DeviceType::ANDROID)]];
yield 'language' => [RedirectConditionType::LANGUAGE, [RedirectCondition::forLanguage('en-US')]];
yield 'query param' => [RedirectConditionType::QUERY_PARAM, [RedirectCondition::forQueryParam('foo', 'bar')]];
yield 'any value query param' => [
RedirectConditionType::ANY_VALUE_QUERY_PARAM,
[RedirectCondition::forAnyValueQueryParam('foo')],
];
yield 'valueless query param' => [
RedirectConditionType::VALUELESS_QUERY_PARAM,
[RedirectCondition::forValuelessQueryParam('foo')],
];
yield 'multiple query params' => [
RedirectConditionType::QUERY_PARAM,
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],

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

@@ -8,11 +8,15 @@ use Shlinkio\Shlink\Core\Util\IpAddressUtils;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\enumValues;
use const Shlinkio\Shlink\ISO_COUNTRY_CODES;
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';
@@ -26,25 +30,12 @@ enum RedirectConditionType: string
RedirectConditionType::DEVICE => contains($value, enumValues(DeviceType::class)),
// RedirectConditionType::LANGUAGE => TODO Validate at least format,
RedirectConditionType::IP_ADDRESS => IpAddressUtils::isStaticIpCidrOrWildcard($value),
RedirectConditionType::GEOLOCATION_COUNTRY_CODE => contains($value, [
// List of ISO 3166-1 alpha-2 two-letter country codes https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
'AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AU', 'AT', 'AZ',
'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR',
'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'TD', 'CL', 'CN', 'CX', 'CC',
'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'CI', 'HR', 'CU', 'CW', 'CY', 'CZ', 'DK', 'DJ', 'DM', 'DO',
'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF',
'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY',
'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM',
'JP', 'JE', 'JO', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY',
'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX',
'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI',
'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH',
'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC',
'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS',
'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK',
'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::GEOLOCATION_COUNTRY_CODE => contains($value, ISO_COUNTRY_CODES),
RedirectConditionType::QUERY_PARAM,
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,
};
}

View File

@@ -32,6 +32,33 @@ class RedirectConditionTest extends TestCase
self::assertEquals($expectedResult, $result);
}
#[Test]
#[TestWith(['nop', '', false])] // param not present
#[TestWith(['foo', '', true])]
#[TestWith(['foo', 'something', true])]
#[TestWith(['foo', 'something else', true])]
public function matchesAnyValueQueryParams(string $param, string $value, bool $expectedResult): void
{
$request = ServerRequestFactory::fromGlobals()->withQueryParams(['foo' => $value]);
$result = RedirectCondition::forAnyValueQueryParam($param)->matchesRequest($request);
self::assertEquals($expectedResult, $result);
}
#[Test]
#[TestWith(['nop', '', false])] // param not present
#[TestWith(['foo', '', true])]
#[TestWith(['foo', null, true])]
#[TestWith(['foo', 'something', false])]
#[TestWith(['foo', 'something else', false])]
public function matchesValuelessQueryParams(string $param, string|null $value, bool $expectedResult): void
{
$request = ServerRequestFactory::fromGlobals()->withQueryParams(['foo' => $value]);
$result = RedirectCondition::forValuelessQueryParam($param)->matchesRequest($request);
self::assertEquals($expectedResult, $result);
}
#[Test]
#[TestWith([null, '', false], 'no accept language')]
#[TestWith(['', '', false], 'empty accept language')]
@@ -141,6 +168,8 @@ class RedirectConditionTest extends TestCase
#[TestWith([RedirectConditionType::DEVICE->value, RedirectConditionType::DEVICE])]
#[TestWith([RedirectConditionType::LANGUAGE->value, RedirectConditionType::LANGUAGE])]
#[TestWith([RedirectConditionType::QUERY_PARAM->value, RedirectConditionType::QUERY_PARAM])]
#[TestWith([RedirectConditionType::ANY_VALUE_QUERY_PARAM->value, RedirectConditionType::ANY_VALUE_QUERY_PARAM])]
#[TestWith([RedirectConditionType::VALUELESS_QUERY_PARAM->value, RedirectConditionType::VALUELESS_QUERY_PARAM])]
#[TestWith([RedirectConditionType::IP_ADDRESS->value, RedirectConditionType::IP_ADDRESS])]
#[TestWith(
[RedirectConditionType::GEOLOCATION_COUNTRY_CODE->value, RedirectConditionType::GEOLOCATION_COUNTRY_CODE],

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\RedirectRule\Model;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
class RedirectConditionTypeTest extends TestCase
{
#[Test]
#[TestWith([RedirectConditionType::QUERY_PARAM, '', false])]
#[TestWith([RedirectConditionType::QUERY_PARAM, 'foo', true])]
#[TestWith([RedirectConditionType::ANY_VALUE_QUERY_PARAM, '', false])]
#[TestWith([RedirectConditionType::ANY_VALUE_QUERY_PARAM, 'foo', true])]
#[TestWith([RedirectConditionType::VALUELESS_QUERY_PARAM, '', false])]
#[TestWith([RedirectConditionType::VALUELESS_QUERY_PARAM, 'foo', true])]
public function isValidFailsForEmptyQueryParams(
RedirectConditionType $conditionType,
string $value,
bool $expectedIsValid,
): void {
self::assertEquals($expectedIsValid, $conditionType->isValid($value));
}
}