mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-10 09:13:11 +08:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f248001460 | ||
|
|
1fe2c93946 | ||
|
|
a3d50605c1 | ||
|
|
5427152f15 | ||
|
|
a4e9c2fdde | ||
|
|
e244b2dc51 | ||
|
|
31dea8fa99 | ||
|
|
be8cf56240 | ||
|
|
0bc7412430 | ||
|
|
6d56e92306 | ||
|
|
97c94f8fcc |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -4,6 +4,41 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [4.0.2] - 2024-03-09
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#2021](https://github.com/shlinkio/shlink/issues/2021) Fix infinite GeoLite2 downloads.
|
||||||
|
|
||||||
|
|
||||||
|
## [4.0.1] - 2024-03-08
|
||||||
|
### Added
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#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
|
## [4.0.0] - 2024-03-03
|
||||||
### Added
|
### Added
|
||||||
* [#1914](https://github.com/shlinkio/shlink/issues/1914) Add new dynamic redirects engine based on rules. Rules are conditions checked against the visitor's request, and when matching, they can result in a redirect to a different long URL.
|
* [#1914](https://github.com/shlinkio/shlink/issues/1914) Add new dynamic redirects engine based on rules. Rules are conditions checked against the visitor's request, and when matching, they can result in a redirect to a different long URL.
|
||||||
@@ -56,7 +91,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* [#1968](https://github.com/shlinkio/shlink/issues/1968) Move migrations from `data` to `module/Core`.
|
* [#1968](https://github.com/shlinkio/shlink/issues/1968) Move migrations from `data` to `module/Core`.
|
||||||
* *Nothing*
|
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"shlinkio/shlink-event-dispatcher": "^4.0",
|
"shlinkio/shlink-event-dispatcher": "^4.0",
|
||||||
"shlinkio/shlink-importer": "^5.3",
|
"shlinkio/shlink-importer": "^5.3",
|
||||||
"shlinkio/shlink-installer": "^9.0",
|
"shlinkio/shlink-installer": "^9.0",
|
||||||
"shlinkio/shlink-ip-geolocation": "^3.5",
|
"shlinkio/shlink-ip-geolocation": "^4.0",
|
||||||
"shlinkio/shlink-json": "^1.1",
|
"shlinkio/shlink-json": "^1.1",
|
||||||
"spiral/roadrunner": "^2023.3",
|
"spiral/roadrunner": "^2023.3",
|
||||||
"spiral/roadrunner-cli": "^2.6",
|
"spiral/roadrunner-cli": "^2.6",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["device", "language", "query"],
|
"enum": ["device", "language", "query-param"],
|
||||||
"description": "The type of the condition, which will condition the logic used to match it"
|
"description": "The type of the condition, which will condition the logic used to match it"
|
||||||
},
|
},
|
||||||
"matchKey": {
|
"matchKey": {
|
||||||
|
|||||||
@@ -65,6 +65,26 @@
|
|||||||
"enum": ["true", "false"],
|
"enum": ["true", "false"],
|
||||||
"default": "false"
|
"default": "false"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "color",
|
||||||
|
"in": "query",
|
||||||
|
"description": "The QR code foreground color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "#000000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bgColor",
|
||||||
|
"in": "query",
|
||||||
|
"description": "The QR code background color. It should be an hex representation of a color, in 3 or 6 characters, optionally preceded by the \"#\" character.",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "#ffffff"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Shlinkio\Shlink\CLI;
|
namespace Shlinkio\Shlink\CLI;
|
||||||
|
|
||||||
use GeoIp2\Database\Reader;
|
|
||||||
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||||
use Laminas\ServiceManager\Factory\InvokableFactory;
|
use Laminas\ServiceManager\Factory\InvokableFactory;
|
||||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||||
@@ -18,6 +17,7 @@ use Shlinkio\Shlink\Core\Tag\TagService;
|
|||||||
use Shlinkio\Shlink\Core\Visit;
|
use Shlinkio\Shlink\Core\Visit;
|
||||||
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
||||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||||
|
use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2ReaderFactory;
|
||||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||||
use Symfony\Component\Console as SymfonyCli;
|
use Symfony\Component\Console as SymfonyCli;
|
||||||
use Symfony\Component\Lock\LockFactory;
|
use Symfony\Component\Lock\LockFactory;
|
||||||
@@ -76,7 +76,7 @@ return [
|
|||||||
ConfigAbstractFactory::class => [
|
ConfigAbstractFactory::class => [
|
||||||
GeoLite\GeolocationDbUpdater::class => [
|
GeoLite\GeolocationDbUpdater::class => [
|
||||||
DbUpdater::class,
|
DbUpdater::class,
|
||||||
Reader::class,
|
GeoLite2ReaderFactory::class,
|
||||||
LOCAL_LOCK_FACTORY,
|
LOCAL_LOCK_FACTORY,
|
||||||
TrackingOptions::class,
|
TrackingOptions::class,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Shlinkio\Shlink\CLI\GeoLite;
|
namespace Shlinkio\Shlink\CLI\GeoLite;
|
||||||
|
|
||||||
use Cake\Chronos\Chronos;
|
use Cake\Chronos\Chronos;
|
||||||
|
use Closure;
|
||||||
use GeoIp2\Database\Reader;
|
use GeoIp2\Database\Reader;
|
||||||
use MaxMind\Db\Reader\Metadata;
|
use MaxMind\Db\Reader\Metadata;
|
||||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||||
@@ -21,12 +22,19 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
|||||||
{
|
{
|
||||||
private const LOCK_NAME = 'geolocation-db-update';
|
private const LOCK_NAME = 'geolocation-db-update';
|
||||||
|
|
||||||
|
/** @var Closure(): Reader */
|
||||||
|
private readonly Closure $geoLiteDbReaderFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(): Reader $geoLiteDbReaderFactory
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly DbUpdaterInterface $dbUpdater,
|
private readonly DbUpdaterInterface $dbUpdater,
|
||||||
private readonly Reader $geoLiteDbReader,
|
callable $geoLiteDbReaderFactory,
|
||||||
private readonly LockFactory $locker,
|
private readonly LockFactory $locker,
|
||||||
private readonly TrackingOptions $trackingOptions,
|
private readonly TrackingOptions $trackingOptions,
|
||||||
) {
|
) {
|
||||||
|
$this->geoLiteDbReaderFactory = $geoLiteDbReaderFactory(...);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +65,7 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
|
|||||||
return $this->downloadNewDb(false, $beforeDownload, $handleProgress);
|
return $this->downloadNewDb(false, $beforeDownload, $handleProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
$meta = $this->geoLiteDbReader->metadata();
|
$meta = ($this->geoLiteDbReaderFactory)()->metadata();
|
||||||
if ($this->buildIsTooOld($meta)) {
|
if ($this->buildIsTooOld($meta)) {
|
||||||
return $this->downloadNewDb(true, $beforeDownload, $handleProgress);
|
return $this->downloadNewDb(true, $beforeDownload, $handleProgress);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ class GeolocationDbUpdaterTest extends TestCase
|
|||||||
|
|
||||||
return new GeolocationDbUpdater(
|
return new GeolocationDbUpdater(
|
||||||
$this->dbUpdater,
|
$this->dbUpdater,
|
||||||
$this->geoLiteDbReader,
|
fn () => $this->geoLiteDbReader,
|
||||||
$locker,
|
$locker,
|
||||||
$options ?? new TrackingOptions(),
|
$options ?? new TrackingOptions(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use BackedEnum;
|
|||||||
use Cake\Chronos\Chronos;
|
use Cake\Chronos\Chronos;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
|
use Doctrine\ORM\Mapping\Builder\FieldBuilder;
|
||||||
|
use GuzzleHttp\Psr7\Query;
|
||||||
use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
use Jaybizzle\CrawlerDetect\CrawlerDetect;
|
||||||
use Laminas\Filter\Word\CamelCaseToSeparator;
|
use Laminas\Filter\Word\CamelCaseToSeparator;
|
||||||
use Laminas\Filter\Word\CamelCaseToUnderscore;
|
use Laminas\Filter\Word\CamelCaseToUnderscore;
|
||||||
@@ -16,7 +17,6 @@ use PUGX\Shortid\Factory as ShortIdFactory;
|
|||||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode;
|
||||||
|
|
||||||
use function array_filter;
|
|
||||||
use function array_keys;
|
use function array_keys;
|
||||||
use function array_map;
|
use function array_map;
|
||||||
use function array_pad;
|
use function array_pad;
|
||||||
@@ -27,6 +27,7 @@ use function implode;
|
|||||||
use function is_array;
|
use function is_array;
|
||||||
use function print_r;
|
use function print_r;
|
||||||
use function Shlinkio\Shlink\Common\buildDateRange;
|
use function Shlinkio\Shlink\Common\buildDateRange;
|
||||||
|
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
use function str_repeat;
|
use function str_repeat;
|
||||||
use function str_replace;
|
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
|
* @param non-empty-string $acceptLanguage
|
||||||
* @return string[];
|
* @param float<0, 1> $minQuality
|
||||||
|
* @return iterable<string>;
|
||||||
*/
|
*/
|
||||||
function acceptLanguageToLocales(string $acceptLanguage): array
|
function acceptLanguageToLocales(string $acceptLanguage, float $minQuality = 0): iterable
|
||||||
{
|
{
|
||||||
$acceptLanguagesList = array_map(function (string $lang): string {
|
/** @var array{string, float|null}[] $acceptLanguagesList */
|
||||||
[$lang] = explode(';', $lang); // Discard everything after the semicolon (en-US;q=0.7)
|
$acceptLanguagesList = map(explode(',', $acceptLanguage), static function (string $lang): array {
|
||||||
return normalizeLocale($lang);
|
// Split locale/language and quality (en-US;q=0.7) -> [en-US, q=0.7]
|
||||||
}, explode(',', $acceptLanguage));
|
[$lang, $qualityString] = array_pad(explode(';', $lang), length: 2, value: '');
|
||||||
return array_filter($acceptLanguagesList, static fn (string $lang) => $lang !== '*');
|
$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
|
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
|
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$acceptedLanguages = acceptLanguageToLocales($acceptLanguage);
|
$acceptedLanguages = acceptLanguageToLocales($acceptLanguage, minQuality: 0.9);
|
||||||
[$matchLanguage, $matchCountryCode] = splitLocale(normalizeLocale($this->matchValue));
|
[$matchLanguage, $matchCountryCode] = splitLocale(normalizeLocale($this->matchValue));
|
||||||
|
|
||||||
return some(
|
return some(
|
||||||
|
|||||||
@@ -75,9 +75,15 @@ class RedirectTest extends ApiTestCase
|
|||||||
];
|
];
|
||||||
yield 'rule: complex matching accept language' => [
|
yield 'rule: complex matching accept language' => [
|
||||||
[
|
[
|
||||||
RequestOptions::HEADERS => ['Accept-Language' => 'fr-FR, es;q=08, en;q=0.5, *;q=0.2'],
|
RequestOptions::HEADERS => ['Accept-Language' => 'fr-FR, es;q=0.9, en;q=0.9, *;q=0.2'],
|
||||||
],
|
],
|
||||||
'https://example.com/only-english',
|
'https://example.com/only-english',
|
||||||
];
|
];
|
||||||
|
yield 'rule: too low quality accept language' => [
|
||||||
|
[
|
||||||
|
RequestOptions::HEADERS => ['Accept-Language' => 'fr-FR, es;q=0.8, en;q=0.5, *;q=0.2'],
|
||||||
|
],
|
||||||
|
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ class RedirectConditionTest extends TestCase
|
|||||||
#[TestWith(['es, en,fr', 'en', true])] // multiple languages match
|
#[TestWith(['es, en,fr', 'en', true])] // multiple languages match
|
||||||
#[TestWith(['es, en-US,fr', 'EN', true])] // multiple locales match
|
#[TestWith(['es, en-US,fr', 'EN', true])] // multiple locales match
|
||||||
#[TestWith(['es_ES', 'es-ES', true])] // single locale match
|
#[TestWith(['es_ES', 'es-ES', true])] // single locale match
|
||||||
|
#[TestWith(['en-US,es-ES;q=0.6', 'es-ES', false])] // too low quality
|
||||||
|
#[TestWith(['en-US,es-ES;q=0.9', 'es-ES', true])] // quality high enough
|
||||||
#[TestWith(['en-UK', 'en-uk', true])] // different casing match
|
#[TestWith(['en-UK', 'en-uk', true])] // different casing match
|
||||||
#[TestWith(['en-UK', 'en', true])] // only lang
|
#[TestWith(['en-UK', 'en', true])] // only lang
|
||||||
#[TestWith(['es-AR', 'en', false])] // different only lang
|
#[TestWith(['es-AR', 'en', false])] // different only lang
|
||||||
|
|||||||
Reference in New Issue
Block a user