Merge pull request #2586 from acelaya-forks/browser-redirect-rule

Support dynamic redirects based on the visitor browser
This commit is contained in:
Alejandro Celaya
2026-03-17 09:12:51 +01:00
committed by GitHub
11 changed files with 117 additions and 25 deletions
+4
View File
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
## [Unreleased]
### Added
* [#2585](https://github.com/shlinkio/shlink/issues/2585) Add new browser condition for dynamic redirects system.
This condition accepts the values `chrome`, `firefox`, `edge`, `safari`, `opera` and `android_browser`, to redirect to different places based on specific known web browsers.
* [#2583](https://github.com/shlinkio/shlink/issues/2583) Support redis clusters with sentinels which have their own ACL authentication.
### Changed
+10
View File
@@ -19,3 +19,13 @@ const MACOS_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5) AppleWebK
. 'Version/18.4 Safari/605.1.15';
const CHROMEOS_USER_AGENT = 'Mozilla/5.0 (X11; CrOS x86_64 16181.61.0) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/134.0.6998.198 Safari/537.36';
const BROWSER_CHROME_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/146.0.0.0 Safari/537.36';
const BROWSER_FIREFOX_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0';
const BROWSER_EDGE_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/146.0.0.0 Safari/537.36 Edg/146.0.3856.62';
const BROWSER_SAFARI_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_4) AppleWebKit/605.1.15 '
. '(KHTML, like Gecko) Version/26.0 Safari/605.1.15';
const BROWSER_OPERA_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
. 'Chrome/146.0.0.0 Safari/537.36 OPR/128.0.0.0';
@@ -25,7 +25,8 @@
"geolocation-country-code",
"geolocation-city-name",
"before-date",
"after-date"
"after-date",
"browser"
],
"description": "The type of the condition, which will determine the logic used to match it"
},
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\RedirectRule;
use Doctrine\Common\Collections\ArrayCollection;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Model\Browser;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
@@ -130,6 +131,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface
RedirectConditionType::AFTER_DATE => RedirectCondition::forAfterDate(
normalizeDate($this->askMandatory('Date to match?', $io)),
),
RedirectConditionType::BROWSER => RedirectCondition::forBrowser(
Browser::from($io->choice('Browser to match?', enumValues(Browser::class))),
),
};
$continue = $io->confirm('Do you want to add another condition?');
@@ -12,6 +12,7 @@ use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandler;
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerAction;
use Shlinkio\Shlink\Core\Model\Browser;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
@@ -135,6 +136,8 @@ class RedirectRuleHandlerTest extends TestCase
return $type->value;
} elseif ($message === 'Device to match?') {
return DeviceType::ANDROID->value;
} elseif ($message === 'Browser to match?') {
return Browser::CHROME->value;
}
// First we select remove action to trigger code branch, then save to finish execution
@@ -180,22 +183,23 @@ class RedirectRuleHandlerTest extends TestCase
true,
];
yield 'IP address' => [RedirectConditionType::IP_ADDRESS, [RedirectCondition::forIpAddress('1.2.3.4')]];
yield 'Geolocation country code' => [
yield 'geolocation country code' => [
RedirectConditionType::GEOLOCATION_COUNTRY_CODE,
[RedirectCondition::forGeolocationCountryCode('FR')],
];
yield 'Geolocation city name' => [
yield 'geolocation city name' => [
RedirectConditionType::GEOLOCATION_CITY_NAME,
[RedirectCondition::forGeolocationCityName('Los angeles')],
];
yield 'Before date' => [
yield 'before date' => [
RedirectConditionType::BEFORE_DATE,
[RedirectCondition::forBeforeDate(normalizeDate('2016-05-01T20:34:16+02:00'))],
];
yield 'After date' => [
yield 'after date' => [
RedirectConditionType::AFTER_DATE,
[RedirectCondition::forAfterDate(normalizeDate('2016-05-01T20:34:16+02:00'))],
];
yield 'browser' => [RedirectConditionType::BROWSER, [RedirectCondition::forBrowser(Browser::CHROME)]];
}
#[Test]
+9 -17
View File
@@ -8,10 +8,11 @@ use BackedEnum;
use Cake\Chronos\Chronos;
use DateTimeInterface;
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use donatj\UserAgent\UserAgent;
use donatj\UserAgent\UserAgentParser;
use GuzzleHttp\Psr7\Query;
use Hidehalo\Nanoid\Client as NanoidClient;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
use Laminas\Filter\Word\CamelCaseToSeparator;
use Laminas\Filter\Word\CamelCaseToUnderscore;
use Laminas\InputFilter\InputFilter;
use Psr\Http\Message\ServerRequestInterface;
@@ -35,7 +36,6 @@ use function str_repeat;
use function str_replace;
use function strtolower;
use function trim;
use function ucfirst;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
@@ -197,14 +197,16 @@ function arrayToString(array $array, int $indentSize = 4): string
function isCrawler(string $userAgent): bool
{
static $detector;
if ($detector === null) {
$detector = new CrawlerDetect();
}
static $detector = new CrawlerDetect();
return $detector->isCrawler($userAgent);
}
function parseUserAgent(string $userAgent): UserAgent
{
static $uaParser = new UserAgentParser();
return $uaParser->parse($userAgent);
}
function determineTableName(string $tableName, array $emConfig = []): string
{
$schema = $emConfig['connection']['schema'] ?? null;
@@ -226,16 +228,6 @@ function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $coll
};
}
function camelCaseToHumanFriendly(string $value): string
{
static $filter;
if ($filter === null) {
$filter = new CamelCaseToSeparator(' ');
}
return ucfirst($filter->filter($value));
}
function camelCaseToSnakeCase(string $value): string
{
static $filter;
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace Shlinkio\Shlink\Core\Model;
use donatj\UserAgent\Browsers;
use function Shlinkio\Shlink\Core\parseUserAgent;
enum Browser: string
{
case CHROME = 'chrome';
case FIREFOX = 'firefox';
case EDGE = 'edge';
case SAFARI = 'safari';
case OPERA = 'opera';
case ANDROID_BROWSER = 'android_browser';
/**
* Determines which browser matches provided user agent.
*/
public static function matchFromUserAgent(string $userAgent): self|null
{
$ua = parseUserAgent($userAgent);
return match ($ua->browser()) {
Browsers::CHROME => self::CHROME,
Browsers::FIREFOX => self::FIREFOX,
Browsers::EDGE => self::EDGE,
Browsers::SAFARI => self::SAFARI,
Browsers::OPERA => self::OPERA,
Browsers::ANDROID_BROWSER => self::ANDROID_BROWSER,
default => null,
};
}
}
+3 -3
View File
@@ -3,7 +3,8 @@
namespace Shlinkio\Shlink\Core\Model;
use donatj\UserAgent\Platforms;
use donatj\UserAgent\UserAgentParser;
use function Shlinkio\Shlink\Core\parseUserAgent;
enum DeviceType: string
{
@@ -22,8 +23,7 @@ enum DeviceType: string
*/
public static function matchFromUserAgent(string $userAgent): array
{
static $uaParser = new UserAgentParser();
$ua = $uaParser->parse($userAgent);
$ua = parseUserAgent($userAgent);
return match ($ua->platform()) {
Platforms::IPHONE, Platforms::IPAD => [self::IOS, self::MOBILE], // iPhone and iPad (except iPadOS 13+)
@@ -6,6 +6,7 @@ use Cake\Chronos\Chronos;
use JsonSerializable;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Model\Browser;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
@@ -87,6 +88,11 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
return new self(RedirectConditionType::AFTER_DATE, $date->toAtomString());
}
public static function forBrowser(Browser $browser): self
{
return new self(RedirectConditionType::BROWSER, $browser->value);
}
public static function fromRawData(array $rawData): self
{
$type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]);
@@ -114,6 +120,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
RedirectConditionType::GEOLOCATION_CITY_NAME => self::forGeolocationCityName($cond->matchValue),
RedirectConditionType::BEFORE_DATE => self::forBeforeDate(normalizeDate($cond->matchValue)),
RedirectConditionType::AFTER_DATE => self::forAfterDate(normalizeDate($cond->matchValue)),
RedirectConditionType::BROWSER => self::forBrowser(Browser::from($cond->matchValue)),
};
}
@@ -133,6 +140,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
RedirectConditionType::GEOLOCATION_CITY_NAME => $this->matchesGeolocationCityName($request),
RedirectConditionType::BEFORE_DATE => $this->matchesBeforeDate(),
RedirectConditionType::AFTER_DATE => $this->matchesAfterDate(),
RedirectConditionType::BROWSER => $this->matchesBrowser($request),
};
}
@@ -226,6 +234,12 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
return Chronos::now()->greaterThan(Chronos::parse($this->matchValue));
}
private function matchesBrowser(ServerRequestInterface $request): bool
{
$browser = Browser::matchFromUserAgent($request->getHeaderLine('User-Agent'));
return $browser !== null && $browser->value === $this->matchValue;
}
public function jsonSerialize(): array
{
return [
@@ -258,6 +272,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
RedirectConditionType::GEOLOCATION_CITY_NAME => sprintf('city name is %s', $this->matchValue),
RedirectConditionType::BEFORE_DATE => sprintf('date is before %s', $this->matchValue),
RedirectConditionType::AFTER_DATE => sprintf('date is after %s', $this->matchValue),
RedirectConditionType::BROWSER => sprintf('browser is %s', $this->matchValue),
};
}
}
@@ -2,6 +2,7 @@
namespace Shlinkio\Shlink\Core\RedirectRule\Model;
use Shlinkio\Shlink\Core\Model\Browser;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\Util\IpAddressUtils;
@@ -22,6 +23,7 @@ enum RedirectConditionType: string
case GEOLOCATION_CITY_NAME = 'geolocation-city-name';
case BEFORE_DATE = 'before-date';
case AFTER_DATE = 'after-date';
case BROWSER = 'browser';
/**
* Tells if a value is valid for the condition type
@@ -30,6 +32,7 @@ enum RedirectConditionType: string
{
return match ($this) {
RedirectConditionType::DEVICE => contains($value, enumValues(DeviceType::class)),
RedirectConditionType::BROWSER => contains($value, enumValues(Browser::class)),
// RedirectConditionType::LANGUAGE => TODO Validate at least format,
RedirectConditionType::IP_ADDRESS => IpAddressUtils::isStaticIpCidrOrWildcard($value),
RedirectConditionType::GEOLOCATION_COUNTRY_CODE => contains($value, ISO_COUNTRY_CODES),
@@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Model\Browser;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
@@ -16,6 +17,10 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location;
use const Shlinkio\Shlink\IP_ADDRESS_REQUEST_ATTRIBUTE;
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
use const ShlinkioTest\Shlink\BROWSER_CHROME_USER_AGENT;
use const ShlinkioTest\Shlink\BROWSER_FIREFOX_USER_AGENT;
use const ShlinkioTest\Shlink\BROWSER_OPERA_USER_AGENT;
use const ShlinkioTest\Shlink\BROWSER_SAFARI_USER_AGENT;
use const ShlinkioTest\Shlink\CHROMEOS_USER_AGENT;
use const ShlinkioTest\Shlink\IOS_USER_AGENT;
use const ShlinkioTest\Shlink\LINUX_USER_AGENT;
@@ -226,4 +231,23 @@ class RedirectConditionTest extends TestCase
yield 'date later than current' => [Chronos::now()->addHours(1), false];
yield 'date earlier than current' => [Chronos::now()->subHours(1), true];
}
#[Test]
#[TestWith([null, Browser::CHROME, false])]
#[TestWith(['unknown', Browser::CHROME, false])]
#[TestWith([BROWSER_CHROME_USER_AGENT, Browser::CHROME, true])]
#[TestWith([BROWSER_FIREFOX_USER_AGENT, Browser::FIREFOX, true])]
#[TestWith([BROWSER_SAFARI_USER_AGENT, Browser::SAFARI, true])]
#[TestWith([BROWSER_OPERA_USER_AGENT, Browser::OPERA, true])]
public function matchesBrowser(string|null $userAgent, Browser $value, bool $expected): void
{
$request = ServerRequestFactory::fromGlobals();
if ($userAgent !== null) {
$request = $request->withHeader('User-Agent', $userAgent);
}
$result = RedirectCondition::forBrowser($value)->matchesRequest($request);
self::assertEquals($expected, $result);
}
}