Repository: shlinkio/shlink (PHP)

Issue:
A dynamic rule based on 'en' language takes priority over all other languages
### Shlink version

4.0.0

### PHP version

8.2.0

### How do you serve Shlink

Other (explain in summary)

### Database engine

MariaDB

### Database version

10.6

### Current behavior

When I insert a dynamic rule with the 'en' language, this rule takes priority over all the others.

### Expected behavior

The rule based on language works correctly regardless of the presence of a dynamic rule with 'en' language.

### How to reproduce

My default language is ITALIAN, and in the Web UI, there's a link to a page in Italian.
Then I added a French page, a German one, and a Spanish one: each with its respective dynamic rule based on 'fr', 'de', and 'es'.
Everything works correctly until I add dynamic rules with 'en' language.
After inserting a dynamic rule based on language that contains 'en', Shlink no longer works correctly and redirects me to the 'en' rule regardless.


Discussion: Have you tried putting that rule the last one on the list?

Browsers usually send multiple accepted languages, as a fallback in case the requested page is not available in the default language.

Every time Shlink verifies a language condition, it just checks if the language set in the condition is among the accepted languages from the request, and English is very likely being sent by your browser.

You can check the Accept-Language header in order to verify this.

A more detailed explanation can be found in [the docs](https://shlink.io/documentation/advanced/dynamic-rule-based-redirects-system/#faqs), where it says "How granular is the language condition?".
Just to give you an example, this is the `Accept-Language` header my browser (Chrome) sends to any website I visit:

![image](https://github.com/shlinkio/shlink/assets/2719332/09ad561f-18ec-45f1-8a0d-035711cabcda)

That means I prefer Spanish from Spain if available, then any Spanish, then English from US, then any English, and then for some reason, Japanese 🤷🏼‍♂️ 

Initially I decided to ignore the quality set for every language (the part after the `q`) and try to match all the languages on the list, but I'm thinking, in order to avoid unexpected matching, Shlink should ignore anything with a quality lower than 0.9

As a workaround, putting the English rule last should do the trick.
I will be away from the office until the weekend, I will get you the requested information as soon as possible.

Yes, I tried to move the priority of the 'en' rule but nothing changes: I don't remember if it was also tested as last, but if it is present it has priority over all of them regardless of the priority order set.

For the test I used Chrome and Firefox with specific extensions that I use to test multilingual on Joomla and Wordpress.

I will let you know the value of the Accepted-Language header as soon as possible.
I can see a slightly different behavior in Firefox, but restricting the rule to match languages with quality between 0.9 and 1 still would match on a more predictable way.

![image](https://github.com/shlinkio/shlink/assets/2719332/80f9815e-01ac-44b2-b8b2-0625bea3ed22)

I did some very quick and quick tests:

Chrome seems to provide a more precise header:
Accept-Language: it-IT,it;q=0.9
which would seem to make Shlink work properly.

While Firefox provides a less precise header:
Accept-Language: it-IT,it;q=0.8,en-US;q=0.5,en;q=0.3
which makes Shlink's algorithm go haywire.

Obviously tested with native browsers WITHOUT EXTENSIONS THAT MODIFY THE ACCEPTED LANGUAGE HEADER.

I think with Firefox, setting the 'en' rule with the lowest priority on Shlink starts to work better.

Could Shlink's algorithm probably be improved so that it better considers the priority of the language with greater weight?

In any case, I managed to do these very quick tests, but before the weekend I won't be able to dedicate myself with the right attention.
Thanks for checking.

I actually put together a PR that should address this: https://github.com/shlinkio/shlink/pull/2045

It changes the logic so that Shlink does not try to match against fallback languages, only the "actual" ones, by discarding any language with a quality lower than `0.9`.

So for example, for `it-IT,it;q=0.9` it would check both `it-IT` and `it`, while for `it-IT,it;q=0.8,en-US;q=0.5,en;q=0.3` it would evaluate only `it-IT`.

This effectively means only `it` or `it-IT` would throw a positive match, which is probably what one would expect.



Fix (patch):
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c2caa10a..9e8232650 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
 
 ## [Unreleased]
 ### Added
-* [#2041](https://github.com/shlinkio/shlink/issues/2041) Document `color` and `bgColor` params for the QR code route in the OAS docs.
+* *Nothing*
 
 ### Changed
 * *Nothing*
@@ -18,7 +18,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
 * *Nothing*
 
 ### Fixed
-* *Nothing*
+* [#2041](https://github.com/shlinkio/shlink/issues/2041) Document missing `color` and `bgColor` params for the QR code route in the OAS docs.
+* [#2043](https://github.com/shlinkio/shlink/issues/2043) Fix language redirect conditions matching too low quality accepted languages.
 
 
 ## [4.0.0] - 2024-03-03
diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php
index e910833ad..5ba45ac21 100644
--- a/module/Core/functions/functions.php
+++ b/module/Core/functions/functions.php
@@ -8,6 +8,7 @@
 use Cake\Chronos\Chronos;
 use DateTimeInterface;
 use Doctrine\ORM\Mapping\Builder\FieldBuilder;
+use GuzzleHttp\Psr7\Query;
 use Jaybizzle\CrawlerDetect\CrawlerDetect;
 use Laminas\Filter\Word\CamelCaseToSeparator;
 use Laminas\Filter\Word\CamelCaseToUnderscore;
@@ -16,7 +17,6 @@
 use Shlinkio\Shlink\Common\Util\DateRange;
 use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
 
-use function array_filter;
 use function array_keys;
 use function array_map;
 use function array_pad;
@@ -27,6 +27,7 @@
 use function is_array;
 use function print_r;
 use function Shlinkio\Shlink\Common\buildDateRange;
+use function Shlinkio\Shlink\Core\ArrayUtils\map;
 use function sprintf;
 use function str_repeat;
 use function str_replace;
@@ -85,16 +86,30 @@ function normalizeLocale(string $locale): string
 }
 
 /**
+ * Parse an accept-language-like pattern into a list of locales, optionally filtering out those which do not match a
+ * minimum quality
+ *
  * @param non-empty-string $acceptLanguage
- * @return string[];
+ * @param float<0, 1> $minQuality
+ * @return iterable<string>;
  */
-function acceptLanguageToLocales(string $acceptLanguage): array
-{
-    $acceptLanguagesList = array_map(function (string $lang): string {
-        [$lang] = explode(';', $lang); // Discard everything after the semicolon (en-US;q=0.7)
-        return normalizeLocale($lang);
-    }, explode(',', $acceptLanguage));
-    return array_filter($acceptLanguagesList, static fn (string $lang) => $lang !== '*');
+function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable
+{
+    /** @var array{string, float|null}[] $acceptLanguagesList */
+    $acceptLanguagesList = map(explode(',', $acceptLanguage), static function (string $lang): array {
+        // Split locale/language and quality (en-US;q=0.7) -> [en-US, q=0.7]
+        [$lang, $qualityString] = array_pad(explode(';', $lang), length: 2, value: '');
+        $normalizedLang = normalizeLocale($lang);
+        $quality = Query::parse(trim($qualityString))['q'] ?? 1;
+
+        return [$normalizedLang, (float) $quality];
+    });
+
+    foreach ($acceptLanguagesList as [$lang, $quality]) {
+        if ($lang !== '*' && $quality >= $minQuality) {
+            yield $lang;
+        }
+    }
 }
 
 /**
@@ -108,7 +123,7 @@ function acceptLanguageToLocales(string $acceptLanguage): array
  */
 function splitLocale(string $locale): array
 {
-    return array_pad(explode('-', $locale), 2, null);
+    return array_pad(explode('-', $locale), length: 2, value: null);
 }
 
 function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php
index 291237332..5e6df4946 100644
--- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php
+++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php
@@ -77,7 +77,7 @@ private function matchesLanguage(ServerRequestInterface $request): bool
             return false;
         }
 
-        $acceptedLanguages = acceptLanguageToLocales($acceptLanguage);
+        $acceptedLanguages = acceptLanguageToLocales($acceptLanguage, minQuality: 0.9);
         [$matchLanguage, $matchCountryCode] = splitLocale(normalizeLocale($this->matchValue));
 
         return some(